Add search, sorting, filters and list view to Studio
- Add debounced live search with keyboard shortcuts (/ focus, Ctrl/Cmd+A select all, Esc clear selection) - Add category filter and sort controls (name, classname, category, imported-first) - Add grid/list view toggle with a compact list table - Add regenerate .nitro action for missing-nitro items - Show result count in the header and a missing-nitro shortcut chip
This commit is contained in:
1 parent
3b8bf74e35
commit
7c9602c5cd
1 file changed
+467
-94
@@ -1,14 +1,18 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
ArrowUpDown,
|
||||||
Check,
|
Check,
|
||||||
CircleAlert,
|
CircleAlert,
|
||||||
CloudDownload,
|
CloudDownload,
|
||||||
Download,
|
Download,
|
||||||
Layers,
|
Layers,
|
||||||
|
LayoutGrid,
|
||||||
|
List,
|
||||||
Loader2,
|
Loader2,
|
||||||
Package,
|
Package,
|
||||||
Pencil,
|
Pencil,
|
||||||
|
RefreshCw,
|
||||||
Search,
|
Search,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Trash2,
|
Trash2,
|
||||||
@@ -21,6 +25,7 @@ import { deleteImportedFurni } from "@/actions/import-furni";
|
|||||||
import { NitroEditorDialog } from "@/app/admin/import/furni/nitro-editor-dialog";
|
import { NitroEditorDialog } from "@/app/admin/import/furni/nitro-editor-dialog";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -87,7 +92,12 @@ export function StudioClient({
|
|||||||
|
|
||||||
// Filters
|
// Filters
|
||||||
const [typeFilter, setTypeFilter] = useState<string>("all");
|
const [typeFilter, setTypeFilter] = useState<string>("all");
|
||||||
|
const [categoryFilter, setCategoryFilter] = useState<string>("all");
|
||||||
const [statusFilter, setStatusFilter] = useState<string>("all");
|
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||||
|
const [sortBy, setSortBy] = useState<string>("default");
|
||||||
|
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
|
||||||
|
const searchRef = useRef<HTMLInputElement>(null);
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
// Stats
|
// Stats
|
||||||
const [stats, setStats] = useState<FurniStats | null>(null);
|
const [stats, setStats] = useState<FurniStats | null>(null);
|
||||||
@@ -115,11 +125,24 @@ export function StudioClient({
|
|||||||
const [deleting, setDeleting] = useState<string | null>(null);
|
const [deleting, setDeleting] = useState<string | null>(null);
|
||||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
|
|
||||||
|
// Nitro regeneration (missing .nitro)
|
||||||
|
const [regeneratingNitro, setRegeneratingNitro] = useState<Set<string>>(
|
||||||
|
new Set(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const categories = useMemo(() => {
|
||||||
|
const cats = new Set(items.map((i) => i.category).filter(Boolean));
|
||||||
|
return ["all", ...Array.from(cats).sort()];
|
||||||
|
}, [items]);
|
||||||
|
|
||||||
const filteredItems = useMemo(() => {
|
const filteredItems = useMemo(() => {
|
||||||
let result = items;
|
let result = items;
|
||||||
if (typeFilter !== "all") {
|
if (typeFilter !== "all") {
|
||||||
result = result.filter((i) => i.type === typeFilter);
|
result = result.filter((i) => i.type === typeFilter);
|
||||||
}
|
}
|
||||||
|
if (categoryFilter !== "all") {
|
||||||
|
result = result.filter((i) => i.category === categoryFilter);
|
||||||
|
}
|
||||||
if (statusFilter !== "all") {
|
if (statusFilter !== "all") {
|
||||||
if (statusFilter === "new")
|
if (statusFilter === "new")
|
||||||
result = result.filter((i) => !i.alreadyImported);
|
result = result.filter((i) => !i.alreadyImported);
|
||||||
@@ -130,8 +153,22 @@ export function StudioClient({
|
|||||||
else if (statusFilter === "missingNitro")
|
else if (statusFilter === "missingNitro")
|
||||||
result = result.filter((i) => i.alreadyImported && !i.nitroExists);
|
result = result.filter((i) => i.alreadyImported && !i.nitroExists);
|
||||||
}
|
}
|
||||||
return result;
|
const sorted = [...result];
|
||||||
}, [items, typeFilter, statusFilter]);
|
if (sortBy === "name") sorted.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
else if (sortBy === "classname")
|
||||||
|
sorted.sort((a, b) => a.classname.localeCompare(b.classname));
|
||||||
|
else if (sortBy === "category")
|
||||||
|
sorted.sort((a, b) => (a.category || "").localeCompare(b.category || ""));
|
||||||
|
else if (sortBy === "imported-first")
|
||||||
|
sorted.sort(
|
||||||
|
(a, b) => (b.alreadyImported ? 1 : 0) - (a.alreadyImported ? 1 : 0),
|
||||||
|
);
|
||||||
|
else if (sortBy === "not-imported-first")
|
||||||
|
sorted.sort(
|
||||||
|
(a, b) => (a.alreadyImported ? 1 : 0) - (b.alreadyImported ? 1 : 0),
|
||||||
|
);
|
||||||
|
return sorted;
|
||||||
|
}, [items, typeFilter, categoryFilter, statusFilter, sortBy]);
|
||||||
|
|
||||||
const allSelected = useMemo(
|
const allSelected = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -213,6 +250,20 @@ export function StudioClient({
|
|||||||
fetchSources();
|
fetchSources();
|
||||||
}, [fetchItems, fetchStats, fetchSources, activeSource]);
|
}, [fetchItems, fetchStats, fetchSources, activeSource]);
|
||||||
|
|
||||||
|
// Debounced auto-search (also triggered by the "/" shortcut focus).
|
||||||
|
useEffect(() => {
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
debounceRef.current = setTimeout(() => {
|
||||||
|
if (searchTerm !== activeSearch) {
|
||||||
|
setActiveSearch(searchTerm);
|
||||||
|
fetchItems(searchTerm, 1, activeSource);
|
||||||
|
}
|
||||||
|
}, 400);
|
||||||
|
return () => {
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
};
|
||||||
|
}, [searchTerm, fetchItems, activeSearch, activeSource]);
|
||||||
|
|
||||||
// Keep detail in sync with the refreshed list.
|
// Keep detail in sync with the refreshed list.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!detail) return;
|
if (!detail) return;
|
||||||
@@ -240,7 +291,7 @@ export function StudioClient({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleAll = () => {
|
const toggleAll = useCallback(() => {
|
||||||
if (allSelected) {
|
if (allSelected) {
|
||||||
const toRemove = new Set(filteredItems.map((i) => i.classname));
|
const toRemove = new Set(filteredItems.map((i) => i.classname));
|
||||||
setSelected((prev) => {
|
setSelected((prev) => {
|
||||||
@@ -255,7 +306,29 @@ export function StudioClient({
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
}, [allSelected, filteredItems]);
|
||||||
|
|
||||||
|
// Keyboard shortcuts: "/" focus search, Ctrl/Cmd+A select all, Esc clear.
|
||||||
|
useEffect(() => {
|
||||||
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
|
const tag = (e.target as HTMLElement)?.tagName;
|
||||||
|
const isInput = tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT";
|
||||||
|
if (e.key === "/" && !e.metaKey && !e.ctrlKey && !e.altKey && !isInput) {
|
||||||
|
e.preventDefault();
|
||||||
|
searchRef.current?.focus();
|
||||||
|
}
|
||||||
|
if (e.key === "a" && (e.metaKey || e.ctrlKey) && !isInput) {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleAll();
|
||||||
|
}
|
||||||
|
if (e.key === "Escape" && !isInput) {
|
||||||
|
setSelected(new Set());
|
||||||
|
searchRef.current?.blur();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => document.removeEventListener("keydown", onKeyDown);
|
||||||
|
}, [toggleAll]);
|
||||||
|
|
||||||
async function importSingle(item: FurniItem) {
|
async function importSingle(item: FurniItem) {
|
||||||
setImportingId(item.classname);
|
setImportingId(item.classname);
|
||||||
@@ -441,6 +514,40 @@ export function StudioClient({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function regenNitro(item: FurniItem) {
|
||||||
|
setRegeneratingNitro((prev) => new Set(prev).add(item.classname));
|
||||||
|
try {
|
||||||
|
const res = await adminFetch("/api/admin/import/furni", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
items: [{ classname: item.classname, revision: item.revision }],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok && data.succeeded > 0) {
|
||||||
|
setItems((prev) =>
|
||||||
|
prev.map((i) =>
|
||||||
|
i.classname === item.classname ? { ...i, nitroExists: true } : i,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
toast.success(`Nitro file regenerated for ${item.classname}`);
|
||||||
|
} else {
|
||||||
|
toast.error(
|
||||||
|
data.results?.[0]?.warning || data.error || "Regeneration failed",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("Network error");
|
||||||
|
} finally {
|
||||||
|
setRegeneratingNitro((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(item.classname);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const detailPreview = useMemo<AutoCatalogPreview | null>(() => {
|
const detailPreview = useMemo<AutoCatalogPreview | null>(() => {
|
||||||
if (!detail) return null;
|
if (!detail) return null;
|
||||||
return previewAutoCatalog(
|
return previewAutoCatalog(
|
||||||
@@ -457,6 +564,12 @@ export function StudioClient({
|
|||||||
[filteredItems, selected],
|
[filteredItems, selected],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const missingNitroCount = useMemo(
|
||||||
|
() =>
|
||||||
|
filteredItems.filter((i) => i.alreadyImported && !i.nitroExists).length,
|
||||||
|
[filteredItems],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex h-[calc(100dvh-7rem)] min-h-[520px] flex-col overflow-hidden rounded-xl border border-[var(--admin-border)]"
|
className="flex h-[calc(100dvh-7rem)] min-h-[520px] flex-col overflow-hidden rounded-xl border border-[var(--admin-border)]"
|
||||||
@@ -485,10 +598,14 @@ export function StudioClient({
|
|||||||
className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[var(--admin-text-muted)]"
|
className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[var(--admin-text-muted)]"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
|
ref={searchRef}
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === "Enter" && submitSearch()}
|
onKeyDown={(e) => {
|
||||||
placeholder="Search furni, classnames, keywords…"
|
if (e.key === "Enter") submitSearch();
|
||||||
|
if (e.key === "Escape") clearSearch();
|
||||||
|
}}
|
||||||
|
placeholder="Search furni, classnames, keywords… (/)"
|
||||||
className="pl-8 pr-8"
|
className="pl-8 pr-8"
|
||||||
/>
|
/>
|
||||||
{searchTerm ? (
|
{searchTerm ? (
|
||||||
@@ -502,9 +619,11 @@ export function StudioClient({
|
|||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" variant="secondary" onClick={submitSearch}>
|
{meta && (
|
||||||
Search
|
<span className="shrink-0 text-xs font-semibold text-[var(--admin-text-muted)]">
|
||||||
</Button>
|
{meta.total.toLocaleString()} results
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
@@ -557,6 +676,19 @@ export function StudioClient({
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
|
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
|
||||||
|
<SelectTrigger className="h-8 w-40 text-xs">
|
||||||
|
<SelectValue placeholder="Category" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<SelectItem key={cat} value={cat}>
|
||||||
|
{cat === "all" ? "All categories" : cat}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||||
<SelectTrigger className="h-8 w-36 text-xs">
|
<SelectTrigger className="h-8 w-36 text-xs">
|
||||||
<SelectValue placeholder="Status" />
|
<SelectValue placeholder="Status" />
|
||||||
@@ -570,6 +702,68 @@ export function StudioClient({
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
|
<Select value={sortBy} onValueChange={setSortBy}>
|
||||||
|
<SelectTrigger className="h-8 w-44 text-xs">
|
||||||
|
<ArrowUpDown size={12} className="mr-1" />
|
||||||
|
<SelectValue placeholder="Sort" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="default">Default order</SelectItem>
|
||||||
|
<SelectItem value="name">Name A–Z</SelectItem>
|
||||||
|
<SelectItem value="classname">Classname A–Z</SelectItem>
|
||||||
|
<SelectItem value="category">Category</SelectItem>
|
||||||
|
<SelectItem value="imported-first">Imported first</SelectItem>
|
||||||
|
<SelectItem value="not-imported-first">
|
||||||
|
Not imported first
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{missingNitroCount > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setStatusFilter("missingNitro")}
|
||||||
|
className={cn(
|
||||||
|
"flex h-8 items-center gap-1.5 rounded-md border px-2.5 text-xs font-medium transition-colors",
|
||||||
|
statusFilter === "missingNitro"
|
||||||
|
? "border-[var(--admin-warning)] bg-[var(--admin-warning)]/10 text-[var(--admin-warning)]"
|
||||||
|
: "border-[var(--admin-border)] text-[var(--admin-warning)] hover:bg-[var(--admin-warning)]/10",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CircleAlert size={12} />
|
||||||
|
{missingNitroCount} no nitro
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex h-8 items-center overflow-hidden rounded-md border border-[var(--admin-border)]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setViewMode("grid")}
|
||||||
|
aria-label="Grid view"
|
||||||
|
className={cn(
|
||||||
|
"grid h-8 w-8 place-items-center transition-colors",
|
||||||
|
viewMode === "grid"
|
||||||
|
? "bg-[var(--admin-accent)] text-[var(--admin-accent-foreground)]"
|
||||||
|
: "text-[var(--admin-text-muted)] hover:bg-[var(--admin-accent)]/10",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<LayoutGrid size={13} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setViewMode("list")}
|
||||||
|
aria-label="List view"
|
||||||
|
className={cn(
|
||||||
|
"grid h-8 w-8 place-items-center transition-colors",
|
||||||
|
viewMode === "list"
|
||||||
|
? "bg-[var(--admin-accent)] text-[var(--admin-accent-foreground)]"
|
||||||
|
: "text-[var(--admin-text-muted)] hover:bg-[var(--admin-accent)]/10",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<List size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={toggleAll}
|
onClick={toggleAll}
|
||||||
@@ -649,90 +843,253 @@ export function StudioClient({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
{viewMode === "grid" ? (
|
||||||
{filteredItems.map((item) => {
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||||
const isSel = selected.has(item.classname);
|
{filteredItems.map((item) => {
|
||||||
return (
|
const isSel = selected.has(item.classname);
|
||||||
<div key={item.classname} className="group relative">
|
return (
|
||||||
<button
|
<div key={item.classname} className="group relative">
|
||||||
type="button"
|
<button
|
||||||
onClick={() => setDetail(item)}
|
type="button"
|
||||||
onDoubleClick={() => toggleSelect(item.classname)}
|
onClick={() => setDetail(item)}
|
||||||
className={cn(
|
onDoubleClick={() => toggleSelect(item.classname)}
|
||||||
"flex w-full flex-col overflow-hidden rounded-xl border text-left transition-all outline-none focus-visible:ring-2 focus-visible:ring-[var(--admin-accent)]",
|
className={cn(
|
||||||
isSel
|
"flex w-full flex-col overflow-hidden rounded-xl border text-left transition-all outline-none focus-visible:ring-2 focus-visible:ring-[var(--admin-accent)]",
|
||||||
? "border-[var(--admin-accent)] bg-[var(--admin-accent)]/10"
|
isSel
|
||||||
: "border-[var(--admin-border)] bg-[var(--admin-surface)] hover:border-[var(--admin-accent)]/60",
|
? "border-[var(--admin-accent)] bg-[var(--admin-accent)]/10"
|
||||||
)}
|
: "border-[var(--admin-border)] bg-[var(--admin-surface)] hover:border-[var(--admin-accent)]/60",
|
||||||
>
|
|
||||||
<div className="relative flex h-24 items-center justify-center overflow-hidden bg-gradient-to-br from-[var(--admin-surface)] to-[var(--admin-canvas)]">
|
|
||||||
<img
|
|
||||||
src={getFurniImageUrl(item)}
|
|
||||||
alt={item.name}
|
|
||||||
className="h-14 w-14 object-contain transition-transform group-hover:scale-110"
|
|
||||||
style={{ imageRendering: "pixelated" }}
|
|
||||||
loading="lazy"
|
|
||||||
onError={(e) => handleImgError(e, item)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-start justify-between gap-1 border-t border-[var(--admin-border)] px-2 py-1.5">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="truncate text-xs font-semibold text-[var(--admin-text)]">
|
|
||||||
{item.name}
|
|
||||||
</p>
|
|
||||||
<p className="truncate font-mono text-[0.6rem] text-[var(--admin-text-muted)]">
|
|
||||||
{item.classname}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1 px-2 pb-1.5">
|
|
||||||
<Badge className="h-4 text-[var(--admin-accent-foreground)]">
|
|
||||||
{TYPE_LABELS[item.type] ?? item.type}
|
|
||||||
</Badge>
|
|
||||||
{item.alreadyImported ? (
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
className={cn(
|
|
||||||
"h-4",
|
|
||||||
item.nitroExists
|
|
||||||
? "text-[var(--admin-success)] border-[var(--admin-success)]/40"
|
|
||||||
: "text-[var(--admin-warning)] border-[var(--admin-warning)]/40",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{item.nitroExists ? "Imported" : "No nitro"}
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge className="h-4 text-[var(--admin-accent-foreground)]">
|
|
||||||
New
|
|
||||||
</Badge>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
>
|
||||||
</button>
|
<div className="relative flex h-24 items-center justify-center overflow-hidden bg-gradient-to-br from-[var(--admin-surface)] to-[var(--admin-canvas)]">
|
||||||
<button
|
<img
|
||||||
type="button"
|
src={getFurniImageUrl(item)}
|
||||||
onClick={(e) => {
|
alt={item.name}
|
||||||
e.stopPropagation();
|
className="h-14 w-14 object-contain transition-transform group-hover:scale-110"
|
||||||
toggleSelect(item.classname);
|
style={{ imageRendering: "pixelated" }}
|
||||||
}}
|
loading="lazy"
|
||||||
className={cn(
|
onError={(e) => handleImgError(e, item)}
|
||||||
"absolute right-1.5 top-1.5 grid h-5 w-5 place-items-center rounded-md border transition-colors",
|
/>
|
||||||
isSel
|
</div>
|
||||||
? "border-[var(--admin-accent)] bg-[var(--admin-accent)]"
|
<div className="flex items-start justify-between gap-1 border-t border-[var(--admin-border)] px-2 py-1.5">
|
||||||
: "border-[var(--admin-border)] bg-[var(--admin-surface)] hover:border-[var(--admin-accent)]/60",
|
<div className="min-w-0">
|
||||||
)}
|
<p className="truncate text-xs font-semibold text-[var(--admin-text)]">
|
||||||
aria-label={isSel ? "Deselect" : "Select"}
|
{item.name}
|
||||||
>
|
</p>
|
||||||
{isSel ? (
|
<p className="truncate font-mono text-[0.6rem] text-[var(--admin-text-muted)]">
|
||||||
<Check
|
{item.classname}
|
||||||
size={12}
|
</p>
|
||||||
className="text-[var(--admin-accent-foreground)]"
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 px-2 pb-1.5">
|
||||||
|
<Badge className="h-4 text-[var(--admin-accent-foreground)]">
|
||||||
|
{TYPE_LABELS[item.type] ?? item.type}
|
||||||
|
</Badge>
|
||||||
|
{item.alreadyImported ? (
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className={cn(
|
||||||
|
"h-4",
|
||||||
|
item.nitroExists
|
||||||
|
? "text-[var(--admin-success)] border-[var(--admin-success)]/40"
|
||||||
|
: "text-[var(--admin-warning)] border-[var(--admin-warning)]/40",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.nitroExists ? "Imported" : "No nitro"}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge className="h-4 text-[var(--admin-accent-foreground)]">
|
||||||
|
New
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleSelect(item.classname);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"absolute right-1.5 top-1.5 grid h-5 w-5 place-items-center rounded-md border transition-colors",
|
||||||
|
isSel
|
||||||
|
? "border-[var(--admin-accent)] bg-[var(--admin-accent)]"
|
||||||
|
: "border-[var(--admin-border)] bg-[var(--admin-surface)] hover:border-[var(--admin-accent)]/60",
|
||||||
|
)}
|
||||||
|
aria-label={isSel ? "Deselect" : "Select"}
|
||||||
|
>
|
||||||
|
{isSel ? (
|
||||||
|
<Check
|
||||||
|
size={12}
|
||||||
|
className="text-[var(--admin-accent-foreground)]"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto rounded-lg border border-[var(--admin-border)]">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead className="bg-[var(--admin-accent)]/8">
|
||||||
|
<tr>
|
||||||
|
<th className="w-9 px-2 py-2 text-left">
|
||||||
|
<Checkbox
|
||||||
|
checked={allSelected}
|
||||||
|
onCheckedChange={toggleAll}
|
||||||
|
aria-label="Select all"
|
||||||
/>
|
/>
|
||||||
) : null}
|
</th>
|
||||||
</button>
|
<th className="w-12 px-1 py-2" />
|
||||||
</div>
|
<th className="px-2 py-2 text-left font-semibold text-[var(--admin-text-muted)]">
|
||||||
);
|
Name
|
||||||
})}
|
</th>
|
||||||
</div>
|
<th className="px-2 py-2 text-left font-semibold text-[var(--admin-text-muted)]">
|
||||||
|
Classname
|
||||||
|
</th>
|
||||||
|
<th className="px-2 py-2 text-left font-semibold text-[var(--admin-text-muted)]">
|
||||||
|
Type
|
||||||
|
</th>
|
||||||
|
<th className="px-2 py-2 text-left font-semibold text-[var(--admin-text-muted)]">
|
||||||
|
Category
|
||||||
|
</th>
|
||||||
|
<th className="px-2 py-2 text-left font-semibold text-[var(--admin-text-muted)]">
|
||||||
|
Status
|
||||||
|
</th>
|
||||||
|
<th className="w-28 px-2 py-2 text-right font-semibold text-[var(--admin-text-muted)]">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredItems.map((item) => {
|
||||||
|
const isSel = selected.has(item.classname);
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={item.classname}
|
||||||
|
className="border-t border-[var(--admin-border)] transition-colors hover:bg-[var(--admin-accent)]/5"
|
||||||
|
>
|
||||||
|
<td className="px-2 py-1.5">
|
||||||
|
<Checkbox
|
||||||
|
checked={isSel}
|
||||||
|
onCheckedChange={() =>
|
||||||
|
toggleSelect(item.classname)
|
||||||
|
}
|
||||||
|
aria-label={`Select ${item.classname}`}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-1 py-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDetail(item)}
|
||||||
|
className="rounded outline-none focus-visible:ring-2 focus-visible:ring-[var(--admin-accent)]"
|
||||||
|
aria-label={`View ${item.name}`}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={getFurniImageUrl(item)}
|
||||||
|
alt=""
|
||||||
|
className="h-8 w-8 object-contain"
|
||||||
|
style={{ imageRendering: "pixelated" }}
|
||||||
|
loading="lazy"
|
||||||
|
onError={(e) => handleImgError(e, item)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="max-w-[220px] truncate px-2 py-1.5 font-medium text-[var(--admin-text)]">
|
||||||
|
{item.name}
|
||||||
|
</td>
|
||||||
|
<td className="max-w-[180px] truncate px-2 py-1.5 font-mono text-[0.7rem] text-[var(--admin-text-muted)]">
|
||||||
|
{item.classname}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5 text-[var(--admin-text-muted)]">
|
||||||
|
{TYPE_LABELS[item.type] ?? item.type}
|
||||||
|
</td>
|
||||||
|
<td className="max-w-[140px] truncate px-2 py-1.5 text-[var(--admin-text-muted)]">
|
||||||
|
{item.category || "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5">
|
||||||
|
{item.alreadyImported ? (
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className={cn(
|
||||||
|
"h-4",
|
||||||
|
item.nitroExists
|
||||||
|
? "text-[var(--admin-success)] border-[var(--admin-success)]/40"
|
||||||
|
: "text-[var(--admin-warning)] border-[var(--admin-warning)]/40",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.nitroExists ? "Imported" : "No nitro"}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge className="h-4 text-[var(--admin-accent-foreground)]">
|
||||||
|
New
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5">
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
{!item.alreadyImported && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => importSingle(item)}
|
||||||
|
disabled={importingId !== null}
|
||||||
|
aria-label={`Import ${item.classname}`}
|
||||||
|
className="grid h-6 w-6 place-items-center rounded-md text-[var(--admin-text-muted)] transition-colors hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-text)] disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{importingId === item.classname ? (
|
||||||
|
<Loader2
|
||||||
|
size={12}
|
||||||
|
className="animate-spin"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<CloudDownload size={12} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{item.alreadyImported && item.nitroExists && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setEditingClassname(item.classname)
|
||||||
|
}
|
||||||
|
aria-label={`Edit nitro ${item.classname}`}
|
||||||
|
className="grid h-6 w-6 place-items-center rounded-md text-[var(--admin-text-muted)] transition-colors hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-text)]"
|
||||||
|
>
|
||||||
|
<Pencil size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{item.alreadyImported &&
|
||||||
|
!item.nitroExists && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => regenNitro(item)}
|
||||||
|
disabled={regeneratingNitro.has(
|
||||||
|
item.classname,
|
||||||
|
)}
|
||||||
|
aria-label={`Regenerate nitro ${item.classname}`}
|
||||||
|
className="grid h-6 w-6 place-items-center rounded-md text-[var(--admin-warning)] transition-colors hover:bg-[var(--admin-warning)]/10 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{regeneratingNitro.has(
|
||||||
|
item.classname,
|
||||||
|
) ? (
|
||||||
|
<Loader2
|
||||||
|
size={12}
|
||||||
|
className="animate-spin"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<RefreshCw size={12} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{meta && meta.currentPage < meta.lastPage && (
|
{meta && meta.currentPage < meta.lastPage && (
|
||||||
<div className="mt-4 flex justify-center">
|
<div className="mt-4 flex justify-center">
|
||||||
@@ -884,9 +1241,25 @@ export function StudioClient({
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{!detail.nitroExists && (
|
{!detail.nitroExists && (
|
||||||
<p className="rounded-md border border-[var(--admin-warning)]/30 bg-[var(--admin-warning)]/10 p-2 text-[0.65rem] text-[var(--admin-warning)]">
|
<>
|
||||||
This furni is in the DB but the .nitro file is missing.
|
<p className="rounded-md border border-[var(--admin-warning)]/30 bg-[var(--admin-warning)]/10 p-2 text-[0.65rem] text-[var(--admin-warning)]">
|
||||||
</p>
|
This furni is in the DB but the .nitro file is
|
||||||
|
missing.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full gap-1.5 text-[var(--admin-warning)]"
|
||||||
|
disabled={regeneratingNitro.has(detail.classname)}
|
||||||
|
onClick={() => regenNitro(detail)}
|
||||||
|
>
|
||||||
|
{regeneratingNitro.has(detail.classname) ? (
|
||||||
|
<Loader2 size={14} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw size={14} />
|
||||||
|
)}
|
||||||
|
Regenerate .nitro
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
|||||||
Reference in new issue
Block a user