diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..f477b863 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 10 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - run: pnpm typecheck + + - run: pnpm test + env: + SKIP_ENV_VALIDATION: true diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 52b1e328..b0252f91 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1569,6 +1569,7 @@ model WebsiteArticles { createdAt DateTime? @map("created_at") @db.Timestamp(0) updatedAt DateTime? @map("updated_at") @db.Timestamp(0) + @@index([userId]) @@map("website_articles") } @@ -1662,6 +1663,8 @@ model WebsiteArticleReactions { reaction String @db.VarChar(50) active Boolean @default(true) + @@index([articleId]) + @@index([userId]) @@map("website_article_reactions") } @@ -1753,6 +1756,8 @@ model WebsiteArticleComments { createdAt DateTime? @map("created_at") @db.Timestamp(0) updatedAt DateTime? @map("updated_at") @db.Timestamp(0) + @@index([articleId]) + @@index([userId]) @@map("website_article_comments") } @@ -1841,6 +1846,8 @@ model WebsiteHelpCenterTickets { createdAt DateTime? @map("created_at") @db.Timestamp(0) updatedAt DateTime? @map("updated_at") @db.Timestamp(0) + @@index([userId]) + @@index([categoryId]) @@map("website_help_center_tickets") } @@ -1877,6 +1884,7 @@ model WebsiteShopArticles { createdAt DateTime? @map("created_at") @db.Timestamp(0) updatedAt DateTime? @map("updated_at") @db.Timestamp(0) + @@index([categoryId]) @@map("website_shop_articles") } @@ -2151,6 +2159,7 @@ model RadioSongRequests { createdAt DateTime? @map("created_at") @db.Timestamp(0) updatedAt DateTime? @map("updated_at") @db.Timestamp(0) + @@index([userId]) @@map("radio_song_requests") } @@ -2361,9 +2370,11 @@ model StaffActivities { description String @db.Text ipAddress String? @map("ip_address") @db.VarChar(45) metadata Json? @db.Json - createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(0) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(0) updatedAt DateTime? @map("updated_at") @db.Timestamp(0) + @@index([userId]) + @@index([action]) @@map("staff_activities") } diff --git a/src/actions/admin-articles.ts b/src/actions/admin-articles.ts index 16e59054..c7f1aa58 100644 --- a/src/actions/admin-articles.ts +++ b/src/actions/admin-articles.ts @@ -48,16 +48,20 @@ export async function createArticle(formData: FormData): Promise { export async function updateArticle(formData: FormData): Promise { await requireStaff(); const id = BigInt(String(formData.get("id"))); - await prisma.websiteArticles.update({ - where: { id }, - data: { - title: String(formData.get("title") ?? "").trim().slice(0, 255), - shortStory: String(formData.get("shortStory") ?? "").trim().slice(0, 255), - fullStory: String(formData.get("fullStory") ?? "").trim(), - image: String(formData.get("image") ?? "").trim().slice(0, 255), - updatedAt: new Date(), - }, - }); + try { + await prisma.websiteArticles.update({ + where: { id }, + data: { + title: String(formData.get("title") ?? "").trim().slice(0, 255), + shortStory: String(formData.get("shortStory") ?? "").trim().slice(0, 255), + fullStory: String(formData.get("fullStory") ?? "").trim(), + image: String(formData.get("image") ?? "").trim().slice(0, 255), + updatedAt: new Date(), + }, + }); + } catch { + redirect("/admin/articles?error=Update failed"); + } revalidatePath(`/admin/articles/${id}`); redirect("/admin/articles"); } @@ -65,8 +69,14 @@ export async function updateArticle(formData: FormData): Promise { export async function deleteArticle(formData: FormData): Promise { await requireStaff(); const id = BigInt(String(formData.get("id"))); - await prisma.websiteArticleReactions.deleteMany({ where: { articleId: id } }); - await prisma.websiteArticleComments.deleteMany({ where: { articleId: id } }); - await prisma.websiteArticles.delete({ where: { id } }); + try { + await prisma.$transaction([ + prisma.websiteArticleReactions.deleteMany({ where: { articleId: id } }), + prisma.websiteArticleComments.deleteMany({ where: { articleId: id } }), + prisma.websiteArticles.delete({ where: { id } }), + ]); + } catch { + redirect("/admin/articles?error=Delete failed"); + } redirect("/admin/articles"); } diff --git a/src/app/admin/loading.tsx b/src/app/admin/loading.tsx new file mode 100644 index 00000000..1250b9e0 --- /dev/null +++ b/src/app/admin/loading.tsx @@ -0,0 +1,7 @@ +export default function AdminLoading() { + return ( +
+
+
+ ); +} diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts index 4eae72c1..67ce3ba2 100644 --- a/src/app/api/health/route.ts +++ b/src/app/api/health/route.ts @@ -1,24 +1,42 @@ import { apiJson } from "@/lib/api"; import { prisma } from "@/lib/prisma"; import { rcon } from "@/lib/services/rcon"; +import { env } from "@/env"; export const dynamic = "force-dynamic"; /** - * Ops health probe: database reachability, emulator RCON reachability, and - * runtime info. Returns HTTP 200 always (read the `status`/`database` fields), - * so it's safe for uptime monitors that only care about reachability. + * Ops health probe: database reachability, emulator RCON reachability, SMTP + * (when configured), and runtime info. Returns HTTP 200 always (read the + * `status`/`database` fields), so it's safe for uptime monitors that only care + * about reachability. */ export async function GET() { const database = await prisma .$queryRaw`SELECT 1`.then(() => true) .catch(() => false); + const emulator = await rcon.send("ping", null).catch(() => false); + let smtp = null; + if (env.SMTP_HOST) { + const nodemailer = await import("nodemailer"); + const test = nodemailer.createTransport({ + host: env.SMTP_HOST, + port: env.SMTP_PORT, + secure: env.SMTP_SECURE, + auth: env.SMTP_USER + ? { user: env.SMTP_USER, pass: env.SMTP_PASSWORD ?? "" } + : undefined, + }); + smtp = await test.verify().then(() => true).catch(() => false); + } + return apiJson({ status: database ? "ok" : "degraded", database, emulator, + smtp, node: process.version, uptime: Math.round(process.uptime()), time: new Date().toISOString(), diff --git a/src/app/badges/page.tsx b/src/app/badges/page.tsx index 7adbf7d9..42ddd35d 100644 --- a/src/app/badges/page.tsx +++ b/src/app/badges/page.tsx @@ -2,7 +2,7 @@ import { getTranslations } from "next-intl/server"; import { ContentCard, EmptyState } from "@/components/public/ui"; import { prisma } from "@/lib/prisma"; -export const dynamic = "force-dynamic"; +export const revalidate = 300; export const metadata = { title: "Badges" }; diff --git a/src/app/community/page.tsx b/src/app/community/page.tsx index a5f2bb16..39376041 100644 --- a/src/app/community/page.tsx +++ b/src/app/community/page.tsx @@ -2,8 +2,6 @@ import { useTranslations } from "next-intl"; import Link from "next/link"; import { ContentCard } from "@/components/public/ui"; -export const dynamic = "force-dynamic"; - export const metadata = { title: "Community" }; const LINKS = [ diff --git a/src/app/developers/page.tsx b/src/app/developers/page.tsx index dc35ef84..7055e5d9 100644 --- a/src/app/developers/page.tsx +++ b/src/app/developers/page.tsx @@ -2,7 +2,6 @@ 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 dynamic = "force-dynamic"; export const metadata = { title: "API" }; diff --git a/src/app/guilds/[id]/forum/page.tsx b/src/app/guilds/[id]/forum/page.tsx index 10854f23..a46615f7 100644 --- a/src/app/guilds/[id]/forum/page.tsx +++ b/src/app/guilds/[id]/forum/page.tsx @@ -4,7 +4,7 @@ import { notFound } from "next/navigation"; import { ContentCard, EmptyState } from "@/components/public/ui"; import { prisma } from "@/lib/prisma"; -export const dynamic = "force-dynamic"; +export const revalidate = 300; type ThreadRow = { id: number; diff --git a/src/app/guilds/[id]/page.tsx b/src/app/guilds/[id]/page.tsx index a30f3965..24c80127 100644 --- a/src/app/guilds/[id]/page.tsx +++ b/src/app/guilds/[id]/page.tsx @@ -6,7 +6,7 @@ import { avatarImageUrl } from "@/lib/format"; import { prisma } from "@/lib/prisma"; import { siteSettings } from "@/lib/services/site-settings"; -export const dynamic = "force-dynamic"; +export const revalidate = 300; type GuildView = { id: number; diff --git a/src/app/guilds/page.tsx b/src/app/guilds/page.tsx index e9a73ab3..f61d73b3 100644 --- a/src/app/guilds/page.tsx +++ b/src/app/guilds/page.tsx @@ -4,7 +4,7 @@ import { ContentCard, EmptyState } from "@/components/public/ui"; import { excerpt } from "@/lib/format"; import { prisma } from "@/lib/prisma"; -export const dynamic = "force-dynamic"; +export const revalidate = 300; export const metadata = { title: "Guilds" }; diff --git a/src/app/help/[category]/page.tsx b/src/app/help/[category]/page.tsx index a5623261..b6fe5bb3 100644 --- a/src/app/help/[category]/page.tsx +++ b/src/app/help/[category]/page.tsx @@ -6,7 +6,7 @@ import { prisma } from "@/lib/prisma"; import { sanitize } from "@/lib/sanitize"; import { siteSettings } from "@/lib/services/site-settings"; -export const dynamic = "force-dynamic"; +export const revalidate = 300; // Asset path matches AtomCMS's Blade view: asset('/assets/images/help-center/'). const HELP_IMAGE_BASE = "/assets/images/help-center"; diff --git a/src/app/help/page.tsx b/src/app/help/page.tsx index 6c5ddb67..023b64a5 100644 --- a/src/app/help/page.tsx +++ b/src/app/help/page.tsx @@ -3,7 +3,7 @@ import Link from "next/link"; import { ContentCard, EmptyState } from "@/components/public/ui"; import { prisma } from "@/lib/prisma"; -export const dynamic = "force-dynamic"; +export const revalidate = 300; type HelpCategory = { id: bigint; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 2afd8ceb..54fd2f53 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -30,10 +30,20 @@ const pixelFont = Pixelify_Sans({ }); export async function generateMetadata(): Promise { + const appUrl = process.env.APP_URL ?? "http://localhost:3000"; const hotel = (await siteSettings.get("hotel_name", "Atom")) ?? "Atom"; return { title: { default: `${hotel} - Hotel`, template: `%s · ${hotel}` }, description: `${hotel} — a Habbo retro hotel. Hang out, collect furni and meet friends.`, + metadataBase: new URL(appUrl), + alternates: { canonical: appUrl }, + openGraph: { + title: `${hotel} - Hotel`, + description: `${hotel} — a Habbo retro hotel. Hang out, collect furni and meet friends.`, + type: "website", + siteName: hotel, + }, + twitter: { card: "summary_large_image", title: `${hotel} - Hotel` }, }; } diff --git a/src/app/leaderboard/page.tsx b/src/app/leaderboard/page.tsx index 3d3f687b..ca497822 100644 --- a/src/app/leaderboard/page.tsx +++ b/src/app/leaderboard/page.tsx @@ -5,7 +5,7 @@ import { avatarImageUrl } from "@/lib/format"; import { prisma } from "@/lib/prisma"; import { siteSettings } from "@/lib/services/site-settings"; -export const dynamic = "force-dynamic"; +export const revalidate = 300; export const metadata = { title: "Leaderboard" }; diff --git a/src/app/loading.tsx b/src/app/loading.tsx new file mode 100644 index 00000000..12994fab --- /dev/null +++ b/src/app/loading.tsx @@ -0,0 +1,7 @@ +export default function RootLoading() { + return ( +
+
+
+ ); +} diff --git a/src/app/maintenance/page.tsx b/src/app/maintenance/page.tsx index 16b1ec9a..86bf9844 100644 --- a/src/app/maintenance/page.tsx +++ b/src/app/maintenance/page.tsx @@ -2,7 +2,7 @@ import { getTranslations } from "next-intl/server"; import { ContentCard } from "@/components/public/ui"; import { siteSettings } from "@/lib/services/site-settings"; -export const dynamic = "force-dynamic"; +export const revalidate = 300; export default async function MaintenancePage() { const t = await getTranslations("pages.maintenance"); diff --git a/src/app/marketplace/page.tsx b/src/app/marketplace/page.tsx index c1954355..fe54f5a7 100644 --- a/src/app/marketplace/page.tsx +++ b/src/app/marketplace/page.tsx @@ -2,7 +2,7 @@ import { getTranslations } from "next-intl/server"; import { ContentCard, EmptyState, StatBlock } from "@/components/public/ui"; import { prisma } from "@/lib/prisma"; -export const dynamic = "force-dynamic"; +export const revalidate = 300; export const metadata = { title: "Marketplace" }; diff --git a/src/app/news/[slug]/page.tsx b/src/app/news/[slug]/page.tsx index 1b18c373..00534756 100644 --- a/src/app/news/[slug]/page.tsx +++ b/src/app/news/[slug]/page.tsx @@ -17,12 +17,24 @@ export async function generateMetadata({ }: { params: Promise<{ slug: string }>; }): Promise { + const appUrl = process.env.APP_URL ?? "http://localhost:3000"; const { slug } = await params; const article = await prisma.websiteArticles .findUnique({ where: { slug }, select: { title: true, shortStory: true } }) .catch(() => null); if (!article) return { title: "Article" }; - return { title: article.title, description: excerpt(article.shortStory, 160) }; + return { + title: article.title, + description: excerpt(article.shortStory, 160), + alternates: { canonical: `${appUrl}/news/${slug}` }, + openGraph: { + title: article.title, + description: excerpt(article.shortStory, 160), + type: "article", + url: `${appUrl}/news/${slug}`, + }, + twitter: { card: "summary", title: article.title }, + }; } // The reaction set offered by the voting UI. Must stay in sync with diff --git a/src/app/news/loading.tsx b/src/app/news/loading.tsx new file mode 100644 index 00000000..197ef694 --- /dev/null +++ b/src/app/news/loading.tsx @@ -0,0 +1,7 @@ +export default function NewsLoading() { + return ( +
+
+
+ ); +} diff --git a/src/app/news/page.tsx b/src/app/news/page.tsx index c4544e09..556229ec 100644 --- a/src/app/news/page.tsx +++ b/src/app/news/page.tsx @@ -4,7 +4,7 @@ import { ContentCard, EmptyState } from "@/components/public/ui"; import { excerpt } from "@/lib/format"; import { prisma } from "@/lib/prisma"; -export const dynamic = "force-dynamic"; +export const revalidate = 300; export const metadata = { title: "News" }; diff --git a/src/app/photos/page.tsx b/src/app/photos/page.tsx index 29600366..6a752ead 100644 --- a/src/app/photos/page.tsx +++ b/src/app/photos/page.tsx @@ -3,7 +3,7 @@ import { ContentCard, EmptyState } from "@/components/public/ui"; import { PhotoLightbox, type LightboxPhoto } from "@/components/public/photo-lightbox"; import { prisma } from "@/lib/prisma"; -export const dynamic = "force-dynamic"; +export const revalidate = 300; export const metadata = { title: "Photos" }; diff --git a/src/app/radio/contests/[id]/page.tsx b/src/app/radio/contests/[id]/page.tsx index 01cd0433..9a8d0fae 100644 --- a/src/app/radio/contests/[id]/page.tsx +++ b/src/app/radio/contests/[id]/page.tsx @@ -3,7 +3,7 @@ import { notFound } from "next/navigation"; import { ContentCard } from "@/components/public/ui"; import { prisma } from "@/lib/prisma"; -export const dynamic = "force-dynamic"; +export const revalidate = 300; function formatDate(d: Date | null | undefined): string { return d ? d.toISOString().slice(0, 19).replace("T", " ") : ""; diff --git a/src/app/radio/contests/page.tsx b/src/app/radio/contests/page.tsx index be4428a4..d8ee4781 100644 --- a/src/app/radio/contests/page.tsx +++ b/src/app/radio/contests/page.tsx @@ -3,7 +3,7 @@ import Link from "next/link"; import { ContentCard, EmptyState } from "@/components/public/ui"; import { prisma } from "@/lib/prisma"; -export const dynamic = "force-dynamic"; +export const revalidate = 300; function formatDate(d: Date | null | undefined): string { return d ? d.toISOString().slice(0, 10) : ""; diff --git a/src/app/radio/giveaways/[id]/page.tsx b/src/app/radio/giveaways/[id]/page.tsx index 846c2a34..abf3238e 100644 --- a/src/app/radio/giveaways/[id]/page.tsx +++ b/src/app/radio/giveaways/[id]/page.tsx @@ -3,7 +3,7 @@ import { notFound } from "next/navigation"; import { ContentCard } from "@/components/public/ui"; import { prisma } from "@/lib/prisma"; -export const dynamic = "force-dynamic"; +export const revalidate = 300; function formatDate(d: Date | null | undefined): string { return d ? d.toISOString().slice(0, 19).replace("T", " ") : ""; diff --git a/src/app/radio/giveaways/page.tsx b/src/app/radio/giveaways/page.tsx index b582cd02..3faf9ae9 100644 --- a/src/app/radio/giveaways/page.tsx +++ b/src/app/radio/giveaways/page.tsx @@ -3,7 +3,7 @@ import Link from "next/link"; import { ContentCard, EmptyState } from "@/components/public/ui"; import { prisma } from "@/lib/prisma"; -export const dynamic = "force-dynamic"; +export const revalidate = 300; function formatDate(d: Date | null | undefined): string { return d ? d.toISOString().slice(0, 10) : ""; diff --git a/src/app/radio/leaderboard/page.tsx b/src/app/radio/leaderboard/page.tsx index 4df9a5d4..26334302 100644 --- a/src/app/radio/leaderboard/page.tsx +++ b/src/app/radio/leaderboard/page.tsx @@ -5,7 +5,7 @@ import { avatarImageUrl } from "@/lib/format"; import { prisma } from "@/lib/prisma"; import { siteSettings } from "@/lib/services/site-settings"; -export const dynamic = "force-dynamic"; +export const revalidate = 300; // The radio_listener_points table in the live AtomCMS DB carries user_id + // points columns, but the generated Prisma model is a stub (id/timestamps only), diff --git a/src/app/radio/page.tsx b/src/app/radio/page.tsx index 8732eea0..882f9f8a 100644 --- a/src/app/radio/page.tsx +++ b/src/app/radio/page.tsx @@ -5,7 +5,7 @@ import { prisma } from "@/lib/prisma"; import { siteSettings } from "@/lib/services/site-settings"; // Radio config + today's schedule are read live from the DB / settings table. -export const dynamic = "force-dynamic"; +export const revalidate = 300; // Canonical weekday order so today's slots come out chronologically and we can // match the current day against whatever casing the `day` column stores. diff --git a/src/app/radio/schedule/page.tsx b/src/app/radio/schedule/page.tsx index 4b45beb8..e89bd6f7 100644 --- a/src/app/radio/schedule/page.tsx +++ b/src/app/radio/schedule/page.tsx @@ -2,7 +2,7 @@ import { getTranslations } from "next-intl/server"; import { ContentCard, EmptyState } from "@/components/public/ui"; import { prisma } from "@/lib/prisma"; -export const dynamic = "force-dynamic"; +export const revalidate = 300; // Weekly order so the table reads Monday → Sunday regardless of insert order. // The `day` column stores a free-form string; we key case-insensitively. diff --git a/src/app/rankings/page.tsx b/src/app/rankings/page.tsx index a4c854c4..d9aae07e 100644 --- a/src/app/rankings/page.tsx +++ b/src/app/rankings/page.tsx @@ -5,7 +5,7 @@ import { avatarImageUrl } from "@/lib/format"; import { prisma } from "@/lib/prisma"; import { siteSettings } from "@/lib/services/site-settings"; -export const dynamic = "force-dynamic"; +export const revalidate = 300; export const metadata = { title: "Rankings" }; diff --git a/src/app/rares/[category]/page.tsx b/src/app/rares/[category]/page.tsx index d0e7d274..7f7bd7b6 100644 --- a/src/app/rares/[category]/page.tsx +++ b/src/app/rares/[category]/page.tsx @@ -5,7 +5,7 @@ import { ContentCard, EmptyState } from "@/components/public/ui"; import { prisma } from "@/lib/prisma"; import { siteSettings } from "@/lib/services/site-settings"; -export const dynamic = "force-dynamic"; +export const revalidate = 300; type RareRow = { id: bigint; diff --git a/src/app/rares/page.tsx b/src/app/rares/page.tsx index 9cd91e0e..0e300b11 100644 --- a/src/app/rares/page.tsx +++ b/src/app/rares/page.tsx @@ -4,7 +4,7 @@ import { ContentCard, EmptyState } from "@/components/public/ui"; import { prisma } from "@/lib/prisma"; import { siteSettings } from "@/lib/services/site-settings"; -export const dynamic = "force-dynamic"; +export const revalidate = 300; export const metadata = { title: "Rare values" }; diff --git a/src/app/register/page.tsx b/src/app/register/page.tsx index b66a0b20..22099abb 100644 --- a/src/app/register/page.tsx +++ b/src/app/register/page.tsx @@ -3,7 +3,7 @@ import { siteSettings } from "@/lib/services/site-settings"; import { RegisterForm } from "@/components/auth/register-form"; import { captchaConfig } from "@/lib/services/captcha"; -export const dynamic = "force-dynamic"; +export const revalidate = 300; export default async function RegisterPage({ searchParams, diff --git a/src/app/robots.ts b/src/app/robots.ts new file mode 100644 index 00000000..323ec548 --- /dev/null +++ b/src/app/robots.ts @@ -0,0 +1,9 @@ +import type { MetadataRoute } from "next"; + +export default function robots(): MetadataRoute.Robots { + const appUrl = process.env.APP_URL ?? "http://localhost:3000"; + return { + rules: { userAgent: "*", allow: "/", disallow: ["/admin/", "/api/", "/settings/", "/me/"] }, + sitemap: `${appUrl}/sitemap.xml`, + }; +} diff --git a/src/app/room/[id]/page.tsx b/src/app/room/[id]/page.tsx index a02d7771..b973cf83 100644 --- a/src/app/room/[id]/page.tsx +++ b/src/app/room/[id]/page.tsx @@ -4,7 +4,7 @@ import { notFound } from "next/navigation"; import { ContentCard, EmptyState, StatBlock } from "@/components/public/ui"; import { prisma } from "@/lib/prisma"; -export const dynamic = "force-dynamic"; +export const revalidate = 300; // Arcturus stores room access as a short string on rooms.state. Map each known // value to a friendly label + lock icon; unknown values fall back to the raw diff --git a/src/app/shop/page.tsx b/src/app/shop/page.tsx index 246881e2..08ecb969 100644 --- a/src/app/shop/page.tsx +++ b/src/app/shop/page.tsx @@ -4,7 +4,7 @@ import { ContentCard, EmptyState } from "@/components/public/ui"; import { excerpt } from "@/lib/format"; import { prisma } from "@/lib/prisma"; -export const dynamic = "force-dynamic"; +export const revalidate = 300; export const metadata = { title: "Shop" }; diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts new file mode 100644 index 00000000..042b7074 --- /dev/null +++ b/src/app/sitemap.ts @@ -0,0 +1,56 @@ +import type { MetadataRoute } from "next"; +import { prisma } from "@/lib/prisma"; + +export default async function sitemap(): Promise { + const appUrl = process.env.APP_URL ?? "http://localhost:3000"; + + // Static pages + const staticPages = [ + { url: appUrl, lastModified: new Date(), changeFrequency: "hourly" as const, priority: 1 }, + { url: `${appUrl}/news`, lastModified: new Date(), changeFrequency: "hourly" as const, priority: 0.9 }, + { url: `${appUrl}/community`, lastModified: new Date(), changeFrequency: "daily" as const, priority: 0.7 }, + { url: `${appUrl}/shop`, lastModified: new Date(), changeFrequency: "daily" as const, priority: 0.8 }, + { url: `${appUrl}/leaderboard`, lastModified: new Date(), changeFrequency: "daily" as const, priority: 0.6 }, + { url: `${appUrl}/rankings`, lastModified: new Date(), changeFrequency: "daily" as const, priority: 0.6 }, + { url: `${appUrl}/staff`, lastModified: new Date(), changeFrequency: "weekly" as const, priority: 0.5 }, + { url: `${appUrl}/help`, lastModified: new Date(), changeFrequency: "weekly" as const, priority: 0.5 }, + { url: `${appUrl}/badges`, lastModified: new Date(), changeFrequency: "weekly" as const, priority: 0.4 }, + { url: `${appUrl}/radio`, lastModified: new Date(), changeFrequency: "daily" as const, priority: 0.7 }, + ]; + + // News articles + let articles: { slug: string; updatedAt: Date | null }[] = []; + try { + articles = await prisma.websiteArticles.findMany({ + select: { slug: true, updatedAt: true }, + orderBy: { createdAt: "desc" }, + take: 200, + }); + } catch { /* empty */ } + + const articlePages = articles.map((a) => ({ + url: `${appUrl}/news/${a.slug}`, + lastModified: a.updatedAt ?? new Date(), + changeFrequency: "monthly" as const, + priority: 0.6, + })); + + // Guilds + let guildIds: number[] = []; + try { + const rows = await prisma.guilds.findMany({ + select: { id: true }, + take: 200, + }); + guildIds = rows.map((r) => Number(r.id)); + } catch { /* empty */ } + + const guildPages = guildIds.map((id) => ({ + url: `${appUrl}/guilds/${id}`, + lastModified: new Date(), + changeFrequency: "daily" as const, + priority: 0.5, + })); + + return [...staticPages, ...articlePages, ...guildPages]; +} diff --git a/src/app/staff/page.tsx b/src/app/staff/page.tsx index 10256870..9233667d 100644 --- a/src/app/staff/page.tsx +++ b/src/app/staff/page.tsx @@ -4,7 +4,7 @@ import { ContentCard, EmptyState } from "@/components/public/ui"; import { prisma } from "@/lib/prisma"; import { siteSettings } from "@/lib/services/site-settings"; -export const dynamic = "force-dynamic"; +export const revalidate = 300; export const metadata = { title: "Staff" }; diff --git a/src/components/mobile-nav.tsx b/src/components/mobile-nav.tsx index 917061b7..13cb51e8 100644 --- a/src/components/mobile-nav.tsx +++ b/src/components/mobile-nav.tsx @@ -2,7 +2,14 @@ import { useState } from "react"; -export function MobileNav({ children }: { children: React.ReactNode }) { +interface MobileNavProps { + children: React.ReactNode; + /** Accessible label for the menu button. Pass translated string from server. */ + menuLabel?: string; + closeLabel?: string; +} + +export function MobileNav({ children, menuLabel = "Open menu", closeLabel = "Close menu" }: MobileNavProps) { const [open, setOpen] = useState(false); return ( @@ -12,7 +19,9 @@ export function MobileNav({ children }: { children: React.ReactNode }) { onClick={() => setOpen(!open)} className="flex md:hidden items-center justify-center w-10 h-10 rounded-lg transition-colors duration-150" style={{ color: "var(--color-navbar-text)" }} - aria-label={open ? "Sluit menu" : "Open menu"} + aria-expanded={open} + aria-controls="mobile-menu" + aria-label={open ? closeLabel : menuLabel} > {open ? ( @@ -43,6 +52,8 @@ export function MobileNav({ children }: { children: React.ReactNode }) { )} - - + + {t("home")} -
- - - {t("community")} - - -
- {t("articles")} - {t("photos")} - {t("staff")} - {t("rankings")} - {t("guilds")} -
-
+ + {t("articles")} + {t("photos")} + {t("staff")} + {t("rankings")} + {t("guilds")} + -
- - - {t("shop")} - - -
- {t("shop")} - {t("market")} - {t("rareValues")} -
-
+ + {t("shop")} + {t("market")} + {t("rareValues")} + - + {t("leaderboards")} - + {t("radio")} -
- - - {t("assistance")} - - -
- {t("helpCenter")} - {session?.user ? ( - {t("myTickets")} - ) : null} - {t("badges")} - {t("drawBadge")} -
-
+ + {t("helpCenter")} + {session?.user ? ( + {t("myTickets")} + ) : null} + {t("badges")} + {t("drawBadge")} + {session?.user ? ( <> diff --git a/src/lib/services/moderation.ts b/src/lib/services/moderation.ts index 40842d66..24b2aad2 100644 --- a/src/lib/services/moderation.ts +++ b/src/lib/services/moderation.ts @@ -25,14 +25,16 @@ const OPENAI_MODERATIONS_URL = "https://api.openai.com/v1/moderations"; const OPENAI_TIMEOUT_MS = 5_000; /** - * In-process cache of the lowercased website_wordfilter words. Mirrors the - * caching strategy in site-settings.ts: populated on first use, kept until - * reloadWordFilter() is called after an admin edits the list. + * In-process cache of the lowercased website_wordfilter words. Auto-refreshes + * after WORD_FILTER_TTL_MS to pick up admin edits without manual cache bust. */ let wordFilterCache: string[] | null = null; +let wordFilterLoadedAt = 0; +const WORD_FILTER_TTL_MS = 60_000; async function loadWordFilter(): Promise { - if (wordFilterCache === null) { + const now = Date.now(); + if (wordFilterCache === null || now - wordFilterLoadedAt > WORD_FILTER_TTL_MS) { try { const rows = await prisma.websiteWordfilter.findMany({ select: { word: true }, @@ -40,6 +42,7 @@ async function loadWordFilter(): Promise { wordFilterCache = rows .map((r) => r.word.trim().toLowerCase()) .filter((w) => w.length > 0); + wordFilterLoadedAt = now; } catch { // DB unavailable — return an empty filter WITHOUT caching, so the next // call retries. Fail-open: a missing blocklist must not block content. diff --git a/src/messages/de.json b/src/messages/de.json index d79a025f..0a464ec4 100644 --- a/src/messages/de.json +++ b/src/messages/de.json @@ -19,6 +19,8 @@ "friends": "Freunde", "messages": "Nachrichten", "admin": "Admin", + "openMenu": "Open menu", + "closeMenu": "Close menu", "drawBadge": "Abzeichen zeichnen" }, "header": { diff --git a/src/messages/en.json b/src/messages/en.json index 8e68083f..37a0a5bb 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -19,7 +19,11 @@ "friends": "Friends", "messages": "Messages", "admin": "Admin", - "drawBadge": "Draw badge" + "openMenu": "Open menu", + "closeMenu": "Close menu", + "drawBadge": "Draw badge", + "openMenu": "Open menu", + "closeMenu": "Close menu" }, "header": { "online": "{count} {hotel} online", diff --git a/src/messages/es.json b/src/messages/es.json index 22bb0440..370fbcd7 100644 --- a/src/messages/es.json +++ b/src/messages/es.json @@ -19,6 +19,8 @@ "friends": "Amigos", "messages": "Mensajes", "admin": "Admin", + "openMenu": "Open menu", + "closeMenu": "Close menu", "drawBadge": "Diseñar insignia" }, "header": { diff --git a/src/messages/fr.json b/src/messages/fr.json index 7dd6da63..7a647641 100644 --- a/src/messages/fr.json +++ b/src/messages/fr.json @@ -19,6 +19,8 @@ "friends": "Amis", "messages": "Messages", "admin": "Admin", + "openMenu": "Open menu", + "closeMenu": "Close menu", "drawBadge": "Dessiner un badge" }, "header": { diff --git a/src/messages/it.json b/src/messages/it.json index 6281d23c..acebba6c 100644 --- a/src/messages/it.json +++ b/src/messages/it.json @@ -19,6 +19,8 @@ "friends": "Amici", "messages": "Messaggi", "admin": "Admin", + "openMenu": "Open menu", + "closeMenu": "Close menu", "drawBadge": "Crea distintivo" }, "header": { diff --git a/src/messages/nl.json b/src/messages/nl.json index 07c70954..cc08466a 100644 --- a/src/messages/nl.json +++ b/src/messages/nl.json @@ -19,6 +19,8 @@ "friends": "Vrienden", "messages": "Berichten", "admin": "Admin", + "openMenu": "Menu openen", + "closeMenu": "Menu sluiten", "drawBadge": "Badge tekenen" }, "header": {