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";
|
"use server";
|
||||||
|
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { db, WebsiteBadges } from "@/lib/db";
|
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import { importBadgeSynced } from "@/lib/services/import-badge";
|
||||||
|
|
||||||
export async function importBadgeFromRemote({
|
export async function importBadgeFromRemote({
|
||||||
code,
|
code,
|
||||||
@@ -14,26 +14,9 @@ export async function importBadgeFromRemote({
|
|||||||
description: string;
|
description: string;
|
||||||
}) {
|
}) {
|
||||||
await requirePermission(PERMS.ASSETS_IMPORT);
|
await requirePermission(PERMS.ASSETS_IMPORT);
|
||||||
try {
|
const result = await importBadgeSynced({ code, name, description });
|
||||||
const now = new Date();
|
if (!result.ok) {
|
||||||
await db
|
return { ok: false as const, error: result.error };
|
||||||
.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" };
|
|
||||||
}
|
}
|
||||||
|
return { ok: true as const };
|
||||||
}
|
}
|
||||||
@@ -7,7 +7,7 @@ import { updateAd } from "@/actions/admin-ads";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { db, WebsiteAds } from "@/lib/db";
|
import { db, WebsiteAds } from "@/lib/db";
|
||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { EditAdDeleteButton } from "../ads-table";
|
import { EditAdDeleteButton } from "../edit-ad-delete-button";
|
||||||
|
|
||||||
export default async function EditAd({
|
export default async function EditAd({
|
||||||
params,
|
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 { 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 { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { createBan, liftBan } from "@/actions/admin-bans";
|
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 { StatusCard } from "@/components/admin/dashboard";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { unixNow } from "@/lib/bans";
|
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 { formatDate } from "@/lib/format-date";
|
||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
|
|
||||||
@@ -19,13 +19,13 @@ export default async function AdminBans() {
|
|||||||
|
|
||||||
const t = await getTranslations("pages.admin.bans");
|
const t = await getTranslations("pages.admin.bans");
|
||||||
const now = unixNow();
|
const now = unixNow();
|
||||||
let bans: (typeof BanTable.$inferSelect)[] = [];
|
let bans: (typeof Ban.$inferSelect)[] = [];
|
||||||
try {
|
try {
|
||||||
bans = await db
|
bans = await db
|
||||||
.select()
|
.select()
|
||||||
.from(BanTable)
|
.from(Ban)
|
||||||
.where(or(eq(BanTable.banExpire, 0), gt(BanTable.banExpire, now)))
|
.where(or(eq(Ban.banExpire, 0), gt(Ban.banExpire, now)))
|
||||||
.orderBy(desc(BanTable.timestamp))
|
.orderBy(desc(Ban.timestamp))
|
||||||
.limit(100);
|
.limit(100);
|
||||||
} catch {
|
} catch {
|
||||||
bans = [];
|
bans = [];
|
||||||
@@ -35,7 +35,7 @@ export default async function AdminBans() {
|
|||||||
const accountBans = bans.filter((b) => b.type === "account").length;
|
const accountBans = bans.filter((b) => b.type === "account").length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminPageShell icon={Ban} title={t("title")} subtitle={t("subtitle")}>
|
<AdminPageShell icon={BanIcon} title={t("title")} subtitle={t("subtitle")}>
|
||||||
<main>
|
<main>
|
||||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||||
<StatusCard
|
<StatusCard
|
||||||
|
|||||||
@@ -190,6 +190,8 @@ export function AuditClient() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
if ((err as Error).name !== "AbortError") {
|
if ((err as Error).name !== "AbortError") {
|
||||||
toast.error("Audit request failed");
|
toast.error("Audit request failed");
|
||||||
|
} else {
|
||||||
|
toast.info("Audit cancelled");
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -203,14 +205,25 @@ export function AuditClient() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{!summary && (
|
{!summary && (
|
||||||
<Button onClick={runAudit} disabled={loading} size="lg">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
{loading ? (
|
<Button onClick={runAudit} disabled={loading} size="lg">
|
||||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
{loading ? (
|
||||||
) : (
|
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||||
<ClipboardList className="w-4 h-4 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"}
|
</div>
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{loading && checkProgress && (
|
{loading && checkProgress && (
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { importBadgeFromRemote } from "@/actions/import-badges";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -25,6 +24,7 @@ import {
|
|||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
|
import { adminFetch } from "@/lib/admin-fetch";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { BadgeEditorDialog } from "./badge-editor-dialog";
|
import { BadgeEditorDialog } from "./badge-editor-dialog";
|
||||||
|
|
||||||
@@ -119,14 +119,32 @@ export function ImportBadgesClient() {
|
|||||||
fetchBadges(activeSearch, newOffset, allHotels);
|
fetchBadges(activeSearch, newOffset, allHotels);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doImport(badge: BadgeItem) {
|
async function postImportBadge(badge: BadgeItem) {
|
||||||
setImportingCode(badge.code);
|
const res = await adminFetch("/api/admin/import/badges", {
|
||||||
try {
|
method: "POST",
|
||||||
const result = await importBadgeFromRemote({
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
code: badge.code,
|
code: badge.code,
|
||||||
name: badge.name,
|
name: badge.name,
|
||||||
description: badge.description,
|
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) {
|
if (result.ok) {
|
||||||
toast.success(`${badge.code} imported`);
|
toast.success(`${badge.code} imported`);
|
||||||
setBadges((prev) =>
|
setBadges((prev) =>
|
||||||
@@ -140,7 +158,7 @@ export function ImportBadgesClient() {
|
|||||||
return n;
|
return n;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.error || `Failed to import ${badge.code}`);
|
toast.error(result.error);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(`Network error importing ${badge.code}`);
|
toast.error(`Network error importing ${badge.code}`);
|
||||||
@@ -159,11 +177,7 @@ export function ImportBadgesClient() {
|
|||||||
fail = 0;
|
fail = 0;
|
||||||
for (const badge of toImport) {
|
for (const badge of toImport) {
|
||||||
try {
|
try {
|
||||||
const result = await importBadgeFromRemote({
|
const result = await postImportBadge(badge);
|
||||||
code: badge.code,
|
|
||||||
name: badge.name,
|
|
||||||
description: badge.description,
|
|
||||||
});
|
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
ok++;
|
ok++;
|
||||||
setBadges((prev) =>
|
setBadges((prev) =>
|
||||||
|
|||||||
@@ -209,6 +209,7 @@ export function ImportFurniClient() {
|
|||||||
// Reorganize catalog
|
// Reorganize catalog
|
||||||
const [reorganizing, setReorganizing] = useState(false);
|
const [reorganizing, setReorganizing] = useState(false);
|
||||||
const searchRef = useRef<HTMLInputElement>(null);
|
const searchRef = useRef<HTMLInputElement>(null);
|
||||||
|
const batchAbortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
// Confirmation dialogs
|
// Confirmation dialogs
|
||||||
const [confirmBatch, setConfirmBatch] = useState(false);
|
const [confirmBatch, setConfirmBatch] = useState(false);
|
||||||
@@ -484,6 +485,9 @@ export function ImportFurniClient() {
|
|||||||
}
|
}
|
||||||
setBatchProgress(new Map(initialProgress));
|
setBatchProgress(new Map(initialProgress));
|
||||||
|
|
||||||
|
const abort = new AbortController();
|
||||||
|
batchAbortRef.current = abort;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await adminFetch("/api/admin/import/furni/batch", {
|
const res = await adminFetch("/api/admin/import/furni/batch", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -500,6 +504,7 @@ export function ImportFurniClient() {
|
|||||||
})),
|
})),
|
||||||
concurrency: 3,
|
concurrency: 3,
|
||||||
}),
|
}),
|
||||||
|
signal: abort.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok || !res.body) {
|
if (!res.ok || !res.body) {
|
||||||
@@ -561,10 +566,17 @@ export function ImportFurniClient() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[Import] Batch import error:", err);
|
if ((err as Error).name === "AbortError") {
|
||||||
toast.error("Connection lost during batch import. Check results above.");
|
toast.info("Batch import cancelled");
|
||||||
|
} else {
|
||||||
|
console.error("[Import] Batch import error:", err);
|
||||||
|
toast.error(
|
||||||
|
"Connection lost during batch import. Check results above.",
|
||||||
|
);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setBatchImporting(false);
|
setBatchImporting(false);
|
||||||
|
batchAbortRef.current = null;
|
||||||
setSelected(new Set());
|
setSelected(new Set());
|
||||||
fetchStats();
|
fetchStats();
|
||||||
}
|
}
|
||||||
@@ -1105,6 +1117,14 @@ export function ImportFurniClient() {
|
|||||||
{progressStats.failed} failed
|
{progressStats.failed} failed
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 ml-auto"
|
||||||
|
onClick={() => batchAbortRef.current?.abort()}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-[250px] overflow-y-auto space-y-0.5">
|
<div className="max-h-[250px] overflow-y-auto space-y-0.5">
|
||||||
{Array.from(batchProgress.values()).map((item) => (
|
{Array.from(batchProgress.values()).map((item) => (
|
||||||
@@ -1425,6 +1445,16 @@ export function ImportFurniClient() {
|
|||||||
}
|
}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{batchImporting && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8"
|
||||||
|
onClick={() => batchAbortRef.current?.abort()}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{hasImported && (
|
{hasImported && (
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
|
|||||||
@@ -9,11 +9,15 @@ import { Label } from "@/components/ui/label";
|
|||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { adminFetch } from "@/lib/admin-fetch";
|
import { adminFetch } from "@/lib/admin-fetch";
|
||||||
|
|
||||||
|
const CLASSNAME_RE = /^[a-zA-Z0-9_]+$/;
|
||||||
|
const NITRO_MAX_BYTES = 50 * 1024 * 1024;
|
||||||
|
|
||||||
interface UploadResult {
|
interface UploadResult {
|
||||||
itemId: number;
|
itemId: number;
|
||||||
catalogItemId: number | null;
|
catalogItemId: number | null;
|
||||||
classname: string;
|
classname: string;
|
||||||
warnings: string[];
|
warnings: string[];
|
||||||
|
sqlFile?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UploadFurniClient() {
|
export function UploadFurniClient() {
|
||||||
@@ -31,6 +35,7 @@ export function UploadFurniClient() {
|
|||||||
const [canlayon, setCanlayon] = useState(false);
|
const [canlayon, setCanlayon] = useState(false);
|
||||||
const [interactionType, setInteractionType] = useState("default");
|
const [interactionType, setInteractionType] = useState("default");
|
||||||
const [customparams, setCustomparams] = useState("");
|
const [customparams, setCustomparams] = useState("");
|
||||||
|
const [generateSql, setGenerateSql] = useState(false);
|
||||||
|
|
||||||
const [nitroFile, setNitroFile] = useState<File | null>(null);
|
const [nitroFile, setNitroFile] = useState<File | null>(null);
|
||||||
const [iconFile, setIconFile] = useState<File | null>(null);
|
const [iconFile, setIconFile] = useState<File | null>(null);
|
||||||
@@ -51,6 +56,7 @@ export function UploadFurniClient() {
|
|||||||
setCanlayon(false);
|
setCanlayon(false);
|
||||||
setInteractionType("default");
|
setInteractionType("default");
|
||||||
setCustomparams("");
|
setCustomparams("");
|
||||||
|
setGenerateSql(false);
|
||||||
setNitroFile(null);
|
setNitroFile(null);
|
||||||
setIconFile(null);
|
setIconFile(null);
|
||||||
setResult(null);
|
setResult(null);
|
||||||
@@ -61,13 +67,35 @@ export function UploadFurniClient() {
|
|||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setUploading(true);
|
|
||||||
setError("");
|
setError("");
|
||||||
setResult(null);
|
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 {
|
try {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.set("classname", classname.trim());
|
fd.set("classname", trimmedClass);
|
||||||
fd.set("name", name.trim());
|
fd.set("name", name.trim());
|
||||||
fd.set("description", description.trim());
|
fd.set("description", description.trim());
|
||||||
fd.set("itemType", itemType);
|
fd.set("itemType", itemType);
|
||||||
@@ -78,7 +106,8 @@ export function UploadFurniClient() {
|
|||||||
fd.set("canlayon", String(canlayon));
|
fd.set("canlayon", String(canlayon));
|
||||||
fd.set("interactionType", interactionType.trim());
|
fd.set("interactionType", interactionType.trim());
|
||||||
fd.set("customparams", customparams.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);
|
if (iconFile) fd.set("icon", iconFile);
|
||||||
|
|
||||||
const res = await adminFetch("/api/admin/import/furni/upload", {
|
const res = await adminFetch("/api/admin/import/furni/upload", {
|
||||||
@@ -219,6 +248,15 @@ export function UploadFurniClient() {
|
|||||||
/>
|
/>
|
||||||
<span className="text-sm">Can lay on</span>
|
<span className="text-sm">Can lay on</span>
|
||||||
</label>
|
</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>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
@@ -304,6 +342,12 @@ export function UploadFurniClient() {
|
|||||||
<code className="font-mono">{result.catalogItemId}</code>
|
<code className="font-mono">{result.catalogItemId}</code>
|
||||||
</div>
|
</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 && (
|
{result.warnings.length > 0 && (
|
||||||
<div className="space-y-1 pt-2">
|
<div className="space-y-1 pt-2">
|
||||||
<span className="text-muted-foreground font-medium">
|
<span className="text-muted-foreground font-medium">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Check, ImageIcon, Loader2, Wrench } from "lucide-react";
|
import { Check, ImageIcon, Loader2, Wrench } from "lucide-react";
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { adminFetch } from "@/lib/admin-fetch";
|
import { adminFetch } from "@/lib/admin-fetch";
|
||||||
@@ -31,6 +31,7 @@ export function RepairIconsClient() {
|
|||||||
failed: number;
|
failed: number;
|
||||||
skipped: number;
|
skipped: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
const startRepair = useCallback(async () => {
|
const startRepair = useCallback(async () => {
|
||||||
setRunning(true);
|
setRunning(true);
|
||||||
@@ -38,9 +39,13 @@ export function RepairIconsClient() {
|
|||||||
setLog([]);
|
setLog([]);
|
||||||
setResult(null);
|
setResult(null);
|
||||||
|
|
||||||
|
const abort = new AbortController();
|
||||||
|
abortRef.current = abort;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await adminFetch("/api/admin/import/furni/repair-icons", {
|
const res = await adminFetch("/api/admin/import/furni/repair-icons", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
signal: abort.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.body) {
|
if (!res.body) {
|
||||||
@@ -131,26 +136,42 @@ export function RepairIconsClient() {
|
|||||||
setResult({ succeeded, failed, skipped });
|
setResult({ succeeded, failed, skipped });
|
||||||
if (succeeded > 0) toast.success(`${succeeded} icons downloaded`);
|
if (succeeded > 0) toast.success(`${succeeded} icons downloaded`);
|
||||||
if (failed > 0) toast.error(`${failed} icons could not be obtained`);
|
if (failed > 0) toast.error(`${failed} icons could not be obtained`);
|
||||||
} catch {
|
} catch (err) {
|
||||||
toast.error("Repair request failed");
|
if ((err as Error).name === "AbortError") {
|
||||||
|
toast.info("Repair cancelled");
|
||||||
|
} else {
|
||||||
|
toast.error("Repair request failed");
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setRunning(false);
|
setRunning(false);
|
||||||
|
abortRef.current = null;
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{!result && (
|
{!result && (
|
||||||
<Button onClick={startRepair} disabled={running} size="lg">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
{running ? (
|
<Button onClick={startRepair} disabled={running} size="lg">
|
||||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
{running ? (
|
||||||
) : (
|
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||||
<Wrench className="w-4 h-4 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
|
</div>
|
||||||
? `Repairing… ${progress ? `${progress.done}/${progress.total}` : ""}`
|
|
||||||
: "Start Icon Repair"}
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{progress && (
|
{progress && (
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Check, CloudDownload, Loader2 } from "lucide-react";
|
import { Check, CloudDownload, Loader2 } from "lucide-react";
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { adminFetch } from "@/lib/admin-fetch";
|
import { adminFetch } from "@/lib/admin-fetch";
|
||||||
@@ -26,6 +26,7 @@ export function SyncAllClient() {
|
|||||||
failed: number;
|
failed: number;
|
||||||
sources: number;
|
sources: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
const startSync = useCallback(async () => {
|
const startSync = useCallback(async () => {
|
||||||
setSyncing(true);
|
setSyncing(true);
|
||||||
@@ -33,11 +34,15 @@ export function SyncAllClient() {
|
|||||||
setLog([]);
|
setLog([]);
|
||||||
setResult(null);
|
setResult(null);
|
||||||
|
|
||||||
|
const abort = new AbortController();
|
||||||
|
abortRef.current = abort;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await adminFetch("/api/admin/import/clone/sync-all", {
|
const res = await adminFetch("/api/admin/import/clone/sync-all", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
|
signal: abort.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.body) {
|
if (!res.body) {
|
||||||
@@ -117,26 +122,42 @@ export function SyncAllClient() {
|
|||||||
setResult({ succeeded, failed, sources: 1 });
|
setResult({ succeeded, failed, sources: 1 });
|
||||||
if (succeeded > 0) toast.success(`${succeeded} items synced`);
|
if (succeeded > 0) toast.success(`${succeeded} items synced`);
|
||||||
if (failed > 0) toast.error(`${failed} items failed`);
|
if (failed > 0) toast.error(`${failed} items failed`);
|
||||||
} catch {
|
} catch (err) {
|
||||||
toast.error("Sync request failed");
|
if ((err as Error).name === "AbortError") {
|
||||||
|
toast.info("Sync cancelled");
|
||||||
|
} else {
|
||||||
|
toast.error("Sync request failed");
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setSyncing(false);
|
setSyncing(false);
|
||||||
|
abortRef.current = null;
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{!result && (
|
{!result && (
|
||||||
<Button onClick={startSync} disabled={syncing} size="lg">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
{syncing ? (
|
<Button onClick={startSync} disabled={syncing} size="lg">
|
||||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
{syncing ? (
|
||||||
) : (
|
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||||
<CloudDownload className="w-4 h-4 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
|
</div>
|
||||||
? `Syncing… ${progress ? `${progress.done}/${progress.total}` : ""}`
|
|
||||||
: "Start Sync All Sources"}
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{progress && (
|
{progress && (
|
||||||
|
|||||||
+5
-11
@@ -2,7 +2,7 @@ import { count, desc, eq, gt, inArray, or } from "drizzle-orm";
|
|||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Ban,
|
Ban as BanIcon,
|
||||||
Database,
|
Database,
|
||||||
Newspaper,
|
Newspaper,
|
||||||
Users,
|
Users,
|
||||||
@@ -11,13 +11,7 @@ import Link from "next/link";
|
|||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { StatusCard } from "@/components/admin/dashboard";
|
import { StatusCard } from "@/components/admin/dashboard";
|
||||||
import { unixNow } from "@/lib/bans";
|
import { unixNow } from "@/lib/bans";
|
||||||
import {
|
import { Ban, db, StaffActivities, User, WebsiteArticles } from "@/lib/db";
|
||||||
Ban as BanTable,
|
|
||||||
db,
|
|
||||||
StaffActivities,
|
|
||||||
User,
|
|
||||||
WebsiteArticles,
|
|
||||||
} from "@/lib/db";
|
|
||||||
import { formatDate } from "@/lib/format-date";
|
import { formatDate } from "@/lib/format-date";
|
||||||
|
|
||||||
type RankCount = { rank: number; count: number };
|
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(WebsiteArticles),
|
||||||
db
|
db
|
||||||
.select({ total: count() })
|
.select({ total: count() })
|
||||||
.from(BanTable)
|
.from(Ban)
|
||||||
.where(or(eq(BanTable.banExpire, 0), gt(BanTable.banExpire, now))),
|
.where(or(eq(Ban.banExpire, 0), gt(Ban.banExpire, now))),
|
||||||
]);
|
]);
|
||||||
users = usersRow[0]?.total ?? 0;
|
users = usersRow[0]?.total ?? 0;
|
||||||
online = onlineRow[0]?.total ?? 0;
|
online = onlineRow[0]?.total ?? 0;
|
||||||
@@ -119,7 +113,7 @@ export default async function AdminDashboard() {
|
|||||||
label={t("activeBans")}
|
label={t("activeBans")}
|
||||||
value={dbOk ? bans : "—"}
|
value={dbOk ? bans : "—"}
|
||||||
state={bans > 0 ? "warn" : "neutral"}
|
state={bans > 0 ? "warn" : "neutral"}
|
||||||
icon={<Ban size={18} />}
|
icon={<BanIcon size={18} />}
|
||||||
/>
|
/>
|
||||||
<StatusCard
|
<StatusCard
|
||||||
label={t("database")}
|
label={t("database")}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { apiError, apiOk } from "@/lib/api-response";
|
|||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { logAudit } from "@/lib/services/audit";
|
import { logAudit } from "@/lib/services/audit";
|
||||||
import { searchBadges } from "@/lib/services/habboassets";
|
import { searchBadges } from "@/lib/services/habboassets";
|
||||||
|
import { importBadgeSynced } from "@/lib/services/import-badge";
|
||||||
|
|
||||||
const EXTERNAL_TEXTS_PATH = path.join(
|
const EXTERNAL_TEXTS_PATH = path.join(
|
||||||
process.cwd(),
|
process.cwd(),
|
||||||
@@ -52,7 +53,7 @@ export const GET = withAdmin(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── Import a single badge ───────────────────────────────────────────
|
// ── Import a single badge (ExternalTexts + WebsiteBadges) ───────────
|
||||||
export const POST = withAdmin(
|
export const POST = withAdmin(
|
||||||
{ permission: PERMS.ASSETS_IMPORT },
|
{ permission: PERMS.ASSETS_IMPORT },
|
||||||
async (request, ctx) => {
|
async (request, ctx) => {
|
||||||
@@ -61,31 +62,14 @@ export const POST = withAdmin(
|
|||||||
|
|
||||||
if (!code || !name) return apiError("Missing code or name", 400);
|
if (!code || !name) return apiError("Missing code or name", 400);
|
||||||
|
|
||||||
try {
|
const result = await importBadgeSynced({
|
||||||
const raw = await fs.readFile(EXTERNAL_TEXTS_PATH, "utf-8");
|
code,
|
||||||
const texts = JSON.parse(raw);
|
name,
|
||||||
|
description: description || "",
|
||||||
|
});
|
||||||
|
|
||||||
const nameKey = `badge_name_${code}`;
|
if (!result.ok) {
|
||||||
const descKey = `badge_desc_${code}`;
|
return apiError(result.error, result.status);
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logAudit({
|
logAudit({
|
||||||
|
|||||||
@@ -1,17 +1,8 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import Image from "next/image";
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export interface OnlineUser {
|
export type { OnlineUser } from "@/components/admin/online-users-widget";
|
||||||
id: number;
|
export { OnlineUsersWidget } from "@/components/admin/online-users-widget";
|
||||||
username: string;
|
|
||||||
look: string;
|
|
||||||
roomId: number | null;
|
|
||||||
roomName: string | null;
|
|
||||||
lastActivity: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
type State = "ok" | "warn" | "danger" | "neutral";
|
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)]",
|
"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({
|
export function StatusCard({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
@@ -138,89 +129,3 @@ export function InfoItem({
|
|||||||
</div>
|
</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,
|
Terminal,
|
||||||
Ticket,
|
Ticket,
|
||||||
Trophy,
|
Trophy,
|
||||||
|
Upload,
|
||||||
Users,
|
Users,
|
||||||
UsersRound,
|
UsersRound,
|
||||||
Volume2,
|
Volume2,
|
||||||
@@ -217,6 +218,38 @@ export const ADMIN_HUBS: AdminHubDefinition[] = [
|
|||||||
{ href: "/admin/devops/errors", labelKey: "errors" },
|
{ 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. */
|
/** 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";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
const ROUTES = [
|
const ROUTES = [
|
||||||
|
"audit",
|
||||||
"badges",
|
"badges",
|
||||||
"badges/edit",
|
"badges/edit",
|
||||||
"clone",
|
"clone",
|
||||||
"clone/batch",
|
"clone/batch",
|
||||||
"clone/icon",
|
"clone/icon",
|
||||||
|
"clone/sync-all",
|
||||||
"clothing",
|
"clothing",
|
||||||
"clothing/batch",
|
"clothing/batch",
|
||||||
"clothing/sets",
|
"clothing/sets",
|
||||||
@@ -17,7 +19,9 @@ const ROUTES = [
|
|||||||
"furni/batch",
|
"furni/batch",
|
||||||
"furni/batch-regen",
|
"furni/batch-regen",
|
||||||
"furni/nitro-editor",
|
"furni/nitro-editor",
|
||||||
|
"furni/repair-icons",
|
||||||
"furni/resync",
|
"furni/resync",
|
||||||
|
"furni/upload",
|
||||||
"pets",
|
"pets",
|
||||||
"pets/batch",
|
"pets/batch",
|
||||||
"pets/icon",
|
"pets/icon",
|
||||||
@@ -35,4 +39,17 @@ describe("admin import backend contract", () => {
|
|||||||
const source = readFileSync("src/actions/import-furni.ts", "utf8");
|
const source = readFileSync("src/actions/import-furni.ts", "utf8");
|
||||||
expect(source).not.toContain("deleted: 0, remaining: 0");
|
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", () => {
|
it("ships ops health alerts, optional DB backup, and health rate limit", () => {
|
||||||
const worker = readFileSync("scripts/jobs-worker.ts", "utf8");
|
const worker = readFileSync("scripts/jobs-worker.ts", "utf8");
|
||||||
expect(worker).toContain("checkOpsHealth");
|
expect(worker).toContain("checkOpsHealth");
|
||||||
@@ -215,4 +230,19 @@ describe("staff smoke contract", () => {
|
|||||||
"/admin/items",
|
"/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",
|
"commando": "Commando",
|
||||||
"rooms": "Rooms",
|
"rooms": "Rooms",
|
||||||
"import": "Import",
|
"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",
|
"translations": "Translations",
|
||||||
"logs": "Logs",
|
"logs": "Logs",
|
||||||
"audit": "Audit",
|
"audit": "Audit",
|
||||||
@@ -2620,7 +2631,9 @@
|
|||||||
"analytics": "Analytics",
|
"analytics": "Analytics",
|
||||||
"analyticsSubtitle": "Hotel activity, retention and economy",
|
"analyticsSubtitle": "Hotel activity, retention and economy",
|
||||||
"devops": "DevOps",
|
"devops": "DevOps",
|
||||||
"devopsSubtitle": "Service health and emulator errors"
|
"devopsSubtitle": "Service health and emulator errors",
|
||||||
|
"import": "Import",
|
||||||
|
"importSubtitle": "Furni, badges, clothing and asset tools"
|
||||||
},
|
},
|
||||||
"wordfilter": {
|
"wordfilter": {
|
||||||
"title": "Word Filter",
|
"title": "Word Filter",
|
||||||
|
|||||||
+14
-1
@@ -2801,6 +2801,17 @@
|
|||||||
"commando": "Commando",
|
"commando": "Commando",
|
||||||
"rooms": "Stanze",
|
"rooms": "Stanze",
|
||||||
"import": "Import",
|
"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",
|
"translations": "Traduzioni",
|
||||||
"logs": "Log",
|
"logs": "Log",
|
||||||
"audit": "Audit",
|
"audit": "Audit",
|
||||||
@@ -2825,7 +2836,9 @@
|
|||||||
"analytics": "Analitiche",
|
"analytics": "Analitiche",
|
||||||
"analyticsSubtitle": "Attività hotel, retention ed economia",
|
"analyticsSubtitle": "Attività hotel, retention ed economia",
|
||||||
"devops": "DevOps",
|
"devops": "DevOps",
|
||||||
"devopsSubtitle": "Salute servizi ed errori emulator"
|
"devopsSubtitle": "Salute servizi ed errori emulator",
|
||||||
|
"import": "Import",
|
||||||
|
"importSubtitle": "Furni, badge, abbigliamento e strumenti asset"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Impostazioni CMS",
|
"title": "Impostazioni CMS",
|
||||||
|
|||||||
Reference in new issue
Block a user