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:
1 parent
22d53d0e9c
commit
7daeccb832
45 files changed
+3298
-70
No files matched your search
@@ -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
@@ -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);
|
||||
@@ -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",
|
||||
|
||||
Generated
+429
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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
@@ -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}`);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
@@ -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?)";
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user