Add dark mode, i18n, messenger/moderation/verify, admin CRUD parity

Web-tier features completing the AtomCMS→Next.js conversion (slice 2):

UI/UX:
- Dark mode: html.dark CSS-var overrides + ThemeSwitcher (localStorage,
  no-flash boot script) wired into the nav.
- i18n (next-intl, cookie-based / no URL routing): en + it catalogs,
  request.ts, provider in root layout, LanguageSwitcher; shell (nav,
  header, footer) fully translated. URLs + access-guard unchanged.
- globals.css: --muted/--border aliases used across admin pages.

User features:
- /messages: offline messages + friend-request accept (server action
  re-reads session, two directional rows, idempotent).
- Email verification: signed-token /verify route + sendVerification wired
  into register (best-effort, never blocks signup).
- Article reactions: toggle UI on news/[slug] + server action.
- Content moderation service (website_wordfilter + optional OpenAI
  moderations, fail-open) wired into article comments + guestbook.

Admin CRUD parity (Filament replacement):
- /admin/shop (+ new/[id]) packages CRUD + read-only orders.
- /admin/transactions read-only PayPal log.
- /admin/permissions, /admin/tags, /admin/ads (+ new/[id]),
  /admin/help-questions (+ new/[id]), /admin/radio/history,
  /admin/users/[id]/edit. All gated by requireStaff + logStaffActivity.

Verified: tsc 0, vitest 48/48, next build 0 (all routes incl. new
admin CRUD + /messages + /verify).
This commit is contained in:
Simo committed 2026-06-28 16:06:42 +02:00
1 parent 22d53d0e9c
commit 7daeccb832
45 files changed
+3312 -84

No files matched your search

