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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)]",
|
||||
|
||||
@@ -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