Compare commits
95
Commits
24c4279a7e
...
v1.0.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01a297884a | ||
|
|
01196b0843 | ||
|
|
48bdd6f2e6 | ||
|
|
2d003a2101 | ||
|
|
2f5d2b6e09 | ||
|
|
f31afb5318 | ||
|
|
b12eaeef82 | ||
|
|
c6905d3865 | ||
|
|
8876b38f0a | ||
|
|
02280d0f42 | ||
|
|
f43bb234ab | ||
|
|
dacc4cadfd | ||
|
|
65f118c918 | ||
|
|
cc458332d7 | ||
|
|
3f43a62243 | ||
|
|
5f98b949ad | ||
|
|
cab4280e63 | ||
|
|
016f35bab3 | ||
|
|
d97db2a3e7 | ||
|
|
ff47fd31e5 | ||
|
|
fa9b8c83d2 | ||
|
|
69506b70e0 | ||
|
|
b897cff901 | ||
|
|
edc8fc6584 | ||
|
|
7df37728aa | ||
|
|
af9f11d934 | ||
|
|
14b9e77c1a | ||
|
|
423a33200e | ||
|
|
0f5c190ab1 | ||
|
|
4bb908ecf7 | ||
|
|
7cc88287b8 | ||
|
|
de95412a6c | ||
|
|
46e9f61b35 | ||
|
|
17847545dd | ||
|
|
b922f6d49f | ||
|
|
8c2fe603d1 | ||
|
|
1e0dd183b1 | ||
|
|
ea71a574a4 | ||
|
|
b77dbb65f6 | ||
|
|
7f7e11adcf | ||
|
|
86d15d0d92 | ||
|
|
e336ac3c66 | ||
|
|
623cced64e | ||
|
|
e464fac102 | ||
|
|
2a727b2a71 | ||
|
|
f072a28aee | ||
|
|
d010344423 | ||
|
|
54c155bcef | ||
|
|
0cbd4ca858 | ||
|
|
0869b82f5f | ||
|
|
29cd09e458 | ||
|
|
04743fde6e | ||
|
|
3284e5a915 | ||
|
|
a906d671fb | ||
|
|
a847ecae04 | ||
|
|
2818d2eb20 | ||
|
|
7253bb61be | ||
|
|
4e2c4ccf07 | ||
|
|
2c0478bfc0 | ||
|
|
960fea3549 | ||
|
|
78b1982145 | ||
|
|
ac3c42f308 | ||
|
|
2e9db75eca | ||
|
|
04b9844f0f | ||
|
|
c670227ad4 | ||
|
|
1acace49d0 | ||
|
|
12e4a07e48 | ||
|
|
fc42a1c138 | ||
|
|
fee71fe7dc | ||
|
|
e8ade7afa3 | ||
|
|
3773c6cb29 | ||
|
|
c432d4e95d | ||
|
|
b37b6bc2b9 | ||
|
|
5fa342018d | ||
|
|
7f8d083763 | ||
|
|
f7f6e09174 | ||
|
|
a9f1f4f99d | ||
|
|
f6ee99801d | ||
|
|
7fc7412f18 | ||
|
|
dae381e07c | ||
|
|
7dbca4117c | ||
|
|
331a18e9c7 | ||
|
|
f7551166c5 | ||
|
|
b6ba554386 | ||
|
|
aabf14aaf9 | ||
|
|
9226558aa0 | ||
|
|
d9e95c823a | ||
|
|
a058a6e923 | ||
|
|
5913328a53 | ||
|
|
71a3b0a400 | ||
|
|
eb603b50a9 | ||
|
|
b9c09f58d3 | ||
|
|
83356b9a9c | ||
|
|
a82cf7d2a1 | ||
|
|
4457117fa9 |
No files matched your search
@@ -47,12 +47,6 @@ RCON_PORT=3001
|
|||||||
# Falls back to NEXT_PUBLIC_APP_URL/imaging when only the app URL is set.
|
# Falls back to NEXT_PUBLIC_APP_URL/imaging when only the app URL is set.
|
||||||
# NEXT_PUBLIC_IMAGER_URL=https://epicnabbo.nl/imaging
|
# NEXT_PUBLIC_IMAGER_URL=https://epicnabbo.nl/imaging
|
||||||
|
|
||||||
# Optional OAuth (enabled when both id+secret are set)
|
|
||||||
DISCORD_CLIENT_ID=
|
|
||||||
DISCORD_CLIENT_SECRET=
|
|
||||||
GOOGLE_CLIENT_ID=
|
|
||||||
GOOGLE_CLIENT_SECRET=
|
|
||||||
|
|
||||||
# Preferred email provider (HTTP API). Used before SMTP when set.
|
# Preferred email provider (HTTP API). Used before SMTP when set.
|
||||||
RESEND_API_KEY=
|
RESEND_API_KEY=
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ jobs:
|
|||||||
git checkout -f "${REF}"
|
git checkout -f "${REF}"
|
||||||
|
|
||||||
export SKIP_ENV_VALIDATION=1
|
export SKIP_ENV_VALIDATION=1
|
||||||
|
export ARGON2_MEMORY_SIZE=1024
|
||||||
|
export ARGON2_ITERATIONS=1
|
||||||
|
export BCRYPT_ROUNDS=4
|
||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
pnpm prisma:generate
|
pnpm prisma:generate
|
||||||
pnpm biome:lint
|
pnpm biome:lint
|
||||||
|
|||||||
@@ -241,6 +241,7 @@ jobs:
|
|||||||
CUTOVER_STARTED=0
|
CUTOVER_STARTED=0
|
||||||
|
|
||||||
error_handler() {
|
error_handler() {
|
||||||
|
cd /var/www/atom-nexst 2>/dev/null || cd / || true
|
||||||
echo "!!! DEPLOYMENT FAILED on line $1 !!!" >&2
|
echo "!!! DEPLOYMENT FAILED on line $1 !!!" >&2
|
||||||
# Roll back the build artifact if cutover already moved .next into place.
|
# Roll back the build artifact if cutover already moved .next into place.
|
||||||
if [ "${CUTOVER_STARTED}" = "1" ] && [ -d "${LIVE}/.next.prev" ]; then
|
if [ "${CUTOVER_STARTED}" = "1" ] && [ -d "${LIVE}/.next.prev" ]; then
|
||||||
@@ -251,7 +252,7 @@ jobs:
|
|||||||
if [ -n "${STAGE}" ] && [ -d "${STAGE}" ]; then
|
if [ -n "${STAGE}" ] && [ -d "${STAGE}" ]; then
|
||||||
git -C "${LIVE}" worktree remove --force "${STAGE}" 2>/dev/null || rm -rf "${STAGE}" || true
|
git -C "${LIVE}" worktree remove --force "${STAGE}" 2>/dev/null || rm -rf "${STAGE}" || true
|
||||||
fi
|
fi
|
||||||
sudo systemctl start atom-nexst.service || true
|
pm2 restart next --update-env 2>/dev/null || pm2 start pnpm --name "next" -- start 2>/dev/null || true
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
trap 'error_handler $LINENO' ERR
|
trap 'error_handler $LINENO' ERR
|
||||||
@@ -260,7 +261,7 @@ jobs:
|
|||||||
|
|
||||||
DEPLOY_USER="$(id -un)"
|
DEPLOY_USER="$(id -un)"
|
||||||
DEPLOY_GROUP="$(id -gn)"
|
DEPLOY_GROUP="$(id -gn)"
|
||||||
sudo chown -R "${DEPLOY_USER}:${DEPLOY_GROUP}" "${LIVE}"
|
sudo chown -R "${DEPLOY_USER}:${DEPLOY_GROUP}" "${LIVE}" 2>/dev/null || true
|
||||||
git config --global --add safe.directory "${LIVE}"
|
git config --global --add safe.directory "${LIVE}"
|
||||||
git -C "${LIVE}" remote set-url origin /docker/gitea/gitea/git/repositories/remco/epicnext-cms.git/
|
git -C "${LIVE}" remote set-url origin /docker/gitea/gitea/git/repositories/remco/epicnext-cms.git/
|
||||||
|
|
||||||
@@ -298,6 +299,13 @@ jobs:
|
|||||||
find . -maxdepth 3 -name '*.tsbuildinfo' -delete 2>/dev/null || true
|
find . -maxdepth 3 -name '*.tsbuildinfo' -delete 2>/dev/null || true
|
||||||
rm -rf .output dist .next .next/types .next/dev
|
rm -rf .output dist .next .next/types .next/dev
|
||||||
|
|
||||||
|
# Restore build cache from last deploy so Turbopack can do
|
||||||
|
# incremental compilation (much faster rebuilds).
|
||||||
|
if [ -d "${LIVE}/.next/cache" ]; then
|
||||||
|
mkdir -p .next/cache
|
||||||
|
cp -r "${LIVE}/.next/cache/." .next/cache/
|
||||||
|
fi
|
||||||
|
|
||||||
# Stage shares MySQL with the live app + emulator. Keep the stage pool
|
# Stage shares MySQL with the live app + emulator. Keep the stage pool
|
||||||
# tiny so install/test/build cannot exhaust max_connections.
|
# tiny so install/test/build cannot exhaust max_connections.
|
||||||
export DATABASE_POOL_SIZE="${DEPLOY_DATABASE_POOL_SIZE:-5}"
|
export DATABASE_POOL_SIZE="${DEPLOY_DATABASE_POOL_SIZE:-5}"
|
||||||
@@ -306,6 +314,7 @@ jobs:
|
|||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
# prisma generate does not need a live DB connection.
|
# prisma generate does not need a live DB connection.
|
||||||
pnpm prisma:generate
|
pnpm prisma:generate
|
||||||
|
export ARGON2_MEMORY_SIZE=1024 ARGON2_ITERATIONS=1 BCRYPT_ROUNDS=4
|
||||||
pnpm typecheck
|
pnpm typecheck
|
||||||
pnpm test
|
pnpm test
|
||||||
# Validate production env (AUTH_SECRET, DATABASE_URL, …) during build.
|
# Validate production env (AUTH_SECRET, DATABASE_URL, …) during build.
|
||||||
@@ -319,22 +328,21 @@ jobs:
|
|||||||
|
|
||||||
echo "Cutover: stop service (free DB connections), migrate, swap .next..."
|
echo "Cutover: stop service (free DB connections), migrate, swap .next..."
|
||||||
CUTOVER_STARTED=1
|
CUTOVER_STARTED=1
|
||||||
sudo systemctl stop atom-nexst.service || true
|
pm2 stop next --kill-timeout 10000 || true
|
||||||
pkill -f 'next-server' || true
|
# Wait for PM2 to fully exit and MariaDB to reclaim connections.
|
||||||
# Give MariaDB a moment to reclaim the live pool.
|
sleep 10
|
||||||
sleep 2
|
|
||||||
|
|
||||||
# Migrate only after live is stopped — avoids ER_CON_COUNT_ERROR while
|
# Migrate only after live is stopped — avoids ER_CON_COUNT_ERROR while
|
||||||
# the old process still holds DATABASE_POOL_SIZE connections.
|
# the old process still holds DATABASE_POOL_SIZE connections.
|
||||||
cd "${STAGE}"
|
cd "${STAGE}"
|
||||||
MIGRATE_OK=0
|
MIGRATE_OK=0
|
||||||
for i in 1 2 3 4 5; do
|
for i in $(seq 1 10); do
|
||||||
if pnpm db:migrate; then
|
if pnpm db:migrate; then
|
||||||
MIGRATE_OK=1
|
MIGRATE_OK=1
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
echo "migrate attempt ${i}/5 failed (likely DB connections), retrying..."
|
echo "migrate attempt ${i}/10 failed (likely DB connections), retrying..."
|
||||||
sleep 3
|
sleep 5
|
||||||
done
|
done
|
||||||
if [ "${MIGRATE_OK}" != "1" ]; then
|
if [ "${MIGRATE_OK}" != "1" ]; then
|
||||||
echo "ERROR: db:migrate failed after retries" >&2
|
echo "ERROR: db:migrate failed after retries" >&2
|
||||||
@@ -356,8 +364,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
echo "Verified live src/ matches HEAD"
|
echo "Verified live src/ matches HEAD"
|
||||||
|
|
||||||
# Atomic-ish artifact swap: keep previous .next until the new one is in place.
|
# Save current .next as backup before swapping (kept until health check passes).
|
||||||
rm -rf .next.prev
|
|
||||||
if [ -d .next ]; then
|
if [ -d .next ]; then
|
||||||
mv .next .next.prev
|
mv .next .next.prev
|
||||||
fi
|
fi
|
||||||
@@ -370,14 +377,15 @@ jobs:
|
|||||||
# Prisma client is gitignored — regenerate into live src/generated.
|
# Prisma client is gitignored — regenerate into live src/generated.
|
||||||
pnpm prisma:generate
|
pnpm prisma:generate
|
||||||
|
|
||||||
sudo chown -R www-data:www-data "${LIVE}"
|
sudo chown -R www-data:www-data "${LIVE}" 2>/dev/null || true
|
||||||
|
|
||||||
echo "Starting systemd service..."
|
echo "Starting PM2 (zero-downtime reload)..."
|
||||||
sudo systemctl start atom-nexst.service
|
pm2 reload next --update-env || pm2 start next --update-env
|
||||||
|
|
||||||
sleep 2
|
sleep 3
|
||||||
if ! systemctl is-active --quiet atom-nexst.service; then
|
if ! pm2 show next 2>/dev/null | grep -q 'online'; then
|
||||||
echo "ERROR: atom-nexst.service failed to start!" >&2
|
echo "ERROR: PM2 next failed to start!" >&2
|
||||||
|
pm2 logs next --lines 20 --nostream >&2 || true
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -397,11 +405,11 @@ jobs:
|
|||||||
if [ "${HEALTH_OK}" != "1" ]; then
|
if [ "${HEALTH_OK}" != "1" ]; then
|
||||||
echo "ERROR: Health check failed after deploy (${HEALTH_URL})" >&2
|
echo "ERROR: Health check failed after deploy (${HEALTH_URL})" >&2
|
||||||
echo "Last body: ${BODY:-<empty>}" >&2
|
echo "Last body: ${BODY:-<empty>}" >&2
|
||||||
journalctl -u atom-nexst.service -n 40 --no-pager >&2 || true
|
pm2 logs next --lines 40 --nostream >&2 || true
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Cleaning stage worktree and previous .next..."
|
echo "Cleaning stage worktree and previous .next backup..."
|
||||||
rm -rf "${LIVE}/.next.prev"
|
rm -rf "${LIVE}/.next.prev"
|
||||||
git -C "${LIVE}" worktree remove --force "${STAGE}" 2>/dev/null || rm -rf "${STAGE}" || true
|
git -C "${LIVE}" worktree remove --force "${STAGE}" 2>/dev/null || rm -rf "${STAGE}" || true
|
||||||
STAGE=""
|
STAGE=""
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
name: Renovate
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 5 * * *" # Every day at 05:00 UTC
|
||||||
|
workflow_dispatch: # Manual trigger
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
renovate:
|
||||||
|
runs-on: shell
|
||||||
|
steps:
|
||||||
|
- name: Self-hosted Renovate
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Zorg ervoor dat de cache-map lokaal bestaat vóór Docker start
|
||||||
|
# Dit voorkomt dat Docker de map automatisch als 'root' aanmaakt
|
||||||
|
mkdir -p /var/tmp/renovate-cache
|
||||||
|
|
||||||
|
docker run --rm \
|
||||||
|
--user "$(id -u):$(id -g)" \
|
||||||
|
-e RENOVATE_TOKEN="${{ secrets.RENOVATE_TOKEN }}" \
|
||||||
|
-e RENOVATE_AUTODISCOVER=false \
|
||||||
|
-e RENOVATE_REPOSITORIES="${{ gitea.repository }}" \
|
||||||
|
-e RENOVATE_ONBOARDING=false \
|
||||||
|
-e RENOVATE_CONFIG_FILE='{"extends":["config:recommended"]}' \
|
||||||
|
-e LOG_LEVEL=info \
|
||||||
|
-v /var/tmp/renovate-cache:/tmp/renovate-cache \
|
||||||
|
ghcr.io/renovatebot/renovate:latest
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
.turbo/
|
.turbo/
|
||||||
.next/
|
.next/
|
||||||
|
.next.prev/
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
.env
|
.env
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
@@ -23,3 +24,6 @@ gitea
|
|||||||
|
|
||||||
# Runtime uploaded media (persistent, outside public/)
|
# Runtime uploaded media (persistent, outside public/)
|
||||||
storage/
|
storage/
|
||||||
|
|
||||||
|
# Test coverage reports
|
||||||
|
coverage/
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"semi": true,
|
|
||||||
"singleQuote": false,
|
|
||||||
"tabWidth": 2,
|
|
||||||
"trailingComma": "all",
|
|
||||||
"printWidth": 110,
|
|
||||||
"arrowParens": "always",
|
|
||||||
"endOfLine": "lf"
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,438 @@
|
|||||||
|
Attribution-NonCommercial-ShareAlike 4.0 International
|
||||||
|
|
||||||
|
=======================================================================
|
||||||
|
|
||||||
|
Creative Commons Corporation ("Creative Commons") is not a law firm and
|
||||||
|
does not provide legal services or legal advice. Distribution of
|
||||||
|
Creative Commons public licenses does not create a lawyer-client or
|
||||||
|
other relationship. Creative Commons makes its licenses and related
|
||||||
|
information provided on an "as-is" basis. Creative Commons gives no
|
||||||
|
warranties regarding its licenses, any material licensed under their
|
||||||
|
terms and conditions, or any related information. Creative Commons
|
||||||
|
disclaims all liability for damages resulting from their use to the
|
||||||
|
fullest extent possible.
|
||||||
|
|
||||||
|
Using Creative Commons Public Licenses
|
||||||
|
|
||||||
|
Creative Commons public licenses provide a standard set of terms and
|
||||||
|
conditions that creators and other rights holders may use to share
|
||||||
|
original works of authorship and other material subject to copyright
|
||||||
|
and certain other rights specified in the public license below. The
|
||||||
|
following considerations are for informational purposes only, are not
|
||||||
|
exhaustive, and do not form part of our licenses.
|
||||||
|
|
||||||
|
Considerations for licensors: Our public licenses are
|
||||||
|
intended for use by those authorized to give the public
|
||||||
|
permission to use material in ways otherwise restricted by
|
||||||
|
copyright and certain other rights. Our licenses are
|
||||||
|
irrevocable. Licensors should read and understand the terms
|
||||||
|
and conditions of the license they choose before applying it.
|
||||||
|
Licensors should also secure all rights necessary before
|
||||||
|
applying our licenses so that the public can reuse the
|
||||||
|
material as expected. Licensors should clearly mark any
|
||||||
|
material not subject to the license. This includes other CC-
|
||||||
|
licensed material, or material used under an exception or
|
||||||
|
limitation to copyright. More considerations for licensors:
|
||||||
|
wiki.creativecommons.org/Considerations_for_licensors
|
||||||
|
|
||||||
|
Considerations for the public: By using one of our public
|
||||||
|
licenses, a licensor grants the public permission to use the
|
||||||
|
licensed material under specified terms and conditions. If
|
||||||
|
the licensor's permission is not necessary for any reason--for
|
||||||
|
example, because of any applicable exception or limitation to
|
||||||
|
copyright--then that use is not regulated by the license. Our
|
||||||
|
licenses grant only permissions under copyright and certain
|
||||||
|
other rights that a licensor has authority to grant. Use of
|
||||||
|
the licensed material may still be restricted for other
|
||||||
|
reasons, including because others have copyright or other
|
||||||
|
rights in the material. A licensor may make special requests,
|
||||||
|
such as asking that all changes be marked or described.
|
||||||
|
Although not required by our licenses, you are encouraged to
|
||||||
|
respect those requests where reasonable. More considerations
|
||||||
|
for the public:
|
||||||
|
wiki.creativecommons.org/Considerations_for_licensees
|
||||||
|
|
||||||
|
=======================================================================
|
||||||
|
|
||||||
|
Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International
|
||||||
|
Public License
|
||||||
|
|
||||||
|
By exercising the Licensed Rights (defined below), You accept and agree
|
||||||
|
to be bound by the terms and conditions of this Creative Commons
|
||||||
|
Attribution-NonCommercial-ShareAlike 4.0 International Public License
|
||||||
|
("Public License"). To the extent this Public License may be
|
||||||
|
interpreted as a contract, You are granted the Licensed Rights in
|
||||||
|
consideration of Your acceptance of these terms and conditions, and the
|
||||||
|
Licensor grants You such rights in consideration of benefits the
|
||||||
|
Licensor receives from making the Licensed Material available under
|
||||||
|
these terms and conditions.
|
||||||
|
|
||||||
|
|
||||||
|
Section 1 -- Definitions.
|
||||||
|
|
||||||
|
a. Adapted Material means material subject to Copyright and Similar
|
||||||
|
Rights that is derived from or based upon the Licensed Material
|
||||||
|
and in which the Licensed Material is translated, altered,
|
||||||
|
arranged, transformed, or otherwise modified in a manner requiring
|
||||||
|
permission under the Copyright and Similar Rights held by the
|
||||||
|
Licensor. For purposes of this Public License, where the Licensed
|
||||||
|
Material is a musical work, performance, or sound recording,
|
||||||
|
Adapted Material is always produced where the Licensed Material is
|
||||||
|
synched in timed relation with a moving image.
|
||||||
|
|
||||||
|
b. Adapter's License means the license You apply to Your Copyright
|
||||||
|
and Similar Rights in Your contributions to Adapted Material in
|
||||||
|
accordance with the terms and conditions of this Public License.
|
||||||
|
|
||||||
|
c. BY-NC-SA Compatible License means a license listed at
|
||||||
|
creativecommons.org/compatiblelicenses, approved by Creative
|
||||||
|
Commons as essentially the equivalent of this Public License.
|
||||||
|
|
||||||
|
d. Copyright and Similar Rights means copyright and/or similar rights
|
||||||
|
closely related to copyright including, without limitation,
|
||||||
|
performance, broadcast, sound recording, and Sui Generis Database
|
||||||
|
Rights, without regard to how the rights are labeled or
|
||||||
|
categorized. For purposes of this Public License, the rights
|
||||||
|
specified in Section 2(b)(1)-(2) are not Copyright and Similar
|
||||||
|
Rights.
|
||||||
|
|
||||||
|
e. Effective Technological Measures means those measures that, in the
|
||||||
|
absence of proper authority, may not be circumvented under laws
|
||||||
|
fulfilling obligations under Article 11 of the WIPO Copyright
|
||||||
|
Treaty adopted on December 20, 1996, and/or similar international
|
||||||
|
agreements.
|
||||||
|
|
||||||
|
f. Exceptions and Limitations means fair use, fair dealing, and/or
|
||||||
|
any other exception or limitation to Copyright and Similar Rights
|
||||||
|
that applies to Your use of the Licensed Material.
|
||||||
|
|
||||||
|
g. License Elements means the license attributes listed in the name
|
||||||
|
of a Creative Commons Public License. The License Elements of this
|
||||||
|
Public License are Attribution, NonCommercial, and ShareAlike.
|
||||||
|
|
||||||
|
h. Licensed Material means the artistic or literary work, database,
|
||||||
|
or other material to which the Licensor applied this Public
|
||||||
|
License.
|
||||||
|
|
||||||
|
i. Licensed Rights means the rights granted to You subject to the
|
||||||
|
terms and conditions of this Public License, which are limited to
|
||||||
|
all Copyright and Similar Rights that apply to Your use of the
|
||||||
|
Licensed Material and that the Licensor has authority to license.
|
||||||
|
|
||||||
|
j. Licensor means the individual(s) or entity(ies) granting rights
|
||||||
|
under this Public License.
|
||||||
|
|
||||||
|
k. NonCommercial means not primarily intended for or directed towards
|
||||||
|
commercial advantage or monetary compensation. For purposes of
|
||||||
|
this Public License, the exchange of the Licensed Material for
|
||||||
|
other material subject to Copyright and Similar Rights by digital
|
||||||
|
file-sharing or similar means is NonCommercial provided there is
|
||||||
|
no payment of monetary compensation in connection with the
|
||||||
|
exchange.
|
||||||
|
|
||||||
|
l. Share means to provide material to the public by any means or
|
||||||
|
process that requires permission under the Licensed Rights, such
|
||||||
|
as reproduction, public display, public performance, distribution,
|
||||||
|
dissemination, communication, or importation, and to make material
|
||||||
|
available to the public including in ways that members of the
|
||||||
|
public may access the material from a place and at a time
|
||||||
|
individually chosen by them.
|
||||||
|
|
||||||
|
m. Sui Generis Database Rights means rights other than copyright
|
||||||
|
resulting from Directive 96/9/EC of the European Parliament and of
|
||||||
|
the Council of 11 March 1996 on the legal protection of databases,
|
||||||
|
as amended and/or succeeded, as well as other essentially
|
||||||
|
equivalent rights anywhere in the world.
|
||||||
|
|
||||||
|
n. You means the individual or entity exercising the Licensed Rights
|
||||||
|
under this Public License. Your has a corresponding meaning.
|
||||||
|
|
||||||
|
|
||||||
|
Section 2 -- Scope.
|
||||||
|
|
||||||
|
a. License grant.
|
||||||
|
|
||||||
|
1. Subject to the terms and conditions of this Public License,
|
||||||
|
the Licensor hereby grants You a worldwide, royalty-free,
|
||||||
|
non-sublicensable, non-exclusive, irrevocable license to
|
||||||
|
exercise the Licensed Rights in the Licensed Material to:
|
||||||
|
|
||||||
|
a. reproduce and Share the Licensed Material, in whole or
|
||||||
|
in part, for NonCommercial purposes only; and
|
||||||
|
|
||||||
|
b. produce, reproduce, and Share Adapted Material for
|
||||||
|
NonCommercial purposes only.
|
||||||
|
|
||||||
|
2. Exceptions and Limitations. For the avoidance of doubt, where
|
||||||
|
Exceptions and Limitations apply to Your use, this Public
|
||||||
|
License does not apply, and You do not need to comply with
|
||||||
|
its terms and conditions.
|
||||||
|
|
||||||
|
3. Term. The term of this Public License is specified in Section
|
||||||
|
6(a).
|
||||||
|
|
||||||
|
4. Media and formats; technical modifications allowed. The
|
||||||
|
Licensor authorizes You to exercise the Licensed Rights in
|
||||||
|
all media and formats whether now known or hereafter created,
|
||||||
|
and to make technical modifications necessary to do so. The
|
||||||
|
Licensor waives and/or agrees not to assert any right or
|
||||||
|
authority to forbid You from making technical modifications
|
||||||
|
necessary to exercise the Licensed Rights, including
|
||||||
|
technical modifications necessary to circumvent Effective
|
||||||
|
Technological Measures. For purposes of this Public License,
|
||||||
|
simply making modifications authorized by this Section 2(a)
|
||||||
|
(4) never produces Adapted Material.
|
||||||
|
|
||||||
|
5. Downstream recipients.
|
||||||
|
|
||||||
|
a. Offer from the Licensor -- Licensed Material. Every
|
||||||
|
recipient of the Licensed Material automatically
|
||||||
|
receives an offer from the Licensor to exercise the
|
||||||
|
Licensed Rights under the terms and conditions of this
|
||||||
|
Public License.
|
||||||
|
|
||||||
|
b. Additional offer from the Licensor -- Adapted Material.
|
||||||
|
Every recipient of Adapted Material from You
|
||||||
|
automatically receives an offer from the Licensor to
|
||||||
|
exercise the Licensed Rights in the Adapted Material
|
||||||
|
under the conditions of the Adapter's License You apply.
|
||||||
|
|
||||||
|
c. No downstream restrictions. You may not offer or impose
|
||||||
|
any additional or different terms or conditions on, or
|
||||||
|
apply any Effective Technological Measures to, the
|
||||||
|
Licensed Material if doing so restricts exercise of the
|
||||||
|
Licensed Rights by any recipient.
|
||||||
|
|
||||||
|
6. No endorsement. Nothing in this Public License constitutes or
|
||||||
|
may be construed as permission to assert or imply that You
|
||||||
|
are, or that Your use of the Licensed Material is, connected
|
||||||
|
with, or sponsored, endorsed, or granted official status by,
|
||||||
|
the Licensor or others designated to receive attribution as
|
||||||
|
provided in Section 3(a)(1)(A)(i).
|
||||||
|
|
||||||
|
b. Other rights.
|
||||||
|
|
||||||
|
1. Moral rights, such as the right of integrity, are not
|
||||||
|
licensed under this Public License, nor are publicity,
|
||||||
|
privacy, and/or other similar personality rights; however, to
|
||||||
|
the extent possible, the Licensor waives and/or agrees not to
|
||||||
|
assert any such rights held by the Licensor to the limited
|
||||||
|
extent necessary to allow You to exercise the Licensed
|
||||||
|
Rights, but not otherwise.
|
||||||
|
|
||||||
|
2. Patent and trademark rights are not licensed under this
|
||||||
|
Public License.
|
||||||
|
|
||||||
|
3. To the extent possible, the Licensor waives any right to
|
||||||
|
collect royalties from You for the exercise of the Licensed
|
||||||
|
Rights, whether directly or through a collecting society
|
||||||
|
under any voluntary or waivable statutory or compulsory
|
||||||
|
licensing scheme. In all other cases the Licensor expressly
|
||||||
|
reserves any right to collect such royalties, including when
|
||||||
|
the Licensed Material is used other than for NonCommercial
|
||||||
|
purposes.
|
||||||
|
|
||||||
|
|
||||||
|
Section 3 -- License Conditions.
|
||||||
|
|
||||||
|
Your exercise of the Licensed Rights is expressly made subject to the
|
||||||
|
following conditions.
|
||||||
|
|
||||||
|
a. Attribution.
|
||||||
|
|
||||||
|
1. If You Share the Licensed Material (including in modified
|
||||||
|
form), You must:
|
||||||
|
|
||||||
|
a. retain the following if it is supplied by the Licensor
|
||||||
|
with the Licensed Material:
|
||||||
|
|
||||||
|
i. identification of the creator(s) of the Licensed
|
||||||
|
Material and any others designated to receive
|
||||||
|
attribution, in any reasonable manner requested by
|
||||||
|
the Licensor (including by pseudonym if
|
||||||
|
designated);
|
||||||
|
|
||||||
|
ii. a copyright notice;
|
||||||
|
|
||||||
|
iii. a notice that refers to this Public License;
|
||||||
|
|
||||||
|
iv. a notice that refers to the disclaimer of
|
||||||
|
warranties;
|
||||||
|
|
||||||
|
v. a URI or hyperlink to the Licensed Material to the
|
||||||
|
extent reasonably practicable;
|
||||||
|
|
||||||
|
b. indicate if You modified the Licensed Material and
|
||||||
|
retain an indication of any previous modifications; and
|
||||||
|
|
||||||
|
c. indicate the Licensed Material is licensed under this
|
||||||
|
Public License, and include the text of, or the URI or
|
||||||
|
hyperlink to, this Public License.
|
||||||
|
|
||||||
|
2. You may satisfy the conditions in Section 3(a)(1) in any
|
||||||
|
reasonable manner based on the medium, means, and context in
|
||||||
|
which You Share the Licensed Material. For example, it may be
|
||||||
|
reasonable to satisfy the conditions by providing a URI or
|
||||||
|
hyperlink to a resource that includes the required
|
||||||
|
information.
|
||||||
|
|
||||||
|
3. If requested by the Licensor, You must remove any of the
|
||||||
|
information required by Section 3(a)(1)(A) to the extent
|
||||||
|
reasonably practicable.
|
||||||
|
|
||||||
|
b. ShareAlike.
|
||||||
|
|
||||||
|
In addition to the conditions in Section 3(a), if You Share
|
||||||
|
Adapted Material You produce, the following conditions also apply.
|
||||||
|
|
||||||
|
1. The Adapter's License You apply must be a Creative Commons
|
||||||
|
license with the same License Elements, this version or
|
||||||
|
later, or a BY-NC-SA Compatible License.
|
||||||
|
|
||||||
|
2. You must include the text of, or the URI or hyperlink to, the
|
||||||
|
Adapter's License You apply. You may satisfy this condition
|
||||||
|
in any reasonable manner based on the medium, means, and
|
||||||
|
context in which You Share Adapted Material.
|
||||||
|
|
||||||
|
3. You may not offer or impose any additional or different terms
|
||||||
|
or conditions on, or apply any Effective Technological
|
||||||
|
Measures to, Adapted Material that restrict exercise of the
|
||||||
|
rights granted under the Adapter's License You apply.
|
||||||
|
|
||||||
|
|
||||||
|
Section 4 -- Sui Generis Database Rights.
|
||||||
|
|
||||||
|
Where the Licensed Rights include Sui Generis Database Rights that
|
||||||
|
apply to Your use of the Licensed Material:
|
||||||
|
|
||||||
|
a. for the avoidance of doubt, Section 2(a)(1) grants You the right
|
||||||
|
to extract, reuse, reproduce, and Share all or a substantial
|
||||||
|
portion of the contents of the database for NonCommercial purposes
|
||||||
|
only;
|
||||||
|
|
||||||
|
b. if You include all or a substantial portion of the database
|
||||||
|
contents in a database in which You have Sui Generis Database
|
||||||
|
Rights, then the database in which You have Sui Generis Database
|
||||||
|
Rights (but not its individual contents) is Adapted Material,
|
||||||
|
|
||||||
|
including for purposes of Section 3(b); and
|
||||||
|
c. You must comply with the conditions in Section 3(a) if You Share
|
||||||
|
all or a substantial portion of the contents of the database.
|
||||||
|
|
||||||
|
For the avoidance of doubt, this Section 4 supplements and does not
|
||||||
|
replace Your obligations under this Public License where the Licensed
|
||||||
|
Rights include other Copyright and Similar Rights.
|
||||||
|
|
||||||
|
|
||||||
|
Section 5 -- Disclaimer of Warranties and Limitation of Liability.
|
||||||
|
|
||||||
|
a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE
|
||||||
|
EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS
|
||||||
|
AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF
|
||||||
|
ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS,
|
||||||
|
IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION,
|
||||||
|
WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS,
|
||||||
|
ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT
|
||||||
|
KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT
|
||||||
|
FULLY ALLOWED IN PART OR IN WHOLE, THIS DISCLAIMER MAY NOT APPLY
|
||||||
|
TO YOU.
|
||||||
|
|
||||||
|
b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE
|
||||||
|
TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION,
|
||||||
|
NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT,
|
||||||
|
INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES,
|
||||||
|
COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR
|
||||||
|
USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN
|
||||||
|
ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR
|
||||||
|
DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT FULLY ALLOWED IN
|
||||||
|
PART OR IN WHOLE, THIS LIMITATION MAY NOT APPLY TO YOU.
|
||||||
|
|
||||||
|
c. The disclaimer of warranties and limitation of liability provided
|
||||||
|
above shall be interpreted in a manner that, to the extent
|
||||||
|
possible, most closely approximates an absolute disclaimer and
|
||||||
|
waiver of all liability.
|
||||||
|
|
||||||
|
|
||||||
|
Section 6 -- Term and Termination.
|
||||||
|
|
||||||
|
a. This Public License applies for the term of the Copyright and
|
||||||
|
Similar Rights licensed here. However, if You fail to comply with
|
||||||
|
this Public License, then Your rights under this Public License
|
||||||
|
terminate automatically.
|
||||||
|
|
||||||
|
b. Where Your right to use the Licensed Material has terminated under
|
||||||
|
Section 6(a), it reinstates:
|
||||||
|
|
||||||
|
1. automatically as of the date the violation is cured, provided
|
||||||
|
it is cured within 30 days of Your discovery of the
|
||||||
|
violation; or
|
||||||
|
|
||||||
|
2. upon express reinstatement by the Licensor.
|
||||||
|
|
||||||
|
For the avoidance of doubt, this Section 6(b) does not affect any
|
||||||
|
right the Licensor may have to seek remedies for Your violations
|
||||||
|
of this Public License.
|
||||||
|
|
||||||
|
c. For the avoidance of doubt, the Licensor may also offer the
|
||||||
|
Licensed Material under separate terms or conditions or stop
|
||||||
|
distributing the Licensed Material at any time; however, doing so
|
||||||
|
will not terminate this Public License.
|
||||||
|
|
||||||
|
d. Sections 1, 5, 6, 7, and 8 survive termination of this Public
|
||||||
|
License.
|
||||||
|
|
||||||
|
|
||||||
|
Section 7 -- Other Terms and Conditions.
|
||||||
|
|
||||||
|
a. The Licensor shall not be bound by any additional or different
|
||||||
|
terms or conditions communicated by You unless expressly agreed.
|
||||||
|
|
||||||
|
b. Any arrangements, understandings, or agreements regarding the
|
||||||
|
Licensed Material not stated herein are separate from and
|
||||||
|
independent of the terms and conditions of this Public License.
|
||||||
|
|
||||||
|
|
||||||
|
Section 8 -- Interpretation.
|
||||||
|
|
||||||
|
a. For the avoidance of doubt, this Public License does not, and
|
||||||
|
shall not be interpreted to, reduce, limit, restrict, or impose
|
||||||
|
conditions on any use of the Licensed Material that could lawfully
|
||||||
|
be made without permission under this Public License.
|
||||||
|
|
||||||
|
b. To the extent possible, if any provision of this Public License is
|
||||||
|
deemed unenforceable, it shall be automatically reformed to the
|
||||||
|
minimum extent necessary to make it enforceable. If the provision
|
||||||
|
cannot be reformed, it shall be severed from this Public License
|
||||||
|
without affecting the enforceability of the remaining terms and
|
||||||
|
conditions.
|
||||||
|
|
||||||
|
c. No term or condition of this Public License will be waived and no
|
||||||
|
failure to comply consented to unless expressly agreed to by the
|
||||||
|
Licensor.
|
||||||
|
|
||||||
|
d. Nothing in this Public License constitutes or may be interpreted
|
||||||
|
as a limitation upon, or waiver of, any privileges and immunities
|
||||||
|
that apply to the Licensor or You, including from the legal
|
||||||
|
processes of any jurisdiction or authority.
|
||||||
|
|
||||||
|
=======================================================================
|
||||||
|
|
||||||
|
Creative Commons is not a party to its public
|
||||||
|
licenses. Notwithstanding, Creative Commons may elect to apply one of
|
||||||
|
its public licenses to material it publishes and in those instances
|
||||||
|
will be considered the "Licensor." The text of the Creative Commons
|
||||||
|
public licenses is dedicated to the public domain under the CC0 Public
|
||||||
|
Domain Dedication. Except for the limited purpose of indicating that
|
||||||
|
material is shared under a Creative Commons public license or as
|
||||||
|
otherwise permitted by the Creative Commons policies published at
|
||||||
|
creativecommons.org/policies, Creative Commons does not authorize the
|
||||||
|
use of the trademark "Creative Commons" or any other trademark or logo
|
||||||
|
of Creative Commons without its prior written consent including
|
||||||
|
without limitation in connection with any unauthorized modifications
|
||||||
|
to any of its public licenses or any other arrangements,
|
||||||
|
understandings, or agreements concerning use of licensed material. For
|
||||||
|
the avoidance of doubt, this paragraph does not form part of the
|
||||||
|
public licenses.
|
||||||
|
|
||||||
|
Creative Commons may be contacted at creativecommons.org.
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
# EpicNext-CMS
|
# EpicNext-CMS v1.0.1
|
||||||
|
|
||||||
A modern, high-performance content management system for Habbo hotel emulators, built on **Next.js 16** (App Router) with **Prisma 7** and **React 19**. Designed to integrate seamlessly with Polaris / Arcturus Morningstar MySQL/MariaDB databases.
|
A modern, high-performance content management system for Habbo hotel emulators, built on **Next.js 16** (App Router) with **Prisma 7** and **React 19**. Designed to integrate seamlessly with Polaris / Arcturus Morningstar MySQL/MariaDB databases.
|
||||||
|
|
||||||
Features a full public-facing website, an administrative panel, NextAuth authentication (argon2id/bcrypt with MD5-to-argon2id upgrade), real-time RCON communication with the emulator, Server-Sent Events for live radio data, smooth page transitions, and extensive extensibility.
|
Features a premium animated homepage (typewriter hero, floating orbs, scroll counters), a full admin panel, NextAuth authentication (argon2id/bcrypt with MD5-to-argon2id upgrade), real-time RCON communication, Server-Sent Events for live radio data, smooth page transitions, and PM2 production deployment.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -98,6 +98,27 @@ Open `http://localhost:3000` in your browser.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Production Deployment (PM2)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm build
|
||||||
|
pm2 start pnpm --name "next" -- start
|
||||||
|
pm2 save
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart after updates:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git pull
|
||||||
|
pnpm install
|
||||||
|
pnpm build
|
||||||
|
pm2 restart next
|
||||||
|
```
|
||||||
|
|
||||||
|
The CMS runs behind an nginx reverse proxy on the default port 3000. Static assets (media uploads) are persisted via `/api/media/*` and survive rebuilds.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Scripts
|
## Scripts
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
@@ -125,13 +146,14 @@ Open `http://localhost:3000` in your browser.
|
|||||||
| **Server-Sent Events** | Real-time radio now-playing & listeners via SSE (no polling) |
|
| **Server-Sent Events** | Real-time radio now-playing & listeners via SSE (no polling) |
|
||||||
| **Redis Caching** | Caches API responses (home, radio config) up to 30s in Redis |
|
| **Redis Caching** | Caches API responses (home, radio config) up to 30s in Redis |
|
||||||
| **Bundle Analyzer** | Run `pnpm analyze` to visualize and optimize bundle sizes |
|
| **Bundle Analyzer** | Run `pnpm analyze` to visualize and optimize bundle sizes |
|
||||||
| **AnimatePresence** | Framer Motion exit animations on modals, dialogs, and lightbox |
|
|
||||||
| **RCON (TCP Socket)** | Live commands to the emulator (credits, badges, kick, ban) |
|
| **RCON (TCP Socket)** | Live commands to the emulator (credits, badges, kick, ban) |
|
||||||
| **Streaming & Suspense** | Next.js App Router streaming for fast page loads |
|
| **Streaming & Suspense** | Next.js App Router streaming for fast page loads |
|
||||||
| **Automatic Image Optimization** | `next/image` with Sharp for resizing and WebP/AVIF |
|
| **Automatic Image Optimization** | `next/image` with Sharp for resizing and WebP/AVIF |
|
||||||
| **CSS-based Animations** | Base UI components use `data-open`/`data-closed` CSS animations |
|
| **CSS-based Animations** | `input-glow`, `btn-shine`, `Reveal` scroll animations, floating orbs |
|
||||||
| **Compression** | Gzip compression enabled on all responses |
|
| **Compression** | Gzip compression enabled on all responses |
|
||||||
| **Stale Times** | Optimized router cache (30s dynamic, 180s static) |
|
| **Stale Times** | Optimized router cache (30s dynamic, 180s static) |
|
||||||
|
| **PWA** | Service worker with skip-waiting, stale CSS chunk recovery |
|
||||||
|
| **Biome** | Fast linting and formatting (replaces ESLint + Prettier) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -158,7 +180,7 @@ Open `http://localhost:3000` in your browser.
|
|||||||
│ │ ├── cache.ts # In-memory cache fallback
|
│ │ ├── cache.ts # In-memory cache fallback
|
||||||
│ │ ├── motion.ts # Framer Motion animation variants
|
│ │ ├── motion.ts # Framer Motion animation variants
|
||||||
│ │ └── use-event-source.ts # React hook for SSE subscriptions
|
│ │ └── use-event-source.ts # React hook for SSE subscriptions
|
||||||
│ ├── messages/ # i18n translations (en, nl, de, fr, es, it)
|
│ ├── messages/ # i18n translations (en, nl, de, fr, es, it, pt, da, no, sv)
|
||||||
│ └── env.ts # Zod-validated environment schema
|
│ └── env.ts # Zod-validated environment schema
|
||||||
├── public/
|
├── public/
|
||||||
│ ├── assets/ # Images, icons, fonts
|
│ ├── assets/ # Images, icons, fonts
|
||||||
@@ -228,7 +250,7 @@ Optional OpenAI-powered moderation for comments and guestbook posts. Set `OPENAI
|
|||||||
```bash
|
```bash
|
||||||
pnpm dev # Start with hot reload
|
pnpm dev # Start with hot reload
|
||||||
pnpm typecheck # Type check all files
|
pnpm typecheck # Type check all files
|
||||||
pnpm test # Run test suite (312+ tests)
|
pnpm test # Run test suite
|
||||||
pnpm analyze # Build and analyze bundle sizes
|
pnpm analyze # Build and analyze bundle sizes
|
||||||
pnpm biome:check # Lint and format
|
pnpm biome:check # Lint and format
|
||||||
```
|
```
|
||||||
|
|||||||
+2
-5
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/2.5.5/schema.json",
|
"$schema": "https://biomejs.dev",
|
||||||
"vcs": {
|
"vcs": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"clientKind": "git",
|
"clientKind": "git",
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
},
|
},
|
||||||
"files": {
|
"files": {
|
||||||
"ignoreUnknown": false,
|
"ignoreUnknown": false,
|
||||||
"includes": ["**", "!setup", "!public"]
|
"includes": ["**", "!setup", "!*.cjs", "!coverage"]
|
||||||
},
|
},
|
||||||
"formatter": {
|
"formatter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
@@ -23,9 +23,6 @@
|
|||||||
"performance": {
|
"performance": {
|
||||||
"noImgElement": "off"
|
"noImgElement": "off"
|
||||||
},
|
},
|
||||||
"a11y": {
|
|
||||||
"noLabelWithoutControl": "off"
|
|
||||||
},
|
|
||||||
"style": {
|
"style": {
|
||||||
"noDescendingSpecificity": "off"
|
"noDescendingSpecificity": "off"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test.describe("Admin panel", () => {
|
||||||
|
test("admin login page redirects unauthenticated users", async ({ page }) => {
|
||||||
|
await page.goto("/admin");
|
||||||
|
await expect(page).toHaveURL(/login/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("admin page has login form", async ({ page }) => {
|
||||||
|
await page.goto("/admin");
|
||||||
|
await expect(page.locator('input[name="username"]')).toBeVisible();
|
||||||
|
await expect(page.locator('input[name="password"]')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test.describe("Authentication flows", () => {
|
||||||
|
test("login form validates required fields", async ({ page }) => {
|
||||||
|
await page.goto("/login");
|
||||||
|
await page.click('button[type="submit"]');
|
||||||
|
await expect(page.locator("text=required")).toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("login with invalid credentials shows error", async ({ page }) => {
|
||||||
|
await page.goto("/login");
|
||||||
|
await page.fill('input[name="username"]', "nonexistent");
|
||||||
|
await page.fill('input[name="password"]', "wrongpassword");
|
||||||
|
await page.click('button[type="submit"]');
|
||||||
|
await expect(page.locator("text=invalid")).toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("register page has password confirmation field", async ({ page }) => {
|
||||||
|
await page.goto("/register");
|
||||||
|
await expect(page.locator('input[name="confirmPassword"]')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("register form validates password match", async ({ page }) => {
|
||||||
|
await page.goto("/register");
|
||||||
|
await page.fill('input[name="password"]', "Password123!");
|
||||||
|
await page.fill('input[name="confirmPassword"]', "DifferentPass123!");
|
||||||
|
await page.click('button[type="submit"]');
|
||||||
|
await expect(page.locator("text=match|komen overeen|kloppen")).toBeVisible({
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import { test, expect } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
test("homepage has title", async ({ page }) => {
|
test("homepage has title", async ({ page }) => {
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test.describe("Public navigation", () => {
|
||||||
|
test("homepage loads with expected elements", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await expect(page.locator("nav")).toBeVisible();
|
||||||
|
await expect(page.locator("footer")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("community page loads", async ({ page }) => {
|
||||||
|
await page.goto("/community");
|
||||||
|
await expect(page).toHaveTitle(/community/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shop page loads", async ({ page }) => {
|
||||||
|
await page.goto("/shop");
|
||||||
|
await expect(page.locator("h1, h2").first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("404 page for unknown routes", async ({ page }) => {
|
||||||
|
const response = await page.goto("/this-page-does-not-exist");
|
||||||
|
expect(response?.status()).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
+28
-2
@@ -1,4 +1,7 @@
|
|||||||
import js from "@eslint/js";
|
import js from "@eslint/js";
|
||||||
|
import nextPlugin from "@next/eslint-plugin-next";
|
||||||
|
import reactHooks from "eslint-plugin-react-hooks";
|
||||||
|
import security from "eslint-plugin-security";
|
||||||
import unusedImports from "eslint-plugin-unused-imports";
|
import unusedImports from "eslint-plugin-unused-imports";
|
||||||
import tseslint from "typescript-eslint";
|
import tseslint from "typescript-eslint";
|
||||||
|
|
||||||
@@ -12,10 +15,33 @@ export default tseslint.config(
|
|||||||
files: ["src/**/*.{ts,tsx}", "pages/**/*.{ts,tsx}", "app/**/*.{ts,tsx}"],
|
files: ["src/**/*.{ts,tsx}", "pages/**/*.{ts,tsx}", "app/**/*.{ts,tsx}"],
|
||||||
plugins: {
|
plugins: {
|
||||||
"unused-imports": unusedImports,
|
"unused-imports": unusedImports,
|
||||||
|
security: security,
|
||||||
|
"react-hooks": reactHooks,
|
||||||
|
"@next/next": nextPlugin,
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
"unused-imports/no-unused-vars": "error",
|
// Laad automatisch alle aanbevolen beveiligings- en framework-regels in
|
||||||
|
...security.configs.recommended.rules,
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
...nextPlugin.configs.recommended.rules,
|
||||||
|
|
||||||
|
// Zorg dat variabelen die beginnen met een underscore (_req) genegeerd worden
|
||||||
|
"@typescript-eslint/no-unused-vars": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
argsIgnorePattern: "^_",
|
||||||
|
varsIgnorePattern: "^_",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"unused-imports/no-unused-vars": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
argsIgnorePattern: "^_",
|
||||||
|
varsIgnorePattern: "^_",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
"no-console": "warn",
|
"no-console": "warn",
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
@@ -5,17 +5,15 @@
|
|||||||
"src/app/**/layout.{ts,tsx}",
|
"src/app/**/layout.{ts,tsx}",
|
||||||
"src/app/**/route.{ts,tsx}",
|
"src/app/**/route.{ts,tsx}",
|
||||||
"src/app/**/{error,not-found,loading,template,default,global-error}.{ts,tsx}",
|
"src/app/**/{error,not-found,loading,template,default,global-error}.{ts,tsx}",
|
||||||
"src/app/**/actions.{ts,tsx}",
|
|
||||||
"next.config.ts",
|
|
||||||
"src/proxy.ts",
|
|
||||||
"src/instrumentation.ts",
|
|
||||||
"sentry.{server,edge}.config.ts"
|
"sentry.{server,edge}.config.ts"
|
||||||
],
|
],
|
||||||
"project": ["src/**/*.{ts,tsx}"],
|
"project": ["src/**/*.{ts,tsx}"],
|
||||||
"ignore": [
|
"ignoreDependencies": [
|
||||||
"src/**/*.test.{ts,tsx}",
|
"tailwindcss-animate",
|
||||||
"scripts/**"
|
"@tailwindcss/forms",
|
||||||
|
"@tailwindcss/typography",
|
||||||
|
"pino-pretty",
|
||||||
|
"tailwindcss"
|
||||||
],
|
],
|
||||||
"ignoreDependencies": ["@types/*", "knip"],
|
"ignoreBinaries": ["sendmail"]
|
||||||
"ignoreBinaries": ["corepack"]
|
|
||||||
}
|
}
|
||||||
File renamed without changes.
+9
-19
@@ -1,7 +1,7 @@
|
|||||||
import type { NextConfig } from "next";
|
|
||||||
import { withSentryConfig } from "@sentry/nextjs";
|
|
||||||
import createNextIntlPlugin from "next-intl/plugin";
|
|
||||||
import withBundleAnalyzer from "@next/bundle-analyzer";
|
import withBundleAnalyzer from "@next/bundle-analyzer";
|
||||||
|
import { withSentryConfig } from "@sentry/nextjs";
|
||||||
|
import type { NextConfig } from "next";
|
||||||
|
import createNextIntlPlugin from "next-intl/plugin";
|
||||||
|
|
||||||
const securityHeaders = [
|
const securityHeaders = [
|
||||||
{ key: "X-DNS-Prefetch-Control", value: "on" },
|
{ key: "X-DNS-Prefetch-Control", value: "on" },
|
||||||
@@ -20,7 +20,7 @@ const securityHeaders = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
turbopack: { root: import.meta.dirname },
|
turbopack: {},
|
||||||
serverExternalPackages: [
|
serverExternalPackages: [
|
||||||
"@prisma/adapter-mariadb",
|
"@prisma/adapter-mariadb",
|
||||||
"mariadb",
|
"mariadb",
|
||||||
@@ -39,18 +39,6 @@ const nextConfig: NextConfig = {
|
|||||||
|
|
||||||
// Disable Next.js telemetry
|
// Disable Next.js telemetry
|
||||||
productionBrowserSourceMaps: false,
|
productionBrowserSourceMaps: false,
|
||||||
output: "standalone",
|
|
||||||
|
|
||||||
// Cache pages longer in the router cache for faster back/forward navigation
|
|
||||||
experimental: {
|
|
||||||
staleTimes: {
|
|
||||||
dynamic: 30,
|
|
||||||
static: 180,
|
|
||||||
},
|
|
||||||
optimizePackageImports: ["lucide-react"],
|
|
||||||
// Keep build-time DB fan-out low (shared MySQL with emulator).
|
|
||||||
staticGenerationMaxConcurrency: 3,
|
|
||||||
},
|
|
||||||
|
|
||||||
// Add caching headers for static assets
|
// Add caching headers for static assets
|
||||||
async headers() {
|
async headers() {
|
||||||
@@ -91,11 +79,12 @@ const withBA = withBundleAnalyzer({
|
|||||||
enabled: process.env.ANALYZE === "true",
|
enabled: process.env.ANALYZE === "true",
|
||||||
});
|
});
|
||||||
|
|
||||||
export default withBA(withSentryConfig(config, {
|
export default withBA(
|
||||||
|
withSentryConfig(config, {
|
||||||
org: process.env.SENTRY_ORG,
|
org: process.env.SENTRY_ORG,
|
||||||
project: process.env.SENTRY_PROJECT,
|
project: process.env.SENTRY_PROJECT,
|
||||||
authToken: sentryAuthToken,
|
authToken: sentryAuthToken,
|
||||||
silent: !process.env.CI,
|
silent: !process.env.CI || !sentryAuthToken,
|
||||||
widenClientFileUpload: true,
|
widenClientFileUpload: true,
|
||||||
sourcemaps: {
|
sourcemaps: {
|
||||||
disable: !sentryAuthToken,
|
disable: !sentryAuthToken,
|
||||||
@@ -104,4 +93,5 @@ export default withBA(withSentryConfig(config, {
|
|||||||
create: Boolean(sentryAuthToken),
|
create: Boolean(sentryAuthToken),
|
||||||
},
|
},
|
||||||
telemetry: false,
|
telemetry: false,
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
+35
-34
@@ -11,7 +11,7 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"prisma:generate": "prisma generate",
|
"prisma:generate": "prisma generate",
|
||||||
"typecheck": "tsc --noEmit --incremental false",
|
"typecheck": "tsc6 --noEmit --incremental false",
|
||||||
"biome:check": "biome check --write .",
|
"biome:check": "biome check --write .",
|
||||||
"biome:lint": "biome lint .",
|
"biome:lint": "biome lint .",
|
||||||
"biome:format": "biome format --write .",
|
"biome:format": "biome format --write .",
|
||||||
@@ -19,8 +19,8 @@
|
|||||||
"analyze": "ANALYZE=true pnpm build",
|
"analyze": "ANALYZE=true pnpm build",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"lint": "eslint . --ext .ts,.tsx --max-warnings=1000",
|
"lint": "eslint . --ext .ts,.tsx --max-warnings=50",
|
||||||
"lint:fix": "eslint . --ext .ts,.tsx --fix --max-warnings=1000",
|
"lint:fix": "eslint . --ext .ts,.tsx --fix --max-warnings=50",
|
||||||
"lhci:collect": "lhci collect",
|
"lhci:collect": "lhci collect",
|
||||||
"lhci:assert": "lhci assert",
|
"lhci:assert": "lhci assert",
|
||||||
"lhci:server": "lhci server",
|
"lhci:server": "lhci server",
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*.{js,jsx,ts,tsx}": [
|
"*.{js,jsx,ts,tsx}": [
|
||||||
"biome check --write",
|
"biome check --write",
|
||||||
"eslint --fix --max-warnings=1000"
|
"eslint --fix --max-warnings=50"
|
||||||
],
|
],
|
||||||
"*.{json,md,css,scss,html}": [
|
"*.{json,md,css,scss,html}": [
|
||||||
"biome format --write"
|
"biome format --write"
|
||||||
@@ -43,11 +43,11 @@
|
|||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@hookform/resolvers": "^5.4.0",
|
"@hookform/resolvers": "^5.5.7",
|
||||||
"@prisma/adapter-mariadb": "^7.9.0",
|
"@prisma/adapter-mariadb": "^7.9.1",
|
||||||
"@prisma/client": "^7.9.0",
|
"@prisma/client": "^7.9.1",
|
||||||
"@sentry/nextjs": "^10.67.0",
|
"@sentry/nextjs": "^10.68.0",
|
||||||
"@tanstack/react-virtual": "^3.14.7",
|
"@tanstack/react-virtual": "^3.14.8",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -60,21 +60,21 @@
|
|||||||
"json5": "^2.2.3",
|
"json5": "^2.2.3",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"lenis": "^1.3.25",
|
"lenis": "^1.3.25",
|
||||||
"lucide-react": "^1.25.0",
|
"lucide-react": "^1.27.0",
|
||||||
"lzma": "^2.3.2",
|
"lzma": "^2.3.2",
|
||||||
"music-metadata": "^11.14.0",
|
"music-metadata": "^11.14.0",
|
||||||
"mysql2": "^3.23.1",
|
"mysql2": "^3.23.2",
|
||||||
"next": "^16.2.11",
|
"next": "^16.2.12",
|
||||||
"next-auth": "5.0.0-beta.32",
|
"next-auth": "5.0.0-beta.32",
|
||||||
"next-intl": "^4.13.3",
|
"next-intl": "^4.13.4",
|
||||||
"next-view-transitions": "^0.3.5",
|
"next-view-transitions": "^0.3.5",
|
||||||
"nodemailer": "^7.0.13",
|
"nodemailer": "^9.0.3",
|
||||||
"otplib": "^13.4.1",
|
"otplib": "^13.4.1",
|
||||||
"pino": "^10.3.1",
|
"pino": "^10.3.1",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
"react-hook-form": "^7.82.0",
|
"react-hook-form": "^7.83.0",
|
||||||
"resend": "^6.18.0",
|
"resend": "^6.18.1",
|
||||||
"sanitize-html": "^2.17.6",
|
"sanitize-html": "^2.17.6",
|
||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
"sharp": "^0.35.3",
|
"sharp": "^0.35.3",
|
||||||
@@ -84,37 +84,38 @@
|
|||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.5.5",
|
"@biomejs/biome": "2.5.6",
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "10.0.1",
|
||||||
"@lhci/cli": "^0.15.1",
|
"@lhci/cli": "^0.15.1",
|
||||||
"@next/bundle-analyzer": "^16.2.11",
|
"@next/bundle-analyzer": "^16.2.12",
|
||||||
"@playwright/test": "^1.61.1",
|
"@next/eslint-plugin-next": "^16.2.12",
|
||||||
|
"@playwright/test": "1.62.0",
|
||||||
"@tailwindcss/forms": "^0.5.11",
|
"@tailwindcss/forms": "^0.5.11",
|
||||||
"@tailwindcss/postcss": "^4.3.3",
|
"@tailwindcss/postcss": "^4.3.3",
|
||||||
"@tailwindcss/typography": "^0.5.20",
|
"@tailwindcss/typography": "^0.5.20",
|
||||||
"@types/bcrypt": "^6.0.0",
|
"@types/bcrypt": "^6.0.0",
|
||||||
"@types/node": "^26.1.1",
|
"@types/node": "^26.1.2",
|
||||||
"@types/nodemailer": "^7.0.12",
|
"@types/nodemailer": "^8.0.1",
|
||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/sanitize-html": "^2.16.1",
|
"@types/sanitize-html": "^2.16.1",
|
||||||
|
"@vitest/coverage-v8": "4.1.10",
|
||||||
"babel-plugin-react-compiler": "^1.0.0",
|
"babel-plugin-react-compiler": "^1.0.0",
|
||||||
"dotenv": "^16.4.0",
|
"dotenv": "^17.4.2",
|
||||||
"eslint": "^10.7.0",
|
"eslint": "10.8.0",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
"eslint-plugin-unused-imports": "^4.4.1",
|
"eslint-plugin-security": "^4.0.1",
|
||||||
|
"eslint-plugin-unused-imports": "4.4.1",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"knip": "^6.27.0",
|
"knip": "^6.29.0",
|
||||||
"lint-staged": "^17.1.0",
|
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
"plausible-tracker": "^0.3.9",
|
"postcss": "^8.5.24",
|
||||||
"postcss": "^8.5.21",
|
"prisma": "^7.9.1",
|
||||||
"prisma": "^7.9.0",
|
|
||||||
"tailwindcss": "^4.3.3",
|
"tailwindcss": "^4.3.3",
|
||||||
"tsx": "^4.23.1",
|
"tsx": "^4.23.1",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "npm:@typescript/typescript6@^6.0.2",
|
||||||
"typescript-eslint": "^8.65.0",
|
"typescript-eslint": "^8.65.0",
|
||||||
"vite": "^8.1.5",
|
"vite": "8.1.5",
|
||||||
"vitest": "^4.1.10"
|
"vitest": "4.1.10"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Generated
+1699
-1540
File diff suppressed because it is too large.
Load diff
+30
-10
@@ -1,15 +1,35 @@
|
|||||||
onlyBuiltDependencies:
|
# pnpm-workspace.yaml
|
||||||
- esbuild
|
|
||||||
- prisma
|
|
||||||
- "@prisma/client"
|
|
||||||
- "@prisma/engines"
|
|
||||||
- sharp
|
|
||||||
- "@parcel/watcher"
|
|
||||||
- "@swc/core"
|
|
||||||
- "@sentry/cli"
|
|
||||||
- bcrypt
|
|
||||||
|
|
||||||
|
# pnpm v11 vervanger voor onlyBuiltDependencies
|
||||||
|
allowBuilds:
|
||||||
|
esbuild: true
|
||||||
|
prisma: true
|
||||||
|
"@prisma/client": true
|
||||||
|
"@prisma/engines": true
|
||||||
|
sharp: true
|
||||||
|
"@parcel/watcher": true
|
||||||
|
"@swc/core": true
|
||||||
|
"@sentry/cli": true
|
||||||
|
bcrypt: true
|
||||||
|
|
||||||
|
# Al jouw overrides netjes bij elkaar inclusief de nieuwe security patches
|
||||||
overrides:
|
overrides:
|
||||||
|
glob: "^10.4.5"
|
||||||
|
inflight: "npm:lru-cache@^10.2.2"
|
||||||
|
rimraf: "^5.0.7"
|
||||||
|
uuid: "^9.0.1"
|
||||||
fast-uri: "^3.1.3"
|
fast-uri: "^3.1.3"
|
||||||
"@hono/node-server": "^1.19.13"
|
"@hono/node-server": "^1.19.13"
|
||||||
postcss: "^8.5.19"
|
postcss: "^8.5.19"
|
||||||
|
# Dwingt alle diepe subdependencies (zoals lhci) naar de veilige tmp-versie
|
||||||
|
tmp: "^0.2.6"
|
||||||
|
# NIEUWE SECURITY PATCHES:
|
||||||
|
sharp: "^0.35.3"
|
||||||
|
brace-expansion: "^5.0.8"
|
||||||
|
|
||||||
|
# Zorgt voor een schone terminal zonder de Next-Auth / Nodemailer waarschuwing
|
||||||
|
peerDependencyRules:
|
||||||
|
allowedVersions:
|
||||||
|
nodemailer: "9.0.3"
|
||||||
|
ignoreMissing:
|
||||||
|
- nodemailer
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS `website_login_logs` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`user_id` INT NOT NULL,
|
||||||
|
`ip` VARCHAR(45) NOT NULL,
|
||||||
|
`user_agent` VARCHAR(512) DEFAULT NULL,
|
||||||
|
`created_at` TIMESTAMP NULL DEFAULT NULL,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `website_login_logs_user_id_index` (`user_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
+611
-917
File diff suppressed because it is too large.
Load diff
+6
-12
@@ -1,5 +1,5 @@
|
|||||||
const CACHE = "atom-v2";
|
const CACHE = "atom-v3";
|
||||||
const API_CACHE = "atom-api-v2";
|
const API_CACHE = "atom-api-v3";
|
||||||
|
|
||||||
self.addEventListener("install", () => self.skipWaiting());
|
self.addEventListener("install", () => self.skipWaiting());
|
||||||
|
|
||||||
@@ -9,7 +9,9 @@ self.addEventListener("activate", (event) => {
|
|||||||
.keys()
|
.keys()
|
||||||
.then((keys) =>
|
.then((keys) =>
|
||||||
Promise.all(
|
Promise.all(
|
||||||
keys.filter((k) => k !== CACHE && k !== API_CACHE).map((k) => caches.delete(k)),
|
keys
|
||||||
|
.filter((k) => k !== CACHE && k !== API_CACHE)
|
||||||
|
.map((k) => caches.delete(k)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.then(() => self.clients.claim()),
|
.then(() => self.clients.claim()),
|
||||||
@@ -60,14 +62,6 @@ self.addEventListener("fetch", (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (req.mode === "navigate") {
|
if (req.mode === "navigate") {
|
||||||
event.respondWith(
|
event.respondWith(fetch(req));
|
||||||
fetch(req)
|
|
||||||
.then((res) => {
|
|
||||||
const copy = res.clone();
|
|
||||||
caches.open(CACHE).then((c) => c.put(req, copy)).catch(() => {});
|
|
||||||
return res;
|
|
||||||
})
|
|
||||||
.catch(() => caches.match(req).then((hit) => hit || caches.match("/"))),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
|
"extends": ["config:recommended", "helpers:disableTypesNodeMajor"],
|
||||||
|
"labels": ["dependencies"],
|
||||||
|
"rangeStrategy": "bump",
|
||||||
|
"dependencyDashboard": true,
|
||||||
|
"dependencyDashboardTitle": "Dependency Dashboard",
|
||||||
|
"commitMessagePrefix": "chore(deps):",
|
||||||
|
"commitMessageAction": "update",
|
||||||
|
"schedule": ["at any time"],
|
||||||
|
"timezone": "Europe/Amsterdam",
|
||||||
|
"rebaseWhen": "conflicted",
|
||||||
|
"prCreation": "immediate",
|
||||||
|
"prConcurrentLimit": 5,
|
||||||
|
"packageRules": [
|
||||||
|
{
|
||||||
|
"description": "Group all dependency updates into a single PR",
|
||||||
|
"matchPackagePatterns": ["*"],
|
||||||
|
"groupName": "All dependencies",
|
||||||
|
"groupSlug": "all",
|
||||||
|
"automerge": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Major updates need manual review",
|
||||||
|
"matchUpdateTypes": ["major"],
|
||||||
|
"labels": ["dependencies", "major"],
|
||||||
|
"automerge": false,
|
||||||
|
"assignees": [],
|
||||||
|
"reviewers": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Disable updates for engine pins",
|
||||||
|
"matchPackageNames": ["node", "pnpm"],
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Disable server-only (abandoned, pinned at 0.0.1)",
|
||||||
|
"matchPackageNames": ["server-only"],
|
||||||
|
"enabled": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+27
-27
@@ -24,11 +24,16 @@ function getDbConfig(): { url: string; database: string } {
|
|||||||
return { url: mysqlConnectionUrl(url), database: dbName };
|
return { url: mysqlConnectionUrl(url), database: dbName };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureConnection(): Promise<void> {
|
async function openConnection() {
|
||||||
const { url } = getDbConfig();
|
const { url } = getDbConfig();
|
||||||
const mysql = await import("mysql2/promise");
|
const mysql = await import("mysql2/promise");
|
||||||
const conn = await mysql.createConnection(url);
|
return mysql.createConnection(url);
|
||||||
try {
|
}
|
||||||
|
void openConnection;
|
||||||
|
|
||||||
|
async function createTrackingTable(
|
||||||
|
conn: import("mysql2/promise").Connection,
|
||||||
|
): Promise<void> {
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
`CREATE TABLE IF NOT EXISTS \`${TRACKING_TABLE}\` (
|
`CREATE TABLE IF NOT EXISTS \`${TRACKING_TABLE}\` (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
@@ -36,15 +41,11 @@ async function ensureConnection(): Promise<void> {
|
|||||||
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||||
);
|
);
|
||||||
} finally {
|
|
||||||
await conn.end();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getApplied(): Promise<Set<string>> {
|
async function getApplied(
|
||||||
const { url } = getDbConfig();
|
conn: import("mysql2/promise").Connection,
|
||||||
const mysql = await import("mysql2/promise");
|
): Promise<Set<string>> {
|
||||||
const conn = await mysql.createConnection(url);
|
|
||||||
try {
|
try {
|
||||||
const [rows] = await conn.execute(
|
const [rows] = await conn.execute(
|
||||||
`SELECT migration FROM \`${TRACKING_TABLE}\` ORDER BY id`,
|
`SELECT migration FROM \`${TRACKING_TABLE}\` ORDER BY id`,
|
||||||
@@ -52,8 +53,6 @@ async function getApplied(): Promise<Set<string>> {
|
|||||||
return new Set((rows as { migration: string }[]).map((r) => r.migration));
|
return new Set((rows as { migration: string }[]).map((r) => r.migration));
|
||||||
} catch {
|
} catch {
|
||||||
return new Set();
|
return new Set();
|
||||||
} finally {
|
|
||||||
await conn.end();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,11 +69,10 @@ function loadMigrations(): MigrationFile[] {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function apply(migration: MigrationFile): Promise<void> {
|
async function apply(
|
||||||
const { url } = getDbConfig();
|
conn: import("mysql2/promise").Connection,
|
||||||
const mysql = await import("mysql2/promise");
|
migration: MigrationFile,
|
||||||
const conn = await mysql.createConnection(url);
|
): Promise<void> {
|
||||||
try {
|
|
||||||
const statements = splitSqlStatements(migration.sql);
|
const statements = splitSqlStatements(migration.sql);
|
||||||
|
|
||||||
for (const stmt of statements) {
|
for (const stmt of statements) {
|
||||||
@@ -86,17 +84,18 @@ async function apply(migration: MigrationFile): Promise<void> {
|
|||||||
[migration.id],
|
[migration.id],
|
||||||
);
|
);
|
||||||
console.log(`[migrate] Applied: ${migration.name}`);
|
console.log(`[migrate] Applied: ${migration.name}`);
|
||||||
} finally {
|
|
||||||
await conn.end();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const flag = process.argv[2];
|
const mysql = await import("mysql2/promise");
|
||||||
|
const { url } = getDbConfig();
|
||||||
|
const conn = await mysql.createConnection(url);
|
||||||
|
|
||||||
if (flag === "--status") {
|
try {
|
||||||
await ensureConnection();
|
await createTrackingTable(conn);
|
||||||
const applied = await getApplied();
|
const applied = await getApplied(conn);
|
||||||
|
|
||||||
|
if (process.argv[2] === "--status") {
|
||||||
const all = loadMigrations();
|
const all = loadMigrations();
|
||||||
|
|
||||||
console.log("\nMigration status:\n");
|
console.log("\nMigration status:\n");
|
||||||
@@ -114,8 +113,6 @@ async function main() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await ensureConnection();
|
|
||||||
const applied = await getApplied();
|
|
||||||
const pending = loadMigrations().filter((m) => !applied.has(m.id));
|
const pending = loadMigrations().filter((m) => !applied.has(m.id));
|
||||||
|
|
||||||
if (pending.length === 0) {
|
if (pending.length === 0) {
|
||||||
@@ -126,13 +123,16 @@ async function main() {
|
|||||||
console.log(`[migrate] Applying ${pending.length} migration(s)...\n`);
|
console.log(`[migrate] Applying ${pending.length} migration(s)...\n`);
|
||||||
for (const m of pending) {
|
for (const m of pending) {
|
||||||
try {
|
try {
|
||||||
await apply(m);
|
await apply(conn, m);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[migrate] FAILED: ${m.name}`, err);
|
console.error(`[migrate] FAILED: ${m.name}`, err);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.log("\n[migrate] Done.");
|
console.log("\n[migrate] Done.");
|
||||||
|
} finally {
|
||||||
|
await conn.end();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((err) => {
|
main().catch((err) => {
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
import { findMissingLocalImports } from "../src/lib/local-imports";
|
|
||||||
|
|
||||||
const missing = await findMissingLocalImports(process.cwd());
|
|
||||||
|
|
||||||
if (missing.length === 0) {
|
|
||||||
console.log("No unresolved local imports.");
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const item of missing)
|
|
||||||
console.error(`${item.importer}: ${item.specifier}`);
|
|
||||||
console.error(`${missing.length} unresolved local import(s).`);
|
|
||||||
process.exitCode = 1;
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
BARE="/docker/gitea/gitea/git/repositories/remco/epicnext-cms.git"
|
|
||||||
GITEA_API="${GITEA_API:-https://gitlab.epicnabbo.nl}"
|
|
||||||
REPO="remco/EpicNext-Cms"
|
|
||||||
TOKEN="${GITEA_TOKEN:-}"
|
|
||||||
|
|
||||||
if [ $# -ne 1 ]; then
|
|
||||||
echo "Usage: $0 <tag>"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
VERSION="$1"
|
|
||||||
|
|
||||||
if [ -z "$TOKEN" ]; then
|
|
||||||
echo "ERROR: GITEA_TOKEN not set"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "=== Creating release for ${VERSION} ==="
|
|
||||||
|
|
||||||
if ! git -C "$BARE" rev-parse "$VERSION" >/dev/null 2>&1; then
|
|
||||||
echo "ERROR: tag $VERSION not found in bare repo"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
PREV_TAG="$(git -C "$BARE" tag --sort=-creatordate | head -2 | tail -1 || echo '')"
|
|
||||||
|
|
||||||
if [ -n "$PREV_TAG" ] && [ "$PREV_TAG" != "$VERSION" ]; then
|
|
||||||
CHANGELOG="$(git -C "$BARE" log --oneline --no-decorate "${PREV_TAG}..${VERSION}")"
|
|
||||||
else
|
|
||||||
CHANGELOG="$(git -C "$BARE" log --oneline --no-decorate "${VERSION}")"
|
|
||||||
fi
|
|
||||||
|
|
||||||
[ -z "$CHANGELOG" ] && CHANGELOG="Initial release"
|
|
||||||
|
|
||||||
echo "Previous tag: ${PREV_TAG:-none}"
|
|
||||||
echo "Changelog (${#CHANGELOG} chars):"
|
|
||||||
echo "${CHANGELOG}"
|
|
||||||
|
|
||||||
BODY="## EpicNext-CMS ${VERSION}
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
${CHANGELOG}
|
|
||||||
|
|
||||||
---
|
|
||||||
*Automated release from Gitea Actions*
|
|
||||||
"
|
|
||||||
|
|
||||||
echo "$BODY" | jq -Rs '{tag_name: $v, name: $v, body: ., draft: false, prerelease: false}' --arg v "${VERSION}" > /tmp/payload.json
|
|
||||||
|
|
||||||
HTTP_CODE="$(curl -s -w '%{http_code}' -o /tmp/release-resp.json \
|
|
||||||
-X POST "${GITEA_API}/api/v1/repos/${REPO}/releases" \
|
|
||||||
-H "Authorization: token ${TOKEN}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d @/tmp/payload.json)"
|
|
||||||
|
|
||||||
if [ "${HTTP_CODE}" = "409" ]; then
|
|
||||||
echo "Release exists, updating..."
|
|
||||||
RELEASES="$(curl -sf "${GITEA_API}/api/v1/repos/${REPO}/releases" \
|
|
||||||
-H "Authorization: token ${TOKEN}")"
|
|
||||||
REL_ID="$(echo "$RELEASES" | jq -r ".[] | select(.tag_name==\"${VERSION}\") | .id")"
|
|
||||||
echo "Release ID: ${REL_ID}"
|
|
||||||
HTTP_CODE="$(curl -s -w '%{http_code}' -o /tmp/release-resp.json \
|
|
||||||
-X PATCH "${GITEA_API}/api/v1/repos/${REPO}/releases/${REL_ID}" \
|
|
||||||
-H "Authorization: token ${TOKEN}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d @/tmp/payload.json)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "${HTTP_CODE:-0}" -ge 200 ] && [ "${HTTP_CODE:-0}" -lt 300 ]; then
|
|
||||||
echo "SUCCESS: Release ${VERSION} created/updated"
|
|
||||||
cat /tmp/release-resp.json | jq -r '.html_url // .id'
|
|
||||||
else
|
|
||||||
echo "FAILED HTTP ${HTTP_CODE}"
|
|
||||||
cat /tmp/release-resp.json
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
@@ -5,6 +5,7 @@ import { redirect } from "next/navigation";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { formPositiveBigInt } from "@/lib/form-data";
|
import { formPositiveBigInt } from "@/lib/form-data";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { adminAction } from "@/lib/safe-action";
|
import { adminAction } from "@/lib/safe-action";
|
||||||
@@ -34,8 +35,11 @@ export async function createAd(formData: FormData): Promise<void> {
|
|||||||
targetType: "website_ad",
|
targetType: "website_ad",
|
||||||
targetId: Number(ad.id),
|
targetId: Number(ad.id),
|
||||||
});
|
});
|
||||||
} catch {
|
} catch (err) {
|
||||||
// DB error — page re-renders unchanged.
|
logger.error("Action failed: createAd", {
|
||||||
|
action: "createAd",
|
||||||
|
error: err instanceof Error ? err.message : "DB error",
|
||||||
|
});
|
||||||
revalidatePath("/admin/ads");
|
revalidatePath("/admin/ads");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -65,8 +69,12 @@ export async function updateAd(formData: FormData): Promise<void> {
|
|||||||
targetType: "website_ad",
|
targetType: "website_ad",
|
||||||
targetId: Number(id),
|
targetId: Number(id),
|
||||||
});
|
});
|
||||||
} catch {
|
} catch (err) {
|
||||||
// Not found or DB error — ignore.
|
logger.error("Action failed: updateAd", {
|
||||||
|
action: "updateAd",
|
||||||
|
id: Number(id),
|
||||||
|
error: err instanceof Error ? err.message : "DB error",
|
||||||
|
});
|
||||||
revalidatePath(`/admin/ads/${id}`);
|
revalidatePath(`/admin/ads/${id}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -115,8 +123,12 @@ export async function deleteAdForm(formData: FormData): Promise<void> {
|
|||||||
targetType: "website_ad",
|
targetType: "website_ad",
|
||||||
targetId: Number(id),
|
targetId: Number(id),
|
||||||
});
|
});
|
||||||
} catch {
|
} catch (err) {
|
||||||
// Not found or DB error — ignore.
|
logger.error("Action failed: deleteAdForm", {
|
||||||
|
action: "deleteAdForm",
|
||||||
|
id: Number(id),
|
||||||
|
error: err instanceof Error ? err.message : "DB error",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
redirect("/admin/ads");
|
redirect("/admin/ads");
|
||||||
}
|
}
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { PERMS } from "@/lib/permissions";
|
|
||||||
import { formPositiveBigInt } from "@/lib/form-data";
|
import { formPositiveBigInt } from "@/lib/form-data";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export async function dismissApplication(formData: FormData): Promise<void> {
|
export async function dismissApplication(formData: FormData): Promise<void> {
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { PERMS } from "@/lib/permissions";
|
|
||||||
import { slugify } from "@/lib/format";
|
import { slugify } from "@/lib/format";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
async function uniqueSlug(title: string): Promise<string> {
|
async function uniqueSlug(title: string): Promise<string> {
|
||||||
@@ -74,9 +74,7 @@ export async function updateArticle(formData: FormData): Promise<void> {
|
|||||||
.normalize("NFC")
|
.normalize("NFC")
|
||||||
.trim()
|
.trim()
|
||||||
.slice(0, 255),
|
.slice(0, 255),
|
||||||
...(rawSlug
|
...(rawSlug ? { slug: await uniqueSlug(rawSlug) } : {}),
|
||||||
? { slug: await uniqueSlug(rawSlug) }
|
|
||||||
: {}),
|
|
||||||
shortStory: String(formData.get("shortStory") ?? "")
|
shortStory: String(formData.get("shortStory") ?? "")
|
||||||
.normalize("NFC")
|
.normalize("NFC")
|
||||||
.trim()
|
.trim()
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { PERMS } from "@/lib/permissions";
|
|
||||||
import { formPositiveBigInt } from "@/lib/form-data";
|
import { formPositiveBigInt } from "@/lib/form-data";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export async function createEmailTemplate(formData: FormData): Promise<void> {
|
export async function createEmailTemplate(formData: FormData): Promise<void> {
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { PERMS } from "@/lib/permissions";
|
|
||||||
import { formPositiveBigInt } from "@/lib/form-data";
|
import { formPositiveBigInt } from "@/lib/form-data";
|
||||||
import { canonicalize, sanitizeField } from "@/lib/foundation/security";
|
import { canonicalize, sanitizeField } from "@/lib/foundation/security";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
|
||||||
@@ -26,8 +26,10 @@ export async function createHelpQuestion(formData: FormData): Promise<void> {
|
|||||||
const imageUrl = sanitizeField(formData.get("imageUrl"));
|
const imageUrl = sanitizeField(formData.get("imageUrl"));
|
||||||
const buttonText = sanitizeField(formData.get("buttonText"));
|
const buttonText = sanitizeField(formData.get("buttonText"));
|
||||||
const buttonUrl = sanitizeField(formData.get("buttonUrl"));
|
const buttonUrl = sanitizeField(formData.get("buttonUrl"));
|
||||||
const buttonColor = sanitizeField(formData.get("buttonColor"), 16) || "#eeb425";
|
const buttonColor =
|
||||||
const buttonBorderColor = sanitizeField(formData.get("buttonBorderColor"), 16) || "#facc15";
|
sanitizeField(formData.get("buttonColor"), 16) || "#eeb425";
|
||||||
|
const buttonBorderColor =
|
||||||
|
sanitizeField(formData.get("buttonBorderColor"), 16) || "#facc15";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const entry = await prisma.websiteHelpCenterCategories.create({
|
const entry = await prisma.websiteHelpCenterCategories.create({
|
||||||
@@ -73,8 +75,10 @@ export async function updateHelpQuestion(formData: FormData): Promise<void> {
|
|||||||
const imageUrl = sanitizeField(formData.get("imageUrl"));
|
const imageUrl = sanitizeField(formData.get("imageUrl"));
|
||||||
const buttonText = sanitizeField(formData.get("buttonText"));
|
const buttonText = sanitizeField(formData.get("buttonText"));
|
||||||
const buttonUrl = sanitizeField(formData.get("buttonUrl"));
|
const buttonUrl = sanitizeField(formData.get("buttonUrl"));
|
||||||
const buttonColor = sanitizeField(formData.get("buttonColor"), 16) || "#eeb425";
|
const buttonColor =
|
||||||
const buttonBorderColor = sanitizeField(formData.get("buttonBorderColor"), 16) || "#facc15";
|
sanitizeField(formData.get("buttonColor"), 16) || "#eeb425";
|
||||||
|
const buttonBorderColor =
|
||||||
|
sanitizeField(formData.get("buttonBorderColor"), 16) || "#facc15";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await prisma.websiteHelpCenterCategories.update({
|
await prisma.websiteHelpCenterCategories.update({
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const { mockUpsert, mockRequirePermission, mockReload, mockRevalidatePath } =
|
||||||
|
vi.hoisted(() => ({
|
||||||
|
mockUpsert: vi.fn(),
|
||||||
|
mockRequirePermission: vi.fn(),
|
||||||
|
mockReload: vi.fn(),
|
||||||
|
mockRevalidatePath: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/permissions", () => ({
|
||||||
|
PERMS: {
|
||||||
|
ADMIN_DASHBOARD: "admin.dashboard",
|
||||||
|
SETTINGS_VIEW: "admin.settings.view",
|
||||||
|
SETTINGS_EDIT: "admin.settings.edit",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/prisma", () => ({
|
||||||
|
prisma: {
|
||||||
|
websiteSetting: { upsert: mockUpsert },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/admin/guard", () => ({
|
||||||
|
requirePermission: mockRequirePermission,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/services/site-settings", () => ({
|
||||||
|
siteSettings: { reload: mockReload },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("next/cache", () => ({
|
||||||
|
revalidatePath: mockRevalidatePath,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { saveMaintenance } from "./admin-maintenance";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("saveMaintenance", () => {
|
||||||
|
it("enables maintenance mode with message and min rank", async () => {
|
||||||
|
mockRequirePermission.mockResolvedValue({
|
||||||
|
id: 1,
|
||||||
|
rank: 7,
|
||||||
|
username: "admin",
|
||||||
|
});
|
||||||
|
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.set("enabled", "on");
|
||||||
|
fd.set("message", "We will be back soon!");
|
||||||
|
fd.set("min_rank", "3");
|
||||||
|
|
||||||
|
await saveMaintenance(fd);
|
||||||
|
|
||||||
|
expect(mockRequirePermission).toHaveBeenCalled();
|
||||||
|
|
||||||
|
expect(mockUpsert).toHaveBeenCalledTimes(3);
|
||||||
|
expect(mockUpsert).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: { key: "maintenance_enabled" },
|
||||||
|
update: { value: "1" },
|
||||||
|
create: expect.objectContaining({
|
||||||
|
key: "maintenance_enabled",
|
||||||
|
value: "1",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(mockUpsert).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: { key: "maintenance_message" },
|
||||||
|
update: { value: "We will be back soon!" },
|
||||||
|
create: expect.objectContaining({
|
||||||
|
key: "maintenance_message",
|
||||||
|
value: "We will be back soon!",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(mockUpsert).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: { key: "min_maintenance_login_rank" },
|
||||||
|
update: { value: "3" },
|
||||||
|
create: expect.objectContaining({
|
||||||
|
key: "min_maintenance_login_rank",
|
||||||
|
value: "3",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockReload).toHaveBeenCalledOnce();
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/maintenance");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables maintenance mode", async () => {
|
||||||
|
mockRequirePermission.mockResolvedValue({
|
||||||
|
id: 1,
|
||||||
|
rank: 7,
|
||||||
|
username: "admin",
|
||||||
|
});
|
||||||
|
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.set("message", "");
|
||||||
|
fd.set("min_rank", "");
|
||||||
|
|
||||||
|
await saveMaintenance(fd);
|
||||||
|
|
||||||
|
expect(mockUpsert).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: { key: "maintenance_enabled" },
|
||||||
|
update: { value: "0" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(mockUpsert).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: { key: "min_maintenance_login_rank" },
|
||||||
|
update: { value: "5" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults min_rank to 5 when input is empty", async () => {
|
||||||
|
mockRequirePermission.mockResolvedValue({
|
||||||
|
id: 1,
|
||||||
|
rank: 7,
|
||||||
|
username: "admin",
|
||||||
|
});
|
||||||
|
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.set("enabled", "on");
|
||||||
|
fd.set("message", "");
|
||||||
|
fd.set("min_rank", "");
|
||||||
|
|
||||||
|
await saveMaintenance(fd);
|
||||||
|
|
||||||
|
expect(mockUpsert).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: { key: "min_maintenance_login_rank" },
|
||||||
|
update: { value: "5" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults min_rank to 5 when input is negative", async () => {
|
||||||
|
mockRequirePermission.mockResolvedValue({
|
||||||
|
id: 1,
|
||||||
|
rank: 7,
|
||||||
|
username: "admin",
|
||||||
|
});
|
||||||
|
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.set("enabled", "on");
|
||||||
|
fd.set("message", "");
|
||||||
|
fd.set("min_rank", "-1");
|
||||||
|
|
||||||
|
await saveMaintenance(fd);
|
||||||
|
|
||||||
|
expect(mockUpsert).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: { key: "min_maintenance_login_rank" },
|
||||||
|
update: { value: "5" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls requirePermission with SETTINGS_EDIT", async () => {
|
||||||
|
mockRequirePermission.mockResolvedValue({
|
||||||
|
id: 1,
|
||||||
|
rank: 7,
|
||||||
|
username: "admin",
|
||||||
|
});
|
||||||
|
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.set("message", "");
|
||||||
|
fd.set("min_rank", "");
|
||||||
|
|
||||||
|
await saveMaintenance(fd);
|
||||||
|
|
||||||
|
expect(mockRequirePermission).toHaveBeenCalledWith("admin.settings.edit");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,8 +4,8 @@ import { mkdir, writeFile } from "node:fs/promises";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { PERMS } from "@/lib/permissions";
|
|
||||||
import { MEDIA_ROOT, resolveMediaPath } from "@/lib/media-storage";
|
import { MEDIA_ROOT, resolveMediaPath } from "@/lib/media-storage";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
|
||||||
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
|
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
|
||||||
const ALLOWED = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
const ALLOWED = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
||||||
@@ -19,7 +19,10 @@ export async function uploadMedia(
|
|||||||
if (file.size > MAX_SIZE)
|
if (file.size > MAX_SIZE)
|
||||||
return { ok: false, error: "File too large (max 5MB)" };
|
return { ok: false, error: "File too large (max 5MB)" };
|
||||||
if (!ALLOWED.includes(file.type))
|
if (!ALLOWED.includes(file.type))
|
||||||
return { ok: false, error: "Invalid file type. Allowed: PNG, JPEG, GIF, WebP" };
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "Invalid file type. Allowed: PNG, JPEG, GIF, WebP",
|
||||||
|
};
|
||||||
|
|
||||||
const baseDir = MEDIA_ROOT;
|
const baseDir = MEDIA_ROOT;
|
||||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { PERMS } from "@/lib/permissions";
|
|
||||||
import { formPositiveBigInt } from "@/lib/form-data";
|
import { formPositiveBigInt } from "@/lib/form-data";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export async function createCategory(formData: FormData): Promise<void> {
|
export async function createCategory(formData: FormData): Promise<void> {
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import {
|
|||||||
} from "@/lib/habbo-gamedata-hotel";
|
} from "@/lib/habbo-gamedata-hotel";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { clearBadgeCache } from "@/lib/services/habboassets";
|
|
||||||
import { clearHabboItCache } from "@/lib/services/habbo-furnidata-cache";
|
import { clearHabboItCache } from "@/lib/services/habbo-furnidata-cache";
|
||||||
|
import { clearBadgeCache } from "@/lib/services/habboassets";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
|
||||||
const managedKeySet = new Set(MANAGED_SETTING_KEYS);
|
const managedKeySet = new Set(MANAGED_SETTING_KEYS);
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { PERMS } from "@/lib/permissions";
|
|
||||||
import { formPositiveBigInt } from "@/lib/form-data";
|
import { formPositiveBigInt } from "@/lib/form-data";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { logServerError } from "@/lib/server-log";
|
import { logServerError } from "@/lib/server-log";
|
||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { PERMS } from "@/lib/permissions";
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
import { ensureReadableThemeColors } from "@/lib/theme-contrast";
|
||||||
import {
|
import {
|
||||||
deleteCustomThemeStore,
|
deleteCustomThemeStore,
|
||||||
getCustomTheme,
|
getCustomTheme,
|
||||||
@@ -15,7 +16,6 @@ import {
|
|||||||
} from "@/lib/theme-custom-store";
|
} from "@/lib/theme-custom-store";
|
||||||
import { FONTS, PRESETS, THEME_COLOR_KEYS } from "@/lib/theme-presets";
|
import { FONTS, PRESETS, THEME_COLOR_KEYS } from "@/lib/theme-presets";
|
||||||
import { presetSettings, settingKey } from "@/lib/theme-settings";
|
import { presetSettings, settingKey } from "@/lib/theme-settings";
|
||||||
import { ensureReadableThemeColors } from "@/lib/theme-contrast";
|
|
||||||
|
|
||||||
// Only hex/keyword colour values are accepted (matches ThemeVars' sanitiser).
|
// Only hex/keyword colour values are accepted (matches ThemeVars' sanitiser).
|
||||||
const COLOR_RE = /^[#a-zA-Z0-9(),.\s%-]+$/;
|
const COLOR_RE = /^[#a-zA-Z0-9(),.\s%-]+$/;
|
||||||
@@ -46,7 +46,10 @@ export async function saveTheme(formData: FormData): Promise<void> {
|
|||||||
}
|
}
|
||||||
const fixed = ensureReadableThemeColors(bag);
|
const fixed = ensureReadableThemeColors(bag);
|
||||||
for (const [key, value] of Object.entries(fixed)) {
|
for (const [key, value] of Object.entries(fixed)) {
|
||||||
await writeSetting(settingKey(key as (typeof THEME_COLOR_KEYS)[number], mode), value);
|
await writeSetting(
|
||||||
|
settingKey(key as (typeof THEME_COLOR_KEYS)[number], mode),
|
||||||
|
value,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const ADMIN_KEYS = [
|
const ADMIN_KEYS = [
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { positiveBigInt } from "@/lib/api";
|
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { positiveBigInt } from "@/lib/api";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import {
|
import {
|
||||||
|
type ActionResult,
|
||||||
actionError,
|
actionError,
|
||||||
actionOk,
|
actionOk,
|
||||||
type ActionResult,
|
|
||||||
} from "@/lib/safe-action-shared";
|
} from "@/lib/safe-action-shared";
|
||||||
import { logServerError } from "@/lib/server-log";
|
import { logServerError } from "@/lib/server-log";
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import { requirePermission } from "@/lib/admin/guard";
|
|||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import {
|
import {
|
||||||
|
type ActionResult,
|
||||||
actionError,
|
actionError,
|
||||||
actionOk,
|
actionOk,
|
||||||
type ActionResult,
|
|
||||||
} from "@/lib/safe-action-shared";
|
} from "@/lib/safe-action-shared";
|
||||||
import { reloadWordFilter } from "@/lib/services/moderation";
|
import { reloadWordFilter } from "@/lib/services/moderation";
|
||||||
import { rcon } from "@/lib/services/rcon";
|
import { rcon } from "@/lib/services/rcon";
|
||||||
@@ -33,9 +33,7 @@ export async function addWord(input: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteWord(input: {
|
export async function deleteWord(input: { id: string }): Promise<ActionResult> {
|
||||||
id: string;
|
|
||||||
}): Promise<ActionResult> {
|
|
||||||
await requirePermission(PERMS.WORDFILTER_EDIT);
|
await requirePermission(PERMS.WORDFILTER_EDIT);
|
||||||
const raw = String(input.id ?? "").normalize("NFC");
|
const raw = String(input.id ?? "").normalize("NFC");
|
||||||
if (!raw) return actionError("Missing word id");
|
if (!raw) return actionError("Missing word id");
|
||||||
|
|||||||
@@ -89,7 +89,8 @@ export async function buyBadge(formData: FormData): Promise<void> {
|
|||||||
// Atomically deduct credits and persist the badge so a failure
|
// Atomically deduct credits and persist the badge so a failure
|
||||||
// between the two operations cannot orphan the user.
|
// between the two operations cannot orphan the user.
|
||||||
if (price > 0) {
|
if (price > 0) {
|
||||||
await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
await prisma.$transaction(
|
||||||
|
async (tx: Prisma.TransactionClient) => {
|
||||||
await tx.user.update({
|
await tx.user.update({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
data: { credits: { decrement: price } },
|
data: { credits: { decrement: price } },
|
||||||
@@ -109,7 +110,8 @@ export async function buyBadge(formData: FormData): Promise<void> {
|
|||||||
data: { userId, slotId, badgeCode: code },
|
data: { userId, slotId, badgeCode: code },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Grant the badge live so it appears immediately for online users.
|
// Grant the badge live so it appears immediately for online users.
|
||||||
|
|||||||
@@ -53,9 +53,9 @@ describe("email verification tokens", () => {
|
|||||||
|
|
||||||
it("rejects legacy forever-valid digests", async () => {
|
it("rejects legacy forever-valid digests", async () => {
|
||||||
const legacy = "a".repeat(64);
|
const legacy = "a".repeat(64);
|
||||||
expect(
|
expect(await isValidVerificationToken("[email protected]", legacy)).toBe(
|
||||||
await isValidVerificationToken("[email protected]", legacy),
|
false,
|
||||||
).toBe(false);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects expired tokens", async () => {
|
it("rejects expired tokens", async () => {
|
||||||
@@ -69,8 +69,10 @@ describe("email verification tokens", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("sends mail with a verify link", async () => {
|
it("sends mail with a verify link", async () => {
|
||||||
mockGetTranslations.mockResolvedValue(
|
mockGetTranslations.mockResolvedValue(((
|
||||||
((key: string, values?: { hotel?: string }) => {
|
key: string,
|
||||||
|
values?: { hotel?: string },
|
||||||
|
) => {
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
subject: `Verify your email · ${values?.hotel}`,
|
subject: `Verify your email · ${values?.hotel}`,
|
||||||
heading: "Verify your email",
|
heading: "Verify your email",
|
||||||
@@ -79,8 +81,7 @@ describe("email verification tokens", () => {
|
|||||||
fallback: "Paste this link:",
|
fallback: "Paste this link:",
|
||||||
};
|
};
|
||||||
return map[key] ?? key;
|
return map[key] ?? key;
|
||||||
}) as never,
|
}) as never);
|
||||||
);
|
|
||||||
|
|
||||||
await sendVerification("[email protected]");
|
await sendVerification("[email protected]");
|
||||||
expect(mockSendMail).toHaveBeenCalledWith(
|
expect(mockSendMail).toHaveBeenCalledWith(
|
||||||
|
|||||||
@@ -3,10 +3,10 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { positiveBigInt } from "@/lib/api";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||||
import { positiveBigInt } from "@/lib/api";
|
|
||||||
import { moderateOrThrow } from "@/lib/services/moderation";
|
import { moderateOrThrow } from "@/lib/services/moderation";
|
||||||
import { createOwnedTicketReply } from "@/lib/services/ticket-replies";
|
import { createOwnedTicketReply } from "@/lib/services/ticket-replies";
|
||||||
|
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
"use server";
|
|
||||||
|
|
||||||
import { auth } from "@/lib/auth";
|
|
||||||
import { logger } from "@/lib/logger";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
|
|
||||||
export async function linkDiscordId(discordId: string): Promise<string | null> {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user?.id) return "Not logged in";
|
|
||||||
|
|
||||||
const userId = Number(session.user.id);
|
|
||||||
if (!discordId || !/^\d{17,20}$/.test(discordId.trim())) {
|
|
||||||
return "Invalid Discord ID format";
|
|
||||||
}
|
|
||||||
|
|
||||||
const discordIdClean = discordId.trim();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const existing = await prisma.socialAccounts.findUnique({
|
|
||||||
where: {
|
|
||||||
provider_providerId: {
|
|
||||||
provider: "discord",
|
|
||||||
providerId: discordIdClean,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
select: { userId: true },
|
|
||||||
});
|
|
||||||
if (existing && Number(existing.userId) !== userId) {
|
|
||||||
return "This Discord ID is already linked to another account";
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
return "Failed to check Discord ID";
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await prisma.socialAccounts.upsert({
|
|
||||||
where: {
|
|
||||||
provider_providerId: {
|
|
||||||
provider: "discord",
|
|
||||||
providerId: discordIdClean,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
userId: BigInt(userId),
|
|
||||||
provider: "discord",
|
|
||||||
providerId: discordIdClean,
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
},
|
|
||||||
update: { userId: BigInt(userId), updatedAt: new Date() },
|
|
||||||
});
|
|
||||||
|
|
||||||
await prisma.user.update({
|
|
||||||
where: { id: userId },
|
|
||||||
data: { mailVerified: "1" },
|
|
||||||
});
|
|
||||||
|
|
||||||
return null;
|
|
||||||
} catch (e) {
|
|
||||||
logger.error("Failed to link Discord account", {
|
|
||||||
module: "link-discord",
|
|
||||||
error: (e as Error).message,
|
|
||||||
});
|
|
||||||
return "Failed to link Discord account";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,8 +3,8 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||||
|
|
||||||
type FriendOutcome =
|
type FriendOutcome =
|
||||||
| "accepted"
|
| "accepted"
|
||||||
@@ -86,7 +86,11 @@ export async function acceptFriend(formData: FormData): Promise<void> {
|
|||||||
outcome = "unauthorized";
|
outcome = "unauthorized";
|
||||||
} else {
|
} else {
|
||||||
const friendId = request.userFromId;
|
const friendId = request.userFromId;
|
||||||
if (!Number.isInteger(friendId) || friendId <= 0 || friendId === meId) {
|
if (
|
||||||
|
!Number.isInteger(friendId) ||
|
||||||
|
friendId <= 0 ||
|
||||||
|
friendId === meId
|
||||||
|
) {
|
||||||
// Malformed/self request — clear it and treat as not found.
|
// Malformed/self request — clear it and treat as not found.
|
||||||
await prisma.messengerFriendrequests.delete({
|
await prisma.messengerFriendrequests.delete({
|
||||||
where: { id: requestId },
|
where: { id: requestId },
|
||||||
|
|||||||
@@ -12,11 +12,7 @@ const NAME_MAX = 255;
|
|||||||
const TEXT_MAX = 5000;
|
const TEXT_MAX = 5000;
|
||||||
const STYLE_MAX = 5000;
|
const STYLE_MAX = 5000;
|
||||||
|
|
||||||
type ApplyOutcome =
|
type ApplyOutcome = "submitted" | "invalid" | "ratelimit" | "error";
|
||||||
| "submitted"
|
|
||||||
| "invalid"
|
|
||||||
| "ratelimit"
|
|
||||||
| "error";
|
|
||||||
|
|
||||||
function applyRedirect(outcome: ApplyOutcome): never {
|
function applyRedirect(outcome: ApplyOutcome): never {
|
||||||
if (outcome === "submitted") redirect("/radio/apply?submitted=1");
|
if (outcome === "submitted") redirect("/radio/apply?submitted=1");
|
||||||
|
|||||||
@@ -9,12 +9,7 @@ import { clientIp, rateLimit } from "@/lib/rate-limit";
|
|||||||
const SONG_MAX = 255;
|
const SONG_MAX = 255;
|
||||||
const ARTIST_MAX = 255;
|
const ARTIST_MAX = 255;
|
||||||
|
|
||||||
type RequestOutcome =
|
type RequestOutcome = "posted" | "empty" | "invalid" | "ratelimit" | "error";
|
||||||
| "posted"
|
|
||||||
| "empty"
|
|
||||||
| "invalid"
|
|
||||||
| "ratelimit"
|
|
||||||
| "error";
|
|
||||||
|
|
||||||
function requestsRedirect(outcome: RequestOutcome): never {
|
function requestsRedirect(outcome: RequestOutcome): never {
|
||||||
if (outcome === "posted") redirect("/radio/requests?posted=1");
|
if (outcome === "posted") redirect("/radio/requests?posted=1");
|
||||||
|
|||||||
@@ -12,12 +12,7 @@ const shoutSchema = z.object({
|
|||||||
message: z.string().min(1, "Message is required").max(255),
|
message: z.string().min(1, "Message is required").max(255),
|
||||||
});
|
});
|
||||||
|
|
||||||
type ShoutOutcome =
|
type ShoutOutcome = "posted" | "invalid" | "moderated" | "ratelimit" | "error";
|
||||||
| "posted"
|
|
||||||
| "invalid"
|
|
||||||
| "moderated"
|
|
||||||
| "ratelimit"
|
|
||||||
| "error";
|
|
||||||
|
|
||||||
function shoutsRedirect(outcome: ShoutOutcome): never {
|
function shoutsRedirect(outcome: ShoutOutcome): never {
|
||||||
if (outcome === "posted") redirect("/radio/shouts?posted=1");
|
if (outcome === "posted") redirect("/radio/shouts?posted=1");
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { redirect } from "next/navigation";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { sendVerification } from "@/actions/email-verify";
|
import { sendVerification } from "@/actions/email-verify";
|
||||||
import { hashPassword } from "@/lib/auth/password";
|
import { hashPassword } from "@/lib/auth/password";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||||
import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha";
|
import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha";
|
||||||
@@ -100,6 +101,7 @@ export async function register(
|
|||||||
});
|
});
|
||||||
if (existing) return "That username is already taken";
|
if (existing) return "That username is already taken";
|
||||||
} catch {
|
} catch {
|
||||||
|
logger.warn("Username uniqueness check failed during registration");
|
||||||
return "Registration is temporarily unavailable";
|
return "Registration is temporarily unavailable";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,18 +124,13 @@ export async function register(
|
|||||||
try {
|
try {
|
||||||
await sendVerification(mail);
|
await sendVerification(mail);
|
||||||
} catch {
|
} catch {
|
||||||
// No-op: account is created; user can request a new link later.
|
logger.warn("Failed to send verification email after registration");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
logger.warn("Account creation failed");
|
||||||
return "Could not create the account (is the username unique?)";
|
return "Could not create the account (is the username unique?)";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasEmail) {
|
|
||||||
redirect("/login?registered=1");
|
redirect("/login?registered=1");
|
||||||
} else {
|
|
||||||
const { signIn } = await import("@/lib/auth");
|
|
||||||
await signIn("credentials", { username, password, redirect: false });
|
|
||||||
redirect("/verify?method=discord");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -3,9 +3,9 @@
|
|||||||
import { mkdir, unlink, writeFile } from "node:fs/promises";
|
import { mkdir, unlink, writeFile } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { resolveMediaPath } from "@/lib/media-storage";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
import { resolveMediaPath } from "@/lib/media-storage";
|
|
||||||
|
|
||||||
const FAVICON_DIR = resolveMediaPath("favicon");
|
const FAVICON_DIR = resolveMediaPath("favicon");
|
||||||
const MAX_SIZE = 2 * 1024 * 1024; // 2MB
|
const MAX_SIZE = 2 * 1024 * 1024; // 2MB
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
import { mkdir, writeFile } from "node:fs/promises";
|
import { mkdir, writeFile } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { resolveMediaPath } from "@/lib/media-storage";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
import { resolveMediaPath } from "@/lib/media-storage";
|
|
||||||
|
|
||||||
const MEDIA_DIR = resolveMediaPath("logo");
|
const MEDIA_DIR = resolveMediaPath("logo");
|
||||||
|
|
||||||
|
|||||||
+14
-4
@@ -3,6 +3,7 @@
|
|||||||
import { auth, signOut } from "@/lib/auth";
|
import { auth, signOut } from "@/lib/auth";
|
||||||
import { invalidateJwtVersionCache } from "@/lib/auth/jwt-version-cache";
|
import { invalidateJwtVersionCache } from "@/lib/auth/jwt-version-cache";
|
||||||
import { personalTokenScope } from "@/lib/auth/personal-token-scope";
|
import { personalTokenScope } from "@/lib/auth/personal-token-scope";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -23,8 +24,11 @@ export async function signOutEverywhere(): Promise<void> {
|
|||||||
data: { websiteJwtVersion: { increment: 1 } },
|
data: { websiteJwtVersion: { increment: 1 } },
|
||||||
});
|
});
|
||||||
await invalidateJwtVersionCache(userId);
|
await invalidateJwtVersionCache(userId);
|
||||||
} catch {
|
} catch (err) {
|
||||||
/* still continue */
|
logger.warn("Failed to bump JWT version during sign-out-everywhere", {
|
||||||
|
userId,
|
||||||
|
error: err instanceof Error ? err.message : "Unknown",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Revoke API bearer tokens (Sanctum / personal_access_tokens).
|
// Revoke API bearer tokens (Sanctum / personal_access_tokens).
|
||||||
@@ -32,8 +36,14 @@ export async function signOutEverywhere(): Promise<void> {
|
|||||||
await prisma.personalAccessTokens.deleteMany({
|
await prisma.personalAccessTokens.deleteMany({
|
||||||
where: personalTokenScope(userId),
|
where: personalTokenScope(userId),
|
||||||
});
|
});
|
||||||
} catch {
|
} catch (err) {
|
||||||
/* still sign out locally */
|
logger.warn(
|
||||||
|
"Failed to revoke personal access tokens during sign-out-everywhere",
|
||||||
|
{
|
||||||
|
userId,
|
||||||
|
error: err instanceof Error ? err.message : "Unknown",
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await signOut({ redirectTo: "/login?signedOutAll=1" });
|
await signOut({ redirectTo: "/login?signedOutAll=1" });
|
||||||
|
|||||||
+1
-6
@@ -28,12 +28,7 @@ function parseBadgeCodes(raw: string | null | undefined): string[] {
|
|||||||
.filter((s) => s.length > 0 && s.length <= 32);
|
.filter((s) => s.length > 0 && s.length <= 32);
|
||||||
}
|
}
|
||||||
|
|
||||||
type BuyOutcome =
|
type BuyOutcome = "bought" | "invalid" | "credits" | "ratelimit" | "error";
|
||||||
| "bought"
|
|
||||||
| "invalid"
|
|
||||||
| "credits"
|
|
||||||
| "ratelimit"
|
|
||||||
| "error";
|
|
||||||
|
|
||||||
function shopRedirect(
|
function shopRedirect(
|
||||||
categoryId: string,
|
categoryId: string,
|
||||||
|
|||||||
@@ -22,12 +22,7 @@ type FriendRequestOutcome =
|
|||||||
| "ratelimit"
|
| "ratelimit"
|
||||||
| "error";
|
| "error";
|
||||||
|
|
||||||
type ThreadOutcome =
|
type ThreadOutcome = "posted" | "invalid" | "not_found" | "ratelimit" | "error";
|
||||||
| "posted"
|
|
||||||
| "invalid"
|
|
||||||
| "not_found"
|
|
||||||
| "ratelimit"
|
|
||||||
| "error";
|
|
||||||
|
|
||||||
type ReplyOutcome =
|
type ReplyOutcome =
|
||||||
| "replied"
|
| "replied"
|
||||||
|
|||||||
@@ -388,6 +388,7 @@ export const muteUser = adminAction(
|
|||||||
{ permission: PERMS.USERS_EDIT, schema: muteSchema },
|
{ permission: PERMS.USERS_EDIT, schema: muteSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const _target = await guardRank(ctx.data.userId, ctx.session.user.rank);
|
const _target = await guardRank(ctx.data.userId, ctx.session.user.rank);
|
||||||
|
void _target;
|
||||||
const success = await rcon.muteUser(ctx.data.userId, ctx.data.duration);
|
const success = await rcon.muteUser(ctx.data.userId, ctx.data.duration);
|
||||||
if (!success)
|
if (!success)
|
||||||
throw new ActionError("Failed to mute. Is the emulator running?");
|
throw new ActionError("Failed to mute. Is the emulator running?");
|
||||||
@@ -440,6 +441,7 @@ export const sendCredits = adminAction(
|
|||||||
{ permission: PERMS.USERS_EDIT, schema: sendCreditsSchema },
|
{ permission: PERMS.USERS_EDIT, schema: sendCreditsSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const _target = await guardRank(ctx.data.userId, ctx.session.user.rank);
|
const _target = await guardRank(ctx.data.userId, ctx.session.user.rank);
|
||||||
|
void _target;
|
||||||
const success = await rcon.giveCredits(ctx.data.userId, ctx.data.amount);
|
const success = await rcon.giveCredits(ctx.data.userId, ctx.data.amount);
|
||||||
if (!success)
|
if (!success)
|
||||||
throw new ActionError("Failed to send credits. Is the emulator running?");
|
throw new ActionError("Failed to send credits. Is the emulator running?");
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ import type { CSSProperties } from "react";
|
|||||||
import { applyStaff } from "@/actions/applications";
|
import { applyStaff } from "@/actions/applications";
|
||||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { formatDate } from "@/lib/format-date";
|
||||||
import { resolveHotelName } from "@/lib/hotel-name";
|
import { resolveHotelName } from "@/lib/hotel-name";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -55,11 +56,6 @@ export default async function ApplyStaffPage({
|
|||||||
const userId = Number(session.user.id);
|
const userId = Number(session.user.id);
|
||||||
const hotelName = await resolveHotelName();
|
const hotelName = await resolveHotelName();
|
||||||
|
|
||||||
function formatDeadline(d: Date | null | undefined): string {
|
|
||||||
if (!d) return t("noDeadline");
|
|
||||||
return d.toISOString().slice(0, 16).replace("T", " ");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Open positions ──────────────────────────────────────────
|
// ── Open positions ──────────────────────────────────────────
|
||||||
// Each query is isolated so a DB hiccup degrades that section to empty
|
// Each query is isolated so a DB hiccup degrades that section to empty
|
||||||
// rather than 500-ing the whole page.
|
// rather than 500-ing the whole page.
|
||||||
@@ -149,7 +145,13 @@ export default async function ApplyStaffPage({
|
|||||||
<div className="content-card-body">
|
<div className="content-card-body">
|
||||||
<p style={{ margin: "0 0 0.5rem" }}>{position.description}</p>
|
<p style={{ margin: "0 0 0.5rem" }}>{position.description}</p>
|
||||||
<p className="muted" style={{ margin: "0 0 0.75rem" }}>
|
<p className="muted" style={{ margin: "0 0 0.75rem" }}>
|
||||||
{t("deadline", { date: formatDeadline(position.applyTo) })}
|
{t("deadline", {
|
||||||
|
date: formatDate(
|
||||||
|
position.applyTo,
|
||||||
|
"datetime",
|
||||||
|
t("noDeadline"),
|
||||||
|
),
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{alreadyApplied ? (
|
{alreadyApplied ? (
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import type { CSSProperties } from "react";
|
|||||||
import { applyTeam } from "@/actions/applications";
|
import { applyTeam } from "@/actions/applications";
|
||||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { resolveHotelName } from "@/lib/hotel-name";
|
import { resolveHotelName } from "@/lib/hotel-name";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,10 @@ export default async function EventDetailPage({
|
|||||||
})
|
})
|
||||||
.catch(() => null);
|
.catch(() => null);
|
||||||
|
|
||||||
if (!event || (event.status !== "published" && event.status !== "completed")) {
|
if (
|
||||||
|
!event ||
|
||||||
|
(event.status !== "published" && event.status !== "completed")
|
||||||
|
) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,8 +73,7 @@ export default async function EventDetailPage({
|
|||||||
const winnerName = new Map(winners.map((u) => [u.id, u.username]));
|
const winnerName = new Map(winners.map((u) => [u.id, u.username]));
|
||||||
|
|
||||||
const isFull =
|
const isFull =
|
||||||
event.maxPlayers != null &&
|
event.maxPlayers != null && event._count.registrations >= event.maxPlayers;
|
||||||
event._count.registrations >= event.maxPlayers;
|
|
||||||
const hasEnded =
|
const hasEnded =
|
||||||
event.status === "completed" ||
|
event.status === "completed" ||
|
||||||
(event.endsAt != null && event.endsAt.getTime() < Date.now());
|
(event.endsAt != null && event.endsAt.getTime() < Date.now());
|
||||||
@@ -157,7 +159,11 @@ export default async function EventDetailPage({
|
|||||||
</div>
|
</div>
|
||||||
</ContentCard>
|
</ContentCard>
|
||||||
|
|
||||||
<ContentCard icon="✍️" title={t("registerTitle")} subtitle={t("registerSubtitle")}>
|
<ContentCard
|
||||||
|
icon="✍️"
|
||||||
|
title={t("registerTitle")}
|
||||||
|
subtitle={t("registerSubtitle")}
|
||||||
|
>
|
||||||
{session?.user && canRegister ? (
|
{session?.user && canRegister ? (
|
||||||
<EventRegisterButton eventId={event.id} />
|
<EventRegisterButton eventId={event.id} />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -112,7 +112,13 @@ export default async function EventsPage() {
|
|||||||
>
|
>
|
||||||
{formatDate(event.startsAt, "datetime", "")}
|
{formatDate(event.startsAt, "datetime", "")}
|
||||||
</p>
|
</p>
|
||||||
<p style={{ margin: 0, fontSize: "0.88rem", lineHeight: 1.5 }}>
|
<p
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: "0.88rem",
|
||||||
|
lineHeight: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{excerpt(event.description, 120)}
|
{excerpt(event.description, 120)}
|
||||||
</p>
|
</p>
|
||||||
<p
|
<p
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { CSSProperties } from "react";
|
|||||||
import { replyToThread } from "@/actions/social";
|
import { replyToThread } from "@/actions/social";
|
||||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
|
import { formatDate } from "@/lib/format-date";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const revalidate = 60;
|
export const revalidate = 60;
|
||||||
@@ -27,11 +28,6 @@ function feedbackStyle(tone: "success" | "error"): CSSProperties {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTimestamp(ts: number | null | undefined): string {
|
|
||||||
if (!ts) return "";
|
|
||||||
return new Date(ts * 1000).toISOString().slice(0, 16).replace("T", " ");
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function GuildForumThreadPage({
|
export default async function GuildForumThreadPage({
|
||||||
params,
|
params,
|
||||||
searchParams,
|
searchParams,
|
||||||
@@ -220,7 +216,9 @@ export default async function GuildForumThreadPage({
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="muted" style={{ fontSize: "0.85rem" }}>
|
<span className="muted" style={{ fontSize: "0.85rem" }}>
|
||||||
{formatTimestamp(post.createdAt)}
|
{formatDate(
|
||||||
|
post.createdAt ? new Date(post.createdAt * 1000) : null,
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p
|
||||||
@@ -240,7 +238,11 @@ export default async function GuildForumThreadPage({
|
|||||||
</ContentCard>
|
</ContentCard>
|
||||||
|
|
||||||
{canReply ? (
|
{canReply ? (
|
||||||
<ContentCard icon="✍️" title={t("replyTitle")} subtitle={t("replySubtitle")}>
|
<ContentCard
|
||||||
|
icon="✍️"
|
||||||
|
title={t("replyTitle")}
|
||||||
|
subtitle={t("replySubtitle")}
|
||||||
|
>
|
||||||
<form action={replyToThread}>
|
<form action={replyToThread}>
|
||||||
<input type="hidden" name="guildId" value={String(guild.id)} />
|
<input type="hidden" name="guildId" value={String(guild.id)} />
|
||||||
<input type="hidden" name="threadId" value={String(thread.id)} />
|
<input type="hidden" name="threadId" value={String(thread.id)} />
|
||||||
@@ -266,8 +268,7 @@ export default async function GuildForumThreadPage({
|
|||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="muted" style={{ margin: 0 }}>
|
<p className="muted" style={{ margin: 0 }}>
|
||||||
{t("loginToReply")}{" "}
|
{t("loginToReply")} <Link href="/login">{t("loginLink")}</Link>
|
||||||
<Link href="/login">{t("loginLink")}</Link>
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { notFound } from "next/navigation";
|
|||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||||
|
import { formatDate } from "@/lib/format-date";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const revalidate = 300;
|
export const revalidate = 300;
|
||||||
@@ -36,11 +37,6 @@ type ThreadRow = {
|
|||||||
locked: number | null;
|
locked: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatTimestamp(ts: number | null | undefined): string {
|
|
||||||
if (!ts) return "";
|
|
||||||
return new Date(ts * 1000).toISOString().slice(0, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function GuildForumPage({
|
export default async function GuildForumPage({
|
||||||
params,
|
params,
|
||||||
searchParams,
|
searchParams,
|
||||||
@@ -194,7 +190,10 @@ export default async function GuildForumPage({
|
|||||||
{t2.postsCount ?? 0}
|
{t2.postsCount ?? 0}
|
||||||
</td>
|
</td>
|
||||||
<td className="muted" style={{ padding: "0.6rem 0.75rem" }}>
|
<td className="muted" style={{ padding: "0.6rem 0.75rem" }}>
|
||||||
{formatTimestamp(t2.createdAt)}
|
{formatDate(
|
||||||
|
t2.createdAt ? new Date(t2.createdAt * 1000) : null,
|
||||||
|
"date",
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { notFound } from "next/navigation";
|
|||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { ContentCard, EmptyState, StatBlock } from "@/components/public/ui";
|
import { ContentCard, EmptyState, StatBlock } from "@/components/public/ui";
|
||||||
import { avatarImageUrl } from "@/lib/format";
|
import { avatarImageUrl } from "@/lib/format";
|
||||||
|
import { formatDate } from "@/lib/format-date";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
|
||||||
@@ -26,11 +27,6 @@ type MemberView = {
|
|||||||
isOwner: boolean;
|
isOwner: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatTimestamp(ts: number): string {
|
|
||||||
if (!ts) return "";
|
|
||||||
return new Date(ts * 1000).toISOString().slice(0, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function GuildPage({
|
export default async function GuildPage({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
@@ -123,7 +119,7 @@ export default async function GuildPage({
|
|||||||
return a.memberSince - b.memberSince;
|
return a.memberSince - b.memberSince;
|
||||||
});
|
});
|
||||||
|
|
||||||
const created = formatTimestamp(guild.dateCreated);
|
const created = formatDate(new Date(guild.dateCreated * 1000), "date");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ import Link from "next/link";
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { ContentCard } from "@/components/public/ui";
|
import { ContentCard } from "@/components/public/ui";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { sanitize } from "@/lib/sanitize";
|
|
||||||
import { SanitizedHtml } from "@/components/shared/sanitized-html";
|
import { SanitizedHtml } from "@/components/shared/sanitized-html";
|
||||||
import { resolveHotelName } from "@/lib/hotel-name";
|
import { resolveHotelName } from "@/lib/hotel-name";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { sanitize } from "@/lib/sanitize";
|
||||||
|
|
||||||
export const revalidate = 300;
|
export const revalidate = 300;
|
||||||
|
|
||||||
|
|||||||
@@ -99,9 +99,7 @@ export default async function HelpTicketDetailPage({
|
|||||||
replies = [];
|
replies = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const authorIds = [
|
const authorIds = [...new Set([userId, ...replies.map((r) => r.userId)])];
|
||||||
...new Set([userId, ...replies.map((r) => r.userId)]),
|
|
||||||
];
|
|
||||||
let users: { id: number; username: string }[] = [];
|
let users: { id: number; username: string }[] = [];
|
||||||
try {
|
try {
|
||||||
users = await prisma.user.findMany({
|
users = await prisma.user.findMany({
|
||||||
@@ -174,7 +172,11 @@ export default async function HelpTicketDetailPage({
|
|||||||
<Link href="/help/tickets">{t("backToList")}</Link>
|
<Link href="/help/tickets">{t("backToList")}</Link>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<ContentCard icon="🎫" title={ticket.title} subtitle={t("ticketId", { id: String(ticket.id) })}>
|
<ContentCard
|
||||||
|
icon="🎫"
|
||||||
|
title={ticket.title}
|
||||||
|
subtitle={t("ticketId", { id: String(ticket.id) })}
|
||||||
|
>
|
||||||
<span className={`online-badge${ticket.open ? " online" : ""}`}>
|
<span className={`online-badge${ticket.open ? " online" : ""}`}>
|
||||||
<span className="online-dot" aria-hidden />
|
<span className="online-dot" aria-hidden />
|
||||||
{ticket.open ? t("statusOpen") : t("statusClosed")}
|
{ticket.open ? t("statusOpen") : t("statusClosed")}
|
||||||
@@ -220,7 +222,9 @@ export default async function HelpTicketDetailPage({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ fontWeight: 700 }}>
|
<span style={{ fontWeight: 700 }}>
|
||||||
{index === 0 ? t("you") : username ? (
|
{index === 0 ? (
|
||||||
|
t("you")
|
||||||
|
) : username ? (
|
||||||
<Link href={`/u/${username}`}>{username}</Link>
|
<Link href={`/u/${username}`}>{username}</Link>
|
||||||
) : (
|
) : (
|
||||||
<span className="muted">
|
<span className="muted">
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ import { auth } from "@/lib/auth";
|
|||||||
* Public site chrome. Route group `(site)` keeps this off `/admin` and `/client`,
|
* Public site chrome. Route group `(site)` keeps this off `/admin` and `/client`,
|
||||||
* so housekeeping is never constrained by the public max-w-7xl grid.
|
* so housekeeping is never constrained by the public max-w-7xl grid.
|
||||||
*/
|
*/
|
||||||
export default async function SiteLayout({ children }: { children: ReactNode }) {
|
export default async function SiteLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
+191
-61
@@ -3,25 +3,29 @@ import Image from "next/image";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { LoginForm } from "@/components/auth/login-form";
|
import { LoginForm } from "@/components/auth/login-form";
|
||||||
|
import { Clock } from "@/components/clock";
|
||||||
|
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||||
|
import { Reveal } from "@/components/motion-reveal";
|
||||||
|
import { ThemeSwitcher } from "@/components/theme-switcher";
|
||||||
import { cached } from "@/lib/cache";
|
import { cached } from "@/lib/cache";
|
||||||
import { avatarImageUrl } from "@/lib/format";
|
import { avatarImageUrl } from "@/lib/format";
|
||||||
|
import { resolveHotelName } from "@/lib/hotel-name";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { captchaConfig } from "@/lib/services/captcha";
|
import { captchaConfig } from "@/lib/services/captcha";
|
||||||
import { resolveHotelName } from "@/lib/hotel-name";
|
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
import { ThemeSwitcher } from "@/components/theme-switcher";
|
|
||||||
import { LanguageSwitcher } from "@/components/language-switcher";
|
|
||||||
|
|
||||||
export default async function LoginPage() {
|
export default async function LoginPage() {
|
||||||
const t = await getTranslations("pages.login");
|
const t = await getTranslations("pages.login");
|
||||||
const th = await getTranslations("header");
|
const th = await getTranslations("header");
|
||||||
const [hotelName, imagerRaw, cfg] = await Promise.all([
|
const [hotelName, imagerRaw, cfg, logo] = await Promise.all([
|
||||||
resolveHotelName(),
|
resolveHotelName(),
|
||||||
siteSettings.get(
|
siteSettings.get(
|
||||||
"habbo_imaging_url",
|
"habbo_imaging_url",
|
||||||
"https://www.habbo.com/habbo-imaging/avatarimage",
|
"https://www.habbo.com/habbo-imaging/avatarimage",
|
||||||
),
|
),
|
||||||
captchaConfig(),
|
captchaConfig(),
|
||||||
|
siteSettings.get("radio_social_discord", ""),
|
||||||
|
siteSettings.get("cms_logo", ""),
|
||||||
]);
|
]);
|
||||||
const imager = imagerRaw ?? "https://www.habbo.com/habbo-imaging/avatarimage";
|
const imager = imagerRaw ?? "https://www.habbo.com/habbo-imaging/avatarimage";
|
||||||
const nonce = (await headers()).get("x-nonce") ?? undefined;
|
const nonce = (await headers()).get("x-nonce") ?? undefined;
|
||||||
@@ -47,45 +51,92 @@ export default async function LoginPage() {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-10 pb-14">
|
||||||
<div className="relative">
|
{/* ── Top Bar ── */}
|
||||||
|
<Reveal>
|
||||||
<div
|
<div
|
||||||
className="absolute -inset-6 rounded-full opacity-[0.06] blur-[40px]"
|
className="relative flex items-center justify-between rounded-xl border px-5 py-3"
|
||||||
style={{ background: "var(--color-primary)" }}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="relative flex items-center justify-between rounded-2xl border px-5 py-3"
|
|
||||||
style={{
|
style={{
|
||||||
background: "color-mix(in srgb, var(--color-surface) 50%, transparent)",
|
background:
|
||||||
backdropFilter: "blur(12px)",
|
"color-mix(in srgb, var(--color-surface) 60%, transparent)",
|
||||||
WebkitBackdropFilter: "blur(12px)",
|
backdropFilter: "blur(14px)",
|
||||||
borderColor: "color-mix(in srgb, var(--color-text-muted) 8%, transparent)",
|
WebkitBackdropFilter: "blur(14px)",
|
||||||
|
borderColor:
|
||||||
|
"color-mix(in srgb, var(--color-text-muted) 8%, transparent)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="text-xs font-bold uppercase tracking-[0.15em] opacity-40" style={{ color: "var(--color-text-muted)" }}>
|
<div className="flex items-center gap-3">
|
||||||
|
{logo ? (
|
||||||
|
<Image
|
||||||
|
src={logo}
|
||||||
|
alt={hotelName}
|
||||||
|
width={100}
|
||||||
|
height={28}
|
||||||
|
className="shrink-0 object-contain"
|
||||||
|
unoptimized
|
||||||
|
style={{ maxHeight: 28, width: "auto", height: "auto" }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Image
|
||||||
|
src="/assets/images/icons/lighthouse.png"
|
||||||
|
alt={hotelName}
|
||||||
|
width={28}
|
||||||
|
height={28}
|
||||||
|
className="shrink-0"
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className="text-sm font-extrabold uppercase tracking-wide"
|
||||||
|
style={{ color: "var(--color-text-readable)" }}
|
||||||
|
>
|
||||||
{hotelName}
|
{hotelName}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-1">
|
</div>
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1.5 rounded-lg px-2 py-1"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"color-mix(in srgb, var(--color-text-muted) 4%, transparent)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
<div className="w-px h-5 mx-1" style={{ background: "color-mix(in srgb, var(--color-text-muted) 12%, transparent)" }} />
|
<div
|
||||||
|
className="w-px h-5"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Clock />
|
||||||
|
<div
|
||||||
|
className="w-px h-5"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<ThemeSwitcher />
|
<ThemeSwitcher />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Reveal>
|
||||||
<div className="flex flex-col lg:flex-row gap-10">
|
<Reveal>
|
||||||
|
<div className="flex flex-col lg:flex-row gap-10 stagger-children">
|
||||||
{/* Left panel */}
|
{/* Left panel */}
|
||||||
<div className="lg:w-80 shrink-0 space-y-4">
|
<div className="lg:w-80 shrink-0 space-y-4">
|
||||||
<div
|
<div
|
||||||
className="relative overflow-hidden rounded-2xl border p-6 text-center"
|
className="relative overflow-hidden rounded-2xl border p-6 text-center card-premium"
|
||||||
style={{
|
style={{
|
||||||
borderColor: "color-mix(in srgb, var(--color-primary) 20%, transparent)",
|
borderColor:
|
||||||
|
"color-mix(in srgb, var(--color-primary) 20%, transparent)",
|
||||||
background: `url(/assets/images/background.png) center/cover no-repeat`,
|
background: `url(/assets/images/background.png) center/cover no-repeat`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0"
|
className="absolute inset-0"
|
||||||
style={{
|
style={{
|
||||||
background: "linear-gradient(135deg, color-mix(in srgb, var(--color-surface) 85%, transparent) 0%, color-mix(in srgb, var(--color-surface) 40%, transparent) 100%)",
|
background:
|
||||||
|
"linear-gradient(135deg, color-mix(in srgb, var(--color-surface) 85%, transparent) 0%, color-mix(in srgb, var(--color-surface) 40%, transparent) 100%)",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -94,16 +145,19 @@ export default async function LoginPage() {
|
|||||||
alt="Frank"
|
alt="Frank"
|
||||||
width={130}
|
width={130}
|
||||||
height={170}
|
height={170}
|
||||||
className="object-contain mx-auto drop-shadow-xl"
|
className="object-contain mx-auto drop-shadow-xl animate-float"
|
||||||
unoptimized
|
unoptimized
|
||||||
priority
|
priority
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className="inline-flex items-center gap-2 px-3.5 py-1 rounded-full text-xs font-bold uppercase tracking-wider mt-4 mb-3 border backdrop-blur-sm"
|
className="inline-flex items-center gap-2 px-3.5 py-1 rounded-full text-xs font-bold uppercase tracking-wider mt-4 mb-3 border backdrop-blur-sm"
|
||||||
style={{
|
style={{
|
||||||
borderColor: "color-mix(in srgb, var(--color-primary) 18%, transparent)",
|
borderColor:
|
||||||
background: "color-mix(in srgb, var(--color-primary) 10%, transparent)",
|
"color-mix(in srgb, var(--color-primary) 18%, transparent)",
|
||||||
color: "var(--color-primary-readable, var(--color-primary))",
|
background:
|
||||||
|
"color-mix(in srgb, var(--color-primary) 10%, transparent)",
|
||||||
|
color:
|
||||||
|
"var(--color-primary-readable, var(--color-primary))",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="relative flex h-2 w-2">
|
<span className="relative flex h-2 w-2">
|
||||||
@@ -114,7 +168,10 @@ export default async function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
<h1
|
<h1
|
||||||
className="text-xl font-black"
|
className="text-xl font-black"
|
||||||
style={{ color: "var(--color-text-readable)" }}
|
style={{
|
||||||
|
color: "var(--color-text-readable)",
|
||||||
|
fontFamily: "var(--font-nunito)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{t("welcomeBack")}
|
{t("welcomeBack")}
|
||||||
</h1>
|
</h1>
|
||||||
@@ -129,32 +186,56 @@ export default async function LoginPage() {
|
|||||||
|
|
||||||
{recentUsers.length > 0 && (
|
{recentUsers.length > 0 && (
|
||||||
<div
|
<div
|
||||||
className="rounded-2xl border p-4"
|
className="overflow-hidden rounded-xl border"
|
||||||
style={{
|
style={{
|
||||||
background: "color-mix(in srgb, var(--color-surface) 70%, transparent)",
|
borderColor:
|
||||||
backdropFilter: "blur(8px)",
|
"color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
|
||||||
WebkitBackdropFilter: "blur(8px)",
|
|
||||||
borderColor: "color-mix(in srgb, var(--color-text-muted) 8%, transparent)",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2.5 px-4 py-3"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"linear-gradient(140deg, #0A1F0A 0%, #0F2D0F 20%, #153C15 40%, #1B4A1B 60%, #205820 75%, #1A4A1A 90%, #143814 100%)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src="/assets/images/icons/navigation/goody.png"
|
||||||
|
alt=""
|
||||||
|
width={18}
|
||||||
|
height={18}
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
<h3
|
<h3
|
||||||
className="text-xs font-bold uppercase tracking-widest mb-3 flex items-center gap-2"
|
className="text-sm font-bold text-white"
|
||||||
style={{ color: "var(--color-text-muted)" }}
|
style={{ fontFamily: "var(--font-nunito)" }}
|
||||||
>
|
>
|
||||||
<span className="w-2 h-2 rounded-full bg-green-500" />
|
|
||||||
{th("online", { count: online, hotel: "" }).trim()}
|
{th("online", { count: online, hotel: "" }).trim()}
|
||||||
</h3>
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="p-4"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"color-mix(in srgb, var(--color-surface) 70%, transparent)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div className="grid grid-cols-4 gap-1.5">
|
<div className="grid grid-cols-4 gap-1.5">
|
||||||
{recentUsers.map((u) => (
|
{recentUsers.map((u) => (
|
||||||
<div
|
<div
|
||||||
key={u.username}
|
key={u.username}
|
||||||
className="flex flex-col items-center gap-0.5 p-1.5 rounded-lg"
|
className="flex flex-col items-center gap-0.5 p-1.5 rounded-lg"
|
||||||
style={{
|
style={{
|
||||||
background: "color-mix(in srgb, var(--color-primary) 4%, transparent)",
|
background:
|
||||||
|
"color-mix(in srgb, var(--color-primary) 4%, transparent)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
src={avatarImageUrl(imager, u.look, { headOnly: true, direction: 2, size: "s" })}
|
src={avatarImageUrl(imager, u.look, {
|
||||||
|
headOnly: true,
|
||||||
|
direction: 2,
|
||||||
|
size: "s",
|
||||||
|
})}
|
||||||
alt=""
|
alt=""
|
||||||
width={26}
|
width={26}
|
||||||
height={32}
|
height={32}
|
||||||
@@ -171,35 +252,61 @@ export default async function LoginPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{latestUsers.length > 0 && (
|
{latestUsers.length > 0 && (
|
||||||
<div
|
<div
|
||||||
className="rounded-2xl border p-4"
|
className="overflow-hidden rounded-xl border"
|
||||||
style={{
|
style={{
|
||||||
background: "color-mix(in srgb, var(--color-surface) 70%, transparent)",
|
borderColor:
|
||||||
backdropFilter: "blur(8px)",
|
"color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
|
||||||
WebkitBackdropFilter: "blur(8px)",
|
|
||||||
borderColor: "color-mix(in srgb, var(--color-text-muted) 8%, transparent)",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<h3
|
<div
|
||||||
className="text-xs font-bold uppercase tracking-widest mb-3 flex items-center gap-2"
|
className="flex items-center gap-2.5 px-4 py-3"
|
||||||
style={{ color: "var(--color-text-muted)" }}
|
style={{
|
||||||
|
background:
|
||||||
|
"linear-gradient(140deg, #5a2d82 0%, #6b3a9e 20%, #7d47b8 40%, #8f54d0 60%, #7d47b8 75%, #6b3a9e 90%, #5a2d82 100%)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
✨ Newest citizens
|
<Image
|
||||||
|
src="/assets/images/icons/friends.png"
|
||||||
|
alt=""
|
||||||
|
width={18}
|
||||||
|
height={18}
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
<h3
|
||||||
|
className="text-sm font-bold text-white"
|
||||||
|
style={{ fontFamily: "var(--font-nunito)" }}
|
||||||
|
>
|
||||||
|
Newest citizens
|
||||||
</h3>
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="p-4"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"color-mix(in srgb, var(--color-surface) 70%, transparent)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div className="grid grid-cols-4 gap-1.5">
|
<div className="grid grid-cols-4 gap-1.5">
|
||||||
{latestUsers.map((u) => (
|
{latestUsers.map((u) => (
|
||||||
<div
|
<div
|
||||||
key={u.username}
|
key={u.username}
|
||||||
className="flex flex-col items-center gap-0.5 p-1.5 rounded-lg"
|
className="flex flex-col items-center gap-0.5 p-1.5 rounded-lg"
|
||||||
style={{
|
style={{
|
||||||
background: "color-mix(in srgb, var(--color-primary) 4%, transparent)",
|
background:
|
||||||
|
"color-mix(in srgb, var(--color-primary) 4%, transparent)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
src={avatarImageUrl(imager, u.look, { headOnly: true, direction: 2, size: "s" })}
|
src={avatarImageUrl(imager, u.look, {
|
||||||
|
headOnly: true,
|
||||||
|
direction: 2,
|
||||||
|
size: "s",
|
||||||
|
})}
|
||||||
alt=""
|
alt=""
|
||||||
width={26}
|
width={26}
|
||||||
height={32}
|
height={32}
|
||||||
@@ -216,14 +323,20 @@ export default async function LoginPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<p className="text-xs text-center" style={{ color: "var(--color-text-muted)" }}>
|
<p
|
||||||
|
className="text-xs text-center"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
{t("noAccount") ?? "No account?"}{" "}
|
{t("noAccount") ?? "No account?"}{" "}
|
||||||
<Link
|
<Link
|
||||||
href="/register"
|
href="/register"
|
||||||
className="font-extrabold hover:underline"
|
className="font-extrabold hover:underline"
|
||||||
style={{ color: "var(--color-primary-readable, var(--color-primary))" }}
|
style={{
|
||||||
|
color: "var(--color-primary-readable, var(--color-primary))",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{t("createOne") ?? "Create one"}
|
{t("createOne") ?? "Create one"}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -233,28 +346,43 @@ export default async function LoginPage() {
|
|||||||
{/* Right: form */}
|
{/* Right: form */}
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div
|
<div
|
||||||
className="rounded-2xl border p-6"
|
className="overflow-hidden rounded-xl border"
|
||||||
style={{
|
style={{
|
||||||
background: "color-mix(in srgb, var(--color-surface) 70%, transparent)",
|
borderColor:
|
||||||
backdropFilter: "blur(8px)",
|
"color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
|
||||||
WebkitBackdropFilter: "blur(8px)",
|
|
||||||
borderColor: "color-mix(in srgb, var(--color-text-muted) 8%, transparent)",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="mb-6">
|
<div
|
||||||
|
className="flex items-center gap-2.5 px-4 py-3"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"linear-gradient(140deg, #1a3a5c 0%, #1e4a6e 20%, #2a5a80 40%, #3a6a90 60%, #4a7aa0 75%, #3a6a90 90%, #2a5a80 100%)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src="/assets/images/icons/navigation/me.png"
|
||||||
|
alt=""
|
||||||
|
width={18}
|
||||||
|
height={18}
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
<h2
|
<h2
|
||||||
className="text-2xl font-black tracking-tight"
|
className="text-sm font-bold text-white"
|
||||||
style={{ color: "var(--color-text-readable)" }}
|
style={{ fontFamily: "var(--font-nunito)" }}
|
||||||
>
|
>
|
||||||
{t("title")}
|
{t("title")}
|
||||||
</h2>
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="p-6"
|
||||||
|
style={{ backgroundColor: "var(--color-surface)" }}
|
||||||
|
>
|
||||||
<p
|
<p
|
||||||
className="text-sm mt-0.5"
|
className="text-sm mb-6"
|
||||||
style={{ color: "var(--color-text-muted)" }}
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
>
|
>
|
||||||
{t("subtitle")}
|
{t("subtitle")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
<LoginForm
|
<LoginForm
|
||||||
captcha={{
|
captcha={{
|
||||||
provider: cfg.provider,
|
provider: cfg.provider,
|
||||||
@@ -267,5 +395,7 @@ export default async function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { ContentCard, EmptyState, StatBlock } from "@/components/public/ui";
|
import { ContentCard, EmptyState, StatBlock } from "@/components/public/ui";
|
||||||
|
import { formatDate } from "@/lib/format-date";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const revalidate = 300;
|
export const revalidate = 300;
|
||||||
@@ -26,10 +27,6 @@ function listedAgo(ts: number, t: RelativeTimeTranslator): string {
|
|||||||
return t("daysAgo", { count: days });
|
return t("daysAgo", { count: days });
|
||||||
}
|
}
|
||||||
|
|
||||||
function listedAt(ts: number): string {
|
|
||||||
return new Date(ts * 1000).toISOString().slice(0, 16).replace("T", " ");
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function MarketplacePage() {
|
export default async function MarketplacePage() {
|
||||||
const t = await getTranslations("pages.marketplace");
|
const t = await getTranslations("pages.marketplace");
|
||||||
let offers: {
|
let offers: {
|
||||||
@@ -127,7 +124,10 @@ export default async function MarketplacePage() {
|
|||||||
<td style={{ fontWeight: 700 }}>
|
<td style={{ fontWeight: 700 }}>
|
||||||
{o.price.toLocaleString()} cr
|
{o.price.toLocaleString()} cr
|
||||||
</td>
|
</td>
|
||||||
<td className="muted" title={listedAt(o.timestamp)}>
|
<td
|
||||||
|
className="muted"
|
||||||
|
title={formatDate(new Date(o.timestamp * 1000))}
|
||||||
|
>
|
||||||
{listedAgo(o.timestamp, t)}
|
{listedAgo(o.timestamp, t)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
+386
-82
@@ -3,7 +3,8 @@ import Link from "next/link";
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import type { CSSProperties, ReactNode } from "react";
|
import type { CSSProperties, ReactNode } from "react";
|
||||||
import { claimReferral } from "@/actions/referral";
|
import { claimReferral } from "@/actions/referral";
|
||||||
import { ContentCard, EmptyState, OnlineBadge } from "@/components/public/ui";
|
|
||||||
|
import { Reveal } from "@/components/motion-reveal";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { avatarImageUrl } from "@/lib/format";
|
import { avatarImageUrl } from "@/lib/format";
|
||||||
import { resolveHotelName } from "@/lib/hotel-name";
|
import { resolveHotelName } from "@/lib/hotel-name";
|
||||||
@@ -68,8 +69,23 @@ export default async function MePage({
|
|||||||
|
|
||||||
let content: ReactNode;
|
let content: ReactNode;
|
||||||
try {
|
try {
|
||||||
const [user, imagerBase, hotelName, neededRaw, amountRaw, currencyRaw] =
|
const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584";
|
||||||
await Promise.all([
|
|
||||||
|
const [
|
||||||
|
user,
|
||||||
|
imagerBase,
|
||||||
|
hotelName,
|
||||||
|
neededRaw,
|
||||||
|
amountRaw,
|
||||||
|
currencyRaw,
|
||||||
|
alertRaw,
|
||||||
|
recentRooms,
|
||||||
|
badges,
|
||||||
|
lastWebLogin,
|
||||||
|
userSettings,
|
||||||
|
friendCount,
|
||||||
|
unreadCount,
|
||||||
|
] = await Promise.all([
|
||||||
prisma.user.findUnique({
|
prisma.user.findUnique({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
select: {
|
select: {
|
||||||
@@ -80,6 +96,8 @@ export default async function MePage({
|
|||||||
rank: true,
|
rank: true,
|
||||||
credits: true,
|
credits: true,
|
||||||
accountCreated: true,
|
accountCreated: true,
|
||||||
|
lastLogin: true,
|
||||||
|
lastOnline: true,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
siteSettings.get(
|
siteSettings.get(
|
||||||
@@ -103,17 +121,56 @@ export default async function MePage({
|
|||||||
.findFirst({
|
.findFirst({
|
||||||
where: {
|
where: {
|
||||||
key: {
|
key: {
|
||||||
in: [
|
in: ["referral_reward_currency_type", "referral_reward_currency"],
|
||||||
"referral_reward_currency_type",
|
|
||||||
"referral_reward_currency",
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
select: { value: true },
|
select: { value: true },
|
||||||
})
|
})
|
||||||
.catch(() => null),
|
.catch(() => null),
|
||||||
|
siteSettings.get("hotel_alert_banner", ""),
|
||||||
|
prisma.rooms
|
||||||
|
.findMany({
|
||||||
|
orderBy: { id: "desc" },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
ownerName: true,
|
||||||
|
users: true,
|
||||||
|
usersMax: true,
|
||||||
|
},
|
||||||
|
take: 6,
|
||||||
|
})
|
||||||
|
.catch(() => []),
|
||||||
|
prisma.usersBadges
|
||||||
|
.findMany({
|
||||||
|
where: { userId, slotId: { gt: 0 } },
|
||||||
|
orderBy: { slotId: "asc" },
|
||||||
|
select: { badgeCode: true, slotId: true },
|
||||||
|
})
|
||||||
|
.catch(() => []),
|
||||||
|
prisma.websiteLoginLogs
|
||||||
|
.findFirst({
|
||||||
|
where: { userId },
|
||||||
|
orderBy: { id: "desc" },
|
||||||
|
select: { createdAt: true },
|
||||||
|
})
|
||||||
|
.catch(() => null),
|
||||||
|
prisma.usersSettings
|
||||||
|
.findUnique({
|
||||||
|
where: { userId },
|
||||||
|
select: { achievementScore: true, respectsReceived: true },
|
||||||
|
})
|
||||||
|
.catch(() => null),
|
||||||
|
prisma.messengerFriendships
|
||||||
|
.count({
|
||||||
|
where: { OR: [{ userOneId: userId }, { userTwoId: userId }] },
|
||||||
|
})
|
||||||
|
.catch(() => 0),
|
||||||
|
prisma.messengerOffline.count({ where: { userId } }).catch(() => 0),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
void lastWebLogin;
|
||||||
|
|
||||||
if (!user) throw new Error("user-not-found");
|
if (!user) throw new Error("user-not-found");
|
||||||
|
|
||||||
const needed = Number.parseInt(neededRaw?.value ?? "5", 10) || 5;
|
const needed = Number.parseInt(neededRaw?.value ?? "5", 10) || 5;
|
||||||
@@ -168,18 +225,26 @@ export default async function MePage({
|
|||||||
const registered = new Date(user.accountCreated * 1000)
|
const registered = new Date(user.accountCreated * 1000)
|
||||||
.toISOString()
|
.toISOString()
|
||||||
.slice(0, 10);
|
.slice(0, 10);
|
||||||
|
const lastLoginDate = user.lastLogin
|
||||||
|
? new Date(user.lastLogin * 1000).toISOString().slice(0, 10)
|
||||||
|
: "Never";
|
||||||
|
const lastOnlineDate = user.lastOnline
|
||||||
|
? new Date(user.lastOnline * 1000).toISOString().slice(0, 10)
|
||||||
|
: "Never";
|
||||||
const referralLink = `/register?ref=${encodeURIComponent(user.username)}`;
|
const referralLink = `/register?ref=${encodeURIComponent(user.username)}`;
|
||||||
|
|
||||||
const imager = imagerBase ?? "https://www.habbo.com/habbo-imaging/avatarimage";
|
const imager =
|
||||||
const avatarFull = avatarImageUrl(imager, user.look, { size: "l", direction: 2 });
|
imagerBase ?? "https://www.habbo.com/habbo-imaging/avatarimage";
|
||||||
const avatarHead = avatarImageUrl(imager, user.look, { headOnly: true, direction: 2, size: "s" });
|
const avatarFull = avatarImageUrl(imager, user.look, {
|
||||||
|
size: "l",
|
||||||
|
direction: 2,
|
||||||
|
});
|
||||||
content = (
|
content = (
|
||||||
<div className="flex flex-col gap-8 pb-8">
|
<div className="flex flex-col gap-8 pb-8">
|
||||||
{/* Feedback banners */}
|
|
||||||
{claimed ? (
|
{claimed ? (
|
||||||
<div role="status" style={feedbackStyle("success")}>
|
<div role="status" style={feedbackStyle("success")}>
|
||||||
Woah! You have successfully claimed your reward — keep up the good work!
|
Woah! You have successfully claimed your reward — keep up the good
|
||||||
|
work!
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{error ? (
|
{error ? (
|
||||||
@@ -188,35 +253,63 @@ export default async function MePage({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* ── Profile header ── */}
|
<Reveal>
|
||||||
<div
|
<div
|
||||||
className="rounded-2xl border p-6 flex flex-col sm:flex-row items-center sm:items-start gap-6"
|
className="relative overflow-hidden rounded-2xl border p-6 flex items-start gap-6"
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 10%, var(--color-surface)), var(--color-surface))`,
|
background: `linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 12%, var(--color-surface)), var(--color-surface))`,
|
||||||
borderColor: "color-mix(in srgb, var(--color-primary) 20%, transparent)",
|
borderColor:
|
||||||
|
"color-mix(in srgb, var(--color-primary) 20%, transparent)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="relative shrink-0">
|
<div
|
||||||
|
className="absolute -top-10 -right-10 w-40 h-40 rounded-full opacity-10"
|
||||||
|
style={{ background: "var(--color-primary)" }}
|
||||||
|
/>
|
||||||
|
<div className="relative shrink-0 flex flex-col items-center gap-3">
|
||||||
<div
|
<div
|
||||||
className="absolute -inset-3 rounded-full opacity-[0.15] blur-[40px]"
|
className="absolute -inset-3 rounded-full opacity-[0.15] blur-[40px]"
|
||||||
style={{ background: "var(--color-primary)" }}
|
style={{ background: "var(--color-primary)" }}
|
||||||
/>
|
/>
|
||||||
<Image
|
<Image
|
||||||
src={avatarFull}
|
src={avatarFull}
|
||||||
alt={`${user.username} avatar`}
|
alt=""
|
||||||
width={120}
|
width={120}
|
||||||
height={160}
|
height={160}
|
||||||
className="object-contain drop-shadow-xl relative"
|
className="object-contain drop-shadow-xl relative"
|
||||||
unoptimized
|
unoptimized
|
||||||
priority
|
priority
|
||||||
/>
|
/>
|
||||||
</div>
|
<Link
|
||||||
<div className="flex-1 text-center sm:text-left">
|
href="/client"
|
||||||
<div
|
className="btn-shine flex items-center justify-center gap-1.5 rounded-xl border py-2 px-4 font-extrabold text-xs transition-all duration-200 hover:scale-[1.02] active:scale-95 shadow-lg"
|
||||||
className="inline-flex items-center gap-2 px-3.5 py-1 rounded-full text-xs font-bold uppercase tracking-wider mb-3 border"
|
|
||||||
style={{
|
style={{
|
||||||
borderColor: "color-mix(in srgb, var(--color-primary) 18%, transparent)",
|
background: "var(--color-primary)",
|
||||||
background: "color-mix(in srgb, var(--color-primary) 10%, transparent)",
|
color: "var(--color-primary-foreground)",
|
||||||
|
borderColor:
|
||||||
|
"color-mix(in srgb, var(--color-primary) 20%, transparent)",
|
||||||
|
boxShadow:
|
||||||
|
"0 4px 24px color-mix(in srgb, var(--color-primary) 35%, transparent)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src="/assets/images/EnterHubbly.png"
|
||||||
|
alt=""
|
||||||
|
width={16}
|
||||||
|
height={16}
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
Enter {hotelName}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0 pt-1">
|
||||||
|
<div
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-[11px] font-bold uppercase tracking-wider mb-3 border"
|
||||||
|
style={{
|
||||||
|
borderColor:
|
||||||
|
"color-mix(in srgb, var(--color-primary) 18%, transparent)",
|
||||||
|
background:
|
||||||
|
"color-mix(in srgb, var(--color-primary) 10%, transparent)",
|
||||||
color: "var(--color-primary-readable, var(--color-primary))",
|
color: "var(--color-primary-readable, var(--color-primary))",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -228,46 +321,74 @@ export default async function MePage({
|
|||||||
>
|
>
|
||||||
{user.username}
|
{user.username}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm mt-1" style={{ color: "var(--color-text-muted)" }}>
|
<p
|
||||||
|
className="text-sm mt-1"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
{user.motto || "No motto"}
|
{user.motto || "No motto"}
|
||||||
</p>
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-x-6 gap-y-1 mt-4 text-xs">
|
||||||
|
<span style={{ color: "var(--color-text-muted)" }}>
|
||||||
|
Registered:{" "}
|
||||||
|
<strong style={{ color: "var(--color-text-readable)" }}>
|
||||||
|
{registered}
|
||||||
|
</strong>
|
||||||
|
</span>
|
||||||
|
<span style={{ color: "var(--color-text-muted)" }}>
|
||||||
|
Last online:{" "}
|
||||||
|
<strong style={{ color: "var(--color-text-readable)" }}>
|
||||||
|
{lastOnlineDate}
|
||||||
|
</strong>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
{/* ── Enter hotel ── */}
|
{alertRaw ? (
|
||||||
<Link
|
<Reveal delay={0.05}>
|
||||||
href="/client"
|
<div
|
||||||
className="flex items-center justify-center gap-3 rounded-2xl border py-4 px-6 font-extrabold text-base transition-all duration-200 hover:scale-[1.02] active:scale-95 shadow-lg"
|
className="rounded-2xl border px-6 py-4 text-center"
|
||||||
style={{
|
style={{
|
||||||
background: "var(--color-primary)",
|
background:
|
||||||
color: "var(--color-primary-foreground)",
|
"color-mix(in srgb, var(--color-accent) 12%, transparent)",
|
||||||
borderColor: "color-mix(in srgb, var(--color-primary) 20%, transparent)",
|
borderColor:
|
||||||
boxShadow: "0 4px 24px color-mix(in srgb, var(--color-primary) 35%, transparent)",
|
"color-mix(in srgb, var(--color-accent) 25%, transparent)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Image
|
<p
|
||||||
src="/assets/images/EnterHubbly.png"
|
className="text-sm font-bold"
|
||||||
alt=""
|
style={{ color: "var(--color-accent)" }}
|
||||||
width={22}
|
>
|
||||||
height={22}
|
{alertRaw}
|
||||||
unoptimized
|
</p>
|
||||||
/>
|
</div>
|
||||||
Enter {hotelName}
|
</Reveal>
|
||||||
</Link>
|
) : null}
|
||||||
|
|
||||||
{/* ── Stat tiles ── */}
|
<Reveal delay={0.1}>
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
|
||||||
{[
|
{[
|
||||||
{ value: user.credits.toLocaleString(), label: "Credits" },
|
{ value: user.credits.toLocaleString(), label: "Credits" },
|
||||||
{ value: `Rank ${user.rank}`, label: "Hotel rank" },
|
{ value: `Rank ${user.rank}`, label: "Hotel rank" },
|
||||||
{ value: registered, label: "Registered" },
|
{
|
||||||
|
value: (userSettings?.achievementScore ?? 0).toLocaleString(),
|
||||||
|
label: "Achievement score",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: (userSettings?.respectsReceived ?? 0).toLocaleString(),
|
||||||
|
label: "Respects",
|
||||||
|
},
|
||||||
|
{ value: friendCount.toLocaleString(), label: "Friends" },
|
||||||
|
{ value: lastLoginDate, label: "Last login" },
|
||||||
].map((s) => (
|
].map((s) => (
|
||||||
<div
|
<div
|
||||||
key={s.label}
|
key={s.label}
|
||||||
className="rounded-2xl border p-5 text-center transition-all duration-200 hover:-translate-y-0.5"
|
className="relative rounded-2xl border p-5 text-center transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg"
|
||||||
style={{
|
style={{
|
||||||
background: "var(--color-surface)",
|
background: "var(--color-surface)",
|
||||||
borderColor: "color-mix(in srgb, var(--color-text-muted) 10%, transparent)",
|
borderColor:
|
||||||
|
"color-mix(in srgb, var(--color-primary) 12%, transparent)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -277,7 +398,7 @@ export default async function MePage({
|
|||||||
{s.value}
|
{s.value}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="text-xs font-bold uppercase tracking-widest"
|
className="text-[11px] font-bold uppercase tracking-widest"
|
||||||
style={{ color: "var(--color-text-muted)" }}
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
>
|
>
|
||||||
{s.label}
|
{s.label}
|
||||||
@@ -285,13 +406,15 @@ export default async function MePage({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
{/* ── Online friends ── */}
|
<Reveal delay={0.15}>
|
||||||
<div
|
<div
|
||||||
className="rounded-2xl border p-5"
|
className="rounded-2xl border p-5"
|
||||||
style={{
|
style={{
|
||||||
background: "var(--color-surface)",
|
background: "var(--color-surface)",
|
||||||
borderColor: "color-mix(in srgb, var(--color-text-muted) 10%, transparent)",
|
borderColor:
|
||||||
|
"color-mix(in srgb, var(--color-primary) 12%, transparent)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
@@ -302,7 +425,10 @@ export default async function MePage({
|
|||||||
>
|
>
|
||||||
Online friends
|
Online friends
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xs mt-0.5" style={{ color: "var(--color-text-muted)" }}>
|
<p
|
||||||
|
className="text-xs mt-0.5"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
{onlineFriends.length === 0
|
{onlineFriends.length === 0
|
||||||
? "None of your friends are online right now"
|
? "None of your friends are online right now"
|
||||||
: `${onlineFriends.length} of your ${friends.length} friends online`}
|
: `${onlineFriends.length} of your ${friends.length} friends online`}
|
||||||
@@ -318,30 +444,32 @@ export default async function MePage({
|
|||||||
</div>
|
</div>
|
||||||
{onlineFriends.length === 0 ? (
|
{onlineFriends.length === 0 ? (
|
||||||
<div className="text-center py-6">
|
<div className="text-center py-6">
|
||||||
<p className="text-sm" style={{ color: "var(--color-text-muted)" }}>
|
<p
|
||||||
|
className="text-sm"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
No friends online right now. Check back later!
|
No friends online right now. Check back later!
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||||
{onlineFriends.map((friend) => {
|
{onlineFriends.map((friend) => (
|
||||||
const avatar = avatarImageUrl(imager, friend.look, {
|
|
||||||
size: "s",
|
|
||||||
headOnly: true,
|
|
||||||
});
|
|
||||||
return (
|
|
||||||
<Link
|
<Link
|
||||||
key={friend.id}
|
key={friend.id}
|
||||||
href={`/u/${friend.username}`}
|
href={`/u/${friend.username}`}
|
||||||
className="flex items-center gap-3 p-3 rounded-xl transition-all duration-200 hover:-translate-y-0.5"
|
className="flex items-center gap-3 p-3 rounded-xl transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md"
|
||||||
style={{
|
style={{
|
||||||
background: "color-mix(in srgb, var(--color-primary) 4%, transparent)",
|
background:
|
||||||
|
"color-mix(in srgb, var(--color-primary) 4%, transparent)",
|
||||||
textDecoration: "none",
|
textDecoration: "none",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
src={avatar}
|
src={avatarImageUrl(imager, friend.look, {
|
||||||
alt={`${friend.username} avatar`}
|
size: "s",
|
||||||
|
headOnly: true,
|
||||||
|
})}
|
||||||
|
alt=""
|
||||||
width={40}
|
width={40}
|
||||||
height={50}
|
height={50}
|
||||||
className="rounded-lg shrink-0"
|
className="rounded-lg shrink-0"
|
||||||
@@ -364,23 +492,27 @@ export default async function MePage({
|
|||||||
className="inline-flex items-center gap-1 text-[10px] font-bold mt-1"
|
className="inline-flex items-center gap-1 text-[10px] font-bold mt-1"
|
||||||
style={{ color: "var(--color-success)" }}
|
style={{ color: "var(--color-success)" }}
|
||||||
>
|
>
|
||||||
<span className="w-1.5 h-1.5 rounded-full" style={{ background: "var(--color-success)" }} />
|
<span
|
||||||
|
className="w-1.5 h-1.5 rounded-full"
|
||||||
|
style={{ background: "var(--color-success)" }}
|
||||||
|
/>{" "}
|
||||||
Online
|
Online
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
{/* ── Referral card ── */}
|
<Reveal delay={0.2}>
|
||||||
<div
|
<div
|
||||||
className="rounded-2xl border p-5"
|
className="rounded-2xl border p-5"
|
||||||
style={{
|
style={{
|
||||||
background: "var(--color-surface)",
|
background: "var(--color-surface)",
|
||||||
borderColor: "color-mix(in srgb, var(--color-text-muted) 10%, transparent)",
|
borderColor:
|
||||||
|
"color-mix(in srgb, var(--color-primary) 12%, transparent)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
@@ -390,21 +522,27 @@ export default async function MePage({
|
|||||||
>
|
>
|
||||||
Refer a friend ({referralTotal}/{needed})
|
Refer a friend ({referralTotal}/{needed})
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xs mt-0.5" style={{ color: "var(--color-text-muted)" }}>
|
<p
|
||||||
|
className="text-xs mt-0.5"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
Refer new users and earn in-game rewards
|
Refer new users and earn in-game rewards
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<p className="text-sm" style={{ color: "var(--color-text-muted)" }}>
|
<p
|
||||||
|
className="text-sm"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
{rewardAmount > 0
|
{rewardAmount > 0
|
||||||
? `For every ${needed} users who register through your referral link you can claim a reward of ${rewardAmount.toLocaleString()} ${rewardCurrency}!`
|
? `For every ${needed} users who register through your referral link you can claim a reward of ${rewardAmount.toLocaleString()} ${rewardCurrency}!`
|
||||||
: `Invite ${needed} users through your referral link to claim a reward!`}
|
: `Invite ${needed} users through your referral link to claim a reward!`}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div role="note" style={feedbackStyle("warning")}>
|
<div role="note" style={feedbackStyle("warning")}>
|
||||||
⚠️ Boosting referrals by making your own accounts will lead to removal of all progress, currency, inventory and a potential ban.
|
Boosting referrals by making your own accounts will lead to
|
||||||
|
removal of all progress, currency, inventory and a potential
|
||||||
|
ban.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
htmlFor="referral-link"
|
htmlFor="referral-link"
|
||||||
@@ -419,26 +557,27 @@ export default async function MePage({
|
|||||||
type="text"
|
type="text"
|
||||||
readOnly
|
readOnly
|
||||||
defaultValue={referralLink}
|
defaultValue={referralLink}
|
||||||
className="flex-1 min-w-0 rounded-xl border-2 px-4 py-2.5 text-sm font-medium"
|
className="input-glow flex-1 min-w-0 rounded-xl border-2 px-4 py-2.5 text-sm font-medium"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: "var(--color-background)",
|
backgroundColor: "var(--color-background)",
|
||||||
color: "var(--color-text-readable, var(--color-text))",
|
color: "var(--color-text-readable, var(--color-text))",
|
||||||
borderColor: "color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
|
borderColor:
|
||||||
|
"color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<CopyReferralButton value={referralLink} />
|
<CopyReferralButton value={referralLink} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{canClaim ? (
|
{canClaim ? (
|
||||||
<form action={claimReferral}>
|
<form action={claimReferral}>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full rounded-xl font-extrabold text-sm py-3.5 transition-all duration-200 hover:scale-[1.02] active:scale-95 shadow-lg"
|
className="btn-shine w-full rounded-xl font-extrabold text-sm py-3.5 transition-all duration-200 hover:scale-[1.02] active:scale-95 shadow-lg"
|
||||||
style={{
|
style={{
|
||||||
background: "var(--color-primary)",
|
background: "var(--color-primary)",
|
||||||
color: "var(--color-primary-foreground)",
|
color: "var(--color-primary-foreground)",
|
||||||
boxShadow: "0 4px 24px color-mix(in srgb, var(--color-primary) 35%, transparent)",
|
boxShadow:
|
||||||
|
"0 4px 24px color-mix(in srgb, var(--color-primary) 35%, transparent)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Claim your referral reward!
|
Claim your referral reward!
|
||||||
@@ -450,7 +589,8 @@ export default async function MePage({
|
|||||||
disabled
|
disabled
|
||||||
className="w-full rounded-xl font-extrabold text-sm py-3.5 opacity-60 cursor-not-allowed"
|
className="w-full rounded-xl font-extrabold text-sm py-3.5 opacity-60 cursor-not-allowed"
|
||||||
style={{
|
style={{
|
||||||
background: "color-mix(in srgb, var(--color-text-muted) 15%, transparent)",
|
background:
|
||||||
|
"color-mix(in srgb, var(--color-text-muted) 15%, transparent)",
|
||||||
color: "var(--color-text-muted)",
|
color: "var(--color-text-muted)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -459,6 +599,165 @@ export default async function MePage({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
|
{recentRooms.length > 0 && (
|
||||||
|
<Reveal delay={0.25}>
|
||||||
|
<div
|
||||||
|
className="rounded-2xl border p-5"
|
||||||
|
style={{
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
borderColor:
|
||||||
|
"color-mix(in srgb, var(--color-primary) 12%, transparent)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h2
|
||||||
|
className="text-lg font-black tracking-tight"
|
||||||
|
style={{ color: "var(--color-text-readable)" }}
|
||||||
|
>
|
||||||
|
Recent rooms
|
||||||
|
</h2>
|
||||||
|
<p
|
||||||
|
className="text-xs mt-0.5"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
Newest rooms in the hotel
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/community"
|
||||||
|
className="text-xs font-bold hover:underline shrink-0"
|
||||||
|
style={{ color: "var(--color-primary)" }}
|
||||||
|
>
|
||||||
|
View all
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||||
|
{recentRooms.map((room) => (
|
||||||
|
<Link
|
||||||
|
key={room.id}
|
||||||
|
href={`/room/${room.id}`}
|
||||||
|
className="block rounded-xl border p-3 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"color-mix(in srgb, var(--color-primary) 4%, transparent)",
|
||||||
|
borderColor:
|
||||||
|
"color-mix(in srgb, var(--color-primary) 10%, transparent)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
className="text-sm font-extrabold truncate"
|
||||||
|
style={{ color: "var(--color-text-readable)" }}
|
||||||
|
>
|
||||||
|
{room.name}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className="text-[10px] font-semibold mt-0.5"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
{room.ownerName} · {room.users}/{room.usersMax}
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{badges.length > 0 && (
|
||||||
|
<Reveal delay={0.3}>
|
||||||
|
<div
|
||||||
|
className="rounded-2xl border p-5"
|
||||||
|
style={{
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
borderColor:
|
||||||
|
"color-mix(in srgb, var(--color-primary) 12%, transparent)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="mb-4">
|
||||||
|
<h2
|
||||||
|
className="text-lg font-black tracking-tight"
|
||||||
|
style={{ color: "var(--color-text-readable)" }}
|
||||||
|
>
|
||||||
|
Badges
|
||||||
|
</h2>
|
||||||
|
<p
|
||||||
|
className="text-xs mt-0.5"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
{badges.length} badge{badges.length !== 1 ? "s" : ""} equipped
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{badges.map((badge) => (
|
||||||
|
<Image
|
||||||
|
key={badge.badgeCode}
|
||||||
|
src={`${BADGE_IMG_BASE}/${badge.badgeCode}.gif`}
|
||||||
|
alt={badge.badgeCode}
|
||||||
|
width={40}
|
||||||
|
height={40}
|
||||||
|
className="rounded-md transition-transform hover:scale-110"
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Reveal delay={0.35}>
|
||||||
|
<div
|
||||||
|
className="rounded-2xl border p-5"
|
||||||
|
style={{
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
borderColor:
|
||||||
|
"color-mix(in srgb, var(--color-primary) 12%, transparent)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2
|
||||||
|
className="text-lg font-black tracking-tight mb-4"
|
||||||
|
style={{ color: "var(--color-text-readable)" }}
|
||||||
|
>
|
||||||
|
Quick links
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
|
{[
|
||||||
|
{ href: "/messages", label: "Messages", badge: unreadCount },
|
||||||
|
{ href: `/u/${user.username}`, label: "My profile", badge: 0 },
|
||||||
|
{ href: "/settings", label: "Settings", badge: 0 },
|
||||||
|
{ href: "/shop", label: "Shop", badge: 0 },
|
||||||
|
].map((link) => (
|
||||||
|
<Link
|
||||||
|
key={link.href}
|
||||||
|
href={link.href}
|
||||||
|
className="relative flex items-center justify-center rounded-xl border p-3 text-sm font-bold transition-all hover:-translate-y-0.5 hover:shadow-md"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"color-mix(in srgb, var(--color-primary) 6%, transparent)",
|
||||||
|
borderColor:
|
||||||
|
"color-mix(in srgb, var(--color-primary) 18%, transparent)",
|
||||||
|
color: "var(--color-text-readable)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
{link.badge > 0 && (
|
||||||
|
<span
|
||||||
|
className="absolute -top-1.5 -right-1.5 flex min-w-[18px] h-[18px] items-center justify-center rounded-full px-1 text-[10px] font-bold leading-none"
|
||||||
|
style={{
|
||||||
|
background: "var(--color-danger)",
|
||||||
|
color: "white",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{link.badge > 99 ? "99+" : link.badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -467,11 +766,16 @@ export default async function MePage({
|
|||||||
className="rounded-2xl border p-6 text-center"
|
className="rounded-2xl border p-6 text-center"
|
||||||
style={{
|
style={{
|
||||||
background: "var(--color-surface)",
|
background: "var(--color-surface)",
|
||||||
borderColor: "color-mix(in srgb, var(--color-text-muted) 10%, transparent)",
|
borderColor:
|
||||||
|
"color-mix(in srgb, var(--color-text-muted) 10%, transparent)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<p className="text-sm font-bold" style={{ color: "var(--color-text-muted)" }}>
|
<p
|
||||||
We couldn't load your dashboard right now. Please try again shortly.
|
className="text-sm font-bold"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
We couldn't load your dashboard right now. Please try again
|
||||||
|
shortly.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { avatarImageUrl } from "@/lib/format";
|
import { avatarImageUrl } from "@/lib/format";
|
||||||
|
import { formatDate } from "@/lib/format-date";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
|
||||||
@@ -39,12 +40,6 @@ function feedbackStyle(tone: "success" | "error"): CSSProperties {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
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({
|
export default async function MessagesPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
@@ -353,7 +348,7 @@ export default async function MessagesPage({
|
|||||||
size: "s",
|
size: "s",
|
||||||
headOnly: true,
|
headOnly: true,
|
||||||
});
|
});
|
||||||
const when = formatTimestamp(msg.sendedOn);
|
const when = formatDate(new Date(msg.sendedOn * 1000));
|
||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
key={msg.id}
|
key={msg.id}
|
||||||
|
|||||||
@@ -16,7 +16,11 @@ import { sanitize } from "@/lib/sanitize";
|
|||||||
|
|
||||||
export const revalidate = 60;
|
export const revalidate = 60;
|
||||||
|
|
||||||
type SearchParams = Promise<{ comment?: string; reaction?: string; error?: string }>;
|
type SearchParams = Promise<{
|
||||||
|
comment?: string;
|
||||||
|
reaction?: string;
|
||||||
|
error?: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
function feedbackStyle(tone: "success" | "error"): CSSProperties {
|
function feedbackStyle(tone: "success" | "error"): CSSProperties {
|
||||||
const accent =
|
const accent =
|
||||||
@@ -78,7 +82,6 @@ const REACTIONS: { key: string; emoji: string }[] = [
|
|||||||
{ key: "wow", emoji: "😮" },
|
{ key: "wow", emoji: "😮" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
export default async function ArticlePage({
|
export default async function ArticlePage({
|
||||||
params,
|
params,
|
||||||
searchParams,
|
searchParams,
|
||||||
@@ -204,9 +207,7 @@ export default async function ArticlePage({
|
|||||||
icon="📰"
|
icon="📰"
|
||||||
title={article.title}
|
title={article.title}
|
||||||
subtitle={
|
subtitle={
|
||||||
article.createdAt
|
article.createdAt ? formatDate(article.createdAt, "date") : undefined
|
||||||
? article.createdAt.toISOString().slice(0, 10)
|
|
||||||
: undefined
|
|
||||||
}
|
}
|
||||||
action={
|
action={
|
||||||
<Link className="btn btn-outline" href="/news">
|
<Link className="btn btn-outline" href="/news">
|
||||||
@@ -223,7 +224,10 @@ export default async function ArticlePage({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{/* Article body is rich HTML (atom uses TinyMCE) — sanitised server-side. */}
|
{/* Article body is rich HTML (atom uses TinyMCE) — sanitised server-side. */}
|
||||||
<SanitizedHtml html={sanitize(article.fullStory)} className="article-body" />
|
<SanitizedHtml
|
||||||
|
html={sanitize(article.fullStory)}
|
||||||
|
className="article-body"
|
||||||
|
/>
|
||||||
</ContentCard>
|
</ContentCard>
|
||||||
|
|
||||||
{/* ── Reactions ─────────────────────────────────────────── */}
|
{/* ── Reactions ─────────────────────────────────────────── */}
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { formatDate } from "@/lib/format-date";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||||
import { excerpt } from "@/lib/format";
|
import { excerpt } from "@/lib/format";
|
||||||
|
import { formatDate } from "@/lib/format-date";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const revalidate = 300;
|
export const revalidate = 300;
|
||||||
|
|
||||||
export const metadata = { title: "News" };
|
export const metadata = { title: "News" };
|
||||||
|
|
||||||
|
|
||||||
export default async function NewsPage() {
|
export default async function NewsPage() {
|
||||||
const t = await getTranslations("pages.news");
|
const t = await getTranslations("pages.news");
|
||||||
|
|
||||||
|
|||||||
+494
-164
@@ -1,31 +1,47 @@
|
|||||||
|
import { headers } from "next/headers";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { AmbientOrbs } from "@/components/ambient-orbs";
|
||||||
|
import { AnimatedCounter } from "@/components/animated-counter";
|
||||||
|
import { HomeLoginForm } from "@/components/auth/home-login-form";
|
||||||
|
import { Clock } from "@/components/clock";
|
||||||
|
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||||
|
import { Reveal } from "@/components/motion-reveal";
|
||||||
|
import { ThemeSwitcher } from "@/components/theme-switcher";
|
||||||
|
import { TypewriterText } from "@/components/typewriter-text";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
import { cached } from "@/lib/cache";
|
import { cached } from "@/lib/cache";
|
||||||
import { avatarImageUrl } from "@/lib/format";
|
import { avatarImageUrl } from "@/lib/format";
|
||||||
import { formatDate } from "@/lib/format-date";
|
import { formatDate } from "@/lib/format-date";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { resolveHotelName } from "@/lib/hotel-name";
|
import { resolveHotelName } from "@/lib/hotel-name";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { captchaConfig } from "@/lib/services/captcha";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
import { auth } from "@/lib/auth";
|
|
||||||
import { ThemeSwitcher } from "@/components/theme-switcher";
|
|
||||||
import { LanguageSwitcher } from "@/components/language-switcher";
|
|
||||||
|
|
||||||
export const revalidate = 120;
|
export const revalidate = 120;
|
||||||
|
|
||||||
async function getHotelData() {
|
async function getHotelData() {
|
||||||
const [hotelName, imagerRaw] = await Promise.all([
|
const [hotelName, imagerRaw, logo] = await Promise.all([
|
||||||
resolveHotelName(),
|
resolveHotelName(),
|
||||||
siteSettings.get(
|
siteSettings.get(
|
||||||
"habbo_imaging_url",
|
"habbo_imaging_url",
|
||||||
"https://www.habbo.com/habbo-imaging/avatarimage",
|
"https://www.habbo.com/habbo-imaging/avatarimage",
|
||||||
),
|
),
|
||||||
|
siteSettings.get("cms_logo", ""),
|
||||||
]);
|
]);
|
||||||
const imager = imagerRaw ?? "https://www.habbo.com/habbo-imaging/avatarimage";
|
const imager = imagerRaw ?? "https://www.habbo.com/habbo-imaging/avatarimage";
|
||||||
|
|
||||||
const [online, users, rooms, articles, recentUsers, recentPhotos, latestUsers] =
|
const [
|
||||||
await Promise.all([
|
online,
|
||||||
|
users,
|
||||||
|
rooms,
|
||||||
|
articles,
|
||||||
|
recentUsers,
|
||||||
|
recentPhotos,
|
||||||
|
latestUsers,
|
||||||
|
] = await Promise.all([
|
||||||
cached("online_count", 10_000, () =>
|
cached("online_count", 10_000, () =>
|
||||||
prisma.user.count({ where: { online: "1" } }),
|
prisma.user.count({ where: { online: "1" } }),
|
||||||
).catch(() => 0),
|
).catch(() => 0),
|
||||||
@@ -63,6 +79,7 @@ async function getHotelData() {
|
|||||||
recentUsers,
|
recentUsers,
|
||||||
recentPhotos,
|
recentPhotos,
|
||||||
latestUsers,
|
latestUsers,
|
||||||
|
logo,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,72 +99,114 @@ export default async function Home() {
|
|||||||
recentUsers,
|
recentUsers,
|
||||||
recentPhotos,
|
recentPhotos,
|
||||||
latestUsers,
|
latestUsers,
|
||||||
|
logo,
|
||||||
} = await getHotelData();
|
} = await getHotelData();
|
||||||
|
|
||||||
|
const captcha = await captchaConfig();
|
||||||
|
const nonce = (await headers()).get("x-nonce") ?? undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-14 pb-14">
|
<div className="flex flex-col gap-10 pb-14">
|
||||||
<style>{`
|
{/* ── Top Bar ── */}
|
||||||
@keyframes hero-glow {
|
<Reveal>
|
||||||
0%, 100% { opacity: 0.08; transform: scale(1); }
|
|
||||||
50% { opacity: 0.15; transform: scale(1.05); }
|
|
||||||
}
|
|
||||||
.hero-glow {
|
|
||||||
animation: hero-glow 6s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
{/* ── Toolbar ── */}
|
|
||||||
<div className="relative">
|
|
||||||
<div
|
<div
|
||||||
className="absolute -inset-6 rounded-full opacity-[0.06] blur-[40px]"
|
className="relative flex items-center justify-between rounded-xl border px-5 py-3"
|
||||||
style={{ background: "var(--color-primary)" }}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="relative flex items-center justify-between rounded-2xl border px-5 py-3"
|
|
||||||
style={{
|
style={{
|
||||||
background: "color-mix(in srgb, var(--color-surface) 50%, transparent)",
|
background:
|
||||||
backdropFilter: "blur(12px)",
|
"color-mix(in srgb, var(--color-surface) 60%, transparent)",
|
||||||
WebkitBackdropFilter: "blur(12px)",
|
backdropFilter: "blur(14px)",
|
||||||
borderColor: "color-mix(in srgb, var(--color-text-muted) 8%, transparent)",
|
WebkitBackdropFilter: "blur(14px)",
|
||||||
|
borderColor:
|
||||||
|
"color-mix(in srgb, var(--color-text-muted) 8%, transparent)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="text-xs font-bold uppercase tracking-[0.15em] opacity-40" style={{ color: "var(--color-text-muted)" }}>
|
<div className="flex items-center gap-3">
|
||||||
|
{logo ? (
|
||||||
|
<Image
|
||||||
|
src={logo}
|
||||||
|
alt={hotelName}
|
||||||
|
width={100}
|
||||||
|
height={28}
|
||||||
|
className="shrink-0 object-contain"
|
||||||
|
unoptimized
|
||||||
|
style={{ maxHeight: 28, width: "auto", height: "auto" }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Image
|
||||||
|
src="/assets/images/icons/lighthouse.png"
|
||||||
|
alt={hotelName}
|
||||||
|
width={28}
|
||||||
|
height={28}
|
||||||
|
className="shrink-0"
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className="text-sm font-extrabold uppercase tracking-wide"
|
||||||
|
style={{ color: "var(--color-text-readable)" }}
|
||||||
|
>
|
||||||
{hotelName}
|
{hotelName}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-1">
|
</div>
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1.5 rounded-lg px-2 py-1"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"color-mix(in srgb, var(--color-text-muted) 4%, transparent)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
<div className="w-px h-5 mx-1" style={{ background: "color-mix(in srgb, var(--color-text-muted) 12%, transparent)" }} />
|
<div
|
||||||
|
className="w-px h-5"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Clock />
|
||||||
|
<div
|
||||||
|
className="w-px h-5"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<ThemeSwitcher />
|
<ThemeSwitcher />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Reveal>
|
||||||
{/* ── 1. Hero ── */}
|
|
||||||
|
{/* ── Hero ── */}
|
||||||
|
<Reveal>
|
||||||
<div
|
<div
|
||||||
className="relative overflow-hidden rounded-3xl border px-6 py-10 md:px-10 md:py-14 lg:py-16"
|
className="relative overflow-hidden rounded-2xl border"
|
||||||
style={{
|
style={{
|
||||||
background: `url(/assets/images/next_header.jpg) center/cover no-repeat`,
|
background: `url(/assets/images/next_header.jpg) center/cover no-repeat`,
|
||||||
borderColor: "color-mix(in srgb, var(--color-text-muted) 8%, transparent)",
|
backgroundPosition: "center 20%",
|
||||||
|
borderColor:
|
||||||
|
"color-mix(in srgb, var(--color-text-muted) 8%, transparent)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
|
||||||
className="absolute inset-0 hero-glow"
|
|
||||||
style={{
|
|
||||||
background: "radial-gradient(ellipse at 30% 20%, color-mix(in srgb, var(--color-primary) 20%, transparent) 0%, transparent 60%)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0"
|
className="absolute inset-0"
|
||||||
style={{
|
style={{
|
||||||
background:
|
background:
|
||||||
"linear-gradient(135deg, color-mix(in srgb, var(--color-surface) 85%, transparent) 0%, color-mix(in srgb, var(--color-surface) 40%, transparent) 50%, color-mix(in srgb, var(--color-surface) 15%, transparent) 100%)",
|
"linear-gradient(135deg, color-mix(in srgb, var(--color-surface) 90%, transparent) 0%, color-mix(in srgb, var(--color-surface) 35%, transparent) 50%, color-mix(in srgb, var(--color-surface) 10%, transparent) 100%)",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="relative flex flex-col lg:flex-row items-center gap-8 lg:gap-14">
|
<AmbientOrbs />
|
||||||
<div className="flex-1 text-center lg:text-left">
|
<div
|
||||||
|
className="relative flex flex-col md:flex-row items-center gap-8 px-6 py-10 md:px-10 md:py-14"
|
||||||
|
style={{ zIndex: 2 }}
|
||||||
|
>
|
||||||
|
<div className="flex-1 text-center md:text-left">
|
||||||
<div
|
<div
|
||||||
className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full text-xs font-bold uppercase tracking-widest mb-5 border backdrop-blur-sm"
|
className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full text-xs font-bold uppercase tracking-widest mb-5 border backdrop-blur-sm"
|
||||||
style={{
|
style={{
|
||||||
background: "color-mix(in srgb, var(--color-primary) 12%, transparent)",
|
background:
|
||||||
borderColor: "color-mix(in srgb, var(--color-primary) 20%, transparent)",
|
"color-mix(in srgb, var(--color-primary) 12%, transparent)",
|
||||||
|
borderColor:
|
||||||
|
"color-mix(in srgb, var(--color-primary) 20%, transparent)",
|
||||||
color: "var(--color-primary-readable, var(--color-primary))",
|
color: "var(--color-primary-readable, var(--color-primary))",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -158,44 +217,56 @@ export default async function Home() {
|
|||||||
{th("online", { count: online, hotel: hotelName })}
|
{th("online", { count: online, hotel: hotelName })}
|
||||||
</div>
|
</div>
|
||||||
<h1
|
<h1
|
||||||
className="text-4xl md:text-5xl lg:text-6xl font-black leading-[1.05] tracking-tight mb-4"
|
className="text-4xl md:text-5xl lg:text-6xl font-black leading-[1.05] tracking-tight mb-3"
|
||||||
style={{ color: "var(--color-text-readable)" }}
|
style={{
|
||||||
|
color: "var(--color-text-readable)",
|
||||||
|
fontFamily: "var(--font-nunito)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{tp("welcome", { hotelName })}
|
<TypewriterText text={tp("welcome", { hotelName })} />
|
||||||
</h1>
|
</h1>
|
||||||
<p
|
<p
|
||||||
className="text-base md:text-lg leading-relaxed max-w-xl mb-8"
|
className="text-base md:text-lg leading-relaxed max-w-xl mb-7"
|
||||||
style={{ color: "var(--color-text-muted)" }}
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
>
|
>
|
||||||
{th("tagline")}
|
{th("tagline")}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap gap-3 justify-center lg:justify-start">
|
<div className="flex flex-wrap gap-3 justify-center md:justify-start">
|
||||||
<Link
|
<Link
|
||||||
href="/register"
|
href="/register"
|
||||||
className="inline-flex items-center gap-2.5 font-extrabold text-sm px-7 py-3.5 rounded-xl transition-all duration-200 hover:scale-[1.03] active:scale-95 shadow-lg"
|
className="inline-flex items-center gap-2.5 font-extrabold text-sm px-7 py-3.5 rounded-xl transition-all duration-200 hover:scale-[1.03] active:scale-95 shadow-lg"
|
||||||
style={{
|
style={{
|
||||||
background: "var(--color-primary)",
|
background: "var(--color-primary)",
|
||||||
color: "var(--color-primary-foreground)",
|
color: "var(--color-primary-foreground)",
|
||||||
boxShadow: "0 4px 24px color-mix(in srgb, var(--color-primary) 35%, transparent)",
|
boxShadow:
|
||||||
|
"0 4px 24px color-mix(in srgb, var(--color-primary) 35%, transparent)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Image src="/assets/images/EnterHubbly.png" alt="" width={20} height={20} unoptimized />
|
<Image
|
||||||
|
src="/assets/images/EnterHubbly.png"
|
||||||
|
alt=""
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
{tp("register")}
|
{tp("register")}
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/login"
|
href="/login"
|
||||||
className="inline-flex items-center gap-2 font-bold text-sm px-7 py-3.5 rounded-xl border-2 transition-all duration-200 hover:scale-[1.03] active:scale-95 backdrop-blur-sm"
|
className="inline-flex items-center gap-2 font-bold text-sm px-7 py-3.5 rounded-xl border-2 transition-all duration-200 hover:scale-[1.03] active:scale-95 backdrop-blur-sm"
|
||||||
style={{
|
style={{
|
||||||
borderColor: "color-mix(in srgb, var(--color-text-muted) 20%, transparent)",
|
borderColor:
|
||||||
|
"color-mix(in srgb, var(--color-text-muted) 20%, transparent)",
|
||||||
color: "var(--color-text-readable)",
|
color: "var(--color-text-readable)",
|
||||||
background: "color-mix(in srgb, var(--color-surface) 50%, transparent)",
|
background:
|
||||||
|
"color-mix(in srgb, var(--color-surface) 50%, transparent)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{th("login")}
|
{th("login")}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="shrink-0 relative">
|
<div className="shrink-0 relative animate-float">
|
||||||
<div
|
<div
|
||||||
className="absolute -bottom-4 -left-4 w-48 h-48 rounded-full opacity-[0.15] blur-[60px]"
|
className="absolute -bottom-4 -left-4 w-48 h-48 rounded-full opacity-[0.15] blur-[60px]"
|
||||||
style={{ background: "var(--color-primary)" }}
|
style={{ background: "var(--color-primary)" }}
|
||||||
@@ -203,8 +274,8 @@ export default async function Home() {
|
|||||||
<Image
|
<Image
|
||||||
src="/assets/images/FrankwithBag.gif"
|
src="/assets/images/FrankwithBag.gif"
|
||||||
alt="Frank"
|
alt="Frank"
|
||||||
width={180}
|
width={170}
|
||||||
height={240}
|
height={230}
|
||||||
className="object-contain drop-shadow-2xl relative"
|
className="object-contain drop-shadow-2xl relative"
|
||||||
unoptimized
|
unoptimized
|
||||||
priority
|
priority
|
||||||
@@ -212,22 +283,38 @@ export default async function Home() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
{/* ── 2. Stats ── */}
|
{/* ── Stats ── */}
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<Reveal>
|
||||||
|
<div className="grid grid-cols-3 gap-4 stagger-children">
|
||||||
{[
|
{[
|
||||||
{ value: online.toLocaleString(), label: tp("statsOnline"), icon: "/assets/images/icons/navigation/home.png" },
|
{
|
||||||
{ value: users.toLocaleString(), label: tp("statsCitizens"), icon: "/assets/images/icons/navigation/me.png" },
|
value: online,
|
||||||
{ value: rooms.toLocaleString(), label: tp("statsRooms"), icon: "/assets/images/icons/catalog.png" },
|
label: tp("statsOnline"),
|
||||||
|
icon: "/assets/images/icons/online-friends.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: users,
|
||||||
|
label: tp("statsCitizens"),
|
||||||
|
icon: "/assets/images/icons/friends.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: rooms,
|
||||||
|
label: tp("statsRooms"),
|
||||||
|
icon: "/assets/images/icons/catalog.png",
|
||||||
|
},
|
||||||
].map((s) => (
|
].map((s) => (
|
||||||
<div
|
<div
|
||||||
key={s.label}
|
key={s.label}
|
||||||
className="relative overflow-hidden rounded-2xl border p-5 text-center transition-all duration-200 hover:-translate-y-0.5"
|
className="relative overflow-hidden rounded-xl border p-5 text-center transition-all duration-300 hover:-translate-y-1 hover:shadow-xl card-premium"
|
||||||
style={{
|
style={{
|
||||||
background: "color-mix(in srgb, var(--color-surface) 70%, transparent)",
|
background:
|
||||||
|
"color-mix(in srgb, var(--color-surface) 70%, transparent)",
|
||||||
backdropFilter: "blur(8px)",
|
backdropFilter: "blur(8px)",
|
||||||
WebkitBackdropFilter: "blur(8px)",
|
WebkitBackdropFilter: "blur(8px)",
|
||||||
borderColor: "color-mix(in srgb, var(--color-text-muted) 8%, transparent)",
|
borderColor:
|
||||||
|
"color-mix(in srgb, var(--color-text-muted) 8%, transparent)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -235,82 +322,168 @@ export default async function Home() {
|
|||||||
style={{ background: "var(--color-primary)" }}
|
style={{ background: "var(--color-primary)" }}
|
||||||
/>
|
/>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Image src={s.icon} alt="" width={28} height={28} className="mx-auto mb-1.5 opacity-50" unoptimized />
|
<div
|
||||||
<div className="text-3xl md:text-4xl font-black tracking-tight mb-0.5" style={{ color: "var(--color-primary)" }}>
|
className="w-10 h-10 rounded-xl flex items-center justify-center mx-auto mb-2.5 animate-pulse-glow"
|
||||||
{s.value}
|
style={{
|
||||||
|
background:
|
||||||
|
"color-mix(in srgb, var(--color-primary) 14%, transparent)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={s.icon}
|
||||||
|
alt=""
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--color-text-muted)" }}>
|
<div
|
||||||
|
className="text-3xl md:text-4xl font-black tracking-tight mb-0.5"
|
||||||
|
style={{ color: "var(--color-primary)" }}
|
||||||
|
>
|
||||||
|
<AnimatedCounter value={s.value} />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="text-xs font-bold uppercase tracking-widest"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
{s.label}
|
{s.label}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
{/* ── 3. Features ── */}
|
{/* ── Login + News ── */}
|
||||||
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="flex flex-col gap-4 md:flex-row">
|
||||||
{[
|
{/* Left: Login & Register */}
|
||||||
{ img: "/assets/images/icons/star.gif", title: tp("featuresAvatar"), desc: tp("featuresAvatarDesc") },
|
<Reveal className="flex w-full flex-col gap-4 md:w-1/3 md:flex-shrink-0">
|
||||||
{ img: "/assets/images/icons/catalog.png", title: tp("featuresFurniture"), desc: tp("featuresFurnitureDesc") },
|
|
||||||
{ img: "/assets/images/icons/navigation/community.png", title: tp("featuresCommunity"), desc: tp("featuresCommunityDesc") },
|
|
||||||
{ img: "/assets/images/icons/navigation/bb.png", title: tp("featuresGames"), desc: tp("featuresGamesDesc") },
|
|
||||||
].map((f) => (
|
|
||||||
<div
|
<div
|
||||||
key={f.title}
|
className="overflow-hidden rounded-xl border"
|
||||||
className="rounded-2xl border p-5 transition-all duration-200 hover:-translate-y-0.5"
|
|
||||||
style={{
|
style={{
|
||||||
background: "color-mix(in srgb, var(--color-surface) 70%, transparent)",
|
borderColor:
|
||||||
backdropFilter: "blur(8px)",
|
"color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
|
||||||
WebkitBackdropFilter: "blur(8px)",
|
|
||||||
borderColor: "color-mix(in srgb, var(--color-text-muted) 8%, transparent)",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="w-11 h-11 rounded-xl flex items-center justify-center mb-3.5"
|
className="flex items-center gap-2.5 px-4 py-3"
|
||||||
style={{ background: "color-mix(in srgb, var(--color-primary) 12%, transparent)" }}
|
|
||||||
>
|
|
||||||
<Image src={f.img} alt="" width={24} height={24} unoptimized />
|
|
||||||
</div>
|
|
||||||
<h3 className="text-sm font-extrabold mb-1.5" style={{ color: "var(--color-text-readable)" }}>
|
|
||||||
{f.title}
|
|
||||||
</h3>
|
|
||||||
<p className="text-xs leading-relaxed" style={{ color: "var(--color-text-muted)" }}>
|
|
||||||
{f.desc}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── 4. Newest citizens ── */}
|
|
||||||
{latestUsers.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<h2 className="text-xl font-black tracking-tight mb-4" style={{ color: "var(--color-text-readable)" }}>
|
|
||||||
{tp("latestUsers", { count: users.toLocaleString() })}
|
|
||||||
</h2>
|
|
||||||
<div className="grid grid-cols-4 sm:grid-cols-8 gap-3">
|
|
||||||
{latestUsers.map((u) => (
|
|
||||||
<div
|
|
||||||
key={u.username}
|
|
||||||
className="flex flex-col items-center gap-1.5 p-3 rounded-2xl border transition-all duration-200 hover:-translate-y-1 hover:shadow-lg"
|
|
||||||
style={{
|
style={{
|
||||||
background: "color-mix(in srgb, var(--color-surface) 70%, transparent)",
|
background:
|
||||||
backdropFilter: "blur(8px)",
|
"linear-gradient(140deg, #1a3a5c 0%, #1e4a6e 20%, #2a5a80 40%, #3a6a90 60%, #4a7aa0 75%, #3a6a90 90%, #2a5a80 100%)",
|
||||||
WebkitBackdropFilter: "blur(8px)",
|
|
||||||
borderColor: "color-mix(in srgb, var(--color-text-muted) 8%, transparent)",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="relative">
|
|
||||||
<div className="absolute -inset-2 rounded-full opacity-[0.1] blur-[12px]" style={{ background: "var(--color-primary)" }} />
|
|
||||||
<Image
|
<Image
|
||||||
src={avatarImageUrl(imager, u.look, { headOnly: true, direction: 2, size: "m" })}
|
src="/assets/images/icons/navigation/me.png"
|
||||||
alt={u.username}
|
alt=""
|
||||||
width={40}
|
width={18}
|
||||||
height={50}
|
height={18}
|
||||||
className="rounded-lg relative"
|
unoptimized
|
||||||
|
/>
|
||||||
|
<h2
|
||||||
|
className="text-sm font-bold text-white"
|
||||||
|
style={{ fontFamily: "var(--font-nunito)" }}
|
||||||
|
>
|
||||||
|
Login
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="p-3"
|
||||||
|
style={{ backgroundColor: "var(--color-surface)" }}
|
||||||
|
>
|
||||||
|
<HomeLoginForm
|
||||||
|
captcha={{
|
||||||
|
provider: captcha.provider,
|
||||||
|
siteKey: captcha.siteKey || undefined,
|
||||||
|
field: captcha.field || undefined,
|
||||||
|
}}
|
||||||
|
nonce={nonce}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Link href="/register" className="relative block group">
|
||||||
|
<div
|
||||||
|
className="overflow-hidden rounded-xl border transition-all duration-200 group-hover:shadow-lg"
|
||||||
|
style={{
|
||||||
|
borderColor:
|
||||||
|
"color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src="/assets/images/EnterHubbly.png"
|
||||||
|
alt="Register"
|
||||||
|
width={300}
|
||||||
|
height={100}
|
||||||
|
className="block w-full"
|
||||||
unoptimized
|
unoptimized
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] font-bold truncate max-w-[64px] text-center leading-tight" style={{ color: "var(--color-text-readable)" }}>
|
<span
|
||||||
|
className="pointer-events-none absolute inset-0 flex items-center text-2xl md:text-3xl font-extrabold text-white"
|
||||||
|
style={{
|
||||||
|
marginLeft: "18px",
|
||||||
|
fontFamily: "var(--font-nunito)",
|
||||||
|
textShadow: "0 2px 8px rgba(0,0,0,0.6)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tp("register")}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{latestUsers.length > 0 && (
|
||||||
|
<div
|
||||||
|
className="overflow-hidden rounded-xl border"
|
||||||
|
style={{
|
||||||
|
borderColor:
|
||||||
|
"color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
|
||||||
|
backgroundColor: "var(--color-surface)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2.5 px-4 py-3"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"linear-gradient(140deg, #0A1F0A 0%, #0F2D0F 20%, #153C15 40%, #1B4A1B 60%, #205820 75%, #1A4A1A 90%, #143814 100%)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src="/assets/images/icons/navigation/goody.png"
|
||||||
|
alt=""
|
||||||
|
width={18}
|
||||||
|
height={18}
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
<h2
|
||||||
|
className="text-sm font-bold text-white"
|
||||||
|
style={{ fontFamily: "var(--font-nunito)" }}
|
||||||
|
>
|
||||||
|
{tp("latestUsers", { count: users.toLocaleString() })}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 gap-2 p-3">
|
||||||
|
{latestUsers.map((u) => (
|
||||||
|
<div
|
||||||
|
key={u.username}
|
||||||
|
className="flex flex-col items-center gap-1"
|
||||||
|
>
|
||||||
|
<div className="relative overflow-visible">
|
||||||
|
<Image
|
||||||
|
src={avatarImageUrl(imager, u.look, {
|
||||||
|
headOnly: true,
|
||||||
|
direction: 3,
|
||||||
|
})}
|
||||||
|
alt={u.username}
|
||||||
|
width={64}
|
||||||
|
height={78}
|
||||||
|
style={{ marginTop: "-8px" }}
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className="text-[11px] font-bold truncate max-w-[64px] text-center leading-tight"
|
||||||
|
style={{ color: "var(--color-text-readable)" }}
|
||||||
|
>
|
||||||
{u.username}
|
{u.username}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -318,98 +491,210 @@ export default async function Home() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
{/* ── 5. News ── */}
|
{/* Right: News */}
|
||||||
<div className="space-y-4">
|
<Reveal delay={0.15} className="flex w-full flex-col gap-4 md:w-2/3">
|
||||||
<div className="flex items-center justify-between">
|
<div
|
||||||
<h2 className="text-xl font-black tracking-tight" style={{ color: "var(--color-text-readable)" }}>
|
className="overflow-hidden rounded-xl border"
|
||||||
|
style={{
|
||||||
|
borderColor:
|
||||||
|
"color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
|
||||||
|
backgroundColor: "var(--color-surface)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between px-4 py-3"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"linear-gradient(140deg, #1a3a5c 0%, #1e4a6e 20%, #2a5a80 40%, #3a6a90 60%, #4a7aa0 75%, #3a6a90 90%, #2a5a80 100%)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<Image
|
||||||
|
src="/assets/images/icons/article.gif"
|
||||||
|
alt=""
|
||||||
|
width={18}
|
||||||
|
height={18}
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
<h2
|
||||||
|
className="text-sm font-bold text-white"
|
||||||
|
style={{ fontFamily: "var(--font-nunito)" }}
|
||||||
|
>
|
||||||
{tp("latestNews")}
|
{tp("latestNews")}
|
||||||
</h2>
|
</h2>
|
||||||
<Link href="/news" className="text-sm font-bold hover:underline" style={{ color: "var(--color-primary)" }}>
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/news"
|
||||||
|
className="text-xs font-bold text-white/80 hover:text-white hover:underline"
|
||||||
|
>
|
||||||
{tp("allNews")}
|
{tp("allNews")}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="p-4">
|
||||||
{articles.length > 0 ? (
|
{articles.length > 0 ? (
|
||||||
<div className="grid sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
{articles.slice(0, 4).map((a) => (
|
{articles.slice(0, 4).map((a) => (
|
||||||
<Link
|
<Link
|
||||||
key={a.slug}
|
key={a.slug}
|
||||||
href={`/news/${a.slug}`}
|
href={`/news/${a.slug}`}
|
||||||
className="flex gap-4 items-start rounded-2xl border p-4 transition-all duration-200 hover:-translate-y-0.5"
|
className="group relative w-full overflow-hidden rounded-lg transition-all duration-300 hover:shadow-lg hover:-translate-y-0.5"
|
||||||
style={{
|
style={{
|
||||||
background: "color-mix(in srgb, var(--color-surface) 70%, transparent)",
|
height: "200px",
|
||||||
backdropFilter: "blur(8px)",
|
backgroundColor: "var(--color-background)",
|
||||||
WebkitBackdropFilter: "blur(8px)",
|
border:
|
||||||
borderColor: "color-mix(in srgb, var(--color-text-muted) 8%, transparent)",
|
"1px solid color-mix(in srgb, var(--color-text-muted) 10%, transparent)",
|
||||||
textDecoration: "none",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="relative w-14 h-14 shrink-0 rounded-xl overflow-hidden">
|
<div className="relative h-full w-full overflow-hidden rounded-lg">
|
||||||
{a.image ? (
|
<Image
|
||||||
<Image src={a.image} alt="" fill className="object-cover" unoptimized />
|
src={a.image}
|
||||||
) : (
|
alt={a.title}
|
||||||
<div className="w-full h-full" style={{ background: `color-mix(in srgb, var(--color-primary) 15%, transparent)` }} />
|
fill
|
||||||
)}
|
className="rounded-lg object-cover transition-all duration-300 group-hover:scale-105"
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
<div className="absolute top-2.5 right-2.5 w-8 h-8 rounded-full flex items-center justify-center bg-black/50 backdrop-blur-sm transition-all duration-300 opacity-0 group-hover:opacity-100 translate-x-1 group-hover:translate-x-0">
|
||||||
|
<span className="text-white text-sm font-bold">
|
||||||
|
→
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div
|
||||||
<h3 className="text-sm font-extrabold leading-snug" style={{ color: "var(--color-text-readable)" }}>
|
className="absolute left-0 w-full p-2.5"
|
||||||
|
style={{
|
||||||
|
bottom: 0,
|
||||||
|
background:
|
||||||
|
"linear-gradient(to top, rgba(0,0,0,0.85) 0%, transparent 100%)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3 className="truncate text-base font-bold text-white text-shadow-sm">
|
||||||
{a.title}
|
{a.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs font-semibold mt-1" style={{ color: "var(--color-text-muted)" }}>
|
<p
|
||||||
|
className="text-[11px] font-semibold mt-0.5 opacity-80"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
{formatDate(a.createdAt, "date", "")}
|
{formatDate(a.createdAt, "date", "")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm" style={{ color: "var(--color-text-muted)" }}>{tp("noArticles")}</p>
|
<p
|
||||||
|
className="py-8 text-center text-sm"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
{tp("noArticles")}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* ── 6. Online users row ── */}
|
{/* ── Online users ── */}
|
||||||
{recentUsers.length > 0 && (
|
{recentUsers.length > 0 && (
|
||||||
<div className="text-center">
|
<Reveal>
|
||||||
<h3 className="text-xs font-bold uppercase tracking-widest mb-3 flex items-center justify-center gap-2" style={{ color: "var(--color-text-muted)" }}>
|
<div
|
||||||
<span className="w-2 h-2 rounded-full bg-green-500" />
|
className="overflow-hidden rounded-xl border"
|
||||||
|
style={{
|
||||||
|
borderColor:
|
||||||
|
"color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
|
||||||
|
backgroundColor: "var(--color-surface)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between px-4 py-3"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"linear-gradient(140deg, #0A1F0A 0%, #0F2D0F 20%, #153C15 40%, #1B4A1B 60%, #205820 75%, #1A4A1A 90%, #143814 100%)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<Image
|
||||||
|
src="/assets/images/icons/navigation/goody.png"
|
||||||
|
alt=""
|
||||||
|
width={18}
|
||||||
|
height={18}
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
<h3
|
||||||
|
className="text-sm font-bold text-white"
|
||||||
|
style={{ fontFamily: "var(--font-nunito)" }}
|
||||||
|
>
|
||||||
{tp("recentUsers")}
|
{tp("recentUsers")}
|
||||||
</h3>
|
</h3>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/community"
|
||||||
|
className="text-xs font-bold text-white/80 hover:text-white hover:underline"
|
||||||
|
>
|
||||||
|
{tp("allUsers")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="p-4">
|
||||||
<div className="flex flex-wrap justify-center gap-2">
|
<div className="flex flex-wrap justify-center gap-2">
|
||||||
{recentUsers.slice(0, 10).map((u) => (
|
{recentUsers.slice(0, 12).map((u) => (
|
||||||
<div
|
<div
|
||||||
key={u.username}
|
key={u.username}
|
||||||
className="flex flex-col items-center gap-0.5 px-2 py-1.5 rounded-xl transition-all duration-200 hover:scale-110"
|
className="flex flex-col items-center gap-0.5 px-2 py-1.5 rounded-lg transition-all duration-200 hover:scale-110 hover:shadow-md"
|
||||||
style={{ background: "color-mix(in srgb, var(--color-primary) 4%, transparent)" }}
|
style={{
|
||||||
|
background:
|
||||||
|
"color-mix(in srgb, var(--color-primary) 5%, transparent)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
src={avatarImageUrl(imager, u.look, { headOnly: true, direction: 2, size: "s" })}
|
src={avatarImageUrl(imager, u.look, {
|
||||||
|
headOnly: true,
|
||||||
|
direction: 2,
|
||||||
|
size: "s",
|
||||||
|
})}
|
||||||
alt={u.username}
|
alt={u.username}
|
||||||
width={30}
|
width={30}
|
||||||
height={36}
|
height={36}
|
||||||
className="rounded-md"
|
className="rounded-md"
|
||||||
unoptimized
|
unoptimized
|
||||||
/>
|
/>
|
||||||
<span className="text-[9px] font-bold truncate max-w-[50px] text-center leading-tight" style={{ color: "var(--color-text-readable)" }}>
|
<span
|
||||||
|
className="text-[9px] font-bold truncate max-w-[50px] text-center leading-tight"
|
||||||
|
style={{ color: "var(--color-text-readable)" }}
|
||||||
|
>
|
||||||
{u.username}
|
{u.username}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── 7. Bottom CTA ── */}
|
{/* ── Bottom CTA ── */}
|
||||||
|
<Reveal>
|
||||||
<div
|
<div
|
||||||
className="relative overflow-hidden rounded-2xl border text-center px-6 py-10"
|
className="relative overflow-hidden rounded-xl border text-center px-6 py-10"
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 8%, var(--color-surface)), color-mix(in srgb, var(--color-surface) 70%, transparent))`,
|
background: `linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 8%, var(--color-surface)), color-mix(in srgb, var(--color-surface) 70%, transparent))`,
|
||||||
borderColor: "color-mix(in srgb, var(--color-primary) 15%, transparent)",
|
borderColor:
|
||||||
|
"color-mix(in srgb, var(--color-primary) 15%, transparent)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2
|
||||||
|
className="text-2xl md:text-3xl font-black tracking-tight mb-2"
|
||||||
|
style={{
|
||||||
|
color: "var(--color-text-readable)",
|
||||||
|
fontFamily: "var(--font-nunito)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<h2 className="text-2xl md:text-3xl font-black tracking-tight mb-2" style={{ color: "var(--color-text-readable)" }}>
|
|
||||||
{tp("joinNow", { hotelName })}
|
{tp("joinNow", { hotelName })}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm mb-6 max-w-sm mx-auto" style={{ color: "var(--color-text-muted)" }}>
|
<p
|
||||||
|
className="text-sm mb-6 max-w-sm mx-auto"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
{tp("welcomeBody")}
|
{tp("welcomeBody")}
|
||||||
</p>
|
</p>
|
||||||
<Link
|
<Link
|
||||||
@@ -418,23 +703,66 @@ export default async function Home() {
|
|||||||
style={{
|
style={{
|
||||||
background: "var(--color-primary)",
|
background: "var(--color-primary)",
|
||||||
color: "var(--color-primary-foreground)",
|
color: "var(--color-primary-foreground)",
|
||||||
boxShadow: "0 4px 24px color-mix(in srgb, var(--color-primary) 35%, transparent)",
|
boxShadow:
|
||||||
|
"0 4px 24px color-mix(in srgb, var(--color-primary) 35%, transparent)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Image src="/assets/images/EnterHubbly.png" alt="" width={20} height={20} unoptimized />
|
<Image
|
||||||
|
src="/assets/images/EnterHubbly.png"
|
||||||
|
alt=""
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
{tp("register")}
|
{tp("register")}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
{/* ── 8. Recent photos (compact) ── */}
|
{/* ── Recent photos ── */}
|
||||||
{recentPhotos.length > 0 && (
|
{recentPhotos.length > 0 && (
|
||||||
<div className="text-center">
|
<Reveal>
|
||||||
<h3 className="text-xs font-bold uppercase tracking-widest mb-3" style={{ color: "var(--color-text-muted)" }}>
|
<div
|
||||||
|
className="overflow-hidden rounded-xl border"
|
||||||
|
style={{
|
||||||
|
borderColor:
|
||||||
|
"color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
|
||||||
|
backgroundColor: "var(--color-surface)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2.5 px-4 py-3"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"linear-gradient(140deg, #5a2d82 0%, #6b3a9e 20%, #7d47b8 40%, #8f54d0 60%, #7d47b8 75%, #6b3a9e 90%, #5a2d82 100%)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src="/assets/images/icons/camera.png"
|
||||||
|
alt=""
|
||||||
|
width={18}
|
||||||
|
height={18}
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
<h3
|
||||||
|
className="text-sm font-bold text-white"
|
||||||
|
style={{ fontFamily: "var(--font-nunito)" }}
|
||||||
|
>
|
||||||
{tp("recentPhotos")}
|
{tp("recentPhotos")}
|
||||||
</h3>
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-4">
|
||||||
<div className="grid grid-cols-4 gap-2 max-w-sm mx-auto">
|
<div className="grid grid-cols-4 gap-2 max-w-sm mx-auto">
|
||||||
{recentPhotos.slice(0, 4).map((p) => (
|
{recentPhotos.slice(0, 4).map((p) => (
|
||||||
<Link key={p.id} href="/photos" className="block rounded-lg overflow-hidden aspect-square">
|
<Link
|
||||||
|
key={p.id}
|
||||||
|
href="/photos"
|
||||||
|
className="block rounded-lg overflow-hidden aspect-square border transition-all duration-200 hover:shadow-md"
|
||||||
|
style={{
|
||||||
|
borderColor:
|
||||||
|
"color-mix(in srgb, var(--color-text-muted) 8%, transparent)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Image
|
<Image
|
||||||
src={p.url}
|
src={p.url}
|
||||||
alt=""
|
alt=""
|
||||||
@@ -447,6 +775,8 @@ export default async function Home() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
PhotoLightbox,
|
PhotoLightbox,
|
||||||
} from "@/components/public/photo-lightbox";
|
} from "@/components/public/photo-lightbox";
|
||||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||||
|
import { formatDate } from "@/lib/format-date";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const revalidate = 300;
|
export const revalidate = 300;
|
||||||
@@ -18,13 +19,6 @@ type Photo = {
|
|||||||
url: string;
|
url: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatDate(timestamp: number): string {
|
|
||||||
return new Date(timestamp * 1000)
|
|
||||||
.toISOString()
|
|
||||||
.slice(0, 16)
|
|
||||||
.replace("T", " ");
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function PhotosPage() {
|
export default async function PhotosPage() {
|
||||||
const t = await getTranslations("pages.photos");
|
const t = await getTranslations("pages.photos");
|
||||||
|
|
||||||
@@ -44,7 +38,7 @@ export default async function PhotosPage() {
|
|||||||
id: String(p.id),
|
id: String(p.id),
|
||||||
url: p.url,
|
url: p.url,
|
||||||
alt: t("photoAlt", { id: p.userId }),
|
alt: t("photoAlt", { id: p.userId }),
|
||||||
date: formatDate(p.timestamp),
|
date: formatDate(new Date(p.timestamp * 1000)),
|
||||||
user: t("byUser", { id: p.userId }),
|
user: t("byUser", { id: p.userId }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,11 @@ export default async function PollDetailPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (poll.startsAt && poll.startsAt.getTime() > now && poll.status === "active") {
|
if (
|
||||||
|
poll.startsAt &&
|
||||||
|
poll.startsAt.getTime() > now &&
|
||||||
|
poll.status === "active"
|
||||||
|
) {
|
||||||
// Not started yet — treat as unavailable for guests
|
// Not started yet — treat as unavailable for guests
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
@@ -101,7 +105,11 @@ export default async function PollDetailPage({
|
|||||||
|
|
||||||
{isOpen && !hasVoted ? (
|
{isOpen && !hasVoted ? (
|
||||||
session?.user ? (
|
session?.user ? (
|
||||||
<ContentCard icon="🗳️" title={t("voteTitle")} subtitle={t("voteSubtitle")}>
|
<ContentCard
|
||||||
|
icon="🗳️"
|
||||||
|
title={t("voteTitle")}
|
||||||
|
subtitle={t("voteSubtitle")}
|
||||||
|
>
|
||||||
<PollVoteForm
|
<PollVoteForm
|
||||||
pollId={poll.id}
|
pollId={poll.id}
|
||||||
questions={poll.questions.map((q) => ({
|
questions={poll.questions.map((q) => ({
|
||||||
@@ -156,7 +164,10 @@ export default async function PollDetailPage({
|
|||||||
const counts = new Map<string, number>();
|
const counts = new Map<string, number>();
|
||||||
for (const opt of options) counts.set(opt, 0);
|
for (const opt of options) counts.set(opt, 0);
|
||||||
for (const vote of votes) {
|
for (const vote of votes) {
|
||||||
for (const part of vote.split("\n").map((a) => a.trim()).filter(Boolean)) {
|
for (const part of vote
|
||||||
|
.split("\n")
|
||||||
|
.map((a) => a.trim())
|
||||||
|
.filter(Boolean)) {
|
||||||
counts.set(part, (counts.get(part) ?? 0) + 1);
|
counts.set(part, (counts.get(part) ?? 0) + 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,10 +99,16 @@ export function PollVoteForm({
|
|||||||
value={answers[q.id] ?? ""}
|
value={answers[q.id] ?? ""}
|
||||||
onChange={(e) => setSingle(q.id, e.target.value)}
|
onChange={(e) => setSingle(q.id, e.target.value)}
|
||||||
placeholder={t("textPlaceholder")}
|
placeholder={t("textPlaceholder")}
|
||||||
style={{ width: "100%", marginTop: "0.35rem", resize: "vertical" }}
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
marginTop: "0.35rem",
|
||||||
|
resize: "vertical",
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : q.type === "multiple" ? (
|
) : q.type === "multiple" ? (
|
||||||
<div style={{ display: "grid", gap: "0.4rem", marginTop: "0.35rem" }}>
|
<div
|
||||||
|
style={{ display: "grid", gap: "0.4rem", marginTop: "0.35rem" }}
|
||||||
|
>
|
||||||
{options.map((opt) => (
|
{options.map((opt) => (
|
||||||
<label
|
<label
|
||||||
key={opt}
|
key={opt}
|
||||||
@@ -123,7 +129,9 @@ export function PollVoteForm({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: "grid", gap: "0.4rem", marginTop: "0.35rem" }}>
|
<div
|
||||||
|
style={{ display: "grid", gap: "0.4rem", marginTop: "0.35rem" }}
|
||||||
|
>
|
||||||
{options.map((opt) => (
|
{options.map((opt) => (
|
||||||
<label
|
<label
|
||||||
key={opt}
|
key={opt}
|
||||||
|
|||||||
@@ -64,7 +64,13 @@ export default async function PollsPage() {
|
|||||||
{poll.title}
|
{poll.title}
|
||||||
</h3>
|
</h3>
|
||||||
{poll.description ? (
|
{poll.description ? (
|
||||||
<p style={{ margin: 0, fontSize: "0.88rem", lineHeight: 1.5 }}>
|
<p
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: "0.88rem",
|
||||||
|
lineHeight: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{excerpt(poll.description, 120)}
|
{excerpt(poll.description, 120)}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { CSSProperties } from "react";
|
|||||||
import { applyDj } from "@/actions/radio-apply";
|
import { applyDj } from "@/actions/radio-apply";
|
||||||
import { ContentCard } from "@/components/public/ui";
|
import { ContentCard } from "@/components/public/ui";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
|
import { formatDate } from "@/lib/format-date";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -57,7 +58,7 @@ export default async function RadioApplyPage({
|
|||||||
.catch(() => null);
|
.catch(() => null);
|
||||||
|
|
||||||
const submittedDate = existing?.createdAt
|
const submittedDate = existing?.createdAt
|
||||||
? existing.createdAt.toISOString().slice(0, 10)
|
? formatDate(existing.createdAt, "date")
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { formatDate } from "@/lib/format-date";
|
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { ContentCard } from "@/components/public/ui";
|
import { ContentCard } from "@/components/public/ui";
|
||||||
|
import { formatDate } from "@/lib/format-date";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const revalidate = 300;
|
export const revalidate = 300;
|
||||||
|
|
||||||
|
|
||||||
export default async function RadioContestDetailPage({
|
export default async function RadioContestDetailPage({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
@@ -61,9 +60,13 @@ export default async function RadioContestDetailPage({
|
|||||||
<tr>
|
<tr>
|
||||||
<th>Period</th>
|
<th>Period</th>
|
||||||
<td>
|
<td>
|
||||||
{contest.startDate ? formatDate(contest.startDate, "datetime-seconds", "") : "—"}
|
{contest.startDate
|
||||||
|
? formatDate(contest.startDate, "datetime-seconds", "")
|
||||||
|
: "—"}
|
||||||
{" — "}
|
{" — "}
|
||||||
{contest.endDate ? formatDate(contest.endDate, "datetime-seconds", "") : "ongoing"}
|
{contest.endDate
|
||||||
|
? formatDate(contest.endDate, "datetime-seconds", "")
|
||||||
|
: "ongoing"}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { formatDate } from "@/lib/format-date";
|
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||||
|
import { formatDate } from "@/lib/format-date";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const revalidate = 300;
|
export const revalidate = 300;
|
||||||
|
|
||||||
|
|
||||||
export default async function RadioContestsPage() {
|
export default async function RadioContestsPage() {
|
||||||
const t = await getTranslations("pages.radioContests");
|
const t = await getTranslations("pages.radioContests");
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { formatDate } from "@/lib/format-date";
|
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { ContentCard } from "@/components/public/ui";
|
import { ContentCard } from "@/components/public/ui";
|
||||||
|
import { formatDate } from "@/lib/format-date";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const revalidate = 300;
|
export const revalidate = 300;
|
||||||
|
|
||||||
|
|
||||||
export default async function RadioGiveawayDetailPage({
|
export default async function RadioGiveawayDetailPage({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
@@ -66,9 +65,13 @@ export default async function RadioGiveawayDetailPage({
|
|||||||
<tr>
|
<tr>
|
||||||
<th>Period</th>
|
<th>Period</th>
|
||||||
<td>
|
<td>
|
||||||
{giveaway.startDate ? formatDate(giveaway.startDate, "datetime-seconds", "") : "—"}
|
{giveaway.startDate
|
||||||
|
? formatDate(giveaway.startDate, "datetime-seconds", "")
|
||||||
|
: "—"}
|
||||||
{" — "}
|
{" — "}
|
||||||
{giveaway.endDate ? formatDate(giveaway.endDate, "datetime-seconds", "") : "ongoing"}
|
{giveaway.endDate
|
||||||
|
? formatDate(giveaway.endDate, "datetime-seconds", "")
|
||||||
|
: "ongoing"}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { formatDate } from "@/lib/format-date";
|
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||||
|
import { formatDate } from "@/lib/format-date";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const revalidate = 300;
|
export const revalidate = 300;
|
||||||
|
|
||||||
|
|
||||||
export default async function RadioGiveawaysPage() {
|
export default async function RadioGiveawaysPage() {
|
||||||
const t = await getTranslations("pages.radioGiveaways");
|
const t = await getTranslations("pages.radioGiveaways");
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
import { formatDate } from "@/lib/format-date";
|
|
||||||
import { submitRequest } from "@/actions/radio-requests";
|
import { submitRequest } from "@/actions/radio-requests";
|
||||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
|
import { formatDate } from "@/lib/format-date";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -179,7 +179,9 @@ export default async function RadioRequestsPage({
|
|||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="muted">
|
<td className="muted">
|
||||||
{r.submittedAt ? formatDate(r.submittedAt, "datetime", "") : "—"}
|
{r.submittedAt
|
||||||
|
? formatDate(r.submittedAt, "datetime", "")
|
||||||
|
: "—"}
|
||||||
</td>
|
</td>
|
||||||
<td>{status}</td>
|
<td>{status}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -3,25 +3,29 @@ import Image from "next/image";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { RegisterForm } from "@/components/auth/register-form";
|
import { RegisterForm } from "@/components/auth/register-form";
|
||||||
|
import { Clock } from "@/components/clock";
|
||||||
|
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||||
|
import { Reveal } from "@/components/motion-reveal";
|
||||||
|
import { ThemeSwitcher } from "@/components/theme-switcher";
|
||||||
import { cached } from "@/lib/cache";
|
import { cached } from "@/lib/cache";
|
||||||
import { avatarImageUrl } from "@/lib/format";
|
import { avatarImageUrl } from "@/lib/format";
|
||||||
|
import { resolveHotelName } from "@/lib/hotel-name";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { captchaConfig } from "@/lib/services/captcha";
|
import { captchaConfig } from "@/lib/services/captcha";
|
||||||
import { resolveHotelName } from "@/lib/hotel-name";
|
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
import { ThemeSwitcher } from "@/components/theme-switcher";
|
|
||||||
import { LanguageSwitcher } from "@/components/language-switcher";
|
|
||||||
|
|
||||||
export default async function RegisterPage() {
|
export default async function RegisterPage() {
|
||||||
const th = await getTranslations("header");
|
const th = await getTranslations("header");
|
||||||
const tpr = await getTranslations("pages.register");
|
const tpr = await getTranslations("pages.register");
|
||||||
const [hotelName, imagerRaw, cfg] = await Promise.all([
|
const [hotelName, imagerRaw, cfg, logo] = await Promise.all([
|
||||||
resolveHotelName(),
|
resolveHotelName(),
|
||||||
siteSettings.get(
|
siteSettings.get(
|
||||||
"habbo_imaging_url",
|
"habbo_imaging_url",
|
||||||
"https://www.habbo.com/habbo-imaging/avatarimage",
|
"https://www.habbo.com/habbo-imaging/avatarimage",
|
||||||
),
|
),
|
||||||
captchaConfig(),
|
captchaConfig(),
|
||||||
|
siteSettings.get("radio_social_discord", ""),
|
||||||
|
siteSettings.get("cms_logo", ""),
|
||||||
]);
|
]);
|
||||||
const imager = imagerRaw ?? "https://www.habbo.com/habbo-imaging/avatarimage";
|
const imager = imagerRaw ?? "https://www.habbo.com/habbo-imaging/avatarimage";
|
||||||
const nonce = (await headers()).get("x-nonce") ?? undefined;
|
const nonce = (await headers()).get("x-nonce") ?? undefined;
|
||||||
@@ -47,45 +51,92 @@ export default async function RegisterPage() {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-10 pb-14">
|
||||||
<div className="relative">
|
{/* ── Top Bar ── */}
|
||||||
|
<Reveal>
|
||||||
<div
|
<div
|
||||||
className="absolute -inset-6 rounded-full opacity-[0.06] blur-[40px]"
|
className="relative flex items-center justify-between rounded-xl border px-5 py-3"
|
||||||
style={{ background: "var(--color-primary)" }}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="relative flex items-center justify-between rounded-2xl border px-5 py-3"
|
|
||||||
style={{
|
style={{
|
||||||
background: "color-mix(in srgb, var(--color-surface) 50%, transparent)",
|
background:
|
||||||
backdropFilter: "blur(12px)",
|
"color-mix(in srgb, var(--color-surface) 60%, transparent)",
|
||||||
WebkitBackdropFilter: "blur(12px)",
|
backdropFilter: "blur(14px)",
|
||||||
borderColor: "color-mix(in srgb, var(--color-text-muted) 8%, transparent)",
|
WebkitBackdropFilter: "blur(14px)",
|
||||||
|
borderColor:
|
||||||
|
"color-mix(in srgb, var(--color-text-muted) 8%, transparent)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="text-xs font-bold uppercase tracking-[0.15em] opacity-40" style={{ color: "var(--color-text-muted)" }}>
|
<div className="flex items-center gap-3">
|
||||||
|
{logo ? (
|
||||||
|
<Image
|
||||||
|
src={logo}
|
||||||
|
alt={hotelName}
|
||||||
|
width={100}
|
||||||
|
height={28}
|
||||||
|
className="shrink-0 object-contain"
|
||||||
|
unoptimized
|
||||||
|
style={{ maxHeight: 28, width: "auto", height: "auto" }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Image
|
||||||
|
src="/assets/images/icons/lighthouse.png"
|
||||||
|
alt={hotelName}
|
||||||
|
width={28}
|
||||||
|
height={28}
|
||||||
|
className="shrink-0"
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className="text-sm font-extrabold uppercase tracking-wide"
|
||||||
|
style={{ color: "var(--color-text-readable)" }}
|
||||||
|
>
|
||||||
{hotelName}
|
{hotelName}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-1">
|
</div>
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1.5 rounded-lg px-2 py-1"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"color-mix(in srgb, var(--color-text-muted) 4%, transparent)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
<div className="w-px h-5 mx-1" style={{ background: "color-mix(in srgb, var(--color-text-muted) 12%, transparent)" }} />
|
<div
|
||||||
|
className="w-px h-5"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Clock />
|
||||||
|
<div
|
||||||
|
className="w-px h-5"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<ThemeSwitcher />
|
<ThemeSwitcher />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Reveal>
|
||||||
<div className="flex flex-col lg:flex-row gap-10">
|
<Reveal>
|
||||||
|
<div className="flex flex-col lg:flex-row gap-10 stagger-children">
|
||||||
{/* Left panel */}
|
{/* Left panel */}
|
||||||
<div className="lg:w-80 shrink-0 space-y-4">
|
<div className="lg:w-80 shrink-0 space-y-4">
|
||||||
<div
|
<div
|
||||||
className="relative overflow-hidden rounded-2xl border p-6 text-center"
|
className="relative overflow-hidden rounded-2xl border p-6 text-center card-premium"
|
||||||
style={{
|
style={{
|
||||||
borderColor: "color-mix(in srgb, var(--color-primary) 20%, transparent)",
|
borderColor:
|
||||||
|
"color-mix(in srgb, var(--color-primary) 20%, transparent)",
|
||||||
background: `url(/assets/images/background.png) center/cover no-repeat`,
|
background: `url(/assets/images/background.png) center/cover no-repeat`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0"
|
className="absolute inset-0"
|
||||||
style={{
|
style={{
|
||||||
background: "linear-gradient(135deg, color-mix(in srgb, var(--color-surface) 85%, transparent) 0%, color-mix(in srgb, var(--color-surface) 40%, transparent) 100%)",
|
background:
|
||||||
|
"linear-gradient(135deg, color-mix(in srgb, var(--color-surface) 85%, transparent) 0%, color-mix(in srgb, var(--color-surface) 40%, transparent) 100%)",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -94,7 +145,7 @@ export default async function RegisterPage() {
|
|||||||
alt="Frank"
|
alt="Frank"
|
||||||
width={130}
|
width={130}
|
||||||
height={170}
|
height={170}
|
||||||
className="object-contain mx-auto drop-shadow-xl"
|
className="object-contain mx-auto drop-shadow-xl animate-float"
|
||||||
unoptimized
|
unoptimized
|
||||||
priority
|
priority
|
||||||
/>
|
/>
|
||||||
@@ -105,7 +156,8 @@ export default async function RegisterPage() {
|
|||||||
"color-mix(in srgb, var(--color-primary) 18%, transparent)",
|
"color-mix(in srgb, var(--color-primary) 18%, transparent)",
|
||||||
background:
|
background:
|
||||||
"color-mix(in srgb, var(--color-primary) 10%, transparent)",
|
"color-mix(in srgb, var(--color-primary) 10%, transparent)",
|
||||||
color: "var(--color-primary-readable, var(--color-primary))",
|
color:
|
||||||
|
"var(--color-primary-readable, var(--color-primary))",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="w-2 h-2 rounded-full bg-green-500" />
|
<span className="w-2 h-2 rounded-full bg-green-500" />
|
||||||
@@ -113,7 +165,10 @@ export default async function RegisterPage() {
|
|||||||
</div>
|
</div>
|
||||||
<h1
|
<h1
|
||||||
className="text-xl font-black"
|
className="text-xl font-black"
|
||||||
style={{ color: "var(--color-text-readable)" }}
|
style={{
|
||||||
|
color: "var(--color-text-readable)",
|
||||||
|
fontFamily: "var(--font-nunito)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{tpr("title")} — {hotelName}
|
{tpr("title")} — {hotelName}
|
||||||
</h1>
|
</h1>
|
||||||
@@ -128,21 +183,40 @@ export default async function RegisterPage() {
|
|||||||
|
|
||||||
{recentUsers.length > 0 && (
|
{recentUsers.length > 0 && (
|
||||||
<div
|
<div
|
||||||
className="rounded-2xl border p-4"
|
className="overflow-hidden rounded-xl border"
|
||||||
style={{
|
style={{
|
||||||
background: "color-mix(in srgb, var(--color-surface) 70%, transparent)",
|
borderColor:
|
||||||
backdropFilter: "blur(8px)",
|
"color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
|
||||||
WebkitBackdropFilter: "blur(8px)",
|
|
||||||
borderColor: "color-mix(in srgb, var(--color-text-muted) 8%, transparent)",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2.5 px-4 py-3"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"linear-gradient(140deg, #0A1F0A 0%, #0F2D0F 20%, #153C15 40%, #1B4A1B 60%, #205820 75%, #1A4A1A 90%, #143814 100%)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src="/assets/images/icons/navigation/goody.png"
|
||||||
|
alt=""
|
||||||
|
width={18}
|
||||||
|
height={18}
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
<h3
|
<h3
|
||||||
className="text-xs font-bold uppercase tracking-widest mb-3 flex items-center gap-2"
|
className="text-sm font-bold text-white"
|
||||||
style={{ color: "var(--color-text-muted)" }}
|
style={{ fontFamily: "var(--font-nunito)" }}
|
||||||
>
|
>
|
||||||
<span className="w-2 h-2 rounded-full bg-green-500" />
|
|
||||||
{tpr("whoIsOnline")}
|
{tpr("whoIsOnline")}
|
||||||
</h3>
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="p-4"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"color-mix(in srgb, var(--color-surface) 70%, transparent)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div className="grid grid-cols-4 gap-1.5">
|
<div className="grid grid-cols-4 gap-1.5">
|
||||||
{recentUsers.map((u) => (
|
{recentUsers.map((u) => (
|
||||||
<div
|
<div
|
||||||
@@ -175,43 +249,68 @@ export default async function RegisterPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{latestUsers.length > 0 && (
|
{latestUsers.length > 0 && (
|
||||||
<div
|
<div
|
||||||
className="rounded-2xl border p-4"
|
className="overflow-hidden rounded-xl border"
|
||||||
style={{
|
style={{
|
||||||
background: "color-mix(in srgb, var(--color-surface) 70%, transparent)",
|
borderColor:
|
||||||
backdropFilter: "blur(8px)",
|
"color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
|
||||||
WebkitBackdropFilter: "blur(8px)",
|
|
||||||
borderColor: "color-mix(in srgb, var(--color-text-muted) 8%, transparent)",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<h3
|
<div
|
||||||
className="text-xs font-bold uppercase tracking-widest mb-3 flex items-center gap-2"
|
className="flex items-center gap-2.5 px-4 py-3"
|
||||||
style={{ color: "var(--color-text-muted)" }}
|
style={{
|
||||||
|
background:
|
||||||
|
"linear-gradient(140deg, #5a2d82 0%, #6b3a9e 20%, #7d47b8 40%, #8f54d0 60%, #7d47b8 75%, #6b3a9e 90%, #5a2d82 100%)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
✨ Newest citizens
|
<Image
|
||||||
|
src="/assets/images/icons/friends.png"
|
||||||
|
alt=""
|
||||||
|
width={18}
|
||||||
|
height={18}
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
<h3
|
||||||
|
className="text-sm font-bold text-white"
|
||||||
|
style={{ fontFamily: "var(--font-nunito)" }}
|
||||||
|
>
|
||||||
|
Newest citizens
|
||||||
</h3>
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="p-4"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"color-mix(in srgb, var(--color-surface) 70%, transparent)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div className="grid grid-cols-4 gap-1.5">
|
<div className="grid grid-cols-4 gap-1.5">
|
||||||
{latestUsers.map((u) => (
|
{latestUsers.map((u) => (
|
||||||
<div
|
<div
|
||||||
key={u.username}
|
key={u.username}
|
||||||
className="flex flex-col items-center gap-0.5 p-1.5 rounded-lg"
|
className="flex flex-col items-center gap-0.5 p-1.5 rounded-lg"
|
||||||
style={{
|
style={{
|
||||||
background: "color-mix(in srgb, var(--color-primary) 4%, transparent)",
|
background:
|
||||||
|
"color-mix(in srgb, var(--color-primary) 4%, transparent)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
src={avatarImageUrl(imager, u.look, { headOnly: true, direction: 2, size: "s" })}
|
src={avatarImageUrl(imager, u.look, {
|
||||||
|
headOnly: true,
|
||||||
|
direction: 2,
|
||||||
|
})}
|
||||||
alt=""
|
alt=""
|
||||||
width={26}
|
width={52}
|
||||||
height={32}
|
height={64}
|
||||||
className="rounded-md"
|
className="rounded-md"
|
||||||
unoptimized
|
unoptimized
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
className="text-[8px] font-bold truncate w-full text-center leading-tight"
|
className="text-[10px] font-bold truncate w-full text-center leading-tight"
|
||||||
style={{ color: "var(--color-text-readable)" }}
|
style={{ color: "var(--color-text-readable)" }}
|
||||||
>
|
>
|
||||||
{u.username}
|
{u.username}
|
||||||
@@ -220,6 +319,7 @@ export default async function RegisterPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<p
|
<p
|
||||||
@@ -242,28 +342,43 @@ export default async function RegisterPage() {
|
|||||||
{/* Right: form */}
|
{/* Right: form */}
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div
|
<div
|
||||||
className="rounded-2xl border p-6"
|
className="overflow-hidden rounded-xl border"
|
||||||
style={{
|
style={{
|
||||||
background: "color-mix(in srgb, var(--color-surface) 70%, transparent)",
|
borderColor:
|
||||||
backdropFilter: "blur(8px)",
|
"color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
|
||||||
WebkitBackdropFilter: "blur(8px)",
|
|
||||||
borderColor: "color-mix(in srgb, var(--color-text-muted) 8%, transparent)",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="mb-6">
|
<div
|
||||||
|
className="flex items-center gap-2.5 px-4 py-3"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"linear-gradient(140deg, #1a3a5c 0%, #1e4a6e 20%, #2a5a80 40%, #3a6a90 60%, #4a7aa0 75%, #3a6a90 90%, #2a5a80 100%)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src="/assets/images/icons/navigation/me.png"
|
||||||
|
alt=""
|
||||||
|
width={18}
|
||||||
|
height={18}
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
<h2
|
<h2
|
||||||
className="text-2xl font-black tracking-tight"
|
className="text-sm font-bold text-white"
|
||||||
style={{ color: "var(--color-text-readable)" }}
|
style={{ fontFamily: "var(--font-nunito)" }}
|
||||||
>
|
>
|
||||||
{tpr("title")}
|
{tpr("title")}
|
||||||
</h2>
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="p-6"
|
||||||
|
style={{ backgroundColor: "var(--color-surface)" }}
|
||||||
|
>
|
||||||
<p
|
<p
|
||||||
className="text-sm mt-0.5"
|
className="text-sm mb-6"
|
||||||
style={{ color: "var(--color-text-muted)" }}
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
>
|
>
|
||||||
{tpr("subtitle")}
|
{tpr("subtitle")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
<RegisterForm
|
<RegisterForm
|
||||||
hotelName={hotelName}
|
hotelName={hotelName}
|
||||||
captcha={{
|
captcha={{
|
||||||
@@ -276,5 +391,7 @@ export default async function RegisterPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { avatarImageUrl } from "@/lib/format";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Search" };
|
||||||
|
|
||||||
|
type SearchParams = Promise<{ q?: string }>;
|
||||||
|
|
||||||
|
export default async function SearchPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: SearchParams;
|
||||||
|
}) {
|
||||||
|
const { q } = await searchParams;
|
||||||
|
const query = q?.trim();
|
||||||
|
|
||||||
|
const imager =
|
||||||
|
(await siteSettings.get(
|
||||||
|
"habbo_imaging_url",
|
||||||
|
"https://www.habbo.com/habbo-imaging/avatarimage",
|
||||||
|
)) ?? "";
|
||||||
|
|
||||||
|
if (!query) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="rounded-2xl border p-10 text-center"
|
||||||
|
style={{
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
borderColor:
|
||||||
|
"color-mix(in srgb, var(--color-text-muted) 10%, transparent)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
className="text-sm font-bold"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
Enter a search term to find users.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = await prisma.user
|
||||||
|
.findMany({
|
||||||
|
where: {
|
||||||
|
username: { contains: query },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
look: true,
|
||||||
|
motto: true,
|
||||||
|
online: true,
|
||||||
|
rank: true,
|
||||||
|
},
|
||||||
|
take: 30,
|
||||||
|
})
|
||||||
|
.catch(() => []);
|
||||||
|
|
||||||
|
const rooms = await prisma.rooms
|
||||||
|
.findMany({
|
||||||
|
where: {
|
||||||
|
name: { contains: query },
|
||||||
|
state: "open",
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
ownerName: true,
|
||||||
|
users: true,
|
||||||
|
usersMax: true,
|
||||||
|
},
|
||||||
|
take: 12,
|
||||||
|
})
|
||||||
|
.catch(() => []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div
|
||||||
|
className="rounded-2xl border p-4"
|
||||||
|
style={{
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
borderColor:
|
||||||
|
"color-mix(in srgb, var(--color-text-muted) 10%, transparent)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<form action="/search" method="GET" className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="q"
|
||||||
|
defaultValue={query}
|
||||||
|
placeholder="Search users or rooms..."
|
||||||
|
className="flex-1 rounded-xl border-2 px-4 py-2.5 text-sm font-medium"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--color-background)",
|
||||||
|
color: "var(--color-text-readable)",
|
||||||
|
borderColor:
|
||||||
|
"color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded-xl px-6 py-2.5 font-extrabold text-sm transition-all duration-200 hover:scale-[1.02] active:scale-95"
|
||||||
|
style={{
|
||||||
|
background: "var(--color-primary)",
|
||||||
|
color: "var(--color-primary-foreground)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm" style={{ color: "var(--color-text-muted)" }}>
|
||||||
|
Results for "{query}" — {users.length} user
|
||||||
|
{users.length !== 1 ? "s" : ""}
|
||||||
|
{rooms.length > 0 &&
|
||||||
|
`, ${rooms.length} room${rooms.length !== 1 ? "s" : ""}`}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{users.length > 0 && (
|
||||||
|
<div
|
||||||
|
className="rounded-2xl border p-5"
|
||||||
|
style={{
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
borderColor:
|
||||||
|
"color-mix(in srgb, var(--color-text-muted) 10%, transparent)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2
|
||||||
|
className="text-lg font-black tracking-tight mb-4"
|
||||||
|
style={{ color: "var(--color-text-readable)" }}
|
||||||
|
>
|
||||||
|
Users
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-3">
|
||||||
|
{users.map((u) => (
|
||||||
|
<Link
|
||||||
|
key={u.id}
|
||||||
|
href={`/u/${u.username}`}
|
||||||
|
className="flex flex-col items-center gap-1.5 rounded-xl p-3 transition-all duration-200 hover:-translate-y-0.5"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"color-mix(in srgb, var(--color-primary) 4%, transparent)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={avatarImageUrl(imager, u.look, {
|
||||||
|
headOnly: true,
|
||||||
|
direction: 2,
|
||||||
|
})}
|
||||||
|
alt={u.username}
|
||||||
|
width={40}
|
||||||
|
height={48}
|
||||||
|
unoptimized
|
||||||
|
className="rounded-lg"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="text-xs font-extrabold truncate max-w-full text-center"
|
||||||
|
style={{ color: "var(--color-text-readable)" }}
|
||||||
|
>
|
||||||
|
{u.username}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="text-[9px] font-semibold"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
{u.online === "1" ? "🟢 Online" : "Offline"}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{rooms.length > 0 && (
|
||||||
|
<div
|
||||||
|
className="rounded-2xl border p-5"
|
||||||
|
style={{
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
borderColor:
|
||||||
|
"color-mix(in srgb, var(--color-text-muted) 10%, transparent)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2
|
||||||
|
className="text-lg font-black tracking-tight mb-4"
|
||||||
|
style={{ color: "var(--color-text-readable)" }}
|
||||||
|
>
|
||||||
|
Rooms
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||||
|
{rooms.map((room) => (
|
||||||
|
<Link
|
||||||
|
key={room.id}
|
||||||
|
href={`/room/${room.id}`}
|
||||||
|
className="rounded-xl border p-3 transition-all duration-200 hover:-translate-y-0.5"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"color-mix(in srgb, var(--color-primary) 4%, transparent)",
|
||||||
|
borderColor:
|
||||||
|
"color-mix(in srgb, var(--color-text-muted) 10%, transparent)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
className="text-sm font-extrabold truncate"
|
||||||
|
style={{ color: "var(--color-text-readable)" }}
|
||||||
|
>
|
||||||
|
{room.name}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className="text-[10px] font-semibold mt-0.5"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
{room.ownerName} · {room.users}/{room.usersMax}
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{users.length === 0 && rooms.length === 0 && (
|
||||||
|
<div
|
||||||
|
className="rounded-2xl border p-10 text-center"
|
||||||
|
style={{
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
borderColor:
|
||||||
|
"color-mix(in srgb, var(--color-text-muted) 10%, transparent)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
className="text-sm font-bold"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
No users or rooms found for "{query}".
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,8 +11,8 @@ import { env } from "@/env";
|
|||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { LaravelEncrypter } from "@/lib/auth/laravel-encrypter";
|
import { LaravelEncrypter } from "@/lib/auth/laravel-encrypter";
|
||||||
import { totpKeyUri } from "@/lib/auth/totp";
|
import { totpKeyUri } from "@/lib/auth/totp";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { resolveHotelName } from "@/lib/hotel-name";
|
import { resolveHotelName } from "@/lib/hotel-name";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
|||||||
@@ -1,137 +0,0 @@
|
|||||||
import Link from "next/link";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { getTranslations } from "next-intl/server";
|
|
||||||
import { ContentCard } from "@/components/public/ui";
|
|
||||||
import { env } from "@/env";
|
|
||||||
import { auth } from "@/lib/auth";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
// Social providers we can link. `available` reflects whether the OAuth app
|
|
||||||
// credentials are configured (mirrors src/lib/auth.ts, which only registers a
|
|
||||||
// provider when both its id + secret env vars are set). When unavailable the
|
|
||||||
// connect button is disabled so we don't bounce the user to a 404 sign-in URL.
|
|
||||||
const PROVIDERS = [
|
|
||||||
{
|
|
||||||
key: "discord",
|
|
||||||
label: "Discord",
|
|
||||||
icon: "🎮",
|
|
||||||
available: Boolean(env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "google",
|
|
||||||
label: "Google",
|
|
||||||
icon: "🔵",
|
|
||||||
available: Boolean(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET),
|
|
||||||
},
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export default async function ConnectionsPage() {
|
|
||||||
const t = await getTranslations("pages.connections");
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user?.id) redirect("/login");
|
|
||||||
|
|
||||||
const id = Number(session.user.id);
|
|
||||||
|
|
||||||
// social_accounts is a CMS-only table that may not exist in every install
|
|
||||||
// (it has no bundled migration). Fall back to "nothing linked" on any error so
|
|
||||||
// the page still renders the generic connect buttons.
|
|
||||||
let linked: Set<string> = new Set();
|
|
||||||
let lookupFailed = false;
|
|
||||||
try {
|
|
||||||
const rows = await prisma.socialAccounts.findMany({
|
|
||||||
where: { userId: BigInt(id) },
|
|
||||||
select: { provider: true },
|
|
||||||
});
|
|
||||||
linked = new Set(rows.map((r) => r.provider.toLowerCase()));
|
|
||||||
} catch {
|
|
||||||
lookupFailed = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// After connecting/erroring, NextAuth bounces back here so we land on a known page.
|
|
||||||
const callbackUrl = "/settings/connections";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main
|
|
||||||
style={{
|
|
||||||
display: "grid",
|
|
||||||
gap: "1.5rem",
|
|
||||||
maxWidth: 620,
|
|
||||||
margin: "0 auto",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<p className="muted" style={{ margin: 0 }}>
|
|
||||||
<Link href="/settings">{t("backToSettings")}</Link>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<ContentCard icon="🔗" title={t("title")} subtitle={t("subtitle")}>
|
|
||||||
<p className="muted" style={{ marginTop: 0 }}>
|
|
||||||
{t("matchByEmail")} <strong>{session.user.name}</strong>.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{lookupFailed ? (
|
|
||||||
<p className="muted" style={{ marginBottom: 0 }}>
|
|
||||||
{t("lookupFailed")}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</ContentCard>
|
|
||||||
|
|
||||||
<div className="card-grid sm-2">
|
|
||||||
{PROVIDERS.map((p) => {
|
|
||||||
const isLinked = linked.has(p.key);
|
|
||||||
return (
|
|
||||||
<ContentCard
|
|
||||||
key={p.key}
|
|
||||||
icon={p.icon}
|
|
||||||
title={p.label}
|
|
||||||
subtitle={
|
|
||||||
!p.available
|
|
||||||
? t("statusUnavailable")
|
|
||||||
: isLinked
|
|
||||||
? t("statusConnected")
|
|
||||||
: t("statusNotConnected")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{isLinked ? (
|
|
||||||
<span
|
|
||||||
className="btn btn-outline"
|
|
||||||
style={{ cursor: "default", pointerEvents: "none" }}
|
|
||||||
aria-disabled="true"
|
|
||||||
>
|
|
||||||
{t("connected")}
|
|
||||||
</span>
|
|
||||||
) : p.available ? (
|
|
||||||
// Plain link into NextAuth's built-in sign-in handler. Re-using
|
|
||||||
// the same OAuth flow as /login binds the provider to the
|
|
||||||
// matching hotel account (see auth.ts signIn callback).
|
|
||||||
<a
|
|
||||||
className="btn btn-primary"
|
|
||||||
href={`/api/auth/signin/${p.key}?callbackUrl=${encodeURIComponent(callbackUrl)}`}
|
|
||||||
>
|
|
||||||
{t("connectProvider", { provider: p.label })}
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<span
|
|
||||||
className="btn"
|
|
||||||
style={{
|
|
||||||
cursor: "not-allowed",
|
|
||||||
opacity: 0.55,
|
|
||||||
pointerEvents: "none",
|
|
||||||
}}
|
|
||||||
aria-disabled="true"
|
|
||||||
>
|
|
||||||
{t("unavailable")}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</ContentCard>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="muted" style={{ margin: 0 }}>
|
|
||||||
{t("footerNote")}
|
|
||||||
</p>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { updateMotto } from "@/actions/user-settings";
|
import { updateMotto } from "@/actions/user-settings";
|
||||||
import { ContentCard } from "@/components/public/ui";
|
import { Reveal } from "@/components/motion-reveal";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { avatarImageUrl } from "@/lib/format";
|
import { avatarImageUrl } from "@/lib/format";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
@@ -39,64 +40,189 @@ export default async function SettingsPage() {
|
|||||||
imagerBase =
|
imagerBase =
|
||||||
(await siteSettings.get("habbo_imaging_url", imagerBase)) ?? imagerBase;
|
(await siteSettings.get("habbo_imaging_url", imagerBase)) ?? imagerBase;
|
||||||
} catch {
|
} catch {
|
||||||
// fall back to the default imager base
|
/* fall back to default */
|
||||||
}
|
}
|
||||||
|
|
||||||
const avatar = avatarImageUrl(imagerBase, user.look, { size: "l" });
|
const avatar = avatarImageUrl(imagerBase, user.look, { size: "l" });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
<div className="flex flex-col gap-6 pb-14">
|
||||||
<ContentCard
|
{/* Header */}
|
||||||
icon="⚙️"
|
<Reveal>
|
||||||
title={t("accountSettings")}
|
|
||||||
subtitle={t("accountSettingsSubtitle")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ContentCard
|
|
||||||
icon="🙂"
|
|
||||||
title={user.username}
|
|
||||||
subtitle={t("publicProfile")}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
|
className="relative overflow-hidden rounded-xl border px-5 py-4"
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
background:
|
||||||
gap: "1.5rem",
|
"color-mix(in srgb, var(--color-surface) 60%, transparent)",
|
||||||
alignItems: "center",
|
backdropFilter: "blur(14px)",
|
||||||
flexWrap: "wrap",
|
borderColor:
|
||||||
|
"color-mix(in srgb, var(--color-text-muted) 8%, transparent)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
<div className="flex items-center gap-3">
|
||||||
<img
|
<div
|
||||||
className="avatar"
|
className="w-10 h-10 rounded-xl flex items-center justify-center"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"color-mix(in srgb, var(--color-primary) 14%, transparent)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src="/assets/images/icons/navigation/me.png"
|
||||||
|
alt=""
|
||||||
|
width={22}
|
||||||
|
height={22}
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1
|
||||||
|
className="text-lg font-black tracking-tight"
|
||||||
|
style={{
|
||||||
|
color: "var(--color-text-readable)",
|
||||||
|
fontFamily: "var(--font-nunito)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("accountSettings")}
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
className="text-xs"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
{t("accountSettingsSubtitle")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
|
{/* Profile card */}
|
||||||
|
<Reveal delay={0.05}>
|
||||||
|
<div
|
||||||
|
className="overflow-hidden rounded-xl border card-premium"
|
||||||
|
style={{
|
||||||
|
borderColor:
|
||||||
|
"color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2.5 px-4 py-3"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"linear-gradient(140deg, #1a3a5c 0%, #1e4a6e 20%, #2a5a80 40%, #3a6a90 60%, #4a7aa0 75%, #3a6a90 90%, #2a5a80 100%)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src="/assets/images/icons/navigation/me.png"
|
||||||
|
alt=""
|
||||||
|
width={18}
|
||||||
|
height={18}
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
<h2
|
||||||
|
className="text-sm font-bold text-white"
|
||||||
|
style={{ fontFamily: "var(--font-nunito)" }}
|
||||||
|
>
|
||||||
|
{t("publicProfile")}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="p-5 flex items-center gap-5"
|
||||||
|
style={{ backgroundColor: "var(--color-surface)" }}
|
||||||
|
>
|
||||||
|
<div className="shrink-0">
|
||||||
|
<div
|
||||||
|
className="rounded-xl overflow-hidden border"
|
||||||
|
style={{
|
||||||
|
borderColor:
|
||||||
|
"color-mix(in srgb, var(--color-primary) 15%, transparent)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
src={avatar}
|
src={avatar}
|
||||||
alt={`${user.username} avatar`}
|
alt={`${user.username} avatar`}
|
||||||
width={100}
|
width={80}
|
||||||
height={150}
|
height={120}
|
||||||
|
className="block"
|
||||||
|
unoptimized
|
||||||
/>
|
/>
|
||||||
<div style={{ minWidth: 0 }}>
|
</div>
|
||||||
<p style={{ margin: "0 0 0.5rem", fontSize: "1.05rem" }}>
|
</div>
|
||||||
{user.motto || <span className="muted">{t("noMotto")}</span>}
|
<div className="min-w-0 flex-1">
|
||||||
|
<h3
|
||||||
|
className="text-base font-extrabold"
|
||||||
|
style={{ color: "var(--color-text-readable)" }}
|
||||||
|
>
|
||||||
|
{user.username}
|
||||||
|
</h3>
|
||||||
|
<p
|
||||||
|
className="text-sm mt-0.5"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
{user.motto || (
|
||||||
|
<span style={{ fontStyle: "italic", opacity: 0.5 }}>
|
||||||
|
{t("noMotto")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
<p className="muted" style={{ margin: 0 }}>
|
<p
|
||||||
|
className="text-xs mt-1.5 font-medium"
|
||||||
|
style={{
|
||||||
|
color:
|
||||||
|
"color-mix(in srgb, var(--color-text-muted) 70%, transparent)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{user.mail || t("noEmail")}
|
{user.mail || t("noEmail")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ContentCard>
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
<ContentCard
|
{/* Motto form */}
|
||||||
icon="✏️"
|
<Reveal delay={0.1}>
|
||||||
title={t("changeMotto")}
|
<div
|
||||||
subtitle={t("changeMottoSubtitle")}
|
className="overflow-hidden rounded-xl border"
|
||||||
|
style={{
|
||||||
|
borderColor:
|
||||||
|
"color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2.5 px-4 py-3"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"linear-gradient(140deg, #5a2d82 0%, #6b3a9e 20%, #7d47b8 40%, #8f54d0 60%, #7d47b8 75%, #6b3a9e 90%, #5a2d82 100%)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src="/assets/images/icons/star.gif"
|
||||||
|
alt=""
|
||||||
|
width={18}
|
||||||
|
height={18}
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
<h2
|
||||||
|
className="text-sm font-bold text-white"
|
||||||
|
style={{ fontFamily: "var(--font-nunito)" }}
|
||||||
|
>
|
||||||
|
{t("changeMotto")}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="p-5"
|
||||||
|
style={{ backgroundColor: "var(--color-surface)" }}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
className="text-xs mb-4"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
{t("changeMottoSubtitle")}
|
||||||
|
</p>
|
||||||
<form
|
<form
|
||||||
action={updateMotto}
|
action={updateMotto}
|
||||||
style={{
|
className="flex flex-col gap-3"
|
||||||
display: "flex",
|
style={{ maxWidth: 520 }}
|
||||||
flexDirection: "column",
|
|
||||||
gap: "0.75rem",
|
|
||||||
maxWidth: 520,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
name="motto"
|
name="motto"
|
||||||
@@ -104,42 +230,144 @@ export default async function SettingsPage() {
|
|||||||
maxLength={127}
|
maxLength={127}
|
||||||
placeholder={t("mottoPlaceholder")}
|
placeholder={t("mottoPlaceholder")}
|
||||||
aria-label="Motto"
|
aria-label="Motto"
|
||||||
|
className="input-glow w-full rounded-xl border-2 px-4 py-3 text-sm font-medium transition-all focus:outline-none"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--color-background)",
|
||||||
|
color: "var(--color-text-readable)",
|
||||||
|
borderColor:
|
||||||
|
"color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<button type="submit" className="btn btn-primary">
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn-shine inline-flex items-center gap-2 font-extrabold text-sm px-5 py-2.5 rounded-xl transition-all duration-200 hover:scale-[1.03] active:scale-95 shadow-lg"
|
||||||
|
style={{
|
||||||
|
background: "var(--color-primary)",
|
||||||
|
color: "var(--color-primary-foreground)",
|
||||||
|
boxShadow:
|
||||||
|
"0 4px 20px color-mix(in srgb, var(--color-primary) 30%, transparent)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{t("saveMotto")}
|
{t("saveMotto")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</ContentCard>
|
</div>
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
<div className="card-grid sm-2">
|
{/* Security card */}
|
||||||
<ContentCard
|
<Reveal delay={0.15}>
|
||||||
icon="🔒"
|
<div
|
||||||
title={t("securityTitle")}
|
className="overflow-hidden rounded-xl border"
|
||||||
subtitle={t("securitySubtitle")}
|
style={{
|
||||||
|
borderColor:
|
||||||
|
"color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2.5 px-4 py-3"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"linear-gradient(140deg, #0A1F0A 0%, #0F2D0F 20%, #153C15 40%, #1B4A1B 60%, #205820 75%, #1A4A1A 90%, #143814 100%)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src="/assets/images/icons/navigation/goody.png"
|
||||||
|
alt=""
|
||||||
|
width={18}
|
||||||
|
height={18}
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
<h2
|
||||||
|
className="text-sm font-bold text-white"
|
||||||
|
style={{ fontFamily: "var(--font-nunito)" }}
|
||||||
|
>
|
||||||
|
{t("securityTitle")}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="p-5"
|
||||||
|
style={{ backgroundColor: "var(--color-surface)" }}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
className="text-xs mb-4"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
>
|
>
|
||||||
<p className="muted" style={{ marginTop: 0 }}>
|
|
||||||
{t("securityDescription")}
|
{t("securityDescription")}
|
||||||
</p>
|
</p>
|
||||||
<Link href="/settings/2fa" className="btn btn-outline">
|
<Link
|
||||||
|
href="/settings/2fa"
|
||||||
|
className="inline-flex items-center gap-2 font-bold text-sm px-5 py-2.5 rounded-xl border-2 transition-all duration-200 hover:scale-[1.03] active:scale-95 backdrop-blur-sm"
|
||||||
|
style={{
|
||||||
|
borderColor:
|
||||||
|
"color-mix(in srgb, var(--color-text-muted) 20%, transparent)",
|
||||||
|
color: "var(--color-text-readable)",
|
||||||
|
background:
|
||||||
|
"color-mix(in srgb, var(--color-surface) 50%, transparent)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{t("twoFactorLink")}
|
{t("twoFactorLink")}
|
||||||
</Link>
|
</Link>
|
||||||
</ContentCard>
|
|
||||||
|
|
||||||
<ContentCard
|
|
||||||
icon="🔗"
|
|
||||||
title={t("connectionsTitle")}
|
|
||||||
subtitle={t("connectionsSubtitle")}
|
|
||||||
>
|
|
||||||
<p className="muted" style={{ marginTop: 0 }}>
|
|
||||||
{t("connectionsDescription")}
|
|
||||||
</p>
|
|
||||||
<Link href="/settings/connections" className="btn btn-outline">
|
|
||||||
{t("manageConnections")}
|
|
||||||
</Link>
|
|
||||||
</ContentCard>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
|
{/* Sessions link */}
|
||||||
|
<Reveal delay={0.2}>
|
||||||
|
<Link
|
||||||
|
href="/settings/sessions"
|
||||||
|
className="block overflow-hidden rounded-xl border transition-all duration-200 hover:shadow-lg hover:-translate-y-0.5"
|
||||||
|
style={{
|
||||||
|
borderColor:
|
||||||
|
"color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between px-5 py-4"
|
||||||
|
style={{ backgroundColor: "var(--color-surface)" }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className="w-10 h-10 rounded-xl flex items-center justify-center"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"color-mix(in srgb, var(--color-primary) 14%, transparent)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src="/assets/images/icons/navigation/community.png"
|
||||||
|
alt=""
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span
|
||||||
|
className="text-sm font-extrabold"
|
||||||
|
style={{ color: "var(--color-text-readable)" }}
|
||||||
|
>
|
||||||
|
Session Management
|
||||||
|
</span>
|
||||||
|
<p
|
||||||
|
className="text-xs mt-0.5"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
View active sessions and sign out everywhere
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className="text-lg font-bold"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
→
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</Reveal>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -3,16 +3,13 @@ import { getTranslations } from "next-intl/server";
|
|||||||
import { signOutEverywhere } from "@/actions/sessions";
|
import { signOutEverywhere } from "@/actions/sessions";
|
||||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
|
import { formatDate } from "@/lib/format-date";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
type SearchParams = Promise<{ signedOutAll?: string }>;
|
type SearchParams = Promise<{ signedOutAll?: string }>;
|
||||||
|
|
||||||
function fromDate(d: Date | null | undefined): string {
|
|
||||||
return d ? d.toISOString().slice(0, 16).replace("T", " ") : "—";
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Tiny user-agent → "Browser on OS" summary (no dependency). */
|
/** Tiny user-agent → "Browser on OS" summary (no dependency). */
|
||||||
function deviceLabel(ua: string | null, unknown: string): string {
|
function deviceLabel(ua: string | null, unknown: string): string {
|
||||||
if (!ua) return unknown;
|
if (!ua) return unknown;
|
||||||
@@ -89,11 +86,7 @@ export default async function SessionsPage({
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<ContentCard
|
<ContentCard icon="🔐" title={t("title")} subtitle={t("subtitle")} />
|
||||||
icon="🔐"
|
|
||||||
title={t("title")}
|
|
||||||
subtitle={t("subtitle")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ContentCard
|
<ContentCard
|
||||||
icon="🚪"
|
icon="🚪"
|
||||||
@@ -107,7 +100,11 @@ export default async function SessionsPage({
|
|||||||
</form>
|
</form>
|
||||||
</ContentCard>
|
</ContentCard>
|
||||||
|
|
||||||
<ContentCard icon="✅" title={t("recentTitle")} padded={logins.length === 0}>
|
<ContentCard
|
||||||
|
icon="✅"
|
||||||
|
title={t("recentTitle")}
|
||||||
|
padded={logins.length === 0}
|
||||||
|
>
|
||||||
{logins.length === 0 ? (
|
{logins.length === 0 ? (
|
||||||
<EmptyState icon="🔐">{t("recentEmpty")}</EmptyState>
|
<EmptyState icon="🔐">{t("recentEmpty")}</EmptyState>
|
||||||
) : (
|
) : (
|
||||||
@@ -123,7 +120,7 @@ export default async function SessionsPage({
|
|||||||
{logins.map((l) => (
|
{logins.map((l) => (
|
||||||
<tr key={String(l.id)}>
|
<tr key={String(l.id)}>
|
||||||
<td className="muted" style={{ whiteSpace: "nowrap" }}>
|
<td className="muted" style={{ whiteSpace: "nowrap" }}>
|
||||||
{fromDate(l.createdAt)}
|
{formatDate(l.createdAt)}
|
||||||
</td>
|
</td>
|
||||||
<td className="muted">{l.ip}</td>
|
<td className="muted">{l.ip}</td>
|
||||||
<td>{deviceLabel(l.userAgent, t("unknownDevice"))}</td>
|
<td>{deviceLabel(l.userAgent, t("unknownDevice"))}</td>
|
||||||
@@ -151,9 +148,11 @@ export default async function SessionsPage({
|
|||||||
{failed.map((f) => (
|
{failed.map((f) => (
|
||||||
<tr key={f.id}>
|
<tr key={f.id}>
|
||||||
<td className="muted" style={{ whiteSpace: "nowrap" }}>
|
<td className="muted" style={{ whiteSpace: "nowrap" }}>
|
||||||
{typeof f.attempted_at === "string"
|
{formatDate(
|
||||||
? f.attempted_at.slice(0, 16).replace("T", " ")
|
typeof f.attempted_at === "string"
|
||||||
: fromDate(f.attempted_at)}
|
? new Date(f.attempted_at)
|
||||||
|
: f.attempted_at,
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="muted">{f.ip_address}</td>
|
<td className="muted">{f.ip_address}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -79,7 +79,12 @@ const ARTICLE_SELECT = {
|
|||||||
export default async function ShopPage({
|
export default async function ShopPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
searchParams: Promise<{ category?: string; bought?: string; error?: string; package?: string }>;
|
searchParams: Promise<{
|
||||||
|
category?: string;
|
||||||
|
bought?: string;
|
||||||
|
error?: string;
|
||||||
|
package?: string;
|
||||||
|
}>;
|
||||||
}) {
|
}) {
|
||||||
const t = await getTranslations("pages.shop");
|
const t = await getTranslations("pages.shop");
|
||||||
const sp = await searchParams;
|
const sp = await searchParams;
|
||||||
@@ -185,8 +190,7 @@ export default async function ShopPage({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<p className="muted" style={{ margin: "0 0 0.75rem" }}>
|
<p className="muted" style={{ margin: "0 0 0.75rem" }}>
|
||||||
{t("creditsHint")}{" "}
|
{t("creditsHint")} <Link href="/shop/topup">{t("topUpLink")}</Link>
|
||||||
<Link href="/shop/topup">{t("topUpLink")}</Link>
|
|
||||||
</p>
|
</p>
|
||||||
{categories.length > 0 ? (
|
{categories.length > 0 ? (
|
||||||
<div
|
<div
|
||||||
@@ -319,8 +323,16 @@ export default async function ShopPage({
|
|||||||
</span>
|
</span>
|
||||||
{isLoggedIn ? (
|
{isLoggedIn ? (
|
||||||
<form action={buyShopArticle} style={{ margin: 0 }}>
|
<form action={buyShopArticle} style={{ margin: 0 }}>
|
||||||
<input type="hidden" name="articleId" value={String(a.id)} />
|
<input
|
||||||
<input type="hidden" name="categoryId" value={categoryId} />
|
type="hidden"
|
||||||
|
name="articleId"
|
||||||
|
value={String(a.id)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="categoryId"
|
||||||
|
value={categoryId}
|
||||||
|
/>
|
||||||
<button type="submit" className="btn btn-primary">
|
<button type="submit" className="btn btn-primary">
|
||||||
{t("buy")}
|
{t("buy")}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -67,11 +67,6 @@ const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584";
|
|||||||
const CURRENCY_DUCKETS = 0;
|
const CURRENCY_DUCKETS = 0;
|
||||||
const CURRENCY_DIAMONDS = 5;
|
const CURRENCY_DIAMONDS = 5;
|
||||||
|
|
||||||
function formatTimestamp(ts: number | null | undefined): string {
|
|
||||||
if (!ts) return "";
|
|
||||||
return new Date(ts * 1000).toISOString().slice(0, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function ProfilePage({
|
export default async function ProfilePage({
|
||||||
params,
|
params,
|
||||||
searchParams,
|
searchParams,
|
||||||
@@ -107,9 +102,7 @@ export default async function ProfilePage({
|
|||||||
if (!user) notFound();
|
if (!user) notFound();
|
||||||
|
|
||||||
const avatar = avatarImageUrl(imagerBase ?? "", user.look, { size: "l" });
|
const avatar = avatarImageUrl(imagerBase ?? "", user.look, { size: "l" });
|
||||||
const registered = new Date(user.accountCreated * 1000)
|
const registered = formatDate(new Date(user.accountCreated * 1000), "date");
|
||||||
.toISOString()
|
|
||||||
.slice(0, 10);
|
|
||||||
const meId = Number(session?.user?.id);
|
const meId = Number(session?.user?.id);
|
||||||
const isLoggedIn = Number.isInteger(meId) && meId > 0;
|
const isLoggedIn = Number.isInteger(meId) && meId > 0;
|
||||||
const isSelf = isLoggedIn && meId === user.id;
|
const isSelf = isLoggedIn && meId === user.id;
|
||||||
@@ -601,7 +594,7 @@ export default async function ProfilePage({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<p className="muted" style={{ margin: "0.4rem 0 0" }}>
|
<p className="muted" style={{ margin: "0.4rem 0 0" }}>
|
||||||
{formatTimestamp(p.timestamp)}
|
{formatDate(new Date(p.timestamp * 1000), "date")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
+111
-88
@@ -1,14 +1,74 @@
|
|||||||
|
import { CheckCircle2, Clock, MailX } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { isValidVerificationToken } from "@/actions/email-verify";
|
import { isValidVerificationToken } from "@/actions/email-verify";
|
||||||
import { DiscordVerifyForm } from "@/components/auth/discord-verify-form";
|
|
||||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
|
||||||
import { auth } from "@/lib/auth";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
type Status = "verified" | "already" | "invalid" | "unavailable" | "discord";
|
type Status = "verified" | "already" | "invalid" | "unavailable";
|
||||||
|
|
||||||
|
function StatusCard({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
children,
|
||||||
|
color,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
color: string;
|
||||||
|
}) {
|
||||||
|
const gradientMap: Record<string, string> = {
|
||||||
|
green:
|
||||||
|
"linear-gradient(140deg, #0A2F1A 0%, #0F3D22 20%, #154C2A 40%, #1B5A32 60%, #20683A 75%, #1A5A30 90%, #144826 100%)",
|
||||||
|
yellow:
|
||||||
|
"linear-gradient(140deg, #3A2F0A 0%, #4A3D0F 20%, #5A4C15 40%, #6A5A1B 60%, #7A6820 75%, #6A5A1A 90%, #5A4814 100%)",
|
||||||
|
red: "linear-gradient(140deg, #3A0F0A 0%, #4A1A0F 20%, #5A2515 40%, #6A301B 60%, #7A3B20 75%, #6A301A 90%, #5A2514 100%)",
|
||||||
|
blue: "linear-gradient(140deg, #0A1A3A 0%, #0F254A 20%, #15305A 40%, #1B3B6A 60%, #20467A 75%, #1A3B6A 90%, #14305A 100%)",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex min-h-[60vh] items-center justify-center px-4">
|
||||||
|
<div
|
||||||
|
className="w-full max-w-md overflow-hidden rounded-xl border"
|
||||||
|
style={{
|
||||||
|
borderColor:
|
||||||
|
"color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
|
||||||
|
backgroundColor: "var(--color-surface)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2.5 px-5 py-4"
|
||||||
|
style={{ background: gradientMap[color] ?? gradientMap.blue }}
|
||||||
|
>
|
||||||
|
<span className="text-white">{icon}</span>
|
||||||
|
<h1 className="text-sm font-bold text-white">{title}</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center gap-4 px-6 py-8 text-center">
|
||||||
|
<div
|
||||||
|
className="flex h-16 w-16 items-center justify-center rounded-full"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"color-mix(in srgb, var(--color-primary) 12%, transparent)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
className="text-sm font-semibold"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
{subtitle}
|
||||||
|
</p>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default async function VerifyPage({
|
export default async function VerifyPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
@@ -16,36 +76,15 @@ export default async function VerifyPage({
|
|||||||
searchParams: Promise<{
|
searchParams: Promise<{
|
||||||
token?: string;
|
token?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
method?: string;
|
|
||||||
linked?: string;
|
|
||||||
}>;
|
}>;
|
||||||
}) {
|
}) {
|
||||||
const t = await getTranslations("pages.verify");
|
const t = await getTranslations("pages.verify");
|
||||||
const {
|
const { token = "", email = "" } = await searchParams;
|
||||||
token = "",
|
|
||||||
email = "",
|
|
||||||
method = "",
|
|
||||||
linked = "",
|
|
||||||
} = await searchParams;
|
|
||||||
const normalisedEmail = email.trim().toLowerCase();
|
const normalisedEmail = email.trim().toLowerCase();
|
||||||
|
|
||||||
let status: Status = "invalid";
|
let status: Status = "invalid";
|
||||||
let discordAlreadyLinked = false;
|
|
||||||
|
|
||||||
if (method === "discord" || linked === "1") {
|
if (normalisedEmail && token) {
|
||||||
status = "discord";
|
|
||||||
const session = await auth();
|
|
||||||
if (session?.user?.id) {
|
|
||||||
const userId = Number(session.user.id);
|
|
||||||
try {
|
|
||||||
const linkedRow = await prisma.socialAccounts.findFirst({
|
|
||||||
where: { userId: BigInt(userId), provider: "discord" },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
discordAlreadyLinked = !!linkedRow;
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
} else if (normalisedEmail && token) {
|
|
||||||
const ok = await isValidVerificationToken(normalisedEmail, token);
|
const ok = await isValidVerificationToken(normalisedEmail, token);
|
||||||
if (ok) {
|
if (ok) {
|
||||||
try {
|
try {
|
||||||
@@ -70,102 +109,86 @@ export default async function VerifyPage({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const linkClass =
|
||||||
|
"btn-shine inline-flex items-center gap-2 font-extrabold text-sm px-6 py-3 rounded-xl transition-all duration-200 hover:scale-[1.02] active:scale-95 shadow-lg";
|
||||||
|
const linkStyle = {
|
||||||
|
background: "var(--color-primary)",
|
||||||
|
color: "var(--color-primary-foreground)",
|
||||||
|
boxShadow:
|
||||||
|
"0 4px 20px color-mix(in srgb, var(--color-primary) 30%, transparent)",
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main style={{ maxWidth: 480, margin: "2rem auto" }}>
|
<>
|
||||||
{status === "verified" && (
|
{status === "verified" && (
|
||||||
<ContentCard
|
<StatusCard
|
||||||
icon="✅"
|
icon={<CheckCircle2 size={22} />}
|
||||||
title={t("verifiedTitle")}
|
title={t("verifiedTitle")}
|
||||||
subtitle={t("verifiedSubtitle")}
|
subtitle={t("verifiedSubtitle")}
|
||||||
|
color="green"
|
||||||
>
|
>
|
||||||
<p className="muted" style={{ marginTop: 0 }}>
|
<p className="text-sm" style={{ color: "var(--color-text-muted)" }}>
|
||||||
{t("verifiedBody")}
|
{t("verifiedBody")}
|
||||||
</p>
|
</p>
|
||||||
<Link href="/login" className="btn btn-primary">
|
<Link href="/login" className={linkClass} style={linkStyle}>
|
||||||
{t("goToLogin")}
|
{t("goToLogin")}
|
||||||
</Link>
|
</Link>
|
||||||
</ContentCard>
|
</StatusCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{status === "already" && (
|
{status === "already" && (
|
||||||
<ContentCard
|
<StatusCard
|
||||||
icon="✅"
|
icon={<CheckCircle2 size={22} />}
|
||||||
title={t("alreadyTitle")}
|
title={t("alreadyTitle")}
|
||||||
subtitle={t("alreadySubtitle")}
|
subtitle={t("alreadySubtitle")}
|
||||||
|
color="yellow"
|
||||||
>
|
>
|
||||||
<p className="muted" style={{ marginTop: 0 }}>
|
<p className="text-sm" style={{ color: "var(--color-text-muted)" }}>
|
||||||
{t("alreadyBody")}
|
{t("alreadyBody")}
|
||||||
</p>
|
</p>
|
||||||
<Link href="/login" className="btn btn-primary">
|
<Link href="/login" className={linkClass} style={linkStyle}>
|
||||||
{t("goToLogin")}
|
{t("goToLogin")}
|
||||||
</Link>
|
</Link>
|
||||||
</ContentCard>
|
</StatusCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{status === "unavailable" && (
|
{status === "unavailable" && (
|
||||||
<ContentCard
|
<StatusCard
|
||||||
icon="⏳"
|
icon={<Clock size={22} />}
|
||||||
title={t("unavailableTitle")}
|
title={t("unavailableTitle")}
|
||||||
subtitle={t("unavailableSubtitle")}
|
subtitle={t("unavailableSubtitle")}
|
||||||
|
color="blue"
|
||||||
>
|
>
|
||||||
<p className="muted" style={{ margin: 0 }}>
|
<p className="text-sm" style={{ color: "var(--color-text-muted)" }}>
|
||||||
{t("unavailableBody")}
|
{t("unavailableBody")}
|
||||||
</p>
|
</p>
|
||||||
</ContentCard>
|
</StatusCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{status === "invalid" && (
|
{status === "invalid" && (
|
||||||
<ContentCard
|
<StatusCard
|
||||||
icon="📧"
|
icon={<MailX size={22} />}
|
||||||
title={t("invalidTitle")}
|
title={t("invalidTitle")}
|
||||||
subtitle={t("invalidSubtitle")}
|
subtitle={t("invalidSubtitle")}
|
||||||
|
color="red"
|
||||||
|
>
|
||||||
|
<p className="text-sm" style={{ color: "var(--color-text-muted)" }}>
|
||||||
|
{t("invalidBody")}
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className={`${linkClass} !shadow-none`}
|
||||||
|
style={{
|
||||||
|
background: "transparent",
|
||||||
|
color: "var(--color-text-readable)",
|
||||||
|
border:
|
||||||
|
"2px solid color-mix(in srgb, var(--color-text-muted) 20%, transparent)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<EmptyState icon="🔗">{t("invalidBody")}</EmptyState>
|
|
||||||
<div style={{ textAlign: "center" }}>
|
|
||||||
<Link href="/login" className="btn btn-outline">
|
|
||||||
{t("backToLogin")}
|
{t("backToLogin")}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</StatusCard>
|
||||||
</ContentCard>
|
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
{status === "discord" && (
|
|
||||||
<ContentCard
|
|
||||||
icon="🎮"
|
|
||||||
title="Verifieer via Discord"
|
|
||||||
subtitle="Koppel je Discord account"
|
|
||||||
>
|
|
||||||
{linked === "1" && (
|
|
||||||
<div
|
|
||||||
className="rounded-md p-4 text-center mb-4"
|
|
||||||
style={{ backgroundColor: "#14532d", color: "white" }}
|
|
||||||
>
|
|
||||||
<p className="text-lg font-bold mb-1">Gelinkt!</p>
|
|
||||||
<p className="text-sm opacity-90">
|
|
||||||
Je Discord account is gekoppeld. Je kunt nu verder.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{discordAlreadyLinked ? (
|
|
||||||
<div
|
|
||||||
className="rounded-md p-4 text-center"
|
|
||||||
style={{ backgroundColor: "#14532d", color: "white" }}
|
|
||||||
>
|
|
||||||
<p className="text-lg font-bold mb-1">Al geverifieerd!</p>
|
|
||||||
<p className="text-sm opacity-90">
|
|
||||||
Je Discord account is al gekoppeld aan dit account.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<DiscordVerifyForm />
|
|
||||||
)}
|
|
||||||
<div style={{ textAlign: "center", marginTop: "1rem" }}>
|
|
||||||
<Link href="/" className="btn btn-primary">
|
|
||||||
Naar home
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</ContentCard>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { StatusCard } from "@/components/admin/dashboard";
|
import { StatusCard } from "@/components/admin/dashboard";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
type Achievement = {
|
type Achievement = {
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import Link from "next/link";
|
|||||||
import { notFound, redirect } from "next/navigation";
|
import { notFound, redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { updateAd } from "@/actions/admin-ads";
|
import { updateAd } from "@/actions/admin-ads";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { Button } from "@/components/ui/button";
|
||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
import { EditAdDeleteButton } from "../ads-table";
|
import { EditAdDeleteButton } from "../ads-table";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -54,9 +55,9 @@ export default async function EditAd({
|
|||||||
required
|
required
|
||||||
maxLength={255}
|
maxLength={255}
|
||||||
/>
|
/>
|
||||||
<button type="submit" className="btn btn-primary">
|
<Button type="submit" variant="default">
|
||||||
{t("form.save")}
|
{t("form.save")}
|
||||||
</button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
<div style={{ marginTop: "1rem" }}>
|
<div style={{ marginTop: "1rem" }}>
|
||||||
<EditAdDeleteButton id={String(ad.id)} />
|
<EditAdDeleteButton id={String(ad.id)} />
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import Link from "next/link";
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { deleteAd } from "@/actions/admin-ads";
|
import { deleteAd } from "@/actions/admin-ads";
|
||||||
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
|
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { useServerAction } from "@/hooks/use-server-action";
|
import { useServerAction } from "@/hooks/use-server-action";
|
||||||
|
|
||||||
export type AdRow = {
|
export type AdRow = {
|
||||||
@@ -64,15 +65,16 @@ export function AdsTable({ ads }: { ads: AdRow[] }) {
|
|||||||
<td>
|
<td>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Link href={`/admin/ads/${ad.id}`}>{t("edit")}</Link>
|
<Link href={`/admin/ads/${ad.id}`}>{t("edit")}</Link>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-danger btn-sm inline-flex items-center gap-1"
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
onClick={() => handleDelete(ad)}
|
onClick={() => handleDelete(ad)}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
{t("delete")}
|
{t("delete")}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -106,14 +108,14 @@ export function EditAdDeleteButton({ id }: { id: string }) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{confirmDialog}
|
{confirmDialog}
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-danger"
|
variant="destructive"
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
>
|
>
|
||||||
{t("form.deleteAd")}
|
{t("form.deleteAd")}
|
||||||
</button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,7 @@ import { Megaphone } from "lucide-react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { createAd } from "@/actions/admin-ads";
|
import { createAd } from "@/actions/admin-ads";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
|
|
||||||
export default async function NewAd() {
|
export default async function NewAd() {
|
||||||
@@ -27,9 +28,9 @@ export default async function NewAd() {
|
|||||||
</div>
|
</div>
|
||||||
<form action={createAd} className="admin-card">
|
<form action={createAd} className="admin-card">
|
||||||
<input name="image" placeholder="Image URL" required maxLength={255} />
|
<input name="image" placeholder="Image URL" required maxLength={255} />
|
||||||
<button type="submit" className="btn btn-primary">
|
<Button type="submit" variant="default">
|
||||||
Create
|
Create
|
||||||
</button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { StatusCard } from "@/components/admin/dashboard";
|
import { StatusCard } from "@/components/admin/dashboard";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { Button } from "@/components/ui/button";
|
||||||
import { redirect } from "next/navigation";
|
import { formatDate } from "@/lib/format-date";
|
||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
import { AdsTable } from "./ads-table";
|
import { AdsTable } from "./ads-table";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -25,16 +27,14 @@ export default async function AdminAds() {
|
|||||||
ads = [];
|
ads = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const latest = ads[0]?.createdAt
|
const latest = ads[0]?.createdAt ? formatDate(ads[0].createdAt, "date") : "—";
|
||||||
? ads[0].createdAt.toISOString().slice(0, 10)
|
|
||||||
: "—";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main>
|
<main>
|
||||||
<div className="flex justify-end mb-6">
|
<div className="flex justify-end mb-6">
|
||||||
<Link href="/admin/ads/new" className="btn btn-primary">
|
<Button variant="default" asChild>
|
||||||
+ {t("newAd")}
|
<Link href="/admin/ads/new">+ {t("newAd")}</Link>
|
||||||
</Link>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||||
@@ -56,9 +56,7 @@ export default async function AdminAds() {
|
|||||||
ads={ads.map((ad) => ({
|
ads={ads.map((ad) => ({
|
||||||
id: String(ad.id),
|
id: String(ad.id),
|
||||||
image: ad.image,
|
image: ad.image,
|
||||||
createdAt: ad.createdAt
|
createdAt: ad.createdAt ? formatDate(ad.createdAt, "date") : null,
|
||||||
? ad.createdAt.toISOString().slice(0, 10)
|
|
||||||
: null,
|
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { sendHotelAlert } from "@/actions/admin-alerts";
|
import { sendHotelAlert } from "@/actions/admin-alerts";
|
||||||
import { StatusCard } from "@/components/admin/dashboard";
|
import { StatusCard } from "@/components/admin/dashboard";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { Button } from "@/components/ui/button";
|
||||||
import { redirect } from "next/navigation";
|
import { formatDate } from "@/lib/format-date";
|
||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
const CRITICAL = new Set(["critical", "error", "danger"]);
|
const CRITICAL = new Set(["critical", "error", "danger"]);
|
||||||
const WARNING = new Set(["warning", "warn"]);
|
const WARNING = new Set(["warning", "warn"]);
|
||||||
|
|
||||||
function fromDate(d: Date | null | undefined): string {
|
|
||||||
return d ? d.toISOString().slice(0, 16).replace("T", " ") : "—";
|
|
||||||
}
|
|
||||||
|
|
||||||
function severityColor(severity: string): string {
|
function severityColor(severity: string): string {
|
||||||
switch (severity.toLowerCase()) {
|
switch (severity.toLowerCase()) {
|
||||||
case "critical":
|
case "critical":
|
||||||
@@ -118,9 +116,9 @@ export default async function AdminAlerts() {
|
|||||||
placeholder={t("form.messagePlaceholder")}
|
placeholder={t("form.messagePlaceholder")}
|
||||||
className="flex-1 min-w-[240px]"
|
className="flex-1 min-w-[240px]"
|
||||||
/>
|
/>
|
||||||
<button type="submit" className="btn btn-primary">
|
<Button type="submit" variant="default">
|
||||||
{t("form.send")}
|
{t("form.send")}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -143,7 +141,7 @@ export default async function AdminAlerts() {
|
|||||||
{alerts.map((a) => (
|
{alerts.map((a) => (
|
||||||
<tr key={String(a.id)}>
|
<tr key={String(a.id)}>
|
||||||
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
||||||
{fromDate(a.createdAt)}
|
{formatDate(a.createdAt)}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<Badge
|
<Badge
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { formatDate } from "@/lib/format-date";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { dismissApplication } from "@/actions/admin-applications";
|
import { dismissApplication } from "@/actions/admin-applications";
|
||||||
import { StatusCard } from "@/components/admin/dashboard";
|
import { StatusCard } from "@/components/admin/dashboard";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { Button } from "@/components/ui/button";
|
||||||
import { redirect } from "next/navigation";
|
import { formatDate } from "@/lib/format-date";
|
||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -17,7 +18,6 @@ type ApplicationRow = {
|
|||||||
createdAt: Date | null;
|
createdAt: Date | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export default async function AdminApplications() {
|
export default async function AdminApplications() {
|
||||||
const { session, permissions } = await getAdminContext();
|
const { session, permissions } = await getAdminContext();
|
||||||
if (!canAccess(permissions, PERMS.USERS_VIEW, session.user.rank)) {
|
if (!canAccess(permissions, PERMS.USERS_VIEW, session.user.rank)) {
|
||||||
@@ -95,9 +95,9 @@ export default async function AdminApplications() {
|
|||||||
</p>
|
</p>
|
||||||
<form action={dismissApplication}>
|
<form action={dismissApplication}>
|
||||||
<input type="hidden" name="id" value={String(a.id)} />
|
<input type="hidden" name="id" value={String(a.id)} />
|
||||||
<button type="submit" className="btn btn-danger">
|
<Button type="submit" variant="destructive">
|
||||||
{t("dismiss")}
|
{t("dismiss")}
|
||||||
</button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { notFound, redirect } from "next/navigation";
|
|||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { deleteArticle, updateArticle } from "@/actions/admin-articles";
|
import { deleteArticle, updateArticle } from "@/actions/admin-articles";
|
||||||
import { ArticleForm } from "@/components/admin/article-form";
|
import { ArticleForm } from "@/components/admin/article-form";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
@@ -75,9 +76,9 @@ export default async function EditArticle({
|
|||||||
/>
|
/>
|
||||||
<form action={deleteArticle} style={{ marginTop: "1rem" }}>
|
<form action={deleteArticle} style={{ marginTop: "1rem" }}>
|
||||||
<input type="hidden" name="id" value={String(article.id)} />
|
<input type="hidden" name="id" value={String(article.id)} />
|
||||||
<button type="submit" className="btn btn-primary">
|
<Button type="submit" variant="default">
|
||||||
{t("form.deleteArticle")}
|
{t("form.deleteArticle")}
|
||||||
</button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
Loaded 100 of 346 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user