feat(housekeeping): cut over administration to ase
This commit is contained in:
1 parent
c9e35cf602
commit
2b8f73a91d
457 files changed
+589
-55936
No files matched your search
@@ -1,926 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,555 +0,0 @@
|
||||
"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")}
|
||||
>
|
||||
← Prev
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!hasMore || loading}
|
||||
onClick={() => goPage("next")}
|
||||
>
|
||||
Next →
|
||||
</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
@@ -1,914 +0,0 @@
|
||||
"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")}
|
||||
>
|
||||
← Prev
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= lastPage || loading}
|
||||
onClick={() => goPage("next")}
|
||||
>
|
||||
Next →
|
||||
</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")}
|
||||
>
|
||||
← Prev
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= lastPage || loading}
|
||||
onClick={() => goPage("next")}
|
||||
>
|
||||
Next →
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,431 +0,0 @@
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
// @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"',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,921 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,375 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,390 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,242 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user