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
@@ -1,8 +1,8 @@
|
||||
"use server";
|
||||
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { db, WebsiteBadges } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { importBadgeSynced } from "@/lib/services/import-badge";
|
||||
|
||||
export async function importBadgeFromRemote({
|
||||
code,
|
||||
@@ -14,26 +14,9 @@ export async function importBadgeFromRemote({
|
||||
description: string;
|
||||
}) {
|
||||
await requirePermission(PERMS.ASSETS_IMPORT);
|
||||
try {
|
||||
const now = new Date();
|
||||
await db
|
||||
.insert(WebsiteBadges)
|
||||
.values({
|
||||
badgeKey: code,
|
||||
badgeName: name,
|
||||
badgeDescription: description,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onDuplicateKeyUpdate({
|
||||
set: {
|
||||
badgeName: name,
|
||||
badgeDescription: description,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
return { ok: true as const };
|
||||
} catch {
|
||||
return { ok: false as const, error: "Failed to import badge" };
|
||||
const result = await importBadgeSynced({ code, name, description });
|
||||
if (!result.ok) {
|
||||
return { ok: false as const, error: result.error };
|
||||
}
|
||||
return { ok: true as const };
|
||||
}
|
||||
@@ -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")}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { apiError, apiOk } from "@/lib/api-response";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { logAudit } from "@/lib/services/audit";
|
||||
import { searchBadges } from "@/lib/services/habboassets";
|
||||
import { importBadgeSynced } from "@/lib/services/import-badge";
|
||||
|
||||
const EXTERNAL_TEXTS_PATH = path.join(
|
||||
process.cwd(),
|
||||
@@ -52,7 +53,7 @@ export const GET = withAdmin(
|
||||
},
|
||||
);
|
||||
|
||||
// ── Import a single badge ───────────────────────────────────────────
|
||||
// ── Import a single badge (ExternalTexts + WebsiteBadges) ───────────
|
||||
export const POST = withAdmin(
|
||||
{ permission: PERMS.ASSETS_IMPORT },
|
||||
async (request, ctx) => {
|
||||
@@ -61,31 +62,14 @@ export const POST = withAdmin(
|
||||
|
||||
if (!code || !name) return apiError("Missing code or name", 400);
|
||||
|
||||
try {
|
||||
const raw = await fs.readFile(EXTERNAL_TEXTS_PATH, "utf-8");
|
||||
const texts = JSON.parse(raw);
|
||||
const result = await importBadgeSynced({
|
||||
code,
|
||||
name,
|
||||
description: description || "",
|
||||
});
|
||||
|
||||
const nameKey = `badge_name_${code}`;
|
||||
const descKey = `badge_desc_${code}`;
|
||||
|
||||
if (texts[nameKey] !== undefined) {
|
||||
return apiError("Badge already exists in ExternalTexts.json", 409);
|
||||
}
|
||||
|
||||
texts[nameKey] = name;
|
||||
texts[descKey] = description || "";
|
||||
|
||||
await fs.writeFile(
|
||||
EXTERNAL_TEXTS_PATH,
|
||||
JSON.stringify(texts, null, 4),
|
||||
"utf-8",
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
"[import-badges] Failed to update ExternalTexts.json:",
|
||||
(err as Error).message,
|
||||
);
|
||||
return apiError("Failed to update ExternalTexts.json", 500);
|
||||
if (!result.ok) {
|
||||
return apiError(result.error, result.status);
|
||||
}
|
||||
|
||||
logAudit({
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface OnlineUser {
|
||||
id: number;
|
||||
username: string;
|
||||
look: string;
|
||||
roomId: number | null;
|
||||
roomName: string | null;
|
||||
lastActivity: Date;
|
||||
}
|
||||
export type { OnlineUser } from "@/components/admin/online-users-widget";
|
||||
export { OnlineUsersWidget } from "@/components/admin/online-users-widget";
|
||||
|
||||
type State = "ok" | "warn" | "danger" | "neutral";
|
||||
|
||||
@@ -32,7 +23,7 @@ const STATE_ICON_BG: Record<State, string> = {
|
||||
"from-[var(--admin-accent)]/15 to-[var(--admin-accent)]/5 text-[var(--admin-accent)]",
|
||||
};
|
||||
|
||||
/** A single live-status tile (online users, emulator, DB, …). */
|
||||
/** A single live-status tile (online users, emulator, DB, …). Server-safe. */
|
||||
export function StatusCard({
|
||||
label,
|
||||
value,
|
||||
@@ -138,89 +129,3 @@ export function InfoItem({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Live list of online users with room and activity tracking */
|
||||
export function OnlineUsersWidget({
|
||||
users,
|
||||
isLoading = false,
|
||||
}: {
|
||||
users: OnlineUser[];
|
||||
isLoading?: boolean;
|
||||
}) {
|
||||
const formatLastSeen = (date: Date) => {
|
||||
const now = new Date();
|
||||
const diff = Math.floor((now.getTime() - date.getTime()) / 1000);
|
||||
|
||||
if (diff < 60) return "Just now";
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||
return `${Math.floor(diff / 86400)}d ago`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--admin-surface)] rounded-xl border border-[var(--admin-border)] p-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="m-0 text-lg font-semibold text-[var(--admin-text)]">
|
||||
Online Now
|
||||
</h3>
|
||||
<span className="text-sm text-[var(--admin-text-muted)] bg-[var(--admin-accent)]/10 px-3 py-1 rounded-full">
|
||||
{isLoading ? "..." : users.length} users
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="max-h-80 overflow-y-auto flex flex-col gap-3">
|
||||
{users.length === 0 ? (
|
||||
<div className="py-8 text-center bg-[var(--admin-text-muted)]/5 rounded-lg">
|
||||
<span className="text-[var(--admin-text-muted)] text-sm">
|
||||
No users online
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
users.map((user) => (
|
||||
<div
|
||||
key={user.id}
|
||||
className="flex items-center gap-3 p-3 bg-[var(--admin-text-muted)]/5 rounded-lg transition-all duration-150 hover:bg-[var(--admin-text-muted)]/10 hover:translate-x-0.5"
|
||||
>
|
||||
<div className="relative flex-none w-[50px] h-[62px]">
|
||||
<Image
|
||||
src={`https://www.habbo.com/habbo-imaging/avatarimage?width=50&height=62&direction=2&headonly=1&crop=face&action=wav&gender=m&look=${encodeURIComponent(user.look)}`}
|
||||
alt={user.username}
|
||||
fill
|
||||
className="rounded object-cover border border-[var(--admin-border)]"
|
||||
unoptimized
|
||||
/>
|
||||
{user.roomId && user.roomName && (
|
||||
<div className="absolute -bottom-0.5 -right-0.5">
|
||||
<span className="block w-[10px] h-[10px] bg-[var(--admin-success-subtle)] rounded-full border-2 border-[var(--admin-surface)]" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-[var(--admin-text)] mb-1 truncate">
|
||||
<a
|
||||
href={`/u/${user.username}`}
|
||||
className="text-inherit no-underline hover:text-[var(--admin-accent)] hover:underline"
|
||||
>
|
||||
{user.username}
|
||||
</a>
|
||||
</div>
|
||||
<div className="text-[0.75rem] text-[var(--admin-text-muted)]">
|
||||
{user.roomId && user.roomName ? (
|
||||
<span className="text-[var(--admin-success)] font-medium">
|
||||
🏠 {user.roomName}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[var(--admin-error)]">
|
||||
⏱️ {formatLastSeen(user.lastActivity)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
|
||||
export interface OnlineUser {
|
||||
id: number;
|
||||
username: string;
|
||||
look: string;
|
||||
roomId: number | null;
|
||||
roomName: string | null;
|
||||
lastActivity: Date;
|
||||
}
|
||||
|
||||
/** Live list of online users with room and activity tracking */
|
||||
export function OnlineUsersWidget({
|
||||
users,
|
||||
isLoading = false,
|
||||
}: {
|
||||
users: OnlineUser[];
|
||||
isLoading?: boolean;
|
||||
}) {
|
||||
const formatLastSeen = (date: Date) => {
|
||||
const now = new Date();
|
||||
const diff = Math.floor((now.getTime() - date.getTime()) / 1000);
|
||||
|
||||
if (diff < 60) return "Just now";
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||
return `${Math.floor(diff / 86400)}d ago`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--admin-surface)] rounded-xl border border-[var(--admin-border)] p-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="m-0 text-lg font-semibold text-[var(--admin-text)]">
|
||||
Online Now
|
||||
</h3>
|
||||
<span className="text-sm text-[var(--admin-text-muted)] bg-[var(--admin-accent)]/10 px-3 py-1 rounded-full">
|
||||
{isLoading ? "..." : users.length} users
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="max-h-80 overflow-y-auto flex flex-col gap-3">
|
||||
{users.length === 0 ? (
|
||||
<div className="py-8 text-center bg-[var(--admin-text-muted)]/5 rounded-lg">
|
||||
<span className="text-[var(--admin-text-muted)] text-sm">
|
||||
No users online
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
users.map((user) => (
|
||||
<div
|
||||
key={user.id}
|
||||
className="flex items-center gap-3 p-3 bg-[var(--admin-text-muted)]/5 rounded-lg transition-all duration-150 hover:bg-[var(--admin-text-muted)]/10 hover:translate-x-0.5"
|
||||
>
|
||||
<div className="relative flex-none w-[50px] h-[62px]">
|
||||
<Image
|
||||
src={`https://www.habbo.com/habbo-imaging/avatarimage?width=50&height=62&direction=2&headonly=1&crop=face&action=wav&gender=m&look=${encodeURIComponent(user.look)}`}
|
||||
alt={user.username}
|
||||
fill
|
||||
className="rounded object-cover border border-[var(--admin-border)]"
|
||||
unoptimized
|
||||
/>
|
||||
{user.roomId && user.roomName && (
|
||||
<div className="absolute -bottom-0.5 -right-0.5">
|
||||
<span className="block w-[10px] h-[10px] bg-[var(--admin-success-subtle)] rounded-full border-2 border-[var(--admin-surface)]" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-[var(--admin-text)] mb-1 truncate">
|
||||
<a
|
||||
href={`/u/${user.username}`}
|
||||
className="text-inherit no-underline hover:text-[var(--admin-accent)] hover:underline"
|
||||
>
|
||||
{user.username}
|
||||
</a>
|
||||
</div>
|
||||
<div className="text-[0.75rem] text-[var(--admin-text-muted)]">
|
||||
{user.roomId && user.roomName ? (
|
||||
<span className="text-[var(--admin-success)] font-medium">
|
||||
🏠 {user.roomName}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[var(--admin-error)]">
|
||||
⏱️ {formatLastSeen(user.lastActivity)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
Terminal,
|
||||
Ticket,
|
||||
Trophy,
|
||||
Upload,
|
||||
Users,
|
||||
UsersRound,
|
||||
Volume2,
|
||||
@@ -217,6 +218,38 @@ export const ADMIN_HUBS: AdminHubDefinition[] = [
|
||||
{ href: "/admin/devops/errors", labelKey: "errors" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "import",
|
||||
titleKey: "import",
|
||||
subtitleKey: "importSubtitle",
|
||||
icon: Upload,
|
||||
prefixes: ["/admin/import"],
|
||||
tabs: [
|
||||
{
|
||||
href: "/admin/import",
|
||||
labelKey: "importOverview",
|
||||
match: ["/admin/import"],
|
||||
},
|
||||
{ href: "/admin/import/badges", labelKey: "importBadges" },
|
||||
{
|
||||
href: "/admin/import/furni/upload",
|
||||
labelKey: "importUpload",
|
||||
match: ["/admin/import/furni/upload"],
|
||||
},
|
||||
{
|
||||
href: "/admin/import/furni",
|
||||
labelKey: "importFurni",
|
||||
match: ["/admin/import/furni"],
|
||||
},
|
||||
{ href: "/admin/import/clothing", labelKey: "importClothing" },
|
||||
{ href: "/admin/import/effects", labelKey: "importEffects" },
|
||||
{ href: "/admin/import/pets", labelKey: "importPets" },
|
||||
{ href: "/admin/import/clone", labelKey: "importClone" },
|
||||
{ href: "/admin/import/sync", labelKey: "importSync" },
|
||||
{ href: "/admin/import/repair-icons", labelKey: "importRepairIcons" },
|
||||
{ href: "/admin/import/audit", labelKey: "importAudit" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** Longest-prefix match so nested routes (e.g. /admin/logs/audit) win. */
|
||||
|
||||
@@ -2,11 +2,13 @@ import { existsSync, readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const ROUTES = [
|
||||
"audit",
|
||||
"badges",
|
||||
"badges/edit",
|
||||
"clone",
|
||||
"clone/batch",
|
||||
"clone/icon",
|
||||
"clone/sync-all",
|
||||
"clothing",
|
||||
"clothing/batch",
|
||||
"clothing/sets",
|
||||
@@ -17,7 +19,9 @@ const ROUTES = [
|
||||
"furni/batch",
|
||||
"furni/batch-regen",
|
||||
"furni/nitro-editor",
|
||||
"furni/repair-icons",
|
||||
"furni/resync",
|
||||
"furni/upload",
|
||||
"pets",
|
||||
"pets/batch",
|
||||
"pets/icon",
|
||||
@@ -35,4 +39,17 @@ describe("admin import backend contract", () => {
|
||||
const source = readFileSync("src/actions/import-furni.ts", "utf8");
|
||||
expect(source).not.toContain("deleted: 0, remaining: 0");
|
||||
});
|
||||
|
||||
it("keeps badge import ExternalTexts + WebsiteBadges in sync", () => {
|
||||
const service = readFileSync("src/lib/services/import-badge.ts", "utf8");
|
||||
expect(service).toContain("ExternalTexts.json");
|
||||
expect(service).toContain("WebsiteBadges");
|
||||
expect(service).toContain("importBadgeSynced");
|
||||
expect(readFileSync("src/actions/import-badges.ts", "utf8")).toContain(
|
||||
"importBadgeSynced",
|
||||
);
|
||||
expect(
|
||||
readFileSync("src/app/api/admin/import/badges/route.ts", "utf8"),
|
||||
).toContain("importBadgeSynced");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { db, WebsiteBadges } from "@/lib/db";
|
||||
|
||||
const EXTERNAL_TEXTS_PATH = path.join(
|
||||
process.cwd(),
|
||||
"public/nitro-assets/gamedata/ExternalTexts.json",
|
||||
);
|
||||
|
||||
export type ImportBadgeResult =
|
||||
| { ok: true }
|
||||
| { ok: false; error: string; status: number };
|
||||
|
||||
/** Write badge name/desc into ExternalTexts.json (409 if already present). */
|
||||
export async function writeBadgeToExternalTexts(
|
||||
code: string,
|
||||
name: string,
|
||||
description: string,
|
||||
): Promise<ImportBadgeResult> {
|
||||
try {
|
||||
const raw = await fs.readFile(EXTERNAL_TEXTS_PATH, "utf-8");
|
||||
const texts = JSON.parse(raw) as Record<string, string>;
|
||||
|
||||
const nameKey = `badge_name_${code}`;
|
||||
const descKey = `badge_desc_${code}`;
|
||||
|
||||
if (texts[nameKey] !== undefined) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Badge already exists in ExternalTexts.json",
|
||||
status: 409,
|
||||
};
|
||||
}
|
||||
|
||||
texts[nameKey] = name;
|
||||
texts[descKey] = description || "";
|
||||
|
||||
await fs.writeFile(
|
||||
EXTERNAL_TEXTS_PATH,
|
||||
JSON.stringify(texts, null, 4),
|
||||
"utf-8",
|
||||
);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
"[import-badges] Failed to update ExternalTexts.json:",
|
||||
(err as Error).message,
|
||||
);
|
||||
return {
|
||||
ok: false,
|
||||
error: "Failed to update ExternalTexts.json",
|
||||
status: 500,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Upsert WebsiteBadges row so CMS badge tables stay in sync. */
|
||||
export async function upsertWebsiteBadge(
|
||||
code: string,
|
||||
name: string,
|
||||
description: string,
|
||||
): Promise<void> {
|
||||
const now = new Date();
|
||||
await db
|
||||
.insert(WebsiteBadges)
|
||||
.values({
|
||||
badgeKey: code,
|
||||
badgeName: name,
|
||||
badgeDescription: description,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onDuplicateKeyUpdate({
|
||||
set: {
|
||||
badgeName: name,
|
||||
badgeDescription: description,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Single import path: ExternalTexts.json + WebsiteBadges.
|
||||
* Callers should gate with ASSETS_IMPORT.
|
||||
*/
|
||||
export async function importBadgeSynced(input: {
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}): Promise<ImportBadgeResult> {
|
||||
const texts = await writeBadgeToExternalTexts(
|
||||
input.code,
|
||||
input.name,
|
||||
input.description,
|
||||
);
|
||||
if (!texts.ok) return texts;
|
||||
|
||||
try {
|
||||
await upsertWebsiteBadge(input.code, input.name, input.description);
|
||||
} catch {
|
||||
return { ok: false, error: "Failed to import badge", status: 500 };
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -179,6 +179,21 @@ describe("staff smoke contract", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps StatusCard server-safe without client boundary", () => {
|
||||
expect(
|
||||
readFileSync("src/components/admin/dashboard.tsx", "utf8"),
|
||||
).not.toMatch(/^["']use client["']/m);
|
||||
expect(existsSync("src/components/admin/online-users-widget.tsx")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
readFileSync("src/components/admin/online-users-widget.tsx", "utf8"),
|
||||
).toContain("use client");
|
||||
expect(existsSync("src/app/admin/ads/edit-ad-delete-button.tsx")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("ships ops health alerts, optional DB backup, and health rate limit", () => {
|
||||
const worker = readFileSync("scripts/jobs-worker.ts", "utf8");
|
||||
expect(worker).toContain("checkOpsHealth");
|
||||
@@ -215,4 +230,19 @@ describe("staff smoke contract", () => {
|
||||
"/admin/items",
|
||||
);
|
||||
});
|
||||
|
||||
it("ships import hub with synced badge import path", () => {
|
||||
const nav = readFileSync("src/lib/admin-nav.ts", "utf8");
|
||||
expect(nav).toContain('id: "import"');
|
||||
expect(nav).toContain("importSubtitle");
|
||||
expect(nav).toContain("/admin/import");
|
||||
expect(existsSync("src/lib/services/import-badge.ts")).toBe(true);
|
||||
const badgeService = readFileSync(
|
||||
"src/lib/services/import-badge.ts",
|
||||
"utf8",
|
||||
);
|
||||
expect(badgeService).toContain("ExternalTexts.json");
|
||||
expect(badgeService).toContain("WebsiteBadges");
|
||||
expect(badgeService).toContain("importBadgeSynced");
|
||||
});
|
||||
});
|
||||
+14
-1
@@ -2597,6 +2597,17 @@
|
||||
"commando": "Commando",
|
||||
"rooms": "Rooms",
|
||||
"import": "Import",
|
||||
"importOverview": "Overview",
|
||||
"importBadges": "Badges",
|
||||
"importFurni": "Furni",
|
||||
"importUpload": "Upload",
|
||||
"importClothing": "Clothing",
|
||||
"importEffects": "Effects",
|
||||
"importPets": "Pets",
|
||||
"importClone": "Clone",
|
||||
"importSync": "Sync",
|
||||
"importRepairIcons": "Repair icons",
|
||||
"importAudit": "Audit",
|
||||
"translations": "Translations",
|
||||
"logs": "Logs",
|
||||
"audit": "Audit",
|
||||
@@ -2620,7 +2631,9 @@
|
||||
"analytics": "Analytics",
|
||||
"analyticsSubtitle": "Hotel activity, retention and economy",
|
||||
"devops": "DevOps",
|
||||
"devopsSubtitle": "Service health and emulator errors"
|
||||
"devopsSubtitle": "Service health and emulator errors",
|
||||
"import": "Import",
|
||||
"importSubtitle": "Furni, badges, clothing and asset tools"
|
||||
},
|
||||
"wordfilter": {
|
||||
"title": "Word Filter",
|
||||
|
||||
+14
-1
@@ -2801,6 +2801,17 @@
|
||||
"commando": "Commando",
|
||||
"rooms": "Stanze",
|
||||
"import": "Import",
|
||||
"importOverview": "Panoramica",
|
||||
"importBadges": "Badge",
|
||||
"importFurni": "Furni",
|
||||
"importUpload": "Upload",
|
||||
"importClothing": "Abbigliamento",
|
||||
"importEffects": "Effetti",
|
||||
"importPets": "Pet",
|
||||
"importClone": "Clone",
|
||||
"importSync": "Sync",
|
||||
"importRepairIcons": "Ripara icone",
|
||||
"importAudit": "Audit",
|
||||
"translations": "Traduzioni",
|
||||
"logs": "Log",
|
||||
"audit": "Audit",
|
||||
@@ -2825,7 +2836,9 @@
|
||||
"analytics": "Analitiche",
|
||||
"analyticsSubtitle": "Attività hotel, retention ed economia",
|
||||
"devops": "DevOps",
|
||||
"devopsSubtitle": "Salute servizi ed errori emulator"
|
||||
"devopsSubtitle": "Salute servizi ed errori emulator",
|
||||
"import": "Import",
|
||||
"importSubtitle": "Furni, badge, abbigliamento e strumenti asset"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Impostazioni CMS",
|
||||
|
||||
Reference in new issue
Block a user