Replace standalone catalog editor with the unified Visual Catalog studio
Make /admin/catalog a full-screen catalog studio that replaces the old listing plus separate [id]/builder-club detail pages: - Embed CatalogManagerWorkspace on /admin/catalog with a Normal/Builder Club toggle, Catalog Sync status, packages (normal), Organize imports and a diagnostics link to /admin/studio/maintenance. - Manage BC items directly in the studio Items tab (new BcItemsEditor, CRUD via existing bc actions; /api/admin/catalog/items now serves BC). - Inline editor: add pageTextTeaser field for both catalogs and remove the legacy full-editor links. - Remove the 'Open full editor' context action from the tree. - Move catalog-items-table (dir + barrel) and catalog-translate-tab out of the app route into src/components/admin/catalog and update all importers. - Keep /admin/catalog/[id], builder-club/[id] and /admin/catalog/maintenance as redirects into the new studio; consolidate maintenance panels into /admin/studio/maintenance and point the nav item there. - Delete the old listing/table/tabs/forms and the standalone bc-manager.
This commit is contained in:
1 parent
1a9e1e791a
commit
1bbd809b43
36 files changed
+526
-2750
No files matched your search
@@ -1,128 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Languages, Package, Settings } from "lucide-react";
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
||||||
import { type CatalogItemData, CatalogItemsTable } from "./catalog-items-table";
|
|
||||||
import { CatalogPageForm } from "./catalog-page-form";
|
|
||||||
import { CatalogTranslateTab } from "./catalog-translate-tab";
|
|
||||||
|
|
||||||
interface CatalogPageData {
|
|
||||||
id: number;
|
|
||||||
caption: string;
|
|
||||||
parentId: number;
|
|
||||||
pageLayout: string;
|
|
||||||
enabled: string;
|
|
||||||
visible: string;
|
|
||||||
minRank: number;
|
|
||||||
clubOnly: string;
|
|
||||||
orderNum: number;
|
|
||||||
iconImage: number;
|
|
||||||
iconColor: number;
|
|
||||||
pageHeadline: string;
|
|
||||||
pageTeaser: string;
|
|
||||||
pageSpecial: string | null;
|
|
||||||
pageText1: string | null;
|
|
||||||
pageText2: string | null;
|
|
||||||
pageTextDetails: string | null;
|
|
||||||
pageTextTeaser: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BaseItemData {
|
|
||||||
id: number;
|
|
||||||
publicName: string;
|
|
||||||
itemName: string;
|
|
||||||
spriteId: number;
|
|
||||||
type: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CatalogDetailTabsProps {
|
|
||||||
catalogPage: CatalogPageData;
|
|
||||||
items: CatalogItemData[];
|
|
||||||
baseItems: BaseItemData[];
|
|
||||||
catalogNameMap: Record<number, string>;
|
|
||||||
childPages: { id: number; caption: string; enabled: string }[];
|
|
||||||
pageId: number;
|
|
||||||
canEdit: boolean;
|
|
||||||
furniDataIdList: number[];
|
|
||||||
furniDescriptionMap: Record<number, string>;
|
|
||||||
furniRevisionMap: Record<number, { classname: string; revision: number }>;
|
|
||||||
allPages: { id: number; caption: string }[];
|
|
||||||
interactionTypes: string[];
|
|
||||||
gamedataHotel: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CatalogDetailTabs({
|
|
||||||
catalogPage,
|
|
||||||
items,
|
|
||||||
baseItems,
|
|
||||||
catalogNameMap,
|
|
||||||
childPages,
|
|
||||||
pageId,
|
|
||||||
canEdit,
|
|
||||||
furniDataIdList,
|
|
||||||
furniDescriptionMap,
|
|
||||||
furniRevisionMap,
|
|
||||||
allPages,
|
|
||||||
interactionTypes,
|
|
||||||
gamedataHotel,
|
|
||||||
}: CatalogDetailTabsProps) {
|
|
||||||
return (
|
|
||||||
<Tabs defaultValue="settings">
|
|
||||||
<TabsList className="overflow-x-auto flex-nowrap w-full">
|
|
||||||
<TabsTrigger value="settings" className="gap-2">
|
|
||||||
<Settings className="h-4 w-4" />
|
|
||||||
Settings
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="items" className="gap-2">
|
|
||||||
<Package className="h-4 w-4" />
|
|
||||||
Items ({items.length})
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="translate" className="gap-2">
|
|
||||||
<Languages className="h-4 w-4" />
|
|
||||||
Translate ({baseItems.length}){(() => {
|
|
||||||
const furniSet = new Set(furniDataIdList);
|
|
||||||
const missing = baseItems.filter((b) => !furniSet.has(b.id)).length;
|
|
||||||
return missing > 0 ? (
|
|
||||||
<span className="ml-1 rounded-full bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)] text-[10px] px-1.5 py-0.5 leading-none font-medium">
|
|
||||||
{missing}
|
|
||||||
</span>
|
|
||||||
) : null;
|
|
||||||
})()}
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<TabsContent value="settings" className="mt-4">
|
|
||||||
<CatalogPageForm
|
|
||||||
catalogPage={catalogPage}
|
|
||||||
childPages={childPages}
|
|
||||||
canEdit={canEdit}
|
|
||||||
allPages={allPages}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="items" className="mt-4">
|
|
||||||
<CatalogItemsTable
|
|
||||||
items={items}
|
|
||||||
pageId={pageId}
|
|
||||||
pageLayout={catalogPage.pageLayout}
|
|
||||||
canEdit={canEdit}
|
|
||||||
interactionTypes={interactionTypes}
|
|
||||||
furniRevisionMap={furniRevisionMap}
|
|
||||||
allPages={allPages}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="translate" className="mt-4">
|
|
||||||
<CatalogTranslateTab
|
|
||||||
baseItems={baseItems}
|
|
||||||
catalogNameMap={catalogNameMap}
|
|
||||||
canEdit={canEdit}
|
|
||||||
furniDataIdList={furniDataIdList}
|
|
||||||
furniDescriptionMap={furniDescriptionMap}
|
|
||||||
furniRevisionMap={furniRevisionMap}
|
|
||||||
gamedataHotel={gamedataHotel}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,746 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Check,
|
|
||||||
ChevronsUpDown,
|
|
||||||
FileText,
|
|
||||||
FolderTree,
|
|
||||||
Loader2,
|
|
||||||
Package,
|
|
||||||
Palette,
|
|
||||||
Save,
|
|
||||||
Settings,
|
|
||||||
Trash2,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import { deleteCatalogPage, updateCatalogPage } from "@/actions/catalog";
|
|
||||||
import { CatalogImagePicker } from "@/components/admin/catalog/catalog-image-picker";
|
|
||||||
import { IconPicker } from "@/components/admin/catalog/icon-picker";
|
|
||||||
import { ImagePreview } from "@/components/admin/catalog-manager/image-preview";
|
|
||||||
import { CatalogIcon, LAYOUT_COLORS } from "@/components/admin/catalog-tree";
|
|
||||||
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
|
|
||||||
import Link from "@/components/link";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import {
|
|
||||||
Command,
|
|
||||||
CommandEmpty,
|
|
||||||
CommandGroup,
|
|
||||||
CommandInput,
|
|
||||||
CommandItem,
|
|
||||||
CommandList,
|
|
||||||
} from "@/components/ui/command";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from "@/components/ui/popover";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import { useServerAction } from "@/hooks/use-server-action";
|
|
||||||
import {
|
|
||||||
CATALOG_LAYOUTS,
|
|
||||||
type CatalogLayout,
|
|
||||||
LAYOUT_DESCRIPTIONS,
|
|
||||||
} from "@/lib/catalog-layouts";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
interface CatalogPageData {
|
|
||||||
id: number;
|
|
||||||
caption: string;
|
|
||||||
parentId: number;
|
|
||||||
pageLayout: string;
|
|
||||||
enabled: string;
|
|
||||||
visible: string;
|
|
||||||
minRank: number;
|
|
||||||
clubOnly: string;
|
|
||||||
orderNum: number;
|
|
||||||
iconImage: number;
|
|
||||||
iconColor: number;
|
|
||||||
pageHeadline: string;
|
|
||||||
pageTeaser: string;
|
|
||||||
pageSpecial: string | null;
|
|
||||||
pageText1: string | null;
|
|
||||||
pageText2: string | null;
|
|
||||||
pageTextDetails: string | null;
|
|
||||||
pageTextTeaser: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CatalogPageFormProps {
|
|
||||||
catalogPage: CatalogPageData;
|
|
||||||
childPages: { id: number; caption: string; enabled: string }[];
|
|
||||||
canEdit: boolean;
|
|
||||||
allPages: { id: number; caption: string }[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CatalogPageForm({
|
|
||||||
catalogPage,
|
|
||||||
childPages,
|
|
||||||
canEdit,
|
|
||||||
allPages,
|
|
||||||
}: CatalogPageFormProps) {
|
|
||||||
const { isPending, run } = useServerAction();
|
|
||||||
const { confirm, dialog: confirmDialog } = useConfirmDialog();
|
|
||||||
const formRef = useRef<HTMLFormElement>(null);
|
|
||||||
|
|
||||||
const [form, setForm] = useState({
|
|
||||||
caption: catalogPage.caption,
|
|
||||||
parentId: catalogPage.parentId,
|
|
||||||
pageLayout: catalogPage.pageLayout,
|
|
||||||
enabled: catalogPage.enabled as "0" | "1",
|
|
||||||
visible: catalogPage.visible as "0" | "1",
|
|
||||||
minRank: catalogPage.minRank,
|
|
||||||
clubOnly: catalogPage.clubOnly as "0" | "1",
|
|
||||||
orderNum: catalogPage.orderNum,
|
|
||||||
iconImage: catalogPage.iconImage,
|
|
||||||
iconColor: catalogPage.iconColor,
|
|
||||||
pageHeadline: catalogPage.pageHeadline,
|
|
||||||
pageTeaser: catalogPage.pageTeaser,
|
|
||||||
pageSpecial: catalogPage.pageSpecial || "",
|
|
||||||
pageText1: catalogPage.pageText1 || "",
|
|
||||||
pageText2: catalogPage.pageText2 || "",
|
|
||||||
pageTextDetails: catalogPage.pageTextDetails || "",
|
|
||||||
pageTextTeaser: catalogPage.pageTextTeaser || "",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Ctrl+S / Cmd+S to save
|
|
||||||
useEffect(() => {
|
|
||||||
if (!canEdit) return;
|
|
||||||
function onKeyDown(e: KeyboardEvent) {
|
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
|
|
||||||
e.preventDefault();
|
|
||||||
formRef.current?.requestSubmit();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.addEventListener("keydown", onKeyDown);
|
|
||||||
return () => document.removeEventListener("keydown", onKeyDown);
|
|
||||||
}, [canEdit]);
|
|
||||||
|
|
||||||
function update<K extends keyof typeof form>(
|
|
||||||
key: K,
|
|
||||||
value: (typeof form)[K],
|
|
||||||
) {
|
|
||||||
setForm((prev) => ({ ...prev, [key]: value }));
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSubmit(e: React.FormEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
run(
|
|
||||||
() =>
|
|
||||||
updateCatalogPage({
|
|
||||||
id: catalogPage.id,
|
|
||||||
...form,
|
|
||||||
expected: catalogPage as unknown as Record<string, unknown>,
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
successMessage: "Page saved.",
|
|
||||||
errorMessage: "Failed to update.",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDelete() {
|
|
||||||
const childCount = childPages.length;
|
|
||||||
const description =
|
|
||||||
childCount > 0
|
|
||||||
? `Delete this catalog page? Its ${childCount} child page(s) will be moved to the parent. All items on this page will be deleted.`
|
|
||||||
: "Delete this catalog page and all its items?";
|
|
||||||
const ok = await confirm({
|
|
||||||
title: "Delete catalog page",
|
|
||||||
description,
|
|
||||||
confirmLabel: "Delete",
|
|
||||||
});
|
|
||||||
if (!ok) return;
|
|
||||||
run(() => deleteCatalogPage({ id: catalogPage.id }), {
|
|
||||||
successMessage: "Page deleted.",
|
|
||||||
redirectTo: "/admin/catalog",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const layoutColorClass =
|
|
||||||
LAYOUT_COLORS[form.pageLayout] || "bg-muted text-muted-foreground";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{confirmDialog}
|
|
||||||
<form ref={formRef} onSubmit={handleSubmit}>
|
|
||||||
<div className="grid gap-4 lg:grid-cols-3">
|
|
||||||
{/* ── Left column (2/3): tabs ─────────────────────────── */}
|
|
||||||
<div className="lg:col-span-2 space-y-4">
|
|
||||||
<Card>
|
|
||||||
<CardContent className="pt-4">
|
|
||||||
<Tabs defaultValue="general">
|
|
||||||
<TabsList className="mb-4">
|
|
||||||
<TabsTrigger value="general" className="gap-2">
|
|
||||||
<Settings className="h-4 w-4" />
|
|
||||||
General
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="texts" className="gap-2">
|
|
||||||
<FileText className="h-4 w-4" />
|
|
||||||
Texts
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="appearance" className="gap-2">
|
|
||||||
<Palette className="h-4 w-4" />
|
|
||||||
Appearance
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
{/* ── General ──────────────────────────────────── */}
|
|
||||||
<TabsContent value="general" className="space-y-4">
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="caption">Caption</Label>
|
|
||||||
<Input
|
|
||||||
id="caption"
|
|
||||||
value={form.caption}
|
|
||||||
onChange={(e) => update("caption", e.target.value)}
|
|
||||||
disabled={!canEdit}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Parent Page</Label>
|
|
||||||
<ParentCombobox
|
|
||||||
value={form.parentId}
|
|
||||||
onChange={(id) => update("parentId", id)}
|
|
||||||
pages={allPages}
|
|
||||||
currentPageId={catalogPage.id}
|
|
||||||
disabled={!canEdit}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Layout</Label>
|
|
||||||
<LayoutSelect
|
|
||||||
value={form.pageLayout}
|
|
||||||
onChange={(v) => update("pageLayout", v)}
|
|
||||||
disabled={!canEdit}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{LAYOUT_DESCRIPTIONS[
|
|
||||||
form.pageLayout as CatalogLayout
|
|
||||||
] ?? "Custom layout"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="orderNum">Order</Label>
|
|
||||||
<Input
|
|
||||||
id="orderNum"
|
|
||||||
type="number"
|
|
||||||
value={form.orderNum}
|
|
||||||
onChange={(e) =>
|
|
||||||
update(
|
|
||||||
"orderNum",
|
|
||||||
parseInt(e.target.value, 10) || 0,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
disabled={!canEdit}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="minRank">Min Rank</Label>
|
|
||||||
<Input
|
|
||||||
id="minRank"
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
max={100}
|
|
||||||
value={form.minRank}
|
|
||||||
onChange={(e) =>
|
|
||||||
update("minRank", parseInt(e.target.value, 10) || 1)
|
|
||||||
}
|
|
||||||
disabled={!canEdit}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
{/* ── Texts ────────────────────────────────────── */}
|
|
||||||
<TabsContent value="texts" className="space-y-4">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label>Headline</Label>
|
|
||||||
<CatalogImagePicker
|
|
||||||
value={form.pageHeadline}
|
|
||||||
onChange={(v) => update("pageHeadline", v)}
|
|
||||||
type="header"
|
|
||||||
label="Headline image"
|
|
||||||
disabled={!canEdit}
|
|
||||||
/>
|
|
||||||
<ImagePreview value={form.pageHeadline} height={50} />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label>Teaser</Label>
|
|
||||||
<CatalogImagePicker
|
|
||||||
value={form.pageTeaser}
|
|
||||||
onChange={(v) => update("pageTeaser", v)}
|
|
||||||
type="teaser"
|
|
||||||
label="Teaser image"
|
|
||||||
disabled={!canEdit}
|
|
||||||
/>
|
|
||||||
<ImagePreview value={form.pageTeaser} height={50} />
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
|
||||||
<TextareaField
|
|
||||||
id="pageText1"
|
|
||||||
label="Text 1"
|
|
||||||
value={form.pageText1}
|
|
||||||
onChange={(v) => update("pageText1", v)}
|
|
||||||
disabled={!canEdit}
|
|
||||||
rows={4}
|
|
||||||
max={5000}
|
|
||||||
/>
|
|
||||||
<TextareaField
|
|
||||||
id="pageText2"
|
|
||||||
label="Text 2"
|
|
||||||
value={form.pageText2}
|
|
||||||
onChange={(v) => update("pageText2", v)}
|
|
||||||
disabled={!canEdit}
|
|
||||||
rows={4}
|
|
||||||
max={5000}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
|
||||||
<TextareaField
|
|
||||||
id="pageTextDetails"
|
|
||||||
label="Details"
|
|
||||||
value={form.pageTextDetails}
|
|
||||||
onChange={(v) => update("pageTextDetails", v)}
|
|
||||||
disabled={!canEdit}
|
|
||||||
rows={3}
|
|
||||||
max={5000}
|
|
||||||
/>
|
|
||||||
<TextareaField
|
|
||||||
id="pageTextTeaser"
|
|
||||||
label="Text Teaser"
|
|
||||||
value={form.pageTextTeaser}
|
|
||||||
onChange={(v) => update("pageTextTeaser", v)}
|
|
||||||
disabled={!canEdit}
|
|
||||||
rows={3}
|
|
||||||
max={5000}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<TextareaField
|
|
||||||
id="pageSpecial"
|
|
||||||
label="Special"
|
|
||||||
value={form.pageSpecial}
|
|
||||||
onChange={(v) => update("pageSpecial", v)}
|
|
||||||
disabled={!canEdit}
|
|
||||||
rows={2}
|
|
||||||
max={2048}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
{/* ── Appearance ───────────────────────────────── */}
|
|
||||||
<TabsContent value="appearance" className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Icon</Label>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<IconPicker
|
|
||||||
value={form.iconImage}
|
|
||||||
onChange={(id) => update("iconImage", id)}
|
|
||||||
disabled={!canEdit}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Icon shown in the catalog tree navigation.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="iconColor">Icon Color</Label>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Input
|
|
||||||
id="iconColor"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
className="w-32"
|
|
||||||
value={form.iconColor}
|
|
||||||
onChange={(e) =>
|
|
||||||
update(
|
|
||||||
"iconColor",
|
|
||||||
parseInt(e.target.value, 10) || 0,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
disabled={!canEdit}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Legacy tint value. Leave at default unless you need to
|
|
||||||
match a specific theme.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Child pages list */}
|
|
||||||
{childPages.length > 0 && (
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardTitle className="text-base flex items-center gap-2">
|
|
||||||
<FolderTree className="h-4 w-4" />
|
|
||||||
Child Pages ({childPages.length})
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{childPages.map((child) => (
|
|
||||||
<Link
|
|
||||||
key={child.id}
|
|
||||||
href={`/admin/catalog/${child.id}`}
|
|
||||||
className="flex items-center justify-between rounded-md px-3 py-2 hover:bg-accent text-sm"
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
{child.caption}
|
|
||||||
<span className="ml-2 text-muted-foreground font-mono text-xs">
|
|
||||||
#{child.id}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<Badge
|
|
||||||
variant={
|
|
||||||
child.enabled === "1" ? "default" : "outline"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{child.enabled === "1" ? "Enabled" : "Disabled"}
|
|
||||||
</Badge>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Right column (1/3): status sticky ────────────────── */}
|
|
||||||
<div className="lg:col-span-1">
|
|
||||||
<Card className="lg:sticky lg:top-4">
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardTitle className="text-base">Status & Preview</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
{/* Preview */}
|
|
||||||
<div className="flex items-center gap-3 rounded-md border bg-muted/30 p-3">
|
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded bg-background shrink-0">
|
|
||||||
<CatalogIcon iconImage={form.iconImage} size={36} />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="truncate text-sm font-medium">
|
|
||||||
{form.caption || "Untitled"}
|
|
||||||
</p>
|
|
||||||
<Badge
|
|
||||||
className={cn(
|
|
||||||
"mt-1 h-5 px-1.5 text-[10px] font-mono border-0",
|
|
||||||
layoutColorClass,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{form.pageLayout}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Toggles */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<ToggleRow
|
|
||||||
id="tg-enabled"
|
|
||||||
label="Enabled"
|
|
||||||
description="Active in the catalog"
|
|
||||||
checked={form.enabled === "1"}
|
|
||||||
onChange={(v) => update("enabled", v ? "1" : "0")}
|
|
||||||
disabled={!canEdit}
|
|
||||||
/>
|
|
||||||
<ToggleRow
|
|
||||||
id="tg-visible"
|
|
||||||
label="Visible"
|
|
||||||
description="Shown in navigation"
|
|
||||||
checked={form.visible === "1"}
|
|
||||||
onChange={(v) => update("visible", v ? "1" : "0")}
|
|
||||||
disabled={!canEdit}
|
|
||||||
/>
|
|
||||||
<ToggleRow
|
|
||||||
id="tg-club"
|
|
||||||
label="Club Only"
|
|
||||||
description="Requires HC subscription"
|
|
||||||
checked={form.clubOnly === "1"}
|
|
||||||
onChange={(v) => update("clubOnly", v ? "1" : "0")}
|
|
||||||
disabled={!canEdit}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats */}
|
|
||||||
<div className="flex items-center gap-4 border-t pt-3 text-xs text-muted-foreground">
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<FolderTree className="h-3.5 w-3.5" />
|
|
||||||
{childPages.length} sub
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<Package className="h-3.5 w-3.5" />#{catalogPage.id}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
{canEdit && (
|
|
||||||
<div className="space-y-2 border-t pt-3">
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={isPending}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
{isPending ? (
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Save className="mr-2 h-4 w-4" />
|
|
||||||
)}
|
|
||||||
Save & Sync
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="destructive"
|
|
||||||
onClick={handleDelete}
|
|
||||||
disabled={isPending}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
|
||||||
Delete Page
|
|
||||||
</Button>
|
|
||||||
<p className="text-center text-[10px] text-muted-foreground">
|
|
||||||
Press <kbd className="rounded border px-1">Ctrl+S</kbd> to
|
|
||||||
save
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Toggle row ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function ToggleRow({
|
|
||||||
id,
|
|
||||||
label,
|
|
||||||
description,
|
|
||||||
checked,
|
|
||||||
onChange,
|
|
||||||
disabled,
|
|
||||||
}: {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
description: string;
|
|
||||||
checked: boolean;
|
|
||||||
onChange: (v: boolean) => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<Label htmlFor={id} className="text-sm font-medium cursor-pointer">
|
|
||||||
{label}
|
|
||||||
</Label>
|
|
||||||
<p className="text-[11px] text-muted-foreground">{description}</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
id={id}
|
|
||||||
checked={checked}
|
|
||||||
onCheckedChange={onChange}
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TextareaField({
|
|
||||||
id,
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
disabled,
|
|
||||||
rows = 3,
|
|
||||||
max,
|
|
||||||
}: {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
onChange: (v: string) => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
rows?: number;
|
|
||||||
max?: number;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Label htmlFor={id}>{label}</Label>
|
|
||||||
{max && (
|
|
||||||
<span className="text-[10px] tabular-nums text-muted-foreground">
|
|
||||||
{value.length}/{max}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Textarea
|
|
||||||
id={id}
|
|
||||||
rows={rows}
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
disabled={disabled}
|
|
||||||
maxLength={max}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Layout select with color swatch ────────────────────────────
|
|
||||||
|
|
||||||
function LayoutSelect({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
disabled,
|
|
||||||
}: {
|
|
||||||
value: string;
|
|
||||||
onChange: (v: string) => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Select value={value} onValueChange={onChange} disabled={disabled}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{CATALOG_LAYOUTS.map((layout) => {
|
|
||||||
const colorClass =
|
|
||||||
LAYOUT_COLORS[layout] || "bg-muted text-muted-foreground";
|
|
||||||
return (
|
|
||||||
<SelectItem key={layout} value={layout}>
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"inline-block h-2 w-2 rounded-full",
|
|
||||||
colorClass,
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{layout}
|
|
||||||
</span>
|
|
||||||
</SelectItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Parent page combobox with search ────────────────────────────
|
|
||||||
|
|
||||||
function ParentCombobox({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
pages,
|
|
||||||
currentPageId,
|
|
||||||
disabled,
|
|
||||||
}: {
|
|
||||||
value: number;
|
|
||||||
onChange: (id: number) => void;
|
|
||||||
pages: { id: number; caption: string }[];
|
|
||||||
currentPageId: number;
|
|
||||||
disabled?: boolean;
|
|
||||||
}) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const options = pages.filter((p) => p.id !== currentPageId);
|
|
||||||
const selected =
|
|
||||||
value === -1
|
|
||||||
? { id: -1, caption: "Root" }
|
|
||||||
: pages.find((p) => p.id === value);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
role="combobox"
|
|
||||||
aria-expanded={open}
|
|
||||||
disabled={disabled}
|
|
||||||
className="w-full justify-between font-normal"
|
|
||||||
>
|
|
||||||
{selected ? (
|
|
||||||
<span className="truncate">
|
|
||||||
<span className="text-muted-foreground font-mono text-xs mr-1.5">
|
|
||||||
{selected.id === -1 ? "—" : `#${selected.id}`}
|
|
||||||
</span>
|
|
||||||
{selected.caption}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-muted-foreground">Select parent...</span>
|
|
||||||
)}
|
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent
|
|
||||||
className="w-[--radix-popover-trigger-width] p-0"
|
|
||||||
align="start"
|
|
||||||
>
|
|
||||||
<Command>
|
|
||||||
<CommandInput placeholder="Search pages..." />
|
|
||||||
<CommandList>
|
|
||||||
<CommandEmpty>No page found.</CommandEmpty>
|
|
||||||
<CommandGroup>
|
|
||||||
<CommandItem
|
|
||||||
value="root"
|
|
||||||
onSelect={() => {
|
|
||||||
onChange(-1);
|
|
||||||
setOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Check
|
|
||||||
className={cn(
|
|
||||||
"mr-2 h-4 w-4",
|
|
||||||
value === -1 ? "opacity-100" : "opacity-0",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<span className="text-muted-foreground font-mono text-xs mr-1.5">
|
|
||||||
—
|
|
||||||
</span>
|
|
||||||
Root
|
|
||||||
</CommandItem>
|
|
||||||
{options.map((page) => (
|
|
||||||
<CommandItem
|
|
||||||
key={page.id}
|
|
||||||
value={`${page.id} ${page.caption}`}
|
|
||||||
onSelect={() => {
|
|
||||||
onChange(page.id);
|
|
||||||
setOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Check
|
|
||||||
className={cn(
|
|
||||||
"mr-2 h-4 w-4",
|
|
||||||
value === page.id ? "opacity-100" : "opacity-0",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<span className="text-muted-foreground font-mono text-xs mr-1.5">
|
|
||||||
#{page.id}
|
|
||||||
</span>
|
|
||||||
<span className="truncate">{page.caption}</span>
|
|
||||||
</CommandItem>
|
|
||||||
))}
|
|
||||||
</CommandGroup>
|
|
||||||
</CommandList>
|
|
||||||
</Command>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import { Skeleton } from "@/components/ui/skeleton";
|
|
||||||
|
|
||||||
export default function CatalogDetailLoading() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Breadcrumb */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Skeleton className="h-4 w-16" />
|
|
||||||
<Skeleton className="h-4 w-4" />
|
|
||||||
<Skeleton className="h-4 w-24" />
|
|
||||||
<Skeleton className="h-4 w-4" />
|
|
||||||
<Skeleton className="h-4 w-20" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Title */}
|
|
||||||
<div>
|
|
||||||
<Skeleton className="h-9 w-64" />
|
|
||||||
<Skeleton className="h-5 w-40 mt-2" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tabs */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex gap-2 border-b pb-2">
|
|
||||||
{Array.from({ length: 4 }, (_, i) => i).map((n) => (
|
|
||||||
<Skeleton
|
|
||||||
key={`skeleton-tab-${n}`}
|
|
||||||
className="h-9 w-24 rounded-md"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tab content */}
|
|
||||||
<div className="rounded-lg border bg-card p-6 space-y-4">
|
|
||||||
{Array.from({ length: 6 }, (_, i) => i).map((n) => (
|
|
||||||
<div key={`skeleton-field-${n}`} className="space-y-2">
|
|
||||||
<Skeleton className="h-4 w-24" />
|
|
||||||
<Skeleton className="h-10 w-full" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,8 @@
|
|||||||
import { asc, eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { ChevronRight } from "lucide-react";
|
|
||||||
import { notFound, redirect } from "next/navigation";
|
import { notFound, redirect } from "next/navigation";
|
||||||
import { Fragment } from "react";
|
|
||||||
import Link from "@/components/link";
|
|
||||||
import { CatalogPages, db } from "@/lib/db";
|
import { CatalogPages, db } from "@/lib/db";
|
||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { loadCatalogItemsData } from "@/lib/services/catalog-items-loader";
|
|
||||||
import { getAncestors } from "@/lib/services/catalog-tree";
|
import { getAncestors } from "@/lib/services/catalog-tree";
|
||||||
import { CatalogDetailTabs } from "./catalog-detail-tabs";
|
|
||||||
|
|
||||||
export default async function CatalogEditPage({
|
export default async function CatalogEditPage({
|
||||||
params,
|
params,
|
||||||
@@ -24,116 +19,14 @@ export default async function CatalogEditPage({
|
|||||||
if (Number.isNaN(pageId)) notFound();
|
if (Number.isNaN(pageId)) notFound();
|
||||||
|
|
||||||
const [catalogPage] = await db
|
const [catalogPage] = await db
|
||||||
.select()
|
.select({ id: CatalogPages.id, parentId: CatalogPages.parentId })
|
||||||
.from(CatalogPages)
|
.from(CatalogPages)
|
||||||
.where(eq(CatalogPages.id, pageId))
|
.where(eq(CatalogPages.id, pageId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!catalogPage) notFound();
|
if (!catalogPage) notFound();
|
||||||
|
|
||||||
const [children, itemsData, ancestors] = await Promise.all([
|
const ancestors = await getAncestors(catalogPage.parentId);
|
||||||
db
|
const rootId = ancestors[0]?.id ?? pageId;
|
||||||
.select({
|
redirect(`/admin/catalog?root=${rootId}&category=${pageId}`);
|
||||||
id: CatalogPages.id,
|
|
||||||
caption: CatalogPages.caption,
|
|
||||||
enabled: CatalogPages.enabled,
|
|
||||||
})
|
|
||||||
.from(CatalogPages)
|
|
||||||
.where(eq(CatalogPages.parentId, pageId))
|
|
||||||
.orderBy(asc(CatalogPages.orderNum)),
|
|
||||||
loadCatalogItemsData(pageId),
|
|
||||||
getAncestors(catalogPage.parentId),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const {
|
|
||||||
items: catalogItems,
|
|
||||||
baseItems,
|
|
||||||
catalogNameMap,
|
|
||||||
furniDataIdList,
|
|
||||||
furniDescriptionMap,
|
|
||||||
furniRevisionMap,
|
|
||||||
interactionTypes,
|
|
||||||
allPages,
|
|
||||||
gamedataHotel,
|
|
||||||
} = itemsData;
|
|
||||||
|
|
||||||
const canEdit = canAccess(permissions, PERMS.CATALOG_EDIT, session.user.rank);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Breadcrumb */}
|
|
||||||
<nav className="flex items-center gap-1 text-sm text-muted-foreground">
|
|
||||||
<Link
|
|
||||||
href={"/admin/catalog"}
|
|
||||||
className="hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
Catalog
|
|
||||||
</Link>
|
|
||||||
{ancestors.map((bc) => (
|
|
||||||
<Fragment key={bc.id}>
|
|
||||||
<ChevronRight className="h-3 w-3 flex-shrink-0" />
|
|
||||||
<Link
|
|
||||||
href={`/admin/catalog/${bc.id}`}
|
|
||||||
className="hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
{bc.caption}
|
|
||||||
</Link>
|
|
||||||
</Fragment>
|
|
||||||
))}
|
|
||||||
<ChevronRight className="h-3 w-3 flex-shrink-0" />
|
|
||||||
<span className="text-foreground font-medium">
|
|
||||||
{catalogPage.caption}
|
|
||||||
</span>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{/* Title */}
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold tracking-tight">
|
|
||||||
{catalogPage.caption}
|
|
||||||
<span className="ml-2 text-base font-normal text-muted-foreground">
|
|
||||||
#{catalogPage.id}
|
|
||||||
</span>
|
|
||||||
</h1>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Layout: {catalogPage.pageLayout} · {catalogItems.length} items
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tabs */}
|
|
||||||
<CatalogDetailTabs
|
|
||||||
catalogPage={{
|
|
||||||
id: catalogPage.id,
|
|
||||||
caption: catalogPage.caption,
|
|
||||||
parentId: catalogPage.parentId,
|
|
||||||
pageLayout: catalogPage.pageLayout,
|
|
||||||
enabled: catalogPage.enabled,
|
|
||||||
visible: catalogPage.visible,
|
|
||||||
minRank: catalogPage.minRank,
|
|
||||||
clubOnly: catalogPage.clubOnly,
|
|
||||||
orderNum: catalogPage.orderNum,
|
|
||||||
iconImage: catalogPage.iconImage,
|
|
||||||
iconColor: catalogPage.iconColor,
|
|
||||||
pageHeadline: catalogPage.pageHeadline,
|
|
||||||
pageTeaser: catalogPage.pageTeaser,
|
|
||||||
pageSpecial: catalogPage.pageSpecial,
|
|
||||||
pageText1: catalogPage.pageText1,
|
|
||||||
pageText2: catalogPage.pageText2,
|
|
||||||
pageTextDetails: catalogPage.pageTextDetails,
|
|
||||||
pageTextTeaser: catalogPage.pageTextTeaser,
|
|
||||||
}}
|
|
||||||
items={catalogItems}
|
|
||||||
baseItems={baseItems}
|
|
||||||
catalogNameMap={catalogNameMap}
|
|
||||||
childPages={children}
|
|
||||||
pageId={catalogPage.id}
|
|
||||||
canEdit={canEdit}
|
|
||||||
furniDataIdList={furniDataIdList}
|
|
||||||
furniDescriptionMap={furniDescriptionMap}
|
|
||||||
furniRevisionMap={furniRevisionMap}
|
|
||||||
allPages={allPages}
|
|
||||||
interactionTypes={interactionTypes}
|
|
||||||
gamedataHotel={gamedataHotel}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,23 @@
|
|||||||
import { asc, eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { notFound, redirect } from "next/navigation";
|
import { notFound, redirect } from "next/navigation";
|
||||||
import { CatalogItemsBc, CatalogPagesBc, db } from "@/lib/db";
|
import { CatalogPagesBc, db } from "@/lib/db";
|
||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { BcPageDetail } from "../bc-manager";
|
|
||||||
|
async function topAncestorId(parentId: number): Promise<number | null> {
|
||||||
|
let current = parentId;
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
if (current <= 0) return null;
|
||||||
|
const [page] = await db
|
||||||
|
.select({ id: CatalogPagesBc.id, parentId: CatalogPagesBc.parentId })
|
||||||
|
.from(CatalogPagesBc)
|
||||||
|
.where(eq(CatalogPagesBc.id, current))
|
||||||
|
.limit(1);
|
||||||
|
if (!page) return null;
|
||||||
|
if (page.parentId <= 0) return page.id;
|
||||||
|
current = page.parentId;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export default async function BcPageDetailPage({
|
export default async function BcPageDetailPage({
|
||||||
params: paramsPromise,
|
params: paramsPromise,
|
||||||
@@ -18,50 +33,13 @@ export default async function BcPageDetailPage({
|
|||||||
redirect("/admin");
|
redirect("/admin");
|
||||||
}
|
}
|
||||||
|
|
||||||
const canEdit = canAccess(permissions, PERMS.CATALOG_EDIT, session.user.rank);
|
|
||||||
|
|
||||||
const [bcPage] = await db
|
const [bcPage] = await db
|
||||||
.select()
|
.select({ id: CatalogPagesBc.id, parentId: CatalogPagesBc.parentId })
|
||||||
.from(CatalogPagesBc)
|
.from(CatalogPagesBc)
|
||||||
.where(eq(CatalogPagesBc.id, id))
|
.where(eq(CatalogPagesBc.id, id))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
if (!bcPage) notFound();
|
if (!bcPage) notFound();
|
||||||
|
|
||||||
const bcItems = await db
|
const rootId = (await topAncestorId(bcPage.parentId)) ?? id;
|
||||||
.select()
|
redirect(`/admin/catalog?catalog=bc&root=${rootId}&category=${id}`);
|
||||||
.from(CatalogItemsBc)
|
|
||||||
.where(eq(CatalogItemsBc.pageId, id))
|
|
||||||
.orderBy(asc(CatalogItemsBc.orderNumber));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BcPageDetail
|
|
||||||
page={{
|
|
||||||
id: bcPage.id,
|
|
||||||
parentId: bcPage.parentId,
|
|
||||||
caption: bcPage.caption,
|
|
||||||
pageLayout: bcPage.pageLayout,
|
|
||||||
iconColor: bcPage.iconColor,
|
|
||||||
iconImage: bcPage.iconImage,
|
|
||||||
orderNum: bcPage.orderNum,
|
|
||||||
visible: bcPage.visible,
|
|
||||||
enabled: bcPage.enabled,
|
|
||||||
pageHeadline: bcPage.pageHeadline,
|
|
||||||
pageTeaser: bcPage.pageTeaser,
|
|
||||||
pageSpecial: bcPage.pageSpecial ?? "",
|
|
||||||
pageText1: bcPage.pageText1 ?? "",
|
|
||||||
pageText2: bcPage.pageText2 ?? "",
|
|
||||||
pageTextDetails: bcPage.pageTextDetails ?? "",
|
|
||||||
pageTextTeaser: bcPage.pageTextTeaser ?? "",
|
|
||||||
}}
|
|
||||||
items={bcItems.map((i) => ({
|
|
||||||
id: i.id,
|
|
||||||
pageId: i.pageId,
|
|
||||||
itemIds: i.itemIds,
|
|
||||||
catalogName: i.catalogName,
|
|
||||||
orderNumber: i.orderNumber,
|
|
||||||
extradata: i.extradata,
|
|
||||||
}))}
|
|
||||||
canEdit={canEdit}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -1,608 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
ArrowLeft,
|
|
||||||
HardHat,
|
|
||||||
Loader2,
|
|
||||||
Package,
|
|
||||||
Pencil,
|
|
||||||
Plus,
|
|
||||||
Save,
|
|
||||||
Trash2,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import {
|
|
||||||
createBcItem,
|
|
||||||
deleteBcItem,
|
|
||||||
updateBcItem,
|
|
||||||
updateBcPage,
|
|
||||||
} from "@/actions/catalog-bc";
|
|
||||||
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
|
|
||||||
import Link from "@/components/link";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import { useServerAction } from "@/hooks/use-server-action";
|
|
||||||
|
|
||||||
// ── Types ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface BcPageData {
|
|
||||||
id: number;
|
|
||||||
parentId: number;
|
|
||||||
caption: string;
|
|
||||||
pageLayout: string;
|
|
||||||
iconColor: number;
|
|
||||||
iconImage: number;
|
|
||||||
orderNum: number;
|
|
||||||
visible: string;
|
|
||||||
enabled: string;
|
|
||||||
pageHeadline: string;
|
|
||||||
pageTeaser: string;
|
|
||||||
pageSpecial: string;
|
|
||||||
pageText1: string;
|
|
||||||
pageText2: string;
|
|
||||||
pageTextDetails: string;
|
|
||||||
pageTextTeaser: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BcItemData {
|
|
||||||
id: number;
|
|
||||||
pageId: number;
|
|
||||||
itemIds: string;
|
|
||||||
catalogName: string;
|
|
||||||
orderNumber: number;
|
|
||||||
extradata: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BcPageDetailProps {
|
|
||||||
page: BcPageData;
|
|
||||||
items: BcItemData[];
|
|
||||||
canEdit: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Main Component ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
|
|
||||||
const { isPending, run } = useServerAction();
|
|
||||||
const { confirm, dialog: confirmDialog } = useConfirmDialog();
|
|
||||||
const formRef = useRef<HTMLFormElement>(null);
|
|
||||||
const [editingItem, setEditingItem] = useState<BcItemData | null>(null);
|
|
||||||
const [addingItem, setAddingItem] = useState(false);
|
|
||||||
|
|
||||||
const [form, setForm] = useState({
|
|
||||||
caption: page.caption,
|
|
||||||
orderNum: page.orderNum,
|
|
||||||
enabled: page.enabled as "0" | "1",
|
|
||||||
visible: page.visible as "0" | "1",
|
|
||||||
pageHeadline: page.pageHeadline,
|
|
||||||
pageTeaser: page.pageTeaser,
|
|
||||||
pageSpecial: page.pageSpecial,
|
|
||||||
pageText1: page.pageText1,
|
|
||||||
pageText2: page.pageText2,
|
|
||||||
pageTextDetails: page.pageTextDetails,
|
|
||||||
pageTextTeaser: page.pageTextTeaser,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Ctrl+S / Cmd+S to save
|
|
||||||
useEffect(() => {
|
|
||||||
if (!canEdit) return;
|
|
||||||
function onKeyDown(e: KeyboardEvent) {
|
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
|
|
||||||
e.preventDefault();
|
|
||||||
formRef.current?.requestSubmit();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.addEventListener("keydown", onKeyDown);
|
|
||||||
return () => document.removeEventListener("keydown", onKeyDown);
|
|
||||||
}, [canEdit]);
|
|
||||||
|
|
||||||
function update<K extends keyof typeof form>(
|
|
||||||
key: K,
|
|
||||||
value: (typeof form)[K],
|
|
||||||
) {
|
|
||||||
setForm((prev) => ({ ...prev, [key]: value }));
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSubmit(e: React.FormEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
run(() => updateBcPage({ id: page.id, ...form }), {
|
|
||||||
successMessage: "BC page updated and catalog synced.",
|
|
||||||
errorMessage: "Failed to update.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{confirmDialog}
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Link
|
|
||||||
href="/admin/catalog?catalog=bc"
|
|
||||||
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
BC Catalog
|
|
||||||
</Link>
|
|
||||||
<span className="text-muted-foreground">/</span>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<HardHat className="h-5 w-5 text-[var(--admin-warning)]" />
|
|
||||||
<h1 className="text-2xl font-bold tracking-tight">{page.caption}</h1>
|
|
||||||
<Badge variant="outline" className="font-mono text-xs">
|
|
||||||
#{page.id}
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="secondary" className="font-mono text-xs">
|
|
||||||
{page.pageLayout}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form ref={formRef} onSubmit={handleSubmit}>
|
|
||||||
<div className="grid gap-4 lg:grid-cols-3">
|
|
||||||
{/* ── Left column: form fields ─────────────── */}
|
|
||||||
<div className="lg:col-span-2 space-y-4">
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardTitle className="text-base">Page Settings</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="caption">Caption</Label>
|
|
||||||
<Input
|
|
||||||
id="caption"
|
|
||||||
value={form.caption}
|
|
||||||
onChange={(e) => update("caption", e.target.value)}
|
|
||||||
disabled={!canEdit}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="orderNum">Order</Label>
|
|
||||||
<Input
|
|
||||||
id="orderNum"
|
|
||||||
type="number"
|
|
||||||
value={form.orderNum}
|
|
||||||
onChange={(e) =>
|
|
||||||
update("orderNum", parseInt(e.target.value, 10) || 0)
|
|
||||||
}
|
|
||||||
disabled={!canEdit}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Headline</Label>
|
|
||||||
<Input
|
|
||||||
value={form.pageHeadline}
|
|
||||||
onChange={(e) => update("pageHeadline", e.target.value)}
|
|
||||||
disabled={!canEdit}
|
|
||||||
placeholder="Headline image path"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Teaser</Label>
|
|
||||||
<Input
|
|
||||||
value={form.pageTeaser}
|
|
||||||
onChange={(e) => update("pageTeaser", e.target.value)}
|
|
||||||
disabled={!canEdit}
|
|
||||||
placeholder="Teaser image path"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Text 1</Label>
|
|
||||||
<Textarea
|
|
||||||
value={form.pageText1}
|
|
||||||
onChange={(e) => update("pageText1", e.target.value)}
|
|
||||||
disabled={!canEdit}
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Text 2</Label>
|
|
||||||
<Textarea
|
|
||||||
value={form.pageText2}
|
|
||||||
onChange={(e) => update("pageText2", e.target.value)}
|
|
||||||
disabled={!canEdit}
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Details</Label>
|
|
||||||
<Textarea
|
|
||||||
value={form.pageTextDetails}
|
|
||||||
onChange={(e) =>
|
|
||||||
update("pageTextDetails", e.target.value)
|
|
||||||
}
|
|
||||||
disabled={!canEdit}
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Text Teaser</Label>
|
|
||||||
<Textarea
|
|
||||||
value={form.pageTextTeaser}
|
|
||||||
onChange={(e) => update("pageTextTeaser", e.target.value)}
|
|
||||||
disabled={!canEdit}
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Special</Label>
|
|
||||||
<Textarea
|
|
||||||
value={form.pageSpecial}
|
|
||||||
onChange={(e) => update("pageSpecial", e.target.value)}
|
|
||||||
disabled={!canEdit}
|
|
||||||
rows={2}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Items */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<CardTitle className="text-base flex items-center gap-2">
|
|
||||||
<Package className="h-4 w-4" />
|
|
||||||
Items ({items.length})
|
|
||||||
</CardTitle>
|
|
||||||
{canEdit && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 text-xs"
|
|
||||||
onClick={() => setAddingItem(true)}
|
|
||||||
>
|
|
||||||
<Plus className="h-3 w-3 mr-1" />
|
|
||||||
Add Item
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{items.length === 0 ? (
|
|
||||||
<p className="text-sm text-muted-foreground text-center py-4">
|
|
||||||
No items in this page.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead className="text-xs">ID</TableHead>
|
|
||||||
<TableHead className="text-xs">Name</TableHead>
|
|
||||||
<TableHead className="text-xs">Item IDs</TableHead>
|
|
||||||
<TableHead className="text-xs">Order</TableHead>
|
|
||||||
<TableHead className="text-xs">Extra Data</TableHead>
|
|
||||||
{canEdit && (
|
|
||||||
<TableHead className="text-xs w-20">
|
|
||||||
Actions
|
|
||||||
</TableHead>
|
|
||||||
)}
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{items.map((item) => (
|
|
||||||
<TableRow key={item.id}>
|
|
||||||
<TableCell className="text-xs font-mono">
|
|
||||||
{item.id}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-xs font-medium">
|
|
||||||
{item.catalogName}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-xs font-mono text-muted-foreground">
|
|
||||||
{item.itemIds}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-xs">
|
|
||||||
{item.orderNumber}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-xs text-muted-foreground max-w-32 truncate">
|
|
||||||
{item.extradata || "\u2014"}
|
|
||||||
</TableCell>
|
|
||||||
{canEdit && (
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-6 w-6 p-0"
|
|
||||||
onClick={() => setEditingItem(item)}
|
|
||||||
>
|
|
||||||
<Pencil className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-6 w-6 p-0 text-[var(--admin-error)] hover:text-[var(--admin-error)]"
|
|
||||||
onClick={async () => {
|
|
||||||
const ok = await confirm({
|
|
||||||
title: "Delete BC item",
|
|
||||||
description: `Delete item "${item.catalogName}"?`,
|
|
||||||
confirmLabel: "Delete",
|
|
||||||
});
|
|
||||||
if (!ok) return;
|
|
||||||
run(() => deleteBcItem({ id: item.id }), {
|
|
||||||
successMessage: "Item deleted.",
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
)}
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Right column: status ─────────────────── */}
|
|
||||||
<div className="lg:col-span-1">
|
|
||||||
<Card className="lg:sticky lg:top-4">
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardTitle className="text-base">Status</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="space-y-3">
|
|
||||||
<ToggleRow
|
|
||||||
id="tg-enabled"
|
|
||||||
label="Enabled"
|
|
||||||
description="Active in the BC catalog"
|
|
||||||
checked={form.enabled === "1"}
|
|
||||||
onChange={(v) => update("enabled", v ? "1" : "0")}
|
|
||||||
disabled={!canEdit}
|
|
||||||
/>
|
|
||||||
<ToggleRow
|
|
||||||
id="tg-visible"
|
|
||||||
label="Visible"
|
|
||||||
description="Shown in BC navigation"
|
|
||||||
checked={form.visible === "1"}
|
|
||||||
onChange={(v) => update("visible", v ? "1" : "0")}
|
|
||||||
disabled={!canEdit}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats */}
|
|
||||||
<div className="flex items-center gap-4 border-t pt-3 text-xs text-muted-foreground">
|
|
||||||
<span>
|
|
||||||
Layout: <span className="font-mono">{page.pageLayout}</span>
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
Parent:{" "}
|
|
||||||
<span className="font-mono">
|
|
||||||
{page.parentId <= 0 ? "Root" : `#${page.parentId}`}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
{canEdit && (
|
|
||||||
<div className="space-y-2 border-t pt-3">
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={isPending}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
{isPending ? (
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Save className="mr-2 h-4 w-4" />
|
|
||||||
)}
|
|
||||||
Save & Sync
|
|
||||||
</Button>
|
|
||||||
<p className="text-center text-[10px] text-muted-foreground">
|
|
||||||
Press <kbd className="rounded border px-1">Ctrl+S</kbd> to
|
|
||||||
save
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{/* Edit Item Dialog */}
|
|
||||||
{editingItem && (
|
|
||||||
<ItemDialog
|
|
||||||
item={editingItem}
|
|
||||||
mode="edit"
|
|
||||||
isPending={isPending}
|
|
||||||
onClose={() => setEditingItem(null)}
|
|
||||||
onSubmit={(data) => {
|
|
||||||
run(() => updateBcItem({ id: editingItem.id, ...data }), {
|
|
||||||
successMessage: "Item updated.",
|
|
||||||
onSuccess: () => setEditingItem(null),
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Add Item Dialog */}
|
|
||||||
{addingItem && (
|
|
||||||
<ItemDialog
|
|
||||||
item={{
|
|
||||||
id: 0,
|
|
||||||
pageId: page.id,
|
|
||||||
itemIds: "",
|
|
||||||
catalogName: "",
|
|
||||||
orderNumber: 1,
|
|
||||||
extradata: "",
|
|
||||||
}}
|
|
||||||
mode="add"
|
|
||||||
isPending={isPending}
|
|
||||||
onClose={() => setAddingItem(false)}
|
|
||||||
onSubmit={(data) => {
|
|
||||||
run(() => createBcItem({ pageId: page.id, ...data }), {
|
|
||||||
successMessage: "Item created.",
|
|
||||||
onSuccess: () => setAddingItem(false),
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Toggle row ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function ToggleRow({
|
|
||||||
id,
|
|
||||||
label,
|
|
||||||
description,
|
|
||||||
checked,
|
|
||||||
onChange,
|
|
||||||
disabled,
|
|
||||||
}: {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
description: string;
|
|
||||||
checked: boolean;
|
|
||||||
onChange: (v: boolean) => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<Label htmlFor={id} className="text-sm font-medium cursor-pointer">
|
|
||||||
{label}
|
|
||||||
</Label>
|
|
||||||
<p className="text-[11px] text-muted-foreground">{description}</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
id={id}
|
|
||||||
checked={checked}
|
|
||||||
onCheckedChange={onChange}
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Item Dialog ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function ItemDialog({
|
|
||||||
item,
|
|
||||||
mode,
|
|
||||||
isPending,
|
|
||||||
onClose,
|
|
||||||
onSubmit,
|
|
||||||
}: {
|
|
||||||
item: BcItemData;
|
|
||||||
mode: "add" | "edit";
|
|
||||||
isPending: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onSubmit: (data: {
|
|
||||||
itemIds: string;
|
|
||||||
catalogName: string;
|
|
||||||
orderNumber: number;
|
|
||||||
extradata: string;
|
|
||||||
}) => void;
|
|
||||||
}) {
|
|
||||||
const [form, setForm] = useState({
|
|
||||||
itemIds: item.itemIds,
|
|
||||||
catalogName: item.catalogName,
|
|
||||||
orderNumber: item.orderNumber,
|
|
||||||
extradata: item.extradata,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
{mode === "add" ? "Add BC Item" : `Edit Item #${item.id}`}
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="space-y-4 py-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Catalog Name</Label>
|
|
||||||
<Input
|
|
||||||
value={form.catalogName}
|
|
||||||
onChange={(e) =>
|
|
||||||
setForm((f) => ({ ...f, catalogName: e.target.value }))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Item IDs (semicolon-separated)</Label>
|
|
||||||
<Input
|
|
||||||
value={form.itemIds}
|
|
||||||
onChange={(e) =>
|
|
||||||
setForm((f) => ({ ...f, itemIds: e.target.value }))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Order</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
value={form.orderNumber}
|
|
||||||
onChange={(e) =>
|
|
||||||
setForm((f) => ({
|
|
||||||
...f,
|
|
||||||
orderNumber: parseInt(e.target.value, 10) || 0,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Extra Data</Label>
|
|
||||||
<Input
|
|
||||||
value={form.extradata}
|
|
||||||
onChange={(e) =>
|
|
||||||
setForm((f) => ({ ...f, extradata: e.target.value }))
|
|
||||||
}
|
|
||||||
placeholder="Optional"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={onClose} disabled={isPending}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => onSubmit(form)}
|
|
||||||
disabled={isPending || !form.catalogName || !form.itemIds}
|
|
||||||
>
|
|
||||||
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
||||||
{mode === "add" ? "Add Item" : "Save"}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Loader2, Plus } from "lucide-react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { createCatalogPage } from "@/actions/catalog";
|
|
||||||
import { createBcPage } from "@/actions/catalog-bc";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { useServerAction } from "@/hooks/use-server-action";
|
|
||||||
import { CATALOG_LAYOUTS } from "@/lib/catalog-layouts";
|
|
||||||
|
|
||||||
interface CatalogActionsProps {
|
|
||||||
catalogType?: "normal" | "bc";
|
|
||||||
/** Prefill parent and auto-open create dialog (from ?newPageParent=). */
|
|
||||||
initialParentId?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CatalogActions({
|
|
||||||
catalogType = "normal",
|
|
||||||
initialParentId = null,
|
|
||||||
}: CatalogActionsProps) {
|
|
||||||
const { run, isPending } = useServerAction();
|
|
||||||
const router = useRouter();
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [caption, setCaption] = useState("");
|
|
||||||
const [parentId, setParentId] = useState("-1");
|
|
||||||
const [pageLayout, setPageLayout] = useState("default_3x3");
|
|
||||||
const isBc = catalogType === "bc";
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (initialParentId == null || initialParentId === "") return;
|
|
||||||
const n = Number.parseInt(initialParentId, 10);
|
|
||||||
if (!Number.isFinite(n)) return;
|
|
||||||
setParentId(String(n));
|
|
||||||
setOpen(true);
|
|
||||||
|
|
||||||
if (typeof window === "undefined") return;
|
|
||||||
const url = new URL(window.location.href);
|
|
||||||
if (url.searchParams.has("newPageParent")) {
|
|
||||||
url.searchParams.delete("newPageParent");
|
|
||||||
const qs = url.searchParams.toString();
|
|
||||||
router.replace(`${url.pathname}${qs ? `?${qs}` : ""}`, {
|
|
||||||
scroll: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [initialParentId, router]);
|
|
||||||
|
|
||||||
function handleCreate() {
|
|
||||||
if (!caption.trim()) {
|
|
||||||
toast.error("Caption is required");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const input = {
|
|
||||||
caption: caption.trim(),
|
|
||||||
parentId: parseInt(parentId, 10),
|
|
||||||
pageLayout,
|
|
||||||
};
|
|
||||||
|
|
||||||
const fn = isBc
|
|
||||||
? () => createBcPage(input)
|
|
||||||
: () => createCatalogPage(input);
|
|
||||||
|
|
||||||
run(fn, {
|
|
||||||
successMessage: `Catalog page "${caption.trim()}" created.`,
|
|
||||||
onSuccess: (data) => {
|
|
||||||
setOpen(false);
|
|
||||||
setCaption("");
|
|
||||||
setParentId("-1");
|
|
||||||
if (data.id) {
|
|
||||||
window.location.href = isBc
|
|
||||||
? `/admin/catalog/builder-club/${data.id}`
|
|
||||||
: `/admin/catalog/${data.id}`;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
|
||||||
<Button type="button" onClick={() => setOpen(true)}>
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
New Page
|
|
||||||
</Button>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
{isBc ? "Create Builder Club Page" : "Create Catalog Page"}
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="caption">Caption</Label>
|
|
||||||
<Input
|
|
||||||
id="caption"
|
|
||||||
value={caption}
|
|
||||||
onChange={(e) => setCaption(e.target.value)}
|
|
||||||
placeholder="Page name"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="parentId">Parent ID (-1 = root)</Label>
|
|
||||||
<Input
|
|
||||||
id="parentId"
|
|
||||||
type="number"
|
|
||||||
value={parentId}
|
|
||||||
onChange={(e) => setParentId(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Layout</Label>
|
|
||||||
<Select value={pageLayout} onValueChange={setPageLayout}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{CATALOG_LAYOUTS.map((layout) => (
|
|
||||||
<SelectItem key={layout} value={layout}>
|
|
||||||
{layout}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={handleCreate}
|
|
||||||
disabled={isPending}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
{isPending ? (
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
)}
|
|
||||||
Create Page
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,211 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Pencil } from "lucide-react";
|
|
||||||
import { deleteCatalogPage, toggleCatalogPage } from "@/actions/catalog";
|
|
||||||
import { deleteBcTreePage, toggleBcPage } from "@/actions/catalog-bc";
|
|
||||||
import { DataTable } from "@/components/admin/data-table";
|
|
||||||
import Link from "@/components/link";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { useServerAction } from "@/hooks/use-server-action";
|
|
||||||
import type { DataTableColumn, PaginatedResult } from "@/types/common";
|
|
||||||
|
|
||||||
interface CatalogPageRow {
|
|
||||||
id: number;
|
|
||||||
caption: string;
|
|
||||||
parentId: number;
|
|
||||||
parentCaption: string;
|
|
||||||
pageLayout: string;
|
|
||||||
enabled: string;
|
|
||||||
visible: string;
|
|
||||||
minRank: number;
|
|
||||||
orderNum: number;
|
|
||||||
itemCount: number;
|
|
||||||
childCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CatalogTableProps {
|
|
||||||
data: PaginatedResult<CatalogPageRow>;
|
|
||||||
canEdit: boolean;
|
|
||||||
catalogType?: "normal" | "bc";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CatalogTable({
|
|
||||||
data,
|
|
||||||
canEdit,
|
|
||||||
catalogType = "normal",
|
|
||||||
}: CatalogTableProps) {
|
|
||||||
const { run, isPending } = useServerAction();
|
|
||||||
const isBc = catalogType === "bc";
|
|
||||||
|
|
||||||
// Parent URLs stay in the same catalog; root takes you to the catalog index.
|
|
||||||
const detailHref = (id: number) =>
|
|
||||||
isBc ? `/admin/catalog/builder-club/${id}` : `/admin/catalog/${id}`;
|
|
||||||
|
|
||||||
function handleToggle(id: number, action: "toggleEnabled" | "toggleVisible") {
|
|
||||||
if (isBc) {
|
|
||||||
const field = action === "toggleEnabled" ? "enabled" : "visible";
|
|
||||||
run(() => toggleBcPage({ id, field }), { successMessage: "Updated." });
|
|
||||||
} else {
|
|
||||||
run(() => toggleCatalogPage({ id, action }), {
|
|
||||||
successMessage: "Updated.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const columns: DataTableColumn<CatalogPageRow>[] = [
|
|
||||||
{ key: "id", label: "ID", sortable: true },
|
|
||||||
{
|
|
||||||
key: "caption",
|
|
||||||
label: "Caption",
|
|
||||||
sortable: true,
|
|
||||||
render: (value, row) => (
|
|
||||||
<Link
|
|
||||||
href={detailHref(row.id)}
|
|
||||||
className="font-medium text-primary hover:underline"
|
|
||||||
>
|
|
||||||
{String(value)}
|
|
||||||
</Link>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "parentId",
|
|
||||||
label: "Parent",
|
|
||||||
sortable: true,
|
|
||||||
render: (_, row) =>
|
|
||||||
row.parentId === -1 ? (
|
|
||||||
<span className="text-xs text-muted-foreground">Root</span>
|
|
||||||
) : (
|
|
||||||
<Link
|
|
||||||
href={detailHref(row.parentId)}
|
|
||||||
className="text-xs hover:underline"
|
|
||||||
>
|
|
||||||
{row.parentCaption}
|
|
||||||
</Link>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "pageLayout",
|
|
||||||
label: "Layout",
|
|
||||||
render: (value) => <Badge variant="secondary">{String(value)}</Badge>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "enabled",
|
|
||||||
label: "Enabled",
|
|
||||||
sortable: true,
|
|
||||||
filterKey: "filter_enabled",
|
|
||||||
filterOptions: [
|
|
||||||
{ label: "Enabled", value: "1" },
|
|
||||||
{ label: "Disabled", value: "0" },
|
|
||||||
],
|
|
||||||
render: (value, row) =>
|
|
||||||
canEdit ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleToggle(row.id, "toggleEnabled")}
|
|
||||||
disabled={isPending}
|
|
||||||
className="cursor-pointer disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<Badge variant={value === "1" ? "default" : "outline"}>
|
|
||||||
{value === "1" ? "Yes" : "No"}
|
|
||||||
</Badge>
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<Badge variant={value === "1" ? "default" : "outline"}>
|
|
||||||
{value === "1" ? "Yes" : "No"}
|
|
||||||
</Badge>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "visible",
|
|
||||||
label: "Visible",
|
|
||||||
sortable: true,
|
|
||||||
filterKey: "filter_visible",
|
|
||||||
filterOptions: [
|
|
||||||
{ label: "Visible", value: "1" },
|
|
||||||
{ label: "Hidden", value: "0" },
|
|
||||||
],
|
|
||||||
render: (value, row) =>
|
|
||||||
canEdit ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleToggle(row.id, "toggleVisible")}
|
|
||||||
disabled={isPending}
|
|
||||||
className="cursor-pointer disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<Badge variant={value === "1" ? "default" : "outline"}>
|
|
||||||
{value === "1" ? "Yes" : "No"}
|
|
||||||
</Badge>
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<Badge variant={value === "1" ? "default" : "outline"}>
|
|
||||||
{value === "1" ? "Yes" : "No"}
|
|
||||||
</Badge>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
...(isBc
|
|
||||||
? []
|
|
||||||
: [
|
|
||||||
{
|
|
||||||
key: "minRank" as const,
|
|
||||||
label: "Min Rank",
|
|
||||||
sortable: true,
|
|
||||||
} satisfies DataTableColumn<CatalogPageRow>,
|
|
||||||
]),
|
|
||||||
{
|
|
||||||
key: "itemCount",
|
|
||||||
label: "Items",
|
|
||||||
render: (value) => <span>{String(value)}</span>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "childCount",
|
|
||||||
label: "Children",
|
|
||||||
render: (value) => <span>{String(value)}</span>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "actions",
|
|
||||||
label: "Actions",
|
|
||||||
render: (_, row) => (
|
|
||||||
<Button variant="ghost" size="icon" asChild>
|
|
||||||
<Link href={detailHref(row.id)}>
|
|
||||||
<Pencil className="h-4 w-4" />
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
async function handleBulkAction(action: string, ids: (number | string)[]) {
|
|
||||||
if (action !== "delete" || ids.length === 0) return;
|
|
||||||
for (const id of ids) {
|
|
||||||
const numId = typeof id === "string" ? Number(id) : id;
|
|
||||||
if (Number.isInteger(numId) && numId > 0) {
|
|
||||||
run(() =>
|
|
||||||
isBc
|
|
||||||
? deleteBcTreePage({ pageId: numId, mode: "reparent" })
|
|
||||||
: deleteCatalogPage({ id: numId }),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DataTable
|
|
||||||
data={data}
|
|
||||||
columns={columns}
|
|
||||||
searchPlaceholder="Search by caption, layout or item..."
|
|
||||||
bulkActions={
|
|
||||||
canEdit
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
label: "Delete Selected",
|
|
||||||
value: "delete",
|
|
||||||
variant: "destructive",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
onBulkAction={canEdit ? handleBulkAction : undefined}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { FolderTree, HardHat, 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 {
|
|
||||||
currentView: string;
|
|
||||||
catalogType?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CatalogViewToggle({
|
|
||||||
currentView,
|
|
||||||
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") {
|
|
||||||
params.delete("view");
|
|
||||||
} else {
|
|
||||||
params.set("view", view);
|
|
||||||
}
|
|
||||||
router.push(`?${params.toString()}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
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");
|
|
||||||
params.delete("root");
|
|
||||||
params.delete("category");
|
|
||||||
router.push(`?${params.toString()}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
{/* Catalog type selector */}
|
|
||||||
<div className="flex items-center rounded-md border bg-card">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className={cn(
|
|
||||||
"h-8 rounded-r-none",
|
|
||||||
catalogType === "normal" && "bg-accent",
|
|
||||||
)}
|
|
||||||
onClick={() => setCatalog("normal")}
|
|
||||||
>
|
|
||||||
<Package className="mr-1.5 h-4 w-4" />
|
|
||||||
{t("normal")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className={cn(
|
|
||||||
"h-8 rounded-l-none",
|
|
||||||
catalogType === "bc" && "bg-accent",
|
|
||||||
)}
|
|
||||||
onClick={() => setCatalog("bc")}
|
|
||||||
>
|
|
||||||
<HardHat className="mr-1.5 h-4 w-4" />
|
|
||||||
Builder Club
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* View mode selector */}
|
|
||||||
<div className="flex items-center rounded-md border bg-card">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className={cn(
|
|
||||||
"h-8 rounded-r-none",
|
|
||||||
currentView === "tree" && "bg-accent",
|
|
||||||
)}
|
|
||||||
onClick={() => setView("tree")}
|
|
||||||
>
|
|
||||||
<FolderTree className="mr-1.5 h-4 w-4" />
|
|
||||||
{t("tree")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className={cn(
|
|
||||||
"h-8 rounded-l-none",
|
|
||||||
currentView === "table" && "bg-accent",
|
|
||||||
)}
|
|
||||||
onClick={() => setView("table")}
|
|
||||||
>
|
|
||||||
<List className="mr-1.5 h-4 w-4" />
|
|
||||||
{t("table")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,4 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
|
||||||
import { CatalogIntegrityPanel } from "@/components/admin/catalog/catalog-integrity-panel";
|
|
||||||
import { CatalogMaintenancePanel } from "@/components/admin/catalog/catalog-maintenance-panel";
|
|
||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
|
|
||||||
export default async function CatalogMaintenancePage() {
|
export default async function CatalogMaintenancePage() {
|
||||||
@@ -9,27 +6,5 @@ export default async function CatalogMaintenancePage() {
|
|||||||
if (!canAccess(permissions, PERMS.CATALOG_VIEW, session.user.rank)) {
|
if (!canAccess(permissions, PERMS.CATALOG_VIEW, session.user.rank)) {
|
||||||
redirect("/admin");
|
redirect("/admin");
|
||||||
}
|
}
|
||||||
|
redirect("/admin/studio/maintenance");
|
||||||
const canRepair = canAccess(
|
|
||||||
permissions,
|
|
||||||
PERMS.CATALOG_EDIT,
|
|
||||||
session.user.rank,
|
|
||||||
);
|
|
||||||
const t = await getTranslations("pages.admin.catalog.integrity");
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<CatalogIntegrityPanel canRepair={canRepair} />
|
|
||||||
{canRepair && (
|
|
||||||
<details className="rounded-xl border p-4">
|
|
||||||
<summary className="cursor-pointer font-medium">
|
|
||||||
{t("legacyTitle")}
|
|
||||||
</summary>
|
|
||||||
<p className="mt-3 text-sm text-muted-foreground">
|
|
||||||
{t("legacyHint")}
|
|
||||||
</p>
|
|
||||||
<CatalogMaintenancePanel />
|
|
||||||
</details>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
+98
-503
@@ -1,26 +1,12 @@
|
|||||||
import {
|
import { asc, count, eq, or } from "drizzle-orm";
|
||||||
and,
|
import { Crown, Store, Wrench } from "lucide-react";
|
||||||
asc,
|
import Link from "next/link";
|
||||||
count,
|
|
||||||
desc,
|
|
||||||
eq,
|
|
||||||
inArray,
|
|
||||||
like,
|
|
||||||
or,
|
|
||||||
type SQL,
|
|
||||||
sql,
|
|
||||||
} from "drizzle-orm";
|
|
||||||
import type { AnyMySqlColumn } from "drizzle-orm/mysql-core";
|
|
||||||
import { Store } from "lucide-react";
|
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
||||||
import { CatalogTree } from "@/components/admin/catalog-tree";
|
import { OrganizeImportsLauncher } from "@/components/admin/studio/organize-imports-launcher";
|
||||||
import Link from "@/components/link";
|
|
||||||
import { CatalogSearch } from "@/features/catalog/components/catalog-search";
|
|
||||||
import { CatalogSyncStatus } from "@/features/catalog/components/catalog-sync-status";
|
import { CatalogSyncStatus } from "@/features/catalog/components/catalog-sync-status";
|
||||||
import { CatalogPackagesDialog } from "@/features/catalog/components/packages/catalog-packages-dialog";
|
import { CatalogPackagesDialog } from "@/features/catalog/components/packages/catalog-packages-dialog";
|
||||||
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
|
||||||
import {
|
import {
|
||||||
CatalogItems,
|
CatalogItems,
|
||||||
CatalogItemsBc,
|
CatalogItemsBc,
|
||||||
@@ -29,15 +15,47 @@ import {
|
|||||||
db,
|
db,
|
||||||
} from "@/lib/db";
|
} from "@/lib/db";
|
||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { getCatalogItemCounts } from "@/lib/services/catalog-tree";
|
import type { RootTab } from "@/types/catalog";
|
||||||
import { CatalogActions } from "./catalog-actions";
|
import { CatalogManagerWorkspace } from "./catalog-manager-dialog-wrapper";
|
||||||
import { CatalogManagerDialog } from "./catalog-manager-dialog-wrapper";
|
|
||||||
import { CatalogTable } from "./catalog-table";
|
|
||||||
import { CatalogViewToggle } from "./catalog-view-toggle";
|
|
||||||
|
|
||||||
async function rawRows<T>(query: ReturnType<typeof sql>): Promise<T[]> {
|
interface Totals {
|
||||||
const [rows] = (await db.execute(query)) as unknown as [T[], unknown];
|
totalPages: number;
|
||||||
return rows ?? [];
|
enabledPages: number;
|
||||||
|
totalItems: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTotals(catalogType: "normal" | "bc"): Promise<Totals> {
|
||||||
|
const pagesTable = catalogType === "bc" ? CatalogPagesBc : CatalogPages;
|
||||||
|
const itemsTable = catalogType === "bc" ? CatalogItemsBc : CatalogItems;
|
||||||
|
const [pages, items, enabled] = await Promise.all([
|
||||||
|
db.select({ total: count() }).from(pagesTable),
|
||||||
|
db.select({ total: count() }).from(itemsTable),
|
||||||
|
db
|
||||||
|
.select({ total: count() })
|
||||||
|
.from(pagesTable)
|
||||||
|
.where(eq(pagesTable.enabled, "1")),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
totalPages: Number(pages[0]?.total ?? 0),
|
||||||
|
totalItems: Number(items[0]?.total ?? 0),
|
||||||
|
enabledPages: Number(enabled[0]?.total ?? 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRootPages(catalogType: "normal" | "bc"): Promise<RootTab[]> {
|
||||||
|
const pagesTable = catalogType === "bc" ? CatalogPagesBc : CatalogPages;
|
||||||
|
const rootRaw = await db
|
||||||
|
.select()
|
||||||
|
.from(pagesTable)
|
||||||
|
.where(or(eq(pagesTable.parentId, -1), eq(pagesTable.parentId, 0)))
|
||||||
|
.orderBy(asc(pagesTable.orderNum), asc(pagesTable.id));
|
||||||
|
return rootRaw.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
caption: p.caption,
|
||||||
|
iconImage: p.iconImage,
|
||||||
|
enabled: String(p.enabled),
|
||||||
|
orderNum: p.orderNum,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function CatalogPagesPage({
|
export default async function CatalogPagesPage({
|
||||||
@@ -56,490 +74,67 @@ export default async function CatalogPagesPage({
|
|||||||
const canEdit = canAccess(permissions, PERMS.CATALOG_EDIT, session.user.rank);
|
const canEdit = canAccess(permissions, PERMS.CATALOG_EDIT, session.user.rank);
|
||||||
const t = await getTranslations("pages.admin.catalog");
|
const t = await getTranslations("pages.admin.catalog");
|
||||||
|
|
||||||
const params = await searchParams;
|
const sp = await searchParams;
|
||||||
const sp = new URLSearchParams(params);
|
const catalogType = sp.catalog === "bc" ? "bc" : "normal";
|
||||||
const { search, perPage, page, sort, order } = parseListParams(sp);
|
|
||||||
const requestedView = sp.get("view");
|
|
||||||
const viewMode = requestedView === "table" ? "table" : "tree";
|
|
||||||
const catalogType = sp.get("catalog") === "bc" ? "bc" : "normal";
|
|
||||||
|
|
||||||
type TreeNodeShape = {
|
const [totals, rootPages] = await Promise.all([
|
||||||
id: number;
|
loadTotals(catalogType),
|
||||||
caption: string;
|
loadRootPages(catalogType),
|
||||||
parentId: number;
|
|
||||||
enabled: string;
|
|
||||||
visible: string;
|
|
||||||
iconImage: number;
|
|
||||||
iconColor: number;
|
|
||||||
pageLayout: string;
|
|
||||||
childCount: number;
|
|
||||||
itemCount: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (catalogType === "bc") {
|
|
||||||
const [totalBcPagesRows, totalBcItemsRows, enabledBcPagesRows] =
|
|
||||||
await Promise.all([
|
|
||||||
db.select({ total: count() }).from(CatalogPagesBc),
|
|
||||||
db.select({ total: count() }).from(CatalogItemsBc),
|
|
||||||
db
|
|
||||||
.select({ total: count() })
|
|
||||||
.from(CatalogPagesBc)
|
|
||||||
.where(eq(CatalogPagesBc.enabled, "1")),
|
|
||||||
]);
|
|
||||||
const totalBcPages = Number(totalBcPagesRows[0]?.total ?? 0);
|
|
||||||
const totalBcItems = Number(totalBcItemsRows[0]?.total ?? 0);
|
|
||||||
const enabledBcPages = Number(enabledBcPagesRows[0]?.total ?? 0);
|
|
||||||
|
|
||||||
let bcRootPages: TreeNodeShape[] = [];
|
|
||||||
if (viewMode === "tree") {
|
|
||||||
const bcRootRaw = await db
|
|
||||||
.select()
|
|
||||||
.from(CatalogPagesBc)
|
|
||||||
.where(
|
|
||||||
or(eq(CatalogPagesBc.parentId, -1), eq(CatalogPagesBc.parentId, 0)),
|
|
||||||
)
|
|
||||||
.orderBy(asc(CatalogPagesBc.orderNum), asc(CatalogPagesBc.id));
|
|
||||||
const bcRootIds = bcRootRaw.map((p) => p.id);
|
|
||||||
const [bcChildCounts, bcItemCounts] = await Promise.all([
|
|
||||||
bcRootIds.length > 0
|
|
||||||
? db
|
|
||||||
.select({
|
|
||||||
parentId: CatalogPagesBc.parentId,
|
|
||||||
total: count(),
|
|
||||||
})
|
|
||||||
.from(CatalogPagesBc)
|
|
||||||
.where(inArray(CatalogPagesBc.parentId, bcRootIds))
|
|
||||||
.groupBy(CatalogPagesBc.parentId)
|
|
||||||
: [],
|
|
||||||
bcRootIds.length > 0
|
|
||||||
? db
|
|
||||||
.select({
|
|
||||||
pageId: CatalogItemsBc.pageId,
|
|
||||||
total: count(),
|
|
||||||
})
|
|
||||||
.from(CatalogItemsBc)
|
|
||||||
.where(inArray(CatalogItemsBc.pageId, bcRootIds))
|
|
||||||
.groupBy(CatalogItemsBc.pageId)
|
|
||||||
: [],
|
|
||||||
]);
|
|
||||||
const bcChildMap = Object.fromEntries(
|
|
||||||
bcChildCounts.map((c) => [c.parentId, Number(c.total)]),
|
|
||||||
);
|
|
||||||
const bcItemMap = Object.fromEntries(
|
|
||||||
bcItemCounts.map((c) => [c.pageId, Number(c.total)]),
|
|
||||||
);
|
|
||||||
bcRootPages = bcRootRaw.map((p) => ({
|
|
||||||
id: p.id,
|
|
||||||
caption: p.caption,
|
|
||||||
parentId: p.parentId,
|
|
||||||
enabled: String(p.enabled),
|
|
||||||
visible: String(p.visible),
|
|
||||||
iconImage: p.iconImage,
|
|
||||||
iconColor: p.iconColor,
|
|
||||||
pageLayout: p.pageLayout,
|
|
||||||
childCount: bcChildMap[p.id] ?? 0,
|
|
||||||
itemCount: bcItemMap[p.id] ?? 0,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
let bcRows: {
|
|
||||||
id: number;
|
|
||||||
caption: string;
|
|
||||||
parentId: number;
|
|
||||||
parentCaption: string;
|
|
||||||
pageLayout: string;
|
|
||||||
enabled: string;
|
|
||||||
visible: string;
|
|
||||||
minRank: number;
|
|
||||||
orderNum: number;
|
|
||||||
itemCount: number;
|
|
||||||
childCount: number;
|
|
||||||
}[] = [];
|
|
||||||
let bcPagination = { total: 0, page: 1, perPage, lastPage: 0 };
|
|
||||||
|
|
||||||
if (viewMode === "table") {
|
|
||||||
const parentFilter = sp.get("parent");
|
|
||||||
const parentId =
|
|
||||||
parentFilter !== null ? parseInt(parentFilter, 10) : undefined;
|
|
||||||
|
|
||||||
const conditions: SQL[] = [];
|
|
||||||
const hasParent = parentId !== undefined && !Number.isNaN(parentId);
|
|
||||||
if (search) {
|
|
||||||
conditions.push(like(CatalogPagesBc.caption, `%${search}%`));
|
|
||||||
}
|
|
||||||
if (hasParent) conditions.push(eq(CatalogPagesBc.parentId, parentId));
|
|
||||||
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
|
||||||
|
|
||||||
const sortKeyMap: Record<string, AnyMySqlColumn> = {
|
|
||||||
parentId: CatalogPagesBc.parentId,
|
|
||||||
pageLayout: CatalogPagesBc.pageLayout,
|
|
||||||
orderNum: CatalogPagesBc.orderNum,
|
|
||||||
caption: CatalogPagesBc.caption,
|
|
||||||
id: CatalogPagesBc.id,
|
|
||||||
};
|
|
||||||
const sortCol = sort
|
|
||||||
? (sortKeyMap[sort] ?? CatalogPagesBc.orderNum)
|
|
||||||
: CatalogPagesBc.orderNum;
|
|
||||||
const orderBy =
|
|
||||||
(sort ? order : "asc") === "desc" ? desc(sortCol) : asc(sortCol);
|
|
||||||
|
|
||||||
const [pages, totalRows] = await Promise.all([
|
|
||||||
db
|
|
||||||
.select()
|
|
||||||
.from(CatalogPagesBc)
|
|
||||||
.where(where)
|
|
||||||
.orderBy(orderBy)
|
|
||||||
.limit(perPage)
|
|
||||||
.offset((page - 1) * perPage),
|
|
||||||
db.select({ total: count() }).from(CatalogPagesBc).where(where),
|
|
||||||
]);
|
|
||||||
const total = Number(totalRows[0]?.total ?? 0);
|
|
||||||
|
|
||||||
bcPagination = calcPagination(total, page, perPage);
|
|
||||||
|
|
||||||
const pageIds = pages.map((p) => p.id);
|
|
||||||
const parentIds = [
|
|
||||||
...new Set(pages.map((p) => p.parentId).filter((id) => id > 0)),
|
|
||||||
];
|
|
||||||
const [childCounts, itemCounts, parentPages] = await Promise.all([
|
|
||||||
pageIds.length > 0
|
|
||||||
? db
|
|
||||||
.select({
|
|
||||||
parentId: CatalogPagesBc.parentId,
|
|
||||||
total: count(),
|
|
||||||
})
|
|
||||||
.from(CatalogPagesBc)
|
|
||||||
.where(inArray(CatalogPagesBc.parentId, pageIds))
|
|
||||||
.groupBy(CatalogPagesBc.parentId)
|
|
||||||
: [],
|
|
||||||
pageIds.length > 0
|
|
||||||
? db
|
|
||||||
.select({
|
|
||||||
pageId: CatalogItemsBc.pageId,
|
|
||||||
total: count(),
|
|
||||||
})
|
|
||||||
.from(CatalogItemsBc)
|
|
||||||
.where(inArray(CatalogItemsBc.pageId, pageIds))
|
|
||||||
.groupBy(CatalogItemsBc.pageId)
|
|
||||||
: [],
|
|
||||||
parentIds.length > 0
|
|
||||||
? db
|
|
||||||
.select({
|
|
||||||
id: CatalogPagesBc.id,
|
|
||||||
caption: CatalogPagesBc.caption,
|
|
||||||
})
|
|
||||||
.from(CatalogPagesBc)
|
|
||||||
.where(inArray(CatalogPagesBc.id, parentIds))
|
|
||||||
: [],
|
|
||||||
]);
|
|
||||||
const childCountMap = Object.fromEntries(
|
|
||||||
childCounts.map((c) => [c.parentId, Number(c.total)]),
|
|
||||||
);
|
|
||||||
const itemCountMap = Object.fromEntries(
|
|
||||||
itemCounts.map((c) => [c.pageId, Number(c.total)]),
|
|
||||||
);
|
|
||||||
const parentNameMap = Object.fromEntries(
|
|
||||||
parentPages.map((p) => [p.id, p.caption]),
|
|
||||||
);
|
|
||||||
|
|
||||||
bcRows = pages.map((p) => ({
|
|
||||||
id: p.id,
|
|
||||||
caption: p.caption,
|
|
||||||
parentId: p.parentId,
|
|
||||||
parentCaption:
|
|
||||||
p.parentId === -1
|
|
||||||
? "Root"
|
|
||||||
: (parentNameMap[p.parentId] ?? `#${p.parentId}`),
|
|
||||||
pageLayout: String(p.pageLayout),
|
|
||||||
enabled: String(p.enabled),
|
|
||||||
visible: String(p.visible),
|
|
||||||
minRank: 0,
|
|
||||||
orderNum: p.orderNum,
|
|
||||||
itemCount: itemCountMap[p.id] ?? 0,
|
|
||||||
childCount: childCountMap[p.id] ?? 0,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AdminPageShell icon={Store} title={t("title")} subtitle={t("subtitle")}>
|
|
||||||
<div className="mb-4 space-y-3">
|
|
||||||
<CatalogSyncStatus canEdit={canEdit} />
|
|
||||||
<CatalogSearch catalogType={catalogType} canEdit={canEdit} />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
{totalBcPages} BC pages ({enabledBcPages} enabled) ·{" "}
|
|
||||||
{totalBcItems} items
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<CatalogViewToggle currentView={viewMode} catalogType="bc" />
|
|
||||||
{canEdit && (
|
|
||||||
<CatalogManagerDialog
|
|
||||||
catalogType="bc"
|
|
||||||
canEdit={canEdit}
|
|
||||||
totals={{
|
|
||||||
totalPages: totalBcPages,
|
|
||||||
enabledPages: enabledBcPages,
|
|
||||||
totalItems: totalBcItems,
|
|
||||||
}}
|
|
||||||
initialRoots={bcRootPages.map((p) => ({
|
|
||||||
id: p.id,
|
|
||||||
caption: p.caption,
|
|
||||||
iconImage: p.iconImage,
|
|
||||||
enabled: p.enabled,
|
|
||||||
orderNum: 0,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{canEdit && (
|
|
||||||
<CatalogActions
|
|
||||||
catalogType="bc"
|
|
||||||
initialParentId={sp.get("newPageParent")}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Link
|
|
||||||
className="btn btn-outline"
|
|
||||||
href="/admin/catalog/maintenance"
|
|
||||||
>
|
|
||||||
{t("workspace.diagnostics")}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{viewMode === "tree" ? (
|
|
||||||
<CatalogTree rootPages={bcRootPages} catalogType="bc" />
|
|
||||||
) : (
|
|
||||||
<CatalogTable
|
|
||||||
data={{ rows: bcRows, ...bcPagination }}
|
|
||||||
canEdit={canEdit}
|
|
||||||
catalogType="bc"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</AdminPageShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [totalPagesRows, totalItemsRows, enabledPagesRows] = await Promise.all([
|
|
||||||
db.select({ total: count() }).from(CatalogPages),
|
|
||||||
db.select({ total: count() }).from(CatalogItems),
|
|
||||||
db
|
|
||||||
.select({ total: count() })
|
|
||||||
.from(CatalogPages)
|
|
||||||
.where(eq(CatalogPages.enabled, "1")),
|
|
||||||
]);
|
]);
|
||||||
const totalPages = Number(totalPagesRows[0]?.total ?? 0);
|
|
||||||
const totalItems = Number(totalItemsRows[0]?.total ?? 0);
|
|
||||||
const enabledPages = Number(enabledPagesRows[0]?.total ?? 0);
|
|
||||||
|
|
||||||
const rootPagesRaw = await db
|
|
||||||
.select()
|
|
||||||
.from(CatalogPages)
|
|
||||||
.where(or(eq(CatalogPages.parentId, -1), eq(CatalogPages.parentId, 0)))
|
|
||||||
.orderBy(asc(CatalogPages.orderNum), asc(CatalogPages.id));
|
|
||||||
const rootIds = rootPagesRaw.map((p) => p.id);
|
|
||||||
const [rootChildCounts, rootItemCountMap] = await Promise.all([
|
|
||||||
rootIds.length > 0
|
|
||||||
? db
|
|
||||||
.select({
|
|
||||||
parentId: CatalogPages.parentId,
|
|
||||||
total: count(),
|
|
||||||
})
|
|
||||||
.from(CatalogPages)
|
|
||||||
.where(inArray(CatalogPages.parentId, rootIds))
|
|
||||||
.groupBy(CatalogPages.parentId)
|
|
||||||
: [],
|
|
||||||
getCatalogItemCounts(rootIds),
|
|
||||||
]);
|
|
||||||
const rootChildMap = Object.fromEntries(
|
|
||||||
rootChildCounts.map((c) => [c.parentId, Number(c.total)]),
|
|
||||||
);
|
|
||||||
const rootItemMap = Object.fromEntries(rootItemCountMap);
|
|
||||||
const rootPages: TreeNodeShape[] = rootPagesRaw.map((p) => ({
|
|
||||||
id: p.id,
|
|
||||||
caption: p.caption,
|
|
||||||
parentId: p.parentId,
|
|
||||||
enabled: String(p.enabled),
|
|
||||||
visible: String(p.visible),
|
|
||||||
iconImage: p.iconImage,
|
|
||||||
iconColor: p.iconColor,
|
|
||||||
pageLayout: p.pageLayout,
|
|
||||||
childCount: rootChildMap[p.id] ?? 0,
|
|
||||||
itemCount: rootItemMap[p.id] ?? 0,
|
|
||||||
}));
|
|
||||||
|
|
||||||
let rows: {
|
|
||||||
id: number;
|
|
||||||
caption: string;
|
|
||||||
parentId: number;
|
|
||||||
parentCaption: string;
|
|
||||||
pageLayout: string;
|
|
||||||
enabled: string;
|
|
||||||
visible: string;
|
|
||||||
minRank: number;
|
|
||||||
orderNum: number;
|
|
||||||
itemCount: number;
|
|
||||||
childCount: number;
|
|
||||||
}[] = [];
|
|
||||||
let pagination = { total: 0, page: 1, perPage, lastPage: 0 };
|
|
||||||
|
|
||||||
if (viewMode === "table") {
|
|
||||||
const parentFilter = sp.get("parent");
|
|
||||||
const parentId =
|
|
||||||
parentFilter !== null ? parseInt(parentFilter, 10) : undefined;
|
|
||||||
|
|
||||||
const conditions: SQL[] = [];
|
|
||||||
const hasParent = parentId !== undefined && !Number.isNaN(parentId);
|
|
||||||
if (search) {
|
|
||||||
const searchPattern = `%${search}%`;
|
|
||||||
const parentClause = hasParent
|
|
||||||
? sql`AND cp.parent_id = ${parentId}`
|
|
||||||
: sql``;
|
|
||||||
|
|
||||||
const matchedIds = await rawRows<{ id: number }>(sql`
|
|
||||||
SELECT DISTINCT cp.id FROM catalog_pages cp
|
|
||||||
LEFT JOIN catalog_items ci ON CAST(ci.page_id AS CHAR) = CAST(cp.id AS CHAR)
|
|
||||||
LEFT JOIN items_base ib ON FIND_IN_SET(ib.id, REPLACE(ci.item_ids, ';', ','))
|
|
||||||
WHERE (cp.caption LIKE ${searchPattern}
|
|
||||||
OR cp.page_layout LIKE ${searchPattern}
|
|
||||||
OR ci.catalog_name LIKE ${searchPattern}
|
|
||||||
OR ib.public_name LIKE ${searchPattern}
|
|
||||||
OR ib.item_name LIKE ${searchPattern})
|
|
||||||
${parentClause}
|
|
||||||
`);
|
|
||||||
const ids = matchedIds.map((r) => r.id);
|
|
||||||
if (ids.length > 0) conditions.push(inArray(CatalogPages.id, ids));
|
|
||||||
else conditions.push(sql`1 = 0`);
|
|
||||||
} else if (hasParent) {
|
|
||||||
conditions.push(eq(CatalogPages.parentId, parentId));
|
|
||||||
}
|
|
||||||
|
|
||||||
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
|
||||||
const sortColMap: Record<string, AnyMySqlColumn> = {
|
|
||||||
caption: CatalogPages.caption,
|
|
||||||
parentId: CatalogPages.parentId,
|
|
||||||
pageLayout: CatalogPages.pageLayout,
|
|
||||||
enabled: CatalogPages.enabled,
|
|
||||||
orderNum: CatalogPages.orderNum,
|
|
||||||
id: CatalogPages.id,
|
|
||||||
minRank: CatalogPages.minRank,
|
|
||||||
};
|
|
||||||
const sortCol = sort
|
|
||||||
? (sortColMap[sort] ?? CatalogPages.orderNum)
|
|
||||||
: CatalogPages.orderNum;
|
|
||||||
const orderBy =
|
|
||||||
(sort ? order : "asc") === "desc" ? desc(sortCol) : asc(sortCol);
|
|
||||||
|
|
||||||
const [pages, totalRows] = await Promise.all([
|
|
||||||
db
|
|
||||||
.select()
|
|
||||||
.from(CatalogPages)
|
|
||||||
.where(where)
|
|
||||||
.orderBy(orderBy)
|
|
||||||
.limit(perPage)
|
|
||||||
.offset((page - 1) * perPage),
|
|
||||||
db.select({ total: count() }).from(CatalogPages).where(where),
|
|
||||||
]);
|
|
||||||
const total = Number(totalRows[0]?.total ?? 0);
|
|
||||||
|
|
||||||
pagination = calcPagination(total, page, perPage);
|
|
||||||
|
|
||||||
const pageIds = pages.map((p) => p.id);
|
|
||||||
const parentIds = [
|
|
||||||
...new Set(pages.map((p) => p.parentId).filter((id) => id > 0)),
|
|
||||||
];
|
|
||||||
const [childCounts, itemCountMapRaw, parentPages] = await Promise.all([
|
|
||||||
pageIds.length > 0
|
|
||||||
? db
|
|
||||||
.select({
|
|
||||||
parentId: CatalogPages.parentId,
|
|
||||||
total: count(),
|
|
||||||
})
|
|
||||||
.from(CatalogPages)
|
|
||||||
.where(inArray(CatalogPages.parentId, pageIds))
|
|
||||||
.groupBy(CatalogPages.parentId)
|
|
||||||
: [],
|
|
||||||
getCatalogItemCounts(pageIds),
|
|
||||||
parentIds.length > 0
|
|
||||||
? db
|
|
||||||
.select({ id: CatalogPages.id, caption: CatalogPages.caption })
|
|
||||||
.from(CatalogPages)
|
|
||||||
.where(inArray(CatalogPages.id, parentIds))
|
|
||||||
: [],
|
|
||||||
]);
|
|
||||||
const childCountMap = Object.fromEntries(
|
|
||||||
childCounts.map((c) => [c.parentId, Number(c.total)]),
|
|
||||||
);
|
|
||||||
const itemCountMap = Object.fromEntries(itemCountMapRaw);
|
|
||||||
const parentNameMap = Object.fromEntries(
|
|
||||||
parentPages.map((p) => [p.id, p.caption]),
|
|
||||||
);
|
|
||||||
|
|
||||||
rows = pages.map((p) => ({
|
|
||||||
id: p.id,
|
|
||||||
caption: p.caption,
|
|
||||||
parentId: p.parentId,
|
|
||||||
parentCaption:
|
|
||||||
p.parentId === -1
|
|
||||||
? "Root"
|
|
||||||
: (parentNameMap[p.parentId] ?? `#${p.parentId}`),
|
|
||||||
pageLayout: p.pageLayout,
|
|
||||||
enabled: p.enabled,
|
|
||||||
visible: p.visible,
|
|
||||||
minRank: p.minRank,
|
|
||||||
orderNum: p.orderNum,
|
|
||||||
itemCount: itemCountMap[p.id] ?? 0,
|
|
||||||
childCount: childCountMap[p.id] ?? 0,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminPageShell icon={Store} title={t("title")} subtitle={t("subtitle")}>
|
<AdminPageShell
|
||||||
<div className="mb-4 space-y-3">
|
icon={Store}
|
||||||
<CatalogSyncStatus canEdit={canEdit} />
|
title={catalogType === "bc" ? "Builder Club Catalog" : t("title")}
|
||||||
<CatalogSearch catalogType={catalogType} canEdit={canEdit} />
|
subtitle={t("subtitle")}
|
||||||
</div>
|
actions={
|
||||||
<div className="space-y-6">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center rounded-lg border border-[var(--admin-border)] p-0.5">
|
||||||
<p className="text-muted-foreground">
|
<Link
|
||||||
{totalPages} pages ({enabledPages} enabled) · {totalItems}{" "}
|
href="/admin/catalog"
|
||||||
items
|
className={
|
||||||
</p>
|
catalogType === "normal"
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
? "inline-flex items-center gap-1.5 rounded-md bg-[var(--admin-accent)]/10 px-3 py-1.5 text-sm font-medium text-[var(--admin-text)]"
|
||||||
<CatalogViewToggle currentView={viewMode} catalogType="normal" />
|
: "inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium text-[var(--admin-text-muted)] hover:text-[var(--admin-text)]"
|
||||||
<CatalogPackagesDialog canEdit={canEdit} />
|
}
|
||||||
{canEdit && (
|
>
|
||||||
<CatalogManagerDialog
|
<Store className="h-3.5 w-3.5" />
|
||||||
catalogType="normal"
|
Normal
|
||||||
canEdit={canEdit}
|
</Link>
|
||||||
totals={{
|
<Link
|
||||||
totalPages,
|
href="/admin/catalog?catalog=bc"
|
||||||
enabledPages,
|
className={
|
||||||
totalItems,
|
catalogType === "bc"
|
||||||
}}
|
? "inline-flex items-center gap-1.5 rounded-md bg-[var(--admin-accent)]/10 px-3 py-1.5 text-sm font-medium text-[var(--admin-text)]"
|
||||||
initialRoots={rootPages.map((p) => ({
|
: "inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium text-[var(--admin-text-muted)] hover:text-[var(--admin-text)]"
|
||||||
id: p.id,
|
}
|
||||||
caption: p.caption,
|
>
|
||||||
iconImage: p.iconImage,
|
<Crown className="h-3.5 w-3.5" />
|
||||||
enabled: p.enabled,
|
Builder Club
|
||||||
orderNum: 0,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{canEdit && (
|
|
||||||
<CatalogActions initialParentId={sp.get("newPageParent")} />
|
|
||||||
)}
|
|
||||||
<Link className="btn btn-outline" href="/admin/catalog/maintenance">
|
|
||||||
{t("workspace.diagnostics")}
|
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
{catalogType === "normal" && (
|
||||||
|
<CatalogPackagesDialog canEdit={canEdit} />
|
||||||
|
)}
|
||||||
|
<OrganizeImportsLauncher />
|
||||||
|
<Link
|
||||||
|
className="inline-flex min-h-10 items-center gap-2 rounded-lg border border-[var(--admin-border)] px-3 text-sm font-medium text-[var(--admin-text-muted)] hover:bg-[var(--admin-accent)]/10"
|
||||||
|
href="/admin/studio/maintenance"
|
||||||
|
>
|
||||||
|
<Wrench className="h-3.5 w-3.5" />
|
||||||
|
{t("workspace.diagnostics")}
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
{viewMode === "tree" ? (
|
>
|
||||||
<CatalogTree rootPages={rootPages} catalogType="normal" />
|
<CatalogSyncStatus canEdit={canEdit} />
|
||||||
) : (
|
<div className="h-[calc(100dvh-13rem)] min-h-[26rem]">
|
||||||
<CatalogTable data={{ rows, ...pagination }} canEdit={canEdit} />
|
<CatalogManagerWorkspace
|
||||||
)}
|
catalogType={catalogType}
|
||||||
|
canEdit={canEdit}
|
||||||
|
totals={totals}
|
||||||
|
initialRoots={rootPages}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</AdminPageShell>
|
</AdminPageShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { CatalogIntegrityPanel } from "@/components/admin/catalog/catalog-integrity-panel";
|
||||||
import { CatalogMaintenancePanel } from "@/components/admin/catalog/catalog-maintenance-panel";
|
import { CatalogMaintenancePanel } from "@/components/admin/catalog/catalog-maintenance-panel";
|
||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
|
|
||||||
@@ -8,9 +10,26 @@ export default async function StudioCatalogMaintenancePage() {
|
|||||||
redirect("/admin");
|
redirect("/admin");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const canRepair = canAccess(
|
||||||
|
permissions,
|
||||||
|
PERMS.CATALOG_EDIT,
|
||||||
|
session.user.rank,
|
||||||
|
);
|
||||||
|
const t = await getTranslations("pages.admin.catalog.integrity");
|
||||||
return (
|
return (
|
||||||
<div className="min-h-0">
|
<div className="min-h-0 space-y-6">
|
||||||
<CatalogMaintenancePanel />
|
<CatalogIntegrityPanel canRepair={canRepair} />
|
||||||
|
{canRepair && (
|
||||||
|
<details className="rounded-xl border p-4">
|
||||||
|
<summary className="cursor-pointer font-medium">
|
||||||
|
{t("legacyTitle")}
|
||||||
|
</summary>
|
||||||
|
<p className="mt-3 text-sm text-muted-foreground">
|
||||||
|
{t("legacyHint")}
|
||||||
|
</p>
|
||||||
|
<CatalogMaintenancePanel />
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import { asc, eq } from "drizzle-orm";
|
||||||
import { apiError, apiOk } from "@/lib/api";
|
import { apiError, apiOk } from "@/lib/api";
|
||||||
import { withAdmin } from "@/lib/api-handler";
|
import { withAdmin } from "@/lib/api-handler";
|
||||||
|
import { CatalogItemsBc, db } from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { loadCatalogItemsData } from "@/lib/services/catalog-items-loader";
|
import { loadCatalogItemsData } from "@/lib/services/catalog-items-loader";
|
||||||
|
|
||||||
@@ -16,6 +18,15 @@ export const GET = withAdmin(
|
|||||||
if (!Number.isFinite(pageId) || pageId <= 0) {
|
if (!Number.isFinite(pageId) || pageId <= 0) {
|
||||||
return apiError("Invalid pageId");
|
return apiError("Invalid pageId");
|
||||||
}
|
}
|
||||||
|
const isBc = request.nextUrl.searchParams.get("catalog") === "bc";
|
||||||
|
if (isBc) {
|
||||||
|
const items = await db
|
||||||
|
.select()
|
||||||
|
.from(CatalogItemsBc)
|
||||||
|
.where(eq(CatalogItemsBc.pageId, pageId))
|
||||||
|
.orderBy(asc(CatalogItemsBc.orderNumber));
|
||||||
|
return apiOk({ items: jsonSafe(items) });
|
||||||
|
}
|
||||||
const data = await loadCatalogItemsData(pageId);
|
const data = await loadCatalogItemsData(pageId);
|
||||||
return apiOk(jsonSafe(data) as unknown as Record<string, unknown>);
|
return apiOk(jsonSafe(data) as unknown as Record<string, unknown>);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useTranslations } from "next-intl";
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { type CatalogKind, createAutoCategory } from "@/actions/catalog";
|
import { type CatalogKind, createAutoCategory } from "@/actions/catalog";
|
||||||
import type { CatalogItemData } from "@/app/admin/catalog/[id]/catalog-items-table/types";
|
import type { CatalogItemData } from "@/components/admin/catalog/catalog-items-table/types";
|
||||||
import { IconPicker } from "@/components/admin/catalog/icon-picker";
|
import { IconPicker } from "@/components/admin/catalog/icon-picker";
|
||||||
import { useCatalogManager } from "@/components/admin/catalog-manager/use-catalog-store";
|
import { useCatalogManager } from "@/components/admin/catalog-manager/use-catalog-store";
|
||||||
import { CatalogIcon, LAYOUT_COLORS } from "@/components/admin/catalog-tree";
|
import { CatalogIcon, LAYOUT_COLORS } from "@/components/admin/catalog-tree";
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { ArrowDownUp, Loader2, Wand2 } from "lucide-react";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { reorderCatalogItems } from "@/actions/catalog-items";
|
import { reorderCatalogItems } from "@/actions/catalog-items";
|
||||||
import type { CatalogItemData } from "@/app/admin/catalog/[id]/catalog-items-table/types";
|
import type { CatalogItemData } from "@/components/admin/catalog/catalog-items-table/types";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
|
|||||||
@@ -0,0 +1,333 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Loader2, Package, Pencil, Plus, Trash2 } from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
createBcItem,
|
||||||
|
deleteBcItem,
|
||||||
|
updateBcItem,
|
||||||
|
} from "@/actions/catalog-bc";
|
||||||
|
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { useLatestRequest } from "@/features/catalog/client/use-latest-request";
|
||||||
|
import { useServerAction } from "@/hooks/use-server-action";
|
||||||
|
|
||||||
|
interface BcItem {
|
||||||
|
id: number;
|
||||||
|
pageId: number;
|
||||||
|
itemIds: string;
|
||||||
|
catalogName: string;
|
||||||
|
orderNumber: number;
|
||||||
|
extradata: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BcItemsEditorProps {
|
||||||
|
pageId: number;
|
||||||
|
canEdit: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BcItemsEditor({ pageId, canEdit }: BcItemsEditorProps) {
|
||||||
|
const { confirm, dialog: confirmDialog } = useConfirmDialog();
|
||||||
|
const requests = useLatestRequest();
|
||||||
|
const [items, setItems] = useState<BcItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
const [editingItem, setEditingItem] = useState<BcItem | null>(null);
|
||||||
|
const [addingItem, setAddingItem] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
const request = requests.start();
|
||||||
|
setError(false);
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/admin/catalog/items?pageId=${pageId}&catalog=bc`,
|
||||||
|
{ signal: request.signal },
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
const data = await res.json();
|
||||||
|
if (!request.isCurrent()) return;
|
||||||
|
setItems(Array.isArray(data.items) ? data.items : []);
|
||||||
|
} catch {
|
||||||
|
if (!request.isCurrent()) return;
|
||||||
|
setError(true);
|
||||||
|
} finally {
|
||||||
|
if (request.isCurrent()) setLoading(false);
|
||||||
|
}
|
||||||
|
}, [pageId, requests]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (pageId > 0) void load();
|
||||||
|
return () => requests.cancel();
|
||||||
|
}, [pageId, load, requests]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{confirmDialog}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
<Package className="h-4 w-4 text-muted-foreground" />
|
||||||
|
BC Items ({items.length})
|
||||||
|
</div>
|
||||||
|
{canEdit && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
onClick={() => setAddingItem(true)}
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3 mr-1" />
|
||||||
|
Add Item
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => void load()}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-8 rounded-md border border-dashed">
|
||||||
|
No items in this page.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-md border overflow-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="text-xs w-16">ID</TableHead>
|
||||||
|
<TableHead className="text-xs">Name</TableHead>
|
||||||
|
<TableHead className="text-xs">Item IDs</TableHead>
|
||||||
|
<TableHead className="text-xs w-20">Order</TableHead>
|
||||||
|
<TableHead className="text-xs">Extra Data</TableHead>
|
||||||
|
{canEdit && <TableHead className="text-xs w-20">Actions</TableHead>}
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{items.map((item) => (
|
||||||
|
<TableRow key={item.id}>
|
||||||
|
<TableCell className="text-xs font-mono">{item.id}</TableCell>
|
||||||
|
<TableCell className="text-xs font-medium">
|
||||||
|
{item.catalogName}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs font-mono text-muted-foreground">
|
||||||
|
{item.itemIds}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs">
|
||||||
|
{item.orderNumber}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs text-muted-foreground max-w-32 truncate">
|
||||||
|
{item.extradata || "\u2014"}
|
||||||
|
</TableCell>
|
||||||
|
{canEdit && (
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 w-6 p-0"
|
||||||
|
onClick={() => setEditingItem(item)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 w-6 p-0 text-[var(--admin-error)] hover:text-[var(--admin-error)]"
|
||||||
|
onClick={async () => {
|
||||||
|
const ok = await confirm({
|
||||||
|
title: "Delete BC item",
|
||||||
|
description: `Delete item "${item.catalogName}"?`,
|
||||||
|
confirmLabel: "Delete",
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
const result = await deleteBcItem({
|
||||||
|
id: item.id,
|
||||||
|
});
|
||||||
|
if (result.ok) {
|
||||||
|
setItems((prev) =>
|
||||||
|
prev.filter((i) => i.id !== item.id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editingItem && (
|
||||||
|
<BcItemDialog
|
||||||
|
item={editingItem}
|
||||||
|
mode="edit"
|
||||||
|
onClose={() => setEditingItem(null)}
|
||||||
|
onSubmit={async (data) => {
|
||||||
|
const result = await updateBcItem({ id: editingItem.id, ...data });
|
||||||
|
if (result.ok) {
|
||||||
|
setItems((prev) =>
|
||||||
|
prev.map((i) =>
|
||||||
|
i.id === editingItem.id ? { ...i, ...data } : i,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{addingItem && (
|
||||||
|
<BcItemDialog
|
||||||
|
item={{
|
||||||
|
id: 0,
|
||||||
|
pageId,
|
||||||
|
itemIds: "",
|
||||||
|
catalogName: "",
|
||||||
|
orderNumber: 1,
|
||||||
|
extradata: "",
|
||||||
|
}}
|
||||||
|
mode="add"
|
||||||
|
onClose={() => setAddingItem(false)}
|
||||||
|
onSubmit={async (data) => {
|
||||||
|
const result = await createBcItem({ pageId, ...data });
|
||||||
|
if (result.ok) await load();
|
||||||
|
return result;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BcItemDialog({
|
||||||
|
item,
|
||||||
|
mode,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
item: BcItem;
|
||||||
|
mode: "add" | "edit";
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (data: {
|
||||||
|
itemIds: string;
|
||||||
|
catalogName: string;
|
||||||
|
orderNumber: number;
|
||||||
|
extradata: string;
|
||||||
|
}) => Promise<{ ok: boolean; error?: string }>;
|
||||||
|
}) {
|
||||||
|
const { run, isPending } = useServerAction();
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
itemIds: item.itemIds,
|
||||||
|
catalogName: item.catalogName,
|
||||||
|
orderNumber: item.orderNumber,
|
||||||
|
extradata: item.extradata,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{mode === "add" ? "Add BC Item" : `Edit Item #${item.id}`}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Catalog Name</Label>
|
||||||
|
<Input
|
||||||
|
value={form.catalogName}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({ ...f, catalogName: e.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Item IDs (semicolon-separated)</Label>
|
||||||
|
<Input
|
||||||
|
value={form.itemIds}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({ ...f, itemIds: e.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Order</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={form.orderNumber}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
orderNumber: parseInt(e.target.value, 10) || 0,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Extra Data</Label>
|
||||||
|
<Input
|
||||||
|
value={form.extradata}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({ ...f, extradata: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="Optional"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onClose} disabled={isPending}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
run(() => onSubmit(form), {
|
||||||
|
successMessage:
|
||||||
|
mode === "add" ? "Item created." : "Item updated.",
|
||||||
|
errorMessage: "Failed to save.",
|
||||||
|
onSuccess: () => onClose(),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
disabled={isPending || !form.catalogName || !form.itemIds}
|
||||||
|
>
|
||||||
|
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
{mode === "add" ? "Add Item" : "Save"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -47,7 +47,7 @@ export function CatalogManagerWorkspace({
|
|||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
aria-label={t("manager")}
|
aria-label={t("manager")}
|
||||||
className="flex h-[calc(100dvh-12rem)] min-h-[32rem] min-w-0 flex-col overflow-hidden rounded-lg border border-[var(--admin-border)] bg-[var(--admin-canvas)] text-[var(--admin-text)]"
|
className="flex h-full min-h-0 min-w-0 flex-col overflow-hidden rounded-lg border border-[var(--admin-border)] bg-[var(--admin-canvas)] text-[var(--admin-text)]"
|
||||||
>
|
>
|
||||||
<CatalogManagerProvider
|
<CatalogManagerProvider
|
||||||
key={catalogType}
|
key={catalogType}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
AlertCircle,
|
AlertCircle,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
ExternalLink,
|
|
||||||
Languages,
|
Languages,
|
||||||
Loader2,
|
Loader2,
|
||||||
MonitorPlay,
|
MonitorPlay,
|
||||||
@@ -20,16 +19,16 @@ import { updateBcPage } from "@/actions/catalog-bc";
|
|||||||
import {
|
import {
|
||||||
type CatalogItemData,
|
type CatalogItemData,
|
||||||
CatalogItemsTable,
|
CatalogItemsTable,
|
||||||
} from "@/app/admin/catalog/[id]/catalog-items-table";
|
} from "@/components/admin/catalog/catalog-items-table";
|
||||||
import { CatalogTranslateTab } from "@/app/admin/catalog/[id]/catalog-translate-tab";
|
import { CatalogTranslateTab } from "@/components/admin/catalog/catalog-translate-tab";
|
||||||
import { BulkImportItems } from "@/components/admin/catalog/bulk-import-items";
|
import { BulkImportItems } from "@/components/admin/catalog/bulk-import-items";
|
||||||
import { CatalogImagePicker } from "@/components/admin/catalog/catalog-image-picker";
|
import { CatalogImagePicker } from "@/components/admin/catalog/catalog-image-picker";
|
||||||
import { IconPicker } from "@/components/admin/catalog/icon-picker";
|
import { IconPicker } from "@/components/admin/catalog/icon-picker";
|
||||||
import { QuickAddFurni } from "@/components/admin/catalog/quick-add-furni";
|
import { QuickAddFurni } from "@/components/admin/catalog/quick-add-furni";
|
||||||
import { AutoOrganize } from "@/components/admin/catalog-manager/auto-organize";
|
import { AutoOrganize } from "@/components/admin/catalog-manager/auto-organize";
|
||||||
|
import { BcItemsEditor } from "@/components/admin/catalog-manager/bc-items-editor";
|
||||||
import { LiveCatalogPreview } from "@/components/admin/catalog-manager/live-catalog-preview";
|
import { LiveCatalogPreview } from "@/components/admin/catalog-manager/live-catalog-preview";
|
||||||
import { CatalogIcon, LAYOUT_COLORS } from "@/components/admin/catalog-tree";
|
import { CatalogIcon, LAYOUT_COLORS } from "@/components/admin/catalog-tree";
|
||||||
import Link from "@/components/link";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -133,10 +132,6 @@ function InlineEditorSession({
|
|||||||
const [offersDirty, setOffersDirty] = useState(false);
|
const [offersDirty, setOffersDirty] = useState(false);
|
||||||
const { dispatch, catalogType, canEdit } = useCatalogManager();
|
const { dispatch, catalogType, canEdit } = useCatalogManager();
|
||||||
const catQs = catalogTreeQs(catalogType);
|
const catQs = catalogTreeQs(catalogType);
|
||||||
const pageHref =
|
|
||||||
catalogType === "bc"
|
|
||||||
? `/admin/catalog/builder-club/${pageId}`
|
|
||||||
: `/admin/catalog/${pageId}`;
|
|
||||||
const [page, setPage] = useState<PageDetail | null>(null);
|
const [page, setPage] = useState<PageDetail | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -425,17 +420,7 @@ function InlineEditorSession({
|
|||||||
{t("preview.title")}
|
{t("preview.title")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Link href={pageHref} target="_blank">
|
</div>
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 text-xs gap-1 text-[var(--admin-text)]"
|
|
||||||
>
|
|
||||||
<ExternalLink className="h-3 w-3" />
|
|
||||||
{t("fullEditor")}
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Dirty banner */}
|
{/* Dirty banner */}
|
||||||
@@ -671,7 +656,10 @@ function InlineEditorSession({
|
|||||||
<ChevronRight className="h-3.5 w-3.5" />
|
<ChevronRight className="h-3.5 w-3.5" />
|
||||||
)}
|
)}
|
||||||
Text fields
|
Text fields
|
||||||
{(form.pageText1 || form.pageText2 || form.pageTextDetails) && (
|
{(form.pageText1 ||
|
||||||
|
form.pageText2 ||
|
||||||
|
form.pageTextDetails ||
|
||||||
|
form.pageTextTeaser) && (
|
||||||
<Badge variant="secondary" className="h-4 px-1 text-[9px]">
|
<Badge variant="secondary" className="h-4 px-1 text-[9px]">
|
||||||
has content
|
has content
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -713,6 +701,17 @@ function InlineEditorSession({
|
|||||||
className="text-sm"
|
className="text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Text Teaser</Label>
|
||||||
|
<Textarea
|
||||||
|
rows={2}
|
||||||
|
value={String(form.pageTextTeaser || "")}
|
||||||
|
onChange={(e) =>
|
||||||
|
update("pageTextTeaser", e.target.value || null)
|
||||||
|
}
|
||||||
|
className="text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs">Special</Label>
|
<Label className="text-xs">Special</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -750,17 +749,7 @@ function InlineEditorSession({
|
|||||||
className="flex-1 overflow-y-auto px-4 pb-4 mt-2"
|
className="flex-1 overflow-y-auto px-4 pb-4 mt-2"
|
||||||
>
|
>
|
||||||
{catalogType === "bc" ? (
|
{catalogType === "bc" ? (
|
||||||
<div className="rounded-md border border-dashed px-4 py-8 text-center text-sm text-muted-foreground space-y-3">
|
<BcItemsEditor pageId={page.id} canEdit={canEdit} />
|
||||||
<p>
|
|
||||||
BC items are managed on the full Builder Club page editor.
|
|
||||||
</p>
|
|
||||||
<Link href={pageHref} target="_blank">
|
|
||||||
<Button variant="outline" size="sm" className="gap-1">
|
|
||||||
<ExternalLink className="h-3 w-3" />
|
|
||||||
Open BC page editor
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
) : itemsError && !itemsData ? (
|
) : itemsError && !itemsData ? (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<Button variant="outline" onClick={reloadItems}>
|
<Button variant="outline" onClick={reloadItems}>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
Wand2,
|
Wand2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import type { CatalogItemData } from "@/app/admin/catalog/[id]/catalog-items-table/types";
|
import type { CatalogItemData } from "@/components/admin/catalog/catalog-items-table/types";
|
||||||
import {
|
import {
|
||||||
getShopGridClass,
|
getShopGridClass,
|
||||||
getShopTileSize,
|
getShopTileSize,
|
||||||
|
|||||||
@@ -97,8 +97,6 @@ export function SortableTree({
|
|||||||
const { state, dispatch, catalogType, canEdit } = useCatalogManager();
|
const { state, dispatch, catalogType, canEdit } = useCatalogManager();
|
||||||
const t = useTranslations("pages.admin.catalog.workspace.duplicate");
|
const t = useTranslations("pages.admin.catalog.workspace.duplicate");
|
||||||
const catQs = catalogTreeQs(catalogType);
|
const catQs = catalogTreeQs(catalogType);
|
||||||
const pageBase =
|
|
||||||
catalogType === "bc" ? "/admin/catalog/builder-club" : "/admin/catalog";
|
|
||||||
const { confirm, dialog: confirmDialog } = useConfirmDialog();
|
const { confirm, dialog: confirmDialog } = useConfirmDialog();
|
||||||
const {
|
const {
|
||||||
nodes,
|
nodes,
|
||||||
@@ -713,8 +711,6 @@ export function SortableTree({
|
|||||||
},
|
},
|
||||||
[state.editorDirty, t, canEdit, nodes, catalogType],
|
[state.editorDirty, t, canEdit, nodes, catalogType],
|
||||||
);
|
);
|
||||||
const handleOpenFull = (id: number) =>
|
|
||||||
window.open(`${pageBase}/${id}`, "_blank");
|
|
||||||
|
|
||||||
// ── Expand/Collapse all ─────────────────────────────────────
|
// ── Expand/Collapse all ─────────────────────────────────────
|
||||||
const handleExpandAll = useCallback(() => {
|
const handleExpandAll = useCallback(() => {
|
||||||
@@ -813,10 +809,8 @@ export function SortableTree({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const activeNode = activeId ? flatItems.find((n) => n.id === activeId) : null;
|
const activeNode = activeId ? flatItems.find((n) => n.id === activeId) : null;
|
||||||
const noop = () => {};
|
const noop = () => {};
|
||||||
|
|
||||||
// Shared props builder for tree items
|
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
|
|
||||||
const getItemProps = useCallback(
|
const getItemProps = useCallback(
|
||||||
(node: FlatTreeNode): Omit<TreeItemProps, "sortMode" | "isOverlay"> => ({
|
(node: FlatTreeNode): Omit<TreeItemProps, "sortMode" | "isOverlay"> => ({
|
||||||
node,
|
node,
|
||||||
@@ -831,7 +825,6 @@ export function SortableTree({
|
|||||||
onToggleVisible: handleToggleVisible,
|
onToggleVisible: handleToggleVisible,
|
||||||
onDuplicate: handleDuplicate,
|
onDuplicate: handleDuplicate,
|
||||||
onDelete: handleDelete,
|
onDelete: handleDelete,
|
||||||
onOpenFull: handleOpenFull,
|
|
||||||
isLoading: loadingIds.has(node.id),
|
isLoading: loadingIds.has(node.id),
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
@@ -1129,7 +1122,6 @@ export function SortableTree({
|
|||||||
onToggleVisible={noop}
|
onToggleVisible={noop}
|
||||||
onDuplicate={noop}
|
onDuplicate={noop}
|
||||||
onDelete={noop}
|
onDelete={noop}
|
||||||
onOpenFull={noop}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</DragOverlay>
|
</DragOverlay>
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { CSS } from "@dnd-kit/utilities";
|
|||||||
import {
|
import {
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Copy as CopyIcon,
|
Copy as CopyIcon,
|
||||||
ExternalLink,
|
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
EyeOff as EyeOffIcon,
|
EyeOff as EyeOffIcon,
|
||||||
@@ -61,7 +60,6 @@ export interface TreeItemProps {
|
|||||||
onToggleVisible: (id: number, currentVisible: string) => void;
|
onToggleVisible: (id: number, currentVisible: string) => void;
|
||||||
onDuplicate: (id: number) => void;
|
onDuplicate: (id: number) => void;
|
||||||
onDelete: (id: number) => void;
|
onDelete: (id: number) => void;
|
||||||
onOpenFull: (id: number) => void;
|
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +112,6 @@ export const TreeItemContent = React.memo(
|
|||||||
onToggleVisible,
|
onToggleVisible,
|
||||||
onDuplicate,
|
onDuplicate,
|
||||||
onDelete,
|
onDelete,
|
||||||
onOpenFull,
|
|
||||||
isLoading,
|
isLoading,
|
||||||
}: TreeItemProps & {
|
}: TreeItemProps & {
|
||||||
isDragging?: boolean;
|
isDragging?: boolean;
|
||||||
@@ -262,10 +259,6 @@ export const TreeItemContent = React.memo(
|
|||||||
<Pencil className="mr-2 h-3.5 w-3.5" />
|
<Pencil className="mr-2 h-3.5 w-3.5" />
|
||||||
Edit
|
Edit
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
<ContextMenuItem onClick={() => onOpenFull(node.id)}>
|
|
||||||
<ExternalLink className="mr-2 h-3.5 w-3.5" />
|
|
||||||
Open full editor
|
|
||||||
</ContextMenuItem>
|
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
onClick={() => navigator.clipboard.writeText(String(node.id))}
|
onClick={() => navigator.clipboard.writeText(String(node.id))}
|
||||||
>
|
>
|
||||||
|
|||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Package } from "lucide-react";
|
import { Package } from "lucide-react";
|
||||||
import type { CatalogItemData } from "@/app/admin/catalog/[id]/catalog-items-table/types";
|
import type { CatalogItemData } from "@/components/admin/catalog/catalog-items-table/types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
getShopGridClass,
|
getShopGridClass,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Crown, Package, Sparkles } from "lucide-react";
|
import { Crown, Package, Sparkles } from "lucide-react";
|
||||||
import type { CatalogItemData } from "@/app/admin/catalog/[id]/catalog-items-table/types";
|
import type { CatalogItemData } from "@/components/admin/catalog/catalog-items-table/types";
|
||||||
import { getPointsLabel } from "@/app/admin/catalog/[id]/catalog-items-table/types";
|
import { getPointsLabel } from "@/components/admin/catalog/catalog-items-table/types";
|
||||||
import {
|
import {
|
||||||
CurrencyIcon,
|
CurrencyIcon,
|
||||||
currencyKindFromPointsType,
|
currencyKindFromPointsType,
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import {
|
|||||||
previewBulkOffers,
|
previewBulkOffers,
|
||||||
undoBulkOffers,
|
undoBulkOffers,
|
||||||
} from "@/actions/catalog-bulk";
|
} from "@/actions/catalog-bulk";
|
||||||
import { MovePageCombobox } from "@/app/admin/catalog/[id]/catalog-items-table/field-helpers";
|
import { MovePageCombobox } from "@/components/admin/catalog/catalog-items-table/field-helpers";
|
||||||
import { POINTS_TYPES } from "@/app/admin/catalog/[id]/catalog-items-table/types";
|
import { POINTS_TYPES } from "@/components/admin/catalog/catalog-items-table/types";
|
||||||
import {
|
import {
|
||||||
CurrencyIcon,
|
CurrencyIcon,
|
||||||
currencyKindFromPointsType,
|
currencyKindFromPointsType,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
catalogDuplicateDestinations,
|
catalogDuplicateDestinations,
|
||||||
previewCatalogDuplicate,
|
previewCatalogDuplicate,
|
||||||
} from "@/actions/catalog-duplicate";
|
} from "@/actions/catalog-duplicate";
|
||||||
import { MovePageCombobox } from "@/app/admin/catalog/[id]/catalog-items-table/field-helpers";
|
import { MovePageCombobox } from "@/components/admin/catalog/catalog-items-table/field-helpers";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { CatalogItemData } from "@/app/admin/catalog/[id]/catalog-items-table/types";
|
import type { CatalogItemData } from "@/components/admin/catalog/catalog-items-table/types";
|
||||||
|
|
||||||
export const OFFER_FILTERS = [
|
export const OFFER_FILTERS = [
|
||||||
"all",
|
"all",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { CatalogItemData } from "@/app/admin/catalog/[id]/catalog-items-table/types";
|
import type { CatalogItemData } from "@/components/admin/catalog/catalog-items-table/types";
|
||||||
import {
|
import {
|
||||||
buildSortOrders,
|
buildSortOrders,
|
||||||
groupSuggestedCategories,
|
groupSuggestedCategories,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { CatalogItemData } from "@/app/admin/catalog/[id]/catalog-items-table/types";
|
import type { CatalogItemData } from "@/components/admin/catalog/catalog-items-table/types";
|
||||||
import type { CatalogLayout } from "@/lib/catalog-layouts";
|
import type { CatalogLayout } from "@/lib/catalog-layouts";
|
||||||
|
|
||||||
export type SortStrategy =
|
export type SortStrategy =
|
||||||
|
|||||||
@@ -452,11 +452,11 @@ export const ADMIN_NAV_GROUPS: AdminNavGroup[] = [
|
|||||||
matchPrefixes: ["/admin/catalog"],
|
matchPrefixes: ["/admin/catalog"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: "/admin/catalog/maintenance",
|
href: "/admin/studio/maintenance",
|
||||||
labelKey: "catalogMaintenance",
|
labelKey: "catalogMaintenance",
|
||||||
icon: Wrench,
|
icon: Wrench,
|
||||||
permission: PERMS.CATALOG_EDIT,
|
permission: PERMS.CATALOG_EDIT,
|
||||||
matchPrefixes: ["/admin/catalog/maintenance"],
|
matchPrefixes: ["/admin/studio/maintenance"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: "/admin/items",
|
href: "/admin/items",
|
||||||
|
|||||||
Reference in new issue
Block a user