feat: comprehensive CMS improvements
CI / runtime-diagnostics (push) Skipped
CI / release (push) Skipped
CI / check (push) Failing after 0s
CI / deploy (push) Skipped

- Fix DOMPurify SSR crash (use isomorphic-dompurify)
- Fix SanitizedHtml to sanitize by default
- Add auth guards to studio/catalog maintenance pages
- Add update/edit to vouchers CRUD
- Add update/edit to rare-values CRUD
- Add approve workflow to applications page
- Add edit form to guilds detail page
- Add SEO metadata to all public pages (21 pages)
- Fix mobile nav accessibility (focus trap, aria attributes)
- Fix missing labels and table accessibility
- Add dynamic imports for heavy client components (6 components)
- Fix silent error swallowing (40+ locations)
- Add content scheduling for articles (publishAt, status)
- Wire up 12 missing webhook notification triggers
- Add global search to admin panel
- Add bulk actions to admin users table
- Fix JSON formatting and a11y issues
This commit is contained in:
openhands committed 2026-09-03 16:00:32 +02:00
1 parent b810b16672
commit 30c95b1a5c
78 files changed
+2252 -134

No files matched your search

+2 -4
View File
@@ -1,8 +1,6 @@
import DOMPurify from "dompurify";
const sanitizeHtml = (html: string) => DOMPurify.sanitize(html);
import DOMPurify from "isomorphic-dompurify";
export function sanitize(html: string | null | undefined): string {
if (!html) return "";
return sanitizeHtml(html);
return DOMPurify.sanitize(html);
}
+4 -1
View File
@@ -1,4 +1,5 @@
import { existsSync, promises as fs } from "node:fs";
import { logServerError } from "@/lib/server-log";
import { resolveFigureSwfUrl } from "@/lib/services/figure-source";
import { listFigureLibraries } from "@/lib/services/figuremap";
import { getGamedataRoot } from "@/lib/services/furni-asset-dirs";
@@ -134,7 +135,9 @@ export async function deleteImportedFigure(
): Promise<{ deletedFile: boolean }> {
const p = await nitroPathFor(lib);
if (existsSync(/*turbopackIgnore: true*/ p)) {
await fs.unlink(/*turbopackIgnore: true*/ p).catch(() => {});
await fs
.unlink(/*turbopackIgnore: true*/ p)
.catch((error) => logServerError("figure.delete_failed", error, { lib }));
return { deletedFile: true };
}
return { deletedFile: false };
+4 -1
View File
@@ -6,6 +6,7 @@ import { and, eq, type SQL, sql } from "drizzle-orm";
import { CatalogPages, db, ItemsBase } from "@/lib/db";
import { officialHabboEnrichmentWarning } from "@/lib/habbo-gamedata-hotel";
import { logger } from "@/lib/logger";
import { logServerError } from "@/lib/server-log";
import {
DEFAULT_FURNI_NITRO_DIR,
getFurniAssetDirs,
@@ -474,7 +475,9 @@ export async function allocateCatalogItemId<T>(
catalogIdSeedChain = new Promise<void>((r) => {
settle = r;
});
await prev.catch(() => {});
await prev.catch((error) =>
logServerError("furni.catalog_id_chain_failed", error),
);
try {
if (
catalogNextId === null ||
+10 -1
View File
@@ -1,6 +1,6 @@
import "server-only";
import { desc } from "drizzle-orm";
import { and, desc, eq, or, sql } from "drizzle-orm";
import { cached } from "@/lib/cache";
import { db, WebsiteArticles } from "@/lib/db";
@@ -42,6 +42,15 @@ export async function getNewsList(limit: number): Promise<NewsListItem[]> {
createdAt: WebsiteArticles.createdAt,
})
.from(WebsiteArticles)
.where(
and(
eq(WebsiteArticles.status, "published"),
or(
sql`${WebsiteArticles.publishAt} IS NULL`,
sql`${WebsiteArticles.publishAt} <= NOW()`,
),
),
)
.orderBy(desc(WebsiteArticles.createdAt))
.limit(FETCH_LIMIT),
);
+10 -2
View File
@@ -2,6 +2,7 @@ import { existsSync, promises as fs } from "node:fs";
import os from "node:os";
import { sql } from "drizzle-orm";
import { db } from "@/lib/db";
import { logServerError } from "@/lib/server-log";
import { extractFurniIconPng } from "@/lib/services/clone-icon";
import {
type CloneSource,
@@ -152,7 +153,10 @@ export async function repairMissingIcons(
if (gamedataRoot) {
const badgeFiles = await fs
.readdir(getRuntimePath(gamedataRoot, "album1584"))
.catch(() => [] as string[]);
.catch((error) => {
logServerError("repair_icons.readdir_album_failed", error);
return [] as string[];
});
const albumBadgeSet = new Set(
badgeFiles.filter((f) => f.endsWith(".gif")).map((f) => f.slice(0, -4)),
);
@@ -373,7 +377,11 @@ export async function repairMissingIcons(
`extract from ${source.name} .nitro failed: ${(err as Error).message}`,
);
} finally {
await fs.unlink(nitroTmp).catch(() => {});
await fs
.unlink(nitroTmp)
.catch((error) =>
logServerError("repair_icons.cleanup_failed", error),
);
}
if (ok) break;
}
+22 -4
View File
@@ -3,6 +3,7 @@ import path from "node:path";
import { eq, sql } from "drizzle-orm";
import { db, ItemsBase } from "@/lib/db";
import { autoDetectInteraction } from "@/lib/furni/auto-interaction";
import { logServerError } from "@/lib/server-log";
import { getFurniAssetWriteTargets } from "@/lib/services/furni-asset-dirs";
import { appendFurniEntry, buildFurniEntry } from "@/lib/services/furni-data";
import {
@@ -96,7 +97,9 @@ async function allocateItemsBaseId<T>(
itemsBaseIdSeedChain = new Promise<void>((r) => {
settle = r;
});
await prev.catch(() => {});
await prev.catch((error) =>
logServerError("upload.items_base_id_chain_failed", error),
);
try {
if (
itemsBaseNextId === null ||
@@ -358,10 +361,25 @@ export async function uploadSingleFurni(params: {
return nextId;
});
} catch (err) {
await fs.unlink(nitroPath).catch(() => {});
if (iconPath) await fs.unlink(iconPath).catch(() => {});
await fs
.unlink(nitroPath)
.catch((error) =>
logServerError("upload.cleanup_nitro_failed", error, { classname }),
);
if (iconPath)
await fs
.unlink(iconPath)
.catch((error) =>
logServerError("upload.cleanup_icon_failed", error, { classname }),
);
await Promise.all(
mirroredPaths.map((file) => fs.unlink(file).catch(() => {})),
mirroredPaths.map((file) =>
fs
.unlink(file)
.catch((error) =>
logServerError("upload.cleanup_mirror_failed", error, { file }),
),
),
);
return {
ok: false,
+11 -2
View File
@@ -2,6 +2,7 @@ import { eq } from "drizzle-orm";
import { env } from "@/env";
import { db, WebsiteSetting } from "@/lib/db";
import { logger } from "@/lib/logger";
import { logServerError } from "@/lib/server-log";
export type { WebhookAction } from "@/types/admin";
@@ -126,6 +127,14 @@ async function sendTelegram(payload: WebhookPayload): Promise<void> {
/** Fire-and-forget notification to Discord + Telegram */
export function notify(payload: WebhookPayload): void {
sendDiscord(payload).catch(() => {});
sendTelegram(payload).catch(() => {});
sendDiscord(payload).catch((error) =>
logServerError("webhook.discord_notify_failed", error, {
action: payload.action,
}),
);
sendTelegram(payload).catch((error) =>
logServerError("webhook.telegram_notify_failed", error, {
action: payload.action,
}),
);
}