Improve Catalog Studio navigation and import workflow
This commit is contained in:
1 parent
bfdf4def0f
commit
bf126fd825
4 files changed
+687
-492
No files matched your search
@@ -111,7 +111,10 @@ export function StudioClient({
|
||||
// Catalog tree
|
||||
const [tree] = useState<TreeNode[]>(initialTree);
|
||||
const [selectedPage, setSelectedPage] = useState<number | null>(null);
|
||||
const [railOpen, setRailOpen] = useState(true);
|
||||
const [railOpen, setRailOpen] = useState(false);
|
||||
useEffect(() => {
|
||||
setRailOpen(window.matchMedia("(min-width: 1024px)").matches);
|
||||
}, []);
|
||||
|
||||
// Search & list
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
@@ -133,7 +136,7 @@ export function StudioClient({
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>("all");
|
||||
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||
const [sortBy, setSortBy] = useState<string>("default");
|
||||
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
|
||||
const [viewMode, setViewMode] = useState<"grid" | "list">("list");
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
@@ -154,7 +157,7 @@ export function StudioClient({
|
||||
const batchDoneRef = useRef(false);
|
||||
const markBatchDone = useCallback(() => {
|
||||
batchDoneRef.current = true;
|
||||
markBatchDone();
|
||||
setBatchDone(true);
|
||||
}, []);
|
||||
const [batchSucceeded, setBatchSucceeded] = useState(0);
|
||||
const [batchFailed, setBatchFailed] = useState(0);
|
||||
@@ -218,13 +221,13 @@ export function StudioClient({
|
||||
const langs = data.languages ?? [];
|
||||
const okCount = langs.filter((l) => l.ok).length;
|
||||
toast.success(
|
||||
`Alle talen vertaald (${okCount}/${langs.length} gelukt)`,
|
||||
`Languages translated (${okCount}/${langs.length} completed)`,
|
||||
);
|
||||
} else {
|
||||
toast.error(data.error ?? "Vertaling mislukt");
|
||||
toast.error(data.error ?? "Translation failed");
|
||||
}
|
||||
} catch {
|
||||
toast.error("Netwerkfout bij het vertalen van alle talen");
|
||||
toast.error("Network error while translating languages");
|
||||
} finally {
|
||||
setBuildingLanguages(false);
|
||||
}
|
||||
@@ -241,7 +244,7 @@ export function StudioClient({
|
||||
try {
|
||||
await setFurnidataTranslateEnabled({ enabled: next });
|
||||
} catch {
|
||||
toast.error("Kon de globale vertaal-instelling niet opslaan");
|
||||
toast.error("Could not save the default translation setting");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -410,7 +413,7 @@ export function StudioClient({
|
||||
}
|
||||
|
||||
if (classnames.length === 0) {
|
||||
toast.info("Alles is al geïmporteerd voor deze bron");
|
||||
toast.info("All items from this source are already imported");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -418,9 +421,9 @@ export function StudioClient({
|
||||
sources.find((s) => s.id === activeSource)?.name || activeSource;
|
||||
|
||||
const confirmed = await confirm({
|
||||
title: "Alles importeren",
|
||||
description: `${classnames.length} items importeren uit "${sourceName}"? Dit kan even duren.`,
|
||||
confirmLabel: "Alles importeren",
|
||||
title: "Import all missing",
|
||||
description: `${classnames.length} items to import from "${sourceName}"? This may take a while.`,
|
||||
confirmLabel: "Import all missing",
|
||||
});
|
||||
if (!confirmed) return;
|
||||
|
||||
@@ -430,6 +433,7 @@ export function StudioClient({
|
||||
initial.set(cn, { classname: cn, status: "pending" });
|
||||
}
|
||||
setBatchProgress(initial);
|
||||
batchDoneRef.current = false;
|
||||
setBatchDone(false);
|
||||
setBatchSucceeded(0);
|
||||
setBatchFailed(0);
|
||||
@@ -543,7 +547,7 @@ export function StudioClient({
|
||||
|
||||
// Auto-translate after clone all if translation is enabled
|
||||
if (translate && okCount > 0) {
|
||||
toast.info("Automatisch vertalen van alle talen...");
|
||||
toast.info("Translating all languages...");
|
||||
buildAllLanguages();
|
||||
}
|
||||
} else {
|
||||
@@ -589,6 +593,12 @@ export function StudioClient({
|
||||
if (updated) setDetail(updated);
|
||||
}, [items, detail]);
|
||||
|
||||
const clearFilters = () => {
|
||||
setTypeFilter("all");
|
||||
setCategoryFilter("all");
|
||||
setStatusFilter("all");
|
||||
setSelectedPage(null);
|
||||
};
|
||||
const clearSearch = () => {
|
||||
setSearchTerm("");
|
||||
setActiveSearch("");
|
||||
@@ -701,15 +711,19 @@ export function StudioClient({
|
||||
}
|
||||
}
|
||||
|
||||
async function importBatch() {
|
||||
const toImport = filteredItems.filter(
|
||||
(i) => selected.has(i.classname) && !i.alreadyImported,
|
||||
async function importBatch(requested?: string[]) {
|
||||
const toImport = (requested ? items : filteredItems).filter(
|
||||
(i) =>
|
||||
(requested
|
||||
? requested.includes(i.classname)
|
||||
: selected.has(i.classname)) && !i.alreadyImported,
|
||||
);
|
||||
if (toImport.length === 0) {
|
||||
toast.info("Select furni that isn't imported yet");
|
||||
return;
|
||||
}
|
||||
|
||||
batchDoneRef.current = false;
|
||||
setBatchDone(false);
|
||||
setBatchSucceeded(0);
|
||||
setBatchFailed(0);
|
||||
@@ -750,7 +764,7 @@ export function StudioClient({
|
||||
});
|
||||
|
||||
if (!res.ok || !res.body) {
|
||||
toast.error(`Batch import mislukt (${res.status})`);
|
||||
toast.error(`Batch import failed (${res.status})`);
|
||||
setBatchProgress(null);
|
||||
return;
|
||||
}
|
||||
@@ -818,18 +832,16 @@ export function StudioClient({
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as Error).name === "AbortError") {
|
||||
toast.info("Batch import geannuleerd door gebruiker");
|
||||
toast.info("Import cancelled");
|
||||
} else if ((err as Error).name === "TimeoutError") {
|
||||
toast.error(
|
||||
"Import tijdelijkstop gedurende te lange tijd – probeer het opnieuw of verlaag de concurrentie",
|
||||
);
|
||||
toast.error("Import timed out. Retry or reduce concurrency.");
|
||||
} else if (err instanceof TypeError && err.message?.includes("fetch")) {
|
||||
toast.error(
|
||||
"Netwerkfout tijdens upload – controleer je internetverbinding en probeer opnieuw",
|
||||
"Network error during upload. Check your connection and retry.",
|
||||
);
|
||||
} else {
|
||||
toast.error(
|
||||
"Onverwende fout tijdens import: " +
|
||||
"Unexpected import error: " +
|
||||
(err instanceof Error ? err.message : String(err)),
|
||||
);
|
||||
}
|
||||
@@ -838,7 +850,7 @@ export function StudioClient({
|
||||
setBatchCancelling(false);
|
||||
setSelected(new Set());
|
||||
fetchStats();
|
||||
if (!batchDoneRef.current) setBatchProgress(null);
|
||||
if (!batchDoneRef.current) markBatchDone();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -857,8 +869,13 @@ export function StudioClient({
|
||||
};
|
||||
|
||||
async function retryFailed(failed: string[]) {
|
||||
if (!activeSource || failed.length === 0) return;
|
||||
if (failed.length === 0) return;
|
||||
if (!activeSource) {
|
||||
await importBatch(failed);
|
||||
return;
|
||||
}
|
||||
// For clone sources, re-run cloneAllMissing with only the failed classnames
|
||||
batchDoneRef.current = false;
|
||||
setBatchDone(false);
|
||||
setBatchSucceeded(0);
|
||||
setBatchFailed(0);
|
||||
@@ -890,7 +907,7 @@ export function StudioClient({
|
||||
});
|
||||
|
||||
if (!res.ok || !res.body) {
|
||||
toast.error(`Retry mislukt (${res.status})`);
|
||||
toast.error(`Retry failed (${res.status})`);
|
||||
setBatchProgress(null);
|
||||
return;
|
||||
}
|
||||
@@ -931,14 +948,14 @@ export function StudioClient({
|
||||
markBatchDone();
|
||||
|
||||
if (okCount > 0) {
|
||||
toast.success(`${okCount} items opnieuw geïmporteerd`);
|
||||
toast.success(`${okCount} items imported again`);
|
||||
}
|
||||
fetchItems(activeSearch, 1, activeSource);
|
||||
fetchStats();
|
||||
fetchCloneStats(activeSource);
|
||||
} catch (err) {
|
||||
if ((err as Error)?.name !== "AbortError") {
|
||||
toast.error("Verbindingsfout tijdens retry");
|
||||
toast.error("Connection failed while retrying");
|
||||
setBatchProgress(null);
|
||||
}
|
||||
} finally {
|
||||
@@ -1153,7 +1170,7 @@ export function StudioClient({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-[520px] flex-col overflow-hidden">
|
||||
<div className="flex h-full min-h-0 min-w-0 flex-col overflow-hidden">
|
||||
{/* ── Header ─────────────────────────────────────────── */}
|
||||
<header className="flex shrink-0 flex-wrap items-center gap-3 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] px-4 py-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
@@ -1161,7 +1178,10 @@ export function StudioClient({
|
||||
<Sparkles size={16} className="text-[var(--admin-accent)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-base font-extrabold leading-tight text-[var(--admin-text)]">
|
||||
<h1
|
||||
style={{ fontSize: 20, margin: 0 }}
|
||||
className="text-base font-extrabold leading-tight text-[var(--admin-text)]"
|
||||
>
|
||||
Studio
|
||||
</h1>
|
||||
<p className="text-[0.65rem] uppercase tracking-wider text-[var(--admin-text-muted)]">
|
||||
@@ -1170,13 +1190,14 @@ export function StudioClient({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
<div className="flex min-w-[180px] flex-1 items-center gap-2">
|
||||
<div className="relative w-full max-w-md">
|
||||
<Search
|
||||
size={14}
|
||||
className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[var(--admin-text-muted)]"
|
||||
/>
|
||||
<Input
|
||||
aria-label="Search furniture"
|
||||
ref={searchRef}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
@@ -1185,6 +1206,7 @@ export function StudioClient({
|
||||
if (e.key === "Escape") clearSearch();
|
||||
}}
|
||||
placeholder="Search furni, classnames, keywords… (/)"
|
||||
style={{ paddingLeft: 32, paddingRight: 32 }}
|
||||
className="pl-8 pr-8"
|
||||
/>
|
||||
{searchTerm ? (
|
||||
@@ -1232,7 +1254,7 @@ export function StudioClient({
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Check size={11} className="text-[var(--admin-success)]" />{" "}
|
||||
{cloneStats.present} aanwezig
|
||||
{cloneStats.present} present
|
||||
</Badge>
|
||||
{cloneStats.clonable > 0 && (
|
||||
<Badge
|
||||
@@ -1246,18 +1268,6 @@ export function StudioClient({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 rounded-md border border-[var(--admin-border)] px-2.5 py-1">
|
||||
<Switch
|
||||
checked={globalTranslate}
|
||||
onCheckedChange={onGlobalTranslateChange}
|
||||
aria-label="Globale vertaling"
|
||||
/>
|
||||
<Globe size={13} className="text-[var(--admin-text-muted)]" />
|
||||
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
|
||||
Vertaling (globaal)
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ── Filters + actions ─────────────────────────────── */}
|
||||
@@ -1283,8 +1293,14 @@ export function StudioClient({
|
||||
</button>
|
||||
|
||||
<Select value={activeSource} onValueChange={changeSource}>
|
||||
<SelectTrigger className="h-8 w-44 text-xs">
|
||||
<SelectValue placeholder="Furni source" />
|
||||
<SelectTrigger
|
||||
aria-label="Furniture source"
|
||||
className="h-8 w-44 text-xs"
|
||||
>
|
||||
<SelectValue>
|
||||
{sources.find((entry) => entry.id === activeSource)?.name ??
|
||||
`${source.label} (official)`}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">{source.label} (official)</SelectItem>
|
||||
@@ -1297,8 +1313,17 @@ export function StudioClient({
|
||||
</Select>
|
||||
|
||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||
<SelectTrigger className="h-8 w-32 text-xs">
|
||||
<SelectValue placeholder="Type" />
|
||||
<SelectTrigger
|
||||
aria-label="Furniture type"
|
||||
className="h-8 w-32 text-xs"
|
||||
>
|
||||
<SelectValue>
|
||||
{typeFilter === "all"
|
||||
? "All types"
|
||||
: typeFilter === "flooritem"
|
||||
? "Floor items"
|
||||
: "Wall items"}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All types</SelectItem>
|
||||
@@ -1308,8 +1333,13 @@ export function StudioClient({
|
||||
</Select>
|
||||
|
||||
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
|
||||
<SelectTrigger className="h-8 w-40 text-xs">
|
||||
<SelectValue placeholder="Category" />
|
||||
<SelectTrigger
|
||||
aria-label="Furniture category"
|
||||
className="h-8 w-40 text-xs"
|
||||
>
|
||||
<SelectValue>
|
||||
{categoryFilter === "all" ? "All categories" : categoryFilter}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categories.map((cat) => (
|
||||
@@ -1321,13 +1351,31 @@ export function StudioClient({
|
||||
</Select>
|
||||
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="h-8 w-36 text-xs">
|
||||
<SelectValue placeholder="Status" />
|
||||
<SelectTrigger
|
||||
aria-label="Import status"
|
||||
className="h-8 w-36 text-xs"
|
||||
>
|
||||
<SelectValue>
|
||||
{
|
||||
(
|
||||
{
|
||||
all: "All statuses",
|
||||
notImported: "Not imported",
|
||||
imported: "Imported",
|
||||
inCatalog: "In catalog",
|
||||
missingNitro: "Missing nitro",
|
||||
missingFurnidata: "Missing furnidata",
|
||||
missingCatalog: "Missing catalog entry",
|
||||
} as Record<string, string>
|
||||
)[statusFilter]
|
||||
}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
All statuses{" "}
|
||||
{notImportedCount > 0 && `(${notImportedCount} te importeren)`}
|
||||
{notImportedCount > 0 &&
|
||||
`(${notImportedCount} available to import)`}
|
||||
</SelectItem>
|
||||
<SelectItem value="notImported">Not imported</SelectItem>
|
||||
<SelectItem value="imported">Imported</SelectItem>
|
||||
@@ -1341,9 +1389,25 @@ export function StudioClient({
|
||||
</Select>
|
||||
|
||||
<Select value={sortBy} onValueChange={setSortBy}>
|
||||
<SelectTrigger className="h-8 w-44 text-xs">
|
||||
<SelectTrigger
|
||||
aria-label="Sort furniture"
|
||||
className="h-8 w-44 text-xs"
|
||||
>
|
||||
<ArrowUpDown size={12} className="mr-1" />
|
||||
<SelectValue placeholder="Sort" />
|
||||
<SelectValue>
|
||||
{
|
||||
(
|
||||
{
|
||||
default: "Default order",
|
||||
name: "Name A–Z",
|
||||
classname: "Classname A–Z",
|
||||
category: "Category",
|
||||
"imported-first": "Imported first",
|
||||
"not-imported-first": "Not imported first",
|
||||
} as Record<string, string>
|
||||
)[sortBy]
|
||||
}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">Default order</SelectItem>
|
||||
@@ -1357,6 +1421,14 @@ export function StudioClient({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{(typeFilter !== "all" ||
|
||||
categoryFilter !== "all" ||
|
||||
statusFilter !== "all" ||
|
||||
selectedPage !== null) && (
|
||||
<Button variant="ghost" size="sm" onClick={clearFilters}>
|
||||
Reset filters
|
||||
</Button>
|
||||
)}
|
||||
{missingNitroCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -1378,6 +1450,7 @@ export function StudioClient({
|
||||
type="button"
|
||||
onClick={() => setViewMode("grid")}
|
||||
aria-label="Grid view"
|
||||
aria-pressed={viewMode === "grid"}
|
||||
className={cn(
|
||||
"grid h-8 w-8 place-items-center transition-colors",
|
||||
viewMode === "grid"
|
||||
@@ -1391,6 +1464,7 @@ export function StudioClient({
|
||||
type="button"
|
||||
onClick={() => setViewMode("list")}
|
||||
aria-label="List view"
|
||||
aria-pressed={viewMode === "list"}
|
||||
className={cn(
|
||||
"grid h-8 w-8 place-items-center transition-colors",
|
||||
viewMode === "list"
|
||||
@@ -1401,19 +1475,152 @@ export function StudioClient({
|
||||
<List size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<details className="shrink-0 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] px-4 py-2">
|
||||
<summary className="cursor-pointer py-1 text-sm font-medium">
|
||||
Import options{" "}
|
||||
<span className="font-normal text-[var(--admin-text-muted)]">
|
||||
· Translation {translate ? "on" : "off"}
|
||||
</span>
|
||||
</summary>
|
||||
<div className="flex max-h-48 flex-wrap items-center gap-3 overflow-auto py-3">
|
||||
{" "}
|
||||
{/* ── Translate control (per-import) ─────────────── */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-8 items-center gap-1.5 rounded-md border border-[var(--admin-border)] px-2.5">
|
||||
<Switch
|
||||
checked={translate}
|
||||
onCheckedChange={setTranslate}
|
||||
aria-label="Translate this import"
|
||||
/>
|
||||
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
|
||||
Translate
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Popover open={langPickerOpen} onOpenChange={setLangPickerOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5"
|
||||
disabled={!translate}
|
||||
>
|
||||
<Languages size={13} />
|
||||
{translateLangs.length === 0
|
||||
? "No languages"
|
||||
: translateLangs.length === ALL_LANGS.length
|
||||
? "All languages"
|
||||
: `${translateLangs.length} languages`}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-60 p-2">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-[var(--admin-text)]">
|
||||
Languages
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-[0.65rem]"
|
||||
onClick={() => setTranslateLangs([...ALL_LANGS])}
|
||||
>
|
||||
All
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-[0.65rem]"
|
||||
onClick={() => setTranslateLangs([])}
|
||||
>
|
||||
None
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid max-h-56 grid-cols-2 gap-1 overflow-y-auto">
|
||||
{ALL_LANGS.map((lang) => {
|
||||
const on = translateLangs.includes(lang);
|
||||
return (
|
||||
<button
|
||||
key={lang}
|
||||
type="button"
|
||||
onClick={() => toggleLang(lang)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs transition-colors",
|
||||
on
|
||||
? "border-[var(--admin-accent)] bg-[var(--admin-accent)]/10 text-[var(--admin-text)]"
|
||||
: "border-[var(--admin-border)] text-[var(--admin-text-muted)] hover:bg-[var(--admin-accent)]/10",
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
width: 18,
|
||||
height: 18,
|
||||
}}
|
||||
className="border-[var(--admin-border)] bg-[var(--admin-surface)] data-checked:border-[var(--admin-accent)] data-checked:bg-[var(--admin-accent)] data-checked:text-[var(--admin-accent-foreground)]"
|
||||
checked={on}
|
||||
aria-label={`Language ${lang}`}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
{lang.toUpperCase()}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5"
|
||||
disabled={buildingLanguages}
|
||||
onClick={buildAllLanguages}
|
||||
title="Translate the entire catalog into all languages"
|
||||
>
|
||||
{buildingLanguages ? (
|
||||
<Loader2 size={13} className="animate-spin" />
|
||||
) : (
|
||||
<Globe size={13} />
|
||||
)}
|
||||
Translate entire catalog
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 rounded-md border border-[var(--admin-border)] px-2.5 py-1">
|
||||
<Switch
|
||||
checked={globalTranslate}
|
||||
onCheckedChange={onGlobalTranslateChange}
|
||||
aria-label="Default translation"
|
||||
/>
|
||||
<Globe size={13} className="text-[var(--admin-text-muted)]" />
|
||||
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
|
||||
Default for future imports
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<fieldset
|
||||
aria-label="Furniture selection"
|
||||
className="flex shrink-0 flex-wrap items-center gap-2 border-b border-[var(--admin-border)] bg-[var(--admin-canvas)] px-4 py-2"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleAll}
|
||||
className="flex h-8 items-center gap-1.5 rounded-md border border-[var(--admin-border)] px-2.5 text-xs font-medium text-[var(--admin-text-muted)] transition-colors hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-text)]"
|
||||
>
|
||||
<CheckboxDot checked={allSelected} />
|
||||
Select all
|
||||
{allSelected ? "Deselect visible" : "Select visible"}
|
||||
</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
|
||||
{
|
||||
filteredItems.filter((item) => selected.has(item.classname))
|
||||
.length
|
||||
}{" "}
|
||||
selected in this view
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -1428,110 +1635,12 @@ export function StudioClient({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* ── Translate control (per-import) ─────────────── */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-8 items-center gap-1.5 rounded-md border border-[var(--admin-border)] px-2.5">
|
||||
<Switch
|
||||
checked={translate}
|
||||
onCheckedChange={setTranslate}
|
||||
aria-label="Vertalen bij import"
|
||||
/>
|
||||
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
|
||||
Vertalen
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Popover open={langPickerOpen} onOpenChange={setLangPickerOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5"
|
||||
disabled={!translate}
|
||||
>
|
||||
<Languages size={13} />
|
||||
{translateLangs.length === 0
|
||||
? "Geen talen"
|
||||
: translateLangs.length === ALL_LANGS.length
|
||||
? "Alle talen"
|
||||
: `${translateLangs.length} talen`}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-60 p-2">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-[var(--admin-text)]">
|
||||
Talen
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-[0.65rem]"
|
||||
onClick={() => setTranslateLangs([...ALL_LANGS])}
|
||||
>
|
||||
Alles
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-[0.65rem]"
|
||||
onClick={() => setTranslateLangs([])}
|
||||
>
|
||||
Geen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid max-h-56 grid-cols-2 gap-1 overflow-y-auto">
|
||||
{ALL_LANGS.map((lang) => {
|
||||
const on = translateLangs.includes(lang);
|
||||
return (
|
||||
<button
|
||||
key={lang}
|
||||
type="button"
|
||||
onClick={() => toggleLang(lang)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs transition-colors",
|
||||
on
|
||||
? "border-[var(--admin-accent)] bg-[var(--admin-accent)]/10 text-[var(--admin-text)]"
|
||||
: "border-[var(--admin-border)] text-[var(--admin-text-muted)] hover:bg-[var(--admin-accent)]/10",
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={on}
|
||||
aria-label={`Taal ${lang}`}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
{lang.toUpperCase()}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5"
|
||||
disabled={buildingLanguages}
|
||||
onClick={buildAllLanguages}
|
||||
title="Vertaal de hele catalogus in alle talen (achteraf)"
|
||||
>
|
||||
{buildingLanguages ? (
|
||||
<Loader2 size={13} className="animate-spin" />
|
||||
) : (
|
||||
<Globe size={13} />
|
||||
)}
|
||||
Alles vertalen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{cloneStats && cloneStats.clonable > 0 && !batchProgress && (
|
||||
<Button size="sm" className="gap-1.5" onClick={cloneAllMissing}>
|
||||
<CloudDownload size={14} />
|
||||
Alles importeren ({cloneStats.clonable})
|
||||
Import all missing ({cloneStats.clonable})
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -1540,14 +1649,14 @@ export function StudioClient({
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
disabled={!!batchProgress}
|
||||
onClick={importBatch}
|
||||
onClick={() => importBatch()}
|
||||
>
|
||||
{batchProgress ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<CloudDownload size={14} />
|
||||
)}
|
||||
Import {selectedNonImported} auto
|
||||
Import selected ({selectedNonImported})
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -1564,7 +1673,7 @@ export function StudioClient({
|
||||
) : (
|
||||
<RefreshCw size={14} />
|
||||
)}
|
||||
Nitro regeneren ({selectedMissingNitro.length})
|
||||
Rebuild Nitro ({selectedMissingNitro.length})
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -1581,25 +1690,45 @@ export function StudioClient({
|
||||
) : (
|
||||
<Trash2 size={14} />
|
||||
)}
|
||||
Verwijder ({selectedImported.length})
|
||||
Delete ({selectedImported.length})
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{selected.size >
|
||||
filteredItems.filter((item) => selected.has(item.classname)).length && (
|
||||
<p className="shrink-0 px-4 py-1 text-xs text-[var(--admin-text-muted)]">
|
||||
Some selected furniture is hidden by your filters. Actions apply to
|
||||
the visible selection.
|
||||
</p>
|
||||
)}
|
||||
{/* ── Body: rail + grid + drawer ────────────────────── */}
|
||||
<div className="relative flex min-h-0 flex-1">
|
||||
{railOpen && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close category panel"
|
||||
onClick={() => setRailOpen(false)}
|
||||
className="absolute inset-0 z-20 bg-[var(--admin-text)]/30 lg:hidden"
|
||||
/>
|
||||
)}
|
||||
{railOpen ? (
|
||||
<CatalogRail
|
||||
tree={tree}
|
||||
selectedId={selectedPage}
|
||||
onSelect={setSelectedPage}
|
||||
onSelect={(id) => {
|
||||
setSelectedPage(id);
|
||||
if (!window.matchMedia("(min-width: 1024px)").matches)
|
||||
setRailOpen(false);
|
||||
}}
|
||||
onClose={() => setRailOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<main className="flex min-h-0 flex-1 flex-col">
|
||||
<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">
|
||||
{loading && items.length === 0 ? (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-3">
|
||||
{Array.from({ length: 10 }, (_, i) => i).map((n) => (
|
||||
<div
|
||||
key={`skeleton-${n}`}
|
||||
@@ -1616,6 +1745,13 @@ export function StudioClient({
|
||||
) : error ? (
|
||||
<div className="py-16 text-center text-sm text-[var(--admin-warning)]">
|
||||
{error}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="ml-3"
|
||||
onClick={() => fetchItems(activeSearch, 1, activeSource)}
|
||||
>
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
) : filteredItems.length === 0 ? (
|
||||
<div className="flex flex-col items-center py-16 text-center">
|
||||
@@ -1639,7 +1775,7 @@ export function StudioClient({
|
||||
) : (
|
||||
<>
|
||||
{viewMode === "grid" ? (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-3">
|
||||
{filteredItems.map((item) => {
|
||||
const isSel = selected.has(item.classname);
|
||||
return (
|
||||
@@ -1647,7 +1783,6 @@ export function StudioClient({
|
||||
<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
|
||||
@@ -1683,7 +1818,7 @@ export function StudioClient({
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 px-2 pb-1.5">
|
||||
<Badge className="h-4 text-[var(--admin-accent-foreground)]">
|
||||
<Badge className="h-4 bg-[var(--admin-accent)] text-[var(--admin-accent-foreground)]">
|
||||
{TYPE_LABELS[item.type] ?? item.type}
|
||||
</Badge>
|
||||
{item.alreadyImported ? (
|
||||
@@ -1724,7 +1859,7 @@ export function StudioClient({
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Badge className="h-4 text-[var(--admin-accent-foreground)]">
|
||||
<Badge className="h-4 bg-[var(--admin-accent)] text-[var(--admin-accent-foreground)]">
|
||||
New
|
||||
</Badge>
|
||||
)}
|
||||
@@ -1737,12 +1872,13 @@ export function StudioClient({
|
||||
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",
|
||||
"absolute right-2 top-2 grid h-8 w-8 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"}
|
||||
aria-label={`${isSel ? "Deselect" : "Select"} ${item.name}`}
|
||||
aria-pressed={isSel}
|
||||
>
|
||||
{isSel ? (
|
||||
<Check
|
||||
@@ -1757,11 +1893,17 @@ export function StudioClient({
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-lg border border-[var(--admin-border)]">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-[var(--admin-accent)]/8">
|
||||
<table className="w-full min-w-[660px] text-sm">
|
||||
<thead className="sticky top-0 z-10 bg-[var(--admin-surface)]">
|
||||
<tr>
|
||||
<th className="w-9 px-2 py-2 text-left">
|
||||
<Checkbox
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
width: 18,
|
||||
height: 18,
|
||||
}}
|
||||
className="border-[var(--admin-border)] bg-[var(--admin-surface)] data-checked:border-[var(--admin-accent)] data-checked:bg-[var(--admin-accent)] data-checked:text-[var(--admin-accent-foreground)]"
|
||||
checked={allSelected}
|
||||
onCheckedChange={toggleAll}
|
||||
aria-label="Select all"
|
||||
@@ -1794,10 +1936,20 @@ export function StudioClient({
|
||||
return (
|
||||
<tr
|
||||
key={item.classname}
|
||||
className="border-t border-[var(--admin-border)] transition-colors hover:bg-[var(--admin-accent)]/5"
|
||||
aria-selected={isSel}
|
||||
className={cn(
|
||||
"border-t border-[var(--admin-border)] transition-colors hover:bg-[var(--admin-accent)]/5",
|
||||
isSel && "bg-[var(--admin-accent)]/10",
|
||||
)}
|
||||
>
|
||||
<td className="px-2 py-1.5">
|
||||
<Checkbox
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
width: 18,
|
||||
height: 18,
|
||||
}}
|
||||
className="border-[var(--admin-border)] bg-[var(--admin-surface)] data-checked:border-[var(--admin-accent)] data-checked:bg-[var(--admin-accent)] data-checked:text-[var(--admin-accent-foreground)]"
|
||||
checked={isSel}
|
||||
onCheckedChange={() =>
|
||||
toggleSelect(item.classname)
|
||||
@@ -1873,7 +2025,7 @@ export function StudioClient({
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Badge className="h-4 text-[var(--admin-accent-foreground)]">
|
||||
<Badge className="h-4 bg-[var(--admin-accent)] text-[var(--admin-accent-foreground)]">
|
||||
New
|
||||
</Badge>
|
||||
)}
|
||||
@@ -1886,7 +2038,7 @@ export function StudioClient({
|
||||
onClick={() => importSingle(item)}
|
||||
disabled={importingId !== null}
|
||||
aria-label={`Import ${item.classname}`}
|
||||
className="grid h-6 w-6 place-items-center rounded-md text-[var(--admin-text-muted)] transition-colors hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-text)] disabled:opacity-50"
|
||||
className="grid h-9 w-9 place-items-center rounded-md text-[var(--admin-text-muted)] transition-colors hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-text)] disabled:opacity-50"
|
||||
>
|
||||
{importingId === item.classname ? (
|
||||
<Loader2
|
||||
@@ -1905,7 +2057,7 @@ export function StudioClient({
|
||||
setEditingClassname(item.classname)
|
||||
}
|
||||
aria-label={`Edit nitro ${item.classname}`}
|
||||
className="grid h-6 w-6 place-items-center rounded-md text-[var(--admin-text-muted)] transition-colors hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-text)]"
|
||||
className="grid h-9 w-9 place-items-center rounded-md text-[var(--admin-text-muted)] transition-colors hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-text)]"
|
||||
>
|
||||
<Pencil size={12} />
|
||||
</button>
|
||||
@@ -1919,7 +2071,7 @@ export function StudioClient({
|
||||
item.classname,
|
||||
)}
|
||||
aria-label={`Regenerate nitro ${item.classname}`}
|
||||
className="grid h-6 w-6 place-items-center rounded-md text-[var(--admin-warning)] transition-colors hover:bg-[var(--admin-warning)]/10 disabled:opacity-50"
|
||||
className="grid h-9 w-9 place-items-center rounded-md text-[var(--admin-warning)] transition-colors hover:bg-[var(--admin-warning)]/10 disabled:opacity-50"
|
||||
>
|
||||
{regeneratingNitro.has(
|
||||
item.classname,
|
||||
@@ -1985,11 +2137,12 @@ export function StudioClient({
|
||||
onCancel={cloneAllAbortRef.current ? cancelCloneAll : cancelBatch}
|
||||
onDismiss={() => {
|
||||
setBatchProgress(null);
|
||||
batchDoneRef.current = false;
|
||||
setBatchDone(false);
|
||||
setVerification(null);
|
||||
setFailedClassnames([]);
|
||||
}}
|
||||
onRetry={activeSource ? retryFailed : undefined}
|
||||
onRetry={retryFailed}
|
||||
/>
|
||||
) : null}
|
||||
</main>
|
||||
|
||||
Reference in new issue
Block a user