'use client' import { AlertCircle, Check, Download, Loader2, Package, Search, Shirt, Trash2, X, } from 'lucide-react' import { useCallback, useEffect, useMemo, 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' // ── 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 // ── Shared SSE reader ───────────────────────────────────────────────────────── async function runSseImport( url: string, body: unknown, onDone: (label: 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) } // ── 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 = { 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 = { ...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 } const figure = buildPreviewFigure(type, id) const headOnly = HEAD_TYPES.has(type) ? '&headonly=1' : '' return ( // biome-ignore lint/performance/noImgElement: dynamic avatar imaging, not a Next asset {`${type}-${id}`} setFailed(true)} /> ) } // ── Librerie (v1) view ──────────────────────────────────────────────────────── function LibsView() { const [searchTerm, setSearchTerm] = useState('') const [activeSearch, setActiveSearch] = useState('') const [items, setItems] = useState([]) const [page, setPage] = useState(1) const [total, setTotal] = useState(0) 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 [busyLib, setBusyLib] = useState(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 fetch(`/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[]) { setBatchProgress({ done: 0, total: toImport.length }) let done = 0 await runSseImport( '/api/admin/import/clothing/batch', { items: toImport, concurrency: 3 }, (label) => { done++ markDone(label) setBatchProgress({ done, total: toImport.length }) }, (succeeded, failed) => { setBatchProgress(null) setSelected(new Set()) if (succeeded > 0) toast.success(`${succeeded} libraries imported`) if (failed > 0) toast.error(`${failed} failed`) }, ) 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 fetch(`/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 (
{(items.length > 0 || total > 0) && (
{total} libraries
{importedCount} on this page
{importable.length} missing here
)}
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 && ( )}
{importable.length > 0 && (
{selected.size > 0 && ( )} {batchProgress && ( {batchProgress.done}/{batchProgress.total} )}
)} {error && (
{error}
)} {!loading && items.length === 0 && !error && (

No libraries found{activeSearch ? ` for "${activeSearch}"` : ''}

)} {items.length > 0 && (
{items.map((p) => { const isSelected = selected.has(p.lib) return (
{!p.nitroExists && ( )}
{p.lib}
{p.nitroExists ? (
Imported
) : ( )}
) })}
)} {total > PER_PAGE && (
Page {page} / {lastPage}
)}
) } // ── Vestiti / Sets (v2) view ────────────────────────────────────────────────── function SetsView() { const [searchTerm, setSearchTerm] = useState('') const [activeSearch, setActiveSearch] = useState('') const [items, setItems] = useState([]) const [page, setPage] = useState(1) const [total, setTotal] = useState(0) 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 [busyKey, setBusyKey] = useState(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 fetch(`/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[]) { setBatchProgress({ done: 0, total: toImport.length }) let done = 0 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) => { setBatchProgress(null) setSelected(new Set()) if (succeeded > 0) toast.success(`${succeeded} sets imported`) if (failed > 0) toast.error(`${failed} failed`) }, ) 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 (
{(items.length > 0 || total > 0) && (
{total} sets
{presentCount} on this page
{importable.length} missing here
)}
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 && ( )}
{importable.length > 0 && (
{selected.size > 0 && ( )} {batchProgress && ( {batchProgress.done}/{batchProgress.total} )}
)} {error && (
{error}
)} {!loading && items.length === 0 && !error && (

No sets found{activeSearch ? ` for "${activeSearch}"` : ''}

)} {items.length > 0 && (
{items.map((it) => { const key = itemKey(it) const isSelected = selected.has(key) return (
{!it.present && ( )}
{it.setType}-{it.setId}
{it.gender} {it.club > 0 && ( HC )} {it.sellable && ( $ )}
{it.present ? ( Imported ) : ( )}
) })}
)} {total > PER_PAGE && (
Page {page} / {lastPage}
)}
) } // ── Root component with mode toggle ────────────────────────────────────────── export function ImportClothingClient() { const [mode, setMode] = useState('libs') return (
{mode === 'libs' ? : }
) }