feat: initial commit
This commit is contained in:
@@ -0,0 +1,542 @@
|
||||
"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);
|
||||
}, []);
|
||||
|
||||
// Demo tasks filter & reconstruct hierarchical structure
|
||||
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);
|
||||
}
|
||||
}
|
||||
}, [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);
|
||||
setSelectedTag(tagName);
|
||||
setSelectedTask(null);
|
||||
setSidebarOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleTrashSelect = useCallback(() => {
|
||||
setIsTrashActive(true);
|
||||
setSelectedTag(null);
|
||||
setSelectedListId(null);
|
||||
setSelectedTask(null);
|
||||
setSidebarOpen(false);
|
||||
}, []);
|
||||
|
||||
// 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);
|
||||
};
|
||||
|
||||
// Move to Trash (Soft Delete)
|
||||
const handleDemoDeleteTask = (id: string) => {
|
||||
const store = getDemoStore();
|
||||
const newTasks = moveToTrashInTree(store.tasks, id);
|
||||
saveDemoStore(store.lists, newTasks);
|
||||
setTasks((prev) => prev.filter((t) => t.id !== id));
|
||||
if (selectedTask?.id === id) {
|
||||
setSelectedTask(null);
|
||||
}
|
||||
refresh();
|
||||
};
|
||||
|
||||
// Restore from Trash
|
||||
const handleRestoreTask = (id: string) => {
|
||||
if (isDemo) {
|
||||
const store = getDemoStore();
|
||||
const newTasks = restoreTaskInTree(store.tasks, id);
|
||||
saveDemoStore(store.lists, newTasks);
|
||||
setTasks((prev) => prev.filter((t) => t.id !== id));
|
||||
refresh();
|
||||
}
|
||||
};
|
||||
|
||||
// Permanent Delete
|
||||
const handlePermanentDeleteTask = (id: string) => {
|
||||
if (isDemo) {
|
||||
const store = getDemoStore();
|
||||
const newTasks = deleteTaskInTree(store.tasks, id);
|
||||
saveDemoStore(store.lists, newTasks);
|
||||
setTasks((prev) => prev.filter((t) => t.id !== id));
|
||||
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}
|
||||
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}-${refreshKey}`}
|
||||
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}
|
||||
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}
|
||||
/>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user