feat(studio): preserve work position and guide import completion
This commit is contained in:
1 parent
c5a2b44807
commit
89af4eb1f6
6 files changed
+425
-10
No files matched your search
@@ -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}`}
|
||||
>
|
||||
|
||||
Reference in new issue
Block a user