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