Improve catalog studio UX and aggressive docker cleanup
Catalog Studio: - Cross-parent drag & drop now uses optimistic updates with rollback on failure (no more full tree reload / visible delay) - Subpage creation adds the node optimistically then refreshes parent only (was full tree reload) - Single page deletion refreshes only the affected parent (was full tree reload) - Root page creation replaces native prompt() with an inline input in the root tab bar - Escape key no longer closes the dialog when an input field is focused - TreeNodeUpdate type now supports parentId and orderNum for optimistic structural changes Docker: - docker-prune.sh default mode now aggressively cleans all unreferenced build cache, images >1h old, and stopped containers >1h old (was 72h/7d/24h which let cache grow past 80% on every push)
This commit is contained in:
1 parent
349188af10
commit
1a9e1e791a
6 files changed
+217
-40
No files matched your search
@@ -200,6 +200,9 @@ function CatalogManagerInner({
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (embedded || event.key !== "Escape" || event.defaultPrevented) return;
|
||||
const target = event.target as HTMLElement;
|
||||
// Never steal Escape from inputs/comboboxes (e.g. root name field).
|
||||
const tag = target?.tagName;
|
||||
if (tag === "INPUT" || tag === "TEXTAREA") return;
|
||||
const focusedDialog = target.closest?.("[role=dialog]");
|
||||
if (
|
||||
focusedDialog &&
|
||||
@@ -282,26 +285,26 @@ function CatalogManagerInner({
|
||||
dispatch({ type: "SET_ACTIVE_TAB", id });
|
||||
};
|
||||
|
||||
const handleCreateRoot = async () => {
|
||||
if (!canEdit) return;
|
||||
const caption = prompt("Root page name:");
|
||||
if (!caption?.trim()) return;
|
||||
const handleCreateRoot = async (caption: string): Promise<boolean> => {
|
||||
if (!canEdit) return false;
|
||||
try {
|
||||
const result =
|
||||
catalogType === "bc"
|
||||
? await createBcPage({
|
||||
parentId: -1,
|
||||
caption: caption.trim(),
|
||||
caption,
|
||||
})
|
||||
: await createCatalogPage({
|
||||
parentId: -1,
|
||||
caption: caption.trim(),
|
||||
caption,
|
||||
});
|
||||
if (!result.ok) throw new Error(result.error);
|
||||
await loadRootPages();
|
||||
toast.success("Root page created");
|
||||
return true;
|
||||
} catch {
|
||||
toast.error("Failed to create root page");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -25,9 +25,10 @@ import {
|
||||
Power,
|
||||
PowerOff,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
deleteTreePage,
|
||||
@@ -49,6 +50,7 @@ import {
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { requestCatalogNavigation } from "@/features/catalog/client/use-catalog-navigation";
|
||||
import { DuplicateCategoryDialog } from "@/features/catalog/components/duplicate-category-dialog";
|
||||
import type { DuplicateInput } from "@/features/catalog/domain/duplicate";
|
||||
@@ -64,7 +66,7 @@ interface RootTabsProps {
|
||||
activeTabId: number | null;
|
||||
onTabClick: (id: number) => void | Promise<void>;
|
||||
onEditTab: (id: number) => void | Promise<void>;
|
||||
onCreateRoot: () => void;
|
||||
onCreateRoot: (caption: string) => Promise<boolean> | boolean;
|
||||
loading?: boolean;
|
||||
onMutation?: () => void;
|
||||
}
|
||||
@@ -85,10 +87,35 @@ export function RootTabs({
|
||||
null,
|
||||
);
|
||||
const [activeDragId, setActiveDragId] = useState<number | null>(null);
|
||||
const [creatingRoot, setCreatingRoot] = useState(false);
|
||||
const [newRootName, setNewRootName] = useState("");
|
||||
const newRootInputRef = useRef<HTMLInputElement>(null);
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||
);
|
||||
|
||||
const startCreateRoot = useCallback(() => {
|
||||
if (!canEdit) return;
|
||||
setCreatingRoot(true);
|
||||
setNewRootName("");
|
||||
requestAnimationFrame(() => {
|
||||
newRootInputRef.current?.focus();
|
||||
});
|
||||
}, [canEdit]);
|
||||
|
||||
const handleCreateRootSubmit = async () => {
|
||||
if (!canEdit || !newRootName.trim()) return;
|
||||
try {
|
||||
const ok = await onCreateRoot(newRootName.trim());
|
||||
if (ok) {
|
||||
setCreatingRoot(false);
|
||||
setNewRootName("");
|
||||
}
|
||||
} catch {
|
||||
// Keep the input open so the operator can retry.
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||
setActiveDragId(event.active.id as number);
|
||||
}, []);
|
||||
@@ -228,18 +255,61 @@ export function RootTabs({
|
||||
)}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
{canEdit && (
|
||||
{canEdit && !creatingRoot && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0 ml-1"
|
||||
title="New root page"
|
||||
onClick={onCreateRoot}
|
||||
onClick={startCreateRoot}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{canEdit && creatingRoot && (
|
||||
<div className="ml-1 flex shrink-0 items-center gap-1.5 rounded-md border bg-muted/40 p-1">
|
||||
<Input
|
||||
ref={newRootInputRef}
|
||||
value={newRootName}
|
||||
onChange={(e) => setNewRootName(e.target.value)}
|
||||
placeholder="Root page name…"
|
||||
className="h-7 w-40 text-sm"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void handleCreateRootSubmit();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setCreatingRoot(false);
|
||||
setNewRootName("");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="h-7 shrink-0 px-2"
|
||||
disabled={!newRootName.trim()}
|
||||
onClick={() => void handleCreateRootSubmit()}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 shrink-0 px-2"
|
||||
onClick={() => {
|
||||
setCreatingRoot(false);
|
||||
setNewRootName("");
|
||||
}}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -183,7 +183,21 @@ export function SortableTree({
|
||||
}
|
||||
}, [activeTabId, dispatch, loadChildren]);
|
||||
|
||||
// Expose reload for mutations (delete/move/create)
|
||||
/** Reload a specific parent's children after mutation (targeted refresh). */
|
||||
const refreshParent = useCallback(
|
||||
async (parentId: number) => {
|
||||
try {
|
||||
const pages = await loadChildren(parentId);
|
||||
dispatch({ type: "SET_CHILDREN", parentId, children: pages });
|
||||
} catch {
|
||||
// Fallback to full reload only on error
|
||||
await loadTabTree();
|
||||
}
|
||||
},
|
||||
[loadChildren, dispatch, loadTabTree],
|
||||
);
|
||||
|
||||
// Expose reload for mutations spanning multiple parents (bulk/duplicate)
|
||||
const loadFullTree = loadTabTree;
|
||||
|
||||
// Load tab children when active root changes (same as Catalog Structure expand)
|
||||
@@ -442,6 +456,7 @@ export function SortableTree({
|
||||
if (!activeNode || !overNode) return;
|
||||
|
||||
if (activeNode.parentId === overNode.parentId) {
|
||||
// ── Same-parent reorder ──────────────────────────────
|
||||
const parentId = activeNode.parentId;
|
||||
const siblings = childrenMap.get(parentId) || [];
|
||||
const oldIndex = siblings.indexOf(active.id as number);
|
||||
@@ -456,7 +471,6 @@ export function SortableTree({
|
||||
.filter((n): n is NonNullable<typeof n> => Boolean(n)),
|
||||
});
|
||||
|
||||
if (!canEdit) return;
|
||||
const reorder =
|
||||
catalogType === "bc" ? reorderBcCatalogPages : reorderCatalogPages;
|
||||
try {
|
||||
@@ -470,27 +484,88 @@ export function SortableTree({
|
||||
onTreeMutation();
|
||||
} catch {
|
||||
toast.error("Failed to reorder");
|
||||
loadFullTree();
|
||||
await refreshParent(parentId);
|
||||
}
|
||||
} else {
|
||||
if (!canEdit) return;
|
||||
// ── Cross-parent move (optimistic) ───────────────────
|
||||
const movedId = active.id as number;
|
||||
const newParentId = overNode.parentId;
|
||||
const oldParentId = activeNode.parentId;
|
||||
const overIndex = (childrenMap.get(newParentId) || []).indexOf(
|
||||
over.id as number,
|
||||
);
|
||||
const newOrderNum =
|
||||
overIndex >= 0
|
||||
? overIndex + 1
|
||||
: (childrenMap.get(newParentId) || []).length + 1;
|
||||
|
||||
// Snapshot for rollback
|
||||
const prevOldSiblings = [...(childrenMap.get(oldParentId) || [])];
|
||||
const prevNewSiblings = [...(childrenMap.get(newParentId) || [])];
|
||||
|
||||
// Optimistically move: remove from old parent, insert into new parent
|
||||
const newSiblings = [...prevNewSiblings];
|
||||
const insertIdx = newSiblings.indexOf(over.id as number);
|
||||
if (insertIdx >= 0) newSiblings.splice(insertIdx, 0, movedId);
|
||||
else newSiblings.push(movedId);
|
||||
|
||||
dispatch({
|
||||
type: "SET_CHILDREN",
|
||||
parentId: oldParentId,
|
||||
children: prevOldSiblings
|
||||
.filter((id) => id !== movedId)
|
||||
.map((id) => nodes.get(id))
|
||||
.filter((n): n is NonNullable<typeof n> => Boolean(n)),
|
||||
});
|
||||
dispatch({
|
||||
type: "SET_CHILDREN",
|
||||
parentId: newParentId,
|
||||
children: newSiblings
|
||||
.map((id) => nodes.get(id))
|
||||
.filter((n): n is NonNullable<typeof n> => Boolean(n)),
|
||||
});
|
||||
dispatch({
|
||||
type: "UPDATE_NODE",
|
||||
update: { id: movedId, parentId: newParentId },
|
||||
});
|
||||
|
||||
const reorder =
|
||||
catalogType === "bc" ? reorderBcTreePage : reorderTreePage;
|
||||
const newParentId = overNode.parentId;
|
||||
const siblings = childrenMap.get(newParentId) || [];
|
||||
const overIndex = siblings.indexOf(over.id as number);
|
||||
try {
|
||||
const result = await reorder({
|
||||
pageId: active.id as number,
|
||||
pageId: movedId,
|
||||
newParentId,
|
||||
newOrderNum: overIndex >= 0 ? overIndex + 1 : siblings.length + 1,
|
||||
newOrderNum,
|
||||
});
|
||||
if (!result.ok) throw new Error();
|
||||
await loadFullTree();
|
||||
// Refresh both parents to get correct server-side order
|
||||
await Promise.all([
|
||||
refreshParent(oldParentId),
|
||||
refreshParent(newParentId),
|
||||
]);
|
||||
onTreeMutation();
|
||||
toast.success("Page moved");
|
||||
} catch {
|
||||
toast.error("Failed to move page");
|
||||
// Rollback: restore both parent children lists
|
||||
dispatch({
|
||||
type: "SET_CHILDREN",
|
||||
parentId: oldParentId,
|
||||
children: prevOldSiblings
|
||||
.map((id) => nodes.get(id))
|
||||
.filter((n): n is NonNullable<typeof n> => Boolean(n)),
|
||||
});
|
||||
dispatch({
|
||||
type: "SET_CHILDREN",
|
||||
parentId: newParentId,
|
||||
children: prevNewSiblings
|
||||
.map((id) => nodes.get(id))
|
||||
.filter((n): n is NonNullable<typeof n> => Boolean(n)),
|
||||
});
|
||||
dispatch({
|
||||
type: "UPDATE_NODE",
|
||||
update: { id: movedId, parentId: oldParentId },
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -505,22 +580,43 @@ export function SortableTree({
|
||||
const handleCreateSubpage = async () => {
|
||||
if (!canEdit || subpageParentId === null || !subpageName.trim()) return;
|
||||
setCreatingSubpage(true);
|
||||
const parentId = subpageParentId;
|
||||
const name = subpageName.trim();
|
||||
try {
|
||||
const result =
|
||||
catalogType === "bc"
|
||||
? await createBcPage({
|
||||
parentId: subpageParentId,
|
||||
caption: subpageName.trim(),
|
||||
parentId,
|
||||
caption: name,
|
||||
})
|
||||
: await createCatalogPage({
|
||||
parentId: subpageParentId,
|
||||
caption: subpageName.trim(),
|
||||
parentId,
|
||||
caption: name,
|
||||
});
|
||||
if (!result.ok) throw new Error(result.error);
|
||||
dispatch({ type: "SET_EXPANDED", id: subpageParentId, expanded: true });
|
||||
await loadFullTree();
|
||||
const data = result.data as Record<string, unknown>;
|
||||
onSelectPage(data.id as number);
|
||||
const newId = data.id as number;
|
||||
|
||||
// Optimistically add the new node to the tree
|
||||
const newNode: TreeNodeData = {
|
||||
id: newId,
|
||||
parentId,
|
||||
caption: name,
|
||||
enabled: "1",
|
||||
visible: "1",
|
||||
iconImage: 0,
|
||||
iconColor: 0,
|
||||
pageLayout: "default_3x3",
|
||||
childCount: 0,
|
||||
itemCount: 0,
|
||||
orderNum: 0,
|
||||
};
|
||||
dispatch({ type: "SET_NODES", nodes: [newNode] });
|
||||
dispatch({ type: "SET_EXPANDED", id: parentId, expanded: true });
|
||||
|
||||
// Refresh parent children to get correct server-side order
|
||||
await refreshParent(parentId);
|
||||
onSelectPage(newId);
|
||||
onTreeMutation();
|
||||
setSubpageParentId(null);
|
||||
setSubpageName("");
|
||||
@@ -567,6 +663,7 @@ export function SortableTree({
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!canEdit) return;
|
||||
const node = nodes.get(id);
|
||||
const parentId = node?.parentId ?? -1;
|
||||
const childInfo =
|
||||
node && node.childCount > 0
|
||||
? ` Its ${node.childCount} child page(s) will be moved to the parent.`
|
||||
@@ -589,7 +686,8 @@ export function SortableTree({
|
||||
if (!result.ok) throw new Error();
|
||||
dispatch({ type: "REMOVE_NODE", id });
|
||||
if (selectedPageId === id) onSelectPage(-1);
|
||||
await loadFullTree();
|
||||
// Children were reparented up — refresh only the affected parent.
|
||||
await refreshParent(parentId);
|
||||
onTreeMutation();
|
||||
} catch {
|
||||
toast.error("Failed to delete page");
|
||||
|
||||
@@ -233,6 +233,12 @@ function reducer(
|
||||
...(action.update.iconImage !== undefined && {
|
||||
iconImage: action.update.iconImage,
|
||||
}),
|
||||
...(action.update.parentId !== undefined && {
|
||||
parentId: action.update.parentId,
|
||||
}),
|
||||
...(action.update.orderNum !== undefined && {
|
||||
orderNum: action.update.orderNum,
|
||||
}),
|
||||
});
|
||||
return { ...state, nodes: nextNodes };
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user