revert fb8e77bb68
Local Build and Deploy / deploy (push) Successful in 1m11s

revert style: clean up code with prettier and eslint
This commit is contained in:
remco committed 2026-07-12 21:02:03 +02:00
1 parent 27de078f54
commit e85e4d74ea
378 files changed
+20710 -22428

No files matched your search

@@ -1,56 +1,62 @@
"use client";
'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";
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;
code: string
open: boolean
onOpenChange: (open: boolean) => void
/** Called after a successful save so the parent can refresh state */
onSaved?: () => void;
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();
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);
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 ?? "");
const data = result.data as { name: string; desc: string }
setName(data.name ?? '')
setDesc(data.desc ?? '')
}
})
.catch(() => {
toast.error("Failed to load badge data");
toast.error('Failed to load badge data')
})
.finally(() => setLoading(false));
}, [open, code]);
.finally(() => setLoading(false))
}, [open, code])
function handleSave() {
run(() => updateBadge({ code, name, desc }), {
successMessage: `Badge ${code} updated`,
errorMessage: "Save failed",
errorMessage: 'Save failed',
onSuccess: () => {
onSaved?.();
onOpenChange(false);
onSaved?.()
onOpenChange(false)
},
});
})
}
return (
@@ -98,5 +104,5 @@ export function BadgeEditorDialog({ code, open, onOpenChange, onSaved }: BadgeEd
</DialogFooter>
</DialogContent>
</Dialog>
);
)
}
@@ -1,4 +1,4 @@
"use client";
'use client'
import {
AlertCircle,
@@ -12,179 +12,183 @@ import {
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";
} 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;
id: number
code: string
name: string
description: string
hotel: string
url_habbo: string
url_habboassets: string
alreadyImported: boolean
}
const PAGE_SIZE = 100;
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 [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("");
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();
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;
setError(data.error || 'Failed to load')
return
}
const list = data.badges || [];
setBadges(list);
setHasMore(list.length >= PAGE_SIZE);
setSelected(new Set());
const list = data.badges || []
setBadges(list)
setHasMore(list.length >= PAGE_SIZE)
setSelected(new Set())
} catch {
setError("Network error");
setError('Network error')
} finally {
setLoading(false);
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]);
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();
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);
}, []);
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [])
function doSearch() {
setActiveSearch(searchTerm);
setOffset(0);
fetchBadges(searchTerm, 0, allHotels);
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);
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);
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)));
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;
});
const n = new Set(prev)
n.delete(badge.code)
return n
})
} else {
toast.error(result.error || `Failed to import ${badge.code}`);
toast.error(result.error || `Failed to import ${badge.code}`)
}
} catch {
toast.error(`Network error importing ${badge.code}`);
toast.error(`Network error importing ${badge.code}`)
} finally {
setImportingCode(null);
setImportingCode(null)
}
}
async function doBatchImport() {
const toImport = badges.filter((b) => selected.has(b.code) && !b.alreadyImported);
if (toImport.length === 0) return;
setBatchImporting(true);
const toImport = badges.filter((b) => selected.has(b.code) && !b.alreadyImported)
if (toImport.length === 0) return
setBatchImporting(true)
let ok = 0,
fail = 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)));
ok++
setBadges((prev) =>
prev.map((b) => (b.code === badge.code ? { ...b, alreadyImported: true } : b)),
)
} else {
fail++;
fail++
}
} catch {
fail++;
fail++
}
}
setBatchImporting(false);
setSelected(new Set());
if (ok > 0) toast.success(`${ok} badge(s) imported`);
if (fail > 0) toast.error(`${fail} failed`);
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 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]);
const importableBadges = useMemo(() => badges.filter((b) => !b.alreadyImported), [badges])
function toggleSelectAll() {
if (selected.size === importableBadges.length) {
setSelected(new Set());
setSelected(new Set())
} else {
setSelected(new Set(importableBadges.map((b) => b.code)));
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;
const importedCount = useMemo(() => badges.filter((b) => b.alreadyImported).length, [badges])
const currentPage = Math.floor(offset / PAGE_SIZE) + 1
return (
<TooltipProvider delayDuration={300}>
@@ -222,10 +226,10 @@ export function ImportBadgesClient() {
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") doSearch();
if (e.key === "Escape") {
setSearchTerm("");
searchRef.current?.blur();
if (e.key === 'Enter') doSearch()
if (e.key === 'Escape') {
setSearchTerm('')
searchRef.current?.blur()
}
}}
className="pl-9 pr-8"
@@ -234,9 +238,9 @@ export function ImportBadgesClient() {
<button
type="button"
onClick={() => {
setSearchTerm("");
setActiveSearch("");
fetchBadges("", 0, allHotels);
setSearchTerm('')
setActiveSearch('')
fetchBadges('', 0, allHotels)
}}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
@@ -245,24 +249,28 @@ export function ImportBadgesClient() {
)}
</div>
<Button onClick={doSearch} size="sm" disabled={loading}>
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Search className="w-4 h-4" />}
{loading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Search className="w-4 h-4" />
)}
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={allHotels ? "default" : "outline"}
variant={allHotels ? 'default' : 'outline'}
size="sm"
onClick={() => setAllHotels((v) => !v)}
>
<Globe className="w-4 h-4 mr-1" />
{allHotels ? "All" : "IT"}
{allHotels ? 'All' : 'IT'}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>
{allHotels
? "Showing all hotels — click for Italian only"
: "Showing Italian only — click for all hotels"}
? 'Showing all hotels — click for Italian only'
: 'Showing Italian only — click for all hotels'}
</p>
</TooltipContent>
</Tooltip>
@@ -279,7 +287,7 @@ export function ImportBadgesClient() {
disabled={batchImporting}
>
{selected.size === importableBadges.length
? "Deselect All"
? 'Deselect All'
: `Select All (${importableBadges.length})`}
</Button>
{selected.size > 0 && (
@@ -322,7 +330,7 @@ export function ImportBadgesClient() {
{!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>
<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>
)}
@@ -330,17 +338,20 @@ export function ImportBadgesClient() {
{/* 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")}
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);
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 && "ring-1 ring-[var(--admin-success)]/30",
isSelected && "ring-2 ring-primary border-primary",
'rounded-lg border overflow-hidden bg-card group relative hover:border-border transition-all',
badge.alreadyImported && 'ring-1 ring-[var(--admin-success)]/30',
isSelected && 'ring-2 ring-primary border-primary',
)}
>
{/* Checkbox for non-imported */}
@@ -349,10 +360,10 @@ export function ImportBadgesClient() {
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",
'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",
? '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" />}
@@ -367,12 +378,12 @@ export function ImportBadgesClient() {
className="image-pixelated drop-shadow-sm"
style={{ maxHeight: 48, maxWidth: 48 }}
onError={(e) => {
const img = e.target as HTMLImageElement;
const img = e.target as HTMLImageElement
if (!img.dataset.fallback) {
img.dataset.fallback = "1";
img.src = badge.url_habboassets;
img.dataset.fallback = '1'
img.src = badge.url_habboassets
} else {
img.style.opacity = "0.3";
img.style.opacity = '0.3'
}
}}
/>
@@ -391,7 +402,9 @@ export function ImportBadgesClient() {
<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>
<p className="text-muted-foreground text-xs mt-1">
{badge.description}
</p>
)}
</TooltipContent>
</Tooltip>
@@ -439,7 +452,7 @@ export function ImportBadgesClient() {
</div>
</div>
</div>
);
)
})}
</div>
)}
@@ -453,7 +466,7 @@ export function ImportBadgesClient() {
variant="outline"
size="sm"
disabled={offset <= 0 || loading}
onClick={() => goPage("prev")}
onClick={() => goPage('prev')}
>
&larr; Prev
</Button>
@@ -461,7 +474,7 @@ export function ImportBadgesClient() {
variant="outline"
size="sm"
disabled={!hasMore || loading}
onClick={() => goPage("next")}
onClick={() => goPage('next')}
>
Next &rarr;
</Button>
@@ -471,13 +484,13 @@ export function ImportBadgesClient() {
{/* Badge Editor Dialog */}
<BadgeEditorDialog
code={editingCode ?? ""}
code={editingCode ?? ''}
open={!!editingCode}
onOpenChange={(open) => {
if (!open) setEditingCode(null);
if (!open) setEditingCode(null)
}}
/>
</div>
</TooltipProvider>
);
)
}
+2 -2
View File
@@ -1,4 +1,4 @@
import { Skeleton } from "@/components/ui/skeleton";
import { Skeleton } from '@/components/ui/skeleton'
export default function ImportBadgesLoading() {
return (
@@ -17,5 +17,5 @@ export default function ImportBadgesLoading() {
<Skeleton className="h-10 w-24 mt-4" />
</div>
</div>
);
)
}
+6 -6
View File
@@ -1,12 +1,12 @@
import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { ImportBadgesClient } from "./import-badges-client";
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();
const { session, permissions } = await getAdminContext()
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
redirect('/admin')
}
return <ImportBadgesClient />;
return <ImportBadgesClient />
}
+286 -262
View File
@@ -1,4 +1,4 @@
"use client";
'use client'
import {
AlertCircle,
@@ -12,46 +12,46 @@ import {
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";
} 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;
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;
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;
total: number
present: number
clonable: number
}
interface PageMeta {
page: number;
perPage: number;
total: number;
page: number
perPage: number
total: number
}
// ── Shared SSE reader (mirrors import-clothing-client.tsx) ────────────────────
@@ -63,63 +63,63 @@ async function runSseImport(
onComplete: (succeeded: number, failed: number) => void,
): Promise<void> {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
})
if (!res.body) {
toast.error("No response stream");
return;
toast.error('No response stream')
return
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
let succeeded = 0;
let failed = 0;
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() ?? "";
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 (!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;
if (evt.type === 'batch_complete') {
succeeded = evt.succeeded
failed = evt.failed
}
}
}
onComplete(succeeded, failed);
onComplete(succeeded, failed)
}
// ── Source form ───────────────────────────────────────────────────────────────
interface SourceFormProps {
initial?: Partial<CloneSource>;
onSave: (src: Partial<CloneSource>) => Promise<void>;
onCancel: () => void;
saving: boolean;
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 ?? "");
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 });
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="text-sm font-medium">{initial?.id ? 'Edit Source' : 'Add Source'}</div>
<div className="grid gap-2">
<Input
placeholder="Name (e.g. HotelX)"
@@ -156,61 +156,61 @@ function SourceForm({ initial, onSave, onCancel, saving }: SourceFormProps) {
</Button>
</div>
</form>
);
)
}
// ── Sources manager ───────────────────────────────────────────────────────────
interface SourcesManagerProps {
sources: CloneSource[];
activeSourceId: string | null;
onSelect: (src: CloneSource) => void;
onRefresh: () => Promise<void>;
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);
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);
setSaving(true)
try {
const res = await fetch("/api/admin/import/clone", {
method: "POST",
headers: { "Content-Type": "application/json" },
const res = await fetch('/api/admin/import/clone', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(src),
});
const data = await res.json();
})
const data = await res.json()
if (!res.ok) {
toast.error(data.error || "Save failed");
return;
toast.error(data.error || 'Save failed')
return
}
toast.success(src.id ? "Source updated" : "Source added");
setShowForm(false);
setEditSrc(null);
await onRefresh();
toast.success(src.id ? 'Source updated' : 'Source added')
setShowForm(false)
setEditSrc(null)
await onRefresh()
} finally {
setSaving(false);
setSaving(false)
}
}
async function handleDelete(src: CloneSource) {
setDeletingId(src.id);
setDeletingId(src.id)
try {
const res = await fetch(`/api/admin/import/clone?id=${encodeURIComponent(src.id)}`, {
method: "DELETE",
});
const data = await res.json();
method: 'DELETE',
})
const data = await res.json()
if (!res.ok) {
toast.error(data.error || "Delete failed");
return;
toast.error(data.error || 'Delete failed')
return
}
toast.success(`${src.name} removed`);
await onRefresh();
toast.success(`${src.name} removed`)
await onRefresh()
} finally {
setDeletingId(null);
setDeletingId(null)
}
}
@@ -223,8 +223,8 @@ function SourcesManager({ sources, activeSourceId, onSelect, onRefresh }: Source
variant="outline"
className="h-7 text-xs"
onClick={() => {
setEditSrc(null);
setShowForm(true);
setEditSrc(null)
setShowForm(true)
}}
>
<Plus className="w-3 h-3 mr-1" />
@@ -246,12 +246,17 @@ function SourcesManager({ sources, activeSourceId, onSelect, onRefresh }: Source
{sources.map((src) => (
<div key={src.id}>
{editSrc?.id === src.id ? (
<SourceForm initial={src} onSave={handleSave} onCancel={() => setEditSrc(null)} saving={saving} />
<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",
'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
@@ -272,8 +277,8 @@ function SourcesManager({ sources, activeSourceId, onSelect, onRefresh }: Source
className="h-6 w-6 p-0"
title="Edit source"
onClick={() => {
setEditSrc(src);
setShowForm(false);
setEditSrc(src)
setShowForm(false)
}}
>
<Pencil className="w-3 h-3" />
@@ -298,7 +303,7 @@ function SourcesManager({ sources, activeSourceId, onSelect, onRefresh }: Source
</div>
))}
</div>
);
)
}
// ── Furni icon with fallback ──────────────────────────────────────────────────
@@ -308,20 +313,20 @@ function FurniIcon({
iconBaseUrl,
classname,
}: {
sourceId: string;
iconBaseUrl: string;
classname: string;
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);
const [stage, setStage] = useState(0)
if (stage >= 2) {
return <Package className="w-8 h-8 text-muted-foreground/50" />;
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)}`;
: `/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
@@ -331,197 +336,203 @@ function FurniIcon({
className="max-h-full max-w-full object-contain image-pixelated"
onError={() => setStage((s) => s + 1)}
/>
);
)
}
// ── Furni grid view ───────────────────────────────────────────────────────────
interface FurniGridProps {
source: CloneSource;
source: CloneSource
}
const PER_PAGE = 50;
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 [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("");
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 (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;
setError(data.error || 'Failed to load')
return
}
setItems(data.item || []);
setMeta(data.meta || null);
setStats(data.stats || null);
setSelected(new Set());
setItems(data.item || [])
setMeta(data.meta || null)
setStats(data.stats || null)
setSelected(new Set())
} catch {
setError("Network error");
setError('Network error')
} finally {
setLoading(false);
setLoading(false)
}
},
[source.id],
);
)
useEffect(() => {
setPage(1);
setSearchTerm("");
setActiveSearch("");
setItems([]);
setStats(null);
setMeta(null);
setFilter("all");
fetchItems("", 1, "all");
}, [fetchItems]);
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);
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 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);
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);
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;
});
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)));
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;
setBatchProgress({ done: 0, total: toClone.length })
let done = 0
await runSseImport(
"/api/admin/import/clone/batch",
'/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 });
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`);
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);
fetchItems(activeSearch, page, filter)
},
);
setBatchProgress(null);
)
setBatchProgress(null)
}
async function cloneOne(it: FurniItem) {
setBusyClassname(it.classname);
setBusyClassname(it.classname)
try {
await cloneViaSse([it]);
await cloneViaSse([it])
} finally {
setBusyClassname(null);
setBusyClassname(null)
}
}
async function cloneSelected() {
const toClone = items.filter((it) => selected.has(it.classname) && !it.present);
if (toClone.length === 0) return;
await cloneViaSse(toClone);
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[] = [];
let names: string[] = []
try {
const res = await fetch(
`/api/admin/import/clone?source=${encodeURIComponent(source.id)}&action=clonable`,
);
const data = await res.json();
)
const data = await res.json()
if (!res.ok) {
toast.error(data.error || "Failed to load clonable list");
return;
toast.error(data.error || 'Failed to load clonable list')
return
}
names = data.classnames || [];
names = data.classnames || []
} catch {
toast.error("Network error");
return;
toast.error('Network error')
return
}
if (names.length === 0) {
toast.info("Nothing to clone — everything is already present");
return;
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;
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;
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);
const chunk = names.slice(i, i + CHUNK)
await runSseImport(
"/api/admin/import/clone/batch",
'/api/admin/import/clone/batch',
{ sourceId: source.id, classnames: chunk, concurrency: 2 },
(classname) => {
done++;
markDone(classname);
setBatchProgress({ done, total: names.length });
done++
markDone(classname)
setBatchProgress({ done, total: names.length })
},
(succeeded, failed) => {
okCount += succeeded;
failCount += 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);
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 (
@@ -556,12 +567,12 @@ function FurniGrid({ source }: FurniGridProps) {
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);
if (e.key === 'Enter') doSearch()
if (e.key === 'Escape') {
setSearchTerm('')
setActiveSearch('')
setPage(1)
fetchItems('', 1, filter)
}
}}
className="pl-9 pr-8"
@@ -570,10 +581,10 @@ function FurniGrid({ source }: FurniGridProps) {
<button
type="button"
onClick={() => {
setSearchTerm("");
setActiveSearch("");
setPage(1);
fetchItems("", 1, filter);
setSearchTerm('')
setActiveSearch('')
setPage(1)
fetchItems('', 1, filter)
}}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
@@ -590,22 +601,22 @@ function FurniGrid({ source }: FurniGridProps) {
<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 },
{ 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"}
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})` : ""}
{typeof f.count === 'number' ? ` (${f.count})` : ''}
</Button>
))}
</div>
@@ -621,11 +632,15 @@ function FurniGrid({ source }: FurniGridProps) {
disabled={!!batchProgress}
onClick={() =>
setSelected((prev) =>
prev.size === importable.length ? new Set() : new Set(importable.map((it) => it.classname)),
prev.size === importable.length
? new Set()
: new Set(importable.map((it) => it.classname)),
)
}
>
{selected.size === importable.length ? "Deselect page" : `Select page (${importable.length})`}
{selected.size === importable.length
? 'Deselect page'
: `Select page (${importable.length})`}
</Button>
)}
{selected.size > 0 && (
@@ -674,22 +689,27 @@ function FurniGrid({ source }: FurniGridProps) {
{!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>
<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")}>
<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);
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 && "ring-1 ring-[var(--admin-success)]/30",
isSelected && "ring-2 ring-primary border-primary",
'group rounded-lg border overflow-hidden bg-card relative hover:border-border transition-all',
it.present && 'ring-1 ring-[var(--admin-success)]/30',
isSelected && 'ring-2 ring-primary border-primary',
)}
>
{!it.present && (
@@ -697,10 +717,10 @@ function FurniGrid({ source }: FurniGridProps) {
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",
'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",
? '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" />}
@@ -709,19 +729,23 @@ function FurniGrid({ source }: FurniGridProps) {
{/* 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"}
{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} />
<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-[var(--admin-overlay)] backdrop-blur-sm rounded px-2 py-1.5 text-[10px] text-[var(--admin-sidebar-text)] space-y-0.5">
{it.description ? <p className="truncate">{it.description}</p> : null}
<div className="flex gap-2 text-[var(--admin-sidebar-text)]/70">
{it.revision ? <span>Rev: {it.revision}</span> : null}
<span>{it.itemType === "s" ? "Floor" : "Wall"}</span>
<span>{it.itemType === 's' ? 'Floor' : 'Wall'}</span>
<span>
{it.xdim}×{it.ydim}
</span>
@@ -738,7 +762,7 @@ function FurniGrid({ source }: FurniGridProps) {
{it.classname}
</div>
<div className="text-[10px] text-muted-foreground truncate">
{it.itemType === "s" ? "Floor" : "Wall"} · {it.category || "other"}
{it.itemType === 's' ? 'Floor' : 'Wall'} · {it.category || 'other'}
</div>
<div className="pt-1">
{it.present ? (
@@ -765,7 +789,7 @@ function FurniGrid({ source }: FurniGridProps) {
</div>
</div>
</div>
);
)
})}
</div>
)}
@@ -781,7 +805,7 @@ function FurniGrid({ source }: FurniGridProps) {
variant="outline"
size="sm"
disabled={page <= 1 || loading}
onClick={() => goPage("prev")}
onClick={() => goPage('prev')}
>
&larr; Prev
</Button>
@@ -789,7 +813,7 @@ function FurniGrid({ source }: FurniGridProps) {
variant="outline"
size="sm"
disabled={page >= lastPage || loading}
onClick={() => goPage("next")}
onClick={() => goPage('next')}
>
Next &rarr;
</Button>
@@ -797,33 +821,33 @@ function FurniGrid({ source }: FurniGridProps) {
</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 [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();
const res = await fetch('/api/admin/import/clone?action=sources')
const data = await res.json()
if (res.ok) {
setSources(data.sources || []);
setSources(data.sources || [])
}
} catch {
/* ignore */
} finally {
setLoadingSources(false);
setLoadingSources(false)
}
}, []);
}, [])
useEffect(() => {
fetchSources();
}, [fetchSources]);
fetchSources()
}, [fetchSources])
return (
<div className="space-y-6">
@@ -859,5 +883,5 @@ export function ImportCloneClient() {
</div>
)}
</div>
);
)
}
+6 -6
View File
@@ -1,11 +1,11 @@
import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { ImportCloneClient } from "./import-clone-client";
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();
const { session, permissions } = await getAdminContext()
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
redirect('/admin')
}
return <ImportCloneClient />;
return <ImportCloneClient />
}
@@ -1,35 +1,45 @@
"use client";
'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";
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;
lib: string
revision: number
nitroExists: boolean
previewType?: string
previewId?: number
}
interface ClothingSetItem {
setType: string;
setId: number;
gender: string;
club: number;
sellable: boolean;
present: boolean;
setType: string
setId: number
gender: string
club: number
sellable: boolean
present: boolean
}
type Mode = "libs" | "sets";
type Mode = 'libs' | 'sets'
const PER_PAGE = 100;
const PER_PAGE = 100
// ── Shared SSE reader ─────────────────────────────────────────────────────────
@@ -40,38 +50,38 @@ async function runSseImport(
onComplete: (succeeded: number, failed: number) => void,
): Promise<void> {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
})
if (!res.body) {
toast.error("No response stream");
return;
toast.error('No response stream')
return
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
let succeeded = 0;
let failed = 0;
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() ?? "";
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 (!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;
if (evt.type === 'batch_complete') {
succeeded = evt.succeeded
failed = evt.failed
}
}
}
onComplete(succeeded, failed);
onComplete(succeeded, failed)
}
// ── Avatar preview (renders a mini avatar wearing the item) ───────────────────
@@ -79,30 +89,30 @@ async function runSseImport(
// 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",
};
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"]);
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(".");
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);
const [failed, setFailed] = useState(false)
if (failed || !type || id === undefined) {
return <Shirt className="w-8 h-8 text-muted-foreground/60" />;
return <Shirt className="w-8 h-8 text-muted-foreground/60" />
}
const figure = buildPreviewFigure(type, id);
const headOnly = HEAD_TYPES.has(type) ? "&headonly=1" : "";
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
@@ -111,129 +121,129 @@ function AvatarPreview({ type, id }: { type?: string; id?: number }) {
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 [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("");
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();
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;
setError(data.error || 'Failed to load')
return
}
setItems(data.item || []);
setTotal(data.meta?.total || 0);
setSelected(new Set());
setItems(data.item || [])
setTotal(data.meta?.total || 0)
setSelected(new Set())
} catch {
setError("Network error");
setError('Network error')
} finally {
setLoading(false);
setLoading(false)
}
}, []);
}, [])
useEffect(() => {
fetchItems("", 1);
}, [fetchItems]);
fetchItems('', 1)
}, [fetchItems])
function doSearch() {
setActiveSearch(searchTerm);
setPage(1);
fetchItems(searchTerm, 1);
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);
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);
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;
});
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)));
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;
setBatchProgress({ done: 0, total: toImport.length })
let done = 0
await runSseImport(
"/api/admin/import/clothing/batch",
'/api/admin/import/clothing/batch',
{ items: toImport, concurrency: 3 },
(label) => {
done++;
markDone(label);
setBatchProgress({ done, total: toImport.length });
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)
setSelected(new Set())
if (succeeded > 0) toast.success(`${succeeded} libraries imported`)
if (failed > 0) toast.error(`${failed} failed`)
},
);
setBatchProgress(null);
)
setBatchProgress(null)
}
async function importOne(p: FigureItem) {
setBusyLib(p.lib);
setBusyLib(p.lib)
try {
await importViaSse([p]);
await importViaSse([p])
} finally {
setBusyLib(null);
setBusyLib(null)
}
}
async function importSelected() {
const toImport = items.filter((p) => selected.has(p.lib) && !p.nitroExists);
if (toImport.length === 0) return;
await importViaSse(toImport);
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);
setBusyLib(p.lib)
try {
const res = await fetch(`/api/admin/import/clothing?lib=${encodeURIComponent(p.lib)}`, {
method: "DELETE",
});
const data = await res.json();
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)));
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");
toast.error(data.error || 'Delete failed')
}
} finally {
setBusyLib(null);
setBusyLib(null)
}
}
@@ -267,12 +277,12 @@ function LibsView() {
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);
if (e.key === 'Enter') doSearch()
if (e.key === 'Escape') {
setSearchTerm('')
setActiveSearch('')
setPage(1)
fetchItems('', 1)
}
}}
className="pl-9 pr-8"
@@ -281,10 +291,10 @@ function LibsView() {
<button
type="button"
onClick={() => {
setSearchTerm("");
setActiveSearch("");
setPage(1);
fetchItems("", 1);
setSearchTerm('')
setActiveSearch('')
setPage(1)
fetchItems('', 1)
}}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
@@ -310,7 +320,9 @@ function LibsView() {
)
}
>
{selected.size === importable.length ? "Deselect page" : `Select page (${importable.length})`}
{selected.size === importable.length
? 'Deselect page'
: `Select page (${importable.length})`}
</Button>
{selected.size > 0 && (
<Button size="sm" className="h-8" onClick={importSelected} disabled={!!batchProgress}>
@@ -340,21 +352,28 @@ function LibsView() {
{!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>
<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")}>
<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);
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 && "ring-1 ring-[var(--admin-success)]/30",
isSelected && "ring-2 ring-primary border-primary",
'rounded-lg border overflow-hidden bg-card relative hover:border-border transition-all',
p.nitroExists && 'ring-1 ring-[var(--admin-success)]/30',
isSelected && 'ring-2 ring-primary border-primary',
)}
>
{!p.nitroExists && (
@@ -362,10 +381,10 @@ function LibsView() {
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",
'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",
? '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" />}
@@ -415,7 +434,7 @@ function LibsView() {
</div>
</div>
</div>
);
)
})}
</div>
)}
@@ -430,7 +449,7 @@ function LibsView() {
variant="outline"
size="sm"
disabled={page <= 1 || loading}
onClick={() => goPage("prev")}
onClick={() => goPage('prev')}
>
&larr; Prev
</Button>
@@ -438,7 +457,7 @@ function LibsView() {
variant="outline"
size="sm"
disabled={page >= lastPage || loading}
onClick={() => goPage("next")}
onClick={() => goPage('next')}
>
Next &rarr;
</Button>
@@ -446,120 +465,120 @@ function LibsView() {
</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);
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}`;
return `${it.setType}-${it.setId}`
}
const fetchItems = useCallback(async (search: string, pageNum: number) => {
setLoading(true);
setError("");
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();
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;
setError(data.error || 'Failed to load')
return
}
setItems(data.item || []);
setTotal(data.meta?.total || 0);
setSelected(new Set());
setItems(data.item || [])
setTotal(data.meta?.total || 0)
setSelected(new Set())
} catch {
setError("Network error");
setError('Network error')
} finally {
setLoading(false);
setLoading(false)
}
}, []);
}, [])
useEffect(() => {
fetchItems("", 1);
}, [fetchItems]);
fetchItems('', 1)
}, [fetchItems])
function doSearch() {
setActiveSearch(searchTerm);
setPage(1);
fetchItems(searchTerm, 1);
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);
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);
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;
});
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)));
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;
setBatchProgress({ done: 0, total: toImport.length })
let done = 0
await runSseImport(
"/api/admin/import/clothing/sets/batch",
'/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 });
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)
setSelected(new Set())
if (succeeded > 0) toast.success(`${succeeded} sets imported`)
if (failed > 0) toast.error(`${failed} failed`)
},
);
setBatchProgress(null);
)
setBatchProgress(null)
}
async function importOne(it: ClothingSetItem) {
const key = itemKey(it);
setBusyKey(key);
const key = itemKey(it)
setBusyKey(key)
try {
await importViaSse([it]);
await importViaSse([it])
} finally {
setBusyKey(null);
setBusyKey(null)
}
}
async function importSelected() {
const toImport = items.filter((it) => selected.has(itemKey(it)) && !it.present);
if (toImport.length === 0) return;
await importViaSse(toImport);
const toImport = items.filter((it) => selected.has(itemKey(it)) && !it.present)
if (toImport.length === 0) return
await importViaSse(toImport)
}
return (
@@ -592,12 +611,12 @@ function SetsView() {
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);
if (e.key === 'Enter') doSearch()
if (e.key === 'Escape') {
setSearchTerm('')
setActiveSearch('')
setPage(1)
fetchItems('', 1)
}
}}
className="pl-9 pr-8"
@@ -606,10 +625,10 @@ function SetsView() {
<button
type="button"
onClick={() => {
setSearchTerm("");
setActiveSearch("");
setPage(1);
fetchItems("", 1);
setSearchTerm('')
setActiveSearch('')
setPage(1)
fetchItems('', 1)
}}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
@@ -631,11 +650,15 @@ function SetsView() {
disabled={!!batchProgress}
onClick={() =>
setSelected((prev) =>
prev.size === importable.length ? new Set() : new Set(importable.map((it) => itemKey(it))),
prev.size === importable.length
? new Set()
: new Set(importable.map((it) => itemKey(it))),
)
}
>
{selected.size === importable.length ? "Deselect page" : `Select page (${importable.length})`}
{selected.size === importable.length
? 'Deselect page'
: `Select page (${importable.length})`}
</Button>
{selected.size > 0 && (
<Button size="sm" className="h-8" onClick={importSelected} disabled={!!batchProgress}>
@@ -665,22 +688,27 @@ function SetsView() {
{!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>
<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")}>
<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);
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 && "ring-1 ring-[var(--admin-success)]/30",
isSelected && "ring-2 ring-primary border-primary",
'rounded-lg border overflow-hidden bg-card relative hover:border-border transition-all',
it.present && 'ring-1 ring-[var(--admin-success)]/30',
isSelected && 'ring-2 ring-primary border-primary',
)}
>
{!it.present && (
@@ -688,10 +716,10 @@ function SetsView() {
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",
'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",
? '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" />}
@@ -744,7 +772,7 @@ function SetsView() {
</div>
</div>
</div>
);
)
})}
</div>
)}
@@ -759,7 +787,7 @@ function SetsView() {
variant="outline"
size="sm"
disabled={page <= 1 || loading}
onClick={() => goPage("prev")}
onClick={() => goPage('prev')}
>
&larr; Prev
</Button>
@@ -767,7 +795,7 @@ function SetsView() {
variant="outline"
size="sm"
disabled={page >= lastPage || loading}
onClick={() => goPage("next")}
onClick={() => goPage('next')}
>
Next &rarr;
</Button>
@@ -775,36 +803,36 @@ function SetsView() {
</div>
)}
</div>
);
)
}
// ── Root component with mode toggle ──────────────────────────────────────────
export function ImportClothingClient() {
const [mode, setMode] = useState<Mode>("libs");
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"}
variant={mode === 'libs' ? 'default' : 'ghost'}
className="h-7 text-xs"
onClick={() => setMode("libs")}
onClick={() => setMode('libs')}
>
Librerie
</Button>
<Button
size="sm"
variant={mode === "sets" ? "default" : "ghost"}
variant={mode === 'sets' ? 'default' : 'ghost'}
className="h-7 text-xs"
onClick={() => setMode("sets")}
onClick={() => setMode('sets')}
>
Vestiti (set)
</Button>
</div>
{mode === "libs" ? <LibsView /> : <SetsView />}
{mode === 'libs' ? <LibsView /> : <SetsView />}
</div>
);
)
}
+1 -1
View File
@@ -9,5 +9,5 @@ export default function Loading() {
</div>
))}
</div>
);
)
}
+6 -6
View File
@@ -1,11 +1,11 @@
import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { ImportClothingClient } from "./import-clothing-client";
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();
const { session, permissions } = await getAdminContext()
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
redirect('/admin')
}
return <ImportClothingClient />;
return <ImportClothingClient />
}
@@ -1,5 +1,5 @@
// src/app/[locale]/admin/import/effects/import-effects-client.tsx
"use client";
'use client'
import {
AlertCircle,
@@ -12,160 +12,165 @@ import {
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";
} 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;
id: string
lib: string
type: string
revision: number
present: boolean
nitroExists: boolean
}
type Filter = "all" | "fx" | "dance";
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 [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("");
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();
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;
setError(data.error || 'Failed to load')
return
}
setEffects(data.effects || []);
setSelected(new Set());
setEffects(data.effects || [])
setSelected(new Set())
} catch {
setError("Network error");
setError('Network error')
} finally {
setLoading(false);
setLoading(false)
}
}, []);
}, [])
useEffect(() => {
fetchEffects("");
}, [fetchEffects]);
fetchEffects('')
}, [fetchEffects])
const visible = useMemo(
() => effects.filter((e) => filter === "all" || e.type === filter),
() => 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]);
)
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;
});
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)));
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" },
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;
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;
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() ?? "";
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;
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`);
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);
setBusyLib(e.lib)
try {
await importViaSse([e]);
await importViaSse([e])
} finally {
setBusyLib(null);
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);
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);
setBusyLib(e.lib)
try {
const res = await fetch(`/api/admin/import/effects?lib=${encodeURIComponent(e.lib)}`, {
method: "DELETE",
});
const data = await res.json();
method: 'DELETE',
})
const data = await res.json()
if (res.ok) {
toast.success(`${e.lib} removed`);
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");
toast.error(data.error || 'Delete failed')
}
} finally {
setBusyLib(null);
setBusyLib(null)
}
}
@@ -201,10 +206,10 @@ export function ImportEffectsClient() {
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") fetchEffects(searchTerm);
if (e.key === "Escape") {
setSearchTerm("");
fetchEffects("");
if (e.key === 'Enter') fetchEffects(searchTerm)
if (e.key === 'Escape') {
setSearchTerm('')
fetchEffects('')
}
}}
className="pl-9 pr-8"
@@ -213,8 +218,8 @@ export function ImportEffectsClient() {
<button
type="button"
onClick={() => {
setSearchTerm("");
fetchEffects("");
setSearchTerm('')
fetchEffects('')
}}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
@@ -225,11 +230,11 @@ export function ImportEffectsClient() {
<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) => (
{(['all', 'fx', 'dance'] as Filter[]).map((f) => (
<Button
key={f}
size="sm"
variant={filter === f ? "default" : "outline"}
variant={filter === f ? 'default' : 'outline'}
onClick={() => setFilter(f)}
>
{f.toUpperCase()}
@@ -251,7 +256,9 @@ export function ImportEffectsClient() {
)
}
>
{selected.size === importable.length ? "Deselect All" : `Select All (${importable.length})`}
{selected.size === importable.length
? 'Deselect All'
: `Select All (${importable.length})`}
</Button>
{selected.size > 0 && (
<Button size="sm" className="h-8" onClick={importSelected} disabled={!!batchProgress}>
@@ -289,17 +296,22 @@ export function ImportEffectsClient() {
{/* Grid */}
{visible.length > 0 && (
<div className={cn("grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3", loading && "opacity-50")}>
<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);
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 && "ring-1 ring-[var(--admin-success)]/30",
isSelected && "ring-2 ring-primary border-primary",
'rounded-lg border overflow-hidden bg-card relative hover:border-border transition-all',
imported && 'ring-1 ring-[var(--admin-success)]/30',
isSelected && 'ring-2 ring-primary border-primary',
)}
>
{!imported && (
@@ -307,10 +319,10 @@ export function ImportEffectsClient() {
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",
'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",
? '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" />}
@@ -318,13 +330,13 @@ export function ImportEffectsClient() {
)}
<div
className={cn(
"flex flex-col items-center justify-center h-20 gap-1",
e.type === "dance"
? "bg-gradient-to-br from-[var(--admin-warning)]/25 to-[var(--admin-warning)]/10"
: "bg-gradient-to-br from-[var(--admin-accent)]/25 to-[var(--admin-accent)]/10",
'flex flex-col items-center justify-center h-20 gap-1',
e.type === 'dance'
? 'bg-gradient-to-br from-[var(--admin-warning)]/25 to-[var(--admin-warning)]/10'
: 'bg-gradient-to-br from-[var(--admin-accent)]/25 to-[var(--admin-accent)]/10',
)}
>
{e.type === "dance" ? (
{e.type === 'dance' ? (
<Music className="w-7 h-7 text-[var(--admin-warning)]/80" />
) : (
<Sparkles className="w-7 h-7 text-[var(--admin-accent-text)]" />
@@ -373,10 +385,10 @@ export function ImportEffectsClient() {
</div>
</div>
</div>
);
)
})}
</div>
)}
</div>
);
)
}
+1 -1
View File
@@ -10,5 +10,5 @@ export default function Loading() {
</div>
))}
</div>
);
)
}
+6 -6
View File
@@ -1,12 +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";
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();
const { session, permissions } = await getAdminContext()
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
redirect('/admin')
}
return <ImportEffectsClient />;
return <ImportEffectsClient />
}
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -1,4 +1,4 @@
import { Skeleton } from "@/components/ui/skeleton";
import { Skeleton } from '@/components/ui/skeleton'
export default function ImportFurniLoading() {
return (
@@ -17,5 +17,5 @@ export default function ImportFurniLoading() {
<Skeleton className="h-10 w-24 mt-4" />
</div>
</div>
);
)
}
+215 -184
View File
@@ -1,4 +1,4 @@
"use client";
'use client'
import {
Box,
@@ -14,65 +14,71 @@ import {
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";
} 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;
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;
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]];
if (!current[keys[i]]) current[keys[i]] = {}
current = current[keys[i]]
}
current[keys[keys.length - 1]] = value;
return clone;
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;
const keys = path.split('.')
let current = obj
for (const key of keys) {
if (current == null || typeof current !== "object") return fallback;
current = current[key];
if (current == null || typeof current !== 'object') return fallback
current = current[key]
}
return current ?? fallback;
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"];
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")}`;
return `#${hex.padStart(6, '0')}`
}
function CopyClassname({ value }: { value: string }) {
const [copied, setCopied] = useState(false);
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);
navigator.clipboard.writeText(value)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
}}
>
<span className="truncate">{value}</span>
@@ -82,177 +88,178 @@ function CopyClassname({ value }: { value: string }) {
<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);
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 [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 [jsonError, setJsonError] = useState<string | null>(null)
const fetchMetadata = useCallback(async () => {
if (!classname) return;
setLoading(true);
setJsonError(null);
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 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 });
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);
toast.error('Network error loading .nitro metadata')
onOpenChange(false)
} finally {
setLoading(false);
setLoading(false)
}
}, [classname, onOpenChange]);
}, [classname, onOpenChange])
useEffect(() => {
if (open && classname) {
setActiveTab("visual");
fetchMetadata();
setActiveTab('visual')
fetchMetadata()
}
}, [open, classname, fetchMetadata]);
}, [open, classname, fetchMetadata])
const handleTabChange = (newTab: string) => {
if (activeTab === "json" && newTab === "visual") {
if (activeTab === 'json' && newTab === 'visual') {
try {
const parsed = JSON.parse(jsonText);
setMetadata(parsed);
setJsonError(null);
const parsed = JSON.parse(jsonText)
setMetadata(parsed)
setJsonError(null)
} catch {
toast.error("Invalid JSON — fix it before switching to Visual tab");
return;
toast.error('Invalid JSON — fix it before switching to Visual tab')
return
}
} else if (activeTab === "visual" && newTab === "json") {
setJsonText(JSON.stringify(metadata, null, 2));
} else if (activeTab === 'visual' && newTab === 'json') {
setJsonText(JSON.stringify(metadata, null, 2))
}
setActiveTab(newTab);
};
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");
};
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;
let metadataToSave = metadata
if (activeTab === "json") {
if (activeTab === 'json') {
try {
metadataToSave = JSON.parse(jsonText);
metadataToSave = JSON.parse(jsonText)
} catch {
toast.error("Invalid JSON");
return;
toast.error('Invalid JSON')
return
}
}
setSaving(true);
setSaving(true)
try {
const res = await fetch("/api/admin/import/furni/nitro-editor", {
method: "PUT",
headers: { "Content-Type": "application/json" },
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;
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));
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");
toast.error('Network error')
} finally {
setSaving(false);
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];
? 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 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 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));
};
: [...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 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 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);
setJsonText(value)
try {
JSON.parse(value);
setJsonError(null);
JSON.parse(value)
setJsonError(null)
} catch (e) {
setJsonError((e as Error).message);
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));
JSON.stringify(flags) !== JSON.stringify(originalFlags))
// Furni preview URL
const starIdx = classname.indexOf("*");
const baseClassname = starIdx !== -1 ? classname.substring(0, starIdx) : classname;
const starIdx = classname.indexOf('*')
const baseClassname = starIdx !== -1 ? classname.substring(0, starIdx) : classname
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -264,18 +271,18 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
<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",
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" }}
style={{ imageRendering: 'pixelated' }}
onError={(e) => {
(e.target as HTMLImageElement).style.opacity = "0";
;(e.target as HTMLImageElement).style.opacity = '0'
}}
/>
</div>
@@ -311,7 +318,7 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
)}
<Badge variant="outline" className="text-[10px] gap-1">
<Eye className="w-2.5 h-2.5" />
{metadata.visualizationType || "?"}
{metadata.visualizationType || '?'}
</Badge>
</div>
)}
@@ -324,7 +331,11 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
<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">
<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">
@@ -332,7 +343,7 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
Visual
</TabsTrigger>
<TabsTrigger value="json" className="gap-1.5">
{"{ }"}
{'{ }'}
JSON
</TabsTrigger>
</TabsList>
@@ -358,7 +369,7 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
type="number"
min={1}
value={dims.x}
onChange={(e) => setDim("x", e.target.value)}
onChange={(e) => setDim('x', e.target.value)}
className="mt-1"
/>
</div>
@@ -371,7 +382,7 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
type="number"
min={1}
value={dims.y}
onChange={(e) => setDim("y", e.target.value)}
onChange={(e) => setDim('y', e.target.value)}
className="mt-1"
/>
</div>
@@ -385,7 +396,7 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
step={0.01}
min={0}
value={dims.z}
onChange={(e) => setDim("z", e.target.value)}
onChange={(e) => setDim('z', e.target.value)}
className="mt-1"
/>
</div>
@@ -429,7 +440,9 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
<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>
<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">
@@ -438,19 +451,19 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
<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);
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"
? '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)}
@@ -458,11 +471,13 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
<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>
<span className="text-xs text-muted-foreground font-medium">
{directions.length}/8
</span>
</div>
</div>
</div>
@@ -481,10 +496,10 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
</Label>
<Input
id="viz-type"
value={metadata.visualizationType || ""}
value={metadata.visualizationType || ''}
onChange={(e) =>
setMetadata((prev: unknown) =>
updateNested(prev, "visualizationType", e.target.value),
updateNested(prev, 'visualizationType', e.target.value),
)
}
className="mt-1 font-mono text-sm"
@@ -496,9 +511,11 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
</Label>
<Input
id="logic-type"
value={metadata.logicType || ""}
value={metadata.logicType || ''}
onChange={(e) =>
setMetadata((prev: unknown) => updateNested(prev, "logicType", e.target.value))
setMetadata((prev: unknown) =>
updateNested(prev, 'logicType', e.target.value),
)
}
className="mt-1 font-mono text-sm"
/>
@@ -525,12 +542,12 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
<span>Ink</span>
</div>
{Object.entries(viz64.layers).map(([layerId, layerData], idx) => {
const layer = layerData as Record<string, unknown>;
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"
idx % 2 === 0 ? 'bg-card' : 'bg-muted/20'
}`}
>
<span className="font-mono text-xs">
@@ -549,7 +566,7 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
`visualizations.${viz64Index}.layers.${layerId}.z`,
parseInt(e.target.value, 10) || 0,
),
);
)
}}
/>
<Input
@@ -565,12 +582,12 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
`visualizations.${viz64Index}.layers.${layerId}.alpha`,
parseInt(e.target.value, 10) || 0,
),
);
)
}}
/>
<Input
className="h-7 text-xs font-mono"
value={(layer.ink as string) ?? ""}
value={(layer.ink as string) ?? ''}
placeholder="—"
onChange={(e) => {
setMetadata((prev: unknown) =>
@@ -579,11 +596,11 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
`visualizations.${viz64Index}.layers.${layerId}.ink`,
e.target.value || undefined,
),
);
)
}}
/>
</div>
);
)
})}
</div>
</section>
@@ -606,20 +623,22 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
<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",
),
}}
/>
))}
{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">
@@ -633,20 +652,25 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
<Input
className="h-7 text-xs font-mono pl-8"
value={
typeof layerColor.color === "number"
? layerColor.color.toString(16).padStart(6, "0").toUpperCase()
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;
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 */}
@@ -654,9 +678,9 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
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",
typeof layerColor.color === 'number'
? layerColor.color.toString(16).padStart(6, '0')
: '000000',
),
}}
/>
@@ -694,7 +718,10 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
<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]">
<Badge
variant="secondary"
className="text-[10px] gap-1 animate-[pulse_1s_ease-in-out_1]"
>
Modifiche non salvate
</Badge>
)}
@@ -712,7 +739,11 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
Annulla
</Button>
<Button onClick={handleSave} disabled={saving || loading || !metadata} className="gap-1.5">
<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>
@@ -720,5 +751,5 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
</DialogFooter>
</DialogContent>
</Dialog>
);
)
}
+6 -6
View File
@@ -1,12 +1,12 @@
import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { ImportFurniClient } from "./import-furni-client";
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();
const { session, permissions } = await getAdminContext()
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
redirect('/admin')
}
return <ImportFurniClient />;
return <ImportFurniClient />
}
+126 -109
View File
@@ -1,22 +1,32 @@
"use client";
'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";
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;
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" />;
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
@@ -26,135 +36,135 @@ function PetIcon({ lib }: { lib: string }) {
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 [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("");
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();
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;
setError(data.error || 'Failed to load')
return
}
setPets(data.pets || []);
setSelected(new Set());
setPets(data.pets || [])
setSelected(new Set())
} catch {
setError("Network error");
setError('Network error')
} finally {
setLoading(false);
setLoading(false)
}
}, []);
}, [])
useEffect(() => {
fetchPets("");
}, [fetchPets]);
fetchPets('')
}, [fetchPets])
const importable = useMemo(() => pets.filter((p) => !p.nitroExists), [pets]);
const importedCount = useMemo(() => pets.filter((p) => p.nitroExists).length, [pets]);
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;
});
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)));
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" },
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;
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;
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() ?? "";
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;
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`);
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);
setBusyLib(p.lib)
try {
await importViaSse([p]);
await importViaSse([p])
} finally {
setBusyLib(null);
setBusyLib(null)
}
}
async function importSelected() {
const items = pets.filter((p) => selected.has(p.lib) && !p.nitroExists);
if (items.length === 0) return;
await importViaSse(items);
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);
setBusyLib(p.lib)
try {
const res = await fetch(`/api/admin/import/pets?lib=${encodeURIComponent(p.lib)}`, {
method: "DELETE",
});
const data = await res.json();
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)));
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");
toast.error(data.error || 'Delete failed')
}
} finally {
setBusyLib(null);
setBusyLib(null)
}
}
@@ -188,10 +198,10 @@ export function ImportPetsClient() {
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") fetchPets(searchTerm);
if (e.key === "Escape") {
setSearchTerm("");
fetchPets("");
if (e.key === 'Enter') fetchPets(searchTerm)
if (e.key === 'Escape') {
setSearchTerm('')
fetchPets('')
}
}}
className="pl-9 pr-8"
@@ -200,8 +210,8 @@ export function ImportPetsClient() {
<button
type="button"
onClick={() => {
setSearchTerm("");
fetchPets("");
setSearchTerm('')
fetchPets('')
}}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
@@ -227,7 +237,9 @@ export function ImportPetsClient() {
)
}
>
{selected.size === importable.length ? "Deselect All" : `Select All (${importable.length})`}
{selected.size === importable.length
? 'Deselect All'
: `Select All (${importable.length})`}
</Button>
{selected.size > 0 && (
<Button size="sm" className="h-8" onClick={importSelected} disabled={!!batchProgress}>
@@ -262,16 +274,21 @@ export function ImportPetsClient() {
)}
{pets.length > 0 && (
<div className={cn("grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3", loading && "opacity-50")}>
<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);
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 && "ring-1 ring-[var(--admin-success)]/30",
isSelected && "ring-2 ring-primary border-primary",
'rounded-lg border overflow-hidden bg-card relative hover:border-border transition-all',
p.nitroExists && 'ring-1 ring-[var(--admin-success)]/30',
isSelected && 'ring-2 ring-primary border-primary',
)}
>
{!p.nitroExists && (
@@ -279,10 +296,10 @@ export function ImportPetsClient() {
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",
'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",
? '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" />}
@@ -330,10 +347,10 @@ export function ImportPetsClient() {
</div>
</div>
</div>
);
)
})}
</div>
)}
</div>
);
)
}
+1 -1
View File
@@ -9,5 +9,5 @@ export default function Loading() {
</div>
))}
</div>
);
)
}
+6 -6
View File
@@ -1,11 +1,11 @@
import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { ImportPetsClient } from "./import-pets-client";
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();
const { session, permissions } = await getAdminContext()
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
redirect('/admin')
}
return <ImportPetsClient />;
return <ImportPetsClient />
}
+6 -6
View File
@@ -1,12 +1,12 @@
import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { RepairClient } from "./repair-client";
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();
const { session, permissions } = await getAdminContext()
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
redirect('/admin')
}
return <RepairClient />;
return <RepairClient />
}
+277 -247
View File
@@ -1,4 +1,4 @@
"use client";
'use client'
import {
AlertCircle,
@@ -10,13 +10,13 @@ import {
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";
} 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,
@@ -24,238 +24,245 @@ import {
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";
} 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";
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;
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[];
total: number
missingBoth: number
missingNitro: number
missingIcon: number
unrepairable: number
items: MissingItem[]
}
type ItemStatus = "idle" | "working" | "done" | "partial" | "failed";
type ItemStatus = 'idle' | 'working' | 'done' | 'partial' | 'failed'
interface ItemProgress {
status: ItemStatus;
message?: string;
nitroRepaired?: boolean;
iconRepaired?: boolean;
unrepairable?: boolean;
status: ItemStatus
message?: string
nitroRepaired?: boolean
iconRepaired?: boolean
unrepairable?: boolean
}
interface RepairSummary {
succeeded: number;
partial: number;
failed: number;
unrepairable: number;
duration: number;
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",
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);
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);
setAuditing(true)
setLastSummary(null)
try {
const res = await fetch("/api/admin/import/repair/audit", { method: "POST" });
const res = await fetch('/api/admin/import/repair/audit', { method: 'POST' })
if (!res.ok) {
toast.error("Audit failed");
return;
toast.error('Audit failed')
return
}
const data = await res.json();
setSummary(data);
setSelected(new Set());
setProgress(new Map());
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)`,
);
`Audit: ${data.items.length} item${data.items.length === 1 ? '' : 's'} need repair (${data.total} scanned)`,
)
} catch {
toast.error("Audit network error");
toast.error('Audit network error')
} finally {
setAuditing(false);
setAuditing(false)
}
}, []);
}, [])
// ── Filters ────────────────────────────────────────────────────────
const filtered = useMemo(() => {
if (!summary) return [];
const s = search.trim().toLowerCase();
if (!summary) return []
const s = search.trim().toLowerCase()
return summary.items.filter((it) => {
if (!TAB_FILTERS[tab](it)) return false;
if (!s) return true;
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]);
)
})
}, [summary, search, tab])
// ── Selection ──────────────────────────────────────────────────────
const allSelected = filtered.length > 0 && filtered.every((i) => selected.has(i.classname));
const allSelected = filtered.length > 0 && filtered.every((i) => selected.has(i.classname))
const toggleAll = () => {
setSelected((prev) => {
if (allSelected) {
const next = new Set(prev);
const next = new Set(prev)
for (const i of filtered) {
next.delete(i.classname);
next.delete(i.classname)
}
return next;
return next
}
const next = new Set(prev);
const next = new Set(prev)
for (const i of filtered) {
next.add(i.classname);
next.add(i.classname)
}
return next;
});
};
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;
});
};
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);
if (classnames.length === 0) return
setRepairing(true)
setLastSummary(null)
// Init progress for these items
setProgress((prev) => {
const next = new Map(prev);
const next = new Map(prev)
for (const cn of classnames) {
next.set(cn, { status: "working" });
next.set(cn, { status: 'working' })
}
return next;
});
return next
})
try {
const res = await fetch("/api/admin/import/repair", {
method: "POST",
headers: { "Content-Type": "application/json" },
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;
toast.error('Failed to start repair')
setRepairing(false)
return
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
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() || "";
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;
if (!line.startsWith('data: ')) continue
try {
const event = JSON.parse(line.slice(6));
if (event.type === "repair_progress") {
const event = JSON.parse(line.slice(6))
if (event.type === 'repair_progress') {
setProgress((prev) => {
const next = new Map(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;
});
})
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;
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") {
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);
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),
};
});
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;
});
const next = new Set(prev)
next.delete(event.classname)
return next
})
}
} else if (event.type === "repair_complete") {
} 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 */
@@ -263,72 +270,72 @@ export function RepairClient() {
}
}
} catch {
toast.error("Connection lost during repair");
toast.error('Connection lost during repair')
} finally {
setRepairing(false);
setRepairing(false)
}
}, []);
}, [])
// ── Delete unrepairable items ──────────────────────────────────────
const deleteSelected = useCallback(async () => {
const toDelete = Array.from(selected);
if (toDelete.length === 0) return;
setConfirmDeleteAll(false);
const toDelete = Array.from(selected)
if (toDelete.length === 0) return
setConfirmDeleteAll(false)
let ok = 0;
let fail = 0;
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;
});
const next = new Map(prev)
next.set(cn, { status: 'working', message: 'Deleting…' })
return next
})
try {
const result = await deleteImportedFurni({ classname: cn });
const result = await deleteImportedFurni({ classname: cn })
if (result.ok) {
ok++;
ok++
setSummary((prev) => {
if (!prev) return prev;
const removed = prev.items.find((i) => i.classname === cn);
if (!removed) return 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),
};
});
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;
});
const next = new Set(prev)
next.delete(cn)
return next
})
} else {
fail++;
fail++
setProgress((prev) => {
const next = new Map(prev);
next.set(cn, { status: "failed", message: "Delete failed" });
return next;
});
const next = new Map(prev)
next.set(cn, { status: 'failed', message: 'Delete failed' })
return next
})
}
} catch {
fail++;
fail++
setProgress((prev) => {
const next = new Map(prev);
next.set(cn, { status: "failed", message: "Delete error" });
return next;
});
const next = new Map(prev)
next.set(cn, { status: 'failed', message: 'Delete error' })
return next
})
}
}
toast.success(`Deleted ${ok}${fail > 0 ? `, ${fail} failed` : ""}`);
}, [selected]);
toast.success(`Deleted ${ok}${fail > 0 ? `, ${fail} failed` : ''}`)
}, [selected])
// ── Render ─────────────────────────────────────────────────────────
const selectedArray = Array.from(selected);
const selectedCount = selectedArray.length;
const selectedArray = Array.from(selected)
const selectedCount = selectedArray.length
return (
<div className="space-y-6">
@@ -337,8 +344,9 @@ export function RepairClient() {
<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.
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">
@@ -349,7 +357,7 @@ export function RepairClient() {
</>
) : (
<>
<RefreshCw className="mr-2 h-4 w-4" /> {summary ? "Re-scan" : "Scan Assets"}
<RefreshCw className="mr-2 h-4 w-4" /> {summary ? 'Re-scan' : 'Scan Assets'}
</>
)}
</Button>
@@ -360,10 +368,12 @@ export function RepairClient() {
{!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="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.
This checks every row in <code>items_base</code> against files on disk. Scanning ~33k
items takes a few seconds.
</p>
</div>
)}
@@ -378,7 +388,12 @@ export function RepairClient() {
tone="danger"
sub="no .nitro · no icon"
/>
<StatCard label="Missing .nitro" value={summary.missingNitro} tone="warn" sub="shows black cube" />
<StatCard
label="Missing .nitro"
value={summary.missingNitro}
tone="warn"
sub="shows black cube"
/>
<StatCard
label="Missing icon"
value={summary.missingIcon}
@@ -422,7 +437,9 @@ export function RepairClient() {
<div className="rounded-md border bg-muted/30 p-10 text-center">
<CheckCircle2 className="mx-auto mb-3 h-10 w-10 text-[var(--admin-success)]" />
<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>
<p className="text-sm text-muted-foreground">
{summary.total} items scanned, nothing to repair.
</p>
</div>
)}
@@ -444,15 +461,16 @@ export function RepairClient() {
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));
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;
toast.info('Nothing to repair — selected items are all flagged unrepairable.')
return
}
runRepair(targets);
runRepair(targets)
}}
disabled={repairing || filtered.length === 0}
>
@@ -484,9 +502,9 @@ export function RepairClient() {
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);
const unrep = summary.items.filter((i) => i.unrepairable).map((i) => i.classname)
setSelected(new Set(unrep))
setConfirmDeleteAll(true)
}}
disabled={repairing}
>
@@ -511,7 +529,11 @@ export function RepairClient() {
<TableHeader>
<TableRow>
<TableHead className="w-[40px]">
<Checkbox checked={allSelected} onCheckedChange={toggleAll} aria-label="Select all" />
<Checkbox
checked={allSelected}
onCheckedChange={toggleAll}
aria-label="Select all"
/>
</TableHead>
<TableHead className="w-[80px]">ID</TableHead>
<TableHead>Classname</TableHead>
@@ -554,12 +576,13 @@ export function RepairClient() {
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
Delete {selectedCount} imported item{selectedCount === 1 ? "" : "s"}?
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.
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>
@@ -573,7 +596,7 @@ export function RepairClient() {
</DialogContent>
</Dialog>
</div>
);
)
}
// ── Small cell helpers ────────────────────────────────────────────────
@@ -584,24 +607,24 @@ function StatCard({
tone,
sub,
}: {
label: string;
value: number;
tone: "default" | "danger" | "warn" | "info";
sub?: string;
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-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)]",
info: "border-[var(--admin-info)]/40 bg-[var(--admin-info)]/5",
};
default: 'border-border',
danger: 'border-destructive/40 bg-destructive/5',
warn: 'border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)]',
info: 'border-[var(--admin-info)]/40 bg-[var(--admin-info)]/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 }) {
@@ -616,49 +639,49 @@ function FileBadge({ present }: { present: boolean }) {
<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") {
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…"}
{progress.message || 'Working…'}
</span>
);
)
}
if (progress.status === "done") {
if (progress.status === 'done') {
const parts = [
progress.nitroRepaired ? ".nitro ✓" : null,
progress.iconRepaired ? "icon ✓" : null,
].filter(Boolean);
progress.nitroRepaired ? '.nitro ✓' : null,
progress.iconRepaired ? 'icon ✓' : null,
].filter(Boolean)
return (
<span className="flex items-center gap-1 text-xs text-[var(--admin-success)] dark:text-[var(--admin-success)]">
<CheckCircle2 className="h-3 w-3" />
{parts.length > 0 ? parts.join(" · ") : "Repaired"}
{parts.length > 0 ? parts.join(' · ') : 'Repaired'}
</span>
);
)
}
if (progress.status === "partial") {
if (progress.status === 'partial') {
return (
<span
className="flex items-center gap-1 text-xs text-[var(--admin-warning)] dark:text-[var(--admin-warning)]"
title={progress.message}
>
<AlertCircle className="h-3 w-3" />
Partial · {progress.message || "check logs"}
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"}
{progress.message || 'Failed'}
</span>
);
)
}
function ItemRow({
@@ -669,32 +692,39 @@ function ItemRow({
onRepair,
disabled,
}: {
item: MissingItem;
selected: boolean;
onToggle: () => void;
progress?: ItemProgress;
onRepair: () => void;
disabled: boolean;
item: MissingItem
selected: boolean
onToggle: () => void
progress?: ItemProgress
onRepair: () => void
disabled: boolean
}) {
const isUnrepairable = item.unrepairable || progress?.unrepairable;
const isUnrepairable = item.unrepairable || progress?.unrepairable
return (
<TableRow className={isUnrepairable ? "bg-destructive/5" : undefined}>
<TableRow className={isUnrepairable ? 'bg-destructive/5' : undefined}>
<TableCell>
<Checkbox checked={selected} onCheckedChange={onToggle} aria-label={`Select ${item.classname}`} />
<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">
<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 className="text-xs">{item.revision || '—'}</TableCell>
<TableCell>
<FileBadge present={item.hasNitro} />
</TableCell>
@@ -712,10 +742,10 @@ function ItemRow({
size="sm"
variant="outline"
onClick={onRepair}
disabled={disabled || progress?.status === "working" || isUnrepairable}
title={isUnrepairable ? "habbo.com does not have this asset" : "Retry download + convert"}
disabled={disabled || progress?.status === 'working' || isUnrepairable}
title={isUnrepairable ? 'habbo.com does not have this asset' : 'Retry download + convert'}
>
{progress?.status === "working" ? (
{progress?.status === 'working' ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Wrench className="h-3 w-3" />
@@ -723,5 +753,5 @@ function ItemRow({
</Button>
</TableCell>
</TableRow>
);
)
}