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

+14
View File
@@ -1,4 +1,5 @@
import { desc, eq, inArray } from "drizzle-orm";
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import type { CSSProperties } from "react";
@@ -14,6 +15,19 @@ import {
import { formatDate } from "@/lib/format-date";
import { resolveHotelName } from "@/lib/hotel-name";
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("pages.applyStaff");
return {
title: t("title"),
description: t("subtitle"),
openGraph: {
title: t("title"),
description: t("subtitle"),
type: "website",
},
};
}
// Canonical Habbo badge image CDN (same base used by the profile page).
const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584";
+14
View File
@@ -1,4 +1,5 @@
import { asc, eq } from "drizzle-orm";
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import type { CSSProperties } from "react";
@@ -8,6 +9,19 @@ import { auth } from "@/lib/auth";
import { db, WebsiteStaffApplications, WebsiteTeams } from "@/lib/db";
import { resolveHotelName } from "@/lib/hotel-name";
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("pages.applyTeam");
return {
title: t("title"),
description: t("subtitle"),
openGraph: {
title: t("title"),
description: t("subtitle"),
type: "website",
},
};
}
// Canonical Habbo badge image CDN (same base used by the profile page).
const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584";
+13 -1
View File
@@ -1,9 +1,21 @@
import { asc } from "drizzle-orm";
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { db, WebsiteBadges } from "@/lib/db";
export const metadata = { title: "Badges" };
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("pages.badges");
return {
title: t("title"),
description: t("subtitle"),
openGraph: {
title: t("title"),
description: t("subtitle"),
type: "website",
},
};
}
// Canonical Habbo badge image CDN. A badge_key (e.g. "ADM") maps to a .gif here.
const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584";
+14 -1
View File
@@ -1,8 +1,21 @@
import type { Metadata } from "next";
import { useTranslations } from "next-intl";
import { getTranslations } from "next-intl/server";
import Link from "@/components/link";
import { ContentCard } from "@/components/public/ui";
export const metadata = { title: "Community" };
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("pages.community");
return {
title: t("title"),
description: t("subtitle"),
openGraph: {
title: t("title"),
description: t("subtitle"),
type: "website",
},
};
}
const LINKS = [
{
+10 -1
View File
@@ -1,9 +1,18 @@
import type { Metadata } from "next";
import { ContentCard } from "@/components/public/ui";
// Public API documentation. Static, hand-maintained from the routes that
// actually exist under src/app/api — keep this in sync when endpoints change.
export const metadata = { title: "API" };
export const metadata: Metadata = {
title: "Developers",
description: "Public API documentation for the hotel.",
openGraph: {
title: "Developers",
description: "Public API documentation for the hotel.",
type: "website",
},
};
type Method = "GET" | "POST" | "DELETE";
+10 -1
View File
@@ -1,4 +1,5 @@
import { desc, eq } from "drizzle-orm";
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { buyBadge } from "@/actions/draw-badge";
import { ContentCard, EmptyState, StatBlock } from "@/components/public/ui";
@@ -10,7 +11,15 @@ import { siteSettings } from "@/lib/services/site-settings";
// Reads the live users + website_drawbadges tables and writes credits/badges on
// purchase — must never be statically rendered.
export const metadata = { title: "Draw a Badge" };
export const metadata: Metadata = {
title: "Draw a Badge",
description: "Draw a random badge and add it to your collection.",
openGraph: {
title: "Draw a Badge",
description: "Draw a random badge and add it to your collection.",
type: "website",
},
};
const DEFAULT_PRICE = 50;
+13 -1
View File
@@ -1,4 +1,5 @@
import { count, desc, eq, inArray } from "drizzle-orm";
import type { Metadata } from "next";
import Image from "next/image";
import { getTranslations } from "next-intl/server";
import Link from "@/components/link";
@@ -12,7 +13,18 @@ import {
import { excerpt, slugify } from "@/lib/format";
import { formatDate } from "@/lib/format-date";
export const metadata = { title: "Events" };
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("pages.events");
return {
title: t("title"),
description: t("subtitle"),
openGraph: {
title: t("title"),
description: t("subtitle"),
type: "website",
},
};
}
export default async function EventsPage() {
const t = await getTranslations("pages.events");
+13 -1
View File
@@ -1,4 +1,5 @@
import { asc, eq, inArray, or } from "drizzle-orm";
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import type { CSSProperties } from "react";
@@ -9,7 +10,18 @@ import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
import { auth } from "@/lib/auth";
import { db, MessengerFriendships, User } from "@/lib/db";
export const metadata = { title: "Friends" };
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("pages.friends");
return {
title: t("title"),
description: t("emptySubtitle"),
openGraph: {
title: t("title"),
description: t("emptySubtitle"),
type: "website",
},
};
}
type SearchParams = Promise<{ removed?: string; error?: string }>;
+13 -1
View File
@@ -1,11 +1,23 @@
import { desc } from "drizzle-orm";
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import Link from "@/components/link";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { db, Guilds } from "@/lib/db";
import { excerpt } from "@/lib/format";
export const metadata = { title: "Guilds" };
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("pages.guilds");
return {
title: t("title"),
description: t("subtitle"),
openGraph: {
title: t("title"),
description: t("subtitle"),
type: "website",
},
};
}
type GuildCard = {
id: number;
+15
View File
@@ -1,6 +1,21 @@
import { asc } from "drizzle-orm";
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import Link from "@/components/link";
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("pages.help");
return {
title: t("title"),
description: t("subtitle"),
openGraph: {
title: t("title"),
description: t("subtitle"),
type: "website",
},
};
}
import { ContentCard, EmptyState } from "@/components/public/ui";
import {
db,
+11 -1
View File
@@ -1,12 +1,22 @@
import dynamic from "next/dynamic";
import type { ReactNode } from "react";
import MotionPageWrapper from "@/components/motion-page-wrapper";
import { Navigation } from "@/components/navigation";
import RadioPlayerGate from "@/components/public/radio-player-gate";
import { SiteFooter } from "@/components/site-footer";
import { SiteHeader } from "@/components/site-header";
import { TopHeader } from "@/components/top-header";
import { auth } from "@/lib/auth";
const RadioPlayerGate = dynamic(
() => import("@/components/public/radio-player-gate"),
{
loading: () => (
<div className="animate-pulse bg-[var(--admin-surface)] rounded h-12" />
),
ssr: false,
},
);
/**
* Public site chrome. Route group `(site)` keeps this off `/admin` and `/client`,
* so housekeeping is never constrained by the public max-w-7xl grid.
+13 -1
View File
@@ -1,4 +1,5 @@
import { desc, eq } from "drizzle-orm";
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import Link from "@/components/link";
import { ContentCard, EmptyState, RankBadge } from "@/components/public/ui";
@@ -10,7 +11,18 @@ import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
import { cached } from "@/lib/cache";
import { db, User, UsersCurrency, UsersSettings } from "@/lib/db";
export const metadata = { title: "Leaderboard" };
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("pages.leaderboard");
return {
title: t("title"),
description: t("subtitle", { currency: "credits" }),
openGraph: {
title: t("title"),
description: t("subtitle", { currency: "credits" }),
type: "website",
},
};
}
// Currency type ids (see UsersCurrency in src/db/schema.ts):
// Credits = -1 (lives on users.credits), Duckets = 0, Diamonds = 5.
+13 -1
View File
@@ -1,10 +1,22 @@
import { desc, eq, inArray } from "drizzle-orm";
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import { ContentCard, EmptyState, StatBlock } from "@/components/public/ui";
import { db, MarketplaceItems, User } from "@/lib/db";
import { formatDate } from "@/lib/format-date";
export const metadata = { title: "Marketplace" };
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("pages.marketplace");
return {
title: t("title"),
description: t("subtitle"),
openGraph: {
title: t("title"),
description: t("subtitle"),
type: "website",
},
};
}
// state == 1 → an active, unsold offer in the Arcturus marketplace.
const STATE_ACTIVE = 1;
+21 -3
View File
@@ -1,4 +1,4 @@
import { and, asc, eq, inArray } from "drizzle-orm";
import { and, asc, eq, inArray, or, sql } from "drizzle-orm";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { getTranslations } from "next-intl/server";
@@ -56,7 +56,16 @@ export async function generateMetadata({
shortStory: WebsiteArticles.shortStory,
})
.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)
.catch(() => []);
if (!article) return { title: "Article" };
@@ -108,7 +117,16 @@ export default async function ArticlePage({
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)
.catch(() => []);
return row ?? null;
+14
View File
@@ -1,8 +1,22 @@
import { count, desc, eq } from "drizzle-orm";
import type { Metadata } from "next";
import { headers } from "next/headers";
import Image from "next/image";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
export const metadata: Metadata = {
title: "Home",
description:
"An online virtual world where you can create your own avatar, make friends, chat, and build your own rooms.",
openGraph: {
title: "Home",
description:
"An online virtual world where you can create your own avatar, make friends, chat, and build your own rooms.",
type: "website",
},
};
import { AmbientOrbs } from "@/components/ambient-orbs";
import { AnimatedCounter } from "@/components/animated-counter";
import { HomeLoginForm } from "@/components/auth/home-login-form";
+13 -1
View File
@@ -1,4 +1,5 @@
import { desc } from "drizzle-orm";
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import {
type LightboxPhoto,
@@ -9,7 +10,18 @@ import { cached } from "@/lib/cache";
import { CameraWeb, db } from "@/lib/db";
import { formatDate } from "@/lib/format-date";
export const metadata = { title: "Photos" };
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("pages.photos");
return {
title: t("title"),
description: t("subtitle"),
openGraph: {
title: t("title"),
description: t("subtitle"),
type: "website",
},
};
}
type Photo = {
id: number;
+13 -1
View File
@@ -1,4 +1,5 @@
import { and, count, desc, inArray, isNull, lte, or } from "drizzle-orm";
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import Link from "@/components/link";
import { ContentCard, EmptyState } from "@/components/public/ui";
@@ -6,7 +7,18 @@ import { db, WebsitePoll, WebsitePollQuestion } from "@/lib/db";
import { excerpt } from "@/lib/format";
import { formatDate } from "@/lib/format-date";
export const metadata = { title: "Polls" };
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("pages.polls");
return {
title: t("title"),
description: t("subtitle"),
openGraph: {
title: t("title"),
description: t("subtitle"),
type: "website",
},
};
}
export default async function PollsPage() {
const t = await getTranslations("pages.polls");
+15
View File
@@ -1,6 +1,21 @@
import { asc, eq, inArray } from "drizzle-orm";
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import Link from "@/components/link";
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("pages.radio");
return {
title: t("title"),
description: t("subtitle"),
openGraph: {
title: t("title"),
description: t("subtitle"),
type: "website",
},
};
}
import { ContentCard, EmptyState, OnlineBadge } from "@/components/public/ui";
import { db, RadioSchedules, User } from "@/lib/db";
import { siteSettings } from "@/lib/services/site-settings";
+13 -1
View File
@@ -1,4 +1,5 @@
import { desc } from "drizzle-orm";
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import Link from "@/components/link";
import {
@@ -12,7 +13,18 @@ import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
import { cached } from "@/lib/cache";
import { db, User } from "@/lib/db";
export const metadata = { title: "Rankings" };
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("pages.rankings");
return {
title: t("title"),
description: t("subtitle"),
openGraph: {
title: t("title"),
description: t("subtitle"),
type: "website",
},
};
}
type TopUser = {
username: string;
+13 -1
View File
@@ -1,11 +1,23 @@
import { asc, count } from "drizzle-orm";
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import Link from "@/components/link";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { db, WebsiteRareValueCategories, WebsiteRareValues } from "@/lib/db";
import { siteSettings } from "@/lib/services/site-settings";
export const metadata = { title: "Rare values" };
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("pages.rares");
return {
title: t("title"),
description: t("subtitle"),
openGraph: {
title: t("title"),
description: t("subtitle"),
type: "website",
},
};
}
type CategoryRow = {
id: bigint;
+9 -1
View File
@@ -5,7 +5,15 @@ import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
import { SurfaceCard } from "@/components/surface-card";
import { db, Rooms, User } from "@/lib/db";
export const metadata: Metadata = { title: "Search" };
export const metadata: Metadata = {
title: "Search",
description: "Search for users and rooms in the hotel.",
openGraph: {
title: "Search",
description: "Search for users and rooms in the hotel.",
type: "website",
},
};
type SearchParams = Promise<{ q?: string }>;
+13 -1
View File
@@ -1,4 +1,5 @@
import { asc, eq } from "drizzle-orm";
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import type { CSSProperties } from "react";
import { buyShopArticle } from "@/actions/shop";
@@ -10,7 +11,18 @@ import { db, WebsiteShopArticles, WebsiteShopCategories } from "@/lib/db";
import { excerpt } from "@/lib/format";
import { creditsPerUnit } from "@/lib/services/paypal";
export const metadata = { title: "Shop" };
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("pages.shop");
return {
title: t("title"),
description: t("subtitle"),
openGraph: {
title: t("title"),
description: t("subtitle"),
type: "website",
},
};
}
function feedbackStyle(tone: "success" | "error"): CSSProperties {
const accent =
+13 -1
View File
@@ -1,11 +1,23 @@
import { asc, desc, eq, gte } from "drizzle-orm";
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import Link from "@/components/link";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { db, User, WebsiteTeams } from "@/lib/db";
import { siteSettings } from "@/lib/services/site-settings";
export const metadata = { title: "Staff" };
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("pages.staff");
return {
title: t("title"),
description: t("subtitle"),
openGraph: {
title: t("title"),
description: t("subtitle"),
type: "website",
},
};
}
type StaffMember = {
username: string;
+14 -1
View File
@@ -1,8 +1,8 @@
import { and, count, eq, gte, sql, sum } from "drizzle-orm";
import { ArrowLeftRight } from "lucide-react";
import dynamic from "next/dynamic";
import { redirect } from "next/navigation";
import { getLocale, getTranslations } from "next-intl/server";
import { RevenueChart } from "@/components/admin/revenue-chart";
import { StatsCard } from "@/components/admin/stats-card";
import { CurrencyIcon } from "@/components/shared/currency-icon";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -16,6 +16,19 @@ import {
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { redisCache } from "@/lib/redis-cache";
const RevenueChart = dynamic(
() =>
import("@/components/admin/revenue-chart").then((m) => ({
default: m.RevenueChart,
})),
{
loading: () => (
<div className="animate-pulse bg-[var(--admin-surface)] rounded h-48" />
),
ssr: false,
},
);
type EconomyData = {
totalCredits: number;
totalPixels: number;
+27 -2
View File
@@ -7,16 +7,41 @@ import {
TrendingUp,
Users,
} from "lucide-react";
import dynamic from "next/dynamic";
import { redirect } from "next/navigation";
import { getLocale, getTranslations } from "next-intl/server";
import { DashboardChart } from "@/components/admin/dashboard-chart";
import { PeakHoursHeatmap } from "@/components/admin/peak-hours-heatmap";
import { StatsCard } from "@/components/admin/stats-card";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Ban, db, Rooms, RoomTradeLog, User } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { redisCache } from "@/lib/redis-cache";
const DashboardChart = dynamic(
() =>
import("@/components/admin/dashboard-chart").then((m) => ({
default: m.DashboardChart,
})),
{
loading: () => (
<div className="animate-pulse bg-[var(--admin-surface)] rounded h-48" />
),
ssr: false,
},
);
const PeakHoursHeatmap = dynamic(
() =>
import("@/components/admin/peak-hours-heatmap").then((m) => ({
default: m.PeakHoursHeatmap,
})),
{
loading: () => (
<div className="animate-pulse bg-[var(--admin-surface)] rounded h-48" />
),
ssr: false,
},
);
type AnalyticsData = {
totalUsers: number;
onlineUsers: number;
+8 -11
View File
@@ -1,13 +1,14 @@
import { desc, inArray } from "drizzle-orm";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { dismissApplication } from "@/actions/admin-applications";
import { ApplicationActions } from "@/components/admin/application-actions";
import { StatusCard } from "@/components/admin/dashboard";
import Link from "@/components/link";
import { Button } from "@/components/ui/button";
import { db, User, WebsiteStaffApplications } from "@/lib/db";
import { formatDate } from "@/lib/format-date";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { logServerError } from "@/lib/server-log";
type ApplicationRow = {
id: bigint;
@@ -31,7 +32,8 @@ export default async function AdminApplications() {
.from(WebsiteStaffApplications)
.orderBy(desc(WebsiteStaffApplications.createdAt))
.limit(100);
} catch {
} catch (error) {
logServerError("applications.query_failed", error);
applications = [];
}
@@ -45,8 +47,8 @@ export default async function AdminApplications() {
.from(User)
.where(inArray(User.id, ids));
for (const u of users) userMap.set(u.id, u.username);
} catch {
// no DB — fall back to raw ids
} catch (error) {
logServerError("applications.user_lookup_failed", error);
}
}
@@ -93,12 +95,7 @@ export default async function AdminApplications() {
<p className="text-sm text-[var(--admin-text)] whitespace-pre-wrap m-0 mb-3">
{a.content}
</p>
<form action={dismissApplication}>
<input type="hidden" name="id" value={String(a.id)} />
<Button type="submit" variant="destructive">
{t("dismiss")}
</Button>
</form>
<ApplicationActions id={String(a.id)} />
</article>
);
})}
+8 -1
View File
@@ -1,5 +1,12 @@
import { redirect } from "next/navigation";
import { CatalogMaintenancePanel } from "@/components/admin/catalog/catalog-maintenance-panel";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function CatalogMaintenancePage() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.CATALOG_VIEW, session.user.rank)) {
redirect("/admin");
}
export default function CatalogMaintenancePage() {
return <CatalogMaintenancePanel />;
}
+141 -1
View File
@@ -1,7 +1,7 @@
import { asc, count, eq, inArray } from "drizzle-orm";
import { notFound, redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { disbandGuild } from "@/actions/admin-guilds";
import { disbandGuild, updateGuild } from "@/actions/admin-guilds";
import Link from "@/components/link";
import { Button } from "@/components/ui/button";
import { db, Guilds, GuildsForumsThreads, GuildsMembers, User } from "@/lib/db";
@@ -35,6 +35,10 @@ export default async function AdminGuildDetailPage({
dateCreated: Guilds.dateCreated,
state: Guilds.state,
forum: Guilds.forum,
readForum: Guilds.readForum,
postMessages: Guilds.postMessages,
postThreads: Guilds.postThreads,
modForum: Guilds.modForum,
})
.from(Guilds)
.where(eq(Guilds.id, id))
@@ -139,6 +143,142 @@ export default async function AdminGuildDetailPage({
</p>
) : null}
{canEdit ? (
<section>
<h2 className="admin-section-title">{t("edit")}</h2>
<form action={updateGuild} className="space-y-3">
<input type="hidden" name="id" value={guild.id} />
<div className="grid grid-cols-[repeat(auto-fit,minmax(200px,1fr))] gap-3">
<div>
<label htmlFor="guild-name" className="text-xs muted">
{t("formName")}
</label>
<input
id="guild-name"
type="text"
name="name"
maxLength={50}
defaultValue={guild.name}
className="w-full rounded border bg-[var(--admin-card)] px-3 py-1.5 text-sm"
/>
</div>
<div>
<label htmlFor="guild-description" className="text-xs muted">
{t("formDescription")}
</label>
<input
id="guild-description"
type="text"
name="description"
maxLength={250}
defaultValue={guild.description}
className="w-full rounded border bg-[var(--admin-card)] px-3 py-1.5 text-sm"
/>
</div>
<div>
<label htmlFor="guild-state" className="text-xs muted">
{t("formState")}
</label>
<select
id="guild-state"
name="state"
defaultValue={guild.state}
className="w-full rounded border bg-[var(--admin-card)] px-3 py-1.5 text-sm"
>
<option value="0">0 — Normal</option>
<option value="1">1 — Locked</option>
<option value="2">2 — Exclusive</option>
</select>
</div>
<div>
<label htmlFor="guild-forum" className="text-xs muted">
{t("formForum")}
</label>
<select
id="guild-forum"
name="forum"
defaultValue={guild.forum}
className="w-full rounded border bg-[var(--admin-card)] px-3 py-1.5 text-sm"
>
<option value="0">0 — Off</option>
<option value="1">1 — On</option>
</select>
</div>
<div>
<label htmlFor="guild-readForum" className="text-xs muted">
{t("formReadForum")}
</label>
<select
id="guild-readForum"
name="readForum"
defaultValue={guild.readForum}
className="w-full rounded border bg-[var(--admin-card)] px-3 py-1.5 text-sm"
>
<option value="EVERYONE">EVERYONE</option>
<option value="OWNER">OWNER</option>
<option value="ADMIN">ADMIN</option>
<option value="MEMBER">MEMBER</option>
<option value="NONE">NONE</option>
</select>
</div>
<div>
<label htmlFor="guild-postMessages" className="text-xs muted">
{t("formPostMessages")}
</label>
<select
id="guild-postMessages"
name="postMessages"
defaultValue={guild.postMessages}
className="w-full rounded border bg-[var(--admin-card)] px-3 py-1.5 text-sm"
>
<option value="EVERYONE">EVERYONE</option>
<option value="OWNER">OWNER</option>
<option value="ADMIN">ADMIN</option>
<option value="MEMBER">MEMBER</option>
<option value="NONE">NONE</option>
</select>
</div>
<div>
<label htmlFor="guild-postThreads" className="text-xs muted">
{t("formPostThreads")}
</label>
<select
id="guild-postThreads"
name="postThreads"
defaultValue={guild.postThreads}
className="w-full rounded border bg-[var(--admin-card)] px-3 py-1.5 text-sm"
>
<option value="EVERYONE">EVERYONE</option>
<option value="OWNER">OWNER</option>
<option value="ADMIN">ADMIN</option>
<option value="MEMBER">MEMBER</option>
<option value="NONE">NONE</option>
</select>
</div>
<div>
<label htmlFor="guild-modForum" className="text-xs muted">
{t("formModForum")}
</label>
<select
id="guild-modForum"
name="modForum"
defaultValue={guild.modForum}
className="w-full rounded border bg-[var(--admin-card)] px-3 py-1.5 text-sm"
>
<option value="ADMINS">ADMINS</option>
<option value="OWNER">OWNER</option>
<option value="MEMBER">MEMBER</option>
<option value="NONE">NONE</option>
</select>
</div>
</div>
<Button type="submit" variant="ghost" size="sm">
{t("save")}
</Button>
</form>
</section>
) : null}
<div className="grid grid-cols-[repeat(auto-fit,minmax(160px,1fr))] gap-3">
<div className="admin-card">
<div className="text-xs muted">{t("colOwner")}</div>
+14 -1
View File
@@ -1,7 +1,20 @@
import dynamic from "next/dynamic";
import { redirect } from "next/navigation";
import { AdminMediaGrid } from "@/components/admin/media-grid";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
const AdminMediaGrid = dynamic(
() =>
import("@/components/admin/media-grid").then((m) => ({
default: m.AdminMediaGrid,
})),
{
loading: () => (
<div className="animate-pulse bg-[var(--admin-surface)] rounded h-48" />
),
ssr: false,
},
);
export default async function AdminMediaPage() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.PAGES_VIEW, session.user.rank)) {
@@ -104,6 +104,7 @@ export function ModActionsClient() {
value={userId}
onChange={(e) => setUserId(e.target.value)}
placeholder="Enter user ID"
aria-label="User ID"
/>
</div>
@@ -144,6 +145,7 @@ export function ModActionsClient() {
onChange={(e) => setMuteDuration(e.target.value)}
className="w-24"
placeholder="Min"
aria-label="Mute duration in minutes"
/>
<Button
variant="outline"
@@ -215,6 +217,7 @@ export function ModActionsClient() {
value={roomId}
onChange={(e) => setRoomId(e.target.value)}
placeholder="Enter room ID"
aria-label="Room ID"
/>
</div>
<Button
+147 -32
View File
@@ -6,6 +6,8 @@ import {
createValue,
deleteCategory,
deleteValue,
updateCategory,
updateValue,
} from "@/actions/admin-rare-values";
import { StatusCard } from "@/components/admin/dashboard";
import { Button } from "@/components/ui/button";
@@ -120,12 +122,49 @@ export default async function AdminRareValues() {
{t("itemCount", { count: rows.length })}
</span>
</div>
<form action={deleteCategory}>
<input type="hidden" name="id" value={catId} />
<Button type="submit" variant="destructive">
{t("deleteCategory")}
</Button>
</form>
<div className="flex gap-2 items-center">
<details className="group">
<summary className="cursor-pointer list-none text-xs font-bold text-[var(--admin-accent)] hover:underline">
{t("editCategory")}
</summary>
<form
action={updateCategory}
className="mt-2 flex gap-2 flex-wrap"
>
<input type="hidden" name="id" value={catId} />
<input
name="name"
defaultValue={cat.name}
placeholder={t("categoryForm.namePlaceholder")}
required
className="flex-1 min-w-[140px]"
/>
<input
name="badge"
defaultValue={cat.badge}
placeholder={t("badgePlaceholder")}
required
/>
<input
name="priority"
type="number"
min={1}
defaultValue={cat.priority}
placeholder={t("priorityPlaceholder")}
className="w-[110px]"
/>
<Button type="submit" variant="default">
{t("categoryForm.save")}
</Button>
</form>
</details>
<form action={deleteCategory}>
<input type="hidden" name="id" value={catId} />
<Button type="submit" variant="destructive">
{t("deleteCategory")}
</Button>
</form>
</div>
</div>
<table style={{ marginTop: "0.75rem" }}>
@@ -141,32 +180,108 @@ export default async function AdminRareValues() {
</tr>
</thead>
<tbody>
{rows.map((v) => (
<tr key={String(v.id)}>
<td>{v.name}</td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{v.itemId ?? "—"}
</td>
<td>{v.creditValue ?? "—"}</td>
<td>{v.currencyValue ?? "—"}</td>
<td>
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-accent)]/18 text-[var(--admin-text)]">
{v.currencyType}
</span>
</td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{v.furnitureIcon}
</td>
<td>
<form action={deleteValue}>
<input type="hidden" name="id" value={String(v.id)} />
<Button type="submit" variant="destructive">
✕
</Button>
</form>
</td>
</tr>
))}
{rows.map((v) => {
const vId = String(v.id);
return (
<tr key={vId}>
<td>{v.name}</td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{v.itemId ?? "—"}
</td>
<td>{v.creditValue ?? "—"}</td>
<td>{v.currencyValue ?? "—"}</td>
<td>
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-accent)]/18 text-[var(--admin-text)]">
{v.currencyType}
</span>
</td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{v.furnitureIcon}
</td>
<td>
<div className="flex gap-1 items-center">
<details className="group">
<summary className="cursor-pointer list-none text-[0.72rem] font-bold text-[var(--admin-accent)] hover:underline">
{t("editValue")}
</summary>
<form
action={updateValue}
className="mt-2 flex gap-1 flex-wrap items-end"
>
<input type="hidden" name="id" value={vId} />
<input
type="hidden"
name="categoryId"
value={catId}
/>
<input
name="name"
defaultValue={v.name}
placeholder={t(
"itemForm.itemNamePlaceholder",
)}
required
className="min-w-[120px]"
/>
<input
name="itemId"
type="number"
min={1}
defaultValue={v.itemId ?? ""}
placeholder={t("itemForm.itemIdPlaceholder")}
className="w-[90px]"
/>
<input
name="furnitureIcon"
defaultValue={v.furnitureIcon}
placeholder={t("itemForm.iconPlaceholder")}
required
className="min-w-[120px]"
/>
<input
name="creditValue"
defaultValue={v.creditValue ?? ""}
placeholder={t("itemForm.creditsPlaceholder")}
className="w-[110px]"
/>
<input
name="currencyValue"
defaultValue={v.currencyValue ?? ""}
placeholder={t(
"itemForm.currencyValuePlaceholder",
)}
className="w-[110px]"
/>
<select
name="currencyType"
defaultValue={v.currencyType}
>
{CURRENCY_TYPES.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
<Button
type="submit"
variant="default"
className="text-xs"
>
{t("itemForm.save")}
</Button>
</form>
</details>
<form action={deleteValue}>
<input type="hidden" name="id" value={vId} />
<Button type="submit" variant="destructive">
✕
</Button>
</form>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
{rows.length === 0 ? (
+8 -1
View File
@@ -1,6 +1,13 @@
import { redirect } from "next/navigation";
import { CatalogMaintenancePanel } from "@/components/admin/catalog/catalog-maintenance-panel";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function StudioCatalogMaintenancePage() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.CATALOG_VIEW, session.user.rank)) {
redirect("/admin");
}
export default function StudioCatalogMaintenancePage() {
return (
<div className="min-h-0">
<CatalogMaintenancePanel />
+131 -9
View File
@@ -2,7 +2,11 @@
import { useTranslations } from "next-intl";
import { useState } from "react";
import { createVoucher, deleteVoucher } from "@/actions/admin-vouchers";
import {
createVoucher,
deleteVoucher,
updateVoucher,
} from "@/actions/admin-vouchers";
import { ConfirmDialog } from "@/components/admin/confirm-dialog";
import { StatusCard } from "@/components/admin/dashboard";
import { CurrencyIcon } from "@/components/shared/currency-icon";
@@ -33,10 +37,15 @@ export function VouchersClient({
const t = useTranslations("pages.admin.vouchers");
const { run, isPending } = useServerAction();
const [pendingDelete, setPendingDelete] = useState<VoucherRow | null>(null);
const [editingVoucher, setEditingVoucher] = useState<VoucherRow | null>(null);
const [code, setCode] = useState("");
const [amount, setAmount] = useState("");
const [maxUses, setMaxUses] = useState("1");
const [expiresAt, setExpiresAt] = useState("");
const [editCode, setEditCode] = useState("");
const [editAmount, setEditAmount] = useState("");
const [editMaxUses, setEditMaxUses] = useState("1");
const [editExpiresAt, setEditExpiresAt] = useState("");
function handleCreate(e: React.FormEvent) {
e.preventDefault();
@@ -72,6 +81,36 @@ export function VouchersClient({
});
}
function handleStartEdit(v: VoucherRow) {
setEditingVoucher(v);
setEditCode(v.code);
setEditAmount(String(v.amount));
setEditMaxUses(String(v.maxUses));
setEditExpiresAt(
v.expiresAt ? new Date(v.expiresAt).toISOString().slice(0, 16) : "",
);
}
function handleUpdate(e: React.FormEvent) {
e.preventDefault();
if (!editingVoucher || !canEdit) return;
run(
() =>
updateVoucher({
id: editingVoucher.id,
code: editCode,
amount: Number(editAmount),
maxUses: Number(editMaxUses),
expiresAt: editExpiresAt || undefined,
}),
{
successMessage: t("updated"),
errorMessage: t("updateError"),
onSuccess: () => setEditingVoucher(null),
},
);
}
return (
<main>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
@@ -158,6 +197,79 @@ export function VouchersClient({
</form>
)}
{canEdit && editingVoucher && (
<form onSubmit={handleUpdate} className="admin-card mb-6">
<h3 style={{ marginTop: 0 }}>
{t("edit")} — {editingVoucher.code}
</h3>
<div className="mb-3 grid grid-cols-1 gap-4 md:grid-cols-2">
<label className="flex flex-col gap-1.5">
<span className="text-sm theme-text-muted dark:theme-text-muted">
{t("form.code")}
</span>
<input
value={editCode}
onChange={(e) => setEditCode(e.target.value)}
placeholder={t("form.codePlaceholder")}
maxLength={255}
required
disabled={isPending}
/>
</label>
<label className="flex flex-col gap-1.5">
<span className="text-sm theme-text-muted dark:theme-text-muted">
{t("form.amount")}
</span>
<input
value={editAmount}
onChange={(e) => setEditAmount(e.target.value)}
type="number"
min={1}
required
disabled={isPending}
/>
</label>
<label className="flex flex-col gap-1.5">
<span className="text-sm theme-text-muted dark:theme-text-muted">
{t("form.maxUses")}
</span>
<input
value={editMaxUses}
onChange={(e) => setEditMaxUses(e.target.value)}
type="number"
min={1}
required
disabled={isPending}
/>
</label>
<label className="flex flex-col gap-1.5">
<span className="text-sm theme-text-muted dark:theme-text-muted">
{t("form.expiresAt")}
</span>
<input
value={editExpiresAt}
onChange={(e) => setEditExpiresAt(e.target.value)}
type="datetime-local"
disabled={isPending}
/>
</label>
</div>
<div className="flex items-center gap-2">
<Button type="submit" variant="default" disabled={isPending}>
{isPending ? t("form.saving") : t("form.save")}
</Button>
<Button
type="button"
variant="ghost"
disabled={isPending}
onClick={() => setEditingVoucher(null)}
>
{t("form.cancel")}
</Button>
</div>
</form>
)}
<section className="mt-6">
<h2 className="admin-section-title">
{t("title")} ({vouchers.length})
@@ -216,14 +328,24 @@ export function VouchersClient({
</td>
{canEdit && (
<td>
<Button
type="button"
variant="destructive"
disabled={isPending}
onClick={() => setPendingDelete(v)}
>
{t("delete")}
</Button>
<div className="flex items-center gap-2">
<Button
type="button"
variant="default"
disabled={isPending}
onClick={() => handleStartEdit(v)}
>
{t("edit")}
</Button>
<Button
type="button"
variant="destructive"
disabled={isPending}
onClick={() => setPendingDelete(v)}
>
{t("delete")}
</Button>
</div>
</td>
)}
</tr>
+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")),
+4 -4
View File
@@ -74,10 +74,10 @@ export default async function ModBansPage() {
<table>
<thead>
<tr>
<th>{t("colUserId")}</th>
<th>{t("colType")}</th>
<th>{t("colReason")}</th>
<th>{t("colExpires")}</th>
<th scope="col">{t("colUserId")}</th>
<th scope="col">{t("colType")}</th>
<th scope="col">{t("colReason")}</th>
<th scope="col">{t("colExpires")}</th>
</tr>
</thead>
<tbody>
+11 -3
View File
@@ -15,6 +15,7 @@ import { requireModPermission } from "@/lib/admin/guard";
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
import { db, SupportTickets, User } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { logServerError } from "@/lib/server-log";
const SORT_COLS = {
id: SupportTickets.id,
@@ -55,7 +56,8 @@ export default async function ModCfhListPage({
.where(like(User.username, `%${q}%`))
.limit(50);
userIds = users.map((u) => u.id);
} catch {
} catch (error) {
logServerError("cfh.user_search_failed", error, { query: q });
userIds = [];
}
@@ -91,7 +93,10 @@ export default async function ModCfhListPage({
.from(SupportTickets)
.where(where)
.then((rows) => rows[0]?.total ?? 0)
.catch(() => 0);
.catch((error) => {
logServerError("cfh.count_failed", error);
return 0;
});
const pagination = calcPagination(total, parsed.page, parsed.perPage);
const tickets = await db
@@ -101,7 +106,10 @@ export default async function ModCfhListPage({
.orderBy(finalOrder)
.offset(pagination.offset)
.limit(pagination.perPage)
.catch(() => []);
.catch((error) => {
logServerError("cfh.query_failed", error);
return [];
});
const userIds = [
...new Set([
+9 -2
View File
@@ -8,6 +8,7 @@ import { calcPagination, parseListParams } from "@/lib/admin-helpers";
import { db, User } from "@/lib/db";
import { getAvatarUrl } from "@/lib/imager";
import { PERMS } from "@/lib/permissions";
import { logServerError } from "@/lib/server-log";
import type { DataTableColumn } from "@/types/common";
type ModUserRow = {
@@ -139,7 +140,10 @@ export default async function ModUsersPage({
.from(User)
.where(where)
.then((rows) => rows[0]?.total ?? 0)
.catch(() => 0),
.catch((error) => {
logServerError("users.count_failed", error);
return 0;
}),
db
.select({
id: User.id,
@@ -155,7 +159,10 @@ export default async function ModUsersPage({
.orderBy(finalOrder)
.offset((parsed.page - 1) * parsed.perPage)
.limit(parsed.perPage)
.catch(() => [] as ModUserRow[]),
.catch((error) => {
logServerError("users.query_failed", error);
return [] as ModUserRow[];
}),
]);
const total = totals;