+43
View File
@@ -0,0 +1,43 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { deleteAd, updateAd } from "@/actions/admin-ads";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
export default async function EditAd({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
let ad: Awaited<ReturnType<typeof prisma.websiteAds.findUnique>> = null;
try {
ad = await prisma.websiteAds.findUnique({ where: { id: BigInt(id) } });
} catch {
notFound();
}
if (!ad) notFound();
return (
<main>
<p className="muted">
<Link href="/admin/ads">← Advertisements</Link>
</p>
<h1>Edit advertisement</h1>
<form action={updateAd} className="card" style={{ display: "grid", gap: "0.6rem" }}>
<input type="hidden" name="id" value={String(ad.id)} />
<input name="image" defaultValue={ad.image} placeholder="Image URL" required maxLength={255} />
<button type="submit" className="btn btn-primary">
Save
</button>
</form>
<form action={deleteAd} style={{ marginTop: "1rem" }}>
<input type="hidden" name="id" value={String(ad.id)} />
<button type="submit" className="btn btn-danger">
Delete advertisement
</button>
</form>
</main>
);
}
+19
View File
@@ -0,0 +1,19 @@
import Link from "next/link";
import { createAd } from "@/actions/admin-ads";
export default function NewAd() {
return (
<main>
<p className="muted">
<Link href="/admin/ads">← Advertisements</Link>
</p>
<h1>New advertisement</h1>
<form action={createAd} className="card" style={{ display: "grid", gap: "0.6rem" }}>
<input name="image" placeholder="Image URL" required maxLength={255} />
<button type="submit" className="btn btn-primary">
Create
</button>
</form>
</main>
);
}
+60
View File
@@ -0,0 +1,60 @@
import Link from "next/link";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
export default async function AdminAds() {
let ads: Awaited<ReturnType<typeof prisma.websiteAds.findMany>> = [];
try {
ads = await prisma.websiteAds.findMany({
orderBy: { id: "desc" },
take: 500,
});
} catch {
ads = [];
}
return (
<main>
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
<h1 style={{ flex: 1 }}>Advertisements</h1>
<Link href="/admin/ads/new">+ New ad</Link>
</div>
<p className="muted">
Website advertisement banners (<code>website_ads</code>). Each ad is an image URL shown
across the site.
</p>
<table>
<thead>
<tr>
<th>Preview</th>
<th>Image URL</th>
<th>Created</th>
<th />
</tr>
</thead>
<tbody>
{ads.map((ad) => (
<tr key={String(ad.id)}>
<td>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={ad.image} alt="" style={{ maxHeight: 40, maxWidth: 120 }} />
</td>
<td className="muted">
<code>{ad.image}</code>
</td>
<td className="muted">
{ad.createdAt ? ad.createdAt.toISOString().slice(0, 10) : ""}
</td>
<td>
<Link href={`/admin/ads/${ad.id}`}>Edit</Link>
</td>
</tr>
))}
</tbody>
</table>
{ads.length === 0 ? <p className="muted">No advertisements yet.</p> : null}
</main>
);
}
@@ -0,0 +1,85 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { deleteHelpQuestion, updateHelpQuestion } from "@/actions/admin-help";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
export default async function EditHelpQuestion({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
let entry: Awaited<
ReturnType<typeof prisma.websiteHelpCenterCategories.findUnique>
> = null;
try {
entry = await prisma.websiteHelpCenterCategories.findUnique({
where: { id: BigInt(id) },
});
} catch {
notFound();
}
if (!entry) notFound();
return (
<main>
<p className="muted">
<Link href="/admin/help-questions">← Help Center</Link>
</p>
<h1>Edit help-center entry</h1>
<form action={updateHelpQuestion} className="card" style={{ display: "grid", gap: "0.6rem" }}>
<input type="hidden" name="id" value={String(entry.id)} />
<input name="name" defaultValue={entry.name} placeholder="Name / title" required maxLength={255} />
<textarea name="content" defaultValue={entry.content} placeholder="Content" rows={6} required />
<input
name="position"
type="number"
min={1}
defaultValue={entry.position}
placeholder="Position"
/>
<input
name="imageUrl"
defaultValue={entry.imageUrl ?? ""}
placeholder="Image URL (optional)"
maxLength={255}
/>
<input
name="buttonText"
defaultValue={entry.buttonText ?? ""}
placeholder="Button text (optional)"
maxLength={255}
/>
<input
name="buttonUrl"
defaultValue={entry.buttonUrl ?? ""}
placeholder="Button URL (optional)"
maxLength={255}
/>
<label style={{ display: "flex", gap: "0.4rem", alignItems: "center" }}>
Button color
<input name="buttonColor" type="color" defaultValue={entry.buttonColor} />
</label>
<label style={{ display: "flex", gap: "0.4rem", alignItems: "center" }}>
Button border color
<input name="buttonBorderColor" type="color" defaultValue={entry.buttonBorderColor} />
</label>
<label style={{ display: "flex", gap: "0.4rem", alignItems: "center" }}>
<input name="smallBox" type="checkbox" value="1" defaultChecked={entry.smallBox} />
Small box
</label>
<button type="submit" className="btn btn-primary">
Save
</button>
</form>
<form action={deleteHelpQuestion} style={{ marginTop: "1rem" }}>
<input type="hidden" name="id" value={String(entry.id)} />
<button type="submit" className="btn btn-danger">
Delete entry
</button>
</form>
</main>
);
}
+36
View File
@@ -0,0 +1,36 @@
import Link from "next/link";
import { createHelpQuestion } from "@/actions/admin-help";
export default function NewHelpQuestion() {
return (
<main>
<p className="muted">
<Link href="/admin/help-questions">← Help Center</Link>
</p>
<h1>New help-center entry</h1>
<form action={createHelpQuestion} className="card" style={{ display: "grid", gap: "0.6rem" }}>
<input name="name" placeholder="Name / title" required maxLength={255} />
<textarea name="content" placeholder="Content" rows={6} required />
<input name="position" type="number" min={1} defaultValue={1} placeholder="Position" />
<input name="imageUrl" placeholder="Image URL (optional)" maxLength={255} />
<input name="buttonText" placeholder="Button text (optional)" maxLength={255} />
<input name="buttonUrl" placeholder="Button URL (optional)" maxLength={255} />
<label style={{ display: "flex", gap: "0.4rem", alignItems: "center" }}>
Button color
<input name="buttonColor" type="color" defaultValue="#eeb425" />
</label>
<label style={{ display: "flex", gap: "0.4rem", alignItems: "center" }}>
Button border color
<input name="buttonBorderColor" type="color" defaultValue="#facc15" />
</label>
<label style={{ display: "flex", gap: "0.4rem", alignItems: "center" }}>
<input name="smallBox" type="checkbox" value="1" />
Small box
</label>
<button type="submit" className="btn btn-primary">
Create
</button>
</form>
</main>
);
}
+59
View File
@@ -0,0 +1,59 @@
import Link from "next/link";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
export default async function AdminHelpQuestions() {
let entries: Awaited<
ReturnType<typeof prisma.websiteHelpCenterCategories.findMany>
> = [];
try {
entries = await prisma.websiteHelpCenterCategories.findMany({
orderBy: [{ position: "asc" }, { id: "asc" }],
take: 500,
});
} catch {
entries = [];
}
return (
<main>
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
<h1 style={{ flex: 1 }}>Help Center</h1>
<Link href="/admin/help-questions/new">+ New entry</Link>
</div>
<p className="muted">
Help-center FAQ entries (<code>website_help_center_categories</code>). Shown to visitors on
the public help page, ordered by position.
</p>
<table>
<thead>
<tr>
<th>Pos.</th>
<th>Name</th>
<th>Button</th>
<th>Small box</th>
<th />
</tr>
</thead>
<tbody>
{entries.map((e) => (
<tr key={String(e.id)}>
<td className="muted">{e.position}</td>
<td>
<Link href={`/admin/help-questions/${e.id}`}>{e.name}</Link>
</td>
<td className="muted">{e.buttonText || "—"}</td>
<td className="muted">{e.smallBox ? "Yes" : "No"}</td>
<td>
<Link href={`/admin/help-questions/${e.id}`}>Edit</Link>
</td>
</tr>
))}
</tbody>
</table>
{entries.length === 0 ? <p className="muted">No help-center entries yet.</p> : null}
</main>
);
}
+6
View File
@@ -29,10 +29,14 @@ export default async function AdminLayout({ children }: { children: ReactNode })
<Link href="/admin/users">Users</Link>
<Link href="/admin/rooms">Rooms</Link>
<Link href="/admin/articles">Articles</Link>
<Link href="/admin/tags">Tags</Link>
<Link href="/admin/catalog">Catalog</Link>
<Link href="/admin/shop">Shop</Link>
<Link href="/admin/transactions">Transactions</Link>
<Link href="/admin/vouchers">Vouchers</Link>
<Link href="/admin/badges">Badges</Link>
<Link href="/admin/radio">Radio</Link>
<Link href="/admin/radio/history">Radio History</Link>
<Link href="/admin/achievements">Achievements</Link>
<Link href="/admin/subscriptions">Subscriptions</Link>
<Link href="/admin/calendar">Calendar</Link>
@@ -40,6 +44,8 @@ export default async function AdminLayout({ children }: { children: ReactNode })
<Link href="/admin/photos">Photos</Link>
<Link href="/admin/bans">Bans</Link>
<Link href="/admin/applications">Applications</Link>
<Link href="/admin/help-questions">Help Center</Link>
<Link href="/admin/ads">Advertisements</Link>
<Link href="/admin/wordfilter">Word Filter</Link>
<Link href="/admin/ip">IP Management</Link>
<Link href="/admin/logs">Logs</Link>
+84 -22
View File
@@ -1,25 +1,29 @@
import {
createPermission,
deletePermission,
updatePermission,
} from "@/actions/admin-permissions";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
// NOTE: The emulator's standalone `permissions` table is not present in the
// Prisma schema (there is no `prisma.permissions` accessor). The real,
// queryable rank-permission table in this schema is `website_housekeeping_permissions`
// (model WebsiteHousekeepingPermissions), which gates each permission by the
// minimum rank that holds it — exactly the rank-permission data this page lists.
// Manages website_permissions (model WebsitePermissions) — the CMS-owned
// rank-permission mapping AtomCMS exposes in admin. Each row is a key with an
// optional value and comment; those are the only editable columns on the table.
type PermissionRow = {
id: bigint;
permission: string;
minRank: number;
description: string | null;
key: string;
value: string | null;
comment: string | null;
};
export default async function AdminPermissions() {
let permissions: PermissionRow[] = [];
try {
permissions = await prisma.websiteHousekeepingPermissions.findMany({
select: { id: true, permission: true, minRank: true, description: true },
orderBy: [{ minRank: "asc" }, { permission: "asc" }],
permissions = await prisma.websitePermissions.findMany({
select: { id: true, key: true, value: true, comment: true },
orderBy: { key: "asc" },
take: 500,
});
} catch {
permissions = [];
@@ -29,29 +33,87 @@ export default async function AdminPermissions() {
<main>
<h1>Permissions</h1>
<p className="muted">
Read-only view of rank permissions. Each entry is the minimum rank that
grants the permission.
CMS-owned rank permissions (<code>website_permissions</code>). Each entry
maps a permission key to its value/name and an optional comment.
</p>
<form action={createPermission} className="card" style={{ marginBottom: "1.5rem" }}>
<h3 style={{ marginTop: 0 }}>Add / overwrite permission</h3>
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
<input name="key" placeholder="key" required maxLength={255} />
<input
name="value"
placeholder="value / name"
maxLength={255}
style={{ flex: 1, minWidth: 160 }}
/>
<input name="comment" placeholder="comment" maxLength={255} />
<button type="submit" className="btn btn-primary">
Save
</button>
</div>
</form>
{permissions.length === 0 ? (
<p className="muted">No permissions found.</p>
) : (
<table>
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr>
<tr style={{ textAlign: "left", color: "var(--muted)" }}>
<th>ID</th>
<th>Permission</th>
<th>Min rank</th>
<th>Description</th>
<th>Key</th>
<th>Value</th>
<th>Comment</th>
<th />
</tr>
</thead>
<tbody>
{permissions.map((p) => (
<tr key={String(p.id)}>
<tr key={String(p.id)} style={{ borderTop: "1px solid var(--border)" }}>
<td>{String(p.id)}</td>
<td>{p.permission}</td>
<td>{p.minRank}</td>
<td className="muted">{p.description ?? "—"}</td>
<td>
<form
id={`perm-${String(p.id)}`}
action={updatePermission}
style={{ display: "contents" }}
>
<input type="hidden" name="id" value={String(p.id)} />
<input name="key" defaultValue={p.key} required maxLength={255} />
</form>
</td>
<td>
<input
form={`perm-${String(p.id)}`}
name="value"
defaultValue={p.value ?? ""}
maxLength={255}
style={{ width: "100%" }}
/>
</td>
<td>
<input
form={`perm-${String(p.id)}`}
name="comment"
defaultValue={p.comment ?? ""}
maxLength={255}
style={{ width: "100%" }}
/>
</td>
<td style={{ display: "flex", gap: "0.5rem" }}>
<button
type="submit"
form={`perm-${String(p.id)}`}
className="btn btn-primary"
>
Save
</button>
<form action={deletePermission}>
<input type="hidden" name="id" value={String(p.id)} />
<button type="submit" className="btn btn-danger">
Delete
</button>
</form>
</td>
</tr>
))}
</tbody>
+125
View File
@@ -0,0 +1,125 @@
import Link from 'next/link';
import { prisma } from '@/lib/prisma';
export const dynamic = 'force-dynamic';
type History = {
id: bigint;
userId: bigint;
showName: string | null;
startedAt: Date;
endedAt: Date | null;
listenersCount: number;
notes: string | null;
};
function formatDate(d: Date | null): string {
return d ? d.toISOString().slice(0, 16).replace('T', ' ') : '—';
}
/** Human-readable airtime between two timestamps (e.g. "1h 23m"). */
function formatDuration(start: Date, end: Date | null): string {
if (!end) return '—';
const ms = end.getTime() - start.getTime();
if (ms <= 0) return '—';
const mins = Math.round(ms / 60000);
const h = Math.floor(mins / 60);
const m = mins % 60;
return h > 0 ? `${h}h ${m}m` : `${m}m`;
}
export default async function AdminRadioHistoryPage() {
let history: History[] = [];
let names = new Map<string, string>();
let dbError = false;
try {
history = await prisma.radioHistory.findMany({
select: {
id: true,
userId: true,
showName: true,
startedAt: true,
endedAt: true,
listenersCount: true,
notes: true,
},
orderBy: { startedAt: 'desc' },
take: 100,
});
// Resolve DJ usernames (users.id is Int, radio_history.user_id is BigInt).
const ids = Array.from(new Set(history.map((h) => Number(h.userId)))).filter(
(n) => Number.isFinite(n) && n > 0,
);
if (ids.length > 0) {
const users = await prisma.user.findMany({
where: { id: { in: ids } },
select: { id: true, username: true },
});
names = new Map(users.map((u) => [String(u.id), u.username]));
}
} catch {
dbError = true;
history = [];
}
return (
<main>
<nav className="muted" style={{ marginBottom: '0.5rem' }}>
<Link href="/admin/radio">Radio</Link> ·{' '}
<Link href="/admin/radio/settings">Settings</Link> ·{' '}
<Link href="/admin/radio/banners">Banners</Link> ·{' '}
<Link href="/admin/radio/ranks">Ranks</Link> ·{' '}
<Link href="/admin/radio/history">History</Link>
</nav>
<h1>Radio Play History</h1>
<p className="muted">
Past DJ shows and airtime (<code>radio_history</code>), most recent first.
Read-only log of who was on air, when, and how many listeners tuned in.
</p>
{dbError ? (
<div className="card" style={{ marginBottom: '1.5rem' }}>
<p className="muted" style={{ margin: 0 }}>
Could not load radio history (database unavailable).
</p>
</div>
) : null}
{history.length === 0 ? (
<p className="muted">No radio history recorded.</p>
) : (
<table>
<thead>
<tr>
<th>ID</th>
<th>DJ</th>
<th>Show</th>
<th>Started</th>
<th>Ended</th>
<th>Airtime</th>
<th>Listeners</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
{history.map((h) => (
<tr key={String(h.id)}>
<td>{String(h.id)}</td>
<td>{names.get(String(h.userId)) ?? `#${String(h.userId)}`}</td>
<td>{h.showName ?? '—'}</td>
<td className="muted">{formatDate(h.startedAt)}</td>
<td className="muted">{formatDate(h.endedAt)}</td>
<td className="muted">{formatDuration(h.startedAt, h.endedAt)}</td>
<td>{h.listenersCount}</td>
<td className="muted">{h.notes ?? '—'}</td>
</tr>
))}
</tbody>
</table>
)}
</main>
);
}
+53
View File
@@ -0,0 +1,53 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { deleteShopArticle, updateShopArticle } from "@/actions/admin-shop";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
export default async function EditShopArticle({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
let pkg: Awaited<ReturnType<typeof prisma.websiteShopArticles.findUnique>> = null;
try {
pkg = await prisma.websiteShopArticles.findUnique({ where: { id: BigInt(id) } });
} catch {
notFound();
}
if (!pkg) notFound();
return (
<main>
<p className="muted">
<Link href="/admin/shop">← Shop</Link>
</p>
<h1>Edit shop package</h1>
<form action={updateShopArticle} className="card" style={{ display: "grid", gap: "0.6rem" }}>
<input type="hidden" name="id" value={String(pkg.id)} />
<input name="name" defaultValue={pkg.name} placeholder="Name" required maxLength={255} />
<input name="info" defaultValue={pkg.info} placeholder="Info / description" maxLength={255} />
<input name="icon" defaultValue={pkg.icon} placeholder="Icon" maxLength={255} />
<input name="color" defaultValue={pkg.color} placeholder="Color (e.g. #ffcc00)" maxLength={255} />
<input name="costs" type="number" min={0} defaultValue={pkg.costs} placeholder="Costs (store price)" required />
<input name="credits" type="number" min={0} defaultValue={pkg.credits ?? undefined} placeholder="Credits given (optional)" />
<input name="duckets" type="number" min={0} defaultValue={pkg.duckets ?? undefined} placeholder="Duckets given (optional)" />
<input name="diamonds" type="number" min={0} defaultValue={pkg.diamonds ?? undefined} placeholder="Diamonds given (optional)" />
<input name="giveRank" type="number" min={0} defaultValue={pkg.giveRank ?? undefined} placeholder="Give rank (optional)" />
<input name="badges" defaultValue={pkg.badges ?? ""} placeholder="Badge codes (optional)" maxLength={255} />
<input name="position" type="number" min={0} defaultValue={pkg.position} placeholder="Position" />
<button type="submit" className="btn btn-primary">
Save
</button>
</form>
<form action={deleteShopArticle} style={{ marginTop: "1rem" }}>
<input type="hidden" name="id" value={String(pkg.id)} />
<button type="submit" className="btn btn-danger">
Delete package
</button>
</form>
</main>
);
}
+29
View File
@@ -0,0 +1,29 @@
import Link from "next/link";
import { createShopArticle } from "@/actions/admin-shop";
export default function NewShopArticle() {
return (
<main>
<p className="muted">
<Link href="/admin/shop">← Shop</Link>
</p>
<h1>New shop package</h1>
<form action={createShopArticle} className="card" style={{ display: "grid", gap: "0.6rem" }}>
<input name="name" placeholder="Name" required maxLength={255} />
<input name="info" placeholder="Info / description" maxLength={255} />
<input name="icon" placeholder="Icon" maxLength={255} />
<input name="color" placeholder="Color (e.g. #ffcc00)" maxLength={255} />
<input name="costs" type="number" min={0} placeholder="Costs (store price)" required />
<input name="credits" type="number" min={0} placeholder="Credits given (optional)" />
<input name="duckets" type="number" min={0} placeholder="Duckets given (optional)" />
<input name="diamonds" type="number" min={0} placeholder="Diamonds given (optional)" />
<input name="giveRank" type="number" min={0} placeholder="Give rank (optional)" />
<input name="badges" placeholder="Badge codes (optional)" maxLength={255} />
<input name="position" type="number" min={0} placeholder="Position" defaultValue={0} />
<button type="submit" className="btn btn-primary">
Create
</button>
</form>
</main>
);
}
+105
View File
@@ -0,0 +1,105 @@
import Link from "next/link";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
export default async function AdminShop() {
let packages: Awaited<ReturnType<typeof prisma.websiteShopArticles.findMany>> = [];
try {
packages = await prisma.websiteShopArticles.findMany({
orderBy: [{ position: "asc" }, { name: "asc" }],
take: 500,
});
} catch {
packages = [];
}
let orders: Awaited<ReturnType<typeof prisma.websitePaypalTransactions.findMany>> = [];
try {
orders = await prisma.websitePaypalTransactions.findMany({
orderBy: { createdAt: "desc" },
take: 50,
});
} catch {
orders = [];
}
return (
<main>
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
<h1 style={{ flex: 1 }}>Shop</h1>
<Link href="/admin/shop/new">+ New package</Link>
</div>
<p className="muted">
Website store packages (<code>website_shop_articles</code>). <code>costs</code> is the price
shown in the store; credits/duckets/diamonds/badges are what the buyer receives.
</p>
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr style={{ textAlign: "left", color: "var(--muted)" }}>
<th>#</th>
<th>Name</th>
<th>Costs</th>
<th>Credits</th>
<th>Duckets</th>
<th>Diamonds</th>
<th>Rank</th>
<th>Position</th>
</tr>
</thead>
<tbody>
{packages.map((p) => (
<tr key={String(p.id)} style={{ borderTop: "1px solid var(--border)" }}>
<td className="muted">{String(p.id)}</td>
<td>
<Link href={`/admin/shop/${p.id}`}>{p.name}</Link>
</td>
<td>{p.costs}</td>
<td className="muted">{p.credits ?? ""}</td>
<td className="muted">{p.duckets ?? ""}</td>
<td className="muted">{p.diamonds ?? ""}</td>
<td className="muted">{p.giveRank ?? ""}</td>
<td className="muted">{p.position}</td>
</tr>
))}
</tbody>
</table>
{packages.length === 0 ? <p className="muted">No shop packages yet.</p> : null}
<h2 style={{ marginTop: "2rem" }}>Recent orders</h2>
<p className="muted">
Read-only PayPal transaction log (<code>website_paypal_transactions</code>).
</p>
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr style={{ textAlign: "left", color: "var(--muted)" }}>
<th>Transaction</th>
<th>User</th>
<th>Description</th>
<th>Amount</th>
<th>Status</th>
<th>Date</th>
</tr>
</thead>
<tbody>
{orders.map((o) => (
<tr key={String(o.id)} style={{ borderTop: "1px solid var(--border)" }}>
<td>
<code>{o.transactionId}</code>
</td>
<td className="muted">{o.userId}</td>
<td className="muted">{o.description ?? ""}</td>
<td>
{o.amount} {o.currency}
</td>
<td className="muted">{o.status ?? ""}</td>
<td className="muted">{o.createdAt ? o.createdAt.toISOString().slice(0, 10) : ""}</td>
</tr>
))}
</tbody>
</table>
{orders.length === 0 ? <p className="muted">No orders recorded.</p> : null}
</main>
);
}
+169
View File
@@ -0,0 +1,169 @@
import { createTag, deleteTag, updateTag } from '@/actions/admin-tags';
import { prisma } from '@/lib/prisma';
export const dynamic = 'force-dynamic';
type Tag = {
id: bigint;
name: string;
backgroundColor: string;
createdAt: Date | null;
};
function formatDate(d: Date | null): string {
return d ? d.toISOString().slice(0, 10) : '—';
}
export default async function AdminTagsPage() {
let tags: Tag[] = [];
let usage = new Map<string, number>();
let dbError = false;
try {
tags = await prisma.tags.findMany({
select: { id: true, name: true, backgroundColor: true, createdAt: true },
orderBy: { name: 'asc' },
take: 500,
});
// How many things each tag is attached to (taggables join table).
const counts = await prisma.taggables.groupBy({
by: ['tagId'],
_count: { tagId: true },
});
usage = new Map(counts.map((c) => [String(c.tagId), c._count.tagId]));
} catch {
dbError = true;
tags = [];
}
return (
<main>
<h1>Tags</h1>
<p className="muted">
Article tags / categories (<code>tags</code>). Each tag has a label and a
background colour, and can be attached to articles via <code>taggables</code>.
</p>
{dbError ? (
<div className="card" style={{ marginBottom: '1.5rem' }}>
<p className="muted" style={{ margin: 0 }}>
Could not load tags (database unavailable).
</p>
</div>
) : null}
<form action={createTag} className="card" style={{ marginBottom: '1.5rem' }}>
<h3 style={{ marginTop: 0 }}>Add tag</h3>
<div className="grid cols-2">
<div>
<label htmlFor="new_name" style={{ display: 'block', fontWeight: 700 }}>
Name
</label>
<input
id="new_name"
name="name"
placeholder="e.g. Events"
required
maxLength={255}
style={{ width: '100%' }}
/>
</div>
<div>
<label
htmlFor="new_backgroundColor"
style={{ display: 'block', fontWeight: 700 }}
>
Background colour
</label>
<input
id="new_backgroundColor"
name="backgroundColor"
placeholder="#888888"
defaultValue="#888888"
maxLength={10}
style={{ width: '100%' }}
/>
</div>
</div>
<div style={{ marginTop: '0.75rem' }}>
<button type="submit" className="btn btn-primary">
Create tag
</button>
</div>
</form>
{tags.length === 0 ? (
<p className="muted">No tags yet.</p>
) : (
<table>
<thead>
<tr>
<th>ID</th>
<th>Tag</th>
<th>Colour</th>
<th>Used by</th>
<th>Created</th>
<th />
</tr>
</thead>
<tbody>
{tags.map((t) => (
<tr key={String(t.id)}>
<td>{String(t.id)}</td>
<td>
<span
style={{
display: 'inline-block',
padding: '0.1rem 0.5rem',
borderRadius: '0.4rem',
background: t.backgroundColor,
color: '#fff',
}}
>
{t.name}
</span>
</td>
<td className="muted">
<code>{t.backgroundColor}</code>
</td>
<td className="muted">{usage.get(String(t.id)) ?? 0}</td>
<td className="muted">{formatDate(t.createdAt)}</td>
<td>
<form
action={updateTag}
style={{ display: 'flex', gap: '0.4rem', alignItems: 'center' }}
>
<input type="hidden" name="id" value={String(t.id)} />
<input
name="name"
defaultValue={t.name}
required
maxLength={255}
style={{ width: 140 }}
/>
<input
name="backgroundColor"
defaultValue={t.backgroundColor}
maxLength={10}
style={{ width: 90 }}
/>
<button type="submit" className="btn btn-primary">
Save
</button>
</form>
<form action={deleteTag} style={{ marginTop: '0.4rem' }}>
<input type="hidden" name="id" value={String(t.id)} />
<button type="submit" className="btn btn-danger">
Delete
</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
)}
</main>
);
}
+131
View File
@@ -0,0 +1,131 @@
import Link from "next/link";
import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
const PER_PAGE = 25;
// Read-only listing of PayPal / top-up transactions recorded by the CMS
// (website_paypal_transactions). No mutations — this page never writes.
//
// website_* ids are BigInt; users.id (the payer) is the emulator-owned Int.
// The model has no Prisma relation to User, so payer usernames are resolved in
// one batched lookup keyed by userId.
type Txn = Awaited<ReturnType<typeof prisma.websitePaypalTransactions.findMany>>[number];
function fromDate(d: Date | null | undefined): string {
return d ? d.toISOString().slice(0, 16).replace("T", " ") : "—";
}
function formatAmount(amount: number, currency: string): string {
const n = Number(amount);
const value = Number.isFinite(n) ? n.toFixed(2) : "0.00";
return `${value} ${currency}`;
}
export default async function AdminTransactions({
searchParams,
}: {
searchParams: Promise<{ page?: string }>;
}) {
await requireStaff();
const sp = await searchParams;
const page = Math.max(1, Number(sp.page ?? "1") || 1);
let transactions: Txn[] = [];
let total = 0;
let names = new Map<number, string>();
try {
[transactions, total] = await Promise.all([
prisma.websitePaypalTransactions.findMany({
orderBy: { id: "desc" },
skip: (page - 1) * PER_PAGE,
take: PER_PAGE,
}),
prisma.websitePaypalTransactions.count(),
]);
const userIds = [...new Set(transactions.map((t) => t.userId))];
if (userIds.length > 0) {
const users = await prisma.user.findMany({
where: { id: { in: userIds } },
select: { id: true, username: true },
});
names = new Map(users.map((u) => [u.id, u.username]));
}
} catch {
transactions = [];
total = 0;
names = new Map();
}
const pages = Math.max(1, Math.ceil(total / PER_PAGE));
return (
<main>
<h1>Transactions</h1>
<p className="muted" style={{ marginTop: "-0.4rem" }}>
Read-only · PayPal / top-up transactions recorded by the CMS, newest first.
</p>
<section className="card">
{transactions.length === 0 ? (
<p className="muted">No transactions.</p>
) : (
<div style={{ overflowX: "auto" }}>
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr style={{ textAlign: "left", color: "var(--muted)" }}>
<th>ID</th>
<th>Payer</th>
<th>Amount</th>
<th>Package</th>
<th>Status</th>
<th>Transaction</th>
<th>Date</th>
</tr>
</thead>
<tbody>
{transactions.map((t) => (
<tr key={String(t.id)} style={{ borderTop: "1px solid var(--border)" }}>
<td>{String(t.id)}</td>
<td>
<Link href={`/admin/users/${t.userId}`}>
{names.get(t.userId) ?? `#${t.userId}`}
</Link>
</td>
<td>{formatAmount(t.amount, t.currency)}</td>
<td>{t.description ?? "—"}</td>
<td>{t.status ?? "—"}</td>
<td className="muted">{t.transactionId}</td>
<td className="muted">{fromDate(t.createdAt)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
<p className="muted">
Page {page} / {pages} · {total} transactions
{page > 1 ? (
<>
{" · "}
<Link href={`/admin/transactions?page=${page - 1}`}>← Prev</Link>
</>
) : null}
{page < pages ? (
<>
{" · "}
<Link href={`/admin/transactions?page=${page + 1}`}>Next →</Link>
</>
) : null}
</p>
</main>
);
}
+145
View File
@@ -0,0 +1,145 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { updateUser } from "@/actions/admin-user-edit";
import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
// users_currency.type values for the non-credits currencies (see send-currency.ts).
const DUCKETS_TYPE = 0;
const DIAMONDS_TYPE = 5;
export default async function AdminUserEdit({
params,
}: {
params: Promise<{ id: string }>;
}) {
// Gate the page with the SAME helper the action re-checks.
await requireStaff();
const { id } = await params;
const userId = Number(id);
if (!Number.isInteger(userId) || userId <= 0) notFound();
let user: {
id: number;
username: string;
mail: string | null;
motto: string;
look: string;
rank: number;
credits: number;
pixels: number;
points: number;
} | null = null;
let duckets = 0;
let diamonds = 0;
try {
user = await prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
username: true,
mail: true,
motto: true,
look: true,
rank: true,
credits: true,
pixels: true,
points: true,
},
});
if (user) {
const currencies = await prisma.usersCurrency.findMany({
where: { userId, type: { in: [DUCKETS_TYPE, DIAMONDS_TYPE] } },
select: { type: true, amount: true },
});
for (const c of currencies) {
if (c.type === DUCKETS_TYPE) duckets = c.amount;
else if (c.type === DIAMONDS_TYPE) diamonds = c.amount;
}
}
} catch {
return (
<main>
<p className="muted">
<Link href="/admin/users">← Users</Link>
</p>
<h1>Edit user</h1>
<p className="muted">Could not load this user right now. Try again shortly.</p>
</main>
);
}
if (!user) notFound();
return (
<main>
<p className="muted">
<Link href={`/admin/users/${user.id}`}>← {user.username}</Link>
</p>
<h1>Edit {user.username}</h1>
<p className="muted">ID {user.id}</p>
<form action={updateUser} className="card">
<input type="hidden" name="id" value={user.id} />
<div className="grid cols-2">
<label>
Email
<input name="mail" type="email" defaultValue={user.mail ?? ""} maxLength={500} />
</label>
<label>
Rank
<input name="rank" type="number" min={1} defaultValue={user.rank} />
</label>
<label>
Motto
<input name="motto" defaultValue={user.motto} maxLength={127} />
</label>
<label>
Look
<input name="look" defaultValue={user.look} maxLength={256} />
</label>
<label>
Credits
<input name="credits" type="number" min={0} defaultValue={user.credits} />
</label>
<label>
Pixels
<input name="pixels" type="number" min={0} defaultValue={user.pixels} />
</label>
<label>
Points
<input name="points" type="number" min={0} defaultValue={user.points} />
</label>
<label>
Duckets
<input name="duckets" type="number" min={0} defaultValue={duckets} />
</label>
<label>
Diamonds
<input name="diamonds" type="number" min={0} defaultValue={diamonds} />
</label>
</div>
<p className="muted">Currency fields set the exact balance, not a delta. Passwords are not editable here.</p>
<div style={{ display: "flex", gap: "0.5rem", marginTop: "0.5rem" }}>
<button type="submit" className="btn btn-primary">Save changes</button>
<Link href={`/admin/users/${user.id}`} className="btn">Cancel</Link>
</div>
</form>
</main>
);
}
+24
View File
@@ -60,6 +60,30 @@
--input-border-color: #4b5563;
--input-focus-color: #eeb425;
--navbar-height: 64px;
/* Aliases used throughout the admin pages' inline styles. */
--muted: var(--color-text-muted);
--border: color-mix(in srgb, var(--color-text-muted) 18%, transparent);
}
/* Dark mode (toggled by html.dark; wins over :root + ThemeVars via specificity). */
html.dark {
--color-background: #0f1117;
--color-surface: #171a21;
--color-dropdown: #1f242d;
--color-navbar: #171a21;
--color-navbar-text: #e5e7eb;
--color-text: #e5e7eb;
--color-text-muted: #9ca3af;
}
html.dark input,
html.dark select,
html.dark textarea {
background: #11151c;
color: var(--color-text);
}
html.dark .currency,
html.dark .article-img {
background: #1f242d;
}
html {
+26 -11
View File
@@ -1,4 +1,6 @@
import type { Metadata } from "next";
import { NextIntlClientProvider } from "next-intl";
import { getLocale, getMessages } from "next-intl/server";
import { Nunito } from "next/font/google";
import type { ReactNode } from "react";
import { Navigation } from "@/components/navigation";
@@ -23,22 +25,35 @@ export const metadata: Metadata = {
export default async function RootLayout({ children }: { children: ReactNode }) {
await enforceSiteAccess();
const locale = await getLocale();
const messages = await getMessages();
return (
<html lang="en" className={`app ${nunito.variable}`}>
<html lang={locale} className={`app ${nunito.variable}`}>
<head>
{/* Apply the saved theme before first paint to avoid a light→dark flash. */}
<script
dangerouslySetInnerHTML={{
__html:
"try{if(localStorage.getItem('theme')==='dark')document.documentElement.classList.add('dark');}catch(e){}",
}}
/>
</head>
<body
className="flex min-h-screen flex-col site-bg"
style={{ backgroundColor: "var(--color-background)" }}
>
<ThemeVars />
<TopHeader />
<SiteHeader />
<Navigation />
<main className="overflow-hidden site-bg">
<div className="mx-auto mt-10 grid max-w-7xl grid-cols-12 gap-x-3 gap-y-8 p-6 md:mt-0">
<div className="col-span-12">{children}</div>
</div>
</main>
<SiteFooter />
<NextIntlClientProvider locale={locale} messages={messages}>
<ThemeVars />
<TopHeader />
<SiteHeader />
<Navigation />
<main className="overflow-hidden site-bg">
<div className="mx-auto mt-10 grid max-w-7xl grid-cols-12 gap-x-3 gap-y-8 p-6 md:mt-0">
<div className="col-span-12">{children}</div>
</div>
</main>
<SiteFooter />
</NextIntlClientProvider>
</body>
</html>
);
+200
View File
@@ -0,0 +1,200 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { avatarImageUrl } from "@/lib/format";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
import { acceptFriend } from "@/actions/messenger";
export const dynamic = "force-dynamic";
function formatTimestamp(seconds: number): string {
if (!seconds) return "";
// sended_on is a unix timestamp in seconds (emulator convention).
return new Date(seconds * 1000).toISOString().slice(0, 16).replace("T", " ");
}
export default async function MessagesPage() {
const session = await auth();
if (!session?.user?.id) redirect("/login");
const userId = Number(session.user.id);
// Pending friend requests addressed to the session user, the most recent
// offline messages they've received, and the avatar-imager base URL.
let requests: { id: number; userFromId: number }[] = [];
let messages: { id: number; userFromId: number; message: string; sendedOn: number }[] = [];
let imagerBase =
"https://www.habbo.com/habbo-imaging/avatarimage";
try {
const [reqRows, msgRows, base] = await Promise.all([
prisma.messengerFriendrequests.findMany({
where: { userToId: userId },
select: { id: true, userFromId: true },
orderBy: { id: "desc" },
take: 100,
}),
prisma.messengerOffline.findMany({
where: { userId },
select: { id: true, userFromId: true, message: true, sendedOn: true },
orderBy: { sendedOn: "desc" },
take: 50,
}),
siteSettings.get(
"habbo_imaging_url",
"https://www.habbo.com/habbo-imaging/avatarimage",
),
]);
requests = reqRows;
messages = msgRows;
if (base) imagerBase = base;
} catch {
requests = [];
messages = [];
}
// Resolve usernames/looks for every counterparty referenced above.
const otherIds = Array.from(
new Set([
...requests.map((r) => r.userFromId),
...messages.map((m) => m.userFromId),
]),
).filter((id) => id && id > 0);
let userById = new Map<
number,
{ id: number; username: string; look: string }
>();
if (otherIds.length > 0) {
try {
const users = await prisma.user.findMany({
where: { id: { in: otherIds } },
select: { id: true, username: true, look: true },
});
userById = new Map(users.map((u) => [u.id, u]));
} catch {
userById = new Map();
}
}
return (
<main>
<h1>Messages</h1>
<p className="muted" style={{ marginTop: "-0.25rem" }}>
Friend requests and offline messages waiting for you.
</p>
{/* ── Pending friend requests ───────────────────────────── */}
<section style={{ marginTop: "1.5rem" }}>
<h2>Friend requests</h2>
{requests.length === 0 ? (
<div className="card">
<p className="muted" style={{ margin: 0 }}>
No pending friend requests.
</p>
</div>
) : (
<div className="grid">
{requests.map((req) => {
const from = userById.get(req.userFromId);
const avatar = avatarImageUrl(imagerBase, from?.look ?? "", {
size: "s",
headOnly: true,
});
return (
<div
key={req.id}
className="card"
style={{ display: "flex", gap: "0.85rem", alignItems: "center" }}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
className="avatar"
src={avatar}
alt={`${from?.username ?? "User"} avatar`}
width={50}
height={50}
/>
<div style={{ minWidth: 0, flex: 1 }}>
<p style={{ margin: 0 }}>
{from ? (
<Link href={`/u/${from.username}`}>
<strong>{from.username}</strong>
</Link>
) : (
<strong>User #{req.userFromId}</strong>
)}
</p>
<p className="muted" style={{ margin: 0 }}>
wants to be your friend.
</p>
</div>
<form action={acceptFriend}>
<input type="hidden" name="requestId" value={String(req.id)} />
<button type="submit" className="btn btn-secondary">
Accept
</button>
</form>
</div>
);
})}
</div>
)}
</section>
{/* ── Offline messages ──────────────────────────────────── */}
<section style={{ marginTop: "2rem" }}>
<h2>Offline messages</h2>
{messages.length === 0 ? (
<div className="card">
<p className="muted" style={{ margin: 0 }}>
You have no offline messages.
</p>
</div>
) : (
<div className="grid">
{messages.map((msg) => {
const from = userById.get(msg.userFromId);
const avatar = avatarImageUrl(imagerBase, from?.look ?? "", {
size: "s",
headOnly: true,
});
const when = formatTimestamp(msg.sendedOn);
return (
<article
key={msg.id}
className="card"
style={{ display: "flex", gap: "0.85rem", alignItems: "flex-start" }}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
className="avatar"
src={avatar}
alt={`${from?.username ?? "User"} avatar`}
width={50}
height={50}
/>
<div style={{ minWidth: 0, flex: 1 }}>
<p
className="muted"
style={{ marginTop: 0, marginBottom: "0.35rem" }}
>
{from ? (
<Link href={`/u/${from.username}`}>{from.username}</Link>
) : (
<span>User #{msg.userFromId}</span>
)}
{when ? <span> · {when}</span> : null}
</p>
<p style={{ margin: 0, whiteSpace: "pre-wrap" }}>{msg.message}</p>
</div>
</article>
);
})}
</div>
)}
</section>
</main>
);
}
+70 -21
View File
@@ -3,9 +3,18 @@ import { notFound } from "next/navigation";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { postComment } from "@/actions/article-comments";
import { toggleReaction } from "@/actions/article-reactions";
export const dynamic = "force-dynamic";
// The reaction set offered by the voting UI. Must stay in sync with
// ALLOWED_REACTIONS in src/actions/article-reactions.ts.
const REACTIONS: { key: string; label: string }[] = [
{ key: "like", label: "👍 Like" },
{ key: "love", label: "❤️ Love" },
{ key: "wow", label: "😮 Wow" },
];
function formatDate(d: Date | null | undefined): string {
return d ? d.toISOString().slice(0, 10) : "";
}
@@ -27,18 +36,26 @@ export default async function ArticlePage({
const articleId = article.id;
// Reaction counts grouped by reaction type for this article.
let reactionGroups: { reaction: string; count: number }[] = [];
const session = await auth();
const loggedIn = Boolean(session?.user?.id);
const sessionUserId = loggedIn ? Number(session!.user!.id) : null;
// Reaction counts grouped by reaction type for this article, plus the
// signed-in user's currently-active reaction (so its button reads as pressed).
let reactionCounts = new Map<string, number>();
let myReaction: string | null = null;
try {
const rows = await prisma.websiteArticleReactions.findMany({
where: { articleId, active: true },
select: { reaction: true },
select: { reaction: true, userId: true },
});
const tally = new Map<string, number>();
for (const r of rows) tally.set(r.reaction, (tally.get(r.reaction) ?? 0) + 1);
reactionGroups = [...tally.entries()].map(([reaction, count]) => ({ reaction, count }));
for (const r of rows) {
reactionCounts.set(r.reaction, (reactionCounts.get(r.reaction) ?? 0) + 1);
if (sessionUserId !== null && r.userId === sessionUserId) myReaction = r.reaction;
}
} catch {
reactionGroups = [];
reactionCounts = new Map();
myReaction = null;
}
// Comments for this article (oldest first).
@@ -68,9 +85,6 @@ export default async function ArticlePage({
}
}
const session = await auth();
const loggedIn = Boolean(session?.user?.id);
return (
<main>
<p className="muted">
@@ -93,19 +107,54 @@ export default async function ArticlePage({
{/* ── Reactions ─────────────────────────────────────────── */}
<section style={{ marginTop: "2rem" }}>
<h2>Reactions</h2>
{reactionGroups.length === 0 ? (
<p className="muted">No reactions yet.</p>
) : (
{loggedIn ? (
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
{reactionGroups.map((r) => (
<span key={r.reaction} className="currency">
<span style={{ fontWeight: 800 }}>{r.reaction}</span>
<span className="muted" style={{ fontSize: "0.85rem" }}>
{r.count}
</span>
</span>
))}
{REACTIONS.map((r) => {
const active = myReaction === r.key;
const count = reactionCounts.get(r.key) ?? 0;
return (
<form key={r.key} action={toggleReaction} style={{ margin: 0 }}>
<input type="hidden" name="articleId" value={String(articleId)} />
<input type="hidden" name="reaction" value={r.key} />
<button
type="submit"
aria-pressed={active}
className={`btn ${active ? "btn-primary" : "btn-outline"}`}
title={active ? "Click to remove your reaction" : `React with ${r.key}`}
>
<span>{r.label}</span>
<span
className="muted"
style={{ fontSize: "0.85rem", color: "inherit", opacity: 0.85 }}
>
{count}
</span>
</button>
</form>
);
})}
</div>
) : reactionCounts.size === 0 ? (
<p className="muted">
No reactions yet. <Link href="/login">Log in</Link> to react.
</p>
) : (
<>
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
{REACTIONS.filter((r) => (reactionCounts.get(r.key) ?? 0) > 0).map((r) => (
<span key={r.key} className="currency">
<span style={{ fontWeight: 800 }}>{r.label}</span>
<span className="muted" style={{ fontSize: "0.85rem" }}>
{reactionCounts.get(r.key) ?? 0}
</span>
</span>
))}
</div>
<p className="muted" style={{ marginTop: "0.6rem" }}>
<Link href="/login">Log in</Link> to react.
</p>
</>
)}
</section>
+103
View File
@@ -0,0 +1,103 @@
import Link from "next/link";
import { isValidVerificationToken } from "@/actions/email-verify";
import { prisma } from "@/lib/prisma";
// Reads + writes the live users table — must never be statically rendered.
export const dynamic = "force-dynamic";
type Status = "verified" | "already" | "invalid" | "unavailable";
export default async function VerifyPage({
searchParams,
}: {
searchParams: Promise<{ token?: string; email?: string }>;
}) {
// Next 16: searchParams is a Promise.
const { token = "", email = "" } = await searchParams;
const normalisedEmail = email.trim().toLowerCase();
let status: Status = "invalid";
if (normalisedEmail && token) {
const ok = await isValidVerificationToken(normalisedEmail, token);
if (ok) {
try {
// mail is VARCHAR(500) and not unique in the emulator schema, so match
// on the email and flip any matching unverified account(s).
const user = await prisma.user.findFirst({
where: { mail: normalisedEmail },
select: { id: true, mailVerified: true },
});
if (!user) {
status = "invalid";
} else if (user.mailVerified === "1") {
status = "already";
} else {
await prisma.user.update({
where: { id: user.id },
// users.mail_verified is a String flag ('0' / '1').
data: { mailVerified: "1" },
});
status = "verified";
}
} catch {
// DB unreachable — don't claim success.
status = "unavailable";
}
} else {
status = "invalid";
}
}
return (
<main>
<h1>Email verification</h1>
<div className="card" style={{ marginTop: "1rem" }}>
{status === "verified" ? (
<>
<h2 style={{ marginTop: 0 }}>You're all set</h2>
<p className="muted">
Your email address has been verified. You can now log in and play.
</p>
<p style={{ marginTop: "1rem" }}>
<Link href="/login" className="btn btn-primary">
Go to login
</Link>
</p>
</>
) : status === "already" ? (
<>
<h2 style={{ marginTop: 0 }}>Already verified</h2>
<p className="muted">This email address has already been confirmed.</p>
<p style={{ marginTop: "1rem" }}>
<Link href="/login" className="btn btn-primary">
Go to login
</Link>
</p>
</>
) : status === "unavailable" ? (
<>
<h2 style={{ marginTop: 0 }}>Try again shortly</h2>
<p className="muted">
We couldn't reach the server to verify your email. Please retry the link in a
moment.
</p>
</>
) : (
<>
<h2 style={{ marginTop: 0 }}>Invalid or expired link</h2>
<p className="muted">
This verification link is not valid. Make sure you opened the full link from your
email, or request a new one.
</p>
<p style={{ marginTop: "1rem" }}>
<Link href="/login" className="btn btn-outline">
Back to login
</Link>
</p>
</>
)}
</div>
</main>
);
}