refactor(catalog): unify commands and embed guarded catalog workspace
CI / check (push) Successful in 1m16s
CI / deploy (push) Successful in 28s

This commit is contained in:
Simo committed 2026-09-06 19:40:52 +02:00
1 parent 16f35568f5
commit 193b6686a9
57 files changed
+3268 -728

No files matched your search

@@ -144,6 +144,8 @@ function SortableRow({
}
interface CatalogItemsTableProps {
onDirtyChange?: (dirty: boolean) => void;
onRefresh?: () => void;
items: CatalogItemData[];
pageId: number;
pageLayout: string;
@@ -154,6 +156,8 @@ interface CatalogItemsTableProps {
}
export function CatalogItemsTable({
onDirtyChange,
onRefresh,
items,
pageId,
pageLayout,
@@ -291,6 +295,9 @@ export function CatalogItemsTable({
}, [items, itemOrder]);
const hasUnsavedChanges = modifiedItems.length > 0 || hasOrderChanges;
useEffect(() => {
onDirtyChange?.(hasUnsavedChanges);
}, [hasUnsavedChanges, onDirtyChange]);
// Warn before navigating away with unsaved changes
useEffect(() => {
@@ -383,7 +390,8 @@ export function CatalogItemsTable({
errorMessage: `Failed to save item #${item.id}.`,
onSuccess: () => {
setSavingItemId(null);
router.refresh();
if (onRefresh) onRefresh();
else router.refresh();
},
onError: () => setSavingItemId(null),
},
@@ -426,6 +434,10 @@ export function CatalogItemsTable({
else failedCount++;
}
if (savedCount > 0) {
if (onRefresh) onRefresh();
else router.refresh();
}
if (failedCount > 0) {
return {
ok: false as const,
@@ -456,7 +468,8 @@ export function CatalogItemsTable({
errorMessage: "Failed to delete items.",
onSuccess: () => {
setSelected(new Set());
router.refresh();
if (onRefresh) onRefresh();
else router.refresh();
},
});
}
@@ -490,7 +503,8 @@ export function CatalogItemsTable({
setSelected(new Set());
setMoveOpen(false);
setMoveTargetPageId(null);
router.refresh();
if (onRefresh) onRefresh();
else router.refresh();
},
},
);
@@ -590,7 +604,8 @@ export function CatalogItemsTable({
onSuccess: () => {
setAddOpen(false);
setNewItem({ ...DEFAULT_NEW_ITEM });
router.refresh();
if (onRefresh) onRefresh();
else router.refresh();
},
},
);
@@ -641,7 +656,8 @@ export function CatalogItemsTable({
onSuccess: () => {
setMoveOneId(null);
setMoveOneTarget(null);
router.refresh();
if (onRefresh) onRefresh();
else router.refresh();
},
},
);
@@ -711,7 +727,8 @@ export function CatalogItemsTable({
onSuccess: () => {
setEditingItem(null);
setEditingBase(null);
router.refresh();
if (onRefresh) onRefresh();
else router.refresh();
},
},
);
@@ -136,10 +136,18 @@ export function CatalogPageForm({
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
run(() => updateCatalogPage({ id: catalogPage.id, ...form }), {
successMessage: "Page updated and catalog synced.",
errorMessage: "Failed to update.",
});
run(
() =>
updateCatalogPage({
id: catalogPage.id,
...form,
expected: catalogPage as unknown as Record<string, unknown>,
}),
{
successMessage: "Page saved.",
errorMessage: "Failed to update.",
},
);
}
async function handleDelete() {
@@ -11,3 +11,11 @@ export const CatalogManagerDialog = dynamic(
),
{ ssr: false },
);
export const CatalogManagerWorkspace = dynamic(
() =>
import("@/components/admin/catalog-manager/catalog-manager-dialog").then(
(m) => ({ default: m.CatalogManagerWorkspace }),
),
{ ssr: false },
);
+22 -7
View File
@@ -1,8 +1,10 @@
"use client";
import { FolderTree, HardHat, List, Package } from "lucide-react";
import { FolderTree, HardHat, LayoutGrid, List, Package } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { requestCatalogNavigation } from "@/features/catalog/client/use-catalog-navigation";
import { cn } from "@/lib/utils";
interface CatalogViewToggleProps {
@@ -15,11 +17,13 @@ export function CatalogViewToggle({
catalogType = "normal",
}: CatalogViewToggleProps) {
const router = useRouter();
const t = useTranslations("pages.admin.catalog.workspace");
const searchParams = useSearchParams();
function setView(view: string) {
if (view === currentView || !requestCatalogNavigation()) return;
const params = new URLSearchParams(searchParams.toString());
if (view === "tree") {
if (view === "manager") {
params.delete("view");
} else {
params.set("view", view);
@@ -28,18 +32,21 @@ export function CatalogViewToggle({
}
function setCatalog(type: string) {
if (type === catalogType || !requestCatalogNavigation()) return;
const params = new URLSearchParams(searchParams.toString());
if (type === "normal") {
params.delete("catalog");
} else {
params.set("catalog", type);
}
params.delete("view"); // reset to tree when switching
params.delete("view");
params.delete("root");
params.delete("category");
router.push(`?${params.toString()}`);
}
return (
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
{/* Catalog type selector */}
<div className="flex items-center rounded-md border bg-card">
<Button
@@ -52,7 +59,7 @@ export function CatalogViewToggle({
onClick={() => setCatalog("normal")}
>
<Package className="mr-1.5 h-4 w-4" />
Catalog
{t("normal")}
</Button>
<Button
variant="ghost"
@@ -69,6 +76,14 @@ export function CatalogViewToggle({
</div>
{/* View mode selector */}
<Button
variant={currentView === "manager" ? "default" : "ghost"}
size="sm"
onClick={() => setView("manager")}
>
<LayoutGrid className="mr-1.5 h-4 w-4" />
{t("editor")}
</Button>
<div className="flex items-center rounded-md border bg-card">
<Button
variant="ghost"
@@ -80,7 +95,7 @@ export function CatalogViewToggle({
onClick={() => setView("tree")}
>
<FolderTree className="mr-1.5 h-4 w-4" />
Tree
{t("tree")}
</Button>
<Button
variant="ghost"
@@ -92,7 +107,7 @@ export function CatalogViewToggle({
onClick={() => setView("table")}
>
<List className="mr-1.5 h-4 w-4" />
Table
{t("table")}
</Button>
</div>
</div>
+57 -8
View File
@@ -15,8 +15,11 @@ import { Store } from "lucide-react";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { AdminPageShell } from "@/components/admin/admin-page-shell";
import { CatalogFixAllButton } from "@/components/admin/catalog/catalog-fix-all-button";
import { CatalogTree } from "@/components/admin/catalog-tree";
import Link from "@/components/link";
import { CatalogSearch } from "@/features/catalog/components/catalog-search";
import { CatalogSyncStatus } from "@/features/catalog/components/catalog-sync-status";
import { loadCatalogOverview } from "@/features/catalog/server/overview";
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
import {
CatalogItems,
@@ -28,7 +31,10 @@ import {
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { getCatalogItemCounts } from "@/lib/services/catalog-tree";
import { CatalogActions } from "./catalog-actions";
import { CatalogManagerDialog } from "./catalog-manager-dialog-wrapper";
import {
CatalogManagerDialog,
CatalogManagerWorkspace,
} from "./catalog-manager-dialog-wrapper";
import { CatalogTable } from "./catalog-table";
import { CatalogViewToggle } from "./catalog-view-toggle";
@@ -56,8 +62,44 @@ export default async function CatalogPagesPage({
const params = await searchParams;
const sp = new URLSearchParams(params);
const { search, perPage, page, sort, order } = parseListParams(sp);
const viewMode = sp.get("view") || "tree";
const catalogType = sp.get("catalog") || "normal";
const requestedView = sp.get("view");
const viewMode =
requestedView === "tree" || requestedView === "table"
? requestedView
: "manager";
const catalogType = sp.get("catalog") === "bc" ? "bc" : "normal";
if (viewMode === "manager") {
const overview = await loadCatalogOverview(catalogType);
return (
<AdminPageShell icon={Store} title={t("title")} subtitle={t("subtitle")}>
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
<CatalogViewToggle currentView={viewMode} catalogType={catalogType} />
<div className="flex flex-wrap items-center gap-2">
<Link className="btn btn-outline" href="/admin/catalog/maintenance">
{t("workspace.diagnostics")}
</Link>
{canEdit && (
<CatalogActions
catalogType={catalogType}
initialParentId={sp.get("newPageParent")}
/>
)}
</div>
</div>
<div className="mb-3">
<CatalogSyncStatus canEdit={canEdit} />
<CatalogSearch catalogType={catalogType} />
</div>
<CatalogManagerWorkspace
key={catalogType}
catalogType={catalogType}
canEdit={canEdit}
initialRoots={overview.roots}
totals={overview.totals}
/>
</AdminPageShell>
);
}
type TreeNodeShape = {
id: number;
@@ -94,7 +136,7 @@ export default async function CatalogPagesPage({
.where(
or(eq(CatalogPagesBc.parentId, -1), eq(CatalogPagesBc.parentId, 0)),
)
.orderBy(asc(CatalogPagesBc.orderNum));
.orderBy(asc(CatalogPagesBc.orderNum), asc(CatalogPagesBc.id));
const bcRootIds = bcRootRaw.map((p) => p.id);
const [bcChildCounts, bcItemCounts] = await Promise.all([
bcRootIds.length > 0
@@ -290,7 +332,12 @@ export default async function CatalogPagesPage({
initialParentId={sp.get("newPageParent")}
/>
)}
<CatalogFixAllButton />
<Link
className="btn btn-outline"
href="/admin/catalog/maintenance"
>
{t("workspace.diagnostics")}
</Link>
</div>
</div>
{viewMode === "tree" ? (
@@ -323,7 +370,7 @@ export default async function CatalogPagesPage({
.select()
.from(CatalogPages)
.where(or(eq(CatalogPages.parentId, -1), eq(CatalogPages.parentId, 0)))
.orderBy(asc(CatalogPages.orderNum));
.orderBy(asc(CatalogPages.orderNum), asc(CatalogPages.id));
const rootIds = rootPagesRaw.map((p) => p.id);
const [rootChildCounts, rootItemCountMap] = await Promise.all([
rootIds.length > 0
@@ -511,7 +558,9 @@ export default async function CatalogPagesPage({
{canEdit && (
<CatalogActions initialParentId={sp.get("newPageParent")} />
)}
<CatalogFixAllButton />
<Link className="btn btn-outline" href="/admin/catalog/maintenance">
{t("workspace.diagnostics")}
</Link>
</div>
</div>