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=
|
DISCORD_WEBHOOK_URL=
|
||||||
ALERT_EMAIL=
|
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)
|
# Optional PayPal top-up (sandbox by default)
|
||||||
PAYPAL_CLIENT_ID=
|
PAYPAL_CLIENT_ID=
|
||||||
PAYPAL_SECRET=
|
PAYPAL_SECRET=
|
||||||
|
|||||||
+6
-1
@@ -1,4 +1,5 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
import createNextIntlPlugin from "next-intl/plugin";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
// This app lives inside the Laravel repo tree (which has its own lockfiles);
|
// 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"],
|
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",
|
"hash-wasm": "^4.12.0",
|
||||||
"next": "^16.2.9",
|
"next": "^16.2.9",
|
||||||
"next-auth": "5.0.0-beta.31",
|
"next-auth": "5.0.0-beta.31",
|
||||||
|
"next-intl": "^4.13.0",
|
||||||
"nodemailer": "^6.9.0",
|
"nodemailer": "^6.9.0",
|
||||||
"otplib": "^12.0.1",
|
"otplib": "^12.0.1",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
|
|||||||
Generated
+429
@@ -29,6 +29,9 @@ importers:
|
|||||||
next-auth:
|
next-auth:
|
||||||
specifier: 5.0.0-beta.31
|
specifier: 5.0.0-beta.31
|
||||||
version: 5.0.0-beta.31([email protected]([email protected]([email protected]))([email protected]))([email protected])([email protected])
|
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:
|
nodemailer:
|
||||||
specifier: ^6.9.0
|
specifier: ^6.9.0
|
||||||
version: 6.10.1
|
version: 6.10.1
|
||||||
@@ -731,6 +734,18 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
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]':
|
'@hono/[email protected]':
|
||||||
resolution: {integrity: sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==}
|
resolution: {integrity: sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==}
|
||||||
engines: {node: '>=18.14.1'}
|
engines: {node: '>=18.14.1'}
|
||||||
@@ -985,6 +1000,94 @@ packages:
|
|||||||
'@panva/[email protected]':
|
'@panva/[email protected]':
|
||||||
resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==}
|
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]':
|
'@petamoriken/[email protected]':
|
||||||
resolution: {integrity: sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==}
|
resolution: {integrity: sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==}
|
||||||
|
|
||||||
@@ -1263,12 +1366,108 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
|
'@schummar/[email protected]':
|
||||||
|
resolution: {integrity: sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==}
|
||||||
|
|
||||||
'@standard-schema/[email protected]':
|
'@standard-schema/[email protected]':
|
||||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
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]':
|
'@swc/[email protected]':
|
||||||
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
|
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
|
||||||
|
|
||||||
|
'@swc/[email protected]':
|
||||||
|
resolution: {integrity: sha512-K6h3iUlqeM946U4sXFYeahefR1YBbXJvko+hv8WS8/0BNJ4OHiHRywMnQUJCqkR7Y9+hqQ1TvEpiKqUhz7NEFg==}
|
||||||
|
|
||||||
'@tailwindcss/[email protected]':
|
'@tailwindcss/[email protected]':
|
||||||
resolution: {integrity: sha512-h9wegbZDPurxG22xZSoWtdzc41/OlNEUQERNqI/0fOwa2aVlWGu7C35E/x6LDyD3lgtztFSSjKZyuVM0hxhbgA==}
|
resolution: {integrity: sha512-h9wegbZDPurxG22xZSoWtdzc41/OlNEUQERNqI/0fOwa2aVlWGu7C35E/x6LDyD3lgtztFSSjKZyuVM0hxhbgA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1754,6 +1953,20 @@ packages:
|
|||||||
resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
|
resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
|
||||||
engines: {node: '>=0.10.0'}
|
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]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==}
|
resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==}
|
||||||
|
|
||||||
@@ -1894,6 +2107,10 @@ packages:
|
|||||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
|
||||||
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-1OBgCKPzo+S7UWWMp3xgvGvIJ0OpV7B3vR4ZDRqD9a4Ch+OT6dakLXG9ivhtmIWVa71nTSXattOHyCg8sNi8/Q==}
|
resolution: {integrity: sha512-1OBgCKPzo+S7UWWMp3xgvGvIJ0OpV7B3vR4ZDRqD9a4Ch+OT6dakLXG9ivhtmIWVa71nTSXattOHyCg8sNi8/Q==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1910,6 +2127,19 @@ packages:
|
|||||||
nodemailer:
|
nodemailer:
|
||||||
optional: true
|
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]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-MEOJiq/UvuezAdqVSceHbqDgZt1kDw2tpGVOlsdIoJsQdbN2JY2hpVG4xnXGkbdJUOEWhnRfiu/O4Hpc9Juwww==}
|
resolution: {integrity: sha512-MEOJiq/UvuezAdqVSceHbqDgZt1kDw2tpGVOlsdIoJsQdbN2JY2hpVG4xnXGkbdJUOEWhnRfiu/O4Hpc9Juwww==}
|
||||||
engines: {node: '>=20.9.0'}
|
engines: {node: '>=20.9.0'}
|
||||||
@@ -1931,6 +2161,9 @@ packages:
|
|||||||
sass:
|
sass:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==}
|
resolution: {integrity: sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==}
|
||||||
engines: {node: '>=6.0.0'}
|
engines: {node: '>=6.0.0'}
|
||||||
@@ -1964,9 +2197,16 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==}
|
resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-ECF4zHLbUItpUgE3OTtLKlPjeBN+fKEczj2zYjDfCGOzicNs0GK3Vg2IoAYwx7LH/XYw43fZQP6xnZ4TkNxSLQ==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==}
|
resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
@@ -2171,6 +2411,11 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
|
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]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||||
|
|
||||||
@@ -2587,6 +2832,18 @@ snapshots:
|
|||||||
'@esbuild/[email protected]':
|
'@esbuild/[email protected]':
|
||||||
optional: true
|
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])':
|
'@hono/[email protected]([email protected])':
|
||||||
dependencies:
|
dependencies:
|
||||||
hono: 4.12.27
|
hono: 4.12.27
|
||||||
@@ -2760,6 +3017,66 @@ snapshots:
|
|||||||
|
|
||||||
'@panva/[email protected]': {}
|
'@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]': {}
|
'@petamoriken/[email protected]': {}
|
||||||
|
|
||||||
'@prisma/[email protected]':
|
'@prisma/[email protected]':
|
||||||
@@ -2988,12 +3305,74 @@ snapshots:
|
|||||||
'@rollup/[email protected]':
|
'@rollup/[email protected]':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@schummar/[email protected]': {}
|
||||||
|
|
||||||
'@standard-schema/[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]':
|
'@swc/[email protected]':
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@swc/[email protected]':
|
||||||
|
dependencies:
|
||||||
|
'@swc/counter': 0.1.3
|
||||||
|
|
||||||
'@tailwindcss/[email protected]([email protected])':
|
'@tailwindcss/[email protected]([email protected])':
|
||||||
dependencies:
|
dependencies:
|
||||||
mini-svg-data-uri: 1.4.4
|
mini-svg-data-uri: 1.4.4
|
||||||
@@ -3447,6 +3826,21 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
safer-buffer: 2.1.2
|
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]: {}
|
||||||
|
|
||||||
[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])([email protected]):
|
[email protected]([email protected]([email protected]([email protected]))([email protected]))([email protected])([email protected]):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@auth/core': 0.41.2([email protected])
|
'@auth/core': 0.41.2([email protected])
|
||||||
@@ -3570,6 +3966,25 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
nodemailer: 6.10.1
|
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]):
|
[email protected]([email protected]([email protected]))([email protected]):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@next/env': 16.2.9
|
'@next/env': 16.2.9
|
||||||
@@ -3594,6 +4009,8 @@ snapshots:
|
|||||||
- '@babel/core'
|
- '@babel/core'
|
||||||
- babel-plugin-macros
|
- babel-plugin-macros
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
@@ -3618,12 +4035,16 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
confbox: 0.2.4
|
confbox: 0.2.4
|
||||||
exsolve: 1.1.0
|
exsolve: 1.1.0
|
||||||
pathe: 2.0.3
|
pathe: 2.0.3
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
cssesc: 3.0.0
|
cssesc: 3.0.0
|
||||||
@@ -3833,6 +4254,14 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[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]([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 { revalidatePath } from "next/cache";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { isAllowed } from "@/lib/services/moderation";
|
||||||
|
|
||||||
// website_article_comments.comment is VARCHAR(255); keep the write within bounds.
|
// website_article_comments.comment is VARCHAR(255); keep the write within bounds.
|
||||||
const COMMENT_MAX = 255;
|
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);
|
const comment = String(formData.get("comment") ?? "").trim().slice(0, COMMENT_MAX);
|
||||||
if (!comment) return;
|
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();
|
const articleIdRaw = String(formData.get("articleId") ?? "").trim();
|
||||||
if (!/^\d+$/.test(articleIdRaw)) return;
|
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 { revalidatePath } from "next/cache";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { isAllowed } from "@/lib/services/moderation";
|
||||||
|
|
||||||
// Emulator/CMS column message is VARCHAR(255); keep the write within bounds.
|
// Emulator/CMS column message is VARCHAR(255); keep the write within bounds.
|
||||||
const MESSAGE_MAX = 255;
|
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);
|
const message = String(formData.get("message") ?? "").trim().slice(0, MESSAGE_MAX);
|
||||||
if (!message) return;
|
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.
|
// Optional: used only to revalidate the correct profile route.
|
||||||
const username = String(formData.get("username") ?? "").trim();
|
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 { headers } from "next/headers";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
import { sendVerification } from "@/actions/email-verify";
|
||||||
import { hashPassword } from "@/lib/auth/password";
|
import { hashPassword } from "@/lib/auth/password";
|
||||||
import { prisma } from "@/lib/prisma";
|
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";
|
h.get("x-forwarded-for")?.split(",")[0]?.trim() ?? h.get("x-real-ip") ?? "0.0.0.0";
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
try {
|
try {
|
||||||
await prisma.user.create({
|
const created = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
username,
|
username,
|
||||||
password: await hashPassword(password),
|
password: await hashPassword(password),
|
||||||
@@ -49,7 +50,16 @@ export async function register(formData: FormData): Promise<void> {
|
|||||||
ipCurrent: ip,
|
ipCurrent: ip,
|
||||||
look: DEFAULT_LOOK,
|
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 {
|
} catch {
|
||||||
error = "Could not create the account (is the username unique?)";
|
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/users">Users</Link>
|
||||||
<Link href="/admin/rooms">Rooms</Link>
|
<Link href="/admin/rooms">Rooms</Link>
|
||||||
<Link href="/admin/articles">Articles</Link>
|
<Link href="/admin/articles">Articles</Link>
|
||||||
|
<Link href="/admin/tags">Tags</Link>
|
||||||
<Link href="/admin/catalog">Catalog</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/vouchers">Vouchers</Link>
|
||||||
<Link href="/admin/badges">Badges</Link>
|
<Link href="/admin/badges">Badges</Link>
|
||||||
<Link href="/admin/radio">Radio</Link>
|
<Link href="/admin/radio">Radio</Link>
|
||||||
|
<Link href="/admin/radio/history">Radio History</Link>
|
||||||
<Link href="/admin/achievements">Achievements</Link>
|
<Link href="/admin/achievements">Achievements</Link>
|
||||||
<Link href="/admin/subscriptions">Subscriptions</Link>
|
<Link href="/admin/subscriptions">Subscriptions</Link>
|
||||||
<Link href="/admin/calendar">Calendar</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/photos">Photos</Link>
|
||||||
<Link href="/admin/bans">Bans</Link>
|
<Link href="/admin/bans">Bans</Link>
|
||||||
<Link href="/admin/applications">Applications</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/wordfilter">Word Filter</Link>
|
||||||
<Link href="/admin/ip">IP Management</Link>
|
<Link href="/admin/ip">IP Management</Link>
|
||||||
<Link href="/admin/logs">Logs</Link>
|
<Link href="/admin/logs">Logs</Link>
|
||||||
|
|||||||
@@ -1,25 +1,29 @@
|
|||||||
|
import {
|
||||||
|
createPermission,
|
||||||
|
deletePermission,
|
||||||
|
updatePermission,
|
||||||
|
} from "@/actions/admin-permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
// NOTE: The emulator's standalone `permissions` table is not present in the
|
// Manages website_permissions (model WebsitePermissions) — the CMS-owned
|
||||||
// Prisma schema (there is no `prisma.permissions` accessor). The real,
|
// rank-permission mapping AtomCMS exposes in admin. Each row is a key with an
|
||||||
// queryable rank-permission table in this schema is `website_housekeeping_permissions`
|
// optional value and comment; those are the only editable columns on the table.
|
||||||
// (model WebsiteHousekeepingPermissions), which gates each permission by the
|
|
||||||
// minimum rank that holds it — exactly the rank-permission data this page lists.
|
|
||||||
type PermissionRow = {
|
type PermissionRow = {
|
||||||
id: bigint;
|
id: bigint;
|
||||||
permission: string;
|
key: string;
|
||||||
minRank: number;
|
value: string | null;
|
||||||
description: string | null;
|
comment: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function AdminPermissions() {
|
export default async function AdminPermissions() {
|
||||||
let permissions: PermissionRow[] = [];
|
let permissions: PermissionRow[] = [];
|
||||||
try {
|
try {
|
||||||
permissions = await prisma.websiteHousekeepingPermissions.findMany({
|
permissions = await prisma.websitePermissions.findMany({
|
||||||
select: { id: true, permission: true, minRank: true, description: true },
|
select: { id: true, key: true, value: true, comment: true },
|
||||||
orderBy: [{ minRank: "asc" }, { permission: "asc" }],
|
orderBy: { key: "asc" },
|
||||||
|
take: 500,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
permissions = [];
|
permissions = [];
|
||||||
@@ -29,29 +33,87 @@ export default async function AdminPermissions() {
|
|||||||
<main>
|
<main>
|
||||||
<h1>Permissions</h1>
|
<h1>Permissions</h1>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
Read-only view of rank permissions. Each entry is the minimum rank that
|
CMS-owned rank permissions (<code>website_permissions</code>). Each entry
|
||||||
grants the permission.
|
maps a permission key to its value/name and an optional comment.
|
||||||
</p>
|
</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 ? (
|
{permissions.length === 0 ? (
|
||||||
<p className="muted">No permissions found.</p>
|
<p className="muted">No permissions found.</p>
|
||||||
) : (
|
) : (
|
||||||
<table>
|
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr style={{ textAlign: "left", color: "var(--muted)" }}>
|
||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
<th>Permission</th>
|
<th>Key</th>
|
||||||
<th>Min rank</th>
|
<th>Value</th>
|
||||||
<th>Description</th>
|
<th>Comment</th>
|
||||||
|
<th />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{permissions.map((p) => (
|
{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>{String(p.id)}</td>
|
||||||
<td>{p.permission}</td>
|
<td>
|
||||||
<td>{p.minRank}</td>
|
<form
|
||||||
<td className="muted">{p.description ?? "—"}</td>
|
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>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</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-border-color: #4b5563;
|
||||||
--input-focus-color: #eeb425;
|
--input-focus-color: #eeb425;
|
||||||
--navbar-height: 64px;
|
--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 {
|
html {
|
||||||
|
|||||||
+26
-11
@@ -1,4 +1,6 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import { NextIntlClientProvider } from "next-intl";
|
||||||
|
import { getLocale, getMessages } from "next-intl/server";
|
||||||
import { Nunito } from "next/font/google";
|
import { Nunito } from "next/font/google";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Navigation } from "@/components/navigation";
|
import { Navigation } from "@/components/navigation";
|
||||||
@@ -23,22 +25,35 @@ export const metadata: Metadata = {
|
|||||||
|
|
||||||
export default async function RootLayout({ children }: { children: ReactNode }) {
|
export default async function RootLayout({ children }: { children: ReactNode }) {
|
||||||
await enforceSiteAccess();
|
await enforceSiteAccess();
|
||||||
|
const locale = await getLocale();
|
||||||
|
const messages = await getMessages();
|
||||||
return (
|
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
|
<body
|
||||||
className="flex min-h-screen flex-col site-bg"
|
className="flex min-h-screen flex-col site-bg"
|
||||||
style={{ backgroundColor: "var(--color-background)" }}
|
style={{ backgroundColor: "var(--color-background)" }}
|
||||||
>
|
>
|
||||||
<ThemeVars />
|
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||||
<TopHeader />
|
<ThemeVars />
|
||||||
<SiteHeader />
|
<TopHeader />
|
||||||
<Navigation />
|
<SiteHeader />
|
||||||
<main className="overflow-hidden site-bg">
|
<Navigation />
|
||||||
<div className="mx-auto mt-10 grid max-w-7xl grid-cols-12 gap-x-3 gap-y-8 p-6 md:mt-0">
|
<main className="overflow-hidden site-bg">
|
||||||
<div className="col-span-12">{children}</div>
|
<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>
|
<div className="col-span-12">{children}</div>
|
||||||
</main>
|
</div>
|
||||||
<SiteFooter />
|
</main>
|
||||||
|
<SiteFooter />
|
||||||
|
</NextIntlClientProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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 { auth } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { postComment } from "@/actions/article-comments";
|
import { postComment } from "@/actions/article-comments";
|
||||||
|
import { toggleReaction } from "@/actions/article-reactions";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
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 {
|
function formatDate(d: Date | null | undefined): string {
|
||||||
return d ? d.toISOString().slice(0, 10) : "";
|
return d ? d.toISOString().slice(0, 10) : "";
|
||||||
}
|
}
|
||||||
@@ -27,18 +36,26 @@ export default async function ArticlePage({
|
|||||||
|
|
||||||
const articleId = article.id;
|
const articleId = article.id;
|
||||||
|
|
||||||
// Reaction counts grouped by reaction type for this article.
|
const session = await auth();
|
||||||
let reactionGroups: { reaction: string; count: number }[] = [];
|
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 {
|
try {
|
||||||
const rows = await prisma.websiteArticleReactions.findMany({
|
const rows = await prisma.websiteArticleReactions.findMany({
|
||||||
where: { articleId, active: true },
|
where: { articleId, active: true },
|
||||||
select: { reaction: true },
|
select: { reaction: true, userId: true },
|
||||||
});
|
});
|
||||||
const tally = new Map<string, number>();
|
for (const r of rows) {
|
||||||
for (const r of rows) tally.set(r.reaction, (tally.get(r.reaction) ?? 0) + 1);
|
reactionCounts.set(r.reaction, (reactionCounts.get(r.reaction) ?? 0) + 1);
|
||||||
reactionGroups = [...tally.entries()].map(([reaction, count]) => ({ reaction, count }));
|
if (sessionUserId !== null && r.userId === sessionUserId) myReaction = r.reaction;
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
reactionGroups = [];
|
reactionCounts = new Map();
|
||||||
|
myReaction = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Comments for this article (oldest first).
|
// 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 (
|
return (
|
||||||
<main>
|
<main>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
@@ -93,19 +107,54 @@ export default async function ArticlePage({
|
|||||||
{/* ── Reactions ─────────────────────────────────────────── */}
|
{/* ── Reactions ─────────────────────────────────────────── */}
|
||||||
<section style={{ marginTop: "2rem" }}>
|
<section style={{ marginTop: "2rem" }}>
|
||||||
<h2>Reactions</h2>
|
<h2>Reactions</h2>
|
||||||
{reactionGroups.length === 0 ? (
|
|
||||||
<p className="muted">No reactions yet.</p>
|
{loggedIn ? (
|
||||||
) : (
|
|
||||||
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
||||||
{reactionGroups.map((r) => (
|
{REACTIONS.map((r) => {
|
||||||
<span key={r.reaction} className="currency">
|
const active = myReaction === r.key;
|
||||||
<span style={{ fontWeight: 800 }}>{r.reaction}</span>
|
const count = reactionCounts.get(r.key) ?? 0;
|
||||||
<span className="muted" style={{ fontSize: "0.85rem" }}>
|
return (
|
||||||
{r.count}
|
<form key={r.key} action={toggleReaction} style={{ margin: 0 }}>
|
||||||
</span>
|
<input type="hidden" name="articleId" value={String(articleId)} />
|
||||||
</span>
|
<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>
|
</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>
|
</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 Link from "next/link";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
|
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||||
|
import { ThemeSwitcher } from "@/components/theme-switcher";
|
||||||
|
|
||||||
export async function Navigation() {
|
export async function Navigation() {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
const t = await getTranslations("nav");
|
||||||
const isStaff = (session?.user?.rank ?? 0) >= 7;
|
const isStaff = (session?.user?.rank ?? 0) >= 7;
|
||||||
|
|
||||||
return (
|
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">
|
<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">
|
<Link href="/" className="nav-item">
|
||||||
<i className="navigation-icon home mr-1 hidden lg:inline-flex" />
|
<i className="navigation-icon home mr-1 hidden lg:inline-flex" />
|
||||||
Home
|
{t("home")}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<details className="relative">
|
<details className="relative">
|
||||||
<summary className="nav-item list-none cursor-pointer [&::-webkit-details-marker]:hidden">
|
<summary className="nav-item list-none cursor-pointer [&::-webkit-details-marker]:hidden">
|
||||||
<i className="navigation-icon community mr-1 hidden lg:inline-flex" />
|
<i className="navigation-icon community mr-1 hidden lg:inline-flex" />
|
||||||
Community
|
{t("community")}
|
||||||
</summary>
|
</summary>
|
||||||
<div
|
<div
|
||||||
className="absolute left-0 mt-1 min-w-[190px] rounded-md shadow-lg z-50 py-1"
|
className="absolute left-0 mt-1 min-w-[190px] rounded-md shadow-lg z-50 py-1"
|
||||||
style={{ backgroundColor: "var(--color-dropdown)" }}
|
style={{ backgroundColor: "var(--color-dropdown)" }}
|
||||||
>
|
>
|
||||||
<Link href="/news" className="dropdown-item">
|
<Link href="/news" className="dropdown-item">
|
||||||
Articles
|
{t("articles")}
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/photos" className="dropdown-item">
|
<Link href="/photos" className="dropdown-item">
|
||||||
Photos
|
{t("photos")}
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/staff" className="dropdown-item">
|
<Link href="/staff" className="dropdown-item">
|
||||||
Staff
|
{t("staff")}
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/rankings" className="dropdown-item">
|
<Link href="/rankings" className="dropdown-item">
|
||||||
Rankings
|
{t("rankings")}
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/guilds" className="dropdown-item">
|
<Link href="/guilds" className="dropdown-item">
|
||||||
Guilds
|
{t("guilds")}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<Link href="/leaderboard" className="nav-item">
|
<Link href="/leaderboard" className="nav-item">
|
||||||
<i className="navigation-icon leaderboards mr-1 hidden lg:inline-flex" />
|
<i className="navigation-icon leaderboards mr-1 hidden lg:inline-flex" />
|
||||||
Leaderboards
|
{t("leaderboards")}
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/rares" className="nav-item">
|
<Link href="/rares" className="nav-item">
|
||||||
Rare values
|
{t("rareValues")}
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/shop" className="nav-item">
|
<Link href="/shop" className="nav-item">
|
||||||
<i className="navigation-icon shop mr-1 hidden lg:inline-flex" />
|
<i className="navigation-icon shop mr-1 hidden lg:inline-flex" />
|
||||||
Shop
|
{t("shop")}
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/marketplace" className="nav-item">
|
<Link href="/marketplace" className="nav-item">
|
||||||
Market
|
{t("market")}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<details className="relative">
|
<details className="relative">
|
||||||
<summary className="nav-item list-none cursor-pointer [&::-webkit-details-marker]:hidden">
|
<summary className="nav-item list-none cursor-pointer [&::-webkit-details-marker]:hidden">
|
||||||
<i className="navigation-icon rules mr-1 hidden lg:inline-flex" />
|
<i className="navigation-icon rules mr-1 hidden lg:inline-flex" />
|
||||||
Assistance
|
{t("assistance")}
|
||||||
</summary>
|
</summary>
|
||||||
<div
|
<div
|
||||||
className="absolute left-0 mt-1 min-w-[190px] rounded-md shadow-lg z-50 py-1"
|
className="absolute left-0 mt-1 min-w-[190px] rounded-md shadow-lg z-50 py-1"
|
||||||
style={{ backgroundColor: "var(--color-dropdown)" }}
|
style={{ backgroundColor: "var(--color-dropdown)" }}
|
||||||
>
|
>
|
||||||
<Link href="/help" className="dropdown-item">
|
<Link href="/help" className="dropdown-item">
|
||||||
Help center
|
{t("helpCenter")}
|
||||||
</Link>
|
</Link>
|
||||||
{session?.user ? (
|
{session?.user ? (
|
||||||
<Link href="/help/tickets" className="dropdown-item">
|
<Link href="/help/tickets" className="dropdown-item">
|
||||||
My tickets
|
{t("myTickets")}
|
||||||
</Link>
|
</Link>
|
||||||
) : null}
|
) : null}
|
||||||
<Link href="/badges" className="dropdown-item">
|
<Link href="/badges" className="dropdown-item">
|
||||||
Badges
|
{t("badges")}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<Link href="/radio" className="nav-item">
|
<Link href="/radio" className="nav-item">
|
||||||
Radio
|
{t("radio")}
|
||||||
</Link>
|
</Link>
|
||||||
{session?.user ? (
|
{session?.user ? (
|
||||||
<Link href="/friends" className="nav-item">
|
<Link href="/friends" className="nav-item">
|
||||||
Friends
|
{t("friends")}
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
{session?.user ? (
|
||||||
|
<Link href="/messages" className="nav-item">
|
||||||
|
{t("messages")}
|
||||||
</Link>
|
</Link>
|
||||||
) : null}
|
) : null}
|
||||||
{isStaff ? (
|
{isStaff ? (
|
||||||
<Link href="/admin" className="nav-item">
|
<Link href="/admin" className="nav-item">
|
||||||
Admin
|
{t("admin")}
|
||||||
</Link>
|
</Link>
|
||||||
) : null}
|
) : null}
|
||||||
|
<div className="ml-auto flex items-center gap-x-2">
|
||||||
|
<LanguageSwitcher />
|
||||||
|
<ThemeSwitcher />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
|
||||||
export async function SiteFooter() {
|
export async function SiteFooter() {
|
||||||
const hotelName = await siteSettings.get("hotel_name", "Atom");
|
const hotelName = await siteSettings.get("hotel_name", "Atom");
|
||||||
const year = new Date().getFullYear();
|
const year = new Date().getFullYear();
|
||||||
|
const t = await getTranslations("footer");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer
|
<footer
|
||||||
@@ -10,7 +12,7 @@ export async function SiteFooter() {
|
|||||||
style={{ backgroundColor: "var(--color-surface)", color: "var(--color-text-muted)" }}
|
style={{ backgroundColor: "var(--color-surface)", color: "var(--color-text-muted)" }}
|
||||||
>
|
>
|
||||||
<div className="md:font-semibold text-[12px] md:text-[14px]">
|
<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>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
|
||||||
export async function SiteHeader() {
|
export async function SiteHeader() {
|
||||||
|
const t = await getTranslations("header");
|
||||||
const [session, hotelName, header, logo] = await Promise.all([
|
const [session, hotelName, header, logo] = await Promise.all([
|
||||||
auth(),
|
auth(),
|
||||||
siteSettings.get("hotel_name", "Atom"),
|
siteSettings.get("hotel_name", "Atom"),
|
||||||
@@ -50,7 +52,7 @@ export async function SiteHeader() {
|
|||||||
style={{ backgroundColor: "var(--color-surface)" }}
|
style={{ backgroundColor: "var(--color-surface)" }}
|
||||||
/>
|
/>
|
||||||
<span className="relative">
|
<span className="relative">
|
||||||
{online} {hotelName} online
|
{t("online", { count: online, hotel: hotelName ?? "Atom" })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -66,7 +68,7 @@ export async function SiteHeader() {
|
|||||||
borderColor: "rgba(255,255,255,0.3)",
|
borderColor: "rgba(255,255,255,0.3)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Nitro client
|
{t("nitroClient")}
|
||||||
</button>
|
</button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -77,10 +79,7 @@ export async function SiteHeader() {
|
|||||||
className="font-semibold flex flex-col items-center md:w-[600px]"
|
className="font-semibold flex flex-col items-center md:w-[600px]"
|
||||||
style={{ color: "#ffffff", textShadow: "0 1px 4px rgba(0,0,0,0.7)" }}
|
style={{ color: "#ffffff", textShadow: "0 1px 4px rgba(0,0,0,0.7)" }}
|
||||||
>
|
>
|
||||||
<p className="hidden text-center text-xl md:block">
|
<p className="hidden text-center text-xl md:block">{t("tagline")}</p>
|
||||||
An online virtual world where you can create your own avatar, make friends, chat,
|
|
||||||
create rooms and much more!
|
|
||||||
</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">
|
<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">
|
<Link href="/login">
|
||||||
<button
|
<button
|
||||||
@@ -92,17 +91,17 @@ export async function SiteHeader() {
|
|||||||
color: "var(--button-text-color)",
|
color: "var(--button-text-color)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Login
|
{t("login")}
|
||||||
</button>
|
</button>
|
||||||
</Link>
|
</Link>
|
||||||
<p className="text-sm uppercase">Or</p>
|
<p className="text-sm uppercase">{t("or")}</p>
|
||||||
<Link href="/register">
|
<Link href="/register">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="uppercase px-8 py-2.5 rounded-full transition ease-in-out duration-200"
|
className="uppercase px-8 py-2.5 rounded-full transition ease-in-out duration-200"
|
||||||
style={{ backgroundColor: "var(--color-accent)", color: "#ffffff" }}
|
style={{ backgroundColor: "var(--color-accent)", color: "#ffffff" }}
|
||||||
>
|
>
|
||||||
Create an account
|
{t("createAccount")}
|
||||||
</button>
|
</button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</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