style: format code biome
Local Build and Deploy / deploy (push) Failing after 46s

This commit is contained in:
openhands committed 2026-07-13 21:57:41 +02:00
1 parent 8efd032cc6
commit df38dccbf1
735 files changed
+128321 -120870

No files matched your search

+102 -103
View File
@@ -8,120 +8,119 @@ 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;
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;
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[];
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[];
}
export function CatalogDetailTabs({
catalogPage,
items,
baseItems,
catalogNameMap,
childPages,
pageId,
canEdit,
furniDataIdList,
furniDescriptionMap,
furniRevisionMap,
allPages,
interactionTypes,
catalogPage,
items,
baseItems,
catalogNameMap,
childPages,
pageId,
canEdit,
furniDataIdList,
furniDescriptionMap,
furniRevisionMap,
allPages,
interactionTypes,
}: CatalogDetailTabsProps) {
return (
<Tabs defaultValue="settings">
<TabsList>
<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>
return (
<Tabs defaultValue="settings">
<TabsList>
<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="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="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}
/>
</TabsContent>
</Tabs>
);
<TabsContent value="translate" className="mt-4">
<CatalogTranslateTab
baseItems={baseItems}
catalogNameMap={catalogNameMap}
canEdit={canEdit}
furniDataIdList={furniDataIdList}
furniDescriptionMap={furniDescriptionMap}
furniRevisionMap={furniRevisionMap}
/>
</TabsContent>
</Tabs>
);
}
@@ -4,4 +4,7 @@
*/
export { CatalogItemsTable } from "./catalog-items-table/catalog-items-table";
export { FurniIcon } from "./catalog-items-table/furni-icon";
export type { BaseItemData, CatalogItemData } from "./catalog-items-table/types";
export type {
BaseItemData,
CatalogItemData,
} from "./catalog-items-table/types";
File diff suppressed because it is too large. Load diff
@@ -4,231 +4,278 @@ import { Check, ChevronsUpDown, Star } from "lucide-react";
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
// ── Toggle field ──────────────────────────────────────────────
export function ToggleField({
label,
checked,
value,
onChange,
disabled,
label,
checked,
value,
onChange,
disabled,
}: {
label: string;
/** Boolean checked state */
checked?: boolean;
/** Number or string (0/1 or '0'/'1') — converted to boolean automatically */
value?: number | string;
onChange: (v: boolean) => void;
disabled?: boolean;
label: string;
/** Boolean checked state */
checked?: boolean;
/** Number or string (0/1 or '0'/'1') — converted to boolean automatically */
value?: number | string;
onChange: (v: boolean) => void;
disabled?: boolean;
}) {
const isChecked = checked ?? (value === 1 || value === "1");
return (
<div className="flex items-center justify-between gap-2">
<Label className="text-xs">{label}</Label>
<Switch checked={isChecked} onCheckedChange={onChange} disabled={disabled} size="sm" />
</div>
);
const isChecked = checked ?? (value === 1 || value === "1");
return (
<div className="flex items-center justify-between gap-2">
<Label className="text-xs">{label}</Label>
<Switch
checked={isChecked}
onCheckedChange={onChange}
disabled={disabled}
size="sm"
/>
</div>
);
}
// ── Interaction Type Combobox ──────────────────────────────────
export function InteractionTypeCombobox({
value,
onChange,
types,
disabled,
value,
onChange,
types,
disabled,
}: {
value: string;
onChange: (v: string) => void;
types: string[];
disabled?: boolean;
value: string;
onChange: (v: string) => void;
types: string[];
disabled?: boolean;
}) {
const [open, setOpen] = useState(false);
const [inputValue, setInputValue] = useState("");
const [open, setOpen] = useState(false);
const [inputValue, setInputValue] = useState("");
const filtered = useMemo(() => {
if (!inputValue.trim()) return types.slice(0, 50);
const q = inputValue.toLowerCase();
return types.filter((t) => t.toLowerCase().includes(q)).slice(0, 50);
}, [types, inputValue]);
const filtered = useMemo(() => {
if (!inputValue.trim()) return types.slice(0, 50);
const q = inputValue.toLowerCase();
return types.filter((t) => t.toLowerCase().includes(q)).slice(0, 50);
}, [types, inputValue]);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className="w-full justify-between font-normal h-8 text-sm"
>
<span className="truncate">{value || "Select type..."}</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 shouldFilter={false}>
<CommandInput
placeholder="Search or type custom..."
value={inputValue}
onValueChange={setInputValue}
/>
<CommandList>
<CommandEmpty>
{inputValue.trim() ? (
<button
type="button"
className="w-full px-2 py-1.5 text-left text-sm hover:bg-accent rounded"
onClick={() => {
onChange(inputValue.trim());
setOpen(false);
setInputValue("");
}}
>
Use &quot;{inputValue.trim()}&quot;
</button>
) : (
"No types found."
)}
</CommandEmpty>
<CommandGroup>
{filtered.map((t) => (
<CommandItem
key={t}
value={t}
onSelect={() => {
onChange(t);
setOpen(false);
setInputValue("");
}}
>
<Check className={cn("mr-2 h-4 w-4", value === t ? "opacity-100" : "opacity-0")} />
<span className="truncate">{t}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className="w-full justify-between font-normal h-8 text-sm"
>
<span className="truncate">{value || "Select type..."}</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 shouldFilter={false}>
<CommandInput
placeholder="Search or type custom..."
value={inputValue}
onValueChange={setInputValue}
/>
<CommandList>
<CommandEmpty>
{inputValue.trim() ? (
<button
type="button"
className="w-full px-2 py-1.5 text-left text-sm hover:bg-accent rounded"
onClick={() => {
onChange(inputValue.trim());
setOpen(false);
setInputValue("");
}}
>
Use &quot;{inputValue.trim()}&quot;
</button>
) : (
"No types found."
)}
</CommandEmpty>
<CommandGroup>
{filtered.map((t) => (
<CommandItem
key={t}
value={t}
onSelect={() => {
onChange(t);
setOpen(false);
setInputValue("");
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
value === t ? "opacity-100" : "opacity-0",
)}
/>
<span className="truncate">{t}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
// ── Move Page Combobox with smart suggestions ─────────────────
export function MovePageCombobox({
value,
onChange,
pages,
currentPageId,
suggestedPageIds,
value,
onChange,
pages,
currentPageId,
suggestedPageIds,
}: {
value: number | null;
onChange: (id: number | null) => void;
pages: { id: number; caption: string }[];
currentPageId: number;
/** Page IDs to show first as suggestions (from move-suggestions.ts) */
suggestedPageIds?: number[];
value: number | null;
onChange: (id: number | null) => void;
pages: { id: number; caption: string }[];
currentPageId: number;
/** Page IDs to show first as suggestions (from move-suggestions.ts) */
suggestedPageIds?: number[];
}) {
const [open, setOpen] = useState(false);
const [open, setOpen] = useState(false);
const options = useMemo(() => pages.filter((p) => p.id !== currentPageId), [pages, currentPageId]);
const options = useMemo(
() => pages.filter((p) => p.id !== currentPageId),
[pages, currentPageId],
);
const selectedPage = value ? pages.find((p) => p.id === value) : null;
const selectedPage = value ? pages.find((p) => p.id === value) : null;
// Build suggested pages list
const suggested = useMemo(() => {
if (!suggestedPageIds?.length) return [];
const pageMap = new Map(options.map((p) => [p.id, p]));
return suggestedPageIds.map((id) => pageMap.get(id)).filter(Boolean) as {
id: number;
caption: string;
}[];
}, [suggestedPageIds, options]);
// Build suggested pages list
const suggested = useMemo(() => {
if (!suggestedPageIds?.length) return [];
const pageMap = new Map(options.map((p) => [p.id, p]));
return suggestedPageIds.map((id) => pageMap.get(id)).filter(Boolean) as {
id: number;
caption: string;
}[];
}, [suggestedPageIds, options]);
// Non-suggested pages (exclude those already in suggestions)
const suggestedSet = useMemo(() => new Set(suggested.map((s) => s.id)), [suggested]);
const remaining = useMemo(() => options.filter((p) => !suggestedSet.has(p.id)), [options, suggestedSet]);
// Non-suggested pages (exclude those already in suggestions)
const suggestedSet = useMemo(
() => new Set(suggested.map((s) => s.id)),
[suggested],
);
const remaining = useMemo(
() => options.filter((p) => !suggestedSet.has(p.id)),
[options, suggestedSet],
);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between font-normal"
>
{selectedPage ? (
<span className="truncate">
<span className="text-muted-foreground font-mono text-xs mr-1.5">#{selectedPage.id}</span>
{selectedPage.caption}
</span>
) : (
<span className="text-muted-foreground">Select target page...</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>
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between font-normal"
>
{selectedPage ? (
<span className="truncate">
<span className="text-muted-foreground font-mono text-xs mr-1.5">
#{selectedPage.id}
</span>
{selectedPage.caption}
</span>
) : (
<span className="text-muted-foreground">Select target page...</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>
{/* Suggested pages */}
{suggested.length > 0 && (
<CommandGroup heading="Suggested">
{suggested.map((page) => (
<CommandItem
key={`s-${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")} />
<Star className="mr-1.5 h-3 w-3 text-[var(--admin-warning)]" />
<span className="text-muted-foreground font-mono text-xs mr-1.5">#{page.id}</span>
<span className="truncate">{page.caption}</span>
</CommandItem>
))}
</CommandGroup>
)}
{/* Suggested pages */}
{suggested.length > 0 && (
<CommandGroup heading="Suggested">
{suggested.map((page) => (
<CommandItem
key={`s-${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",
)}
/>
<Star className="mr-1.5 h-3 w-3 text-[var(--admin-warning)]" />
<span className="text-muted-foreground font-mono text-xs mr-1.5">
#{page.id}
</span>
<span className="truncate">{page.caption}</span>
</CommandItem>
))}
</CommandGroup>
)}
{/* All pages */}
<CommandGroup heading={suggested.length > 0 ? "All pages" : undefined}>
{remaining.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>
);
{/* All pages */}
<CommandGroup
heading={suggested.length > 0 ? "All pages" : undefined}
>
{remaining.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>
);
}
@@ -3,40 +3,46 @@
import { Image as ImageIcon } from "lucide-react";
import { getHabboCdnIconUrl, getLocalIconUrl } from "@/lib/furni/classname";
export function FurniIcon({ classname, revision }: { classname: string; revision: number }) {
if (!classname) {
return (
<div className="w-10 h-10 rounded bg-muted flex items-center justify-center">
<ImageIcon className="h-4 w-4 text-muted-foreground" />
</div>
);
}
export function FurniIcon({
classname,
revision,
}: {
classname: string;
revision: number;
}) {
if (!classname) {
return (
<div className="w-10 h-10 rounded bg-muted flex items-center justify-center">
<ImageIcon className="h-4 w-4 text-muted-foreground" />
</div>
);
}
const localUrl = getLocalIconUrl(classname);
const localUrl = getLocalIconUrl(classname);
function handleError(e: React.SyntheticEvent<HTMLImageElement>) {
const img = e.target as HTMLImageElement;
const stage = img.dataset.fallback;
if (!stage && revision > 0) {
img.dataset.fallback = "cdn";
img.src = getHabboCdnIconUrl(classname, revision);
} else {
img.dataset.fallback = "none";
img.style.opacity = "0";
}
}
function handleError(e: React.SyntheticEvent<HTMLImageElement>) {
const img = e.target as HTMLImageElement;
const stage = img.dataset.fallback;
if (!stage && revision > 0) {
img.dataset.fallback = "cdn";
img.src = getHabboCdnIconUrl(classname, revision);
} else {
img.dataset.fallback = "none";
img.style.opacity = "0";
}
}
return (
<div className="w-10 h-10 rounded bg-muted/50 flex items-center justify-center overflow-hidden">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={localUrl}
alt={classname}
className="max-w-full max-h-full object-contain"
style={{ imageRendering: "pixelated" }}
onError={handleError}
loading="lazy"
/>
</div>
);
return (
<div className="w-10 h-10 rounded bg-muted/50 flex items-center justify-center overflow-hidden">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={localUrl}
alt={classname}
className="max-w-full max-h-full object-contain"
style={{ imageRendering: "pixelated" }}
onError={handleError}
loading="lazy"
/>
</div>
);
}
@@ -2,11 +2,17 @@
import { Loader2, Music } from "lucide-react";
import { useEffect, useState } from "react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
getCachedSongPickerOptions,
loadSongPickerOptions,
type SoundtrackOption,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
getCachedSongPickerOptions,
loadSongPickerOptions,
type SoundtrackOption,
} from "@/lib/client-cache/song-picker-cache";
// Re-export so existing `import { invalidateSongPickerCache } from '.../song-picker'`
@@ -14,73 +20,79 @@ import {
export { invalidateSongPickerCache } from "@/lib/client-cache/song-picker-cache";
interface Props {
value: number;
onChange: (id: number) => void;
disabled?: boolean;
value: number;
onChange: (id: number) => void;
disabled?: boolean;
}
function formatLength(seconds: number): string {
if (!seconds || seconds <= 0) return "";
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${String(s).padStart(2, "0")}`;
if (!seconds || seconds <= 0) return "";
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${String(s).padStart(2, "0")}`;
}
export function SongPicker({ value, onChange, disabled }: Props) {
const initial = getCachedSongPickerOptions();
const [options, setOptions] = useState<SoundtrackOption[]>(initial ?? []);
const [loading, setLoading] = useState(!initial);
const initial = getCachedSongPickerOptions();
const [options, setOptions] = useState<SoundtrackOption[]>(initial ?? []);
const [loading, setLoading] = useState(!initial);
useEffect(() => {
if (getCachedSongPickerOptions()) return;
let cancelled = false;
loadSongPickerOptions()
.then((items) => {
if (!cancelled) setOptions(items);
})
.catch(() => {
if (!cancelled) setOptions([]);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (getCachedSongPickerOptions()) return;
let cancelled = false;
loadSongPickerOptions()
.then((items) => {
if (!cancelled) setOptions(items);
})
.catch(() => {
if (!cancelled) setOptions([]);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
return (
<Select
value={String(value)}
onValueChange={(v) => onChange(parseInt(v, 10) || 0)}
disabled={disabled || loading}
>
<SelectTrigger>
{loading ? (
<span className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" /> Loading…
</span>
) : (
<SelectValue placeholder="No song" />
)}
</SelectTrigger>
<SelectContent>
<SelectItem value="0">
<span className="text-muted-foreground">— No song —</span>
</SelectItem>
{options.map((opt) => (
<SelectItem key={opt.id} value={String(opt.id)}>
<span className="inline-flex items-center gap-2">
<Music className="h-3 w-3 text-muted-foreground" />
<span className="font-medium">{opt.name}</span>
{opt.author && <span className="text-xs text-muted-foreground">— {opt.author}</span>}
{opt.length > 0 && (
<span className="text-xs text-muted-foreground font-mono">{formatLength(opt.length)}</span>
)}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
);
return (
<Select
value={String(value)}
onValueChange={(v) => onChange(parseInt(v, 10) || 0)}
disabled={disabled || loading}
>
<SelectTrigger>
{loading ? (
<span className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" /> Loading…
</span>
) : (
<SelectValue placeholder="No song" />
)}
</SelectTrigger>
<SelectContent>
<SelectItem value="0">
<span className="text-muted-foreground">— No song —</span>
</SelectItem>
{options.map((opt) => (
<SelectItem key={opt.id} value={String(opt.id)}>
<span className="inline-flex items-center gap-2">
<Music className="h-3 w-3 text-muted-foreground" />
<span className="font-medium">{opt.name}</span>
{opt.author && (
<span className="text-xs text-muted-foreground">
— {opt.author}
</span>
)}
{opt.length > 0 && (
<span className="text-xs text-muted-foreground font-mono">
{formatLength(opt.length)}
</span>
)}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
);
}
@@ -1,85 +1,87 @@
export interface BaseItemData {
id: number;
spriteId: number;
publicName: string;
itemName: string;
type: string;
width: number;
length: number;
stackHeight: number;
allowStack: number;
allowSit: number;
allowLay: number;
allowWalk: number;
allowGift: number;
allowTrade: number;
allowRecycle: number;
allowMarketplaceSell: number;
allowInventoryStack: number;
interactionType: string;
interactionModesCount: number;
vendingIds: string;
customparams: string;
effectIdMale: number;
effectIdFemale: number;
clothingOnWalk: string;
id: number;
spriteId: number;
publicName: string;
itemName: string;
type: string;
width: number;
length: number;
stackHeight: number;
allowStack: number;
allowSit: number;
allowLay: number;
allowWalk: number;
allowGift: number;
allowTrade: number;
allowRecycle: number;
allowMarketplaceSell: number;
allowInventoryStack: number;
interactionType: string;
interactionModesCount: number;
vendingIds: string;
customparams: string;
effectIdMale: number;
effectIdFemale: number;
clothingOnWalk: string;
}
export interface CatalogItemData {
id: number;
catalogName: string;
itemIds: string;
costCredits: number;
costPoints: number;
pointsType: number;
amount: number;
limitedSells: number;
limitedStack: number;
orderNumber: number;
offerId: number;
songId: number;
haveOffer: string;
clubOnly: string;
extradata: string;
baseName: string;
baseItemName: string;
spriteId: number;
baseItem: BaseItemData | null;
id: number;
catalogName: string;
itemIds: string;
costCredits: number;
costPoints: number;
pointsType: number;
amount: number;
limitedSells: number;
limitedStack: number;
orderNumber: number;
offerId: number;
songId: number;
haveOffer: string;
clubOnly: string;
extradata: string;
baseName: string;
baseItemName: string;
spriteId: number;
baseItem: BaseItemData | null;
}
export interface InlineEdit {
costCredits: number;
costPoints: number;
pointsType: number;
amount: number;
costCredits: number;
costPoints: number;
pointsType: number;
amount: number;
}
export const POINTS_TYPES: Record<number, { label: string; color: string }> = {
0: { label: "Duckets", color: "text-[var(--admin-info)]" },
5: { label: "Diamonds", color: "text-[var(--admin-accent-text)]" },
101: { label: "Snowflakes", color: "text-[var(--admin-info)]" },
103: { label: "Hearts", color: "text-[var(--admin-error)]" },
104: { label: "Stars", color: "text-[var(--admin-warning)]" },
105: { label: "Shells", color: "text-[var(--admin-warning)]" },
0: { label: "Duckets", color: "text-[var(--admin-info)]" },
5: { label: "Diamonds", color: "text-[var(--admin-accent-text)]" },
101: { label: "Snowflakes", color: "text-[var(--admin-info)]" },
103: { label: "Hearts", color: "text-[var(--admin-error)]" },
104: { label: "Stars", color: "text-[var(--admin-warning)]" },
105: { label: "Shells", color: "text-[var(--admin-warning)]" },
};
export const DEFAULT_NEW_ITEM = {
catalogName: "",
itemIds: "0",
costCredits: 0,
costPoints: 0,
pointsType: 0,
amount: 1,
limitedSells: 0,
limitedStack: 0,
orderNumber: 1,
offerId: -1,
songId: 0,
haveOffer: "1",
clubOnly: "0",
extradata: "",
catalogName: "",
itemIds: "0",
costCredits: 0,
costPoints: 0,
pointsType: 0,
amount: 1,
limitedSells: 0,
limitedStack: 0,
orderNumber: 1,
offerId: -1,
songId: 0,
haveOffer: "1",
clubOnly: "0",
extradata: "",
};
export function getPointsLabel(type: number) {
return POINTS_TYPES[type] || { label: `Type ${type}`, color: "theme-text-muted" };
return (
POINTS_TYPES[type] || { label: `Type ${type}`, color: "theme-text-muted" }
);
}
File diff suppressed because it is too large. Load diff
@@ -1,16 +1,16 @@
"use client";
import {
AlertTriangle,
CheckCircle2,
Database,
FileJson,
Filter,
Loader2,
RotateCcw,
Save,
ShoppingCart,
Sparkles,
AlertTriangle,
CheckCircle2,
Database,
FileJson,
Filter,
Loader2,
RotateCcw,
Save,
ShoppingCart,
Sparkles,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useMemo, useState } from "react";
@@ -21,467 +21,541 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useServerAction } from "@/hooks/use-server-action";
import { FurniIcon } from "./catalog-items-table";
interface BaseItemForTranslation {
id: number;
publicName: string;
itemName: string;
spriteId: number;
type: string;
id: number;
publicName: string;
itemName: string;
spriteId: number;
type: string;
}
interface CatalogTranslateTabProps {
baseItems: BaseItemForTranslation[];
catalogNameMap: Record<number, string>;
canEdit: boolean;
furniDataIdList: number[];
furniDescriptionMap: Record<number, string>;
furniRevisionMap?: Record<number, { classname: string; revision: number }>;
baseItems: BaseItemForTranslation[];
catalogNameMap: Record<number, string>;
canEdit: boolean;
furniDataIdList: number[];
furniDescriptionMap: Record<number, string>;
furniRevisionMap?: Record<number, { classname: string; revision: number }>;
}
type FilterMode = "all" | "missing" | "modified";
export function CatalogTranslateTab({
baseItems,
catalogNameMap,
canEdit,
furniDataIdList,
furniDescriptionMap,
furniRevisionMap = {},
baseItems,
catalogNameMap,
canEdit,
furniDataIdList,
furniDescriptionMap,
furniRevisionMap = {},
}: CatalogTranslateTabProps) {
const router = useRouter();
const { run, isPending } = useServerAction();
const [isSuggesting, setIsSuggesting] = useState(false);
const [filterMode, setFilterMode] = useState<FilterMode>("all");
const router = useRouter();
const { run, isPending } = useServerAction();
const [isSuggesting, setIsSuggesting] = useState(false);
const [filterMode, setFilterMode] = useState<FilterMode>("all");
const furniDataIdSet = useMemo(() => new Set(furniDataIdList), [furniDataIdList]);
const [selected, setSelected] = useState<Set<number>>(new Set());
const furniDataIdSet = useMemo(
() => new Set(furniDataIdList),
[furniDataIdList],
);
const [selected, setSelected] = useState<Set<number>>(new Set());
// ── Editable state: names + descriptions ─────────────────────────
const [editedNames, setEditedNames] = useState<Record<number, string>>(
Object.fromEntries(baseItems.map((item) => [item.id, item.publicName])),
);
// ── Editable state: names + descriptions ─────────────────────────
const [editedNames, setEditedNames] = useState<Record<number, string>>(
Object.fromEntries(baseItems.map((item) => [item.id, item.publicName])),
);
const [editedDescs, setEditedDescs] = useState<Record<number, string>>(
Object.fromEntries(baseItems.map((item) => [item.id, furniDescriptionMap[item.id] ?? ""])),
);
const [editedDescs, setEditedDescs] = useState<Record<number, string>>(
Object.fromEntries(
baseItems.map((item) => [item.id, furniDescriptionMap[item.id] ?? ""]),
),
);
// Track which items have name or description changes
const isNameChanged = useCallback(
(item: BaseItemForTranslation) => editedNames[item.id] !== item.publicName,
[editedNames],
);
// Track which items have name or description changes
const isNameChanged = useCallback(
(item: BaseItemForTranslation) => editedNames[item.id] !== item.publicName,
[editedNames],
);
const isDescChanged = useCallback(
(item: BaseItemForTranslation) => editedDescs[item.id] !== (furniDescriptionMap[item.id] ?? ""),
[editedDescs, furniDescriptionMap],
);
const isDescChanged = useCallback(
(item: BaseItemForTranslation) =>
editedDescs[item.id] !== (furniDescriptionMap[item.id] ?? ""),
[editedDescs, furniDescriptionMap],
);
const changedItems = useMemo(
() => baseItems.filter((item) => isNameChanged(item) || isDescChanged(item)),
[baseItems, isNameChanged, isDescChanged],
);
const changedItems = useMemo(
() =>
baseItems.filter((item) => isNameChanged(item) || isDescChanged(item)),
[baseItems, isNameChanged, isDescChanged],
);
const missingItems = useMemo(
() => baseItems.filter((item) => !furniDataIdSet.has(item.id)),
[baseItems, furniDataIdSet],
);
const missingItems = useMemo(
() => baseItems.filter((item) => !furniDataIdSet.has(item.id)),
[baseItems, furniDataIdSet],
);
const filteredItems = useMemo(() => {
switch (filterMode) {
case "missing":
return baseItems.filter((item) => !furniDataIdSet.has(item.id));
case "modified":
return baseItems.filter((item) => isNameChanged(item) || isDescChanged(item));
default:
return baseItems;
}
}, [baseItems, filterMode, furniDataIdSet, isNameChanged, isDescChanged]);
const filteredItems = useMemo(() => {
switch (filterMode) {
case "missing":
return baseItems.filter((item) => !furniDataIdSet.has(item.id));
case "modified":
return baseItems.filter(
(item) => isNameChanged(item) || isDescChanged(item),
);
default:
return baseItems;
}
}, [baseItems, filterMode, furniDataIdSet, isNameChanged, isDescChanged]);
const toggleOne = useCallback((id: number) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const toggleOne = useCallback((id: number) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const toggleAll = useCallback(() => {
setSelected((prev) =>
prev.size === filteredItems.length ? new Set() : new Set(filteredItems.map((i) => i.id)),
);
}, [filteredItems]);
const toggleAll = useCallback(() => {
setSelected((prev) =>
prev.size === filteredItems.length
? new Set()
: new Set(filteredItems.map((i) => i.id)),
);
}, [filteredItems]);
// Items to act on: selected ones if any, otherwise all
const targetItems = useMemo(() => {
if (selected.size === 0) return baseItems;
return baseItems.filter((i) => selected.has(i.id));
}, [baseItems, selected]);
// Items to act on: selected ones if any, otherwise all
const targetItems = useMemo(() => {
if (selected.size === 0) return baseItems;
return baseItems.filter((i) => selected.has(i.id));
}, [baseItems, selected]);
function handleNameChange(id: number, value: string) {
setEditedNames((prev) => ({ ...prev, [id]: value }));
}
function handleNameChange(id: number, value: string) {
setEditedNames((prev) => ({ ...prev, [id]: value }));
}
function handleDescChange(id: number, value: string) {
setEditedDescs((prev) => ({ ...prev, [id]: value }));
}
function handleDescChange(id: number, value: string) {
setEditedDescs((prev) => ({ ...prev, [id]: value }));
}
function handleReset() {
setEditedNames(Object.fromEntries(baseItems.map((item) => [item.id, item.publicName])));
setEditedDescs(
Object.fromEntries(baseItems.map((item) => [item.id, furniDescriptionMap[item.id] ?? ""])),
);
}
function handleReset() {
setEditedNames(
Object.fromEntries(baseItems.map((item) => [item.id, item.publicName])),
);
setEditedDescs(
Object.fromEntries(
baseItems.map((item) => [item.id, furniDescriptionMap[item.id] ?? ""]),
),
);
}
async function handleSuggestFromHabboFurni() {
if (targetItems.length === 0) return;
setIsSuggesting(true);
async function handleSuggestFromHabboFurni() {
if (targetItems.length === 0) return;
setIsSuggesting(true);
try {
// Collect classnames from selected items (or all if none selected)
const classnames = targetItems.map((item) => item.itemName).filter(Boolean);
if (classnames.length === 0) {
toast.info("No classnames found to look up.");
setIsSuggesting(false);
return;
}
try {
// Collect classnames from selected items (or all if none selected)
const classnames = targetItems
.map((item) => item.itemName)
.filter(Boolean);
if (classnames.length === 0) {
toast.info("No classnames found to look up.");
setIsSuggesting(false);
return;
}
// Batch in chunks of 50 to avoid too-long URLs
const CHUNK_SIZE = 50;
const allSuggestions: Record<string, { name: string; description: string }> = {};
// Batch in chunks of 50 to avoid too-long URLs
const CHUNK_SIZE = 50;
const allSuggestions: Record<
string,
{ name: string; description: string }
> = {};
for (let i = 0; i < classnames.length; i += CHUNK_SIZE) {
const chunk = classnames.slice(i, i + CHUNK_SIZE);
const res = await fetch(
`/api/admin/catalog/suggest?classnames=${encodeURIComponent(chunk.join(","))}`,
);
if (res.ok) {
const data = await res.json();
Object.assign(allSuggestions, data.suggestions || {});
}
}
for (let i = 0; i < classnames.length; i += CHUNK_SIZE) {
const chunk = classnames.slice(i, i + CHUNK_SIZE);
const res = await fetch(
`/api/admin/catalog/suggest?classnames=${encodeURIComponent(chunk.join(","))}`,
);
if (res.ok) {
const data = await res.json();
Object.assign(allSuggestions, data.suggestions || {});
}
}
// Apply suggestions to items that have a match (names + descriptions)
let nameCount = 0;
let descCount = 0;
const newNames = { ...editedNames };
const newDescs = { ...editedDescs };
// Apply suggestions to items that have a match (names + descriptions)
let nameCount = 0;
let descCount = 0;
const newNames = { ...editedNames };
const newDescs = { ...editedDescs };
for (const item of targetItems) {
const suggestion = allSuggestions[item.itemName];
if (!suggestion) continue;
for (const item of targetItems) {
const suggestion = allSuggestions[item.itemName];
if (!suggestion) continue;
if (suggestion.name && suggestion.name !== item.publicName) {
newNames[item.id] = suggestion.name;
nameCount++;
}
if (suggestion.name && suggestion.name !== item.publicName) {
newNames[item.id] = suggestion.name;
nameCount++;
}
const currentDesc = furniDescriptionMap[item.id] ?? "";
if (suggestion.description && suggestion.description !== currentDesc) {
newDescs[item.id] = suggestion.description;
descCount++;
}
}
const currentDesc = furniDescriptionMap[item.id] ?? "";
if (suggestion.description && suggestion.description !== currentDesc) {
newDescs[item.id] = suggestion.description;
descCount++;
}
}
setEditedNames(newNames);
setEditedDescs(newDescs);
setEditedNames(newNames);
setEditedDescs(newDescs);
const total = nameCount + descCount;
if (total > 0) {
const parts = [];
if (nameCount > 0) parts.push(`${nameCount} name(s)`);
if (descCount > 0) parts.push(`${descCount} description(s)`);
toast.success(`HabboFurni: ${parts.join(" + ")} suggested from habbo.it. Review and save.`);
} else {
toast.info(
`HabboFurni: No new suggestions found (${Object.keys(allSuggestions).length} looked up, all already match).`,
);
}
} catch (err) {
toast.error(`HabboFurni error: ${(err as Error).message}`);
} finally {
setIsSuggesting(false);
}
}
const total = nameCount + descCount;
if (total > 0) {
const parts = [];
if (nameCount > 0) parts.push(`${nameCount} name(s)`);
if (descCount > 0) parts.push(`${descCount} description(s)`);
toast.success(
`HabboFurni: ${parts.join(" + ")} suggested from habbo.it. Review and save.`,
);
} else {
toast.info(
`HabboFurni: No new suggestions found (${Object.keys(allSuggestions).length} looked up, all already match).`,
);
}
} catch (err) {
toast.error(`HabboFurni error: ${(err as Error).message}`);
} finally {
setIsSuggesting(false);
}
}
async function handleSaveAll() {
if (changedItems.length === 0) return;
async function handleSaveAll() {
if (changedItems.length === 0) return;
run(
() =>
translateCatalogItems({
items: changedItems.map((item) => ({
id: item.id,
publicName: editedNames[item.id],
description: editedDescs[item.id],
})),
}),
{
successMessage: "Translations saved.",
errorMessage: "Failed to save translations.",
onSuccess: (data) => {
const d = data as Record<string, number | boolean>;
const parts = [];
if (d.namesUpdated && (d.namesUpdated as number) > 0)
parts.push(`${d.namesUpdated} name(s) updated`);
if (d.descriptionsUpdated && (d.descriptionsUpdated as number) > 0)
parts.push(`${d.descriptionsUpdated} description(s) updated`);
if (parts.length === 0) parts.push(`${d.updated ?? 0} item(s) processed`);
if (d.furniDataUpdated) parts.push("FurnitureData.json synced");
if (d.furniDataInserted && (d.furniDataInserted as number) > 0) {
parts.push(`${d.furniDataInserted} new item(s) added to FurnitureData.json`);
}
parts.push("emulator cache refreshed");
toast.success(`${parts.join(" · ")}.`);
router.refresh();
},
},
);
}
run(
() =>
translateCatalogItems({
items: changedItems.map((item) => ({
id: item.id,
publicName: editedNames[item.id],
description: editedDescs[item.id],
})),
}),
{
successMessage: "Translations saved.",
errorMessage: "Failed to save translations.",
onSuccess: (data) => {
const d = data as Record<string, number | boolean>;
const parts = [];
if (d.namesUpdated && (d.namesUpdated as number) > 0)
parts.push(`${d.namesUpdated} name(s) updated`);
if (d.descriptionsUpdated && (d.descriptionsUpdated as number) > 0)
parts.push(`${d.descriptionsUpdated} description(s) updated`);
if (parts.length === 0)
parts.push(`${d.updated ?? 0} item(s) processed`);
if (d.furniDataUpdated) parts.push("FurnitureData.json synced");
if (d.furniDataInserted && (d.furniDataInserted as number) > 0) {
parts.push(
`${d.furniDataInserted} new item(s) added to FurnitureData.json`,
);
}
parts.push("emulator cache refreshed");
toast.success(`${parts.join(" · ")}.`);
router.refresh();
},
},
);
}
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-4">
<div className="min-w-0">
<CardTitle>Translate Item Names</CardTitle>
<p className="text-sm text-muted-foreground mt-1">
Edit the public name and description of furniture items. Saving will update:
</p>
<div className="flex gap-3 mt-2 flex-wrap">
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<Database className="h-3 w-3" /> items_base (name)
</span>
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<ShoppingCart className="h-3 w-3" /> catalogName (auto)
</span>
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<FileJson className="h-3 w-3" /> FurnitureData.json (name + desc)
</span>
</div>
{missingItems.length > 0 && (
<div className="flex items-center gap-1.5 mt-2 text-xs text-[var(--admin-warning)] dark:text-[var(--admin-warning)]">
<AlertTriangle className="h-3.5 w-3.5" />
<span>
{missingItems.length} item(s) missing from FurnitureData.json — will be auto-inserted on save
</span>
</div>
)}
</div>
{canEdit && (
<div className="flex gap-2 flex-shrink-0 flex-wrap justify-end">
<Button
variant="outline"
size="sm"
onClick={handleSuggestFromHabboFurni}
disabled={isSuggesting || isPending || targetItems.length === 0}
title="Suggest Italian names + descriptions from HabboFurni API (habbo.it)"
>
{isSuggesting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Sparkles className="mr-2 h-4 w-4" />
)}
Suggest IT{selected.size > 0 ? ` (${selected.size})` : ""}
</Button>
<Button
variant="outline"
size="sm"
onClick={handleReset}
disabled={changedItems.length === 0 || isPending}
>
<RotateCcw className="mr-2 h-4 w-4" />
Reset
</Button>
<Button size="sm" onClick={handleSaveAll} disabled={isPending || changedItems.length === 0}>
{isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save All ({changedItems.length})
</Button>
</div>
)}
</CardHeader>
<CardContent>
{/* Filter bar */}
{baseItems.length > 0 && (
<div className="flex items-center gap-2 mb-4">
<Filter className="h-4 w-4 text-muted-foreground" />
<div className="flex gap-1">
<Button
variant={filterMode === "all" ? "default" : "outline"}
size="sm"
className="h-7 text-xs"
onClick={() => setFilterMode("all")}
>
All ({baseItems.length})
</Button>
<Button
variant={filterMode === "missing" ? "default" : "outline"}
size="sm"
className="h-7 text-xs"
onClick={() => setFilterMode("missing")}
disabled={missingItems.length === 0}
>
<AlertTriangle className="mr-1 h-3 w-3" />
Missing ({missingItems.length})
</Button>
<Button
variant={filterMode === "modified" ? "default" : "outline"}
size="sm"
className="h-7 text-xs"
onClick={() => setFilterMode("modified")}
disabled={changedItems.length === 0}
>
Modified ({changedItems.length})
</Button>
</div>
</div>
)}
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-4">
<div className="min-w-0">
<CardTitle>Translate Item Names</CardTitle>
<p className="text-sm text-muted-foreground mt-1">
Edit the public name and description of furniture items. Saving will
update:
</p>
<div className="flex gap-3 mt-2 flex-wrap">
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<Database className="h-3 w-3" /> items_base (name)
</span>
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<ShoppingCart className="h-3 w-3" /> catalogName (auto)
</span>
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<FileJson className="h-3 w-3" /> FurnitureData.json (name + desc)
</span>
</div>
{missingItems.length > 0 && (
<div className="flex items-center gap-1.5 mt-2 text-xs text-[var(--admin-warning)] dark:text-[var(--admin-warning)]">
<AlertTriangle className="h-3.5 w-3.5" />
<span>
{missingItems.length} item(s) missing from FurnitureData.json —
will be auto-inserted on save
</span>
</div>
)}
</div>
{canEdit && (
<div className="flex gap-2 flex-shrink-0 flex-wrap justify-end">
<Button
variant="outline"
size="sm"
onClick={handleSuggestFromHabboFurni}
disabled={isSuggesting || isPending || targetItems.length === 0}
title="Suggest Italian names + descriptions from HabboFurni API (habbo.it)"
>
{isSuggesting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Sparkles className="mr-2 h-4 w-4" />
)}
Suggest IT{selected.size > 0 ? ` (${selected.size})` : ""}
</Button>
<Button
variant="outline"
size="sm"
onClick={handleReset}
disabled={changedItems.length === 0 || isPending}
>
<RotateCcw className="mr-2 h-4 w-4" />
Reset
</Button>
<Button
size="sm"
onClick={handleSaveAll}
disabled={isPending || changedItems.length === 0}
>
{isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save All ({changedItems.length})
</Button>
</div>
)}
</CardHeader>
<CardContent>
{/* Filter bar */}
{baseItems.length > 0 && (
<div className="flex items-center gap-2 mb-4">
<Filter className="h-4 w-4 text-muted-foreground" />
<div className="flex gap-1">
<Button
variant={filterMode === "all" ? "default" : "outline"}
size="sm"
className="h-7 text-xs"
onClick={() => setFilterMode("all")}
>
All ({baseItems.length})
</Button>
<Button
variant={filterMode === "missing" ? "default" : "outline"}
size="sm"
className="h-7 text-xs"
onClick={() => setFilterMode("missing")}
disabled={missingItems.length === 0}
>
<AlertTriangle className="mr-1 h-3 w-3" />
Missing ({missingItems.length})
</Button>
<Button
variant={filterMode === "modified" ? "default" : "outline"}
size="sm"
className="h-7 text-xs"
onClick={() => setFilterMode("modified")}
disabled={changedItems.length === 0}
>
Modified ({changedItems.length})
</Button>
</div>
</div>
)}
{baseItems.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
No items to translate in this page.
</p>
) : filteredItems.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">No items match the current filter.</p>
) : (
<TooltipProvider>
<div className="rounded-md border overflow-auto max-h-[600px]">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[40px] px-2">
<Checkbox
checked={filteredItems.length > 0 && selected.size === filteredItems.length}
onCheckedChange={toggleAll}
aria-label="Select all"
/>
</TableHead>
<TableHead className="w-[50px]"></TableHead>
<TableHead className="w-[60px]">ID</TableHead>
<TableHead className="w-[80px]">Type</TableHead>
<TableHead>Item Name</TableHead>
<TableHead className="min-w-[200px]">Public Name</TableHead>
<TableHead className="min-w-[200px]">Description</TableHead>
<TableHead className="w-[100px]">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredItems.map((item) => {
const nameChanged = isNameChanged(item);
const descChanged = isDescChanged(item);
const isChanged = nameChanged || descChanged;
const isMissing = !furniDataIdSet.has(item.id);
const currentCatalogName = catalogNameMap[item.id];
return (
<TableRow
key={item.id}
className={
isChanged
? "bg-[var(--admin-warning-subtle)] dark:bg-[var(--admin-warning-subtle)]"
: isMissing
? "bg-[var(--admin-warning-subtle)] dark:bg-[var(--admin-warning-subtle)]"
: undefined
}
>
<TableCell className="px-2">
<Checkbox
checked={selected.has(item.id)}
onCheckedChange={() => toggleOne(item.id)}
aria-label={`Select ${item.itemName}`}
/>
</TableCell>
<TableCell className="px-1">
{(() => {
const revInfo = furniRevisionMap[item.id];
return revInfo ? (
<FurniIcon classname={revInfo.classname} revision={revInfo.revision} />
) : (
<FurniIcon classname={item.itemName} revision={0} />
);
})()}
</TableCell>
<TableCell className="font-mono text-xs">{item.id}</TableCell>
<TableCell>
<Badge variant={item.type === "s" ? "default" : "secondary"} className="text-xs">
{item.type === "s" ? "Floor" : item.type === "i" ? "Wall" : item.type}
</Badge>
</TableCell>
<TableCell className="text-xs text-muted-foreground font-mono">
{item.itemName}
</TableCell>
<TableCell>
<Input
value={editedNames[item.id] || ""}
onChange={(e) => handleNameChange(item.id, e.target.value)}
disabled={!canEdit}
className={`h-8 text-sm ${nameChanged ? "border-[var(--admin-warning-border)] dark:border-[var(--admin-warning-border)]" : ""}`}
/>
{currentCatalogName && currentCatalogName !== item.publicName && (
<p className="text-[11px] text-muted-foreground mt-0.5 truncate">
catalog: {currentCatalogName}
</p>
)}
</TableCell>
<TableCell>
<Input
value={editedDescs[item.id] || ""}
onChange={(e) => handleDescChange(item.id, e.target.value)}
disabled={!canEdit}
placeholder="No description"
className={`h-8 text-sm ${descChanged ? "border-[var(--admin-warning-border)] dark:border-[var(--admin-warning-border)]" : ""}`}
/>
</TableCell>
<TableCell>
<div className="flex items-center gap-1.5">
{isChanged ? (
<Badge
variant="secondary"
className="text-xs bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)]"
>
Modified
</Badge>
) : (
<span className="text-xs text-muted-foreground">—</span>
)}
{isMissing ? (
<Tooltip>
<TooltipTrigger asChild>
<AlertTriangle className="h-3.5 w-3.5 text-[var(--admin-warning)] flex-shrink-0" />
</TooltipTrigger>
<TooltipContent>
<p>Missing from FurnitureData.json</p>
<p className="text-xs text-muted-foreground">
Will be auto-inserted on save
</p>
</TooltipContent>
</Tooltip>
) : (
<Tooltip>
<TooltipTrigger asChild>
<CheckCircle2 className="h-3.5 w-3.5 text-[var(--admin-success)] flex-shrink-0" />
</TooltipTrigger>
<TooltipContent>In FurnitureData.json</TooltipContent>
</Tooltip>
)}
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</TooltipProvider>
)}
</CardContent>
</Card>
);
{baseItems.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
No items to translate in this page.
</p>
) : filteredItems.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
No items match the current filter.
</p>
) : (
<TooltipProvider>
<div className="rounded-md border overflow-auto max-h-[600px]">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[40px] px-2">
<Checkbox
checked={
filteredItems.length > 0 &&
selected.size === filteredItems.length
}
onCheckedChange={toggleAll}
aria-label="Select all"
/>
</TableHead>
<TableHead className="w-[50px]"></TableHead>
<TableHead className="w-[60px]">ID</TableHead>
<TableHead className="w-[80px]">Type</TableHead>
<TableHead>Item Name</TableHead>
<TableHead className="min-w-[200px]">Public Name</TableHead>
<TableHead className="min-w-[200px]">Description</TableHead>
<TableHead className="w-[100px]">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredItems.map((item) => {
const nameChanged = isNameChanged(item);
const descChanged = isDescChanged(item);
const isChanged = nameChanged || descChanged;
const isMissing = !furniDataIdSet.has(item.id);
const currentCatalogName = catalogNameMap[item.id];
return (
<TableRow
key={item.id}
className={
isChanged
? "bg-[var(--admin-warning-subtle)] dark:bg-[var(--admin-warning-subtle)]"
: isMissing
? "bg-[var(--admin-warning-subtle)] dark:bg-[var(--admin-warning-subtle)]"
: undefined
}
>
<TableCell className="px-2">
<Checkbox
checked={selected.has(item.id)}
onCheckedChange={() => toggleOne(item.id)}
aria-label={`Select ${item.itemName}`}
/>
</TableCell>
<TableCell className="px-1">
{(() => {
const revInfo = furniRevisionMap[item.id];
return revInfo ? (
<FurniIcon
classname={revInfo.classname}
revision={revInfo.revision}
/>
) : (
<FurniIcon
classname={item.itemName}
revision={0}
/>
);
})()}
</TableCell>
<TableCell className="font-mono text-xs">
{item.id}
</TableCell>
<TableCell>
<Badge
variant={
item.type === "s" ? "default" : "secondary"
}
className="text-xs"
>
{item.type === "s"
? "Floor"
: item.type === "i"
? "Wall"
: item.type}
</Badge>
</TableCell>
<TableCell className="text-xs text-muted-foreground font-mono">
{item.itemName}
</TableCell>
<TableCell>
<Input
value={editedNames[item.id] || ""}
onChange={(e) =>
handleNameChange(item.id, e.target.value)
}
disabled={!canEdit}
className={`h-8 text-sm ${nameChanged ? "border-[var(--admin-warning-border)] dark:border-[var(--admin-warning-border)]" : ""}`}
/>
{currentCatalogName &&
currentCatalogName !== item.publicName && (
<p className="text-[11px] text-muted-foreground mt-0.5 truncate">
catalog: {currentCatalogName}
</p>
)}
</TableCell>
<TableCell>
<Input
value={editedDescs[item.id] || ""}
onChange={(e) =>
handleDescChange(item.id, e.target.value)
}
disabled={!canEdit}
placeholder="No description"
className={`h-8 text-sm ${descChanged ? "border-[var(--admin-warning-border)] dark:border-[var(--admin-warning-border)]" : ""}`}
/>
</TableCell>
<TableCell>
<div className="flex items-center gap-1.5">
{isChanged ? (
<Badge
variant="secondary"
className="text-xs bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)]"
>
Modified
</Badge>
) : (
<span className="text-xs text-muted-foreground">
—
</span>
)}
{isMissing ? (
<Tooltip>
<TooltipTrigger asChild>
<AlertTriangle className="h-3.5 w-3.5 text-[var(--admin-warning)] flex-shrink-0" />
</TooltipTrigger>
<TooltipContent>
<p>Missing from FurnitureData.json</p>
<p className="text-xs text-muted-foreground">
Will be auto-inserted on save
</p>
</TooltipContent>
</Tooltip>
) : (
<Tooltip>
<TooltipTrigger asChild>
<CheckCircle2 className="h-3.5 w-3.5 text-[var(--admin-success)] flex-shrink-0" />
</TooltipTrigger>
<TooltipContent>
In FurnitureData.json
</TooltipContent>
</Tooltip>
)}
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</TooltipProvider>
)}
</CardContent>
</Card>
);
}
+34 -34
View File
@@ -1,41 +1,41 @@
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>
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>
{/* 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 }).map((_, i) => (
<Skeleton key={i} className="h-9 w-24 rounded-md" />
))}
</div>
{/* Tabs */}
<div className="space-y-4">
<div className="flex gap-2 border-b pb-2">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} 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 }).map((_, i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-10 w-full" />
</div>
))}
</div>
</div>
</div>
);
{/* Tab content */}
<div className="rounded-lg border bg-card p-6 space-y-4">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-10 w-full" />
</div>
))}
</div>
</div>
</div>
);
}
+107 -97
View File
@@ -9,112 +9,122 @@ import { getAncestors } from "@/lib/services/catalog-tree";
import { CatalogDetailTabs } from "./catalog-detail-tabs";
export default async function CatalogEditPage({
params,
params,
}: {
params: Promise<{ locale: string; id: string }>;
params: Promise<{ locale: string; id: string }>;
}) {
const { id } = await params;
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.CATALOG_VIEW, session.user.rank)) {
redirect("/admin");
}
const { id } = await params;
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.CATALOG_VIEW, session.user.rank)) {
redirect("/admin");
}
const pageId = parseInt(id, 10);
if (Number.isNaN(pageId)) notFound();
const pageId = parseInt(id, 10);
if (Number.isNaN(pageId)) notFound();
const catalogPage = await prisma.catalogPages.findUnique({
where: { id: pageId },
});
const catalogPage = await prisma.catalogPages.findUnique({
where: { id: pageId },
});
if (!catalogPage) notFound();
if (!catalogPage) notFound();
const [children, itemsData, ancestors] = await Promise.all([
prisma.catalogPages.findMany({
where: { parentId: pageId },
orderBy: { orderNum: "asc" },
select: { id: true, caption: true, enabled: true },
}),
loadCatalogItemsData(pageId),
getAncestors(catalogPage.parentId),
]);
const [children, itemsData, ancestors] = await Promise.all([
prisma.catalogPages.findMany({
where: { parentId: pageId },
orderBy: { orderNum: "asc" },
select: { id: true, caption: true, enabled: true },
}),
loadCatalogItemsData(pageId),
getAncestors(catalogPage.parentId),
]);
const {
items: catalogItems,
baseItems,
catalogNameMap,
furniDataIdList,
furniDescriptionMap,
furniRevisionMap,
interactionTypes,
allPages,
} = itemsData;
const {
items: catalogItems,
baseItems,
catalogNameMap,
furniDataIdList,
furniDescriptionMap,
furniRevisionMap,
interactionTypes,
allPages,
} = itemsData;
const canEdit = canAccess(permissions, PERMS.CATALOG_EDIT, session.user.rank);
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>
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>
{/* 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}
/>
</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}
/>
</div>
);
}
@@ -4,58 +4,58 @@ import { prisma } from "@/lib/prisma";
import { BcPageDetail } from "../bc-manager";
export default async function BcPageDetailPage({
params: paramsPromise,
params: paramsPromise,
}: {
params: Promise<{ locale: string; id: string }>;
params: Promise<{ locale: string; id: string }>;
}) {
const { id: idStr } = await paramsPromise;
const id = parseInt(idStr, 10);
if (Number.isNaN(id)) notFound();
const { id: idStr } = await paramsPromise;
const id = parseInt(idStr, 10);
if (Number.isNaN(id)) notFound();
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.CATALOG_VIEW, session.user.rank)) {
redirect("/admin");
}
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.CATALOG_VIEW, session.user.rank)) {
redirect("/admin");
}
const canEdit = canAccess(permissions, PERMS.CATALOG_EDIT, session.user.rank);
const canEdit = canAccess(permissions, PERMS.CATALOG_EDIT, session.user.rank);
const bcPage = await prisma.catalogPagesBc.findUnique({ where: { id } });
if (!bcPage) notFound();
const bcPage = await prisma.catalogPagesBc.findUnique({ where: { id } });
if (!bcPage) notFound();
const bcItems = await prisma.catalogItemsBc.findMany({
where: { pageId: id },
orderBy: { orderNumber: "asc" },
});
const bcItems = await prisma.catalogItemsBc.findMany({
where: { pageId: id },
orderBy: { orderNumber: "asc" },
});
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}
/>
);
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}
/>
);
}
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -1,5 +1,5 @@
import { redirect } from "next/navigation";
export default function BuilderClubPage() {
redirect("/admin/catalog?catalog=bc");
redirect("/admin/catalog?catalog=bc");
}
+115 -93
View File
@@ -6,111 +6,133 @@ 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, DialogTrigger } from "@/components/ui/dialog";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} 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 {
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";
catalogType?: "normal" | "bc";
}
export function CatalogActions({ catalogType = "normal" }: CatalogActionsProps) {
const { run, isPending } = useServerAction();
const [open, setOpen] = useState(false);
const [caption, setCaption] = useState("");
const [parentId, setParentId] = useState("-1");
const [pageLayout, setPageLayout] = useState("default_3x3");
const isBc = catalogType === "bc";
export function CatalogActions({
catalogType = "normal",
}: CatalogActionsProps) {
const { run, isPending } = useServerAction();
const [open, setOpen] = useState(false);
const [caption, setCaption] = useState("");
const [parentId, setParentId] = useState("-1");
const [pageLayout, setPageLayout] = useState("default_3x3");
const isBc = catalogType === "bc";
function handleCreate() {
if (!caption.trim()) {
toast.error("Caption is required");
return;
}
function handleCreate() {
if (!caption.trim()) {
toast.error("Caption is required");
return;
}
const input = {
caption: caption.trim(),
parentId: parseInt(parentId, 10),
pageLayout,
};
const input = {
caption: caption.trim(),
parentId: parseInt(parentId, 10),
pageLayout,
};
const fn = isBc ? () => createBcPage(input) : () => createCatalogPage(input);
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}`;
}
},
});
}
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}>
<DialogTrigger asChild>
<Button>
<Plus className="mr-2 h-4 w-4" />
New Page
</Button>
</DialogTrigger>
<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>
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>
<Plus className="mr-2 h-4 w-4" />
New Page
</Button>
</DialogTrigger>
<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>
);
<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>
);
}
+173 -144
View File
@@ -11,157 +11,186 @@ import { useServerAction } from "@/hooks/use-server-action";
import type { DataTableColumn, PaginatedResult } from "@/types";
interface CatalogPageRow {
id: number;
caption: string;
parentId: number;
parentCaption: string;
pageLayout: string;
enabled: string;
visible: string;
minRank: number;
orderNum: number;
itemCount: number;
childCount: number;
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";
data: PaginatedResult<CatalogPageRow>;
canEdit: boolean;
catalogType?: "normal" | "bc";
}
export function CatalogTable({ data, canEdit, catalogType = "normal" }: CatalogTableProps) {
const { run, isPending } = useServerAction();
const isBc = catalogType === "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}`);
// 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." });
}
}
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>
),
},
];
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>
),
},
];
return (
<DataTable
data={data}
columns={columns}
searchPlaceholder="Search by caption, layout or item..."
bulkActions={
canEdit ? [{ label: "Delete Selected", value: "delete", variant: "destructive" }] : undefined
}
/>
);
return (
<DataTable
data={data}
columns={columns}
searchPlaceholder="Search by caption, layout or item..."
bulkActions={
canEdit
? [
{
label: "Delete Selected",
value: "delete",
variant: "destructive",
},
]
: undefined
}
/>
);
}
+85 -70
View File
@@ -6,80 +6,95 @@ import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface CatalogViewToggleProps {
currentView: string;
catalogType?: string;
currentView: string;
catalogType?: string;
}
export function CatalogViewToggle({ currentView, catalogType = "normal" }: CatalogViewToggleProps) {
const router = useRouter();
const searchParams = useSearchParams();
export function CatalogViewToggle({
currentView,
catalogType = "normal",
}: CatalogViewToggleProps) {
const router = useRouter();
const searchParams = useSearchParams();
function setView(view: string) {
const params = new URLSearchParams(searchParams.toString());
if (view === "tree") {
params.delete("view");
} else {
params.set("view", view);
}
router.push(`?${params.toString()}`);
}
function setView(view: string) {
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) {
const params = new URLSearchParams(searchParams.toString());
if (type === "normal") {
params.delete("catalog");
} else {
params.set("catalog", type);
}
params.delete("view"); // reset to tree when switching
router.push(`?${params.toString()}`);
}
function setCatalog(type: string) {
const params = new URLSearchParams(searchParams.toString());
if (type === "normal") {
params.delete("catalog");
} else {
params.set("catalog", type);
}
params.delete("view"); // reset to tree when switching
router.push(`?${params.toString()}`);
}
return (
<div className="flex 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" />
Catalog
</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>
return (
<div className="flex 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" />
Catalog
</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" />
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" />
Table
</Button>
</div>
</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" />
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" />
Table
</Button>
</div>
</div>
);
}
+39 -39
View File
@@ -1,44 +1,44 @@
import { Skeleton } from "@/components/ui/skeleton";
export default function CatalogLoading() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<Skeleton className="h-9 w-48" />
<Skeleton className="h-5 w-64 mt-2" />
</div>
<div className="flex items-center gap-2">
<Skeleton className="h-10 w-24" />
<Skeleton className="h-10 w-28" />
</div>
</div>
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<Skeleton className="h-9 w-48" />
<Skeleton className="h-5 w-64 mt-2" />
</div>
<div className="flex items-center gap-2">
<Skeleton className="h-10 w-24" />
<Skeleton className="h-10 w-28" />
</div>
</div>
{/* Tree skeleton */}
<div className="rounded-lg border bg-card">
<div className="border-b px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<Skeleton className="h-4 w-4" />
<Skeleton className="h-4 w-32" />
<Skeleton className="h-5 w-16 rounded-full" />
</div>
<Skeleton className="h-8 w-56" />
</div>
<div className="p-2 space-y-1">
{Array.from({ length: 12 }).map((_, i) => (
<div
key={i}
className="flex items-center gap-2 px-2 py-1.5"
style={{ paddingLeft: `${(i % 3) * 20 + 8}px` }}
>
<Skeleton className="h-5 w-5 shrink-0" />
<Skeleton className="h-5 w-5 shrink-0" />
<Skeleton className="h-4 flex-1 max-w-48" />
<Skeleton className="h-5 w-8 rounded-full" />
</div>
))}
</div>
</div>
</div>
);
{/* Tree skeleton */}
<div className="rounded-lg border bg-card">
<div className="border-b px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<Skeleton className="h-4 w-4" />
<Skeleton className="h-4 w-32" />
<Skeleton className="h-5 w-16 rounded-full" />
</div>
<Skeleton className="h-8 w-56" />
</div>
<div className="p-2 space-y-1">
{Array.from({ length: 12 }).map((_, i) => (
<div
key={i}
className="flex items-center gap-2 px-2 py-1.5"
style={{ paddingLeft: `${(i % 3) * 20 + 8}px` }}
>
<Skeleton className="h-5 w-5 shrink-0" />
<Skeleton className="h-5 w-5 shrink-0" />
<Skeleton className="h-4 flex-1 max-w-48" />
<Skeleton className="h-5 w-8 rounded-full" />
</div>
))}
</div>
</div>
</div>
);
}
+373 -331
View File
@@ -10,283 +10,313 @@ import { CatalogTable } from "./catalog-table";
import { CatalogViewToggle } from "./catalog-view-toggle";
const CatalogManagerDialog = dynamic(() =>
import("@/components/admin/catalog-manager/catalog-manager-dialog").then((m) => ({
default: m.CatalogManagerDialog,
})),
import("@/components/admin/catalog-manager/catalog-manager-dialog").then(
(m) => ({
default: m.CatalogManagerDialog,
}),
),
);
export default async function CatalogPagesPage({
params: paramsPromise,
searchParams,
params: paramsPromise,
searchParams,
}: {
params: Promise<{ locale: string }>;
searchParams: Promise<Record<string, string>>;
params: Promise<{ locale: string }>;
searchParams: Promise<Record<string, string>>;
}) {
await paramsPromise;
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.CATALOG_VIEW, session.user.rank)) {
redirect("/admin");
}
await paramsPromise;
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.CATALOG_VIEW, session.user.rank)) {
redirect("/admin");
}
const canEdit = canAccess(permissions, PERMS.CATALOG_EDIT, session.user.rank);
const canEdit = canAccess(permissions, PERMS.CATALOG_EDIT, session.user.rank);
const params = await searchParams;
const sp = new URLSearchParams(params);
const { search, perPage, page, sort, order } = parseListParams(sp);
const viewMode = sp.get("view") || "tree";
const catalogType = sp.get("catalog") || "normal";
const params = await searchParams;
const sp = new URLSearchParams(params);
const { search, perPage, page, sort, order } = parseListParams(sp);
const viewMode = sp.get("view") || "tree";
const catalogType = sp.get("catalog") || "normal";
// ── Builder Club mode ──────────────────────────────────────────
type TreeNodeShape = {
id: number;
caption: string;
parentId: number;
enabled: string;
visible: string;
iconImage: number;
iconColor: number;
pageLayout: string;
childCount: number;
itemCount: number;
};
// ── Builder Club mode ──────────────────────────────────────────
type TreeNodeShape = {
id: number;
caption: string;
parentId: number;
enabled: string;
visible: string;
iconImage: number;
iconColor: number;
pageLayout: string;
childCount: number;
itemCount: number;
};
if (catalogType === "bc") {
const [totalBcPages, totalBcItems, enabledBcPages] = await Promise.all([
prisma.catalogPagesBc.count(),
prisma.catalogItemsBc.count(),
prisma.catalogPagesBc.count({ where: { enabled: "1" } }),
]);
if (catalogType === "bc") {
const [totalBcPages, totalBcItems, enabledBcPages] = await Promise.all([
prisma.catalogPagesBc.count(),
prisma.catalogItemsBc.count(),
prisma.catalogPagesBc.count({ where: { enabled: "1" } }),
]);
// ── BC tree view ──────────────────────────────────────────────
let bcRootPages: TreeNodeShape[] = [];
if (viewMode === "tree") {
const bcRootRaw = await prisma.catalogPagesBc.findMany({
where: { OR: [{ parentId: -1 }, { parentId: 0 }] },
orderBy: { orderNum: "asc" },
});
const bcRootIds = bcRootRaw.map((p) => p.id);
const [bcChildCounts, bcItemCounts] = await Promise.all([
bcRootIds.length > 0
? prisma.catalogPagesBc.groupBy({
by: ["parentId"],
where: { parentId: { in: bcRootIds } },
_count: true,
})
: [],
bcRootIds.length > 0
? prisma.catalogItemsBc.groupBy({
by: ["pageId"],
where: { pageId: { in: bcRootIds } },
_count: true,
})
: [],
]);
const bcChildMap = Object.fromEntries(bcChildCounts.map((c) => [c.parentId, c._count]));
const bcItemMap = Object.fromEntries(bcItemCounts.map((c) => [c.pageId, c._count]));
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,
}));
}
// ── BC tree view ──────────────────────────────────────────────
let bcRootPages: TreeNodeShape[] = [];
if (viewMode === "tree") {
const bcRootRaw = await prisma.catalogPagesBc.findMany({
where: { OR: [{ parentId: -1 }, { parentId: 0 }] },
orderBy: { orderNum: "asc" },
});
const bcRootIds = bcRootRaw.map((p) => p.id);
const [bcChildCounts, bcItemCounts] = await Promise.all([
bcRootIds.length > 0
? prisma.catalogPagesBc.groupBy({
by: ["parentId"],
where: { parentId: { in: bcRootIds } },
_count: true,
})
: [],
bcRootIds.length > 0
? prisma.catalogItemsBc.groupBy({
by: ["pageId"],
where: { pageId: { in: bcRootIds } },
_count: true,
})
: [],
]);
const bcChildMap = Object.fromEntries(
bcChildCounts.map((c) => [c.parentId, c._count]),
);
const bcItemMap = Object.fromEntries(
bcItemCounts.map((c) => [c.pageId, c._count]),
);
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,
}));
}
// ── BC table view ─────────────────────────────────────────────
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 };
// ── BC table view ─────────────────────────────────────────────
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;
if (viewMode === "table") {
const parentFilter = sp.get("parent");
const parentId =
parentFilter !== null ? parseInt(parentFilter, 10) : undefined;
const where: Record<string, unknown> = {};
const hasParent = parentId !== undefined && !Number.isNaN(parentId);
if (search) {
const pattern = { contains: search };
where.OR = [{ caption: pattern }];
}
if (hasParent) where.parentId = parentId;
const where: Record<string, unknown> = {};
const hasParent = parentId !== undefined && !Number.isNaN(parentId);
if (search) {
const pattern = { contains: search };
where.OR = [{ caption: pattern }];
}
if (hasParent) where.parentId = parentId;
// Prisma sort key is snake_case for BC model. Translate known keys.
const sortKeyMap: Record<string, string> = {
parentId: "parentId",
pageLayout: "pageLayout",
orderNum: "orderNum",
};
const sortKey = sort ? (sortKeyMap[sort] ?? sort) : "orderNum";
const orderBy = { [sortKey]: sort ? order : "asc" };
// Prisma sort key is snake_case for BC model. Translate known keys.
const sortKeyMap: Record<string, string> = {
parentId: "parentId",
pageLayout: "pageLayout",
orderNum: "orderNum",
};
const sortKey = sort ? (sortKeyMap[sort] ?? sort) : "orderNum";
const orderBy = { [sortKey]: sort ? order : "asc" };
const [pages, total] = await Promise.all([
prisma.catalogPagesBc.findMany({
where,
orderBy,
skip: (page - 1) * perPage,
take: perPage,
}),
prisma.catalogPagesBc.count({ where }),
]);
const [pages, total] = await Promise.all([
prisma.catalogPagesBc.findMany({
where,
orderBy,
skip: (page - 1) * perPage,
take: perPage,
}),
prisma.catalogPagesBc.count({ where }),
]);
bcPagination = calcPagination(total, page, perPage);
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
? prisma.catalogPagesBc.groupBy({
by: ["parentId"],
where: { parentId: { in: pageIds } },
_count: true,
})
: [],
pageIds.length > 0
? prisma.catalogItemsBc.groupBy({
by: ["pageId"],
where: { pageId: { in: pageIds } },
_count: true,
})
: [],
parentIds.length > 0
? prisma.catalogPagesBc.findMany({
where: { id: { in: parentIds } },
select: { id: true, caption: true },
})
: [],
]);
const childCountMap = Object.fromEntries(childCounts.map((c) => [c.parentId, c._count]));
const itemCountMap = Object.fromEntries(itemCounts.map((c) => [c.pageId, c._count]));
const parentNameMap = Object.fromEntries(parentPages.map((p) => [p.id, p.caption]));
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
? prisma.catalogPagesBc.groupBy({
by: ["parentId"],
where: { parentId: { in: pageIds } },
_count: true,
})
: [],
pageIds.length > 0
? prisma.catalogItemsBc.groupBy({
by: ["pageId"],
where: { pageId: { in: pageIds } },
_count: true,
})
: [],
parentIds.length > 0
? prisma.catalogPagesBc.findMany({
where: { id: { in: parentIds } },
select: { id: true, caption: true },
})
: [],
]);
const childCountMap = Object.fromEntries(
childCounts.map((c) => [c.parentId, c._count]),
);
const itemCountMap = Object.fromEntries(
itemCounts.map((c) => [c.pageId, c._count]),
);
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, // BC pages have no min rank column
orderNum: p.orderNum,
itemCount: itemCountMap[p.id] ?? 0,
childCount: childCountMap[p.id] ?? 0,
}));
}
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, // BC pages have no min rank column
orderNum: p.orderNum,
itemCount: itemCountMap[p.id] ?? 0,
childCount: childCountMap[p.id] ?? 0,
}));
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Catalog Pages</h1>
<p className="text-muted-foreground">
{totalBcPages} BC pages ({enabledBcPages} enabled) &middot; {totalBcItems} items
</p>
</div>
<div className="flex items-center gap-2">
<CatalogViewToggle currentView={viewMode} catalogType="bc" />
{canEdit && <CatalogActions catalogType="bc" />}
</div>
</div>
{viewMode === "tree" ? (
<CatalogTree rootPages={bcRootPages} catalogType="bc" />
) : (
<CatalogTable data={{ rows: bcRows, ...bcPagination }} canEdit={canEdit} catalogType="bc" />
)}
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Catalog Pages</h1>
<p className="text-muted-foreground">
{totalBcPages} BC pages ({enabledBcPages} enabled) &middot;{" "}
{totalBcItems} items
</p>
</div>
<div className="flex items-center gap-2">
<CatalogViewToggle currentView={viewMode} catalogType="bc" />
{canEdit && <CatalogActions catalogType="bc" />}
</div>
</div>
{viewMode === "tree" ? (
<CatalogTree rootPages={bcRootPages} catalogType="bc" />
) : (
<CatalogTable
data={{ rows: bcRows, ...bcPagination }}
canEdit={canEdit}
catalogType="bc"
/>
)}
</div>
);
}
// ── Normal catalog mode ────────────────────────────────────────
// ── Normal catalog mode ────────────────────────────────────────
// Global stats (lightweight counts)
const [totalPages, totalItems, enabledPages] = await Promise.all([
prisma.catalogPages.count(),
prisma.catalogItems.count(),
prisma.catalogPages.count({ where: { enabled: "1" } }),
]);
// Global stats (lightweight counts)
const [totalPages, totalItems, enabledPages] = await Promise.all([
prisma.catalogPages.count(),
prisma.catalogItems.count(),
prisma.catalogPages.count({ where: { enabled: "1" } }),
]);
// ── Tree view data ──────────────────────────────────────────────
let rootPages: TreeNodeShape[] = [];
if (viewMode === "tree") {
const rootPagesRaw = await prisma.catalogPages.findMany({
where: { parentId: -1 },
orderBy: { orderNum: "asc" },
});
const rootIds = rootPagesRaw.map((p) => p.id);
const [rootChildCounts, rootItemCounts] = await Promise.all([
rootIds.length > 0
? prisma.catalogPages.groupBy({
by: ["parentId"],
where: { parentId: { in: rootIds } },
_count: true,
})
: [],
rootIds.length > 0
? prisma.catalogItems.groupBy({
by: ["pageId"],
where: { pageId: { in: rootIds } },
_count: true,
})
: [],
]);
const rootChildMap = Object.fromEntries(rootChildCounts.map((c) => [c.parentId, c._count]));
const rootItemMap = Object.fromEntries(rootItemCounts.map((c) => [c.pageId, c._count]));
rootPages = 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,
}));
}
// ── Tree view data ──────────────────────────────────────────────
let rootPages: TreeNodeShape[] = [];
if (viewMode === "tree") {
const rootPagesRaw = await prisma.catalogPages.findMany({
where: { parentId: -1 },
orderBy: { orderNum: "asc" },
});
const rootIds = rootPagesRaw.map((p) => p.id);
const [rootChildCounts, rootItemCounts] = await Promise.all([
rootIds.length > 0
? prisma.catalogPages.groupBy({
by: ["parentId"],
where: { parentId: { in: rootIds } },
_count: true,
})
: [],
rootIds.length > 0
? prisma.catalogItems.groupBy({
by: ["pageId"],
where: { pageId: { in: rootIds } },
_count: true,
})
: [],
]);
const rootChildMap = Object.fromEntries(
rootChildCounts.map((c) => [c.parentId, c._count]),
);
const rootItemMap = Object.fromEntries(
rootItemCounts.map((c) => [c.pageId, c._count]),
);
rootPages = 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,
}));
}
// ── Table view data (skip if tree) ────────────────────────────
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 };
// ── Table view data (skip if tree) ────────────────────────────
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;
if (viewMode === "table") {
const parentFilter = sp.get("parent");
const parentId =
parentFilter !== null ? parseInt(parentFilter, 10) : undefined;
const where: Record<string, unknown> = {};
const hasParent = parentId !== undefined && !Number.isNaN(parentId);
if (search) {
const searchPattern = `%${search}%`;
const parentClause = hasParent ? Prisma.sql`AND cp.parentId = ${parentId}` : Prisma.empty;
const where: Record<string, unknown> = {};
const hasParent = parentId !== undefined && !Number.isNaN(parentId);
if (search) {
const searchPattern = `%${search}%`;
const parentClause = hasParent
? Prisma.sql`AND cp.parentId = ${parentId}`
: Prisma.empty;
const matchedIds = await prisma.$queryRaw<{ id: number }[]>(Prisma.sql`
const matchedIds = await prisma.$queryRaw<{ id: number }[]>(Prisma.sql`
SELECT DISTINCT cp.id FROM catalog_pages cp
LEFT JOIN catalog_items ci ON ci.pageId = cp.id
LEFT JOIN items_base ib ON FIND_IN_SET(ib.id, REPLACE(ci.itemIds, ';', ','))
@@ -297,90 +327,102 @@ export default async function CatalogPagesPage({
OR ib.item_name LIKE ${searchPattern})
${parentClause}
`);
where.id = { in: matchedIds.map((r) => r.id) };
} else if (hasParent) {
where.parentId = parentId;
}
where.id = { in: matchedIds.map((r) => r.id) };
} else if (hasParent) {
where.parentId = parentId;
}
const orderBy = sort ? { [sort]: order } : { orderNum: "asc" as const };
const orderBy = sort ? { [sort]: order } : { orderNum: "asc" as const };
const [pages, total] = await Promise.all([
prisma.catalogPages.findMany({
where,
orderBy,
skip: (page - 1) * perPage,
take: perPage,
}),
prisma.catalogPages.count({ where }),
]);
const [pages, total] = await Promise.all([
prisma.catalogPages.findMany({
where,
orderBy,
skip: (page - 1) * perPage,
take: perPage,
}),
prisma.catalogPages.count({ where }),
]);
pagination = calcPagination(total, page, perPage);
pagination = calcPagination(total, page, perPage);
// Count children and items per page, and resolve parent names in parallel
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
? prisma.catalogPages.groupBy({
by: ["parentId"],
where: { parentId: { in: pageIds } },
_count: true,
})
: [],
pageIds.length > 0
? prisma.catalogItems.groupBy({
by: ["pageId"],
where: { pageId: { in: pageIds } },
_count: true,
})
: [],
parentIds.length > 0
? prisma.catalogPages.findMany({
where: { id: { in: parentIds } },
select: { id: true, caption: true },
})
: [],
]);
const childCountMap = Object.fromEntries(childCounts.map((c) => [c.parentId, c._count]));
const itemCountMap = Object.fromEntries(itemCounts.map((c) => [c.pageId, c._count]));
const parentNameMap = Object.fromEntries(parentPages.map((p) => [p.id, p.caption]));
// Count children and items per page, and resolve parent names in parallel
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
? prisma.catalogPages.groupBy({
by: ["parentId"],
where: { parentId: { in: pageIds } },
_count: true,
})
: [],
pageIds.length > 0
? prisma.catalogItems.groupBy({
by: ["pageId"],
where: { pageId: { in: pageIds } },
_count: true,
})
: [],
parentIds.length > 0
? prisma.catalogPages.findMany({
where: { id: { in: parentIds } },
select: { id: true, caption: true },
})
: [],
]);
const childCountMap = Object.fromEntries(
childCounts.map((c) => [c.parentId, c._count]),
);
const itemCountMap = Object.fromEntries(
itemCounts.map((c) => [c.pageId, c._count]),
);
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,
}));
}
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 (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Catalog Pages</h1>
<p className="text-muted-foreground">
{totalPages} pages ({enabledPages} enabled) &middot; {totalItems} items
</p>
</div>
<div className="flex items-center gap-2">
<CatalogViewToggle currentView={viewMode} catalogType="normal" />
{canEdit && <CatalogManagerDialog />}
{canEdit && <CatalogActions />}
</div>
</div>
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Catalog Pages</h1>
<p className="text-muted-foreground">
{totalPages} pages ({enabledPages} enabled) &middot; {totalItems}{" "}
items
</p>
</div>
<div className="flex items-center gap-2">
<CatalogViewToggle currentView={viewMode} catalogType="normal" />
{canEdit && <CatalogManagerDialog />}
{canEdit && <CatalogActions />}
</div>
</div>
{viewMode === "tree" ? (
<CatalogTree rootPages={rootPages} catalogType="normal" />
) : (
<CatalogTable data={{ rows, ...pagination }} canEdit={canEdit} />
)}
</div>
);
{viewMode === "tree" ? (
<CatalogTree rootPages={rootPages} catalogType="normal" />
) : (
<CatalogTable data={{ rows, ...pagination }} canEdit={canEdit} />
)}
</div>
);
}