1387 lines
50 KiB
TypeScript
1387 lines
50 KiB
TypeScript
"use client";
|
|
import React, { useState, useEffect, useRef, useCallback } from "react";
|
|
import { useI18n } from "@/lib/i18n";
|
|
import { ContextMenu } from "@/components/ui/ContextMenu";
|
|
import { KanbanView } from "./KanbanView";
|
|
import { useUserPrefs } from "@/lib/useUserPrefs";
|
|
import { getDemoStore, saveDemoStore, MockTask } from "@/lib/mockData";
|
|
|
|
export interface Tag { id: string; name: string; color: string }
|
|
|
|
export interface Task {
|
|
id: string;
|
|
listId: string;
|
|
parentId: string | null;
|
|
title: string;
|
|
note: string | null;
|
|
completed: boolean;
|
|
completedAt: string | null;
|
|
dueDate: string | null;
|
|
priority: number;
|
|
sortOrder: number;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
deletedAt?: string | null;
|
|
isDeleted?: boolean;
|
|
children: Task[];
|
|
tags?: { tag: Tag }[];
|
|
}
|
|
|
|
export interface List { id: string; name: string; color: string; icon: string }
|
|
export interface User { id: string; name?: string | null; email?: string | null }
|
|
|
|
const PRIORITY_COLORS = ["transparent", "var(--priority-low)", "var(--priority-medium)", "var(--priority-high)"];
|
|
|
|
interface TaskItemProps {
|
|
task: Task;
|
|
depth?: number;
|
|
isSelected: boolean;
|
|
onSelect: (t: Task) => void;
|
|
onToggle: (id: string, completed: boolean) => void;
|
|
onUpdateTitle: (id: string, title: string) => void;
|
|
onAddSubtask: (title: string, parentId: string) => void;
|
|
onContextMenu: (x: number, y: number, task: Task) => void;
|
|
isTrashMode?: boolean;
|
|
onRestore?: (id: string) => void;
|
|
onPermanentDelete?: (id: string) => void;
|
|
onDragTask?: (draggedId: string, targetId: string, position: "before" | "after" | "inside") => void;
|
|
}
|
|
|
|
// Recursive Task Tree Item (Supports 1st, 2nd, 3rd, N-level sub-tasks seamlessly)
|
|
function RecursiveTaskItem({
|
|
task,
|
|
depth = 0,
|
|
isSelected,
|
|
onSelect,
|
|
onToggle,
|
|
onUpdateTitle,
|
|
onAddSubtask,
|
|
onContextMenu,
|
|
isTrashMode = false,
|
|
onRestore,
|
|
onPermanentDelete,
|
|
onDragTask,
|
|
}: TaskItemProps) {
|
|
const { t, lang } = useI18n();
|
|
const [expanded, setExpanded] = useState(true);
|
|
const [editingTitle, setEditingTitle] = useState(false);
|
|
const [tempTitle, setTempTitle] = useState(task.title);
|
|
|
|
const [addingSubtask, setAddingSubtask] = useState(false);
|
|
const [subtaskInput, setSubtaskInput] = useState("");
|
|
|
|
// Touch swipe support
|
|
const touchStartX = useRef<number | null>(null);
|
|
const [swipeOffset, setSwipeOffset] = useState(0);
|
|
|
|
const editInputRef = useRef<HTMLInputElement>(null);
|
|
const subInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const completedChildren = task.children?.filter((c) => c.completed).length || 0;
|
|
const totalChildren = task.children?.length || 0;
|
|
|
|
useEffect(() => {
|
|
setTempTitle(task.title);
|
|
}, [task.title]);
|
|
|
|
useEffect(() => {
|
|
if (editingTitle) {
|
|
editInputRef.current?.focus();
|
|
editInputRef.current?.select();
|
|
}
|
|
}, [editingTitle]);
|
|
|
|
useEffect(() => {
|
|
if (addingSubtask) {
|
|
subInputRef.current?.focus();
|
|
}
|
|
}, [addingSubtask]);
|
|
|
|
// 마감일 상태 반환: label + 색상
|
|
const getDueDateInfo = (d: string | null): { label: string; color: string } | null => {
|
|
if (!d) return null;
|
|
const date = new Date(d);
|
|
const now = new Date();
|
|
const isToday = date.toDateString() === now.toDateString();
|
|
const isTomorrow = date.toDateString() === new Date(now.getTime() + 86400000).toDateString();
|
|
const isPast = date < now && !isToday;
|
|
if (isToday) return { label: t("today"), color: "#EF4444" };
|
|
if (isTomorrow) return { label: t("tomorrow"), color: "#F97316" };
|
|
if (isPast) return { label: date.toLocaleDateString(lang === "ko" ? "ko-KR" : lang === "ja" ? "ja-JP" : "en-US", { month: "short", day: "numeric" }), color: "#DC2626" };
|
|
return { label: date.toLocaleDateString(lang === "ko" ? "ko-KR" : lang === "ja" ? "ja-JP" : "en-US", { month: "short", day: "numeric" }), color: "var(--text-tertiary)" };
|
|
};
|
|
|
|
const priorityLabels = [t("priorityNone"), t("priorityLow"), t("priorityMedium"), t("priorityHigh")];
|
|
|
|
const handleSaveTitle = () => {
|
|
const trimmed = tempTitle.trim();
|
|
if (trimmed && trimmed !== task.title) {
|
|
onUpdateTitle(task.id, trimmed);
|
|
} else {
|
|
setTempTitle(task.title);
|
|
}
|
|
setEditingTitle(false);
|
|
};
|
|
|
|
const handleCreateSubtask = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
const trimmed = subtaskInput.trim();
|
|
if (trimmed) {
|
|
onAddSubtask(trimmed, task.id);
|
|
setSubtaskInput("");
|
|
setAddingSubtask(false);
|
|
setExpanded(true);
|
|
}
|
|
};
|
|
|
|
// Touch Swipe handlers
|
|
const handleTouchStart = (e: React.TouchEvent) => {
|
|
if (isTrashMode) return;
|
|
touchStartX.current = e.touches[0].clientX;
|
|
};
|
|
|
|
const handleTouchMove = (e: React.TouchEvent) => {
|
|
if (touchStartX.current === null) return;
|
|
const deltaX = e.touches[0].clientX - touchStartX.current;
|
|
if (Math.abs(deltaX) < 120) {
|
|
setSwipeOffset(deltaX);
|
|
}
|
|
};
|
|
|
|
const handleTouchEnd = () => {
|
|
if (swipeOffset > 70) {
|
|
// Swiped Right -> Toggle Complete
|
|
onToggle(task.id, !task.completed);
|
|
}
|
|
setSwipeOffset(0);
|
|
touchStartX.current = null;
|
|
};
|
|
|
|
const [dragOverPos, setDragOverPos] = useState<"top" | "bottom" | "inside" | null>(null);
|
|
|
|
return (
|
|
<div style={{ paddingLeft: depth > 0 ? 24 : 0, position: "relative" }}>
|
|
<div
|
|
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}
|
|
onContextMenu={(e) => {
|
|
e.preventDefault();
|
|
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 relY = e.clientY - rect.top;
|
|
const h = rect.height;
|
|
if (relY < h * 0.25) {
|
|
setDragOverPos("top");
|
|
} else if (relY > h * 0.75) {
|
|
setDragOverPos("bottom");
|
|
} else {
|
|
setDragOverPos("inside");
|
|
}
|
|
}}
|
|
onDragLeave={() => setDragOverPos(null)}
|
|
onDrop={(e) => {
|
|
if (isTrashMode) return;
|
|
e.preventDefault();
|
|
const draggedId = e.dataTransfer.getData("text/plain");
|
|
if (draggedId && draggedId !== task.id && onDragTask) {
|
|
const pos: "before" | "after" | "inside" =
|
|
dragOverPos === "top" ? "before" : dragOverPos === "bottom" ? "after" : "inside";
|
|
onDragTask(draggedId, task.id, pos);
|
|
}
|
|
setDragOverPos(null);
|
|
}}
|
|
id={`task-${task.id}`}
|
|
style={{
|
|
borderLeft: depth > 0 ? "2px solid var(--border)" : "none",
|
|
marginLeft: depth > 0 ? 8 : 0,
|
|
transform: `translateX(${swipeOffset}px)`,
|
|
transition: swipeOffset === 0 ? "transform 0.2s cubic-bezier(0.16, 1, 0.3, 1)" : "none",
|
|
outline: dragOverPos === "inside" ? "2px dashed var(--accent)" : undefined,
|
|
outlineOffset: -2,
|
|
background: dragOverPos === "inside" ? "var(--accent-light, rgba(75, 123, 245, 0.12))" : undefined,
|
|
}}
|
|
>
|
|
{/* Notion-style 6-dot Drag Handle */}
|
|
{!isTrashMode && (
|
|
<div
|
|
className="task-drag-handle"
|
|
draggable="true"
|
|
onDragStart={(e) => {
|
|
e.stopPropagation();
|
|
e.dataTransfer.setData("text/plain", task.id);
|
|
e.dataTransfer.setData("application/task-id", 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
|
|
type="button"
|
|
className="icon-btn"
|
|
style={{ width: 18, height: 18, flexShrink: 0, padding: 0, opacity: 0.6 }}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setExpanded((p) => !p);
|
|
}}
|
|
title="Expand/Collapse"
|
|
>
|
|
<svg
|
|
width="10"
|
|
height="10"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2.5"
|
|
style={{ transform: expanded ? "rotate(90deg)" : "rotate(0deg)", transition: "transform 0.15s" }}
|
|
>
|
|
<polyline points="9 18 15 12 9 6" />
|
|
</svg>
|
|
</button>
|
|
) : depth > 0 ? (
|
|
<span style={{ width: 12, flexShrink: 0 }} />
|
|
) : null}
|
|
|
|
{/* Check button (hidden in trash mode) */}
|
|
{!isTrashMode ? (
|
|
<button
|
|
className={`task-check-btn${task.completed ? " checked" : ""}`}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onToggle(task.id, !task.completed);
|
|
}}
|
|
aria-label={task.completed ? "Mark incomplete" : "Mark complete"}
|
|
type="button"
|
|
style={{ width: depth > 0 ? 17 : 19, height: depth > 0 ? 17 : 19, flexShrink: 0 }}
|
|
/>
|
|
) : (
|
|
<span style={{ fontSize: 13, opacity: 0.5, flexShrink: 0 }}>🗑️</span>
|
|
)}
|
|
|
|
{/* Title, Meta and Tags */}
|
|
<div className="task-body">
|
|
{editingTitle && !isTrashMode ? (
|
|
<input
|
|
ref={editInputRef}
|
|
className="form-input"
|
|
value={tempTitle}
|
|
onChange={(e) => setTempTitle(e.target.value)}
|
|
onBlur={handleSaveTitle}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") handleSaveTitle();
|
|
if (e.key === "Escape") {
|
|
setTempTitle(task.title);
|
|
setEditingTitle(false);
|
|
}
|
|
}}
|
|
onClick={(e) => e.stopPropagation()}
|
|
style={{ padding: "2px 6px", fontSize: depth > 0 ? 13 : 14, fontWeight: 500, height: 26, width: "100%" }}
|
|
/>
|
|
) : (
|
|
<div
|
|
className="task-title"
|
|
onDoubleClick={(e) => {
|
|
if (isTrashMode) return;
|
|
e.stopPropagation();
|
|
setEditingTitle(true);
|
|
}}
|
|
title="Double click or press F2 to edit"
|
|
style={{ fontSize: depth > 0 ? 13 : 14, fontWeight: depth === 0 ? 600 : 500 }}
|
|
>
|
|
{task.title}
|
|
</div>
|
|
)}
|
|
|
|
<div className="task-meta" style={{ flexWrap: "wrap", gap: 6 }}>
|
|
{task.priority > 0 && !isTrashMode && (
|
|
<div
|
|
className="task-priority-dot"
|
|
style={{ background: PRIORITY_COLORS[task.priority] }}
|
|
title={priorityLabels[task.priority]}
|
|
/>
|
|
)}
|
|
{task.dueDate && !isTrashMode && (() => {
|
|
const dueDateInfo = getDueDateInfo(task.dueDate);
|
|
if (!dueDateInfo) return null;
|
|
return (
|
|
<span
|
|
className="task-due"
|
|
style={{ color: task.completed ? undefined : dueDateInfo.color, fontWeight: (dueDateInfo.color !== "var(--text-tertiary)" && !task.completed) ? 600 : undefined }}
|
|
>
|
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<rect x="3" y="4" width="18" height="18" rx="2" /><line x1="16" y1="2" x2="16" y2="6" /><line x1="8" y1="2" x2="8" y2="6" /><line x1="3" y1="10" x2="21" y2="10" />
|
|
</svg>
|
|
{dueDateInfo.label}
|
|
</span>
|
|
);
|
|
})()}
|
|
{totalChildren > 0 && !isTrashMode && (
|
|
<span
|
|
className="task-sub-count"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setExpanded((p) => !p);
|
|
}}
|
|
style={{ cursor: "pointer" }}
|
|
>
|
|
{completedChildren}/{totalChildren}
|
|
</span>
|
|
)}
|
|
|
|
{/* Tags Badges */}
|
|
{task.tags && task.tags.length > 0 && (
|
|
<div style={{ display: "flex", gap: 4 }}>
|
|
{task.tags.map((tg) => (
|
|
<span
|
|
key={tg.tag.id}
|
|
className="badge"
|
|
style={{
|
|
background: tg.tag.color + "22",
|
|
color: tg.tag.color,
|
|
fontSize: 10,
|
|
padding: "1px 6px",
|
|
borderRadius: 4,
|
|
fontWeight: 600,
|
|
}}
|
|
>
|
|
#{tg.tag.name}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{task.note && !task.completed && depth === 0 && (
|
|
<div className="task-note-preview">{task.note.replace(/[#*`]/g, "").slice(0, 80)}</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Right Actions: Subtask Add Button & Trash Mode Controls */}
|
|
{isTrashMode ? (
|
|
<div style={{ display: "flex", gap: 6, marginLeft: "auto" }}>
|
|
<button
|
|
className="btn btn-ghost btn-sm"
|
|
style={{ fontSize: 11, padding: "2px 8px" }}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
if (onRestore) onRestore(task.id);
|
|
}}
|
|
title="Restore task"
|
|
type="button"
|
|
>
|
|
↩️ Restore
|
|
</button>
|
|
<button
|
|
className="btn btn-ghost btn-sm"
|
|
style={{ fontSize: 11, padding: "2px 8px", color: "var(--danger)" }}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
if (onPermanentDelete) onPermanentDelete(task.id);
|
|
}}
|
|
title="Delete permanently"
|
|
type="button"
|
|
>
|
|
❌
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="task-actions-right">
|
|
<button
|
|
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();
|
|
setAddingSubtask(true);
|
|
setExpanded(true);
|
|
}}
|
|
title="Add subtask"
|
|
type="button"
|
|
>
|
|
+ {t("subtasks")}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Recursive Children (N-Depth Sub-tasks) */}
|
|
{totalChildren > 0 && expanded && (
|
|
<div className="subtask-recursive-tree">
|
|
{task.children.map((child) => (
|
|
<RecursiveTaskItem
|
|
key={child.id}
|
|
task={child}
|
|
depth={depth + 1}
|
|
isSelected={isSelected}
|
|
onSelect={onSelect}
|
|
onToggle={onToggle}
|
|
onUpdateTitle={onUpdateTitle}
|
|
onAddSubtask={onAddSubtask}
|
|
onContextMenu={onContextMenu}
|
|
isTrashMode={isTrashMode}
|
|
onRestore={onRestore}
|
|
onPermanentDelete={onPermanentDelete}
|
|
onDragTask={onDragTask}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Inline Add Subtask form for this node */}
|
|
{addingSubtask && !isTrashMode && (
|
|
<form
|
|
onSubmit={handleCreateSubtask}
|
|
style={{ display: "flex", gap: 6, padding: "4px 0 4px 32px" }}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<span style={{ color: "var(--accent)", fontSize: 13, lineHeight: "26px" }}>↳</span>
|
|
<input
|
|
ref={subInputRef}
|
|
className="form-input"
|
|
placeholder={t("addSubtaskPlaceholder")}
|
|
value={subtaskInput}
|
|
onChange={(e) => setSubtaskInput(e.target.value)}
|
|
onBlur={() => {
|
|
if (!subtaskInput.trim()) setAddingSubtask(false);
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Escape") setAddingSubtask(false);
|
|
}}
|
|
style={{ height: 26, fontSize: 12.5, padding: "2px 8px", flex: 1 }}
|
|
/>
|
|
<button className="btn btn-primary btn-sm" type="submit" style={{ padding: "2px 8px", fontSize: 11 }}>
|
|
{t("add")}
|
|
</button>
|
|
</form>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface Props {
|
|
user: User;
|
|
listId: string | null;
|
|
lists: List[];
|
|
tasks: Task[];
|
|
setTasks: React.Dispatch<React.SetStateAction<Task[]>>;
|
|
selectedTaskId: string | null;
|
|
onTaskSelect: (t: Task | null) => void;
|
|
showCompleted: boolean;
|
|
onToggleCompleted: () => void;
|
|
onMenuOpen: () => void;
|
|
onRefresh: () => void;
|
|
isDemo?: boolean;
|
|
isTrashActive?: boolean;
|
|
selectedTag?: string | null;
|
|
onEmptyTrash?: () => void;
|
|
onRestoreTask?: (id: string) => void;
|
|
onPermanentDeleteTask?: (id: string) => void;
|
|
onDemoAddTask?: (
|
|
title: string,
|
|
listId: string,
|
|
parentId?: string | null,
|
|
meta?: { dueDate?: string | null; priority?: number; tags?: { tag: Tag }[] }
|
|
) => void;
|
|
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;
|
|
}
|
|
|
|
export function TaskList({
|
|
user: _user,
|
|
listId,
|
|
lists,
|
|
tasks,
|
|
setTasks,
|
|
selectedTaskId,
|
|
onTaskSelect,
|
|
showCompleted,
|
|
onToggleCompleted,
|
|
onMenuOpen,
|
|
onRefresh,
|
|
isDemo = false,
|
|
isTrashActive = false,
|
|
selectedTag = null,
|
|
onEmptyTrash,
|
|
onRestoreTask,
|
|
onPermanentDeleteTask,
|
|
onDemoAddTask,
|
|
onDemoToggleTask,
|
|
onUpdateTaskTitle,
|
|
onUpdateListName,
|
|
onDeleteTaskWithUndo,
|
|
onDeleteTask,
|
|
}: Props) {
|
|
const { t } = useI18n();
|
|
const [newTaskTitle, setNewTaskTitle] = useState("");
|
|
const [loading, setLoading] = useState(false);
|
|
const [editingHeader, setEditingHeader] = useState(false);
|
|
const [headerTitle, setHeaderTitle] = useState("");
|
|
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; task: Task } | null>(null);
|
|
|
|
const { prefs, updatePrefs } = useUserPrefs();
|
|
// Only show kanban if the Labs flag is explicitly enabled
|
|
const kanbanEnabled = Boolean(prefs.labs?.kanbanBoard);
|
|
const viewMode = kanbanEnabled ? (prefs.viewMode || "list") : "list";
|
|
|
|
const handleToggleViewMode = () => {
|
|
const nextMode = viewMode === "kanban" ? "list" : "kanban";
|
|
updatePrefs({ viewMode: nextMode });
|
|
};
|
|
|
|
// TickTick-style Quick Add Preset states
|
|
const [quickDueDate, setQuickDueDate] = useState<string | null>(null);
|
|
const [quickPriority, setQuickPriority] = useState<number>(0);
|
|
const [showQuickDue, setShowQuickDue] = useState(false);
|
|
const [showQuickPriority, setShowQuickPriority] = useState(false);
|
|
|
|
const priorityLabels = [t("priorityNone"), t("priorityLow"), t("priorityMedium"), t("priorityHigh")];
|
|
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const headerInputRef = useRef<HTMLInputElement>(null);
|
|
const currentList = lists.find((l) => l.id === listId);
|
|
|
|
useEffect(() => {
|
|
if (currentList) setHeaderTitle(currentList.name);
|
|
}, [currentList]);
|
|
|
|
useEffect(() => {
|
|
if (editingHeader) {
|
|
headerInputRef.current?.focus();
|
|
headerInputRef.current?.select();
|
|
}
|
|
}, [editingHeader]);
|
|
|
|
const fetchTasks = useCallback(async () => {
|
|
if (!listId || isDemo || isTrashActive || selectedTag) return;
|
|
setLoading(true);
|
|
try {
|
|
const res = await fetch(`/api/tasks?listId=${listId}&showCompleted=${showCompleted}`);
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
const data = await res.json();
|
|
setTasks(Array.isArray(data) ? data : []);
|
|
} catch (err) {
|
|
console.error("[TaskList] fetchTasks failed", err);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [listId, showCompleted, isDemo, isTrashActive, selectedTag, setTasks]);
|
|
|
|
useEffect(() => {
|
|
fetchTasks();
|
|
}, [fetchTasks]);
|
|
|
|
useEffect(() => {
|
|
const handler = () => inputRef.current?.focus();
|
|
document.addEventListener("checkflow:addTask", handler);
|
|
return () => document.removeEventListener("checkflow:addTask", handler);
|
|
}, []);
|
|
|
|
// Recursive update helper for local tree optimistic state
|
|
const updateTaskCompletedInTree = (tree: Task[], targetId: string, isComp: boolean): Task[] => {
|
|
return tree.map((node) => {
|
|
if (node.id === targetId) {
|
|
return {
|
|
...node,
|
|
completed: isComp,
|
|
completedAt: isComp ? new Date().toISOString() : null,
|
|
children: node.children ? updateTaskCompletedInTree(node.children, targetId, isComp) : [],
|
|
};
|
|
}
|
|
if (node.children && node.children.length > 0) {
|
|
return {
|
|
...node,
|
|
children: updateTaskCompletedInTree(node.children, targetId, isComp),
|
|
};
|
|
}
|
|
return node;
|
|
});
|
|
};
|
|
|
|
const handleToggle = useCallback(
|
|
async (id: string, completed: boolean) => {
|
|
// 1. Optimistic UI update immediately
|
|
setTasks((prev) => {
|
|
const updated = updateTaskCompletedInTree(prev, id, completed);
|
|
return showCompleted ? updated : updated.filter((t) => t.id !== id || !completed);
|
|
});
|
|
|
|
if (isDemo) {
|
|
if (onDemoToggleTask) onDemoToggleTask(id, completed);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const res = await fetch(`/api/tasks/${id}`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ completed }),
|
|
});
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
onRefresh();
|
|
} catch (err) {
|
|
console.error("[TaskList] handleToggle failed", err);
|
|
// Rollback on failure
|
|
fetchTasks();
|
|
}
|
|
},
|
|
[isDemo, onDemoToggleTask, onRefresh, showCompleted, setTasks, fetchTasks]
|
|
);
|
|
|
|
const handleAddTask = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
const title = newTaskTitle.trim();
|
|
if (!title || !listId) return;
|
|
|
|
const meta = {
|
|
dueDate: quickDueDate,
|
|
priority: quickPriority,
|
|
};
|
|
|
|
if (isDemo) {
|
|
if (onDemoAddTask) onDemoAddTask(title, listId, null, meta);
|
|
setNewTaskTitle("");
|
|
setQuickDueDate(null);
|
|
setQuickPriority(0);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const res = await fetch("/api/tasks", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ title, listId, ...meta }),
|
|
});
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
const task = await res.json();
|
|
setTasks((prev) => [...prev, task]);
|
|
setNewTaskTitle("");
|
|
setQuickDueDate(null);
|
|
setQuickPriority(0);
|
|
onRefresh();
|
|
} catch (err) {
|
|
console.error("[TaskList] handleAddTask failed", err);
|
|
}
|
|
};
|
|
|
|
const insertSubtaskInTree = (tree: Task[], pId: string, subtask: Task): Task[] => {
|
|
return tree.map((node) => {
|
|
if (node.id === pId) {
|
|
const currentChildren = node.children || [];
|
|
return { ...node, children: [...currentChildren, subtask] };
|
|
}
|
|
if (node.children && node.children.length > 0) {
|
|
return { ...node, children: insertSubtaskInTree(node.children, pId, subtask) };
|
|
}
|
|
return node;
|
|
});
|
|
};
|
|
|
|
const handleAddSubtaskInline = async (title: string, parentId: string) => {
|
|
if (!listId) return;
|
|
|
|
const tempId = "temp-subtask-" + Date.now();
|
|
const tempSubtask: Task = {
|
|
id: tempId,
|
|
listId,
|
|
parentId,
|
|
title: title.trim(),
|
|
note: null,
|
|
completed: false,
|
|
completedAt: null,
|
|
dueDate: null,
|
|
priority: 0,
|
|
sortOrder: 999,
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
children: [],
|
|
tags: [],
|
|
};
|
|
|
|
// Optimistic insert
|
|
setTasks((prev) => insertSubtaskInTree(prev, parentId, tempSubtask));
|
|
|
|
if (isDemo) {
|
|
if (onDemoAddTask) onDemoAddTask(title, listId, parentId);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const res = await fetch("/api/tasks", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ title, listId, parentId }),
|
|
});
|
|
if (res.ok) {
|
|
const createdTask = await res.json();
|
|
// Replace temp subtask with actual created task
|
|
setTasks((prev) => {
|
|
const replaceTemp = (nodes: Task[]): Task[] => {
|
|
return nodes.map((n) => {
|
|
if (n.id === tempId) return createdTask;
|
|
if (n.children && n.children.length > 0) {
|
|
return { ...n, children: replaceTemp(n.children) };
|
|
}
|
|
return n;
|
|
});
|
|
};
|
|
return replaceTemp(prev);
|
|
});
|
|
onRefresh();
|
|
} else {
|
|
fetchTasks();
|
|
}
|
|
} catch (err) {
|
|
console.error("Failed to add subtask inline", err);
|
|
fetchTasks();
|
|
}
|
|
};
|
|
|
|
const handleSaveHeaderTitle = () => {
|
|
const trimmed = headerTitle.trim();
|
|
if (trimmed && listId && onUpdateListName && trimmed !== currentList?.name) {
|
|
onUpdateListName(listId, trimmed);
|
|
} else if (currentList) {
|
|
setHeaderTitle(currentList.name);
|
|
}
|
|
setEditingHeader(false);
|
|
};
|
|
|
|
// Helper: check if targetId is inside the subtree of ancestorId (prevents circular nesting / node disappearing)
|
|
const isDescendantNode = (nodes: Task[], ancestorId: string, targetId: string): boolean => {
|
|
for (const node of nodes) {
|
|
if (node.id === ancestorId) {
|
|
const checkChildren = (children: Task[]): boolean => {
|
|
for (const c of children) {
|
|
if (c.id === targetId) return true;
|
|
if (c.children && c.children.length > 0 && checkChildren(c.children)) return true;
|
|
}
|
|
return false;
|
|
};
|
|
return checkChildren(node.children || []);
|
|
}
|
|
if (node.children && node.children.length > 0) {
|
|
if (isDescendantNode(node.children, ancestorId, targetId)) return true;
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
|
|
// Robust Tree-aware reordering function (1st, 2nd, 3rd depth task reordering, promoting to top-level, and demoting into subtask)
|
|
const handleReorderTasks = useCallback((draggedId: string, targetId: string, position: "before" | "after" | "inside") => {
|
|
// Prevent dropping onto itself or into its own subtree (which causes loops/disappearing tasks)
|
|
if (draggedId === targetId) return;
|
|
|
|
setTasks((prev) => {
|
|
if (isDescendantNode(prev, draggedId, targetId)) {
|
|
return prev;
|
|
}
|
|
|
|
// 1. Extract and remove the dragged node from its current position
|
|
let extractedNode: Task | null = null;
|
|
const removeNode = (nodes: Task[]): Task[] => {
|
|
const result: Task[] = [];
|
|
for (const node of nodes) {
|
|
if (node.id === draggedId) {
|
|
extractedNode = { ...node };
|
|
} else {
|
|
const updatedNode = { ...node };
|
|
if (updatedNode.children && updatedNode.children.length > 0) {
|
|
updatedNode.children = removeNode(updatedNode.children);
|
|
}
|
|
result.push(updatedNode);
|
|
}
|
|
}
|
|
return result;
|
|
};
|
|
|
|
const treeWithoutDragged = removeNode(prev);
|
|
if (!extractedNode) return prev;
|
|
const safeExtractedNode: Task = extractedNode;
|
|
|
|
let determinedParentId: string | null = null;
|
|
|
|
// 2. Insert into the target position
|
|
if (position === "inside") {
|
|
determinedParentId = targetId;
|
|
const insertInside = (nodes: Task[]): { list: Task[]; inserted: boolean } => {
|
|
let hasInserted = false;
|
|
const newNodes = nodes.map((node) => {
|
|
if (node.id === targetId) {
|
|
hasInserted = true;
|
|
const nodeWithNewParent: Task = { ...safeExtractedNode, parentId: targetId };
|
|
return {
|
|
...node,
|
|
children: [...(node.children || []), nodeWithNewParent],
|
|
};
|
|
}
|
|
if (node.children && node.children.length > 0) {
|
|
const res = insertInside(node.children);
|
|
if (res.inserted) {
|
|
hasInserted = true;
|
|
return { ...node, children: res.list };
|
|
}
|
|
}
|
|
return node;
|
|
});
|
|
return { list: newNodes, inserted: hasInserted };
|
|
};
|
|
|
|
const result = insertInside(treeWithoutDragged);
|
|
const finalTree = result.inserted ? result.list : [...treeWithoutDragged, { ...safeExtractedNode, parentId: null }];
|
|
|
|
if (isDemo && typeof window !== "undefined") {
|
|
const store = getDemoStore();
|
|
saveDemoStore(store.lists, finalTree as unknown as MockTask[]);
|
|
} else {
|
|
fetch(`/api/tasks/${draggedId}`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ parentId: determinedParentId }),
|
|
}).catch((err) => console.error("Failed to update parentId", err));
|
|
}
|
|
return finalTree;
|
|
} else {
|
|
// Drop before/after as sibling (if target is at root level, parentId becomes null -> promoted to 1st level)
|
|
const insertSibling = (nodes: Task[], currentParentId: string | null): { list: Task[]; inserted: boolean } => {
|
|
const idx = nodes.findIndex((n) => n.id === targetId);
|
|
if (idx !== -1) {
|
|
determinedParentId = currentParentId;
|
|
const insertIdx = position === "before" ? idx : idx + 1;
|
|
const nodeWithNewParent: Task = { ...safeExtractedNode, parentId: currentParentId };
|
|
const newNodes = [...nodes];
|
|
newNodes.splice(insertIdx, 0, nodeWithNewParent);
|
|
return { list: newNodes, inserted: true };
|
|
}
|
|
|
|
let hasInserted = false;
|
|
const newNodes = nodes.map((node) => {
|
|
if (!hasInserted && node.children && node.children.length > 0) {
|
|
const res = insertSibling(node.children, node.id);
|
|
if (res.inserted) {
|
|
hasInserted = true;
|
|
return { ...node, children: res.list };
|
|
}
|
|
}
|
|
return node;
|
|
});
|
|
|
|
return { list: newNodes, inserted: hasInserted };
|
|
};
|
|
|
|
const result = insertSibling(treeWithoutDragged, null);
|
|
const finalTree = result.inserted ? result.list : [...treeWithoutDragged, { ...safeExtractedNode, parentId: null }];
|
|
|
|
if (isDemo && typeof window !== "undefined") {
|
|
const store = getDemoStore();
|
|
saveDemoStore(store.lists, finalTree as unknown as MockTask[]);
|
|
} else {
|
|
fetch(`/api/tasks/${draggedId}`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ parentId: determinedParentId }),
|
|
}).catch((err) => console.error("Failed to update sibling parentId", err));
|
|
}
|
|
return finalTree;
|
|
}
|
|
});
|
|
}, [isDemo, 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)" }}>
|
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" style={{ opacity: 0.3, marginBottom: 12 }}>
|
|
<path d="M9 11l3 3L22 4" /><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
|
|
</svg>
|
|
<p>{t("selectListToStart")}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Filter top-level vs completed
|
|
const incompleteTasks = tasks.filter((t) => !t.completed);
|
|
const completedTasks = tasks.filter((t) => t.completed);
|
|
|
|
return (
|
|
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
|
{/* Header */}
|
|
<div className="main-header" style={{ justifyContent: "space-between" }}>
|
|
<div style={{ display: "flex", alignItems: "center", gap: 10, flex: 1, minWidth: 0, marginRight: 12 }}>
|
|
<button className="icon-btn mobile-only" id="menu-btn" onClick={onMenuOpen} aria-label="Menu" type="button">
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<line x1="3" y1="6" x2="21" y2="6" /><line x1="3" y1="12" x2="21" y2="12" /><line x1="3" y1="18" x2="21" y2="18" />
|
|
</svg>
|
|
</button>
|
|
|
|
{/* Header Title / Trash Header / Tag Header */}
|
|
{isTrashActive ? (
|
|
<div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
|
|
<span style={{ fontSize: 18, fontWeight: 700, color: "var(--danger)" }}>🗑️ {t("trash") || "Trash"}</span>
|
|
<span style={{ fontSize: 12, color: "var(--text-tertiary)" }}>({tasks.length})</span>
|
|
</div>
|
|
) : selectedTag ? (
|
|
<div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
|
|
<span style={{ fontSize: 18, fontWeight: 700, color: "var(--accent)" }}>🏷️ #{selectedTag}</span>
|
|
<span style={{ fontSize: 12, color: "var(--text-tertiary)" }}>({tasks.length})</span>
|
|
</div>
|
|
) : editingHeader ? (
|
|
<input
|
|
ref={headerInputRef}
|
|
id="header-rename-input"
|
|
className="form-input"
|
|
value={headerTitle}
|
|
onChange={(e) => setHeaderTitle(e.target.value)}
|
|
onBlur={handleSaveHeaderTitle}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") handleSaveHeaderTitle();
|
|
if (e.key === "Escape") {
|
|
if (currentList) setHeaderTitle(currentList.name);
|
|
setEditingHeader(false);
|
|
}
|
|
}}
|
|
onClick={(e) => e.stopPropagation()}
|
|
autoFocus
|
|
style={{ fontSize: 18, fontWeight: 700, height: 36, padding: "2px 10px", width: "100%", maxWidth: 320 }}
|
|
/>
|
|
) : (
|
|
<div
|
|
className="main-header-title"
|
|
id="main-header-title"
|
|
style={{
|
|
color: currentList?.color,
|
|
cursor: "pointer",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 8,
|
|
padding: "4px 8px",
|
|
borderRadius: "var(--radius-sm)",
|
|
transition: "background var(--dur-fast)",
|
|
minWidth: 0,
|
|
overflow: "hidden",
|
|
textOverflow: "ellipsis",
|
|
whiteSpace: "nowrap",
|
|
}}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setEditingHeader(true);
|
|
}}
|
|
title="Click to rename list"
|
|
>
|
|
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{currentList?.name || t("tasks")}</span>
|
|
<span style={{ fontSize: 12, color: "var(--text-tertiary)", fontWeight: 400, flexShrink: 0 }}>
|
|
({incompleteTasks.length})
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Header Actions */}
|
|
<div className="main-header-actions" style={{ flexShrink: 0, marginLeft: "auto", display: "flex", alignItems: "center", gap: 8 }}>
|
|
{/* View Switcher: Minimal & Matte Single Toggle (Only shown when Kanban is enabled in Labs) */}
|
|
{!isTrashActive && !selectedTag && kanbanEnabled && (
|
|
<button
|
|
type="button"
|
|
className={`view-switcher-toggle-btn${viewMode === "kanban" ? " active" : ""}`}
|
|
onClick={handleToggleViewMode}
|
|
title={viewMode === "kanban" ? `${t("listView")} (Switch to List)` : `${t("kanbanView")} (Switch to Kanban)`}
|
|
style={{
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
gap: 5,
|
|
padding: "4px 8px",
|
|
fontSize: 12,
|
|
fontWeight: 600,
|
|
background: viewMode === "kanban" ? "var(--accent-light)" : "var(--bg-secondary)",
|
|
color: viewMode === "kanban" ? "var(--accent)" : "var(--text-secondary)",
|
|
border: "1px solid var(--border)",
|
|
borderRadius: "var(--radius-sm)",
|
|
cursor: "pointer",
|
|
transition: "all var(--dur-fast)",
|
|
}}
|
|
>
|
|
<span>{viewMode === "kanban" ? "📊" : "📋"}</span>
|
|
<span style={{ fontSize: 11.5 }}>
|
|
{viewMode === "kanban" ? t("kanbanView") : t("listView")}
|
|
</span>
|
|
</button>
|
|
)}
|
|
|
|
{isTrashActive ? (
|
|
<button
|
|
id="empty-trash-btn"
|
|
className="btn btn-ghost btn-sm"
|
|
style={{ color: "var(--danger)" }}
|
|
onClick={onEmptyTrash}
|
|
type="button"
|
|
>
|
|
🧹 {t("emptyTrash") || "Empty Trash"}
|
|
</button>
|
|
) : (
|
|
<button
|
|
id="toggle-completed-btn"
|
|
className="btn btn-ghost btn-sm"
|
|
onClick={onToggleCompleted}
|
|
title={showCompleted ? t("hideDone") : t("showDone")}
|
|
type="button"
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<polyline points="20 6 9 17 4 12" />
|
|
</svg>
|
|
<span className="desktop-only">{showCompleted ? t("hideDone") : t("showDone")}</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Main Task View (List View vs Kanban View) */}
|
|
{viewMode === "kanban" && !isTrashActive && !selectedTag ? (
|
|
<div style={{ flex: 1, minHeight: 0, overflow: "hidden" }}>
|
|
<KanbanView
|
|
tasks={tasks}
|
|
selectedTaskId={selectedTaskId}
|
|
onSelectTask={onTaskSelect}
|
|
onToggleTask={handleToggle}
|
|
onAddTask={(title, priority = 0) => {
|
|
if (!listId) return;
|
|
if (isDemo && onDemoAddTask) {
|
|
onDemoAddTask(title, listId, null, { priority });
|
|
} else if (!isDemo) {
|
|
fetch("/api/tasks", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ title, listId, priority }),
|
|
}).then(() => onRefresh());
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
) : (
|
|
/* Task list container */
|
|
<div
|
|
className="task-list-container"
|
|
onContextMenu={(e) => {
|
|
if (e.target === e.currentTarget) {
|
|
e.preventDefault();
|
|
}
|
|
}}
|
|
>
|
|
{loading && (
|
|
<div style={{ padding: "20px", textAlign: "center", color: "var(--text-tertiary)" }}>{t("loading")}</div>
|
|
)}
|
|
{!loading && tasks.length === 0 && (
|
|
<div className="task-list-empty">
|
|
<svg width="56" height="56" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
|
<path d="M9 11l3 3L22 4" /><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
|
|
</svg>
|
|
<p>{isTrashActive ? "Trash is empty" : selectedTag ? "No tasks with this tag" : t("noTasksYet")}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Tasks Tree */}
|
|
{(isTrashActive ? tasks : incompleteTasks).map((task) => (
|
|
<RecursiveTaskItem
|
|
key={task.id}
|
|
task={task}
|
|
depth={0}
|
|
isSelected={selectedTaskId === task.id}
|
|
onSelect={onTaskSelect}
|
|
onToggle={handleToggle}
|
|
onUpdateTitle={(id, title) => onUpdateTaskTitle && onUpdateTaskTitle(id, title)}
|
|
onAddSubtask={handleAddSubtaskInline}
|
|
onContextMenu={(x, y, tItem) => setContextMenu({ x, y, task: tItem })}
|
|
isTrashMode={isTrashActive}
|
|
onRestore={onRestoreTask}
|
|
onPermanentDelete={onPermanentDeleteTask}
|
|
onDragTask={handleReorderTasks}
|
|
/>
|
|
))}
|
|
|
|
{/* Completed Tasks */}
|
|
{!isTrashActive && showCompleted && completedTasks.length > 0 && (
|
|
<div>
|
|
<div
|
|
style={{
|
|
padding: "16px 20px 6px",
|
|
fontSize: 11,
|
|
fontWeight: 600,
|
|
color: "var(--text-tertiary)",
|
|
textTransform: "uppercase",
|
|
letterSpacing: "0.5px",
|
|
}}
|
|
>
|
|
{t("completedSection")} ({completedTasks.length})
|
|
</div>
|
|
{completedTasks.map((task) => (
|
|
<RecursiveTaskItem
|
|
key={task.id}
|
|
task={task}
|
|
depth={0}
|
|
isSelected={selectedTaskId === task.id}
|
|
onSelect={onTaskSelect}
|
|
onToggle={handleToggle}
|
|
onUpdateTitle={(id, title) => onUpdateTaskTitle && onUpdateTaskTitle(id, title)}
|
|
onAddSubtask={handleAddSubtaskInline}
|
|
onContextMenu={(x, y, tItem) => setContextMenu({ x, y, task: tItem })}
|
|
isTrashMode={isTrashActive}
|
|
onRestore={onRestoreTask}
|
|
onPermanentDelete={onPermanentDeleteTask}
|
|
onDragTask={handleReorderTasks}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Full-featured Context Menu */}
|
|
{contextMenu && !isTrashActive && (
|
|
<ContextMenu
|
|
x={contextMenu.x}
|
|
y={contextMenu.y}
|
|
items={[
|
|
{
|
|
label: contextMenu.task.completed ? t("showDone") : t("completedSection"),
|
|
icon: contextMenu.task.completed ? "↩️" : "✅",
|
|
onClick: () => handleToggle(contextMenu.task.id, !contextMenu.task.completed),
|
|
},
|
|
{
|
|
label: t("taskTitlePlaceholder"),
|
|
icon: "✏️",
|
|
onClick: () => onTaskSelect(contextMenu.task),
|
|
},
|
|
{
|
|
label: t("subtasks"),
|
|
icon: "↳",
|
|
onClick: () => {
|
|
onTaskSelect(contextMenu.task);
|
|
},
|
|
},
|
|
{ divider: true, label: "" },
|
|
{
|
|
label: t("deleteTaskConfirm").split("?")[0],
|
|
icon: "🗑️",
|
|
danger: true,
|
|
onClick: () => handleDeleteTask(contextMenu.task),
|
|
},
|
|
]}
|
|
onClose={() => setContextMenu(null)}
|
|
/>
|
|
)}
|
|
|
|
{/* Add task bar with TickTick-style Quick Presets (hidden in trash mode) */}
|
|
{!isTrashActive && !selectedTag && (
|
|
<div className="add-task-bar" style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
|
<form onSubmit={handleAddTask} style={{ display: "flex", alignItems: "center", gap: 8, width: "100%" }}>
|
|
<button className="task-check-btn" style={{ opacity: 0.4, flexShrink: 0 }} aria-hidden="true" type="button" />
|
|
<input
|
|
ref={inputRef}
|
|
id="add-task-input"
|
|
className="add-task-input"
|
|
placeholder={t("addTaskPlaceholder")}
|
|
value={newTaskTitle}
|
|
onChange={(e) => setNewTaskTitle(e.target.value)}
|
|
/>
|
|
{newTaskTitle.trim() && (
|
|
<button type="submit" className="btn btn-primary btn-sm" id="add-task-btn">
|
|
{t("add")}
|
|
</button>
|
|
)}
|
|
</form>
|
|
|
|
{/* TickTick-style Preset Toolbar (Due Date, Priority) */}
|
|
<div style={{ display: "flex", alignItems: "center", gap: 8, paddingLeft: 28, flexWrap: "wrap" }}>
|
|
{/* Due Date Preset */}
|
|
<div style={{ position: "relative" }}>
|
|
<button
|
|
type="button"
|
|
className={`tick-meta-chip${quickDueDate ? " active" : ""}`}
|
|
onClick={() => setShowQuickDue((p) => !p)}
|
|
style={{
|
|
fontSize: 11,
|
|
padding: "2px 8px",
|
|
borderRadius: "var(--radius-sm)",
|
|
color: quickDueDate ? "var(--accent)" : "var(--text-tertiary)",
|
|
borderColor: quickDueDate ? "var(--accent)" : "transparent",
|
|
}}
|
|
title={t("selectDate")}
|
|
>
|
|
📅 {quickDueDate ? new Date(quickDueDate).toLocaleDateString(undefined, { month: "short", day: "numeric" }) : t("selectDate")}
|
|
</button>
|
|
|
|
{showQuickDue && (
|
|
<div
|
|
className="dropdown"
|
|
style={{ left: 0, bottom: "calc(100% + 6px)", minWidth: 150, padding: 6, zIndex: 120 }}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div
|
|
className="context-menu-item"
|
|
style={{ padding: "4px 8px", fontSize: 12, cursor: "pointer" }}
|
|
onClick={() => {
|
|
setQuickDueDate(new Date().toISOString().split("T")[0]);
|
|
setShowQuickDue(false);
|
|
}}
|
|
>
|
|
⚡ {t("quickToday")}
|
|
</div>
|
|
<div
|
|
className="context-menu-item"
|
|
style={{ padding: "4px 8px", fontSize: 12, cursor: "pointer" }}
|
|
onClick={() => {
|
|
const tm = new Date(Date.now() + 86400000).toISOString().split("T")[0];
|
|
setQuickDueDate(tm);
|
|
setShowQuickDue(false);
|
|
}}
|
|
>
|
|
🌅 {t("quickTomorrow")}
|
|
</div>
|
|
<div
|
|
className="context-menu-item"
|
|
style={{ padding: "4px 8px", fontSize: 12, cursor: "pointer" }}
|
|
onClick={() => {
|
|
const nw = new Date(Date.now() + 7 * 86400000).toISOString().split("T")[0];
|
|
setQuickDueDate(nw);
|
|
setShowQuickDue(false);
|
|
}}
|
|
>
|
|
🗓️ {t("quickNextWeek")}
|
|
</div>
|
|
<div style={{ borderTop: "1px solid var(--border)", margin: "4px 0", paddingTop: 4 }}>
|
|
<input
|
|
type="date"
|
|
className="form-input"
|
|
style={{ fontSize: 11, padding: "2px 6px", width: "100%" }}
|
|
value={quickDueDate || ""}
|
|
onChange={(e) => {
|
|
setQuickDueDate(e.target.value || null);
|
|
setShowQuickDue(false);
|
|
}}
|
|
/>
|
|
</div>
|
|
{quickDueDate && (
|
|
<div
|
|
className="context-menu-item"
|
|
style={{ padding: "4px 8px", fontSize: 11, color: "var(--danger)", cursor: "pointer" }}
|
|
onClick={() => {
|
|
setQuickDueDate(null);
|
|
setShowQuickDue(false);
|
|
}}
|
|
>
|
|
✕ {t("clear")}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Priority Preset */}
|
|
<div style={{ position: "relative" }}>
|
|
<button
|
|
type="button"
|
|
className={`tick-meta-chip${quickPriority > 0 ? " active" : ""}`}
|
|
onClick={() => setShowQuickPriority((p) => !p)}
|
|
style={{
|
|
fontSize: 11,
|
|
padding: "2px 8px",
|
|
borderRadius: "var(--radius-sm)",
|
|
color: quickPriority > 0 ? PRIORITY_COLORS[quickPriority] : "var(--text-tertiary)",
|
|
borderColor: quickPriority > 0 ? PRIORITY_COLORS[quickPriority] : "transparent",
|
|
}}
|
|
title={t("selectPriority")}
|
|
>
|
|
🚩 {quickPriority > 0 ? priorityLabels[quickPriority] : t("selectPriority")}
|
|
</button>
|
|
|
|
{showQuickPriority && (
|
|
<div
|
|
className="dropdown"
|
|
style={{ left: 0, bottom: "calc(100% + 6px)", minWidth: 120, padding: 4, zIndex: 120 }}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{[0, 1, 2, 3].map((pVal) => (
|
|
<div
|
|
key={pVal}
|
|
className="context-menu-item"
|
|
style={{
|
|
padding: "4px 8px",
|
|
fontSize: 12,
|
|
cursor: "pointer",
|
|
color: pVal > 0 ? PRIORITY_COLORS[pVal] : "inherit",
|
|
fontWeight: quickPriority === pVal ? 700 : 400,
|
|
}}
|
|
onClick={() => {
|
|
setQuickPriority(pVal);
|
|
setShowQuickPriority(false);
|
|
}}
|
|
>
|
|
{pVal === 0 ? `○ ${t("priorityNone")}` : `● ${priorityLabels[pVal]}`}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Quick Helper Text */}
|
|
{(quickDueDate || quickPriority > 0) && (
|
|
<button
|
|
type="button"
|
|
className="btn btn-ghost btn-sm"
|
|
style={{ fontSize: 10, padding: "1px 6px", opacity: 0.6 }}
|
|
onClick={() => {
|
|
setQuickDueDate(null);
|
|
setQuickPriority(0);
|
|
}}
|
|
>
|
|
{t("clear")}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
} |