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

@@ -11,6 +11,7 @@ import {
Save,
Settings,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { updateCatalogPage } from "@/actions/catalog";
@@ -41,6 +42,7 @@ import {
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { useLatestRequest } from "@/features/catalog/client/use-latest-request";
import {
CATALOG_LAYOUTS,
type CatalogLayout,
@@ -106,7 +108,21 @@ interface InlineEditorProps {
type FormState = Record<string, string | number | null>;
export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
export function InlineEditor(props: InlineEditorProps) {
const { catalogType } = useCatalogManager();
return (
<InlineEditorSession key={`${catalogType}:${props.pageId}`} {...props} />
);
}
function InlineEditorSession({ pageId, onSaved }: InlineEditorProps) {
const pageRequests = useLatestRequest();
const itemRequests = useLatestRequest();
const saveRequests = useLatestRequest();
const tActions = useTranslations("pages.admin.actions");
const tNavigation = useTranslations("pages.admin.navigation");
const t = useTranslations("pages.admin.catalog.workspace");
const [offersDirty, setOffersDirty] = useState(false);
const { dispatch, catalogType, canEdit } = useCatalogManager();
const catQs = catalogTreeQs(catalogType);
const pageHref =
@@ -126,6 +142,7 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
const [itemsData, setItemsData] = useState<ItemsData | null>(null);
const [itemsLoading, setItemsLoading] = useState(false);
const [itemsLoaded, setItemsLoaded] = useState(false);
const [itemsError, setItemsError] = useState(false);
// ── Dirty tracking ──────────────────────────────────────────
const isDirty = useMemo(() => {
@@ -133,8 +150,8 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
}, [form, original]);
useEffect(() => {
dispatch({ type: "SET_DIRTY", dirty: isDirty });
}, [isDirty, dispatch]);
dispatch({ type: "SET_DIRTY", dirty: isDirty || offersDirty });
}, [isDirty, offersDirty, dispatch]);
// ── Ctrl+S handler ──────────────────────────────────────────
useEffect(() => {
@@ -151,11 +168,16 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
const loadPage = useCallback(
async (id: number) => {
const request = pageRequests.start();
setLoading(true);
try {
const res = await fetch(`/api/admin/catalog/tree?pageId=${id}${catQs}`);
const res = await fetch(
`/api/admin/catalog/tree?pageId=${id}${catQs}`,
{ signal: request.signal },
);
if (!res.ok) throw new Error();
const data = await res.json();
if (!request.isCurrent()) return;
const p = data.page as PageDetail;
setPage(p);
const formData: FormState = {
@@ -163,8 +185,9 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
pageLayout: p.pageLayout,
enabled: String(p.enabled),
visible: String(p.visible),
minRank: p.minRank ?? 0,
clubOnly: String(p.clubOnly ?? "0"),
...(catalogType === "normal"
? { minRank: p.minRank ?? 0, clubOnly: String(p.clubOnly ?? "0") }
: {}),
orderNum: p.orderNum,
iconImage: p.iconImage,
iconColor: p.iconColor,
@@ -179,29 +202,41 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
setForm(formData);
setOriginal(formData);
} catch {
if (!request.isCurrent()) return;
toast.error("Failed to load page");
setPage(null);
} finally {
setLoading(false);
if (request.isCurrent()) setLoading(false);
}
},
[catQs],
[catQs, catalogType, pageRequests],
);
const loadItemsData = useCallback(async (id: number) => {
setItemsLoading(true);
try {
const res = await fetch(`/api/admin/catalog/items?pageId=${id}`);
if (!res.ok) throw new Error();
const data = await res.json();
setItemsData(data as ItemsData);
setItemsLoaded(true);
} catch {
toast.error("Failed to load items");
} finally {
setItemsLoading(false);
}
}, []);
const loadItemsData = useCallback(
async (id: number) => {
const request = itemRequests.start();
setItemsError(false);
setItemsLoading(true);
try {
const res = await fetch(`/api/admin/catalog/items?pageId=${id}`, {
signal: request.signal,
});
if (!res.ok) throw new Error();
const data = await res.json();
if (!request.isCurrent()) return;
setItemsData(data as ItemsData);
setItemsLoaded(true);
} catch {
if (!request.isCurrent()) return;
setItemsLoaded(true);
setItemsError(true);
toast.error("Failed to load items");
} finally {
if (request.isCurrent()) setItemsLoading(false);
}
},
[itemRequests],
);
const reloadItems = useCallback(() => {
if (pageId && pageId > 0) loadItemsData(pageId);
@@ -250,15 +285,21 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
}
async function handleSave() {
if (!page || !canEdit) return;
if (!page || !canEdit || saving) return;
const request = saveRequests.start();
setSaving(true);
try {
const result =
catalogType === "bc"
? await updateBcPage({ id: page.id, ...form })
: await updateCatalogPage({ id: page.id, ...form });
? await updateBcPage({ id: page.id, ...form, expected: original })
: await updateCatalogPage({
id: page.id,
...form,
expected: original,
});
if (!request.isCurrent()) return;
if (!result.ok) throw new Error(result.error || "Failed to save");
toast.success("Page updated & catalog synced");
toast.success(tActions("saved"));
setOriginal({ ...form });
setPage((prev) =>
prev
@@ -281,9 +322,10 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
iconImage: (form.iconImage as number) ?? page.iconImage,
});
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to save");
if (request.isCurrent())
toast.error(e instanceof Error ? e.message : "Failed to save");
} finally {
setSaving(false);
if (request.isCurrent()) setSaving(false);
}
}
@@ -303,7 +345,14 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
);
}
if (!page) return null;
if (!page)
return (
<div className="flex flex-1 items-center justify-center">
<Button variant="outline" onClick={() => loadPage(pageId)}>
{t("retryPage")}
</Button>
</div>
);
const layoutColor =
LAYOUT_COLORS[String(form.pageLayout)] || "bg-muted text-muted-foreground";
@@ -386,7 +435,12 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
{/* Tabs */}
<Tabs
value={activeTab}
onValueChange={setActiveTab}
onValueChange={(tab) => {
if (tab === activeTab) return;
if (offersDirty && !window.confirm(tActions("leaveUnsaved"))) return;
setOffersDirty(false);
setActiveTab(tab);
}}
className="flex-1 flex flex-col min-h-0"
>
<TabsList className="mx-4 mt-2 shrink-0">
@@ -490,17 +544,19 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
className="h-8 text-sm"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Min Rank</Label>
<Input
type="number"
value={form.minRank as number}
onChange={(e) =>
update("minRank", parseInt(e.target.value, 10) || 1)
}
className="h-8 text-sm"
/>
</div>
{catalogType === "normal" && (
<div className="space-y-1.5">
<Label className="text-xs">Min Rank</Label>
<Input
type="number"
value={form.minRank as number}
onChange={(e) =>
update("minRank", parseInt(e.target.value, 10) || 1)
}
className="h-8 text-sm"
/>
</div>
)}
<div className="space-y-1.5">
<Label className="text-xs">Icon</Label>
<IconPicker
@@ -526,13 +582,15 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
checked={form.visible === "1"}
onChange={(v) => update("visible", v ? "1" : "0")}
/>
<ToggleRow
id="ie-club"
label="Club Only"
description="Requires HC subscription"
checked={form.clubOnly === "1"}
onChange={(v) => update("clubOnly", v ? "1" : "0")}
/>
{catalogType === "normal" && (
<ToggleRow
id="ie-club"
label="Club Only"
description="Requires HC subscription"
checked={form.clubOnly === "1"}
onChange={(v) => update("clubOnly", v ? "1" : "0")}
/>
)}
</div>
{/* Headline image picker */}
@@ -641,7 +699,7 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save & Sync
{tNavigation("save")}
</Button>
</div>
</div>
@@ -662,7 +720,13 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
</Button>
</Link>
</div>
) : itemsLoading ? (
) : itemsError && !itemsData ? (
<div className="flex items-center justify-center py-12">
<Button variant="outline" onClick={reloadItems}>
{t("retryItems")}
</Button>
</div>
) : itemsLoading && !itemsData ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
@@ -704,6 +768,8 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
{/* Items table */}
<CatalogItemsTable
onDirtyChange={setOffersDirty}
onRefresh={reloadItems}
items={itemsData.items}
pageId={page.id}
pageLayout={page.pageLayout}
@@ -725,7 +791,13 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
<div className="rounded-md border border-dashed px-4 py-8 text-center text-sm text-muted-foreground">
Translate tools are available for the shop catalog only.
</div>
) : itemsLoading ? (
) : itemsError && !itemsData ? (
<div className="flex items-center justify-center py-12">
<Button variant="outline" onClick={reloadItems}>
{t("retryItems")}
</Button>
</div>
) : itemsLoading && !itemsData ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>