Refactor/shared and tasks components (#473)

* refactor: remove unused TasksSettings component

* refactor: migrate TodoList component to a new file with improved structure and normalization logic

* refactor: Move Tooltip and DarkModeToggle to shared/ui

* refactor: Move Tooltip and DarkModeToggle to shared/view/ui

* refactor: move GeminiLogo to llm-logo-provider and update imports

* refactor: remove unused GeminiStatus component

* refactor: move components in src/components/ui to src/shared/view/ui

* refactor: move ErrorBoundary component to main-content/view and update imports

* refactor: move VersionUpgradeModal to its own module

* refactor(wizard): rebuild project creation flow as modular TypeScript components

Replace the monolithic `ProjectCreationWizard.jsx` with a feature-based TS
implementation under `src/components/project-creation-wizard`, while preserving
existing behavior and improving readability, maintainability, and state isolation.

Why:
- The previous wizard mixed API logic, flow state, folder browsing, and UI in one file.
- Refactoring and testing were difficult due to tightly coupled concerns.
- We needed stronger type safety and localized component state.

What changed:
- Deleted:
  - `src/components/ProjectCreationWizard.jsx`
- Added new modular structure:
  - `src/components/project-creation-wizard/index.ts`
  - `src/components/project-creation-wizard/ProjectCreationWizard.tsx`
  - `src/components/project-creation-wizard/types.ts`
  - `src/components/project-creation-wizard/data/workspaceApi.ts`
  - `src/components/project-creation-wizard/hooks/useGithubTokens.ts`
  - `src/components/project-creation-wizard/utils/pathUtils.ts`
  - `src/components/project-creation-wizard/components/*`
    - `WizardProgress`, `WizardFooter`, `ErrorBanner`
    - `StepTypeSelection`, `StepConfiguration`, `StepReview`
    - `WorkspacePathField`, `GithubAuthenticationCard`, `FolderBrowserModal`
- Updated import usage:
  - `src/components/sidebar/view/subcomponents/SidebarModals.tsx`
    now imports from `../../../project-creation-wizard`.

Implementation details:
- Migrated wizard logic to TypeScript using `type` aliases only.
- Kept component prop types colocated in each component file.
- Split responsibilities by feature:
  - container/orchestration in `ProjectCreationWizard.tsx`
  - API/SSE and request parsing in `data/workspaceApi.ts`
  - GitHub token loading/caching behavior in `useGithubTokens`
  - path/URL helpers in `utils/pathUtils.ts`
- Localized UI-only state to child components:
  - folder browser modal state (current path, hidden folders, create-folder input)
  - path suggestion dropdown state with debounced lookup
- Preserved existing UX flows:
  - step navigation and validation
  - existing/new workspace modes
  - optional GitHub clone + auth modes
  - clone progress via SSE
  - folder browsing + folder creation
- Added focused comments for non-obvious logic (debounce, SSE auth constraint, path edge cases).

* refactor(quick-settings): migrate panel to typed feature-based modules

Refactor QuickSettingsPanel from a single JSX component into a modular TypeScript feature structure while preserving behavior and translations.

Highlights:
- Replace legacy src/components/QuickSettingsPanel.jsx with a typed entrypoint (src/components/QuickSettingsPanel.tsx).
- Introduce src/components/quick-settings-panel/ with clear separation of concerns:
  - view/: panel shell, header, handle, section wrappers, toggle rows, and content sections.
  - hooks/: drag interactions and whisper mode persistence.
  - constants.ts and types.ts for shared config and strict local typing.
- Move drag logic into useQuickSettingsDrag with explicit touch/mouse handling, drag threshold detection, click suppression after drag, position clamping, and localStorage persistence.
- Keep user-visible behavior intact:
  - same open/close panel interactions.
  - same mobile/desktop drag behavior and persisted handle position.
  - same quick preference toggles and wiring to useUiPreferences.
  - same hidden whisper section behavior and localStorage/event updates.
- Improve readability and maintainability by extracting repetitive setting rows and section scaffolding into reusable components.
- Add focused comments around non-obvious behavior (drag click suppression, touch scroll lock, hidden whisper section intent).
- Keep files small and reviewable (all new/changed files are under 300 lines).

Validation:
- npm run typecheck
- npm run build

* refactor(quick-settings-panel): restructure QuickSettingsPanel import and create index file

* refactor(shared): move shared ui components to share/view/ui without subfolders

* refactor(LanguageSelector): move LanguageSelector to shared UI components

* refactor(prd-editor): modularize PRD editor with typed feature modules

Break the legacy PRDEditor.jsx monolith into a feature-based TypeScript architecture under src/components/prd-editor while keeping behavior parity and readability.

Key changes:

- Replace PRDEditor.jsx with a typed orchestrator component and a compatibility export bridge at src/components/PRDEditor.tsx.

- Split responsibilities into dedicated hooks: document loading/init, existing PRD registry fetching, save workflow with overwrite detection, and keyboard shortcuts.

- Split UI into focused view components: header, editor/preview body, footer stats, loading state, generate-tasks modal, and overwrite-confirm modal.

- Move filename concerns into utility helpers (sanitize, extension handling, default naming) and centralize template/constants.

- Keep component-local state close to the UI that owns it (workspace controls/modal toggles), while shared workflow state remains in the feature container.

- Reuse the existing MarkdownPreview component for safer markdown rendering instead of ad-hoc HTML conversion.

- Update TaskMasterPanel integration to consume typed PRDEditor directly (remove any-cast) and pass isExisting metadata for correct overwrite behavior.

- Keep all new/changed files below 300 lines and add targeted comments where behavior needs clarification.

Validation:

- npm run typecheck

- npm run build

* refactor(TaskMasterPanel): update PRDEditor import path to match new structure

* refactor(TaskMaster): Remove unused TaskMasterSetupWizard and TaskMasterStatus components

* refactor(TaskDetail): remove unused TaskIndicator import

* refactor(task-master): migrate tasks to a typed feature module

- introduce a new feature-oriented TaskMaster domain under src/components/task-master

- add typed TaskMaster context/provider with explicit project, task, MCP, and loading state handling

- split task UI into focused components (panel, board, toolbar, content, card, detail modal, setup/help modals, banner)

- move task board filtering/sorting/kanban derivation into dedicated hooks and utilities

- relocate CreateTaskModal into the feature module and keep task views modular/readable

- remove legacy monolithic TaskList/TaskDetail/TaskCard files and route main task panel to the new feature panel

- replace contexts/TaskMasterContext.jsx with a typed contexts/TaskMasterContext.ts re-export to the feature context

- update MainContent project sync logic to compare by project name to avoid state churn

- validation: npm run typecheck, npm run build

* refactor(MobileNav): remove unused React import and TaskMasterContext

* refactor(auth): migrate login and setup flows to typed feature module

- Introduce a new feature-based auth module under src/components/auth with clear separation of concerns:\n  - context/AuthContext.tsx for session lifecycle, onboarding status checks, token persistence, and auth actions\n  - view/* components for loading, route guarding, form layout, input fields, and error display\n  - shared auth constants, utility helpers, and type aliases (no interfaces)\n- Convert login and setup UIs to TypeScript and keep form state local to each component for readability and component-level ownership\n- Add explicit API payload typing and safe JSON parsing helpers to improve resilience when backend responses are malformed or incomplete\n- Centralize error fallback handling for auth requests to reduce repeated logic

- Replace legacy auth entrypoints with the new feature module in app wiring:\n  - App now imports AuthProvider and ProtectedRoute from src/components/auth\n  - WebSocketContext, TaskMasterContext, and Onboarding now consume useAuth from the new typed auth context\n- Remove duplicated legacy auth screens (LoginForm.jsx, SetupForm.jsx, ProtectedRoute.jsx)\n- Keep backward compatibility by turning src/contexts/AuthContext.jsx into a thin re-export of the new provider/hook

Result: auth code now follows a feature/domain structure, is fully typed, easier to navigate, and cleaner to extend without touching unrelated UI areas.

* refactor(AppContent): update MobileNav import path and add MobileNav component

* refactor(DiffViewer): rename different diff viewers and place them in different components

* refactor(components): reorganize onboarding/provider auth/sidebar indicator into domain features

- Move onboarding out of root-level components into a dedicated feature module:
  - add src/components/onboarding/view/Onboarding.tsx
  - split onboarding UI into focused subcomponents:
    - OnboardingStepProgress
    - GitConfigurationStep
    - AgentConnectionsStep
    - AgentConnectionCard
  - add onboarding-local types and utils for provider status and validation helpers

- Move multi-provider login modal into a dedicated provider-auth feature:
  - add src/components/provider-auth/view/ProviderLoginModal.tsx
  - add src/components/provider-auth/types.ts
  - keep provider-specific command/title behavior and Gemini setup guidance
  - preserve compatibility for both onboarding flow and settings login flow

- Move TaskIndicator into the sidebar domain:
  - add src/components/sidebar/view/subcomponents/TaskIndicator.tsx
  - update SidebarProjectItem to consume local sidebar TaskIndicator

- Update integration points to the new structure:
  - ProtectedRoute now imports onboarding from onboarding feature
  - Settings now imports ProviderLoginModal directly (remove legacy cast wrapper)
  - git panel consumers now import shared GitDiffViewer by explicit name

- Rename git shared diff view to clearer domain naming:
  - replace shared DiffViewer with shared GitDiffViewer
  - update FileChangeItem and CommitHistoryItem imports accordingly

- Remove superseded root-level legacy components:
  - delete src/components/LoginModal.jsx
  - delete src/components/Onboarding.jsx
  - delete src/components/TaskIndicator.jsx
  - delete old src/components/git-panel/view/shared/DiffViewer.tsx

- Result:
  - clearer feature boundaries (auth vs onboarding vs provider-auth vs sidebar)
  - easier navigation and ownership by domain
  - preserved runtime behavior with improved readability and modularity

* refactor(MainContent): remove TaskMasterPanel import and relocate to task-master component

* fix: update import paths for Input component in FileTree and FileTreeNode

* refactor(FileTree): make file tree context menu a typescript component and move it inside the file tree view

* refactor(FileTree): remove unused ScrollArea import

* feat: setup eslint with typescript and react rules, add unused imports plugin

* fix: remove unused imports, functions, and types after discovering using `npm run lint`

* feat: setup eslint-plugin-react, react-refresh, import-x, and tailwindcss plugins with recommended rules and configurations

* chore: reformat files after running `npm run lint:fix`

* chore: add omments about eslint config plugin uses

* feat: add husky and lint-staged for pre-commit linting

* feat: setup commitlint with conventional config

* fix: i18n translations

---------

Co-authored-by: Haileyesus <something@gmail.com>
Co-authored-by: viper151 <simosmik@gmail.com>
This commit is contained in:
Haileyesus
2026-03-06 01:47:58 +03:00
committed by GitHub
parent 8d28438fe7
commit 844de26ada
254 changed files with 14571 additions and 9347 deletions

View File

@@ -0,0 +1,273 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { api } from '../../../utils/api';
import { useAuth } from '../../auth/context/AuthContext';
import { useWebSocket } from '../../../contexts/WebSocketContext';
import type {
TaskMasterContextError,
TaskMasterContextValue,
TaskMasterMcpStatus,
TaskMasterProject,
TaskMasterProjectInfo,
TaskMasterProjectInput,
TaskMasterTask,
TaskMasterWebSocketMessage,
} from '../types';
const TaskMasterContext = createContext<TaskMasterContextValue | null>(null);
function createTaskMasterError(context: string, error: unknown): TaskMasterContextError {
const message = error instanceof Error ? error.message : `Failed to ${context}`;
return {
message,
context,
timestamp: new Date().toISOString(),
};
}
function enrichProject(project: TaskMasterProject): TaskMasterProject {
return {
...project,
taskMasterConfigured: project.taskmaster?.hasTaskmaster ?? false,
taskMasterStatus: project.taskmaster?.status ?? 'not-configured',
taskCount: Number(project.taskmaster?.metadata?.taskCount ?? 0),
completedCount: Number(project.taskmaster?.metadata?.completed ?? 0),
};
}
function getNextTask(tasks: TaskMasterTask[]): TaskMasterTask | null {
return tasks.find((task) => task.status === 'pending' || task.status === 'in-progress') ?? null;
}
function isTaskMasterMessage(
message: TaskMasterWebSocketMessage | null,
): message is TaskMasterWebSocketMessage & { type: string } {
if (!message?.type) {
return false;
}
return message.type.startsWith('taskmaster-');
}
export function useTaskMaster() {
const context = useContext(TaskMasterContext);
if (!context) {
throw new Error('useTaskMaster must be used within a TaskMasterProvider');
}
return context;
}
export function TaskMasterProvider({ children }: { children: React.ReactNode }) {
const { latestMessage } = useWebSocket();
const { user, token, isLoading: isAuthLoading } = useAuth();
const [projects, setProjects] = useState<TaskMasterProject[]>([]);
const [currentProject, setCurrentProjectState] = useState<TaskMasterProject | null>(null);
const [projectTaskMaster, setProjectTaskMaster] = useState<TaskMasterProjectInfo | null>(null);
const [mcpServerStatus, setMcpServerStatus] = useState<TaskMasterMcpStatus>(null);
const [tasks, setTasks] = useState<TaskMasterTask[]>([]);
const [nextTask, setNextTask] = useState<TaskMasterTask | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isLoadingTasks, setIsLoadingTasks] = useState(false);
const [isLoadingMCP, setIsLoadingMCP] = useState(false);
const [error, setError] = useState<TaskMasterContextError | null>(null);
const currentProjectNameRef = useRef<string | null>(null);
useEffect(() => {
currentProjectNameRef.current = currentProject?.name ?? null;
}, [currentProject?.name]);
const clearError = useCallback(() => {
setError(null);
}, []);
const handleError = useCallback((context: string, caughtError: unknown) => {
console.error(`TaskMaster ${context} error:`, caughtError);
setError(createTaskMasterError(context, caughtError));
}, []);
const setCurrentProject = useCallback((project: TaskMasterProjectInput) => {
const normalizedProject = project ? enrichProject(project as TaskMasterProject) : null;
setCurrentProjectState(normalizedProject);
setProjectTaskMaster(normalizedProject?.taskmaster ?? null);
// Project-scoped task data is reset immediately to avoid stale task rendering.
setTasks([]);
setNextTask(null);
}, []);
const refreshProjects = useCallback(async () => {
if (!user || !token) {
setProjects([]);
setCurrentProjectState(null);
setProjectTaskMaster(null);
setTasks([]);
setNextTask(null);
return;
}
try {
setIsLoading(true);
clearError();
const response = await api.get('/projects');
if (!response.ok) {
throw new Error(`Failed to fetch projects: ${response.status}`);
}
const data = (await response.json()) as unknown;
const loadedProjects = Array.isArray(data) ? (data as TaskMasterProject[]) : [];
const enrichedProjects = loadedProjects.map((project) => enrichProject(project));
setProjects(enrichedProjects);
const currentProjectName = currentProjectNameRef.current;
if (!currentProjectName) {
return;
}
const matchingProject = enrichedProjects.find((project) => project.name === currentProjectName) ?? null;
setCurrentProjectState(matchingProject);
setProjectTaskMaster(matchingProject?.taskmaster ?? null);
} catch (caughtError) {
handleError('load projects', caughtError);
} finally {
setIsLoading(false);
}
}, [clearError, handleError, token, user]);
const refreshTasks = useCallback(async () => {
const projectName = currentProject?.name;
if (!projectName || !user || !token) {
setTasks([]);
setNextTask(null);
return;
}
try {
setIsLoadingTasks(true);
clearError();
const response = await api.get(`/taskmaster/tasks/${encodeURIComponent(projectName)}`);
if (!response.ok) {
const errorPayload = (await response.json()) as { message?: string };
throw new Error(errorPayload.message ?? 'Failed to load tasks');
}
const data = (await response.json()) as { tasks?: TaskMasterTask[] };
const loadedTasks = Array.isArray(data.tasks) ? data.tasks : [];
setTasks(loadedTasks);
setNextTask(getNextTask(loadedTasks));
} catch (caughtError) {
handleError('load tasks', caughtError);
setTasks([]);
setNextTask(null);
} finally {
setIsLoadingTasks(false);
}
}, [clearError, currentProject?.name, handleError, token, user]);
const refreshMCPStatus = useCallback(async () => {
if (!user || !token) {
setMcpServerStatus(null);
return;
}
try {
setIsLoadingMCP(true);
clearError();
const response = await api.get('/mcp-utils/taskmaster-server');
if (!response.ok) {
throw new Error(`Failed to load MCP status: ${response.status}`);
}
const status = (await response.json()) as TaskMasterMcpStatus;
setMcpServerStatus(status);
} catch (caughtError) {
handleError('check MCP server status', caughtError);
setMcpServerStatus(null);
} finally {
setIsLoadingMCP(false);
}
}, [clearError, handleError, token, user]);
useEffect(() => {
if (!isAuthLoading && user && token) {
void refreshProjects();
void refreshMCPStatus();
}
}, [isAuthLoading, refreshMCPStatus, refreshProjects, token, user]);
useEffect(() => {
if (currentProject?.name && user && token) {
void refreshTasks();
}
}, [currentProject?.name, refreshTasks, token, user]);
useEffect(() => {
const message = latestMessage as TaskMasterWebSocketMessage | null;
if (!isTaskMasterMessage(message)) {
return;
}
if (message.type === 'taskmaster-project-updated' && message.projectName) {
void refreshProjects();
return;
}
if (message.type === 'taskmaster-tasks-updated' && message.projectName === currentProject?.name) {
void refreshTasks();
return;
}
if (message.type === 'taskmaster-mcp-status-changed') {
void refreshMCPStatus();
}
}, [currentProject?.name, latestMessage, refreshMCPStatus, refreshProjects, refreshTasks]);
const contextValue = useMemo<TaskMasterContextValue>(
() => ({
projects,
currentProject,
projectTaskMaster,
mcpServerStatus,
tasks,
nextTask,
isLoading,
isLoadingTasks,
isLoadingMCP,
error,
refreshProjects,
setCurrentProject,
refreshTasks,
refreshMCPStatus,
clearError,
}),
[
clearError,
currentProject,
error,
isLoading,
isLoadingMCP,
isLoadingTasks,
mcpServerStatus,
nextTask,
projectTaskMaster,
projects,
refreshMCPStatus,
refreshProjects,
refreshTasks,
setCurrentProject,
tasks,
],
);
return <TaskMasterContext.Provider value={contextValue}>{children}</TaskMasterContext.Provider>;
}
export default TaskMasterContext;

View File

@@ -0,0 +1,64 @@
import { useCallback, useEffect, useState } from 'react';
import { api } from '../../../utils/api';
import type { PrdFile } from '../types';
type UseProjectPrdFilesOptions = {
projectName?: string;
};
type PrdResponse = {
prdFiles?: PrdFile[];
prds?: PrdFile[];
};
function normalizePrdResponse(responseData: PrdResponse): PrdFile[] {
if (Array.isArray(responseData.prdFiles)) {
return responseData.prdFiles;
}
if (Array.isArray(responseData.prds)) {
return responseData.prds;
}
return [];
}
export function useProjectPrdFiles({ projectName }: UseProjectPrdFilesOptions) {
const [prdFiles, setPrdFiles] = useState<PrdFile[]>([]);
const [isLoadingPrdFiles, setIsLoadingPrdFiles] = useState(false);
const refreshPrdFiles = useCallback(async () => {
if (!projectName) {
setPrdFiles([]);
return;
}
try {
setIsLoadingPrdFiles(true);
const response = await api.get(`/taskmaster/prd/${encodeURIComponent(projectName)}`);
if (!response.ok) {
setPrdFiles([]);
return;
}
const data = (await response.json()) as PrdResponse;
setPrdFiles(normalizePrdResponse(data));
} catch (error) {
console.error('Failed to load PRD files:', error);
setPrdFiles([]);
} finally {
setIsLoadingPrdFiles(false);
}
}, [projectName]);
useEffect(() => {
void refreshPrdFiles();
}, [refreshPrdFiles]);
return {
prdFiles,
isLoadingPrdFiles,
refreshPrdFiles,
};
}

View File

@@ -0,0 +1,97 @@
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { TaskBoardSortField, TaskBoardSortOrder, TaskBoardView, TaskKanbanColumn, TaskMasterTask } from '../types';
import { buildKanbanColumns } from '../utils/taskKanban';
import { sortTasks, toggleSortOrder } from '../utils/taskSorting';
type UseTaskBoardStateOptions = {
tasks: TaskMasterTask[];
defaultView?: TaskBoardView;
};
function matchesSearch(task: TaskMasterTask, searchTerm: string): boolean {
if (!searchTerm) {
return true;
}
const normalizedSearch = searchTerm.toLowerCase();
const description = typeof task.description === 'string' ? task.description : '';
return (
task.title.toLowerCase().includes(normalizedSearch)
|| description.toLowerCase().includes(normalizedSearch)
|| String(task.id).toLowerCase().includes(normalizedSearch)
);
}
export function useTaskBoardState({ tasks, defaultView = 'kanban' }: UseTaskBoardStateOptions) {
const { t } = useTranslation('tasks');
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState('all');
const [priorityFilter, setPriorityFilter] = useState('all');
const [sortField, setSortField] = useState<TaskBoardSortField>('id');
const [sortOrder, setSortOrder] = useState<TaskBoardSortOrder>('asc');
const [viewMode, setViewMode] = useState<TaskBoardView>(defaultView);
const [showFilters, setShowFilters] = useState(false);
const statuses = useMemo(() => {
return [...new Set(tasks.map((task) => task.status).filter(Boolean))] as string[];
}, [tasks]);
const priorities = useMemo(() => {
return [...new Set(tasks.map((task) => task.priority).filter(Boolean))] as string[];
}, [tasks]);
const filteredTasks = useMemo(() => {
const filtered = tasks.filter((task) => {
const status = task.status ?? 'pending';
const priority = task.priority ?? 'medium';
const matchesStatus = statusFilter === 'all' || status === statusFilter;
const matchesPriority = priorityFilter === 'all' || priority === priorityFilter;
return matchesSearch(task, searchTerm) && matchesStatus && matchesPriority;
});
return sortTasks(filtered, sortField, sortOrder);
}, [tasks, searchTerm, statusFilter, priorityFilter, sortField, sortOrder]);
const kanbanColumns = useMemo<TaskKanbanColumn[]>(() => {
return buildKanbanColumns(filteredTasks, t);
}, [filteredTasks, t]);
const handleSortChange = (nextSortField: TaskBoardSortField) => {
setSortOrder((currentOrder) => toggleSortOrder(sortField, currentOrder, nextSortField));
setSortField(nextSortField);
};
const clearFilters = () => {
setSearchTerm('');
setStatusFilter('all');
setPriorityFilter('all');
};
return {
searchTerm,
setSearchTerm,
statusFilter,
setStatusFilter,
priorityFilter,
setPriorityFilter,
sortField,
setSortField,
sortOrder,
setSortOrder,
viewMode,
setViewMode,
showFilters,
setShowFilters,
statuses,
priorities,
filteredTasks,
kanbanColumns,
handleSortChange,
clearFilters,
};
}

View File

@@ -0,0 +1,4 @@
export { default as TaskMasterPanel } from './view/TaskMasterPanel';
export { default as NextTaskBanner } from './view/NextTaskBanner';
export { TaskMasterProvider, useTaskMaster } from './context/TaskMasterContext';

View File

@@ -0,0 +1,128 @@
import type { Project } from '../../types/app';
export type TaskId = string | number;
export type TaskStatus =
| 'pending'
| 'in-progress'
| 'done'
| 'review'
| 'blocked'
| 'deferred'
| 'cancelled'
| string;
export type TaskPriority = 'high' | 'medium' | 'low' | string;
export type TaskMasterTask = {
id: TaskId;
title: string;
description?: string;
status?: TaskStatus;
priority?: TaskPriority;
details?: string;
testStrategy?: string;
parentId?: TaskId;
dependencies?: TaskId[];
subtasks?: TaskMasterTask[];
createdAt?: string;
updatedAt?: string;
[key: string]: unknown;
};
export type TaskReference = {
id: TaskId;
title?: string;
[key: string]: unknown;
};
export type TaskSelection = TaskMasterTask | TaskReference;
export type PrdFile = {
name: string;
content?: string;
isExisting?: boolean;
modified?: string;
created?: string;
path?: string;
size?: number;
[key: string]: unknown;
};
export type TaskMasterProjectInfo = {
hasTaskmaster?: boolean;
status?: string;
metadata?: Record<string, unknown>;
[key: string]: unknown;
};
export type TaskMasterProject = Project & {
taskMasterConfigured?: boolean;
taskMasterStatus?: string;
taskCount?: number;
completedCount?: number;
taskmaster?: TaskMasterProjectInfo;
};
export type TaskMasterProjectInput = TaskMasterProject | Project | null;
export type TaskMasterContextError = {
message: string;
context: string;
timestamp: string;
};
export type TaskMasterMcpStatus = {
hasMCPServer?: boolean;
isConfigured?: boolean;
hasApiKeys?: boolean;
scope?: string;
config?: {
command?: string;
args?: string[];
url?: string;
envVars?: string[];
type?: string;
};
reason?: string;
[key: string]: unknown;
} | null;
export type TaskMasterWebSocketMessage = {
type?: string;
projectName?: string;
[key: string]: unknown;
};
export type TaskMasterContextValue = {
projects: TaskMasterProject[];
currentProject: TaskMasterProject | null;
projectTaskMaster: TaskMasterProjectInfo | null;
mcpServerStatus: TaskMasterMcpStatus;
tasks: TaskMasterTask[];
nextTask: TaskMasterTask | null;
isLoading: boolean;
isLoadingTasks: boolean;
isLoadingMCP: boolean;
error: TaskMasterContextError | null;
refreshProjects: () => Promise<void>;
setCurrentProject: (project: TaskMasterProjectInput) => void;
refreshTasks: () => Promise<void>;
refreshMCPStatus: () => Promise<void>;
clearError: () => void;
};
export type TaskBoardView = 'kanban' | 'list' | 'grid';
export type TaskBoardSortField = 'id' | 'title' | 'status' | 'priority' | 'updated';
export type TaskBoardSortOrder = 'asc' | 'desc';
export type TaskKanbanColumn = {
id: string;
title: string;
status: string;
color: string;
headerColor: string;
tasks: TaskMasterTask[];
};

View File

@@ -0,0 +1,72 @@
import type { TFunction } from 'i18next';
import type { TaskKanbanColumn, TaskMasterTask } from '../types';
const KANBAN_COLUMN_CONFIG = [
{
id: 'pending',
titleKey: 'kanban.pending',
status: 'pending',
color: 'bg-slate-50 dark:bg-slate-900/50 border-slate-200 dark:border-slate-700',
headerColor: 'bg-slate-100 dark:bg-slate-800 text-slate-800 dark:text-slate-200',
},
{
id: 'in-progress',
titleKey: 'kanban.inProgress',
status: 'in-progress',
color: 'bg-blue-50 dark:bg-blue-900/50 border-blue-200 dark:border-blue-700',
headerColor: 'bg-blue-100 dark:bg-blue-800 text-blue-800 dark:text-blue-200',
},
{
id: 'done',
titleKey: 'kanban.done',
status: 'done',
color: 'bg-emerald-50 dark:bg-emerald-900/50 border-emerald-200 dark:border-emerald-700',
headerColor: 'bg-emerald-100 dark:bg-emerald-800 text-emerald-800 dark:text-emerald-200',
},
{
id: 'blocked',
titleKey: 'kanban.blocked',
status: 'blocked',
color: 'bg-red-50 dark:bg-red-900/50 border-red-200 dark:border-red-700',
headerColor: 'bg-red-100 dark:bg-red-800 text-red-800 dark:text-red-200',
},
{
id: 'deferred',
titleKey: 'kanban.deferred',
status: 'deferred',
color: 'bg-amber-50 dark:bg-amber-900/50 border-amber-200 dark:border-amber-700',
headerColor: 'bg-amber-100 dark:bg-amber-800 text-amber-800 dark:text-amber-200',
},
{
id: 'cancelled',
titleKey: 'kanban.cancelled',
status: 'cancelled',
color: 'bg-gray-50 dark:bg-gray-900/50 border-gray-200 dark:border-gray-700',
headerColor: 'bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200',
},
] as const;
const CORE_WORKFLOW_STATUSES = new Set(['pending', 'in-progress', 'done']);
export function buildKanbanColumns(tasks: TaskMasterTask[], t: TFunction<'tasks'>): TaskKanbanColumn[] {
const tasksByStatus = tasks.reduce<Record<string, TaskMasterTask[]>>((accumulator, task) => {
const status = task.status ?? 'pending';
if (!accumulator[status]) {
accumulator[status] = [];
}
accumulator[status].push(task);
return accumulator;
}, {});
return KANBAN_COLUMN_CONFIG.filter((column) => {
const hasTasks = (tasksByStatus[column.status] ?? []).length > 0;
return hasTasks || CORE_WORKFLOW_STATUSES.has(column.status);
}).map((column) => ({
id: column.id,
title: t(column.titleKey),
status: column.status,
color: column.color,
headerColor: column.headerColor,
tasks: tasksByStatus[column.status] ?? [],
}));
}

View File

@@ -0,0 +1,100 @@
import type { TaskBoardSortField, TaskBoardSortOrder, TaskMasterTask } from '../types';
const STATUS_ORDER: Record<string, number> = {
pending: 1,
'in-progress': 2,
review: 3,
done: 4,
blocked: 5,
deferred: 6,
cancelled: 7,
};
const PRIORITY_ORDER: Record<string, number> = {
low: 1,
medium: 2,
high: 3,
};
function toComparableIdParts(taskId: string | number): number[] {
return String(taskId)
.split('.')
.map((part) => Number.parseInt(part, 10))
.map((part) => (Number.isNaN(part) ? 0 : part));
}
function compareTaskIds(leftId: string | number, rightId: string | number): number {
const leftParts = toComparableIdParts(leftId);
const rightParts = toComparableIdParts(rightId);
const maxDepth = Math.max(leftParts.length, rightParts.length);
for (let index = 0; index < maxDepth; index += 1) {
const left = leftParts[index] ?? 0;
const right = rightParts[index] ?? 0;
if (left !== right) {
return left - right;
}
}
return 0;
}
function getSortValue(task: TaskMasterTask, field: TaskBoardSortField): number | string {
if (field === 'title') {
return task.title.toLowerCase();
}
if (field === 'status') {
return STATUS_ORDER[task.status ?? 'pending'] ?? 999;
}
if (field === 'priority') {
return PRIORITY_ORDER[task.priority ?? 'medium'] ?? 0;
}
if (field === 'updated') {
const timestamp = task.updatedAt ?? task.createdAt ?? '';
return new Date(timestamp).getTime() || 0;
}
return 0;
}
export function sortTasks(
tasks: TaskMasterTask[],
field: TaskBoardSortField,
order: TaskBoardSortOrder,
): TaskMasterTask[] {
const sortedTasks = [...tasks];
sortedTasks.sort((leftTask, rightTask) => {
const direction = order === 'asc' ? 1 : -1;
if (field === 'id') {
return compareTaskIds(leftTask.id, rightTask.id) * direction;
}
const leftValue = getSortValue(leftTask, field);
const rightValue = getSortValue(rightTask, field);
if (typeof leftValue === 'string' && typeof rightValue === 'string') {
return leftValue.localeCompare(rightValue) * direction;
}
return (Number(leftValue) - Number(rightValue)) * direction;
});
return sortedTasks;
}
export function toggleSortOrder(
currentField: TaskBoardSortField,
currentOrder: TaskBoardSortOrder,
nextField: TaskBoardSortField,
): TaskBoardSortOrder {
if (currentField !== nextField) {
return 'asc';
}
return currentOrder === 'asc' ? 'desc' : 'asc';
}

View File

@@ -0,0 +1,213 @@
import { useState } from 'react';
import {
CheckCircle,
Circle,
Eye,
Flag,
List,
Play,
Settings,
Target,
Terminal,
Zap,
} from 'lucide-react';
import { cn } from '../../../lib/utils';
import { useTaskMaster } from '../context/TaskMasterContext';
import TaskDetailModal from './TaskDetailModal';
import TaskMasterSetupModal from './modals/TaskMasterSetupModal';
type NextTaskBannerProps = {
onShowAllTasks?: (() => void) | null;
onStartTask?: (() => void) | null;
className?: string;
};
function PriorityIndicator({ priority }: { priority?: string }) {
if (priority === 'high') {
return (
<div className="flex h-4 w-4 items-center justify-center rounded bg-red-100 dark:bg-red-900/50" title="High Priority">
<Zap className="h-2.5 w-2.5 text-red-600 dark:text-red-400" />
</div>
);
}
if (priority === 'medium') {
return (
<div className="flex h-4 w-4 items-center justify-center rounded bg-amber-100 dark:bg-amber-900/50" title="Medium Priority">
<Flag className="h-2.5 w-2.5 text-amber-600 dark:text-amber-400" />
</div>
);
}
return (
<div className="flex h-4 w-4 items-center justify-center rounded bg-gray-100 dark:bg-gray-800" title="Low Priority">
<Circle className="h-2.5 w-2.5 text-gray-400 dark:text-gray-500" />
</div>
);
}
export default function NextTaskBanner({ onShowAllTasks = null, onStartTask = null, className = '' }: NextTaskBannerProps) {
const {
nextTask,
tasks,
currentProject,
isLoadingTasks,
projectTaskMaster,
refreshTasks,
refreshProjects,
setCurrentProject,
} = useTaskMaster();
const [showTaskDetail, setShowTaskDetail] = useState(false);
const [showSetupModal, setShowSetupModal] = useState(false);
const [showSetupDetails, setShowSetupDetails] = useState(false);
if (!currentProject || isLoadingTasks) {
return null;
}
const hasTasks = Array.isArray(tasks) && tasks.length > 0;
const hasTaskMaster = Boolean(projectTaskMaster?.hasTaskmaster || currentProject.taskmaster?.hasTaskmaster);
const handleSetupRefresh = () => {
void refreshProjects();
setCurrentProject(currentProject);
void refreshTasks();
};
if (!hasTasks && !hasTaskMaster) {
return (
<>
<div className={cn('bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-lg p-3 mb-4', className)}>
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<List className="h-4 w-4 text-blue-600 dark:text-blue-400" />
<p className="text-sm font-medium text-gray-900 dark:text-white">TaskMaster AI is not configured</p>
</div>
<button
onClick={() => setShowSetupModal(true)}
className="flex items-center gap-1 rounded bg-blue-600 px-2 py-1 text-xs text-white transition-colors hover:bg-blue-700"
>
<Terminal className="h-3 w-3" />
Initialize
</button>
</div>
<button
onClick={() => setShowSetupDetails((current) => !current)}
className="mt-2 flex items-center gap-1 text-xs text-blue-700 hover:underline dark:text-blue-300"
>
<Settings className="h-3 w-3" />
{showSetupDetails ? 'Hide details' : 'What is TaskMaster?'}
</button>
{showSetupDetails && (
<div className="mt-3 space-y-1 text-xs text-blue-900 dark:text-blue-100">
<p>- AI-powered task management with dependencies and subtasks.</p>
<p>- PRD-driven task generation for faster project bootstrapping.</p>
<p>- Kanban and list views for day-to-day execution.</p>
</div>
)}
</div>
<TaskMasterSetupModal
isOpen={showSetupModal}
project={currentProject}
onClose={() => setShowSetupModal(false)}
onAfterClose={handleSetupRefresh}
/>
</>
);
}
if (nextTask) {
return (
<>
<div className={cn('bg-slate-50 dark:bg-slate-900/30 border border-slate-200 dark:border-slate-700 rounded-lg p-3 mb-4', className)}>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="mb-1 flex items-center gap-2">
<div className="flex h-5 w-5 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900/50">
<Target className="h-3 w-3 text-blue-600 dark:text-blue-400" />
</div>
<span className="text-xs font-medium text-slate-600 dark:text-slate-400">Task {nextTask.id}</span>
<PriorityIndicator priority={nextTask.priority} />
</div>
<p className="line-clamp-1 text-sm font-medium text-slate-900 dark:text-slate-100">{nextTask.title}</p>
</div>
<div className="flex flex-shrink-0 items-center gap-1">
<button
onClick={() => onStartTask?.()}
className="flex items-center gap-1 rounded-md bg-blue-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-blue-700"
>
<Play className="h-3 w-3" />
Start Task
</button>
<button
onClick={() => setShowTaskDetail(true)}
className="rounded-md border border-slate-300 px-2 py-1.5 text-xs text-slate-600 hover:bg-slate-100 dark:border-slate-600 dark:text-slate-300 dark:hover:bg-slate-800"
title="View task details"
>
<Eye className="h-3 w-3" />
</button>
{onShowAllTasks && (
<button
onClick={onShowAllTasks}
className="rounded-md border border-slate-300 px-2 py-1.5 text-xs text-slate-600 hover:bg-slate-100 dark:border-slate-600 dark:text-slate-300 dark:hover:bg-slate-800"
title="View all tasks"
>
<List className="h-3 w-3" />
</button>
)}
</div>
</div>
</div>
<TaskDetailModal
task={nextTask}
isOpen={showTaskDetail}
onClose={() => setShowTaskDetail(false)}
onStatusChange={() => {
void refreshTasks();
}}
/>
</>
);
}
if (hasTasks) {
const completedTasks = tasks.filter((task) => task.status === 'done').length;
return (
<div className={cn('bg-purple-50 dark:bg-purple-950 border border-purple-200 dark:border-purple-800 rounded-lg p-3 mb-4', className)}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<CheckCircle className="h-4 w-4 text-purple-600 dark:text-purple-400" />
<span className="text-sm font-medium text-gray-900 dark:text-white">
{completedTasks === tasks.length ? 'All tasks complete' : 'No pending tasks'}
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-600 dark:text-gray-400">
{completedTasks}/{tasks.length}
</span>
{onShowAllTasks && (
<button
onClick={onShowAllTasks}
className="rounded bg-purple-600 px-2 py-1 text-xs text-white transition-colors hover:bg-purple-700"
>
Review
</button>
)}
</div>
</div>
</div>
);
}
return null;
}

View File

@@ -0,0 +1,199 @@
import { useState } from 'react';
import { cn } from '../../../lib/utils';
import { api } from '../../../utils/api';
import { useTaskMaster } from '../context/TaskMasterContext';
import { useTaskBoardState } from '../hooks/useTaskBoardState';
import type { PrdFile, TaskBoardView, TaskMasterProject, TaskMasterTask, TaskSelection } from '../types';
import TaskBoardContent from './TaskBoardContent';
import TaskBoardToolbar from './TaskBoardToolbar';
import TaskEmptyState from './TaskEmptyState';
import CreateTaskModal from './modals/CreateTaskModal';
import TaskHelpModal from './modals/TaskHelpModal';
import TaskMasterSetupModal from './modals/TaskMasterSetupModal';
type TaskBoardProps = {
tasks?: TaskMasterTask[];
onTaskClick?: ((task: TaskSelection) => void) | null;
className?: string;
showParentTasks?: boolean;
defaultView?: TaskBoardView;
currentProject?: TaskMasterProject | null;
onTaskCreated?: (() => void) | null;
onShowPRDEditor?: ((file?: PrdFile) => void) | null;
existingPRDs?: PrdFile[];
onRefreshPRDs?: ((showNotification?: boolean) => void) | null;
};
export default function TaskBoard({
tasks = [],
onTaskClick = null,
className = '',
showParentTasks = false,
defaultView = 'kanban',
currentProject = null,
onTaskCreated = null,
onShowPRDEditor = null,
existingPRDs = [],
onRefreshPRDs = null,
}: TaskBoardProps) {
const { projectTaskMaster, refreshProjects, refreshTasks, setCurrentProject } = useTaskMaster();
const [showCreateModal, setShowCreateModal] = useState(false);
const [showHelpModal, setShowHelpModal] = useState(false);
const [showSetupModal, setShowSetupModal] = useState(false);
const {
searchTerm,
setSearchTerm,
statusFilter,
setStatusFilter,
priorityFilter,
setPriorityFilter,
sortField,
setSortField,
sortOrder,
setSortOrder,
viewMode,
setViewMode,
showFilters,
setShowFilters,
statuses,
priorities,
filteredTasks,
kanbanColumns,
handleSortChange,
clearFilters,
} = useTaskBoardState({ tasks, defaultView });
const hasTaskMasterDirectory = Boolean(
currentProject?.taskMasterConfigured
|| currentProject?.taskmaster?.hasTaskmaster
|| projectTaskMaster?.hasTaskmaster,
);
const loadPrdAndOpenEditor = async (prd: PrdFile) => {
if (!currentProject?.name) {
return;
}
try {
const response = await api.get(
`/taskmaster/prd/${encodeURIComponent(currentProject.name)}/${encodeURIComponent(prd.name)}`,
);
if (!response.ok) {
throw new Error(`Failed to load PRD ${prd.name}`);
}
const data = (await response.json()) as { content?: string };
onShowPRDEditor?.({
name: prd.name,
content: data.content ?? '',
isExisting: true,
});
} catch (error) {
console.error('Failed to open PRD in editor:', error);
}
};
const refreshAfterSetup = () => {
void refreshProjects();
if (currentProject) {
setCurrentProject(currentProject);
}
void refreshTasks();
onRefreshPRDs?.(false);
};
if (tasks.length === 0) {
return (
<>
<TaskEmptyState
className={className}
hasTaskMasterDirectory={hasTaskMasterDirectory}
existingPrds={existingPRDs}
onOpenSetupModal={() => setShowSetupModal(true)}
onCreatePrd={() => onShowPRDEditor?.()}
onOpenPrd={(prd) => {
void loadPrdAndOpenEditor(prd);
}}
/>
<TaskMasterSetupModal
isOpen={showSetupModal}
project={currentProject}
onClose={() => setShowSetupModal(false)}
onAfterClose={refreshAfterSetup}
/>
</>
);
}
return (
<div className={cn('space-y-4', className)}>
<TaskBoardToolbar
hasProject={Boolean(currentProject)}
hasTaskMasterConfigured={hasTaskMasterDirectory}
totalTaskCount={tasks.length}
filteredTaskCount={filteredTasks.length}
searchTerm={searchTerm}
onSearchTermChange={setSearchTerm}
viewMode={viewMode}
onViewModeChange={setViewMode}
showFilters={showFilters}
onToggleFilters={() => setShowFilters((current) => !current)}
statusFilter={statusFilter}
onStatusFilterChange={setStatusFilter}
priorityFilter={priorityFilter}
onPriorityFilterChange={setPriorityFilter}
sortField={sortField}
sortOrder={sortOrder}
onSortChange={handleSortChange}
onSortConfigChange={(field, order) => {
setSortField(field);
setSortOrder(order);
}}
statuses={statuses}
priorities={priorities}
onClearFilters={clearFilters}
existingPrds={existingPRDs}
onCreatePrd={() => onShowPRDEditor?.()}
onOpenPrd={(prd) => {
void loadPrdAndOpenEditor(prd);
}}
onOpenHelp={() => setShowHelpModal(true)}
onOpenCreateTask={() => setShowCreateModal(true)}
/>
<TaskBoardContent
viewMode={viewMode}
filteredTaskCount={filteredTasks.length}
kanbanColumns={kanbanColumns}
filteredTasks={filteredTasks}
showParentTasks={showParentTasks}
onTaskClick={(task) => onTaskClick?.(task)}
/>
<CreateTaskModal
isOpen={showCreateModal}
onClose={() => {
setShowCreateModal(false);
onTaskCreated?.();
}}
/>
<TaskHelpModal
isOpen={showHelpModal}
onClose={() => setShowHelpModal(false)}
onCreatePrd={() => onShowPRDEditor?.()}
/>
<TaskMasterSetupModal
isOpen={showSetupModal}
project={currentProject}
onClose={() => setShowSetupModal(false)}
onAfterClose={refreshAfterSetup}
/>
</div>
);
}

View File

@@ -0,0 +1,124 @@
import { Search } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { cn } from '../../../lib/utils';
import type { TaskBoardView, TaskKanbanColumn, TaskMasterTask, TaskSelection } from '../types';
import TaskCard from './TaskCard';
type TaskBoardContentProps = {
viewMode: TaskBoardView;
filteredTaskCount: number;
kanbanColumns: TaskKanbanColumn[];
filteredTasks: TaskMasterTask[];
showParentTasks: boolean;
onTaskClick: (task: TaskSelection) => void;
};
function KanbanColumns({
columns,
showParentTasks,
onTaskClick,
}: {
columns: TaskKanbanColumn[];
showParentTasks: boolean;
onTaskClick: (task: TaskSelection) => void;
}) {
const { t } = useTranslation('tasks');
return (
<div
className={cn(
'grid gap-6',
columns.length === 1 && 'grid-cols-1 max-w-md mx-auto',
columns.length === 2 && 'grid-cols-1 md:grid-cols-2',
columns.length === 3 && 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
columns.length === 4 && 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4',
columns.length === 5 && 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5',
columns.length >= 6 && 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6',
)}
>
{columns.map((column) => (
<div key={column.id} className={cn('rounded-xl border shadow-sm transition-shadow hover:shadow-md', column.color)}>
<div className={cn('px-4 py-3 rounded-t-xl border-b', column.headerColor)}>
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold">{column.title}</h3>
<span className="rounded-full bg-white/60 px-2 py-1 text-xs font-medium dark:bg-black/20">
{column.tasks.length}
</span>
</div>
</div>
<div className="max-h-[calc(100vh-300px)] min-h-[200px] space-y-3 overflow-y-auto p-3">
{column.tasks.length === 0 ? (
<div className="py-8 text-center text-gray-400 dark:text-gray-500">
<div className="mx-auto mb-2 flex h-8 w-8 items-center justify-center rounded-full bg-gray-200 dark:bg-gray-700">
<div className="h-3 w-3 rounded-full bg-gray-300 dark:bg-gray-600" />
</div>
<div className="text-xs font-medium text-gray-500 dark:text-gray-400">{t('kanban.noTasksYet')}</div>
<div className="mt-1 text-xs text-gray-400 dark:text-gray-500">
{column.status === 'pending'
? t('kanban.tasksWillAppear')
: column.status === 'in-progress'
? t('kanban.moveTasksHere')
: column.status === 'done'
? t('kanban.completedTasksHere')
: t('kanban.statusTasksHere')}
</div>
</div>
) : (
column.tasks.map((task) => (
<TaskCard
key={String(task.id)}
task={task}
onClick={() => onTaskClick(task)}
showParent={showParentTasks}
className="w-full shadow-sm hover:shadow-md"
/>
))
)}
</div>
</div>
))}
</div>
);
}
export default function TaskBoardContent({
viewMode,
filteredTaskCount,
kanbanColumns,
filteredTasks,
showParentTasks,
onTaskClick,
}: TaskBoardContentProps) {
const { t } = useTranslation('tasks');
if (filteredTaskCount === 0) {
return (
<div className="py-12 text-center">
<div className="text-gray-500 dark:text-gray-400">
<Search className="mx-auto mb-4 h-12 w-12 opacity-50" />
<h3 className="mb-2 text-lg font-medium">{t('noMatchingTasks.title')}</h3>
<p className="text-sm">{t('noMatchingTasks.description')}</p>
</div>
</div>
);
}
if (viewMode === 'kanban') {
return <KanbanColumns columns={kanbanColumns} showParentTasks={showParentTasks} onTaskClick={onTaskClick} />;
}
return (
<div className={cn('gap-4', viewMode === 'grid' ? 'grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3' : 'space-y-4')}>
{filteredTasks.map((task) => (
<TaskCard
key={String(task.id)}
task={task}
onClick={() => onTaskClick(task)}
showParent={showParentTasks}
className={viewMode === 'grid' ? 'h-full' : ''}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,266 @@
import { useEffect, useRef, useState } from 'react';
import {
ChevronDown,
Columns,
FileText,
Filter,
Grid,
HelpCircle,
List,
Plus,
Search,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { cn } from '../../../lib/utils';
import type { PrdFile, TaskBoardSortField, TaskBoardSortOrder, TaskBoardView } from '../types';
import TaskFiltersPanel from './shared/TaskFiltersPanel';
import TaskQuickSortBar from './shared/TaskQuickSortBar';
type TaskBoardToolbarProps = {
hasProject: boolean;
hasTaskMasterConfigured: boolean;
totalTaskCount: number;
filteredTaskCount: number;
searchTerm: string;
onSearchTermChange: (value: string) => void;
viewMode: TaskBoardView;
onViewModeChange: (viewMode: TaskBoardView) => void;
showFilters: boolean;
onToggleFilters: () => void;
statusFilter: string;
onStatusFilterChange: (status: string) => void;
priorityFilter: string;
onPriorityFilterChange: (priority: string) => void;
sortField: TaskBoardSortField;
sortOrder: TaskBoardSortOrder;
onSortChange: (field: TaskBoardSortField) => void;
onSortConfigChange: (field: TaskBoardSortField, order: TaskBoardSortOrder) => void;
statuses: string[];
priorities: string[];
onClearFilters: () => void;
existingPrds: PrdFile[];
onCreatePrd: () => void;
onOpenPrd: (prd: PrdFile) => void;
onOpenHelp: () => void;
onOpenCreateTask: () => void;
};
export default function TaskBoardToolbar({
hasProject,
hasTaskMasterConfigured,
totalTaskCount,
filteredTaskCount,
searchTerm,
onSearchTermChange,
viewMode,
onViewModeChange,
showFilters,
onToggleFilters,
statusFilter,
onStatusFilterChange,
priorityFilter,
onPriorityFilterChange,
sortField,
sortOrder,
onSortChange,
onSortConfigChange,
statuses,
priorities,
onClearFilters,
existingPrds,
onCreatePrd,
onOpenPrd,
onOpenHelp,
onOpenCreateTask,
}: TaskBoardToolbarProps) {
const { t } = useTranslation('tasks');
const [isPrdDropdownOpen, setIsPrdDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const handleMouseDown = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsPrdDropdownOpen(false);
}
};
document.addEventListener('mousedown', handleMouseDown);
return () => document.removeEventListener('mousedown', handleMouseDown);
}, []);
return (
<>
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="relative max-w-md flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<input
type="text"
value={searchTerm}
onChange={(event) => onSearchTermChange(event.target.value)}
placeholder={t('search.placeholder')}
className="w-full rounded-lg border border-gray-300 bg-white py-2 pl-10 pr-4 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-white"
/>
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="flex rounded-lg bg-gray-100 p-1 dark:bg-gray-800">
<button
onClick={() => onViewModeChange('kanban')}
className={cn(
'p-2 rounded-md',
viewMode === 'kanban'
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300',
)}
title={t('views.kanban')}
>
<Columns className="h-4 w-4" />
</button>
<button
onClick={() => onViewModeChange('list')}
className={cn(
'p-2 rounded-md',
viewMode === 'list'
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300',
)}
title={t('views.list')}
>
<List className="h-4 w-4" />
</button>
<button
onClick={() => onViewModeChange('grid')}
className={cn(
'p-2 rounded-md',
viewMode === 'grid'
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300',
)}
title={t('views.grid')}
>
<Grid className="h-4 w-4" />
</button>
</div>
<button
onClick={onToggleFilters}
className={cn(
'flex items-center gap-2 px-3 py-2 rounded-lg border transition-colors',
showFilters
? 'bg-blue-50 dark:bg-blue-900 border-blue-200 dark:border-blue-700 text-blue-700 dark:text-blue-300'
: 'bg-white dark:bg-gray-800 border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700',
)}
>
<Filter className="h-4 w-4" />
<span className="hidden sm:inline">{t('filters.button')}</span>
<ChevronDown className={cn('w-4 h-4 transition-transform', showFilters && 'rotate-180')} />
</button>
{hasProject && (
<>
<button
onClick={onOpenHelp}
className="rounded-lg border border-gray-300 p-2 text-gray-600 hover:bg-gray-100 hover:text-blue-600 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-blue-400"
title={t('buttons.help')}
>
<HelpCircle className="h-4 w-4" />
</button>
<div ref={dropdownRef} className="relative">
{existingPrds.length > 0 ? (
<>
<button
onClick={() => setIsPrdDropdownOpen((current) => !current)}
className="flex items-center gap-2 rounded-lg bg-purple-600 px-3 py-2 font-medium text-white hover:bg-purple-700"
title={t('buttons.prdsAvailable', { count: existingPrds.length })}
>
<FileText className="h-4 w-4" />
<span className="hidden sm:inline">{t('buttons.prds')}</span>
<span className="min-w-5 rounded-full bg-purple-500 px-1.5 py-0.5 text-center text-xs">
{existingPrds.length}
</span>
<ChevronDown className={cn('w-3 h-3 transition-transform hidden sm:block', isPrdDropdownOpen && 'rotate-180')} />
</button>
{isPrdDropdownOpen && (
<div className="absolute right-0 top-full z-30 mt-2 w-56 rounded-lg border border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-800">
<div className="p-2">
<button
onClick={() => {
onCreatePrd();
setIsPrdDropdownOpen(false);
}}
className="flex w-full items-center gap-2 rounded px-3 py-2 text-left text-sm font-medium text-purple-700 hover:bg-purple-50 dark:text-purple-300 dark:hover:bg-purple-900/30"
>
<Plus className="h-4 w-4" />
{t('buttons.createNewPRD')}
</button>
<div className="my-1 border-t border-gray-200 dark:border-gray-700" />
{existingPrds.map((prd) => (
<button
key={prd.name}
onClick={() => {
onOpenPrd(prd);
setIsPrdDropdownOpen(false);
}}
className="flex w-full items-center gap-2 rounded px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"
>
<FileText className="h-4 w-4" />
<span className="truncate">{prd.name}</span>
</button>
))}
</div>
</div>
)}
</>
) : (
<button
onClick={onCreatePrd}
className="flex items-center gap-2 rounded-lg bg-purple-600 px-3 py-2 font-medium text-white hover:bg-purple-700"
title={t('buttons.addPRD')}
>
<FileText className="h-4 w-4" />
<span className="hidden sm:inline">{t('buttons.addPRD')}</span>
</button>
)}
</div>
{(hasTaskMasterConfigured || totalTaskCount > 0) && (
<button
onClick={onOpenCreateTask}
className="flex items-center gap-2 rounded-lg bg-blue-600 px-3 py-2 font-medium text-white hover:bg-blue-700"
title={t('buttons.addTask')}
>
<Plus className="h-4 w-4" />
<span className="hidden sm:inline">{t('buttons.addTask')}</span>
</button>
)}
</>
)}
</div>
</div>
<TaskFiltersPanel
showFilters={showFilters}
statusFilter={statusFilter}
onStatusFilterChange={onStatusFilterChange}
priorityFilter={priorityFilter}
onPriorityFilterChange={onPriorityFilterChange}
sortField={sortField}
sortOrder={sortOrder}
onSortConfigChange={onSortConfigChange}
statuses={statuses}
priorities={priorities}
filteredTaskCount={filteredTaskCount}
totalTaskCount={totalTaskCount}
onClearFilters={onClearFilters}
/>
<TaskQuickSortBar sortField={sortField} sortOrder={sortOrder} onSortChange={onSortChange} />
</>
);
}

View File

@@ -0,0 +1,210 @@
import { memo } from 'react';
import {
AlertCircle,
ArrowRight,
CheckCircle,
ChevronUp,
Circle,
Clock,
Minus,
Pause,
X,
} from 'lucide-react';
import { cn } from '../../../lib/utils';
import { Tooltip } from '../../../shared/view/ui';
import type { TaskMasterTask } from '../types';
type TaskCardProps = {
task: TaskMasterTask;
onClick?: (() => void) | null;
showParent?: boolean;
className?: string;
};
type TaskStatusStyle = {
icon: typeof Circle;
statusText: string;
iconColor: string;
textColor: string;
};
function getStatusStyle(status?: string): TaskStatusStyle {
if (status === 'done') {
return {
icon: CheckCircle,
statusText: 'Done',
iconColor: 'text-green-600 dark:text-green-400',
textColor: 'text-green-900 dark:text-green-100',
};
}
if (status === 'in-progress') {
return {
icon: Clock,
statusText: 'In Progress',
iconColor: 'text-blue-600 dark:text-blue-400',
textColor: 'text-blue-900 dark:text-blue-100',
};
}
if (status === 'review') {
return {
icon: AlertCircle,
statusText: 'Review',
iconColor: 'text-amber-600 dark:text-amber-400',
textColor: 'text-amber-900 dark:text-amber-100',
};
}
if (status === 'deferred') {
return {
icon: Pause,
statusText: 'Deferred',
iconColor: 'text-gray-500 dark:text-gray-400',
textColor: 'text-gray-700 dark:text-gray-300',
};
}
if (status === 'cancelled') {
return {
icon: X,
statusText: 'Cancelled',
iconColor: 'text-red-600 dark:text-red-400',
textColor: 'text-red-900 dark:text-red-100',
};
}
return {
icon: Circle,
statusText: 'Pending',
iconColor: 'text-slate-500 dark:text-slate-400',
textColor: 'text-slate-900 dark:text-slate-100',
};
}
function renderPriorityIcon(priority?: string) {
if (priority === 'high') {
return (
<Tooltip content="High priority">
<div className="flex h-4 w-4 items-center justify-center rounded bg-red-100 dark:bg-red-900/30">
<ChevronUp className="h-2.5 w-2.5 text-red-600 dark:text-red-400" />
</div>
</Tooltip>
);
}
if (priority === 'medium') {
return (
<Tooltip content="Medium priority">
<div className="flex h-4 w-4 items-center justify-center rounded bg-amber-100 dark:bg-amber-900/30">
<Minus className="h-2.5 w-2.5 text-amber-600 dark:text-amber-400" />
</div>
</Tooltip>
);
}
if (priority === 'low') {
return (
<Tooltip content="Low priority">
<div className="flex h-4 w-4 items-center justify-center rounded bg-blue-100 dark:bg-blue-900/30">
<Circle className="h-1.5 w-1.5 fill-current text-blue-600 dark:text-blue-400" />
</div>
</Tooltip>
);
}
return (
<Tooltip content="No priority set">
<div className="flex h-4 w-4 items-center justify-center rounded bg-gray-100 dark:bg-gray-800">
<Circle className="h-1.5 w-1.5 text-gray-400 dark:text-gray-500" />
</div>
</Tooltip>
);
}
function getSubtaskProgress(task: TaskMasterTask): { completed: number; total: number; percentage: number } {
const subtasks = task.subtasks ?? [];
const total = subtasks.length;
const completed = subtasks.filter((subtask) => subtask.status === 'done').length;
const percentage = total > 0 ? Math.round((completed / total) * 100) : 0;
return { completed, total, percentage };
}
function TaskCard({ task, onClick = null, showParent = false, className = '' }: TaskCardProps) {
const statusStyle = getStatusStyle(task.status);
const progress = getSubtaskProgress(task);
return (
<div
className={cn(
'bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-3 space-y-3',
'hover:shadow-md hover:border-blue-300 dark:hover:border-blue-600 transition-all duration-200',
onClick ? 'cursor-pointer hover:-translate-y-0.5' : 'cursor-default',
className,
)}
onClick={onClick ?? undefined}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="mb-1 flex items-center gap-2">
<Tooltip content={`Task ID: ${task.id}`}>
<span className="rounded bg-gray-100 px-2 py-0.5 font-mono text-xs text-gray-500 dark:bg-gray-700 dark:text-gray-400">
{task.id}
</span>
</Tooltip>
</div>
<h3 className="line-clamp-2 text-sm font-medium leading-tight text-gray-900 dark:text-white">
{task.title}
</h3>
{showParent && task.parentId && (
<span className="text-xs font-medium text-gray-500 dark:text-gray-400">Task {task.parentId}</span>
)}
</div>
<div className="flex-shrink-0">{renderPriorityIcon(task.priority)}</div>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center">
{Array.isArray(task.dependencies) && task.dependencies.length > 0 && (
<Tooltip content={`Depends on: ${task.dependencies.map((dependency) => `Task ${dependency}`).join(', ')}`}>
<div className="flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400">
<ArrowRight className="h-3 w-3" />
<span>Depends on: {task.dependencies.join(', ')}</span>
</div>
</Tooltip>
)}
</div>
<Tooltip content={`Status: ${statusStyle.statusText}`}>
<div className="flex items-center gap-1">
<div className={cn('w-2 h-2 rounded-full', statusStyle.iconColor.replace('text-', 'bg-'))} />
<span className={cn('text-xs font-medium', statusStyle.textColor)}>{statusStyle.statusText}</span>
</div>
</Tooltip>
</div>
{progress.total > 0 && (
<div className="ml-3">
<div className="mb-1 flex items-center gap-2">
<span className="text-xs text-gray-500 dark:text-gray-400">Progress:</span>
<div className="h-1.5 flex-1 rounded-full bg-gray-200 dark:bg-gray-700" title={`${progress.completed} of ${progress.total} subtasks completed`}>
<div
className={cn('h-full rounded-full transition-all duration-300', task.status === 'done' ? 'bg-green-500' : 'bg-blue-500')}
style={{ width: `${progress.percentage}%` }}
/>
</div>
<span className="text-xs text-gray-500 dark:text-gray-400">
{progress.completed}/{progress.total}
</span>
</div>
</div>
)}
</div>
);
}
export default memo(TaskCard);

View File

@@ -0,0 +1,318 @@
import { useEffect, useMemo, useState } from 'react';
import {
AlertCircle,
ArrowRight,
CheckCircle,
ChevronDown,
ChevronRight,
Circle,
Clock,
Copy,
Edit,
Pause,
Save,
X,
} from 'lucide-react';
import { cn } from '../../../lib/utils';
import { copyTextToClipboard } from '../../../utils/clipboard';
import { api } from '../../../utils/api';
import { useTaskMaster } from '../context/TaskMasterContext';
import type { TaskId, TaskMasterTask, TaskReference } from '../types';
type TaskDetailModalProps = {
task: TaskMasterTask | null;
isOpen?: boolean;
className?: string;
onClose: () => void;
onEdit?: ((task: TaskMasterTask) => void) | null;
onStatusChange?: ((taskId: TaskId, status: string) => void) | null;
onTaskClick?: ((task: TaskReference) => void) | null;
};
const STATUS_OPTIONS = [
{ value: 'pending', label: 'Pending' },
{ value: 'in-progress', label: 'In Progress' },
{ value: 'review', label: 'Review' },
{ value: 'done', label: 'Done' },
{ value: 'deferred', label: 'Deferred' },
{ value: 'cancelled', label: 'Cancelled' },
];
function getStatusIcon(status?: string) {
if (status === 'done') return CheckCircle;
if (status === 'in-progress') return Clock;
if (status === 'review') return AlertCircle;
if (status === 'deferred') return Pause;
if (status === 'cancelled') return X;
return Circle;
}
function getPriorityBadgeClass(priority?: string): string {
if (priority === 'high') return 'text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950';
if (priority === 'medium') return 'text-yellow-600 dark:text-yellow-400 bg-yellow-50 dark:bg-yellow-950';
if (priority === 'low') return 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-950';
return 'text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-gray-800';
}
export default function TaskDetailModal({
task,
isOpen = true,
className = '',
onClose,
onEdit = null,
onStatusChange = null,
onTaskClick = null,
}: TaskDetailModalProps) {
const { currentProject, refreshTasks } = useTaskMaster();
const [isEditMode, setIsEditMode] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [showDetails, setShowDetails] = useState(false);
const [showTestStrategy, setShowTestStrategy] = useState(false);
const [editableTask, setEditableTask] = useState<TaskMasterTask | null>(task);
useEffect(() => {
setEditableTask(task);
setIsEditMode(false);
}, [task]);
const StatusIcon = useMemo(() => getStatusIcon(task?.status), [task?.status]);
if (!isOpen || !task || !editableTask) {
return null;
}
const handleSaveChanges = async () => {
if (!currentProject?.name) {
return;
}
const updates: Record<string, string> = {};
if (editableTask.title !== task.title) {
updates.title = editableTask.title;
}
if (editableTask.description !== task.description) {
updates.description = editableTask.description ?? '';
}
if (editableTask.details !== task.details) {
updates.details = editableTask.details ?? '';
}
if (Object.keys(updates).length === 0) {
setIsEditMode(false);
return;
}
setIsSaving(true);
try {
const response = await api.taskmaster.updateTask(currentProject.name, task.id, updates);
if (!response.ok) {
const errorPayload = (await response.json()) as { message?: string };
throw new Error(errorPayload.message ?? 'Failed to update task');
}
setIsEditMode(false);
await refreshTasks();
onEdit?.(editableTask);
} catch (error) {
console.error('Failed to save task changes:', error);
alert(error instanceof Error ? error.message : 'Failed to update task');
} finally {
setIsSaving(false);
}
};
const handleStatusSelect = async (nextStatus: string) => {
if (!currentProject?.name || nextStatus === task.status) {
return;
}
try {
const response = await api.taskmaster.updateTask(currentProject.name, task.id, { status: nextStatus });
if (!response.ok) {
const errorPayload = (await response.json()) as { message?: string };
throw new Error(errorPayload.message ?? 'Failed to update task status');
}
await refreshTasks();
onStatusChange?.(task.id, nextStatus);
} catch (error) {
console.error('Failed to update task status:', error);
alert(error instanceof Error ? error.message : 'Failed to update task status');
}
};
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 md:p-4">
<div
className={cn(
'w-full md:max-w-4xl h-full md:h-[90vh] bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 md:rounded-lg shadow-xl flex flex-col',
className,
)}
>
<div className="flex items-center justify-between border-b border-gray-200 p-4 dark:border-gray-700 md:p-6">
<div className="flex min-w-0 flex-1 items-center gap-3">
<StatusIcon className="h-6 w-6 text-blue-600 dark:text-blue-400" />
<div className="min-w-0 flex-1">
<button
onClick={() => copyTextToClipboard(String(task.id))}
className="mb-2 inline-flex items-center gap-1 rounded bg-gray-100 px-2 py-1 text-xs text-gray-600 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
title="Copy task ID"
>
<span>Task {task.id}</span>
<Copy className="h-3 w-3" />
</button>
{isEditMode ? (
<input
type="text"
value={editableTask.title}
onChange={(event) => setEditableTask({ ...editableTask, title: event.target.value })}
className="w-full border-b-2 border-blue-500 bg-transparent text-lg font-semibold text-gray-900 focus:outline-none dark:text-white"
/>
) : (
<h1 className="line-clamp-2 text-lg font-semibold text-gray-900 dark:text-white md:text-xl">{task.title}</h1>
)}
</div>
</div>
<div className="flex items-center gap-2">
{isEditMode ? (
<>
<button
onClick={handleSaveChanges}
disabled={isSaving}
className="rounded-md p-2 text-green-600 hover:bg-green-50 disabled:opacity-50 dark:hover:bg-green-950"
title="Save"
>
<Save className={cn('w-5 h-5', isSaving && 'animate-spin')} />
</button>
<button
onClick={() => {
setEditableTask(task);
setIsEditMode(false);
}}
disabled={isSaving}
className="rounded-md p-2 text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800"
title="Cancel editing"
>
<X className="h-5 w-5" />
</button>
</>
) : (
<button
onClick={() => setIsEditMode(true)}
className="rounded-md p-2 text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800"
title="Edit task"
>
<Edit className="h-5 w-5" />
</button>
)}
<button onClick={onClose} className="rounded-md p-2 text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800" title="Close">
<X className="h-5 w-5" />
</button>
</div>
</div>
<div className="flex-1 space-y-6 overflow-y-auto p-4 md:p-6">
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">Status</label>
<select
value={task.status ?? 'pending'}
onChange={(event) => {
void handleStatusSelect(event.target.value);
}}
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-white"
>
{STATUS_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">Priority</label>
<div className={cn('px-3 py-2 rounded-md text-sm font-medium capitalize', getPriorityBadgeClass(task.priority))}>
{task.priority ?? 'Not set'}
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">Dependencies</label>
{Array.isArray(task.dependencies) && task.dependencies.length > 0 ? (
<div className="flex flex-wrap gap-1">
{task.dependencies.map((dependency) => (
<button
key={String(dependency)}
onClick={() => onTaskClick?.({ id: dependency })}
className="rounded bg-blue-100 px-2 py-1 text-sm text-blue-700 hover:bg-blue-200 dark:bg-blue-900 dark:text-blue-300 dark:hover:bg-blue-800"
>
<ArrowRight className="mr-1 inline h-3 w-3" />
{dependency}
</button>
))}
</div>
) : (
<span className="text-sm text-gray-500 dark:text-gray-400">No dependencies</span>
)}
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">Description</label>
{isEditMode ? (
<textarea
rows={4}
value={editableTask.description ?? ''}
onChange={(event) => setEditableTask({ ...editableTask, description: event.target.value })}
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 dark:border-gray-600 dark:bg-gray-800"
/>
) : (
<p className="whitespace-pre-wrap text-gray-700 dark:text-gray-300">{task.description || 'No description provided'}</p>
)}
</div>
{task.details && (
<div className="rounded-lg border border-gray-200 dark:border-gray-700">
<button
onClick={() => setShowDetails((current) => !current)}
className="flex w-full items-center justify-between p-4 text-left hover:bg-gray-50 dark:hover:bg-gray-800"
>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Implementation Details</span>
{showDetails ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
{showDetails && (
<div className="border-t border-gray-200 p-4 dark:border-gray-700">
<p className="whitespace-pre-wrap text-gray-700 dark:text-gray-300">{task.details}</p>
</div>
)}
</div>
)}
{task.testStrategy && (
<div className="rounded-lg border border-gray-200 dark:border-gray-700">
<button
onClick={() => setShowTestStrategy((current) => !current)}
className="flex w-full items-center justify-between p-4 text-left hover:bg-gray-50 dark:hover:bg-gray-800"
>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Test Strategy</span>
{showTestStrategy ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
{showTestStrategy && (
<div className="border-t border-gray-200 bg-blue-50 p-4 dark:border-gray-700 dark:bg-blue-950/30">
<p className="whitespace-pre-wrap text-gray-700 dark:text-gray-300">{task.testStrategy}</p>
</div>
)}
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,134 @@
import { FileText, Settings, Terminal } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { cn } from '../../../lib/utils';
import type { PrdFile } from '../types';
type TaskEmptyStateProps = {
className?: string;
hasTaskMasterDirectory: boolean;
existingPrds: PrdFile[];
onOpenSetupModal: () => void;
onCreatePrd: () => void;
onOpenPrd: (prd: PrdFile) => void;
};
export default function TaskEmptyState({
className = '',
hasTaskMasterDirectory,
existingPrds,
onOpenSetupModal,
onCreatePrd,
onOpenPrd,
}: TaskEmptyStateProps) {
const { t } = useTranslation('tasks');
if (!hasTaskMasterDirectory) {
return (
<div className={cn('text-center py-12', className)}>
<div className="mx-auto max-w-md">
<div className="mb-4 text-blue-600 dark:text-blue-400">
<Settings className="mx-auto mb-4 h-12 w-12" />
</div>
<h3 className="mb-2 text-lg font-semibold text-gray-900 dark:text-white">{t('notConfigured.title')}</h3>
<p className="mb-6 text-sm text-gray-600 dark:text-gray-400">{t('notConfigured.description')}</p>
<div className="mb-6 rounded-lg bg-blue-50 p-4 text-left dark:bg-blue-950">
<h4 className="mb-3 text-sm font-medium text-blue-900 dark:text-blue-100">{t('notConfigured.whatIsTitle')}</h4>
<div className="space-y-1 text-xs text-blue-800 dark:text-blue-200">
<p>- {t('notConfigured.features.aiPowered')}</p>
<p>- {t('notConfigured.features.prdTemplates')}</p>
<p>- {t('notConfigured.features.dependencyTracking')}</p>
<p>- {t('notConfigured.features.progressVisualization')}</p>
<p>- {t('notConfigured.features.cliIntegration')}</p>
</div>
</div>
<button
onClick={onOpenSetupModal}
className="mx-auto flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 font-medium text-white transition-colors hover:bg-blue-700"
>
<Terminal className="h-4 w-4" />
{t('notConfigured.initializeButton')}
</button>
</div>
</div>
);
}
return (
<div className={cn('text-center py-12', className)}>
<div className="mx-auto max-w-4xl">
<div className="mb-6 rounded-xl border border-blue-200 bg-gradient-to-r from-blue-50 to-indigo-50 p-6 text-left dark:border-blue-800 dark:from-blue-950/50 dark:to-indigo-950/50">
<div className="mb-4 flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-100 dark:bg-blue-900/50">
<FileText className="h-5 w-5 text-blue-600 dark:text-blue-400" />
</div>
<div>
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">{t('gettingStarted.title')}</h2>
<p className="text-sm text-gray-600 dark:text-gray-400">{t('gettingStarted.subtitle')}</p>
</div>
</div>
<div className="mb-4 space-y-3">
<div className="rounded-lg border border-blue-100 bg-white p-3 dark:border-blue-800/50 dark:bg-gray-800/60">
<h4 className="mb-1 font-medium text-gray-900 dark:text-white">1. {t('gettingStarted.steps.createPRD.title')}</h4>
<p className="mb-3 text-sm text-gray-600 dark:text-gray-400">{t('gettingStarted.steps.createPRD.description')}</p>
<button
onClick={onCreatePrd}
className="inline-flex items-center gap-2 rounded bg-purple-100 px-2 py-1 text-xs text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-300 dark:hover:bg-purple-900/50"
>
<FileText className="h-3 w-3" />
{t('gettingStarted.steps.createPRD.addButton')}
</button>
{existingPrds.length > 0 && (
<div className="mt-3 border-t border-gray-200 pt-3 dark:border-gray-700">
<p className="mb-2 text-xs text-gray-500 dark:text-gray-400">{t('gettingStarted.steps.createPRD.existingPRDs')}</p>
<div className="flex flex-wrap gap-2">
{existingPrds.map((prd) => (
<button
key={prd.name}
onClick={() => onOpenPrd(prd)}
className="inline-flex items-center gap-1 rounded bg-gray-100 px-2 py-1 text-xs text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
>
<FileText className="h-3 w-3" />
{prd.name}
</button>
))}
</div>
</div>
)}
</div>
<div className="rounded-lg border border-blue-100 bg-white p-3 dark:border-blue-800/50 dark:bg-gray-800/60">
<h4 className="mb-1 font-medium text-gray-900 dark:text-white">2. {t('gettingStarted.steps.generateTasks.title')}</h4>
<p className="text-sm text-gray-600 dark:text-gray-400">{t('gettingStarted.steps.generateTasks.description')}</p>
</div>
<div className="rounded-lg border border-blue-100 bg-white p-3 dark:border-blue-800/50 dark:bg-gray-800/60">
<h4 className="mb-1 font-medium text-gray-900 dark:text-white">3. {t('gettingStarted.steps.analyzeTasks.title')}</h4>
<p className="text-sm text-gray-600 dark:text-gray-400">{t('gettingStarted.steps.analyzeTasks.description')}</p>
</div>
<div className="rounded-lg border border-blue-100 bg-white p-3 dark:border-blue-800/50 dark:bg-gray-800/60">
<h4 className="mb-1 font-medium text-gray-900 dark:text-white">4. {t('gettingStarted.steps.startBuilding.title')}</h4>
<p className="text-sm text-gray-600 dark:text-gray-400">{t('gettingStarted.steps.startBuilding.description')}</p>
</div>
</div>
<button
onClick={onCreatePrd}
className="inline-flex items-center gap-2 rounded-lg bg-purple-600 px-4 py-2 font-medium text-white hover:bg-purple-700"
>
<FileText className="h-4 w-4" />
{t('buttons.addPRD')}
</button>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400">{t('gettingStarted.tip')}</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,150 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import PRDEditor from '../../prd-editor';
import { useTaskMaster } from '../context/TaskMasterContext';
import { useProjectPrdFiles } from '../hooks/useProjectPrdFiles';
import type { PrdFile, TaskMasterTask, TaskSelection } from '../types';
import TaskBoard from './TaskBoard';
import TaskDetailModal from './TaskDetailModal';
type TaskMasterPanelProps = {
isVisible: boolean;
};
const PRD_SAVE_MESSAGE = 'PRD saved successfully!';
export default function TaskMasterPanel({ isVisible }: TaskMasterPanelProps) {
const { tasks, currentProject, refreshTasks } = useTaskMaster();
const [selectedTask, setSelectedTask] = useState<TaskMasterTask | null>(null);
const [isTaskDetailOpen, setIsTaskDetailOpen] = useState(false);
const [isPrdEditorOpen, setIsPrdEditorOpen] = useState(false);
const [selectedPrd, setSelectedPrd] = useState<PrdFile | null>(null);
const [prdNotification, setPrdNotification] = useState<string | null>(null);
const notificationTimeoutRef = useRef<number | null>(null);
const { prdFiles, refreshPrdFiles } = useProjectPrdFiles({ projectName: currentProject?.name });
const showPrdNotification = useCallback((message: string) => {
if (notificationTimeoutRef.current) {
window.clearTimeout(notificationTimeoutRef.current);
}
setPrdNotification(message);
notificationTimeoutRef.current = window.setTimeout(() => {
setPrdNotification(null);
notificationTimeoutRef.current = null;
}, 3000);
}, []);
const refreshPrdData = useCallback(
async (showNotification = false) => {
await refreshPrdFiles();
if (showNotification) {
showPrdNotification(PRD_SAVE_MESSAGE);
}
},
[refreshPrdFiles, showPrdNotification],
);
useEffect(() => {
return () => {
if (notificationTimeoutRef.current) {
window.clearTimeout(notificationTimeoutRef.current);
}
};
}, []);
const handleTaskClick = useCallback(
(taskSelection: TaskSelection) => {
const selectedId = String(taskSelection.id);
if (!taskSelection.title) {
const fullTask = tasks.find((task) => String(task.id) === selectedId) ?? null;
if (fullTask) {
setSelectedTask(fullTask);
setIsTaskDetailOpen(true);
}
return;
}
setSelectedTask(taskSelection as TaskMasterTask);
setIsTaskDetailOpen(true);
},
[tasks],
);
return (
<>
<div className={`h-full ${isVisible ? 'block' : 'hidden'}`}>
<div className="flex h-full flex-col overflow-hidden">
<TaskBoard
tasks={tasks}
onTaskClick={handleTaskClick}
showParentTasks
className="flex-1 overflow-y-auto p-4"
currentProject={currentProject}
onTaskCreated={refreshTasks}
onShowPRDEditor={(prd) => {
setSelectedPrd(prd ?? null);
setIsPrdEditorOpen(true);
}}
existingPRDs={prdFiles}
onRefreshPRDs={(showNotification = false) => {
void refreshPrdData(showNotification);
}}
/>
</div>
</div>
<TaskDetailModal
task={selectedTask}
isOpen={isTaskDetailOpen}
onClose={() => {
setIsTaskDetailOpen(false);
setSelectedTask(null);
}}
onStatusChange={() => {
void refreshTasks();
}}
onTaskClick={handleTaskClick}
/>
{isPrdEditorOpen && (
<PRDEditor
project={currentProject}
projectPath={currentProject?.fullPath || currentProject?.path}
onClose={() => {
setIsPrdEditorOpen(false);
setSelectedPrd(null);
}}
isNewFile={!selectedPrd?.isExisting}
file={{
name: selectedPrd?.name || 'prd.txt',
content: selectedPrd?.content || '',
isExisting: selectedPrd?.isExisting,
}}
onSave={async () => {
setIsPrdEditorOpen(false);
setSelectedPrd(null);
await refreshPrdData(true);
await refreshTasks();
}}
/>
)}
{prdNotification && (
<div className="animate-in slide-in-from-bottom-2 fixed bottom-4 right-4 z-50 duration-300">
<div className="flex items-center gap-3 rounded-lg bg-green-600 px-4 py-3 text-white shadow-lg">
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="font-medium">{prdNotification}</span>
</div>
</div>
)}
</>
);
}

View File

@@ -0,0 +1,73 @@
import { Sparkles, X } from 'lucide-react';
type CreateTaskModalProps = {
isOpen: boolean;
onClose: () => void;
};
export default function CreateTaskModal({ isOpen, onClose }: CreateTaskModalProps) {
if (!isOpen) {
return null;
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
<div className="w-full max-w-md rounded-lg border border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-800">
<div className="flex items-center justify-between border-b border-gray-200 p-6 dark:border-gray-700">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-blue-100 dark:bg-blue-900/50">
<Sparkles className="h-4 w-4 text-blue-600 dark:text-blue-400" />
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Create AI-Generated Task</h3>
</div>
<button
onClick={onClose}
className="rounded-md p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700 dark:hover:text-gray-300"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="space-y-6 p-6">
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-800 dark:bg-blue-900/20">
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-blue-100 dark:bg-blue-900/50">
<Sparkles className="h-4 w-4 text-blue-600 dark:text-blue-400" />
</div>
<div>
<h4 className="mb-2 font-semibold text-blue-900 dark:text-blue-100">Pro tip: ask Claude Code directly</h4>
<p className="mb-3 text-sm text-blue-800 dark:text-blue-200">
Ask for a task in chat with context and requirements. TaskMaster can generate implementation-ready tasks.
</p>
<div className="rounded border border-blue-200 bg-white p-3 dark:border-blue-700 dark:bg-gray-800">
<p className="mb-1 text-xs font-medium text-gray-600 dark:text-gray-400">Example:</p>
<p className="font-mono text-sm text-gray-900 dark:text-white">
Please add a task for profile image uploads and include best-practice research.
</p>
</div>
</div>
</div>
</div>
<div className="border-t border-gray-200 pt-4 text-center dark:border-gray-700">
<a
href="https://github.com/eyaltoledano/claude-task-master/blob/main/docs/examples.md"
target="_blank"
rel="noopener noreferrer"
className="inline-block text-sm font-medium text-blue-600 underline hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
View TaskMaster documentation
</a>
</div>
<button
onClick={onClose}
className="w-full rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
>
Got it
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,129 @@
import { ExternalLink, FileText, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
type TaskHelpModalProps = {
isOpen: boolean;
onClose: () => void;
onCreatePrd: () => void;
};
type HelpStep = {
index: number;
title: string;
description: string;
accent: string;
};
export default function TaskHelpModal({ isOpen, onClose, onCreatePrd }: TaskHelpModalProps) {
const { t } = useTranslation('tasks');
if (!isOpen) {
return null;
}
const steps: HelpStep[] = [
{
index: 1,
title: t('gettingStarted.steps.createPRD.title'),
description: t('gettingStarted.steps.createPRD.description'),
accent: 'border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-950/40',
},
{
index: 2,
title: t('gettingStarted.steps.generateTasks.title'),
description: t('gettingStarted.steps.generateTasks.description'),
accent: 'border-emerald-200 dark:border-emerald-800 bg-emerald-50 dark:bg-emerald-950/40',
},
{
index: 3,
title: t('gettingStarted.steps.analyzeTasks.title'),
description: t('gettingStarted.steps.analyzeTasks.description'),
accent: 'border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-950/40',
},
{
index: 4,
title: t('gettingStarted.steps.startBuilding.title'),
description: t('gettingStarted.steps.startBuilding.description'),
accent: 'border-purple-200 dark:border-purple-800 bg-purple-50 dark:bg-purple-950/40',
},
];
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
<div className="max-h-[90vh] w-full max-w-4xl overflow-hidden rounded-lg border border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-900">
<div className="flex items-center justify-between border-b border-gray-200 p-6 dark:border-gray-700">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-100 dark:bg-blue-900/50">
<FileText className="h-5 w-5 text-blue-600 dark:text-blue-400" />
</div>
<div>
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">{t('helpGuide.title')}</h2>
<p className="text-sm text-gray-600 dark:text-gray-400">{t('helpGuide.subtitle')}</p>
</div>
</div>
<button
onClick={onClose}
className="rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700 dark:hover:text-gray-300"
title="Close"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="max-h-[calc(90vh-120px)] space-y-4 overflow-y-auto p-6">
{steps.map((step) => (
<div key={step.index} className={`rounded-lg border p-4 ${step.accent}`}>
<div className="flex gap-4">
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-sm font-semibold text-white">
{step.index}
</div>
<div>
<h4 className="mb-2 font-medium text-gray-900 dark:text-white">{step.title}</h4>
<p className="text-sm text-gray-700 dark:text-gray-300">{step.description}</p>
{step.index === 1 && (
<button
onClick={() => {
onCreatePrd();
onClose();
}}
className="mt-3 inline-flex items-center gap-2 rounded-lg bg-purple-100 px-3 py-1.5 text-sm text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-300 dark:hover:bg-purple-900/50"
>
<FileText className="h-4 w-4" />
{t('buttons.addPRD')}
</button>
)}
</div>
</div>
</div>
))}
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800/50">
<h4 className="mb-2 font-medium text-gray-900 dark:text-white">{t('helpGuide.proTips.title')}</h4>
<ul className="space-y-2 text-sm text-gray-600 dark:text-gray-400">
<li>{t('helpGuide.proTips.search')}</li>
<li>{t('helpGuide.proTips.views')}</li>
<li>{t('helpGuide.proTips.filters')}</li>
<li>{t('helpGuide.proTips.details')}</li>
</ul>
</div>
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-800 dark:bg-blue-950/40">
<h4 className="mb-2 font-medium text-blue-900 dark:text-blue-100">{t('helpGuide.learnMore.title')}</h4>
<p className="mb-3 text-sm text-blue-800 dark:text-blue-200">{t('helpGuide.learnMore.description')}</p>
<a
href="https://github.com/eyaltoledano/claude-task-master"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-3 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
{t('helpGuide.learnMore.githubButton')}
<ExternalLink className="h-4 w-4" />
</a>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,102 @@
import { useState } from 'react';
import { Plus, Terminal } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { cn } from '../../../../lib/utils';
import Shell from '../../../shell/view/Shell';
import type { TaskMasterProject } from '../../types';
type TaskMasterSetupModalProps = {
isOpen: boolean;
project: TaskMasterProject | null;
onClose: () => void;
onAfterClose?: (() => void) | null;
};
export default function TaskMasterSetupModal({ isOpen, project, onClose, onAfterClose = null }: TaskMasterSetupModalProps) {
const { t } = useTranslation('tasks');
const [isTaskMasterComplete, setIsTaskMasterComplete] = useState(false);
if (!isOpen || !project) {
return null;
}
const closeModal = () => {
onClose();
setIsTaskMasterComplete(false);
// Delay refresh slightly so the CLI has time to flush writes to disk.
window.setTimeout(() => {
onAfterClose?.();
}, 800);
};
return (
<div className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 p-4 pt-16 backdrop-blur-sm">
<div className="flex h-[600px] w-full max-w-4xl flex-col rounded-lg border border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-900">
<div className="flex items-center justify-between border-b border-gray-200 p-4 dark:border-gray-700">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-blue-100 dark:bg-blue-900/50">
<Terminal className="h-4 w-4 text-blue-600 dark:text-blue-400" />
</div>
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">{t('setupModal.title')}</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">{t('setupModal.subtitle', { projectName: project.displayName })}</p>
</div>
</div>
<button
onClick={closeModal}
className="rounded-md p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-800 dark:hover:text-gray-300"
title="Close"
>
<Plus className="h-5 w-5 rotate-45" />
</button>
</div>
<div className="flex-1 p-4">
<div className="h-full overflow-hidden rounded-lg bg-black">
<Shell
selectedProject={project}
selectedSession={null}
initialCommand="npx task-master init"
isPlainShell
isActive
onProcessComplete={(exitCode) => {
if (exitCode === 0) {
setIsTaskMasterComplete(true);
}
}}
/>
</div>
</div>
<div className="border-t border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800/50">
<div className="flex items-center justify-between">
<div className="text-sm text-gray-600 dark:text-gray-400">
{isTaskMasterComplete ? (
<span className="flex items-center gap-2 text-green-600 dark:text-green-400">
<span className="h-2 w-2 rounded-full bg-green-500" />
{t('setupModal.completed')}
</span>
) : (
t('setupModal.willStart')
)}
</div>
<button
onClick={closeModal}
className={cn(
'px-4 py-2 text-sm font-medium rounded-md transition-colors',
isTaskMasterComplete
? 'bg-green-600 hover:bg-green-700 text-white'
: 'text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600',
)}
>
{isTaskMasterComplete ? t('setupModal.closeContinueButton') : t('setupModal.closeButton')}
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,108 @@
import { useTranslation } from 'react-i18next';
import type { TaskBoardSortField, TaskBoardSortOrder } from '../../types';
type TaskFiltersPanelProps = {
showFilters: boolean;
statusFilter: string;
onStatusFilterChange: (status: string) => void;
priorityFilter: string;
onPriorityFilterChange: (priority: string) => void;
sortField: TaskBoardSortField;
sortOrder: TaskBoardSortOrder;
onSortConfigChange: (field: TaskBoardSortField, order: TaskBoardSortOrder) => void;
statuses: string[];
priorities: string[];
filteredTaskCount: number;
totalTaskCount: number;
onClearFilters: () => void;
};
export default function TaskFiltersPanel({
showFilters,
statusFilter,
onStatusFilterChange,
priorityFilter,
onPriorityFilterChange,
sortField,
sortOrder,
onSortConfigChange,
statuses,
priorities,
filteredTaskCount,
totalTaskCount,
onClearFilters,
}: TaskFiltersPanelProps) {
const { t } = useTranslation('tasks');
if (!showFilters) {
return null;
}
return (
<div className="space-y-4 rounded-lg bg-gray-50 p-4 dark:bg-gray-800">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">{t('filters.status')}</label>
<select
value={statusFilter}
onChange={(event) => onStatusFilterChange(event.target.value)}
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 dark:border-gray-600 dark:bg-gray-800"
>
<option value="all">{t('filters.allStatuses')}</option>
{statuses.map((status) => (
<option key={status} value={status}>
{t(`statuses.${status}`, status)}
</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">{t('filters.priority')}</label>
<select
value={priorityFilter}
onChange={(event) => onPriorityFilterChange(event.target.value)}
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 dark:border-gray-600 dark:bg-gray-800"
>
<option value="all">{t('filters.allPriorities')}</option>
{priorities.map((priority) => (
<option key={priority} value={priority}>
{t(`priorities.${priority}`, priority)}
</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">{t('filters.sortBy')}</label>
<select
value={`${sortField}-${sortOrder}`}
onChange={(event) => {
const [field, order] = event.target.value.split('-') as [TaskBoardSortField, TaskBoardSortOrder];
onSortConfigChange(field, order);
}}
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 dark:border-gray-600 dark:bg-gray-800"
>
<option value="id-asc">{t('sort.idAsc')}</option>
<option value="id-desc">{t('sort.idDesc')}</option>
<option value="title-asc">{t('sort.titleAsc')}</option>
<option value="title-desc">{t('sort.titleDesc')}</option>
<option value="status-asc">{t('sort.statusAsc')}</option>
<option value="status-desc">{t('sort.statusDesc')}</option>
<option value="priority-asc">{t('sort.priorityAsc')}</option>
<option value="priority-desc">{t('sort.priorityDesc')}</option>
</select>
</div>
</div>
<div className="flex items-center justify-between">
<div className="text-sm text-gray-600 dark:text-gray-400">
{t('filters.showing', { filtered: filteredTaskCount, total: totalTaskCount })}
</div>
<button onClick={onClearFilters} className="text-sm font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300">
{t('filters.clearFilters')}
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,62 @@
import { ArrowDown, ArrowUp, ArrowUpDown } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { cn } from '../../../../lib/utils';
import type { TaskBoardSortField, TaskBoardSortOrder } from '../../types';
type TaskQuickSortBarProps = {
sortField: TaskBoardSortField;
sortOrder: TaskBoardSortOrder;
onSortChange: (field: TaskBoardSortField) => void;
};
function getSortIcon(field: TaskBoardSortField, currentField: TaskBoardSortField, currentOrder: TaskBoardSortOrder) {
if (field !== currentField) {
return <ArrowUpDown className="h-4 w-4" />;
}
return currentOrder === 'asc' ? <ArrowUp className="h-4 w-4" /> : <ArrowDown className="h-4 w-4" />;
}
export default function TaskQuickSortBar({ sortField, sortOrder, onSortChange }: TaskQuickSortBarProps) {
const { t } = useTranslation('tasks');
return (
<div className="flex flex-wrap gap-2">
<button
onClick={() => onSortChange('id')}
className={cn(
'flex items-center gap-1 px-3 py-1.5 rounded-md text-sm',
sortField === 'id'
? 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300'
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700',
)}
>
{t('sort.id')} {getSortIcon('id', sortField, sortOrder)}
</button>
<button
onClick={() => onSortChange('status')}
className={cn(
'flex items-center gap-1 px-3 py-1.5 rounded-md text-sm',
sortField === 'status'
? 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300'
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700',
)}
>
{t('sort.status')} {getSortIcon('status', sortField, sortOrder)}
</button>
<button
onClick={() => onSortChange('priority')}
className={cn(
'flex items-center gap-1 px-3 py-1.5 rounded-md text-sm',
sortField === 'priority'
? 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300'
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700',
)}
>
{t('sort.priority')} {getSortIcon('priority', sortField, sortOrder)}
</button>
</div>
);
}