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
Build and Push Docker Image / build-and-push (push) Canceled after 23s
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user