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
+6
View File
@@ -28,6 +28,12 @@
- Refined `docker-compose.yml` and `.env.example` to support standalone 2-file deployment (`docker-compose.yml` + `.env`) without cloning source code, public registry images (`CHECKFLOW_IMAGE`), Watchtower auto-updating labels, and automated database migrations.
- Automated Gitea Actions CI workflow (`.gitea/workflows/docker-build.yaml`) for building & pushing container images to Gitea Container Registry with lowercase repository name normalization (`env.REPO`) and OCI source labels (`org.opencontainers.image.source`) for automatic repository package linkage.
- Completely revamped `README.md` to reflect universal 2-file Docker deployment for general users, Watchtower auto-update, architecture, and open-standard CalDAV sync guidelines.
- **Enhanced Task Row UX & Interactive Undo Toast (v0.6.3)**:
- Added Notion-style 6-dot drag handle on each task row with drag-and-drop task reordering.
- Added `F2` shortcut key and double-click to trigger inline title editing.
- Moved "+ Subtask" button to the right-aligned hover area and removed redundant explicit edit pencil button.
- Implemented floating interactive Undo Toast banner with 6-second grace period for soft delete and instant undo.
- Fixed Trash and task tree duplicate rendering in LocalStorage demo store.
- Validated that `npm run lint` and `npm run build` execute with 0 errors and 0 warnings.
- **Next Steps (Todo):**
+2 -1
View File
@@ -81,7 +81,8 @@ src/
| `checkpoint-v0.5.0` | VSCode 무채색 다크 테마, 에디터 조잡한 툴바 제거 & 순수 캔버스 전환, 모듈형 블록 커스텀 (서브태스크 ↔ 노트 순서 스왑 및 스플릿 리사이저 높이 조절), CheckFlow Labs (3컬럼 칸반 보드 뷰), 기본값 복원(Reset to Defaults), 보안 패치 완료 | 2026-08-21 |
| `checkpoint-v0.6.0` | 글로벌 `UserPrefs` 시스템(`userPrefs.ts` / `useUserPrefs.ts`) 도입, 사이드바 드래그 리사이저, 디테일 패널 드래그 리사이저, Labs 탭 확장(밀도/폰트/라운드/애니메이션/색조/채도/패널 너비), 뷰 전환(리스트↔칸반) 실시간 반응, 파스텔 매트 라이트 팔레트 적용 | 2026-08-21 |
| `checkpoint-v0.6.1` | CalDAV / DAVx⁵ 양방향 동기화 프로토콜 고도화(OPTIONS, PROPFIND, REPORT, PUT, DELETE), 설정 모달 내 플랫폼별(Android DAVx⁵ / Apple Reminders / Thunderbird) 인터랙티브 가이드 및 실시간 엔드포인트 테스트 핑, 다국어 i18n 동기화 | 2026-08-21 |
| `checkpoint-v0.6.2` | **(현재 최신)** 표준 태스크 내보내기(Export) 기능 구현: TickTick/RFC 4180 호환 CSV (UTF-8 BOM 포함) 및 RFC 5545 iCalendar (`.ics` VTODO), 사이드바 프로필 메뉴 연동, 데모 모드 로컬 Blob 내보내기 지원, 다국어 사전 동기화 | 2026-08-21 |
| `checkpoint-v0.6.2` | 표준 태스크 내보내기(Export) 기능 구현: TickTick/RFC 4180 호환 CSV (UTF-8 BOM 포함) 및 RFC 5545 iCalendar (`.ics` VTODO), 사이드바 프로필 메뉴 연동, 데모 모드 로컬 Blob 내보내기 지원, 다국어 사전 동기화 | 2026-08-21 |
| `checkpoint-v0.6.3` | **(현재 최신)** 노션 스타일 6도트 드래그 핸들(`⋮⋮`) & DND 태스크 재정렬, F2 단축키 인라인 제목 수정, 우측 호버 서브태스크 추가 버튼, 6초 플로팅 인터랙티브 실행 취소(Undo) 토스트 배너 탑재, 휴지통 및 로컬 스토리지 트리 중복 버그 수정 | 2026-08-21 |
---
+192 -28
View File
@@ -157,48 +157,212 @@ a { color: inherit; text-decoration: none; }
}
/* ============================================
SIDEBAR
TOAST / NOTIFICATIONS & UNDO BANNER
============================================ */
.sidebar {
width: var(--sidebar-width);
background: var(--bg-sidebar);
border-right: 1px solid var(--border);
.toast-container {
position: fixed;
bottom: 24px;
right: 24px;
display: flex;
flex-direction: column;
flex-shrink: 0;
overflow: hidden;
transition: width var(--dur-slow) var(--ease-out),
transform var(--dur-slow) var(--ease-out);
z-index: 20;
min-width: 0;
gap: 8px;
z-index: 200;
pointer-events: none;
}
.sidebar.collapsed {
width: 0;
}
.sidebar-header {
.toast {
background: var(--text-primary);
color: var(--bg-primary);
padding: 10px 16px;
border-radius: var(--radius-md);
font-size: 13.5px;
font-weight: 500;
box-shadow: var(--shadow-md);
display: flex;
align-items: center;
gap: 10px;
padding: 16px 16px 12px;
border-bottom: 1px solid var(--border);
min-height: var(--header-height);
animation: toastIn var(--dur-normal) var(--ease-out);
pointer-events: all;
}
.sidebar-logo {
width: 28px;
height: 28px;
background: var(--accent);
border-radius: var(--radius-sm);
.toast-btn {
color: var(--accent);
font-weight: 600;
font-size: 13px;
padding: 2px 6px;
border-radius: var(--radius-xs);
transition: background var(--dur-fast);
}
.toast-btn:hover { background: rgba(255,255,255,0.15); }
@keyframes toastIn {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
/* Notion-style Task Drag Handle */
.task-drag-handle {
width: 16px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 14px;
font-weight: 700;
letter-spacing: -0.5px;
color: var(--text-tertiary);
opacity: 0;
cursor: grab;
transition: opacity var(--dur-fast), color var(--dur-fast), transform var(--dur-fast);
flex-shrink: 0;
margin-left: -4px;
margin-right: 2px;
user-select: none;
}
.task-item:hover .task-drag-handle {
opacity: 0.7;
}
.task-drag-handle:hover {
opacity: 1 !important;
color: var(--accent);
transform: scale(1.15);
}
.task-drag-handle:active {
cursor: grabbing;
}
/* Dragging & Reorder Feedback */
.task-item.is-dragging {
opacity: 0.4;
background: var(--bg-active);
box-shadow: var(--shadow-md);
}
.task-item.drag-over-top {
border-top: 2px solid var(--accent) !important;
}
.task-item.drag-over-bottom {
border-bottom: 2px solid var(--accent) !important;
}
/* Right-aligned task actions hover container */
.task-actions-right {
display: flex;
align-items: center;
gap: 4px;
margin-left: auto;
opacity: 0;
transition: opacity var(--dur-fast);
}
.task-item:hover .task-actions-right {
opacity: 1;
}
.task-item:focus-within .task-actions-right {
opacity: 1;
}
/* Floating Interactive Undo Toast Banner */
.undo-toast-banner {
position: fixed;
bottom: 28px;
left: 50%;
transform: translateX(-50%);
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border-medium);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15), 0 2px 8px rgba(0, 0, 0, 0.08);
padding: 10px 18px;
border-radius: var(--radius-full);
display: flex;
align-items: center;
gap: 16px;
z-index: 300;
animation: undoToastIn 280ms cubic-bezier(0.175, 0.885, 0.32, 1.275);
backdrop-filter: blur(12px);
min-width: 320px;
max-width: 90vw;
justify-content: space-between;
}
[data-theme="dark"] .undo-toast-banner {
background: #252526;
border-color: #3e3e42;
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.6), 0 4px 12px rgba(0, 0, 0, 0.4);
}
.undo-toast-content {
display: flex;
align-items: center;
gap: 10px;
font-size: 13.5px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.undo-toast-icon {
font-size: 16px;
}
.undo-toast-msg strong {
font-weight: 600;
color: var(--accent);
}
.undo-toast-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.undo-toast-btn {
background: var(--accent);
color: white;
font-weight: 600;
font-size: 12.5px;
padding: 4px 12px;
border-radius: var(--radius-full);
transition: transform var(--dur-fast), filter var(--dur-fast), background var(--dur-fast);
cursor: pointer;
}
.undo-toast-btn:hover {
filter: brightness(1.1);
transform: translateY(-1px);
}
.undo-toast-btn:active {
transform: translateY(0);
}
.undo-toast-close {
color: var(--text-tertiary);
font-size: 12px;
padding: 4px 6px;
border-radius: var(--radius-full);
cursor: pointer;
transition: color var(--dur-fast), background var(--dur-fast);
}
.undo-toast-close:hover {
color: var(--text-primary);
background: var(--bg-hover);
}
@keyframes undoToastIn {
from {
opacity: 0;
transform: translate(-50%, 24px) scale(0.94);
}
to {
opacity: 1;
transform: translate(-50%, 0) scale(1);
}
}
.sidebar-title {
+108 -4
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 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);
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"
+102 -36
View File
@@ -43,6 +43,7 @@ interface TaskItemProps {
isTrashMode?: boolean;
onRestore?: (id: string) => void;
onPermanentDelete?: (id: string) => void;
onDragTask?: (draggedId: string, targetId: string, position: "before" | "after") => void;
}
// Recursive Task Tree Item (Supports 1st, 2nd, 3rd, N-level sub-tasks seamlessly)
@@ -58,6 +59,7 @@ function RecursiveTaskItem({
isTrashMode = false,
onRestore,
onPermanentDelete,
onDragTask,
}: TaskItemProps) {
const { t, lang } = useI18n();
const [expanded, setExpanded] = useState(true);
@@ -154,11 +156,21 @@ function RecursiveTaskItem({
touchStartX.current = null;
};
const [dragOverPos, setDragOverPos] = useState<"top" | "bottom" | null>(null);
return (
<div style={{ paddingLeft: depth > 0 ? 24 : 0, position: "relative" }}>
<div
className={`task-item${task.completed ? " completed" : ""}${isSelected ? " selected" : ""}`}
className={`task-item${task.completed ? " completed" : ""}${isSelected ? " selected" : ""}${dragOverPos ? ` drag-over-${dragOverPos}` : ""}`}
onClick={() => onSelect(task)}
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "F2" && !isTrashMode) {
e.preventDefault();
e.stopPropagation();
setEditingTitle(true);
}
}}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
@@ -167,6 +179,24 @@ function RecursiveTaskItem({
e.stopPropagation();
onContextMenu(e.clientX, e.clientY, task);
}}
onDragOver={(e) => {
if (isTrashMode) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
const rect = e.currentTarget.getBoundingClientRect();
const mid = rect.top + rect.height / 2;
setDragOverPos(e.clientY < mid ? "top" : "bottom");
}}
onDragLeave={() => setDragOverPos(null)}
onDrop={(e) => {
if (isTrashMode) return;
e.preventDefault();
const draggedId = e.dataTransfer.getData("text/plain");
if (draggedId && draggedId !== task.id && onDragTask) {
onDragTask(draggedId, task.id, dragOverPos === "top" ? "before" : "after");
}
setDragOverPos(null);
}}
id={`task-${task.id}`}
style={{
borderLeft: depth > 0 ? "2px solid var(--border)" : "none",
@@ -175,6 +205,30 @@ function RecursiveTaskItem({
transition: swipeOffset === 0 ? "transform 0.2s cubic-bezier(0.16, 1, 0.3, 1)" : "none",
}}
>
{/* Notion-style 6-dot Drag Handle */}
{!isTrashMode && (
<div
className="task-drag-handle"
draggable
onDragStart={(e) => {
e.stopPropagation();
e.dataTransfer.setData("text/plain", task.id);
e.dataTransfer.effectAllowed = "move";
}}
title="Drag to reorder"
onClick={(e) => e.stopPropagation()}
>
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor">
<circle cx="5" cy="3" r="1.5" />
<circle cx="11" cy="3" r="1.5" />
<circle cx="5" cy="8" r="1.5" />
<circle cx="11" cy="8" r="1.5" />
<circle cx="5" cy="13" r="1.5" />
<circle cx="11" cy="13" r="1.5" />
</svg>
</div>
)}
{/* Toggle Expand Arrow if has children */}
{totalChildren > 0 ? (
<button
@@ -246,7 +300,7 @@ function RecursiveTaskItem({
e.stopPropagation();
setEditingTitle(true);
}}
title="Double click to edit"
title="Double click or press F2 to edit"
style={{ fontSize: depth > 0 ? 13 : 14, fontWeight: depth === 0 ? 600 : 500 }}
>
{task.title}
@@ -310,23 +364,6 @@ function RecursiveTaskItem({
))}
</div>
)}
{/* Quick Inline Add Subtask button */}
{!isTrashMode && (
<button
className="badge badge-neutral"
style={{ cursor: "pointer", fontSize: 10, padding: "1px 6px", border: "1px solid var(--border)", background: "transparent" }}
onClick={(e) => {
e.stopPropagation();
setAddingSubtask(true);
setExpanded(true);
}}
title="Add subtask"
type="button"
>
+ {t("subtasks")}
</button>
)}
</div>
{task.note && !task.completed && depth === 0 && (
@@ -334,9 +371,9 @@ function RecursiveTaskItem({
)}
</div>
{/* Trash Mode Actions or Edit Icon */}
{/* Right Actions: Subtask Add Button & Trash Mode Controls */}
{isTrashMode ? (
<div style={{ display: "flex", gap: 6 }}>
<div style={{ display: "flex", gap: 6, marginLeft: "auto" }}>
<button
className="btn btn-ghost btn-sm"
style={{ fontSize: 11, padding: "2px 8px" }}
@@ -363,20 +400,27 @@ function RecursiveTaskItem({
</button>
</div>
) : (
<div className="task-actions-right">
<button
className="icon-btn"
style={{ width: 22, height: 22, opacity: 0.35, flexShrink: 0 }}
className="badge badge-neutral"
style={{
cursor: "pointer",
fontSize: 11,
padding: "2px 8px",
border: "1px solid var(--border)",
background: "var(--bg-secondary)",
}}
onClick={(e) => {
e.stopPropagation();
setEditingTitle(true);
setAddingSubtask(true);
setExpanded(true);
}}
title="Edit title"
title="Add subtask"
type="button"
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 20h9" /><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
</svg>
+ {t("subtasks")}
</button>
</div>
)}
</div>
@@ -397,6 +441,7 @@ function RecursiveTaskItem({
isTrashMode={isTrashMode}
onRestore={onRestore}
onPermanentDelete={onPermanentDelete}
onDragTask={onDragTask}
/>
))}
</div>
@@ -460,6 +505,7 @@ interface Props {
onDemoToggleTask?: (id: string, completed: boolean) => void;
onUpdateTaskTitle?: (id: string, title: string) => void;
onUpdateListName?: (id: string, name: string) => void;
onDeleteTaskWithUndo?: (task: Task) => void;
onDeleteTask?: (id: string) => void;
}
@@ -485,6 +531,7 @@ export function TaskList({
onDemoToggleTask,
onUpdateTaskTitle,
onUpdateListName,
onDeleteTaskWithUndo,
onDeleteTask,
}: Props) {
const { t } = useI18n();
@@ -640,6 +687,30 @@ export function TaskList({
setEditingHeader(false);
};
const handleReorderTasks = useCallback((draggedId: string, targetId: string, position: "before" | "after") => {
setTasks((prev) => {
const draggedIndex = prev.findIndex((t) => t.id === draggedId);
const targetIndex = prev.findIndex((t) => t.id === targetId);
if (draggedIndex === -1 || targetIndex === -1) return prev;
const newArr = [...prev];
const [draggedItem] = newArr.splice(draggedIndex, 1);
const insertAt = position === "before"
? (draggedIndex < targetIndex ? targetIndex - 1 : targetIndex)
: (draggedIndex < targetIndex ? targetIndex : targetIndex + 1);
newArr.splice(Math.max(0, insertAt), 0, draggedItem);
return newArr;
});
}, [setTasks]);
const handleDeleteTask = useCallback((task: Task) => {
if (onDeleteTaskWithUndo) {
onDeleteTaskWithUndo(task);
} else if (onDeleteTask) {
onDeleteTask(task.id);
}
}, [onDeleteTaskWithUndo, onDeleteTask]);
if (!listId && !isTrashActive && !selectedTag) {
return (
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", height: "100%", color: "var(--text-tertiary)" }}>
@@ -830,6 +901,7 @@ export function TaskList({
isTrashMode={isTrashActive}
onRestore={onRestoreTask}
onPermanentDelete={onPermanentDeleteTask}
onDragTask={handleReorderTasks}
/>
))}
@@ -862,6 +934,7 @@ export function TaskList({
isTrashMode={isTrashActive}
onRestore={onRestoreTask}
onPermanentDelete={onPermanentDeleteTask}
onDragTask={handleReorderTasks}
/>
))}
</div>
@@ -897,14 +970,7 @@ export function TaskList({
label: t("deleteTaskConfirm").split("?")[0],
icon: "🗑️",
danger: true,
onClick: async () => {
if (onDeleteTask) {
onDeleteTask(contextMenu.task.id);
} else if (!isDemo) {
await fetch(`/api/tasks/${contextMenu.task.id}`, { method: "DELETE" });
onRefresh();
}
},
onClick: () => handleDeleteTask(contextMenu.task),
},
]}
onClose={() => setContextMenu(null)}
+13 -6
View File
@@ -334,18 +334,25 @@ export function findTaskInTree(tree: MockTask[], id: string): MockTask | null {
return null;
}
// Get all active tasks or all trash tasks flattened
// Get all active tasks or all trash tasks flattened (strictly deduplicated)
export function getAllTrashTasks(tree: MockTask[]): MockTask[] {
let trash: MockTask[] = [];
for (const node of tree) {
const map = new Map<string, MockTask>();
function collect(nodes: MockTask[]) {
for (const node of nodes) {
if (node.isDeleted) {
trash.push(node);
if (!map.has(node.id)) {
map.set(node.id, { ...node, children: [] });
}
}
if (node.children && node.children.length > 0) {
trash = trash.concat(getAllTrashTasks(node.children));
collect(node.children);
}
}
return trash;
}
collect(tree);
return Array.from(map.values());
}
// Filter tasks by custom tag