feat: Studio bulk ops, perf fixes, bug fixes, confirm dialog visibility
Confirm Dialog: - Overlay opacity 80% -> 95% (solid black) - Card uses bg-surface with glow shadow ring-primary/40 - Added AlertTriangle icon for danger variant - Removed backdrop-blur for maximum opacity Studio features: - Bulk delete: select imported items and delete in batch - Bulk nitro regen: select items missing .nitro and regenerate all - Added Trash2 and RefreshCw toolbar buttons for bulk actions Performance: - BatchProgress: React.memo + useMemo for entries/total/completed/percent - BatchProgress: ETA interval uses useRef to avoid re-creation per completed - CatalogRail: React.memo + useCallback for toggle - Removed redundant double setBatchProgress calls - Removed redundant new Map(initial) wrapping Bug fixes: - importBatch: stale batchDone closure always cleared progress panel (use ref) - importBatch: added missing toast.error on HTTP failure - bulkRegenNitro: now checks per-item results instead of marking all as succeeded - single deleteItem: now removes classname from selected set - retryFailed: clears progress panel on HTTP error and stream error - Removed unused 'done' variable (2x) from cloneAllMissing/retryFailed Lint: - biome format fixes for selectedMissingNitro and setItems
This commit is contained in:
1 parent
4863f78795
commit
93601e58e0
4 files changed
+214
-45
No files matched your search
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { AlertTriangle } from "lucide-react";
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
@@ -77,7 +78,7 @@ export function ConfirmDialog({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Dismiss"
|
aria-label="Dismiss"
|
||||||
className="absolute inset-0 bg-black/80 backdrop-blur-sm"
|
className="absolute inset-0 bg-black/95"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!isLoading) onOpenChange(false);
|
if (!isLoading) onOpenChange(false);
|
||||||
@@ -89,19 +90,26 @@ export function ConfirmDialog({
|
|||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="confirm-dialog-title"
|
aria-labelledby="confirm-dialog-title"
|
||||||
aria-describedby="confirm-dialog-desc"
|
aria-describedby="confirm-dialog-desc"
|
||||||
className="relative z-10 w-full max-w-md rounded-lg border border-border bg-background/100 p-6 text-card-foreground shadow-2xl ring-1 ring-black/10 backdrop-blur-none"
|
className="relative z-10 w-full max-w-md rounded-xl border-2 border-primary/40 bg-surface p-6 text-card-foreground shadow-[0_0_60px_-10px_rgba(245,158,11,0.3)]"
|
||||||
variants={modalContentVariants}
|
variants={modalContentVariants}
|
||||||
initial="hidden"
|
initial="hidden"
|
||||||
animate="visible"
|
animate="visible"
|
||||||
exit="exit"
|
exit="exit"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-2 text-left">
|
<div className="flex flex-col gap-2 text-left">
|
||||||
<h2
|
<div className="flex items-center gap-3">
|
||||||
id="confirm-dialog-title"
|
{variant === "danger" && (
|
||||||
className="text-lg font-semibold leading-none"
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-destructive/15">
|
||||||
>
|
<AlertTriangle className="h-5 w-5 text-destructive" />
|
||||||
{title}
|
</div>
|
||||||
</h2>
|
)}
|
||||||
|
<h2
|
||||||
|
id="confirm-dialog-title"
|
||||||
|
className="text-lg font-semibold leading-none"
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
<p
|
<p
|
||||||
id="confirm-dialog-desc"
|
id="confirm-dialog-desc"
|
||||||
className="text-sm text-muted-foreground"
|
className="text-sm text-muted-foreground"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Ban, Check, Loader2, ShieldCheck, X } from "lucide-react";
|
import { Ban, Check, Loader2, ShieldCheck, X } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { memo, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -40,7 +40,7 @@ function formatEta(ms: number): string {
|
|||||||
return `${min}m ${sec}s`;
|
return `${min}m ${sec}s`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BatchProgress({
|
export const BatchProgress = memo(function BatchProgress({
|
||||||
progress,
|
progress,
|
||||||
done,
|
done,
|
||||||
succeeded,
|
succeeded,
|
||||||
@@ -65,33 +65,42 @@ export function BatchProgress({
|
|||||||
onDismiss?: () => void;
|
onDismiss?: () => void;
|
||||||
onRetry?: (classnames: string[]) => void;
|
onRetry?: (classnames: string[]) => void;
|
||||||
}) {
|
}) {
|
||||||
const entries = [...progress.entries()];
|
const { entries, total, completed, percent } = useMemo(() => {
|
||||||
const total = entries.length;
|
const e = [...progress.entries()];
|
||||||
const completed = entries.filter(
|
const t = e.length;
|
||||||
([, s]) => s.status === "done" || s.status === "failed",
|
const c = e.filter(
|
||||||
).length;
|
([, s]) => s.status === "done" || s.status === "failed",
|
||||||
const percent = total > 0 ? Math.round((completed / total) * 100) : 0;
|
).length;
|
||||||
|
return {
|
||||||
|
entries: e,
|
||||||
|
total: t,
|
||||||
|
completed: c,
|
||||||
|
percent: t > 0 ? Math.round((c / t) * 100) : 0,
|
||||||
|
};
|
||||||
|
}, [progress]);
|
||||||
|
|
||||||
// Live ETA calculation
|
// Live ETA calculation
|
||||||
const [eta, setEta] = useState<string | null>(null);
|
const [eta, setEta] = useState<string | null>(null);
|
||||||
|
const completedRef = useRef(completed);
|
||||||
|
const totalRef = useRef(total);
|
||||||
|
completedRef.current = completed;
|
||||||
|
totalRef.current = total;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (done || !startTime || completed === 0) {
|
if (done || !startTime || completed === 0) {
|
||||||
setEta(null);
|
setEta(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const elapsed = Date.now() - startTime;
|
const calc = () => {
|
||||||
const avgPerItem = elapsed / completed;
|
|
||||||
const remaining = (total - completed) * avgPerItem;
|
|
||||||
setEta(formatEta(remaining));
|
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
const elapsed = Date.now() - startTime;
|
const elapsed = Date.now() - startTime;
|
||||||
const avgPerItem = elapsed / completed;
|
const avgPerItem = elapsed / completedRef.current;
|
||||||
const remaining = (total - completed) * avgPerItem;
|
const remaining = (totalRef.current - completedRef.current) * avgPerItem;
|
||||||
setEta(formatEta(remaining));
|
setEta(formatEta(remaining));
|
||||||
}, 2000);
|
};
|
||||||
|
calc();
|
||||||
|
const interval = setInterval(calc, 2000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [done, startTime, completed, total]);
|
}, [done, startTime, completed]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-t border-[var(--admin-border)] p-3">
|
<div className="border-t border-[var(--admin-border)] p-3">
|
||||||
@@ -244,4 +253,4 @@ export function BatchProgress({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ChevronDown, ChevronRight, FolderTree } from "lucide-react";
|
import { ChevronDown, ChevronRight, FolderTree } from "lucide-react";
|
||||||
import { useMemo, useState } from "react";
|
import { memo, useCallback, 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";
|
||||||
|
|
||||||
export function CatalogRail({
|
export const CatalogRail = memo(function CatalogRail({
|
||||||
tree,
|
tree,
|
||||||
selectedId,
|
selectedId,
|
||||||
onSelect,
|
onSelect,
|
||||||
@@ -31,14 +31,14 @@ export function CatalogRail({
|
|||||||
return tree.filter((n) => !hidden.has(n.id));
|
return tree.filter((n) => !hidden.has(n.id));
|
||||||
}, [tree, collapsed]);
|
}, [tree, collapsed]);
|
||||||
|
|
||||||
const toggle = (id: number) => {
|
const toggle = useCallback((id: number) => {
|
||||||
setCollapsed((prev) => {
|
setCollapsed((prev) => {
|
||||||
const next = new Set(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 className="flex h-full min-h-0 w-64 shrink-0 flex-col border-r border-[var(--admin-border)] bg-[var(--admin-surface)]">
|
||||||
@@ -100,4 +100,4 @@ export function CatalogRail({
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
@@ -151,6 +151,11 @@ export function StudioClient({
|
|||||||
BatchItemStatus
|
BatchItemStatus
|
||||||
> | null>(null);
|
> | null>(null);
|
||||||
const [batchDone, setBatchDone] = useState(false);
|
const [batchDone, setBatchDone] = useState(false);
|
||||||
|
const batchDoneRef = useRef(false);
|
||||||
|
const markBatchDone = useCallback(() => {
|
||||||
|
batchDoneRef.current = true;
|
||||||
|
markBatchDone();
|
||||||
|
}, []);
|
||||||
const [batchSucceeded, setBatchSucceeded] = useState(0);
|
const [batchSucceeded, setBatchSucceeded] = useState(0);
|
||||||
const [batchFailed, setBatchFailed] = useState(0);
|
const [batchFailed, setBatchFailed] = useState(0);
|
||||||
const [batchCancelling, setBatchCancelling] = useState(false);
|
const [batchCancelling, setBatchCancelling] = useState(false);
|
||||||
@@ -424,7 +429,7 @@ export function StudioClient({
|
|||||||
for (const cn of classnames) {
|
for (const cn of classnames) {
|
||||||
initial.set(cn, { classname: cn, status: "pending" });
|
initial.set(cn, { classname: cn, status: "pending" });
|
||||||
}
|
}
|
||||||
setBatchProgress(new Map(initial));
|
setBatchProgress(initial);
|
||||||
setBatchDone(false);
|
setBatchDone(false);
|
||||||
setBatchSucceeded(0);
|
setBatchSucceeded(0);
|
||||||
setBatchFailed(0);
|
setBatchFailed(0);
|
||||||
@@ -436,7 +441,6 @@ export function StudioClient({
|
|||||||
cloneAllAbortRef.current = abort;
|
cloneAllAbortRef.current = abort;
|
||||||
|
|
||||||
const CHUNK = 400;
|
const CHUNK = 400;
|
||||||
let done = 0;
|
|
||||||
let okCount = 0;
|
let okCount = 0;
|
||||||
let failCount = 0;
|
let failCount = 0;
|
||||||
|
|
||||||
@@ -479,7 +483,6 @@ export function StudioClient({
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
if (evt.status === "done" || evt.status === "failed") {
|
if (evt.status === "done" || evt.status === "failed") {
|
||||||
done++;
|
|
||||||
if (evt.status === "failed") {
|
if (evt.status === "failed") {
|
||||||
setFailedClassnames((prev) =>
|
setFailedClassnames((prev) =>
|
||||||
prev.includes(cn) ? prev : [...prev, cn],
|
prev.includes(cn) ? prev : [...prev, cn],
|
||||||
@@ -504,7 +507,7 @@ export function StudioClient({
|
|||||||
if (!abort.signal.aborted) {
|
if (!abort.signal.aborted) {
|
||||||
setBatchSucceeded(okCount);
|
setBatchSucceeded(okCount);
|
||||||
setBatchFailed(failCount);
|
setBatchFailed(failCount);
|
||||||
setBatchDone(true);
|
markBatchDone();
|
||||||
|
|
||||||
if (okCount > 0) {
|
if (okCount > 0) {
|
||||||
toast.success(
|
toast.success(
|
||||||
@@ -690,7 +693,6 @@ export function StudioClient({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setBatchProgress(new Map());
|
|
||||||
setBatchDone(false);
|
setBatchDone(false);
|
||||||
setBatchSucceeded(0);
|
setBatchSucceeded(0);
|
||||||
setBatchFailed(0);
|
setBatchFailed(0);
|
||||||
@@ -702,7 +704,7 @@ export function StudioClient({
|
|||||||
for (const i of toImport) {
|
for (const i of toImport) {
|
||||||
initial.set(i.classname, { classname: i.classname, status: "pending" });
|
initial.set(i.classname, { classname: i.classname, status: "pending" });
|
||||||
}
|
}
|
||||||
setBatchProgress(new Map(initial));
|
setBatchProgress(initial);
|
||||||
|
|
||||||
const abort = new AbortController();
|
const abort = new AbortController();
|
||||||
batchAbortRef.current = abort;
|
batchAbortRef.current = abort;
|
||||||
@@ -731,6 +733,7 @@ export function StudioClient({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok || !res.body) {
|
if (!res.ok || !res.body) {
|
||||||
|
toast.error(`Batch import mislukt (${res.status})`);
|
||||||
setBatchProgress(null);
|
setBatchProgress(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -789,7 +792,7 @@ export function StudioClient({
|
|||||||
iconsSynced: event.iconsSynced ?? [],
|
iconsSynced: event.iconsSynced ?? [],
|
||||||
ownershipFixed: event.ownershipFixed ?? [],
|
ownershipFixed: event.ownershipFixed ?? [],
|
||||||
});
|
});
|
||||||
setBatchDone(true);
|
markBatchDone();
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* skip malformed */
|
/* skip malformed */
|
||||||
@@ -807,7 +810,7 @@ export function StudioClient({
|
|||||||
setBatchCancelling(false);
|
setBatchCancelling(false);
|
||||||
setSelected(new Set());
|
setSelected(new Set());
|
||||||
fetchStats();
|
fetchStats();
|
||||||
if (!batchDone) setBatchProgress(null);
|
if (!batchDoneRef.current) setBatchProgress(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -828,7 +831,6 @@ export function StudioClient({
|
|||||||
async function retryFailed(failed: string[]) {
|
async function retryFailed(failed: string[]) {
|
||||||
if (!activeSource || failed.length === 0) return;
|
if (!activeSource || failed.length === 0) return;
|
||||||
// For clone sources, re-run cloneAllMissing with only the failed classnames
|
// For clone sources, re-run cloneAllMissing with only the failed classnames
|
||||||
setBatchProgress(new Map());
|
|
||||||
setBatchDone(false);
|
setBatchDone(false);
|
||||||
setBatchSucceeded(0);
|
setBatchSucceeded(0);
|
||||||
setBatchFailed(0);
|
setBatchFailed(0);
|
||||||
@@ -839,11 +841,10 @@ export function StudioClient({
|
|||||||
for (const cn of failed) {
|
for (const cn of failed) {
|
||||||
initial.set(cn, { classname: cn, status: "pending" });
|
initial.set(cn, { classname: cn, status: "pending" });
|
||||||
}
|
}
|
||||||
setBatchProgress(new Map(initial));
|
setBatchProgress(initial);
|
||||||
|
|
||||||
const abort = new AbortController();
|
const abort = new AbortController();
|
||||||
cloneAllAbortRef.current = abort;
|
cloneAllAbortRef.current = abort;
|
||||||
let done = 0;
|
|
||||||
let okCount = 0;
|
let okCount = 0;
|
||||||
let failCount = 0;
|
let failCount = 0;
|
||||||
|
|
||||||
@@ -862,6 +863,7 @@ export function StudioClient({
|
|||||||
|
|
||||||
if (!res.ok || !res.body) {
|
if (!res.ok || !res.body) {
|
||||||
toast.error(`Retry mislukt (${res.status})`);
|
toast.error(`Retry mislukt (${res.status})`);
|
||||||
|
setBatchProgress(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -881,7 +883,6 @@ export function StudioClient({
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
if (evt.status === "done" || evt.status === "failed") {
|
if (evt.status === "done" || evt.status === "failed") {
|
||||||
done++;
|
|
||||||
if (evt.status === "failed") {
|
if (evt.status === "failed") {
|
||||||
setFailedClassnames((prev) =>
|
setFailedClassnames((prev) =>
|
||||||
prev.includes(cn) ? prev : [...prev, cn],
|
prev.includes(cn) ? prev : [...prev, cn],
|
||||||
@@ -899,7 +900,7 @@ export function StudioClient({
|
|||||||
|
|
||||||
setBatchSucceeded(okCount);
|
setBatchSucceeded(okCount);
|
||||||
setBatchFailed(failCount);
|
setBatchFailed(failCount);
|
||||||
setBatchDone(true);
|
markBatchDone();
|
||||||
|
|
||||||
if (okCount > 0) {
|
if (okCount > 0) {
|
||||||
toast.success(`${okCount} items opnieuw geïmporteerd`);
|
toast.success(`${okCount} items opnieuw geïmporteerd`);
|
||||||
@@ -910,6 +911,7 @@ export function StudioClient({
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
if ((err as Error)?.name !== "AbortError") {
|
if ((err as Error)?.name !== "AbortError") {
|
||||||
toast.error("Verbindingsfout tijdens retry");
|
toast.error("Verbindingsfout tijdens retry");
|
||||||
|
setBatchProgress(null);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
cloneAllAbortRef.current = null;
|
cloneAllAbortRef.current = null;
|
||||||
@@ -926,6 +928,11 @@ export function StudioClient({
|
|||||||
errors?: string[];
|
errors?: string[];
|
||||||
};
|
};
|
||||||
setItems((prev) => prev.filter((i) => i.classname !== deleting));
|
setItems((prev) => prev.filter((i) => i.classname !== deleting));
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(deleting);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
if (detail?.classname === deleting) setDetail(null);
|
if (detail?.classname === deleting) setDetail(null);
|
||||||
fetchStats();
|
fetchStats();
|
||||||
toast.success(
|
toast.success(
|
||||||
@@ -976,6 +983,104 @@ export function StudioClient({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [bulkDeleting, setBulkDeleting] = useState(false);
|
||||||
|
const [bulkRegenning, setBulkRegenning] = useState(false);
|
||||||
|
|
||||||
|
async function bulkDeleteSelected() {
|
||||||
|
if (selectedImported.length === 0) return;
|
||||||
|
const confirmed = await confirm({
|
||||||
|
title: `${selectedImported.length} items verwijderen?`,
|
||||||
|
description: `Alle ${selectedImported.length} geselecteerde geïmporteerde items worden permanent verwijderd uit de database en het bestandssysteem.`,
|
||||||
|
confirmLabel: "Verwijderen",
|
||||||
|
variant: "danger",
|
||||||
|
});
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
setBulkDeleting(true);
|
||||||
|
let deleted = 0;
|
||||||
|
let failed = 0;
|
||||||
|
try {
|
||||||
|
for (const item of selectedImported) {
|
||||||
|
try {
|
||||||
|
const result = await deleteImportedFurni({
|
||||||
|
classname: item.classname,
|
||||||
|
});
|
||||||
|
if (result.ok) {
|
||||||
|
deleted++;
|
||||||
|
} else {
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setItems((prev) => prev.filter((i) => !selected.has(i.classname)));
|
||||||
|
setSelected(new Set());
|
||||||
|
fetchStats();
|
||||||
|
toast.success(
|
||||||
|
`Verwijderd: ${deleted}${failed > 0 ? `, mislukt: ${failed}` : ""}`,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
toast.error("Bulk delete mislukt");
|
||||||
|
} finally {
|
||||||
|
setBulkDeleting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bulkRegenNitro() {
|
||||||
|
if (selectedMissingNitro.length === 0) return;
|
||||||
|
setBulkRegenning(true);
|
||||||
|
const cns = selectedMissingNitro.map((i) => i.classname);
|
||||||
|
setRegeneratingNitro((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
for (const cn of cns) next.add(cn);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const res = await adminFetch("/api/admin/import/furni", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
items: selectedMissingNitro.map((i) => ({
|
||||||
|
classname: i.classname,
|
||||||
|
revision: i.revision,
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
const succeededClassnames = new Set<string>(
|
||||||
|
(data.results ?? [])
|
||||||
|
.filter((r: { ok: boolean }) => r.ok)
|
||||||
|
.map((r: { classname: string }) => r.classname),
|
||||||
|
);
|
||||||
|
setItems((prev) =>
|
||||||
|
prev.map((i) =>
|
||||||
|
succeededClassnames.has(i.classname)
|
||||||
|
? { ...i, nitroExists: true }
|
||||||
|
: i,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const succeeded = Number(data.succeeded ?? 0);
|
||||||
|
const failed = Number(data.failed ?? 0);
|
||||||
|
toast.success(
|
||||||
|
`Nitro geregenereerd: ${succeeded}${failed > 0 ? `, mislukt: ${failed}` : ""}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toast.error(data.error || "Regeneratie mislukt");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("Network error");
|
||||||
|
} finally {
|
||||||
|
setRegeneratingNitro((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
for (const cn of cns) next.delete(cn);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setBulkRegenning(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const detailPreview = useMemo<AutoCatalogPreview | null>(() => {
|
const detailPreview = useMemo<AutoCatalogPreview | null>(() => {
|
||||||
if (!detail) return null;
|
if (!detail) return null;
|
||||||
return previewAutoCatalog(
|
return previewAutoCatalog(
|
||||||
@@ -998,6 +1103,19 @@ export function StudioClient({
|
|||||||
[filteredItems],
|
[filteredItems],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const selectedImported = useMemo(
|
||||||
|
() =>
|
||||||
|
filteredItems.filter(
|
||||||
|
(i) => selected.has(i.classname) && i.alreadyImported,
|
||||||
|
),
|
||||||
|
[filteredItems, selected],
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedMissingNitro = useMemo(
|
||||||
|
() => selectedImported.filter((i) => !i.nitroExists),
|
||||||
|
[selectedImported],
|
||||||
|
);
|
||||||
|
|
||||||
const notImportedCount = useMemo(
|
const notImportedCount = useMemo(
|
||||||
() =>
|
() =>
|
||||||
activeSource && cloneStats
|
activeSource && cloneStats
|
||||||
@@ -1404,6 +1522,40 @@ export function StudioClient({
|
|||||||
Import {selectedNonImported} auto
|
Import {selectedNonImported} auto
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{selectedMissingNitro.length > 0 && !batchProgress && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="gap-1.5"
|
||||||
|
disabled={bulkRegenning}
|
||||||
|
onClick={bulkRegenNitro}
|
||||||
|
>
|
||||||
|
{bulkRegenning ? (
|
||||||
|
<Loader2 size={14} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw size={14} />
|
||||||
|
)}
|
||||||
|
Nitro regeneren ({selectedMissingNitro.length})
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedImported.length > 0 && !batchProgress && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
className="gap-1.5"
|
||||||
|
disabled={bulkDeleting}
|
||||||
|
onClick={bulkDeleteSelected}
|
||||||
|
>
|
||||||
|
{bulkDeleting ? (
|
||||||
|
<Loader2 size={14} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trash2 size={14} />
|
||||||
|
)}
|
||||||
|
Verwijder ({selectedImported.length})
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Body: rail + grid + drawer ────────────────────── */}
|
{/* ── Body: rail + grid + drawer ────────────────────── */}
|
||||||
|
|||||||
Reference in new issue
Block a user