Fix: Full N-depth recursive subtask tree rendering in main list, universal custom context menu on all subtask levels, and bidirectional realtime sync
This commit is contained in:
+111
-162
@@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
"use client";
|
||||
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { ContextMenu, MenuItem } from "@/components/ui/ContextMenu";
|
||||
@@ -17,7 +17,7 @@ export interface Task {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
children: Task[];
|
||||
tags: { tag: { id: string; name: string; color: string } }[];
|
||||
tags?: { tag: { id: string; name: string; color: string } }[];
|
||||
}
|
||||
|
||||
export interface List { id: string; name: string; color: string; icon: string }
|
||||
@@ -32,17 +32,19 @@ function isOverdue(d: string | null) {
|
||||
|
||||
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;
|
||||
onDeleteTask?: (id: string) => void;
|
||||
}
|
||||
|
||||
function TaskItem({
|
||||
// Recursive Task Tree Item (Supports 1st, 2nd, 3rd, N-level sub-tasks seamlessly)
|
||||
function RecursiveTaskItem({
|
||||
task,
|
||||
depth = 0,
|
||||
isSelected,
|
||||
onSelect,
|
||||
onToggle,
|
||||
@@ -62,6 +64,7 @@ function TaskItem({
|
||||
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);
|
||||
@@ -115,17 +118,50 @@ function TaskItem({
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative" }}>
|
||||
<div style={{ paddingLeft: depth > 0 ? 24 : 0, position: "relative" }}>
|
||||
<div
|
||||
className={`task-item${task.completed ? " completed" : ""}${isSelected ? " selected" : ""}`}
|
||||
onClick={() => onSelect(task)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onContextMenu(e.clientX, e.clientY, task);
|
||||
}}
|
||||
id={`task-${task.id}`}
|
||||
style={{ group: "item" } as React.CSSProperties}
|
||||
style={{
|
||||
borderLeft: depth > 0 ? "2px solid var(--border)" : "none",
|
||||
marginLeft: depth > 0 ? 8 : 0,
|
||||
}}
|
||||
>
|
||||
{/* 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 */}
|
||||
<button
|
||||
className={`task-check-btn${task.completed ? " checked" : ""}`}
|
||||
onClick={(e) => {
|
||||
@@ -134,8 +170,10 @@ function TaskItem({
|
||||
}}
|
||||
aria-label={task.completed ? "Mark incomplete" : "Mark complete"}
|
||||
type="button"
|
||||
style={{ width: depth > 0 ? 17 : 19, height: depth > 0 ? 17 : 19, flexShrink: 0 }}
|
||||
/>
|
||||
|
||||
{/* Title and Meta */}
|
||||
<div className="task-body">
|
||||
{editingTitle ? (
|
||||
<input
|
||||
@@ -152,7 +190,7 @@ function TaskItem({
|
||||
}
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ padding: "2px 6px", fontSize: 14, fontWeight: 500, height: 26, width: "100%" }}
|
||||
style={{ padding: "2px 6px", fontSize: depth > 0 ? 13 : 14, fontWeight: 500, height: 26, width: "100%" }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
@@ -162,7 +200,7 @@ function TaskItem({
|
||||
setEditingTitle(true);
|
||||
}}
|
||||
title="Double click to edit"
|
||||
style={{ cursor: "text" }}
|
||||
style={{ fontSize: depth > 0 ? 13 : 14, fontWeight: depth === 0 ? 600 : 500 }}
|
||||
>
|
||||
{task.title}
|
||||
</div>
|
||||
@@ -184,32 +222,20 @@ function TaskItem({
|
||||
{formatDate(task.dueDate)}
|
||||
</span>
|
||||
)}
|
||||
{task.children && task.children.length > 0 && (
|
||||
{totalChildren > 0 && (
|
||||
<span
|
||||
className="task-sub-count"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setExpanded((p) => !p);
|
||||
}}
|
||||
title="Toggle subtasks"
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
style={{ transform: expanded ? "rotate(90deg)" : "rotate(0deg)", transition: "transform 0.15s" }}
|
||||
>
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
{completedChildren}/{task.children.length}
|
||||
{completedChildren}/{totalChildren}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Quick Add Subtask Button */}
|
||||
{/* Quick Inline Add Subtask button */}
|
||||
<button
|
||||
className="badge badge-neutral"
|
||||
style={{ cursor: "pointer", fontSize: 10, padding: "1px 6px", border: "1px solid var(--border)", background: "transparent" }}
|
||||
@@ -225,7 +251,7 @@ function TaskItem({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{task.note && !task.completed && (
|
||||
{task.note && !task.completed && depth === 0 && (
|
||||
<div className="task-note-preview">{task.note.replace(/[#*`]/g, "").slice(0, 80)}</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -233,7 +259,7 @@ function TaskItem({
|
||||
{/* Quick Edit icon on hover */}
|
||||
<button
|
||||
className="icon-btn"
|
||||
style={{ width: 22, height: 22, opacity: 0.4, flexShrink: 0 }}
|
||||
style={{ width: 22, height: 22, opacity: 0.35, flexShrink: 0 }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditingTitle(true);
|
||||
@@ -247,125 +273,51 @@ function TaskItem({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sub-tasks List */}
|
||||
{((task.children && task.children.length > 0) || addingSubtask) && expanded && (
|
||||
<div className="subtask-list" style={{ paddingLeft: 38 }}>
|
||||
{task.children?.map((child) => (
|
||||
<SubTaskItem
|
||||
{/* Recursive Children (N-Depth Sub-tasks) */}
|
||||
{totalChildren > 0 && expanded && (
|
||||
<div className="subtask-recursive-tree">
|
||||
{task.children.map((child) => (
|
||||
<RecursiveTaskItem
|
||||
key={child.id}
|
||||
child={child}
|
||||
onToggle={onToggle}
|
||||
task={child}
|
||||
depth={depth + 1}
|
||||
isSelected={isSelected}
|
||||
onSelect={onSelect}
|
||||
onToggle={onToggle}
|
||||
onUpdateTitle={onUpdateTitle}
|
||||
onAddSubtask={onAddSubtask}
|
||||
onContextMenu={onContextMenu}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Inline Add Subtask input */}
|
||||
{addingSubtask && (
|
||||
<form onSubmit={handleCreateSubtask} style={{ display: "flex", gap: 6, padding: "4px 0" }}>
|
||||
<span style={{ color: "var(--accent)", fontSize: 14, lineHeight: "24px" }}>↳</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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Sub-task Item with Inline Rename
|
||||
function SubTaskItem({
|
||||
child,
|
||||
onToggle,
|
||||
onSelect,
|
||||
onUpdateTitle,
|
||||
}: {
|
||||
child: Task;
|
||||
onToggle: (id: string, completed: boolean) => void;
|
||||
onSelect: (t: Task) => void;
|
||||
onUpdateTitle: (id: string, title: string) => void;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [temp, setTemp] = useState(child.title);
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (editing) {
|
||||
ref.current?.focus();
|
||||
ref.current?.select();
|
||||
}
|
||||
}, [editing]);
|
||||
|
||||
const handleSave = () => {
|
||||
const trimmed = temp.trim();
|
||||
if (trimmed && trimmed !== child.title) {
|
||||
onUpdateTitle(child.id, trimmed);
|
||||
} else {
|
||||
setTemp(child.title);
|
||||
}
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`subtask-item${child.completed ? " completed" : ""}`}
|
||||
onClick={() => onSelect(child)}
|
||||
id={`subtask-${child.id}`}
|
||||
>
|
||||
<button
|
||||
className={`subtask-check-btn${child.completed ? " checked" : ""}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggle(child.id, !child.completed);
|
||||
}}
|
||||
aria-label={child.completed ? "Mark incomplete" : "Mark complete"}
|
||||
type="button"
|
||||
/>
|
||||
{editing ? (
|
||||
<input
|
||||
ref={ref}
|
||||
className="form-input"
|
||||
value={temp}
|
||||
onChange={(e) => setTemp(e.target.value)}
|
||||
onBlur={handleSave}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleSave();
|
||||
if (e.key === "Escape") {
|
||||
setTemp(child.title);
|
||||
setEditing(false);
|
||||
}
|
||||
}}
|
||||
{/* Inline Add Subtask form for this node */}
|
||||
{addingSubtask && (
|
||||
<form
|
||||
onSubmit={handleCreateSubtask}
|
||||
style={{ display: "flex", gap: 6, padding: "4px 0 4px 32px" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ padding: "1px 6px", fontSize: 12.5, height: 22, flex: 1 }}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="subtask-title"
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditing(true);
|
||||
}}
|
||||
title="Double click to edit"
|
||||
style={{ cursor: "text" }}
|
||||
>
|
||||
{child.title}
|
||||
</span>
|
||||
<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>
|
||||
);
|
||||
@@ -469,27 +421,12 @@ export function TaskList({
|
||||
body: JSON.stringify({ completed }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const updated = await res.json();
|
||||
setTasks((prev) =>
|
||||
prev
|
||||
.map((t) => {
|
||||
if (t.id === id) return { ...t, completed, completedAt: updated.completedAt };
|
||||
if (t.children && t.children.some((c) => c.id === id)) {
|
||||
return {
|
||||
...t,
|
||||
children: t.children.map((c) => (c.id === id ? { ...c, completed } : c)),
|
||||
};
|
||||
}
|
||||
return t;
|
||||
})
|
||||
.filter((t) => showCompleted || !t.completed)
|
||||
);
|
||||
onRefresh();
|
||||
} catch (err) {
|
||||
console.error("[TaskList] handleToggle failed", err);
|
||||
}
|
||||
},
|
||||
[isDemo, onDemoToggleTask, showCompleted, onRefresh, setTasks]
|
||||
[isDemo, onDemoToggleTask, onRefresh]
|
||||
);
|
||||
|
||||
const handleAddTask = async (e: React.FormEvent) => {
|
||||
@@ -533,10 +470,6 @@ export function TaskList({
|
||||
body: JSON.stringify({ title, listId, parentId }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const sub = await res.json();
|
||||
setTasks((prev) =>
|
||||
prev.map((t) => (t.id === parentId ? { ...t, children: [...(t.children || []), sub] } : t))
|
||||
);
|
||||
onRefresh();
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -565,6 +498,7 @@ export function TaskList({
|
||||
);
|
||||
}
|
||||
|
||||
// Filter top-level vs completed
|
||||
const incompleteTasks = tasks.filter((t) => !t.completed);
|
||||
const completedTasks = tasks.filter((t) => t.completed);
|
||||
|
||||
@@ -572,7 +506,7 @@ export function TaskList({
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
{/* Header */}
|
||||
<div className="main-header">
|
||||
<button className="icon-btn mobile-only" id="menu-btn" onClick={onMenuOpen} aria-label="Menu">
|
||||
<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>
|
||||
@@ -629,6 +563,7 @@ export function TaskList({
|
||||
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" />
|
||||
@@ -638,8 +573,16 @@ export function TaskList({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Task list */}
|
||||
<div className="task-list-container">
|
||||
{/* Task list container with background context menu handler */}
|
||||
<div
|
||||
className="task-list-container"
|
||||
onContextMenu={(e) => {
|
||||
// If clicked directly on empty container area, prevent native browser menu
|
||||
if (e.target === e.currentTarget) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{loading && (
|
||||
<div style={{ padding: "20px", textAlign: "center", color: "var(--text-tertiary)" }}>{t("loading")}</div>
|
||||
)}
|
||||
@@ -651,10 +594,13 @@ export function TaskList({
|
||||
<p>{t("noTasksYet")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Incomplete Tasks Recursive Tree */}
|
||||
{incompleteTasks.map((task) => (
|
||||
<TaskItem
|
||||
<RecursiveTaskItem
|
||||
key={task.id}
|
||||
task={task}
|
||||
depth={0}
|
||||
isSelected={selectedTaskId === task.id}
|
||||
onSelect={onTaskSelect}
|
||||
onToggle={handleToggle}
|
||||
@@ -663,11 +609,13 @@ export function TaskList({
|
||||
onContextMenu={(x, y, tItem) => setContextMenu({ x, y, task: tItem })}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Completed Tasks Recursive Tree */}
|
||||
{showCompleted && completedTasks.length > 0 && (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
padding: "12px 20px 4px",
|
||||
padding: "16px 20px 6px",
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: "var(--text-tertiary)",
|
||||
@@ -678,9 +626,10 @@ export function TaskList({
|
||||
{t("completedSection")} ({completedTasks.length})
|
||||
</div>
|
||||
{completedTasks.map((task) => (
|
||||
<TaskItem
|
||||
<RecursiveTaskItem
|
||||
key={task.id}
|
||||
task={task}
|
||||
depth={0}
|
||||
isSelected={selectedTaskId === task.id}
|
||||
onSelect={onTaskSelect}
|
||||
onToggle={handleToggle}
|
||||
@@ -693,7 +642,7 @@ export function TaskList({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Context Menu on Task */}
|
||||
{/* Full-featured Context Menu on ANY Task at ANY Depth */}
|
||||
{contextMenu && (
|
||||
<ContextMenu
|
||||
x={contextMenu.x}
|
||||
@@ -737,7 +686,7 @@ export function TaskList({
|
||||
|
||||
{/* Add task bar */}
|
||||
<div className="add-task-bar">
|
||||
<button className="task-check-btn" style={{ opacity: 0.4, flexShrink: 0 }} aria-hidden="true" />
|
||||
<button className="task-check-btn" style={{ opacity: 0.4, flexShrink: 0 }} aria-hidden="true" type="button" />
|
||||
<form onSubmit={handleAddTask} style={{ flex: 1, display: "flex", gap: 8 }}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
|
||||
Reference in New Issue
Block a user