Improve board view: section management, completed column, better styling
This commit is contained in:
4
dist/index.html
vendored
4
dist/index.html
vendored
@@ -6,8 +6,8 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="description" content="Todo App - Task management made simple" />
|
<meta name="description" content="Todo App - Task management made simple" />
|
||||||
<title>Todo App</title>
|
<title>Todo App</title>
|
||||||
<script type="module" crossorigin src="/assets/index-CyVj0RaD.js"></script>
|
<script type="module" crossorigin src="/assets/index-BbNHrKUH.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-D509-xKq.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-oe9qB6TG.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -1,36 +1,48 @@
|
|||||||
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 type { Task, Project, Section } from '@/types';
|
||||||
import { cn, formatDate, isOverdue, getPriorityColor } from '@/lib/utils';
|
import { cn, formatDate, isOverdue, getPriorityColor } from '@/lib/utils';
|
||||||
import { useTasksStore } from '@/stores/tasks';
|
import { useTasksStore } from '@/stores/tasks';
|
||||||
import { AddTask } from '@/components/AddTask';
|
import { AddTask } from '@/components/AddTask';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
interface BoardViewProps {
|
interface BoardViewProps {
|
||||||
project: Project;
|
project: Project;
|
||||||
sections: Section[];
|
sections: Section[];
|
||||||
tasks: Task[];
|
tasks: Task[];
|
||||||
|
completedTasks: Task[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
onSectionsChange: (sections: Section[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function TaskCard({ task }: { task: Task }) {
|
function TaskCard({ task, muted }: { task: Task; muted?: boolean }) {
|
||||||
const { toggleComplete, setSelectedTask } = useTasksStore();
|
const { toggleComplete, setSelectedTask } = useTasksStore();
|
||||||
const overdue = !task.isCompleted && isOverdue(task.dueDate);
|
const overdue = !task.isCompleted && isOverdue(task.dueDate);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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)}
|
onClick={() => setSelectedTask(task)}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
{/* Priority indicator */}
|
|
||||||
<span
|
<span
|
||||||
className="mt-1 w-2 h-2 rounded-full flex-shrink-0"
|
className="mt-1 w-2 h-2 rounded-full flex-shrink-0"
|
||||||
style={{ backgroundColor: getPriorityColor(task.priority) }}
|
style={{ backgroundColor: getPriorityColor(task.priority) }}
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className={cn(
|
<p
|
||||||
'text-sm font-medium text-gray-900',
|
className={cn(
|
||||||
task.isCompleted && 'line-through text-gray-500'
|
'text-sm font-medium',
|
||||||
)}>
|
task.isCompleted
|
||||||
|
? 'line-through text-gray-400'
|
||||||
|
: 'text-gray-900'
|
||||||
|
)}
|
||||||
|
>
|
||||||
{task.title}
|
{task.title}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -42,10 +54,12 @@ function TaskCard({ task }: { task: Task }) {
|
|||||||
|
|
||||||
<div className="flex items-center gap-2 mt-2 flex-wrap">
|
<div className="flex items-center gap-2 mt-2 flex-wrap">
|
||||||
{task.dueDate && (
|
{task.dueDate && (
|
||||||
<span className={cn(
|
<span
|
||||||
'inline-flex items-center gap-1 text-xs',
|
className={cn(
|
||||||
overdue ? 'text-red-500' : 'text-gray-500'
|
'inline-flex items-center gap-1 text-xs',
|
||||||
)}>
|
overdue ? 'text-red-500' : 'text-gray-500'
|
||||||
|
)}
|
||||||
|
>
|
||||||
<Calendar className="w-3 h-3" />
|
<Calendar className="w-3 h-3" />
|
||||||
{formatDate(task.dueDate)}
|
{formatDate(task.dueDate)}
|
||||||
</span>
|
</span>
|
||||||
@@ -66,43 +80,238 @@ 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({
|
function BoardColumn({
|
||||||
title,
|
title,
|
||||||
tasks,
|
tasks,
|
||||||
projectId,
|
projectId,
|
||||||
sectionId,
|
sectionId,
|
||||||
|
onRename,
|
||||||
|
onDelete,
|
||||||
|
muted,
|
||||||
|
showAddTask,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
tasks: Task[];
|
tasks: Task[];
|
||||||
projectId: string;
|
projectId: string;
|
||||||
sectionId?: string;
|
sectionId?: string;
|
||||||
|
onRename?: (newName: string) => void;
|
||||||
|
onDelete?: () => void;
|
||||||
|
muted?: boolean;
|
||||||
|
showAddTask?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex-shrink-0 w-72 flex flex-col bg-gray-100 rounded-xl max-h-full">
|
<div
|
||||||
{/* Column header */}
|
className={cn(
|
||||||
<div className="px-3 py-3 flex items-center justify-between">
|
'flex-shrink-0 w-80 flex flex-col rounded-xl max-h-full',
|
||||||
<h3 className="text-sm font-semibold text-gray-700">
|
muted ? 'bg-gray-50' : 'bg-gray-100'
|
||||||
{title}
|
)}
|
||||||
<span className="ml-2 text-xs font-normal text-gray-400">{tasks.length}</span>
|
>
|
||||||
</h3>
|
<EditableHeader
|
||||||
</div>
|
title={title}
|
||||||
|
count={tasks.length}
|
||||||
|
sectionId={sectionId}
|
||||||
|
projectId={projectId}
|
||||||
|
onRename={onRename}
|
||||||
|
onDelete={onDelete}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Tasks */}
|
|
||||||
<div className="flex-1 overflow-y-auto px-2 pb-2 space-y-2">
|
<div className="flex-1 overflow-y-auto px-2 pb-2 space-y-2">
|
||||||
{tasks.map((task) => (
|
{tasks.map((task) => (
|
||||||
<TaskCard key={task.id} task={task} />
|
<TaskCard key={task.id} task={task} muted={muted} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Add task */}
|
{showAddTask !== false && (
|
||||||
<div className="px-2 pb-2">
|
<div className="px-2 pb-2">
|
||||||
<AddTask projectId={projectId} sectionId={sectionId} />
|
<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>
|
||||||
</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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-12 text-gray-500">Loading tasks...</div>
|
<div className="text-center py-12 text-gray-500">Loading tasks...</div>
|
||||||
@@ -110,26 +319,111 @@ export function BoardView({ project, sections, tasks, isLoading }: BoardViewProp
|
|||||||
}
|
}
|
||||||
|
|
||||||
const unsectionedTasks = tasks.filter((t) => !t.sectionId);
|
const unsectionedTasks = tasks.filter((t) => !t.sectionId);
|
||||||
const columns = [
|
|
||||||
{ title: 'No Section', tasks: unsectionedTasks, sectionId: undefined },
|
const handleRename = async (sectionId: string, newName: string) => {
|
||||||
...sections.map((section) => ({
|
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,
|
title: section.name,
|
||||||
tasks: tasks.filter((t) => t.sectionId === section.id),
|
tasks: tasks.filter((t) => t.sectionId === section.id),
|
||||||
sectionId: section.id,
|
sectionId: section.id,
|
||||||
})),
|
onRename: (name: string) => handleRename(section.id, name),
|
||||||
];
|
onDelete: () => handleDelete(section.id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
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) => (
|
{columns.map((col) => (
|
||||||
<BoardColumn
|
<BoardColumn
|
||||||
key={col.sectionId || 'unsectioned'}
|
key={col.key}
|
||||||
title={col.title}
|
title={col.title}
|
||||||
tasks={col.tasks}
|
tasks={col.tasks}
|
||||||
projectId={project.id}
|
projectId={project.id}
|
||||||
sectionId={col.sectionId}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,7 +97,9 @@ export function ProjectPage() {
|
|||||||
project={project}
|
project={project}
|
||||||
sections={sections}
|
sections={sections}
|
||||||
tasks={tasks}
|
tasks={tasks}
|
||||||
|
completedTasks={completedTasks}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
onSectionsChange={setSections}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
/* List view */
|
/* List view */
|
||||||
|
|||||||
Reference in New Issue
Block a user