style: format code with prettier
Local Build and Deploy / deploy (push) Successful in 49s

This commit is contained in:
openhands committed 2026-07-12 21:07:34 +02:00
1 parent e85e4d74ea
commit 2e4ed76121
378 files changed
+22404 -20686

No files matched your search

@@ -1,62 +1,56 @@
'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 (
@@ -104,5 +98,5 @@ export function BadgeEditorDialog({ code, open, onOpenChange, onSaved }: BadgeEd
</DialogFooter>
</DialogContent>
</Dialog>
)
);
}
@@ -1,4 +1,4 @@
'use client'
"use client";
import {
AlertCircle,
@@ -12,183 +12,179 @@ 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}>
@@ -226,10 +222,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"
@@ -238,9 +234,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"
>
@@ -249,28 +245,24 @@ 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>
@@ -287,7 +279,7 @@ export function ImportBadgesClient() {
disabled={batchImporting}
>
{selected.size === importableBadges.length
? 'Deselect All'
? "Deselect All"
: `Select All (${importableBadges.length})`}
</Button>
{selected.size > 0 && (
@@ -330,7 +322,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>
)}
@@ -338,20 +330,17 @@ 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 */}
@@ -360,10 +349,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" />}
@@ -378,12 +367,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";
}
}}
/>
@@ -402,9 +391,7 @@ 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>
@@ -452,7 +439,7 @@ export function ImportBadgesClient() {
</div>
</div>
</div>
)
);
})}
</div>
)}
@@ -466,7 +453,7 @@ export function ImportBadgesClient() {
variant="outline"
size="sm"
disabled={offset <= 0 || loading}
onClick={() => goPage('prev')}
onClick={() => goPage("prev")}
>
&larr; Prev
</Button>
@@ -474,7 +461,7 @@ export function ImportBadgesClient() {
variant="outline"
size="sm"
disabled={!hasMore || loading}
onClick={() => goPage('next')}
onClick={() => goPage("next")}
>
Next &rarr;
</Button>
@@ -484,13 +471,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 />;
}
+262 -286
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,17 +246,12 @@ 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
@@ -277,8 +272,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" />
@@ -303,7 +298,7 @@ function SourcesManager({ sources, activeSourceId, onSelect, onRefresh }: Source
</div>
))}
</div>
)
);
}
// ── Furni icon with fallback ──────────────────────────────────────────────────
@@ -313,20 +308,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
@@ -336,203 +331,197 @@ 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 (
@@ -567,12 +556,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"
@@ -581,10 +570,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"
>
@@ -601,22 +590,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>
@@ -632,15 +621,11 @@ 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 && (
@@ -689,27 +674,22 @@ 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 && (
@@ -717,10 +697,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" />}
@@ -729,23 +709,19 @@ 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>
@@ -762,7 +738,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 ? (
@@ -789,7 +765,7 @@ function FurniGrid({ source }: FurniGridProps) {
</div>
</div>
</div>
)
);
})}
</div>
)}
@@ -805,7 +781,7 @@ function FurniGrid({ source }: FurniGridProps) {
variant="outline"
size="sm"
disabled={page <= 1 || loading}
onClick={() => goPage('prev')}
onClick={() => goPage("prev")}
>
&larr; Prev
</Button>
@@ -813,7 +789,7 @@ function FurniGrid({ source }: FurniGridProps) {
variant="outline"
size="sm"
disabled={page >= lastPage || loading}
onClick={() => goPage('next')}
onClick={() => goPage("next")}
>
Next &rarr;
</Button>
@@ -821,33 +797,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">
@@ -883,5 +859,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,45 +1,35 @@
'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 ─────────────────────────────────────────────────────────
@@ -50,38 +40,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) ───────────────────
@@ -89,30 +79,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
@@ -121,129 +111,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);
}
}
@@ -277,12 +267,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"
@@ -291,10 +281,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"
>
@@ -320,9 +310,7 @@ 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}>
@@ -352,28 +340,21 @@ 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 && (
@@ -381,10 +362,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" />}
@@ -434,7 +415,7 @@ function LibsView() {
</div>
</div>
</div>
)
);
})}
</div>
)}
@@ -449,7 +430,7 @@ function LibsView() {
variant="outline"
size="sm"
disabled={page <= 1 || loading}
onClick={() => goPage('prev')}
onClick={() => goPage("prev")}
>
&larr; Prev
</Button>
@@ -457,7 +438,7 @@ function LibsView() {
variant="outline"
size="sm"
disabled={page >= lastPage || loading}
onClick={() => goPage('next')}
onClick={() => goPage("next")}
>
Next &rarr;
</Button>
@@ -465,120 +446,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 (
@@ -611,12 +592,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"
@@ -625,10 +606,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"
>
@@ -650,15 +631,11 @@ 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}>
@@ -688,27 +665,22 @@ 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 && (
@@ -716,10 +688,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" />}
@@ -772,7 +744,7 @@ function SetsView() {
</div>
</div>
</div>
)
);
})}
</div>
)}
@@ -787,7 +759,7 @@ function SetsView() {
variant="outline"
size="sm"
disabled={page <= 1 || loading}
onClick={() => goPage('prev')}
onClick={() => goPage("prev")}
>
&larr; Prev
</Button>
@@ -795,7 +767,7 @@ function SetsView() {
variant="outline"
size="sm"
disabled={page >= lastPage || loading}
onClick={() => goPage('next')}
onClick={() => goPage("next")}
>
Next &rarr;
</Button>
@@ -803,36 +775,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,165 +12,160 @@ 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);
}
}
@@ -206,10 +201,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"
@@ -218,8 +213,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"
>
@@ -230,11 +225,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()}
@@ -256,9 +251,7 @@ 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}>
@@ -296,22 +289,17 @@ 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 && (
@@ -319,10 +307,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" />}
@@ -330,13 +318,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)]" />
@@ -385,10 +373,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>
)
);
}
+184 -215
View File
@@ -1,4 +1,4 @@
'use client'
"use client";
import {
Box,
@@ -14,71 +14,65 @@ 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>
@@ -88,178 +82,177 @@ 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}>
@@ -271,18 +264,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>
@@ -318,7 +311,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>
)}
@@ -331,11 +324,7 @@ 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">
@@ -343,7 +332,7 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
Visual
</TabsTrigger>
<TabsTrigger value="json" className="gap-1.5">
{'{ }'}
{"{ }"}
JSON
</TabsTrigger>
</TabsList>
@@ -369,7 +358,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>
@@ -382,7 +371,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>
@@ -396,7 +385,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>
@@ -440,9 +429,7 @@ 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">
@@ -451,19 +438,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)}
@@ -471,13 +458,11 @@ 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>
@@ -496,10 +481,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"
@@ -511,11 +496,9 @@ 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"
/>
@@ -542,12 +525,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">
@@ -566,7 +549,7 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
`visualizations.${viz64Index}.layers.${layerId}.z`,
parseInt(e.target.value, 10) || 0,
),
)
);
}}
/>
<Input
@@ -582,12 +565,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) =>
@@ -596,11 +579,11 @@ export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditor
`visualizations.${viz64Index}.layers.${layerId}.ink`,
e.target.value || undefined,
),
)
);
}}
/>
</div>
)
);
})}
</div>
</section>
@@ -623,22 +606,20 @@ 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">
@@ -652,25 +633,20 @@ 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 */}
@@ -678,9 +654,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",
),
}}
/>
@@ -718,10 +694,7 @@ 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>
)}
@@ -739,11 +712,7 @@ 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>
@@ -751,5 +720,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 />;
}
+109 -126
View File
@@ -1,32 +1,22 @@
'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
@@ -36,135 +26,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);
}
}
@@ -198,10 +188,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"
@@ -210,8 +200,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"
>
@@ -237,9 +227,7 @@ 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}>
@@ -274,21 +262,16 @@ 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 && (
@@ -296,10 +279,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" />}
@@ -347,10 +330,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 />;
}
+247 -277
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,245 +24,238 @@ 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 */
@@ -270,72 +263,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">
@@ -344,9 +337,8 @@ 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">
@@ -357,7 +349,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>
@@ -368,12 +360,10 @@ 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>
)}
@@ -388,12 +378,7 @@ 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}
@@ -437,9 +422,7 @@ 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>
)}
@@ -461,16 +444,15 @@ 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}
>
@@ -502,9 +484,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}
>
@@ -529,11 +511,7 @@ 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>
@@ -576,13 +554,12 @@ 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>
@@ -596,7 +573,7 @@ export function RepairClient() {
</DialogContent>
</Dialog>
</div>
)
);
}
// ── Small cell helpers ────────────────────────────────────────────────
@@ -607,24 +584,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 }) {
@@ -639,49 +616,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({
@@ -692,39 +669,32 @@ 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>
@@ -742,10 +712,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" />
@@ -753,5 +723,5 @@ function ItemRow({
</Button>
</TableCell>
</TableRow>
)
);
}