"use client"; import { Check, CloudDownload, Download, Loader2, Package, Pencil, Plus, Search, Trash2, X, } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { useConfirmDialog } from "@/components/admin/confirm-dialog"; import { ImportErrorBanner } from "@/components/admin/import-error-banner"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { adminFetch } from "@/lib/admin-fetch"; import { HABBO_GAMEDATA_HOTELS } from "@/lib/habbo-gamedata-hotel"; import { runSseImport } from "@/lib/sse-client"; import { cn } from "@/lib/utils"; // ── Types ───────────────────────────────────────────────────────────────────── interface CloneSource { id: string; name: string; furnidataUrl: string; nitroBaseUrl: string; iconBaseUrl: string; hotel: string; } interface FurniItem { classname: string; name: string; description?: string; revision?: number; category?: string; itemType: "s" | "i"; xdim: number; ydim: number; present: boolean; } interface CloneStats { total: number; present: number; clonable: number; } interface PageMeta { page: number; perPage: number; total: number; } // ── Source form ─────────────────────────────────────────────────────────────── interface SourceFormProps { initial?: Partial; onSave: (src: Partial) => Promise; onCancel: () => void; saving: boolean; } function SourceForm({ initial, onSave, onCancel, saving }: SourceFormProps) { const [name, setName] = useState(initial?.name ?? ""); const [furnidataUrl, setFurnidataUrl] = useState(initial?.furnidataUrl ?? ""); const [nitroBaseUrl, setNitroBaseUrl] = useState(initial?.nitroBaseUrl ?? ""); const [iconBaseUrl, setIconBaseUrl] = useState(initial?.iconBaseUrl ?? ""); const [hotel, setHotel] = useState(initial?.hotel ?? ""); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); await onSave({ id: initial?.id, name, furnidataUrl, nitroBaseUrl, iconBaseUrl, hotel, }); } return (
{initial?.id ? "Edit Source" : "Add Source"}
setName(e.target.value)} required /> setFurnidataUrl(e.target.value)} required /> setNitroBaseUrl(e.target.value)} required /> setIconBaseUrl(e.target.value)} required />
); } // ── Sources manager ─────────────────────────────────────────────────────────── interface SourcesManagerProps { sources: CloneSource[]; activeSourceId: string | null; onSelect: (src: CloneSource) => void; onRefresh: () => Promise; } function SourcesManager({ sources, activeSourceId, onSelect, onRefresh, }: SourcesManagerProps) { const [showForm, setShowForm] = useState(false); const [editSrc, setEditSrc] = useState(null); const [saving, setSaving] = useState(false); const [deletingId, setDeletingId] = useState(null); async function handleSave(src: Partial) { setSaving(true); try { const res = await adminFetch("/api/admin/import/clone", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(src), }); const data = await res.json(); if (!res.ok) { toast.error(data.error || "Save failed"); return; } toast.success(src.id ? "Source updated" : "Source added"); setShowForm(false); setEditSrc(null); await onRefresh(); } finally { setSaving(false); } } async function handleDelete(src: CloneSource) { setDeletingId(src.id); try { const res = await adminFetch( `/api/admin/import/clone?id=${encodeURIComponent(src.id)}`, { method: "DELETE", }, ); const data = await res.json(); if (!res.ok) { toast.error(data.error || "Delete failed"); return; } toast.success(`${src.name} removed`); await onRefresh(); } finally { setDeletingId(null); } } return (
Clone Sources
{showForm && !editSrc && ( setShowForm(false)} saving={saving} /> )} {sources.length === 0 && !showForm && (
No sources yet. Add one to start cloning furni from another hotel.
)} {sources.map((src) => (
{editSrc?.id === src.id ? ( setEditSrc(null)} saving={saving} /> ) : (
)}
))}
); } // ── Furni icon with fallback ────────────────────────────────────────────────── function FurniIcon({ sourceId, iconBaseUrl, classname, }: { sourceId: string; iconBaseUrl: string; classname: string; }) { // 0 = source's standalone icon, 1 = icon extracted from the .nitro (our // endpoint), 2 = give up and show a placeholder. const [stage, setStage] = useState(0); if (stage >= 2) { return ; } const src = stage === 0 ? `${iconBaseUrl}/${classname}_icon.png` : `/api/admin/import/clone/icon?source=${encodeURIComponent(sourceId)}&classname=${encodeURIComponent(classname)}`; return ( {classname} setStage((s) => s + 1)} /> ); } // ── Furni grid view ─────────────────────────────────────────────────────────── interface FurniGridProps { source: CloneSource; } const PER_PAGE = 50; function FurniGrid({ source }: FurniGridProps) { const { confirm, dialog: confirmDialog } = useConfirmDialog(); const [searchTerm, setSearchTerm] = useState(""); const [activeSearch, setActiveSearch] = useState(""); const [items, setItems] = useState([]); const [stats, setStats] = useState(null); const [meta, setMeta] = useState(null); const [page, setPage] = useState(1); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); const [selected, setSelected] = useState>(new Set()); const [batchProgress, setBatchProgress] = useState<{ done: number; total: number; } | null>(null); const [busyClassname, setBusyClassname] = useState(null); const [filter, setFilter] = useState<"all" | "missing" | "present">("all"); const batchAbortRef = useRef(null); const fetchItems = useCallback( async ( search: string, pageNum: number, filterVal: "all" | "missing" | "present" = "all", ) => { setLoading(true); setError(""); try { const params = new URLSearchParams({ source: source.id, page: String(pageNum), perPage: String(PER_PAGE), }); if (search) params.set("search", search); if (filterVal !== "all") params.set("filter", filterVal); const res = await adminFetch(`/api/admin/import/clone?${params}`); const data = await res.json(); if (!res.ok) { setError(data.error || "Failed to load"); return; } setItems(data.items || []); setMeta(data.meta || null); setStats(data.stats || null); setSelected(new Set()); } catch { setError("Network error"); } finally { setLoading(false); } }, [source.id], ); useEffect(() => { setPage(1); setSearchTerm(""); setActiveSearch(""); setItems([]); setStats(null); setMeta(null); setFilter("all"); fetchItems("", 1, "all"); }, [fetchItems]); function doSearch() { setActiveSearch(searchTerm); setPage(1); fetchItems(searchTerm, 1, filter); } function goPage(dir: "prev" | "next") { const next = dir === "next" ? page + 1 : Math.max(1, page - 1); setPage(next); fetchItems(activeSearch, next, filter); } function changeFilter(f: "all" | "missing" | "present") { setFilter(f); setPage(1); fetchItems(activeSearch, 1, f); } const importable = items.filter((it) => !it.present); const total = meta?.total ?? 0; const lastPage = Math.max(Math.ceil(total / PER_PAGE), 1); function toggle(classname: string) { setSelected((prev) => { const n = new Set(prev); if (n.has(classname)) n.delete(classname); else n.add(classname); return n; }); } function markDone(classname: string) { setItems((prev) => prev.map((it) => it.classname === classname ? { ...it, present: true } : it, ), ); } async function cloneViaSse(toClone: FurniItem[]) { batchAbortRef.current?.abort(); const abort = new AbortController(); batchAbortRef.current = abort; setBatchProgress({ done: 0, total: toClone.length }); let done = 0; try { await runSseImport( "/api/admin/import/clone/batch", { sourceId: source.id, classnames: toClone.map((it) => it.classname), concurrency: 10, }, (classname) => { done++; markDone(classname); setBatchProgress({ done, total: toClone.length }); }, (succeeded, failed) => { setSelected(new Set()); if (succeeded > 0) toast.success(`${succeeded} furni cloned`); if (failed > 0) toast.error(`${failed} failed`); fetchItems(activeSearch, page, filter); }, abort.signal, () => { done++; setBatchProgress({ done, total: toClone.length }); }, ); } catch (err) { if ((err as Error)?.name === "AbortError") { toast.info("Clone cancelled"); } else { throw err; } } finally { if (batchAbortRef.current === abort) batchAbortRef.current = null; setBatchProgress(null); } } async function cloneOne(it: FurniItem) { setBusyClassname(it.classname); try { await cloneViaSse([it]); } finally { setBusyClassname(null); } } async function cloneSelected() { const toClone = items.filter( (it) => selected.has(it.classname) && !it.present, ); if (toClone.length === 0) return; await cloneViaSse(toClone); } // Clone EVERY not-yet-present furni from the source (across all pages), // in chunks of 400 to stay under the batch endpoint's 500 cap. async function cloneAll() { let names: string[] = []; try { const res = await adminFetch( `/api/admin/import/clone?source=${encodeURIComponent(source.id)}&action=clonable`, ); const data = await res.json(); if (!res.ok) { toast.error(data.error || "Failed to load clonable list"); return; } names = data.classnames || []; } catch { toast.error("Network error"); return; } if (names.length === 0) { toast.info("Nothing to clone — everything is already present"); return; } const confirmed = await confirm({ title: "Clone All Furni", description: `Clone ALL ${names.length} furni from "${source.name}"? This can take a while.`, confirmLabel: "Clone All", }); if (!confirmed) return; batchAbortRef.current?.abort(); const abort = new AbortController(); batchAbortRef.current = abort; const CHUNK = 400; setBatchProgress({ done: 0, total: names.length }); let done = 0; let okCount = 0; let failCount = 0; try { for (let i = 0; i < names.length; i += CHUNK) { if (abort.signal.aborted) break; const chunk = names.slice(i, i + CHUNK); await runSseImport( "/api/admin/import/clone/batch", { sourceId: source.id, classnames: chunk, concurrency: 10 }, (classname) => { done++; markDone(classname); setBatchProgress({ done, total: names.length }); }, (succeeded, failed) => { okCount += succeeded; failCount += failed; }, abort.signal, () => { done++; setBatchProgress({ done, total: names.length }); }, ); } if (abort.signal.aborted) { toast.info("Clone cancelled"); } else { if (okCount > 0) toast.success(`${okCount} furni cloned`); if (failCount > 0) toast.error(`${failCount} failed`); } setSelected(new Set()); fetchItems(activeSearch, page, filter); } catch (err) { if ((err as Error)?.name === "AbortError") { toast.info("Clone cancelled"); } else { throw err; } } finally { if (batchAbortRef.current === abort) batchAbortRef.current = null; setBatchProgress(null); } } return (
{confirmDialog} {/* Stats bar */} {stats && (
{stats.total} total
{stats.present} present
{stats.clonable} clonable
)} {/* Search bar */}
setSearchTerm(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") doSearch(); if (e.key === "Escape") { setSearchTerm(""); setActiveSearch(""); setPage(1); fetchItems("", 1, filter); } }} className="pl-9 pr-8" /> {searchTerm && ( )}
{/* Filter: all / not-present / present */}
{( [ { key: "all", label: "All", count: stats?.total }, { key: "missing", label: "Not present (don't have)", count: stats?.clonable, }, { key: "present", label: "Present", count: stats?.present }, ] as const ).map((f) => ( ))}
{/* Bulk actions */} {(importable.length > 0 || (stats?.clonable ?? 0) > 0) && (
{importable.length > 0 && ( )} {selected.size > 0 && ( )} {(stats?.clonable ?? 0) > 0 && ( )} {batchProgress && ( <> {batchProgress.done}/{batchProgress.total} )}
)} {/* Error */} {error && } {/* Empty state */} {!loading && items.length === 0 && !error && (

No furni found{activeSearch ? ` for "${activeSearch}"` : ""}

)} {/* Grid */} {items.length > 0 && (
{items.map((it) => { const isSelected = selected.has(it.classname); return (
{!it.present && ( )} {/* Type badge */}
{it.itemType === "s" ? "floor" : "wall"}
{/* Hover detail tooltip */}
{it.description ? (

{it.description}

) : null}
{it.revision ? Rev: {it.revision} : null} {it.itemType === "s" ? "Floor" : "Wall"} {it.xdim}×{it.ydim}
{it.name || it.classname}
{it.classname}
{it.itemType === "s" ? "Floor" : "Wall"} ·{" "} {it.category || "other"}
{it.present ? ( Present ) : ( )}
); })}
)} {/* Pagination */} {total > PER_PAGE && (
Page {page} / {lastPage}
)}
); } // ── Root component ──────────────────────────────────────────────────────────── export function ImportCloneClient() { const [sources, setSources] = useState([]); const [activeSource, setActiveSource] = useState(null); const [loadingSources, setLoadingSources] = useState(true); const fetchSources = useCallback(async () => { try { const res = await adminFetch("/api/admin/import/clone?action=sources"); const data = await res.json(); if (res.ok) { setSources(data.sources || []); } } catch { /* ignore */ } finally { setLoadingSources(false); } }, []); useEffect(() => { fetchSources(); }, [fetchSources]); return (
{/* Sources panel */}
{loadingSources ? (
Loading sources…
) : ( )}
{/* Furni grid panel */} {activeSource ? (
{activeSource.name}
) : (
Select a source above to browse and clone furni.
)}
); }