From 641b6b8cb88ce5e91cb1dbb7e4db98a8f8d1d18c Mon Sep 17 00:00:00 2001 From: openhands Date: Sun, 13 Sep 2026 12:04:11 +0200 Subject: [PATCH] feat(studio): bulk select all results, persist view prefs, back-to-top --- src/components/admin/studio/studio-client.tsx | 223 ++++++++++++++++-- 1 file changed, 206 insertions(+), 17 deletions(-) diff --git a/src/components/admin/studio/studio-client.tsx b/src/components/admin/studio/studio-client.tsx index 72f39051..1b1823b7 100644 --- a/src/components/admin/studio/studio-client.tsx +++ b/src/components/admin/studio/studio-client.tsx @@ -3,6 +3,7 @@ import { useVirtualizer } from "@tanstack/react-virtual"; import { ArrowDown, + ArrowUp, ArrowUpDown, Check, CircleAlert, @@ -12,6 +13,7 @@ import { Layers, LayoutGrid, List, + ListChecks, Loader2, Package, PanelLeftClose, @@ -107,6 +109,39 @@ const GRID_CARD_W = 150; const GRID_GAP = 12; const GRID_ROW_H = 176; +// Persisted UI preferences (survive reloads). +const LS_VIEW_MODE = "studio:viewMode"; +const LS_SORT_BY = "studio:sortBy"; +const SORT_KEYS = [ + "default", + "name", + "classname", + "category", + "imported-first", + "not-imported-first", +] as const; +function readLs( + key: string, + fallback: T, + valid: ReadonlyArray, +): T { + if (typeof window === "undefined") return fallback; + try { + const raw = window.localStorage.getItem(key); + if (!raw) return fallback; + return valid.includes(raw as T) ? (raw as T) : fallback; + } catch { + return fallback; + } +} +function writeLs(key: string, value: string) { + try { + window.localStorage.setItem(key, value); + } catch { + /* private mode / quota — ignore */ + } +} + // Table layout: a shared grid template keeps the sticky header and each // virtualized row in perfect column alignment. const TABLE_COLS = @@ -159,8 +194,20 @@ export function StudioClient({ 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">("list"); + const [sortBy, setSortBy] = useState(() => + readLs(LS_SORT_BY, "default", SORT_KEYS), + ); + const [viewMode, setViewMode] = useState<"grid" | "list">(() => + readLs(LS_VIEW_MODE, "list", ["grid", "list"] as const), + ); + const changeViewMode = (mode: "grid" | "list") => { + setViewMode(mode); + writeLs(LS_VIEW_MODE, mode); + }; + const changeSortBy = (value: string) => { + setSortBy(value); + writeLs(LS_SORT_BY, value); + }; const searchRef = useRef(null); const debounceRef = useRef | null>(null); @@ -171,6 +218,12 @@ export function StudioClient({ const [selected, setSelected] = useState>(new Set()); const [detail, setDetail] = useState(null); const listRef = useRef(null); + // Mirrors `items` synchronously so "select all results" can read the + // fully-loaded list right after the last page resolves. + const itemsRef = useRef([]); + // Bumped when the list query changes (search/source) to cancel a running + // bulk "select all" loop. + const listGenRef = useRef(0); const detailOrigin = useRef<{ top: number; trigger: HTMLElement | null; @@ -319,6 +372,9 @@ export function StudioClient({ // Infinite scroll: auto-load the next page when the sentinel enters view. const sentinelRef = useRef(null); + // Bulk "select all results" run state; guards the sentinel while pages load. + const [loadingAll, setLoadingAll] = useState(false); + const loadingAllRef = useRef(false); const fetchStats = useCallback(async () => { try { @@ -333,7 +389,11 @@ export function StudioClient({ }, []); const fetchItems = useCallback( - async (search: string, page: number, source?: string) => { + async ( + search: string, + page: number, + source?: string, + ): Promise => { listRequest.current?.abort(); const controller = new AbortController(); listRequest.current = controller; @@ -347,19 +407,19 @@ export function StudioClient({ `/api/admin/import/furni?${qs.toString()}`, { signal: controller.signal }, ); - if (controller.signal.aborted) return; + if (controller.signal.aborted) return null; if (!res.ok) { setError(`Request failed (${res.status})`); setLoading(false); - return; + return null; } const data = await res.json(); - if (controller.signal.aborted) return; + if (controller.signal.aborted) return null; + const fetched: FurniItem[] = data.items ?? []; if (page === 1) { - setItems(data.items ?? []); - const available = new Set( - (data.items ?? []).map((item: FurniItem) => item.classname), - ); + itemsRef.current = fetched; + setItems(fetched); + const available = new Set(fetched.map((item) => item.classname)); setSelected( (previous) => new Set( @@ -368,17 +428,28 @@ export function StudioClient({ ); setDetail((previous) => previous - ? ((data.items ?? []).find( - (item: FurniItem) => item.classname === previous.classname, + ? (fetched.find( + (item) => item.classname === previous.classname, ) ?? null) : null, ); } else { - setItems((prev) => [...prev, ...(data.items ?? [])]); + // Pages can arrive out of order (infinite scroll + select-all races); + // skip classnames that are already present. + const prev = itemsRef.current; + const seen = new Set(prev.map((item) => item.classname)); + const fresh = fetched.filter((item) => !seen.has(item.classname)); + if (fresh.length > 0) { + const next = [...prev, ...fresh]; + itemsRef.current = next; + setItems(next); + } } setMeta(data.meta ?? null); + return fetched; } catch { if (!controller.signal.aborted) setError("Network error"); + return null; } finally { if (!controller.signal.aborted) setLoading(false); } @@ -397,6 +468,7 @@ export function StudioClient({ if ( entries.some((e) => e.isIntersecting) && !loading && + !loadingAll && !batchProgress ) { fetchItems(activeSearch, (meta?.currentPage ?? 1) + 1, activeSource); @@ -413,14 +485,36 @@ export function StudioClient({ hasMorePages, meta?.currentPage, loading, + loadingAll, batchProgress, activeSearch, activeSource, fetchItems, ]); + // Floating "back to top" affordance once the list is long. + const [showBackToTop, setShowBackToTop] = useState(false); + useEffect(() => { + const el = listRef.current; + if (!el) return; + let raf = 0; + const onScroll = () => { + cancelAnimationFrame(raf); + raf = requestAnimationFrame(() => { + setShowBackToTop(el.scrollTop > 600); + }); + }; + el.addEventListener("scroll", onScroll, { passive: true }); + onScroll(); + return () => { + cancelAnimationFrame(raf); + el.removeEventListener("scroll", onScroll); + }; + }, []); + const changeSource = (sourceId: string) => { sourceGeneration.current += 1; + listGenRef.current += 1; setConfirmDelete(false); setDeleting(null); setEditingClassname(null); @@ -541,6 +635,7 @@ export function StudioClient({ if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => { if (searchTerm !== activeSearch) { + listGenRef.current += 1; setActiveSearch(searchTerm); fetchItems(searchTerm, 1, activeSource); } @@ -564,12 +659,14 @@ export function StudioClient({ setSelectedPage(null); }; const clearSearch = () => { + listGenRef.current += 1; setSearchTerm(""); setActiveSearch(""); fetchItems("", 1, activeSource); }; const submitSearch = () => { + listGenRef.current += 1; setActiveSearch(searchTerm); fetchItems(searchTerm, 1, activeSource); }; @@ -600,6 +697,63 @@ export function StudioClient({ } }, [allSelected, filteredItems]); + // Bulk "select every matching result": loads all remaining pages in the + // background, then selects whatever matches the active filters. Cancels + // itself when the query (search/source) changes or the request is aborted. + const selectAllResults = useCallback(async () => { + if (loadingAllRef.current) return; + const gen = listGenRef.current; + const search = activeSearch; + const source = activeSource; + const first = meta?.currentPage ?? 1; + const last = meta?.lastPage ?? first; + if (first >= last) { + // Everything is already loaded — just select the visible matches. + setSelected((prev) => { + const next = new Set(prev); + for (const item of filteredItems) next.add(item.classname); + return next; + }); + return; + } + loadingAllRef.current = true; + setLoadingAll(true); + try { + for (let page = first + 1; page <= last; page += 1) { + if (listGenRef.current !== gen) break; + const got = await fetchItems(search, page, source); + if (!got) break; + } + if (listGenRef.current === gen) { + const matching = filterFurniture(itemsRef.current, { + typeFilter, + categoryFilter, + statusFilter, + sortBy, + }); + setSelected((prev) => { + const next = new Set(prev); + for (const item of matching) next.add(item.classname); + return next; + }); + } + } finally { + loadingAllRef.current = false; + setLoadingAll(false); + } + }, [ + activeSearch, + activeSource, + meta?.currentPage, + meta?.lastPage, + filteredItems, + typeFilter, + categoryFilter, + statusFilter, + sortBy, + fetchItems, + ]); + // Keyboard shortcuts: "/" focus search, Ctrl/Cmd+A select all, Esc clear. useEffect(() => { function onKeyDown(e: KeyboardEvent) { @@ -1310,7 +1464,7 @@ export function StudioClient({ - + {hasMorePages && meta && ( + + )} + {selected.size > 0 && ( { @@ -1661,7 +1833,23 @@ export function StudioClient({ /> ) : null} -
+
+