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