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
@@ -5,7 +5,7 @@ import { useTranslations } from "next-intl";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { type CatalogKind, createAutoCategory } from "@/actions/catalog";
|
||||
import type { CatalogItemData } from "@/app/admin/catalog/[id]/catalog-items-table/types";
|
||||
import type { CatalogItemData } from "@/components/admin/catalog/catalog-items-table/types";
|
||||
import { IconPicker } from "@/components/admin/catalog/icon-picker";
|
||||
import { useCatalogManager } from "@/components/admin/catalog-manager/use-catalog-store";
|
||||
import { CatalogIcon, LAYOUT_COLORS } from "@/components/admin/catalog-tree";
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ArrowDownUp, Loader2, Wand2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { reorderCatalogItems } from "@/actions/catalog-items";
|
||||
import type { CatalogItemData } from "@/app/admin/catalog/[id]/catalog-items-table/types";
|
||||
import type { CatalogItemData } from "@/components/admin/catalog/catalog-items-table/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -47,7 +47,7 @@ export function CatalogManagerWorkspace({
|
||||
return (
|
||||
<section
|
||||
aria-label={t("manager")}
|
||||
className="flex h-[calc(100dvh-12rem)] min-h-[32rem] min-w-0 flex-col overflow-hidden rounded-lg border border-[var(--admin-border)] bg-[var(--admin-canvas)] text-[var(--admin-text)]"
|
||||
className="flex h-full min-h-0 min-w-0 flex-col overflow-hidden rounded-lg border border-[var(--admin-border)] bg-[var(--admin-canvas)] text-[var(--admin-text)]"
|
||||
>
|
||||
<CatalogManagerProvider
|
||||
key={catalogType}
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
AlertCircle,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ExternalLink,
|
||||
Languages,
|
||||
Loader2,
|
||||
MonitorPlay,
|
||||
@@ -20,16 +19,16 @@ import { updateBcPage } from "@/actions/catalog-bc";
|
||||
import {
|
||||
type CatalogItemData,
|
||||
CatalogItemsTable,
|
||||
} from "@/app/admin/catalog/[id]/catalog-items-table";
|
||||
import { CatalogTranslateTab } from "@/app/admin/catalog/[id]/catalog-translate-tab";
|
||||
} from "@/components/admin/catalog/catalog-items-table";
|
||||
import { CatalogTranslateTab } from "@/components/admin/catalog/catalog-translate-tab";
|
||||
import { BulkImportItems } from "@/components/admin/catalog/bulk-import-items";
|
||||
import { CatalogImagePicker } from "@/components/admin/catalog/catalog-image-picker";
|
||||
import { IconPicker } from "@/components/admin/catalog/icon-picker";
|
||||
import { QuickAddFurni } from "@/components/admin/catalog/quick-add-furni";
|
||||
import { AutoOrganize } from "@/components/admin/catalog-manager/auto-organize";
|
||||
import { BcItemsEditor } from "@/components/admin/catalog-manager/bc-items-editor";
|
||||
import { LiveCatalogPreview } from "@/components/admin/catalog-manager/live-catalog-preview";
|
||||
import { CatalogIcon, LAYOUT_COLORS } from "@/components/admin/catalog-tree";
|
||||
import Link from "@/components/link";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -133,10 +132,6 @@ function InlineEditorSession({
|
||||
const [offersDirty, setOffersDirty] = useState(false);
|
||||
const { dispatch, catalogType, canEdit } = useCatalogManager();
|
||||
const catQs = catalogTreeQs(catalogType);
|
||||
const pageHref =
|
||||
catalogType === "bc"
|
||||
? `/admin/catalog/builder-club/${pageId}`
|
||||
: `/admin/catalog/${pageId}`;
|
||||
const [page, setPage] = useState<PageDetail | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -425,17 +420,7 @@ function InlineEditorSession({
|
||||
{t("preview.title")}
|
||||
</Button>
|
||||
)}
|
||||
<Link href={pageHref} target="_blank">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs gap-1 text-[var(--admin-text)]"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
{t("fullEditor")}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dirty banner */}
|
||||
@@ -671,7 +656,10 @@ function InlineEditorSession({
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Text fields
|
||||
{(form.pageText1 || form.pageText2 || form.pageTextDetails) && (
|
||||
{(form.pageText1 ||
|
||||
form.pageText2 ||
|
||||
form.pageTextDetails ||
|
||||
form.pageTextTeaser) && (
|
||||
<Badge variant="secondary" className="h-4 px-1 text-[9px]">
|
||||
has content
|
||||
</Badge>
|
||||
@@ -713,6 +701,17 @@ function InlineEditorSession({
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Text Teaser</Label>
|
||||
<Textarea
|
||||
rows={2}
|
||||
value={String(form.pageTextTeaser || "")}
|
||||
onChange={(e) =>
|
||||
update("pageTextTeaser", e.target.value || null)
|
||||
}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Special</Label>
|
||||
<Input
|
||||
@@ -750,17 +749,7 @@ function InlineEditorSession({
|
||||
className="flex-1 overflow-y-auto px-4 pb-4 mt-2"
|
||||
>
|
||||
{catalogType === "bc" ? (
|
||||
<div className="rounded-md border border-dashed px-4 py-8 text-center text-sm text-muted-foreground space-y-3">
|
||||
<p>
|
||||
BC items are managed on the full Builder Club page editor.
|
||||
</p>
|
||||
<Link href={pageHref} target="_blank">
|
||||
<Button variant="outline" size="sm" className="gap-1">
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
Open BC page editor
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
<BcItemsEditor pageId={page.id} canEdit={canEdit} />
|
||||
) : itemsError && !itemsData ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Button variant="outline" onClick={reloadItems}>
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
Wand2,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { CatalogItemData } from "@/app/admin/catalog/[id]/catalog-items-table/types";
|
||||
import type { CatalogItemData } from "@/components/admin/catalog/catalog-items-table/types";
|
||||
import {
|
||||
getShopGridClass,
|
||||
getShopTileSize,
|
||||
|
||||
@@ -97,8 +97,6 @@ export function SortableTree({
|
||||
const { state, dispatch, catalogType, canEdit } = useCatalogManager();
|
||||
const t = useTranslations("pages.admin.catalog.workspace.duplicate");
|
||||
const catQs = catalogTreeQs(catalogType);
|
||||
const pageBase =
|
||||
catalogType === "bc" ? "/admin/catalog/builder-club" : "/admin/catalog";
|
||||
const { confirm, dialog: confirmDialog } = useConfirmDialog();
|
||||
const {
|
||||
nodes,
|
||||
@@ -713,8 +711,6 @@ export function SortableTree({
|
||||
},
|
||||
[state.editorDirty, t, canEdit, nodes, catalogType],
|
||||
);
|
||||
const handleOpenFull = (id: number) =>
|
||||
window.open(`${pageBase}/${id}`, "_blank");
|
||||
|
||||
// ── Expand/Collapse all ─────────────────────────────────────
|
||||
const handleExpandAll = useCallback(() => {
|
||||
@@ -813,10 +809,8 @@ export function SortableTree({
|
||||
};
|
||||
|
||||
const activeNode = activeId ? flatItems.find((n) => n.id === activeId) : null;
|
||||
const noop = () => {};
|
||||
const noop = () => {};
|
||||
|
||||
// Shared props builder for tree items
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
|
||||
const getItemProps = useCallback(
|
||||
(node: FlatTreeNode): Omit<TreeItemProps, "sortMode" | "isOverlay"> => ({
|
||||
node,
|
||||
@@ -831,7 +825,6 @@ export function SortableTree({
|
||||
onToggleVisible: handleToggleVisible,
|
||||
onDuplicate: handleDuplicate,
|
||||
onDelete: handleDelete,
|
||||
onOpenFull: handleOpenFull,
|
||||
isLoading: loadingIds.has(node.id),
|
||||
}),
|
||||
[
|
||||
@@ -1129,7 +1122,6 @@ export function SortableTree({
|
||||
onToggleVisible={noop}
|
||||
onDuplicate={noop}
|
||||
onDelete={noop}
|
||||
onOpenFull={noop}
|
||||
/>
|
||||
)}
|
||||
</DragOverlay>
|
||||
|
||||
@@ -5,7 +5,6 @@ import { CSS } from "@dnd-kit/utilities";
|
||||
import {
|
||||
ChevronRight,
|
||||
Copy as CopyIcon,
|
||||
ExternalLink,
|
||||
Eye,
|
||||
EyeOff,
|
||||
EyeOff as EyeOffIcon,
|
||||
@@ -61,7 +60,6 @@ export interface TreeItemProps {
|
||||
onToggleVisible: (id: number, currentVisible: string) => void;
|
||||
onDuplicate: (id: number) => void;
|
||||
onDelete: (id: number) => void;
|
||||
onOpenFull: (id: number) => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
@@ -114,7 +112,6 @@ export const TreeItemContent = React.memo(
|
||||
onToggleVisible,
|
||||
onDuplicate,
|
||||
onDelete,
|
||||
onOpenFull,
|
||||
isLoading,
|
||||
}: TreeItemProps & {
|
||||
isDragging?: boolean;
|
||||
@@ -262,10 +259,6 @@ export const TreeItemContent = React.memo(
|
||||
<Pencil className="mr-2 h-3.5 w-3.5" />
|
||||
Edit
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onOpenFull(node.id)}>
|
||||
<ExternalLink className="mr-2 h-3.5 w-3.5" />
|
||||
Open full editor
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => navigator.clipboard.writeText(String(node.id))}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Re-export barrel for backward compatibility.
|
||||
* Implementation moved to ./catalog-items-table/ directory.
|
||||
*/
|
||||
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";
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -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" }
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Package } from "lucide-react";
|
||||
import type { CatalogItemData } from "@/app/admin/catalog/[id]/catalog-items-table/types";
|
||||
import type { CatalogItemData } from "@/components/admin/catalog/catalog-items-table/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
getShopGridClass,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { Crown, Package, Sparkles } from "lucide-react";
|
||||
import type { CatalogItemData } from "@/app/admin/catalog/[id]/catalog-items-table/types";
|
||||
import { getPointsLabel } from "@/app/admin/catalog/[id]/catalog-items-table/types";
|
||||
import type { CatalogItemData } from "@/components/admin/catalog/catalog-items-table/types";
|
||||
import { getPointsLabel } from "@/components/admin/catalog/catalog-items-table/types";
|
||||
import {
|
||||
CurrencyIcon,
|
||||
currencyKindFromPointsType,
|
||||
|
||||
Reference in new issue
Block a user