Replace standalone catalog editor with the unified Visual Catalog studio
CI / check (push) Failing after 26s
CI / preflight (push) Skipped
CI / deploy (push) Skipped
CI / publish-container (push) Skipped

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:
openhands committed 2026-09-14 17:20:52 +02:00
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>
);
}
-44
View File
@@ -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>
);
}
+5 -112
View File
@@ -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} &middot; {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>
);
}
-158
View File
@@ -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>
);
}
-211
View File
@@ -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 -26
View File
@@ -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
View File
@@ -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) &middot;{" "}
{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) &middot; {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>
); );
+21 -2
View File
@@ -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>
); );
} }
+11
View File
@@ -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))}
> >
@@ -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,
+2 -2
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 =
+2 -2
View File
@@ -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",