"use client"; import { AlertCircle, Check, CloudDownload, Download, Loader2, Package, Pencil, Plus, Search, Trash2, X, } 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 { Input } from "@/components/ui/input"; import { cn } from "@/lib/utils"; // ── Types ───────────────────────────────────────────────────────────────────── interface CloneSource { id: string; name: string; furnidataUrl: string; nitroBaseUrl: string; iconBaseUrl: 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; } // ── Shared SSE reader (mirrors import-clothing-client.tsx) ──────────────────── async function runSseImport( url: string, body: unknown, onDone: (classname: string) => void, onComplete: (succeeded: number, failed: number) => void, ): Promise { const res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (!res.body) { toast.error("No response stream"); return; } const reader = res.body.getReader(); const decoder = new TextDecoder(); let buf = ""; let succeeded = 0; let failed = 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; const evt = JSON.parse(part.slice(6)); if ( evt.type === "item_progress" && (evt.status === "done" || evt.status === "failed") ) { onDone(evt.classname); } if (evt.type === "batch_complete") { succeeded = evt.succeeded; failed = evt.failed; } } } onComplete(succeeded, failed); } // ── 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 ?? ""); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); await onSave({ id: initial?.id, name, furnidataUrl, nitroBaseUrl, iconBaseUrl, }); } 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 fetch("/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 fetch( `/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 ( // biome-ignore lint/performance/noImgElement: remote icon from clone source, not a Next asset {classname} setStage((s) => s + 1)} /> ); } // ── Furni grid view ─────────────────────────────────────────────────────────── interface FurniGridProps { source: CloneSource; } const PER_PAGE = 50; function FurniGrid({ source }: FurniGridProps) { 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 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 fetch(`/api/admin/import/clone?${params}`); const data = await res.json(); if (!res.ok) { setError(data.error || "Failed to load"); return; } setItems(data.item || []); 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[]) { setBatchProgress({ done: 0, total: toClone.length }); let done = 0; await runSseImport( "/api/admin/import/clone/batch", { sourceId: source.id, classnames: toClone.map((it) => it.classname), concurrency: 2, }, (classname) => { done++; markDone(classname); setBatchProgress({ done, total: toClone.length }); }, (succeeded, failed) => { setBatchProgress(null); setSelected(new Set()); if (succeeded > 0) toast.success(`${succeeded} furni cloned`); if (failed > 0) toast.error(`${failed} failed`); // Refresh stats after batch fetchItems(activeSearch, page, filter); }, ); 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 fetch( `/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; } if ( !window.confirm( `Clone ALL ${names.length} furni from "${source.name}"? This can take a while.`, ) ) { return; } const CHUNK = 400; setBatchProgress({ done: 0, total: names.length }); let done = 0; let okCount = 0; let failCount = 0; for (let i = 0; i < names.length; i += CHUNK) { const chunk = names.slice(i, i + CHUNK); await runSseImport( "/api/admin/import/clone/batch", { sourceId: source.id, classnames: chunk, concurrency: 2 }, (classname) => { done++; markDone(classname); setBatchProgress({ done, total: names.length }); }, (succeeded, failed) => { okCount += succeeded; failCount += failed; }, ); } setBatchProgress(null); setSelected(new Set()); if (okCount > 0) toast.success(`${okCount} furni cloned`); if (failCount > 0) toast.error(`${failCount} failed`); fetchItems(activeSearch, page, filter); } return (
{/* 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 && (
{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 fetch("/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.
)}
); }