Fix Studio card markup and improve furni browsing UX

- Fix invalid nested button elements in furni grid cards that broke
  card borders, hover styles and selection rendering
- Fix stale-closure bug where switching furni source fetched from the
  previously selected source
- Add progress bar plus Cancel/Dismiss controls to batch imports
- Make the detail drawer an overlay on small screens
- Add clear-search and clear-selection actions in the toolbar
- Use pixelated image rendering and keyboard focus rings on cards
This commit is contained in:
openhands committed 2026-08-18 18:49:53 +02:00
1 parent f285a7cd98
commit 3b8bf74e35
3 files changed
+202 -90

No files matched your search

+60 -8
View File
@@ -1,7 +1,8 @@
"use client";
import { Check, Loader2, X } from "lucide-react";
import { Ban, Check, Loader2, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { BatchItemStatus } from "./studio-types";
@@ -10,18 +11,30 @@ export function BatchProgress({
done,
succeeded,
failed,
cancelling,
onCancel,
onDismiss,
}: {
progress: Map<string, BatchItemStatus>;
done: boolean;
succeeded: number;
failed: number;
cancelling?: boolean;
onCancel?: () => void;
onDismiss?: () => void;
}) {
const entries = [...progress.entries()];
const total = entries.length;
const completed = entries.filter(
([, s]) => s.status === "done" || s.status === "failed",
).length;
const percent = total > 0 ? Math.round((completed / total) * 100) : 0;
return (
<div className="border-t border-[var(--admin-border)] p-3">
<div className="mb-2 flex items-center justify-between">
<div className="mb-2 flex items-center justify-between gap-3">
<span className="text-xs font-bold uppercase tracking-wider text-[var(--admin-text-muted)]">
Importing…
{done ? "Import finished" : "Importing…"}
</span>
{done ? (
<span
@@ -32,15 +45,31 @@ export function BatchProgress({
: "text-[var(--admin-success)]",
)}
>
✓ {succeeded} ok{failed > 0 ? ` · ${failed} failed` : ""}
{completed}/{total} · {succeeded} ok
{failed > 0 ? ` · ${failed} failed` : ""}
</span>
) : (
<Loader2
size={14}
className="animate-spin text-[var(--admin-accent)]"
/>
<span className="flex items-center gap-2">
<span className="text-xs font-mono text-[var(--admin-text-muted)]">
{completed}/{total}
</span>
<Loader2
size={14}
className="animate-spin text-[var(--admin-accent)]"
/>
</span>
)}
</div>
{!done && (
<div className="mb-2 h-1.5 overflow-hidden rounded-full bg-[var(--admin-accent)]/10">
<div
className="h-full rounded-full bg-[var(--admin-accent)] transition-all duration-300"
style={{ width: `${percent}%` }}
/>
</div>
)}
<div className="max-h-44 space-y-1 overflow-y-auto pr-1">
{entries.map(([classname, s]) => (
<div key={classname} className="flex items-center gap-2 text-xs">
@@ -66,6 +95,29 @@ export function BatchProgress({
</div>
))}
</div>
<div className="mt-2 flex justify-end gap-2">
{done ? (
<Button size="sm" variant="secondary" onClick={onDismiss}>
<Check size={14} />
Dismiss
</Button>
) : (
<Button
size="sm"
variant="outline"
onClick={onCancel}
disabled={cancelling}
>
{cancelling ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Ban size={14} />
)}
Cancel
</Button>
)}
</div>
</div>
);
}
+1 -1
View File
@@ -67,7 +67,7 @@ export function CatalogRail({
onSelect(selectedId === node.id ? null : node.id);
}}
className={cn(
"group flex w-full items-center gap-1.5 rounded-md px-1.5 py-1.5 text-left text-xs transition-colors",
"group flex w-full cursor-pointer items-center gap-1.5 rounded-md px-1.5 py-1.5 text-left text-xs transition-colors outline-none focus-visible:ring-2 focus-visible:ring-[var(--admin-accent)]",
selectedId === node.id
? "bg-[var(--admin-accent)]/15 text-[var(--admin-text)]"
: "text-[var(--admin-text-muted)] hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-text)]",
+141 -81
View File
@@ -105,6 +105,7 @@ export function StudioClient({
const [batchDone, setBatchDone] = useState(false);
const [batchSucceeded, setBatchSucceeded] = useState(0);
const [batchFailed, setBatchFailed] = useState(0);
const [batchCancelling, setBatchCancelling] = useState(false);
const batchAbortRef = useRef<AbortController | null>(null);
// Nitro editor
@@ -152,13 +153,13 @@ export function StudioClient({
}, []);
const fetchItems = useCallback(
async (search = activeSearch, page = 1) => {
async (search: string, page: number, source?: string) => {
setLoading(true);
setError("");
try {
const qs = new URLSearchParams({ page: String(page) });
if (search) qs.set("search", search);
if (activeSource) qs.set("source", activeSource);
if (source) qs.set("source", source);
const res = await adminFetch(
`/api/admin/import/furni?${qs.toString()}`,
);
@@ -180,7 +181,7 @@ export function StudioClient({
setLoading(false);
}
},
[activeSearch, activeSource],
[],
);
const changeSource = (sourceId: string) => {
@@ -192,7 +193,6 @@ export function StudioClient({
setItems([]);
setActiveSearch("");
setSearchTerm("");
fetchItems("", 1);
};
const fetchSources = useCallback(async () => {
@@ -208,10 +208,10 @@ export function StudioClient({
}, []);
useEffect(() => {
fetchItems();
fetchItems("", 1, activeSource);
fetchStats();
fetchSources();
}, [fetchItems, fetchStats, fetchSources]);
}, [fetchItems, fetchStats, fetchSources, activeSource]);
// Keep detail in sync with the refreshed list.
useEffect(() => {
@@ -220,9 +220,15 @@ export function StudioClient({
if (updated) setDetail(updated);
}, [items, detail]);
const clearSearch = () => {
setSearchTerm("");
setActiveSearch("");
fetchItems("", 1, activeSource);
};
const submitSearch = () => {
setActiveSearch(searchTerm);
fetchItems(searchTerm, 1);
fetchItems(searchTerm, 1, activeSource);
};
function toggleSelect(classname: string) {
@@ -305,6 +311,7 @@ export function StudioClient({
setBatchDone(false);
setBatchSucceeded(0);
setBatchFailed(0);
setBatchCancelling(false);
const initial = new Map<string, BatchItemStatus>();
for (const i of toImport) {
@@ -394,12 +401,20 @@ export function StudioClient({
}
} finally {
batchAbortRef.current = null;
setBatchCancelling(false);
setSelected(new Set());
fetchStats();
if (!batchDone) setBatchProgress(null);
}
}
const cancelBatch = () => {
if (batchAbortRef.current) {
setBatchCancelling(true);
batchAbortRef.current.abort();
}
};
async function deleteItem() {
if (!deleting) return;
try {
@@ -444,7 +459,7 @@ export function StudioClient({
return (
<div
className="flex h-[calc(100vh-6.5rem)] min-h-[520px] flex-col overflow-hidden rounded-xl border border-[var(--admin-border)]"
className="flex h-[calc(100dvh-7rem)] min-h-[520px] flex-col overflow-hidden rounded-xl border border-[var(--admin-border)]"
style={{ backgroundColor: "var(--admin-canvas)" }}
>
{/* ── Header ─────────────────────────────────────────── */}
@@ -474,8 +489,18 @@ export function StudioClient({
onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && submitSearch()}
placeholder="Search furni, classnames, keywords…"
className="pl-8"
className="pl-8 pr-8"
/>
{searchTerm ? (
<button
type="button"
onClick={clearSearch}
aria-label="Clear search"
className="absolute right-2 top-1/2 -translate-y-1/2 grid h-5 w-5 place-items-center rounded-md text-[var(--admin-text-muted)] transition-colors hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-text)]"
>
<X size={12} />
</button>
) : null}
</div>
<Button size="sm" variant="secondary" onClick={submitSearch}>
Search
@@ -554,6 +579,23 @@ export function StudioClient({
Select all
</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)]">
{selected.size} selected
</span>
)}
{selected.size > 0 && (
<button
type="button"
onClick={() => setSelected(new Set())}
className="flex h-8 items-center gap-1 rounded-md px-2 text-xs font-medium text-[var(--admin-text-muted)] transition-colors hover:text-[var(--admin-warning)]"
>
<X size={12} />
Clear
</button>
)}
<div className="flex-1" />
{selectedNonImported > 0 && (
@@ -574,7 +616,7 @@ export function StudioClient({
</div>
{/* ── Body: rail + grid + drawer ────────────────────── */}
<div className="flex min-h-0 flex-1">
<div className="relative flex min-h-0 flex-1">
<CatalogRail
tree={tree}
selectedId={selectedPage}
@@ -611,76 +653,83 @@ export function StudioClient({
{filteredItems.map((item) => {
const isSel = selected.has(item.classname);
return (
<button
key={item.classname}
type="button"
onClick={() => setDetail(item)}
onDoubleClick={() => toggleSelect(item.classname)}
className={cn(
"group relative flex flex-col overflow-hidden rounded-xl border text-left transition-all",
isSel
? "border-[var(--admin-accent)] bg-[var(--admin-accent)]/10"
: "border-[var(--admin-border)] bg-[var(--admin-surface)] hover:border-[var(--admin-accent)]/60",
)}
>
<div className="relative flex h-24 items-center justify-center overflow-hidden bg-gradient-to-br from-[var(--admin-surface)] to-[var(--admin-canvas)]">
<img
src={getFurniImageUrl(item)}
alt={item.name}
className="h-14 w-14 object-contain transition-transform group-hover:scale-110"
loading="lazy"
onError={(e) => handleImgError(e, item)}
/>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
toggleSelect(item.classname);
}}
className="absolute right-1.5 top-1.5 grid h-5 w-5 place-items-center rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)]"
aria-label="Select"
>
{isSel ? (
<Check
size={12}
className="text-[var(--admin-accent)]"
/>
) : null}
</button>
</div>
<div className="flex items-start justify-between gap-1 border-t border-[var(--admin-border)] px-2 py-1.5">
<div className="min-w-0">
<p className="truncate text-xs font-semibold text-[var(--admin-text)]">
{item.name}
</p>
<p className="truncate font-mono text-[0.6rem] text-[var(--admin-text-muted)]">
{item.classname}
</p>
</div>
</div>
<div className="flex items-center gap-1 px-2 pb-1.5">
<Badge className="h-4 text-[var(--admin-accent-foreground)]">
{TYPE_LABELS[item.type] ?? item.type}
</Badge>
{item.alreadyImported ? (
<Badge
variant="secondary"
className={cn(
"h-4",
item.nitroExists
? "text-[var(--admin-success)] border-[var(--admin-success)]/40"
: "text-[var(--admin-warning)] border-[var(--admin-warning)]/40",
)}
>
{item.nitroExists ? "Imported" : "No nitro"}
</Badge>
) : (
<Badge className="h-4 text-[var(--admin-accent-foreground)]">
New
</Badge>
<div key={item.classname} className="group relative">
<button
type="button"
onClick={() => setDetail(item)}
onDoubleClick={() => toggleSelect(item.classname)}
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
? "border-[var(--admin-accent)] bg-[var(--admin-accent)]/10"
: "border-[var(--admin-border)] bg-[var(--admin-surface)] hover:border-[var(--admin-accent)]/60",
)}
</div>
</button>
>
<div className="relative flex h-24 items-center justify-center overflow-hidden bg-gradient-to-br from-[var(--admin-surface)] to-[var(--admin-canvas)]">
<img
src={getFurniImageUrl(item)}
alt={item.name}
className="h-14 w-14 object-contain transition-transform group-hover:scale-110"
style={{ imageRendering: "pixelated" }}
loading="lazy"
onError={(e) => handleImgError(e, item)}
/>
</div>
<div className="flex items-start justify-between gap-1 border-t border-[var(--admin-border)] px-2 py-1.5">
<div className="min-w-0">
<p className="truncate text-xs font-semibold text-[var(--admin-text)]">
{item.name}
</p>
<p className="truncate font-mono text-[0.6rem] text-[var(--admin-text-muted)]">
{item.classname}
</p>
</div>
</div>
<div className="flex items-center gap-1 px-2 pb-1.5">
<Badge className="h-4 text-[var(--admin-accent-foreground)]">
{TYPE_LABELS[item.type] ?? item.type}
</Badge>
{item.alreadyImported ? (
<Badge
variant="secondary"
className={cn(
"h-4",
item.nitroExists
? "text-[var(--admin-success)] border-[var(--admin-success)]/40"
: "text-[var(--admin-warning)] border-[var(--admin-warning)]/40",
)}
>
{item.nitroExists ? "Imported" : "No nitro"}
</Badge>
) : (
<Badge className="h-4 text-[var(--admin-accent-foreground)]">
New
</Badge>
)}
</div>
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
toggleSelect(item.classname);
}}
className={cn(
"absolute right-1.5 top-1.5 grid h-5 w-5 place-items-center rounded-md border transition-colors",
isSel
? "border-[var(--admin-accent)] bg-[var(--admin-accent)]"
: "border-[var(--admin-border)] bg-[var(--admin-surface)] hover:border-[var(--admin-accent)]/60",
)}
aria-label={isSel ? "Deselect" : "Select"}
>
{isSel ? (
<Check
size={12}
className="text-[var(--admin-accent-foreground)]"
/>
) : null}
</button>
</div>
);
})}
</div>
@@ -690,7 +739,11 @@ export function StudioClient({
<Button
variant="secondary"
onClick={() =>
fetchItems(activeSearch, meta.currentPage + 1)
fetchItems(
activeSearch,
meta.currentPage + 1,
activeSource,
)
}
disabled={loading}
>
@@ -714,13 +767,19 @@ export function StudioClient({
done={batchDone}
succeeded={batchSucceeded}
failed={batchFailed}
cancelling={batchCancelling}
onCancel={cancelBatch}
onDismiss={() => {
setBatchProgress(null);
setBatchDone(false);
}}
/>
) : null}
</main>
{/* ── Detail drawer ─────────────────────────────── */}
{detail ? (
<aside className="flex h-full w-80 shrink-0 flex-col border-l border-[var(--admin-border)] bg-[var(--admin-surface)]">
<aside className="absolute inset-y-0 right-0 z-20 flex h-full w-80 shrink-0 flex-col border-l border-[var(--admin-border)] bg-[var(--admin-surface)] shadow-xl lg:static lg:h-full lg:shadow-none">
<div className="flex shrink-0 items-center justify-between border-b border-[var(--admin-border)] px-3 py-2.5">
<span className="text-xs font-bold uppercase tracking-wider text-[var(--admin-text-muted)]">
Furni detail
@@ -740,6 +799,7 @@ export function StudioClient({
src={getFurniImageUrl(detail)}
alt={detail.name}
className="h-28 w-28 object-contain"
style={{ imageRendering: "pixelated" }}
onError={(e) => handleImgError(e, detail)}
/>
</div>