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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user