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,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>
)
}