feat(studio): bulk select all results, persist view prefs, back-to-top
CI / check (push) Successful in 2m27s
CI / deploy (push) Successful in 1m18s
CI / publish-container (push) Successful in 50s

This commit is contained in:
openhands committed 2026-09-13 12:04:11 +02:00
1 parent b92f897ba2
commit 641b6b8cb8
1 file changed
+206 -17
+206 -17
View File
@@ -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<T extends string>(
key: string,
fallback: T,
valid: ReadonlyArray<T>,
): 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<string>("all");
const [categoryFilter, setCategoryFilter] = useState<string>("all");
const [statusFilter, setStatusFilter] = useState<string>("all");
const [sortBy, setSortBy] = useState<string>("default");
const [viewMode, setViewMode] = useState<"grid" | "list">("list");
const [sortBy, setSortBy] = useState<string>(() =>
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<HTMLInputElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -171,6 +218,12 @@ export function StudioClient({
const [selected, setSelected] = useState<Set<string>>(new Set());
const [detail, setDetail] = useState<FurniItem | null>(null);
const listRef = useRef<HTMLDivElement>(null);
// Mirrors `items` synchronously so "select all results" can read the
// fully-loaded list right after the last page resolves.
const itemsRef = useRef<FurniItem[]>([]);
// 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<HTMLDivElement | null>(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<FurniItem[] | null> => {
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({
</SelectContent>
</Select>
<Select value={sortBy} onValueChange={setSortBy}>
<Select value={sortBy} onValueChange={changeSortBy}>
<SelectTrigger
aria-label="Sort furniture"
className="h-8 w-44 text-xs"
@@ -1370,7 +1524,7 @@ export function StudioClient({
<div className="flex h-8 items-center overflow-hidden rounded-md border border-[var(--admin-border)]">
<button
type="button"
onClick={() => setViewMode("grid")}
onClick={() => changeViewMode("grid")}
aria-label="Grid view"
aria-pressed={viewMode === "grid"}
className={cn(
@@ -1384,7 +1538,7 @@ export function StudioClient({
</button>
<button
type="button"
onClick={() => setViewMode("list")}
onClick={() => changeViewMode("list")}
aria-label="List view"
aria-pressed={viewMode === "list"}
className={cn(
@@ -1536,6 +1690,24 @@ export function StudioClient({
{allSelected ? "Deselect visible" : "Select visible"}
</button>
{hasMorePages && meta && (
<button
type="button"
onClick={selectAllResults}
disabled={loadingAll}
className="flex h-8 items-center gap-1.5 rounded-md border border-[var(--admin-accent)]/40 bg-[var(--admin-accent)]/5 px-2.5 text-xs font-medium text-[var(--admin-text)] transition-colors hover:bg-[var(--admin-accent)]/10 disabled:cursor-not-allowed disabled:opacity-60"
>
{loadingAll ? (
<Loader2 size={13} className="animate-spin" />
) : (
<ListChecks size={13} />
)}
{loadingAll
? "Loading all…"
: `Select all ${meta.total.toLocaleString()} results`}
</button>
)}
{selected.size > 0 && (
<span className="flex h-8 items-center gap-1.5 rounded-md border border-[var(--admin-border)] bg-[var(--admin-accent)]/5 px-2.5 text-xs font-medium text-[var(--admin-text)]">
{
@@ -1661,7 +1833,23 @@ export function StudioClient({
/>
) : null}
<main className="flex min-h-0 min-w-0 flex-1 flex-col">
<main className="relative flex min-h-0 min-w-0 flex-1 flex-col">
<button
type="button"
onClick={() =>
listRef.current?.scrollTo({ top: 0, behavior: "smooth" })
}
aria-label="Scroll to top"
title="Scroll to top"
className={cn(
"absolute bottom-4 right-4 z-10 grid h-9 w-9 place-items-center rounded-full border border-[var(--admin-border)] bg-[var(--admin-surface)] text-[var(--admin-text-muted)] shadow-lg transition-all hover:text-[var(--admin-text)]",
showBackToTop
? "translate-y-0 opacity-100"
: "pointer-events-none translate-y-2 opacity-0",
)}
>
<ArrowUp size={15} />
</button>
<div
ref={listRef}
data-testid="studio-furniture-list"
@@ -1980,6 +2168,7 @@ export function StudioClient({
<img
src={getFurniImageUrl(item)}
alt=""
decoding="async"
className="h-8 w-8 object-contain"
style={{ imageRendering: "pixelated" }}
loading="lazy"