Add dark mode, i18n, messenger/moderation/verify, admin CRUD parity
Web-tier features completing the AtomCMS→Next.js conversion (slice 2): UI/UX: - Dark mode: html.dark CSS-var overrides + ThemeSwitcher (localStorage, no-flash boot script) wired into the nav. - i18n (next-intl, cookie-based / no URL routing): en + it catalogs, request.ts, provider in root layout, LanguageSwitcher; shell (nav, header, footer) fully translated. URLs + access-guard unchanged. - globals.css: --muted/--border aliases used across admin pages. User features: - /messages: offline messages + friend-request accept (server action re-reads session, two directional rows, idempotent). - Email verification: signed-token /verify route + sendVerification wired into register (best-effort, never blocks signup). - Article reactions: toggle UI on news/[slug] + server action. - Content moderation service (website_wordfilter + optional OpenAI moderations, fail-open) wired into article comments + guestbook. Admin CRUD parity (Filament replacement): - /admin/shop (+ new/[id]) packages CRUD + read-only orders. - /admin/transactions read-only PayPal log. - /admin/permissions, /admin/tags, /admin/ads (+ new/[id]), /admin/help-questions (+ new/[id]), /admin/radio/history, /admin/users/[id]/edit. All gated by requireStaff + logStaffActivity. Verified: tsc 0, vitest 48/48, next build 0 (all routes incl. new admin CRUD + /messages + /verify).
This commit is contained in:
1 parent
22d53d0e9c
commit
7daeccb832
45 files changed
+3312
-84
No files matched your search
@@ -38,6 +38,11 @@ SMTP_FROM=
|
||||
DISCORD_WEBHOOK_URL=
|
||||
ALERT_EMAIL=
|
||||
|
||||
# Optional AI content moderation (user comments / guestbook).
|
||||
# When set, posts are checked against the OpenAI Moderations endpoint in
|
||||
# addition to the website_wordfilter blocklist. Fail-open if unset/erroring.
|
||||
OPENAI_API_KEY=
|
||||
|
||||
# Optional PayPal top-up (sandbox by default)
|
||||
PAYPAL_CLIENT_ID=
|
||||
PAYPAL_SECRET=
|
||||
|
||||
+6
-1
@@ -1,4 +1,5 @@
|
||||
import type { NextConfig } from "next";
|
||||
import createNextIntlPlugin from "next-intl/plugin";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
// This app lives inside the Laravel repo tree (which has its own lockfiles);
|
||||
@@ -9,4 +10,8 @@ const nextConfig: NextConfig = {
|
||||
serverExternalPackages: ["@prisma/adapter-mariadb", "mariadb", "@prisma/client"],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
// next-intl WITHOUT i18n routing — locale comes from the NEXT_LOCALE cookie via
|
||||
// src/i18n/request.ts, so URLs and the access-guard middleware stay unchanged.
|
||||
const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
|
||||
|
||||
export default withNextIntl(nextConfig);
|
||||
@@ -25,6 +25,7 @@
|
||||
"hash-wasm": "^4.12.0",
|
||||
"next": "^16.2.9",
|
||||
"next-auth": "5.0.0-beta.31",
|
||||
"next-intl": "^4.13.0",
|
||||
"nodemailer": "^6.9.0",
|
||||
"otplib": "^12.0.1",
|
||||
"react": "^19.2.0",
|
||||
|
||||
Generated
+429
@@ -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]):
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
// users_currency.type values for the non-credits currencies (mirror send-currency.ts).
|
||||
// Credits live on users.credits; pixels/points live on the users row too;
|
||||
// duckets/diamonds live in users_currency keyed by (user_id, type).
|
||||
const DUCKETS_TYPE = 0;
|
||||
const DIAMONDS_TYPE = 5;
|
||||
|
||||
function toInt(value: FormDataEntryValue | null, min = 0): number | null {
|
||||
if (value == null) return null;
|
||||
const raw = String(value).trim();
|
||||
if (raw === "") return null;
|
||||
const n = Number(raw);
|
||||
if (!Number.isFinite(n)) return null;
|
||||
const i = Math.trunc(n);
|
||||
return i < min ? min : i;
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit the SAFE website-managed fields of a users row (and the duckets/diamonds
|
||||
* balances in users_currency). Never touches the password. Re-reads the staff
|
||||
* user from the session and logs the action. emulator-owned users.id is Int.
|
||||
*/
|
||||
export async function updateUser(formData: FormData): Promise<void> {
|
||||
// Never trust the client: re-check staff inside the action.
|
||||
const staff = await requireStaff();
|
||||
|
||||
const userId = Number(formData.get("id"));
|
||||
if (!Number.isInteger(userId) || userId <= 0) return;
|
||||
|
||||
const existing = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!existing) return;
|
||||
|
||||
// users row — only existing, safe columns.
|
||||
const mailRaw = String(formData.get("mail") ?? "").trim();
|
||||
const motto = String(formData.get("motto") ?? "").slice(0, 127);
|
||||
const look = String(formData.get("look") ?? "").slice(0, 256);
|
||||
const rank = toInt(formData.get("rank"), 1);
|
||||
const credits = toInt(formData.get("credits"), 0);
|
||||
const pixels = toInt(formData.get("pixels"), 0);
|
||||
const points = toInt(formData.get("points"), 0);
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
mail: mailRaw === "" ? null : mailRaw.slice(0, 500),
|
||||
motto,
|
||||
look,
|
||||
...(rank != null ? { rank } : {}),
|
||||
...(credits != null ? { credits } : {}),
|
||||
...(pixels != null ? { pixels } : {}),
|
||||
...(points != null ? { points } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
// users_currency — set exact balances for duckets / diamonds.
|
||||
const duckets = toInt(formData.get("duckets"), 0);
|
||||
const diamonds = toInt(formData.get("diamonds"), 0);
|
||||
if (duckets != null) {
|
||||
await prisma.usersCurrency.upsert({
|
||||
where: { userId_type: { userId, type: DUCKETS_TYPE } },
|
||||
update: { amount: duckets },
|
||||
create: { userId, type: DUCKETS_TYPE, amount: duckets },
|
||||
});
|
||||
}
|
||||
if (diamonds != null) {
|
||||
await prisma.usersCurrency.upsert({
|
||||
where: { userId_type: { userId, type: DIAMONDS_TYPE } },
|
||||
update: { amount: diamonds },
|
||||
create: { userId, type: DIAMONDS_TYPE, amount: diamonds },
|
||||
});
|
||||
}
|
||||
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "user_edit",
|
||||
description: `Edited account fields of user #${userId}`,
|
||||
targetType: "user",
|
||||
targetId: userId,
|
||||
});
|
||||
|
||||
revalidatePath(`/admin/users/${userId}`);
|
||||
revalidatePath(`/admin/users/${userId}/edit`);
|
||||
redirect(`/admin/users/${userId}`);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { isAllowed } from "@/lib/services/moderation";
|
||||
|
||||
// website_article_comments.comment is VARCHAR(255); keep the write within bounds.
|
||||
const COMMENT_MAX = 255;
|
||||
@@ -23,6 +24,9 @@ export async function postComment(formData: FormData): Promise<void> {
|
||||
const comment = String(formData.get("comment") ?? "").trim().slice(0, COMMENT_MAX);
|
||||
if (!comment) return;
|
||||
|
||||
// Block filtered/AI-flagged content before it touches the DB (fail-open).
|
||||
if (!(await isAllowed(comment)).ok) return;
|
||||
|
||||
const articleIdRaw = String(formData.get("articleId") ?? "").trim();
|
||||
if (!/^\d+$/.test(articleIdRaw)) return;
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
// The reaction set the UI offers. The action rejects anything outside this list
|
||||
// so the website_article_reactions.reaction VARCHAR(50) only ever holds known
|
||||
// values. Keep this in sync with REACTIONS in src/app/news/[slug]/page.tsx.
|
||||
const ALLOWED_REACTIONS = new Set(["like", "love", "wow"]);
|
||||
|
||||
/**
|
||||
* Toggle the SIGNED-IN user's reaction on a news article.
|
||||
*
|
||||
* The voter id is read from the session (re-fetched via auth()), never from the
|
||||
* submitted FormData, so a crafted form cannot vote as another account. A user
|
||||
* has at most one ACTIVE reaction per article:
|
||||
* - clicking the reaction they already have active -> deactivates it (un-vote)
|
||||
* - clicking a different reaction -> that reaction becomes active and any other
|
||||
* reaction rows for this user/article are deactivated
|
||||
* - first-ever reaction of a type -> a new active row is created
|
||||
*
|
||||
* Rows are toggled (active flag) rather than deleted so a user's history of
|
||||
* reaction types is preserved. website_article_reactions has no composite
|
||||
* unique key, so we resolve the existing row with findFirst rather than upsert.
|
||||
*/
|
||||
export async function toggleReaction(formData: FormData): Promise<void> {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return;
|
||||
|
||||
const userId = Number(session.user.id);
|
||||
if (!Number.isFinite(userId)) return;
|
||||
|
||||
const reaction = String(formData.get("reaction") ?? "").trim().toLowerCase();
|
||||
if (!ALLOWED_REACTIONS.has(reaction)) return;
|
||||
|
||||
const articleIdRaw = String(formData.get("articleId") ?? "").trim();
|
||||
if (!/^\d+$/.test(articleIdRaw)) return;
|
||||
|
||||
let articleId: bigint;
|
||||
try {
|
||||
articleId = BigInt(articleIdRaw);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
let slug: string | null = null;
|
||||
try {
|
||||
// Confirm the article exists (and grab its slug for revalidation).
|
||||
const article = await prisma.websiteArticles.findUnique({
|
||||
where: { id: articleId },
|
||||
select: { slug: true },
|
||||
});
|
||||
if (!article) return;
|
||||
slug = article.slug;
|
||||
|
||||
// The user's current row for THIS reaction on THIS article, if any.
|
||||
const existing = await prisma.websiteArticleReactions.findFirst({
|
||||
where: { userId, articleId, reaction },
|
||||
select: { id: true, active: true },
|
||||
});
|
||||
|
||||
if (existing?.active) {
|
||||
// Already reacting with this exact reaction -> un-vote (deactivate it).
|
||||
await prisma.websiteArticleReactions.update({
|
||||
where: { id: existing.id },
|
||||
data: { active: false },
|
||||
});
|
||||
} else {
|
||||
// Switching to (or first-time picking) this reaction: clear any other
|
||||
// active reaction by this user on this article, then activate this one.
|
||||
await prisma.websiteArticleReactions.updateMany({
|
||||
where: { userId, articleId, active: true },
|
||||
data: { active: false },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
await prisma.websiteArticleReactions.update({
|
||||
where: { id: existing.id },
|
||||
data: { active: true },
|
||||
});
|
||||
} else {
|
||||
await prisma.websiteArticleReactions.create({
|
||||
data: { userId, articleId, reaction, active: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// DB unavailable — fail soft; nothing to persist.
|
||||
return;
|
||||
}
|
||||
|
||||
if (slug) revalidatePath(`/news/${slug}`);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
"use server";
|
||||
|
||||
import { createHash, timingSafeEqual } from "node:crypto";
|
||||
import { env } from "@/env";
|
||||
import { sendMail } from "@/lib/services/email";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
// Stateless email verification, AtomCMS-faithful but DB-table-free.
|
||||
//
|
||||
// Instead of persisting a row (password_resets style), the token is a keyed
|
||||
// digest of the email address: sha256(email + APP_KEY). Because APP_KEY is a
|
||||
// server-only secret, an attacker who only knows the email cannot forge a
|
||||
// matching token, and /verify can recompute + compare it without any storage.
|
||||
// The token is therefore deterministic per (email, secret) pair and stays valid
|
||||
// until the account's mail_verified flips to '1' (after which /verify no-ops).
|
||||
|
||||
/** Secret mixed into the digest. Falls back to AUTH_SECRET, then a constant. */
|
||||
function verifySecret(): string {
|
||||
return env.APP_KEY || env.AUTH_SECRET || "atom-cms-verify";
|
||||
}
|
||||
|
||||
/** Compute the verification token for an email (lowercased + trimmed). */
|
||||
export async function verificationToken(email: string): Promise<string> {
|
||||
const normalised = email.trim().toLowerCase();
|
||||
return createHash("sha256").update(`${normalised}|${verifySecret()}`).digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Constant-time check that `token` matches the expected digest for `email`.
|
||||
* Returns false on any length/format mismatch rather than throwing.
|
||||
*/
|
||||
export async function isValidVerificationToken(
|
||||
email: string,
|
||||
token: string,
|
||||
): Promise<boolean> {
|
||||
if (!email || !token || !/^[a-f0-9]{64}$/i.test(token)) return false;
|
||||
const expected = await verificationToken(email);
|
||||
const a = Buffer.from(expected, "utf8");
|
||||
const b = Buffer.from(token.toLowerCase(), "utf8");
|
||||
if (a.length !== b.length) return false;
|
||||
return timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the verification link + email and send it. No-ops gracefully when SMTP
|
||||
* is unconfigured (sendMail returns false). `userId` is accepted for a faithful
|
||||
* call signature, but the stateless token only needs the email.
|
||||
*/
|
||||
export async function sendVerification(userId: number, email: string): Promise<boolean> {
|
||||
const normalised = email.trim().toLowerCase();
|
||||
if (!normalised) return false;
|
||||
|
||||
const token = await verificationToken(normalised);
|
||||
const base = env.APP_URL.replace(/\/+$/, "");
|
||||
const link = `${base}/verify?token=${encodeURIComponent(token)}&email=${encodeURIComponent(
|
||||
normalised,
|
||||
)}`;
|
||||
|
||||
const hotelName = (await siteSettings.get("hotel_name", env.HOTEL_NAME)) ?? env.HOTEL_NAME;
|
||||
|
||||
const html = `
|
||||
<div style="font-family:sans-serif;line-height:1.5;color:#0f172a">
|
||||
<h2 style="margin:0 0 0.5rem">Verify your email</h2>
|
||||
<p>Welcome to ${escapeHtml(hotelName)}! Confirm this email address to finish setting up your account.</p>
|
||||
<p style="margin:1.25rem 0">
|
||||
<a href="${link}"
|
||||
style="display:inline-block;padding:0.6rem 1.2rem;border-radius:8px;background:#eeb425;color:#1a1a2e;font-weight:700;text-decoration:none">
|
||||
Verify email
|
||||
</a>
|
||||
</p>
|
||||
<p style="color:#64748b;font-size:0.875rem">If the button doesn't work, paste this link into your browser:</p>
|
||||
<p style="color:#64748b;font-size:0.875rem;word-break:break-all">${link}</p>
|
||||
</div>
|
||||
`.trim();
|
||||
|
||||
// `userId` referenced so a faithful caller signature isn't flagged unused.
|
||||
void userId;
|
||||
|
||||
return sendMail(normalised, `Verify your email · ${hotelName}`, html);
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { isAllowed } from "@/lib/services/moderation";
|
||||
|
||||
// Emulator/CMS column message is VARCHAR(255); keep the write within bounds.
|
||||
const MESSAGE_MAX = 255;
|
||||
@@ -27,6 +28,9 @@ export async function postGuestbook(formData: FormData): Promise<void> {
|
||||
const message = String(formData.get("message") ?? "").trim().slice(0, MESSAGE_MAX);
|
||||
if (!message) return;
|
||||
|
||||
// Block filtered/AI-flagged content before it touches the DB (fail-open).
|
||||
if (!(await isAllowed(message)).ok) return;
|
||||
|
||||
// Optional: used only to revalidate the correct profile route.
|
||||
const username = String(formData.get("username") ?? "").trim();
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
/**
|
||||
* Accept a pending friend request as the SIGNED-IN user.
|
||||
*
|
||||
* The ACCEPTER is re-read from the session via auth() and is never trusted from
|
||||
* the submitted FormData. Only the request id comes from the form, and the
|
||||
* request is only honoured when its target (user_to_id) is the session user —
|
||||
* so a crafted form cannot accept a request addressed to someone else.
|
||||
*
|
||||
* Arcturus/AtomCMS stores friendships as TWO directional rows in
|
||||
* messenger_friendships (one user_one_id→user_two_id, one the reverse). We
|
||||
* create both inside a transaction and delete the originating request so it no
|
||||
* longer shows as pending in the in-game messenger or here.
|
||||
*/
|
||||
export async function acceptFriend(formData: FormData): Promise<void> {
|
||||
const session = await auth();
|
||||
const meId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(meId) || meId <= 0) return;
|
||||
|
||||
const requestId = Number(formData.get("requestId"));
|
||||
if (!Number.isInteger(requestId) || requestId <= 0) return;
|
||||
|
||||
try {
|
||||
// The request must exist AND be addressed to the session user.
|
||||
const request = await prisma.messengerFriendrequests.findUnique({
|
||||
where: { id: requestId },
|
||||
select: { id: true, userFromId: true, userToId: true },
|
||||
});
|
||||
if (!request || request.userToId !== meId) return;
|
||||
|
||||
const friendId = request.userFromId;
|
||||
if (!Number.isInteger(friendId) || friendId <= 0 || friendId === meId) {
|
||||
// Malformed/self request — just clear it.
|
||||
await prisma.messengerFriendrequests.delete({ where: { id: requestId } });
|
||||
return;
|
||||
}
|
||||
|
||||
const friendsSince = Math.floor(Date.now() / 1000);
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Don't double-insert if a friendship already exists in either direction.
|
||||
const existing = await tx.messengerFriendships.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ userOneId: meId, userTwoId: friendId },
|
||||
{ userOneId: friendId, userTwoId: meId },
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
await tx.messengerFriendships.createMany({
|
||||
data: [
|
||||
{ userOneId: meId, userTwoId: friendId, friendsSince },
|
||||
{ userOneId: friendId, userTwoId: meId, friendsSince },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
await tx.messengerFriendrequests.delete({ where: { id: requestId } });
|
||||
});
|
||||
} catch {
|
||||
// DB unavailable — fail soft; nothing to persist.
|
||||
return;
|
||||
}
|
||||
|
||||
revalidatePath("/messages");
|
||||
revalidatePath("/friends");
|
||||
}
|
||||
+11
-1
@@ -2,6 +2,7 @@
|
||||
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { sendVerification } from "@/actions/email-verify";
|
||||
import { hashPassword } from "@/lib/auth/password";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
@@ -39,7 +40,7 @@ export async function register(formData: FormData): Promise<void> {
|
||||
h.get("x-forwarded-for")?.split(",")[0]?.trim() ?? h.get("x-real-ip") ?? "0.0.0.0";
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
try {
|
||||
await prisma.user.create({
|
||||
const created = await prisma.user.create({
|
||||
data: {
|
||||
username,
|
||||
password: await hashPassword(password),
|
||||
@@ -49,7 +50,16 @@ export async function register(formData: FormData): Promise<void> {
|
||||
ipCurrent: ip,
|
||||
look: DEFAULT_LOOK,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
// Fire the verification email. Best-effort: a mail/SMTP failure must not
|
||||
// abort a successful registration, so swallow its errors here.
|
||||
try {
|
||||
await sendVerification(created.id, mail);
|
||||
} catch {
|
||||
// No-op: account is created; user can request a new link later.
|
||||
}
|
||||
} catch {
|
||||
error = "Could not create the account (is the username unique?)";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { deleteAd, updateAd } from "@/actions/admin-ads";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function EditAd({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
let ad: Awaited<ReturnType<typeof prisma.websiteAds.findUnique>> = null;
|
||||
try {
|
||||
ad = await prisma.websiteAds.findUnique({ where: { id: BigInt(id) } });
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
if (!ad) notFound();
|
||||
|
||||
return (
|
||||
<main>
|
||||
<p className="muted">
|
||||
<Link href="/admin/ads">← Advertisements</Link>
|
||||
</p>
|
||||
<h1>Edit advertisement</h1>
|
||||
<form action={updateAd} className="card" style={{ display: "grid", gap: "0.6rem" }}>
|
||||
<input type="hidden" name="id" value={String(ad.id)} />
|
||||
<input name="image" defaultValue={ad.image} placeholder="Image URL" required maxLength={255} />
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Save
|
||||
</button>
|
||||
</form>
|
||||
<form action={deleteAd} style={{ marginTop: "1rem" }}>
|
||||
<input type="hidden" name="id" value={String(ad.id)} />
|
||||
<button type="submit" className="btn btn-danger">
|
||||
Delete advertisement
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import Link from "next/link";
|
||||
import { createAd } from "@/actions/admin-ads";
|
||||
|
||||
export default function NewAd() {
|
||||
return (
|
||||
<main>
|
||||
<p className="muted">
|
||||
<Link href="/admin/ads">← Advertisements</Link>
|
||||
</p>
|
||||
<h1>New advertisement</h1>
|
||||
<form action={createAd} className="card" style={{ display: "grid", gap: "0.6rem" }}>
|
||||
<input name="image" placeholder="Image URL" required maxLength={255} />
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Create
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import Link from "next/link";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdminAds() {
|
||||
let ads: Awaited<ReturnType<typeof prisma.websiteAds.findMany>> = [];
|
||||
try {
|
||||
ads = await prisma.websiteAds.findMany({
|
||||
orderBy: { id: "desc" },
|
||||
take: 500,
|
||||
});
|
||||
} catch {
|
||||
ads = [];
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
|
||||
<h1 style={{ flex: 1 }}>Advertisements</h1>
|
||||
<Link href="/admin/ads/new">+ New ad</Link>
|
||||
</div>
|
||||
<p className="muted">
|
||||
Website advertisement banners (<code>website_ads</code>). Each ad is an image URL shown
|
||||
across the site.
|
||||
</p>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Preview</th>
|
||||
<th>Image URL</th>
|
||||
<th>Created</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ads.map((ad) => (
|
||||
<tr key={String(ad.id)}>
|
||||
<td>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={ad.image} alt="" style={{ maxHeight: 40, maxWidth: 120 }} />
|
||||
</td>
|
||||
<td className="muted">
|
||||
<code>{ad.image}</code>
|
||||
</td>
|
||||
<td className="muted">
|
||||
{ad.createdAt ? ad.createdAt.toISOString().slice(0, 10) : ""}
|
||||
</td>
|
||||
<td>
|
||||
<Link href={`/admin/ads/${ad.id}`}>Edit</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{ads.length === 0 ? <p className="muted">No advertisements yet.</p> : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { deleteHelpQuestion, updateHelpQuestion } from "@/actions/admin-help";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function EditHelpQuestion({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
let entry: Awaited<
|
||||
ReturnType<typeof prisma.websiteHelpCenterCategories.findUnique>
|
||||
> = null;
|
||||
try {
|
||||
entry = await prisma.websiteHelpCenterCategories.findUnique({
|
||||
where: { id: BigInt(id) },
|
||||
});
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
if (!entry) notFound();
|
||||
|
||||
return (
|
||||
<main>
|
||||
<p className="muted">
|
||||
<Link href="/admin/help-questions">← Help Center</Link>
|
||||
</p>
|
||||
<h1>Edit help-center entry</h1>
|
||||
<form action={updateHelpQuestion} className="card" style={{ display: "grid", gap: "0.6rem" }}>
|
||||
<input type="hidden" name="id" value={String(entry.id)} />
|
||||
<input name="name" defaultValue={entry.name} placeholder="Name / title" required maxLength={255} />
|
||||
<textarea name="content" defaultValue={entry.content} placeholder="Content" rows={6} required />
|
||||
<input
|
||||
name="position"
|
||||
type="number"
|
||||
min={1}
|
||||
defaultValue={entry.position}
|
||||
placeholder="Position"
|
||||
/>
|
||||
<input
|
||||
name="imageUrl"
|
||||
defaultValue={entry.imageUrl ?? ""}
|
||||
placeholder="Image URL (optional)"
|
||||
maxLength={255}
|
||||
/>
|
||||
<input
|
||||
name="buttonText"
|
||||
defaultValue={entry.buttonText ?? ""}
|
||||
placeholder="Button text (optional)"
|
||||
maxLength={255}
|
||||
/>
|
||||
<input
|
||||
name="buttonUrl"
|
||||
defaultValue={entry.buttonUrl ?? ""}
|
||||
placeholder="Button URL (optional)"
|
||||
maxLength={255}
|
||||
/>
|
||||
<label style={{ display: "flex", gap: "0.4rem", alignItems: "center" }}>
|
||||
Button color
|
||||
<input name="buttonColor" type="color" defaultValue={entry.buttonColor} />
|
||||
</label>
|
||||
<label style={{ display: "flex", gap: "0.4rem", alignItems: "center" }}>
|
||||
Button border color
|
||||
<input name="buttonBorderColor" type="color" defaultValue={entry.buttonBorderColor} />
|
||||
</label>
|
||||
<label style={{ display: "flex", gap: "0.4rem", alignItems: "center" }}>
|
||||
<input name="smallBox" type="checkbox" value="1" defaultChecked={entry.smallBox} />
|
||||
Small box
|
||||
</label>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Save
|
||||
</button>
|
||||
</form>
|
||||
<form action={deleteHelpQuestion} style={{ marginTop: "1rem" }}>
|
||||
<input type="hidden" name="id" value={String(entry.id)} />
|
||||
<button type="submit" className="btn btn-danger">
|
||||
Delete entry
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import Link from "next/link";
|
||||
import { createHelpQuestion } from "@/actions/admin-help";
|
||||
|
||||
export default function NewHelpQuestion() {
|
||||
return (
|
||||
<main>
|
||||
<p className="muted">
|
||||
<Link href="/admin/help-questions">← Help Center</Link>
|
||||
</p>
|
||||
<h1>New help-center entry</h1>
|
||||
<form action={createHelpQuestion} className="card" style={{ display: "grid", gap: "0.6rem" }}>
|
||||
<input name="name" placeholder="Name / title" required maxLength={255} />
|
||||
<textarea name="content" placeholder="Content" rows={6} required />
|
||||
<input name="position" type="number" min={1} defaultValue={1} placeholder="Position" />
|
||||
<input name="imageUrl" placeholder="Image URL (optional)" maxLength={255} />
|
||||
<input name="buttonText" placeholder="Button text (optional)" maxLength={255} />
|
||||
<input name="buttonUrl" placeholder="Button URL (optional)" maxLength={255} />
|
||||
<label style={{ display: "flex", gap: "0.4rem", alignItems: "center" }}>
|
||||
Button color
|
||||
<input name="buttonColor" type="color" defaultValue="#eeb425" />
|
||||
</label>
|
||||
<label style={{ display: "flex", gap: "0.4rem", alignItems: "center" }}>
|
||||
Button border color
|
||||
<input name="buttonBorderColor" type="color" defaultValue="#facc15" />
|
||||
</label>
|
||||
<label style={{ display: "flex", gap: "0.4rem", alignItems: "center" }}>
|
||||
<input name="smallBox" type="checkbox" value="1" />
|
||||
Small box
|
||||
</label>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Create
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import Link from "next/link";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdminHelpQuestions() {
|
||||
let entries: Awaited<
|
||||
ReturnType<typeof prisma.websiteHelpCenterCategories.findMany>
|
||||
> = [];
|
||||
try {
|
||||
entries = await prisma.websiteHelpCenterCategories.findMany({
|
||||
orderBy: [{ position: "asc" }, { id: "asc" }],
|
||||
take: 500,
|
||||
});
|
||||
} catch {
|
||||
entries = [];
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
|
||||
<h1 style={{ flex: 1 }}>Help Center</h1>
|
||||
<Link href="/admin/help-questions/new">+ New entry</Link>
|
||||
</div>
|
||||
<p className="muted">
|
||||
Help-center FAQ entries (<code>website_help_center_categories</code>). Shown to visitors on
|
||||
the public help page, ordered by position.
|
||||
</p>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Pos.</th>
|
||||
<th>Name</th>
|
||||
<th>Button</th>
|
||||
<th>Small box</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((e) => (
|
||||
<tr key={String(e.id)}>
|
||||
<td className="muted">{e.position}</td>
|
||||
<td>
|
||||
<Link href={`/admin/help-questions/${e.id}`}>{e.name}</Link>
|
||||
</td>
|
||||
<td className="muted">{e.buttonText || "—"}</td>
|
||||
<td className="muted">{e.smallBox ? "Yes" : "No"}</td>
|
||||
<td>
|
||||
<Link href={`/admin/help-questions/${e.id}`}>Edit</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{entries.length === 0 ? <p className="muted">No help-center entries yet.</p> : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -29,10 +29,14 @@ export default async function AdminLayout({ children }: { children: ReactNode })
|
||||
<Link href="/admin/users">Users</Link>
|
||||
<Link href="/admin/rooms">Rooms</Link>
|
||||
<Link href="/admin/articles">Articles</Link>
|
||||
<Link href="/admin/tags">Tags</Link>
|
||||
<Link href="/admin/catalog">Catalog</Link>
|
||||
<Link href="/admin/shop">Shop</Link>
|
||||
<Link href="/admin/transactions">Transactions</Link>
|
||||
<Link href="/admin/vouchers">Vouchers</Link>
|
||||
<Link href="/admin/badges">Badges</Link>
|
||||
<Link href="/admin/radio">Radio</Link>
|
||||
<Link href="/admin/radio/history">Radio History</Link>
|
||||
<Link href="/admin/achievements">Achievements</Link>
|
||||
<Link href="/admin/subscriptions">Subscriptions</Link>
|
||||
<Link href="/admin/calendar">Calendar</Link>
|
||||
@@ -40,6 +44,8 @@ export default async function AdminLayout({ children }: { children: ReactNode })
|
||||
<Link href="/admin/photos">Photos</Link>
|
||||
<Link href="/admin/bans">Bans</Link>
|
||||
<Link href="/admin/applications">Applications</Link>
|
||||
<Link href="/admin/help-questions">Help Center</Link>
|
||||
<Link href="/admin/ads">Advertisements</Link>
|
||||
<Link href="/admin/wordfilter">Word Filter</Link>
|
||||
<Link href="/admin/ip">IP Management</Link>
|
||||
<Link href="/admin/logs">Logs</Link>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { deleteShopArticle, updateShopArticle } from "@/actions/admin-shop";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function EditShopArticle({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
let pkg: Awaited<ReturnType<typeof prisma.websiteShopArticles.findUnique>> = null;
|
||||
try {
|
||||
pkg = await prisma.websiteShopArticles.findUnique({ where: { id: BigInt(id) } });
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
if (!pkg) notFound();
|
||||
|
||||
return (
|
||||
<main>
|
||||
<p className="muted">
|
||||
<Link href="/admin/shop">← Shop</Link>
|
||||
</p>
|
||||
<h1>Edit shop package</h1>
|
||||
<form action={updateShopArticle} className="card" style={{ display: "grid", gap: "0.6rem" }}>
|
||||
<input type="hidden" name="id" value={String(pkg.id)} />
|
||||
<input name="name" defaultValue={pkg.name} placeholder="Name" required maxLength={255} />
|
||||
<input name="info" defaultValue={pkg.info} placeholder="Info / description" maxLength={255} />
|
||||
<input name="icon" defaultValue={pkg.icon} placeholder="Icon" maxLength={255} />
|
||||
<input name="color" defaultValue={pkg.color} placeholder="Color (e.g. #ffcc00)" maxLength={255} />
|
||||
<input name="costs" type="number" min={0} defaultValue={pkg.costs} placeholder="Costs (store price)" required />
|
||||
<input name="credits" type="number" min={0} defaultValue={pkg.credits ?? undefined} placeholder="Credits given (optional)" />
|
||||
<input name="duckets" type="number" min={0} defaultValue={pkg.duckets ?? undefined} placeholder="Duckets given (optional)" />
|
||||
<input name="diamonds" type="number" min={0} defaultValue={pkg.diamonds ?? undefined} placeholder="Diamonds given (optional)" />
|
||||
<input name="giveRank" type="number" min={0} defaultValue={pkg.giveRank ?? undefined} placeholder="Give rank (optional)" />
|
||||
<input name="badges" defaultValue={pkg.badges ?? ""} placeholder="Badge codes (optional)" maxLength={255} />
|
||||
<input name="position" type="number" min={0} defaultValue={pkg.position} placeholder="Position" />
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Save
|
||||
</button>
|
||||
</form>
|
||||
<form action={deleteShopArticle} style={{ marginTop: "1rem" }}>
|
||||
<input type="hidden" name="id" value={String(pkg.id)} />
|
||||
<button type="submit" className="btn btn-danger">
|
||||
Delete package
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import Link from "next/link";
|
||||
import { createShopArticle } from "@/actions/admin-shop";
|
||||
|
||||
export default function NewShopArticle() {
|
||||
return (
|
||||
<main>
|
||||
<p className="muted">
|
||||
<Link href="/admin/shop">← Shop</Link>
|
||||
</p>
|
||||
<h1>New shop package</h1>
|
||||
<form action={createShopArticle} className="card" style={{ display: "grid", gap: "0.6rem" }}>
|
||||
<input name="name" placeholder="Name" required maxLength={255} />
|
||||
<input name="info" placeholder="Info / description" maxLength={255} />
|
||||
<input name="icon" placeholder="Icon" maxLength={255} />
|
||||
<input name="color" placeholder="Color (e.g. #ffcc00)" maxLength={255} />
|
||||
<input name="costs" type="number" min={0} placeholder="Costs (store price)" required />
|
||||
<input name="credits" type="number" min={0} placeholder="Credits given (optional)" />
|
||||
<input name="duckets" type="number" min={0} placeholder="Duckets given (optional)" />
|
||||
<input name="diamonds" type="number" min={0} placeholder="Diamonds given (optional)" />
|
||||
<input name="giveRank" type="number" min={0} placeholder="Give rank (optional)" />
|
||||
<input name="badges" placeholder="Badge codes (optional)" maxLength={255} />
|
||||
<input name="position" type="number" min={0} placeholder="Position" defaultValue={0} />
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Create
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
export async function SiteFooter() {
|
||||
const hotelName = await siteSettings.get("hotel_name", "Atom");
|
||||
const year = new Date().getFullYear();
|
||||
const t = await getTranslations("footer");
|
||||
|
||||
return (
|
||||
<footer
|
||||
@@ -10,7 +12,7 @@ export async function SiteFooter() {
|
||||
style={{ backgroundColor: "var(--color-surface)", color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<div className="md:font-semibold text-[12px] md:text-[14px]">
|
||||
© {year} - {hotelName} is a not for profit educational project
|
||||
{t("copyright", { year, hotel: hotelName ?? "Atom" })}
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import Link from "next/link";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
export async function SiteHeader() {
|
||||
const t = await getTranslations("header");
|
||||
const [session, hotelName, header, logo] = await Promise.all([
|
||||
auth(),
|
||||
siteSettings.get("hotel_name", "Atom"),
|
||||
@@ -50,7 +52,7 @@ export async function SiteHeader() {
|
||||
style={{ backgroundColor: "var(--color-surface)" }}
|
||||
/>
|
||||
<span className="relative">
|
||||
{online} {hotelName} online
|
||||
{t("online", { count: online, hotel: hotelName ?? "Atom" })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -66,7 +68,7 @@ export async function SiteHeader() {
|
||||
borderColor: "rgba(255,255,255,0.3)",
|
||||
}}
|
||||
>
|
||||
Nitro client
|
||||
{t("nitroClient")}
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
@@ -77,10 +79,7 @@ export async function SiteHeader() {
|
||||
className="font-semibold flex flex-col items-center md:w-[600px]"
|
||||
style={{ color: "#ffffff", textShadow: "0 1px 4px rgba(0,0,0,0.7)" }}
|
||||
>
|
||||
<p className="hidden text-center text-xl md:block">
|
||||
An online virtual world where you can create your own avatar, make friends, chat,
|
||||
create rooms and much more!
|
||||
</p>
|
||||
<p className="hidden text-center text-xl md:block">{t("tagline")}</p>
|
||||
<div className="flex flex-col items-center justify-center gap-x-6 gap-y-4 md:mt-6 md:flex-row md:gap-y-0">
|
||||
<Link href="/login">
|
||||
<button
|
||||
@@ -92,17 +91,17 @@ export async function SiteHeader() {
|
||||
color: "var(--button-text-color)",
|
||||
}}
|
||||
>
|
||||
Login
|
||||
{t("login")}
|
||||
</button>
|
||||
</Link>
|
||||
<p className="text-sm uppercase">Or</p>
|
||||
<p className="text-sm uppercase">{t("or")}</p>
|
||||
<Link href="/register">
|
||||
<button
|
||||
type="button"
|
||||
className="uppercase px-8 py-2.5 rounded-full transition ease-in-out duration-200"
|
||||
style={{ backgroundColor: "var(--color-accent)", color: "#ffffff" }}
|
||||
>
|
||||
Create an account
|
||||
{t("createAccount")}
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* Light/dark toggle. The actual class lives on <html> (so `.app.dark` selectors
|
||||
* and the dark CSS-var block apply); we mirror localStorage so the choice
|
||||
* survives reloads. The inline boot script in the root layout applies the saved
|
||||
* theme before paint to avoid a flash — this component only reflects/toggles it.
|
||||
*/
|
||||
export function ThemeSwitcher() {
|
||||
const [dark, setDark] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setDark(document.documentElement.classList.contains("dark"));
|
||||
}, []);
|
||||
|
||||
function toggle() {
|
||||
const next = !dark;
|
||||
setDark(next);
|
||||
document.documentElement.classList.toggle("dark", next);
|
||||
try {
|
||||
localStorage.setItem("theme", next ? "dark" : "light");
|
||||
} catch {
|
||||
/* private mode / storage disabled — runtime toggle still works */
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
className="nav-item cursor-pointer"
|
||||
aria-label={dark ? "Switch to light mode" : "Switch to dark mode"}
|
||||
title={dark ? "Light mode" : "Dark mode"}
|
||||
>
|
||||
<span aria-hidden className="text-lg leading-none">
|
||||
{dark ? "☀" : "☾"}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { getRequestConfig } from "next-intl/server";
|
||||
|
||||
// i18n WITHOUT routing: the locale is chosen by a `NEXT_LOCALE` cookie (set by
|
||||
// the language switcher) rather than a URL segment, so every existing route and
|
||||
// the access-guard middleware keep working unchanged. English is the fallback.
|
||||
export const SUPPORTED_LOCALES = ["en", "it"] as const;
|
||||
export type AppLocale = (typeof SUPPORTED_LOCALES)[number];
|
||||
export const DEFAULT_LOCALE: AppLocale = "en";
|
||||
|
||||
export function isSupportedLocale(value: string | undefined): value is AppLocale {
|
||||
return !!value && (SUPPORTED_LOCALES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export default getRequestConfig(async () => {
|
||||
const store = await cookies();
|
||||
const cookieLocale = store.get("NEXT_LOCALE")?.value;
|
||||
const locale: AppLocale = isSupportedLocale(cookieLocale) ? cookieLocale : DEFAULT_LOCALE;
|
||||
|
||||
return {
|
||||
locale,
|
||||
messages: (await import(`../messages/${locale}.json`)).default,
|
||||
};
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"nav": {
|
||||
"home": "Home",
|
||||
"community": "Community",
|
||||
"articles": "Articles",
|
||||
"photos": "Photos",
|
||||
"staff": "Staff",
|
||||
"rankings": "Rankings",
|
||||
"guilds": "Guilds",
|
||||
"leaderboards": "Leaderboards",
|
||||
"rareValues": "Rare values",
|
||||
"shop": "Shop",
|
||||
"market": "Market",
|
||||
"assistance": "Assistance",
|
||||
"helpCenter": "Help center",
|
||||
"myTickets": "My tickets",
|
||||
"badges": "Badges",
|
||||
"radio": "Radio",
|
||||
"friends": "Friends",
|
||||
"messages": "Messages",
|
||||
"admin": "Admin"
|
||||
},
|
||||
"header": {
|
||||
"online": "{count} {hotel} online",
|
||||
"nitroClient": "Nitro client",
|
||||
"tagline": "An online virtual world where you can create your own avatar, make friends, chat, create rooms and much more!",
|
||||
"login": "Login",
|
||||
"or": "Or",
|
||||
"createAccount": "Create an account"
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© {year} - {hotel} is a not for profit educational project"
|
||||
},
|
||||
"common": {
|
||||
"language": "Language",
|
||||
"lightMode": "Light mode",
|
||||
"darkMode": "Dark mode"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"nav": {
|
||||
"home": "Home",
|
||||
"community": "Community",
|
||||
"articles": "Articoli",
|
||||
"photos": "Foto",
|
||||
"staff": "Staff",
|
||||
"rankings": "Classifiche",
|
||||
"guilds": "Gruppi",
|
||||
"leaderboards": "Classifiche",
|
||||
"rareValues": "Valori rari",
|
||||
"shop": "Negozio",
|
||||
"market": "Mercato",
|
||||
"assistance": "Assistenza",
|
||||
"helpCenter": "Centro assistenza",
|
||||
"myTickets": "I miei ticket",
|
||||
"badges": "Distintivi",
|
||||
"radio": "Radio",
|
||||
"friends": "Amici",
|
||||
"messages": "Messaggi",
|
||||
"admin": "Admin"
|
||||
},
|
||||
"header": {
|
||||
"online": "{count} online su {hotel}",
|
||||
"nitroClient": "Client Nitro",
|
||||
"tagline": "Un mondo virtuale online dove puoi creare il tuo avatar, fare amicizia, chattare, creare stanze e molto altro!",
|
||||
"login": "Accedi",
|
||||
"or": "Oppure",
|
||||
"createAccount": "Crea un account"
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© {year} - {hotel} è un progetto educativo senza scopo di lucro"
|
||||
},
|
||||
"common": {
|
||||
"language": "Lingua",
|
||||
"lightMode": "Tema chiaro",
|
||||
"darkMode": "Tema scuro"
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user