feat(ux): enhance n-depth tree DND reordering, full i18n localization, admin live stats & sidebar redesign
Build and Push Docker Image / build-and-push (push) Successful in 9m22s

This commit is contained in:
2026-08-22 09:08:10 +09:00
parent 06d5344aef
commit 0f44090210
18 changed files with 1307 additions and 651 deletions
+196 -100
View File
@@ -44,7 +44,7 @@ interface TaskItemProps {
isTrashMode?: boolean;
onRestore?: (id: string) => void;
onPermanentDelete?: (id: string) => void;
onDragTask?: (draggedId: string, targetId: string, position: "before" | "after") => void;
onDragTask?: (draggedId: string, targetId: string, position: "before" | "after" | "inside") => void;
}
// Recursive Task Tree Item (Supports 1st, 2nd, 3rd, N-level sub-tasks seamlessly)
@@ -157,7 +157,7 @@ function RecursiveTaskItem({
touchStartX.current = null;
};
const [dragOverPos, setDragOverPos] = useState<"top" | "bottom" | null>(null);
const [dragOverPos, setDragOverPos] = useState<"top" | "bottom" | "inside" | null>(null);
return (
<div style={{ paddingLeft: depth > 0 ? 24 : 0, position: "relative" }}>
@@ -185,8 +185,15 @@ function RecursiveTaskItem({
e.preventDefault();
e.dataTransfer.dropEffect = "move";
const rect = e.currentTarget.getBoundingClientRect();
const mid = rect.top + rect.height / 2;
setDragOverPos(e.clientY < mid ? "top" : "bottom");
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) => {
@@ -194,7 +201,9 @@ function RecursiveTaskItem({
e.preventDefault();
const draggedId = e.dataTransfer.getData("text/plain");
if (draggedId && draggedId !== task.id && onDragTask) {
onDragTask(draggedId, task.id, dragOverPos === "top" ? "before" : "after");
const pos: "before" | "after" | "inside" =
dragOverPos === "top" ? "before" : dragOverPos === "bottom" ? "after" : "inside";
onDragTask(draggedId, task.id, pos);
}
setDragOverPos(null);
}}
@@ -204,6 +213,9 @@ function RecursiveTaskItem({
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 */}
@@ -771,17 +783,43 @@ export function TaskList({
setEditingHeader(false);
};
// Tree-aware reordering function that supports reordering both top-level tasks and nested subtasks
const handleReorderTasks = useCallback((draggedId: string, targetId: string, position: "before" | "after") => {
setTasks((prev) => {
// Helper to find and remove a node from the tree
let extractedNode: Task | null = null;
// 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;
extractedNode = { ...node };
} else {
const updatedNode = { ...node };
if (updatedNode.children && updatedNode.children.length > 0) {
@@ -795,43 +833,93 @@ export function TaskList({
const treeWithoutDragged = removeNode(prev);
if (!extractedNode) return prev;
const safeExtractedNode: Task = extractedNode;
// Helper to insert the extracted node adjacent to targetId in the same list/level
const insertNode = (nodes: Task[]): { list: Task[]; inserted: boolean } => {
const idx = nodes.findIndex((n) => n.id === targetId);
if (idx !== -1) {
const newNodes = [...nodes];
const insertIdx = position === "before" ? idx : idx + 1;
// Maintain target's parentId for the dragged node
const adjustedNode = { ...extractedNode!, parentId: nodes[idx].parentId };
newNodes.splice(insertIdx, 0, adjustedNode);
return { list: newNodes, inserted: true };
}
let determinedParentId: string | null = null;
let hasInserted = false;
const newNodes = nodes.map((node) => {
if (!hasInserted && node.children && node.children.length > 0) {
const res = insertNode(node.children);
if (res.inserted) {
// 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;
return { ...node, children: res.list };
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 };
}
return node;
});
return { list: newNodes, inserted: hasInserted };
};
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;
});
const finalTree = insertNode(treeWithoutDragged).list;
return { list: newNodes, inserted: hasInserted };
};
// Update demo store or backend sort orders
if (isDemo && typeof window !== "undefined") {
const store = getDemoStore();
saveDemoStore(store.lists, finalTree as unknown as MockTask[]);
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;
}
return finalTree;
});
}, [isDemo, setTasks]);
@@ -861,70 +949,78 @@ export function TaskList({
return (
<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" 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>
<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 }}>
<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 }}>
<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", 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>
)}
{/* 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={{ display: "flex", alignItems: "center", gap: 8 }}>
<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