diff --git a/scripts/docker-prune.sh b/scripts/docker-prune.sh index 7e80930c..e8b1a1fc 100644 --- a/scripts/docker-prune.sh +++ b/scripts/docker-prune.sh @@ -2,17 +2,15 @@ # Reclaim Docker's unused cache so host storage stays bounded. # # Modes: -# (default) — gentle, age-windowed (keeps rollback + rebuild speed): -# - BuildKit cache older than 72h, hard-capped at 4 GB (Debian /pnpm store -# is shared across builds; everything newer speeds up rebuilds). -# - Images referenced by NO container and older than 7 days. -# - Containers stopped for more than 24h. -# --force — emergency mode ("never let the disk max out"): drops every age -# window and reclaims all unused bytes Docker can free: +# (default) — post-deploy cleanup (safe, fast): +# - ALL unreferenced build cache (new build will re-populate what it needs). +# - ALL unreferenced images older than 1h (keeps current image + very recent). +# - ALL stopped containers older than 1h. +# --force — emergency mode ("never let the disk max out"): drops everything +# with no age windows: # - ALL unreferenced build cache, -# - ALL unreferenced images (no 7-day grace), +# - ALL unreferenced images (no age grace), # - ALL stopped containers. -# Trade-off: waiting rebuilds re-fetch deps/images later. # # Volumes are NEVER pruned in either mode: mariadb-turbo-data is a database. # Idempotent; exits 0 when Docker is unavailable. @@ -41,9 +39,9 @@ if (( FORCE )); then docker image prune -af >>"$LOG_FILE" 2>&1 || true docker container prune -f >>"$LOG_FILE" 2>&1 || true else - docker builder prune -af --filter "until=72h" --max-used-space=4g >>"$LOG_FILE" 2>&1 || true - docker image prune -af --filter "until=168h" >>"$LOG_FILE" 2>&1 || true - docker container prune -f --filter "until=24h" >>"$LOG_FILE" 2>&1 || true + docker builder prune -af >>"$LOG_FILE" 2>&1 || true + docker image prune -af --filter "until=1h" >>"$LOG_FILE" 2>&1 || true + docker container prune -f --filter "until=1h" >>"$LOG_FILE" 2>&1 || true fi printf '\n[%s] === docker prune complete%s ===\n' "$(now)" "$( (( FORCE )) && printf ' (FORCE)' )" >>"$LOG_FILE" diff --git a/src/components/admin/catalog-manager/catalog-manager-dialog.tsx b/src/components/admin/catalog-manager/catalog-manager-dialog.tsx index 6ba82e13..bca763d1 100644 --- a/src/components/admin/catalog-manager/catalog-manager-dialog.tsx +++ b/src/components/admin/catalog-manager/catalog-manager-dialog.tsx @@ -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 => { + 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; } }; diff --git a/src/components/admin/catalog-manager/root-tabs.tsx b/src/components/admin/catalog-manager/root-tabs.tsx index 177c187c..ade14412 100644 --- a/src/components/admin/catalog-manager/root-tabs.tsx +++ b/src/components/admin/catalog-manager/root-tabs.tsx @@ -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; onEditTab: (id: number) => void | Promise; - onCreateRoot: () => void; + onCreateRoot: (caption: string) => Promise | boolean; loading?: boolean; onMutation?: () => void; } @@ -85,10 +87,35 @@ export function RootTabs({ null, ); const [activeDragId, setActiveDragId] = useState(null); + const [creatingRoot, setCreatingRoot] = useState(false); + const [newRootName, setNewRootName] = useState(""); + const newRootInputRef = useRef(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({ )} - {canEdit && ( + {canEdit && !creatingRoot && ( )} + {canEdit && creatingRoot && ( +
+ 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(""); + } + }} + /> + + +
+ )} ); } diff --git a/src/components/admin/catalog-manager/sortable-tree.tsx b/src/components/admin/catalog-manager/sortable-tree.tsx index 8572269a..5e221e88 100644 --- a/src/components/admin/catalog-manager/sortable-tree.tsx +++ b/src/components/admin/catalog-manager/sortable-tree.tsx @@ -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 => 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 => Boolean(n)), + }); + dispatch({ + type: "SET_CHILDREN", + parentId: newParentId, + children: newSiblings + .map((id) => nodes.get(id)) + .filter((n): n is NonNullable => 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 => Boolean(n)), + }); + dispatch({ + type: "SET_CHILDREN", + parentId: newParentId, + children: prevNewSiblings + .map((id) => nodes.get(id)) + .filter((n): n is NonNullable => 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; - 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"); diff --git a/src/components/admin/catalog-manager/use-catalog-store.tsx b/src/components/admin/catalog-manager/use-catalog-store.tsx index 9d293d36..85ec27fd 100644 --- a/src/components/admin/catalog-manager/use-catalog-store.tsx +++ b/src/components/admin/catalog-manager/use-catalog-store.tsx @@ -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 }; } diff --git a/src/types/catalog.ts b/src/types/catalog.ts index 442131c5..b22b319d 100644 --- a/src/types/catalog.ts +++ b/src/types/catalog.ts @@ -39,6 +39,8 @@ export interface TreeNodeUpdate { visible?: string; pageLayout?: string; iconImage?: number; + parentId?: number; + orderNum?: number; } export interface RootTab {