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";
|
"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 { cn } from "@/lib/utils";
|
||||||
import type { BatchItemStatus } from "./studio-types";
|
import type { BatchItemStatus } from "./studio-types";
|
||||||
|
|
||||||
@@ -10,18 +11,30 @@ export function BatchProgress({
|
|||||||
done,
|
done,
|
||||||
succeeded,
|
succeeded,
|
||||||
failed,
|
failed,
|
||||||
|
cancelling,
|
||||||
|
onCancel,
|
||||||
|
onDismiss,
|
||||||
}: {
|
}: {
|
||||||
progress: Map<string, BatchItemStatus>;
|
progress: Map<string, BatchItemStatus>;
|
||||||
done: boolean;
|
done: boolean;
|
||||||
succeeded: number;
|
succeeded: number;
|
||||||
failed: number;
|
failed: number;
|
||||||
|
cancelling?: boolean;
|
||||||
|
onCancel?: () => void;
|
||||||
|
onDismiss?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const entries = [...progress.entries()];
|
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 (
|
return (
|
||||||
<div className="border-t border-[var(--admin-border)] p-3">
|
<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)]">
|
<span className="text-xs font-bold uppercase tracking-wider text-[var(--admin-text-muted)]">
|
||||||
Importing…
|
{done ? "Import finished" : "Importing…"}
|
||||||
</span>
|
</span>
|
||||||
{done ? (
|
{done ? (
|
||||||
<span
|
<span
|
||||||
@@ -32,15 +45,31 @@ export function BatchProgress({
|
|||||||
: "text-[var(--admin-success)]",
|
: "text-[var(--admin-success)]",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
✓ {succeeded} ok{failed > 0 ? ` · ${failed} failed` : ""}
|
{completed}/{total} · {succeeded} ok
|
||||||
|
{failed > 0 ? ` · ${failed} failed` : ""}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<Loader2
|
<span className="flex items-center gap-2">
|
||||||
size={14}
|
<span className="text-xs font-mono text-[var(--admin-text-muted)]">
|
||||||
className="animate-spin text-[var(--admin-accent)]"
|
{completed}/{total}
|
||||||
/>
|
</span>
|
||||||
|
<Loader2
|
||||||
|
size={14}
|
||||||
|
className="animate-spin text-[var(--admin-accent)]"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<div className="max-h-44 space-y-1 overflow-y-auto pr-1">
|
||||||
{entries.map(([classname, s]) => (
|
{entries.map(([classname, s]) => (
|
||||||
<div key={classname} className="flex items-center gap-2 text-xs">
|
<div key={classname} className="flex items-center gap-2 text-xs">
|
||||||
@@ -66,6 +95,29 @@ export function BatchProgress({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -67,7 +67,7 @@ export function CatalogRail({
|
|||||||
onSelect(selectedId === node.id ? null : node.id);
|
onSelect(selectedId === node.id ? null : node.id);
|
||||||
}}
|
}}
|
||||||
className={cn(
|
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
|
selectedId === node.id
|
||||||
? "bg-[var(--admin-accent)]/15 text-[var(--admin-text)]"
|
? "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)]",
|
: "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 [batchDone, setBatchDone] = useState(false);
|
||||||
const [batchSucceeded, setBatchSucceeded] = useState(0);
|
const [batchSucceeded, setBatchSucceeded] = useState(0);
|
||||||
const [batchFailed, setBatchFailed] = useState(0);
|
const [batchFailed, setBatchFailed] = useState(0);
|
||||||
|
const [batchCancelling, setBatchCancelling] = useState(false);
|
||||||
const batchAbortRef = useRef<AbortController | null>(null);
|
const batchAbortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
// Nitro editor
|
// Nitro editor
|
||||||
@@ -152,13 +153,13 @@ export function StudioClient({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchItems = useCallback(
|
const fetchItems = useCallback(
|
||||||
async (search = activeSearch, page = 1) => {
|
async (search: string, page: number, source?: string) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const qs = new URLSearchParams({ page: String(page) });
|
const qs = new URLSearchParams({ page: String(page) });
|
||||||
if (search) qs.set("search", search);
|
if (search) qs.set("search", search);
|
||||||
if (activeSource) qs.set("source", activeSource);
|
if (source) qs.set("source", source);
|
||||||
const res = await adminFetch(
|
const res = await adminFetch(
|
||||||
`/api/admin/import/furni?${qs.toString()}`,
|
`/api/admin/import/furni?${qs.toString()}`,
|
||||||
);
|
);
|
||||||
@@ -180,7 +181,7 @@ export function StudioClient({
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[activeSearch, activeSource],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const changeSource = (sourceId: string) => {
|
const changeSource = (sourceId: string) => {
|
||||||
@@ -192,7 +193,6 @@ export function StudioClient({
|
|||||||
setItems([]);
|
setItems([]);
|
||||||
setActiveSearch("");
|
setActiveSearch("");
|
||||||
setSearchTerm("");
|
setSearchTerm("");
|
||||||
fetchItems("", 1);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchSources = useCallback(async () => {
|
const fetchSources = useCallback(async () => {
|
||||||
@@ -208,10 +208,10 @@ export function StudioClient({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchItems();
|
fetchItems("", 1, activeSource);
|
||||||
fetchStats();
|
fetchStats();
|
||||||
fetchSources();
|
fetchSources();
|
||||||
}, [fetchItems, fetchStats, fetchSources]);
|
}, [fetchItems, fetchStats, fetchSources, activeSource]);
|
||||||
|
|
||||||
// Keep detail in sync with the refreshed list.
|
// Keep detail in sync with the refreshed list.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -220,9 +220,15 @@ export function StudioClient({
|
|||||||
if (updated) setDetail(updated);
|
if (updated) setDetail(updated);
|
||||||
}, [items, detail]);
|
}, [items, detail]);
|
||||||
|
|
||||||
|
const clearSearch = () => {
|
||||||
|
setSearchTerm("");
|
||||||
|
setActiveSearch("");
|
||||||
|
fetchItems("", 1, activeSource);
|
||||||
|
};
|
||||||
|
|
||||||
const submitSearch = () => {
|
const submitSearch = () => {
|
||||||
setActiveSearch(searchTerm);
|
setActiveSearch(searchTerm);
|
||||||
fetchItems(searchTerm, 1);
|
fetchItems(searchTerm, 1, activeSource);
|
||||||
};
|
};
|
||||||
|
|
||||||
function toggleSelect(classname: string) {
|
function toggleSelect(classname: string) {
|
||||||
@@ -305,6 +311,7 @@ export function StudioClient({
|
|||||||
setBatchDone(false);
|
setBatchDone(false);
|
||||||
setBatchSucceeded(0);
|
setBatchSucceeded(0);
|
||||||
setBatchFailed(0);
|
setBatchFailed(0);
|
||||||
|
setBatchCancelling(false);
|
||||||
|
|
||||||
const initial = new Map<string, BatchItemStatus>();
|
const initial = new Map<string, BatchItemStatus>();
|
||||||
for (const i of toImport) {
|
for (const i of toImport) {
|
||||||
@@ -394,12 +401,20 @@ export function StudioClient({
|
|||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
batchAbortRef.current = null;
|
batchAbortRef.current = null;
|
||||||
|
setBatchCancelling(false);
|
||||||
setSelected(new Set());
|
setSelected(new Set());
|
||||||
fetchStats();
|
fetchStats();
|
||||||
if (!batchDone) setBatchProgress(null);
|
if (!batchDone) setBatchProgress(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cancelBatch = () => {
|
||||||
|
if (batchAbortRef.current) {
|
||||||
|
setBatchCancelling(true);
|
||||||
|
batchAbortRef.current.abort();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
async function deleteItem() {
|
async function deleteItem() {
|
||||||
if (!deleting) return;
|
if (!deleting) return;
|
||||||
try {
|
try {
|
||||||
@@ -444,7 +459,7 @@ export function StudioClient({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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)" }}
|
style={{ backgroundColor: "var(--admin-canvas)" }}
|
||||||
>
|
>
|
||||||
{/* ── Header ─────────────────────────────────────────── */}
|
{/* ── Header ─────────────────────────────────────────── */}
|
||||||
@@ -474,8 +489,18 @@ export function StudioClient({
|
|||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === "Enter" && submitSearch()}
|
onKeyDown={(e) => e.key === "Enter" && submitSearch()}
|
||||||
placeholder="Search furni, classnames, keywords…"
|
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>
|
</div>
|
||||||
<Button size="sm" variant="secondary" onClick={submitSearch}>
|
<Button size="sm" variant="secondary" onClick={submitSearch}>
|
||||||
Search
|
Search
|
||||||
@@ -554,6 +579,23 @@ export function StudioClient({
|
|||||||
Select all
|
Select all
|
||||||
</button>
|
</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" />
|
<div className="flex-1" />
|
||||||
|
|
||||||
{selectedNonImported > 0 && (
|
{selectedNonImported > 0 && (
|
||||||
@@ -574,7 +616,7 @@ export function StudioClient({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Body: rail + grid + drawer ────────────────────── */}
|
{/* ── Body: rail + grid + drawer ────────────────────── */}
|
||||||
<div className="flex min-h-0 flex-1">
|
<div className="relative flex min-h-0 flex-1">
|
||||||
<CatalogRail
|
<CatalogRail
|
||||||
tree={tree}
|
tree={tree}
|
||||||
selectedId={selectedPage}
|
selectedId={selectedPage}
|
||||||
@@ -611,76 +653,83 @@ export function StudioClient({
|
|||||||
{filteredItems.map((item) => {
|
{filteredItems.map((item) => {
|
||||||
const isSel = selected.has(item.classname);
|
const isSel = selected.has(item.classname);
|
||||||
return (
|
return (
|
||||||
<button
|
<div key={item.classname} className="group relative">
|
||||||
key={item.classname}
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setDetail(item)}
|
onClick={() => setDetail(item)}
|
||||||
onDoubleClick={() => toggleSelect(item.classname)}
|
onDoubleClick={() => toggleSelect(item.classname)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group relative flex flex-col overflow-hidden rounded-xl border text-left transition-all",
|
"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
|
isSel
|
||||||
? "border-[var(--admin-accent)] bg-[var(--admin-accent)]/10"
|
? "border-[var(--admin-accent)] bg-[var(--admin-accent)]/10"
|
||||||
: "border-[var(--admin-border)] bg-[var(--admin-surface)] hover:border-[var(--admin-accent)]/60",
|
: "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>
|
>
|
||||||
</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>
|
</div>
|
||||||
@@ -690,7 +739,11 @@ export function StudioClient({
|
|||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
fetchItems(activeSearch, meta.currentPage + 1)
|
fetchItems(
|
||||||
|
activeSearch,
|
||||||
|
meta.currentPage + 1,
|
||||||
|
activeSource,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
@@ -714,13 +767,19 @@ export function StudioClient({
|
|||||||
done={batchDone}
|
done={batchDone}
|
||||||
succeeded={batchSucceeded}
|
succeeded={batchSucceeded}
|
||||||
failed={batchFailed}
|
failed={batchFailed}
|
||||||
|
cancelling={batchCancelling}
|
||||||
|
onCancel={cancelBatch}
|
||||||
|
onDismiss={() => {
|
||||||
|
setBatchProgress(null);
|
||||||
|
setBatchDone(false);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* ── Detail drawer ─────────────────────────────── */}
|
{/* ── Detail drawer ─────────────────────────────── */}
|
||||||
{detail ? (
|
{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">
|
<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)]">
|
<span className="text-xs font-bold uppercase tracking-wider text-[var(--admin-text-muted)]">
|
||||||
Furni detail
|
Furni detail
|
||||||
@@ -740,6 +799,7 @@ export function StudioClient({
|
|||||||
src={getFurniImageUrl(detail)}
|
src={getFurniImageUrl(detail)}
|
||||||
alt={detail.name}
|
alt={detail.name}
|
||||||
className="h-28 w-28 object-contain"
|
className="h-28 w-28 object-contain"
|
||||||
|
style={{ imageRendering: "pixelated" }}
|
||||||
onError={(e) => handleImgError(e, detail)}
|
onError={(e) => handleImgError(e, detail)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in new issue
Block a user