Files
checkflow/src/components/layout/AppShell.tsx
T
2026-08-21 23:00:50 +09:00

672 lines
22 KiB
TypeScript

"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 {
getDemoStore,
saveDemoStore,
MockList,
MockTask,
MockTag,
updateTaskInTree,
moveToTrashInTree,
restoreTaskInTree,
deleteTaskInTree,
emptyTrashInTree,
addTaskToTree,
findTaskInTree,
getAllTrashTasks,
filterTasksByTag,
} from "@/lib/mockData";
interface AppShellProps {
user: User;
isDemo?: boolean;
}
export function AppShell({ user, isDemo = false }: AppShellProps) {
const [lists, setLists] = useState<List[]>(() => {
if (isDemo && typeof window !== "undefined") {
return getDemoStore().lists as List[];
}
return [];
});
const [selectedListId, setSelectedListId] = useState<string | null>(() => {
if (isDemo && typeof window !== "undefined") {
const storeLists = getDemoStore().lists;
return storeLists.length > 0 ? storeLists[0].id : null;
}
return null;
});
const [selectedTag, setSelectedTag] = useState<string | null>(null);
const [isTrashActive, setIsTrashActive] = useState(false);
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
const [tasks, setTasks] = useState<Task[]>(() => {
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);
// 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);
}, []);
// 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 = (store.tasks as Task[]).filter(
(t) => !t.isDeleted && t.listId === selectedListId && (showCompleted ? true : !t.completed)
);
setTasks(listTasks);
} else {
setTasks([]);
}
} else {
// Authenticated API mode
if (isTrashActive) {
setTasks([]);
} 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 {
setTasks([]);
}
}, [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<NodeJS.Timeout | null>(null);
// Unified task delete handler with immediate Undo Toast
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);
setTasks((prev) => prev.filter((t) => t.id !== task.id));
if (selectedTask?.id === task.id) setSelectedTask(null);
} else {
setTasks((prev) => prev.filter((t) => t.id !== task.id));
if (selectedTask?.id === task.id) setSelectedTask(null);
fetch(`/api/tasks/${task.id}`, { method: "DELETE" }).catch((e) => console.error(e));
}
undoTimerRef.current = setTimeout(() => {
setUndoToast(null);
}, 6000);
}, [isDemo, selectedTask]);
// Undo delete
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", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: task.title,
listId: task.listId,
parentId: task.parentId,
note: task.note,
completed: task.completed,
dueDate: task.dueDate,
priority: task.priority,
}),
});
if (res.ok) {
const created = await res.json();
if (created.listId === selectedListId) {
setTasks((prev) => [...prev, created]);
}
refresh();
}
} catch (err) {
console.error("Failed to restore task", 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 = (id: string) => {
if (isDemo) {
const store = getDemoStore();
const newTasks = restoreTaskInTree(store.tasks, id);
saveDemoStore(store.lists, newTasks);
setTasks(getAllTrashTasks(newTasks) as Task[]);
refresh();
}
};
// Permanent Delete
const handlePermanentDeleteTask = (id: string) => {
if (isDemo) {
const store = getDemoStore();
const newTasks = deleteTaskInTree(store.tasks, id);
saveDemoStore(store.lists, newTasks);
setTasks(getAllTrashTasks(newTasks) as Task[]);
refresh();
}
};
// Empty Trash
const handleEmptyTrash = () => {
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();
}
};
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 (
<div className="app-layout">
{/* Dynamic CSS variable injection from user prefs */}
<style>{prefsStyle}</style>
{/* Sidebar 모바일 오버레이 */}
{sidebarOpen && (
<div
className="modal-overlay"
style={{ zIndex: 25 }}
onClick={() => setSidebarOpen(false)}
/>
)}
{/* 바텀시트 모바일 오버레이 — 태스크 상세 패널이 열릴 때 */}
{selectedTask && (
<div
className="bottom-sheet-overlay"
onClick={() => setSelectedTask(null)}
/>
)}
<Sidebar
user={user}
lists={lists}
setLists={setLists}
selectedListId={selectedListId}
onListSelect={handleListSelect}
selectedTag={selectedTag}
onTagSelect={handleTagSelect}
isTrashActive={isTrashActive}
onTrashSelect={handleTrashSelect}
trashCount={isDemo && typeof window !== "undefined" ? getAllTrashTasks(getDemoStore().tasks).length : undefined}
mobileOpen={sidebarOpen}
onClose={() => setSidebarOpen(false)}
isDemo={isDemo}
onDemoCreateList={handleDemoCreateList}
onDemoDeleteList={handleDemoDeleteList}
/>
{/* Sidebar resize handle */}
<div
className="sidebar-resize-handle"
onMouseDown={handleSidebarResizeStart}
title="Drag to resize sidebar"
style={{
width: 4,
cursor: "col-resize",
background: "transparent",
flexShrink: 0,
transition: "background var(--dur-fast)",
position: "relative",
zIndex: 10,
}}
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.background = "var(--accent-medium)"; }}
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = "transparent"; }}
/>
<div className="main-content">
<TaskList
key={`${selectedListId}-${isTrashActive}-${selectedTag}`}
user={user}
listId={selectedListId}
lists={lists}
tasks={tasks}
setTasks={setTasks}
selectedTaskId={selectedTask?.id ?? null}
onTaskSelect={handleTaskSelect}
showCompleted={showCompleted}
onToggleCompleted={() => 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();
}}
/>
</div>
{selectedTask && (
<>
{/* Detail panel resize handle (left edge) */}
<div
onMouseDown={handleDetailResizeStart}
title="Drag to resize detail panel"
style={{
width: 4,
cursor: "col-resize",
background: "transparent",
flexShrink: 0,
zIndex: 10,
transition: "background var(--dur-fast)",
}}
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.background = "var(--accent-medium)"; }}
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = "transparent"; }}
/>
<TaskDetail
task={selectedTask}
onClose={() => 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) */}
<CommandPalette
isOpen={cmdPaletteOpen}
onClose={() => setCmdPaletteOpen(false)}
tasks={tasks}
onSelectTask={handleTaskSelect}
/>
{/* Interactive Undo Toast Banner */}
{undoToast && (
<div className="undo-toast-banner" role="alert">
<div className="undo-toast-content">
<span className="undo-toast-icon">🗑️</span>
<span className="undo-toast-msg">
<strong>{undoToast.title || "할 일"}</strong>() 삭제되었습니다.
</span>
</div>
<div className="undo-toast-actions">
<button type="button" className="undo-toast-btn" onClick={handleUndoDelete}>
실행 취소
</button>
<button
type="button"
className="undo-toast-close"
onClick={() => {
if (undoTimerRef.current) clearTimeout(undoTimerRef.current);
setUndoToast(null);
}}
title="닫기"
>
</button>
</div>
</div>
)}
{/* Mobile FAB */}
<button
className="fab"
id="mobile-add-task"
aria-label="Add task"
onClick={() => {
document.dispatchEvent(new CustomEvent("checkflow:addTask"));
}}
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
</svg>
</button>
</div>
);
}