{!summary && (
-
{Array.from(batchProgress.values()).map((item) => (
@@ -1425,6 +1445,16 @@ export function ImportFurniClient() {
}
)}
+ {batchImporting && (
+ batchAbortRef.current?.abort()}
+ >
+ Cancel
+
+ )}
{hasImported && (
(null);
const [iconFile, setIconFile] = useState(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() {
/>
Can lay on
+
@@ -304,6 +342,12 @@ export function UploadFurniClient() {
{result.catalogItemId}
)}
+ {result.sqlFile ? (
+
+ SQL file:
+ {result.sqlFile}
+
+ ) : null}
{result.warnings.length > 0 && (
diff --git a/src/app/admin/import/repair-icons/repair-icons-client.tsx b/src/app/admin/import/repair-icons/repair-icons-client.tsx
index a0a3bd3d..23e6a327 100644
--- a/src/app/admin/import/repair-icons/repair-icons-client.tsx
+++ b/src/app/admin/import/repair-icons/repair-icons-client.tsx
@@ -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(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 (
{!result && (
-
- {running ? (
-
- ) : (
-
+
+
+ {running ? (
+
+ ) : (
+
+ )}
+ {running
+ ? `Repairing… ${progress ? `${progress.done}/${progress.total}` : ""}`
+ : "Start Icon Repair"}
+
+ {running && (
+ abortRef.current?.abort()}
+ >
+ Cancel
+
)}
- {running
- ? `Repairing… ${progress ? `${progress.done}/${progress.total}` : ""}`
- : "Start Icon Repair"}
-
+
)}
{progress && (
diff --git a/src/app/admin/import/sync/sync-all-client.tsx b/src/app/admin/import/sync/sync-all-client.tsx
index 4c546615..cf551c0e 100644
--- a/src/app/admin/import/sync/sync-all-client.tsx
+++ b/src/app/admin/import/sync/sync-all-client.tsx
@@ -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(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 (
{!result && (
-
- {syncing ? (
-
- ) : (
-
+
+
+ {syncing ? (
+
+ ) : (
+
+ )}
+ {syncing
+ ? `Syncing… ${progress ? `${progress.done}/${progress.total}` : ""}`
+ : "Start Sync All Sources"}
+
+ {syncing && (
+ abortRef.current?.abort()}
+ >
+ Cancel
+
)}
- {syncing
- ? `Syncing… ${progress ? `${progress.done}/${progress.total}` : ""}`
- : "Start Sync All Sources"}
-
+
)}
{progress && (
diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx
index 4a8bc3bd..d1999b96 100644
--- a/src/app/admin/page.tsx
+++ b/src/app/admin/page.tsx
@@ -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={}
+ icon={}
/>
{
@@ -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({
diff --git a/src/components/admin/dashboard.tsx b/src/components/admin/dashboard.tsx
index 608a9f9e..ba180c35 100644
--- a/src/components/admin/dashboard.tsx
+++ b/src/components/admin/dashboard.tsx
@@ -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 = {
"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({
);
}
-
-/** 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 (
-
-
-
- Online Now
-
-
- {isLoading ? "..." : users.length} users
-
-
-
-
- {users.length === 0 ? (
-
-
- No users online
-
-
- ) : (
- users.map((user) => (
-
-
-
- {user.roomId && user.roomName && (
-
-
-
- )}
-
-
-
-
-
- {user.roomId && user.roomName ? (
-
- 🏠 {user.roomName}
-
- ) : (
-
- ⏱️ {formatLastSeen(user.lastActivity)}
-
- )}
-
-
-
- ))
- )}
-
-
- );
-}
diff --git a/src/components/admin/online-users-widget.tsx b/src/components/admin/online-users-widget.tsx
new file mode 100644
index 00000000..2b593f7a
--- /dev/null
+++ b/src/components/admin/online-users-widget.tsx
@@ -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 (
+
+
+
+ Online Now
+
+
+ {isLoading ? "..." : users.length} users
+
+
+
+
+ {users.length === 0 ? (
+
+
+ No users online
+
+
+ ) : (
+ users.map((user) => (
+
+
+
+ {user.roomId && user.roomName && (
+
+
+
+ )}
+
+
+
+
+
+ {user.roomId && user.roomName ? (
+
+ 🏠 {user.roomName}
+
+ ) : (
+
+ ⏱️ {formatLastSeen(user.lastActivity)}
+
+ )}
+
+
+
+ ))
+ )}
+
+
+ );
+}
diff --git a/src/lib/admin-nav.ts b/src/lib/admin-nav.ts
index 56c4bfc9..4b7c70bb 100644
--- a/src/lib/admin-nav.ts
+++ b/src/lib/admin-nav.ts
@@ -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. */
diff --git a/src/lib/import-backend-contract.test.ts b/src/lib/import-backend-contract.test.ts
index 22acfaa3..6aaf9672 100644
--- a/src/lib/import-backend-contract.test.ts
+++ b/src/lib/import-backend-contract.test.ts
@@ -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");
+ });
});
diff --git a/src/lib/services/import-badge.ts b/src/lib/services/import-badge.ts
new file mode 100644
index 00000000..e83cb203
--- /dev/null
+++ b/src/lib/services/import-badge.ts
@@ -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 {
+ try {
+ const raw = await fs.readFile(EXTERNAL_TEXTS_PATH, "utf-8");
+ const texts = JSON.parse(raw) as Record;
+
+ 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 {
+ 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 {
+ 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 };
+}
diff --git a/src/lib/staff-smoke-contract.test.ts b/src/lib/staff-smoke-contract.test.ts
index 03c58822..14a826a9 100644
--- a/src/lib/staff-smoke-contract.test.ts
+++ b/src/lib/staff-smoke-contract.test.ts
@@ -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");
+ });
});
diff --git a/src/messages/en.json b/src/messages/en.json
index 756f6a09..10b6d756 100644
--- a/src/messages/en.json
+++ b/src/messages/en.json
@@ -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",
diff --git a/src/messages/it.json b/src/messages/it.json
index edb6cdef..b86f40a9 100644
--- a/src/messages/it.json
+++ b/src/messages/it.json
@@ -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",