"use client"; import React, { useState, useEffect, useRef, useCallback } from "react"; import { useI18n } from "@/lib/i18n"; import { ContextMenu, MenuItem } from "@/components/ui/ContextMenu"; export interface Tag { id: string; name: string; color: string } 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; deletedAt?: string | null; isDeleted?: boolean; children: Task[]; tags?: { tag: Tag }[]; } 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; depth?: number; 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; isTrashMode?: boolean; onRestore?: (id: string) => void; onPermanentDelete?: (id: string) => void; } // Recursive Task Tree Item (Supports 1st, 2nd, 3rd, N-level sub-tasks seamlessly) function RecursiveTaskItem({ task, depth = 0, isSelected, onSelect, onToggle, onUpdateTitle, onAddSubtask, onContextMenu, isTrashMode = false, onRestore, onPermanentDelete, }: 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(""); // Touch swipe support const touchStartX = useRef(null); const [swipeOffset, setSwipeOffset] = useState(0); const editInputRef = useRef(null); const subInputRef = useRef(null); const completedChildren = task.children?.filter((c) => c.completed).length || 0; const totalChildren = task.children?.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); } }; // Touch Swipe handlers const handleTouchStart = (e: React.TouchEvent) => { if (isTrashMode) return; touchStartX.current = e.touches[0].clientX; }; const handleTouchMove = (e: React.TouchEvent) => { if (touchStartX.current === null) return; const deltaX = e.touches[0].clientX - touchStartX.current; if (Math.abs(deltaX) < 120) { setSwipeOffset(deltaX); } }; const handleTouchEnd = () => { if (swipeOffset > 70) { // Swiped Right -> Toggle Complete onToggle(task.id, !task.completed); } setSwipeOffset(0); touchStartX.current = null; }; return (
0 ? 24 : 0, position: "relative" }}>
onSelect(task)} onTouchStart={handleTouchStart} onTouchMove={handleTouchMove} onTouchEnd={handleTouchEnd} onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); onContextMenu(e.clientX, e.clientY, task); }} id={`task-${task.id}`} style={{ borderLeft: depth > 0 ? "2px solid var(--border)" : "none", marginLeft: depth > 0 ? 8 : 0, transform: `translateX(${swipeOffset}px)`, transition: swipeOffset === 0 ? "transform 0.2s cubic-bezier(0.16, 1, 0.3, 1)" : "none", }} > {/* Toggle Expand Arrow if has children */} {totalChildren > 0 ? ( ) : depth > 0 ? ( ) : null} {/* Check button (hidden in trash mode) */} {!isTrashMode ? ( )}
{task.note && !task.completed && depth === 0 && (
{task.note.replace(/[#*`]/g, "").slice(0, 80)}
)}
{/* Trash Mode Actions or Edit Icon */} {isTrashMode ? (
) : ( )} {/* Recursive Children (N-Depth Sub-tasks) */} {totalChildren > 0 && expanded && (
{task.children.map((child) => ( ))}
)} {/* Inline Add Subtask form for this node */} {addingSubtask && !isTrashMode && (
e.stopPropagation()} > 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 }} />
)} ); } interface Props { user: User; listId: string | null; lists: List[]; tasks: Task[]; setTasks: React.Dispatch>; selectedTaskId: string | null; onTaskSelect: (t: Task | null) => void; showCompleted: boolean; onToggleCompleted: () => void; onMenuOpen: () => void; onRefresh: () => void; isDemo?: boolean; isTrashActive?: boolean; selectedTag?: string | null; onEmptyTrash?: () => void; onRestoreTask?: (id: string) => void; onPermanentDeleteTask?: (id: string) => void; 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; onDeleteTask?: (id: string) => void; } export function TaskList({ user, listId, lists, tasks, setTasks, selectedTaskId, onTaskSelect, showCompleted, onToggleCompleted, onMenuOpen, onRefresh, isDemo = false, isTrashActive = false, selectedTag = null, onEmptyTrash, onRestoreTask, onPermanentDeleteTask, onDemoAddTask, onDemoToggleTask, onUpdateTaskTitle, onUpdateListName, onDeleteTask, }: 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(null); const headerInputRef = useRef(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 || isTrashActive || selectedTag) 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, isTrashActive, selectedTag, 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}`); onRefresh(); } catch (err) { console.error("[TaskList] handleToggle failed", err); } }, [isDemo, onDemoToggleTask, onRefresh] ); 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) { 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 && !isTrashActive && !selectedTag) { return (

{t("selectListToStart")}

); } // Filter top-level vs completed const incompleteTasks = tasks.filter((t) => !t.completed); const completedTasks = tasks.filter((t) => t.completed); return (
{/* Header */}
{/* Header Title / Trash Header / Tag Header */} {isTrashActive ? (
🗑️ {t("trash") || "Trash"} ({tasks.length})
) : selectedTag ? (
🏷️ #{selectedTag} ({tasks.length})
) : editingHeader ? ( 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 }} /> ) : (
{ e.stopPropagation(); setEditingHeader(true); }} title="Click to rename list" > {currentList?.name || t("tasks")} ✏️
)} {/* Header Actions */}
{isTrashActive ? ( ) : ( )}
{/* Task list container */}
{ if (e.target === e.currentTarget) { e.preventDefault(); } }} > {loading && (
{t("loading")}
)} {!loading && tasks.length === 0 && (

{isTrashActive ? "Trash is empty" : selectedTag ? "No tasks with this tag" : t("noTasksYet")}

)} {/* Tasks Tree */} {incompleteTasks.map((task) => ( onUpdateTaskTitle && onUpdateTaskTitle(id, title)} onAddSubtask={handleAddSubtaskInline} onContextMenu={(x, y, tItem) => setContextMenu({ x, y, task: tItem })} isTrashMode={isTrashActive} onRestore={onRestoreTask} onPermanentDelete={onPermanentDeleteTask} /> ))} {/* Completed Tasks */} {!isTrashActive && showCompleted && completedTasks.length > 0 && (
{t("completedSection")} ({completedTasks.length})
{completedTasks.map((task) => ( onUpdateTaskTitle && onUpdateTaskTitle(id, title)} onAddSubtask={handleAddSubtaskInline} onContextMenu={(x, y, tItem) => setContextMenu({ x, y, task: tItem })} isTrashMode={isTrashActive} onRestore={onRestoreTask} onPermanentDelete={onPermanentDeleteTask} /> ))}
)}
{/* Full-featured Context Menu */} {contextMenu && !isTrashActive && ( 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 (onDeleteTask) { onDeleteTask(contextMenu.task.id); } else if (!isDemo) { await fetch(`/api/tasks/${contextMenu.task.id}`, { method: "DELETE" }); onRefresh(); } }, }, ]} onClose={() => setContextMenu(null)} /> )} {/* Add task bar (hidden in trash mode) */} {!isTrashActive && !selectedTag && (
)}
)}
); }