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>
);
}