This commit is contained in:
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")}
|
||||
>
|
||||
← Prev
|
||||
</Button>
|
||||
@@ -474,7 +461,7 @@ export function ImportBadgesClient() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!hasMore || loading}
|
||||
onClick={() => goPage('next')}
|
||||
onClick={() => goPage("next")}
|
||||
>
|
||||
Next →
|
||||
</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>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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")}
|
||||
>
|
||||
← 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 →
|
||||
</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>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -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")}
|
||||
>
|
||||
← Prev
|
||||
</Button>
|
||||
@@ -457,7 +438,7 @@ function LibsView() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= lastPage || loading}
|
||||
onClick={() => goPage('next')}
|
||||
onClick={() => goPage("next")}
|
||||
>
|
||||
Next →
|
||||
</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")}
|
||||
>
|
||||
← Prev
|
||||
</Button>
|
||||
@@ -795,7 +767,7 @@ function SetsView() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= lastPage || loading}
|
||||
onClick={() => goPage('next')}
|
||||
onClick={() => goPage("next")}
|
||||
>
|
||||
Next →
|
||||
</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>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -9,5 +9,5 @@ export default function Loading() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -10,5 +10,5 @@ export default function Loading() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -9,5 +9,5 @@ export default function Loading() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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 “Scan Assets” to audit imported furniture.
|
||||
</p>
|
||||
<p className="font-medium">Click “Scan Assets” 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>
|
||||
)
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user