Checkpoint: Initial stable CheckFlow base before i18n and demo mode
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
"use client";
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
|
||||
interface Task {
|
||||
id: string; listId: string; parentId: string | null; title: string; note: string | null;
|
||||
completed: boolean; completedAt: string | null; dueDate: string | null; priority: number;
|
||||
sortOrder: number; createdAt: string; updatedAt: string;
|
||||
children: Task[]; tags: { tag: { id: string; name: string; color: string } }[];
|
||||
}
|
||||
interface List { id: string; name: string; color: string; icon: string }
|
||||
interface User { id: string; name?: string | null; email?: string | null }
|
||||
|
||||
const PRIORITY_COLORS = ["transparent", "var(--priority-low)", "var(--priority-medium)", "var(--priority-high)"];
|
||||
const PRIORITY_LABELS = ["", "Low", "Medium", "High"];
|
||||
|
||||
function formatDate(d: string | null) {
|
||||
if (!d) return null;
|
||||
const date = new Date(d);
|
||||
const now = new Date();
|
||||
const isToday = date.toDateString() === now.toDateString();
|
||||
const isTomorrow = date.toDateString() === new Date(now.getTime() + 86400000).toDateString();
|
||||
if (isToday) return "Today";
|
||||
if (isTomorrow) return "Tomorrow";
|
||||
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
function isOverdue(d: string | null) {
|
||||
if (!d) return false;
|
||||
return new Date(d) < new Date() && new Date(d).toDateString() !== new Date().toDateString();
|
||||
}
|
||||
|
||||
interface TaskItemProps {
|
||||
task: Task; isSelected: boolean;
|
||||
onSelect: (t: Task) => void;
|
||||
onToggle: (id: string, completed: boolean) => void;
|
||||
}
|
||||
|
||||
function TaskItem({ task, isSelected, onSelect, onToggle }: TaskItemProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const completedChildren = task.children.filter((c) => c.completed).length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className={`task-item${task.completed ? " completed" : ""}${isSelected ? " selected" : ""}`}
|
||||
onClick={() => onSelect(task)}
|
||||
id={`task-${task.id}`}
|
||||
>
|
||||
<button
|
||||
className={`task-check-btn${task.completed ? " checked" : ""}`}
|
||||
onClick={(e) => { e.stopPropagation(); onToggle(task.id, !task.completed); }}
|
||||
aria-label={task.completed ? "Mark incomplete" : "Mark complete"}
|
||||
/>
|
||||
<div className="task-body">
|
||||
<div className="task-title">{task.title}</div>
|
||||
<div className="task-meta">
|
||||
{task.priority > 0 && (
|
||||
<div className="task-priority-dot" style={{ background: PRIORITY_COLORS[task.priority] }} title={PRIORITY_LABELS[task.priority]} />
|
||||
)}
|
||||
{task.dueDate && (
|
||||
<span className={`task-due${isOverdue(task.dueDate) && !task.completed ? " overdue" : ""}`}>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
||||
{formatDate(task.dueDate)}
|
||||
</span>
|
||||
)}
|
||||
{task.children.length > 0 && (
|
||||
<span
|
||||
className="task-sub-count"
|
||||
onClick={(e) => { e.stopPropagation(); setExpanded((p) => !p); }}
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
{completedChildren}/{task.children.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{task.note && !task.completed && (
|
||||
<div className="task-note-preview">{task.note.replace(/[#*`]/g, "").slice(0, 80)}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sub-tasks */}
|
||||
{task.children.length > 0 && expanded && (
|
||||
<div className="subtask-list">
|
||||
{task.children.map((child) => (
|
||||
<div
|
||||
key={child.id}
|
||||
className={`subtask-item${child.completed ? " completed" : ""}`}
|
||||
onClick={() => onSelect(child)}
|
||||
id={`subtask-${child.id}`}
|
||||
>
|
||||
<button
|
||||
className={`subtask-check-btn${child.completed ? " checked" : ""}`}
|
||||
onClick={(e) => { e.stopPropagation(); onToggle(child.id, !child.completed); }}
|
||||
aria-label={child.completed ? "Mark incomplete" : "Mark complete"}
|
||||
/>
|
||||
<span className="subtask-title">{child.title}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
user: User; listId: string | null; lists: List[]; tasks: Task[];
|
||||
setTasks: React.Dispatch<React.SetStateAction<Task[]>>;
|
||||
selectedTaskId: string | null; onTaskSelect: (t: Task | null) => void;
|
||||
showCompleted: boolean; onToggleCompleted: () => void;
|
||||
onMenuOpen: () => void; onRefresh: () => void;
|
||||
}
|
||||
|
||||
export function TaskList({ user, listId, lists, tasks, setTasks, selectedTaskId, onTaskSelect, showCompleted, onToggleCompleted, onMenuOpen, onRefresh }: Props) {
|
||||
const [newTaskTitle, setNewTaskTitle] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const currentList = lists.find((l) => l.id === listId);
|
||||
|
||||
const fetchTasks = useCallback(async () => {
|
||||
if (!listId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/tasks?listId=${listId}&showCompleted=${showCompleted}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
setTasks(Array.isArray(data) ? data : []);
|
||||
} catch (err) {
|
||||
console.error("[TaskList] fetchTasks failed", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [listId, showCompleted]);
|
||||
|
||||
useEffect(() => { fetchTasks(); }, [fetchTasks]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => inputRef.current?.focus();
|
||||
document.addEventListener("checkflow:addTask", handler);
|
||||
return () => document.removeEventListener("checkflow:addTask", handler);
|
||||
}, []);
|
||||
|
||||
const handleToggle = useCallback(async (id: string, completed: boolean) => {
|
||||
try {
|
||||
const res = await fetch(`/api/tasks/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ completed }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const updated = await res.json();
|
||||
// Functional update avoids stale closure on `tasks`
|
||||
setTasks((prev) =>
|
||||
prev.map((t) => {
|
||||
if (t.id === id) return { ...t, completed, completedAt: updated.completedAt };
|
||||
return { ...t, children: t.children.map((c) => (c.id === id ? { ...c, completed } : c)) };
|
||||
}).filter((t) => showCompleted || !t.completed)
|
||||
);
|
||||
onRefresh();
|
||||
} catch (err) {
|
||||
console.error("[TaskList] handleToggle failed", err);
|
||||
}
|
||||
}, [showCompleted, onRefresh]);
|
||||
|
||||
const handleAddTask = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const title = newTaskTitle.trim();
|
||||
if (!title || !listId) return;
|
||||
try {
|
||||
const res = await fetch("/api/tasks", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title, listId }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const task = await res.json();
|
||||
setTasks((prev) => [...prev, task]);
|
||||
setNewTaskTitle("");
|
||||
onRefresh();
|
||||
} catch (err) {
|
||||
console.error("[TaskList] handleAddTask failed", err);
|
||||
}
|
||||
};
|
||||
|
||||
if (!listId) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", height: "100%", color: "var(--text-tertiary)" }}>
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" style={{ opacity: 0.3, marginBottom: 12 }}><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
|
||||
<p>Select a list to get started</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const incompleteTasks = tasks.filter((t) => !t.completed);
|
||||
const completedTasks = tasks.filter((t) => t.completed);
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
{/* Header */}
|
||||
<div className="main-header">
|
||||
<button className="icon-btn mobile-only" id="menu-btn" onClick={onMenuOpen} aria-label="Menu">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
||||
</button>
|
||||
<div className="main-header-title" style={{ color: currentList?.color }}>
|
||||
{currentList?.name || "Tasks"}
|
||||
</div>
|
||||
<div className="main-header-actions">
|
||||
<button
|
||||
id="toggle-completed-btn"
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={onToggleCompleted}
|
||||
title={showCompleted ? "Hide completed" : "Show completed"}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
{showCompleted ? "Hide done" : "Show done"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Task list */}
|
||||
<div className="task-list-container">
|
||||
{loading && (
|
||||
<div style={{ padding: "20px", textAlign: "center", color: "var(--text-tertiary)" }}>Loading...</div>
|
||||
)}
|
||||
{!loading && incompleteTasks.length === 0 && completedTasks.length === 0 && (
|
||||
<div className="task-list-empty">
|
||||
<svg width="56" height="56" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
|
||||
<p>No tasks yet. Add one below!</p>
|
||||
</div>
|
||||
)}
|
||||
{incompleteTasks.map((task) => (
|
||||
<TaskItem key={task.id} task={task} isSelected={selectedTaskId === task.id} onSelect={onTaskSelect} onToggle={handleToggle} />
|
||||
))}
|
||||
{showCompleted && completedTasks.length > 0 && (
|
||||
<div>
|
||||
<div style={{ padding: "12px 20px 4px", fontSize: 11, fontWeight: 600, color: "var(--text-tertiary)", textTransform: "uppercase", letterSpacing: "0.5px" }}>
|
||||
Completed ({completedTasks.length})
|
||||
</div>
|
||||
{completedTasks.map((task) => (
|
||||
<TaskItem key={task.id} task={task} isSelected={selectedTaskId === task.id} onSelect={onTaskSelect} onToggle={handleToggle} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add task */}
|
||||
<div className="add-task-bar">
|
||||
<button
|
||||
className="task-check-btn"
|
||||
style={{ opacity: 0.4, flexShrink: 0 }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<form onSubmit={handleAddTask} style={{ flex: 1, display: "flex", gap: 8 }}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
id="add-task-input"
|
||||
className="add-task-input"
|
||||
placeholder="Add a task..."
|
||||
value={newTaskTitle}
|
||||
onChange={(e) => setNewTaskTitle(e.target.value)}
|
||||
/>
|
||||
{newTaskTitle.trim() && (
|
||||
<button type="submit" className="btn btn-primary btn-sm" id="add-task-btn">Add</button>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user