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
Build and Push Docker Image / build-and-push (push) Successful in 9m22s
This commit is contained in:
+196
-100
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user