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

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

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

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

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

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

No files matched your search

+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
@@ -29,6 +29,9 @@ importers:
next-auth:
specifier: 5.0.0-beta.31
version: 5.0.0-beta.31([email protected]([email protected]([email protected]))([email protected]))([email protected])([email protected])
next-intl:
specifier: ^4.13.0
version: 4.13.0([email protected]([email protected]([email protected]))([email protected]))([email protected])([email protected])
nodemailer:
specifier: ^6.9.0
version: 6.10.1
@@ -731,6 +734,18 @@ packages:
cpu: [x64]
os: [win32]
'@formatjs/[email protected]':
resolution: {integrity: sha512-H5aexk1Le7T9TPmscacZ+1pR6CTa2n1wq+HDVGXhH8TzUlQQpeXzZs91dRtmFHrbeNbjPFPfQujUqm7MHgVoXQ==}
'@formatjs/[email protected]':
resolution: {integrity: sha512-YyzzxVgYJ8DELmmkhn0Yr0rUj0dTJFf9Jp628K3S0ysInBWxLVDOS8i3RP91cCp4DMK4WYb4cVMhWA9i4knSJg==}
'@formatjs/[email protected]':
resolution: {integrity: sha512-XuSva+8ZGawk8VnD5VD6UeH8KarQ/Z022zgjHDoHmlNiAewstXuuzXc0Hk5pGFSdG+nNw5bfJKXqj1ZXHn9yUA==}
'@formatjs/[email protected]':
resolution: {integrity: sha512-P/IC3qws3jH+1fEs+o0RIFgXKRaQlFehjS5W0FPAqdo6hgzawLl+eD0q0JjheQ3XtoOe5n8WSYfX06KQZI/QJA==}
'@hono/[email protected]':
resolution: {integrity: sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==}
engines: {node: '>=18.14.1'}
@@ -985,6 +1000,94 @@ packages:
'@panva/[email protected]':
resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==}
'@parcel/[email protected]':
resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [android]
'@parcel/[email protected]':
resolution: {integrity: sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [darwin]
'@parcel/[email protected]':
resolution: {integrity: sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [darwin]
'@parcel/[email protected]':
resolution: {integrity: sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [freebsd]
'@parcel/[email protected]':
resolution: {integrity: sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==}
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
libc: [glibc]
'@parcel/[email protected]':
resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==}
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
libc: [musl]
'@parcel/[email protected]':
resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@parcel/[email protected]':
resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@parcel/[email protected]':
resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@parcel/[email protected]':
resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
'@parcel/[email protected]':
resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [win32]
'@parcel/[email protected]':
resolution: {integrity: sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==}
engines: {node: '>= 10.0.0'}
cpu: [ia32]
os: [win32]
'@parcel/[email protected]':
resolution: {integrity: sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [win32]
'@parcel/[email protected]':
resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==}
engines: {node: '>= 10.0.0'}
'@petamoriken/[email protected]':
resolution: {integrity: sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==}
@@ -1263,12 +1366,108 @@ packages:
cpu: [x64]
os: [win32]
'@schummar/[email protected]':
resolution: {integrity: sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==}
'@standard-schema/[email protected]':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@swc/[email protected]':
resolution: {integrity: sha512-v1aVuvXdo/BHxJzco9V2xpHrvwWmhfS8t6gziY5wJxd+Z2h8AeJRnAwPD8itCDaGXVBwJ/CaKfxEzTkG0Va0OA==}
engines: {node: '>=10'}
cpu: [arm64]
os: [darwin]
'@swc/[email protected]':
resolution: {integrity: sha512-lp3d4Lamc8dt5huYdGLSR+9hLxmfr1jb0l+4XXG2zPqZwYWRN9R0U2qYoTrggiU2RWW0oV9VbWM3kBnqIc2kdQ==}
engines: {node: '>=10'}
cpu: [x64]
os: [darwin]
'@swc/[email protected]':
resolution: {integrity: sha512-JWTQQELtsG5GgphDrr/XqqmM2pDN3cZqbMS0Mrg+iTiXL3F74sn/S2IyYE/5u4h2KLkTf9qQ7dXyxsbx7YzkeA==}
engines: {node: '>=10'}
cpu: [arm]
os: [linux]
'@swc/[email protected]':
resolution: {integrity: sha512-B4otJRdPWIsmiSBf0uG7Z/+vMWmkufjz5MmYxubwKuZazDW14Zd3symga1N62QR4RT+kEFeHEgsXfZGyn/w0hw==}
engines: {node: '>=10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@swc/[email protected]':
resolution: {integrity: sha512-6zB6OnpViBxYy4tgY3v2i6AZY9fwkcHZ032UOwtwUuW1d19sdT07qF0kZe6/3UR1tUaK6jjg2rmVcUIBCEYVjQ==}
engines: {node: '>=10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@swc/[email protected]':
resolution: {integrity: sha512-coxE1ZWdB3uSDVNoEtYNrRi/1epvckZx9cTJ8ICUxTMTxGk+yvQ/Twacp3ruZSaMPGCriUjP86C37VhaT6nyRg==}
engines: {node: '>=10'}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@swc/[email protected]':
resolution: {integrity: sha512-lXfLhs+LpBsD5inuYx+YDH5WsPPBQ95KPUiy8P5wq9ob9xKDZFqwNfU2QW6bGO8NqRO/H9JQomTSt5Yyh+FGfA==}
engines: {node: '>=10'}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@swc/[email protected]':
resolution: {integrity: sha512-07XnKwTmKy8TGOZG3D9fRnLWGynxPjwQnZLVmBFbo6F+7vHYzBIOuwXEhemrChBWb6yDNZsVCcMWCPX6FDD2xg==}
engines: {node: '>=10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@swc/[email protected]':
resolution: {integrity: sha512-TJc+bsSIaBh+hZvZ5GRtW/K1bw66TJ9vsUwvVIsZdiWxU5ObLwZvfcnZ3UpgVfMnFibRes9uriJrQNBHEEogRQ==}
engines: {node: '>=10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@swc/[email protected]':
resolution: {integrity: sha512-jfd7s2/bUQYkOHLs+LWQNKZdmDa8+sufKLllhpWAhVQ2GDCwsHe3vR/j+OSiItZNtkzFuaawa3+SAKz9y5gYfw==}
engines: {node: '>=10'}
cpu: [arm64]
os: [win32]
'@swc/[email protected]':
resolution: {integrity: sha512-rLAE8JvucqEW1ZGohxPQrQWPBQeJG4+ypKbWfdlU/qmKScvCkxf9/Jxnzki1dkUQCQ7P5Enp13RlvqOlvx/32g==}
engines: {node: '>=10'}
cpu: [ia32]
os: [win32]
'@swc/[email protected]':
resolution: {integrity: sha512-h8MLDHZcfIukwQWj03rIJZx1I0E81AYj2X7J/nGErG4nz+QAv6G1Z+peotvinL3lqpbo32tLYSMFo32/ySzxKg==}
engines: {node: '>=10'}
cpu: [x64]
os: [win32]
'@swc/[email protected]':
resolution: {integrity: sha512-1CuKjFkPxIgGdeHVuNbkxmBxkcbdc08u0aiI43pFq6yY1tTVKmXT9hFEooyyKs/sJ3xf1GPHyEwTtk9Xl8dvQw==}
engines: {node: '>=10'}
peerDependencies:
'@swc/helpers': '>=0.5.17'
peerDependenciesMeta:
'@swc/helpers':
optional: true
'@swc/[email protected]':
resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
'@swc/[email protected]':
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
'@swc/[email protected]':
resolution: {integrity: sha512-K6h3iUlqeM946U4sXFYeahefR1YBbXJvko+hv8WS8/0BNJ4OHiHRywMnQUJCqkR7Y9+hqQ1TvEpiKqUhz7NEFg==}
'@tailwindcss/[email protected]':
resolution: {integrity: sha512-h9wegbZDPurxG22xZSoWtdzc41/OlNEUQERNqI/0fOwa2aVlWGu7C35E/x6LDyD3lgtztFSSjKZyuVM0hxhbgA==}
peerDependencies:
@@ -1754,6 +1953,20 @@ packages:
resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
engines: {node: '>=0.10.0'}
[email protected]:
resolution: {integrity: sha512-SIFMeUHZJjzS5RvIGvybKvWoHjDm9cGVEs2EpJ8PmywOdJLWyblPm7TdPLLoUtkJtwQD7iGhl2WMptZ+N0on+w==}
[email protected]:
resolution: {integrity: sha512-cGzymZerpDhVXRKjKLgXKda9gI29TU2o88L7gwNMHp3WZVxA/0c5tX52udXbW9JklDApolvMXZG6Dhhdz5eirA==}
[email protected]:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'}
[email protected]:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'}
[email protected]:
resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==}
@@ -1894,6 +2107,10 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
[email protected]:
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
engines: {node: '>= 0.6'}
[email protected]:
resolution: {integrity: sha512-1OBgCKPzo+S7UWWMp3xgvGvIJ0OpV7B3vR4ZDRqD9a4Ch+OT6dakLXG9ivhtmIWVa71nTSXattOHyCg8sNi8/Q==}
peerDependencies:
@@ -1910,6 +2127,19 @@ packages:
nodemailer:
optional: true
[email protected]:
resolution: {integrity: sha512-6S/fJI0KXvLCL8nhBo9P8eGaJPzmwJBTCzX0NaUIj0VyU8U89d//T+vjMLdNIXl5MlLaYH7B9MbAjb8Mvu+tqQ==}
[email protected]:
resolution: {integrity: sha512-OvNq2v5XLx4EkQOsAhVE9g+6zdb83XHusADCXXtIW4LILYnjEVaeINdr1lkVWKSjzwNUiMSlH5N4K0OQTRiv6A==}
peerDependencies:
next: ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0
typescript: '*'
peerDependenciesMeta:
typescript:
optional: true
[email protected]:
resolution: {integrity: sha512-MEOJiq/UvuezAdqVSceHbqDgZt1kDw2tpGVOlsdIoJsQdbN2JY2hpVG4xnXGkbdJUOEWhnRfiu/O4Hpc9Juwww==}
engines: {node: '>=20.9.0'}
@@ -1931,6 +2161,9 @@ packages:
sass:
optional: true
[email protected]:
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
[email protected]:
resolution: {integrity: sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==}
engines: {node: '>=6.0.0'}
@@ -1964,9 +2197,16 @@ packages:
[email protected]:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
[email protected]:
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
engines: {node: '>=12'}
[email protected]:
resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==}
[email protected]:
resolution: {integrity: sha512-ECF4zHLbUItpUgE3OTtLKlPjeBN+fKEczj2zYjDfCGOzicNs0GK3Vg2IoAYwx7LH/XYw43fZQP6xnZ4TkNxSLQ==}
[email protected]:
resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==}
engines: {node: '>=4'}
@@ -2171,6 +2411,11 @@ packages:
[email protected]:
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
[email protected]:
resolution: {integrity: sha512-fAFDrWaASxlhXOipcOyb5VDD+YONqj6+8O8EcG/J7RBoOUF3A8YahRWLN+mBxYMrlMQB8N6Voqk5X+YC+HSL0A==}
peerDependencies:
react: ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0
[email protected]:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
@@ -2587,6 +2832,18 @@ snapshots:
'@esbuild/[email protected]':
optional: true
'@formatjs/[email protected]': {}
'@formatjs/[email protected]':
dependencies:
'@formatjs/icu-skeleton-parser': 2.1.10
'@formatjs/[email protected]': {}
'@formatjs/[email protected]':
dependencies:
'@formatjs/fast-memoize': 3.1.6
'@hono/[email protected]([email protected])':
dependencies:
hono: 4.12.27
@@ -2760,6 +3017,66 @@ snapshots:
'@panva/[email protected]': {}
'@parcel/[email protected]':
optional: true
'@parcel/[email protected]':
optional: true
'@parcel/[email protected]':
optional: true
'@parcel/[email protected]':
optional: true
'@parcel/[email protected]':
optional: true
'@parcel/[email protected]':
optional: true
'@parcel/[email protected]':
optional: true
'@parcel/[email protected]':
optional: true
'@parcel/[email protected]':
optional: true
'@parcel/[email protected]':
optional: true
'@parcel/[email protected]':
optional: true
'@parcel/[email protected]':
optional: true
'@parcel/[email protected]':
optional: true
'@parcel/[email protected]':
dependencies:
detect-libc: 2.1.2
is-glob: 4.0.3
node-addon-api: 7.1.1
picomatch: 4.0.4
optionalDependencies:
'@parcel/watcher-android-arm64': 2.5.6
'@parcel/watcher-darwin-arm64': 2.5.6
'@parcel/watcher-darwin-x64': 2.5.6
'@parcel/watcher-freebsd-x64': 2.5.6
'@parcel/watcher-linux-arm-glibc': 2.5.6
'@parcel/watcher-linux-arm-musl': 2.5.6
'@parcel/watcher-linux-arm64-glibc': 2.5.6
'@parcel/watcher-linux-arm64-musl': 2.5.6
'@parcel/watcher-linux-x64-glibc': 2.5.6
'@parcel/watcher-linux-x64-musl': 2.5.6
'@parcel/watcher-win32-arm64': 2.5.6
'@parcel/watcher-win32-ia32': 2.5.6
'@parcel/watcher-win32-x64': 2.5.6
'@petamoriken/[email protected]': {}
'@prisma/[email protected]':
@@ -2988,12 +3305,74 @@ snapshots:
'@rollup/[email protected]':
optional: true
'@schummar/[email protected]': {}
'@standard-schema/[email protected]': {}
'@swc/[email protected]':
optional: true
'@swc/[email protected]':
optional: true
'@swc/[email protected]':
optional: true
'@swc/[email protected]':
optional: true
'@swc/[email protected]':
optional: true
'@swc/[email protected]':
optional: true
'@swc/[email protected]':
optional: true
'@swc/[email protected]':
optional: true
'@swc/[email protected]':
optional: true
'@swc/[email protected]':
optional: true
'@swc/[email protected]':
optional: true
'@swc/[email protected]':
optional: true
'@swc/[email protected]':
dependencies:
'@swc/counter': 0.1.3
'@swc/types': 0.1.27
optionalDependencies:
'@swc/core-darwin-arm64': 1.15.43
'@swc/core-darwin-x64': 1.15.43
'@swc/core-linux-arm-gnueabihf': 1.15.43
'@swc/core-linux-arm64-gnu': 1.15.43
'@swc/core-linux-arm64-musl': 1.15.43
'@swc/core-linux-ppc64-gnu': 1.15.43
'@swc/core-linux-s390x-gnu': 1.15.43
'@swc/core-linux-x64-gnu': 1.15.43
'@swc/core-linux-x64-musl': 1.15.43
'@swc/core-win32-arm64-msvc': 1.15.43
'@swc/core-win32-ia32-msvc': 1.15.43
'@swc/core-win32-x64-msvc': 1.15.43
'@swc/[email protected]': {}
'@swc/[email protected]':
dependencies:
tslib: 2.8.1
'@swc/[email protected]':
dependencies:
'@swc/counter': 0.1.3
'@tailwindcss/[email protected]([email protected])':
dependencies:
mini-svg-data-uri: 1.4.4
@@ -3447,6 +3826,21 @@ snapshots:
dependencies:
safer-buffer: 2.1.2
[email protected]:
dependencies:
'@formatjs/icu-messageformat-parser': 3.5.12
[email protected]:
dependencies:
'@formatjs/fast-memoize': 3.1.6
'@formatjs/icu-messageformat-parser': 3.5.12
[email protected]: {}
[email protected]:
dependencies:
is-extglob: 2.1.1
[email protected]: {}
[email protected]: {}
@@ -3562,6 +3956,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]([email protected]([email protected]([email protected]))([email protected]))([email protected])([email protected]):
dependencies:
'@auth/core': 0.41.2([email protected])
@@ -3570,6 +3966,25 @@ snapshots:
optionalDependencies:
nodemailer: 6.10.1
[email protected]: {}
[email protected]([email protected]([email protected]([email protected]))([email protected]))([email protected])([email protected]):
dependencies:
'@formatjs/intl-localematcher': 0.8.10
'@parcel/watcher': 2.5.6
'@swc/core': 1.15.43
icu-minify: 4.13.0
negotiator: 1.0.0
next: 16.2.9([email protected]([email protected]))([email protected])
next-intl-swc-plugin-extractor: 4.13.0
po-parser: 2.1.1
react: 19.2.7
use-intl: 4.13.0([email protected])
optionalDependencies:
typescript: 5.9.3
transitivePeerDependencies:
- '@swc/helpers'
[email protected]([email protected]([email protected]))([email protected]):
dependencies:
'@next/env': 16.2.9
@@ -3594,6 +4009,8 @@ snapshots:
- '@babel/core'
- babel-plugin-macros
[email protected]: {}
[email protected]: {}
[email protected]: {}
@@ -3618,12 +4035,16 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
confbox: 0.2.4
exsolve: 1.1.0
pathe: 2.0.3
[email protected]: {}
[email protected]:
dependencies:
cssesc: 3.0.0
@@ -3833,6 +4254,14 @@ snapshots:
[email protected]: {}
[email protected]([email protected]):
dependencies:
'@formatjs/fast-memoize': 3.1.6
'@schummar/icu-type-parser': 1.21.5
icu-minify: 4.13.0
intl-messageformat: 11.2.9
react: 19.2.7
[email protected]: {}
[email protected]([email protected]):
+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");
}
+125
View File
@@ -0,0 +1,125 @@
"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 help-center FAQ entries (website_help_center_categories). Each entry
// is a titled content block with an optional image and call-to-action button.
function parsePosition(value: FormDataEntryValue | null): number {
const n = Number(value);
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 1;
}
export async function createHelpQuestion(formData: FormData): Promise<void> {
const staff = await requireStaff();
const name = String(formData.get("name") ?? "").trim().slice(0, 255);
const content = String(formData.get("content") ?? "").trim();
if (!name || !content) return;
const imageUrl = String(formData.get("imageUrl") ?? "").trim().slice(0, 255);
const buttonText = String(formData.get("buttonText") ?? "").trim().slice(0, 255);
const buttonUrl = String(formData.get("buttonUrl") ?? "").trim().slice(0, 255);
const buttonColor = String(formData.get("buttonColor") ?? "").trim().slice(0, 16) || "#eeb425";
const buttonBorderColor =
String(formData.get("buttonBorderColor") ?? "").trim().slice(0, 16) || "#facc15";
try {
const entry = await prisma.websiteHelpCenterCategories.create({
data: {
name,
content,
position: parsePosition(formData.get("position")),
imageUrl: imageUrl || null,
buttonText: buttonText || null,
buttonUrl: buttonUrl || null,
buttonColor,
buttonBorderColor,
smallBox: formData.get("smallBox") != null,
},
});
await logStaffActivity({
staffId: staff.id,
action: "help_create",
description: `Created help-center entry #${entry.id} (${name})`,
targetType: "help_center_category",
targetId: Number(entry.id),
});
} catch {
// Unique name collision or DB error — re-render unchanged.
revalidatePath("/admin/help-questions");
return;
}
redirect("/admin/help-questions");
}
export async function updateHelpQuestion(formData: FormData): Promise<void> {
const staff = await requireStaff();
const raw = String(formData.get("id") ?? "");
if (!/^\d+$/.test(raw)) return;
const id = BigInt(raw);
const name = String(formData.get("name") ?? "").trim().slice(0, 255);
const content = String(formData.get("content") ?? "").trim();
if (!name || !content) return;
const imageUrl = String(formData.get("imageUrl") ?? "").trim().slice(0, 255);
const buttonText = String(formData.get("buttonText") ?? "").trim().slice(0, 255);
const buttonUrl = String(formData.get("buttonUrl") ?? "").trim().slice(0, 255);
const buttonColor = String(formData.get("buttonColor") ?? "").trim().slice(0, 16) || "#eeb425";
const buttonBorderColor =
String(formData.get("buttonBorderColor") ?? "").trim().slice(0, 16) || "#facc15";
try {
await prisma.websiteHelpCenterCategories.update({
where: { id },
data: {
name,
content,
position: parsePosition(formData.get("position")),
imageUrl: imageUrl || null,
buttonText: buttonText || null,
buttonUrl: buttonUrl || null,
buttonColor,
buttonBorderColor,
smallBox: formData.get("smallBox") != null,
},
});
await logStaffActivity({
staffId: staff.id,
action: "help_update",
description: `Updated help-center entry #${id} (${name})`,
targetType: "help_center_category",
targetId: Number(id),
});
} catch {
// Not found, unique collision, or DB error — ignore.
revalidatePath(`/admin/help-questions/${id}`);
return;
}
redirect("/admin/help-questions");
}
export async function deleteHelpQuestion(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.websiteHelpCenterCategories.delete({ where: { id } });
await logStaffActivity({
staffId: staff.id,
action: "help_delete",
description: `Deleted help-center entry #${id}`,
targetType: "help_center_category",
targetId: Number(id),
});
} catch {
// Not found or DB error — ignore.
}
redirect("/admin/help-questions");
}
+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");
}
+142
View File
@@ -0,0 +1,142 @@
"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";
// Website store packages (website_shop_articles). This CMS-owned table backs
// the public store; rows here are the buyable packages, not orders. The closest
// "orders" record is website_paypal_transactions, exposed read-only by the page.
/** Parse an UnsignedInt form value, returning null when blank/invalid/negative. */
function optUInt(formData: FormData, key: string): number | null {
const raw = String(formData.get(key) ?? "").trim();
if (raw === "") return null;
const n = Number(raw);
if (!Number.isFinite(n) || n < 0) return null;
return Math.floor(n);
}
/** Parse a required non-negative UnsignedInt, falling back to 0. */
function reqUInt(formData: FormData, key: string): number {
const n = optUInt(formData, key);
return n ?? 0;
}
export async function createShopArticle(formData: FormData): Promise<void> {
const staff = await requireStaff();
const name = String(formData.get("name") ?? "").trim().slice(0, 255);
if (!name) return;
const now = new Date();
try {
const created = await prisma.websiteShopArticles.create({
data: {
name,
info: String(formData.get("info") ?? "").trim().slice(0, 255),
icon: String(formData.get("icon") ?? "").trim().slice(0, 255),
color: String(formData.get("color") ?? "").trim().slice(0, 255),
costs: reqUInt(formData, "costs"),
giveRank: optUInt(formData, "giveRank"),
credits: optUInt(formData, "credits"),
duckets: optUInt(formData, "duckets"),
diamonds: optUInt(formData, "diamonds"),
badges: (String(formData.get("badges") ?? "").trim().slice(0, 255)) || null,
position: reqUInt(formData, "position"),
createdAt: now,
updatedAt: now,
},
});
await logStaffActivity({
staffId: staff.id,
action: "shop_create",
description: `Created shop package "${name}" (${created.costs} costs)`,
targetType: "shop_article",
targetId: Number(created.id),
});
} catch {
// Unique constraint on `name` (or DB unavailable) — swallow and re-render.
return;
}
redirect("/admin/shop");
}
export async function updateShopArticle(formData: FormData): Promise<void> {
const staff = await requireStaff();
const raw = String(formData.get("id") ?? "").trim();
if (!raw) return;
let id: bigint;
try {
id = BigInt(raw);
} catch {
return;
}
const name = String(formData.get("name") ?? "").trim().slice(0, 255);
if (!name) return;
try {
await prisma.websiteShopArticles.update({
where: { id },
data: {
name,
info: String(formData.get("info") ?? "").trim().slice(0, 255),
icon: String(formData.get("icon") ?? "").trim().slice(0, 255),
color: String(formData.get("color") ?? "").trim().slice(0, 255),
costs: reqUInt(formData, "costs"),
giveRank: optUInt(formData, "giveRank"),
credits: optUInt(formData, "credits"),
duckets: optUInt(formData, "duckets"),
diamonds: optUInt(formData, "diamonds"),
badges: (String(formData.get("badges") ?? "").trim().slice(0, 255)) || null,
position: reqUInt(formData, "position"),
updatedAt: new Date(),
},
});
await logStaffActivity({
staffId: staff.id,
action: "shop_update",
description: `Updated shop package #${id} ("${name}")`,
targetType: "shop_article",
targetId: Number(id),
});
} catch {
return;
}
revalidatePath(`/admin/shop/${id}`);
redirect("/admin/shop");
}
export async function deleteShopArticle(formData: FormData): Promise<void> {
const staff = await requireStaff();
const raw = String(formData.get("id") ?? "").trim();
if (!raw) return;
let id: bigint;
try {
id = BigInt(raw);
} catch {
return;
}
try {
await prisma.websiteShopArticles.delete({ where: { id } });
await logStaffActivity({
staffId: staff.id,
action: "shop_delete",
description: `Deleted shop package #${id}`,
targetType: "shop_article",
targetId: Number(id),
});
} catch {
return;
}
redirect("/admin/shop");
}
+107
View File
@@ -0,0 +1,107 @@
'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';
// ── Helpers ────────────────────────────────────────────────────────────────
/** Parse a FormData field into a positive BigInt id, or null when invalid. */
function parseId(raw: FormDataEntryValue | null): bigint | null {
if (typeof raw !== 'string' || raw.trim() === '') return null;
try {
const id = BigInt(raw.trim());
return id > 0n ? id : null;
} catch {
return null;
}
}
function str(raw: FormDataEntryValue | null): string {
return typeof raw === 'string' ? raw : '';
}
/** Normalise a hex-ish colour into the 10-char background_color column. */
function normaliseColor(raw: string): string {
const v = raw.trim().slice(0, 10);
return v || '#888888';
}
// ── Tags CRUD (tags + taggables, AtomCMS article tags/categories) ──────────
export async function createTag(formData: FormData): Promise<void> {
const staff = await requireStaff();
const name = str(formData.get('name')).trim().slice(0, 255);
if (!name) return;
const backgroundColor = normaliseColor(str(formData.get('backgroundColor')));
const now = new Date();
try {
const created = await prisma.tags.create({
data: { name, backgroundColor, createdAt: now, updatedAt: now },
});
await logStaffActivity({
staffId: staff.id,
action: 'tag_create',
description: `Created tag "${name}" (#${created.id})`,
targetType: 'tag',
targetId: Number(created.id),
});
} catch {
// Fail soft — DB unavailable or duplicate.
}
revalidatePath('/admin/tags');
}
export async function updateTag(formData: FormData): Promise<void> {
const staff = await requireStaff();
const id = parseId(formData.get('id'));
if (id === null) return;
const name = str(formData.get('name')).trim().slice(0, 255);
const backgroundColor = normaliseColor(str(formData.get('backgroundColor')));
if (!name) return;
try {
await prisma.tags.update({
where: { id },
data: { name, backgroundColor, updatedAt: new Date() },
});
await logStaffActivity({
staffId: staff.id,
action: 'tag_update',
description: `Updated tag #${id} → "${name}"`,
targetType: 'tag',
targetId: Number(id),
});
} catch {
// Row may be gone; ignore.
}
revalidatePath('/admin/tags');
}
export async function deleteTag(formData: FormData): Promise<void> {
const staff = await requireStaff();
const id = parseId(formData.get('id'));
if (id === null) return;
try {
// Remove the tag and any taggable links pointing at it.
await prisma.$transaction([
prisma.taggables.deleteMany({ where: { tagId: id } }),
prisma.tags.delete({ where: { id } }),
]);
await logStaffActivity({
staffId: staff.id,
action: 'tag_delete',
description: `Deleted tag #${id}`,
targetType: 'tag',
targetId: Number(id),
});
} catch {
// Already deleted; ignore.
}
revalidatePath('/admin/tags');
}
+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>
+84 -22
View File
@@ -1,25 +1,29 @@
import {
createPermission,
deletePermission,
updatePermission,
} from "@/actions/admin-permissions";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
// NOTE: The emulator's standalone `permissions` table is not present in the
// Prisma schema (there is no `prisma.permissions` accessor). The real,
// queryable rank-permission table in this schema is `website_housekeeping_permissions`
// (model WebsiteHousekeepingPermissions), which gates each permission by the
// minimum rank that holds it — exactly the rank-permission data this page lists.
// Manages website_permissions (model WebsitePermissions) — the CMS-owned
// rank-permission mapping AtomCMS exposes in admin. Each row is a key with an
// optional value and comment; those are the only editable columns on the table.
type PermissionRow = {
id: bigint;
permission: string;
minRank: number;
description: string | null;
key: string;
value: string | null;
comment: string | null;
};
export default async function AdminPermissions() {
let permissions: PermissionRow[] = [];
try {
permissions = await prisma.websiteHousekeepingPermissions.findMany({
select: { id: true, permission: true, minRank: true, description: true },
orderBy: [{ minRank: "asc" }, { permission: "asc" }],
permissions = await prisma.websitePermissions.findMany({
select: { id: true, key: true, value: true, comment: true },
orderBy: { key: "asc" },
take: 500,
});
} catch {
permissions = [];
@@ -29,29 +33,87 @@ export default async function AdminPermissions() {
<main>
<h1>Permissions</h1>
<p className="muted">
Read-only view of rank permissions. Each entry is the minimum rank that
grants the permission.
CMS-owned rank permissions (<code>website_permissions</code>). Each entry
maps a permission key to its value/name and an optional comment.
</p>
<form action={createPermission} className="card" style={{ marginBottom: "1.5rem" }}>
<h3 style={{ marginTop: 0 }}>Add / overwrite permission</h3>
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
<input name="key" placeholder="key" required maxLength={255} />
<input
name="value"
placeholder="value / name"
maxLength={255}
style={{ flex: 1, minWidth: 160 }}
/>
<input name="comment" placeholder="comment" maxLength={255} />
<button type="submit" className="btn btn-primary">
Save
</button>
</div>
</form>
{permissions.length === 0 ? (
<p className="muted">No permissions found.</p>
) : (
<table>
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr>
<tr style={{ textAlign: "left", color: "var(--muted)" }}>
<th>ID</th>
<th>Permission</th>
<th>Min rank</th>
<th>Description</th>
<th>Key</th>
<th>Value</th>
<th>Comment</th>
<th />
</tr>
</thead>
<tbody>
{permissions.map((p) => (
<tr key={String(p.id)}>
<tr key={String(p.id)} style={{ borderTop: "1px solid var(--border)" }}>
<td>{String(p.id)}</td>
<td>{p.permission}</td>
<td>{p.minRank}</td>
<td className="muted">{p.description ?? "—"}</td>
<td>
<form
id={`perm-${String(p.id)}`}
action={updatePermission}
style={{ display: "contents" }}
>
<input type="hidden" name="id" value={String(p.id)} />
<input name="key" defaultValue={p.key} required maxLength={255} />
</form>
</td>
<td>
<input
form={`perm-${String(p.id)}`}
name="value"
defaultValue={p.value ?? ""}
maxLength={255}
style={{ width: "100%" }}
/>
</td>
<td>
<input
form={`perm-${String(p.id)}`}
name="comment"
defaultValue={p.comment ?? ""}
maxLength={255}
style={{ width: "100%" }}
/>
</td>
<td style={{ display: "flex", gap: "0.5rem" }}>
<button
type="submit"
form={`perm-${String(p.id)}`}
className="btn btn-primary"
>
Save
</button>
<form action={deletePermission}>
<input type="hidden" name="id" value={String(p.id)} />
<button type="submit" className="btn btn-danger">
Delete
</button>
</form>
</td>
</tr>
))}
</tbody>
+125
View File
@@ -0,0 +1,125 @@
import Link from 'next/link';
import { prisma } from '@/lib/prisma';
export const dynamic = 'force-dynamic';
type History = {
id: bigint;
userId: bigint;
showName: string | null;
startedAt: Date;
endedAt: Date | null;
listenersCount: number;
notes: string | null;
};
function formatDate(d: Date | null): string {
return d ? d.toISOString().slice(0, 16).replace('T', ' ') : '—';
}
/** Human-readable airtime between two timestamps (e.g. "1h 23m"). */
function formatDuration(start: Date, end: Date | null): string {
if (!end) return '—';
const ms = end.getTime() - start.getTime();
if (ms <= 0) return '—';
const mins = Math.round(ms / 60000);
const h = Math.floor(mins / 60);
const m = mins % 60;
return h > 0 ? `${h}h ${m}m` : `${m}m`;
}
export default async function AdminRadioHistoryPage() {
let history: History[] = [];
let names = new Map<string, string>();
let dbError = false;
try {
history = await prisma.radioHistory.findMany({
select: {
id: true,
userId: true,
showName: true,
startedAt: true,
endedAt: true,
listenersCount: true,
notes: true,
},
orderBy: { startedAt: 'desc' },
take: 100,
});
// Resolve DJ usernames (users.id is Int, radio_history.user_id is BigInt).
const ids = Array.from(new Set(history.map((h) => Number(h.userId)))).filter(
(n) => Number.isFinite(n) && n > 0,
);
if (ids.length > 0) {
const users = await prisma.user.findMany({
where: { id: { in: ids } },
select: { id: true, username: true },
});
names = new Map(users.map((u) => [String(u.id), u.username]));
}
} catch {
dbError = true;
history = [];
}
return (
<main>
<nav className="muted" style={{ marginBottom: '0.5rem' }}>
<Link href="/admin/radio">Radio</Link> ·{' '}
<Link href="/admin/radio/settings">Settings</Link> ·{' '}
<Link href="/admin/radio/banners">Banners</Link> ·{' '}
<Link href="/admin/radio/ranks">Ranks</Link> ·{' '}
<Link href="/admin/radio/history">History</Link>
</nav>
<h1>Radio Play History</h1>
<p className="muted">
Past DJ shows and airtime (<code>radio_history</code>), most recent first.
Read-only log of who was on air, when, and how many listeners tuned in.
</p>
{dbError ? (
<div className="card" style={{ marginBottom: '1.5rem' }}>
<p className="muted" style={{ margin: 0 }}>
Could not load radio history (database unavailable).
</p>
</div>
) : null}
{history.length === 0 ? (
<p className="muted">No radio history recorded.</p>
) : (
<table>
<thead>
<tr>
<th>ID</th>
<th>DJ</th>
<th>Show</th>
<th>Started</th>
<th>Ended</th>
<th>Airtime</th>
<th>Listeners</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
{history.map((h) => (
<tr key={String(h.id)}>
<td>{String(h.id)}</td>
<td>{names.get(String(h.userId)) ?? `#${String(h.userId)}`}</td>
<td>{h.showName ?? '—'}</td>
<td className="muted">{formatDate(h.startedAt)}</td>
<td className="muted">{formatDate(h.endedAt)}</td>
<td className="muted">{formatDuration(h.startedAt, h.endedAt)}</td>
<td>{h.listenersCount}</td>
<td className="muted">{h.notes ?? '—'}</td>
</tr>
))}
</tbody>
</table>
)}
</main>
);
}
+53
View File
@@ -0,0 +1,53 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { deleteShopArticle, updateShopArticle } from "@/actions/admin-shop";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
export default async function EditShopArticle({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
let pkg: Awaited<ReturnType<typeof prisma.websiteShopArticles.findUnique>> = null;
try {
pkg = await prisma.websiteShopArticles.findUnique({ where: { id: BigInt(id) } });
} catch {
notFound();
}
if (!pkg) notFound();
return (
<main>
<p className="muted">
<Link href="/admin/shop">← Shop</Link>
</p>
<h1>Edit shop package</h1>
<form action={updateShopArticle} className="card" style={{ display: "grid", gap: "0.6rem" }}>
<input type="hidden" name="id" value={String(pkg.id)} />
<input name="name" defaultValue={pkg.name} placeholder="Name" required maxLength={255} />
<input name="info" defaultValue={pkg.info} placeholder="Info / description" maxLength={255} />
<input name="icon" defaultValue={pkg.icon} placeholder="Icon" maxLength={255} />
<input name="color" defaultValue={pkg.color} placeholder="Color (e.g. #ffcc00)" maxLength={255} />
<input name="costs" type="number" min={0} defaultValue={pkg.costs} placeholder="Costs (store price)" required />
<input name="credits" type="number" min={0} defaultValue={pkg.credits ?? undefined} placeholder="Credits given (optional)" />
<input name="duckets" type="number" min={0} defaultValue={pkg.duckets ?? undefined} placeholder="Duckets given (optional)" />
<input name="diamonds" type="number" min={0} defaultValue={pkg.diamonds ?? undefined} placeholder="Diamonds given (optional)" />
<input name="giveRank" type="number" min={0} defaultValue={pkg.giveRank ?? undefined} placeholder="Give rank (optional)" />
<input name="badges" defaultValue={pkg.badges ?? ""} placeholder="Badge codes (optional)" maxLength={255} />
<input name="position" type="number" min={0} defaultValue={pkg.position} placeholder="Position" />
<button type="submit" className="btn btn-primary">
Save
</button>
</form>
<form action={deleteShopArticle} style={{ marginTop: "1rem" }}>
<input type="hidden" name="id" value={String(pkg.id)} />
<button type="submit" className="btn btn-danger">
Delete package
</button>
</form>
</main>
);
}
+29
View File
@@ -0,0 +1,29 @@
import Link from "next/link";
import { createShopArticle } from "@/actions/admin-shop";
export default function NewShopArticle() {
return (
<main>
<p className="muted">
<Link href="/admin/shop">← Shop</Link>
</p>
<h1>New shop package</h1>
<form action={createShopArticle} className="card" style={{ display: "grid", gap: "0.6rem" }}>
<input name="name" placeholder="Name" required maxLength={255} />
<input name="info" placeholder="Info / description" maxLength={255} />
<input name="icon" placeholder="Icon" maxLength={255} />
<input name="color" placeholder="Color (e.g. #ffcc00)" maxLength={255} />
<input name="costs" type="number" min={0} placeholder="Costs (store price)" required />
<input name="credits" type="number" min={0} placeholder="Credits given (optional)" />
<input name="duckets" type="number" min={0} placeholder="Duckets given (optional)" />
<input name="diamonds" type="number" min={0} placeholder="Diamonds given (optional)" />
<input name="giveRank" type="number" min={0} placeholder="Give rank (optional)" />
<input name="badges" placeholder="Badge codes (optional)" maxLength={255} />
<input name="position" type="number" min={0} placeholder="Position" defaultValue={0} />
<button type="submit" className="btn btn-primary">
Create
</button>
</form>
</main>
);
}
+105
View File
@@ -0,0 +1,105 @@
import Link from "next/link";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
export default async function AdminShop() {
let packages: Awaited<ReturnType<typeof prisma.websiteShopArticles.findMany>> = [];
try {
packages = await prisma.websiteShopArticles.findMany({
orderBy: [{ position: "asc" }, { name: "asc" }],
take: 500,
});
} catch {
packages = [];
}
let orders: Awaited<ReturnType<typeof prisma.websitePaypalTransactions.findMany>> = [];
try {
orders = await prisma.websitePaypalTransactions.findMany({
orderBy: { createdAt: "desc" },
take: 50,
});
} catch {
orders = [];
}
return (
<main>
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
<h1 style={{ flex: 1 }}>Shop</h1>
<Link href="/admin/shop/new">+ New package</Link>
</div>
<p className="muted">
Website store packages (<code>website_shop_articles</code>). <code>costs</code> is the price
shown in the store; credits/duckets/diamonds/badges are what the buyer receives.
</p>
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr style={{ textAlign: "left", color: "var(--muted)" }}>
<th>#</th>
<th>Name</th>
<th>Costs</th>
<th>Credits</th>
<th>Duckets</th>
<th>Diamonds</th>
<th>Rank</th>
<th>Position</th>
</tr>
</thead>
<tbody>
{packages.map((p) => (
<tr key={String(p.id)} style={{ borderTop: "1px solid var(--border)" }}>
<td className="muted">{String(p.id)}</td>
<td>
<Link href={`/admin/shop/${p.id}`}>{p.name}</Link>
</td>
<td>{p.costs}</td>
<td className="muted">{p.credits ?? ""}</td>
<td className="muted">{p.duckets ?? ""}</td>
<td className="muted">{p.diamonds ?? ""}</td>
<td className="muted">{p.giveRank ?? ""}</td>
<td className="muted">{p.position}</td>
</tr>
))}
</tbody>
</table>
{packages.length === 0 ? <p className="muted">No shop packages yet.</p> : null}
<h2 style={{ marginTop: "2rem" }}>Recent orders</h2>
<p className="muted">
Read-only PayPal transaction log (<code>website_paypal_transactions</code>).
</p>
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr style={{ textAlign: "left", color: "var(--muted)" }}>
<th>Transaction</th>
<th>User</th>
<th>Description</th>
<th>Amount</th>
<th>Status</th>
<th>Date</th>
</tr>
</thead>
<tbody>
{orders.map((o) => (
<tr key={String(o.id)} style={{ borderTop: "1px solid var(--border)" }}>
<td>
<code>{o.transactionId}</code>
</td>
<td className="muted">{o.userId}</td>
<td className="muted">{o.description ?? ""}</td>
<td>
{o.amount} {o.currency}
</td>
<td className="muted">{o.status ?? ""}</td>
<td className="muted">{o.createdAt ? o.createdAt.toISOString().slice(0, 10) : ""}</td>
</tr>
))}
</tbody>
</table>
{orders.length === 0 ? <p className="muted">No orders recorded.</p> : null}
</main>
);
}
+169
View File
@@ -0,0 +1,169 @@
import { createTag, deleteTag, updateTag } from '@/actions/admin-tags';
import { prisma } from '@/lib/prisma';
export const dynamic = 'force-dynamic';
type Tag = {
id: bigint;
name: string;
backgroundColor: string;
createdAt: Date | null;
};
function formatDate(d: Date | null): string {
return d ? d.toISOString().slice(0, 10) : '—';
}
export default async function AdminTagsPage() {
let tags: Tag[] = [];
let usage = new Map<string, number>();
let dbError = false;
try {
tags = await prisma.tags.findMany({
select: { id: true, name: true, backgroundColor: true, createdAt: true },
orderBy: { name: 'asc' },
take: 500,
});
// How many things each tag is attached to (taggables join table).
const counts = await prisma.taggables.groupBy({
by: ['tagId'],
_count: { tagId: true },
});
usage = new Map(counts.map((c) => [String(c.tagId), c._count.tagId]));
} catch {
dbError = true;
tags = [];
}
return (
<main>
<h1>Tags</h1>
<p className="muted">
Article tags / categories (<code>tags</code>). Each tag has a label and a
background colour, and can be attached to articles via <code>taggables</code>.
</p>
{dbError ? (
<div className="card" style={{ marginBottom: '1.5rem' }}>
<p className="muted" style={{ margin: 0 }}>
Could not load tags (database unavailable).
</p>
</div>
) : null}
<form action={createTag} className="card" style={{ marginBottom: '1.5rem' }}>
<h3 style={{ marginTop: 0 }}>Add tag</h3>
<div className="grid cols-2">
<div>
<label htmlFor="new_name" style={{ display: 'block', fontWeight: 700 }}>
Name
</label>
<input
id="new_name"
name="name"
placeholder="e.g. Events"
required
maxLength={255}
style={{ width: '100%' }}
/>
</div>
<div>
<label
htmlFor="new_backgroundColor"
style={{ display: 'block', fontWeight: 700 }}
>
Background colour
</label>
<input
id="new_backgroundColor"
name="backgroundColor"
placeholder="#888888"
defaultValue="#888888"
maxLength={10}
style={{ width: '100%' }}
/>
</div>
</div>
<div style={{ marginTop: '0.75rem' }}>
<button type="submit" className="btn btn-primary">
Create tag
</button>
</div>
</form>
{tags.length === 0 ? (
<p className="muted">No tags yet.</p>
) : (
<table>
<thead>
<tr>
<th>ID</th>
<th>Tag</th>
<th>Colour</th>
<th>Used by</th>
<th>Created</th>
<th />
</tr>
</thead>
<tbody>
{tags.map((t) => (
<tr key={String(t.id)}>
<td>{String(t.id)}</td>
<td>
<span
style={{
display: 'inline-block',
padding: '0.1rem 0.5rem',
borderRadius: '0.4rem',
background: t.backgroundColor,
color: '#fff',
}}
>
{t.name}
</span>
</td>
<td className="muted">
<code>{t.backgroundColor}</code>
</td>
<td className="muted">{usage.get(String(t.id)) ?? 0}</td>
<td className="muted">{formatDate(t.createdAt)}</td>
<td>
<form
action={updateTag}
style={{ display: 'flex', gap: '0.4rem', alignItems: 'center' }}
>
<input type="hidden" name="id" value={String(t.id)} />
<input
name="name"
defaultValue={t.name}
required
maxLength={255}
style={{ width: 140 }}
/>
<input
name="backgroundColor"
defaultValue={t.backgroundColor}
maxLength={10}
style={{ width: 90 }}
/>
<button type="submit" className="btn btn-primary">
Save
</button>
</form>
<form action={deleteTag} style={{ marginTop: '0.4rem' }}>
<input type="hidden" name="id" value={String(t.id)} />
<button type="submit" className="btn btn-danger">
Delete
</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
)}
</main>
);
}
+131
View File
@@ -0,0 +1,131 @@
import Link from "next/link";
import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
const PER_PAGE = 25;
// Read-only listing of PayPal / top-up transactions recorded by the CMS
// (website_paypal_transactions). No mutations — this page never writes.
//
// website_* ids are BigInt; users.id (the payer) is the emulator-owned Int.
// The model has no Prisma relation to User, so payer usernames are resolved in
// one batched lookup keyed by userId.
type Txn = Awaited<ReturnType<typeof prisma.websitePaypalTransactions.findMany>>[number];
function fromDate(d: Date | null | undefined): string {
return d ? d.toISOString().slice(0, 16).replace("T", " ") : "—";
}
function formatAmount(amount: number, currency: string): string {
const n = Number(amount);
const value = Number.isFinite(n) ? n.toFixed(2) : "0.00";
return `${value} ${currency}`;
}
export default async function AdminTransactions({
searchParams,
}: {
searchParams: Promise<{ page?: string }>;
}) {
await requireStaff();
const sp = await searchParams;
const page = Math.max(1, Number(sp.page ?? "1") || 1);
let transactions: Txn[] = [];
let total = 0;
let names = new Map<number, string>();
try {
[transactions, total] = await Promise.all([
prisma.websitePaypalTransactions.findMany({
orderBy: { id: "desc" },
skip: (page - 1) * PER_PAGE,
take: PER_PAGE,
}),
prisma.websitePaypalTransactions.count(),
]);
const userIds = [...new Set(transactions.map((t) => t.userId))];
if (userIds.length > 0) {
const users = await prisma.user.findMany({
where: { id: { in: userIds } },
select: { id: true, username: true },
});
names = new Map(users.map((u) => [u.id, u.username]));
}
} catch {
transactions = [];
total = 0;
names = new Map();
}
const pages = Math.max(1, Math.ceil(total / PER_PAGE));
return (
<main>
<h1>Transactions</h1>
<p className="muted" style={{ marginTop: "-0.4rem" }}>
Read-only · PayPal / top-up transactions recorded by the CMS, newest first.
</p>
<section className="card">
{transactions.length === 0 ? (
<p className="muted">No transactions.</p>
) : (
<div style={{ overflowX: "auto" }}>
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr style={{ textAlign: "left", color: "var(--muted)" }}>
<th>ID</th>
<th>Payer</th>
<th>Amount</th>
<th>Package</th>
<th>Status</th>
<th>Transaction</th>
<th>Date</th>
</tr>
</thead>
<tbody>
{transactions.map((t) => (
<tr key={String(t.id)} style={{ borderTop: "1px solid var(--border)" }}>
<td>{String(t.id)}</td>
<td>
<Link href={`/admin/users/${t.userId}`}>
{names.get(t.userId) ?? `#${t.userId}`}
</Link>
</td>
<td>{formatAmount(t.amount, t.currency)}</td>
<td>{t.description ?? "—"}</td>
<td>{t.status ?? "—"}</td>
<td className="muted">{t.transactionId}</td>
<td className="muted">{fromDate(t.createdAt)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
<p className="muted">
Page {page} / {pages} · {total} transactions
{page > 1 ? (
<>
{" · "}
<Link href={`/admin/transactions?page=${page - 1}`}>← Prev</Link>
</>
) : null}
{page < pages ? (
<>
{" · "}
<Link href={`/admin/transactions?page=${page + 1}`}>Next →</Link>
</>
) : null}
</p>
</main>
);
}
+145
View File
@@ -0,0 +1,145 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { updateUser } from "@/actions/admin-user-edit";
import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
// users_currency.type values for the non-credits currencies (see send-currency.ts).
const DUCKETS_TYPE = 0;
const DIAMONDS_TYPE = 5;
export default async function AdminUserEdit({
params,
}: {
params: Promise<{ id: string }>;
}) {
// Gate the page with the SAME helper the action re-checks.
await requireStaff();
const { id } = await params;
const userId = Number(id);
if (!Number.isInteger(userId) || userId <= 0) notFound();
let user: {
id: number;
username: string;
mail: string | null;
motto: string;
look: string;
rank: number;
credits: number;
pixels: number;
points: number;
} | null = null;
let duckets = 0;
let diamonds = 0;
try {
user = await prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
username: true,
mail: true,
motto: true,
look: true,
rank: true,
credits: true,
pixels: true,
points: true,
},
});
if (user) {
const currencies = await prisma.usersCurrency.findMany({
where: { userId, type: { in: [DUCKETS_TYPE, DIAMONDS_TYPE] } },
select: { type: true, amount: true },
});
for (const c of currencies) {
if (c.type === DUCKETS_TYPE) duckets = c.amount;
else if (c.type === DIAMONDS_TYPE) diamonds = c.amount;
}
}
} catch {
return (
<main>
<p className="muted">
<Link href="/admin/users">← Users</Link>
</p>
<h1>Edit user</h1>
<p className="muted">Could not load this user right now. Try again shortly.</p>
</main>
);
}
if (!user) notFound();
return (
<main>
<p className="muted">
<Link href={`/admin/users/${user.id}`}>← {user.username}</Link>
</p>
<h1>Edit {user.username}</h1>
<p className="muted">ID {user.id}</p>
<form action={updateUser} className="card">
<input type="hidden" name="id" value={user.id} />
<div className="grid cols-2">
<label>
Email
<input name="mail" type="email" defaultValue={user.mail ?? ""} maxLength={500} />
</label>
<label>
Rank
<input name="rank" type="number" min={1} defaultValue={user.rank} />
</label>
<label>
Motto
<input name="motto" defaultValue={user.motto} maxLength={127} />
</label>
<label>
Look
<input name="look" defaultValue={user.look} maxLength={256} />
</label>
<label>
Credits
<input name="credits" type="number" min={0} defaultValue={user.credits} />
</label>
<label>
Pixels
<input name="pixels" type="number" min={0} defaultValue={user.pixels} />
</label>
<label>
Points
<input name="points" type="number" min={0} defaultValue={user.points} />
</label>
<label>
Duckets
<input name="duckets" type="number" min={0} defaultValue={duckets} />
</label>
<label>
Diamonds
<input name="diamonds" type="number" min={0} defaultValue={diamonds} />
</label>
</div>
<p className="muted">Currency fields set the exact balance, not a delta. Passwords are not editable here.</p>
<div style={{ display: "flex", gap: "0.5rem", marginTop: "0.5rem" }}>
<button type="submit" className="btn btn-primary">Save changes</button>
<Link href={`/admin/users/${user.id}`} className="btn">Cancel</Link>
</div>
</form>
</main>
);
}
+24
View File
@@ -60,6 +60,30 @@
--input-border-color: #4b5563;
--input-focus-color: #eeb425;
--navbar-height: 64px;
/* Aliases used throughout the admin pages' inline styles. */
--muted: var(--color-text-muted);
--border: color-mix(in srgb, var(--color-text-muted) 18%, transparent);
}
/* Dark mode (toggled by html.dark; wins over :root + ThemeVars via specificity). */
html.dark {
--color-background: #0f1117;
--color-surface: #171a21;
--color-dropdown: #1f242d;
--color-navbar: #171a21;
--color-navbar-text: #e5e7eb;
--color-text: #e5e7eb;
--color-text-muted: #9ca3af;
}
html.dark input,
html.dark select,
html.dark textarea {
background: #11151c;
color: var(--color-text);
}
html.dark .currency,
html.dark .article-img {
background: #1f242d;
}
html {
+26 -11
View File
@@ -1,4 +1,6 @@
import type { Metadata } from "next";
import { NextIntlClientProvider } from "next-intl";
import { getLocale, getMessages } from "next-intl/server";
import { Nunito } from "next/font/google";
import type { ReactNode } from "react";
import { Navigation } from "@/components/navigation";
@@ -23,22 +25,35 @@ export const metadata: Metadata = {
export default async function RootLayout({ children }: { children: ReactNode }) {
await enforceSiteAccess();
const locale = await getLocale();
const messages = await getMessages();
return (
<html lang="en" className={`app ${nunito.variable}`}>
<html lang={locale} className={`app ${nunito.variable}`}>
<head>
{/* Apply the saved theme before first paint to avoid a light→dark flash. */}
<script
dangerouslySetInnerHTML={{
__html:
"try{if(localStorage.getItem('theme')==='dark')document.documentElement.classList.add('dark');}catch(e){}",
}}
/>
</head>
<body
className="flex min-h-screen flex-col site-bg"
style={{ backgroundColor: "var(--color-background)" }}
>
<ThemeVars />
<TopHeader />
<SiteHeader />
<Navigation />
<main className="overflow-hidden site-bg">
<div className="mx-auto mt-10 grid max-w-7xl grid-cols-12 gap-x-3 gap-y-8 p-6 md:mt-0">
<div className="col-span-12">{children}</div>
</div>
</main>
<SiteFooter />
<NextIntlClientProvider locale={locale} messages={messages}>
<ThemeVars />
<TopHeader />
<SiteHeader />
<Navigation />
<main className="overflow-hidden site-bg">
<div className="mx-auto mt-10 grid max-w-7xl grid-cols-12 gap-x-3 gap-y-8 p-6 md:mt-0">
<div className="col-span-12">{children}</div>
</div>
</main>
<SiteFooter />
</NextIntlClientProvider>
</body>
</html>
);
+200
View File
@@ -0,0 +1,200 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { avatarImageUrl } from "@/lib/format";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
import { acceptFriend } from "@/actions/messenger";
export const dynamic = "force-dynamic";
function formatTimestamp(seconds: number): string {
if (!seconds) return "";
// sended_on is a unix timestamp in seconds (emulator convention).
return new Date(seconds * 1000).toISOString().slice(0, 16).replace("T", " ");
}
export default async function MessagesPage() {
const session = await auth();
if (!session?.user?.id) redirect("/login");
const userId = Number(session.user.id);
// Pending friend requests addressed to the session user, the most recent
// offline messages they've received, and the avatar-imager base URL.
let requests: { id: number; userFromId: number }[] = [];
let messages: { id: number; userFromId: number; message: string; sendedOn: number }[] = [];
let imagerBase =
"https://www.habbo.com/habbo-imaging/avatarimage";
try {
const [reqRows, msgRows, base] = await Promise.all([
prisma.messengerFriendrequests.findMany({
where: { userToId: userId },
select: { id: true, userFromId: true },
orderBy: { id: "desc" },
take: 100,
}),
prisma.messengerOffline.findMany({
where: { userId },
select: { id: true, userFromId: true, message: true, sendedOn: true },
orderBy: { sendedOn: "desc" },
take: 50,
}),
siteSettings.get(
"habbo_imaging_url",
"https://www.habbo.com/habbo-imaging/avatarimage",
),
]);
requests = reqRows;
messages = msgRows;
if (base) imagerBase = base;
} catch {
requests = [];
messages = [];
}
// Resolve usernames/looks for every counterparty referenced above.
const otherIds = Array.from(
new Set([
...requests.map((r) => r.userFromId),
...messages.map((m) => m.userFromId),
]),
).filter((id) => id && id > 0);
let userById = new Map<
number,
{ id: number; username: string; look: string }
>();
if (otherIds.length > 0) {
try {
const users = await prisma.user.findMany({
where: { id: { in: otherIds } },
select: { id: true, username: true, look: true },
});
userById = new Map(users.map((u) => [u.id, u]));
} catch {
userById = new Map();
}
}
return (
<main>
<h1>Messages</h1>
<p className="muted" style={{ marginTop: "-0.25rem" }}>
Friend requests and offline messages waiting for you.
</p>
{/* ── Pending friend requests ───────────────────────────── */}
<section style={{ marginTop: "1.5rem" }}>
<h2>Friend requests</h2>
{requests.length === 0 ? (
<div className="card">
<p className="muted" style={{ margin: 0 }}>
No pending friend requests.
</p>
</div>
) : (
<div className="grid">
{requests.map((req) => {
const from = userById.get(req.userFromId);
const avatar = avatarImageUrl(imagerBase, from?.look ?? "", {
size: "s",
headOnly: true,
});
return (
<div
key={req.id}
className="card"
style={{ display: "flex", gap: "0.85rem", alignItems: "center" }}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
className="avatar"
src={avatar}
alt={`${from?.username ?? "User"} avatar`}
width={50}
height={50}
/>
<div style={{ minWidth: 0, flex: 1 }}>
<p style={{ margin: 0 }}>
{from ? (
<Link href={`/u/${from.username}`}>
<strong>{from.username}</strong>
</Link>
) : (
<strong>User #{req.userFromId}</strong>
)}
</p>
<p className="muted" style={{ margin: 0 }}>
wants to be your friend.
</p>
</div>
<form action={acceptFriend}>
<input type="hidden" name="requestId" value={String(req.id)} />
<button type="submit" className="btn btn-secondary">
Accept
</button>
</form>
</div>
);
})}
</div>
)}
</section>
{/* ── Offline messages ──────────────────────────────────── */}
<section style={{ marginTop: "2rem" }}>
<h2>Offline messages</h2>
{messages.length === 0 ? (
<div className="card">
<p className="muted" style={{ margin: 0 }}>
You have no offline messages.
</p>
</div>
) : (
<div className="grid">
{messages.map((msg) => {
const from = userById.get(msg.userFromId);
const avatar = avatarImageUrl(imagerBase, from?.look ?? "", {
size: "s",
headOnly: true,
});
const when = formatTimestamp(msg.sendedOn);
return (
<article
key={msg.id}
className="card"
style={{ display: "flex", gap: "0.85rem", alignItems: "flex-start" }}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
className="avatar"
src={avatar}
alt={`${from?.username ?? "User"} avatar`}
width={50}
height={50}
/>
<div style={{ minWidth: 0, flex: 1 }}>
<p
className="muted"
style={{ marginTop: 0, marginBottom: "0.35rem" }}
>
{from ? (
<Link href={`/u/${from.username}`}>{from.username}</Link>
) : (
<span>User #{msg.userFromId}</span>
)}
{when ? <span> · {when}</span> : null}
</p>
<p style={{ margin: 0, whiteSpace: "pre-wrap" }}>{msg.message}</p>
</div>
</article>
);
})}
</div>
)}
</section>
</main>
);
}
+70 -21
View File
@@ -3,9 +3,18 @@ import { notFound } from "next/navigation";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { postComment } from "@/actions/article-comments";
import { toggleReaction } from "@/actions/article-reactions";
export const dynamic = "force-dynamic";
// The reaction set offered by the voting UI. Must stay in sync with
// ALLOWED_REACTIONS in src/actions/article-reactions.ts.
const REACTIONS: { key: string; label: string }[] = [
{ key: "like", label: "👍 Like" },
{ key: "love", label: "❤️ Love" },
{ key: "wow", label: "😮 Wow" },
];
function formatDate(d: Date | null | undefined): string {
return d ? d.toISOString().slice(0, 10) : "";
}
@@ -27,18 +36,26 @@ export default async function ArticlePage({
const articleId = article.id;
// Reaction counts grouped by reaction type for this article.
let reactionGroups: { reaction: string; count: number }[] = [];
const session = await auth();
const loggedIn = Boolean(session?.user?.id);
const sessionUserId = loggedIn ? Number(session!.user!.id) : null;
// Reaction counts grouped by reaction type for this article, plus the
// signed-in user's currently-active reaction (so its button reads as pressed).
let reactionCounts = new Map<string, number>();
let myReaction: string | null = null;
try {
const rows = await prisma.websiteArticleReactions.findMany({
where: { articleId, active: true },
select: { reaction: true },
select: { reaction: true, userId: true },
});
const tally = new Map<string, number>();
for (const r of rows) tally.set(r.reaction, (tally.get(r.reaction) ?? 0) + 1);
reactionGroups = [...tally.entries()].map(([reaction, count]) => ({ reaction, count }));
for (const r of rows) {
reactionCounts.set(r.reaction, (reactionCounts.get(r.reaction) ?? 0) + 1);
if (sessionUserId !== null && r.userId === sessionUserId) myReaction = r.reaction;
}
} catch {
reactionGroups = [];
reactionCounts = new Map();
myReaction = null;
}
// Comments for this article (oldest first).
@@ -68,9 +85,6 @@ export default async function ArticlePage({
}
}
const session = await auth();
const loggedIn = Boolean(session?.user?.id);
return (
<main>
<p className="muted">
@@ -93,19 +107,54 @@ export default async function ArticlePage({
{/* ── Reactions ─────────────────────────────────────────── */}
<section style={{ marginTop: "2rem" }}>
<h2>Reactions</h2>
{reactionGroups.length === 0 ? (
<p className="muted">No reactions yet.</p>
) : (
{loggedIn ? (
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
{reactionGroups.map((r) => (
<span key={r.reaction} className="currency">
<span style={{ fontWeight: 800 }}>{r.reaction}</span>
<span className="muted" style={{ fontSize: "0.85rem" }}>
{r.count}
</span>
</span>
))}
{REACTIONS.map((r) => {
const active = myReaction === r.key;
const count = reactionCounts.get(r.key) ?? 0;
return (
<form key={r.key} action={toggleReaction} style={{ margin: 0 }}>
<input type="hidden" name="articleId" value={String(articleId)} />
<input type="hidden" name="reaction" value={r.key} />
<button
type="submit"
aria-pressed={active}
className={`btn ${active ? "btn-primary" : "btn-outline"}`}
title={active ? "Click to remove your reaction" : `React with ${r.key}`}
>
<span>{r.label}</span>
<span
className="muted"
style={{ fontSize: "0.85rem", color: "inherit", opacity: 0.85 }}
>
{count}
</span>
</button>
</form>
);
})}
</div>
) : reactionCounts.size === 0 ? (
<p className="muted">
No reactions yet. <Link href="/login">Log in</Link> to react.
</p>
) : (
<>
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
{REACTIONS.filter((r) => (reactionCounts.get(r.key) ?? 0) > 0).map((r) => (
<span key={r.key} className="currency">
<span style={{ fontWeight: 800 }}>{r.label}</span>
<span className="muted" style={{ fontSize: "0.85rem" }}>
{reactionCounts.get(r.key) ?? 0}
</span>
</span>
))}
</div>
<p className="muted" style={{ marginTop: "0.6rem" }}>
<Link href="/login">Log in</Link> to react.
</p>
</>
)}
</section>
+103
View File
@@ -0,0 +1,103 @@
import Link from "next/link";
import { isValidVerificationToken } from "@/actions/email-verify";
import { prisma } from "@/lib/prisma";
// Reads + writes the live users table — must never be statically rendered.
export const dynamic = "force-dynamic";
type Status = "verified" | "already" | "invalid" | "unavailable";
export default async function VerifyPage({
searchParams,
}: {
searchParams: Promise<{ token?: string; email?: string }>;
}) {
// Next 16: searchParams is a Promise.
const { token = "", email = "" } = await searchParams;
const normalisedEmail = email.trim().toLowerCase();
let status: Status = "invalid";
if (normalisedEmail && token) {
const ok = await isValidVerificationToken(normalisedEmail, token);
if (ok) {
try {
// mail is VARCHAR(500) and not unique in the emulator schema, so match
// on the email and flip any matching unverified account(s).
const user = await prisma.user.findFirst({
where: { mail: normalisedEmail },
select: { id: true, mailVerified: true },
});
if (!user) {
status = "invalid";
} else if (user.mailVerified === "1") {
status = "already";
} else {
await prisma.user.update({
where: { id: user.id },
// users.mail_verified is a String flag ('0' / '1').
data: { mailVerified: "1" },
});
status = "verified";
}
} catch {
// DB unreachable — don't claim success.
status = "unavailable";
}
} else {
status = "invalid";
}
}
return (
<main>
<h1>Email verification</h1>
<div className="card" style={{ marginTop: "1rem" }}>
{status === "verified" ? (
<>
<h2 style={{ marginTop: 0 }}>You're all set</h2>
<p className="muted">
Your email address has been verified. You can now log in and play.
</p>
<p style={{ marginTop: "1rem" }}>
<Link href="/login" className="btn btn-primary">
Go to login
</Link>
</p>
</>
) : status === "already" ? (
<>
<h2 style={{ marginTop: 0 }}>Already verified</h2>
<p className="muted">This email address has already been confirmed.</p>
<p style={{ marginTop: "1rem" }}>
<Link href="/login" className="btn btn-primary">
Go to login
</Link>
</p>
</>
) : status === "unavailable" ? (
<>
<h2 style={{ marginTop: 0 }}>Try again shortly</h2>
<p className="muted">
We couldn't reach the server to verify your email. Please retry the link in a
moment.
</p>
</>
) : (
<>
<h2 style={{ marginTop: 0 }}>Invalid or expired link</h2>
<p className="muted">
This verification link is not valid. Make sure you opened the full link from your
email, or request a new one.
</p>
<p style={{ marginTop: "1rem" }}>
<Link href="/login" className="btn btn-outline">
Back to login
</Link>
</p>
</>
)}
</div>
</main>
);
}
+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>
);
}
+31 -18
View File
@@ -1,8 +1,12 @@
import { getTranslations } from "next-intl/server";
import Link from "next/link";
import { auth } from "@/lib/auth";
import { LanguageSwitcher } from "@/components/language-switcher";
import { ThemeSwitcher } from "@/components/theme-switcher";
export async function Navigation() {
const session = await auth();
const t = await getTranslations("nav");
const isStaff = (session?.user?.rank ?? 0) >= 7;
return (
@@ -10,87 +14,96 @@ export async function Navigation() {
<div className="max-w-7xl min-h-[60px] px-4 flex items-center gap-x-6 md:gap-x-8 mx-auto flex-wrap">
<Link href="/" className="nav-item">
<i className="navigation-icon home mr-1 hidden lg:inline-flex" />
Home
{t("home")}
</Link>
<details className="relative">
<summary className="nav-item list-none cursor-pointer [&::-webkit-details-marker]:hidden">
<i className="navigation-icon community mr-1 hidden lg:inline-flex" />
Community
{t("community")}
</summary>
<div
className="absolute left-0 mt-1 min-w-[190px] rounded-md shadow-lg z-50 py-1"
style={{ backgroundColor: "var(--color-dropdown)" }}
>
<Link href="/news" className="dropdown-item">
Articles
{t("articles")}
</Link>
<Link href="/photos" className="dropdown-item">
Photos
{t("photos")}
</Link>
<Link href="/staff" className="dropdown-item">
Staff
{t("staff")}
</Link>
<Link href="/rankings" className="dropdown-item">
Rankings
{t("rankings")}
</Link>
<Link href="/guilds" className="dropdown-item">
Guilds
{t("guilds")}
</Link>
</div>
</details>
<Link href="/leaderboard" className="nav-item">
<i className="navigation-icon leaderboards mr-1 hidden lg:inline-flex" />
Leaderboards
{t("leaderboards")}
</Link>
<Link href="/rares" className="nav-item">
Rare values
{t("rareValues")}
</Link>
<Link href="/shop" className="nav-item">
<i className="navigation-icon shop mr-1 hidden lg:inline-flex" />
Shop
{t("shop")}
</Link>
<Link href="/marketplace" className="nav-item">
Market
{t("market")}
</Link>
<details className="relative">
<summary className="nav-item list-none cursor-pointer [&::-webkit-details-marker]:hidden">
<i className="navigation-icon rules mr-1 hidden lg:inline-flex" />
Assistance
{t("assistance")}
</summary>
<div
className="absolute left-0 mt-1 min-w-[190px] rounded-md shadow-lg z-50 py-1"
style={{ backgroundColor: "var(--color-dropdown)" }}
>
<Link href="/help" className="dropdown-item">
Help center
{t("helpCenter")}
</Link>
{session?.user ? (
<Link href="/help/tickets" className="dropdown-item">
My tickets
{t("myTickets")}
</Link>
) : null}
<Link href="/badges" className="dropdown-item">
Badges
{t("badges")}
</Link>
</div>
</details>
<Link href="/radio" className="nav-item">
Radio
{t("radio")}
</Link>
{session?.user ? (
<Link href="/friends" className="nav-item">
Friends
{t("friends")}
</Link>
) : null}
{session?.user ? (
<Link href="/messages" className="nav-item">
{t("messages")}
</Link>
) : null}
{isStaff ? (
<Link href="/admin" className="nav-item">
Admin
{t("admin")}
</Link>
) : null}
<div className="ml-auto flex items-center gap-x-2">
<LanguageSwitcher />
<ThemeSwitcher />
</div>
</div>
</nav>
);
+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,
};
});
+142
View File
@@ -0,0 +1,142 @@
import { prisma } from "@/lib/prisma";
// AtomCMS-faithful content moderation, used by user-generated-content actions
// (article comments, mottos, registration, etc.) before they touch the DB.
//
// Two layers, evaluated in order:
// 1. website_wordfilter — the CMS-owned blocklist (prisma.websiteWordfilter),
// mirroring AtomCMS's word filter. Loaded once and cached in-process like
// site-settings, so the common path never hits the DB.
// 2. OpenAI Moderations — only when OPENAI_API_KEY is set. A flagged result
// blocks the content.
//
// Philosophy: FAIL-OPEN. A DB outage, a network blip, or an OpenAI error must
// never block a legitimate post — those paths return { ok: true }. We only
// return { ok: false } on a *confirmed* hit (a matched filter word, or an
// explicitly-flagged AI result). Pure server module; uses global fetch only.
export interface ModerationResult {
ok: boolean;
reason?: string;
}
const OPENAI_MODERATIONS_URL = "https://api.openai.com/v1/moderations";
// Bound the AI call so a slow/hung endpoint can't stall a server action.
const OPENAI_TIMEOUT_MS = 5_000;
/**
* In-process cache of the lowercased website_wordfilter words. Mirrors the
* caching strategy in site-settings.ts: populated on first use, kept until
* reloadWordFilter() is called after an admin edits the list.
*/
let wordFilterCache: string[] | null = null;
async function loadWordFilter(): Promise<string[]> {
if (wordFilterCache === null) {
try {
const rows = await prisma.websiteWordfilter.findMany({
select: { word: true },
});
wordFilterCache = rows
.map((r) => r.word.trim().toLowerCase())
.filter((w) => w.length > 0);
} catch {
// DB unavailable — return an empty filter WITHOUT caching, so the next
// call retries. Fail-open: a missing blocklist must not block content.
return [];
}
}
return wordFilterCache;
}
/** Invalidate the cached word filter after the blocklist is edited. */
export function reloadWordFilter(): void {
wordFilterCache = null;
}
/**
* Check `text` against the cached website_wordfilter list. Returns the matched
* word, or null if clean. Substring match on the lowercased text, matching
* AtomCMS's behaviour (filtered words are blocked even inside other words).
*/
async function wordFilterHit(text: string): Promise<string | null> {
const words = await loadWordFilter();
if (words.length === 0) return null;
const haystack = text.toLowerCase();
for (const word of words) {
if (haystack.includes(word)) return word;
}
return null;
}
/**
* Ask the OpenAI Moderations endpoint whether `text` is flagged. Returns true
* ONLY on a confirmed flag. Any error (no key, network failure, non-2xx,
* malformed body, timeout) returns false — fail-open.
*/
async function openAiFlagged(text: string): Promise<boolean> {
const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) return false;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), OPENAI_TIMEOUT_MS);
try {
const res = await fetch(OPENAI_MODERATIONS_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({ input: text }),
signal: controller.signal,
cache: "no-store",
});
if (!res.ok) return false;
const data: unknown = await res.json();
const results = (data as { results?: Array<{ flagged?: boolean }> })?.results;
if (!Array.isArray(results)) return false;
return results.some((r) => r?.flagged === true);
} catch {
// Network error / abort / parse failure — fail-open.
return false;
} finally {
clearTimeout(timer);
}
}
/**
* Decide whether `text` is allowed. Resolves to { ok:false, reason } on a
* confirmed wordfilter or AI hit; otherwise { ok:true }. Never throws — empty
* or non-string input is treated as allowed (nothing to moderate).
*/
export async function isAllowed(text: string): Promise<ModerationResult> {
if (typeof text !== "string" || text.trim().length === 0) {
return { ok: true };
}
// Layer 1: local blocklist (cheap, cached).
const hit = await wordFilterHit(text);
if (hit) {
return { ok: false, reason: `Blocked by word filter: "${hit}"` };
}
// Layer 2: OpenAI moderation (only when configured).
if (await openAiFlagged(text)) {
return { ok: false, reason: "Blocked by automated content moderation" };
}
return { ok: true };
}
/**
* Same check as isAllowed(), but throws an Error with the moderation reason when
* the content is rejected. Convenient inside server actions that want to bail
* early. Resolves silently when the content is allowed.
*/
export async function moderateOrThrow(text: string): Promise<void> {
const result = await isAllowed(text);
if (!result.ok) {
throw new Error(result.reason ?? "Content not allowed");
}
}
+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"
}
}