720 lines
24 KiB
TypeScript
720 lines
24 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 { 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<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);
|
|
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<NodeJS.Timeout | null>(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 (
|
|
<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 : apiTrashCount}
|
|
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 || t("tasks")}</strong> {t("taskUndoHint")}
|
|
</span>
|
|
</div>
|
|
<div className="undo-toast-actions">
|
|
<button type="button" className="undo-toast-btn" onClick={handleUndoDelete}>
|
|
{t("undoDelete")}
|
|
</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>
|
|
);
|
|
}
|