Studio: add clone-all-per-hotel, per-item status detail, retry, ETA, and auto-translate
- Add 'Clone all missing' button for clone sources with confirmation dialog - Show per-item status badges: nitro, furnidata, catalog entry - Add status filters: missing furnidata, missing catalog entry - Add ETA and percentage to batch progress bar - Add retry button for failed items after batch import - Auto-translate all languages after clone-all completes - Fix statusFilter 'all' bug that incorrectly filtered imported items - Bulk-load FurnitureData + catalog references for O(1) per-item checks
This commit is contained in:
1 parent
1610aa1008
commit
1374bd332f
4 files changed
+580
-47
No files matched your search
@@ -30,6 +30,7 @@ import {
|
||||
setFurnidataTranslateEnabled,
|
||||
} from "@/actions/import-furni";
|
||||
import { NitroEditorDialog } from "@/app/admin/import/furni/nitro-editor-dialog";
|
||||
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
@@ -60,6 +61,7 @@ import {
|
||||
previewAutoCatalog,
|
||||
} from "@/lib/furni/auto-catalog";
|
||||
import type { FurniImportSource } from "@/lib/habbo-gamedata-hotel";
|
||||
import { readSseStream } from "@/lib/sse-client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { TreeNode } from "@/types/catalog";
|
||||
import { BatchProgress } from "./batch-progress";
|
||||
@@ -164,6 +166,20 @@ export function StudioClient({
|
||||
const [deleting, setDeleting] = useState<string | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
|
||||
// Confirm dialog for clone all
|
||||
const { confirm, dialog: confirmDialog } = useConfirmDialog();
|
||||
|
||||
// Clone source stats (shown when a clone source is active)
|
||||
const [cloneStats, setCloneStats] = useState<{
|
||||
total: number;
|
||||
present: number;
|
||||
clonable: number;
|
||||
} | null>(null);
|
||||
const [cloneAllCancelling, setCloneAllCancelling] = useState(false);
|
||||
const cloneAllAbortRef = useRef<AbortController | null>(null);
|
||||
const batchStartTimeRef = useRef<number>(0);
|
||||
const [failedClassnames, setFailedClassnames] = useState<string[]>([]);
|
||||
|
||||
// Nitro regeneration (missing .nitro)
|
||||
const [regeneratingNitro, setRegeneratingNitro] = useState<Set<string>>(
|
||||
new Set(),
|
||||
@@ -237,17 +253,22 @@ export function StudioClient({
|
||||
if (categoryFilter !== "all") {
|
||||
result = result.filter((i) => i.category === categoryFilter);
|
||||
}
|
||||
if (statusFilter !== "all") {
|
||||
if (statusFilter === "imported")
|
||||
result = result.filter((i) => i.alreadyImported);
|
||||
else if (statusFilter === "inCatalog")
|
||||
result = result.filter((i) => i.alreadyImported && i.nitroExists);
|
||||
else if (statusFilter === "missingNitro")
|
||||
result = result.filter((i) => i.alreadyImported && !i.nitroExists);
|
||||
}
|
||||
// Not imported filter - works independently (also with statusFilter === "all")
|
||||
if (statusFilter === "notImported" || statusFilter === "all")
|
||||
result = result.filter((i) => i.alreadyImported === false);
|
||||
if (statusFilter === "imported")
|
||||
result = result.filter((i) => i.alreadyImported);
|
||||
else if (statusFilter === "inCatalog")
|
||||
result = result.filter((i) => i.alreadyImported && i.nitroExists);
|
||||
else if (statusFilter === "missingNitro")
|
||||
result = result.filter((i) => i.alreadyImported && !i.nitroExists);
|
||||
else if (statusFilter === "missingFurnidata")
|
||||
result = result.filter(
|
||||
(i) => i.alreadyImported && i.hasFurnidata === false,
|
||||
);
|
||||
else if (statusFilter === "missingCatalog")
|
||||
result = result.filter(
|
||||
(i) => i.alreadyImported && i.hasCatalogEntry === false,
|
||||
);
|
||||
else if (statusFilter === "notImported")
|
||||
result = result.filter((i) => !i.alreadyImported);
|
||||
const sorted = [...result];
|
||||
if (sortBy === "name") sorted.sort((a, b) => a.name.localeCompare(b.name));
|
||||
else if (sortBy === "classname")
|
||||
@@ -325,6 +346,9 @@ export function StudioClient({
|
||||
setItems([]);
|
||||
setActiveSearch("");
|
||||
setSearchTerm("");
|
||||
setCloneStats(null);
|
||||
setBatchProgress(null);
|
||||
fetchCloneStats(sourceId);
|
||||
};
|
||||
|
||||
const fetchSources = useCallback(async () => {
|
||||
@@ -339,6 +363,183 @@ export function StudioClient({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchCloneStats = useCallback(async (sourceId: string) => {
|
||||
if (!sourceId) {
|
||||
setCloneStats(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await adminFetch(
|
||||
`/api/admin/import/clone?source=${encodeURIComponent(sourceId)}`,
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setCloneStats(data.stats ?? null);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, []);
|
||||
|
||||
async function cloneAllMissing() {
|
||||
if (!activeSource) return;
|
||||
|
||||
// Fetch all clonable classnames
|
||||
let classnames: string[] = [];
|
||||
try {
|
||||
const res = await adminFetch(
|
||||
`/api/admin/import/clone?source=${encodeURIComponent(activeSource)}&action=clonable`,
|
||||
);
|
||||
const data = (await res.json()) as {
|
||||
classnames?: string[];
|
||||
error?: string;
|
||||
};
|
||||
if (!res.ok) {
|
||||
toast.error(data.error || "Failed to fetch clonable items");
|
||||
return;
|
||||
}
|
||||
classnames = data.classnames || [];
|
||||
} catch {
|
||||
toast.error("Network error fetching clonable items");
|
||||
return;
|
||||
}
|
||||
|
||||
if (classnames.length === 0) {
|
||||
toast.info("Alles is al geïmporteerd voor deze bron");
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceName =
|
||||
sources.find((s) => s.id === activeSource)?.name || activeSource;
|
||||
|
||||
const confirmed = await confirm({
|
||||
title: "Alles importeren",
|
||||
description: `${classnames.length} items importeren uit "${sourceName}"? Dit kan even duren.`,
|
||||
confirmLabel: "Alles importeren",
|
||||
});
|
||||
if (!confirmed) return;
|
||||
|
||||
// Initialize progress tracking as a Map for BatchProgress compatibility
|
||||
const initial = new Map<string, BatchItemStatus>();
|
||||
for (const cn of classnames) {
|
||||
initial.set(cn, { classname: cn, status: "pending" });
|
||||
}
|
||||
setBatchProgress(new Map(initial));
|
||||
setBatchDone(false);
|
||||
setBatchSucceeded(0);
|
||||
setBatchFailed(0);
|
||||
setFailedClassnames([]);
|
||||
setCloneAllCancelling(false);
|
||||
batchStartTimeRef.current = Date.now();
|
||||
|
||||
const abort = new AbortController();
|
||||
cloneAllAbortRef.current = abort;
|
||||
|
||||
const CHUNK = 400;
|
||||
let done = 0;
|
||||
let okCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
try {
|
||||
for (let i = 0; i < classnames.length; i += CHUNK) {
|
||||
if (abort.signal.aborted) break;
|
||||
const chunk = classnames.slice(i, i + CHUNK);
|
||||
|
||||
const res = await adminFetch("/api/admin/import/clone/batch", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
sourceId: activeSource,
|
||||
classnames: chunk,
|
||||
concurrency: 10,
|
||||
}),
|
||||
signal: abort.signal,
|
||||
});
|
||||
|
||||
if (!res.ok || !res.body) {
|
||||
toast.error(`Batch chunk mislukt (${res.status})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
await readSseStream(
|
||||
res.body,
|
||||
(evt) => {
|
||||
if (evt.type === "item_progress") {
|
||||
const cn = String(evt.classname ?? "");
|
||||
setBatchProgress((prev) => {
|
||||
if (!prev) return prev;
|
||||
const next = new Map(prev);
|
||||
next.set(cn, {
|
||||
classname: cn,
|
||||
status: evt.status as BatchItemStatus["status"],
|
||||
message: evt.message as string | undefined,
|
||||
});
|
||||
return next;
|
||||
});
|
||||
if (evt.status === "done" || evt.status === "failed") {
|
||||
done++;
|
||||
if (evt.status === "failed") {
|
||||
setFailedClassnames((prev) =>
|
||||
prev.includes(cn) ? prev : [...prev, cn],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (evt.type === "batch_complete") {
|
||||
okCount += Number(evt.succeeded ?? 0);
|
||||
failCount += Number(evt.failed ?? 0);
|
||||
}
|
||||
if (evt.type === "error") {
|
||||
toast.error(
|
||||
typeof evt.message === "string" ? evt.message : "Import fout",
|
||||
);
|
||||
}
|
||||
},
|
||||
abort.signal,
|
||||
);
|
||||
}
|
||||
|
||||
if (!abort.signal.aborted) {
|
||||
setBatchSucceeded(okCount);
|
||||
setBatchFailed(failCount);
|
||||
setBatchDone(true);
|
||||
|
||||
if (okCount > 0) {
|
||||
toast.success(
|
||||
`${okCount} items geïmporteerd${
|
||||
failCount > 0 ? ` (${failCount} mislukt)` : ""
|
||||
}`,
|
||||
);
|
||||
}
|
||||
if (failCount > 0 && okCount === 0) {
|
||||
toast.error(`${failCount} items mislukt`);
|
||||
}
|
||||
|
||||
fetchItems(activeSearch, 1, activeSource);
|
||||
fetchStats();
|
||||
fetchCloneStats(activeSource);
|
||||
|
||||
// Auto-translate after clone all if translation is enabled
|
||||
if (translate && okCount > 0) {
|
||||
toast.info("Automatisch vertalen van alle talen...");
|
||||
buildAllLanguages();
|
||||
}
|
||||
} else {
|
||||
toast.info("Clone all geannuleerd");
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as Error)?.name === "AbortError") {
|
||||
toast.info("Clone all geannuleerd");
|
||||
} else {
|
||||
toast.error("Verbindingsfout tijdens clone all");
|
||||
}
|
||||
} finally {
|
||||
cloneAllAbortRef.current = null;
|
||||
setCloneAllCancelling(false);
|
||||
setSelected(new Set());
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchItems("", 1, activeSource);
|
||||
fetchStats();
|
||||
@@ -492,6 +693,8 @@ export function StudioClient({
|
||||
setBatchSucceeded(0);
|
||||
setBatchFailed(0);
|
||||
setBatchCancelling(false);
|
||||
setFailedClassnames([]);
|
||||
batchStartTimeRef.current = Date.now();
|
||||
|
||||
const initial = new Map<string, BatchItemStatus>();
|
||||
for (const i of toImport) {
|
||||
@@ -565,6 +768,12 @@ export function StudioClient({
|
||||
: i,
|
||||
),
|
||||
);
|
||||
} else if (event.status === "failed") {
|
||||
setFailedClassnames((prev) =>
|
||||
prev.includes(event.classname)
|
||||
? prev
|
||||
: [...prev, event.classname],
|
||||
);
|
||||
}
|
||||
} else if (event.type === "batch_complete") {
|
||||
setBatchSucceeded(event.succeeded);
|
||||
@@ -607,6 +816,103 @@ export function StudioClient({
|
||||
}
|
||||
};
|
||||
|
||||
const cancelCloneAll = () => {
|
||||
if (cloneAllAbortRef.current) {
|
||||
setCloneAllCancelling(true);
|
||||
cloneAllAbortRef.current.abort();
|
||||
}
|
||||
};
|
||||
|
||||
async function retryFailed(failed: string[]) {
|
||||
if (!activeSource || failed.length === 0) return;
|
||||
// For clone sources, re-run cloneAllMissing with only the failed classnames
|
||||
setBatchProgress(new Map());
|
||||
setBatchDone(false);
|
||||
setBatchSucceeded(0);
|
||||
setBatchFailed(0);
|
||||
setFailedClassnames([]);
|
||||
batchStartTimeRef.current = Date.now();
|
||||
|
||||
const initial = new Map<string, BatchItemStatus>();
|
||||
for (const cn of failed) {
|
||||
initial.set(cn, { classname: cn, status: "pending" });
|
||||
}
|
||||
setBatchProgress(new Map(initial));
|
||||
|
||||
const abort = new AbortController();
|
||||
cloneAllAbortRef.current = abort;
|
||||
let done = 0;
|
||||
let okCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
try {
|
||||
const res = await adminFetch("/api/admin/import/clone/batch", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
sourceId: activeSource,
|
||||
classnames: failed,
|
||||
concurrency: 10,
|
||||
}),
|
||||
signal: abort.signal,
|
||||
});
|
||||
|
||||
if (!res.ok || !res.body) {
|
||||
toast.error(`Retry mislukt (${res.status})`);
|
||||
return;
|
||||
}
|
||||
|
||||
await readSseStream(
|
||||
res.body,
|
||||
(evt) => {
|
||||
if (evt.type === "item_progress") {
|
||||
const cn = String(evt.classname ?? "");
|
||||
setBatchProgress((prev) => {
|
||||
if (!prev) return prev;
|
||||
const next = new Map(prev);
|
||||
next.set(cn, {
|
||||
classname: cn,
|
||||
status: evt.status as BatchItemStatus["status"],
|
||||
message: evt.message as string | undefined,
|
||||
});
|
||||
return next;
|
||||
});
|
||||
if (evt.status === "done" || evt.status === "failed") {
|
||||
done++;
|
||||
if (evt.status === "failed") {
|
||||
setFailedClassnames((prev) =>
|
||||
prev.includes(cn) ? prev : [...prev, cn],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (evt.type === "batch_complete") {
|
||||
okCount += Number(evt.succeeded ?? 0);
|
||||
failCount += Number(evt.failed ?? 0);
|
||||
}
|
||||
},
|
||||
abort.signal,
|
||||
);
|
||||
|
||||
setBatchSucceeded(okCount);
|
||||
setBatchFailed(failCount);
|
||||
setBatchDone(true);
|
||||
|
||||
if (okCount > 0) {
|
||||
toast.success(`${okCount} items opnieuw geïmporteerd`);
|
||||
}
|
||||
fetchItems(activeSearch, 1, activeSource);
|
||||
fetchStats();
|
||||
fetchCloneStats(activeSource);
|
||||
} catch (err) {
|
||||
if ((err as Error)?.name !== "AbortError") {
|
||||
toast.error("Verbindingsfout tijdens retry");
|
||||
}
|
||||
} finally {
|
||||
cloneAllAbortRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteItem() {
|
||||
if (!deleting) return;
|
||||
try {
|
||||
@@ -690,8 +996,11 @@ export function StudioClient({
|
||||
);
|
||||
|
||||
const notImportedCount = useMemo(
|
||||
() => filteredItems.filter((i) => i.alreadyImported === false).length,
|
||||
[filteredItems],
|
||||
() =>
|
||||
activeSource && cloneStats
|
||||
? cloneStats.clonable
|
||||
: filteredItems.filter((i) => i.alreadyImported === false).length,
|
||||
[filteredItems, activeSource, cloneStats],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -748,7 +1057,7 @@ export function StudioClient({
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{stats && (
|
||||
{stats && !activeSource && (
|
||||
<>
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Package size={11} /> {stats.totalInDb} in DB
|
||||
@@ -767,6 +1076,26 @@ export function StudioClient({
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
{cloneStats && activeSource && (
|
||||
<>
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Package size={11} /> {cloneStats.total} total
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Check size={11} className="text-[var(--admin-success)]" />{" "}
|
||||
{cloneStats.present} aanwezig
|
||||
</Badge>
|
||||
{cloneStats.clonable > 0 && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="gap-1 text-[var(--admin-accent)] border-[var(--admin-accent)]/40"
|
||||
>
|
||||
<CloudDownload size={11} /> {cloneStats.clonable} te
|
||||
importeren
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 rounded-md border border-[var(--admin-border)] px-2.5 py-1">
|
||||
@@ -848,11 +1177,17 @@ export function StudioClient({
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
All statuses {notImportedCount > 0 && notImportedCount}
|
||||
All statuses{" "}
|
||||
{notImportedCount > 0 && `(${notImportedCount} te importeren)`}
|
||||
</SelectItem>
|
||||
<SelectItem value="notImported">Not imported</SelectItem>
|
||||
<SelectItem value="imported">Imported</SelectItem>
|
||||
<SelectItem value="inCatalog">In catalog</SelectItem>
|
||||
<SelectItem value="missingNitro">Missing nitro</SelectItem>
|
||||
<SelectItem value="missingFurnidata">Missing furnidata</SelectItem>
|
||||
<SelectItem value="missingCatalog">
|
||||
Missing catalog entry
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -1044,6 +1379,13 @@ export function StudioClient({
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{cloneStats && cloneStats.clonable > 0 && !batchProgress && (
|
||||
<Button size="sm" className="gap-1.5" onClick={cloneAllMissing}>
|
||||
<CloudDownload size={14} />
|
||||
Alles importeren ({cloneStats.clonable})
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{selectedNonImported > 0 && (
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -1162,17 +1504,42 @@ export function StudioClient({
|
||||
{TYPE_LABELS[item.type] ?? item.type}
|
||||
</Badge>
|
||||
{item.alreadyImported ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
"h-4",
|
||||
item.nitroExists
|
||||
? "text-[var(--admin-success)] border-[var(--admin-success)]/40"
|
||||
: "text-[var(--admin-warning)] border-[var(--admin-warning)]/40",
|
||||
<>
|
||||
{!item.nitroExists && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-4 text-[var(--admin-warning)] border-[var(--admin-warning)]/40"
|
||||
>
|
||||
No nitro
|
||||
</Badge>
|
||||
)}
|
||||
>
|
||||
{item.nitroExists ? "Imported" : "No nitro"}
|
||||
</Badge>
|
||||
{item.hasFurnidata === false && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-4 text-[var(--admin-warning)] border-[var(--admin-warning)]/40"
|
||||
>
|
||||
No furnidata
|
||||
</Badge>
|
||||
)}
|
||||
{item.hasCatalogEntry === false && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-4 text-[var(--admin-warning)] border-[var(--admin-warning)]/40"
|
||||
>
|
||||
No catalog
|
||||
</Badge>
|
||||
)}
|
||||
{item.nitroExists &&
|
||||
item.hasFurnidata !== false &&
|
||||
item.hasCatalogEntry !== false && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-4 text-[var(--admin-success)] border-[var(--admin-success)]/40"
|
||||
>
|
||||
Imported
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Badge className="h-4 text-[var(--admin-accent-foreground)]">
|
||||
New
|
||||
@@ -1286,17 +1653,42 @@ export function StudioClient({
|
||||
</td>
|
||||
<td className="px-2 py-1.5">
|
||||
{item.alreadyImported ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
"h-4",
|
||||
item.nitroExists
|
||||
? "text-[var(--admin-success)] border-[var(--admin-success)]/40"
|
||||
: "text-[var(--admin-warning)] border-[var(--admin-warning)]/40",
|
||||
<div className="flex flex-wrap gap-0.5">
|
||||
{!item.nitroExists && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-4 text-[0.6rem] text-[var(--admin-warning)] border-[var(--admin-warning)]/40"
|
||||
>
|
||||
No nitro
|
||||
</Badge>
|
||||
)}
|
||||
>
|
||||
{item.nitroExists ? "Imported" : "No nitro"}
|
||||
</Badge>
|
||||
{item.hasFurnidata === false && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-4 text-[0.6rem] text-[var(--admin-warning)] border-[var(--admin-warning)]/40"
|
||||
>
|
||||
No furnidata
|
||||
</Badge>
|
||||
)}
|
||||
{item.hasCatalogEntry === false && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-4 text-[0.6rem] text-[var(--admin-warning)] border-[var(--admin-warning)]/40"
|
||||
>
|
||||
No catalog
|
||||
</Badge>
|
||||
)}
|
||||
{item.nitroExists &&
|
||||
item.hasFurnidata !== false &&
|
||||
item.hasCatalogEntry !== false && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-4 text-[0.6rem] text-[var(--admin-success)] border-[var(--admin-success)]/40"
|
||||
>
|
||||
Imported
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Badge className="h-4 text-[var(--admin-accent-foreground)]">
|
||||
New
|
||||
@@ -1401,14 +1793,20 @@ export function StudioClient({
|
||||
done={batchDone}
|
||||
succeeded={batchSucceeded}
|
||||
failed={batchFailed}
|
||||
cancelling={batchCancelling}
|
||||
cancelling={batchCancelling || cloneAllCancelling}
|
||||
verification={verification}
|
||||
onCancel={cancelBatch}
|
||||
startTime={batchStartTimeRef.current || undefined}
|
||||
failedClassnames={
|
||||
failedClassnames.length > 0 ? failedClassnames : undefined
|
||||
}
|
||||
onCancel={cloneAllAbortRef.current ? cancelCloneAll : cancelBatch}
|
||||
onDismiss={() => {
|
||||
setBatchProgress(null);
|
||||
setBatchDone(false);
|
||||
setVerification(null);
|
||||
setFailedClassnames([]);
|
||||
}}
|
||||
onRetry={activeSource ? retryFailed : undefined}
|
||||
/>
|
||||
) : null}
|
||||
</main>
|
||||
@@ -1586,6 +1984,8 @@ export function StudioClient({
|
||||
onOpenChange={(open) => !open && setEditingClassname(null)}
|
||||
/>
|
||||
|
||||
{confirmDialog}
|
||||
|
||||
<Dialog open={confirmDelete} onOpenChange={setConfirmDelete}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
|
||||
Reference in new issue
Block a user