feat: comprehensive CMS improvements
- 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:
1 parent
b810b16672
commit
30c95b1a5c
78 files changed
+2252
-134
No files matched your search
@@ -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) });
|
||||
},
|
||||
);
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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")),
|
||||
|
||||
Reference in new issue
Block a user