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;