feat: enhance task item UX with Notion drag handle, F2 edit, and floating undo toast (v0.6.3)
Build and Push Docker Image / build-and-push (push) Canceled after 23s

This commit is contained in:
2026-08-21 19:42:50 +09:00
parent 5579e71839
commit 94217d773d
6 changed files with 437 additions and 89 deletions
+112 -8
View File
@@ -299,16 +299,91 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
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 newTasks = moveToTrashInTree(store.tasks, id);
saveDemoStore(store.lists, newTasks);
setTasks((prev) => prev.filter((t) => t.id !== id));
if (selectedTask?.id === id) {
setSelectedTask(null);
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);
}
refresh();
};
// Restore from Trash
@@ -435,7 +510,7 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
<div className="main-content">
<TaskList
key={`${selectedListId}-${isTrashActive}-${selectedTag}-${refreshKey}`}
key={`${selectedListId}-${isTrashActive}-${selectedTag}`}
user={user}
listId={selectedListId}
lists={lists}
@@ -457,6 +532,7 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
onDemoToggleTask={handleDemoToggleTask}
onUpdateTaskTitle={handleUpdateTaskTitle}
onUpdateListName={handleUpdateListName}
onDeleteTaskWithUndo={handleDeleteTaskWithUndo}
onDeleteTask={isDemo ? handleDemoDeleteTask : async (id: string) => {
await fetch(`/api/tasks/${id}`, { method: "DELETE" });
refresh();
@@ -524,6 +600,34 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
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"
@@ -539,4 +643,4 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
</button>
</div>
);
}
}