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
+10
-12
@@ -2,17 +2,15 @@
|
|||||||
# Reclaim Docker's unused cache so host storage stays bounded.
|
# Reclaim Docker's unused cache so host storage stays bounded.
|
||||||
#
|
#
|
||||||
# Modes:
|
# Modes:
|
||||||
# (default) — gentle, age-windowed (keeps rollback + rebuild speed):
|
# (default) — post-deploy cleanup (safe, fast):
|
||||||
# - BuildKit cache older than 72h, hard-capped at 4 GB (Debian /pnpm store
|
# - ALL unreferenced build cache (new build will re-populate what it needs).
|
||||||
# is shared across builds; everything newer speeds up rebuilds).
|
# - ALL unreferenced images older than 1h (keeps current image + very recent).
|
||||||
# - Images referenced by NO container and older than 7 days.
|
# - ALL stopped containers older than 1h.
|
||||||
# - Containers stopped for more than 24h.
|
# --force — emergency mode ("never let the disk max out"): drops everything
|
||||||
# --force — emergency mode ("never let the disk max out"): drops every age
|
# with no age windows:
|
||||||
# window and reclaims all unused bytes Docker can free:
|
|
||||||
# - ALL unreferenced build cache,
|
# - ALL unreferenced build cache,
|
||||||
# - ALL unreferenced images (no 7-day grace),
|
# - ALL unreferenced images (no age grace),
|
||||||
# - ALL stopped containers.
|
# - 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.
|
# Volumes are NEVER pruned in either mode: mariadb-turbo-data is a database.
|
||||||
# Idempotent; exits 0 when Docker is unavailable.
|
# Idempotent; exits 0 when Docker is unavailable.
|
||||||
@@ -41,9 +39,9 @@ if (( FORCE )); then
|
|||||||
docker image prune -af >>"$LOG_FILE" 2>&1 || true
|
docker image prune -af >>"$LOG_FILE" 2>&1 || true
|
||||||
docker container prune -f >>"$LOG_FILE" 2>&1 || true
|
docker container prune -f >>"$LOG_FILE" 2>&1 || true
|
||||||
else
|
else
|
||||||
docker builder prune -af --filter "until=72h" --max-used-space=4g >>"$LOG_FILE" 2>&1 || true
|
docker builder prune -af >>"$LOG_FILE" 2>&1 || true
|
||||||
docker image prune -af --filter "until=168h" >>"$LOG_FILE" 2>&1 || true
|
docker image prune -af --filter "until=1h" >>"$LOG_FILE" 2>&1 || true
|
||||||
docker container prune -f --filter "until=24h" >>"$LOG_FILE" 2>&1 || true
|
docker container prune -f --filter "until=1h" >>"$LOG_FILE" 2>&1 || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
printf '\n[%s] === docker prune complete%s ===\n' "$(now)" "$( (( FORCE )) && printf ' (FORCE)' )" >>"$LOG_FILE"
|
printf '\n[%s] === docker prune complete%s ===\n' "$(now)" "$( (( FORCE )) && printf ' (FORCE)' )" >>"$LOG_FILE"
|
||||||
|
|||||||
@@ -200,6 +200,9 @@ function CatalogManagerInner({
|
|||||||
const onKey = (event: KeyboardEvent) => {
|
const onKey = (event: KeyboardEvent) => {
|
||||||
if (embedded || event.key !== "Escape" || event.defaultPrevented) return;
|
if (embedded || event.key !== "Escape" || event.defaultPrevented) return;
|
||||||
const target = event.target as HTMLElement;
|
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]");
|
const focusedDialog = target.closest?.("[role=dialog]");
|
||||||
if (
|
if (
|
||||||
focusedDialog &&
|
focusedDialog &&
|
||||||
@@ -282,26 +285,26 @@ function CatalogManagerInner({
|
|||||||
dispatch({ type: "SET_ACTIVE_TAB", id });
|
dispatch({ type: "SET_ACTIVE_TAB", id });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateRoot = async () => {
|
const handleCreateRoot = async (caption: string): Promise<boolean> => {
|
||||||
if (!canEdit) return;
|
if (!canEdit) return false;
|
||||||
const caption = prompt("Root page name:");
|
|
||||||
if (!caption?.trim()) return;
|
|
||||||
try {
|
try {
|
||||||
const result =
|
const result =
|
||||||
catalogType === "bc"
|
catalogType === "bc"
|
||||||
? await createBcPage({
|
? await createBcPage({
|
||||||
parentId: -1,
|
parentId: -1,
|
||||||
caption: caption.trim(),
|
caption,
|
||||||
})
|
})
|
||||||
: await createCatalogPage({
|
: await createCatalogPage({
|
||||||
parentId: -1,
|
parentId: -1,
|
||||||
caption: caption.trim(),
|
caption,
|
||||||
});
|
});
|
||||||
if (!result.ok) throw new Error(result.error);
|
if (!result.ok) throw new Error(result.error);
|
||||||
await loadRootPages();
|
await loadRootPages();
|
||||||
toast.success("Root page created");
|
toast.success("Root page created");
|
||||||
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Failed to create root page");
|
toast.error("Failed to create root page");
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -25,9 +25,10 @@ import {
|
|||||||
Power,
|
Power,
|
||||||
PowerOff,
|
PowerOff,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
deleteTreePage,
|
deleteTreePage,
|
||||||
@@ -49,6 +50,7 @@ import {
|
|||||||
ContextMenuSeparator,
|
ContextMenuSeparator,
|
||||||
ContextMenuTrigger,
|
ContextMenuTrigger,
|
||||||
} from "@/components/ui/context-menu";
|
} from "@/components/ui/context-menu";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
import { requestCatalogNavigation } from "@/features/catalog/client/use-catalog-navigation";
|
import { requestCatalogNavigation } from "@/features/catalog/client/use-catalog-navigation";
|
||||||
import { DuplicateCategoryDialog } from "@/features/catalog/components/duplicate-category-dialog";
|
import { DuplicateCategoryDialog } from "@/features/catalog/components/duplicate-category-dialog";
|
||||||
import type { DuplicateInput } from "@/features/catalog/domain/duplicate";
|
import type { DuplicateInput } from "@/features/catalog/domain/duplicate";
|
||||||
@@ -64,7 +66,7 @@ interface RootTabsProps {
|
|||||||
activeTabId: number | null;
|
activeTabId: number | null;
|
||||||
onTabClick: (id: number) => void | Promise<void>;
|
onTabClick: (id: number) => void | Promise<void>;
|
||||||
onEditTab: (id: number) => void | Promise<void>;
|
onEditTab: (id: number) => void | Promise<void>;
|
||||||
onCreateRoot: () => void;
|
onCreateRoot: (caption: string) => Promise<boolean> | boolean;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
onMutation?: () => void;
|
onMutation?: () => void;
|
||||||
}
|
}
|
||||||
@@ -85,10 +87,35 @@ export function RootTabs({
|
|||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const [activeDragId, setActiveDragId] = useState<number | null>(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(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
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) => {
|
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||||
setActiveDragId(event.active.id as number);
|
setActiveDragId(event.active.id as number);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -228,18 +255,61 @@ export function RootTabs({
|
|||||||
)}
|
)}
|
||||||
</DragOverlay>
|
</DragOverlay>
|
||||||
</DndContext>
|
</DndContext>
|
||||||
{canEdit && (
|
{canEdit && !creatingRoot && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7 shrink-0 ml-1"
|
className="h-7 w-7 shrink-0 ml-1"
|
||||||
title="New root page"
|
title="New root page"
|
||||||
onClick={onCreateRoot}
|
onClick={startCreateRoot}
|
||||||
>
|
>
|
||||||
<Plus className="h-3.5 w-3.5" />
|
<Plus className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -183,7 +183,21 @@ export function SortableTree({
|
|||||||
}
|
}
|
||||||
}, [activeTabId, dispatch, loadChildren]);
|
}, [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;
|
const loadFullTree = loadTabTree;
|
||||||
|
|
||||||
// Load tab children when active root changes (same as Catalog Structure expand)
|
// 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 || !overNode) return;
|
||||||
|
|
||||||
if (activeNode.parentId === overNode.parentId) {
|
if (activeNode.parentId === overNode.parentId) {
|
||||||
|
// ── Same-parent reorder ──────────────────────────────
|
||||||
const parentId = activeNode.parentId;
|
const parentId = activeNode.parentId;
|
||||||
const siblings = childrenMap.get(parentId) || [];
|
const siblings = childrenMap.get(parentId) || [];
|
||||||
const oldIndex = siblings.indexOf(active.id as number);
|
const oldIndex = siblings.indexOf(active.id as number);
|
||||||
@@ -456,7 +471,6 @@ export function SortableTree({
|
|||||||
.filter((n): n is NonNullable<typeof n> => Boolean(n)),
|
.filter((n): n is NonNullable<typeof n> => Boolean(n)),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!canEdit) return;
|
|
||||||
const reorder =
|
const reorder =
|
||||||
catalogType === "bc" ? reorderBcCatalogPages : reorderCatalogPages;
|
catalogType === "bc" ? reorderBcCatalogPages : reorderCatalogPages;
|
||||||
try {
|
try {
|
||||||
@@ -470,27 +484,88 @@ export function SortableTree({
|
|||||||
onTreeMutation();
|
onTreeMutation();
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Failed to reorder");
|
toast.error("Failed to reorder");
|
||||||
loadFullTree();
|
await refreshParent(parentId);
|
||||||
}
|
}
|
||||||
} else {
|
} 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 =
|
const reorder =
|
||||||
catalogType === "bc" ? reorderBcTreePage : reorderTreePage;
|
catalogType === "bc" ? reorderBcTreePage : reorderTreePage;
|
||||||
const newParentId = overNode.parentId;
|
|
||||||
const siblings = childrenMap.get(newParentId) || [];
|
|
||||||
const overIndex = siblings.indexOf(over.id as number);
|
|
||||||
try {
|
try {
|
||||||
const result = await reorder({
|
const result = await reorder({
|
||||||
pageId: active.id as number,
|
pageId: movedId,
|
||||||
newParentId,
|
newParentId,
|
||||||
newOrderNum: overIndex >= 0 ? overIndex + 1 : siblings.length + 1,
|
newOrderNum,
|
||||||
});
|
});
|
||||||
if (!result.ok) throw new Error();
|
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();
|
onTreeMutation();
|
||||||
toast.success("Page moved");
|
toast.success("Page moved");
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Failed to move page");
|
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 () => {
|
const handleCreateSubpage = async () => {
|
||||||
if (!canEdit || subpageParentId === null || !subpageName.trim()) return;
|
if (!canEdit || subpageParentId === null || !subpageName.trim()) return;
|
||||||
setCreatingSubpage(true);
|
setCreatingSubpage(true);
|
||||||
|
const parentId = subpageParentId;
|
||||||
|
const name = subpageName.trim();
|
||||||
try {
|
try {
|
||||||
const result =
|
const result =
|
||||||
catalogType === "bc"
|
catalogType === "bc"
|
||||||
? await createBcPage({
|
? await createBcPage({
|
||||||
parentId: subpageParentId,
|
parentId,
|
||||||
caption: subpageName.trim(),
|
caption: name,
|
||||||
})
|
})
|
||||||
: await createCatalogPage({
|
: await createCatalogPage({
|
||||||
parentId: subpageParentId,
|
parentId,
|
||||||
caption: subpageName.trim(),
|
caption: name,
|
||||||
});
|
});
|
||||||
if (!result.ok) throw new Error(result.error);
|
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>;
|
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();
|
onTreeMutation();
|
||||||
setSubpageParentId(null);
|
setSubpageParentId(null);
|
||||||
setSubpageName("");
|
setSubpageName("");
|
||||||
@@ -567,6 +663,7 @@ export function SortableTree({
|
|||||||
const handleDelete = async (id: number) => {
|
const handleDelete = async (id: number) => {
|
||||||
if (!canEdit) return;
|
if (!canEdit) return;
|
||||||
const node = nodes.get(id);
|
const node = nodes.get(id);
|
||||||
|
const parentId = node?.parentId ?? -1;
|
||||||
const childInfo =
|
const childInfo =
|
||||||
node && node.childCount > 0
|
node && node.childCount > 0
|
||||||
? ` Its ${node.childCount} child page(s) will be moved to the parent.`
|
? ` 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();
|
if (!result.ok) throw new Error();
|
||||||
dispatch({ type: "REMOVE_NODE", id });
|
dispatch({ type: "REMOVE_NODE", id });
|
||||||
if (selectedPageId === id) onSelectPage(-1);
|
if (selectedPageId === id) onSelectPage(-1);
|
||||||
await loadFullTree();
|
// Children were reparented up — refresh only the affected parent.
|
||||||
|
await refreshParent(parentId);
|
||||||
onTreeMutation();
|
onTreeMutation();
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Failed to delete page");
|
toast.error("Failed to delete page");
|
||||||
|
|||||||
@@ -233,6 +233,12 @@ function reducer(
|
|||||||
...(action.update.iconImage !== undefined && {
|
...(action.update.iconImage !== undefined && {
|
||||||
iconImage: action.update.iconImage,
|
iconImage: action.update.iconImage,
|
||||||
}),
|
}),
|
||||||
|
...(action.update.parentId !== undefined && {
|
||||||
|
parentId: action.update.parentId,
|
||||||
|
}),
|
||||||
|
...(action.update.orderNum !== undefined && {
|
||||||
|
orderNum: action.update.orderNum,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
return { ...state, nodes: nextNodes };
|
return { ...state, nodes: nextNodes };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ export interface TreeNodeUpdate {
|
|||||||
visible?: string;
|
visible?: string;
|
||||||
pageLayout?: string;
|
pageLayout?: string;
|
||||||
iconImage?: number;
|
iconImage?: number;
|
||||||
|
parentId?: number;
|
||||||
|
orderNum?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RootTab {
|
export interface RootTab {
|
||||||
|
|||||||
Reference in new issue
Block a user