Fix: Real-time 2-level subtask sync between panels and inline title editing for list and tasks
This commit is contained in:
@@ -30,7 +30,7 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
|
||||
}
|
||||
}, [isDemo]);
|
||||
|
||||
// Demo tasks filter
|
||||
// Demo tasks filter & reconstruct hierarchical structure
|
||||
useEffect(() => {
|
||||
if (isDemo && selectedListId) {
|
||||
const store = getDemoStore();
|
||||
@@ -47,9 +47,28 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
|
||||
setSelectedTask(task);
|
||||
}, []);
|
||||
|
||||
// Update a task and sync with main list and selected panel
|
||||
const handleTaskUpdate = useCallback((updated: Task) => {
|
||||
setTasks((prev) => prev.map((t) => (t.id === updated.id ? updated : t)));
|
||||
setSelectedTask(updated);
|
||||
setTasks((prev) =>
|
||||
prev.map((t) => {
|
||||
if (t.id === updated.id) {
|
||||
return { ...updated, children: updated.children || t.children || [] };
|
||||
}
|
||||
// If a sub-task was updated directly, update parent's children
|
||||
if (t.children && t.children.some((c) => c.id === updated.id)) {
|
||||
const newChildren = t.children.map((c) => (c.id === updated.id ? updated : c));
|
||||
return { ...t, children: newChildren };
|
||||
}
|
||||
return t;
|
||||
})
|
||||
);
|
||||
|
||||
setSelectedTask((prev) => {
|
||||
if (prev && prev.id === updated.id) {
|
||||
return { ...updated, children: updated.children || prev.children || [] };
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleListSelect = useCallback((id: string) => {
|
||||
@@ -58,6 +77,34 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
|
||||
setSidebarOpen(false);
|
||||
}, []);
|
||||
|
||||
// Update List Name (Inline edit on header or sidebar)
|
||||
const handleUpdateListName = async (id: string, newName: string) => {
|
||||
const trimmed = newName.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
if (isDemo) {
|
||||
const store = getDemoStore();
|
||||
const newLists = store.lists.map((l) => (l.id === id ? { ...l, name: trimmed } : l));
|
||||
saveDemoStore(newLists, store.tasks);
|
||||
setLists(newLists);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/lists/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: trimmed }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const updated = await res.json();
|
||||
setLists((prev) => prev.map((l) => (l.id === id ? { ...l, name: updated.name } : l)));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to update list name", err);
|
||||
}
|
||||
};
|
||||
|
||||
// Demo Handlers
|
||||
const handleDemoCreateList = (name: string, color: string) => {
|
||||
const newList: MockList = {
|
||||
@@ -85,11 +132,11 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
|
||||
refresh();
|
||||
};
|
||||
|
||||
const handleDemoAddTask = (title: string, listId: string) => {
|
||||
const handleDemoAddTask = (title: string, listId: string, parentId: string | null = null) => {
|
||||
const newTask: Task = {
|
||||
id: "demo-task-" + Date.now(),
|
||||
listId,
|
||||
parentId: null,
|
||||
parentId: parentId || null,
|
||||
title,
|
||||
note: null,
|
||||
completed: false,
|
||||
@@ -103,9 +150,29 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
|
||||
tags: [],
|
||||
};
|
||||
const store = getDemoStore();
|
||||
const newTasks = [...store.tasks, newTask as MockTask];
|
||||
saveDemoStore(store.lists, newTasks);
|
||||
setTasks((prev) => [...prev, newTask]);
|
||||
|
||||
if (parentId) {
|
||||
// Add as subtask to parent
|
||||
const newTasks = store.tasks.map((t) => {
|
||||
if (t.id === parentId) {
|
||||
const currentChildren = t.children || [];
|
||||
return { ...t, children: [...currentChildren, newTask as MockTask] };
|
||||
}
|
||||
return t;
|
||||
});
|
||||
saveDemoStore(store.lists, newTasks);
|
||||
setTasks((prev) =>
|
||||
prev.map((t) => (t.id === parentId ? { ...t, children: [...(t.children || []), newTask] } : t))
|
||||
);
|
||||
if (selectedTask && selectedTask.id === parentId) {
|
||||
setSelectedTask((prev) => (prev ? { ...prev, children: [...(prev.children || []), newTask] } : prev));
|
||||
}
|
||||
} else {
|
||||
// Top-level task
|
||||
const newTasks = [...store.tasks, newTask as MockTask];
|
||||
saveDemoStore(store.lists, newTasks);
|
||||
setTasks((prev) => [...prev, newTask]);
|
||||
}
|
||||
refresh();
|
||||
};
|
||||
|
||||
@@ -115,12 +182,49 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
|
||||
if (t.id === id) {
|
||||
return { ...t, completed, completedAt: completed ? new Date().toISOString() : null };
|
||||
}
|
||||
return {
|
||||
...t,
|
||||
children: t.children?.map((c) => (c.id === id ? { ...c, completed } : c)) || [],
|
||||
};
|
||||
if (t.children && t.children.some((c) => c.id === id)) {
|
||||
const newChildren = t.children.map((c) =>
|
||||
c.id === id ? { ...c, completed, completedAt: completed ? new Date().toISOString() : null } : c
|
||||
);
|
||||
return { ...t, children: newChildren };
|
||||
}
|
||||
return t;
|
||||
});
|
||||
saveDemoStore(store.lists, newTasks);
|
||||
|
||||
setTasks((prev) =>
|
||||
prev
|
||||
.map((t) => {
|
||||
if (t.id === id) {
|
||||
return { ...t, completed, completedAt: completed ? new Date().toISOString() : null };
|
||||
}
|
||||
if (t.children && t.children.some((c) => c.id === id)) {
|
||||
const newChildren = t.children.map((c) =>
|
||||
c.id === id ? { ...c, completed, completedAt: completed ? new Date().toISOString() : null } : c
|
||||
);
|
||||
return { ...t, children: newChildren };
|
||||
}
|
||||
return t;
|
||||
})
|
||||
.filter((t) => showCompleted || !t.completed)
|
||||
);
|
||||
|
||||
if (selectedTask) {
|
||||
if (selectedTask.id === id) {
|
||||
setSelectedTask((prev) => (prev ? { ...prev, completed, completedAt: completed ? new Date().toISOString() : null } : prev));
|
||||
} else if (selectedTask.children && selectedTask.children.some((c) => c.id === id)) {
|
||||
setSelectedTask((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
children: prev.children.map((c) =>
|
||||
c.id === id ? { ...c, completed, completedAt: completed ? new Date().toISOString() : null } : c
|
||||
),
|
||||
}
|
||||
: prev
|
||||
);
|
||||
}
|
||||
}
|
||||
refresh();
|
||||
};
|
||||
|
||||
@@ -128,19 +232,79 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
|
||||
const store = getDemoStore();
|
||||
const newTasks = store.tasks.map((t) => (t.id === updated.id ? (updated as MockTask) : t));
|
||||
saveDemoStore(store.lists, newTasks);
|
||||
setTasks((prev) => prev.map((t) => (t.id === updated.id ? updated : t)));
|
||||
setSelectedTask(updated);
|
||||
handleTaskUpdate(updated);
|
||||
};
|
||||
|
||||
const handleDemoDeleteTask = (id: string) => {
|
||||
const store = getDemoStore();
|
||||
const newTasks = store.tasks.filter((t) => t.id !== id);
|
||||
// Remove if main task or remove from parent children
|
||||
const newTasks = store.tasks
|
||||
.filter((t) => t.id !== id)
|
||||
.map((t) => {
|
||||
if (t.children && t.children.some((c) => c.id === id)) {
|
||||
return { ...t, children: t.children.filter((c) => c.id !== id) };
|
||||
}
|
||||
return t;
|
||||
});
|
||||
saveDemoStore(store.lists, newTasks);
|
||||
setTasks((prev) => prev.filter((t) => t.id !== id));
|
||||
setSelectedTask(null);
|
||||
setTasks((prev) =>
|
||||
prev
|
||||
.filter((t) => t.id !== id)
|
||||
.map((t) => (t.children ? { ...t, children: t.children.filter((c) => c.id !== id) } : t))
|
||||
);
|
||||
if (selectedTask?.id === id) {
|
||||
setSelectedTask(null);
|
||||
} else if (selectedTask?.children?.some((c) => c.id === id)) {
|
||||
setSelectedTask((prev) => (prev ? { ...prev, children: prev.children.filter((c) => c.id !== id) } : prev));
|
||||
}
|
||||
refresh();
|
||||
};
|
||||
|
||||
// Inline update task title from main list
|
||||
const handleUpdateTaskTitle = async (taskId: string, newTitle: string) => {
|
||||
const trimmed = newTitle.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
if (isDemo) {
|
||||
const store = getDemoStore();
|
||||
const newTasks = store.tasks.map((t) => {
|
||||
if (t.id === taskId) return { ...t, title: trimmed };
|
||||
if (t.children && t.children.some((c) => c.id === taskId)) {
|
||||
return { ...t, children: t.children.map((c) => (c.id === taskId ? { ...c, title: trimmed } : c)) };
|
||||
}
|
||||
return t;
|
||||
});
|
||||
saveDemoStore(store.lists, newTasks);
|
||||
setTasks((prev) =>
|
||||
prev.map((t) => {
|
||||
if (t.id === taskId) return { ...t, title: trimmed };
|
||||
if (t.children && t.children.some((c) => c.id === taskId)) {
|
||||
return { ...t, children: t.children.map((c) => (c.id === taskId ? { ...c, title: trimmed } : c)) };
|
||||
}
|
||||
return t;
|
||||
})
|
||||
);
|
||||
if (selectedTask?.id === taskId) {
|
||||
setSelectedTask((prev) => (prev ? { ...prev, title: trimmed } : prev));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/tasks/${taskId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title: trimmed }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const updated = await res.json();
|
||||
handleTaskUpdate(updated);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to update task title", err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app-layout">
|
||||
{/* Mobile overlay */}
|
||||
@@ -182,6 +346,8 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
|
||||
isDemo={isDemo}
|
||||
onDemoAddTask={handleDemoAddTask}
|
||||
onDemoToggleTask={handleDemoToggleTask}
|
||||
onUpdateTaskTitle={handleUpdateTaskTitle}
|
||||
onUpdateListName={handleUpdateListName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
"use client";
|
||||
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
|
||||
@@ -34,13 +34,49 @@ interface TaskItemProps {
|
||||
isSelected: boolean;
|
||||
onSelect: (t: Task) => void;
|
||||
onToggle: (id: string, completed: boolean) => void;
|
||||
onUpdateTitle: (id: string, title: string) => void;
|
||||
onAddSubtask: (title: string, parentId: string) => void;
|
||||
onDeleteTask?: (id: string) => void;
|
||||
}
|
||||
|
||||
function TaskItem({ task, isSelected, onSelect, onToggle }: TaskItemProps) {
|
||||
function TaskItem({
|
||||
task,
|
||||
isSelected,
|
||||
onSelect,
|
||||
onToggle,
|
||||
onUpdateTitle,
|
||||
onAddSubtask,
|
||||
}: 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("");
|
||||
|
||||
const editInputRef = useRef<HTMLInputElement>(null);
|
||||
const subInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const completedChildren = task.children?.filter((c) => c.completed).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]);
|
||||
|
||||
const formatDate = (d: string | null) => {
|
||||
if (!d) return null;
|
||||
const date = new Date(d);
|
||||
@@ -54,12 +90,34 @@ function TaskItem({ task, isSelected, onSelect, onToggle }: TaskItemProps) {
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ position: "relative" }}>
|
||||
<div
|
||||
className={`task-item${task.completed ? " completed" : ""}${isSelected ? " selected" : ""}`}
|
||||
onClick={() => onSelect(task)}
|
||||
id={`task-${task.id}`}
|
||||
style={{ group: "item" } as React.CSSProperties}
|
||||
>
|
||||
<button
|
||||
className={`task-check-btn${task.completed ? " checked" : ""}`}
|
||||
@@ -68,9 +126,41 @@ function TaskItem({ task, isSelected, onSelect, onToggle }: TaskItemProps) {
|
||||
onToggle(task.id, !task.completed);
|
||||
}}
|
||||
aria-label={task.completed ? "Mark incomplete" : "Mark complete"}
|
||||
type="button"
|
||||
/>
|
||||
|
||||
<div className="task-body">
|
||||
<div className="task-title">{task.title}</div>
|
||||
{editingTitle ? (
|
||||
<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: 14, fontWeight: 500, height: 26, width: "100%" }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="task-title"
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditingTitle(true);
|
||||
}}
|
||||
title="Double click to edit"
|
||||
style={{ cursor: "text" }}
|
||||
>
|
||||
{task.title}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="task-meta">
|
||||
{task.priority > 0 && (
|
||||
<div
|
||||
@@ -94,6 +184,8 @@ function TaskItem({ task, isSelected, onSelect, onToggle }: TaskItemProps) {
|
||||
e.stopPropagation();
|
||||
setExpanded((p) => !p);
|
||||
}}
|
||||
title="Toggle subtasks"
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<svg
|
||||
width="10"
|
||||
@@ -109,40 +201,169 @@ function TaskItem({ task, isSelected, onSelect, onToggle }: TaskItemProps) {
|
||||
{completedChildren}/{task.children.length}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Quick Add Subtask Button */}
|
||||
<button
|
||||
className="badge badge-neutral"
|
||||
style={{ cursor: "pointer", fontSize: 10, padding: "1px 6px", border: "1px solid var(--border)", background: "transparent" }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setAddingSubtask(true);
|
||||
setExpanded(true);
|
||||
}}
|
||||
title="Add subtask"
|
||||
type="button"
|
||||
>
|
||||
+ {t("subtasks")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{task.note && !task.completed && (
|
||||
<div className="task-note-preview">{task.note.replace(/[#*`]/g, "").slice(0, 80)}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick Edit icon on hover */}
|
||||
<button
|
||||
className="icon-btn"
|
||||
style={{ width: 22, height: 22, opacity: 0.4, flexShrink: 0 }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditingTitle(true);
|
||||
}}
|
||||
title="Edit title"
|
||||
type="button"
|
||||
>
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 20h9" /><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sub-tasks */}
|
||||
{task.children && task.children.length > 0 && expanded && (
|
||||
<div className="subtask-list">
|
||||
{task.children.map((child) => (
|
||||
<div
|
||||
{/* Sub-tasks List */}
|
||||
{((task.children && task.children.length > 0) || addingSubtask) && expanded && (
|
||||
<div className="subtask-list" style={{ paddingLeft: 38 }}>
|
||||
{task.children?.map((child) => (
|
||||
<SubTaskItem
|
||||
key={child.id}
|
||||
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"}
|
||||
/>
|
||||
<span className="subtask-title">{child.title}</span>
|
||||
</div>
|
||||
child={child}
|
||||
onToggle={onToggle}
|
||||
onSelect={onSelect}
|
||||
onUpdateTitle={onUpdateTitle}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 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);
|
||||
}
|
||||
}}
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
listId: string | null;
|
||||
@@ -156,8 +377,10 @@ interface Props {
|
||||
onMenuOpen: () => void;
|
||||
onRefresh: () => void;
|
||||
isDemo?: boolean;
|
||||
onDemoAddTask?: (title: string, listId: string) => void;
|
||||
onDemoAddTask?: (title: string, listId: string, parentId?: string | null) => void;
|
||||
onDemoToggleTask?: (id: string, completed: boolean) => void;
|
||||
onUpdateTaskTitle?: (id: string, title: string) => void;
|
||||
onUpdateListName?: (id: string, name: string) => void;
|
||||
}
|
||||
|
||||
export function TaskList({
|
||||
@@ -175,13 +398,30 @@ export function TaskList({
|
||||
isDemo = false,
|
||||
onDemoAddTask,
|
||||
onDemoToggleTask,
|
||||
onUpdateTaskTitle,
|
||||
onUpdateListName,
|
||||
}: Props) {
|
||||
const { t } = useI18n();
|
||||
const [newTaskTitle, setNewTaskTitle] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [editingHeader, setEditingHeader] = useState(false);
|
||||
const [headerTitle, setHeaderTitle] = useState("");
|
||||
|
||||
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) return;
|
||||
setLoading(true);
|
||||
@@ -226,7 +466,13 @@ export function TaskList({
|
||||
prev
|
||||
.map((t) => {
|
||||
if (t.id === id) return { ...t, completed, completedAt: updated.completedAt };
|
||||
return { ...t, children: t.children?.map((c) => (c.id === id ? { ...c, completed } : c)) || [] };
|
||||
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)
|
||||
);
|
||||
@@ -244,7 +490,7 @@ export function TaskList({
|
||||
if (!title || !listId) return;
|
||||
|
||||
if (isDemo) {
|
||||
if (onDemoAddTask) onDemoAddTask(title, listId);
|
||||
if (onDemoAddTask) onDemoAddTask(title, listId, null);
|
||||
setNewTaskTitle("");
|
||||
return;
|
||||
}
|
||||
@@ -265,6 +511,41 @@ export function TaskList({
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddSubtaskInline = async (title: string, parentId: string) => {
|
||||
if (!listId) return;
|
||||
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 sub = await res.json();
|
||||
setTasks((prev) =>
|
||||
prev.map((t) => (t.id === parentId ? { ...t, children: [...(t.children || []), sub] } : t))
|
||||
);
|
||||
onRefresh();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to add subtask inline", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveHeaderTitle = () => {
|
||||
const trimmed = headerTitle.trim();
|
||||
if (trimmed && listId && onUpdateListName && trimmed !== currentList?.name) {
|
||||
onUpdateListName(listId, trimmed);
|
||||
} else if (currentList) {
|
||||
setHeaderTitle(currentList.name);
|
||||
}
|
||||
setEditingHeader(false);
|
||||
};
|
||||
|
||||
if (!listId) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", height: "100%", color: "var(--text-tertiary)" }}>
|
||||
@@ -288,9 +569,52 @@ export function TaskList({
|
||||
<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>
|
||||
<div className="main-header-title" style={{ color: currentList?.color }}>
|
||||
{currentList?.name || t("tasks")}
|
||||
</div>
|
||||
|
||||
{/* Inline Editable List Title */}
|
||||
{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", maxWidth: 360 }}
|
||||
/>
|
||||
) : (
|
||||
<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)",
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditingHeader(true);
|
||||
}}
|
||||
title="Click to rename list"
|
||||
>
|
||||
<span>{currentList?.name || t("tasks")}</span>
|
||||
<span style={{ fontSize: 13, opacity: 0.5 }}>✏️</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="main-header-actions">
|
||||
<button
|
||||
id="toggle-completed-btn"
|
||||
@@ -326,6 +650,8 @@ export function TaskList({
|
||||
isSelected={selectedTaskId === task.id}
|
||||
onSelect={onTaskSelect}
|
||||
onToggle={handleToggle}
|
||||
onUpdateTitle={(id, title) => onUpdateTaskTitle && onUpdateTaskTitle(id, title)}
|
||||
onAddSubtask={handleAddSubtaskInline}
|
||||
/>
|
||||
))}
|
||||
{showCompleted && completedTasks.length > 0 && (
|
||||
@@ -349,6 +675,8 @@ export function TaskList({
|
||||
isSelected={selectedTaskId === task.id}
|
||||
onSelect={onTaskSelect}
|
||||
onToggle={handleToggle}
|
||||
onUpdateTitle={(id, title) => onUpdateTaskTitle && onUpdateTaskTitle(id, title)}
|
||||
onAddSubtask={handleAddSubtaskInline}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user