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:
1 parent
f285a7cd98
commit
3b8bf74e35
3 files changed
+202
-90
No files matched your search
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user