Feature: Mobile bottom-sheet drawer (slide-up from bottom, swipe-down to close, overlay), update AGENTS.md to v4.0

This commit is contained in:
2026-08-20 22:51:43 +09:00
parent fc90ca7d0b
commit 36c8d690d9
12 changed files with 1820 additions and 273 deletions
+115 -17
View File
@@ -3,15 +3,21 @@ import { useState, useCallback, useEffect } from "react";
import { Sidebar } from "./Sidebar";
import { TaskList, Task, List, User } from "../tasks/TaskList";
import { TaskDetail } from "../tasks/TaskDetail";
import { CommandPalette } from "../ui/CommandPalette";
import {
getDemoStore,
saveDemoStore,
MockList,
MockTask,
updateTaskInTree,
moveToTrashInTree,
restoreTaskInTree,
deleteTaskInTree,
emptyTrashInTree,
addTaskToTree,
findTaskInTree,
getAllTrashTasks,
filterTasksByTag,
} from "@/lib/mockData";
interface AppShellProps {
@@ -22,33 +28,49 @@ interface AppShellProps {
export function AppShell({ user, isDemo = false }: AppShellProps) {
const [lists, setLists] = useState<List[]>([]);
const [selectedListId, setSelectedListId] = useState<string | null>(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[]>([]);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [showCompleted, setShowCompleted] = useState(false);
const [cmdPaletteOpen, setCmdPaletteOpen] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
// Listen for Ctrl+K event
useEffect(() => {
const handler = () => setCmdPaletteOpen(true);
document.addEventListener("checkflow:openCommandPalette", handler);
return () => document.removeEventListener("checkflow:openCommandPalette", handler);
}, []);
// Demo store loading
useEffect(() => {
if (isDemo) {
const store = getDemoStore();
setLists(store.lists);
if (store.lists.length > 0) {
if (store.lists.length > 0 && !selectedListId && !isTrashActive && !selectedTag) {
setSelectedListId(store.lists[0].id);
}
}
}, [isDemo]);
}, [isDemo, isTrashActive, selectedTag]);
// Demo tasks filter & reconstruct hierarchical structure
useEffect(() => {
if (isDemo && selectedListId) {
if (isDemo) {
const store = getDemoStore();
const listTasks = (store.tasks as Task[]).filter(
(t) => t.listId === selectedListId && (showCompleted ? true : !t.completed)
);
setTasks(listTasks);
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, showCompleted, refreshKey]);
}, [isDemo, selectedListId, isTrashActive, selectedTag, showCompleted, refreshKey]);
const refresh = useCallback(() => setRefreshKey((k) => k + 1), []);
@@ -68,12 +90,29 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
}, []);
const handleListSelect = useCallback((id: string) => {
setIsTrashActive(false);
setSelectedTag(null);
setSelectedListId(id);
setSelectedTask(null);
setSidebarOpen(false);
}, []);
// Update List Name (Inline edit on header or sidebar)
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;
@@ -101,7 +140,7 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
}
};
// Demo Handlers with Full N-Depth Recursive Tree Support
// Demo Handlers
const handleDemoCreateList = (name: string, color: string) => {
const newList: MockList = {
id: "demo-list-" + Date.now(),
@@ -142,6 +181,8 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
sortOrder: tasks.length,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
isDeleted: false,
children: [],
tags: [],
};
@@ -151,7 +192,6 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
setTasks((prev) => addTaskToTree(prev as MockTask[], parentId, newTask as MockTask) as Task[]);
// If currently selected task is the parent, update its children in the detail panel immediately
if (selectedTask && selectedTask.id === parentId) {
setSelectedTask((prev) =>
prev ? { ...prev, children: [...(prev.children || []), newTask] } : prev
@@ -175,7 +215,6 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
(updateTaskInTree(prev as MockTask[], updated) as Task[]).filter((t) => showCompleted || !t.completed)
);
// Keep selectedTask 100% in sync with the tree
if (selectedTask) {
const latestSelected = findTaskInTree(newTasks, selectedTask.id);
if (latestSelected) {
@@ -193,18 +232,52 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
handleTaskUpdate(updated);
};
// Move to Trash (Soft Delete)
const handleDemoDeleteTask = (id: string) => {
const store = getDemoStore();
const newTasks = deleteTaskInTree(store.tasks, id);
const newTasks = moveToTrashInTree(store.tasks, id);
saveDemoStore(store.lists, newTasks);
setTasks((prev) => deleteTaskInTree(prev as MockTask[], id) as Task[]);
setTasks((prev) => prev.filter((t) => t.id !== id));
if (selectedTask?.id === id) {
setSelectedTask(null);
}
refresh();
};
// Inline update task title from main list
// 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;
@@ -238,7 +311,7 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
return (
<div className="app-layout">
{/* Mobile overlay */}
{/* Sidebar 모바일 오버레이 */}
{sidebarOpen && (
<div
className="modal-overlay"
@@ -247,12 +320,24 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
/>
)}
{/* 바텀시트 모바일 오버레이 — 태스크 상세 패널이 열릴 때 */}
{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}
@@ -262,7 +347,7 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
<div className="main-content">
<TaskList
key={`${selectedListId}-${refreshKey}`}
key={`${selectedListId}-${isTrashActive}-${selectedTag}-${refreshKey}`}
user={user}
listId={selectedListId}
lists={lists}
@@ -275,6 +360,11 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
onMenuOpen={() => setSidebarOpen(true)}
onRefresh={refresh}
isDemo={isDemo}
isTrashActive={isTrashActive}
selectedTag={selectedTag}
onEmptyTrash={handleEmptyTrash}
onRestoreTask={handleRestoreTask}
onPermanentDeleteTask={handlePermanentDeleteTask}
onDemoAddTask={handleDemoAddTask}
onDemoToggleTask={handleDemoToggleTask}
onUpdateTaskTitle={handleUpdateTaskTitle}
@@ -303,6 +393,14 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
/>
)}
{/* Global Command Palette (Ctrl+K) */}
<CommandPalette
isOpen={cmdPaletteOpen}
onClose={() => setCmdPaletteOpen(false)}
tasks={tasks}
onSelectTask={handleTaskSelect}
/>
{/* Mobile FAB */}
<button
className="fab"