Reapply "Add missing admin action files and navigation links"

This reverts commit 4d515bc400.
This commit is contained in:
Simo committed 2026-07-11 20:52:56 +02:00
1 parent 96ed768f14
commit 5b4228261a
338 files changed
+28143 -5948

No files matched your search

@@ -0,0 +1,108 @@
'use client'
import { Loader2 } from 'lucide-react'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { getBadgeData, updateBadge } from '@/actions/badges'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useServerAction } from '@/hooks/use-server-action'
interface BadgeEditorDialogProps {
code: string
open: boolean
onOpenChange: (open: boolean) => void
/** Called after a successful save so the parent can refresh state */
onSaved?: () => void
}
export function BadgeEditorDialog({ code, open, onOpenChange, onSaved }: BadgeEditorDialogProps) {
const [name, setName] = useState('')
const [desc, setDesc] = useState('')
const [loading, setLoading] = useState(false)
const { run, isPending: saving } = useServerAction()
// Fetch current values when dialog opens
useEffect(() => {
if (!open || !code) return
setLoading(true)
getBadgeData({ code })
.then((result) => {
if (result.ok) {
const data = result.data as { name: string; desc: string }
setName(data.name ?? '')
setDesc(data.desc ?? '')
}
})
.catch(() => {
toast.error('Failed to load badge data')
})
.finally(() => setLoading(false))
}, [open, code])
function handleSave() {
run(() => updateBadge({ code, name, desc }), {
successMessage: `Badge ${code} updated`,
errorMessage: 'Save failed',
onSuccess: () => {
onSaved?.()
onOpenChange(false)
},
})
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">Edit Badge — {code}</DialogTitle>
</DialogHeader>
{loading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="badge-name">Name</Label>
<Input
id="badge-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Badge name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="badge-desc">Description</Label>
<Input
id="badge-desc"
value={desc}
onChange={(e) => setDesc(e.target.value)}
placeholder="Badge description"
/>
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
Cancel
</Button>
<Button onClick={handleSave} disabled={loading || saving}>
{saving && <Loader2 className="w-4 h-4 animate-spin mr-1" />}
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,496 @@
'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 { importBadgeFromRemote } from '@/actions/import-badges'
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 { 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 doImport(badge: BadgeItem) {
setImportingCode(badge.code)
try {
const result = await importBadgeFromRemote({
code: badge.code,
name: badge.name,
description: badge.description,
})
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 || `Failed to import ${badge.code}`)
}
} 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 importBadgeFromRemote({
code: badge.code,
name: badge.name,
description: badge.description,
})
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-emerald-500" />
<span className="font-medium text-emerald-600">{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 }).map((_, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: static skeleton placeholders
<div key={i} 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 && 'shadow-[0_0_6px_rgba(34,197,94,0.15)]',
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-emerald-500/15 text-emerald-500 border-0 gap-0.5 flex-1 justify-center">
<Check className="w-2.5 h-2.5" />
Imported
</Badge>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 shrink-0"
title="Edit badge name/description"
onClick={() => setEditingCode(badge.code)}
>
<Pencil className="w-3 h-3" />
</Button>
</div>
) : (
<Button
size="sm"
variant="outline"
className="w-full text-xs h-7"
onClick={() => doImport(badge)}
disabled={importingCode === badge.code || batchImporting}
>
{importingCode === badge.code ? (
<Loader2 className="w-3 h-3 animate-spin mr-1" />
) : (
<Download className="w-3 h-3 mr-1" />
)}
Import
</Button>
)}
</div>
</div>
</div>
)
})}
</div>
)}
{/* Pagination */}
{(offset > 0 || hasMore) && (
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Page {currentPage}</span>
<div className="flex gap-1">
<Button
variant="outline"
size="sm"
disabled={offset <= 0 || loading}
onClick={() => goPage('prev')}
>
&larr; Prev
</Button>
<Button
variant="outline"
size="sm"
disabled={!hasMore || loading}
onClick={() => goPage('next')}
>
Next &rarr;
</Button>
</div>
</div>
)}
{/* Badge Editor Dialog */}
<BadgeEditorDialog
code={editingCode ?? ''}
open={!!editingCode}
onOpenChange={(open) => {
if (!open) setEditingCode(null)
}}
/>
</div>
</TooltipProvider>
)
}
+21
View File
@@ -0,0 +1,21 @@
import { Skeleton } from '@/components/ui/skeleton'
export default function ImportBadgesLoading() {
return (
<div className="space-y-6">
<div>
<Skeleton className="h-9 w-48" />
<Skeleton className="h-5 w-72 mt-2" />
</div>
<div className="rounded-lg border bg-card p-6 space-y-4">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-10 w-full" />
</div>
))}
<Skeleton className="h-10 w-24 mt-4" />
</div>
</div>
)
}
+12
View File
@@ -0,0 +1,12 @@
import { redirect } from 'next/navigation'
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
import { ImportBadgesClient } from './import-badges-client'
export default async function ImportBadgesPage(_props: { params: Promise<{ locale: string }> }) {
const { session, permissions } = await getAdminContext()
if (!canAccess(permissions, PERMS.SETTINGS_EDIT, session.user.rank)) {
redirect('/admin')
}
return <ImportBadgesClient />
}
@@ -0,0 +1,887 @@
'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<void> {
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<CloneSource>
onSave: (src: Partial<CloneSource>) => Promise<void>
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 (
<form onSubmit={handleSubmit} className="space-y-3 rounded-lg border p-4 bg-card">
<div className="text-sm font-medium">{initial?.id ? 'Edit Source' : 'Add Source'}</div>
<div className="grid gap-2">
<Input
placeholder="Name (e.g. HotelX)"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
<Input
placeholder="FurnitureData URL (e.g. https://x/FurnitureData.json)"
value={furnidataUrl}
onChange={(e) => setFurnidataUrl(e.target.value)}
required
/>
<Input
placeholder="Nitro base URL (e.g. https://x/bundled/furniture)"
value={nitroBaseUrl}
onChange={(e) => setNitroBaseUrl(e.target.value)}
required
/>
<Input
placeholder="Icon base URL (e.g. https://x/icons)"
value={iconBaseUrl}
onChange={(e) => setIconBaseUrl(e.target.value)}
required
/>
</div>
<div className="flex gap-2">
<Button type="submit" size="sm" disabled={saving}>
{saving ? <Loader2 className="w-3 h-3 animate-spin mr-1" /> : null}
Save
</Button>
<Button type="button" variant="outline" size="sm" onClick={onCancel} disabled={saving}>
Cancel
</Button>
</div>
</form>
)
}
// ── Sources manager ───────────────────────────────────────────────────────────
interface SourcesManagerProps {
sources: CloneSource[]
activeSourceId: string | null
onSelect: (src: CloneSource) => void
onRefresh: () => Promise<void>
}
function SourcesManager({ sources, activeSourceId, onSelect, onRefresh }: SourcesManagerProps) {
const [showForm, setShowForm] = useState(false)
const [editSrc, setEditSrc] = useState<CloneSource | null>(null)
const [saving, setSaving] = useState(false)
const [deletingId, setDeletingId] = useState<string | null>(null)
async function handleSave(src: Partial<CloneSource>) {
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 (
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">Clone Sources</span>
<Button
size="sm"
variant="outline"
className="h-7 text-xs"
onClick={() => {
setEditSrc(null)
setShowForm(true)
}}
>
<Plus className="w-3 h-3 mr-1" />
Add Source
</Button>
</div>
{showForm && !editSrc && (
<SourceForm onSave={handleSave} onCancel={() => setShowForm(false)} saving={saving} />
)}
{sources.length === 0 && !showForm && (
<div className="text-center py-6 text-muted-foreground text-sm">
<CloudDownload className="w-8 h-8 mx-auto mb-2 opacity-40" />
No sources yet. Add one to start cloning furni from another hotel.
</div>
)}
{sources.map((src) => (
<div key={src.id}>
{editSrc?.id === src.id ? (
<SourceForm
initial={src}
onSave={handleSave}
onCancel={() => setEditSrc(null)}
saving={saving}
/>
) : (
<div
className={cn(
'w-full rounded-lg border px-3 py-2.5 flex items-center gap-2 transition-colors hover:border-border',
activeSourceId === src.id && 'border-primary ring-1 ring-primary bg-primary/5',
)}
>
<button
type="button"
className="flex-1 min-w-0 flex items-center gap-2 text-left cursor-pointer"
onClick={() => onSelect(src)}
>
<CloudDownload className="w-4 h-4 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{src.name}</div>
<div className="text-xs text-muted-foreground truncate">{src.furnidataUrl}</div>
</div>
</button>
<div className="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
title="Edit source"
onClick={() => {
setEditSrc(src)
setShowForm(false)
}}
>
<Pencil className="w-3 h-3" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-destructive hover:text-destructive"
title="Delete source"
disabled={deletingId === src.id}
onClick={() => handleDelete(src)}
>
{deletingId === src.id ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<Trash2 className="w-3 h-3" />
)}
</Button>
</div>
</div>
)}
</div>
))}
</div>
)
}
// ── 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 <Package className="w-8 h-8 text-muted-foreground/50" />
}
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
<img
key={stage}
src={src}
alt={classname}
className="max-h-full max-w-full object-contain image-pixelated"
onError={() => 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<FurniItem[]>([])
const [stats, setStats] = useState<CloneStats | null>(null)
const [meta, setMeta] = useState<PageMeta | null>(null)
const [page, setPage] = useState(1)
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 [busyClassname, setBusyClassname] = useState<string | null>(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.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[]) {
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 (
<div className="space-y-4">
{/* Stats bar */}
{stats && (
<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">{stats.total}</span>
<span className="text-muted-foreground">total</span>
</div>
<div className="flex items-center gap-1.5 text-sm">
<Check className="h-4 w-4 text-emerald-500" />
<span className="font-medium text-emerald-600">{stats.present}</span>
<span className="text-muted-foreground">present</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">{stats.clonable}</span>
<span className="text-muted-foreground">clonable</span>
</div>
</div>
)}
{/* Search bar */}
<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 classname or name…"
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, filter)
}
}}
className="pl-9 pr-8"
/>
{searchTerm && (
<button
type="button"
onClick={() => {
setSearchTerm('')
setActiveSearch('')
setPage(1)
fetchItems('', 1, filter)
}}
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>
{/* Filter: all / not-present / present */}
<div className="flex items-center gap-1 flex-wrap">
{(
[
{ 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) => (
<Button
key={f.key}
type="button"
size="sm"
variant={filter === f.key ? 'default' : 'outline'}
className="h-7 text-xs"
disabled={loading || !!batchProgress}
onClick={() => changeFilter(f.key)}
>
{f.label}
{typeof f.count === 'number' ? ` (${f.count})` : ''}
</Button>
))}
</div>
{/* Bulk actions */}
{(importable.length > 0 || (stats?.clonable ?? 0) > 0) && (
<div className="flex items-center gap-2 flex-wrap">
{importable.length > 0 && (
<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) => it.classname)),
)
}
>
{selected.size === importable.length
? 'Deselect page'
: `Select page (${importable.length})`}
</Button>
)}
{selected.size > 0 && (
<Button
variant="outline"
size="sm"
className="h-8"
onClick={cloneSelected}
disabled={!!batchProgress}
>
{batchProgress ? (
<Loader2 className="w-4 h-4 animate-spin mr-1" />
) : (
<CloudDownload className="w-4 h-4 mr-1" />
)}
Clone {selected.size} selected
</Button>
)}
{(stats?.clonable ?? 0) > 0 && (
<Button size="sm" className="h-8" onClick={cloneAll} disabled={!!batchProgress}>
{batchProgress ? (
<Loader2 className="w-4 h-4 animate-spin mr-1" />
) : (
<CloudDownload className="w-4 h-4 mr-1" />
)}
Clone all clonable ({stats?.clonable})
</Button>
)}
{batchProgress && (
<span className="text-xs text-muted-foreground">
{batchProgress.done}/{batchProgress.total}
</span>
)}
</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 state */}
{!loading && items.length === 0 && !error && (
<div className="text-center py-12 text-muted-foreground">
<CloudDownload className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p className="text-sm">No furni found{activeSearch ? ` for "${activeSearch}"` : ''}</p>
</div>
)}
{/* Grid */}
{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 isSelected = selected.has(it.classname)
return (
<div
key={it.classname}
className={cn(
'group rounded-lg border overflow-hidden bg-card relative hover:border-border transition-all',
it.present && 'shadow-[0_0_6px_rgba(34,197,94,0.15)]',
isSelected && 'ring-2 ring-primary border-primary',
)}
>
{!it.present && (
<button
type="button"
onClick={() => toggle(it.classname)}
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>
)}
{/* Type badge */}
<div className="absolute top-1.5 right-1.5 z-10">
<Badge variant="outline" className="text-[9px] px-1 py-0 bg-background/80">
{it.itemType === 's' ? 'floor' : 'wall'}
</Badge>
</div>
<div className="relative flex items-center justify-center h-28 bg-accent/20">
<FurniIcon
sourceId={source.id}
iconBaseUrl={source.iconBaseUrl}
classname={it.classname}
/>
{/* Hover detail tooltip */}
<div className="absolute bottom-1 left-1 right-1 z-10 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none">
<div className="bg-black/80 backdrop-blur-sm rounded px-2 py-1.5 text-[10px] text-white space-y-0.5">
{it.description ? <p className="truncate">{it.description}</p> : null}
<div className="flex gap-2 text-white/70">
{it.revision ? <span>Rev: {it.revision}</span> : null}
<span>{it.itemType === 's' ? 'Floor' : 'Wall'}</span>
<span>
{it.xdim}×{it.ydim}
</span>
</div>
</div>
</div>
</div>
<div className="px-2.5 py-2 border-t space-y-1">
<div className="text-xs font-bold truncate" title={it.name}>
{it.name || it.classname}
</div>
<div className="text-[10px] text-muted-foreground truncate" title={it.classname}>
{it.classname}
</div>
<div className="text-[10px] text-muted-foreground truncate">
{it.itemType === 's' ? 'Floor' : 'Wall'} · {it.category || 'other'}
</div>
<div className="pt-1">
{it.present ? (
<Badge className="text-[10px] px-1.5 py-0 bg-emerald-500/15 text-emerald-500 border-0 gap-0.5 w-full justify-center">
<Check className="w-2.5 h-2.5" />
Present
</Badge>
) : (
<Button
size="sm"
variant="outline"
className="w-full text-xs h-7"
disabled={busyClassname === it.classname || !!batchProgress}
onClick={() => cloneOne(it)}
>
{busyClassname === it.classname ? (
<Loader2 className="w-3 h-3 animate-spin mr-1" />
) : (
<CloudDownload className="w-3 h-3 mr-1" />
)}
Clone
</Button>
)}
</div>
</div>
</div>
)
})}
</div>
)}
{/* Pagination */}
{total > PER_PAGE && (
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
Page {page} / {lastPage}
</span>
<div className="flex gap-1">
<Button
variant="outline"
size="sm"
disabled={page <= 1 || loading}
onClick={() => goPage('prev')}
>
&larr; Prev
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= lastPage || loading}
onClick={() => goPage('next')}
>
Next &rarr;
</Button>
</div>
</div>
)}
</div>
)
}
// ── Root component ────────────────────────────────────────────────────────────
export function ImportCloneClient() {
const [sources, setSources] = useState<CloneSource[]>([])
const [activeSource, setActiveSource] = useState<CloneSource | null>(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 (
<div className="space-y-6">
{/* Sources panel */}
<div className="rounded-xl border bg-card p-4 space-y-4">
{loadingSources ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" />
Loading sources…
</div>
) : (
<SourcesManager
sources={sources}
activeSourceId={activeSource?.id ?? null}
onSelect={setActiveSource}
onRefresh={fetchSources}
/>
)}
</div>
{/* Furni grid panel */}
{activeSource ? (
<div className="rounded-xl border bg-card p-4">
<div className="flex items-center gap-2 mb-4">
<CloudDownload className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium">{activeSource.name}</span>
</div>
<FurniGrid source={activeSource} />
</div>
) : (
<div className="rounded-xl border bg-card p-8 text-center text-muted-foreground text-sm">
Select a source above to browse and clone furni.
</div>
)}
</div>
)
}
+11
View File
@@ -0,0 +1,11 @@
import { redirect } from 'next/navigation'
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
import { ImportCloneClient } from './import-clone-client'
export default async function ImportClonePage(_props: { params: Promise<{ locale: string }> }) {
const { session, permissions } = await getAdminContext()
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect('/admin')
}
return <ImportCloneClient />
}
@@ -0,0 +1,838 @@
'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<void> {
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<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)
const headOnly = HEAD_TYPES.has(type) ? '&headonly=1' : ''
return (
// biome-ignore lint/performance/noImgElement: dynamic avatar imaging, not a Next asset
<img
src={`/api/imaging/avatar?figure=${encodeURIComponent(figure)}&size=l&direction=2&head_direction=3${headOnly}`}
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 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 (
<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-emerald-500" />
<span className="font-medium text-emerald-600">{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>
)}
</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 && 'shadow-[0_0_6px_rgba(34,197,94,0.15)]',
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-emerald-500/15 text-emerald-500 border-0 gap-0.5 flex-1 justify-center">
<Check className="w-2.5 h-2.5" />
Imported
</Badge>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 shrink-0"
title="Remove library"
disabled={busyLib === p.lib || !!batchProgress}
onClick={() => remove(p)}
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
) : (
<Button
size="sm"
variant="outline"
className="w-full text-xs h-7"
disabled={busyLib === p.lib || !!batchProgress}
onClick={() => importOne(p)}
>
{busyLib === p.lib ? (
<Loader2 className="w-3 h-3 animate-spin mr-1" />
) : (
<Download className="w-3 h-3 mr-1" />
)}
Import
</Button>
)}
</div>
</div>
</div>
)
})}
</div>
)}
{total > PER_PAGE && (
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
Page {page} / {lastPage}
</span>
<div className="flex gap-1">
<Button
variant="outline"
size="sm"
disabled={page <= 1 || loading}
onClick={() => goPage('prev')}
>
&larr; Prev
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= lastPage || loading}
onClick={() => goPage('next')}
>
Next &rarr;
</Button>
</div>
</div>
)}
</div>
)
}
// ── Vestiti / Sets (v2) view ──────────────────────────────────────────────────
function SetsView() {
const [searchTerm, setSearchTerm] = useState('')
const [activeSearch, setActiveSearch] = useState('')
const [items, setItems] = useState<ClothingSetItem[]>([])
const [page, setPage] = useState(1)
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [selected, setSelected] = useState<Set<string>>(new Set())
const [batchProgress, setBatchProgress] = useState<{ done: number; total: number } | null>(null)
const [busyKey, setBusyKey] = useState<string | null>(null)
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 (
<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-emerald-500" />
<span className="font-medium text-emerald-600">{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>
)}
</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 && 'shadow-[0_0_6px_rgba(34,197,94,0.15)]',
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-yellow-500/20 text-yellow-600 border-0">
HC
</Badge>
)}
{it.sellable && (
<Badge className="text-[10px] px-1.5 py-0 bg-blue-500/15 text-blue-500 border-0">
$
</Badge>
)}
</div>
<div className="pt-1">
{it.present ? (
<Badge className="text-[10px] px-1.5 py-0 bg-emerald-500/15 text-emerald-500 border-0 gap-0.5 w-full justify-center">
<Check className="w-2.5 h-2.5" />
Imported
</Badge>
) : (
<Button
size="sm"
variant="outline"
className="w-full text-xs h-7"
disabled={busyKey === key || !!batchProgress}
onClick={() => importOne(it)}
>
{busyKey === key ? (
<Loader2 className="w-3 h-3 animate-spin mr-1" />
) : (
<Download className="w-3 h-3 mr-1" />
)}
Import
</Button>
)}
</div>
</div>
</div>
)
})}
</div>
)}
{total > PER_PAGE && (
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
Page {page} / {lastPage}
</span>
<div className="flex gap-1">
<Button
variant="outline"
size="sm"
disabled={page <= 1 || loading}
onClick={() => goPage('prev')}
>
&larr; Prev
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= lastPage || loading}
onClick={() => goPage('next')}
>
Next &rarr;
</Button>
</div>
</div>
)}
</div>
)
}
// ── Root component with mode toggle ──────────────────────────────────────────
export function ImportClothingClient() {
const [mode, setMode] = useState<Mode>('libs')
return (
<div className="space-y-4">
<div className="flex gap-1 rounded-lg border p-1 w-fit">
<Button
size="sm"
variant={mode === 'libs' ? 'default' : 'ghost'}
className="h-7 text-xs"
onClick={() => setMode('libs')}
>
Librerie
</Button>
<Button
size="sm"
variant={mode === 'sets' ? 'default' : 'ghost'}
className="h-7 text-xs"
onClick={() => setMode('sets')}
>
Vestiti (set)
</Button>
</div>
{mode === 'libs' ? <LibsView /> : <SetsView />}
</div>
)
}
+13
View File
@@ -0,0 +1,13 @@
export default function Loading() {
return (
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
{Array.from({ length: 18 }).map((_, i) => (
<div key={`figure-skeleton-${i}`} 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-7 bg-muted rounded" />
</div>
))}
</div>
)
}
+11
View File
@@ -0,0 +1,11 @@
import { redirect } from 'next/navigation'
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
import { ImportClothingClient } from './import-clothing-client'
export default async function ImportClothingPage(_props: { params: Promise<{ locale: string }> }) {
const { session, permissions } = await getAdminContext()
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect('/admin')
}
return <ImportClothingClient />
}
@@ -0,0 +1,394 @@
// 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, 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'
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 fetchEffects = useCallback(async (search: string) => {
setLoading(true)
setError('')
try {
const params = new URLSearchParams()
if (search) params.set('search', search)
const res = await fetch(`/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[]) {
setBatchProgress({ done: 0, total: items.length })
const res = await fetch('/api/admin/import/effects/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items, concurrency: 3 }),
})
if (!res.body) {
toast.error('No response stream')
setBatchProgress(null)
return
}
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buf = ''
let done = 0
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') {
done++
succeeded++
markDone(evt.classname)
setBatchProgress({ done, total: items.length })
} else if (evt.type === 'item_progress' && evt.status === 'failed') {
done++
failed++
setBatchProgress({ done, total: items.length })
} else if (evt.type === 'batch_complete') {
succeeded = evt.succeeded
failed = evt.failed
}
}
}
setBatchProgress(null)
setSelected(new Set())
if (succeeded > 0) toast.success(`${succeeded} effect(s) imported`)
if (failed > 0) toast.error(`${failed} failed`)
}
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 fetch(`/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-emerald-500" />
<span className="font-medium text-emerald-600">{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>
)}
</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 && 'shadow-[0_0_6px_rgba(34,197,94,0.15)]',
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-amber-500/25 to-orange-500/10'
: 'bg-gradient-to-br from-violet-500/25 to-fuchsia-500/10',
)}
>
{e.type === 'dance' ? (
<Music className="w-7 h-7 text-amber-500/80" />
) : (
<Sparkles className="w-7 h-7 text-violet-500/80" />
)}
<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-emerald-500/15 text-emerald-500 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>
)
}
+14
View File
@@ -0,0 +1,14 @@
// src/app/[locale]/admin/import/effects/loading.tsx
export default function Loading() {
return (
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
{Array.from({ length: 18 }).map((_, i) => (
<div key={`effect-skeleton-${i}`} 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-7 bg-muted rounded" />
</div>
))}
</div>
)
}
+12
View File
@@ -0,0 +1,12 @@
// src/app/[locale]/admin/import/effects/page.tsx
import { redirect } from 'next/navigation'
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
import { ImportEffectsClient } from './import-effects-client'
export default async function ImportEffectsPage(_props: { params: Promise<{ locale: string }> }) {
const { session, permissions } = await getAdminContext()
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect('/admin')
}
return <ImportEffectsClient />
}
File diff suppressed because it is too large. Load diff
+21
View File
@@ -0,0 +1,21 @@
import { Skeleton } from '@/components/ui/skeleton'
export default function ImportFurniLoading() {
return (
<div className="space-y-6">
<div>
<Skeleton className="h-9 w-48" />
<Skeleton className="h-5 w-72 mt-2" />
</div>
<div className="rounded-lg border bg-card p-6 space-y-4">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-10 w-full" />
</div>
))}
<Skeleton className="h-10 w-24 mt-4" />
</div>
</div>
)
}
@@ -0,0 +1,755 @@
'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'
interface NitroEditorDialogProps {
classname: string
open: boolean
onOpenChange: (open: boolean) => void
}
// biome-ignore lint/suspicious/noExplicitAny: explicitly chosen here, see surrounding code
function updateNested(obj: any, path: string, value: unknown): any {
const clone = JSON.parse(JSON.stringify(obj))
const keys = path.split('.')
let current = clone
for (let i = 0; i < keys.length - 1; i++) {
if (!current[keys[i]]) current[keys[i]] = {}
current = current[keys[i]]
}
current[keys[keys.length - 1]] = value
return clone
}
// biome-ignore lint/suspicious/noExplicitAny: explicitly chosen here, see surrounding code
function getNested(obj: any, path: string, fallback?: unknown): any {
const keys = path.split('.')
let current = obj
for (const key of keys) {
if (current == null || typeof current !== 'object') return fallback
current = current[key]
}
return current ?? fallback
}
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-green-500 shrink-0" />
) : (
<Copy className="w-3 h-3 shrink-0" />
)}
</button>
)
}
export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditorDialogProps) {
// biome-ignore lint/suspicious/noExplicitAny: explicitly chosen here, see surrounding code
const [metadata, setMetadata] = useState<any>(null)
// biome-ignore lint/suspicious/noExplicitAny: explicitly chosen here, see surrounding code
const [originalMetadata, setOriginalMetadata] = useState<any>(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 [jsonError, setJsonError] = useState<string | null>(null)
const fetchMetadata = useCallback(async () => {
if (!classname) return
setLoading(true)
setJsonError(null)
try {
const res = await fetch(
`/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 fetch('/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 salvato')
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: number[] = 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
setMetadata((prev: unknown) => updateNested(prev, `logic.model.dimensions.${axis}`, num))
}
const toggleDirection = (dir: number) => {
const current: number[] = 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
setMetadata((prev: unknown) => updateNested(prev, 'logic.model.directions', next))
}
// Get visualization with size=64 for layer/color editing
// biome-ignore lint/suspicious/noExplicitAny: explicitly chosen here, see surrounding code
const viz64 = metadata?.visualizations?.find((v: any) => v.size === 64)
const viz64Index =
metadata?.visualizations?.findIndex((v: { size: number }) => 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} colori
</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">Dimensioni</h3>
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<Label htmlFor="dim-x" className="text-xs text-muted-foreground">
X (larghezza)
</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 (profondità)
</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 (altezza)
</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">Interazione</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>
</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">Direzioni</h3>
<span className="text-xs text-muted-foreground ml-auto">
{directions.length} attive
</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">Tipo</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) =>
setMetadata((prev: unknown) =>
updateNested(prev, '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) =>
setMetadata((prev: unknown) =>
updateNested(prev, '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) => {
setMetadata((prev: unknown) =>
updateNested(
prev,
`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) => {
setMetadata((prev: unknown) =>
updateNested(
prev,
`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) => {
setMetadata((prev: unknown) =>
updateNested(
prev,
`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} varianti
</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">Variante #{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
setMetadata((prev: unknown) =>
updateNested(
prev,
`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]"
>
Modifiche non salvate
</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}>
Annulla
</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" />}
Salva
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
+12
View File
@@ -0,0 +1,12 @@
import { redirect } from 'next/navigation'
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
import { ImportFurniClient } from './import-furni-client'
export default async function ImportFurniPage(_props: { params: Promise<{ locale: string }> }) {
const { session, permissions } = await getAdminContext()
if (!canAccess(permissions, PERMS.CATALOG_EDIT, session.user.rank)) {
redirect('/admin')
}
return <ImportFurniClient />
}
@@ -0,0 +1,356 @@
'use client'
import {
AlertCircle,
Check,
Download,
Loader2,
Package,
PawPrint,
Search,
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'
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 (
// biome-ignore lint/performance/noImgElement: tiny dynamic sprite, not a Next asset
<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 fetchPets = useCallback(async (search: string) => {
setLoading(true)
setError('')
try {
const params = new URLSearchParams()
if (search) params.set('search', search)
const res = await fetch(`/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[]) {
setBatchProgress({ done: 0, total: items.length })
const res = await fetch('/api/admin/import/pets/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items, concurrency: 3 }),
})
if (!res.body) {
toast.error('No response stream')
setBatchProgress(null)
return
}
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buf = ''
let done = 0
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') {
done++
markDone(evt.classname)
setBatchProgress({ done, total: items.length })
} else if (evt.type === 'item_progress' && evt.status === 'failed') {
done++
setBatchProgress({ done, total: items.length })
} else if (evt.type === 'batch_complete') {
succeeded = evt.succeeded
failed = evt.failed
}
}
}
setBatchProgress(null)
setSelected(new Set())
if (succeeded > 0) toast.success(`${succeeded} pet(s) imported`)
if (failed > 0) toast.error(`${failed} failed`)
}
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 fetch(`/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-emerald-500" />
<span className="font-medium text-emerald-600">{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>
)}
</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 && 'shadow-[0_0_6px_rgba(34,197,94,0.15)]',
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-emerald-500/15 text-emerald-500 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>
)
}
+13
View File
@@ -0,0 +1,13 @@
export default function Loading() {
return (
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
{Array.from({ length: 18 }).map((_, i) => (
<div key={`pet-skeleton-${i}`} 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-7 bg-muted rounded" />
</div>
))}
</div>
)
}
+11
View File
@@ -0,0 +1,11 @@
import { redirect } from 'next/navigation'
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
import { ImportPetsClient } from './import-pets-client'
export default async function ImportPetsPage(_props: { params: Promise<{ locale: string }> }) {
const { session, permissions } = await getAdminContext()
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect('/admin')
}
return <ImportPetsClient />
}
+12
View File
@@ -0,0 +1,12 @@
import { redirect } from 'next/navigation'
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
import { RepairClient } from './repair-client'
export default async function RepairImportedPage() {
const { session, permissions } = await getAdminContext()
if (!canAccess(permissions, PERMS.CATALOG_EDIT, session.user.rank)) {
redirect('/admin')
}
return <RepairClient />
}
@@ -0,0 +1,757 @@
'use client'
import {
AlertCircle,
CheckCircle2,
Loader2,
Play,
RefreshCw,
Search,
Trash2,
Wrench,
XCircle,
} from 'lucide-react'
import { useCallback, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { deleteImportedFurni } from '@/actions/import-furni'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
type MissingKind = 'nitro' | 'icon' | 'both'
interface MissingItem {
id: number
classname: string
baseClassname: string
revision: number
type: 's' | 'i'
publicName: string
hasSwf: boolean
hasIcon: boolean
hasNitro: boolean
missing: MissingKind
unrepairable: boolean
}
interface AuditSummary {
total: number
missingBoth: number
missingNitro: number
missingIcon: number
unrepairable: number
items: MissingItem[]
}
type ItemStatus = 'idle' | 'working' | 'done' | 'partial' | 'failed'
interface ItemProgress {
status: ItemStatus
message?: string
nitroRepaired?: boolean
iconRepaired?: boolean
unrepairable?: boolean
}
interface RepairSummary {
succeeded: number
partial: number
failed: number
unrepairable: number
duration: number
}
const TAB_FILTERS: Record<string, (m: MissingItem) => boolean> = {
all: () => true,
both: (m) => m.missing === 'both',
nitro: (m) => m.missing === 'nitro',
icon: (m) => m.missing === 'icon',
unrepairable: (m) => m.unrepairable,
}
export function RepairClient() {
const [summary, setSummary] = useState<AuditSummary | null>(null)
const [auditing, setAuditing] = useState(false)
const [repairing, setRepairing] = useState(false)
const [search, setSearch] = useState('')
const [tab, setTab] = useState<keyof typeof TAB_FILTERS>('all')
const [selected, setSelected] = useState<Set<string>>(new Set())
const [progress, setProgress] = useState<Map<string, ItemProgress>>(new Map())
const [lastSummary, setLastSummary] = useState<RepairSummary | null>(null)
const [confirmDeleteAll, setConfirmDeleteAll] = useState(false)
// ── Audit ──────────────────────────────────────────────────────────
const runAudit = useCallback(async () => {
setAuditing(true)
setLastSummary(null)
try {
const res = await fetch('/api/admin/import/repair/audit', { method: 'POST' })
if (!res.ok) {
toast.error('Audit failed')
return
}
const data = await res.json()
setSummary(data)
setSelected(new Set())
setProgress(new Map())
toast.success(
`Audit: ${data.items.length} item${data.items.length === 1 ? '' : 's'} need repair (${data.total} scanned)`,
)
} catch {
toast.error('Audit network error')
} finally {
setAuditing(false)
}
}, [])
// ── Filters ────────────────────────────────────────────────────────
const filtered = useMemo(() => {
if (!summary) return []
const s = search.trim().toLowerCase()
return summary.items.filter((it) => {
if (!TAB_FILTERS[tab](it)) return false
if (!s) return true
return (
it.classname.toLowerCase().includes(s) ||
it.publicName.toLowerCase().includes(s) ||
String(it.id).includes(s)
)
})
}, [summary, search, tab])
// ── Selection ──────────────────────────────────────────────────────
const allSelected = filtered.length > 0 && filtered.every((i) => selected.has(i.classname))
const toggleAll = () => {
setSelected((prev) => {
if (allSelected) {
const next = new Set(prev)
for (const i of filtered) {
next.delete(i.classname)
}
return next
}
const next = new Set(prev)
for (const i of filtered) {
next.add(i.classname)
}
return next
})
}
const toggleOne = (cn: string) => {
setSelected((prev) => {
const next = new Set(prev)
if (next.has(cn)) next.delete(cn)
else next.add(cn)
return next
})
}
// ── Repair ─────────────────────────────────────────────────────────
const runRepair = useCallback(async (classnames: string[]) => {
if (classnames.length === 0) return
setRepairing(true)
setLastSummary(null)
// Init progress for these items
setProgress((prev) => {
const next = new Map(prev)
for (const cn of classnames) {
next.set(cn, { status: 'working' })
}
return next
})
try {
const res = await fetch('/api/admin/import/repair', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ classnames, concurrency: 6 }),
})
if (!res.ok || !res.body) {
toast.error('Failed to start repair')
setRepairing(false)
return
}
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (!line.startsWith('data: ')) continue
try {
const event = JSON.parse(line.slice(6))
if (event.type === 'repair_progress') {
setProgress((prev) => {
const next = new Map(prev)
next.set(event.classname, {
status: event.status as ItemStatus,
message: event.error || (event.warnings?.[0] ?? undefined),
nitroRepaired: event.nitroRepaired,
iconRepaired: event.iconRepaired,
unrepairable: event.unrepairable,
})
return next
})
// Promote newly-discovered unrepairable flags onto the audit list
if (event.unrepairable) {
setSummary((prev) => {
if (!prev) return prev
const item = prev.items.find((i) => i.classname === event.classname)
if (!item || item.unrepairable) return prev
return {
...prev,
unrepairable: prev.unrepairable + 1,
items: prev.items.map((i) =>
i.classname === event.classname ? { ...i, unrepairable: true } : i,
),
}
})
}
// Remove repaired items from the audit list on "done"
if (event.status === 'done') {
setSummary((prev) => {
if (!prev) return prev
const items = prev.items.filter((i) => i.classname !== event.classname)
const diff = prev.items.length - items.length
if (diff === 0) return prev
const removed = prev.items.find((i) => i.classname === event.classname)
return {
...prev,
items,
missingBoth: prev.missingBoth - (removed?.missing === 'both' ? 1 : 0),
missingNitro: prev.missingNitro - (removed?.missing === 'nitro' ? 1 : 0),
missingIcon: prev.missingIcon - (removed?.missing === 'icon' ? 1 : 0),
}
})
setSelected((prev) => {
const next = new Set(prev)
next.delete(event.classname)
return next
})
}
} else if (event.type === 'repair_complete') {
setLastSummary({
succeeded: event.succeeded,
partial: event.partial,
failed: event.failed,
unrepairable: event.unrepairable ?? 0,
duration: event.duration,
})
}
} catch {
/* skip malformed */
}
}
}
} catch {
toast.error('Connection lost during repair')
} finally {
setRepairing(false)
}
}, [])
// ── Delete unrepairable items ──────────────────────────────────────
const deleteSelected = useCallback(async () => {
const toDelete = Array.from(selected)
if (toDelete.length === 0) return
setConfirmDeleteAll(false)
let ok = 0
let fail = 0
for (const cn of toDelete) {
setProgress((prev) => {
const next = new Map(prev)
next.set(cn, { status: 'working', message: 'Deleting…' })
return next
})
try {
const result = await deleteImportedFurni({ classname: cn })
if (result.ok) {
ok++
setSummary((prev) => {
if (!prev) return prev
const removed = prev.items.find((i) => i.classname === cn)
if (!removed) return prev
return {
...prev,
total: prev.total - 1,
items: prev.items.filter((i) => i.classname !== cn),
missingBoth: prev.missingBoth - (removed.missing === 'both' ? 1 : 0),
missingNitro: prev.missingNitro - (removed.missing === 'nitro' ? 1 : 0),
missingIcon: prev.missingIcon - (removed.missing === 'icon' ? 1 : 0),
}
})
setSelected((prev) => {
const next = new Set(prev)
next.delete(cn)
return next
})
} else {
fail++
setProgress((prev) => {
const next = new Map(prev)
next.set(cn, { status: 'failed', message: 'Delete failed' })
return next
})
}
} catch {
fail++
setProgress((prev) => {
const next = new Map(prev)
next.set(cn, { status: 'failed', message: 'Delete error' })
return next
})
}
}
toast.success(`Deleted ${ok}${fail > 0 ? `, ${fail} failed` : ''}`)
}, [selected])
// ── Render ─────────────────────────────────────────────────────────
const selectedArray = Array.from(selected)
const selectedCount = selectedArray.length
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Repair Imported Assets</h1>
<p className="text-muted-foreground">
Re-download missing <code className="rounded bg-muted px-1">.nitro</code> bundles and
icons for items already imported. Items with no public asset on habbo.com can be deleted
from here.
</p>
</div>
<div className="flex gap-2">
<Button onClick={runAudit} disabled={auditing || repairing}>
{auditing ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Scanning…
</>
) : (
<>
<RefreshCw className="mr-2 h-4 w-4" /> {summary ? 'Re-scan' : 'Scan Assets'}
</>
)}
</Button>
</div>
</div>
{/* Initial empty state */}
{!summary && !auditing && (
<div className="rounded-md border bg-muted/30 p-10 text-center">
<Wrench className="mx-auto mb-3 h-10 w-10 text-muted-foreground" />
<p className="font-medium">
Click &ldquo;Scan Assets&rdquo; to audit imported furniture.
</p>
<p className="text-sm text-muted-foreground">
This checks every row in <code>items_base</code> against files on disk. Scanning ~33k
items takes a few seconds.
</p>
</div>
)}
{/* Stats */}
{summary && (
<div className="grid grid-cols-2 gap-4 md:grid-cols-5">
<StatCard label="Imported total" value={summary.total} tone="default" />
<StatCard
label="Missing both"
value={summary.missingBoth}
tone="danger"
sub="no .nitro · no icon"
/>
<StatCard
label="Missing .nitro"
value={summary.missingNitro}
tone="warn"
sub="shows black cube"
/>
<StatCard
label="Missing icon"
value={summary.missingIcon}
tone="info"
sub="generic icon in client"
/>
<StatCard
label="Unrepairable"
value={summary.unrepairable}
tone="danger"
sub="no asset on habbo.com"
/>
</div>
)}
{/* Batch repair summary */}
{lastSummary && (
<div className="rounded-md border bg-muted/30 p-3 text-sm">
Last run: <b>{lastSummary.succeeded}</b> fixed
{lastSummary.partial > 0 && (
<>
, <b>{lastSummary.partial}</b> partial
</>
)}
{lastSummary.failed > 0 && (
<>
, <b className="text-destructive">{lastSummary.failed}</b> failed
</>
)}
{lastSummary.unrepairable > 0 && (
<>
, <b className="text-destructive">{lastSummary.unrepairable}</b> unrepairable
</>
)}
&nbsp;·&nbsp;{(lastSummary.duration / 1000).toFixed(1)}s
</div>
)}
{/* Table area */}
{summary && summary.items.length === 0 && (
<div className="rounded-md border bg-muted/30 p-10 text-center">
<CheckCircle2 className="mx-auto mb-3 h-10 w-10 text-emerald-500" />
<p className="font-medium">All imported items have their assets on disk.</p>
<p className="text-sm text-muted-foreground">
{summary.total} items scanned, nothing to repair.
</p>
</div>
)}
{summary && summary.items.length > 0 && (
<>
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-2">
<div className="relative flex-1 min-w-[240px]">
<Search className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Filter by classname, name, or id…"
className="pl-8"
/>
</div>
<Button
variant="default"
onClick={() => {
// Skip unrepairable items — no point paying the network cost for them
const pool = selectedCount > 0 ? selectedArray : filtered.map((i) => i.classname)
const unrep =
summary?.items.filter((i) => i.unrepairable).map((i) => i.classname) ?? []
const unrepSet = new Set(unrep)
const targets = pool.filter((cn) => !unrepSet.has(cn))
if (targets.length === 0) {
toast.info('Nothing to repair — selected items are all flagged unrepairable.')
return
}
runRepair(targets)
}}
disabled={repairing || filtered.length === 0}
>
{repairing ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Repairing…
</>
) : (
<>
<Play className="mr-2 h-4 w-4" />
{selectedCount > 0
? `Repair Selected (${selectedCount})`
: `Repair All Visible (${filtered.length})`}
</>
)}
</Button>
<Button
variant="destructive"
onClick={() => setConfirmDeleteAll(true)}
disabled={repairing || selectedCount === 0}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete Selected ({selectedCount})
</Button>
{summary && summary.unrepairable > 0 && (
<Button
variant="destructive"
onClick={() => {
// Auto-select every unrepairable item, then open the same confirm dialog
const unrep = summary.items.filter((i) => i.unrepairable).map((i) => i.classname)
setSelected(new Set(unrep))
setConfirmDeleteAll(true)
}}
disabled={repairing}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete All Unrepairable ({summary.unrepairable})
</Button>
)}
</div>
{/* Tabs */}
<Tabs value={tab} onValueChange={(v) => setTab(v as keyof typeof TAB_FILTERS)}>
<TabsList>
<TabsTrigger value="all">All ({summary.items.length})</TabsTrigger>
<TabsTrigger value="both">Both ({summary.missingBoth})</TabsTrigger>
<TabsTrigger value="nitro">.nitro ({summary.missingNitro})</TabsTrigger>
<TabsTrigger value="icon">Icon ({summary.missingIcon})</TabsTrigger>
<TabsTrigger value="unrepairable">Unrepairable ({summary.unrepairable})</TabsTrigger>
</TabsList>
<TabsContent value={tab} className="mt-4">
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[40px]">
<Checkbox
checked={allSelected}
onCheckedChange={toggleAll}
aria-label="Select all"
/>
</TableHead>
<TableHead className="w-[80px]">ID</TableHead>
<TableHead>Classname</TableHead>
<TableHead className="w-[90px]">Rev</TableHead>
<TableHead className="w-[100px]">.nitro</TableHead>
<TableHead className="w-[100px]">Icon</TableHead>
<TableHead className="w-[100px]">SWF</TableHead>
<TableHead className="w-[240px]">Status</TableHead>
<TableHead className="w-[120px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.length === 0 && (
<TableRow>
<TableCell colSpan={9} className="py-10 text-center text-muted-foreground">
No items match the current filter.
</TableCell>
</TableRow>
)}
{filtered.map((it) => (
<ItemRow
key={it.classname}
item={it}
selected={selected.has(it.classname)}
onToggle={() => toggleOne(it.classname)}
progress={progress.get(it.classname)}
onRepair={() => runRepair([it.classname])}
disabled={repairing}
/>
))}
</TableBody>
</Table>
</div>
</TabsContent>
</Tabs>
</>
)}
<Dialog open={confirmDeleteAll} onOpenChange={setConfirmDeleteAll}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
Delete {selectedCount} imported item{selectedCount === 1 ? '' : 's'}?
</DialogTitle>
<DialogDescription>
This removes the rows from <code>items_base</code> / <code>catalog_items</code>, the
entry in <code>FurnitureData.json</code>, and any remaining asset files on disk. Use
this for items that have no public asset on habbo.com and therefore cannot be
repaired. This cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setConfirmDeleteAll(false)}>
Cancel
</Button>
<Button variant="destructive" onClick={deleteSelected}>
<Trash2 className="mr-2 h-4 w-4" /> Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
// ── Small cell helpers ────────────────────────────────────────────────
function StatCard({
label,
value,
tone,
sub,
}: {
label: string
value: number
tone: 'default' | 'danger' | 'warn' | 'info'
sub?: string
}) {
const tones = {
default: 'border-border',
danger: 'border-destructive/40 bg-destructive/5',
warn: 'border-amber-500/40 bg-amber-500/5',
info: 'border-sky-500/40 bg-sky-500/5',
}
return (
<div className={`rounded-md border p-4 ${tones[tone]}`}>
<div className="text-xs uppercase tracking-wide text-muted-foreground">{label}</div>
<div className="mt-1 text-2xl font-bold">{value.toLocaleString()}</div>
{sub && <div className="mt-0.5 text-xs text-muted-foreground">{sub}</div>}
</div>
)
}
function FileBadge({ present }: { present: boolean }) {
return present ? (
<Badge
variant="secondary"
className="border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-400"
>
<CheckCircle2 className="mr-1 h-3 w-3" /> On disk
</Badge>
) : (
<Badge variant="secondary" className="border-destructive/40 bg-destructive/10 text-destructive">
<XCircle className="mr-1 h-3 w-3" /> Missing
</Badge>
)
}
function StatusCell({ progress }: { progress?: ItemProgress }) {
if (!progress || progress.status === 'idle')
return <span className="text-muted-foreground text-xs">—</span>
if (progress.status === 'working') {
return (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
{progress.message || 'Working…'}
</span>
)
}
if (progress.status === 'done') {
const parts = [
progress.nitroRepaired ? '.nitro ✓' : null,
progress.iconRepaired ? 'icon ✓' : null,
].filter(Boolean)
return (
<span className="flex items-center gap-1 text-xs text-emerald-600 dark:text-emerald-400">
<CheckCircle2 className="h-3 w-3" />
{parts.length > 0 ? parts.join(' · ') : 'Repaired'}
</span>
)
}
if (progress.status === 'partial') {
return (
<span
className="flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400"
title={progress.message}
>
<AlertCircle className="h-3 w-3" />
Partial · {progress.message || 'check logs'}
</span>
)
}
return (
<span className="flex items-center gap-1 text-xs text-destructive" title={progress.message}>
<XCircle className="h-3 w-3" />
{progress.message || 'Failed'}
</span>
)
}
function ItemRow({
item,
selected,
onToggle,
progress,
onRepair,
disabled,
}: {
item: MissingItem
selected: boolean
onToggle: () => void
progress?: ItemProgress
onRepair: () => void
disabled: boolean
}) {
const isUnrepairable = item.unrepairable || progress?.unrepairable
return (
<TableRow className={isUnrepairable ? 'bg-destructive/5' : undefined}>
<TableCell>
<Checkbox
checked={selected}
onCheckedChange={onToggle}
aria-label={`Select ${item.classname}`}
/>
</TableCell>
<TableCell className="font-mono text-xs">{item.id}</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<span className="font-mono text-sm">{item.classname}</span>
{isUnrepairable && (
<Badge
variant="secondary"
className="border-destructive/40 bg-destructive/10 text-destructive"
>
<AlertCircle className="mr-1 h-3 w-3" /> Unrepairable
</Badge>
)}
</div>
<div className="text-xs text-muted-foreground">{item.publicName}</div>
</TableCell>
<TableCell className="text-xs">{item.revision || '—'}</TableCell>
<TableCell>
<FileBadge present={item.hasNitro} />
</TableCell>
<TableCell>
<FileBadge present={item.hasIcon} />
</TableCell>
<TableCell>
<FileBadge present={item.hasSwf} />
</TableCell>
<TableCell>
<StatusCell progress={progress} />
</TableCell>
<TableCell>
<Button
size="sm"
variant="outline"
onClick={onRepair}
disabled={disabled || progress?.status === 'working' || isUnrepairable}
title={isUnrepairable ? 'habbo.com does not have this asset' : 'Retry download + convert'}
>
{progress?.status === 'working' ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Wrench className="h-3 w-3" />
)}
</Button>
</TableCell>
</TableRow>
)
}