This commit is contained in:
1 parent
8efd032cc6
commit
df38dccbf1
735 files changed
+128321
-120870
No files matched your search
@@ -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 "{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>
|
||||
);
|
||||
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,
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} · {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} · {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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user