From 7c9602c5cde1f48fee44209ac82165269f12de7c Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 18 Aug 2026 19:05:54 +0200 Subject: [PATCH] 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 --- src/components/admin/studio/studio-client.tsx | 561 +++++++++++++++--- 1 file changed, 467 insertions(+), 94 deletions(-) diff --git a/src/components/admin/studio/studio-client.tsx b/src/components/admin/studio/studio-client.tsx index 16114b1f..5d9135a6 100644 --- a/src/components/admin/studio/studio-client.tsx +++ b/src/components/admin/studio/studio-client.tsx @@ -1,14 +1,18 @@ "use client"; import { + ArrowUpDown, Check, CircleAlert, CloudDownload, Download, Layers, + LayoutGrid, + List, Loader2, Package, Pencil, + RefreshCw, Search, Sparkles, Trash2, @@ -21,6 +25,7 @@ import { deleteImportedFurni } from "@/actions/import-furni"; import { NitroEditorDialog } from "@/app/admin/import/furni/nitro-editor-dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; import { Dialog, DialogContent, @@ -87,7 +92,12 @@ export function StudioClient({ // Filters const [typeFilter, setTypeFilter] = useState("all"); + const [categoryFilter, setCategoryFilter] = useState("all"); const [statusFilter, setStatusFilter] = useState("all"); + const [sortBy, setSortBy] = useState("default"); + const [viewMode, setViewMode] = useState<"grid" | "list">("grid"); + const searchRef = useRef(null); + const debounceRef = useRef | null>(null); // Stats const [stats, setStats] = useState(null); @@ -115,11 +125,24 @@ export function StudioClient({ const [deleting, setDeleting] = useState(null); const [confirmDelete, setConfirmDelete] = useState(false); + // Nitro regeneration (missing .nitro) + const [regeneratingNitro, setRegeneratingNitro] = useState>( + 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(() => { let result = items; if (typeFilter !== "all") { result = result.filter((i) => i.type === typeFilter); } + if (categoryFilter !== "all") { + result = result.filter((i) => i.category === categoryFilter); + } if (statusFilter !== "all") { if (statusFilter === "new") result = result.filter((i) => !i.alreadyImported); @@ -130,8 +153,22 @@ export function StudioClient({ else if (statusFilter === "missingNitro") result = result.filter((i) => i.alreadyImported && !i.nitroExists); } - return result; - }, [items, typeFilter, statusFilter]); + const sorted = [...result]; + 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( () => @@ -213,6 +250,20 @@ export function StudioClient({ fetchSources(); }, [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. useEffect(() => { if (!detail) return; @@ -240,7 +291,7 @@ export function StudioClient({ }); } - const toggleAll = () => { + const toggleAll = useCallback(() => { if (allSelected) { const toRemove = new Set(filteredItems.map((i) => i.classname)); setSelected((prev) => { @@ -255,7 +306,29 @@ export function StudioClient({ 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) { 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(() => { if (!detail) return null; return previewAutoCatalog( @@ -457,6 +564,12 @@ export function StudioClient({ [filteredItems, selected], ); + const missingNitroCount = useMemo( + () => + filteredItems.filter((i) => i.alreadyImported && !i.nitroExists).length, + [filteredItems], + ); + return (
setSearchTerm(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && submitSearch()} - placeholder="Search furni, classnames, keywords…" + onKeyDown={(e) => { + if (e.key === "Enter") submitSearch(); + if (e.key === "Escape") clearSearch(); + }} + placeholder="Search furni, classnames, keywords… (/)" className="pl-8 pr-8" /> {searchTerm ? ( @@ -502,9 +619,11 @@ export function StudioClient({ ) : null}
- + {meta && ( + + {meta.total.toLocaleString()} results + + )}
@@ -557,6 +676,19 @@ export function StudioClient({ + + + + + {missingNitroCount > 0 && ( + + )} + +
+ + +
+
- - + + + ); + })} + + ) : ( +
+ + + + + + + + + + + + + + {filteredItems.map((item) => { + const isSel = selected.has(item.classname); + return ( + + + + + + + + + + + ); + })} + +
+ - ) : null} - - - ); - })} - + + + Name + + Classname + + Type + + Category + + Status + + Actions +
+ + toggleSelect(item.classname) + } + aria-label={`Select ${item.classname}`} + /> + + + + {item.name} + + {item.classname} + + {TYPE_LABELS[item.type] ?? item.type} + + {item.category || "—"} + + {item.alreadyImported ? ( + + {item.nitroExists ? "Imported" : "No nitro"} + + ) : ( + + New + + )} + +
+ {!item.alreadyImported && ( + + )} + {item.alreadyImported && item.nitroExists && ( + + )} + {item.alreadyImported && + !item.nitroExists && ( + + )} +
+
+
+ )} {meta && meta.currentPage < meta.lastPage && (
@@ -884,9 +1241,25 @@ export function StudioClient({ ) : (
{!detail.nitroExists && ( -

- This furni is in the DB but the .nitro file is missing. -

+ <> +

+ This furni is in the DB but the .nitro file is + missing. +

+ + )}