feat(ui): Redesign sidebar, one-touch toggles, fix trash & subtask optimistic UI, auto-admin, and full n-depth DND
Build and Push Docker Image / build-and-push (push) Successful in 10m30s
Build and Push Docker Image / build-and-push (push) Successful in 10m30s
This commit is contained in:
@@ -4,6 +4,7 @@ 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 }
|
||||
|
||||
@@ -209,10 +210,11 @@ function RecursiveTaskItem({
|
||||
{!isTrashMode && (
|
||||
<div
|
||||
className="task-drag-handle"
|
||||
draggable
|
||||
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"
|
||||
@@ -543,11 +545,12 @@ export function TaskList({
|
||||
|
||||
const { prefs, updatePrefs } = useUserPrefs();
|
||||
// Only show kanban if the Labs flag is explicitly enabled
|
||||
const kanbanEnabled = prefs.labs?.kanbanBoard ?? false;
|
||||
const viewMode = kanbanEnabled ? prefs.viewMode : "list";
|
||||
const kanbanEnabled = Boolean(prefs.labs?.kanbanBoard);
|
||||
const viewMode = kanbanEnabled ? (prefs.viewMode || "list") : "list";
|
||||
|
||||
const handleViewModeChange = (mode: "list" | "kanban") => {
|
||||
updatePrefs({ viewMode: mode });
|
||||
const handleToggleViewMode = () => {
|
||||
const nextMode = viewMode === "kanban" ? "list" : "kanban";
|
||||
updatePrefs({ viewMode: nextMode });
|
||||
};
|
||||
|
||||
// TickTick-style Quick Add Preset states
|
||||
@@ -598,8 +601,35 @@ export function TaskList({
|
||||
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;
|
||||
@@ -615,9 +645,11 @@ export function TaskList({
|
||||
onRefresh();
|
||||
} catch (err) {
|
||||
console.error("[TaskList] handleToggle failed", err);
|
||||
// Rollback on failure
|
||||
fetchTasks();
|
||||
}
|
||||
},
|
||||
[isDemo, onDemoToggleTask, onRefresh]
|
||||
[isDemo, onDemoToggleTask, onRefresh, showCompleted, setTasks, fetchTasks]
|
||||
);
|
||||
|
||||
const handleAddTask = async (e: React.FormEvent) => {
|
||||
@@ -656,8 +688,43 @@ export function TaskList({
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
@@ -670,10 +737,27 @@ export function TaskList({
|
||||
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();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -687,21 +771,69 @@ 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) => {
|
||||
const draggedIndex = prev.findIndex((t) => t.id === draggedId);
|
||||
const targetIndex = prev.findIndex((t) => t.id === targetId);
|
||||
if (draggedIndex === -1 || targetIndex === -1) return prev;
|
||||
// Helper to find and remove a node from the tree
|
||||
let extractedNode: Task | null = null;
|
||||
|
||||
const newArr = [...prev];
|
||||
const [draggedItem] = newArr.splice(draggedIndex, 1);
|
||||
const insertAt = position === "before"
|
||||
? (draggedIndex < targetIndex ? targetIndex - 1 : targetIndex)
|
||||
: (draggedIndex < targetIndex ? targetIndex : targetIndex + 1);
|
||||
newArr.splice(Math.max(0, insertAt), 0, draggedItem);
|
||||
return newArr;
|
||||
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;
|
||||
|
||||
// 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 hasInserted = false;
|
||||
const newNodes = nodes.map((node) => {
|
||||
if (!hasInserted && node.children && node.children.length > 0) {
|
||||
const res = insertNode(node.children);
|
||||
if (res.inserted) {
|
||||
hasInserted = true;
|
||||
return { ...node, children: res.list };
|
||||
}
|
||||
}
|
||||
return node;
|
||||
});
|
||||
|
||||
return { list: newNodes, inserted: hasInserted };
|
||||
};
|
||||
|
||||
const finalTree = insertNode(treeWithoutDragged).list;
|
||||
|
||||
// Update demo store or backend sort orders
|
||||
if (isDemo && typeof window !== "undefined") {
|
||||
const store = getDemoStore();
|
||||
saveDemoStore(store.lists, finalTree as unknown as MockTask[]);
|
||||
}
|
||||
|
||||
return finalTree;
|
||||
});
|
||||
}, [setTasks]);
|
||||
}, [isDemo, setTasks]);
|
||||
|
||||
const handleDeleteTask = useCallback((task: Task) => {
|
||||
if (onDeleteTaskWithUndo) {
|
||||
@@ -793,26 +925,33 @@ export function TaskList({
|
||||
|
||||
{/* Header Actions */}
|
||||
<div className="main-header-actions" style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
{/* View Switcher: List vs Kanban (Hidden in Trash/Tag mode) */}
|
||||
{!isTrashActive && !selectedTag && (
|
||||
<div className="view-switcher-group">
|
||||
<button
|
||||
type="button"
|
||||
className={`view-switcher-btn${viewMode === "list" ? " active" : ""}`}
|
||||
onClick={() => handleViewModeChange("list")}
|
||||
title={t("listView")}
|
||||
>
|
||||
📋 <span className="desktop-only">{t("listView")}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`view-switcher-btn${viewMode === "kanban" ? " active" : ""}`}
|
||||
onClick={() => handleViewModeChange("kanban")}
|
||||
title={t("kanbanView")}
|
||||
>
|
||||
📊 <span className="desktop-only">{t("kanbanView")}</span>
|
||||
</button>
|
||||
</div>
|
||||
{/* 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 ? (
|
||||
|
||||
Reference in New Issue
Block a user