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
+3298 -70

No files matched your search

+5
View File
@@ -38,6 +38,11 @@ SMTP_FROM=
DISCORD_WEBHOOK_URL=
ALERT_EMAIL=
# Optional AI content moderation (user comments / guestbook).
# When set, posts are checked against the OpenAI Moderations endpoint in
# addition to the website_wordfilter blocklist. Fail-open if unset/erroring.
OPENAI_API_KEY=
# Optional PayPal top-up (sandbox by default)
PAYPAL_CLIENT_ID=
PAYPAL_SECRET=
+6 -1
View File
@@ -1,4 +1,5 @@
import type { NextConfig } from "next";
import createNextIntlPlugin from "next-intl/plugin";
const nextConfig: NextConfig = {
// This app lives inside the Laravel repo tree (which has its own lockfiles);
@@ -9,4 +10,8 @@ const nextConfig: NextConfig = {
serverExternalPackages: ["@prisma/adapter-mariadb", "mariadb", "@prisma/client"],
};
export default nextConfig;
// next-intl WITHOUT i18n routing — locale comes from the NEXT_LOCALE cookie via
// src/i18n/request.ts, so URLs and the access-guard middleware stay unchanged.
const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
export default withNextIntl(nextConfig);
+1
View File
@@ -25,6 +25,7 @@
"hash-wasm": "^4.12.0",
"next": "^16.2.9",
"next-auth": "5.0.0-beta.31",
"next-intl": "^4.13.0",
"nodemailer": "^6.9.0",
"otplib": "^12.0.1",
"react": "^19.2.0",
+429
View File
File diff suppressed because it is too large. Load diff
+84
View File
@@ -0,0 +1,84 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
import { logStaffActivity } from "@/lib/services/staff-activity";
// CRUD for website advertisements (website_ads). Emulator does not own this
// table; it only stores an image URL rendered in the site layout/widgets.
export async function createAd(formData: FormData): Promise<void> {
const staff = await requireStaff();
const image = String(formData.get("image") ?? "").trim().slice(0, 255);
if (!image) return;
const now = new Date();
try {
const ad = await prisma.websiteAds.create({
data: { image, createdAt: now, updatedAt: now },
});
await logStaffActivity({
staffId: staff.id,
action: "ad_create",
description: `Created advertisement #${ad.id} (${image})`,
targetType: "website_ad",
targetId: Number(ad.id),
});
} catch {
// DB error — page re-renders unchanged.
revalidatePath("/admin/ads");
return;
}
redirect("/admin/ads");
}
export async function updateAd(formData: FormData): Promise<void> {
const staff = await requireStaff();
const raw = String(formData.get("id") ?? "");
if (!/^\d+$/.test(raw)) return;
const id = BigInt(raw);
const image = String(formData.get("image") ?? "").trim().slice(0, 255);
if (!image) return;
try {
await prisma.websiteAds.update({
where: { id },
data: { image, updatedAt: new Date() },
});
await logStaffActivity({
staffId: staff.id,
action: "ad_update",
description: `Updated advertisement #${id} (${image})`,
targetType: "website_ad",
targetId: Number(id),
});
} catch {
// Not found or DB error — ignore.
revalidatePath(`/admin/ads/${id}`);
return;
}
redirect("/admin/ads");
}
export async function deleteAd(formData: FormData): Promise<void> {
const staff = await requireStaff();
const raw = String(formData.get("id") ?? "");
if (!/^\d+$/.test(raw)) return;
const id = BigInt(raw);
try {
await prisma.websiteAds.delete({ where: { id } });
await logStaffActivity({
staffId: staff.id,
action: "ad_delete",
description: `Deleted advertisement #${id}`,
targetType: "website_ad",
targetId: Number(id),
});
} catch {
// Not found or DB error — ignore.
}
redirect("/admin/ads");
}
File diff suppressed because it is too large. Load diff
+98
View File
@@ -0,0 +1,98 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
import { logStaffActivity } from "@/lib/services/staff-activity";
// website_permissions (model WebsitePermissions) is the CMS-owned rank-permission
// mapping AtomCMS exposes in admin: a key/value(/comment) row per permission.
// Editable columns on the table are exactly: key (unique), value, comment.
// id is BigInt and created_at/updated_at are managed here — no other columns
// exist, so there are no extra staff flags to toggle.
export async function createPermission(formData: FormData): Promise<void> {
const staff = await requireStaff();
const key = String(formData.get("key") ?? "").trim().slice(0, 255);
const value = String(formData.get("value") ?? "").trim().slice(0, 255);
const comment = String(formData.get("comment") ?? "").trim().slice(0, 255);
if (!key) return;
const now = new Date();
try {
await prisma.websitePermissions.upsert({
where: { key },
update: { value: value || null, comment: comment || null, updatedAt: now },
create: {
key,
value: value || null,
comment: comment || null,
createdAt: now,
updatedAt: now,
},
});
await logStaffActivity({
staffId: staff.id,
action: "permission_create",
description: `Saved permission "${key}" = "${value}"`,
targetType: "permission",
});
} catch {
// ignore (e.g. constraint failure) — page re-renders current state
}
revalidatePath("/admin/permissions");
}
export async function updatePermission(formData: FormData): Promise<void> {
const staff = await requireStaff();
const raw = String(formData.get("id") ?? "");
if (!raw) return;
const id = BigInt(raw);
const key = String(formData.get("key") ?? "").trim().slice(0, 255);
const value = String(formData.get("value") ?? "").trim().slice(0, 255);
const comment = String(formData.get("comment") ?? "").trim().slice(0, 255);
if (!key) return;
try {
await prisma.websitePermissions.update({
where: { id },
data: {
key,
value: value || null,
comment: comment || null,
updatedAt: new Date(),
},
});
await logStaffActivity({
staffId: staff.id,
action: "permission_update",
description: `Updated permission #${raw} ("${key}" = "${value}")`,
targetType: "permission",
});
} catch {
// ignore (e.g. duplicate key) — page re-renders current state
}
revalidatePath("/admin/permissions");
}
export async function deletePermission(formData: FormData): Promise<void> {
const staff = await requireStaff();
const raw = String(formData.get("id") ?? "");
if (!raw) return;
try {
const deleted = await prisma.websitePermissions.delete({
where: { id: BigInt(raw) },
select: { key: true },
});
await logStaffActivity({
staffId: staff.id,
action: "permission_delete",
description: `Deleted permission #${raw} ("${deleted.key}")`,
targetType: "permission",
});
} catch {
// ignore (e.g. already removed)
}
revalidatePath("/admin/permissions");
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+94
View File
@@ -0,0 +1,94 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
import { logStaffActivity } from "@/lib/services/staff-activity";
// users_currency.type values for the non-credits currencies (mirror send-currency.ts).
// Credits live on users.credits; pixels/points live on the users row too;
// duckets/diamonds live in users_currency keyed by (user_id, type).
const DUCKETS_TYPE = 0;
const DIAMONDS_TYPE = 5;
function toInt(value: FormDataEntryValue | null, min = 0): number | null {
if (value == null) return null;
const raw = String(value).trim();
if (raw === "") return null;
const n = Number(raw);
if (!Number.isFinite(n)) return null;
const i = Math.trunc(n);
return i < min ? min : i;
}
/**
* Edit the SAFE website-managed fields of a users row (and the duckets/diamonds
* balances in users_currency). Never touches the password. Re-reads the staff
* user from the session and logs the action. emulator-owned users.id is Int.
*/
export async function updateUser(formData: FormData): Promise<void> {
// Never trust the client: re-check staff inside the action.
const staff = await requireStaff();
const userId = Number(formData.get("id"));
if (!Number.isInteger(userId) || userId <= 0) return;
const existing = await prisma.user.findUnique({
where: { id: userId },
select: { id: true },
});
if (!existing) return;
// users row — only existing, safe columns.
const mailRaw = String(formData.get("mail") ?? "").trim();
const motto = String(formData.get("motto") ?? "").slice(0, 127);
const look = String(formData.get("look") ?? "").slice(0, 256);
const rank = toInt(formData.get("rank"), 1);
const credits = toInt(formData.get("credits"), 0);
const pixels = toInt(formData.get("pixels"), 0);
const points = toInt(formData.get("points"), 0);
await prisma.user.update({
where: { id: userId },
data: {
mail: mailRaw === "" ? null : mailRaw.slice(0, 500),
motto,
look,
...(rank != null ? { rank } : {}),
...(credits != null ? { credits } : {}),
...(pixels != null ? { pixels } : {}),
...(points != null ? { points } : {}),
},
});
// users_currency — set exact balances for duckets / diamonds.
const duckets = toInt(formData.get("duckets"), 0);
const diamonds = toInt(formData.get("diamonds"), 0);
if (duckets != null) {
await prisma.usersCurrency.upsert({
where: { userId_type: { userId, type: DUCKETS_TYPE } },
update: { amount: duckets },
create: { userId, type: DUCKETS_TYPE, amount: duckets },
});
}
if (diamonds != null) {
await prisma.usersCurrency.upsert({
where: { userId_type: { userId, type: DIAMONDS_TYPE } },
update: { amount: diamonds },
create: { userId, type: DIAMONDS_TYPE, amount: diamonds },
});
}
await logStaffActivity({
staffId: staff.id,
action: "user_edit",
description: `Edited account fields of user #${userId}`,
targetType: "user",
targetId: userId,
});
revalidatePath(`/admin/users/${userId}`);
revalidatePath(`/admin/users/${userId}/edit`);
redirect(`/admin/users/${userId}`);
}
+4
View File
@@ -3,6 +3,7 @@
import { revalidatePath } from "next/cache";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { isAllowed } from "@/lib/services/moderation";
// website_article_comments.comment is VARCHAR(255); keep the write within bounds.
const COMMENT_MAX = 255;
@@ -23,6 +24,9 @@ export async function postComment(formData: FormData): Promise<void> {
const comment = String(formData.get("comment") ?? "").trim().slice(0, COMMENT_MAX);
if (!comment) return;
// Block filtered/AI-flagged content before it touches the DB (fail-open).
if (!(await isAllowed(comment)).ok) return;
const articleIdRaw = String(formData.get("articleId") ?? "").trim();
if (!/^\d+$/.test(articleIdRaw)) return;
+94
View File
@@ -0,0 +1,94 @@
"use server";
import { revalidatePath } from "next/cache";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
// The reaction set the UI offers. The action rejects anything outside this list
// so the website_article_reactions.reaction VARCHAR(50) only ever holds known
// values. Keep this in sync with REACTIONS in src/app/news/[slug]/page.tsx.
const ALLOWED_REACTIONS = new Set(["like", "love", "wow"]);
/**
* Toggle the SIGNED-IN user's reaction on a news article.
*
* The voter id is read from the session (re-fetched via auth()), never from the
* submitted FormData, so a crafted form cannot vote as another account. A user
* has at most one ACTIVE reaction per article:
* - clicking the reaction they already have active -> deactivates it (un-vote)
* - clicking a different reaction -> that reaction becomes active and any other
* reaction rows for this user/article are deactivated
* - first-ever reaction of a type -> a new active row is created
*
* Rows are toggled (active flag) rather than deleted so a user's history of
* reaction types is preserved. website_article_reactions has no composite
* unique key, so we resolve the existing row with findFirst rather than upsert.
*/
export async function toggleReaction(formData: FormData): Promise<void> {
const session = await auth();
if (!session?.user?.id) return;
const userId = Number(session.user.id);
if (!Number.isFinite(userId)) return;
const reaction = String(formData.get("reaction") ?? "").trim().toLowerCase();
if (!ALLOWED_REACTIONS.has(reaction)) return;
const articleIdRaw = String(formData.get("articleId") ?? "").trim();
if (!/^\d+$/.test(articleIdRaw)) return;
let articleId: bigint;
try {
articleId = BigInt(articleIdRaw);
} catch {
return;
}
let slug: string | null = null;
try {
// Confirm the article exists (and grab its slug for revalidation).
const article = await prisma.websiteArticles.findUnique({
where: { id: articleId },
select: { slug: true },
});
if (!article) return;
slug = article.slug;
// The user's current row for THIS reaction on THIS article, if any.
const existing = await prisma.websiteArticleReactions.findFirst({
where: { userId, articleId, reaction },
select: { id: true, active: true },
});
if (existing?.active) {
// Already reacting with this exact reaction -> un-vote (deactivate it).
await prisma.websiteArticleReactions.update({
where: { id: existing.id },
data: { active: false },
});
} else {
// Switching to (or first-time picking) this reaction: clear any other
// active reaction by this user on this article, then activate this one.
await prisma.websiteArticleReactions.updateMany({
where: { userId, articleId, active: true },
data: { active: false },
});
if (existing) {
await prisma.websiteArticleReactions.update({
where: { id: existing.id },
data: { active: true },
});
} else {
await prisma.websiteArticleReactions.create({
data: { userId, articleId, reaction, active: true },
});
}
}
} catch {
// DB unavailable — fail soft; nothing to persist.
return;
}
if (slug) revalidatePath(`/news/${slug}`);
}
+88
View File
@@ -0,0 +1,88 @@
"use server";
import { createHash, timingSafeEqual } from "node:crypto";
import { env } from "@/env";
import { sendMail } from "@/lib/services/email";
import { siteSettings } from "@/lib/services/site-settings";
// Stateless email verification, AtomCMS-faithful but DB-table-free.
//
// Instead of persisting a row (password_resets style), the token is a keyed
// digest of the email address: sha256(email + APP_KEY). Because APP_KEY is a
// server-only secret, an attacker who only knows the email cannot forge a
// matching token, and /verify can recompute + compare it without any storage.
// The token is therefore deterministic per (email, secret) pair and stays valid
// until the account's mail_verified flips to '1' (after which /verify no-ops).
/** Secret mixed into the digest. Falls back to AUTH_SECRET, then a constant. */
function verifySecret(): string {
return env.APP_KEY || env.AUTH_SECRET || "atom-cms-verify";
}
/** Compute the verification token for an email (lowercased + trimmed). */
export async function verificationToken(email: string): Promise<string> {
const normalised = email.trim().toLowerCase();
return createHash("sha256").update(`${normalised}|${verifySecret()}`).digest("hex");
}
/**
* Constant-time check that `token` matches the expected digest for `email`.
* Returns false on any length/format mismatch rather than throwing.
*/
export async function isValidVerificationToken(
email: string,
token: string,
): Promise<boolean> {
if (!email || !token || !/^[a-f0-9]{64}$/i.test(token)) return false;
const expected = await verificationToken(email);
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(token.toLowerCase(), "utf8");
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}
/**
* Build the verification link + email and send it. No-ops gracefully when SMTP
* is unconfigured (sendMail returns false). `userId` is accepted for a faithful
* call signature, but the stateless token only needs the email.
*/
export async function sendVerification(userId: number, email: string): Promise<boolean> {
const normalised = email.trim().toLowerCase();
if (!normalised) return false;
const token = await verificationToken(normalised);
const base = env.APP_URL.replace(/\/+$/, "");
const link = `${base}/verify?token=${encodeURIComponent(token)}&email=${encodeURIComponent(
normalised,
)}`;
const hotelName = (await siteSettings.get("hotel_name", env.HOTEL_NAME)) ?? env.HOTEL_NAME;
const html = `
<div style="font-family:sans-serif;line-height:1.5;color:#0f172a">
<h2 style="margin:0 0 0.5rem">Verify your email</h2>
<p>Welcome to ${escapeHtml(hotelName)}! Confirm this email address to finish setting up your account.</p>
<p style="margin:1.25rem 0">
<a href="${link}"
style="display:inline-block;padding:0.6rem 1.2rem;border-radius:8px;background:#eeb425;color:#1a1a2e;font-weight:700;text-decoration:none">
Verify email
</a>
</p>
<p style="color:#64748b;font-size:0.875rem">If the button doesn't work, paste this link into your browser:</p>
<p style="color:#64748b;font-size:0.875rem;word-break:break-all">${link}</p>
</div>
`.trim();
// `userId` referenced so a faithful caller signature isn't flagged unused.
void userId;
return sendMail(normalised, `Verify your email · ${hotelName}`, html);
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
+4
View File
@@ -3,6 +3,7 @@
import { revalidatePath } from "next/cache";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { isAllowed } from "@/lib/services/moderation";
// Emulator/CMS column message is VARCHAR(255); keep the write within bounds.
const MESSAGE_MAX = 255;
@@ -27,6 +28,9 @@ export async function postGuestbook(formData: FormData): Promise<void> {
const message = String(formData.get("message") ?? "").trim().slice(0, MESSAGE_MAX);
if (!message) return;
// Block filtered/AI-flagged content before it touches the DB (fail-open).
if (!(await isAllowed(message)).ok) return;
// Optional: used only to revalidate the correct profile route.
const username = String(formData.get("username") ?? "").trim();
+75
View File
@@ -0,0 +1,75 @@
"use server";
import { revalidatePath } from "next/cache";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
/**
* Accept a pending friend request as the SIGNED-IN user.
*
* The ACCEPTER is re-read from the session via auth() and is never trusted from
* the submitted FormData. Only the request id comes from the form, and the
* request is only honoured when its target (user_to_id) is the session user —
* so a crafted form cannot accept a request addressed to someone else.
*
* Arcturus/AtomCMS stores friendships as TWO directional rows in
* messenger_friendships (one user_one_id→user_two_id, one the reverse). We
* create both inside a transaction and delete the originating request so it no
* longer shows as pending in the in-game messenger or here.
*/
export async function acceptFriend(formData: FormData): Promise<void> {
const session = await auth();
const meId = Number(session?.user?.id);
if (!Number.isInteger(meId) || meId <= 0) return;
const requestId = Number(formData.get("requestId"));
if (!Number.isInteger(requestId) || requestId <= 0) return;
try {
// The request must exist AND be addressed to the session user.
const request = await prisma.messengerFriendrequests.findUnique({
where: { id: requestId },
select: { id: true, userFromId: true, userToId: true },
});
if (!request || request.userToId !== meId) return;
const friendId = request.userFromId;
if (!Number.isInteger(friendId) || friendId <= 0 || friendId === meId) {
// Malformed/self request — just clear it.
await prisma.messengerFriendrequests.delete({ where: { id: requestId } });
return;
}
const friendsSince = Math.floor(Date.now() / 1000);
await prisma.$transaction(async (tx) => {
// Don't double-insert if a friendship already exists in either direction.
const existing = await tx.messengerFriendships.findFirst({
where: {
OR: [
{ userOneId: meId, userTwoId: friendId },
{ userOneId: friendId, userTwoId: meId },
],
},
select: { id: true },
});
if (!existing) {
await tx.messengerFriendships.createMany({
data: [
{ userOneId: meId, userTwoId: friendId, friendsSince },
{ userOneId: friendId, userTwoId: meId, friendsSince },
],
});
}
await tx.messengerFriendrequests.delete({ where: { id: requestId } });
});
} catch {
// DB unavailable — fail soft; nothing to persist.
return;
}
revalidatePath("/messages");
revalidatePath("/friends");
}
+11 -1
View File
@@ -2,6 +2,7 @@
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { sendVerification } from "@/actions/email-verify";
import { hashPassword } from "@/lib/auth/password";
import { prisma } from "@/lib/prisma";
@@ -39,7 +40,7 @@ export async function register(formData: FormData): Promise<void> {
h.get("x-forwarded-for")?.split(",")[0]?.trim() ?? h.get("x-real-ip") ?? "0.0.0.0";
const now = Math.floor(Date.now() / 1000);
try {
await prisma.user.create({
const created = await prisma.user.create({
data: {
username,
password: await hashPassword(password),
@@ -49,7 +50,16 @@ export async function register(formData: FormData): Promise<void> {
ipCurrent: ip,
look: DEFAULT_LOOK,
},
select: { id: true },
});
// Fire the verification email. Best-effort: a mail/SMTP failure must not
// abort a successful registration, so swallow its errors here.
try {
await sendVerification(created.id, mail);
} catch {
// No-op: account is created; user can request a new link later.
}
} catch {
error = "Could not create the account (is the username unique?)";
}
+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>
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+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>
);
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+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 {
+16 -1
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,12 +25,24 @@ 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)" }}
>
<NextIntlClientProvider locale={locale} messages={messages}>
<ThemeVars />
<TopHeader />
<SiteHeader />
@@ -39,6 +53,7 @@ export default async function RootLayout({ children }: { children: ReactNode })
</div>
</main>
<SiteFooter />
</NextIntlClientProvider>
</body>
</html>
);
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+45
View File
@@ -0,0 +1,45 @@
"use client";
import { useLocale } from "next-intl";
import { useRouter } from "next/navigation";
import { useTransition } from "react";
const LOCALES: { code: string; label: string }[] = [
{ code: "en", label: "EN" },
{ code: "it", label: "IT" },
];
/**
* Locale picker. Writes the chosen locale to the `NEXT_LOCALE` cookie (read by
* src/i18n/request.ts on the server) and refreshes the route so server
* components re-render with the new messages. No URL change — matches the
* cookie-based, routing-free i18n setup.
*/
export function LanguageSwitcher() {
const locale = useLocale();
const router = useRouter();
const [pending, startTransition] = useTransition();
function onChange(e: React.ChangeEvent<HTMLSelectElement>) {
const next = e.target.value;
document.cookie = `NEXT_LOCALE=${next};path=/;max-age=31536000;samesite=lax`;
startTransition(() => router.refresh());
}
return (
<select
value={locale}
onChange={onChange}
disabled={pending}
aria-label="Language"
className="nav-item cursor-pointer bg-transparent text-[13px]"
style={{ border: "none", padding: "0 0.25rem" }}
>
{LOCALES.map((l) => (
<option key={l.code} value={l.code}>
{l.label}
</option>
))}
</select>
);
}
File diff suppressed because it is too large. Load diff
+3 -1
View File
@@ -1,8 +1,10 @@
import { getTranslations } from "next-intl/server";
import { siteSettings } from "@/lib/services/site-settings";
export async function SiteFooter() {
const hotelName = await siteSettings.get("hotel_name", "Atom");
const year = new Date().getFullYear();
const t = await getTranslations("footer");
return (
<footer
@@ -10,7 +12,7 @@ export async function SiteFooter() {
style={{ backgroundColor: "var(--color-surface)", color: "var(--color-text-muted)" }}
>
<div className="md:font-semibold text-[12px] md:text-[14px]">
© {year} - {hotelName} is a not for profit educational project
{t("copyright", { year, hotel: hotelName ?? "Atom" })}
</div>
</footer>
);
+8 -9
View File
@@ -1,9 +1,11 @@
import { getTranslations } from "next-intl/server";
import Link from "next/link";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
export async function SiteHeader() {
const t = await getTranslations("header");
const [session, hotelName, header, logo] = await Promise.all([
auth(),
siteSettings.get("hotel_name", "Atom"),
@@ -50,7 +52,7 @@ export async function SiteHeader() {
style={{ backgroundColor: "var(--color-surface)" }}
/>
<span className="relative">
{online} {hotelName} online
{t("online", { count: online, hotel: hotelName ?? "Atom" })}
</span>
</div>
</div>
@@ -66,7 +68,7 @@ export async function SiteHeader() {
borderColor: "rgba(255,255,255,0.3)",
}}
>
Nitro client
{t("nitroClient")}
</button>
</Link>
</div>
@@ -77,10 +79,7 @@ export async function SiteHeader() {
className="font-semibold flex flex-col items-center md:w-[600px]"
style={{ color: "#ffffff", textShadow: "0 1px 4px rgba(0,0,0,0.7)" }}
>
<p className="hidden text-center text-xl md:block">
An online virtual world where you can create your own avatar, make friends, chat,
create rooms and much more!
</p>
<p className="hidden text-center text-xl md:block">{t("tagline")}</p>
<div className="flex flex-col items-center justify-center gap-x-6 gap-y-4 md:mt-6 md:flex-row md:gap-y-0">
<Link href="/login">
<button
@@ -92,17 +91,17 @@ export async function SiteHeader() {
color: "var(--button-text-color)",
}}
>
Login
{t("login")}
</button>
</Link>
<p className="text-sm uppercase">Or</p>
<p className="text-sm uppercase">{t("or")}</p>
<Link href="/register">
<button
type="button"
className="uppercase px-8 py-2.5 rounded-full transition ease-in-out duration-200"
style={{ backgroundColor: "var(--color-accent)", color: "#ffffff" }}
>
Create an account
{t("createAccount")}
</button>
</Link>
</div>
+42
View File
@@ -0,0 +1,42 @@
"use client";
import { useEffect, useState } from "react";
/**
* Light/dark toggle. The actual class lives on <html> (so `.app.dark` selectors
* and the dark CSS-var block apply); we mirror localStorage so the choice
* survives reloads. The inline boot script in the root layout applies the saved
* theme before paint to avoid a flash — this component only reflects/toggles it.
*/
export function ThemeSwitcher() {
const [dark, setDark] = useState(false);
useEffect(() => {
setDark(document.documentElement.classList.contains("dark"));
}, []);
function toggle() {
const next = !dark;
setDark(next);
document.documentElement.classList.toggle("dark", next);
try {
localStorage.setItem("theme", next ? "dark" : "light");
} catch {
/* private mode / storage disabled — runtime toggle still works */
}
}
return (
<button
type="button"
onClick={toggle}
className="nav-item cursor-pointer"
aria-label={dark ? "Switch to light mode" : "Switch to dark mode"}
title={dark ? "Light mode" : "Dark mode"}
>
<span aria-hidden className="text-lg leading-none">
{dark ? "☀" : "☾"}
</span>
</button>
);
}
+24
View File
@@ -0,0 +1,24 @@
import { cookies } from "next/headers";
import { getRequestConfig } from "next-intl/server";
// i18n WITHOUT routing: the locale is chosen by a `NEXT_LOCALE` cookie (set by
// the language switcher) rather than a URL segment, so every existing route and
// the access-guard middleware keep working unchanged. English is the fallback.
export const SUPPORTED_LOCALES = ["en", "it"] as const;
export type AppLocale = (typeof SUPPORTED_LOCALES)[number];
export const DEFAULT_LOCALE: AppLocale = "en";
export function isSupportedLocale(value: string | undefined): value is AppLocale {
return !!value && (SUPPORTED_LOCALES as readonly string[]).includes(value);
}
export default getRequestConfig(async () => {
const store = await cookies();
const cookieLocale = store.get("NEXT_LOCALE")?.value;
const locale: AppLocale = isSupportedLocale(cookieLocale) ? cookieLocale : DEFAULT_LOCALE;
return {
locale,
messages: (await import(`../messages/${locale}.json`)).default,
};
});
File diff suppressed because it is too large. Load diff
+39
View File
@@ -0,0 +1,39 @@
{
"nav": {
"home": "Home",
"community": "Community",
"articles": "Articles",
"photos": "Photos",
"staff": "Staff",
"rankings": "Rankings",
"guilds": "Guilds",
"leaderboards": "Leaderboards",
"rareValues": "Rare values",
"shop": "Shop",
"market": "Market",
"assistance": "Assistance",
"helpCenter": "Help center",
"myTickets": "My tickets",
"badges": "Badges",
"radio": "Radio",
"friends": "Friends",
"messages": "Messages",
"admin": "Admin"
},
"header": {
"online": "{count} {hotel} online",
"nitroClient": "Nitro client",
"tagline": "An online virtual world where you can create your own avatar, make friends, chat, create rooms and much more!",
"login": "Login",
"or": "Or",
"createAccount": "Create an account"
},
"footer": {
"copyright": "© {year} - {hotel} is a not for profit educational project"
},
"common": {
"language": "Language",
"lightMode": "Light mode",
"darkMode": "Dark mode"
}
}
+39
View File
@@ -0,0 +1,39 @@
{
"nav": {
"home": "Home",
"community": "Community",
"articles": "Articoli",
"photos": "Foto",
"staff": "Staff",
"rankings": "Classifiche",
"guilds": "Gruppi",
"leaderboards": "Classifiche",
"rareValues": "Valori rari",
"shop": "Negozio",
"market": "Mercato",
"assistance": "Assistenza",
"helpCenter": "Centro assistenza",
"myTickets": "I miei ticket",
"badges": "Distintivi",
"radio": "Radio",
"friends": "Amici",
"messages": "Messaggi",
"admin": "Admin"
},
"header": {
"online": "{count} online su {hotel}",
"nitroClient": "Client Nitro",
"tagline": "Un mondo virtuale online dove puoi creare il tuo avatar, fare amicizia, chattare, creare stanze e molto altro!",
"login": "Accedi",
"or": "Oppure",
"createAccount": "Crea un account"
},
"footer": {
"copyright": "© {year} - {hotel} è un progetto educativo senza scopo di lucro"
},
"common": {
"language": "Lingua",
"lightMode": "Tema chiaro",
"darkMode": "Tema scuro"
}
}