Revert "Merge pull request 'Complete Housekeeping migration and /ase cutover' (#52) from codex/housekeeping-complete into main"
CI / check (push) Successful in 27s
CI / release (push) Skipped
CI / deploy (push) Successful in 43s

This reverts commit 488b6e57c4, reversing
changes made to b506b4499a.
This commit is contained in:
Simo committed 2026-08-30 21:31:34 +02:00
1 parent 488b6e57c4
commit b1ddda66ff
802 files changed
+61370 -76659

No files matched your search

+926
View File
@@ -0,0 +1,926 @@
"use client";
import {
AlertCircle,
Check,
CheckCircle2,
ClipboardList,
ExternalLink,
Loader2,
SearchX,
} from "lucide-react";
import { useCallback, useRef, useState } from "react";
import { flushSync } from "react-dom";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { adminFetch } from "@/lib/admin-fetch";
interface AuditIssue {
type: string;
severity: "error" | "warning" | "info";
classname?: string;
itemId?: number;
catalogId?: number;
message: string;
sourceName?: string;
}
interface MissingFromSource {
sourceName: string;
sourceId: string;
classname: string;
itemName: string;
}
interface UnrepairableItem {
classname: string;
itemId: number;
missing: Array<"nitro" | "icon">;
}
interface AuditSummary {
totalItemsBase: number;
totalCatalogItems: number;
missingCatalog: number;
orphanedCatalog: number;
missingNitro: number;
missingIcon: number;
duplicates: number;
missingFromSourcesTotal: number;
unrepairableCount: number;
iconsRepaired: number;
iconsStillMissing: number;
nitrosRepaired: number;
nitrosStillMissing: number;
catalogSqlCount: number;
catalogSqlApplied: number;
furniDataAdded: number;
furniDataDuplicatesRemoved: number;
orphanedRemoved: number;
orphanedCleaned: number;
duplicatesMerged: number;
duplicateRowsRemoved: number;
remappedReferences: number;
}
interface RepairStats {
succeeded: number;
failed: number;
skipped: number;
stillMissing: number;
}
interface AuditEvent {
type: string;
message?: string;
current?: number;
total?: number;
section?: string;
issues?: AuditIssue[];
missingFromSources?: MissingFromSource[];
unrepairable?: UnrepairableItem[];
summary?: AuditSummary;
repair?: RepairStats;
repairNitro?: RepairStats;
sql?: string;
sqlCount?: number;
applied?: number;
sqlFailed?: number;
furniData?: {
added: number;
removedDuplicates: number;
removedIdConflicts: number;
};
structure?: {
orphanedRemoved: number;
orphanedCleaned: number;
duplicatesMerged: number;
duplicateRowsRemoved: number;
remappedReferences: number;
};
}
const TYPE_LABELS: Record<string, string> = {
missing_catalog: "No catalog entry",
orphaned_catalog: "Orphaned catalog reference",
missing_nitro: "Missing .nitro file",
missing_icon: "Missing icon file",
duplicate_classname: "Duplicate classname",
source_fetch_failed: "Source fetch failed",
};
function AuditCheckbox({
checked,
disabled,
onChange,
children,
}: {
checked: boolean;
disabled?: boolean;
onChange: (value: boolean) => void;
children: React.ReactNode;
}) {
return (
// biome-ignore lint/a11y/useSemanticElements: custom checkbox so the checked state shows a visible checkmark on every admin theme
<button
type="button"
role="checkbox"
aria-checked={checked}
disabled={disabled}
onClick={() => onChange(!checked)}
className={`flex items-center gap-2 text-sm text-muted-foreground cursor-pointer select-none ${
disabled ? "opacity-50 cursor-not-allowed" : ""
}`}
>
<span
aria-hidden
className={`inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-[3px] border-2 transition-colors ${
checked
? "border-[var(--admin-accent)] bg-[var(--admin-accent)]"
: "border-[var(--admin-border)] bg-transparent"
}`}
>
{checked && <Check className="h-3 w-3 text-[var(--admin-surface)]" />}
</span>
<span className="pointer-events-none">{children}</span>
</button>
);
}
export function AuditClient() {
const [loading, setLoading] = useState(false);
const [progress, setProgress] = useState("");
const [activeTab, setActiveTab] = useState<
"issues" | "sources" | "unrepairable"
>("issues");
const [repairEnabled, setRepairEnabled] = useState(true);
const [repairNitros, setRepairNitros] = useState(true);
const [generateSql, setGenerateSql] = useState(false);
const [applySql, setApplySql] = useState(false);
const [repairFurniData, setRepairFurniData] = useState(false);
const [repairStructure, setRepairStructure] = useState(false);
const [checkFurniDataIds, setCheckFurniDataIds] = useState(false);
const [issues, setIssues] = useState<AuditIssue[]>([]);
const [missingFromSources, setMissingFromSources] = useState<
MissingFromSource[]
>([]);
const [unrepairable, setUnrepairable] = useState<UnrepairableItem[]>([]);
const [summary, setSummary] = useState<AuditSummary | null>(null);
const [repairStats, setRepairStats] = useState<RepairStats | null>(null);
const [nitroRepairStats, setNitroRepairStats] = useState<RepairStats | null>(
null,
);
const [sqlText, setSqlText] = useState<string | null>(null);
const [sqlApplied, setSqlApplied] = useState<{
applied: number;
failed: number;
} | null>(null);
const [furniDataResult, setFurniDataResult] = useState<{
added: number;
removedDuplicates: number;
removedIdConflicts: number;
} | null>(null);
const [structureResult, setStructureResult] = useState<{
orphanedRemoved: number;
orphanedCleaned: number;
duplicatesMerged: number;
duplicateRowsRemoved: number;
remappedReferences: number;
} | null>(null);
const [checkProgress, setCheckProgress] = useState<{
section: string;
current: number;
total: number;
} | null>(null);
const abortRef = useRef<AbortController | null>(null);
const runAudit = useCallback(async () => {
setLoading(true);
setProgress("Starting audit…");
setIssues([]);
setMissingFromSources([]);
setUnrepairable([]);
setSummary(null);
setRepairStats(null);
setNitroRepairStats(null);
setSqlText(null);
setSqlApplied(null);
setFurniDataResult(null);
setStructureResult(null);
setCheckProgress(null);
setActiveTab("issues");
const abort = new AbortController();
abortRef.current = abort;
try {
const res = await adminFetch("/api/admin/import/audit", {
method: "POST",
signal: abort.signal,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
repair: repairEnabled,
repairNitros,
sql: generateSql || applySql,
applySql,
repairFurniData,
repairStructure,
checkFurniDataIds,
}),
});
if (!res.body) {
toast.error("No response stream");
setLoading(false);
return;
}
if (!res.headers.get("content-type")?.includes("text/event-stream")) {
let errMsg = "Audit failed";
try {
const json = await res.json();
errMsg = json.error || errMsg;
} catch {
// ignore
}
toast.error(errMsg);
setLoading(false);
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const parts = buf.split("\n\n");
buf = parts.pop() ?? "";
for (const part of parts) {
if (!part.startsWith("data: ")) continue;
try {
const evt = JSON.parse(part.slice(6)) as AuditEvent;
flushSync(() => {
if (evt.type === "progress") {
setProgress(evt.message ?? "");
}
if (evt.type === "items_loaded") {
setProgress(`Loaded ${evt.total} items_base entries`);
}
if (evt.type === "catalog_loaded") {
setProgress(`Loaded ${evt.total} catalog_items entries`);
}
if (evt.type === "checking") {
setProgress(`Checking ${evt.section}…`);
if (evt.total) {
setCheckProgress({
section: evt.section ?? "",
current: evt.current ?? 0,
total: evt.total,
});
}
}
if (evt.type === "repair_complete") {
if (evt.repair) setRepairStats(evt.repair);
if (evt.repairNitro) setNitroRepairStats(evt.repairNitro);
}
if (evt.type === "sql_ready") {
setSqlText(evt.sql ?? null);
}
if (evt.type === "sql_applied") {
setSqlApplied({
applied: evt.applied ?? 0,
failed: evt.sqlFailed ?? 0,
});
}
if (evt.type === "furnidata_repair_complete") {
if (evt.furniData) setFurniDataResult(evt.furniData);
}
if (evt.type === "structure_repair_complete") {
if (evt.structure) setStructureResult(evt.structure);
}
if (evt.type === "batch_complete") {
setIssues(evt.issues ?? []);
setMissingFromSources(evt.missingFromSources ?? []);
setUnrepairable(evt.unrepairable ?? []);
if (evt.summary) setSummary(evt.summary);
setCheckProgress(null);
setProgress("Done");
const errCount =
evt.issues?.filter((i) => i.severity === "error").length ?? 0;
const warnCount =
evt.issues?.filter((i) => i.severity === "warning").length ??
0;
const legacyCount = evt.unrepairable?.length ?? 0;
if (errCount > 0) {
toast.error(`${errCount} error(s) found`);
} else if (warnCount > 0) {
toast.warning("Warnings found");
} else if (legacyCount > 0) {
toast.info(`${legacyCount} legacy item(s) can't be repaired`);
} else {
toast.success("No issues found");
}
}
if (evt.type === "error") {
setProgress(`ERROR: ${evt.message ?? ""}`);
toast.error(evt.message ?? "Audit failed");
}
});
} catch {
// skip parse errors
}
}
}
} catch (err) {
if ((err as Error).name !== "AbortError") {
toast.error("Audit request failed");
} else {
toast.info("Audit cancelled");
}
} finally {
setLoading(false);
abortRef.current = null;
}
}, [
repairEnabled,
repairNitros,
generateSql,
applySql,
repairFurniData,
repairStructure,
checkFurniDataIds,
]);
const errors = issues.filter((i) => i.severity === "error");
const warnings = issues.filter((i) => i.severity === "warning");
const downloadSql = () => {
if (!sqlText) return;
const blob = new Blob([sqlText], { type: "text/sql" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `catalog-repair-${new Date().toISOString().slice(0, 10)}.sql`;
a.click();
URL.revokeObjectURL(url);
};
return (
<div className="space-y-6">
{!summary && (
<div className="flex items-center gap-2 flex-wrap">
<Button onClick={runAudit} disabled={loading} size="lg">
{loading ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
) : (
<ClipboardList className="w-4 h-4 mr-2" />
)}
{loading ? progress || "Running…" : "Run Full Catalog Audit"}
</Button>
<AuditCheckbox
checked={repairEnabled}
disabled={loading}
onChange={setRepairEnabled}
>
Repair missing icons
</AuditCheckbox>
<AuditCheckbox
checked={repairNitros}
disabled={loading}
onChange={setRepairNitros}
>
Repair missing .nitro
</AuditCheckbox>
<AuditCheckbox
checked={generateSql}
disabled={loading}
onChange={setGenerateSql}
>
Generate catalog SQL
</AuditCheckbox>
<AuditCheckbox
checked={applySql}
disabled={loading}
onChange={setApplySql}
>
Apply SQL to database
</AuditCheckbox>
<AuditCheckbox
checked={repairFurniData}
disabled={loading}
onChange={setRepairFurniData}
>
Repair FurnitureData.json (add missing + dedupe)
</AuditCheckbox>
<AuditCheckbox
checked={checkFurniDataIds}
disabled={loading}
onChange={setCheckFurniDataIds}
>
Check FurnitureData.json for spriteId conflicts
</AuditCheckbox>
<AuditCheckbox
checked={repairStructure}
disabled={loading}
onChange={setRepairStructure}
>
Repair structural issues (orphaned refs + duplicates)
</AuditCheckbox>
{loading && (
<Button
variant="outline"
size="lg"
onClick={() => abortRef.current?.abort()}
>
Cancel
</Button>
)}
</div>
)}
{loading && checkProgress && (
<div className="text-sm text-muted-foreground">
{checkProgress.section}: {checkProgress.current} /{" "}
{checkProgress.total}
</div>
)}
{summary && (
<>
{/* Summary cards */}
<div className="grid gap-3 grid-cols-2 sm:grid-cols-4">
<div className="rounded-xl border bg-card p-3 sm:p-4 space-y-1">
<div className="text-xl sm:text-2xl font-bold">
{summary.totalItemsBase}
</div>
<div className="text-xs text-muted-foreground">
items_base entries
</div>
</div>
<div className="rounded-xl border bg-card p-3 sm:p-4 space-y-1">
<div className="text-xl sm:text-2xl font-bold">
{summary.totalCatalogItems}
</div>
<div className="text-xs text-muted-foreground">
catalog_items entries
</div>
</div>
<div
className={`rounded-xl border p-3 sm:p-4 space-y-1 ${errors.length > 0 ? "border-[var(--admin-error-border)] bg-[var(--admin-error-subtle)]" : "bg-card"}`}
>
<div
className={`text-xl sm:text-2xl font-bold ${errors.length > 0 ? "text-[var(--admin-error)]" : ""}`}
>
{errors.length}
</div>
<div className="text-xs text-muted-foreground">Errors</div>
</div>
<div
className={`rounded-xl border p-3 sm:p-4 space-y-1 ${warnings.length > 0 ? "border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)]" : "bg-card"}`}
>
<div
className={`text-xl sm:text-2xl font-bold ${warnings.length > 0 ? "text-[var(--admin-warning)]" : ""}`}
>
{warnings.length}
</div>
<div className="text-xs text-muted-foreground">Warnings</div>
</div>
</div>
{/* Quick stats */}
<div className="grid gap-2 sm:grid-cols-3 text-sm">
<div className="flex items-center gap-2">
{summary.missingCatalog > 0 ? (
<AlertCircle className="w-4 h-4 text-[var(--admin-warning)]" />
) : (
<CheckCircle2 className="w-4 h-4 text-[var(--admin-success)]" />
)}
<span>{summary.missingCatalog} items without catalog entry</span>
</div>
<div className="flex items-center gap-2">
{summary.orphanedCatalog > 0 ? (
<AlertCircle className="w-4 h-4 text-[var(--admin-error)]" />
) : (
<CheckCircle2 className="w-4 h-4 text-[var(--admin-success)]" />
)}
<span>{summary.orphanedCatalog} orphaned catalog references</span>
</div>
<div className="flex items-center gap-2">
{summary.missingNitro > 0 ? (
<AlertCircle className="w-4 h-4 text-[var(--admin-error)]" />
) : (
<CheckCircle2 className="w-4 h-4 text-[var(--admin-success)]" />
)}
<span>{summary.missingNitro} items without .nitro</span>
</div>
<div className="flex items-center gap-2">
{summary.missingIcon > 0 ? (
<AlertCircle className="w-4 h-4 text-[var(--admin-warning)]" />
) : (
<CheckCircle2 className="w-4 h-4 text-[var(--admin-success)]" />
)}
<span>{summary.missingIcon} items without icon</span>
</div>
<div className="flex items-center gap-2">
{summary.duplicates > 0 ? (
<AlertCircle className="w-4 h-4 text-[var(--admin-error)]" />
) : (
<CheckCircle2 className="w-4 h-4 text-[var(--admin-success)]" />
)}
<span>{summary.duplicates} duplicate classnames</span>
</div>
<div className="flex items-center gap-2">
{summary.missingFromSourcesTotal > 0 ? (
<AlertCircle className="w-4 h-4 text-[var(--admin-warning)]" />
) : (
<CheckCircle2 className="w-4 h-4 text-[var(--admin-success)]" />
)}
<span>
{summary.missingFromSourcesTotal} items missing from clone
sources
</span>
</div>
<div className="flex items-center gap-2">
{summary.unrepairableCount > 0 ? (
<AlertCircle className="w-4 h-4 text-[var(--admin-text-muted)]" />
) : (
<CheckCircle2 className="w-4 h-4 text-[var(--admin-success)]" />
)}
<span>
{summary.unrepairableCount} item(s) can't be repaired
(legacy/broken)
</span>
</div>
{repairStats && (
<div className="flex items-center gap-2 col-span-full">
<span className="text-xs text-muted-foreground">
Icon repair:{" "}
<span className="text-[var(--admin-success)] font-medium">
{repairStats.succeeded} fixed
</span>
{repairStats.failed > 0 && (
<>
{" · "}
<span className="text-[var(--admin-error)] font-medium">
{repairStats.failed} failed
</span>
</>
)}
{repairStats.skipped > 0 && (
<>
{" · "}
<span className="text-[var(--admin-text-muted)]">
{repairStats.skipped} skipped
</span>
</>
)}
{repairStats.stillMissing > 0 && (
<>
{" · "}
<span className="text-[var(--admin-warning)] font-medium">
{repairStats.stillMissing} still missing
</span>
</>
)}
</span>
</div>
)}
{nitroRepairStats && (
<div className="flex items-center gap-2 col-span-full">
<span className="text-xs text-muted-foreground">
Nitro repair:{" "}
<span className="text-[var(--admin-success)] font-medium">
{nitroRepairStats.succeeded} fixed
</span>
{nitroRepairStats.failed > 0 && (
<>
{" · "}
<span className="text-[var(--admin-error)] font-medium">
{nitroRepairStats.failed} failed
</span>
</>
)}
{nitroRepairStats.skipped > 0 && (
<>
{" · "}
<span className="text-[var(--admin-text-muted)]">
{nitroRepairStats.skipped} skipped
</span>
</>
)}
{nitroRepairStats.stillMissing > 0 && (
<>
{" · "}
<span className="text-[var(--admin-warning)] font-medium">
{nitroRepairStats.stillMissing} still missing
</span>
</>
)}
</span>
</div>
)}
{sqlText && (
<div className="flex items-center gap-2 col-span-full">
<span className="text-xs text-muted-foreground">
Catalog SQL:{" "}
<span className="font-medium">
{summary.catalogSqlCount} statement(s) generated
</span>
{sqlApplied && (
<>
{" · "}
<span className="text-[var(--admin-success)] font-medium">
{sqlApplied.applied} applied
</span>
{sqlApplied.failed > 0 && (
<>
{" · "}
<span className="text-[var(--admin-error)] font-medium">
{sqlApplied.failed} failed
</span>
</>
)}
</>
)}
</span>
<Button variant="outline" size="sm" onClick={downloadSql}>
Download .sql
</Button>
</div>
)}
{furniDataResult && (
<div className="flex items-center gap-2 col-span-full">
<span className="text-xs text-muted-foreground">
FurnitureData repair:{" "}
<span className="text-[var(--admin-success)] font-medium">
{furniDataResult.added} added
</span>
{furniDataResult.removedDuplicates > 0 && (
<>
{" · "}
<span className="text-[var(--admin-warning)] font-medium">
{furniDataResult.removedDuplicates} duplicates removed
</span>
</>
)}
{furniDataResult.removedIdConflicts > 0 && (
<>
{" · "}
<span className="text-[var(--admin-warning)] font-medium">
{furniDataResult.removedIdConflicts} spriteId conflicts
fixed
</span>
</>
)}
</span>
</div>
)}
{structureResult && (
<div className="flex items-center gap-2 col-span-full">
<span className="text-xs text-muted-foreground">
Structural repair:{" "}
{structureResult.orphanedRemoved > 0 && (
<>
<span className="text-[var(--admin-success)] font-medium">
{structureResult.orphanedRemoved} orphaned rows removed
</span>
{" · "}
</>
)}
{structureResult.orphanedCleaned > 0 && (
<>
<span className="font-medium">
{structureResult.orphanedCleaned} cleaned
</span>
{" · "}
</>
)}
{structureResult.duplicatesMerged > 0 && (
<>
<span className="text-[var(--admin-success)] font-medium">
{structureResult.duplicatesMerged} classname(s) merged
</span>
{" · "}
</>
)}
{structureResult.duplicateRowsRemoved > 0 && (
<>
<span className="text-[var(--admin-warning)] font-medium">
{structureResult.duplicateRowsRemoved} duplicate rows
removed
</span>
{" · "}
</>
)}
<span className="text-[var(--admin-text-muted)]">
{structureResult.remappedReferences} reference(s) remapped
</span>
</span>
</div>
)}
</div>
{/* Tabs */}
{issues.length > 0 ||
missingFromSources.length > 0 ||
unrepairable.length > 0 ? (
<div className="border-b border-border">
<div className="flex gap-4">
<button
type="button"
onClick={() => setActiveTab("issues")}
className={`pb-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === "issues"
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
Issues ({issues.length})
</button>
<button
type="button"
onClick={() => setActiveTab("sources")}
className={`pb-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === "sources"
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
Missing from sources ({missingFromSources.length})
</button>
<button
type="button"
onClick={() => setActiveTab("unrepairable")}
className={`pb-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === "unrepairable"
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
Unrepairable (legacy) ({unrepairable.length})
</button>
</div>
</div>
) : null}
{/* Issues list */}
{activeTab === "issues" && issues.length > 0 && (
<div className="space-y-2">
{issues.map((issue) => (
<div
key={issue.message}
className={`rounded-xl border p-4 text-sm ${
issue.severity === "error"
? "border-[var(--admin-error-border)] bg-[var(--admin-error-subtle)]"
: issue.severity === "warning"
? "border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)]"
: "bg-card"
}`}
>
<div className="flex items-start gap-2">
<AlertCircle
className={`w-4 h-4 mt-0.5 shrink-0 ${
issue.severity === "error"
? "text-[var(--admin-error)]"
: issue.severity === "warning"
? "text-[var(--admin-warning)]"
: "text-[var(--admin-text-muted)]"
}`}
/>
<div className="space-y-0.5 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span
className={`font-medium ${
issue.severity === "error"
? "text-[var(--admin-error)]"
: issue.severity === "warning"
? "text-[var(--admin-warning)]"
: "text-[var(--admin-text-muted)]"
}`}
>
{TYPE_LABELS[issue.type] || issue.type}
</span>
{issue.classname && (
<code className="text-xs bg-background/50 px-1.5 py-0.5 rounded font-mono break-all max-w-[200px]">
{issue.classname}
</code>
)}
{issue.sourceName && (
<span className="text-xs text-muted-foreground">
source: {issue.sourceName}
</span>
)}
</div>
<p className="text-muted-foreground">{issue.message}</p>
</div>
</div>
</div>
))}
</div>
)}
{/* Missing from sources list */}
{activeTab === "sources" && missingFromSources.length > 0 && (
<div className="space-y-2">
{missingFromSources.map((item) => (
<div
key={`${item.sourceId}-${item.classname}`}
className="rounded-xl border bg-card p-3 text-sm flex items-center gap-2"
>
<ExternalLink className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
<code className="font-mono text-xs">{item.classname}</code>
<span className="text-xs text-muted-foreground">
— {item.itemName}
</span>
<span className="text-xs text-muted-foreground ml-auto">
in {item.sourceName}
</span>
</div>
))}
</div>
)}
{/* Unrepairable (legacy) list */}
{activeTab === "unrepairable" && unrepairable.length > 0 && (
<div className="space-y-2">
{unrepairable.map((item) => (
<div
key={item.classname}
className="rounded-xl border bg-card p-3 text-sm flex items-center gap-2"
>
<AlertCircle className="w-3.5 h-3.5 text-[var(--admin-text-muted)] shrink-0" />
<code className="font-mono text-xs">{item.classname}</code>
<span className="text-xs text-muted-foreground">
(ID {item.itemId})
</span>
<span className="text-xs text-[var(--admin-warning)] ml-auto">
missing {item.missing.join(" + ")}
</span>
</div>
))}
</div>
)}
{activeTab === "issues" &&
issues.length === 0 &&
missingFromSources.length === 0 && (
<div className="rounded-xl border bg-card p-6 sm:p-12 text-center text-muted-foreground">
<CheckCircle2 className="w-10 h-10 sm:w-12 sm:h-12 mx-auto mb-3 text-[var(--admin-success)]" />
<p className="text-base sm:text-lg font-medium">
No issues found
</p>
<p className="text-sm">Your catalog is clean!</p>
</div>
)}
<Button
variant="outline"
onClick={() => {
setSummary(null);
setIssues([]);
setMissingFromSources([]);
setUnrepairable([]);
setRepairStats(null);
setNitroRepairStats(null);
setSqlText(null);
setSqlApplied(null);
setFurniDataResult(null);
setStructureResult(null);
setProgress("");
setCheckProgress(null);
}}
>
Run Again
</Button>
</>
)}
{!loading && !summary && (
<div className="rounded-xl border bg-card p-6 sm:p-12 text-center text-muted-foreground">
<SearchX className="w-10 h-10 sm:w-12 sm:h-12 mx-auto mb-3 opacity-40" />
<p>
Run a full audit to check for missing catalog entries, broken
references, missing files, and items available from clone sources.
</p>
</div>
)}
</div>
);
}
@@ -0,0 +1,119 @@
"use client";
import { Loader2 } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { getBadgeData, updateBadge } from "@/actions/badges";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useServerAction } from "@/hooks/use-server-action";
interface BadgeEditorDialogProps {
code: string;
open: boolean;
onOpenChange: (open: boolean) => void;
/** Called after a successful save so the parent can refresh state */
onSaved?: () => void;
}
export function BadgeEditorDialog({
code,
open,
onOpenChange,
onSaved,
}: BadgeEditorDialogProps) {
const [name, setName] = useState("");
const [desc, setDesc] = useState("");
const [loading, setLoading] = useState(false);
const { run, isPending: saving } = useServerAction();
// Fetch current values when dialog opens
useEffect(() => {
if (!open || !code) return;
setLoading(true);
getBadgeData({ code })
.then((result) => {
if (result.ok) {
const data = result.data as { name: string; desc: string };
setName(data.name ?? "");
setDesc(data.desc ?? "");
}
})
.catch(() => {
toast.error("Failed to load badge data");
})
.finally(() => setLoading(false));
}, [open, code]);
function handleSave() {
run(() => updateBadge({ code, name, desc }), {
successMessage: `Badge ${code} updated`,
errorMessage: "Save failed",
onSuccess: () => {
onSaved?.();
onOpenChange(false);
},
});
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
Edit Badge — {code}
</DialogTitle>
</DialogHeader>
{loading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="badge-name">Name</Label>
<Input
id="badge-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Badge name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="badge-desc">Description</Label>
<Input
id="badge-desc"
value={desc}
onChange={(e) => setDesc(e.target.value)}
placeholder="Badge description"
/>
</div>
</div>
)}
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={saving}
>
Cancel
</Button>
<Button onClick={handleSave} disabled={loading || saving}>
{saving && <Loader2 className="w-4 h-4 animate-spin mr-1" />}
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,555 @@
"use client";
import {
AlertCircle,
BadgeCheck,
Check,
CheckCircle2,
Download,
Globe,
Loader2,
Package,
Pencil,
Search,
X,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { adminFetch } from "@/lib/admin-fetch";
import { cn } from "@/lib/utils";
import { BadgeEditorDialog } from "./badge-editor-dialog";
interface BadgeItem {
id: number;
code: string;
name: string;
description: string;
hotel: string;
url_habbo: string;
url_habboassets: string;
alreadyImported: boolean;
}
const PAGE_SIZE = 100;
export function ImportBadgesClient() {
const [searchTerm, setSearchTerm] = useState("");
const [activeSearch, setActiveSearch] = useState("");
const [badges, setBadges] = useState<BadgeItem[]>([]);
const [offset, setOffset] = useState(0);
const [hasMore, setHasMore] = useState(false);
const [loading, setLoading] = useState(false);
const [importingCode, setImportingCode] = useState<string | null>(null);
const [error, setError] = useState("");
const [allHotels, setAllHotels] = useState(true);
const [editingCode, setEditingCode] = useState<string | null>(null);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [batchImporting, setBatchImporting] = useState(false);
const searchRef = useRef<HTMLInputElement>(null);
const fetchBadges = useCallback(
async (search: string, off: number, all: boolean) => {
setLoading(true);
setError("");
try {
const params = new URLSearchParams({ offset: String(off) });
if (search) params.set("search", search);
if (all) params.set("allHotels", "1");
const res = await fetch(`/api/admin/import/badges?${params}`);
const data = await res.json();
if (!res.ok) {
setError(data.error || "Failed to load");
return;
}
const list = data.badges || [];
setBadges(list);
setHasMore(list.length >= PAGE_SIZE);
setSelected(new Set());
} catch {
setError("Network error");
} finally {
setLoading(false);
}
},
[],
);
// Auto-load on mount and when allHotels changes
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
useEffect(() => {
setOffset(0);
fetchBadges(activeSearch, 0, allHotels);
}, [fetchBadges, allHotels]);
// "/" keyboard shortcut to focus search
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if (e.key === "/" && !e.metaKey && !e.ctrlKey && !e.altKey) {
const tag = (e.target as HTMLElement)?.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
e.preventDefault();
searchRef.current?.focus();
}
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, []);
function doSearch() {
setActiveSearch(searchTerm);
setOffset(0);
fetchBadges(searchTerm, 0, allHotels);
}
function goPage(direction: "prev" | "next") {
const newOffset =
direction === "next"
? offset + PAGE_SIZE
: Math.max(0, offset - PAGE_SIZE);
setOffset(newOffset);
fetchBadges(activeSearch, newOffset, allHotels);
}
async function postImportBadge(badge: BadgeItem) {
const res = await adminFetch("/api/admin/import/badges", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
code: badge.code,
name: badge.name,
description: badge.description,
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
return {
ok: false as const,
error:
(data as { error?: string }).error ||
`Failed to import ${badge.code}`,
};
}
return { ok: true as const };
}
async function doImport(badge: BadgeItem) {
setImportingCode(badge.code);
try {
const result = await postImportBadge(badge);
if (result.ok) {
toast.success(`${badge.code} imported`);
setBadges((prev) =>
prev.map((b) =>
b.code === badge.code ? { ...b, alreadyImported: true } : b,
),
);
setSelected((prev) => {
const n = new Set(prev);
n.delete(badge.code);
return n;
});
} else {
toast.error(result.error);
}
} catch {
toast.error(`Network error importing ${badge.code}`);
} finally {
setImportingCode(null);
}
}
async function doBatchImport() {
const toImport = badges.filter(
(b) => selected.has(b.code) && !b.alreadyImported,
);
if (toImport.length === 0) return;
setBatchImporting(true);
let ok = 0,
fail = 0;
for (const badge of toImport) {
try {
const result = await postImportBadge(badge);
if (result.ok) {
ok++;
setBadges((prev) =>
prev.map((b) =>
b.code === badge.code ? { ...b, alreadyImported: true } : b,
),
);
} else {
fail++;
}
} catch {
fail++;
}
}
setBatchImporting(false);
setSelected(new Set());
if (ok > 0) toast.success(`${ok} badge(s) imported`);
if (fail > 0) toast.error(`${fail} failed`);
}
function toggleSelect(code: string) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(code)) next.delete(code);
else next.add(code);
return next;
});
}
const importableBadges = useMemo(
() => badges.filter((b) => !b.alreadyImported),
[badges],
);
function toggleSelectAll() {
if (selected.size === importableBadges.length) {
setSelected(new Set());
} else {
setSelected(new Set(importableBadges.map((b) => b.code)));
}
}
// Stats
const importedCount = useMemo(
() => badges.filter((b) => b.alreadyImported).length,
[badges],
);
const currentPage = Math.floor(offset / PAGE_SIZE) + 1;
return (
<TooltipProvider delayDuration={300}>
<div className="space-y-4">
{/* Stats bar */}
{badges.length > 0 && (
<div className="flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-1.5 text-sm">
<Package className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{badges.length}</span>
<span className="text-muted-foreground">results</span>
</div>
<div className="flex items-center gap-1.5 text-sm">
<CheckCircle2 className="h-4 w-4 text-[var(--admin-success)]" />
<span className="font-medium text-[var(--admin-success)]">
{importedCount}
</span>
<span className="text-muted-foreground">imported</span>
</div>
{importableBadges.length > 0 && (
<div className="flex items-center gap-1.5 text-sm">
<Download className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{importableBadges.length}</span>
<span className="text-muted-foreground">available</span>
</div>
)}
</div>
)}
{/* Search bar */}
<div className="flex gap-2">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
ref={searchRef}
placeholder="Search badges by name... /"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") doSearch();
if (e.key === "Escape") {
setSearchTerm("");
searchRef.current?.blur();
}
}}
className="pl-9 pr-8"
/>
{searchTerm && (
<button
type="button"
onClick={() => {
setSearchTerm("");
setActiveSearch("");
fetchBadges("", 0, allHotels);
}}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
<Button onClick={doSearch} size="sm" disabled={loading}>
{loading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Search className="w-4 h-4" />
)}
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={allHotels ? "default" : "outline"}
size="sm"
onClick={() => setAllHotels((v) => !v)}
>
<Globe className="w-4 h-4 mr-1" />
{allHotels ? "All" : "IT"}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>
{allHotels
? "Showing all hotels — click for Italian only"
: "Showing Italian only — click for all hotels"}
</p>
</TooltipContent>
</Tooltip>
</div>
{/* Bulk actions */}
{importableBadges.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
<Button
variant="outline"
size="sm"
onClick={toggleSelectAll}
className="text-xs h-8"
disabled={batchImporting}
>
{selected.size === importableBadges.length
? "Deselect All"
: `Select All (${importableBadges.length})`}
</Button>
{selected.size > 0 && (
<Button
size="sm"
className="h-8"
onClick={doBatchImport}
disabled={batchImporting}
>
{batchImporting ? (
<Loader2 className="w-4 h-4 animate-spin mr-1" />
) : (
<Download className="w-4 h-4 mr-1" />
)}
Import {selected.size} selected
</Button>
)}
</div>
)}
{/* Error */}
{error && (
<div className="rounded-lg border px-4 py-3 text-sm bg-destructive/10 border-destructive/20 text-destructive flex items-center gap-2">
<AlertCircle className="w-4 h-4 shrink-0" />
{error}
</div>
)}
{/* Loading skeleton */}
{loading && badges.length === 0 && (
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
{Array.from({ length: 18 }, (_, i) => i).map((n) => (
<div
key={`skeleton-badge-${n}`}
className="rounded-lg border bg-card p-3 animate-pulse"
>
<div className="h-12 w-12 bg-muted rounded mx-auto mb-2" />
<div className="h-4 bg-muted rounded w-3/4 mx-auto mb-1" />
<div className="h-3 bg-muted rounded w-1/2 mx-auto mb-2" />
<div className="h-7 bg-muted rounded" />
</div>
))}
</div>
)}
{/* Empty state */}
{!loading && badges.length === 0 && !error && (
<div className="text-center py-12 text-muted-foreground">
<BadgeCheck className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p className="text-sm">
No badges found{activeSearch ? ` for "${activeSearch}"` : ""}
</p>
{!activeSearch && (
<p className="text-xs mt-1">
Try searching by badge name or code
</p>
)}
</div>
)}
{/* Badge grid */}
{badges.length > 0 && (
<div
className={cn(
"grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3",
loading && "opacity-50",
)}
>
{badges.map((badge) => {
const isSelected = selected.has(badge.code);
return (
<div
key={badge.code}
className={cn(
"rounded-lg border overflow-hidden bg-card group relative hover:border-border transition-all",
badge.alreadyImported &&
"ring-1 ring-[var(--admin-success)]/30",
isSelected && "ring-2 ring-primary border-primary",
)}
>
{/* Checkbox for non-imported */}
{!badge.alreadyImported && (
<button
type="button"
onClick={() => toggleSelect(badge.code)}
className={cn(
"absolute top-1.5 left-1.5 z-10 w-5 h-5 rounded border-2 flex items-center justify-center transition-colors",
isSelected
? "bg-primary border-primary text-primary-foreground"
: "bg-background/80 border-muted-foreground/30 hover:border-muted-foreground",
)}
>
{isSelected && <Check className="w-3 h-3" />}
</button>
)}
{/* Badge image area */}
<div className="flex items-center justify-center h-20 bg-accent/20">
<img
src={badge.url_habbo}
alt={badge.code}
className="image-pixelated drop-shadow-sm"
style={{ maxHeight: 48, maxWidth: 48 }}
onError={(e) => {
const img = e.target as HTMLImageElement;
if (!img.dataset.fallback) {
img.dataset.fallback = "1";
img.src = badge.url_habboassets;
} else {
img.style.opacity = "0.3";
}
}}
/>
</div>
{/* Info */}
<div className="px-2.5 py-2 border-t space-y-1">
<div className="text-xs font-bold truncate">
{badge.code}
</div>
{badge.name && (
<Tooltip>
<TooltipTrigger asChild>
<div className="text-[11px] text-muted-foreground truncate cursor-default">
{badge.name}
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-64">
<p className="font-medium">{badge.name}</p>
{badge.description && (
<p className="text-muted-foreground text-xs mt-1">
{badge.description}
</p>
)}
</TooltipContent>
</Tooltip>
)}
{allHotels && badge.hotel && (
<Badge variant="outline" className="text-[9px] px-1 h-4">
{badge.hotel.toUpperCase()}
</Badge>
)}
{/* Action */}
<div className="pt-1">
{badge.alreadyImported ? (
<div className="flex items-center gap-1">
<Badge className="text-[10px] px-1.5 py-0 bg-[var(--admin-success-subtle)] text-[var(--admin-success)] border-0 gap-0.5 flex-1 justify-center">
<Check className="w-2.5 h-2.5" />
Imported
</Badge>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 shrink-0"
title="Edit badge name/description"
onClick={() => setEditingCode(badge.code)}
>
<Pencil className="w-3 h-3" />
</Button>
</div>
) : (
<Button
size="sm"
variant="outline"
className="w-full text-xs h-7"
onClick={() => doImport(badge)}
disabled={
importingCode === badge.code || batchImporting
}
>
{importingCode === badge.code ? (
<Loader2 className="w-3 h-3 animate-spin mr-1" />
) : (
<Download className="w-3 h-3 mr-1" />
)}
Import
</Button>
)}
</div>
</div>
</div>
);
})}
</div>
)}
{/* Pagination */}
{(offset > 0 || hasMore) && (
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
Page {currentPage}
</span>
<div className="flex gap-1">
<Button
variant="outline"
size="sm"
disabled={offset <= 0 || loading}
onClick={() => goPage("prev")}
>
&larr; Prev
</Button>
<Button
variant="outline"
size="sm"
disabled={!hasMore || loading}
onClick={() => goPage("next")}
>
Next &rarr;
</Button>
</div>
</div>
)}
{/* Badge Editor Dialog */}
<BadgeEditorDialog
code={editingCode ?? ""}
open={!!editingCode}
onOpenChange={(open) => {
if (!open) setEditingCode(null);
}}
/>
</div>
</TooltipProvider>
);
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,914 @@
"use client";
import {
AlertCircle,
Check,
Download,
Loader2,
Package,
Search,
Shirt,
Trash2,
X,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { adminFetch } from "@/lib/admin-fetch";
import { getAvatarUrl } from "@/lib/imager";
import { runSseImport } from "@/lib/sse-client";
import { cn } from "@/lib/utils";
// ── Shared types ─────────────────────────────────────────────────────────────
interface FigureItem {
lib: string;
revision: number;
nitroExists: boolean;
previewType?: string;
previewId?: number;
}
interface ClothingSetItem {
setType: string;
setId: number;
gender: string;
club: number;
sellable: boolean;
present: boolean;
}
type Mode = "libs" | "sets";
const PER_PAGE = 100;
// ── Avatar preview (renders a mini avatar wearing the item) ───────────────────
// A default dressed avatar; the previewed part overrides/adds its slot so the
// item is shown on a complete figure.
const BASE_FIGURE: Record<string, string> = {
hd: "hd-180-1",
hr: "hr-828-45",
ch: "ch-665-92",
lg: "lg-700-82",
sh: "sh-705-62",
};
// Head-worn slots → render head-only (zoomed) so the item fills the frame;
// everything else renders the full body.
const HEAD_TYPES = new Set(["hd", "hr", "ha", "he", "ea", "fa", "fc"]);
function buildPreviewFigure(type: string, id: number): string {
const parts: Record<string, string> = { ...BASE_FIGURE };
parts[type] = `${type}-${id}`; // default color
return Object.values(parts).join(".");
}
function AvatarPreview({ type, id }: { type?: string; id?: number }) {
const [failed, setFailed] = useState(false);
if (failed || !type || id === undefined) {
return <Shirt className="w-8 h-8 text-muted-foreground/60" />;
}
const figure = buildPreviewFigure(type, id);
return (
<img
src={getAvatarUrl(figure, {
direction: 2,
headDirection: 3,
headOnly: HEAD_TYPES.has(type),
})}
alt={`${type}-${id}`}
className="max-h-full object-contain image-pixelated"
onError={() => setFailed(true)}
/>
);
}
// ── Librerie (v1) view ────────────────────────────────────────────────────────
function LibsView() {
const [searchTerm, setSearchTerm] = useState("");
const [activeSearch, setActiveSearch] = useState("");
const [items, setItems] = useState<FigureItem[]>([]);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [selected, setSelected] = useState<Set<string>>(new Set());
const [batchProgress, setBatchProgress] = useState<{
done: number;
total: number;
} | null>(null);
const [busyLib, setBusyLib] = useState<string | null>(null);
const batchAbortRef = useRef<AbortController | null>(null);
const fetchItems = useCallback(async (search: string, pageNum: number) => {
setLoading(true);
setError("");
try {
const params = new URLSearchParams({ page: String(pageNum) });
if (search) params.set("search", search);
const res = await adminFetch(`/api/admin/import/clothing?${params}`);
const data = await res.json();
if (!res.ok) {
setError(data.error || "Failed to load");
return;
}
setItems(data.items || []);
setTotal(data.meta?.total || 0);
setSelected(new Set());
} catch {
setError("Network error");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchItems("", 1);
}, [fetchItems]);
function doSearch() {
setActiveSearch(searchTerm);
setPage(1);
fetchItems(searchTerm, 1);
}
function goPage(dir: "prev" | "next") {
const next = dir === "next" ? page + 1 : Math.max(1, page - 1);
setPage(next);
fetchItems(activeSearch, next);
}
const importable = useMemo(
() => items.filter((p) => !p.nitroExists),
[items],
);
const importedCount = useMemo(
() => items.filter((p) => p.nitroExists).length,
[items],
);
const lastPage = Math.max(Math.ceil(total / PER_PAGE), 1);
function toggle(lib: string) {
setSelected((prev) => {
const n = new Set(prev);
if (n.has(lib)) n.delete(lib);
else n.add(lib);
return n;
});
}
function markDone(lib: string) {
setItems((prev) =>
prev.map((p) => (p.lib === lib ? { ...p, nitroExists: true } : p)),
);
}
async function importViaSse(toImport: FigureItem[]) {
batchAbortRef.current?.abort();
const abort = new AbortController();
batchAbortRef.current = abort;
setBatchProgress({ done: 0, total: toImport.length });
let done = 0;
try {
await runSseImport(
"/api/admin/import/clothing/batch",
{ items: toImport, concurrency: 3 },
(label) => {
done++;
markDone(label);
setBatchProgress({ done, total: toImport.length });
},
(succeeded, failed) => {
setSelected(new Set());
if (succeeded > 0) toast.success(`${succeeded} libraries imported`);
if (failed > 0) toast.error(`${failed} failed`);
},
abort.signal,
() => {
done++;
setBatchProgress({ done, total: toImport.length });
},
);
} catch (err) {
if ((err as Error)?.name === "AbortError") {
toast.info("Import cancelled");
} else {
throw err;
}
} finally {
if (batchAbortRef.current === abort) batchAbortRef.current = null;
setBatchProgress(null);
}
}
async function importOne(p: FigureItem) {
setBusyLib(p.lib);
try {
await importViaSse([p]);
} finally {
setBusyLib(null);
}
}
async function importSelected() {
const toImport = items.filter((p) => selected.has(p.lib) && !p.nitroExists);
if (toImport.length === 0) return;
await importViaSse(toImport);
}
async function remove(p: FigureItem) {
setBusyLib(p.lib);
try {
const res = await adminFetch(
`/api/admin/import/clothing?lib=${encodeURIComponent(p.lib)}`,
{
method: "DELETE",
},
);
const data = await res.json();
if (res.ok) {
toast.success(`${p.lib} removed`);
setItems((prev) =>
prev.map((x) => (x.lib === p.lib ? { ...x, nitroExists: false } : x)),
);
} else {
toast.error(data.error || "Delete failed");
}
} finally {
setBusyLib(null);
}
}
return (
<div className="space-y-4">
{(items.length > 0 || total > 0) && (
<div className="flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-1.5 text-sm">
<Package className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{total}</span>
<span className="text-muted-foreground">libraries</span>
</div>
<div className="flex items-center gap-1.5 text-sm">
<Check className="h-4 w-4 text-[var(--admin-success)]" />
<span className="font-medium text-[var(--admin-success)]">
{importedCount}
</span>
<span className="text-muted-foreground">on this page</span>
</div>
<div className="flex items-center gap-1.5 text-sm">
<Download className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{importable.length}</span>
<span className="text-muted-foreground">missing here</span>
</div>
</div>
)}
<div className="flex gap-2 flex-wrap">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search libraries (e.g. Hair_F, shirt_M)..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") doSearch();
if (e.key === "Escape") {
setSearchTerm("");
setActiveSearch("");
setPage(1);
fetchItems("", 1);
}
}}
className="pl-9 pr-8"
/>
{searchTerm && (
<button
type="button"
onClick={() => {
setSearchTerm("");
setActiveSearch("");
setPage(1);
fetchItems("", 1);
}}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
<Button onClick={doSearch} size="sm" disabled={loading}>
{loading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Search className="w-4 h-4" />
)}
</Button>
</div>
{importable.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
<Button
variant="outline"
size="sm"
className="text-xs h-8"
disabled={!!batchProgress}
onClick={() =>
setSelected((prev) =>
prev.size === importable.length
? new Set()
: new Set(importable.map((p) => p.lib)),
)
}
>
{selected.size === importable.length
? "Deselect page"
: `Select page (${importable.length})`}
</Button>
{selected.size > 0 && (
<Button
size="sm"
className="h-8"
onClick={importSelected}
disabled={!!batchProgress}
>
{batchProgress ? (
<Loader2 className="w-4 h-4 animate-spin mr-1" />
) : (
<Download className="w-4 h-4 mr-1" />
)}
Import {selected.size} selected
</Button>
)}
{batchProgress && (
<>
<span className="text-xs text-muted-foreground">
{batchProgress.done}/{batchProgress.total}
</span>
<Button
size="sm"
variant="outline"
className="h-8"
onClick={() => batchAbortRef.current?.abort()}
>
Cancel
</Button>
</>
)}
</div>
)}
{error && (
<div className="rounded-lg border px-4 py-3 text-sm bg-destructive/10 border-destructive/20 text-destructive flex items-center gap-2">
<AlertCircle className="w-4 h-4 shrink-0" />
{error}
</div>
)}
{!loading && items.length === 0 && !error && (
<div className="text-center py-12 text-muted-foreground">
<Shirt className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p className="text-sm">
No libraries found{activeSearch ? ` for "${activeSearch}"` : ""}
</p>
</div>
)}
{items.length > 0 && (
<div
className={cn(
"grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3",
loading && "opacity-50",
)}
>
{items.map((p) => {
const isSelected = selected.has(p.lib);
return (
<div
key={p.lib}
className={cn(
"rounded-lg border overflow-hidden bg-card relative hover:border-border transition-all",
p.nitroExists && "ring-1 ring-[var(--admin-success)]/30",
isSelected && "ring-2 ring-primary border-primary",
)}
>
{!p.nitroExists && (
<button
type="button"
onClick={() => toggle(p.lib)}
className={cn(
"absolute top-1.5 left-1.5 z-10 w-5 h-5 rounded border-2 flex items-center justify-center transition-colors",
isSelected
? "bg-primary border-primary text-primary-foreground"
: "bg-background/80 border-muted-foreground/30 hover:border-muted-foreground",
)}
>
{isSelected && <Check className="w-3 h-3" />}
</button>
)}
<div className="flex items-center justify-center h-28 bg-accent/20">
<AvatarPreview type={p.previewType} id={p.previewId} />
</div>
<div className="px-2.5 py-2 border-t space-y-1">
<div className="text-xs font-bold truncate" title={p.lib}>
{p.lib}
</div>
<div className="pt-1">
{p.nitroExists ? (
<div className="flex items-center gap-1">
<Badge className="text-[10px] px-1.5 py-0 bg-[var(--admin-success-subtle)] text-[var(--admin-success)] border-0 gap-0.5 flex-1 justify-center">
<Check className="w-2.5 h-2.5" />
Imported
</Badge>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 shrink-0"
title="Remove library"
disabled={busyLib === p.lib || !!batchProgress}
onClick={() => remove(p)}
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
) : (
<Button
size="sm"
variant="outline"
className="w-full text-xs h-7"
disabled={busyLib === p.lib || !!batchProgress}
onClick={() => importOne(p)}
>
{busyLib === p.lib ? (
<Loader2 className="w-3 h-3 animate-spin mr-1" />
) : (
<Download className="w-3 h-3 mr-1" />
)}
Import
</Button>
)}
</div>
</div>
</div>
);
})}
</div>
)}
{total > PER_PAGE && (
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
Page {page} / {lastPage}
</span>
<div className="flex gap-1">
<Button
variant="outline"
size="sm"
disabled={page <= 1 || loading}
onClick={() => goPage("prev")}
>
&larr; Prev
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= lastPage || loading}
onClick={() => goPage("next")}
>
Next &rarr;
</Button>
</div>
</div>
)}
</div>
);
}
// ── Vestiti / Sets (v2) view ──────────────────────────────────────────────────
function SetsView() {
const [searchTerm, setSearchTerm] = useState("");
const [activeSearch, setActiveSearch] = useState("");
const [items, setItems] = useState<ClothingSetItem[]>([]);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [selected, setSelected] = useState<Set<string>>(new Set());
const [batchProgress, setBatchProgress] = useState<{
done: number;
total: number;
} | null>(null);
const [busyKey, setBusyKey] = useState<string | null>(null);
const batchAbortRef = useRef<AbortController | null>(null);
function itemKey(it: ClothingSetItem): string {
return `${it.setType}-${it.setId}`;
}
const fetchItems = useCallback(async (search: string, pageNum: number) => {
setLoading(true);
setError("");
try {
const params = new URLSearchParams({ page: String(pageNum) });
if (search) params.set("search", search);
const res = await adminFetch(`/api/admin/import/clothing/sets?${params}`);
const data = await res.json();
if (!res.ok) {
setError(data.error || "Failed to load");
return;
}
setItems(data.items || []);
setTotal(data.meta?.total || 0);
setSelected(new Set());
} catch {
setError("Network error");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchItems("", 1);
}, [fetchItems]);
function doSearch() {
setActiveSearch(searchTerm);
setPage(1);
fetchItems(searchTerm, 1);
}
function goPage(dir: "prev" | "next") {
const next = dir === "next" ? page + 1 : Math.max(1, page - 1);
setPage(next);
fetchItems(activeSearch, next);
}
const importable = useMemo(() => items.filter((it) => !it.present), [items]);
const presentCount = useMemo(
() => items.filter((it) => it.present).length,
[items],
);
const lastPage = Math.max(Math.ceil(total / PER_PAGE), 1);
function toggle(key: string) {
setSelected((prev) => {
const n = new Set(prev);
if (n.has(key)) n.delete(key);
else n.add(key);
return n;
});
}
function markDone(label: string) {
setItems((prev) =>
prev.map((it) => (itemKey(it) === label ? { ...it, present: true } : it)),
);
}
async function importViaSse(toImport: ClothingSetItem[]) {
batchAbortRef.current?.abort();
const abort = new AbortController();
batchAbortRef.current = abort;
setBatchProgress({ done: 0, total: toImport.length });
let done = 0;
try {
await runSseImport(
"/api/admin/import/clothing/sets/batch",
{
items: toImport.map((it) => ({
setType: it.setType,
setId: it.setId,
})),
concurrency: 3,
},
(label) => {
done++;
markDone(label);
setBatchProgress({ done, total: toImport.length });
},
(succeeded, failed) => {
setSelected(new Set());
if (succeeded > 0) toast.success(`${succeeded} sets imported`);
if (failed > 0) toast.error(`${failed} failed`);
},
abort.signal,
() => {
done++;
setBatchProgress({ done, total: toImport.length });
},
);
} catch (err) {
if ((err as Error)?.name === "AbortError") {
toast.info("Import cancelled");
} else {
throw err;
}
} finally {
if (batchAbortRef.current === abort) batchAbortRef.current = null;
setBatchProgress(null);
}
}
async function importOne(it: ClothingSetItem) {
const key = itemKey(it);
setBusyKey(key);
try {
await importViaSse([it]);
} finally {
setBusyKey(null);
}
}
async function importSelected() {
const toImport = items.filter(
(it) => selected.has(itemKey(it)) && !it.present,
);
if (toImport.length === 0) return;
await importViaSse(toImport);
}
return (
<div className="space-y-4">
{(items.length > 0 || total > 0) && (
<div className="flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-1.5 text-sm">
<Package className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{total}</span>
<span className="text-muted-foreground">sets</span>
</div>
<div className="flex items-center gap-1.5 text-sm">
<Check className="h-4 w-4 text-[var(--admin-success)]" />
<span className="font-medium text-[var(--admin-success)]">
{presentCount}
</span>
<span className="text-muted-foreground">on this page</span>
</div>
<div className="flex items-center gap-1.5 text-sm">
<Download className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{importable.length}</span>
<span className="text-muted-foreground">missing here</span>
</div>
</div>
)}
<div className="flex gap-2 flex-wrap">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search by set id or type (e.g. hr, ch, 1027)..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") doSearch();
if (e.key === "Escape") {
setSearchTerm("");
setActiveSearch("");
setPage(1);
fetchItems("", 1);
}
}}
className="pl-9 pr-8"
/>
{searchTerm && (
<button
type="button"
onClick={() => {
setSearchTerm("");
setActiveSearch("");
setPage(1);
fetchItems("", 1);
}}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
<Button onClick={doSearch} size="sm" disabled={loading}>
{loading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Search className="w-4 h-4" />
)}
</Button>
</div>
{importable.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
<Button
variant="outline"
size="sm"
className="text-xs h-8"
disabled={!!batchProgress}
onClick={() =>
setSelected((prev) =>
prev.size === importable.length
? new Set()
: new Set(importable.map((it) => itemKey(it))),
)
}
>
{selected.size === importable.length
? "Deselect page"
: `Select page (${importable.length})`}
</Button>
{selected.size > 0 && (
<Button
size="sm"
className="h-8"
onClick={importSelected}
disabled={!!batchProgress}
>
{batchProgress ? (
<Loader2 className="w-4 h-4 animate-spin mr-1" />
) : (
<Download className="w-4 h-4 mr-1" />
)}
Import {selected.size} selected
</Button>
)}
{batchProgress && (
<>
<span className="text-xs text-muted-foreground">
{batchProgress.done}/{batchProgress.total}
</span>
<Button
size="sm"
variant="outline"
className="h-8"
onClick={() => batchAbortRef.current?.abort()}
>
Cancel
</Button>
</>
)}
</div>
)}
{error && (
<div className="rounded-lg border px-4 py-3 text-sm bg-destructive/10 border-destructive/20 text-destructive flex items-center gap-2">
<AlertCircle className="w-4 h-4 shrink-0" />
{error}
</div>
)}
{!loading && items.length === 0 && !error && (
<div className="text-center py-12 text-muted-foreground">
<Shirt className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p className="text-sm">
No sets found{activeSearch ? ` for "${activeSearch}"` : ""}
</p>
</div>
)}
{items.length > 0 && (
<div
className={cn(
"grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3",
loading && "opacity-50",
)}
>
{items.map((it) => {
const key = itemKey(it);
const isSelected = selected.has(key);
return (
<div
key={key}
className={cn(
"rounded-lg border overflow-hidden bg-card relative hover:border-border transition-all",
it.present && "ring-1 ring-[var(--admin-success)]/30",
isSelected && "ring-2 ring-primary border-primary",
)}
>
{!it.present && (
<button
type="button"
onClick={() => toggle(key)}
className={cn(
"absolute top-1.5 left-1.5 z-10 w-5 h-5 rounded border-2 flex items-center justify-center transition-colors",
isSelected
? "bg-primary border-primary text-primary-foreground"
: "bg-background/80 border-muted-foreground/30 hover:border-muted-foreground",
)}
>
{isSelected && <Check className="w-3 h-3" />}
</button>
)}
<div className="flex items-center justify-center h-28 bg-accent/20">
<AvatarPreview type={it.setType} id={it.setId} />
</div>
<div className="px-2.5 py-2 border-t space-y-1">
<div className="text-xs font-bold truncate" title={key}>
{it.setType}-{it.setId}
</div>
<div className="flex items-center gap-1 flex-wrap">
<Badge
variant="outline"
className="text-[10px] px-1.5 py-0"
>
{it.gender}
</Badge>
{it.club > 0 && (
<Badge className="text-[10px] px-1.5 py-0 bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)] border-0">
HC
</Badge>
)}
{it.sellable && (
<Badge className="text-[10px] px-1.5 py-0 bg-[var(--admin-info-subtle)] text-[var(--admin-info)] border-0">
$
</Badge>
)}
</div>
<div className="pt-1">
{it.present ? (
<Badge className="text-[10px] px-1.5 py-0 bg-[var(--admin-success-subtle)] text-[var(--admin-success)] border-0 gap-0.5 w-full justify-center">
<Check className="w-2.5 h-2.5" />
Imported
</Badge>
) : (
<Button
size="sm"
variant="outline"
className="w-full text-xs h-7"
disabled={busyKey === key || !!batchProgress}
onClick={() => importOne(it)}
>
{busyKey === key ? (
<Loader2 className="w-3 h-3 animate-spin mr-1" />
) : (
<Download className="w-3 h-3 mr-1" />
)}
Import
</Button>
)}
</div>
</div>
</div>
);
})}
</div>
)}
{total > PER_PAGE && (
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
Page {page} / {lastPage}
</span>
<div className="flex gap-1">
<Button
variant="outline"
size="sm"
disabled={page <= 1 || loading}
onClick={() => goPage("prev")}
>
&larr; Prev
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= lastPage || loading}
onClick={() => goPage("next")}
>
Next &rarr;
</Button>
</div>
</div>
)}
</div>
);
}
// ── Root component with mode toggle ──────────────────────────────────────────
export function ImportClothingClient() {
const [mode, setMode] = useState<Mode>("libs");
return (
<div className="space-y-4">
<div className="flex gap-1 rounded-lg border p-1 w-fit">
<Button
size="sm"
variant={mode === "libs" ? "default" : "ghost"}
className="h-7 text-xs"
onClick={() => setMode("libs")}
>
Libraries
</Button>
<Button
size="sm"
variant={mode === "sets" ? "default" : "ghost"}
className="h-7 text-xs"
onClick={() => setMode("sets")}
>
Sets
</Button>
</div>
{mode === "libs" ? <LibsView /> : <SetsView />}
</div>
);
}
@@ -0,0 +1,431 @@
// src/app/[locale]/admin/import/effects/import-effects-client.tsx
"use client";
import {
AlertCircle,
Check,
Download,
Loader2,
Music,
Package,
Search,
Sparkles,
Trash2,
X,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { adminFetch } from "@/lib/admin-fetch";
import { runSseImport } from "@/lib/sse-client";
import { cn } from "@/lib/utils";
interface EffectItem {
id: string;
lib: string;
type: string;
revision: number;
present: boolean;
nitroExists: boolean;
}
type Filter = "all" | "fx" | "dance";
export function ImportEffectsClient() {
const [searchTerm, setSearchTerm] = useState("");
const [effects, setEffects] = useState<EffectItem[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [filter, setFilter] = useState<Filter>("all");
const [selected, setSelected] = useState<Set<string>>(new Set());
const [batchProgress, setBatchProgress] = useState<{
done: number;
total: number;
} | null>(null);
const [busyLib, setBusyLib] = useState<string | null>(null);
const batchAbortRef = useRef<AbortController | null>(null);
const fetchEffects = useCallback(async (search: string) => {
setLoading(true);
setError("");
try {
const params = new URLSearchParams();
if (search) params.set("search", search);
const res = await adminFetch(`/api/admin/import/effects?${params}`);
const data = await res.json();
if (!res.ok) {
setError(data.error || "Failed to load");
return;
}
setEffects(data.effects || []);
setSelected(new Set());
} catch {
setError("Network error");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchEffects("");
}, [fetchEffects]);
const visible = useMemo(
() => effects.filter((e) => filter === "all" || e.type === filter),
[effects, filter],
);
const importable = useMemo(
() => visible.filter((e) => !e.present || !e.nitroExists),
[visible],
);
const importedCount = useMemo(
() => visible.filter((e) => e.present && e.nitroExists).length,
[visible],
);
function toggle(lib: string) {
setSelected((prev) => {
const n = new Set(prev);
if (n.has(lib)) n.delete(lib);
else n.add(lib);
return n;
});
}
function markDone(lib: string) {
setEffects((prev) =>
prev.map((e) =>
e.lib === lib ? { ...e, present: true, nitroExists: true } : e,
),
);
}
async function importViaSse(items: EffectItem[]) {
batchAbortRef.current?.abort();
const abort = new AbortController();
batchAbortRef.current = abort;
setBatchProgress({ done: 0, total: items.length });
let done = 0;
try {
await runSseImport(
"/api/admin/import/effects/batch",
{ items, concurrency: 3 },
(lib) => {
done++;
markDone(lib);
setBatchProgress({ done, total: items.length });
},
(succeeded, failed) => {
setSelected(new Set());
if (succeeded > 0) toast.success(`${succeeded} effect(s) imported`);
if (failed > 0) toast.error(`${failed} failed`);
},
abort.signal,
() => {
done++;
setBatchProgress({ done, total: items.length });
},
);
} catch (err) {
if ((err as Error)?.name === "AbortError") {
toast.info("Import cancelled");
} else {
throw err;
}
} finally {
if (batchAbortRef.current === abort) batchAbortRef.current = null;
setBatchProgress(null);
}
}
async function importOne(e: EffectItem) {
setBusyLib(e.lib);
try {
await importViaSse([e]);
} finally {
setBusyLib(null);
}
}
async function importSelected() {
const items = visible.filter(
(e) => selected.has(e.lib) && (!e.present || !e.nitroExists),
);
if (items.length === 0) return;
await importViaSse(items);
}
async function remove(e: EffectItem) {
setBusyLib(e.lib);
try {
const res = await adminFetch(
`/api/admin/import/effects?lib=${encodeURIComponent(e.lib)}`,
{
method: "DELETE",
},
);
const data = await res.json();
if (res.ok) {
toast.success(`${e.lib} removed`);
setEffects((prev) =>
prev.map((x) =>
x.lib === e.lib ? { ...x, present: false, nitroExists: false } : x,
),
);
} else {
toast.error(data.error || "Delete failed");
}
} finally {
setBusyLib(null);
}
}
return (
<div className="space-y-4">
{/* Stats */}
{effects.length > 0 && (
<div className="flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-1.5 text-sm">
<Package className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{visible.length}</span>
<span className="text-muted-foreground">effects</span>
</div>
<div className="flex items-center gap-1.5 text-sm">
<Check className="h-4 w-4 text-[var(--admin-success)]" />
<span className="font-medium text-[var(--admin-success)]">
{importedCount}
</span>
<span className="text-muted-foreground">imported</span>
</div>
<div className="flex items-center gap-1.5 text-sm">
<Download className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{importable.length}</span>
<span className="text-muted-foreground">available</span>
</div>
</div>
)}
{/* Search + filter */}
<div className="flex gap-2 flex-wrap">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search effects by lib / id..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") fetchEffects(searchTerm);
if (e.key === "Escape") {
setSearchTerm("");
fetchEffects("");
}
}}
className="pl-9 pr-8"
/>
{searchTerm && (
<button
type="button"
onClick={() => {
setSearchTerm("");
fetchEffects("");
}}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
<Button
onClick={() => fetchEffects(searchTerm)}
size="sm"
disabled={loading}
>
{loading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Search className="w-4 h-4" />
)}
</Button>
{(["all", "fx", "dance"] as Filter[]).map((f) => (
<Button
key={f}
size="sm"
variant={filter === f ? "default" : "outline"}
onClick={() => setFilter(f)}
>
{f.toUpperCase()}
</Button>
))}
</div>
{/* Bulk actions */}
{importable.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
<Button
variant="outline"
size="sm"
className="text-xs h-8"
disabled={!!batchProgress}
onClick={() =>
setSelected((prev) =>
prev.size === importable.length
? new Set()
: new Set(importable.map((e) => e.lib)),
)
}
>
{selected.size === importable.length
? "Deselect All"
: `Select All (${importable.length})`}
</Button>
{selected.size > 0 && (
<Button
size="sm"
className="h-8"
onClick={importSelected}
disabled={!!batchProgress}
>
{batchProgress ? (
<Loader2 className="w-4 h-4 animate-spin mr-1" />
) : (
<Download className="w-4 h-4 mr-1" />
)}
Import {selected.size} selected
</Button>
)}
{batchProgress && (
<>
<span className="text-xs text-muted-foreground">
{batchProgress.done}/{batchProgress.total}
</span>
<Button
size="sm"
variant="outline"
className="h-8"
onClick={() => batchAbortRef.current?.abort()}
>
Cancel
</Button>
</>
)}
</div>
)}
{/* Error */}
{error && (
<div className="rounded-lg border px-4 py-3 text-sm bg-destructive/10 border-destructive/20 text-destructive flex items-center gap-2">
<AlertCircle className="w-4 h-4 shrink-0" />
{error}
</div>
)}
{/* Empty */}
{!loading && visible.length === 0 && !error && (
<div className="text-center py-12 text-muted-foreground">
<Sparkles className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p className="text-sm">No effects found</p>
</div>
)}
{/* Grid */}
{visible.length > 0 && (
<div
className={cn(
"grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3",
loading && "opacity-50",
)}
>
{visible.map((e) => {
const imported = e.present && e.nitroExists;
const isSelected = selected.has(e.lib);
return (
<div
key={e.lib}
className={cn(
"rounded-lg border overflow-hidden bg-card relative hover:border-border transition-all",
imported && "ring-1 ring-[var(--admin-success)]/30",
isSelected && "ring-2 ring-primary border-primary",
)}
>
{!imported && (
<button
type="button"
onClick={() => toggle(e.lib)}
className={cn(
"absolute top-1.5 left-1.5 z-10 w-5 h-5 rounded border-2 flex items-center justify-center transition-colors",
isSelected
? "bg-primary border-primary text-primary-foreground"
: "bg-background/80 border-muted-foreground/30 hover:border-muted-foreground",
)}
>
{isSelected && <Check className="w-3 h-3" />}
</button>
)}
<div
className={cn(
"flex flex-col items-center justify-center h-20 gap-1",
e.type === "dance"
? "bg-gradient-to-br from-[var(--admin-warning)]/25 to-[var(--admin-warning)]/10"
: "bg-gradient-to-br from-[var(--admin-accent)]/25 to-[var(--admin-accent)]/10",
)}
>
{e.type === "dance" ? (
<Music className="w-7 h-7 text-[var(--admin-warning)]/80" />
) : (
<Sparkles className="w-7 h-7 text-[var(--admin-accent-text)]" />
)}
<span className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
{e.type}
</span>
</div>
<div className="px-2.5 py-2 border-t space-y-1">
<div className="text-xs font-bold truncate">{e.lib}</div>
<div className="text-[10px] text-muted-foreground truncate">
id {e.id}
</div>
<div className="pt-1">
{imported ? (
<div className="flex items-center gap-1">
<Badge className="text-[10px] px-1.5 py-0 bg-[var(--admin-success-subtle)] text-[var(--admin-success)] border-0 gap-0.5 flex-1 justify-center">
<Check className="w-2.5 h-2.5" />
Imported
</Badge>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 shrink-0"
title="Remove effect"
disabled={busyLib === e.lib || !!batchProgress}
onClick={() => remove(e)}
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
) : (
<Button
size="sm"
variant="outline"
className="w-full text-xs h-7"
disabled={busyLib === e.lib || !!batchProgress}
onClick={() => importOne(e)}
>
{busyLib === e.lib ? (
<Loader2 className="w-3 h-3 animate-spin mr-1" />
) : (
<Download className="w-3 h-3 mr-1" />
)}
Import
</Button>
)}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
);
}
@@ -0,0 +1,38 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import * as hotelHelpers from "@/lib/habbo-gamedata-hotel";
describe("furni import source metadata", () => {
it("describes the configured hotel without changing the global asset CDN", () => {
const helpers = hotelHelpers as unknown as {
buildFurniImportSource?: (hotel: string) => {
hotel: string;
label: string;
host: string;
furnidataUrl: string;
assetCdnHost: string;
};
officialHabboEnrichmentWarning?: (hotel: string, name: string) => string;
};
expect(helpers.buildFurniImportSource).toBeTypeOf("function");
expect(helpers.officialHabboEnrichmentWarning).toBeTypeOf("function");
if (
!helpers.buildFurniImportSource ||
!helpers.officialHabboEnrichmentWarning
) {
return;
}
expect(helpers.buildFurniImportSource("nl")).toEqual({
hotel: "nl",
label: "Netherlands (habbo.nl)",
host: "habbo.nl",
furnidataUrl: "https://www.habbo.nl/gamedata/furnidata_json/1",
assetCdnHost: "images.habbo.com",
});
expect(helpers.officialHabboEnrichmentWarning("nl", "Stoel")).toBe(
'Enriched from habbo.nl: "Stoel"',
);
});
});
@@ -0,0 +1,921 @@
"use client";
import {
Box,
Check,
Compass,
Copy,
Eye,
Layers,
Loader2,
MousePointerClick,
Palette,
RotateCcw,
Ruler,
Save,
Sparkles,
} from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { adminFetch } from "@/lib/admin-fetch";
import {
autoDetectInteraction,
getInteractionTypeLabel,
nitroAnimationStatesCount,
} from "@/lib/furni/auto-interaction";
interface NitroEditorDialogProps {
classname: string;
name?: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}
interface NitroVisualization {
size?: number;
layers?: Record<string, { z?: number; alpha?: number; ink?: string }>;
colors?: Record<string, Record<string, { color: number }>>;
animations?: Record<string, unknown>;
}
// The .nitro metadata is untyped JSON from disk; typed only for the fields the
// editor reads/writes. Unknown keys are traversed via the path helpers below.
interface NitroMetadata {
visualizationType?: string;
logicType?: string;
logic?: {
model?: {
dimensions?: { x: number; y: number; z: number };
directions?: number[];
};
};
visualizations?: NitroVisualization[];
}
type JsonObject = Record<string, unknown>;
function updateNested<T extends object>(
obj: T,
path: string,
value: unknown,
): T {
const clone: T = JSON.parse(JSON.stringify(obj));
const keys = path.split(".");
let current: JsonObject = clone as JsonObject;
for (let i = 0; i < keys.length - 1; i++) {
const next = current[keys[i]];
if (!next || typeof next !== "object" || Array.isArray(next)) {
current[keys[i]] = {};
}
current = current[keys[i]] as JsonObject;
}
current[keys[keys.length - 1]] = value;
return clone;
}
function getNested<T>(obj: unknown, path: string, fallback: T): T {
const keys = path.split(".");
let current: unknown = obj;
for (const key of keys) {
if (current == null || typeof current !== "object") return fallback;
current = (current as JsonObject)[key];
}
return current === undefined ? fallback : (current as T);
}
const ALL_DIRECTIONS = [0, 1, 2, 3, 4, 5, 6, 7];
const DIR_LABELS = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
function hexToColor(hex: string): string {
return `#${hex.padStart(6, "0")}`;
}
function CopyClassname({ value }: { value: string }) {
const [copied, setCopied] = useState(false);
return (
<button
type="button"
className="flex items-center gap-1.5 font-mono text-sm text-muted-foreground mt-0.5 hover:text-foreground transition-colors"
onClick={() => {
navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}}
>
<span className="truncate">{value}</span>
{copied ? (
<Check className="w-3 h-3 text-[var(--admin-success)] shrink-0" />
) : (
<Copy className="w-3 h-3 shrink-0" />
)}
</button>
);
}
export function NitroEditorDialog({
classname,
name = "",
open,
onOpenChange,
}: NitroEditorDialogProps) {
const [metadata, setMetadata] = useState<NitroMetadata | null>(null);
const [originalMetadata, setOriginalMetadata] =
useState<NitroMetadata | null>(null);
const [jsonText, setJsonText] = useState("");
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [activeTab, setActiveTab] = useState<string>("visual");
const [flags, setFlags] = useState({
canstandon: false,
cansiton: false,
canlayon: false,
});
const [originalFlags, setOriginalFlags] = useState({
canstandon: false,
cansiton: false,
canlayon: false,
});
const [autoDetectResult, setAutoDetectResult] = useState<ReturnType<
typeof autoDetectInteraction
> | null>(null);
const [jsonError, setJsonError] = useState<string | null>(null);
const handleAutoDetect = () => {
// Use real data when available: the DB flags for this item plus the
// .nitro animation states already loaded in the editor. Keyword
// detection only kicks in when neither is present — no false positives.
const nitroJson =
metadata && typeof metadata === "object"
? (metadata as Record<string, unknown>)
: null;
const result = autoDetectInteraction(classname, name, {
cansiton: flags.cansiton,
canlayon: flags.canlayon,
canstandon: flags.canstandon,
logicType: nitroJson?.logicType as string | undefined,
animationStates: nitroAnimationStatesCount(nitroJson),
});
setAutoDetectResult(result);
setFlags({
canstandon: result.canStand,
cansiton: result.canSit,
canlayon: result.canLay,
});
toast.success(`Auto-detected: ${result.reason}`);
};
const patchMeta = (path: string, value: unknown) => {
setMetadata((prev) =>
prev == null ? prev : updateNested(prev, path, value),
);
};
const fetchMetadata = useCallback(async () => {
if (!classname) return;
setLoading(true);
setJsonError(null);
try {
const res = await adminFetch(
`/api/admin/import/furni/nitro-editor?classname=${encodeURIComponent(classname)}`,
);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
toast.error(err.error || "Failed to load .nitro metadata");
onOpenChange(false);
return;
}
const data = await res.json();
setMetadata(data.metadata);
setOriginalMetadata(JSON.parse(JSON.stringify(data.metadata)));
setJsonText(JSON.stringify(data.metadata, null, 2));
setFlags(data.flags);
setOriginalFlags({ ...data.flags });
} catch {
toast.error("Network error loading .nitro metadata");
onOpenChange(false);
} finally {
setLoading(false);
}
}, [classname, onOpenChange]);
useEffect(() => {
if (open && classname) {
setActiveTab("visual");
fetchMetadata();
}
}, [open, classname, fetchMetadata]);
const handleTabChange = (newTab: string) => {
if (activeTab === "json" && newTab === "visual") {
try {
const parsed = JSON.parse(jsonText);
setMetadata(parsed);
setJsonError(null);
} catch {
toast.error("Invalid JSON — fix it before switching to Visual tab");
return;
}
} else if (activeTab === "visual" && newTab === "json") {
setJsonText(JSON.stringify(metadata, null, 2));
}
setActiveTab(newTab);
};
const handleReset = () => {
if (!originalMetadata) return;
setMetadata(JSON.parse(JSON.stringify(originalMetadata)));
setFlags({ ...originalFlags });
setJsonText(JSON.stringify(originalMetadata, null, 2));
toast.info("Reset to original values");
};
const handleSave = async () => {
let metadataToSave = metadata;
if (activeTab === "json") {
try {
metadataToSave = JSON.parse(jsonText);
} catch {
toast.error("Invalid JSON");
return;
}
}
setSaving(true);
try {
const res = await adminFetch("/api/admin/import/furni/nitro-editor", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
classname,
metadata: metadataToSave,
...flags,
}),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
toast.error(err.error || "Save failed");
return;
}
toast.success("Nitro metadata saved");
setMetadata(metadataToSave);
setOriginalMetadata(JSON.parse(JSON.stringify(metadataToSave)));
setOriginalFlags({ ...flags });
setJsonText(JSON.stringify(metadataToSave, null, 2));
} catch {
toast.error("Network error");
} finally {
setSaving(false);
}
};
// ── Visual tab helpers ──
const dims = metadata
? getNested(metadata, "logic.model.dimensions", { x: 1, y: 1, z: 0 })
: { x: 1, y: 1, z: 0 };
const directions = metadata
? getNested(metadata, "logic.model.directions", [0])
: [0];
const setDim = (axis: "x" | "y" | "z", value: string) => {
const num =
axis === "z" ? parseFloat(value) || 0 : parseInt(value, 10) || 0;
patchMeta(`logic.model.dimensions.${axis}`, num);
};
const toggleDirection = (dir: number) => {
const current = getNested(metadata, "logic.model.directions", [0]);
const next = current.includes(dir)
? current.filter((d: number) => d !== dir)
: [...current, dir].sort((a: number, b: number) => a - b);
if (next.length === 0) return;
patchMeta("logic.model.directions", next);
};
// Get visualization with size=64 for layer/color editing
const viz64 = metadata?.visualizations?.find((v) => v.size === 64);
const viz64Index =
metadata?.visualizations?.findIndex((v) => v.size === 64) ?? -1;
const layerCount = viz64?.layers ? Object.keys(viz64.layers).length : 0;
const colorCount = viz64?.colors ? Object.keys(viz64.colors).length : 0;
const animCount = viz64?.animations
? Object.keys(viz64.animations).length
: 0;
const handleJsonTextChange = (value: string) => {
setJsonText(value);
try {
JSON.parse(value);
setJsonError(null);
} catch (e) {
setJsonError((e as Error).message);
}
};
// Check if there are unsaved changes
const hasChanges =
metadata &&
originalMetadata &&
(JSON.stringify(metadata) !== JSON.stringify(originalMetadata) ||
JSON.stringify(flags) !== JSON.stringify(originalFlags));
// Furni preview URL
const starIdx = classname.indexOf("*");
const baseClassname =
starIdx !== -1 ? classname.substring(0, starIdx) : classname;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-4xl max-h-[90vh] flex flex-col p-0 gap-0">
{/* Header with preview */}
<DialogHeader className="px-6 pt-5 pb-4 border-b bg-muted/30">
<div className="flex items-start gap-4">
{/* Furni preview */}
<div
className="w-20 h-20 rounded-lg border bg-card flex items-center justify-center shrink-0 overflow-hidden"
style={{
backgroundImage: "url(/images/furni_floor.png)",
backgroundRepeat: "repeat",
backgroundPosition: "center",
}}
>
<img
src={`/swf/dcr/hof_furni/icons/${baseClassname}_icon.png`}
alt={classname}
className="max-w-[60px] max-h-[60px] drop-shadow-md"
style={{ imageRendering: "pixelated" }}
onError={(e) => {
(e.target as HTMLImageElement).style.opacity = "0";
}}
/>
</div>
<div className="flex-1 min-w-0">
<DialogTitle className="text-lg">Edit Nitro</DialogTitle>
<CopyClassname value={classname} />
{/* Quick stats */}
{metadata && (
<div className="flex gap-2 mt-2 flex-wrap">
<Badge variant="outline" className="text-[10px] gap-1">
<Ruler className="w-2.5 h-2.5" />
{dims.x}×{dims.y}×{dims.z}
</Badge>
<Badge variant="outline" className="text-[10px] gap-1">
<Compass className="w-2.5 h-2.5" />
{directions.length} dir
</Badge>
<Badge variant="outline" className="text-[10px] gap-1">
<Layers className="w-2.5 h-2.5" />
{layerCount} layers
</Badge>
{colorCount > 0 && (
<Badge variant="outline" className="text-[10px] gap-1">
<Palette className="w-2.5 h-2.5" />
{colorCount} colors
</Badge>
)}
{animCount > 0 && (
<Badge variant="outline" className="text-[10px] gap-1">
<Sparkles className="w-2.5 h-2.5" />
{animCount} anim
</Badge>
)}
<Badge variant="outline" className="text-[10px] gap-1">
<Eye className="w-2.5 h-2.5" />
{metadata.visualizationType || "?"}
</Badge>
</div>
)}
</div>
</div>
</DialogHeader>
{loading ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : metadata ? (
<Tabs
value={activeTab}
onValueChange={handleTabChange}
className="flex-1 flex flex-col min-h-0"
>
<div className="px-6 pt-3 pb-0">
<TabsList className="w-fit">
<TabsTrigger value="visual" className="gap-1.5">
<Box className="w-3.5 h-3.5" />
Visual
</TabsTrigger>
<TabsTrigger value="json" className="gap-1.5">
{"{ }"}
JSON
</TabsTrigger>
</TabsList>
</div>
{/* ── Visual Tab ── */}
<TabsContent
value="visual"
className="overflow-y-auto flex-1 px-6 py-4 space-y-5"
>
{/* Dimensions + Interaction in 2-column layout */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
{/* Dimensions card */}
<section className="rounded-lg border bg-card p-4">
<div className="flex items-center gap-2 mb-3">
<Ruler className="w-4 h-4 text-muted-foreground" />
<h3 className="text-sm font-medium">Dimensions</h3>
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<Label
htmlFor="dim-x"
className="text-xs text-muted-foreground"
>
X (width)
</Label>
<Input
id="dim-x"
type="number"
min={1}
value={dims.x}
onChange={(e) => setDim("x", e.target.value)}
className="mt-1"
/>
</div>
<div>
<Label
htmlFor="dim-y"
className="text-xs text-muted-foreground"
>
Y (depth)
</Label>
<Input
id="dim-y"
type="number"
min={1}
value={dims.y}
onChange={(e) => setDim("y", e.target.value)}
className="mt-1"
/>
</div>
<div>
<Label
htmlFor="dim-z"
className="text-xs text-muted-foreground"
>
Z (height)
</Label>
<Input
id="dim-z"
type="number"
step={0.01}
min={0}
value={dims.z}
onChange={(e) => setDim("z", e.target.value)}
className="mt-1"
/>
</div>
</div>
</section>
{/* Interaction card */}
<section className="rounded-lg border bg-card p-4">
<div className="flex items-center gap-2 mb-3">
<MousePointerClick className="w-4 h-4 text-muted-foreground" />
<h3 className="text-sm font-medium">Interaction</h3>
</div>
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between rounded-md border px-3 py-2 hover:bg-muted/50 transition-colors">
<span className="text-sm">Can Stand On</span>
<Switch
checked={flags.canstandon}
onCheckedChange={(v) =>
setFlags((f) => ({ ...f, canstandon: !!v }))
}
/>
</div>
<div className="flex items-center justify-between rounded-md border px-3 py-2 hover:bg-muted/50 transition-colors">
<span className="text-sm">Can Sit On</span>
<Switch
checked={flags.cansiton}
onCheckedChange={(v) =>
setFlags((f) => ({ ...f, cansiton: !!v }))
}
/>
</div>
<div className="flex items-center justify-between rounded-md border px-3 py-2 hover:bg-muted/50 transition-colors">
<span className="text-sm">Can Lay On</span>
<Switch
checked={flags.canlayon}
onCheckedChange={(v) =>
setFlags((f) => ({ ...f, canlayon: !!v }))
}
/>
</div>
</div>
<div className="mt-4 flex flex-col gap-2">
<Button
variant="outline"
size="sm"
onClick={handleAutoDetect}
className="gap-1.5 w-full"
>
<Sparkles className="w-3.5 h-3.5" />
Auto-detect from name
</Button>
{autoDetectResult && (
<div className="rounded-md bg-muted/50 p-3 text-xs space-y-1">
<div className="flex items-center gap-1.5 flex-wrap">
<Badge
variant={
autoDetectResult.canLay ? "default" : "secondary"
}
className="gap-1"
>
{autoDetectResult.canLay ? (
<Layers className="w-2.5 h-2.5" />
) : autoDetectResult.canSit ? (
<MousePointerClick className="w-2.5 h-2.5" />
) : (
<MousePointerClick className="w-2.5 h-2.5" />
)}
{getInteractionTypeLabel(
autoDetectResult.interactionType,
)}
</Badge>
{autoDetectResult.clickLimit > 0 && (
<Badge variant="outline" className="gap-1">
Click limit: {autoDetectResult.clickLimit}
</Badge>
)}
{autoDetectResult.interactionModesCount > 0 && (
<Badge variant="outline" className="gap-1">
Modes: {autoDetectResult.interactionModesCount}
</Badge>
)}
</div>
<p className="text-muted-foreground">
{autoDetectResult.reason}
</p>
</div>
)}
</div>
</section>
</div>
{/* Directions - visual compass */}
<section className="rounded-lg border bg-card p-4">
<div className="flex items-center gap-2 mb-3">
<Compass className="w-4 h-4 text-muted-foreground" />
<h3 className="text-sm font-medium">Directions</h3>
<span className="text-xs text-muted-foreground ml-auto">
{directions.length} active
</span>
</div>
<div className="flex items-center justify-center">
<div className="relative w-48 h-48">
{/* Compass circle */}
<div className="absolute inset-0 rounded-full border-2 border-muted" />
<div className="absolute inset-4 rounded-full border border-muted/50" />
{/* Direction buttons arranged in circle */}
{ALL_DIRECTIONS.map((dir) => {
const angle = (dir * 45 - 90) * (Math.PI / 180);
const r = 80;
const x = 96 + r * Math.cos(angle) - 18;
const y = 96 + r * Math.sin(angle) - 18;
const isActive = directions.includes(dir);
return (
<button
type="button"
key={dir}
className={`absolute w-9 h-9 rounded-full border-2 flex flex-col items-center justify-center text-xs font-medium transition-all ${
isActive
? "bg-primary text-primary-foreground border-primary shadow-md scale-110"
: "bg-card text-muted-foreground border-muted hover:border-foreground/30 hover:scale-105"
}`}
style={{ left: x, top: y }}
onClick={() => toggleDirection(dir)}
>
<span className="text-[9px] leading-none">
{DIR_LABELS[dir]}
</span>
<span className="text-[8px] leading-none opacity-60">
{dir}
</span>
</button>
);
})}
{/* Center label */}
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-xs text-muted-foreground font-medium">
{directions.length}/8
</span>
</div>
</div>
</div>
</section>
{/* Types */}
<section className="rounded-lg border bg-card p-4">
<div className="flex items-center gap-2 mb-3">
<Eye className="w-4 h-4 text-muted-foreground" />
<h3 className="text-sm font-medium">Types</h3>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label
htmlFor="viz-type"
className="text-xs text-muted-foreground"
>
Visualization Type
</Label>
<Input
id="viz-type"
value={metadata.visualizationType || ""}
onChange={(e) =>
patchMeta("visualizationType", e.target.value)
}
className="mt-1 font-mono text-sm"
/>
</div>
<div>
<Label
htmlFor="logic-type"
className="text-xs text-muted-foreground"
>
Logic Type
</Label>
<Input
id="logic-type"
value={metadata.logicType || ""}
onChange={(e) => patchMeta("logicType", e.target.value)}
className="mt-1 font-mono text-sm"
/>
</div>
</div>
</section>
{/* Layers */}
{viz64?.layers && layerCount > 0 && (
<section className="rounded-lg border bg-card p-4">
<div className="flex items-center gap-2 mb-3">
<Layers className="w-4 h-4 text-muted-foreground" />
<h3 className="text-sm font-medium">Layer</h3>
<Badge variant="secondary" className="text-[10px] ml-auto">
{layerCount}
</Badge>
</div>
<div className="rounded-md border overflow-hidden">
{/* Header */}
<div className="grid grid-cols-4 gap-2 items-center text-[10px] font-medium text-muted-foreground uppercase tracking-wider bg-muted/50 px-3 py-1.5">
<span>Layer</span>
<span>Z-Index</span>
<span>Alpha</span>
<span>Ink</span>
</div>
{Object.entries(viz64.layers).map(
([layerId, layerData], idx) => {
const layer = layerData as Record<string, unknown>;
return (
<div
key={layerId}
className={`grid grid-cols-4 gap-2 items-center text-sm px-3 py-1.5 ${
idx % 2 === 0 ? "bg-card" : "bg-muted/20"
}`}
>
<span className="font-mono text-xs">
<Badge
variant="outline"
className="text-[10px] px-1.5"
>
#{layerId}
</Badge>
</span>
<Input
type="number"
className="h-7 text-xs"
value={(layer.z as number) ?? 0}
onChange={(e) => {
patchMeta(
`visualizations.${viz64Index}.layers.${layerId}.z`,
parseInt(e.target.value, 10) || 0,
);
}}
/>
<Input
type="number"
className="h-7 text-xs"
min={0}
max={255}
value={(layer.alpha as number) ?? 255}
onChange={(e) => {
patchMeta(
`visualizations.${viz64Index}.layers.${layerId}.alpha`,
parseInt(e.target.value, 10) || 0,
);
}}
/>
<Input
className="h-7 text-xs font-mono"
value={(layer.ink as string) ?? ""}
placeholder="—"
onChange={(e) => {
patchMeta(
`visualizations.${viz64Index}.layers.${layerId}.ink`,
e.target.value || undefined,
);
}}
/>
</div>
);
},
)}
</div>
</section>
)}
{/* Colors */}
{viz64?.colors && colorCount > 0 && (
<section className="rounded-lg border bg-card p-4">
<div className="flex items-center gap-2 mb-3">
<Palette className="w-4 h-4 text-muted-foreground" />
<h3 className="text-sm font-medium">Colori</h3>
<Badge variant="secondary" className="text-[10px] ml-auto">
{colorCount} colors
</Badge>
</div>
<div className="space-y-3">
{Object.entries(viz64.colors).map(
([colorId, colorLayers]) => (
<div key={colorId} className="rounded-md border p-3">
<div className="flex items-center gap-2 mb-2">
<span className="text-xs font-medium">
Color Variant #{colorId}
</span>
{/* Color preview dots */}
<div className="flex gap-1 ml-auto">
{Object.values(
colorLayers as Record<
string,
{ color: number }
>,
).map((lc, i) => (
<div
// biome-ignore lint/suspicious/noArrayIndexKey: color preview dots, position is the identity
key={i}
className="w-4 h-4 rounded-full border border-border shadow-sm"
style={{
backgroundColor: hexToColor(
typeof lc.color === "number"
? lc.color.toString(16).padStart(6, "0")
: "000000",
),
}}
/>
))}
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{Object.entries(
colorLayers as Record<string, { color: number }>,
).map(([layerId, layerColor]) => (
<div
key={layerId}
className="flex items-center gap-2"
>
<Label className="text-xs text-muted-foreground shrink-0 w-6">
L{layerId}
</Label>
<div className="relative flex-1">
<Input
className="h-7 text-xs font-mono pl-8"
value={
typeof layerColor.color === "number"
? layerColor.color
.toString(16)
.padStart(6, "0")
.toUpperCase()
: String(layerColor.color)
}
onChange={(e) => {
const hex = e.target.value
.replace(/[^0-9a-fA-F]/g, "")
.slice(0, 6);
const num = parseInt(hex, 16) || 0;
patchMeta(
`visualizations.${viz64Index}.colors.${colorId}.${layerId}.color`,
num,
);
}}
/>
{/* Color swatch inside input */}
<div
className="absolute left-1.5 top-1/2 -translate-y-1/2 w-4 h-4 rounded border border-border"
style={{
backgroundColor: hexToColor(
typeof layerColor.color === "number"
? layerColor.color
.toString(16)
.padStart(6, "0")
: "000000",
),
}}
/>
</div>
</div>
))}
</div>
</div>
),
)}
</div>
</section>
)}
</TabsContent>
{/* ── JSON Tab ── */}
<TabsContent
value="json"
className="flex-1 flex flex-col min-h-0 px-6 py-4"
>
<div className="relative flex-1 min-h-0">
<Textarea
className="h-full min-h-[400px] font-mono text-xs resize-none"
value={jsonText}
onChange={(e) => handleJsonTextChange(e.target.value)}
spellCheck={false}
/>
{jsonError && (
<div className="absolute bottom-2 left-2 right-2 rounded-md bg-destructive/10 border border-destructive/20 px-3 py-1.5 text-xs text-destructive">
{jsonError}
</div>
)}
</div>
</TabsContent>
</Tabs>
) : null}
<DialogFooter className="px-6 py-4 border-t bg-muted/30">
<div className="flex items-center gap-2 w-full">
{hasChanges && (
<Badge
variant="secondary"
className="text-[10px] gap-1 animate-[pulse_1s_ease-in-out_1]"
>
Unsaved changes
</Badge>
)}
<div className="flex-1" />
<Button
variant="ghost"
size="sm"
onClick={handleReset}
disabled={saving || loading || !metadata || !hasChanges}
className="gap-1.5"
>
<RotateCcw className="w-3.5 h-3.5" />
Reset
</Button>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={saving}
>
Cancel
</Button>
<Button
onClick={handleSave}
disabled={saving || loading || !metadata}
className="gap-1.5"
>
{saving ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Save className="w-4 h-4" />
)}
Save
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,375 @@
"use client";
import { AlertCircle, Check, Loader2, Upload } from "lucide-react";
import { useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { adminFetch } from "@/lib/admin-fetch";
const CLASSNAME_RE = /^[a-zA-Z0-9_]+$/;
const NITRO_MAX_BYTES = 50 * 1024 * 1024;
interface UploadResult {
itemId: number;
catalogItemId: number | null;
classname: string;
warnings: string[];
sqlFile?: string | null;
}
export function UploadFurniClient() {
const nitroRef = useRef<HTMLInputElement>(null);
const iconRef = useRef<HTMLInputElement>(null);
const [classname, setClassname] = useState("");
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [itemType, setItemType] = useState<"s" | "i">("s");
const [xdim, setXdim] = useState(1);
const [ydim, setYdim] = useState(1);
const [canstandon, setCanstandon] = useState(false);
const [cansiton, setCansiton] = useState(false);
const [canlayon, setCanlayon] = useState(false);
const [interactionType, setInteractionType] = useState("default");
const [customparams, setCustomparams] = useState("");
const [generateSql, setGenerateSql] = useState(false);
const [nitroFile, setNitroFile] = useState<File | null>(null);
const [iconFile, setIconFile] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const [result, setResult] = useState<UploadResult | null>(null);
const [error, setError] = useState("");
function resetForm() {
setClassname("");
setName("");
setDescription("");
setItemType("s");
setXdim(1);
setYdim(1);
setCanstandon(false);
setCansiton(false);
setCanlayon(false);
setInteractionType("default");
setCustomparams("");
setGenerateSql(false);
setNitroFile(null);
setIconFile(null);
setResult(null);
setError("");
if (nitroRef.current) nitroRef.current.value = "";
if (iconRef.current) iconRef.current.value = "";
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
setResult(null);
const trimmedClass = classname.trim();
if (!CLASSNAME_RE.test(trimmedClass)) {
const msg =
"Classname must contain only letters, numbers, and underscores";
setError(msg);
toast.error(msg);
return;
}
if (!nitroFile) {
const msg = "Nitro file is required";
setError(msg);
toast.error(msg);
return;
}
if (nitroFile.size > NITRO_MAX_BYTES) {
const msg = "Nitro file must be 50MB or smaller";
setError(msg);
toast.error(msg);
return;
}
setUploading(true);
try {
const fd = new FormData();
fd.set("classname", trimmedClass);
fd.set("name", name.trim());
fd.set("description", description.trim());
fd.set("itemType", itemType);
fd.set("xdim", String(xdim));
fd.set("ydim", String(ydim));
fd.set("canstandon", String(canstandon));
fd.set("cansiton", String(cansiton));
fd.set("canlayon", String(canlayon));
fd.set("interactionType", interactionType.trim());
fd.set("customparams", customparams.trim());
if (generateSql) fd.set("generateSql", "true");
fd.set("nitro", nitroFile);
if (iconFile) fd.set("icon", iconFile);
const res = await adminFetch("/api/admin/import/furni/upload", {
method: "POST",
body: fd,
});
const data = await res.json();
if (!res.ok) {
setError(data.error || "Upload failed");
toast.error(data.error || "Upload failed");
return;
}
setResult(data);
toast.success(`${data.classname} imported successfully`);
} catch {
const msg = "Network error";
setError(msg);
toast.error(msg);
} finally {
setUploading(false);
}
}
return (
<div className="space-y-6">
{!result ? (
<form onSubmit={handleSubmit} className="space-y-6">
<div className="rounded-xl border bg-card p-4 sm:p-5 space-y-5">
<div className="text-sm font-medium">Furniture Details</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="classname">Classname *</Label>
<Input
id="classname"
placeholder="e.g. rare_dragon"
value={classname}
onChange={(e) => setClassname(e.target.value)}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="name">Name *</Label>
<Input
id="name"
placeholder="e.g. Dragon Egg"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
placeholder="Optional description"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
/>
</div>
<div className="grid gap-4 sm:grid-cols-4">
<div className="space-y-1.5">
<Label htmlFor="itemType">Type *</Label>
<select
id="itemType"
value={itemType}
onChange={(e) => setItemType(e.target.value as "s" | "i")}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="s">Floor (s)</option>
<option value="i">Wall (i)</option>
</select>
</div>
<div className="space-y-1.5">
<Label htmlFor="xdim">Width (x)</Label>
<Input
id="xdim"
type="number"
min={1}
max={64}
value={xdim}
onChange={(e) => setXdim(Number(e.target.value))}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="ydim">Length (y)</Label>
<Input
id="ydim"
type="number"
min={1}
max={64}
value={ydim}
onChange={(e) => setYdim(Number(e.target.value))}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="interactionType">Interaction</Label>
<Input
id="interactionType"
placeholder="default"
value={interactionType}
onChange={(e) => setInteractionType(e.target.value)}
/>
</div>
</div>
<div className="flex items-center gap-4 flex-wrap">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={canstandon}
onChange={(e) => setCanstandon(e.target.checked)}
className="h-4 w-4 rounded border-input"
/>
<span className="text-sm">Can stand on</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={cansiton}
onChange={(e) => setCansiton(e.target.checked)}
className="h-4 w-4 rounded border-input"
/>
<span className="text-sm">Can sit on</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={canlayon}
onChange={(e) => setCanlayon(e.target.checked)}
className="h-4 w-4 rounded border-input"
/>
<span className="text-sm">Can lay on</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={generateSql}
onChange={(e) => setGenerateSql(e.target.checked)}
className="h-4 w-4 rounded border-input"
/>
<span className="text-sm">Generate SQL</span>
</label>
</div>
<div className="space-y-1.5">
<Label htmlFor="customparams">Custom params</Label>
<Input
id="customparams"
placeholder="Optional custom parameters"
value={customparams}
onChange={(e) => setCustomparams(e.target.value)}
/>
</div>
</div>
<div className="rounded-xl border bg-card p-5 space-y-4">
<div className="text-sm font-medium">Files</div>
<div className="space-y-1.5">
<Label htmlFor="nitro">.nitro file *</Label>
<Input
ref={nitroRef}
id="nitro"
type="file"
accept=".nitro"
onChange={(e) => setNitroFile(e.target.files?.[0] ?? null)}
required
/>
<p className="text-xs text-muted-foreground">
The .nitro bundle file (max 50MB)
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="icon">Icon (PNG)</Label>
<Input
ref={iconRef}
id="icon"
type="file"
accept=".png,image/png"
onChange={(e) => setIconFile(e.target.files?.[0] ?? null)}
/>
<p className="text-xs text-muted-foreground">
Optional 64x64 PNG icon (max 2MB). Leave empty to skip.
</p>
</div>
</div>
{error && (
<div className="rounded-lg border px-4 py-3 text-sm bg-destructive/10 border-destructive/20 text-destructive flex items-center gap-2">
<AlertCircle className="w-4 h-4 shrink-0" />
{error}
</div>
)}
<div className="flex gap-3">
<Button type="submit" disabled={uploading}>
{uploading ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
) : (
<Upload className="w-4 h-4 mr-2" />
)}
{uploading ? "Importing…" : "Import Furniture"}
</Button>
</div>
</form>
) : (
<div className="rounded-xl border bg-card p-6 space-y-4">
<div className="flex items-center gap-2">
<Check className="w-5 h-5 text-[var(--admin-success)]" />
<span className="text-lg font-semibold">Import Successful</span>
</div>
<div className="space-y-2 text-sm">
<div className="flex gap-2">
<span className="text-muted-foreground w-32">Classname:</span>
<code className="font-mono">{result.classname}</code>
</div>
<div className="flex gap-2">
<span className="text-muted-foreground w-32">Item ID:</span>
<code className="font-mono">{result.itemId}</code>
</div>
{result.catalogItemId && (
<div className="flex gap-2">
<span className="text-muted-foreground w-32">Catalog ID:</span>
<code className="font-mono">{result.catalogItemId}</code>
</div>
)}
{result.sqlFile ? (
<div className="flex gap-2">
<span className="text-muted-foreground w-32">SQL file:</span>
<code className="font-mono break-all">{result.sqlFile}</code>
</div>
) : null}
{result.warnings.length > 0 && (
<div className="space-y-1 pt-2">
<span className="text-muted-foreground font-medium">
Warnings:
</span>
{result.warnings.map((w) => (
<div
key={w}
className="flex items-center gap-1.5 text-[var(--admin-warning)] dark:text-[var(--admin-warning)]"
>
<AlertCircle className="w-3 h-3 shrink-0" />
<span>{w}</span>
</div>
))}
</div>
)}
</div>
<Button variant="outline" onClick={resetForm}>
Import Another
</Button>
</div>
)}
</div>
);
}
@@ -0,0 +1,390 @@
"use client";
import {
AlertCircle,
Check,
Download,
Loader2,
Package,
PawPrint,
Search,
Trash2,
X,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { adminFetch } from "@/lib/admin-fetch";
import { runSseImport } from "@/lib/sse-client";
import { cn } from "@/lib/utils";
interface PetItem {
lib: string;
nitroExists: boolean;
}
/** Real pet preview cropped from its .nitro; falls back to the paw glyph. */
function PetIcon({ lib }: { lib: string }) {
const [failed, setFailed] = useState(false);
if (failed) return <PawPrint className="w-8 h-8 text-muted-foreground/60" />;
return (
<img
src={`/api/admin/import/pets/icon?lib=${encodeURIComponent(lib)}&v=6`}
alt={lib}
className="image-pixelated"
style={{ maxHeight: 56, maxWidth: 56 }}
onError={() => setFailed(true)}
/>
);
}
export function ImportPetsClient() {
const [searchTerm, setSearchTerm] = useState("");
const [pets, setPets] = useState<PetItem[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [selected, setSelected] = useState<Set<string>>(new Set());
const [batchProgress, setBatchProgress] = useState<{
done: number;
total: number;
} | null>(null);
const [busyLib, setBusyLib] = useState<string | null>(null);
const batchAbortRef = useRef<AbortController | null>(null);
const fetchPets = useCallback(async (search: string) => {
setLoading(true);
setError("");
try {
const params = new URLSearchParams();
if (search) params.set("search", search);
const res = await adminFetch(`/api/admin/import/pets?${params}`);
const data = await res.json();
if (!res.ok) {
setError(data.error || "Failed to load");
return;
}
setPets(data.pets || []);
setSelected(new Set());
} catch {
setError("Network error");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchPets("");
}, [fetchPets]);
const importable = useMemo(() => pets.filter((p) => !p.nitroExists), [pets]);
const importedCount = useMemo(
() => pets.filter((p) => p.nitroExists).length,
[pets],
);
function toggle(lib: string) {
setSelected((prev) => {
const n = new Set(prev);
if (n.has(lib)) n.delete(lib);
else n.add(lib);
return n;
});
}
function markDone(lib: string) {
setPets((prev) =>
prev.map((p) => (p.lib === lib ? { ...p, nitroExists: true } : p)),
);
}
async function importViaSse(items: PetItem[]) {
batchAbortRef.current?.abort();
const abort = new AbortController();
batchAbortRef.current = abort;
setBatchProgress({ done: 0, total: items.length });
let done = 0;
try {
await runSseImport(
"/api/admin/import/pets/batch",
{ items, concurrency: 3 },
(lib) => {
done++;
markDone(lib);
setBatchProgress({ done, total: items.length });
},
(succeeded, failed) => {
setSelected(new Set());
if (succeeded > 0) toast.success(`${succeeded} pet(s) imported`);
if (failed > 0) toast.error(`${failed} failed`);
},
abort.signal,
() => {
done++;
setBatchProgress({ done, total: items.length });
},
);
} catch (err) {
if ((err as Error)?.name === "AbortError") {
toast.info("Import cancelled");
} else {
throw err;
}
} finally {
if (batchAbortRef.current === abort) batchAbortRef.current = null;
setBatchProgress(null);
}
}
async function importOne(p: PetItem) {
setBusyLib(p.lib);
try {
await importViaSse([p]);
} finally {
setBusyLib(null);
}
}
async function importSelected() {
const items = pets.filter((p) => selected.has(p.lib) && !p.nitroExists);
if (items.length === 0) return;
await importViaSse(items);
}
async function remove(p: PetItem) {
setBusyLib(p.lib);
try {
const res = await adminFetch(
`/api/admin/import/pets?lib=${encodeURIComponent(p.lib)}`,
{
method: "DELETE",
},
);
const data = await res.json();
if (res.ok) {
toast.success(`${p.lib} removed`);
setPets((prev) =>
prev.map((x) => (x.lib === p.lib ? { ...x, nitroExists: false } : x)),
);
} else {
toast.error(data.error || "Delete failed");
}
} finally {
setBusyLib(null);
}
}
return (
<div className="space-y-4">
{pets.length > 0 && (
<div className="flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-1.5 text-sm">
<Package className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{pets.length}</span>
<span className="text-muted-foreground">pets</span>
</div>
<div className="flex items-center gap-1.5 text-sm">
<Check className="h-4 w-4 text-[var(--admin-success)]" />
<span className="font-medium text-[var(--admin-success)]">
{importedCount}
</span>
<span className="text-muted-foreground">imported</span>
</div>
<div className="flex items-center gap-1.5 text-sm">
<Download className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{importable.length}</span>
<span className="text-muted-foreground">missing</span>
</div>
</div>
)}
<div className="flex gap-2 flex-wrap">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search pets..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") fetchPets(searchTerm);
if (e.key === "Escape") {
setSearchTerm("");
fetchPets("");
}
}}
className="pl-9 pr-8"
/>
{searchTerm && (
<button
type="button"
onClick={() => {
setSearchTerm("");
fetchPets("");
}}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
<Button
onClick={() => fetchPets(searchTerm)}
size="sm"
disabled={loading}
>
{loading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Search className="w-4 h-4" />
)}
</Button>
</div>
{importable.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
<Button
variant="outline"
size="sm"
className="text-xs h-8"
disabled={!!batchProgress}
onClick={() =>
setSelected((prev) =>
prev.size === importable.length
? new Set()
: new Set(importable.map((p) => p.lib)),
)
}
>
{selected.size === importable.length
? "Deselect All"
: `Select All (${importable.length})`}
</Button>
{selected.size > 0 && (
<Button
size="sm"
className="h-8"
onClick={importSelected}
disabled={!!batchProgress}
>
{batchProgress ? (
<Loader2 className="w-4 h-4 animate-spin mr-1" />
) : (
<Download className="w-4 h-4 mr-1" />
)}
Import {selected.size} selected
</Button>
)}
{batchProgress && (
<>
<span className="text-xs text-muted-foreground">
{batchProgress.done}/{batchProgress.total}
</span>
<Button
size="sm"
variant="outline"
className="h-8"
onClick={() => batchAbortRef.current?.abort()}
>
Cancel
</Button>
</>
)}
</div>
)}
{error && (
<div className="rounded-lg border px-4 py-3 text-sm bg-destructive/10 border-destructive/20 text-destructive flex items-center gap-2">
<AlertCircle className="w-4 h-4 shrink-0" />
{error}
</div>
)}
{!loading && pets.length === 0 && !error && (
<div className="text-center py-12 text-muted-foreground">
<PawPrint className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p className="text-sm">No pets found</p>
</div>
)}
{pets.length > 0 && (
<div
className={cn(
"grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3",
loading && "opacity-50",
)}
>
{pets.map((p) => {
const isSelected = selected.has(p.lib);
return (
<div
key={p.lib}
className={cn(
"rounded-lg border overflow-hidden bg-card relative hover:border-border transition-all",
p.nitroExists && "ring-1 ring-[var(--admin-success)]/30",
isSelected && "ring-2 ring-primary border-primary",
)}
>
{!p.nitroExists && (
<button
type="button"
onClick={() => toggle(p.lib)}
className={cn(
"absolute top-1.5 left-1.5 z-10 w-5 h-5 rounded border-2 flex items-center justify-center transition-colors",
isSelected
? "bg-primary border-primary text-primary-foreground"
: "bg-background/80 border-muted-foreground/30 hover:border-muted-foreground",
)}
>
{isSelected && <Check className="w-3 h-3" />}
</button>
)}
<div className="flex items-center justify-center h-20 bg-accent/20">
<PetIcon lib={p.lib} />
</div>
<div className="px-2.5 py-2 border-t space-y-1">
<div className="text-xs font-bold truncate">{p.lib}</div>
<div className="pt-1">
{p.nitroExists ? (
<div className="flex items-center gap-1">
<Badge className="text-[10px] px-1.5 py-0 bg-[var(--admin-success-subtle)] text-[var(--admin-success)] border-0 gap-0.5 flex-1 justify-center">
<Check className="w-2.5 h-2.5" />
Imported
</Badge>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 shrink-0"
title="Remove pet"
disabled={busyLib === p.lib || !!batchProgress}
onClick={() => remove(p)}
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
) : (
<Button
size="sm"
variant="outline"
className="w-full text-xs h-7"
disabled={busyLib === p.lib || !!batchProgress}
onClick={() => importOne(p)}
>
{busyLib === p.lib ? (
<Loader2 className="w-3 h-3 animate-spin mr-1" />
) : (
<Download className="w-3 h-3 mr-1" />
)}
Import
</Button>
)}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
);
}
@@ -0,0 +1,242 @@
"use client";
import { Check, ImageIcon, Loader2, Wrench } from "lucide-react";
import { useCallback, useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { adminFetch } from "@/lib/admin-fetch";
interface RepairEvent {
type: string;
classname?: string;
status?: string;
index?: number;
total?: number;
succeeded?: number;
failed?: number;
skipped?: number;
message?: string;
warnings?: string[];
}
export function RepairIconsClient() {
const [running, setRunning] = useState(false);
const [progress, setProgress] = useState<{
done: number;
total: number;
} | null>(null);
const [log, setLog] = useState<string[]>([]);
const [result, setResult] = useState<{
succeeded: number;
failed: number;
skipped: number;
} | null>(null);
const abortRef = useRef<AbortController | null>(null);
const startRepair = useCallback(async () => {
setRunning(true);
setProgress(null);
setLog([]);
setResult(null);
const abort = new AbortController();
abortRef.current = abort;
try {
const res = await adminFetch("/api/admin/import/furni/repair-icons", {
method: "POST",
signal: abort.signal,
});
if (!res.body) {
toast.error("No response stream");
setRunning(false);
return;
}
if (!res.headers.get("content-type")?.includes("text/event-stream")) {
let errMsg = "Repair failed";
try {
const json = await res.json();
errMsg = json.error || errMsg;
} catch {
// ignore
}
toast.error(errMsg);
setRunning(false);
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
let total = 0;
let succeeded = 0;
let failed = 0;
let skipped = 0;
while (true) {
const { value, done: streamDone } = await reader.read();
if (streamDone) break;
buf += decoder.decode(value, { stream: true });
const parts = buf.split("\n\n");
buf = parts.pop() ?? "";
for (const part of parts) {
if (!part.startsWith("data: ")) continue;
try {
const evt = JSON.parse(part.slice(6)) as RepairEvent;
if (evt.type === "started") {
total = evt.total ?? 0;
setProgress({ done: 0, total });
setLog((prev) => [...prev, `Scanning ${total} furniture items…`]);
}
if (evt.type === "progress") {
const processed = (evt.index ?? 0) + 1;
setProgress({ done: processed, total });
if (evt.status === "skipped") {
skipped++;
} else if (evt.status === "done") {
succeeded++;
} else if (evt.status === "failed") {
failed++;
}
const icon =
evt.status === "done"
? "✓"
: evt.status === "failed"
? "✗"
: "–";
setLog((prev) => [
...prev,
`${icon} ${evt.classname ?? ""} (${evt.status})`,
]);
}
if (evt.type === "batch_complete") {
succeeded = evt.succeeded ?? succeeded;
failed = evt.failed ?? failed;
skipped = evt.skipped ?? skipped;
}
if (evt.type === "error") {
setLog((prev) => [
...prev,
`ERROR: ${evt.message ?? "Unknown error"}`,
]);
toast.error(evt.message ?? "Repair failed");
}
} catch {
// skip parse errors
}
}
}
setResult({ succeeded, failed, skipped });
if (succeeded > 0) toast.success(`${succeeded} icons downloaded`);
if (failed > 0) toast.error(`${failed} icons could not be obtained`);
} catch (err) {
if ((err as Error).name === "AbortError") {
toast.info("Repair cancelled");
} else {
toast.error("Repair request failed");
}
} finally {
setRunning(false);
abortRef.current = null;
}
}, []);
return (
<div className="space-y-4">
{!result && (
<div className="flex items-center gap-2 flex-wrap">
<Button onClick={startRepair} disabled={running} size="lg">
{running ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
) : (
<Wrench className="w-4 h-4 mr-2" />
)}
{running
? `Repairing… ${progress ? `${progress.done}/${progress.total}` : ""}`
: "Start Icon Repair"}
</Button>
{running && (
<Button
variant="outline"
size="lg"
onClick={() => abortRef.current?.abort()}
>
Cancel
</Button>
)}
</div>
)}
{progress && (
<div className="text-sm text-muted-foreground">
Processed {progress.done} / {progress.total} items
</div>
)}
{log.length > 0 && (
<div className="rounded-xl border bg-card p-4 max-h-80 overflow-x-auto overflow-y-auto space-y-0.5">
{log.map((line) => (
<div
key={line}
className="text-xs font-mono text-muted-foreground whitespace-nowrap"
>
{line}
</div>
))}
</div>
)}
{result && (
<div className="rounded-xl border bg-card p-5 space-y-3">
<div className="flex items-center gap-2">
<Check className="w-5 h-5 text-[var(--admin-success)]" />
<span className="text-lg font-semibold">Repair Complete</span>
</div>
<div className="text-sm space-y-1">
<p>
<span className="text-muted-foreground">Downloads:</span>{" "}
{result.succeeded}
</p>
<p>
<span className="text-muted-foreground">
Skipped (already had icon):
</span>{" "}
{result.skipped}
</p>
<p>
<span className="text-muted-foreground">Failed:</span>{" "}
{result.failed}
</p>
</div>
<Button
variant="outline"
onClick={() => {
setResult(null);
setLog([]);
}}
>
Run Again
</Button>
</div>
)}
{!running && !result && (
<div className="rounded-xl border bg-card p-6 text-center text-muted-foreground text-sm">
<ImageIcon className="w-10 h-10 mx-auto mb-3 opacity-40" />
<p>
Scans all furniture in the database and downloads any missing icon
files. Icons are extracted from local .nitro bundles or downloaded
from configured clone sources.
</p>
</div>
)}
</div>
);
}
@@ -0,0 +1,213 @@
"use client";
import { Check, CloudDownload, Loader2 } from "lucide-react";
import { useCallback, useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { adminFetch } from "@/lib/admin-fetch";
interface SyncEvent {
type: string;
classname?: string;
status?: string;
succeeded?: number;
failed?: number;
}
export function SyncAllClient() {
const [syncing, setSyncing] = useState(false);
const [progress, setProgress] = useState<{
done: number;
total: number;
} | null>(null);
const [log, setLog] = useState<string[]>([]);
const [result, setResult] = useState<{
succeeded: number;
failed: number;
sources: number;
} | null>(null);
const abortRef = useRef<AbortController | null>(null);
const startSync = useCallback(async () => {
setSyncing(true);
setProgress(null);
setLog([]);
setResult(null);
const abort = new AbortController();
abortRef.current = abort;
try {
const res = await adminFetch("/api/admin/import/clone/sync-all", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
signal: abort.signal,
});
if (!res.body) {
toast.error("No response stream");
setSyncing(false);
return;
}
if (!res.headers.get("content-type")?.includes("text/event-stream")) {
let errMsg = "Sync failed";
try {
const json = await res.json();
errMsg = json.error || errMsg;
} catch {
// ignore
}
toast.error(errMsg);
setSyncing(false);
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
let succeeded = 0;
let failed = 0;
let totalItems = 0;
while (true) {
const { value, done: streamDone } = await reader.read();
if (streamDone) break;
buf += decoder.decode(value, { stream: true });
const parts = buf.split("\n\n");
buf = parts.pop() ?? "";
for (const part of parts) {
if (!part.startsWith("data: ")) continue;
try {
const evt = JSON.parse(part.slice(6)) as SyncEvent;
if (
evt.type === "item_progress" &&
(evt.status === "done" || evt.status === "failed")
) {
totalItems++;
if (evt.status === "done") succeeded++;
else failed++;
setProgress({ done: succeeded + failed, total: totalItems });
setLog((prev) => [
...prev,
`${evt.status === "done" ? "✓" : "✗"} ${evt.classname ?? ""}`,
]);
}
if (evt.type === "batch_complete") {
succeeded = evt.succeeded ?? succeeded;
failed = evt.failed ?? failed;
}
} catch {
// skip parse errors
}
}
}
// Check for skipped state (all up to date)
try {
const parsed = JSON.parse(
buf.includes("data:") ? (buf.split("data:").pop() ?? "{}") : "{}",
);
if (parsed.skipped) {
toast.info(parsed.message || "All sources are up to date");
setResult({ succeeded: 0, failed: 0, sources: 0 });
setSyncing(false);
return;
}
} catch {
// not a skipped response
}
setResult({ succeeded, failed, sources: 1 });
if (succeeded > 0) toast.success(`${succeeded} items synced`);
if (failed > 0) toast.error(`${failed} items failed`);
} catch (err) {
if ((err as Error).name === "AbortError") {
toast.info("Sync cancelled");
} else {
toast.error("Sync request failed");
}
} finally {
setSyncing(false);
abortRef.current = null;
}
}, []);
return (
<div className="space-y-4">
{!result && (
<div className="flex items-center gap-2 flex-wrap">
<Button onClick={startSync} disabled={syncing} size="lg">
{syncing ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
) : (
<CloudDownload className="w-4 h-4 mr-2" />
)}
{syncing
? `Syncing… ${progress ? `${progress.done}/${progress.total}` : ""}`
: "Start Sync All Sources"}
</Button>
{syncing && (
<Button
variant="outline"
size="lg"
onClick={() => abortRef.current?.abort()}
>
Cancel
</Button>
)}
</div>
)}
{progress && (
<div className="text-sm text-muted-foreground">
Processed {progress.done} items
{progress.total > 0 ? ` out of ${progress.total}` : ""}
</div>
)}
{log.length > 0 && (
<div className="rounded-xl border bg-card p-4 max-h-80 overflow-x-auto overflow-y-auto space-y-0.5">
{log.map((line) => (
<div
key={line}
className="text-xs font-mono text-muted-foreground whitespace-nowrap"
>
{line}
</div>
))}
</div>
)}
{result && result.sources > 0 && (
<div className="rounded-xl border bg-card p-5 space-y-3">
<div className="flex items-center gap-2">
<Check className="w-5 h-5 text-[var(--admin-success)]" />
<span className="text-lg font-semibold">Sync Complete</span>
</div>
<div className="text-sm space-y-1">
<p>
<span className="text-muted-foreground">Succeeded:</span>{" "}
{result.succeeded}
</p>
<p>
<span className="text-muted-foreground">Failed:</span>{" "}
{result.failed}
</p>
</div>
</div>
)}
{!syncing && !result && (
<div className="rounded-xl border bg-card p-6 text-center text-muted-foreground text-sm">
<CloudDownload className="w-10 h-10 mx-auto mb-3 opacity-40" />
<p>
Click the button above to scan all clone sources for missing
furniture and import it automatically.
</p>
</div>
)}
</div>
);
}