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.
|
- 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.
|
- 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.
|
- 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.
|
- Validated that `npm run lint` and `npm run build` execute with 0 errors and 0 warnings.
|
||||||
|
|
||||||
- **Next Steps (Todo):**
|
- **Next Steps (Todo):**
|
||||||
|
|||||||
@@ -81,7 +81,8 @@ src/
|
|||||||
| `checkpoint-v0.5.0` | VSCode 무채색 다크 테마, 에디터 조잡한 툴바 제거 & 순수 캔버스 전환, 모듈형 블록 커스텀 (서브태스크 ↔ 노트 순서 스왑 및 스플릿 리사이저 높이 조절), CheckFlow Labs (3컬럼 칸반 보드 뷰), 기본값 복원(Reset to Defaults), 보안 패치 완료 | 2026-08-21 |
|
| `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.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.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 {
|
.toast-container {
|
||||||
width: var(--sidebar-width);
|
position: fixed;
|
||||||
background: var(--bg-sidebar);
|
bottom: 24px;
|
||||||
border-right: 1px solid var(--border);
|
right: 24px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex-shrink: 0;
|
gap: 8px;
|
||||||
overflow: hidden;
|
z-index: 200;
|
||||||
transition: width var(--dur-slow) var(--ease-out),
|
pointer-events: none;
|
||||||
transform var(--dur-slow) var(--ease-out);
|
|
||||||
z-index: 20;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar.collapsed {
|
.toast {
|
||||||
width: 0;
|
background: var(--text-primary);
|
||||||
}
|
color: var(--bg-primary);
|
||||||
|
padding: 10px 16px;
|
||||||
.sidebar-header {
|
border-radius: var(--radius-md);
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 500;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 16px 16px 12px;
|
animation: toastIn var(--dur-normal) var(--ease-out);
|
||||||
border-bottom: 1px solid var(--border);
|
pointer-events: all;
|
||||||
min-height: var(--header-height);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-logo {
|
.toast-btn {
|
||||||
width: 28px;
|
color: var(--accent);
|
||||||
height: 28px;
|
font-weight: 600;
|
||||||
background: var(--accent);
|
font-size: 13px;
|
||||||
border-radius: var(--radius-sm);
|
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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: white;
|
color: var(--text-tertiary);
|
||||||
font-size: 14px;
|
opacity: 0;
|
||||||
font-weight: 700;
|
cursor: grab;
|
||||||
letter-spacing: -0.5px;
|
transition: opacity var(--dur-fast), color var(--dur-fast), transform var(--dur-fast);
|
||||||
flex-shrink: 0;
|
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 {
|
.sidebar-title {
|
||||||
|
|||||||
@@ -299,16 +299,91 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
|
|||||||
handleTaskUpdate(updated);
|
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)
|
// Move to Trash (Soft Delete)
|
||||||
const handleDemoDeleteTask = (id: string) => {
|
const handleDemoDeleteTask = (id: string) => {
|
||||||
const store = getDemoStore();
|
const store = getDemoStore();
|
||||||
const newTasks = moveToTrashInTree(store.tasks, id);
|
const target = findTaskInTree(store.tasks, id);
|
||||||
saveDemoStore(store.lists, newTasks);
|
if (target) {
|
||||||
setTasks((prev) => prev.filter((t) => t.id !== id));
|
handleDeleteTaskWithUndo(target as Task);
|
||||||
if (selectedTask?.id === id) {
|
} else {
|
||||||
setSelectedTask(null);
|
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
|
// Restore from Trash
|
||||||
@@ -435,7 +510,7 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
|
|||||||
|
|
||||||
<div className="main-content">
|
<div className="main-content">
|
||||||
<TaskList
|
<TaskList
|
||||||
key={`${selectedListId}-${isTrashActive}-${selectedTag}-${refreshKey}`}
|
key={`${selectedListId}-${isTrashActive}-${selectedTag}`}
|
||||||
user={user}
|
user={user}
|
||||||
listId={selectedListId}
|
listId={selectedListId}
|
||||||
lists={lists}
|
lists={lists}
|
||||||
@@ -457,6 +532,7 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
|
|||||||
onDemoToggleTask={handleDemoToggleTask}
|
onDemoToggleTask={handleDemoToggleTask}
|
||||||
onUpdateTaskTitle={handleUpdateTaskTitle}
|
onUpdateTaskTitle={handleUpdateTaskTitle}
|
||||||
onUpdateListName={handleUpdateListName}
|
onUpdateListName={handleUpdateListName}
|
||||||
|
onDeleteTaskWithUndo={handleDeleteTaskWithUndo}
|
||||||
onDeleteTask={isDemo ? handleDemoDeleteTask : async (id: string) => {
|
onDeleteTask={isDemo ? handleDemoDeleteTask : async (id: string) => {
|
||||||
await fetch(`/api/tasks/${id}`, { method: "DELETE" });
|
await fetch(`/api/tasks/${id}`, { method: "DELETE" });
|
||||||
refresh();
|
refresh();
|
||||||
@@ -524,6 +600,34 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
|
|||||||
onSelectTask={handleTaskSelect}
|
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 */}
|
{/* Mobile FAB */}
|
||||||
<button
|
<button
|
||||||
className="fab"
|
className="fab"
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ interface TaskItemProps {
|
|||||||
isTrashMode?: boolean;
|
isTrashMode?: boolean;
|
||||||
onRestore?: (id: string) => void;
|
onRestore?: (id: string) => void;
|
||||||
onPermanentDelete?: (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)
|
// Recursive Task Tree Item (Supports 1st, 2nd, 3rd, N-level sub-tasks seamlessly)
|
||||||
@@ -58,6 +59,7 @@ function RecursiveTaskItem({
|
|||||||
isTrashMode = false,
|
isTrashMode = false,
|
||||||
onRestore,
|
onRestore,
|
||||||
onPermanentDelete,
|
onPermanentDelete,
|
||||||
|
onDragTask,
|
||||||
}: TaskItemProps) {
|
}: TaskItemProps) {
|
||||||
const { t, lang } = useI18n();
|
const { t, lang } = useI18n();
|
||||||
const [expanded, setExpanded] = useState(true);
|
const [expanded, setExpanded] = useState(true);
|
||||||
@@ -154,11 +156,21 @@ function RecursiveTaskItem({
|
|||||||
touchStartX.current = null;
|
touchStartX.current = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const [dragOverPos, setDragOverPos] = useState<"top" | "bottom" | null>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ paddingLeft: depth > 0 ? 24 : 0, position: "relative" }}>
|
<div style={{ paddingLeft: depth > 0 ? 24 : 0, position: "relative" }}>
|
||||||
<div
|
<div
|
||||||
className={`task-item${task.completed ? " completed" : ""}${isSelected ? " selected" : ""}`}
|
className={`task-item${task.completed ? " completed" : ""}${isSelected ? " selected" : ""}${dragOverPos ? ` drag-over-${dragOverPos}` : ""}`}
|
||||||
onClick={() => onSelect(task)}
|
onClick={() => onSelect(task)}
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "F2" && !isTrashMode) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setEditingTitle(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
onTouchStart={handleTouchStart}
|
onTouchStart={handleTouchStart}
|
||||||
onTouchMove={handleTouchMove}
|
onTouchMove={handleTouchMove}
|
||||||
onTouchEnd={handleTouchEnd}
|
onTouchEnd={handleTouchEnd}
|
||||||
@@ -167,6 +179,24 @@ function RecursiveTaskItem({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onContextMenu(e.clientX, e.clientY, task);
|
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}`}
|
id={`task-${task.id}`}
|
||||||
style={{
|
style={{
|
||||||
borderLeft: depth > 0 ? "2px solid var(--border)" : "none",
|
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",
|
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 */}
|
{/* Toggle Expand Arrow if has children */}
|
||||||
{totalChildren > 0 ? (
|
{totalChildren > 0 ? (
|
||||||
<button
|
<button
|
||||||
@@ -246,7 +300,7 @@ function RecursiveTaskItem({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setEditingTitle(true);
|
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 }}
|
style={{ fontSize: depth > 0 ? 13 : 14, fontWeight: depth === 0 ? 600 : 500 }}
|
||||||
>
|
>
|
||||||
{task.title}
|
{task.title}
|
||||||
@@ -310,23 +364,6 @@ function RecursiveTaskItem({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{task.note && !task.completed && depth === 0 && (
|
{task.note && !task.completed && depth === 0 && (
|
||||||
@@ -334,9 +371,9 @@ function RecursiveTaskItem({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Trash Mode Actions or Edit Icon */}
|
{/* Right Actions: Subtask Add Button & Trash Mode Controls */}
|
||||||
{isTrashMode ? (
|
{isTrashMode ? (
|
||||||
<div style={{ display: "flex", gap: 6 }}>
|
<div style={{ display: "flex", gap: 6, marginLeft: "auto" }}>
|
||||||
<button
|
<button
|
||||||
className="btn btn-ghost btn-sm"
|
className="btn btn-ghost btn-sm"
|
||||||
style={{ fontSize: 11, padding: "2px 8px" }}
|
style={{ fontSize: 11, padding: "2px 8px" }}
|
||||||
@@ -363,20 +400,27 @@ function RecursiveTaskItem({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<div className="task-actions-right">
|
||||||
className="icon-btn"
|
<button
|
||||||
style={{ width: 22, height: 22, opacity: 0.35, flexShrink: 0 }}
|
className="badge badge-neutral"
|
||||||
onClick={(e) => {
|
style={{
|
||||||
e.stopPropagation();
|
cursor: "pointer",
|
||||||
setEditingTitle(true);
|
fontSize: 11,
|
||||||
}}
|
padding: "2px 8px",
|
||||||
title="Edit title"
|
border: "1px solid var(--border)",
|
||||||
type="button"
|
background: "var(--bg-secondary)",
|
||||||
>
|
}}
|
||||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
onClick={(e) => {
|
||||||
<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" />
|
e.stopPropagation();
|
||||||
</svg>
|
setAddingSubtask(true);
|
||||||
</button>
|
setExpanded(true);
|
||||||
|
}}
|
||||||
|
title="Add subtask"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
+ {t("subtasks")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -397,6 +441,7 @@ function RecursiveTaskItem({
|
|||||||
isTrashMode={isTrashMode}
|
isTrashMode={isTrashMode}
|
||||||
onRestore={onRestore}
|
onRestore={onRestore}
|
||||||
onPermanentDelete={onPermanentDelete}
|
onPermanentDelete={onPermanentDelete}
|
||||||
|
onDragTask={onDragTask}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -460,6 +505,7 @@ interface Props {
|
|||||||
onDemoToggleTask?: (id: string, completed: boolean) => void;
|
onDemoToggleTask?: (id: string, completed: boolean) => void;
|
||||||
onUpdateTaskTitle?: (id: string, title: string) => void;
|
onUpdateTaskTitle?: (id: string, title: string) => void;
|
||||||
onUpdateListName?: (id: string, name: string) => void;
|
onUpdateListName?: (id: string, name: string) => void;
|
||||||
|
onDeleteTaskWithUndo?: (task: Task) => void;
|
||||||
onDeleteTask?: (id: string) => void;
|
onDeleteTask?: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -485,6 +531,7 @@ export function TaskList({
|
|||||||
onDemoToggleTask,
|
onDemoToggleTask,
|
||||||
onUpdateTaskTitle,
|
onUpdateTaskTitle,
|
||||||
onUpdateListName,
|
onUpdateListName,
|
||||||
|
onDeleteTaskWithUndo,
|
||||||
onDeleteTask,
|
onDeleteTask,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -640,6 +687,30 @@ export function TaskList({
|
|||||||
setEditingHeader(false);
|
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) {
|
if (!listId && !isTrashActive && !selectedTag) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", height: "100%", color: "var(--text-tertiary)" }}>
|
<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}
|
isTrashMode={isTrashActive}
|
||||||
onRestore={onRestoreTask}
|
onRestore={onRestoreTask}
|
||||||
onPermanentDelete={onPermanentDeleteTask}
|
onPermanentDelete={onPermanentDeleteTask}
|
||||||
|
onDragTask={handleReorderTasks}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@@ -862,6 +934,7 @@ export function TaskList({
|
|||||||
isTrashMode={isTrashActive}
|
isTrashMode={isTrashActive}
|
||||||
onRestore={onRestoreTask}
|
onRestore={onRestoreTask}
|
||||||
onPermanentDelete={onPermanentDeleteTask}
|
onPermanentDelete={onPermanentDeleteTask}
|
||||||
|
onDragTask={handleReorderTasks}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -897,14 +970,7 @@ export function TaskList({
|
|||||||
label: t("deleteTaskConfirm").split("?")[0],
|
label: t("deleteTaskConfirm").split("?")[0],
|
||||||
icon: "🗑️",
|
icon: "🗑️",
|
||||||
danger: true,
|
danger: true,
|
||||||
onClick: async () => {
|
onClick: () => handleDeleteTask(contextMenu.task),
|
||||||
if (onDeleteTask) {
|
|
||||||
onDeleteTask(contextMenu.task.id);
|
|
||||||
} else if (!isDemo) {
|
|
||||||
await fetch(`/api/tasks/${contextMenu.task.id}`, { method: "DELETE" });
|
|
||||||
onRefresh();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
onClose={() => setContextMenu(null)}
|
onClose={() => setContextMenu(null)}
|
||||||
|
|||||||
+16
-9
@@ -334,18 +334,25 @@ export function findTaskInTree(tree: MockTask[], id: string): MockTask | null {
|
|||||||
return 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[] {
|
export function getAllTrashTasks(tree: MockTask[]): MockTask[] {
|
||||||
let trash: MockTask[] = [];
|
const map = new Map<string, MockTask>();
|
||||||
for (const node of tree) {
|
|
||||||
if (node.isDeleted) {
|
function collect(nodes: MockTask[]) {
|
||||||
trash.push(node);
|
for (const node of nodes) {
|
||||||
}
|
if (node.isDeleted) {
|
||||||
if (node.children && node.children.length > 0) {
|
if (!map.has(node.id)) {
|
||||||
trash = trash.concat(getAllTrashTasks(node.children));
|
map.set(node.id, { ...node, children: [] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (node.children && node.children.length > 0) {
|
||||||
|
collect(node.children);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return trash;
|
|
||||||
|
collect(tree);
|
||||||
|
return Array.from(map.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter tasks by custom tag
|
// Filter tasks by custom tag
|
||||||
|
|||||||
Reference in New Issue
Block a user