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="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>
|
||||
|
||||
@@ -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 { 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>
|
||||
|
||||
@@ -42,10 +54,12 @@ function TaskCard({ task }: { task: Task }) {
|
||||
|
||||
<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>
|
||||
@@ -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({
|
||||
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>
|
||||
<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}
|
||||
/>
|
||||
|
||||
{/* Tasks */}
|
||||
<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>
|
||||
@@ -110,26 +319,111 @@ export function BoardView({ project, sections, tasks, isLoading }: BoardViewProp
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -97,7 +97,9 @@ export function ProjectPage() {
|
||||
project={project}
|
||||
sections={sections}
|
||||
tasks={tasks}
|
||||
completedTasks={completedTasks}
|
||||
isLoading={isLoading}
|
||||
onSectionsChange={setSections}
|
||||
/>
|
||||
) : (
|
||||
/* List view */
|
||||
|
||||
Reference in New Issue
Block a user