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

+216
View File
@@ -0,0 +1,216 @@
import { eq, like, or } from "drizzle-orm";
import { withAdmin } from "@/lib/api-handler";
import { apiOk } from "@/lib/api-response";
import {
db,
Guilds,
Rooms,
User,
WebsiteArticles,
WebsiteRareValues,
WebsiteShopArticles,
} from "@/lib/db";
import { PERMS } from "@/lib/permissions";
type SearchResult = {
type: string;
id: number | string;
title: string;
subtitle: string;
url: string;
};
const PER_TYPE = 5;
const MAX_TOTAL = 20;
export const GET = withAdmin(
{ permission: PERMS.ADMIN_DASHBOARD },
async (request) => {
const q = (request.nextUrl.searchParams.get("q") || "").trim();
if (q.length < 2) {
return apiOk({ results: [] });
}
const pattern = `%${q}%`;
const idExact = Number.parseInt(q, 10);
const hasId = Number.isFinite(idExact) && String(idExact) === q;
const [users, articles, rooms, guilds, shopArticles, rareValues] =
await Promise.all([
db
.select({
id: User.id,
title: User.username,
subtitle: User.mail,
})
.from(User)
.where(
or(
like(User.username, pattern),
like(User.mail, pattern),
...(hasId ? [eq(User.id, idExact)] : []),
),
)
.limit(PER_TYPE),
db
.select({
id: WebsiteArticles.id,
title: WebsiteArticles.title,
subtitle: WebsiteArticles.slug,
})
.from(WebsiteArticles)
.where(
or(
like(WebsiteArticles.title, pattern),
like(WebsiteArticles.slug, pattern),
),
)
.limit(PER_TYPE),
db
.select({
id: Rooms.id,
title: Rooms.name,
subtitle: Rooms.ownerName,
})
.from(Rooms)
.where(
or(
like(Rooms.name, pattern),
...(hasId ? [eq(Rooms.id, idExact)] : []),
),
)
.limit(PER_TYPE),
db
.select({
id: Guilds.id,
title: Guilds.name,
subtitle: Guilds.description,
})
.from(Guilds)
.where(
or(
like(Guilds.name, pattern),
...(hasId ? [eq(Guilds.id, idExact)] : []),
),
)
.limit(PER_TYPE),
db
.select({
id: WebsiteShopArticles.id,
title: WebsiteShopArticles.name,
subtitle: WebsiteShopArticles.info,
})
.from(WebsiteShopArticles)
.where(
or(
like(WebsiteShopArticles.name, pattern),
...(hasId ? [eq(WebsiteShopArticles.id, BigInt(idExact))] : []),
),
)
.limit(PER_TYPE),
db
.select({
id: WebsiteRareValues.id,
title: WebsiteRareValues.name,
subtitle: WebsiteRareValues.itemId,
})
.from(WebsiteRareValues)
.where(
or(
like(WebsiteRareValues.name, pattern),
...(hasId ? [eq(WebsiteRareValues.itemId, idExact)] : []),
),
)
.limit(PER_TYPE),
]);
const groupMap: Record<
string,
{ type: string; items: Array<SearchResult> }
> = {
users: { type: "users", items: [] },
articles: { type: "articles", items: [] },
rooms: { type: "rooms", items: [] },
guilds: { type: "guilds", items: [] },
shop: { type: "shop", items: [] },
rareValues: { type: "rareValues", items: [] },
};
for (const r of users) {
groupMap.users.items.push({
type: "users",
id: r.id,
title: r.title,
subtitle: r.subtitle || "",
url: `/admin/users/show/${r.id}`,
});
}
for (const r of articles) {
groupMap.articles.items.push({
type: "articles",
id: Number(r.id),
title: r.title,
subtitle: r.subtitle,
url: `/admin/articles/${r.id}`,
});
}
for (const r of rooms) {
groupMap.rooms.items.push({
type: "rooms",
id: r.id,
title: r.title || `Room #${r.id}`,
subtitle: r.subtitle || "",
url: `/admin/rooms/${r.id}`,
});
}
for (const r of guilds) {
groupMap.guilds.items.push({
type: "guilds",
id: r.id,
title: r.title || `Guild #${r.id}`,
subtitle: r.subtitle || "",
url: `/admin/guilds/${r.id}`,
});
}
for (const r of shopArticles) {
groupMap.shop.items.push({
type: "shop",
id: Number(r.id),
title: r.title,
subtitle: r.subtitle || "",
url: `/admin/shop/${r.id}`,
});
}
for (const r of rareValues) {
groupMap.rareValues.items.push({
type: "rareValues",
id: Number(r.id),
title: r.title,
subtitle: r.subtitle != null ? `Item #${r.subtitle}` : "",
url: `/admin/rare-values/${r.id}`,
});
}
const results: SearchResult[] = [];
const order = [
"users",
"articles",
"rooms",
"guilds",
"shop",
"rareValues",
];
for (const key of order) {
for (const item of groupMap[key].items) {
results.push(item);
}
}
return apiOk({ results: results.slice(0, MAX_TOTAL) });
},
);
+11 -2
View File
@@ -1,4 +1,4 @@
import { eq } from "drizzle-orm";
import { and, eq, or, sql } from "drizzle-orm";
import { apiJson } from "@/lib/api";
import { db, WebsiteArticles } from "@/lib/db";
import { apiCacheKey, cacheSafe, redisCache } from "@/lib/redis-cache";
@@ -30,7 +30,16 @@ export async function GET(
updatedAt: WebsiteArticles.updatedAt,
})
.from(WebsiteArticles)
.where(eq(WebsiteArticles.slug, slug))
.where(
and(
eq(WebsiteArticles.slug, slug),
eq(WebsiteArticles.status, "published"),
or(
sql`${WebsiteArticles.publishAt} IS NULL`,
sql`${WebsiteArticles.publishAt} <= NOW()`,
),
),
)
.limit(1);
if (!article) {
+13 -2
View File
@@ -1,4 +1,4 @@
import { count, desc } from "drizzle-orm";
import { and, count, desc, eq, or, sql } from "drizzle-orm";
import { apiJson, pagination } from "@/lib/api";
import { db, WebsiteArticles } from "@/lib/db";
import { apiCacheKey, cacheSafe, redisCache } from "@/lib/redis-cache";
@@ -17,8 +17,18 @@ export async function GET(req: Request) {
apiCacheKey(`articles:${page}:${perPage}`),
60,
async () => {
const publishedFilter = and(
eq(WebsiteArticles.status, "published"),
or(
sql`${WebsiteArticles.publishAt} IS NULL`,
sql`${WebsiteArticles.publishAt} <= NOW()`,
),
);
const [totalRows, articles] = await Promise.all([
db.select({ total: count() }).from(WebsiteArticles),
db
.select({ total: count() })
.from(WebsiteArticles)
.where(publishedFilter),
db
.select({
id: WebsiteArticles.id,
@@ -29,6 +39,7 @@ export async function GET(req: Request) {
createdAt: WebsiteArticles.createdAt,
})
.from(WebsiteArticles)
.where(publishedFilter)
.orderBy(desc(WebsiteArticles.createdAt))
.limit(take)
.offset(skip),
+10 -1
View File
@@ -1,4 +1,4 @@
import { count, desc, eq } from "drizzle-orm";
import { and, count, desc, eq, or, sql } from "drizzle-orm";
import { env } from "@/env";
import { apiJson } from "@/lib/api";
import { db, User, WebsiteArticles } from "@/lib/db";
@@ -24,6 +24,15 @@ export async function GET(_req: Request) {
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(4),
db.select({ total: count() }).from(User).where(eq(User.online, "1")),