Improve board view: section management, completed column, better styling

This commit is contained in:
2026-01-28 18:54:09 +00:00
parent 872a06d713
commit 621559ee22
3 changed files with 339 additions and 43 deletions

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-CyVj0RaD.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D509-xKq.css">
<script type="module" crossorigin src="/assets/index-BbNHrKUH.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-oe9qB6TG.css">
</head>
<body>
<div id="root"></div>

View File

@@ -1,56 +1,70 @@
import { Calendar, Flag, Plus } from 'lucide-react';
import { useState, useRef, useEffect } from 'react';
import { Calendar, Pencil, Plus, Trash2, Check, X, CheckCircle2 } 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';
import { api } from '@/lib/api';
interface BoardViewProps {
project: Project;
sections: Section[];
tasks: Task[];
completedTasks: Task[];
isLoading: boolean;
onSectionsChange: (sections: Section[]) => void;
}
function TaskCard({ task }: { task: Task }) {
function TaskCard({ task, muted }: { task: Task; muted?: boolean }) {
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"
className={cn(
'border rounded-lg p-3 shadow-sm hover:shadow-md transition-shadow cursor-pointer',
muted
? 'bg-gray-50 border-gray-200 opacity-70'
: 'bg-white border-gray-200'
)}
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'
)}>
<p
className={cn(
'text-sm font-medium',
task.isCompleted
? 'line-through text-gray-400'
: 'text-gray-900'
)}
>
{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'
)}>
<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"
@@ -66,70 +80,350 @@ function TaskCard({ task }: { task: Task }) {
);
}
function EditableHeader({
title,
count,
sectionId,
projectId,
onRename,
onDelete,
}: {
title: string;
count: number;
sectionId?: string;
projectId: string;
onRename?: (newName: string) => void;
onDelete?: () => void;
}) {
const [editing, setEditing] = useState(false);
const [value, setValue] = useState(title);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (editing && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [editing]);
const submit = () => {
const trimmed = value.trim();
if (trimmed && trimmed !== title && onRename) {
onRename(trimmed);
} else {
setValue(title);
}
setEditing(false);
};
const handleDelete = (e: React.MouseEvent) => {
e.stopPropagation();
if (onDelete && confirm(`Delete section "${title}"? Tasks will become unsectioned.`)) {
onDelete();
}
};
if (editing) {
return (
<div className="flex items-center gap-1 px-3 py-2">
<input
ref={inputRef}
className="text-sm font-semibold text-gray-700 bg-white border border-gray-300 rounded px-2 py-1 flex-1 outline-none focus:border-blue-400"
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') submit();
if (e.key === 'Escape') { setValue(title); setEditing(false); }
}}
onBlur={submit}
/>
</div>
);
}
return (
<div className="group px-3 py-3 flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-700 flex items-center gap-2">
{title}
<span className="text-xs font-normal text-gray-400">{count}</span>
</h3>
{sectionId && (
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => { setValue(title); setEditing(true); }}
className="p-1 text-gray-400 hover:text-gray-600 rounded"
title="Rename section"
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button
onClick={handleDelete}
className="p-1 text-gray-400 hover:text-red-500 rounded"
title="Delete section"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
)}
</div>
);
}
function BoardColumn({
title,
tasks,
projectId,
sectionId,
onRename,
onDelete,
muted,
showAddTask,
}: {
title: string;
tasks: Task[];
projectId: string;
sectionId?: string;
onRename?: (newName: string) => void;
onDelete?: () => void;
muted?: boolean;
showAddTask?: boolean;
}) {
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={cn(
'flex-shrink-0 w-80 flex flex-col rounded-xl max-h-full',
muted ? 'bg-gray-50' : 'bg-gray-100'
)}
>
<EditableHeader
title={title}
count={tasks.length}
sectionId={sectionId}
projectId={projectId}
onRename={onRename}
onDelete={onDelete}
/>
<div className="flex-1 overflow-y-auto px-2 pb-2 space-y-2">
{tasks.map((task) => (
<TaskCard key={task.id} task={task} />
<TaskCard key={task.id} task={task} muted={muted} />
))}
</div>
{/* Add task */}
<div className="px-2 pb-2">
<AddTask projectId={projectId} sectionId={sectionId} />
{showAddTask !== false && (
<div className="px-2 pb-2">
<AddTask projectId={projectId} sectionId={sectionId} />
</div>
)}
</div>
);
}
function AddSectionColumn({
projectId,
onAdd,
}: {
projectId: string;
onAdd: (section: Section) => void;
}) {
const [adding, setAdding] = useState(false);
const [name, setName] = useState('');
const [submitting, setSubmitting] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (adding && inputRef.current) {
inputRef.current.focus();
}
}, [adding]);
const submit = async () => {
const trimmed = name.trim();
if (!trimmed || submitting) return;
setSubmitting(true);
try {
const section = await api.createSection(projectId, { name: trimmed });
onAdd(section);
setName('');
setAdding(false);
} catch (err) {
console.error('Failed to create section:', err);
} finally {
setSubmitting(false);
}
};
if (!adding) {
return (
<div className="flex-shrink-0 w-80">
<button
onClick={() => setAdding(true)}
className="w-full flex items-center gap-2 px-4 py-3 text-sm text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-xl transition-colors border-2 border-dashed border-gray-200 hover:border-gray-300"
>
<Plus className="w-4 h-4" />
Add section
</button>
</div>
);
}
return (
<div className="flex-shrink-0 w-80 bg-gray-100 rounded-xl p-3">
<input
ref={inputRef}
className="w-full text-sm font-semibold text-gray-700 bg-white border border-gray-300 rounded px-3 py-2 outline-none focus:border-blue-400 mb-2"
placeholder="Section name"
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') submit();
if (e.key === 'Escape') { setName(''); setAdding(false); }
}}
disabled={submitting}
/>
<div className="flex items-center gap-2">
<button
onClick={submit}
disabled={!name.trim() || submitting}
className="flex items-center gap-1 px-3 py-1.5 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
<Check className="w-3.5 h-3.5" />
Add
</button>
<button
onClick={() => { setName(''); setAdding(false); }}
className="flex items-center gap-1 px-3 py-1.5 text-sm text-gray-500 hover:text-gray-700 rounded transition-colors"
>
<X className="w-3.5 h-3.5" />
Cancel
</button>
</div>
</div>
);
}
export function BoardView({ project, sections, tasks, isLoading }: BoardViewProps) {
export function BoardView({
project,
sections,
tasks,
completedTasks,
isLoading,
onSectionsChange,
}: BoardViewProps) {
const [showCompleted, setShowCompleted] = useState(true);
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) => ({
const handleRename = async (sectionId: string, newName: string) => {
try {
await api.updateSection(project.id, sectionId, { name: newName });
onSectionsChange(
sections.map((s) => (s.id === sectionId ? { ...s, name: newName } : s))
);
} catch (err) {
console.error('Failed to rename section:', err);
}
};
const handleDelete = async (sectionId: string) => {
try {
await api.deleteSection(project.id, sectionId);
onSectionsChange(sections.filter((s) => s.id !== sectionId));
} catch (err) {
console.error('Failed to delete section:', err);
}
};
const handleAdd = (section: Section) => {
onSectionsChange([...sections, section]);
};
const columns: {
key: string;
title: string;
tasks: Task[];
sectionId?: string;
muted?: boolean;
showAddTask?: boolean;
onRename?: (name: string) => void;
onDelete?: () => void;
}[] = [];
// Only show "No Section" column if there are unsectioned tasks
if (unsectionedTasks.length > 0) {
columns.push({
key: 'unsectioned',
title: 'No Section',
tasks: unsectionedTasks,
sectionId: undefined,
});
}
// Section columns
for (const section of sections) {
columns.push({
key: section.id,
title: section.name,
tasks: tasks.filter((t) => t.sectionId === section.id),
sectionId: section.id,
})),
];
onRename: (name: string) => handleRename(section.id, name),
onDelete: () => handleDelete(section.id),
});
}
return (
<div className="flex gap-4 overflow-x-auto pb-4" style={{ minHeight: '60vh' }}>
<div
className="flex gap-4 overflow-x-auto pb-4"
style={{ minHeight: '60vh' }}
>
{columns.map((col) => (
<BoardColumn
key={col.sectionId || 'unsectioned'}
key={col.key}
title={col.title}
tasks={col.tasks}
projectId={project.id}
sectionId={col.sectionId}
onRename={col.onRename}
onDelete={col.onDelete}
muted={col.muted}
showAddTask={col.showAddTask}
/>
))}
{/* Done column */}
{completedTasks.length > 0 && (
<div className="flex-shrink-0 w-80 flex flex-col bg-gray-50 rounded-xl max-h-full">
<div className="px-3 py-3 flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-400 flex items-center gap-2">
<CheckCircle2 className="w-4 h-4" />
Done
<span className="text-xs font-normal">{completedTasks.length}</span>
</h3>
<button
onClick={() => setShowCompleted(!showCompleted)}
className="text-xs text-gray-400 hover:text-gray-600 transition-colors"
>
{showCompleted ? 'Hide' : 'Show'}
</button>
</div>
{showCompleted && (
<div className="flex-1 overflow-y-auto px-2 pb-2 space-y-2">
{completedTasks.map((task) => (
<TaskCard key={task.id} task={task} muted />
))}
</div>
)}
</div>
)}
{/* Add section button */}
<AddSectionColumn projectId={project.id} onAdd={handleAdd} />
</div>
);
}

View File

@@ -97,7 +97,9 @@ export function ProjectPage() {
project={project}
sections={sections}
tasks={tasks}
completedTasks={completedTasks}
isLoading={isLoading}
onSectionsChange={setSections}
/>
) : (
/* List view */