"use client"; import { useState, useCallback, useEffect, useRef } from "react"; import { Sidebar } from "./Sidebar"; import { TaskList, Task, List, User } from "../tasks/TaskList"; import { useUserPrefs, usePrefsStyle } from "@/lib/useUserPrefs"; import { TaskDetail } from "../tasks/TaskDetail"; import { CommandPalette } from "../ui/CommandPalette"; import { useI18n } from "@/lib/i18n"; import { getDemoStore, saveDemoStore, MockList, MockTask, MockTag, updateTaskInTree, moveToTrashInTree, restoreTaskInTree, deleteTaskInTree, emptyTrashInTree, addTaskToTree, findTaskInTree, getAllTrashTasks, filterTasksByTag, filterActiveTree, removeTaskFromTree, } from "@/lib/mockData"; interface AppShellProps { user: User; isDemo?: boolean; } export function AppShell({ user, isDemo = false }: AppShellProps) { const [lists, setLists] = useState(() => { if (isDemo && typeof window !== "undefined") { return getDemoStore().lists as List[]; } return []; }); const [selectedListId, setSelectedListId] = useState(() => { if (isDemo && typeof window !== "undefined") { const storeLists = getDemoStore().lists; return storeLists.length > 0 ? storeLists[0].id : null; } return null; }); const [selectedTag, setSelectedTag] = useState(null); const [isTrashActive, setIsTrashActive] = useState(false); const [selectedTask, setSelectedTask] = useState(null); const [tasks, setTasks] = useState(() => { if (isDemo && typeof window !== "undefined") { const store = getDemoStore(); const firstListId = store.lists[0]?.id; return (store.tasks as Task[]).filter((t) => !t.isDeleted && t.listId === firstListId && !t.completed); } return []; }); const [sidebarOpen, setSidebarOpen] = useState(false); const [showCompleted, setShowCompleted] = useState(false); const [cmdPaletteOpen, setCmdPaletteOpen] = useState(false); const [refreshKey, setRefreshKey] = useState(0); const { t } = useI18n(); // Global user preferences (reactive) const { prefs, updatePrefs } = useUserPrefs(); const prefsStyle = usePrefsStyle(prefs); // Sidebar resize drag const sidebarResizing = useRef(false); const handleSidebarResizeStart = (e: React.MouseEvent) => { e.preventDefault(); sidebarResizing.current = true; document.body.style.cursor = "col-resize"; document.body.style.userSelect = "none"; const onMove = (ev: MouseEvent) => { if (!sidebarResizing.current) return; const newW = Math.max(160, Math.min(420, ev.clientX)); updatePrefs({ sidebarWidth: newW }); }; const onUp = () => { sidebarResizing.current = false; document.body.style.cursor = ""; document.body.style.userSelect = ""; window.removeEventListener("mousemove", onMove); window.removeEventListener("mouseup", onUp); }; window.addEventListener("mousemove", onMove); window.addEventListener("mouseup", onUp); }; // Detail panel resize drag const detailResizing = useRef(false); const handleDetailResizeStart = (e: React.MouseEvent) => { e.preventDefault(); detailResizing.current = true; document.body.style.cursor = "col-resize"; document.body.style.userSelect = "none"; const onMove = (ev: MouseEvent) => { if (!detailResizing.current) return; const newW = Math.max(300, Math.min(760, window.innerWidth - ev.clientX)); updatePrefs({ detailWidth: newW }); }; const onUp = () => { detailResizing.current = false; document.body.style.cursor = ""; document.body.style.userSelect = ""; window.removeEventListener("mousemove", onMove); window.removeEventListener("mouseup", onUp); }; window.addEventListener("mousemove", onMove); window.addEventListener("mouseup", onUp); }; // Listen for Ctrl+K event useEffect(() => { const handler = () => setCmdPaletteOpen(true); document.addEventListener("checkflow:openCommandPalette", handler); return () => document.removeEventListener("checkflow:openCommandPalette", handler); }, []); const [apiTrashCount, setApiTrashCount] = useState(0); // Task filter & reconstruct hierarchical structure for Demo and API modes useEffect(() => { if (isDemo) { const store = getDemoStore(); setLists(store.lists); if (isTrashActive) { setTasks(getAllTrashTasks(store.tasks) as Task[]); } else if (selectedTag) { setTasks(filterTasksByTag(store.tasks, selectedTag) as Task[]); } else if (selectedListId) { const listTasks = filterActiveTree(store.tasks as MockTask[], showCompleted).filter( (t) => t.listId === selectedListId ); setTasks(listTasks as Task[]); } else { setTasks([]); } } else { // Authenticated API mode // Also fetch trash count for sidebar fetch("/api/tasks?countTrash=true") .then((r) => (r.ok ? r.json() : { count: 0 })) .then((data) => setApiTrashCount(data.count || 0)) .catch(() => {}); if (isTrashActive) { fetch("/api/tasks?isTrash=true") .then((r) => (r.ok ? r.json() : [])) .then((data) => setTasks(Array.isArray(data) ? data : [])) .catch((err) => console.error("Failed to load trash tasks", err)); } else if (selectedTag) { fetch(`/api/tasks?tag=${encodeURIComponent(selectedTag)}&showCompleted=${showCompleted}`) .then((r) => (r.ok ? r.json() : [])) .then((data) => setTasks(Array.isArray(data) ? data : [])) .catch((err) => console.error("Failed to load tag tasks", err)); } else if (selectedListId) { fetch(`/api/tasks?listId=${selectedListId}&showCompleted=${showCompleted}`) .then((r) => (r.ok ? r.json() : [])) .then((data) => setTasks(Array.isArray(data) ? data : [])) .catch((err) => console.error("Failed to load list tasks", err)); } } }, [isDemo, selectedListId, isTrashActive, selectedTag, showCompleted, refreshKey]); const refresh = useCallback(() => setRefreshKey((k) => k + 1), []); const handleTaskSelect = useCallback((task: Task | null) => { setSelectedTask(task); }, []); // Update a task in full tree (supports unlimited N-depth nesting) const handleTaskUpdate = useCallback((updated: Task) => { setTasks((prev) => updateTaskInTree(prev as MockTask[], updated as MockTask) as Task[]); setSelectedTask((prev) => { if (prev && prev.id === updated.id) { return { ...updated, children: updated.children || prev.children || [] }; } return prev; }); }, []); const handleListSelect = useCallback((id: string) => { setIsTrashActive(false); setSelectedTag(null); setSelectedListId(id); setSelectedTask(null); setSidebarOpen(false); }, []); const handleTagSelect = useCallback((tagName: string | null) => { setIsTrashActive(false); setSelectedListId(null); setSelectedTag(tagName); setSelectedTask(null); setSidebarOpen(false); }, []); const handleTrashSelect = useCallback(() => { setSelectedListId(null); setSelectedTag(null); setIsTrashActive(true); setSelectedTask(null); setSidebarOpen(false); if (isDemo) { const store = getDemoStore(); setTasks(getAllTrashTasks(store.tasks) as Task[]); } else { fetch("/api/tasks?isTrash=true") .then((r) => (r.ok ? r.json() : [])) .then((data) => setTasks(Array.isArray(data) ? data : [])) .catch((err) => console.error("Failed to load trash tasks", err)); } }, [isDemo]); // Update List Name const handleUpdateListName = async (id: string, newName: string) => { const trimmed = newName.trim(); if (!trimmed) return; if (isDemo) { const store = getDemoStore(); const newLists = store.lists.map((l) => (l.id === id ? { ...l, name: trimmed } : l)); saveDemoStore(newLists, store.tasks); setLists(newLists); return; } try { const res = await fetch(`/api/lists/${id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: trimmed }), }); if (res.ok) { const updated = await res.json(); setLists((prev) => prev.map((l) => (l.id === id ? { ...l, name: updated.name } : l))); } } catch (err) { console.error("Failed to update list name", err); } }; // Demo Handlers const handleDemoCreateList = (name: string, color: string) => { const newList: MockList = { id: "demo-list-" + Date.now(), name, color, icon: "list", }; const store = getDemoStore(); const newLists = [...store.lists, newList]; saveDemoStore(newLists, store.tasks); setLists(newLists); setSelectedListId(newList.id); }; const handleDemoDeleteList = (id: string) => { const store = getDemoStore(); const newLists = store.lists.filter((l) => l.id !== id); const newTasks = store.tasks.filter((t) => t.listId !== id); saveDemoStore(newLists, newTasks); setLists(newLists); if (selectedListId === id) { setSelectedListId(newLists[0]?.id || null); } refresh(); }; const handleDemoAddTask = ( title: string, listId: string, parentId: string | null = null, meta?: { dueDate?: string | null; priority?: number; tags?: { tag: MockTag }[] } ) => { const newTask: Task = { id: "demo-task-" + Date.now(), listId, parentId: parentId || null, title, note: null, completed: false, completedAt: null, dueDate: meta?.dueDate || null, priority: meta?.priority || 0, sortOrder: tasks.length, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), deletedAt: null, isDeleted: false, children: [], tags: meta?.tags || [], }; const store = getDemoStore(); const newTasks = addTaskToTree(store.tasks, parentId, newTask as MockTask); saveDemoStore(store.lists, newTasks); setTasks((prev) => addTaskToTree(prev as MockTask[], parentId, newTask as MockTask) as Task[]); if (selectedTask && selectedTask.id === parentId) { setSelectedTask((prev) => prev ? { ...prev, children: [...(prev.children || []), newTask] } : prev ); } refresh(); }; const handleDemoToggleTask = (id: string, completed: boolean) => { const store = getDemoStore(); const target = findTaskInTree(store.tasks, id); if (target) { const updated = { ...target, completed, completedAt: completed ? new Date().toISOString() : null, }; const newTasks = updateTaskInTree(store.tasks, updated); saveDemoStore(store.lists, newTasks); setTasks((prev) => (updateTaskInTree(prev as MockTask[], updated) as Task[]).filter((t) => showCompleted || !t.completed) ); if (selectedTask) { const latestSelected = findTaskInTree(newTasks, selectedTask.id); if (latestSelected) { setSelectedTask(latestSelected as Task); } } } refresh(); }; const handleDemoUpdateTask = (updated: Task) => { const store = getDemoStore(); const newTasks = updateTaskInTree(store.tasks, updated as MockTask); saveDemoStore(store.lists, newTasks); handleTaskUpdate(updated); }; // Undo Toast state const [undoToast, setUndoToast] = useState<{ task: Task; title: string } | null>(null); const undoTimerRef = useRef(null); // Unified task delete handler with immediate Undo Toast (recursively removes task & all descendants) const handleDeleteTaskWithUndo = useCallback((task: Task) => { if (undoTimerRef.current) clearTimeout(undoTimerRef.current); setUndoToast({ task, title: task.title }); if (isDemo) { const store = getDemoStore(); const newTasks = moveToTrashInTree(store.tasks, task.id); saveDemoStore(store.lists, newTasks); if (isTrashActive) { setTasks(getAllTrashTasks(newTasks) as Task[]); } else if (selectedListId) { setTasks(filterActiveTree(newTasks, showCompleted).filter((t) => t.listId === selectedListId) as Task[]); } else if (selectedTag) { setTasks(filterTasksByTag(newTasks, selectedTag) as Task[]); } if (selectedTask?.id === task.id) setSelectedTask(null); } else { // API Mode: Optimistically strip target task and all its subtasks from active tree immediately setTasks((prev) => removeTaskFromTree(prev as MockTask[], task.id) as Task[]); setApiTrashCount((c) => c + 1); if (selectedTask?.id === task.id) setSelectedTask(null); fetch(`/api/tasks/${task.id}`, { method: "DELETE" }) .then(() => { // Re-sync trash count in background fetch("/api/tasks?countTrash=true") .then((r) => (r.ok ? r.json() : { count: 0 })) .then((data) => setApiTrashCount(data.count || 0)) .catch(() => {}); }) .catch((e) => console.error(e)); } undoTimerRef.current = setTimeout(() => { setUndoToast(null); }, 6000); }, [isDemo, selectedTask, isTrashActive, selectedListId, selectedTag, showCompleted]); // Undo delete (Restores exact soft-deleted task hierarchy) const handleUndoDelete = useCallback(async () => { if (!undoToast) return; if (undoTimerRef.current) clearTimeout(undoTimerRef.current); const { task } = undoToast; setUndoToast(null); if (isDemo) { const store = getDemoStore(); const newTasks = restoreTaskInTree(store.tasks, task.id); saveDemoStore(store.lists, newTasks); if (task.listId === selectedListId) { setTasks((prev) => { if (prev.some((t) => t.id === task.id)) return prev; return [...prev, { ...task, isDeleted: false, deletedAt: null }]; }); } refresh(); } else { try { const res = await fetch(`/api/tasks/${task.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ isDeleted: false, deletedAt: null }), }); if (res.ok) { refresh(); } } catch (err) { console.error("Failed to restore task via Undo", err); } } }, [undoToast, isDemo, selectedListId, refresh]); // Move to Trash (Soft Delete) const handleDemoDeleteTask = (id: string) => { const store = getDemoStore(); const target = findTaskInTree(store.tasks, id); if (target) { handleDeleteTaskWithUndo(target as Task); } else { const newTasks = moveToTrashInTree(store.tasks, id); saveDemoStore(store.lists, newTasks); setTasks((prev) => prev.filter((t) => t.id !== id)); if (selectedTask?.id === id) setSelectedTask(null); } }; // Restore from Trash (Restores task and all its nested subtasks) const handleRestoreTask = async (id: string) => { if (isDemo) { const store = getDemoStore(); const newTasks = restoreTaskInTree(store.tasks, id); saveDemoStore(store.lists, newTasks); setTasks(getAllTrashTasks(newTasks) as Task[]); refresh(); } else { try { await fetch(`/api/tasks/${id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ isDeleted: false, deletedAt: null }), }); refresh(); } catch (err) { console.error("Failed to restore task", err); } } }; // Permanent Delete const handlePermanentDeleteTask = async (id: string) => { if (isDemo) { const store = getDemoStore(); const newTasks = deleteTaskInTree(store.tasks, id); saveDemoStore(store.lists, newTasks); setTasks(getAllTrashTasks(newTasks) as Task[]); refresh(); } else { try { await fetch(`/api/tasks/${id}?permanent=true`, { method: "DELETE" }); refresh(); } catch (err) { console.error("Failed to permanently delete task", err); } } }; // Empty Trash const handleEmptyTrash = async () => { if (!confirm("Permanently empty all items in trash?")) return; if (isDemo) { const store = getDemoStore(); const newTasks = emptyTrashInTree(store.tasks); saveDemoStore(store.lists, newTasks); setTasks([]); refresh(); } else { try { await fetch("/api/tasks?trash=true", { method: "DELETE" }); refresh(); } catch (err) { console.error("Failed to empty trash", err); } } }; const handleUpdateTaskTitle = async (taskId: string, newTitle: string) => { const trimmed = newTitle.trim(); if (!trimmed) return; if (isDemo) { const store = getDemoStore(); const target = findTaskInTree(store.tasks, taskId); if (target) { const updated = { ...target, title: trimmed }; const newTasks = updateTaskInTree(store.tasks, updated); saveDemoStore(store.lists, newTasks); handleTaskUpdate(updated as Task); } return; } try { const res = await fetch(`/api/tasks/${taskId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title: trimmed }), }); if (res.ok) { const updated = await res.json(); handleTaskUpdate(updated); } } catch (err) { console.error("Failed to update task title", err); } }; return (
{/* Dynamic CSS variable injection from user prefs */} {/* Sidebar 모바일 오버레이 */} {sidebarOpen && (
setSidebarOpen(false)} /> )} {/* 바텀시트 모바일 오버레이 — 태스크 상세 패널이 열릴 때 */} {selectedTask && (
setSelectedTask(null)} /> )} setSidebarOpen(false)} isDemo={isDemo} onDemoCreateList={handleDemoCreateList} onDemoDeleteList={handleDemoDeleteList} /> {/* Sidebar resize handle */}
{ (e.currentTarget as HTMLElement).style.background = "var(--accent-medium)"; }} onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = "transparent"; }} />
setShowCompleted((p) => !p)} onMenuOpen={() => setSidebarOpen(true)} onRefresh={refresh} isDemo={isDemo} isTrashActive={isTrashActive} selectedTag={selectedTag} onEmptyTrash={handleEmptyTrash} onRestoreTask={handleRestoreTask} onPermanentDeleteTask={handlePermanentDeleteTask} onDemoAddTask={handleDemoAddTask} onDemoToggleTask={handleDemoToggleTask} onUpdateTaskTitle={handleUpdateTaskTitle} onUpdateListName={handleUpdateListName} onDeleteTaskWithUndo={handleDeleteTaskWithUndo} onDeleteTask={isDemo ? handleDemoDeleteTask : async (id: string) => { await fetch(`/api/tasks/${id}`, { method: "DELETE" }); refresh(); }} />
{selectedTask && ( <> {/* Detail panel resize handle (left edge) */}
{ (e.currentTarget as HTMLElement).style.background = "var(--accent-medium)"; }} onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = "transparent"; }} /> setSelectedTask(null)} onUpdate={handleTaskUpdate} onDelete={() => { setSelectedTask(null); refresh(); }} listId={selectedTask.listId} listName={lists.find((l) => l.id === selectedTask.listId)?.name} lists={lists} onMoveList={async (targetListId) => { if (isDemo) { const store = getDemoStore(); const updated = { ...selectedTask, listId: targetListId }; const newTasks = updateTaskInTree(store.tasks, updated as MockTask); saveDemoStore(store.lists, newTasks); handleTaskUpdate(updated); } else { await fetch(`/api/tasks/${selectedTask.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ listId: targetListId }), }); handleTaskUpdate({ ...selectedTask, listId: targetListId }); } refresh(); }} isDemo={isDemo} onDemoUpdateTask={handleDemoUpdateTask} onDemoDeleteTask={handleDemoDeleteTask} /> )} {/* Global Command Palette (Ctrl+K) */} setCmdPaletteOpen(false)} tasks={tasks} onSelectTask={handleTaskSelect} /> {/* Interactive Undo Toast Banner */} {undoToast && (
🗑️ {undoToast.title || t("tasks")} {t("taskUndoHint")}
)} {/* Mobile FAB */}
); }