Add kanban board view, project creation, and task assignment

This commit is contained in:
2026-01-28 17:57:57 +00:00
parent ff70948a54
commit 094e29d838
12 changed files with 459 additions and 26 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
dist/index.html vendored
View File

@@ -6,8 +6,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Todo App - Task management made simple" />
<title>Todo App</title>
<script type="module" crossorigin src="/assets/index-KBG7ug1d.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DH_ujkYf.css">
<script type="module" crossorigin src="/assets/index-B4OukgoH.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CVystegy.css">
</head>
<body>
<div id="root"></div>

View File

@@ -9,6 +9,7 @@ import { InboxPage } from '@/pages/Inbox';
import { TodayPage } from '@/pages/Today';
import { UpcomingPage } from '@/pages/Upcoming';
import { AdminPage } from '@/pages/Admin';
import { ProjectPage } from '@/pages/Project';
const queryClient = new QueryClient({
defaultOptions: {
@@ -51,6 +52,8 @@ function AppRoutes() {
<Route path="/today" element={<TodayPage />} />
<Route path="/upcoming" element={<UpcomingPage />} />
<Route path="/admin" element={<AdminPage />} />
<Route path="/project/:id" element={<ProjectPage />} />
<Route path="/project/:id/board" element={<ProjectPage />} />
{/* Redirects */}
<Route path="/" element={<Navigate to="/inbox" replace />} />

View File

@@ -1,5 +1,5 @@
import { useState, useRef, useEffect } from 'react';
import { Plus, Calendar, Flag, Tag, X } from 'lucide-react';
import { Plus, Calendar, Flag, Tag, X, UserCircle } from 'lucide-react';
import type { Priority } from '@/types';
import { cn, getPriorityColor } from '@/lib/utils';
import { useTasksStore } from '@/stores/tasks';
@@ -18,10 +18,17 @@ export function AddTask({ projectId, sectionId, parentId, onClose, autoFocus = f
const [description, setDescription] = useState('');
const [dueDate, setDueDate] = useState('');
const [priority, setPriority] = useState<Priority>('p4');
const [assigneeId, setAssigneeId] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const { createTask, projects } = useTasksStore();
const { createTask, projects, users, fetchUsers } = useTasksStore();
useEffect(() => {
if (isExpanded && users.length === 0) {
fetchUsers();
}
}, [isExpanded]);
useEffect(() => {
if (isExpanded && inputRef.current) {
@@ -43,6 +50,7 @@ export function AddTask({ projectId, sectionId, parentId, onClose, autoFocus = f
parentId,
dueDate: dueDate || undefined,
priority,
assigneeId: assigneeId || undefined,
});
// Reset form
@@ -50,6 +58,7 @@ export function AddTask({ projectId, sectionId, parentId, onClose, autoFocus = f
setDescription('');
setDueDate('');
setPriority('p4');
setAssigneeId('');
if (onClose) {
onClose();
@@ -68,6 +77,7 @@ export function AddTask({ projectId, sectionId, parentId, onClose, autoFocus = f
setDescription('');
setDueDate('');
setPriority('p4');
setAssigneeId('');
onClose?.();
};
@@ -141,6 +151,25 @@ export function AddTask({ projectId, sectionId, parentId, onClose, autoFocus = f
<option value="p4">Priority 4</option>
</select>
{/* Assignee selector */}
{users.length > 0 && (
<select
value={assigneeId}
onChange={(e) => setAssigneeId(e.target.value)}
className={cn(
'appearance-none px-2 py-1 text-xs rounded border border-gray-200 bg-white cursor-pointer hover:bg-gray-50',
assigneeId ? 'text-blue-600' : 'text-gray-500'
)}
>
<option value="">Unassigned</option>
{users.map((u) => (
<option key={u.id} value={u.id}>
{u.name}
</option>
))}
</select>
)}
{/* Project selector (if not in a specific project context) */}
{!projectId && projects.length > 0 && (
<select

View File

@@ -1,20 +1,58 @@
import { useState } from 'react';
import { useState, useRef, useEffect } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import {
Inbox, Calendar, CalendarDays, Plus, ChevronDown, ChevronRight,
Hash, Settings, LogOut, User, FolderPlus, Tag
Hash, Settings, LogOut, User, FolderPlus, Tag, X, Check
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAuthStore } from '@/stores/auth';
import { useTasksStore } from '@/stores/tasks';
const PROJECT_COLORS = [
'#3b82f6', '#ef4444', '#22c55e', '#f59e0b', '#8b5cf6',
'#ec4899', '#06b6d4', '#f97316', '#6366f1', '#14b8a6',
];
export function Sidebar() {
const location = useLocation();
const navigate = useNavigate();
const { user, logout } = useAuthStore();
const { projects, labels } = useTasksStore();
const { projects, labels, createProject } = useTasksStore();
const [projectsExpanded, setProjectsExpanded] = useState(true);
const [labelsExpanded, setLabelsExpanded] = useState(true);
const [showNewProject, setShowNewProject] = useState(false);
const [newProjectName, setNewProjectName] = useState('');
const [newProjectColor, setNewProjectColor] = useState(PROJECT_COLORS[0]);
const [isCreatingProject, setIsCreatingProject] = useState(false);
const newProjectInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (showNewProject && newProjectInputRef.current) {
newProjectInputRef.current.focus();
}
}, [showNewProject]);
const handleCreateProject = async () => {
if (!newProjectName.trim() || isCreatingProject) return;
setIsCreatingProject(true);
try {
const project = await createProject({ name: newProjectName.trim(), color: newProjectColor });
setNewProjectName('');
setNewProjectColor(PROJECT_COLORS[0]);
setShowNewProject(false);
navigate(`/project/${project.id}`);
} catch (error) {
console.error('Failed to create project:', error);
} finally {
setIsCreatingProject(false);
}
};
const handleCancelNewProject = () => {
setShowNewProject(false);
setNewProjectName('');
setNewProjectColor(PROJECT_COLORS[0]);
};
const handleLogout = async () => {
await logout();
@@ -74,13 +112,16 @@ export function Sidebar() {
>
<span>Projects</span>
<div className="flex items-center gap-1">
<Link
to="/projects/new"
<button
className="p-1 hover:bg-gray-200 rounded"
onClick={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
setShowNewProject(true);
setProjectsExpanded(true);
}}
>
<Plus className="w-3 h-3" />
</Link>
</button>
{projectsExpanded ? (
<ChevronDown className="w-3 h-3" />
) : (
@@ -97,7 +138,7 @@ export function Sidebar() {
to={`/project/${project.id}`}
className={cn(
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors',
location.pathname === `/project/${project.id}`
location.pathname === `/project/${project.id}` || location.pathname === `/project/${project.id}/board`
? 'bg-blue-50 text-blue-600'
: 'text-gray-700 hover:bg-gray-100'
)}
@@ -109,6 +150,63 @@ export function Sidebar() {
<span className="truncate">{project.name}</span>
</Link>
))}
{/* Inline create project form */}
{showNewProject && (
<div className="px-2 py-2">
<form
onSubmit={(e) => {
e.preventDefault();
handleCreateProject();
}}
className="border border-gray-200 rounded-lg p-2 bg-white shadow-sm space-y-2"
>
<input
ref={newProjectInputRef}
type="text"
value={newProjectName}
onChange={(e) => setNewProjectName(e.target.value)}
placeholder="Project name"
className="w-full text-sm text-gray-900 placeholder-gray-400 border border-gray-200 rounded px-2 py-1 outline-none focus:border-blue-400"
/>
<div className="flex items-center gap-1 flex-wrap">
{PROJECT_COLORS.map((color) => (
<button
key={color}
type="button"
onClick={() => setNewProjectColor(color)}
className={cn(
'w-5 h-5 rounded-full border-2 transition-all',
newProjectColor === color ? 'border-gray-700 scale-110' : 'border-transparent'
)}
style={{ backgroundColor: color }}
/>
))}
</div>
<div className="flex items-center justify-end gap-1">
<button
type="button"
onClick={handleCancelNewProject}
className="px-2 py-1 text-xs text-gray-500 hover:bg-gray-100 rounded"
>
Cancel
</button>
<button
type="submit"
disabled={!newProjectName.trim() || isCreatingProject}
className={cn(
'px-2 py-1 text-xs font-medium rounded',
newProjectName.trim() && !isCreatingProject
? 'bg-blue-600 text-white hover:bg-blue-700'
: 'bg-gray-100 text-gray-400 cursor-not-allowed'
)}
>
{isCreatingProject ? 'Adding...' : 'Add'}
</button>
</div>
</form>
</div>
)}
</div>
)}
</div>

View File

@@ -117,6 +117,16 @@ export function TaskItem({ task, onClick, showProject = false }: TaskItemProps)
</div>
</div>
{/* Assignee avatar */}
{task.assignee && (
<span
className="flex-shrink-0 w-5 h-5 rounded-full bg-blue-100 text-blue-700 flex items-center justify-center text-[10px] font-medium"
title={task.assignee.name}
>
{task.assignee.name.charAt(0).toUpperCase()}
</span>
)}
{/* Priority flag */}
{task.priority !== 'p4' && (
<Flag

135
src/pages/Board.tsx Normal file
View File

@@ -0,0 +1,135 @@
import { Calendar, Flag, Plus } from 'lucide-react';
import type { Task, Project, Section } from '@/types';
import { cn, formatDate, isOverdue, getPriorityColor } from '@/lib/utils';
import { useTasksStore } from '@/stores/tasks';
import { AddTask } from '@/components/AddTask';
interface BoardViewProps {
project: Project;
sections: Section[];
tasks: Task[];
isLoading: boolean;
}
function TaskCard({ task }: { task: Task }) {
const { toggleComplete, setSelectedTask } = useTasksStore();
const overdue = !task.isCompleted && isOverdue(task.dueDate);
return (
<div
className="bg-white border border-gray-200 rounded-lg p-3 shadow-sm hover:shadow-md transition-shadow cursor-pointer"
onClick={() => setSelectedTask(task)}
>
<div className="flex items-start gap-2">
{/* Priority indicator */}
<span
className="mt-1 w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: getPriorityColor(task.priority) }}
/>
<div className="flex-1 min-w-0">
<p className={cn(
'text-sm font-medium text-gray-900',
task.isCompleted && 'line-through text-gray-500'
)}>
{task.title}
</p>
{task.description && (
<p className="text-xs text-gray-500 mt-1 line-clamp-2">
{task.description}
</p>
)}
<div className="flex items-center gap-2 mt-2 flex-wrap">
{task.dueDate && (
<span className={cn(
'inline-flex items-center gap-1 text-xs',
overdue ? 'text-red-500' : 'text-gray-500'
)}>
<Calendar className="w-3 h-3" />
{formatDate(task.dueDate)}
</span>
)}
{task.assignee && (
<span
className="w-5 h-5 rounded-full bg-blue-100 text-blue-700 flex items-center justify-center text-[10px] font-medium flex-shrink-0"
title={task.assignee.name}
>
{task.assignee.name.charAt(0).toUpperCase()}
</span>
)}
</div>
</div>
</div>
</div>
);
}
function BoardColumn({
title,
tasks,
projectId,
sectionId,
}: {
title: string;
tasks: Task[];
projectId: string;
sectionId?: string;
}) {
return (
<div className="flex-shrink-0 w-72 flex flex-col bg-gray-100 rounded-xl max-h-full">
{/* Column header */}
<div className="px-3 py-3 flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-700">
{title}
<span className="ml-2 text-xs font-normal text-gray-400">{tasks.length}</span>
</h3>
</div>
{/* Tasks */}
<div className="flex-1 overflow-y-auto px-2 pb-2 space-y-2">
{tasks.map((task) => (
<TaskCard key={task.id} task={task} />
))}
</div>
{/* Add task */}
<div className="px-2 pb-2">
<AddTask projectId={projectId} sectionId={sectionId} />
</div>
</div>
);
}
export function BoardView({ project, sections, tasks, isLoading }: BoardViewProps) {
if (isLoading) {
return (
<div className="text-center py-12 text-gray-500">Loading tasks...</div>
);
}
const unsectionedTasks = tasks.filter((t) => !t.sectionId);
const columns = [
{ title: 'No Section', tasks: unsectionedTasks, sectionId: undefined },
...sections.map((section) => ({
title: section.name,
tasks: tasks.filter((t) => t.sectionId === section.id),
sectionId: section.id,
})),
];
return (
<div className="flex gap-4 overflow-x-auto pb-4" style={{ minHeight: '60vh' }}>
{columns.map((col) => (
<BoardColumn
key={col.sectionId || 'unsectioned'}
title={col.title}
tasks={col.tasks}
projectId={project.id}
sectionId={col.sectionId}
/>
))}
</div>
);
}

147
src/pages/Project.tsx Normal file
View File

@@ -0,0 +1,147 @@
import { useEffect, useState } from 'react';
import { useParams, useNavigate, useLocation } from 'react-router-dom';
import { LayoutList, LayoutGrid, Plus } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useTasksStore } from '@/stores/tasks';
import { TaskItem } from '@/components/TaskItem';
import { AddTask } from '@/components/AddTask';
import { BoardView } from '@/pages/Board';
import { api } from '@/lib/api';
import type { Project as ProjectType, Section } from '@/types';
export function ProjectPage() {
const { id } = useParams<{ id: string }>();
const location = useLocation();
const navigate = useNavigate();
const { tasks, isLoading, fetchTasks, setSelectedTask } = useTasksStore();
const [project, setProject] = useState<ProjectType | null>(null);
const [sections, setSections] = useState<Section[]>([]);
const isBoardView = location.pathname.endsWith('/board');
useEffect(() => {
if (!id) return;
fetchTasks({ projectId: id, completed: false });
api.getProject(id).then((p) => {
setProject(p);
setSections(p.sections || []);
}).catch(console.error);
}, [id]);
if (!project) {
return (
<div className="flex items-center justify-center py-12 text-gray-500">
Loading project...
</div>
);
}
const toggleView = () => {
if (isBoardView) {
navigate(`/project/${id}`);
} else {
navigate(`/project/${id}/board`);
}
};
// Group tasks by section
const unsectionedTasks = tasks.filter((t) => !t.sectionId);
const tasksBySection = sections.map((section) => ({
section,
tasks: tasks.filter((t) => t.sectionId === section.id),
}));
return (
<div className="max-w-5xl mx-auto">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<span
className="w-4 h-4 rounded"
style={{ backgroundColor: project.color }}
/>
<h1 className="text-2xl font-bold text-gray-900">{project.name}</h1>
</div>
<div className="flex items-center gap-1 bg-gray-100 rounded-lg p-0.5">
<button
onClick={() => !isBoardView || toggleView()}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md transition-colors',
!isBoardView
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-500 hover:text-gray-700'
)}
>
<LayoutList className="w-4 h-4" />
List
</button>
<button
onClick={() => isBoardView || toggleView()}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md transition-colors',
isBoardView
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-500 hover:text-gray-700'
)}
>
<LayoutGrid className="w-4 h-4" />
Board
</button>
</div>
</div>
{isBoardView ? (
<BoardView
project={project}
sections={sections}
tasks={tasks}
isLoading={isLoading}
/>
) : (
/* List view */
<div className="space-y-6">
{isLoading ? (
<div className="text-center py-12 text-gray-500">Loading tasks...</div>
) : (
<>
{/* Unsectioned tasks */}
{unsectionedTasks.length > 0 && (
<div className="space-y-1">
{unsectionedTasks.map((task) => (
<TaskItem
key={task.id}
task={task}
onClick={() => setSelectedTask(task)}
/>
))}
</div>
)}
{/* Add task for unsectioned */}
<AddTask projectId={id} />
{/* Sections */}
{tasksBySection.map(({ section, tasks: sectionTasks }) => (
<div key={section.id}>
<h3 className="text-sm font-semibold text-gray-700 mb-2 px-3 py-1 border-b border-gray-200">
{section.name}
</h3>
<div className="space-y-1">
{sectionTasks.map((task) => (
<TaskItem
key={task.id}
task={task}
onClick={() => setSelectedTask(task)}
/>
))}
</div>
<AddTask projectId={id} sectionId={section.id} />
</div>
))}
</>
)}
</div>
)}
</div>
);
}

View File

@@ -8,9 +8,11 @@ vi.mock('@/lib/api', () => ({
getTasks: vi.fn(),
getProjects: vi.fn(),
getLabels: vi.fn(),
getUsers: vi.fn(),
createTask: vi.fn(),
updateTask: vi.fn(),
deleteTask: vi.fn(),
createProject: vi.fn(),
},
}));
@@ -62,6 +64,7 @@ describe('useTasksStore', () => {
tasks: [],
projects: [],
labels: [],
users: [],
isLoading: false,
selectedTask: null,
activeProjectId: null,
@@ -74,6 +77,7 @@ describe('useTasksStore', () => {
expect(state.tasks).toEqual([]);
expect(state.projects).toEqual([]);
expect(state.labels).toEqual([]);
expect(state.users).toEqual([]);
expect(state.isLoading).toBe(false);
expect(state.selectedTask).toBeNull();
expect(state.activeProjectId).toBeNull();

View File

@@ -1,11 +1,12 @@
import { create } from 'zustand';
import type { Task, TaskCreate, TaskUpdate, Project, Label } from '@/types';
import type { Task, TaskCreate, TaskUpdate, Project, Label, User } from '@/types';
import { api } from '@/lib/api';
interface TasksState {
tasks: Task[];
projects: Project[];
labels: Label[];
users: User[];
isLoading: boolean;
// Selected items
@@ -17,11 +18,13 @@ interface TasksState {
fetchTasks: (params?: Parameters<typeof api.getTasks>[0]) => Promise<void>;
fetchProjects: () => Promise<void>;
fetchLabels: () => Promise<void>;
fetchUsers: () => Promise<void>;
createTask: (data: TaskCreate) => Promise<Task>;
updateTask: (id: string, data: TaskUpdate) => Promise<void>;
deleteTask: (id: string) => Promise<void>;
toggleComplete: (id: string) => Promise<void>;
createProject: (data: { name: string; color?: string; icon?: string }) => Promise<Project>;
setSelectedTask: (task: Task | null) => void;
setActiveProject: (projectId: string | null) => void;
@@ -32,6 +35,7 @@ export const useTasksStore = create<TasksState>((set, get) => ({
tasks: [],
projects: [],
labels: [],
users: [],
isLoading: false,
selectedTask: null,
activeProjectId: null,
@@ -66,6 +70,15 @@ export const useTasksStore = create<TasksState>((set, get) => ({
}
},
fetchUsers: async () => {
try {
const users = await api.getUsers();
set({ users });
} catch (error) {
console.error('Failed to fetch users:', error);
}
},
createTask: async (data) => {
const task = await api.createTask(data);
set((state) => ({ tasks: [task, ...state.tasks] }));
@@ -90,6 +103,12 @@ export const useTasksStore = create<TasksState>((set, get) => ({
}));
},
createProject: async (data) => {
const project = await api.createProject(data);
set((state) => ({ projects: [...state.projects, project] }));
return project;
},
toggleComplete: async (id) => {
const task = get().tasks.find((t) => t.id === id);
if (!task) return;

View File

@@ -1,4 +1,4 @@
/// <reference types="vitest" />
/// <reference types="vitest/config" />
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'