Files
checkflow/src/components/tasks/TaskList.tsx
T

759 lines
24 KiB
TypeScript

"use client";
import React, { useState, useEffect, useRef, useCallback } from "react";
import { useI18n } from "@/lib/i18n";
import { ContextMenu, MenuItem } from "@/components/ui/ContextMenu";
export 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 } }[];
}
export interface List { id: string; name: string; color: string; icon: string }
export interface User { id: string; name?: string | null; email?: string | null }
const PRIORITY_COLORS = ["transparent", "var(--priority-low)", "var(--priority-medium)", "var(--priority-high)"];
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;
onUpdateTitle: (id: string, title: string) => void;
onAddSubtask: (title: string, parentId: string) => void;
onContextMenu: (x: number, y: number, task: Task) => void;
onDeleteTask?: (id: string) => void;
}
function TaskItem({
task,
isSelected,
onSelect,
onToggle,
onUpdateTitle,
onAddSubtask,
onContextMenu,
}: TaskItemProps) {
const { t, lang } = useI18n();
const [expanded, setExpanded] = useState(true);
const [editingTitle, setEditingTitle] = useState(false);
const [tempTitle, setTempTitle] = useState(task.title);
const [addingSubtask, setAddingSubtask] = useState(false);
const [subtaskInput, setSubtaskInput] = useState("");
const editInputRef = useRef<HTMLInputElement>(null);
const subInputRef = useRef<HTMLInputElement>(null);
const completedChildren = task.children?.filter((c) => c.completed).length || 0;
useEffect(() => {
setTempTitle(task.title);
}, [task.title]);
useEffect(() => {
if (editingTitle) {
editInputRef.current?.focus();
editInputRef.current?.select();
}
}, [editingTitle]);
useEffect(() => {
if (addingSubtask) {
subInputRef.current?.focus();
}
}, [addingSubtask]);
const 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 t("today");
if (isTomorrow) return t("tomorrow");
return date.toLocaleDateString(lang === "ko" ? "ko-KR" : lang === "ja" ? "ja-JP" : "en-US", { month: "short", day: "numeric" });
};
const priorityLabels = [t("priorityNone"), t("priorityLow"), t("priorityMedium"), t("priorityHigh")];
const handleSaveTitle = () => {
const trimmed = tempTitle.trim();
if (trimmed && trimmed !== task.title) {
onUpdateTitle(task.id, trimmed);
} else {
setTempTitle(task.title);
}
setEditingTitle(false);
};
const handleCreateSubtask = (e: React.FormEvent) => {
e.preventDefault();
const trimmed = subtaskInput.trim();
if (trimmed) {
onAddSubtask(trimmed, task.id);
setSubtaskInput("");
setAddingSubtask(false);
setExpanded(true);
}
};
return (
<div style={{ position: "relative" }}>
<div
className={`task-item${task.completed ? " completed" : ""}${isSelected ? " selected" : ""}`}
onClick={() => onSelect(task)}
onContextMenu={(e) => {
e.preventDefault();
onContextMenu(e.clientX, e.clientY, task);
}}
id={`task-${task.id}`}
style={{ group: "item" } as React.CSSProperties}
>
<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"}
type="button"
/>
<div className="task-body">
{editingTitle ? (
<input
ref={editInputRef}
className="form-input"
value={tempTitle}
onChange={(e) => setTempTitle(e.target.value)}
onBlur={handleSaveTitle}
onKeyDown={(e) => {
if (e.key === "Enter") handleSaveTitle();
if (e.key === "Escape") {
setTempTitle(task.title);
setEditingTitle(false);
}
}}
onClick={(e) => e.stopPropagation()}
style={{ padding: "2px 6px", fontSize: 14, fontWeight: 500, height: 26, width: "100%" }}
/>
) : (
<div
className="task-title"
onDoubleClick={(e) => {
e.stopPropagation();
setEditingTitle(true);
}}
title="Double click to edit"
style={{ cursor: "text" }}
>
{task.title}
</div>
)}
<div className="task-meta">
{task.priority > 0 && (
<div
className="task-priority-dot"
style={{ background: PRIORITY_COLORS[task.priority] }}
title={priorityLabels[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 && task.children.length > 0 && (
<span
className="task-sub-count"
onClick={(e) => {
e.stopPropagation();
setExpanded((p) => !p);
}}
title="Toggle subtasks"
style={{ cursor: "pointer" }}
>
<svg
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
style={{ transform: expanded ? "rotate(90deg)" : "rotate(0deg)", transition: "transform 0.15s" }}
>
<polyline points="9 18 15 12 9 6" />
</svg>
{completedChildren}/{task.children.length}
</span>
)}
{/* Quick Add Subtask Button */}
<button
className="badge badge-neutral"
style={{ cursor: "pointer", fontSize: 10, padding: "1px 6px", border: "1px solid var(--border)", background: "transparent" }}
onClick={(e) => {
e.stopPropagation();
setAddingSubtask(true);
setExpanded(true);
}}
title="Add subtask"
type="button"
>
+ {t("subtasks")}
</button>
</div>
{task.note && !task.completed && (
<div className="task-note-preview">{task.note.replace(/[#*`]/g, "").slice(0, 80)}</div>
)}
</div>
{/* Quick Edit icon on hover */}
<button
className="icon-btn"
style={{ width: 22, height: 22, opacity: 0.4, flexShrink: 0 }}
onClick={(e) => {
e.stopPropagation();
setEditingTitle(true);
}}
title="Edit title"
type="button"
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 20h9" /><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
</svg>
</button>
</div>
{/* Sub-tasks List */}
{((task.children && task.children.length > 0) || addingSubtask) && expanded && (
<div className="subtask-list" style={{ paddingLeft: 38 }}>
{task.children?.map((child) => (
<SubTaskItem
key={child.id}
child={child}
onToggle={onToggle}
onSelect={onSelect}
onUpdateTitle={onUpdateTitle}
/>
))}
{/* Inline Add Subtask input */}
{addingSubtask && (
<form onSubmit={handleCreateSubtask} style={{ display: "flex", gap: 6, padding: "4px 0" }}>
<span style={{ color: "var(--accent)", fontSize: 14, lineHeight: "24px" }}></span>
<input
ref={subInputRef}
className="form-input"
placeholder={t("addSubtaskPlaceholder")}
value={subtaskInput}
onChange={(e) => setSubtaskInput(e.target.value)}
onBlur={() => {
if (!subtaskInput.trim()) setAddingSubtask(false);
}}
onKeyDown={(e) => {
if (e.key === "Escape") setAddingSubtask(false);
}}
style={{ height: 26, fontSize: 12.5, padding: "2px 8px", flex: 1 }}
/>
<button className="btn btn-primary btn-sm" type="submit" style={{ padding: "2px 8px", fontSize: 11 }}>
{t("add")}
</button>
</form>
)}
</div>
)}
</div>
);
}
// Sub-task Item with Inline Rename
function SubTaskItem({
child,
onToggle,
onSelect,
onUpdateTitle,
}: {
child: Task;
onToggle: (id: string, completed: boolean) => void;
onSelect: (t: Task) => void;
onUpdateTitle: (id: string, title: string) => void;
}) {
const [editing, setEditing] = useState(false);
const [temp, setTemp] = useState(child.title);
const ref = useRef<HTMLInputElement>(null);
useEffect(() => {
if (editing) {
ref.current?.focus();
ref.current?.select();
}
}, [editing]);
const handleSave = () => {
const trimmed = temp.trim();
if (trimmed && trimmed !== child.title) {
onUpdateTitle(child.id, trimmed);
} else {
setTemp(child.title);
}
setEditing(false);
};
return (
<div
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"}
type="button"
/>
{editing ? (
<input
ref={ref}
className="form-input"
value={temp}
onChange={(e) => setTemp(e.target.value)}
onBlur={handleSave}
onKeyDown={(e) => {
if (e.key === "Enter") handleSave();
if (e.key === "Escape") {
setTemp(child.title);
setEditing(false);
}
}}
onClick={(e) => e.stopPropagation()}
style={{ padding: "1px 6px", fontSize: 12.5, height: 22, flex: 1 }}
/>
) : (
<span
className="subtask-title"
onDoubleClick={(e) => {
e.stopPropagation();
setEditing(true);
}}
title="Double click to edit"
style={{ cursor: "text" }}
>
{child.title}
</span>
)}
</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;
isDemo?: boolean;
onDemoAddTask?: (title: string, listId: string, parentId?: string | null) => void;
onDemoToggleTask?: (id: string, completed: boolean) => void;
onUpdateTaskTitle?: (id: string, title: string) => void;
onUpdateListName?: (id: string, name: string) => void;
}
export function TaskList({
user,
listId,
lists,
tasks,
setTasks,
selectedTaskId,
onTaskSelect,
showCompleted,
onToggleCompleted,
onMenuOpen,
onRefresh,
isDemo = false,
onDemoAddTask,
onDemoToggleTask,
onUpdateTaskTitle,
onUpdateListName,
}: Props) {
const { t } = useI18n();
const [newTaskTitle, setNewTaskTitle] = useState("");
const [loading, setLoading] = useState(false);
const [editingHeader, setEditingHeader] = useState(false);
const [headerTitle, setHeaderTitle] = useState("");
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; task: Task } | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const headerInputRef = useRef<HTMLInputElement>(null);
const currentList = lists.find((l) => l.id === listId);
useEffect(() => {
if (currentList) setHeaderTitle(currentList.name);
}, [currentList]);
useEffect(() => {
if (editingHeader) {
headerInputRef.current?.focus();
headerInputRef.current?.select();
}
}, [editingHeader]);
const fetchTasks = useCallback(async () => {
if (!listId || isDemo) 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, isDemo, setTasks]);
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) => {
if (isDemo) {
if (onDemoToggleTask) onDemoToggleTask(id, completed);
return;
}
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();
setTasks((prev) =>
prev
.map((t) => {
if (t.id === id) return { ...t, completed, completedAt: updated.completedAt };
if (t.children && t.children.some((c) => c.id === id)) {
return {
...t,
children: t.children.map((c) => (c.id === id ? { ...c, completed } : c)),
};
}
return t;
})
.filter((t) => showCompleted || !t.completed)
);
onRefresh();
} catch (err) {
console.error("[TaskList] handleToggle failed", err);
}
},
[isDemo, onDemoToggleTask, showCompleted, onRefresh, setTasks]
);
const handleAddTask = async (e: React.FormEvent) => {
e.preventDefault();
const title = newTaskTitle.trim();
if (!title || !listId) return;
if (isDemo) {
if (onDemoAddTask) onDemoAddTask(title, listId, null);
setNewTaskTitle("");
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);
}
};
const handleAddSubtaskInline = async (title: string, parentId: string) => {
if (!listId) return;
if (isDemo) {
if (onDemoAddTask) onDemoAddTask(title, listId, parentId);
return;
}
try {
const res = await fetch("/api/tasks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, listId, parentId }),
});
if (res.ok) {
const sub = await res.json();
setTasks((prev) =>
prev.map((t) => (t.id === parentId ? { ...t, children: [...(t.children || []), sub] } : t))
);
onRefresh();
}
} catch (err) {
console.error("Failed to add subtask inline", err);
}
};
const handleSaveHeaderTitle = () => {
const trimmed = headerTitle.trim();
if (trimmed && listId && onUpdateListName && trimmed !== currentList?.name) {
onUpdateListName(listId, trimmed);
} else if (currentList) {
setHeaderTitle(currentList.name);
}
setEditingHeader(false);
};
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>{t("selectListToStart")}</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>
{/* Inline Editable List Title */}
{editingHeader ? (
<input
ref={headerInputRef}
id="header-rename-input"
className="form-input"
value={headerTitle}
onChange={(e) => setHeaderTitle(e.target.value)}
onBlur={handleSaveHeaderTitle}
onKeyDown={(e) => {
if (e.key === "Enter") handleSaveHeaderTitle();
if (e.key === "Escape") {
if (currentList) setHeaderTitle(currentList.name);
setEditingHeader(false);
}
}}
onClick={(e) => e.stopPropagation()}
autoFocus
style={{ fontSize: 18, fontWeight: 700, height: 36, padding: "2px 10px", maxWidth: 360 }}
/>
) : (
<div
className="main-header-title"
id="main-header-title"
style={{
color: currentList?.color,
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: 8,
padding: "4px 8px",
borderRadius: "var(--radius-sm)",
transition: "background var(--dur-fast)",
}}
onClick={(e) => {
e.stopPropagation();
setEditingHeader(true);
}}
title="Click to rename list"
>
<span>{currentList?.name || t("tasks")}</span>
<span style={{ fontSize: 13, opacity: 0.5 }}>✏️</span>
</div>
)}
<div className="main-header-actions">
<button
id="toggle-completed-btn"
className="btn btn-ghost btn-sm"
onClick={onToggleCompleted}
title={showCompleted ? t("hideDone") : t("showDone")}
>
<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 ? t("hideDone") : t("showDone")}
</button>
</div>
</div>
{/* Task list */}
<div className="task-list-container">
{loading && (
<div style={{ padding: "20px", textAlign: "center", color: "var(--text-tertiary)" }}>{t("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>{t("noTasksYet")}</p>
</div>
)}
{incompleteTasks.map((task) => (
<TaskItem
key={task.id}
task={task}
isSelected={selectedTaskId === task.id}
onSelect={onTaskSelect}
onToggle={handleToggle}
onUpdateTitle={(id, title) => onUpdateTaskTitle && onUpdateTaskTitle(id, title)}
onAddSubtask={handleAddSubtaskInline}
onContextMenu={(x, y, tItem) => setContextMenu({ x, y, task: tItem })}
/>
))}
{showCompleted && completedTasks.length > 0 && (
<div>
<div
style={{
padding: "12px 20px 4px",
fontSize: 11,
fontWeight: 600,
color: "var(--text-tertiary)",
textTransform: "uppercase",
letterSpacing: "0.5px",
}}
>
{t("completedSection")} ({completedTasks.length})
</div>
{completedTasks.map((task) => (
<TaskItem
key={task.id}
task={task}
isSelected={selectedTaskId === task.id}
onSelect={onTaskSelect}
onToggle={handleToggle}
onUpdateTitle={(id, title) => onUpdateTaskTitle && onUpdateTaskTitle(id, title)}
onAddSubtask={handleAddSubtaskInline}
onContextMenu={(x, y, tItem) => setContextMenu({ x, y, task: tItem })}
/>
))}
</div>
)}
</div>
{/* Context Menu on Task */}
{contextMenu && (
<ContextMenu
x={contextMenu.x}
y={contextMenu.y}
items={[
{
label: contextMenu.task.completed ? t("showDone") : t("completedSection"),
icon: contextMenu.task.completed ? "↩️" : "✅",
onClick: () => handleToggle(contextMenu.task.id, !contextMenu.task.completed),
},
{
label: t("taskTitlePlaceholder"),
icon: "✏️",
onClick: () => onTaskSelect(contextMenu.task),
},
{
label: t("subtasks"),
icon: "↳",
onClick: () => {
onTaskSelect(contextMenu.task);
},
},
{ divider: true, label: "" },
{
label: t("deleteTaskConfirm").split("?")[0],
icon: "🗑️",
danger: true,
onClick: async () => {
if (isDemo) {
setTasks((prev) => prev.filter((t) => t.id !== contextMenu.task.id));
} else {
await fetch(`/api/tasks/${contextMenu.task.id}`, { method: "DELETE" });
onRefresh();
}
},
},
]}
onClose={() => setContextMenu(null)}
/>
)}
{/* Add task bar */}
<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={t("addTaskPlaceholder")}
value={newTaskTitle}
onChange={(e) => setNewTaskTitle(e.target.value)}
/>
{newTaskTitle.trim() && (
<button type="submit" className="btn btn-primary btn-sm" id="add-task-btn">
{t("add")}
</button>
)}
</form>
</div>
</div>
);
}