feat(studio): preserve work position and guide import completion
CI / check (push) Successful in 2m20s
CI / deploy (push) Successful in 18s
CI / publish-container (push) Successful in 1m22s

This commit is contained in:
Simo committed 2026-09-11 10:48:09 +02:00
1 parent c5a2b44807
commit 89af4eb1f6
6 files changed
+425 -10

No files matched your search

+78 -7
View File
@@ -27,6 +27,7 @@ import {
Suspense,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
@@ -157,6 +158,32 @@ export function StudioClient({
// Selection + detail
const [selected, setSelected] = useState<Set<string>>(new Set());
const [detail, setDetail] = useState<FurniItem | null>(null);
const listRef = useRef<HTMLDivElement>(null);
const detailOrigin = useRef<{
top: number;
trigger: HTMLElement | null;
} | null>(null);
const openDetail = (item: FurniItem) => {
detailOrigin.current = {
top: listRef.current?.scrollTop ?? 0,
trigger:
document.activeElement instanceof HTMLElement
? document.activeElement
: null,
};
setDetail(item);
};
useLayoutEffect(() => {
if (detail || !detailOrigin.current) return;
const origin = detailOrigin.current;
detailOrigin.current = null;
if (listRef.current) listRef.current.scrollTop = origin.top;
if (origin.trigger?.isConnected)
origin.trigger.focus({ preventScroll: true });
}, [detail]);
const sourceGeneration = useRef(0);
const listRequest = useRef<AbortController | null>(null);
useEffect(() => () => listRequest.current?.abort(), []);
// Import state
const importingId = jobs.busy ? (review?.items[0]?.classname ?? null) : null;
@@ -256,6 +283,9 @@ export function StudioClient({
const fetchItems = useCallback(
async (search: string, page: number, source?: string) => {
listRequest.current?.abort();
const controller = new AbortController();
listRequest.current = controller;
setLoading(true);
setError("");
try {
@@ -264,29 +294,56 @@ export function StudioClient({
if (source) qs.set("source", source);
const res = await adminFetch(
`/api/admin/import/furni?${qs.toString()}`,
{ signal: controller.signal },
);
if (controller.signal.aborted) return;
if (!res.ok) {
setError(`Request failed (${res.status})`);
setLoading(false);
return;
}
const data = await res.json();
if (controller.signal.aborted) return;
if (page === 1) {
setItems(data.items ?? []);
const available = new Set(
(data.items ?? []).map((item: FurniItem) => item.classname),
);
setSelected(
(previous) =>
new Set(
[...previous].filter((classname) => available.has(classname)),
),
);
setDetail((previous) =>
previous
? ((data.items ?? []).find(
(item: FurniItem) => item.classname === previous.classname,
) ?? null)
: null,
);
} else {
setItems((prev) => [...prev, ...(data.items ?? [])]);
}
setMeta(data.meta ?? null);
} catch {
setError("Network error");
if (!controller.signal.aborted) setError("Network error");
} finally {
setLoading(false);
if (!controller.signal.aborted) setLoading(false);
}
},
[],
);
const changeSource = (sourceId: string) => {
sourceGeneration.current += 1;
setConfirmDelete(false);
setDeleting(null);
setEditingClassname(null);
listRequest.current?.abort();
detailOrigin.current = null;
setDetail(null);
setReview(null);
setActiveSource(sourceId);
setActiveSourceName(
sourceId ? (sources.find((s) => s.id === sourceId)?.name ?? "") : "",
@@ -332,6 +389,7 @@ export function StudioClient({
async function prepareCloneReview() {
if (!activeSource || preparingReview) return;
const generation = sourceGeneration.current;
setPreparingReview(true);
try {
const response = await adminFetch(
@@ -340,6 +398,7 @@ export function StudioClient({
"&action=clonable",
);
const result = await response.json();
if (generation !== sourceGeneration.current) return;
if (!response.ok || !Array.isArray(result.classnames))
throw new Error(result.error || "Could not load missing furniture");
const missing = new Set<string>(result.classnames);
@@ -358,6 +417,7 @@ export function StudioClient({
page,
);
const data = await res.json();
if (generation !== sourceGeneration.current) return;
if (
!res.ok ||
!Array.isArray(data.items) ||
@@ -369,6 +429,7 @@ export function StudioClient({
lastPage = data.meta.lastPage;
page++;
} while (page <= lastPage && collected.size < missing.size);
if (generation !== sourceGeneration.current) return;
if (collected.size !== missing.size)
throw new Error(
"The source changed while preparing the selection. Refresh and retry.",
@@ -458,7 +519,8 @@ export function StudioClient({
// Keyboard shortcuts: "/" focus search, Ctrl/Cmd+A select all, Esc clear.
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if (review) return;
if (review || editingClassname || confirmDelete || e.defaultPrevented)
return;
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) {
@@ -470,13 +532,18 @@ export function StudioClient({
toggleAll();
}
if (e.key === "Escape" && !isInput) {
if (detail) {
e.preventDefault();
setDetail(null);
return;
}
setSelected(new Set());
searchRef.current?.blur();
}
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [toggleAll, review]);
}, [toggleAll, review, editingClassname, confirmDelete, detail]);
async function importBatch(
requested?: string[],
@@ -1511,7 +1578,11 @@ export function StudioClient({
) : null}
<main className="flex min-h-0 min-w-0 flex-1 flex-col">
<div className="min-h-0 flex-1 overflow-y-auto p-3">
<div
ref={listRef}
data-testid="studio-furniture-list"
className="min-h-0 flex-1 overflow-y-auto p-3"
>
{loading && items.length === 0 ? (
<div className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-3">
{Array.from({ length: 10 }, (_, i) => i).map((n) => (
@@ -1567,7 +1638,7 @@ export function StudioClient({
<div key={item.classname} className="group relative">
<button
type="button"
onClick={() => setDetail(item)}
onClick={() => openDetail(item)}
className={cn(
"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)]",
isSel
@@ -1745,7 +1816,7 @@ export function StudioClient({
<td className="px-1 py-1.5">
<button
type="button"
onClick={() => setDetail(item)}
onClick={() => openDetail(item)}
className="rounded outline-none focus-visible:ring-2 focus-visible:ring-[var(--admin-accent)]"
aria-label={`View ${item.name}`}
>