feat(admin): server-safe StatusCard and Import hub polish
Split OnlineUsersWidget from StatusCard, decouple ad delete button, sync badge import to ExternalTexts+WebsiteBadges, add Import section hub with cancelable SSE jobs and upload SQL option. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
1 parent
db957d7fb1
commit
9c4949186c
21 files changed
+576
-249
No files matched your search
@@ -190,6 +190,8 @@ export function AuditClient() {
|
||||
} catch (err) {
|
||||
if ((err as Error).name !== "AbortError") {
|
||||
toast.error("Audit request failed");
|
||||
} else {
|
||||
toast.info("Audit cancelled");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -203,14 +205,25 @@ export function AuditClient() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{!summary && (
|
||||
<Button onClick={runAudit} disabled={loading} size="lg">
|
||||
{loading ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<ClipboardList className="w-4 h-4 mr-2" />
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Button onClick={runAudit} disabled={loading} size="lg">
|
||||
{loading ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<ClipboardList className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{loading ? progress || "Running…" : "Run Full Catalog Audit"}
|
||||
</Button>
|
||||
{loading && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
onClick={() => abortRef.current?.abort()}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
{loading ? progress || "Running…" : "Run Full Catalog Audit"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && checkProgress && (
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { importBadgeFromRemote } from "@/actions/import-badges";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -25,6 +24,7 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { adminFetch } from "@/lib/admin-fetch";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { BadgeEditorDialog } from "./badge-editor-dialog";
|
||||
|
||||
@@ -119,14 +119,32 @@ export function ImportBadgesClient() {
|
||||
fetchBadges(activeSearch, newOffset, allHotels);
|
||||
}
|
||||
|
||||
async function doImport(badge: BadgeItem) {
|
||||
setImportingCode(badge.code);
|
||||
try {
|
||||
const result = await importBadgeFromRemote({
|
||||
async function postImportBadge(badge: BadgeItem) {
|
||||
const res = await adminFetch("/api/admin/import/badges", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
code: badge.code,
|
||||
name: badge.name,
|
||||
description: badge.description,
|
||||
});
|
||||
}),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
return {
|
||||
ok: false as const,
|
||||
error:
|
||||
(data as { error?: string }).error ||
|
||||
`Failed to import ${badge.code}`,
|
||||
};
|
||||
}
|
||||
return { ok: true as const };
|
||||
}
|
||||
|
||||
async function doImport(badge: BadgeItem) {
|
||||
setImportingCode(badge.code);
|
||||
try {
|
||||
const result = await postImportBadge(badge);
|
||||
if (result.ok) {
|
||||
toast.success(`${badge.code} imported`);
|
||||
setBadges((prev) =>
|
||||
@@ -140,7 +158,7 @@ export function ImportBadgesClient() {
|
||||
return n;
|
||||
});
|
||||
} else {
|
||||
toast.error(result.error || `Failed to import ${badge.code}`);
|
||||
toast.error(result.error);
|
||||
}
|
||||
} catch {
|
||||
toast.error(`Network error importing ${badge.code}`);
|
||||
@@ -159,11 +177,7 @@ export function ImportBadgesClient() {
|
||||
fail = 0;
|
||||
for (const badge of toImport) {
|
||||
try {
|
||||
const result = await importBadgeFromRemote({
|
||||
code: badge.code,
|
||||
name: badge.name,
|
||||
description: badge.description,
|
||||
});
|
||||
const result = await postImportBadge(badge);
|
||||
if (result.ok) {
|
||||
ok++;
|
||||
setBadges((prev) =>
|
||||
|
||||
@@ -209,6 +209,7 @@ export function ImportFurniClient() {
|
||||
// Reorganize catalog
|
||||
const [reorganizing, setReorganizing] = useState(false);
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
const batchAbortRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Confirmation dialogs
|
||||
const [confirmBatch, setConfirmBatch] = useState(false);
|
||||
@@ -484,6 +485,9 @@ export function ImportFurniClient() {
|
||||
}
|
||||
setBatchProgress(new Map(initialProgress));
|
||||
|
||||
const abort = new AbortController();
|
||||
batchAbortRef.current = abort;
|
||||
|
||||
try {
|
||||
const res = await adminFetch("/api/admin/import/furni/batch", {
|
||||
method: "POST",
|
||||
@@ -500,6 +504,7 @@ export function ImportFurniClient() {
|
||||
})),
|
||||
concurrency: 3,
|
||||
}),
|
||||
signal: abort.signal,
|
||||
});
|
||||
|
||||
if (!res.ok || !res.body) {
|
||||
@@ -561,10 +566,17 @@ export function ImportFurniClient() {
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[Import] Batch import error:", err);
|
||||
toast.error("Connection lost during batch import. Check results above.");
|
||||
if ((err as Error).name === "AbortError") {
|
||||
toast.info("Batch import cancelled");
|
||||
} else {
|
||||
console.error("[Import] Batch import error:", err);
|
||||
toast.error(
|
||||
"Connection lost during batch import. Check results above.",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setBatchImporting(false);
|
||||
batchAbortRef.current = null;
|
||||
setSelected(new Set());
|
||||
fetchStats();
|
||||
}
|
||||
@@ -1105,6 +1117,14 @@ export function ImportFurniClient() {
|
||||
{progressStats.failed} failed
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 ml-auto"
|
||||
onClick={() => batchAbortRef.current?.abort()}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
<div className="max-h-[250px] overflow-y-auto space-y-0.5">
|
||||
{Array.from(batchProgress.values()).map((item) => (
|
||||
@@ -1425,6 +1445,16 @@ export function ImportFurniClient() {
|
||||
}
|
||||
</Button>
|
||||
)}
|
||||
{batchImporting && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => batchAbortRef.current?.abort()}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
{hasImported && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
|
||||
@@ -9,11 +9,15 @@ import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { adminFetch } from "@/lib/admin-fetch";
|
||||
|
||||
const CLASSNAME_RE = /^[a-zA-Z0-9_]+$/;
|
||||
const NITRO_MAX_BYTES = 50 * 1024 * 1024;
|
||||
|
||||
interface UploadResult {
|
||||
itemId: number;
|
||||
catalogItemId: number | null;
|
||||
classname: string;
|
||||
warnings: string[];
|
||||
sqlFile?: string | null;
|
||||
}
|
||||
|
||||
export function UploadFurniClient() {
|
||||
@@ -31,6 +35,7 @@ export function UploadFurniClient() {
|
||||
const [canlayon, setCanlayon] = useState(false);
|
||||
const [interactionType, setInteractionType] = useState("default");
|
||||
const [customparams, setCustomparams] = useState("");
|
||||
const [generateSql, setGenerateSql] = useState(false);
|
||||
|
||||
const [nitroFile, setNitroFile] = useState<File | null>(null);
|
||||
const [iconFile, setIconFile] = useState<File | null>(null);
|
||||
@@ -51,6 +56,7 @@ export function UploadFurniClient() {
|
||||
setCanlayon(false);
|
||||
setInteractionType("default");
|
||||
setCustomparams("");
|
||||
setGenerateSql(false);
|
||||
setNitroFile(null);
|
||||
setIconFile(null);
|
||||
setResult(null);
|
||||
@@ -61,13 +67,35 @@ export function UploadFurniClient() {
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setUploading(true);
|
||||
setError("");
|
||||
setResult(null);
|
||||
|
||||
const trimmedClass = classname.trim();
|
||||
if (!CLASSNAME_RE.test(trimmedClass)) {
|
||||
const msg =
|
||||
"Classname must contain only letters, numbers, and underscores";
|
||||
setError(msg);
|
||||
toast.error(msg);
|
||||
return;
|
||||
}
|
||||
if (!nitroFile) {
|
||||
const msg = "Nitro file is required";
|
||||
setError(msg);
|
||||
toast.error(msg);
|
||||
return;
|
||||
}
|
||||
if (nitroFile.size > NITRO_MAX_BYTES) {
|
||||
const msg = "Nitro file must be 50MB or smaller";
|
||||
setError(msg);
|
||||
toast.error(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.set("classname", classname.trim());
|
||||
fd.set("classname", trimmedClass);
|
||||
fd.set("name", name.trim());
|
||||
fd.set("description", description.trim());
|
||||
fd.set("itemType", itemType);
|
||||
@@ -78,7 +106,8 @@ export function UploadFurniClient() {
|
||||
fd.set("canlayon", String(canlayon));
|
||||
fd.set("interactionType", interactionType.trim());
|
||||
fd.set("customparams", customparams.trim());
|
||||
if (nitroFile) fd.set("nitro", nitroFile);
|
||||
if (generateSql) fd.set("generateSql", "true");
|
||||
fd.set("nitro", nitroFile);
|
||||
if (iconFile) fd.set("icon", iconFile);
|
||||
|
||||
const res = await adminFetch("/api/admin/import/furni/upload", {
|
||||
@@ -219,6 +248,15 @@ export function UploadFurniClient() {
|
||||
/>
|
||||
<span className="text-sm">Can lay on</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={generateSql}
|
||||
onChange={(e) => setGenerateSql(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-input"
|
||||
/>
|
||||
<span className="text-sm">Generate SQL</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
@@ -304,6 +342,12 @@ export function UploadFurniClient() {
|
||||
<code className="font-mono">{result.catalogItemId}</code>
|
||||
</div>
|
||||
)}
|
||||
{result.sqlFile ? (
|
||||
<div className="flex gap-2">
|
||||
<span className="text-muted-foreground w-32">SQL file:</span>
|
||||
<code className="font-mono break-all">{result.sqlFile}</code>
|
||||
</div>
|
||||
) : null}
|
||||
{result.warnings.length > 0 && (
|
||||
<div className="space-y-1 pt-2">
|
||||
<span className="text-muted-foreground font-medium">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Check, ImageIcon, Loader2, Wrench } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { adminFetch } from "@/lib/admin-fetch";
|
||||
@@ -31,6 +31,7 @@ export function RepairIconsClient() {
|
||||
failed: number;
|
||||
skipped: number;
|
||||
} | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const startRepair = useCallback(async () => {
|
||||
setRunning(true);
|
||||
@@ -38,9 +39,13 @@ export function RepairIconsClient() {
|
||||
setLog([]);
|
||||
setResult(null);
|
||||
|
||||
const abort = new AbortController();
|
||||
abortRef.current = abort;
|
||||
|
||||
try {
|
||||
const res = await adminFetch("/api/admin/import/furni/repair-icons", {
|
||||
method: "POST",
|
||||
signal: abort.signal,
|
||||
});
|
||||
|
||||
if (!res.body) {
|
||||
@@ -131,26 +136,42 @@ export function RepairIconsClient() {
|
||||
setResult({ succeeded, failed, skipped });
|
||||
if (succeeded > 0) toast.success(`${succeeded} icons downloaded`);
|
||||
if (failed > 0) toast.error(`${failed} icons could not be obtained`);
|
||||
} catch {
|
||||
toast.error("Repair request failed");
|
||||
} catch (err) {
|
||||
if ((err as Error).name === "AbortError") {
|
||||
toast.info("Repair cancelled");
|
||||
} else {
|
||||
toast.error("Repair request failed");
|
||||
}
|
||||
} finally {
|
||||
setRunning(false);
|
||||
abortRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{!result && (
|
||||
<Button onClick={startRepair} disabled={running} size="lg">
|
||||
{running ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<Wrench className="w-4 h-4 mr-2" />
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Button onClick={startRepair} disabled={running} size="lg">
|
||||
{running ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<Wrench className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{running
|
||||
? `Repairing… ${progress ? `${progress.done}/${progress.total}` : ""}`
|
||||
: "Start Icon Repair"}
|
||||
</Button>
|
||||
{running && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
onClick={() => abortRef.current?.abort()}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
{running
|
||||
? `Repairing… ${progress ? `${progress.done}/${progress.total}` : ""}`
|
||||
: "Start Icon Repair"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{progress && (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Check, CloudDownload, Loader2 } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { adminFetch } from "@/lib/admin-fetch";
|
||||
@@ -26,6 +26,7 @@ export function SyncAllClient() {
|
||||
failed: number;
|
||||
sources: number;
|
||||
} | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const startSync = useCallback(async () => {
|
||||
setSyncing(true);
|
||||
@@ -33,11 +34,15 @@ export function SyncAllClient() {
|
||||
setLog([]);
|
||||
setResult(null);
|
||||
|
||||
const abort = new AbortController();
|
||||
abortRef.current = abort;
|
||||
|
||||
try {
|
||||
const res = await adminFetch("/api/admin/import/clone/sync-all", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
signal: abort.signal,
|
||||
});
|
||||
|
||||
if (!res.body) {
|
||||
@@ -117,26 +122,42 @@ export function SyncAllClient() {
|
||||
setResult({ succeeded, failed, sources: 1 });
|
||||
if (succeeded > 0) toast.success(`${succeeded} items synced`);
|
||||
if (failed > 0) toast.error(`${failed} items failed`);
|
||||
} catch {
|
||||
toast.error("Sync request failed");
|
||||
} catch (err) {
|
||||
if ((err as Error).name === "AbortError") {
|
||||
toast.info("Sync cancelled");
|
||||
} else {
|
||||
toast.error("Sync request failed");
|
||||
}
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
abortRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{!result && (
|
||||
<Button onClick={startSync} disabled={syncing} size="lg">
|
||||
{syncing ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<CloudDownload className="w-4 h-4 mr-2" />
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Button onClick={startSync} disabled={syncing} size="lg">
|
||||
{syncing ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<CloudDownload className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{syncing
|
||||
? `Syncing… ${progress ? `${progress.done}/${progress.total}` : ""}`
|
||||
: "Start Sync All Sources"}
|
||||
</Button>
|
||||
{syncing && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
onClick={() => abortRef.current?.abort()}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
{syncing
|
||||
? `Syncing… ${progress ? `${progress.done}/${progress.total}` : ""}`
|
||||
: "Start Sync All Sources"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{progress && (
|
||||
|
||||
Reference in new issue
Block a user