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
@@ -7,7 +7,7 @@ import { updateAd } from "@/actions/admin-ads";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { db, WebsiteAds } from "@/lib/db";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { EditAdDeleteButton } from "../ads-table";
|
||||
import { EditAdDeleteButton } from "../edit-ad-delete-button";
|
||||
|
||||
export default async function EditAd({
|
||||
params,
|
||||
|
||||
@@ -84,38 +84,3 @@ export function AdsTable({ ads }: { ads: AdRow[] }) {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function EditAdDeleteButton({ id }: { id: string }) {
|
||||
const t = useTranslations("pages.admin.ads");
|
||||
const { run, isPending } = useServerAction();
|
||||
const { confirm, dialog: confirmDialog } = useConfirmDialog();
|
||||
|
||||
async function handleDelete() {
|
||||
const ok = await confirm({
|
||||
title: t("delete"),
|
||||
description: t("confirmDelete"),
|
||||
confirmLabel: t("delete"),
|
||||
cancelLabel: t("form.cancel"),
|
||||
});
|
||||
if (!ok) return;
|
||||
run(() => deleteAd({ id }), {
|
||||
successMessage: t("deleteSuccess"),
|
||||
errorMessage: t("deleteError"),
|
||||
redirectTo: "/admin/ads",
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{confirmDialog}
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
disabled={isPending}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
{t("form.deleteAd")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { deleteAd } from "@/actions/admin-ads";
|
||||
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
|
||||
export function EditAdDeleteButton({ id }: { id: string }) {
|
||||
const t = useTranslations("pages.admin.ads");
|
||||
const { run, isPending } = useServerAction();
|
||||
const { confirm, dialog: confirmDialog } = useConfirmDialog();
|
||||
|
||||
async function handleDelete() {
|
||||
const ok = await confirm({
|
||||
title: t("delete"),
|
||||
description: t("confirmDelete"),
|
||||
confirmLabel: t("delete"),
|
||||
cancelLabel: t("form.cancel"),
|
||||
});
|
||||
if (!ok) return;
|
||||
run(() => deleteAd({ id }), {
|
||||
successMessage: t("deleteSuccess"),
|
||||
errorMessage: t("deleteError"),
|
||||
redirectTo: "/admin/ads",
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{confirmDialog}
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
disabled={isPending}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{t("form.deleteAd")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { desc, eq, gt, or } from "drizzle-orm";
|
||||
import { Ban } from "lucide-react";
|
||||
import { Ban as BanIcon } from "lucide-react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { createBan, liftBan } from "@/actions/admin-bans";
|
||||
@@ -7,7 +7,7 @@ import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { unixNow } from "@/lib/bans";
|
||||
import { Ban as BanTable, db } from "@/lib/db";
|
||||
import { Ban, db } from "@/lib/db";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
|
||||
@@ -19,13 +19,13 @@ export default async function AdminBans() {
|
||||
|
||||
const t = await getTranslations("pages.admin.bans");
|
||||
const now = unixNow();
|
||||
let bans: (typeof BanTable.$inferSelect)[] = [];
|
||||
let bans: (typeof Ban.$inferSelect)[] = [];
|
||||
try {
|
||||
bans = await db
|
||||
.select()
|
||||
.from(BanTable)
|
||||
.where(or(eq(BanTable.banExpire, 0), gt(BanTable.banExpire, now)))
|
||||
.orderBy(desc(BanTable.timestamp))
|
||||
.from(Ban)
|
||||
.where(or(eq(Ban.banExpire, 0), gt(Ban.banExpire, now)))
|
||||
.orderBy(desc(Ban.timestamp))
|
||||
.limit(100);
|
||||
} catch {
|
||||
bans = [];
|
||||
@@ -35,7 +35,7 @@ export default async function AdminBans() {
|
||||
const accountBans = bans.filter((b) => b.type === "account").length;
|
||||
|
||||
return (
|
||||
<AdminPageShell icon={Ban} title={t("title")} subtitle={t("subtitle")}>
|
||||
<AdminPageShell icon={BanIcon} title={t("title")} subtitle={t("subtitle")}>
|
||||
<main>
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||
<StatusCard
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
+5
-11
@@ -2,7 +2,7 @@ import { count, desc, eq, gt, inArray, or } from "drizzle-orm";
|
||||
import {
|
||||
Activity,
|
||||
ArrowRight,
|
||||
Ban,
|
||||
Ban as BanIcon,
|
||||
Database,
|
||||
Newspaper,
|
||||
Users,
|
||||
@@ -11,13 +11,7 @@ import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { unixNow } from "@/lib/bans";
|
||||
import {
|
||||
Ban as BanTable,
|
||||
db,
|
||||
StaffActivities,
|
||||
User,
|
||||
WebsiteArticles,
|
||||
} from "@/lib/db";
|
||||
import { Ban, db, StaffActivities, User, WebsiteArticles } from "@/lib/db";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
|
||||
type RankCount = { rank: number; count: number };
|
||||
@@ -36,8 +30,8 @@ export default async function AdminDashboard() {
|
||||
db.select({ total: count() }).from(WebsiteArticles),
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(BanTable)
|
||||
.where(or(eq(BanTable.banExpire, 0), gt(BanTable.banExpire, now))),
|
||||
.from(Ban)
|
||||
.where(or(eq(Ban.banExpire, 0), gt(Ban.banExpire, now))),
|
||||
]);
|
||||
users = usersRow[0]?.total ?? 0;
|
||||
online = onlineRow[0]?.total ?? 0;
|
||||
@@ -119,7 +113,7 @@ export default async function AdminDashboard() {
|
||||
label={t("activeBans")}
|
||||
value={dbOk ? bans : "—"}
|
||||
state={bans > 0 ? "warn" : "neutral"}
|
||||
icon={<Ban size={18} />}
|
||||
icon={<BanIcon size={18} />}
|
||||
/>
|
||||
<StatusCard
|
||||
label={t("database")}
|
||||
|
||||
Reference in new issue
Block a user