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
+105
-24
@@ -3,6 +3,7 @@
|
|||||||
import {
|
import {
|
||||||
BadgeCheck,
|
BadgeCheck,
|
||||||
Cat,
|
Cat,
|
||||||
|
ChevronDown,
|
||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
Copy,
|
Copy,
|
||||||
Dumbbell,
|
Dumbbell,
|
||||||
@@ -15,30 +16,93 @@ import {
|
|||||||
Wrench,
|
Wrench,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
import Link from "@/components/link";
|
import Link from "@/components/link";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const TABS: { href: string; label: string; icon: LucideIcon }[] = [
|
type StudioLink = { href: string; label: string; icon: LucideIcon };
|
||||||
{ href: "/admin/studio/furni", label: "Furni", icon: Puzzle },
|
const assets: StudioLink[] = [
|
||||||
{ href: "/admin/studio/upload", label: "Upload", icon: Upload },
|
{ href: "/admin/studio/furni", label: "Furniture", icon: Puzzle },
|
||||||
{ href: "/admin/studio/badges", label: "Badges", icon: BadgeCheck },
|
{ href: "/admin/studio/badges", label: "Badges", icon: BadgeCheck },
|
||||||
{ href: "/admin/studio/clothing", label: "Clothing", icon: Shirt },
|
{ href: "/admin/studio/clothing", label: "Clothing", icon: Shirt },
|
||||||
{ href: "/admin/studio/effects", label: "Effects", icon: Dumbbell },
|
{ href: "/admin/studio/effects", label: "Effects", icon: Dumbbell },
|
||||||
{ href: "/admin/studio/pets", label: "Pets", icon: Cat },
|
{ href: "/admin/studio/pets", label: "Pets", icon: Cat },
|
||||||
{ href: "/admin/studio/clone", label: "Clone", icon: Copy },
|
];
|
||||||
{ href: "/admin/studio/sync", label: "Sync", icon: RefreshCw },
|
const imports: StudioLink[] = [
|
||||||
|
{ href: "/admin/studio/upload", label: "Upload files", icon: Upload },
|
||||||
|
{ href: "/admin/studio/clone", label: "Import from a source", icon: Copy },
|
||||||
|
{ href: "/admin/studio/sync", label: "Sync & repository", icon: RefreshCw },
|
||||||
|
];
|
||||||
|
const maintenance: StudioLink[] = [
|
||||||
|
{ href: "/admin/studio/audit", label: "Catalog audit", icon: ClipboardCheck },
|
||||||
{
|
{
|
||||||
href: "/admin/studio/repair-icons",
|
href: "/admin/studio/repair-icons",
|
||||||
label: "Repair Icons",
|
label: "Repair icons",
|
||||||
icon: ImageIcon,
|
icon: ImageIcon,
|
||||||
},
|
},
|
||||||
{ href: "/admin/studio/audit", label: "Audit", icon: ClipboardCheck },
|
|
||||||
{
|
{
|
||||||
href: "/admin/studio/maintenance",
|
href: "/admin/studio/maintenance",
|
||||||
label: "Catalogus onderhoud",
|
label: "Catalog maintenance",
|
||||||
icon: Wrench,
|
icon: Wrench,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
const activePath = (path: string, href: string) =>
|
||||||
|
path === href || path.startsWith(`${href}/`);
|
||||||
|
|
||||||
|
function StudioMenu({
|
||||||
|
label,
|
||||||
|
links,
|
||||||
|
pathname,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
links: StudioLink[];
|
||||||
|
pathname: string;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const active = links.find((link) => activePath(pathname, link.href));
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-10 items-center gap-2 rounded-lg px-3 text-sm font-medium",
|
||||||
|
active
|
||||||
|
? "bg-[var(--admin-accent)]/10 text-[var(--admin-text)]"
|
||||||
|
: "text-[var(--admin-text-muted)] hover:bg-[var(--admin-accent)]/10",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
<ChevronDown size={14} />
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
align="end"
|
||||||
|
className="w-64 border-[var(--admin-border)] bg-[var(--admin-surface)] p-1.5 text-[var(--admin-text)]"
|
||||||
|
>
|
||||||
|
{links.map(({ href, label: title, icon: Icon }) => (
|
||||||
|
<Link
|
||||||
|
key={href}
|
||||||
|
href={href}
|
||||||
|
style={{
|
||||||
|
color: activePath(pathname, href)
|
||||||
|
? "var(--admin-text)"
|
||||||
|
: "var(--admin-text-muted)",
|
||||||
|
}}
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
aria-current={activePath(pathname, href) ? "page" : undefined}
|
||||||
|
className="flex min-h-11 items-center gap-3 rounded-md px-3 text-sm hover:bg-[var(--admin-accent)]/10 aria-[current=page]:bg-[var(--admin-accent)]/15"
|
||||||
|
>
|
||||||
|
<Icon size={16} />
|
||||||
|
{title}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function StudioLayout({
|
export default function StudioLayout({
|
||||||
children,
|
children,
|
||||||
@@ -47,31 +111,48 @@ export default function StudioLayout({
|
|||||||
}) {
|
}) {
|
||||||
const pathname = usePathname() ?? "";
|
const pathname = usePathname() ?? "";
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="flex h-[calc(100dvh-7rem)] min-h-[600px] min-w-0 flex-col overflow-hidden rounded-xl border border-[var(--admin-border)] bg-[var(--admin-canvas)] text-[var(--admin-text)]">
|
||||||
className="flex h-[calc(100dvh-7rem)] min-h-[520px] flex-col overflow-hidden rounded-xl border border-[var(--admin-border)]"
|
<nav
|
||||||
style={{ backgroundColor: "var(--admin-canvas)" }}
|
aria-label="Catalog Studio"
|
||||||
>
|
className="flex shrink-0 flex-wrap items-center justify-between gap-1 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] p-2"
|
||||||
<div className="flex shrink-0 items-center gap-1 overflow-x-auto border-b border-[var(--admin-border)] bg-[var(--admin-surface)] px-2 py-1.5">
|
>
|
||||||
{TABS.map(({ href, label, icon: Icon }) => {
|
<div className="flex flex-wrap gap-1">
|
||||||
const active = pathname === href || pathname.startsWith(`${href}/`);
|
{assets.map(({ href, label, icon: Icon }) => (
|
||||||
return (
|
|
||||||
<Link
|
<Link
|
||||||
key={href}
|
key={href}
|
||||||
href={href}
|
href={href}
|
||||||
|
style={{
|
||||||
|
color: activePath(pathname, href)
|
||||||
|
? "var(--admin-accent-foreground)"
|
||||||
|
: "var(--admin-text-muted)",
|
||||||
|
}}
|
||||||
|
aria-current={activePath(pathname, href) ? "page" : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex shrink-0 items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium transition-colors",
|
"flex min-h-10 items-center gap-2 rounded-lg px-3 text-sm font-medium transition-colors",
|
||||||
active
|
activePath(pathname, href)
|
||||||
? "bg-[var(--admin-accent)] text-[var(--admin-accent-foreground)]"
|
? "bg-[var(--admin-accent)] text-[var(--admin-accent-foreground)]"
|
||||||
: "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",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon size={13} />
|
<Icon size={16} />
|
||||||
{label}
|
{label}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
))}
|
||||||
})}
|
</div>
|
||||||
</div>
|
<div className="flex flex-wrap gap-1">
|
||||||
<div className="min-h-0 flex-1 overflow-auto">{children}</div>
|
<StudioMenu
|
||||||
|
label="Import & sync"
|
||||||
|
links={imports}
|
||||||
|
pathname={pathname}
|
||||||
|
/>
|
||||||
|
<StudioMenu
|
||||||
|
label="Maintenance"
|
||||||
|
links={maintenance}
|
||||||
|
pathname={pathname}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<div className="min-h-0 min-w-0 flex-1 overflow-auto">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,44 +1,19 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Ban, Check, Loader2, ShieldCheck, X } from "lucide-react";
|
import { Ban, Check, Circle, Loader2, RefreshCw, X } from "lucide-react";
|
||||||
import { memo, useEffect, useMemo, useRef, useState } from "react";
|
import { memo } from "react";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import type { BatchItemStatus, VerificationSummary } from "./studio-types";
|
import type { BatchItemStatus, VerificationSummary } from "./studio-types";
|
||||||
|
|
||||||
function SummaryRow({
|
const statusLabels: Record<BatchItemStatus["status"], string> = {
|
||||||
label,
|
pending: "Waiting",
|
||||||
value,
|
started: "Starting",
|
||||||
tone,
|
downloading: "Downloading assets",
|
||||||
}: {
|
converting: "Preparing assets",
|
||||||
label: string;
|
writing_db: "Saving",
|
||||||
value: string | number;
|
done: "Imported",
|
||||||
tone?: "ok" | "warn";
|
failed: "Failed",
|
||||||
}) {
|
};
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between gap-2 text-xs">
|
|
||||||
<span className="text-[var(--admin-text-muted)]">{label}</span>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"font-mono font-semibold",
|
|
||||||
tone === "warn"
|
|
||||||
? "text-[var(--admin-warning)]"
|
|
||||||
: "text-[var(--admin-success)]",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{value}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatEta(ms: number): string {
|
|
||||||
if (ms < 60_000) return `${Math.ceil(ms / 1000)}s`;
|
|
||||||
const min = Math.floor(ms / 60_000);
|
|
||||||
const sec = Math.ceil((ms % 60_000) / 1000);
|
|
||||||
return `${min}m ${sec}s`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const BatchProgress = memo(function BatchProgress({
|
export const BatchProgress = memo(function BatchProgress({
|
||||||
progress,
|
progress,
|
||||||
@@ -47,7 +22,6 @@ export const BatchProgress = memo(function BatchProgress({
|
|||||||
failed,
|
failed,
|
||||||
cancelling,
|
cancelling,
|
||||||
verification,
|
verification,
|
||||||
startTime,
|
|
||||||
failedClassnames,
|
failedClassnames,
|
||||||
onCancel,
|
onCancel,
|
||||||
onDismiss,
|
onDismiss,
|
||||||
@@ -65,192 +39,151 @@ export const BatchProgress = memo(function BatchProgress({
|
|||||||
onDismiss?: () => void;
|
onDismiss?: () => void;
|
||||||
onRetry?: (classnames: string[]) => void;
|
onRetry?: (classnames: string[]) => void;
|
||||||
}) {
|
}) {
|
||||||
const { entries, total, completed, percent } = useMemo(() => {
|
const entries = [...progress.values()];
|
||||||
const e = [...progress.entries()];
|
const completed = entries.filter(
|
||||||
const t = e.length;
|
(item) => item.status === "done" || item.status === "failed",
|
||||||
const c = e.filter(
|
).length;
|
||||||
([, s]) => s.status === "done" || s.status === "failed",
|
const total = entries.length;
|
||||||
).length;
|
const percent = total ? Math.round((completed / total) * 100) : 0;
|
||||||
return {
|
const imported = done
|
||||||
entries: e,
|
? succeeded
|
||||||
total: t,
|
: entries.filter((item) => item.status === "done").length;
|
||||||
completed: c,
|
const errors = done
|
||||||
percent: t > 0 ? Math.round((c / t) * 100) : 0,
|
? failed
|
||||||
};
|
: entries.filter((item) => item.status === "failed").length;
|
||||||
}, [progress]);
|
const unfinished = Math.max(0, total - completed);
|
||||||
|
const heading = !done
|
||||||
// Live ETA calculation
|
? cancelling
|
||||||
const [eta, setEta] = useState<string | null>(null);
|
? "Stopping import…"
|
||||||
const completedRef = useRef(completed);
|
: "Importing furniture…"
|
||||||
const totalRef = useRef(total);
|
: unfinished
|
||||||
completedRef.current = completed;
|
? "Import stopped"
|
||||||
totalRef.current = total;
|
: errors
|
||||||
|
? "Import completed with errors"
|
||||||
useEffect(() => {
|
: "Import completed";
|
||||||
if (done || !startTime || completed === 0) {
|
const issues = verification
|
||||||
setEta(null);
|
? verification.furniDataMissing + verification.furniDataConflicts
|
||||||
return;
|
: 0;
|
||||||
}
|
|
||||||
const calc = () => {
|
|
||||||
const elapsed = Date.now() - startTime;
|
|
||||||
const avgPerItem = elapsed / completedRef.current;
|
|
||||||
const remaining = (totalRef.current - completedRef.current) * avgPerItem;
|
|
||||||
setEta(formatEta(remaining));
|
|
||||||
};
|
|
||||||
calc();
|
|
||||||
const interval = setInterval(calc, 2000);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [done, startTime, completed]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-t border-[var(--admin-border)] p-3">
|
<section
|
||||||
<div className="mb-2 flex items-center justify-between gap-3">
|
aria-label="Import progress"
|
||||||
<span className="text-xs font-bold uppercase tracking-wider text-[var(--admin-text-muted)]">
|
className="max-h-[45%] shrink-0 overflow-auto border-t border-[var(--admin-border)] bg-[var(--admin-surface)] p-4"
|
||||||
{done ? "Import finished" : "Importing…"}
|
>
|
||||||
</span>
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
{done ? (
|
<div role="status" aria-live="polite">
|
||||||
<span
|
<h2
|
||||||
className={cn(
|
style={{ fontSize: 14, margin: 0 }}
|
||||||
"text-xs font-bold",
|
className="text-sm font-semibold"
|
||||||
failed > 0
|
|
||||||
? "text-[var(--admin-warning)]"
|
|
||||||
: "text-[var(--admin-success)]",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{completed}/{total} · {succeeded} ok
|
{heading}
|
||||||
{failed > 0 ? ` · ${failed} failed` : ""}
|
</h2>
|
||||||
</span>
|
<p className="mt-1 text-xs text-[var(--admin-text-muted)]">
|
||||||
) : (
|
{imported} imported · {errors} failed
|
||||||
<span className="flex items-center gap-2">
|
{unfinished > 0 &&
|
||||||
<span className="text-xs font-mono text-[var(--admin-text-muted)]">
|
` · ${unfinished} ${done ? "not completed" : "remaining"}`}
|
||||||
{completed}/{total}
|
</p>
|
||||||
</span>
|
|
||||||
{eta && (
|
|
||||||
<span className="text-xs font-mono text-[var(--admin-accent)]">
|
|
||||||
~{eta}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<Loader2
|
|
||||||
size={14}
|
|
||||||
className="animate-spin text-[var(--admin-accent)]"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!done && (
|
|
||||||
<div className="mb-2 flex items-center gap-2">
|
|
||||||
<div className="h-1.5 flex-1 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>
|
|
||||||
<span className="text-xs font-mono font-bold text-[var(--admin-text)]">
|
|
||||||
{percent}%
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{done ? (
|
||||||
<div className="max-h-44 space-y-1 overflow-y-auto pr-1">
|
<>
|
||||||
{entries.map(([classname, s]) => (
|
{!!failedClassnames?.length && onRetry && (
|
||||||
<div key={classname} className="flex items-center gap-2 text-xs">
|
<Button
|
||||||
{s.status === "done" ? (
|
size="sm"
|
||||||
<Check
|
variant="outline"
|
||||||
size={12}
|
onClick={() => onRetry(failedClassnames)}
|
||||||
className="shrink-0 text-[var(--admin-success)]"
|
>
|
||||||
/>
|
<RefreshCw size={14} />
|
||||||
) : s.status === "failed" ? (
|
Retry failed ({failedClassnames.length})
|
||||||
<X size={12} className="shrink-0 text-[var(--admin-warning)]" />
|
</Button>
|
||||||
) : (
|
)}
|
||||||
<Loader2
|
<Button size="sm" variant="secondary" onClick={onDismiss}>
|
||||||
size={12}
|
<Check size={14} />
|
||||||
className="shrink-0 animate-spin text-[var(--admin-accent)]"
|
Close summary
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<span className="min-w-0 flex-1 truncate font-mono">
|
|
||||||
{classname}
|
|
||||||
</span>
|
|
||||||
<span className="shrink-0 capitalize text-[var(--admin-text-muted)]">
|
|
||||||
{s.status.replace(/_/g, " ")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{done && verification && (
|
|
||||||
<div className="mt-2 space-y-1.5 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-canvas)] p-2.5">
|
|
||||||
<div className="flex items-center gap-1.5 text-xs font-bold uppercase tracking-wider text-[var(--admin-text-muted)]">
|
|
||||||
<ShieldCheck size={13} className="text-[var(--admin-success)]" />
|
|
||||||
Post-import verification
|
|
||||||
</div>
|
|
||||||
<SummaryRow
|
|
||||||
label="Catalog offer_id fixed"
|
|
||||||
value={verification.offerIdsFixed}
|
|
||||||
/>
|
|
||||||
<SummaryRow
|
|
||||||
label="Furnidata ids fixed"
|
|
||||||
value={verification.furniDataFixedIds}
|
|
||||||
/>
|
|
||||||
<SummaryRow
|
|
||||||
label="Furnidata entries missing"
|
|
||||||
value={verification.furniDataMissing}
|
|
||||||
tone={verification.furniDataMissing > 0 ? "warn" : "ok"}
|
|
||||||
/>
|
|
||||||
<SummaryRow
|
|
||||||
label="Furnidata id conflicts"
|
|
||||||
value={verification.furniDataConflicts}
|
|
||||||
tone={verification.furniDataConflicts > 0 ? "warn" : "ok"}
|
|
||||||
/>
|
|
||||||
<SummaryRow
|
|
||||||
label="Nitros synced"
|
|
||||||
value={verification.nitrosSynced.length}
|
|
||||||
/>
|
|
||||||
<SummaryRow
|
|
||||||
label="Icons synced"
|
|
||||||
value={verification.iconsSynced.length}
|
|
||||||
/>
|
|
||||||
<SummaryRow
|
|
||||||
label="Folders chowned"
|
|
||||||
value={verification.ownershipFixed.length}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-2 flex justify-end gap-2">
|
|
||||||
{done ? (
|
|
||||||
<>
|
|
||||||
{failedClassnames && failedClassnames.length > 0 && onRetry && (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => onRetry(failedClassnames)}
|
|
||||||
>
|
|
||||||
<Loader2 size={14} />
|
|
||||||
Retry {failedClassnames.length} failed
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
</>
|
||||||
<Button size="sm" variant="secondary" onClick={onDismiss}>
|
) : (
|
||||||
<Check size={14} />
|
<Button
|
||||||
Dismiss
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={cancelling}
|
||||||
|
>
|
||||||
|
{cancelling ? (
|
||||||
|
<Loader2 className="animate-spin" size={14} />
|
||||||
|
) : (
|
||||||
|
<Ban size={14} />
|
||||||
|
)}
|
||||||
|
Stop import
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
)}
|
||||||
) : (
|
</div>
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={onCancel}
|
|
||||||
disabled={cancelling}
|
|
||||||
>
|
|
||||||
{cancelling ? (
|
|
||||||
<Loader2 size={14} className="animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Ban size={14} />
|
|
||||||
)}
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div
|
||||||
|
role="progressbar"
|
||||||
|
aria-label="Furniture processed"
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={total || 1}
|
||||||
|
aria-valuenow={completed}
|
||||||
|
className="my-3 h-2 overflow-hidden rounded-full bg-[var(--admin-accent)]/10"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-[var(--admin-accent)] transition-all"
|
||||||
|
style={{ width: `${percent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{done && verification && (
|
||||||
|
<p className="mb-2 text-sm text-[var(--admin-text-muted)]">
|
||||||
|
{issues
|
||||||
|
? `${issues} catalog data issue(s) still need attention. Open Catalog audit to review them.`
|
||||||
|
: "Catalog data verification completed."}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<details>
|
||||||
|
<summary className="cursor-pointer text-xs font-medium">
|
||||||
|
View item details ({total})
|
||||||
|
</summary>
|
||||||
|
<div className="mt-2 max-h-44 space-y-2 overflow-auto">
|
||||||
|
{entries.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.classname}
|
||||||
|
className="rounded-md bg-[var(--admin-canvas)] p-2 text-xs"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{item.status === "done" ? (
|
||||||
|
<Check size={14} className="text-[var(--admin-success)]" />
|
||||||
|
) : item.status === "failed" ? (
|
||||||
|
<X size={14} className="text-[var(--admin-warning)]" />
|
||||||
|
) : done || item.status === "pending" ? (
|
||||||
|
<Circle size={12} />
|
||||||
|
) : (
|
||||||
|
<Loader2 size={14} className="animate-spin" />
|
||||||
|
)}
|
||||||
|
<span className="min-w-0 flex-1 break-all font-medium">
|
||||||
|
{item.classname}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-[var(--admin-text-muted)]">
|
||||||
|
{done && item.status !== "done" && item.status !== "failed"
|
||||||
|
? "Not completed"
|
||||||
|
: statusLabels[item.status]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{item.message && (
|
||||||
|
<p className="mt-1 break-words text-[var(--admin-text-muted)]">
|
||||||
|
{item.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{[...new Set(item.warnings ?? [])].map((warning) => (
|
||||||
|
<p
|
||||||
|
key={warning}
|
||||||
|
className="mt-1 break-words text-[var(--admin-warning)]"
|
||||||
|
>
|
||||||
|
{warning}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ChevronDown, ChevronRight, FolderTree } from "lucide-react";
|
import { ChevronDown, ChevronRight, FolderTree, Search, X } from "lucide-react";
|
||||||
import { memo, useCallback, useMemo, useState } from "react";
|
import { memo, useMemo, useState } from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { TreeNode } from "@/types/catalog";
|
import type { TreeNode } from "@/types/catalog";
|
||||||
|
|
||||||
@@ -10,93 +9,122 @@ export const CatalogRail = memo(function CatalogRail({
|
|||||||
tree,
|
tree,
|
||||||
selectedId,
|
selectedId,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
tree: TreeNode[];
|
tree: TreeNode[];
|
||||||
selectedId: number | null;
|
selectedId: number | null;
|
||||||
onSelect: (id: number | null) => void;
|
onSelect: (id: number | null) => void;
|
||||||
|
onClose?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [collapsed, setCollapsed] = useState<Set<number>>(new Set());
|
const [collapsed, setCollapsed] = useState<Set<number>>(new Set());
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
const visible = useMemo(() => {
|
const visible = useMemo(() => {
|
||||||
|
if (search.trim())
|
||||||
|
return tree.filter((node) =>
|
||||||
|
node.caption.toLowerCase().includes(search.trim().toLowerCase()),
|
||||||
|
);
|
||||||
const hidden = new Set<number>();
|
const hidden = new Set<number>();
|
||||||
for (const node of tree) {
|
for (const node of tree)
|
||||||
if (hidden.has(node.parentId)) {
|
if (hidden.has(node.parentId) || collapsed.has(node.parentId))
|
||||||
hidden.add(node.id);
|
hidden.add(node.id);
|
||||||
continue;
|
return tree.filter((node) => !hidden.has(node.id));
|
||||||
}
|
}, [tree, collapsed, search]);
|
||||||
if (collapsed.has(node.parentId)) {
|
function toggle(id: number) {
|
||||||
hidden.add(node.id);
|
setCollapsed((previous) => {
|
||||||
}
|
const next = new Set(previous);
|
||||||
}
|
|
||||||
return tree.filter((n) => !hidden.has(n.id));
|
|
||||||
}, [tree, collapsed]);
|
|
||||||
|
|
||||||
const toggle = useCallback((id: number) => {
|
|
||||||
setCollapsed((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(id)) next.delete(id);
|
if (next.has(id)) next.delete(id);
|
||||||
else next.add(id);
|
else next.add(id);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, []);
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="flex h-full min-h-0 w-64 shrink-0 flex-col border-r border-[var(--admin-border)] bg-[var(--admin-surface)]">
|
<aside
|
||||||
<div className="flex items-center gap-2 border-b border-[var(--admin-border)] px-3 py-2.5">
|
aria-label="Catalog categories"
|
||||||
<FolderTree size={14} className="text-[var(--admin-accent)]" />
|
className="absolute inset-y-0 left-0 z-30 flex w-64 max-w-[85%] flex-col border-r border-[var(--admin-border)] bg-[var(--admin-surface)] shadow-xl lg:static lg:z-auto lg:shrink-0 lg:shadow-none"
|
||||||
<span className="text-xs font-bold uppercase tracking-wider text-[var(--admin-text-muted)]">
|
>
|
||||||
Catalog
|
<div className="flex items-center gap-2 px-3 pt-3">
|
||||||
</span>
|
<FolderTree size={16} />
|
||||||
|
<h2
|
||||||
|
style={{ fontSize: 14, margin: 0 }}
|
||||||
|
className="flex-1 text-sm font-semibold"
|
||||||
|
>
|
||||||
|
Categories
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close categories"
|
||||||
|
className="grid h-9 w-9 place-items-center rounded-md hover:bg-[var(--admin-accent)]/10"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto p-1.5">
|
<label className="mx-3 my-3 flex items-center gap-2 rounded-md border border-[var(--admin-border)] px-2">
|
||||||
{visible.length === 0 ? (
|
<Search size={14} />
|
||||||
<p className="px-2 py-4 text-xs text-[var(--admin-text-muted)]">
|
<input
|
||||||
No catalog pages found.
|
value={search}
|
||||||
|
onChange={(event) => setSearch(event.target.value)}
|
||||||
|
placeholder="Find a category…"
|
||||||
|
aria-label="Find a category"
|
||||||
|
className="h-9 min-w-0 w-full border-0 bg-transparent text-sm outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(null)}
|
||||||
|
aria-pressed={selectedId === null}
|
||||||
|
className="mx-2 mb-2 rounded-md px-3 py-2 text-left text-sm hover:bg-[var(--admin-accent)]/10 aria-pressed:bg-[var(--admin-accent)]/15"
|
||||||
|
>
|
||||||
|
All categories
|
||||||
|
</button>
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-3">
|
||||||
|
{visible.length === 0 && (
|
||||||
|
<p className="p-3 text-sm text-[var(--admin-text-muted)]">
|
||||||
|
No matching categories.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
|
||||||
visible.map((node) => {
|
|
||||||
const isRoot = node.parentId <= 0;
|
|
||||||
const hasChildren = node.childCount > 0;
|
|
||||||
const isCollapsed = collapsed.has(node.id);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={node.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
if (hasChildren) toggle(node.id);
|
|
||||||
onSelect(selectedId === node.id ? null : node.id);
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
"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)]",
|
|
||||||
)}
|
|
||||||
style={{ paddingLeft: `${6 + node.depth * 12}px` }}
|
|
||||||
>
|
|
||||||
{hasChildren ? (
|
|
||||||
isCollapsed ? (
|
|
||||||
<ChevronRight size={12} className="shrink-0" />
|
|
||||||
) : (
|
|
||||||
<ChevronDown size={12} className="shrink-0" />
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<span className="w-3 shrink-0" />
|
|
||||||
)}
|
|
||||||
<span className="min-w-0 flex-1 truncate">
|
|
||||||
{isRoot ? (
|
|
||||||
<span className="font-semibold">{node.caption}</span>
|
|
||||||
) : (
|
|
||||||
node.caption
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<span className="shrink-0 rounded-full bg-[var(--admin-accent)]/10 px-1.5 text-[0.6rem] font-semibold text-[var(--admin-accent)]">
|
|
||||||
{node.itemCount}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
)}
|
||||||
|
{visible.map((node) => (
|
||||||
|
<div
|
||||||
|
key={node.id}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center rounded-md",
|
||||||
|
selectedId === node.id && "bg-[var(--admin-accent)]/15",
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
paddingLeft: search.trim() ? 0 : Math.min(node.depth * 12, 60),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{node.childCount > 0 && !search.trim() ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`${collapsed.has(node.id) ? "Expand" : "Collapse"} ${node.caption}`}
|
||||||
|
aria-expanded={!collapsed.has(node.id)}
|
||||||
|
onClick={() => toggle(node.id)}
|
||||||
|
className="grid h-10 w-8 shrink-0 place-items-center rounded-md hover:bg-[var(--admin-accent)]/10"
|
||||||
|
>
|
||||||
|
{collapsed.has(node.id) ? (
|
||||||
|
<ChevronRight size={14} />
|
||||||
|
) : (
|
||||||
|
<ChevronDown size={14} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="w-8 shrink-0" />
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(node.id)}
|
||||||
|
aria-pressed={selectedId === node.id}
|
||||||
|
title={node.caption}
|
||||||
|
className="flex min-h-10 min-w-0 flex-1 items-center gap-2 rounded-md py-2 pr-2 text-left text-sm hover:bg-[var(--admin-accent)]/10"
|
||||||
|
>
|
||||||
|
<span className="min-w-0 flex-1 truncate">{node.caption}</span>
|
||||||
|
<span className="text-xs tabular-nums text-[var(--admin-text-muted)]">
|
||||||
|
{node.itemCount}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -111,7 +111,10 @@ export function StudioClient({
|
|||||||
// Catalog tree
|
// Catalog tree
|
||||||
const [tree] = useState<TreeNode[]>(initialTree);
|
const [tree] = useState<TreeNode[]>(initialTree);
|
||||||
const [selectedPage, setSelectedPage] = useState<number | null>(null);
|
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
|
// Search & list
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
@@ -133,7 +136,7 @@ export function StudioClient({
|
|||||||
const [categoryFilter, setCategoryFilter] = useState<string>("all");
|
const [categoryFilter, setCategoryFilter] = useState<string>("all");
|
||||||
const [statusFilter, setStatusFilter] = useState<string>("all");
|
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||||
const [sortBy, setSortBy] = useState<string>("default");
|
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 searchRef = useRef<HTMLInputElement>(null);
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
@@ -154,7 +157,7 @@ export function StudioClient({
|
|||||||
const batchDoneRef = useRef(false);
|
const batchDoneRef = useRef(false);
|
||||||
const markBatchDone = useCallback(() => {
|
const markBatchDone = useCallback(() => {
|
||||||
batchDoneRef.current = true;
|
batchDoneRef.current = true;
|
||||||
markBatchDone();
|
setBatchDone(true);
|
||||||
}, []);
|
}, []);
|
||||||
const [batchSucceeded, setBatchSucceeded] = useState(0);
|
const [batchSucceeded, setBatchSucceeded] = useState(0);
|
||||||
const [batchFailed, setBatchFailed] = useState(0);
|
const [batchFailed, setBatchFailed] = useState(0);
|
||||||
@@ -218,13 +221,13 @@ export function StudioClient({
|
|||||||
const langs = data.languages ?? [];
|
const langs = data.languages ?? [];
|
||||||
const okCount = langs.filter((l) => l.ok).length;
|
const okCount = langs.filter((l) => l.ok).length;
|
||||||
toast.success(
|
toast.success(
|
||||||
`Alle talen vertaald (${okCount}/${langs.length} gelukt)`,
|
`Languages translated (${okCount}/${langs.length} completed)`,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
toast.error(data.error ?? "Vertaling mislukt");
|
toast.error(data.error ?? "Translation failed");
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Netwerkfout bij het vertalen van alle talen");
|
toast.error("Network error while translating languages");
|
||||||
} finally {
|
} finally {
|
||||||
setBuildingLanguages(false);
|
setBuildingLanguages(false);
|
||||||
}
|
}
|
||||||
@@ -241,7 +244,7 @@ export function StudioClient({
|
|||||||
try {
|
try {
|
||||||
await setFurnidataTranslateEnabled({ enabled: next });
|
await setFurnidataTranslateEnabled({ enabled: next });
|
||||||
} catch {
|
} 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) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,9 +421,9 @@ export function StudioClient({
|
|||||||
sources.find((s) => s.id === activeSource)?.name || activeSource;
|
sources.find((s) => s.id === activeSource)?.name || activeSource;
|
||||||
|
|
||||||
const confirmed = await confirm({
|
const confirmed = await confirm({
|
||||||
title: "Alles importeren",
|
title: "Import all missing",
|
||||||
description: `${classnames.length} items importeren uit "${sourceName}"? Dit kan even duren.`,
|
description: `${classnames.length} items to import from "${sourceName}"? This may take a while.`,
|
||||||
confirmLabel: "Alles importeren",
|
confirmLabel: "Import all missing",
|
||||||
});
|
});
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
|
|
||||||
@@ -430,6 +433,7 @@ export function StudioClient({
|
|||||||
initial.set(cn, { classname: cn, status: "pending" });
|
initial.set(cn, { classname: cn, status: "pending" });
|
||||||
}
|
}
|
||||||
setBatchProgress(initial);
|
setBatchProgress(initial);
|
||||||
|
batchDoneRef.current = false;
|
||||||
setBatchDone(false);
|
setBatchDone(false);
|
||||||
setBatchSucceeded(0);
|
setBatchSucceeded(0);
|
||||||
setBatchFailed(0);
|
setBatchFailed(0);
|
||||||
@@ -543,7 +547,7 @@ export function StudioClient({
|
|||||||
|
|
||||||
// Auto-translate after clone all if translation is enabled
|
// Auto-translate after clone all if translation is enabled
|
||||||
if (translate && okCount > 0) {
|
if (translate && okCount > 0) {
|
||||||
toast.info("Automatisch vertalen van alle talen...");
|
toast.info("Translating all languages...");
|
||||||
buildAllLanguages();
|
buildAllLanguages();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -589,6 +593,12 @@ export function StudioClient({
|
|||||||
if (updated) setDetail(updated);
|
if (updated) setDetail(updated);
|
||||||
}, [items, detail]);
|
}, [items, detail]);
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
setTypeFilter("all");
|
||||||
|
setCategoryFilter("all");
|
||||||
|
setStatusFilter("all");
|
||||||
|
setSelectedPage(null);
|
||||||
|
};
|
||||||
const clearSearch = () => {
|
const clearSearch = () => {
|
||||||
setSearchTerm("");
|
setSearchTerm("");
|
||||||
setActiveSearch("");
|
setActiveSearch("");
|
||||||
@@ -701,15 +711,19 @@ export function StudioClient({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function importBatch() {
|
async function importBatch(requested?: string[]) {
|
||||||
const toImport = filteredItems.filter(
|
const toImport = (requested ? items : filteredItems).filter(
|
||||||
(i) => selected.has(i.classname) && !i.alreadyImported,
|
(i) =>
|
||||||
|
(requested
|
||||||
|
? requested.includes(i.classname)
|
||||||
|
: selected.has(i.classname)) && !i.alreadyImported,
|
||||||
);
|
);
|
||||||
if (toImport.length === 0) {
|
if (toImport.length === 0) {
|
||||||
toast.info("Select furni that isn't imported yet");
|
toast.info("Select furni that isn't imported yet");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
batchDoneRef.current = false;
|
||||||
setBatchDone(false);
|
setBatchDone(false);
|
||||||
setBatchSucceeded(0);
|
setBatchSucceeded(0);
|
||||||
setBatchFailed(0);
|
setBatchFailed(0);
|
||||||
@@ -750,7 +764,7 @@ export function StudioClient({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok || !res.body) {
|
if (!res.ok || !res.body) {
|
||||||
toast.error(`Batch import mislukt (${res.status})`);
|
toast.error(`Batch import failed (${res.status})`);
|
||||||
setBatchProgress(null);
|
setBatchProgress(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -818,18 +832,16 @@ export function StudioClient({
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if ((err as Error).name === "AbortError") {
|
if ((err as Error).name === "AbortError") {
|
||||||
toast.info("Batch import geannuleerd door gebruiker");
|
toast.info("Import cancelled");
|
||||||
} else if ((err as Error).name === "TimeoutError") {
|
} else if ((err as Error).name === "TimeoutError") {
|
||||||
toast.error(
|
toast.error("Import timed out. Retry or reduce concurrency.");
|
||||||
"Import tijdelijkstop gedurende te lange tijd – probeer het opnieuw of verlaag de concurrentie",
|
|
||||||
);
|
|
||||||
} else if (err instanceof TypeError && err.message?.includes("fetch")) {
|
} else if (err instanceof TypeError && err.message?.includes("fetch")) {
|
||||||
toast.error(
|
toast.error(
|
||||||
"Netwerkfout tijdens upload – controleer je internetverbinding en probeer opnieuw",
|
"Network error during upload. Check your connection and retry.",
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
toast.error(
|
toast.error(
|
||||||
"Onverwende fout tijdens import: " +
|
"Unexpected import error: " +
|
||||||
(err instanceof Error ? err.message : String(err)),
|
(err instanceof Error ? err.message : String(err)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -838,7 +850,7 @@ export function StudioClient({
|
|||||||
setBatchCancelling(false);
|
setBatchCancelling(false);
|
||||||
setSelected(new Set());
|
setSelected(new Set());
|
||||||
fetchStats();
|
fetchStats();
|
||||||
if (!batchDoneRef.current) setBatchProgress(null);
|
if (!batchDoneRef.current) markBatchDone();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -857,8 +869,13 @@ export function StudioClient({
|
|||||||
};
|
};
|
||||||
|
|
||||||
async function retryFailed(failed: string[]) {
|
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
|
// For clone sources, re-run cloneAllMissing with only the failed classnames
|
||||||
|
batchDoneRef.current = false;
|
||||||
setBatchDone(false);
|
setBatchDone(false);
|
||||||
setBatchSucceeded(0);
|
setBatchSucceeded(0);
|
||||||
setBatchFailed(0);
|
setBatchFailed(0);
|
||||||
@@ -890,7 +907,7 @@ export function StudioClient({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok || !res.body) {
|
if (!res.ok || !res.body) {
|
||||||
toast.error(`Retry mislukt (${res.status})`);
|
toast.error(`Retry failed (${res.status})`);
|
||||||
setBatchProgress(null);
|
setBatchProgress(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -931,14 +948,14 @@ export function StudioClient({
|
|||||||
markBatchDone();
|
markBatchDone();
|
||||||
|
|
||||||
if (okCount > 0) {
|
if (okCount > 0) {
|
||||||
toast.success(`${okCount} items opnieuw geïmporteerd`);
|
toast.success(`${okCount} items imported again`);
|
||||||
}
|
}
|
||||||
fetchItems(activeSearch, 1, activeSource);
|
fetchItems(activeSearch, 1, activeSource);
|
||||||
fetchStats();
|
fetchStats();
|
||||||
fetchCloneStats(activeSource);
|
fetchCloneStats(activeSource);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if ((err as Error)?.name !== "AbortError") {
|
if ((err as Error)?.name !== "AbortError") {
|
||||||
toast.error("Verbindingsfout tijdens retry");
|
toast.error("Connection failed while retrying");
|
||||||
setBatchProgress(null);
|
setBatchProgress(null);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1153,7 +1170,7 @@ export function StudioClient({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
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 ─────────────────────────────────────────── */}
|
||||||
<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">
|
<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">
|
<div className="flex items-center gap-2.5">
|
||||||
@@ -1161,7 +1178,10 @@ export function StudioClient({
|
|||||||
<Sparkles size={16} className="text-[var(--admin-accent)]" />
|
<Sparkles size={16} className="text-[var(--admin-accent)]" />
|
||||||
</div>
|
</div>
|
||||||
<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
|
Studio
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-[0.65rem] uppercase tracking-wider text-[var(--admin-text-muted)]">
|
<p className="text-[0.65rem] uppercase tracking-wider text-[var(--admin-text-muted)]">
|
||||||
@@ -1170,13 +1190,14 @@ export function StudioClient({
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="relative w-full max-w-md">
|
||||||
<Search
|
<Search
|
||||||
size={14}
|
size={14}
|
||||||
className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[var(--admin-text-muted)]"
|
className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[var(--admin-text-muted)]"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
|
aria-label="Search furniture"
|
||||||
ref={searchRef}
|
ref={searchRef}
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
@@ -1185,6 +1206,7 @@ export function StudioClient({
|
|||||||
if (e.key === "Escape") clearSearch();
|
if (e.key === "Escape") clearSearch();
|
||||||
}}
|
}}
|
||||||
placeholder="Search furni, classnames, keywords… (/)"
|
placeholder="Search furni, classnames, keywords… (/)"
|
||||||
|
style={{ paddingLeft: 32, paddingRight: 32 }}
|
||||||
className="pl-8 pr-8"
|
className="pl-8 pr-8"
|
||||||
/>
|
/>
|
||||||
{searchTerm ? (
|
{searchTerm ? (
|
||||||
@@ -1232,7 +1254,7 @@ export function StudioClient({
|
|||||||
</Badge>
|
</Badge>
|
||||||
<Badge variant="secondary" className="gap-1">
|
<Badge variant="secondary" className="gap-1">
|
||||||
<Check size={11} className="text-[var(--admin-success)]" />{" "}
|
<Check size={11} className="text-[var(--admin-success)]" />{" "}
|
||||||
{cloneStats.present} aanwezig
|
{cloneStats.present} present
|
||||||
</Badge>
|
</Badge>
|
||||||
{cloneStats.clonable > 0 && (
|
{cloneStats.clonable > 0 && (
|
||||||
<Badge
|
<Badge
|
||||||
@@ -1246,18 +1268,6 @@ export function StudioClient({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</header>
|
||||||
|
|
||||||
{/* ── Filters + actions ─────────────────────────────── */}
|
{/* ── Filters + actions ─────────────────────────────── */}
|
||||||
@@ -1283,8 +1293,14 @@ export function StudioClient({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<Select value={activeSource} onValueChange={changeSource}>
|
<Select value={activeSource} onValueChange={changeSource}>
|
||||||
<SelectTrigger className="h-8 w-44 text-xs">
|
<SelectTrigger
|
||||||
<SelectValue placeholder="Furni source" />
|
aria-label="Furniture source"
|
||||||
|
className="h-8 w-44 text-xs"
|
||||||
|
>
|
||||||
|
<SelectValue>
|
||||||
|
{sources.find((entry) => entry.id === activeSource)?.name ??
|
||||||
|
`${source.label} (official)`}
|
||||||
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="">{source.label} (official)</SelectItem>
|
<SelectItem value="">{source.label} (official)</SelectItem>
|
||||||
@@ -1297,8 +1313,17 @@ export function StudioClient({
|
|||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||||
<SelectTrigger className="h-8 w-32 text-xs">
|
<SelectTrigger
|
||||||
<SelectValue placeholder="Type" />
|
aria-label="Furniture type"
|
||||||
|
className="h-8 w-32 text-xs"
|
||||||
|
>
|
||||||
|
<SelectValue>
|
||||||
|
{typeFilter === "all"
|
||||||
|
? "All types"
|
||||||
|
: typeFilter === "flooritem"
|
||||||
|
? "Floor items"
|
||||||
|
: "Wall items"}
|
||||||
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">All types</SelectItem>
|
<SelectItem value="all">All types</SelectItem>
|
||||||
@@ -1308,8 +1333,13 @@ export function StudioClient({
|
|||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
|
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
|
||||||
<SelectTrigger className="h-8 w-40 text-xs">
|
<SelectTrigger
|
||||||
<SelectValue placeholder="Category" />
|
aria-label="Furniture category"
|
||||||
|
className="h-8 w-40 text-xs"
|
||||||
|
>
|
||||||
|
<SelectValue>
|
||||||
|
{categoryFilter === "all" ? "All categories" : categoryFilter}
|
||||||
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{categories.map((cat) => (
|
{categories.map((cat) => (
|
||||||
@@ -1321,13 +1351,31 @@ export function StudioClient({
|
|||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||||
<SelectTrigger className="h-8 w-36 text-xs">
|
<SelectTrigger
|
||||||
<SelectValue placeholder="Status" />
|
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>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">
|
<SelectItem value="all">
|
||||||
All statuses{" "}
|
All statuses{" "}
|
||||||
{notImportedCount > 0 && `(${notImportedCount} te importeren)`}
|
{notImportedCount > 0 &&
|
||||||
|
`(${notImportedCount} available to import)`}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="notImported">Not imported</SelectItem>
|
<SelectItem value="notImported">Not imported</SelectItem>
|
||||||
<SelectItem value="imported">Imported</SelectItem>
|
<SelectItem value="imported">Imported</SelectItem>
|
||||||
@@ -1341,9 +1389,25 @@ export function StudioClient({
|
|||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Select value={sortBy} onValueChange={setSortBy}>
|
<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" />
|
<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>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="default">Default order</SelectItem>
|
<SelectItem value="default">Default order</SelectItem>
|
||||||
@@ -1357,6 +1421,14 @@ export function StudioClient({
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
|
{(typeFilter !== "all" ||
|
||||||
|
categoryFilter !== "all" ||
|
||||||
|
statusFilter !== "all" ||
|
||||||
|
selectedPage !== null) && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={clearFilters}>
|
||||||
|
Reset filters
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{missingNitroCount > 0 && (
|
{missingNitroCount > 0 && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1378,6 +1450,7 @@ export function StudioClient({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setViewMode("grid")}
|
onClick={() => setViewMode("grid")}
|
||||||
aria-label="Grid view"
|
aria-label="Grid view"
|
||||||
|
aria-pressed={viewMode === "grid"}
|
||||||
className={cn(
|
className={cn(
|
||||||
"grid h-8 w-8 place-items-center transition-colors",
|
"grid h-8 w-8 place-items-center transition-colors",
|
||||||
viewMode === "grid"
|
viewMode === "grid"
|
||||||
@@ -1391,6 +1464,7 @@ export function StudioClient({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setViewMode("list")}
|
onClick={() => setViewMode("list")}
|
||||||
aria-label="List view"
|
aria-label="List view"
|
||||||
|
aria-pressed={viewMode === "list"}
|
||||||
className={cn(
|
className={cn(
|
||||||
"grid h-8 w-8 place-items-center transition-colors",
|
"grid h-8 w-8 place-items-center transition-colors",
|
||||||
viewMode === "list"
|
viewMode === "list"
|
||||||
@@ -1401,19 +1475,152 @@ export function StudioClient({
|
|||||||
<List size={13} />
|
<List size={13} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={toggleAll}
|
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)]"
|
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} />
|
<CheckboxDot checked={allSelected} />
|
||||||
Select all
|
{allSelected ? "Deselect visible" : "Select visible"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{selected.size > 0 && (
|
{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)]">
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1428,110 +1635,12 @@ export function StudioClient({
|
|||||||
</button>
|
</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" />
|
<div className="flex-1" />
|
||||||
|
|
||||||
{cloneStats && cloneStats.clonable > 0 && !batchProgress && (
|
{cloneStats && cloneStats.clonable > 0 && !batchProgress && (
|
||||||
<Button size="sm" className="gap-1.5" onClick={cloneAllMissing}>
|
<Button size="sm" className="gap-1.5" onClick={cloneAllMissing}>
|
||||||
<CloudDownload size={14} />
|
<CloudDownload size={14} />
|
||||||
Alles importeren ({cloneStats.clonable})
|
Import all missing ({cloneStats.clonable})
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1540,14 +1649,14 @@ export function StudioClient({
|
|||||||
size="sm"
|
size="sm"
|
||||||
className="gap-1.5"
|
className="gap-1.5"
|
||||||
disabled={!!batchProgress}
|
disabled={!!batchProgress}
|
||||||
onClick={importBatch}
|
onClick={() => importBatch()}
|
||||||
>
|
>
|
||||||
{batchProgress ? (
|
{batchProgress ? (
|
||||||
<Loader2 size={14} className="animate-spin" />
|
<Loader2 size={14} className="animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<CloudDownload size={14} />
|
<CloudDownload size={14} />
|
||||||
)}
|
)}
|
||||||
Import {selectedNonImported} auto
|
Import selected ({selectedNonImported})
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1564,7 +1673,7 @@ export function StudioClient({
|
|||||||
) : (
|
) : (
|
||||||
<RefreshCw size={14} />
|
<RefreshCw size={14} />
|
||||||
)}
|
)}
|
||||||
Nitro regeneren ({selectedMissingNitro.length})
|
Rebuild Nitro ({selectedMissingNitro.length})
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1581,25 +1690,45 @@ export function StudioClient({
|
|||||||
) : (
|
) : (
|
||||||
<Trash2 size={14} />
|
<Trash2 size={14} />
|
||||||
)}
|
)}
|
||||||
Verwijder ({selectedImported.length})
|
Delete ({selectedImported.length})
|
||||||
</Button>
|
</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 ────────────────────── */}
|
{/* ── Body: rail + grid + drawer ────────────────────── */}
|
||||||
<div className="relative flex min-h-0 flex-1">
|
<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 ? (
|
{railOpen ? (
|
||||||
<CatalogRail
|
<CatalogRail
|
||||||
tree={tree}
|
tree={tree}
|
||||||
selectedId={selectedPage}
|
selectedId={selectedPage}
|
||||||
onSelect={setSelectedPage}
|
onSelect={(id) => {
|
||||||
|
setSelectedPage(id);
|
||||||
|
if (!window.matchMedia("(min-width: 1024px)").matches)
|
||||||
|
setRailOpen(false);
|
||||||
|
}}
|
||||||
|
onClose={() => setRailOpen(false)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : 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">
|
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
||||||
{loading && items.length === 0 ? (
|
{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) => (
|
{Array.from({ length: 10 }, (_, i) => i).map((n) => (
|
||||||
<div
|
<div
|
||||||
key={`skeleton-${n}`}
|
key={`skeleton-${n}`}
|
||||||
@@ -1616,6 +1745,13 @@ export function StudioClient({
|
|||||||
) : error ? (
|
) : error ? (
|
||||||
<div className="py-16 text-center text-sm text-[var(--admin-warning)]">
|
<div className="py-16 text-center text-sm text-[var(--admin-warning)]">
|
||||||
{error}
|
{error}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="ml-3"
|
||||||
|
onClick={() => fetchItems(activeSearch, 1, activeSource)}
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : filteredItems.length === 0 ? (
|
) : filteredItems.length === 0 ? (
|
||||||
<div className="flex flex-col items-center py-16 text-center">
|
<div className="flex flex-col items-center py-16 text-center">
|
||||||
@@ -1639,7 +1775,7 @@ export function StudioClient({
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{viewMode === "grid" ? (
|
{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) => {
|
{filteredItems.map((item) => {
|
||||||
const isSel = selected.has(item.classname);
|
const isSel = selected.has(item.classname);
|
||||||
return (
|
return (
|
||||||
@@ -1647,7 +1783,6 @@ export function StudioClient({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setDetail(item)}
|
onClick={() => setDetail(item)}
|
||||||
onDoubleClick={() => toggleSelect(item.classname)}
|
|
||||||
className={cn(
|
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)]",
|
"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
|
||||||
@@ -1683,7 +1818,7 @@ export function StudioClient({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 px-2 pb-1.5">
|
<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}
|
{TYPE_LABELS[item.type] ?? item.type}
|
||||||
</Badge>
|
</Badge>
|
||||||
{item.alreadyImported ? (
|
{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
|
New
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
@@ -1737,12 +1872,13 @@ export function StudioClient({
|
|||||||
toggleSelect(item.classname);
|
toggleSelect(item.classname);
|
||||||
}}
|
}}
|
||||||
className={cn(
|
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
|
isSel
|
||||||
? "border-[var(--admin-accent)] bg-[var(--admin-accent)]"
|
? "border-[var(--admin-accent)] bg-[var(--admin-accent)]"
|
||||||
: "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",
|
||||||
)}
|
)}
|
||||||
aria-label={isSel ? "Deselect" : "Select"}
|
aria-label={`${isSel ? "Deselect" : "Select"} ${item.name}`}
|
||||||
|
aria-pressed={isSel}
|
||||||
>
|
>
|
||||||
{isSel ? (
|
{isSel ? (
|
||||||
<Check
|
<Check
|
||||||
@@ -1757,11 +1893,17 @@ export function StudioClient({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto rounded-lg border border-[var(--admin-border)]">
|
<div className="overflow-x-auto rounded-lg border border-[var(--admin-border)]">
|
||||||
<table className="w-full text-xs">
|
<table className="w-full min-w-[660px] text-sm">
|
||||||
<thead className="bg-[var(--admin-accent)]/8">
|
<thead className="sticky top-0 z-10 bg-[var(--admin-surface)]">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="w-9 px-2 py-2 text-left">
|
<th className="w-9 px-2 py-2 text-left">
|
||||||
<Checkbox
|
<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}
|
checked={allSelected}
|
||||||
onCheckedChange={toggleAll}
|
onCheckedChange={toggleAll}
|
||||||
aria-label="Select all"
|
aria-label="Select all"
|
||||||
@@ -1794,10 +1936,20 @@ export function StudioClient({
|
|||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={item.classname}
|
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">
|
<td className="px-2 py-1.5">
|
||||||
<Checkbox
|
<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}
|
checked={isSel}
|
||||||
onCheckedChange={() =>
|
onCheckedChange={() =>
|
||||||
toggleSelect(item.classname)
|
toggleSelect(item.classname)
|
||||||
@@ -1873,7 +2025,7 @@ export function StudioClient({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Badge className="h-4 text-[var(--admin-accent-foreground)]">
|
<Badge className="h-4 bg-[var(--admin-accent)] text-[var(--admin-accent-foreground)]">
|
||||||
New
|
New
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
@@ -1886,7 +2038,7 @@ export function StudioClient({
|
|||||||
onClick={() => importSingle(item)}
|
onClick={() => importSingle(item)}
|
||||||
disabled={importingId !== null}
|
disabled={importingId !== null}
|
||||||
aria-label={`Import ${item.classname}`}
|
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 ? (
|
{importingId === item.classname ? (
|
||||||
<Loader2
|
<Loader2
|
||||||
@@ -1905,7 +2057,7 @@ export function StudioClient({
|
|||||||
setEditingClassname(item.classname)
|
setEditingClassname(item.classname)
|
||||||
}
|
}
|
||||||
aria-label={`Edit nitro ${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} />
|
<Pencil size={12} />
|
||||||
</button>
|
</button>
|
||||||
@@ -1919,7 +2071,7 @@ export function StudioClient({
|
|||||||
item.classname,
|
item.classname,
|
||||||
)}
|
)}
|
||||||
aria-label={`Regenerate nitro ${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(
|
{regeneratingNitro.has(
|
||||||
item.classname,
|
item.classname,
|
||||||
@@ -1985,11 +2137,12 @@ export function StudioClient({
|
|||||||
onCancel={cloneAllAbortRef.current ? cancelCloneAll : cancelBatch}
|
onCancel={cloneAllAbortRef.current ? cancelCloneAll : cancelBatch}
|
||||||
onDismiss={() => {
|
onDismiss={() => {
|
||||||
setBatchProgress(null);
|
setBatchProgress(null);
|
||||||
|
batchDoneRef.current = false;
|
||||||
setBatchDone(false);
|
setBatchDone(false);
|
||||||
setVerification(null);
|
setVerification(null);
|
||||||
setFailedClassnames([]);
|
setFailedClassnames([]);
|
||||||
}}
|
}}
|
||||||
onRetry={activeSource ? retryFailed : undefined}
|
onRetry={retryFailed}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
Reference in new issue
Block a user