Replace standalone catalog editor with the unified Visual Catalog studio
Make /admin/catalog a full-screen catalog studio that replaces the old listing plus separate [id]/builder-club detail pages: - Embed CatalogManagerWorkspace on /admin/catalog with a Normal/Builder Club toggle, Catalog Sync status, packages (normal), Organize imports and a diagnostics link to /admin/studio/maintenance. - Manage BC items directly in the studio Items tab (new BcItemsEditor, CRUD via existing bc actions; /api/admin/catalog/items now serves BC). - Inline editor: add pageTextTeaser field for both catalogs and remove the legacy full-editor links. - Remove the 'Open full editor' context action from the tree. - Move catalog-items-table (dir + barrel) and catalog-translate-tab out of the app route into src/components/admin/catalog and update all importers. - Keep /admin/catalog/[id], builder-club/[id] and /admin/catalog/maintenance as redirects into the new studio; consolidate maintenance panels into /admin/studio/maintenance and point the nav item there. - Delete the old listing/table/tabs/forms and the standalone bc-manager.
This commit is contained in:
1 parent
1a9e1e791a
commit
1bbd809b43
36 files changed
+526
-2750
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,300 @@
|
||||
"use client";
|
||||
|
||||
import { Check, ChevronsUpDown, Star } from "lucide-react";
|
||||
import { useId, useMemo, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
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 { Switch } from "@/components/ui/switch";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ── Toggle field ──────────────────────────────────────────────
|
||||
|
||||
export function ToggleField({
|
||||
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;
|
||||
}) {
|
||||
const id = useId();
|
||||
const isChecked = checked ?? (value === 1 || value === "1");
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label htmlFor={id} className="text-xs">
|
||||
{label}
|
||||
</Label>
|
||||
<Switch
|
||||
id={id}
|
||||
checked={isChecked}
|
||||
onCheckedChange={onChange}
|
||||
disabled={disabled}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Interaction Type Combobox ──────────────────────────────────
|
||||
|
||||
export function InteractionTypeCombobox({
|
||||
value,
|
||||
onChange,
|
||||
types,
|
||||
disabled,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
types: string[];
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
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]);
|
||||
|
||||
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 "{inputValue.trim()}"
|
||||
</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,
|
||||
disabled = false,
|
||||
labels,
|
||||
}: {
|
||||
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[];
|
||||
disabled?: boolean;
|
||||
labels?: {
|
||||
placeholder: string;
|
||||
search: string;
|
||||
empty: string;
|
||||
suggested: string;
|
||||
all: string;
|
||||
};
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const options = useMemo(
|
||||
() => pages.filter((p) => p.id !== currentPageId),
|
||||
[pages, currentPageId],
|
||||
);
|
||||
|
||||
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]);
|
||||
|
||||
// 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"
|
||||
disabled={disabled}
|
||||
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">
|
||||
{labels?.placeholder ?? "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={labels?.search ?? "Search pages..."} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{labels?.empty ?? "No page found."}</CommandEmpty>
|
||||
|
||||
{/* Suggested pages */}
|
||||
{suggested.length > 0 && (
|
||||
<CommandGroup heading={labels?.suggested ?? "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 ? (labels?.all ?? "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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2, Music } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
getCachedSongPickerOptions,
|
||||
invalidateSongPickerCache,
|
||||
loadSongPickerOptions,
|
||||
type SoundtrackOption,
|
||||
} from "@/lib/client-cache/song-picker-cache";
|
||||
|
||||
// Re-export so existing `import { invalidateSongPickerCache } from '.../song-picker'`
|
||||
// callsites keep working. New code should import from the lib module directly.
|
||||
export { invalidateSongPickerCache };
|
||||
|
||||
interface Props {
|
||||
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")}`;
|
||||
}
|
||||
|
||||
export function SongPicker({ value, onChange, disabled }: Props) {
|
||||
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;
|
||||
};
|
||||
}, []);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export interface InlineEdit {
|
||||
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)]" },
|
||||
};
|
||||
|
||||
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: "",
|
||||
};
|
||||
|
||||
export function getPointsLabel(type: number) {
|
||||
return (
|
||||
POINTS_TYPES[type] || { label: `Type ${type}`, color: "theme-text-muted" }
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user