Compare commits
117
Commits
0fa832d577
...
v1.0.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52459c0b74 | ||
|
|
a640e40a5d | ||
|
|
649e2f0663 | ||
|
|
1a9b361420 | ||
|
|
3e0ba73d1c | ||
|
|
bef1775dea | ||
|
|
c5284e0b79 | ||
|
|
539e6d3fad | ||
|
|
7033d65846 | ||
|
|
35d0f3ee01 | ||
|
|
93a7e9a7c2 | ||
|
|
08321df2aa | ||
|
|
55a47949f1 | ||
|
|
0bedc04692 | ||
|
|
814d8650be | ||
|
|
193b6686a9 | ||
|
|
16f35568f5 | ||
|
|
3f2f2c6ed1 | ||
|
|
75dc84d9f1 | ||
|
|
fea8023ac6 | ||
|
|
d1003bb89b | ||
|
|
9b0ea2fb16 | ||
|
|
ef94646d60 | ||
|
|
96234075f8 | ||
|
|
a070004e7b | ||
|
|
35f66d879f | ||
|
|
9dc9d1fa4b | ||
|
|
251738fdda | ||
|
|
b5dcadb67b | ||
|
|
7c1f109a9d | ||
|
|
85a6df162b | ||
|
|
2840ef5d9d | ||
|
|
31691c1c7d | ||
|
|
6146995758 | ||
|
|
9ae03056e2 | ||
|
|
9bc0834bbb | ||
|
|
8c12fc6c60 | ||
|
|
b12e405b34 | ||
|
|
8b59bd1d2d | ||
|
|
180129699c | ||
|
|
51d1284950 | ||
|
|
85facd349a | ||
|
|
fdbb143558 | ||
|
|
226d280c22 | ||
|
|
667637f8da | ||
|
|
98b0009206 | ||
|
|
816e3875c2 | ||
|
|
c6b919c01d | ||
|
|
e17346ab9f | ||
|
|
3ae4f21217 | ||
|
|
570f3dde44 | ||
|
|
3c24df9b1d | ||
|
|
dcb5f46eed | ||
|
|
cd75361b9f | ||
|
|
08b0b5021e | ||
|
|
2619bec165 | ||
|
|
775d14f861 | ||
|
|
2d14e02a68 | ||
|
|
33b6d1520e | ||
|
|
f0dcbee162 | ||
|
|
159b1f7d1b | ||
|
|
8c1efae296 | ||
|
|
8a919f85b2 | ||
|
|
7880d5d5df | ||
|
|
a78d8256e0 | ||
|
|
9dc6b8a261 | ||
|
|
9f791ba284 | ||
|
|
26f071117b | ||
|
|
2578bf6a09 | ||
|
|
bf126fd825 | ||
|
|
bfdf4def0f | ||
|
|
b6d866b087 | ||
|
|
c0c6926309 | ||
|
|
9ec9ab31ad | ||
|
|
bfbf244141 | ||
|
|
4795ed4f8f | ||
|
|
458972fdfd | ||
|
|
5e09e115a9 | ||
|
|
4007b01da9 | ||
|
|
399c047515 | ||
|
|
61769e355b | ||
|
|
3fdcf028e8 | ||
|
|
10da44ee56 | ||
|
|
43ecdaee19 | ||
|
|
cdb0fef6c9 | ||
|
|
4139aa79ff | ||
|
|
3c0acc3fcf | ||
|
|
a52cbead8a | ||
|
|
c949319332 | ||
|
|
e97a9f3119 | ||
|
|
f0efb6b169 | ||
|
|
c992bed970 | ||
|
|
d01975706d | ||
|
|
c1b0c40725 | ||
|
|
15a0007fa7 | ||
|
|
4ce5f8ae56 | ||
|
|
4852847e0f | ||
|
|
3bf9765917 | ||
|
|
2f96ad63f0 | ||
|
|
c4a3bec367 | ||
|
|
fcf3d62197 | ||
|
|
7f1482cd1e | ||
|
|
84eec70925 | ||
|
|
2a7702cade | ||
|
|
848d62ba69 | ||
|
|
1673da5afe | ||
|
|
5e598a08b4 | ||
|
|
30c95b1a5c | ||
|
|
b810b16672 | ||
|
|
a30e4af32e | ||
|
|
67b67798b9 | ||
|
|
e1ecb190bc | ||
|
|
fb978612d7 | ||
|
|
c1cc1fdc1c | ||
|
|
037b295b93 | ||
|
|
cb927db78d | ||
|
|
58c35a2920 |
No files matched your search
Whitespace-only changes.
+12
-2
@@ -7,8 +7,18 @@ storage
|
||||
prod.log
|
||||
update.log
|
||||
.pm2
|
||||
.env
|
||||
# Only the pnpm lockfile is used. Ignore other lockfile formats and stray
|
||||
# package managers so they never taint the build context by accident.
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
bun.lockb
|
||||
.npmrc.bak
|
||||
# NOTE: .env is intentionally NOT ignored here — the build loads it (only inside
|
||||
# a build RUN layer) to produce NEXT_PUBLIC_* + validated build-time values.
|
||||
# It is not copied into the runtime image (the runner stage copies only
|
||||
# .next/standalone, public/, and .next/static).
|
||||
# .env
|
||||
*.tsbuildinfo
|
||||
# ~3GB client assets; mounted as a volume at runtime (see docker-compose.yml)
|
||||
# Runtime write targets; bound as RW volumes at runtime (see docker-compose.yml)
|
||||
public/nitro-assets
|
||||
public/swf
|
||||
@@ -76,3 +76,8 @@ LOG_LEVEL=error
|
||||
|
||||
# --- FLARESOLVERR (Cloudflare bypass for clone sources) ---
|
||||
FLARESOLVERR_URL=http://localhost:8191
|
||||
|
||||
# Catalog Studio export: dedicated clean clone on Beta-3 with Git push credentials.
|
||||
CATALOG_GIT_CHECKOUT=
|
||||
# Persistent directory shared by CMS and worker, outside the catalog clone.
|
||||
CATALOG_GIT_STATE_DIR=
|
||||
@@ -0,0 +1,2 @@
|
||||
.husky/* text eol=lf
|
||||
*.sh text eol=lf
|
||||
+66
-529
@@ -1,547 +1,84 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- "v*"
|
||||
branches: [main, master]
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
branches: [main, master]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
runtime-diagnostics:
|
||||
if: gitea.event_name == 'workflow_dispatch'
|
||||
runs-on: shell
|
||||
steps:
|
||||
- name: Read recent CMS runtime errors
|
||||
run: |
|
||||
set -e
|
||||
echo "--- Recent CMS runtime errors ---"
|
||||
pm2 logs next --lines 250 --nostream 2>&1 \
|
||||
| grep -Ei 'error|permissions|permission_ranks|permission_definitions|unknown column|doesn.t exist|digest' \
|
||||
| tail -120 \
|
||||
|| true
|
||||
echo "--- Permission page database probe ---"
|
||||
cd /var/www/atom-nexst
|
||||
pnpm diag:permissions
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Lint, typecheck & unit tests
|
||||
# Draait op de host (self-hosted) waar Node 26 +
|
||||
# pnpm 11 geïnstalleerd zijn en internet beschikbaar is.
|
||||
# De job-container (docker mode) heeft GEEN outbound
|
||||
# internet, dus we draaien alles direct op de host.
|
||||
# ─────────────────────────────────────────────
|
||||
check:
|
||||
if: startsWith(gitea.ref_name, 'v') == false
|
||||
runs-on: shell
|
||||
runs-on: self-hosted
|
||||
steps:
|
||||
- name: Typecheck, lint, and test
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: ${{ gitea.repository }}
|
||||
token: ${{ gitea.token }}
|
||||
|
||||
- name: Toolchain check
|
||||
run: node scripts/check-node-toolchain.mjs
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Dependency security audit
|
||||
run: pnpm deps:audit
|
||||
|
||||
- name: Lint
|
||||
run: pnpm biome:lint
|
||||
|
||||
- name: CMS translation contracts
|
||||
run: pnpm i18n:check
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: Test
|
||||
env:
|
||||
SKIP_ENV_VALIDATION: 1
|
||||
NODE_ENV: test
|
||||
DATABASE_URL: "mysql://test:test@localhost:3306/test?charset=utf8mb4"
|
||||
REDIS_URL: "redis://127.0.0.1:6379?connect_timeout=2"
|
||||
AUTH_SECRET: "ci-test-secret-key-that-is-long-enough"
|
||||
BCRYPT_ROUNDS: 4
|
||||
run: |
|
||||
set -e
|
||||
WORK="$(mktemp -d /var/tmp/epicnext-ci.XXXXXX)"
|
||||
cleanup() { rm -rf "${WORK}"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "--- CI checks (${WORK}) ---"
|
||||
git clone --depth 50 \
|
||||
/docker/gitea/gitea/git/repositories/remco/epicnext-cms.git \
|
||||
"${WORK}"
|
||||
cd "${WORK}"
|
||||
|
||||
REF="${{ gitea.sha }}"
|
||||
if [ -z "${REF}" ]; then
|
||||
echo "ERROR: missing gitea.sha" >&2
|
||||
exit 1
|
||||
if [ -x /usr/bin/time ]; then
|
||||
/usr/bin/time -f 'Tests: %e seconds; peak process RSS: %M KiB' pnpm test --maxWorkers=2
|
||||
else
|
||||
time pnpm test --maxWorkers=2
|
||||
fi
|
||||
git fetch --depth 50 origin "${REF}"
|
||||
git checkout -f "${REF}"
|
||||
|
||||
node scripts/check-node-toolchain.mjs
|
||||
|
||||
export SKIP_ENV_VALIDATION=1
|
||||
export NODE_ENV=test
|
||||
export DATABASE_URL="mysql://test:test@localhost:3306/test?charset=utf8mb4"
|
||||
export AUTH_SECRET="ci-test-secret-key-that-is-long-enough"
|
||||
export REDIS_URL="redis://127.0.0.1:6379?connect_timeout=1"
|
||||
export BCRYPT_ROUNDS=4
|
||||
pnpm install --frozen-lockfile
|
||||
# Types come from the committed Drizzle schema (src/db/schema.ts).
|
||||
pnpm biome:lint
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
pnpm knip
|
||||
echo "--- CI checks passed ---"
|
||||
- name: Knip (unused files/exports)
|
||||
run: pnpm knip
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Docker build & deploy
|
||||
# Draait op de host (self-hosted) zodat Docker
|
||||
# toegang heeft tot de daemon en volumes.
|
||||
# ─────────────────────────────────────────────
|
||||
deploy:
|
||||
needs: check
|
||||
if: gitea.event_name == 'push' && gitea.ref_name == 'main'
|
||||
runs-on: shell
|
||||
if: gitea.event_name == 'push' && (gitea.ref_name == 'main' || gitea.ref_name == 'master')
|
||||
runs-on: self-hosted
|
||||
steps:
|
||||
- name: Deploy
|
||||
run: |
|
||||
set -e
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: ${{ gitea.repository }}
|
||||
token: ${{ gitea.token }}
|
||||
|
||||
exec 9>/var/tmp/epic_web_control_deploy.lock
|
||||
flock -n 9 || { echo "ERROR: Another deployment is already running! Cancelling."; exit 1; }
|
||||
|
||||
echo "--- Deploying ---"
|
||||
|
||||
LIVE="/var/www/atom-nexst"
|
||||
STAGE=""
|
||||
CUTOVER_STARTED=0
|
||||
|
||||
error_handler() {
|
||||
cd /var/www/atom-nexst 2>/dev/null || cd / || true
|
||||
echo "!!! DEPLOYMENT FAILED on line $1 !!!" >&2
|
||||
# Leave the healthy current process untouched when staging fails.
|
||||
# Restart only when cutover has already stopped or replaced it.
|
||||
if [ "${CUTOVER_STARTED}" = "1" ]; then
|
||||
if [ -d "${LIVE}/.next.prev" ]; then
|
||||
echo "Rolling back .next to previous artifact..." >&2
|
||||
rm -rf "${LIVE}/.next" || true
|
||||
mv "${LIVE}/.next.prev" "${LIVE}/.next" || true
|
||||
fi
|
||||
if [ -d "${LIVE}/node_modules.prev" ]; then
|
||||
echo "Rolling back node_modules to previous artifact..." >&2
|
||||
rm -rf "${LIVE}/node_modules" || true
|
||||
mv "${LIVE}/node_modules.prev" "${LIVE}/node_modules" || true
|
||||
fi
|
||||
pm2 restart next --update-env 2>/dev/null || pm2 start pnpm --name "next" -- start 2>/dev/null || true
|
||||
fi
|
||||
if [ -n "${STAGE}" ] && [ -d "${STAGE}" ]; then
|
||||
git -C "${LIVE}" worktree remove --force "${STAGE}" 2>/dev/null || rm -rf "${STAGE}" || true
|
||||
fi
|
||||
exit 1
|
||||
}
|
||||
trap 'error_handler $LINENO' ERR
|
||||
|
||||
docker image prune -f
|
||||
|
||||
DEPLOY_USER="$(id -un)"
|
||||
DEPLOY_GROUP="$(id -gn)"
|
||||
sudo chown -R "${DEPLOY_USER}:${DEPLOY_GROUP}" "${LIVE}" 2>/dev/null || true
|
||||
git config --global --add safe.directory "${LIVE}"
|
||||
git -C "${LIVE}" remote set-url origin /docker/gitea/gitea/git/repositories/remco/epicnext-cms.git/
|
||||
|
||||
echo "Fetching origin/main..."
|
||||
git -C "${LIVE}" fetch origin --prune
|
||||
|
||||
echo "Clearing sticky git index bits (if any)..."
|
||||
STICKY_LIST="$(git -C "${LIVE}" ls-files -v | awk '/^[a-zS]/ {print substr($0,3)}' || true)"
|
||||
if [ -n "${STICKY_LIST}" ]; then
|
||||
echo "${STICKY_LIST}" | while IFS= read -r f; do
|
||||
[ -n "$f" ] || continue
|
||||
git -C "${LIVE}" update-index --no-skip-worktree --no-assume-unchanged -- "$f" 2>/dev/null || true
|
||||
done
|
||||
fi
|
||||
|
||||
export APP_VERSION="$(git -C "${LIVE}" rev-parse --short origin/main)"
|
||||
echo "APP_VERSION=${APP_VERSION}"
|
||||
|
||||
STAGE="/var/tmp/atom-nexst-stage-${APP_VERSION}"
|
||||
echo "Preparing stage worktree at ${STAGE} (live site stays up)..."
|
||||
git -C "${LIVE}" worktree remove --force "${STAGE}" 2>/dev/null || rm -rf "${STAGE}" || true
|
||||
git -C "${LIVE}" worktree add --detach "${STAGE}" origin/main
|
||||
|
||||
# Production env stays on the live tree; stage only needs a symlink for build/migrate.
|
||||
ln -sfn "${LIVE}/.env" "${STAGE}/.env"
|
||||
|
||||
if ! grep -qE '^[[:space:]]*REDIS_URL=.+' "${LIVE}/.env" 2>/dev/null; then
|
||||
echo "WARNING: REDIS_URL is unset in ${LIVE}/.env" >&2
|
||||
echo "WARNING: Rate limits, site-settings cache, and JWT invalidation cache will be in-process only." >&2
|
||||
fi
|
||||
|
||||
cd "${STAGE}"
|
||||
node scripts/check-node-toolchain.mjs
|
||||
rm -f tsconfig.tsbuildinfo .tsbuildinfo
|
||||
find . -maxdepth 3 -name '*.tsbuildinfo' -delete 2>/dev/null || true
|
||||
rm -rf .output dist .next .next/types .next/dev .next/cache
|
||||
|
||||
# No build-cache restore: every deploy is a fully clean, from-scratch
|
||||
# build so the shipped output is 100% up to date with origin/main.
|
||||
|
||||
# Stage shares MySQL with the live app + emulator. Keep the stage pool
|
||||
# tiny so install/test/build cannot exhaust max_connections.
|
||||
export DATABASE_POOL_SIZE="${DEPLOY_DATABASE_POOL_SIZE:-5}"
|
||||
echo "STAGE DATABASE_POOL_SIZE=${DATABASE_POOL_SIZE}"
|
||||
|
||||
pnpm install --frozen-lockfile
|
||||
# Types come from the committed Drizzle schema (src/db/schema.ts).
|
||||
export BCRYPT_ROUNDS=4
|
||||
pnpm typecheck
|
||||
# Validate production env (AUTH_SECRET, DATABASE_URL, …) during build.
|
||||
# Do not set SKIP_ENV_VALIDATION here — that flag is for tests/tooling only.
|
||||
pnpm build
|
||||
|
||||
if [ ! -d "${STAGE}/.next" ]; then
|
||||
echo "ERROR: stage build produced no .next/" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Migrating staged release while the current app stays online..."
|
||||
cd "${STAGE}"
|
||||
MIGRATE_OK=0
|
||||
for i in $(seq 1 10); do
|
||||
if pnpm db:migrate; then
|
||||
MIGRATE_OK=1
|
||||
break
|
||||
fi
|
||||
echo "migrate attempt ${i}/10 failed (likely DB connections), retrying..."
|
||||
sleep 5
|
||||
done
|
||||
if [ "${MIGRATE_OK}" != "1" ]; then
|
||||
echo "ERROR: db:migrate failed after retries" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "${LIVE}"
|
||||
echo "Hard reset live tree to origin/main (no nuclear src wipe)..."
|
||||
git reset --hard origin/main
|
||||
# Keep env, uploads, runtime-imported furni assets, and deps we are
|
||||
# about to replace from stage. The app can write furni files while it
|
||||
# remains online during staging, so cleaning those paths races with
|
||||
# active imports and can fail with "Directory not empty".
|
||||
git clean -fd \
|
||||
-e .env -e .env.local -e .env.production -e .env*.local \
|
||||
-e storage -e public/cache \
|
||||
-e public/swf/dcr/hof_furni \
|
||||
-e public/nitro-assets/bundled/furniture \
|
||||
-e node_modules -e node_modules.prev -e .next -e .next.prev
|
||||
|
||||
if ! git diff --exit-code HEAD -- src >/dev/null; then
|
||||
echo "ERROR: live src/ still differs from HEAD after reset:" >&2
|
||||
git diff --stat HEAD -- src >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
echo "Verified live src/ matches HEAD"
|
||||
|
||||
# Next.js prefers an already-set process PORT over .env. PM2 may still
|
||||
# have PORT=3000 from an older start, while .env (and nginx) expect 3002.
|
||||
# Export PORT before start so the process matches health checks.
|
||||
DEPLOY_PORT="$(grep -E '^[[:space:]]*PORT=' "${LIVE}/.env" 2>/dev/null | tail -1 | cut -d= -f2- || true)"
|
||||
DEPLOY_PORT="$(printf '%s' "${DEPLOY_PORT}" | tr -cd '0-9')"
|
||||
DEPLOY_PORT="${DEPLOY_PORT:-3002}"
|
||||
export PORT="${DEPLOY_PORT}"
|
||||
echo "PM2/health PORT=${PORT}"
|
||||
|
||||
free_tcp_port() {
|
||||
local port="$1"
|
||||
[ -n "${port}" ] || return 0
|
||||
if command -v fuser >/dev/null 2>&1; then
|
||||
fuser -k "${port}/tcp" 2>/dev/null || true
|
||||
elif command -v lsof >/dev/null 2>&1; then
|
||||
# Portable fallback when fuser is unavailable.
|
||||
lsof -tiTCP:"${port}" -sTCP:LISTEN 2>/dev/null | xargs -r kill -9 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
echo "Cutover: atomically swap artifacts and restart on PORT=${PORT}..."
|
||||
CUTOVER_STARTED=1
|
||||
pm2 stop next --kill-timeout 10000 || true
|
||||
|
||||
cd "${LIVE}"
|
||||
|
||||
# Rename both current artifacts so cutover and rollback stay fast.
|
||||
if [ -d .next ]; then
|
||||
mv .next .next.prev
|
||||
fi
|
||||
mv "${STAGE}/.next" .next
|
||||
if [ -d node_modules ]; then
|
||||
mv node_modules node_modules.prev
|
||||
fi
|
||||
mv "${STAGE}/node_modules" node_modules
|
||||
|
||||
free_tcp_port "${PORT}"
|
||||
free_tcp_port 3000
|
||||
echo "Starting PM2 with a clean PORT=${PORT} listener..."
|
||||
pm2 delete next 2>/dev/null || true
|
||||
PORT="${PORT}" pm2 start pnpm --name next -- start
|
||||
pm2 save 2>/dev/null || true
|
||||
|
||||
sleep 3
|
||||
if ! pm2 show next 2>/dev/null | grep -q 'online'; then
|
||||
echo "ERROR: PM2 next failed to start!" >&2
|
||||
pm2 logs next --lines 20 --nostream >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Waiting for HTTP health check on port ${PORT}..."
|
||||
HEALTH_URL="${DEPLOY_HEALTH_URL:-http://127.0.0.1:${PORT}/api/health}"
|
||||
HEALTH_OK=0
|
||||
for i in $(seq 1 20); do
|
||||
BODY="$(curl -sf --max-time 5 "${HEALTH_URL}" 2>/dev/null || true)"
|
||||
if echo "${BODY}" | grep -q '"database":true'; then
|
||||
echo "Health OK (${HEALTH_URL})"
|
||||
HEALTH_OK=1
|
||||
break
|
||||
fi
|
||||
echo "Health attempt ${i}/20 failed (body=${BODY:-<empty>}), retrying..."
|
||||
sleep 2
|
||||
done
|
||||
if [ "${HEALTH_OK}" != "1" ]; then
|
||||
echo "ERROR: Health check failed after deploy (${HEALTH_URL})" >&2
|
||||
echo "Last body: ${BODY:-<empty>}" >&2
|
||||
echo "Listeners on PORT ${PORT}:" >&2
|
||||
ss -tlnp 2>/dev/null | grep ":${PORT} " >&2 || netstat -tlnp 2>/dev/null | grep ":${PORT} " >&2 || true
|
||||
pm2 env 0 2>/dev/null | grep -E '^PORT=' >&2 || true
|
||||
pm2 logs next --lines 40 --nostream >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Cleaning stage worktree and previous artifact backups..."
|
||||
rm -rf "${LIVE}/.next.prev" "${LIVE}/node_modules.prev"
|
||||
git -C "${LIVE}" worktree remove --force "${STAGE}" 2>/dev/null || rm -rf "${STAGE}" || true
|
||||
STAGE=""
|
||||
|
||||
echo "--- Deployed successfully ---"
|
||||
|
||||
release:
|
||||
if: startsWith(gitea.ref_name, 'v')
|
||||
runs-on: shell
|
||||
steps:
|
||||
- name: Build and deploy
|
||||
- name: Build, deploy and smoke test
|
||||
shell: bash
|
||||
env:
|
||||
VERSION: ${{ gitea.ref_name }}
|
||||
run: |
|
||||
set -e
|
||||
exec 2>&1
|
||||
WORK="$(mktemp -d /var/tmp/epicnext-deploy.XXXXXX)"
|
||||
cleanup() { rm -rf "${WORK}"; }
|
||||
trap cleanup EXIT
|
||||
echo "=== Deploying ${VERSION} ==="
|
||||
git clone --depth 50 \
|
||||
/docker/gitea/gitea/git/repositories/remco/epicnext-cms.git \
|
||||
"${WORK}"
|
||||
cd "${WORK}"
|
||||
git checkout "${VERSION}"
|
||||
node scripts/check-node-toolchain.mjs
|
||||
export NODE_ENV=production
|
||||
export SKIP_ENV_VALIDATION=1
|
||||
pnpm install --frozen-lockfile
|
||||
# Types come from the committed Drizzle schema (src/db/schema.ts).
|
||||
# Tag releases must apply CMS SQL migrations against the live DB
|
||||
# (same path as push-to-main deploy), using the production .env.
|
||||
LIVE="/var/www/atom-nexst"
|
||||
ln -sfn "${LIVE}/.env" "${WORK}/.env"
|
||||
MIGRATE_OK=0
|
||||
for i in $(seq 1 10); do
|
||||
if pnpm db:migrate; then
|
||||
MIGRATE_OK=1
|
||||
break
|
||||
fi
|
||||
echo "migrate attempt ${i}/10 failed (likely DB connections), retrying..."
|
||||
sleep 5
|
||||
done
|
||||
if [ "${MIGRATE_OK}" != "1" ]; then
|
||||
echo "ERROR: db:migrate failed after retries" >&2
|
||||
exit 1
|
||||
fi
|
||||
pnpm build
|
||||
pm2 restart next --update-env
|
||||
pm2 save
|
||||
echo "=== Deploy complete ==="
|
||||
- name: Create Release
|
||||
env:
|
||||
VERSION: ${{ gitea.ref_name }}
|
||||
GITEA_API: ${{ gitea.api_url }}
|
||||
GITEA_REPO: ${{ gitea.repository }}
|
||||
run: |
|
||||
set -e
|
||||
exec 2>&1
|
||||
BARE="/docker/gitea/gitea/git/repositories/remco/epicnext-cms.git"
|
||||
echo "=== Creating release for ${VERSION} ==="
|
||||
|
||||
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 --max-count=50 "${PREV_TAG}..${VERSION}")"
|
||||
[ -z "$CHANGELOG" ] && CHANGELOG="No commit changes since ${PREV_TAG}"
|
||||
else
|
||||
TOTAL="$(git -C "$BARE" rev-list --count "${VERSION}" 2>/dev/null || echo '?')"
|
||||
CHANGELOG="Initial release of EpicNext-CMS (${TOTAL} commits)."
|
||||
fi
|
||||
[ -z "$CHANGELOG" ] && CHANGELOG="Initial release"
|
||||
|
||||
# Pin the other components at their current commits so the release is reproducible.
|
||||
CAT_REF="$(git ls-remote https://gitlab.epicnabbo.nl/remco/Epicnabbo-Catalogus-Updated-Daily.git Beta-3 2>/dev/null | awk '{print $1}')"
|
||||
NITRO_REF="$(git ls-remote https://github.com/duckietm/Nitro-V3.git main 2>/dev/null | awk '{print $1}')"
|
||||
RENDER_REF="$(git ls-remote https://github.com/duckietm/Nitro_Render_V3.git main 2>/dev/null | awk '{print $1}')"
|
||||
EMU_REF="$(git ls-remote https://github.com/duckietm/Polaris-Emulator.git main 2>/dev/null | awk '{print $1}')"
|
||||
|
||||
{
|
||||
echo "# EpicNext-CMS ${VERSION}"
|
||||
echo ""
|
||||
echo "> Modern, high-performance CMS for Habbo hotel emulators — built on Next.js 16, React 19 and Drizzle ORM. Integrates with Polaris / Arcturus Morningstar databases."
|
||||
echo ""
|
||||
echo "## Menu"
|
||||
echo "- [What is EpicNext-CMS?](#what-is-epicnext-cms)"
|
||||
echo "- [System Requirements](#system-requirements)"
|
||||
echo "- [Installation Wizard](#installation-wizard)"
|
||||
echo "- [How it is used](#how-it-is-used)"
|
||||
echo "- [Changes](#changes)"
|
||||
echo "- [Linked repositories](#linked-repositories)"
|
||||
echo ""
|
||||
echo '<a id="what-is-epicnext-cms"></a>'
|
||||
echo "## What is EpicNext-CMS?"
|
||||
echo ""
|
||||
echo "EpicNext-CMS is a full public-facing hotel website plus an administrative panel. It features NextAuth authentication (bcrypt with MD5 upgrade), real-time RCON communication with the emulator, Server-Sent Events for live radio, smooth page transitions and extensive extensibility. Full documentation: https://gitlab.epicnabbo.nl/remco/EpicNext-Cms/src/branch/main/README.md"
|
||||
echo ""
|
||||
echo '<a id="system-requirements"></a>'
|
||||
echo "## System Requirements"
|
||||
echo ""
|
||||
echo "What you need to install before running the CMS:"
|
||||
echo ""
|
||||
echo "| Component | Version | Notes |"
|
||||
echo "| --------- | ------- | ----- |"
|
||||
echo "| Node.js | 26.7.0 | Current release pinned in .nvmrc |"
|
||||
echo "| pnpm | >= 10.33.4 | Package manager (npm/yarn not supported) |"
|
||||
echo "| MySQL / MariaDB | 8.0+ / 10.6+ | Shared with the emulator |"
|
||||
echo "| Redis | 7.x+ | Optional — caching, rate limiting, SSE |"
|
||||
echo "| Java | 17+ | Only if building the emulator |"
|
||||
echo "| Maven | 3.9+ | Only if building the emulator |"
|
||||
echo ""
|
||||
echo "The CMS shares its database with the Polaris / Arcturus emulator. It only reads/writes emulator-owned tables and never alters them."
|
||||
echo ""
|
||||
echo '<a id="installation-wizard"></a>'
|
||||
echo "## Installation Wizard"
|
||||
echo ""
|
||||
echo "A complete hotel stack = **EpicNext-CMS** (this repo) + **Polaris Emulator** + **Nitro V3 client** + **Catalogus** data. Follow the steps in order."
|
||||
echo ""
|
||||
echo "**Quick links:** [Full setup guide](https://github.com/duckietm/Complete-Retro-on-Ubuntu) · [EpicNext-CMS repo](https://gitlab.epicnabbo.nl/remco/EpicNext-Cms) · [Reference configs in this repo](https://gitlab.epicnabbo.nl/remco/EpicNext-Cms/src/branch/main/setup)"
|
||||
echo ""
|
||||
echo "### 1. Clone & Install the CMS"
|
||||
echo '```bash'
|
||||
echo "git clone https://gitlab.epicnabbo.nl/remco/EpicNext-Cms.git"
|
||||
echo "cd EpicNext-Cms"
|
||||
echo "pnpm install"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "### 2. Database Setup"
|
||||
echo ""
|
||||
echo "The CMS shares the emulator database. Import the Polaris/Arcturus database first, then create the CMS schema:"
|
||||
echo '```sql'
|
||||
echo "CREATE DATABASE IF NOT EXISTS epicnext_cms CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "### 3. Configure Environment"
|
||||
echo '```bash'
|
||||
echo "cp .env.example .env"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "Edit .env with at minimum: DATABASE_URL, AUTH_SECRET, HOTEL_NAME and APP_URL. See .env.example for RCON, email, Redis, OAuth and PayPal options."
|
||||
echo ""
|
||||
echo "### 4. Run CMS Migrations"
|
||||
echo '```bash'
|
||||
echo "pnpm db:migrate"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "Creates all CMS-owned tables (website_*, radio_*, acl_*, admin_audit_log). Emulator tables are never touched. Check status with pnpm db:migrate:status. Runtime types come from the committed Drizzle schema (src/db/schema.ts) via '@/lib/db'."
|
||||
echo ""
|
||||
echo "### 5. Polaris Emulator"
|
||||
echo ""
|
||||
echo "Clone and build the emulator (requires Java 17+ and Maven 3.9+):"
|
||||
echo '```bash'
|
||||
echo "git clone https://github.com/duckietm/Polaris-Emulator.git /var/www/emulator"
|
||||
echo "cd /var/www/emulator/Emulator"
|
||||
echo "mvn clean package"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "Place the built Habbo-*-jar-with-dependencies.jar next to **config.ini** (see [setup/emulator/config.ini](https://gitlab.epicnabbo.nl/remco/EpicNext-Cms/src/branch/main/setup/emulator/config.ini)), then create a systemd unit from [setup/emulator/emulator.service](https://gitlab.epicnabbo.nl/remco/EpicNext-Cms/src/branch/main/setup/emulator/emulator.service) with the [emulator](https://gitlab.epicnabbo.nl/remco/EpicNext-Cms/src/branch/main/setup/emulator/emulator) launcher so it starts on boot. The bundled update-Nitrov3.sh in this repo automates cloning, building and updating the emulator and Nitro — run it any time to pull the latest commits and rebuild:"
|
||||
echo '```bash'
|
||||
echo "./update-Nitrov3.sh"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "### 6. Nitro V3 & Renderer"
|
||||
echo ""
|
||||
echo "Clone both Nitro repos and build the client:"
|
||||
echo '```bash'
|
||||
echo "git clone https://github.com/duckietm/Nitro_Render_V3.git /var/www/Nitro_Render_V3"
|
||||
echo "git clone https://github.com/duckietm/Nitro-V3.git /var/www/Nitro-V3"
|
||||
echo "cd /var/www/Nitro_Render_V3 && yarn install && yarn link"
|
||||
echo "cd /var/www/Nitro-V3 && yarn install && yarn link \"@nitrots/nitro-renderer\" && yarn build"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "Copy the reference configs from [setup/nitro/](https://gitlab.epicnabbo.nl/remco/EpicNext-Cms/src/branch/main/setup/nitro) into /var/www/Nitro-V3/public/configuration, keep them as *.json, and replace **MY_DOMAIN** with your domain, API URL and gamedata paths (see the Full setup guide, NitroV3_And_Emulator.md)."
|
||||
echo ""
|
||||
echo "### 7. Catalogus (catalog & gamedata)"
|
||||
echo ""
|
||||
echo "Catalogus holds the daily-updated catalog/gamedata. Clone the Beta-3 branch alongside the other components:"
|
||||
echo '```bash'
|
||||
echo "git clone -b Beta-3 https://gitlab.epicnabbo.nl/remco/Epicnabbo-Catalogus-Updated-Daily.git /var/www/catalogus"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "### 8. Build & Start the CMS"
|
||||
echo '```bash'
|
||||
echo "# Development (hot reload)"
|
||||
echo "pnpm dev"
|
||||
echo ""
|
||||
echo "# Production"
|
||||
echo "pnpm build && pnpm start"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "Open http://localhost:3000 in your browser."
|
||||
echo ""
|
||||
echo "### 9. First Login"
|
||||
echo ""
|
||||
echo "1. Register at /register, or log in with an existing emulator account."
|
||||
echo "2. Grant admin access: UPDATE users SET rank = 7 WHERE username = 'yourname';"
|
||||
echo "3. Visit /admin and configure your hotel via Admin -> CMS Settings."
|
||||
echo ""
|
||||
echo '<a id="how-it-is-used"></a>'
|
||||
echo "## How it is used"
|
||||
echo ""
|
||||
echo "- Public site: browse the hotel, news, radio and the Nitro client at /client."
|
||||
echo "- Admin panel: /admin for CMS settings, theming (12 presets), users, radio and more."
|
||||
echo "- Background jobs: run pnpm jobs:worker for daily backups and cleanup."
|
||||
echo "- Optional: Cloudflare Turnstile / reCAPTCHA, OpenAI moderation and email/PayPal via .env."
|
||||
echo ""
|
||||
echo '<a id="changes"></a>'
|
||||
echo "## Changes"
|
||||
echo '```'
|
||||
echo "${CHANGELOG}"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo '<a id="linked-repositories"></a>'
|
||||
echo "## Linked repositories (exact commits)"
|
||||
echo ""
|
||||
echo "The game components below are pinned to the exact commits used by this release and are deployed alongside the CMS:"
|
||||
echo ""
|
||||
echo "| Component | Repository | Commit |"
|
||||
echo "|-----------|------------|--------|"
|
||||
echo "| Catalogus | https://gitlab.epicnabbo.nl/remco/Epicnabbo-Catalogus-Updated-Daily | ${CAT_REF:-?} |"
|
||||
echo "| Nitro-V3 | https://github.com/duckietm/Nitro-V3 | ${NITRO_REF:-?} |"
|
||||
echo "| Nitro-Render-V3 | https://github.com/duckietm/Nitro_Render_V3 | ${RENDER_REF:-?} |"
|
||||
echo "| Polaris Emulator | https://github.com/duckietm/Polaris-Emulator | ${EMU_REF:-?} |"
|
||||
echo ""
|
||||
echo "**[Nitro-V3](https://github.com/duckietm/Nitro-V3)** · **[Nitro Renderer](https://github.com/duckietm/Nitro_Render_V3)** · **[Polaris Emulator](https://github.com/duckietm/Polaris-Emulator)** · **[Catalogus](https://gitlab.epicnabbo.nl/remco/Epicnabbo-Catalogus-Updated-Daily)**"
|
||||
echo ""
|
||||
echo "---"
|
||||
echo "*Automated release from Gitea Actions*"
|
||||
} > /tmp/release-body.md
|
||||
|
||||
PAYLOAD="$(jq -Rs --arg v "${VERSION}" '{tag_name: $v, name: $v, body: ., draft: false, prerelease: false}' < /tmp/release-body.md)"
|
||||
|
||||
TOKEN="${GITEA_TOKEN:-${{ secrets.GITEA_TOKEN }}}"
|
||||
|
||||
HTTP_CODE="$(curl -s -w '%{http_code}' -o /tmp/release-resp.json \
|
||||
-X POST "${GITEA_API}/repos/${GITEA_REPO}/releases" \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$PAYLOAD")"
|
||||
|
||||
if [ "${HTTP_CODE}" = "409" ]; then
|
||||
RELEASES="$(curl -sf "${GITEA_API}/repos/${GITEA_REPO}/releases" \
|
||||
-H "Authorization: token ${TOKEN}")"
|
||||
REL_ID="$(echo "$RELEASES" | jq -r ".[] | select(.tag_name==\"${VERSION}\") | .id")"
|
||||
HTTP_CODE="$(curl -s -w '%{http_code}' -o /tmp/release-resp.json \
|
||||
-X PATCH "${GITEA_API}/repos/${GITEA_REPO}/releases/${REL_ID}" \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$PAYLOAD")"
|
||||
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
|
||||
DEPLOY_BRANCH: ${{ gitea.ref_name }}
|
||||
run: bash scripts/ci-deploy.sh
|
||||
@@ -6,8 +6,12 @@ on:
|
||||
|
||||
jobs:
|
||||
renovate:
|
||||
runs-on: shell
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Fix Git safe directory
|
||||
run: "git config --global --add safe.directory '*' && git remote set-url origin https://epicnabbo.nl || true"
|
||||
- name: Install Bash in Alpine
|
||||
run: "if [ -f /etc/alpine-release ]; then apk add --no-cache bash; fi"
|
||||
- name: Self-hosted Renovate
|
||||
run: |
|
||||
set -e
|
||||
|
||||
@@ -33,3 +33,11 @@ public/tmp/
|
||||
|
||||
# Test coverage reports
|
||||
coverage/
|
||||
|
||||
# Playwright test artifacts
|
||||
test-results/
|
||||
playwright-report/
|
||||
blob-report/
|
||||
.aider*
|
||||
|
||||
/public/vendor/tinymce/
|
||||
@@ -0,0 +1,73 @@
|
||||
# Catalog Studio repository export
|
||||
|
||||
Studio mutations automatically queue an export to
|
||||
`https://gitlab.epicnabbo.nl/remco/Epicnabbo-Catalogus-Updated-Daily.git`, branch
|
||||
`Beta-3`. The jobs worker processes pending exports every minute. Streaming
|
||||
imports stay active until their stream completes. Server actions for catalog
|
||||
pages, offers, deletion and maintenance are covered as well.
|
||||
|
||||
## Server setup
|
||||
|
||||
1. Create a **dedicated clean clone** of the repository on `Beta-3`, outside the
|
||||
CMS directory. Configure non-interactive Git push authentication for the
|
||||
worker OS account using its credential helper. Do not put tokens in URLs.
|
||||
2. Set `CATALOG_GIT_CHECKOUT` to that absolute clone directory in the CMS and
|
||||
worker environments. Set `CATALOG_GIT_STATE_DIR` to a persistent, writable
|
||||
directory outside the clone, shared by both processes on the same host.
|
||||
3. Run `pnpm jobs:worker` alongside the CMS under your process supervisor.
|
||||
Both processes must have access to the configured asset directories and DB.
|
||||
The worker command enables the React server condition for server-only modules.
|
||||
4. Restart the CMS after setting the environment. In **Studio → Sync**, use
|
||||
**Export now / retry** for the initial export. Subsequent mutations queue
|
||||
automatically. Status shows pending/active operations and the last commit.
|
||||
|
||||
Leaving `CATALOG_GIT_CHECKOUT` empty disables export. No credentials are shipped.
|
||||
This source change alone does not configure or deploy the production service.
|
||||
|
||||
## Exported content
|
||||
|
||||
| Source | Repository destination |
|
||||
| --- | --- |
|
||||
| Configured Nitro bundles, including furniture, figures, effects and pets | `Gamedata/bundled` |
|
||||
| Configured furni icons | `Gamedata/icons` |
|
||||
| Existing badge/catalog images | `Gamedata/c_images` |
|
||||
| FurnitureData and supported public game-data JSON files | `Gamedata/config` |
|
||||
| Existing localized FurnitureData files | `catalogue version 2 ( Final (Dev)/langs furnidata` |
|
||||
| `items_base`, `catalog_pages`, `catalog_items` | `catalogue version 2 ( Final (Dev)/sqls` |
|
||||
| `catalog_pages_bc`, `catalog_items_bc`, when present | Same SQL directory |
|
||||
|
||||
SQL is read in one consistent, read-only InnoDB transaction. Dumps contain table
|
||||
definitions and deterministic upserts with hexadecimal UTF-8 string literals.
|
||||
Import `items_base.sql`, then `catalog_pages.sql`, then `catalog_items.sql`.
|
||||
Existing schemas are not migrated by these dumps. Rows absent from the source
|
||||
are omitted; importing an upsert dump into another existing database does not
|
||||
delete that database's extra rows. No user, session or credential tables are exported.
|
||||
|
||||
Only existing local assets are exported. Translation generation follows Studio's
|
||||
existing setting; export does not generate missing languages or download assets.
|
||||
Public JSON is explicitly allowlisted so translation caches and private runtime
|
||||
files cannot enter the repository. Invalid JSON or concurrent Studio changes
|
||||
prevent publication of that snapshot.
|
||||
|
||||
## Failure and concurrency behavior
|
||||
|
||||
- Pending events survive process restarts; events added during publication remain
|
||||
pending for the next run. Partial imports are exported as their settled local
|
||||
state, including successful items from a batch containing failures.
|
||||
- A single filesystem lock serializes the worker. Dead local process markers are
|
||||
recovered on the next run. For an unreadable marker or a marker from another
|
||||
host, stop the CMS and worker before repairing the queue directory.
|
||||
- A failed push retains the local commit and pending events for retry. Concurrent
|
||||
upstream commits are rebased; a conflict aborts the rebase and leaves the event
|
||||
pending. Resolve conflicts in the dedicated clone, then retry.
|
||||
- The checkout must be clean before each run. Unrelated files are preserved and
|
||||
no force push is used. Missing local files do not cause remote deletions.
|
||||
- Status errors omit raw Git output to avoid exposing authentication material.
|
||||
|
||||
## Verification
|
||||
|
||||
Run the `catalog-git-*` service tests and `src/lib/catalog-export-api.test.ts` with
|
||||
Vitest. The integration test creates a temporary bare remote and verifies push,
|
||||
failed-push recovery, no-op export and preservation of unrelated files. SQL
|
||||
snapshot tests mock the database; production DB import and production push need
|
||||
verification in the deployed environment.
|
||||
@@ -0,0 +1,30 @@
|
||||
# CMS translation audit and editor
|
||||
|
||||
The CMS editor at `/admin/translations/cms` now uses the same bundled catalogs as the request-time translator. Runtime changes are stored separately in `storage/cms-translations/<locale>.json`, inside the existing persistent `/app/storage` mount. No source-file write, environment-variable change or rebuild is required to apply an edit.
|
||||
|
||||
Only overrides are saved. Unchanged messages continue to receive updates from Git. Saves use a file lock, an atomic replacement and a revision check; a stale editor cannot overwrite another operator's changes. ICU syntax, argument names, rich-text tags and allowed keys are checked server-side. Invalid or obsolete overrides are excluded when reading a new release. Storage read errors are reported to the CMS error monitor and public pages fall back to bundled text.
|
||||
|
||||
The page starts in the operator's language. It shows all English reference keys, including missing translations, and supports search by key, translated text or English source. Filters separate missing, identical and modified text. Drafts survive language switches and failed saves. Users without `SETTINGS_EDIT` can review and export but cannot save.
|
||||
|
||||
## Audit outcome, 6 September 2026
|
||||
|
||||
- Scanned 25 JSON catalogs; 22 languages are selectable. The small Arabic, Finnish and Japanese catalogs are legacy files and remain outside the supported locale list.
|
||||
- Repaired 66 malformed ICU messages across the 22 active catalogs, including HTML fragments and unescaped JSON examples.
|
||||
- Added 199 missing English reference keys used by page components, with Italian translations, and fixed the incorrect navigation namespace in the admin error page.
|
||||
- Completed the 31 previously missing Italian reference keys.
|
||||
- Repaired missing `count` and `preset` variables in other locales.
|
||||
- Final checks: zero malformed messages, zero argument/tag mismatches and zero missing references among the statically resolved translation calls.
|
||||
- English contains 3,423 reference keys. Italian covers all of them; 629 values match English. Dutch is missing 524 reference keys and has 618 identical values. Matching English can be intentional for names and technical labels; this is not proof of translation quality.
|
||||
- Found 1,577 literal JSX text candidates outside translation calls. These include labels, technical strings and names; they are an editorial inventory, not 1,577 confirmed bugs. The largest concentrations are the catalog item table (118), Studio main component (79), import audit (53), sound management (50) and permission editor (42).
|
||||
|
||||
## Repeatable checks
|
||||
|
||||
- `pnpm i18n:check`: fails on malformed messages, incompatible variables/tags, empty messages, source parsing errors or missing statically referenced keys. Runs in Gitea CI.
|
||||
- `pnpm i18n:audit`: prints coverage and findings.
|
||||
- `node scripts/audit-cms-translations.mjs --json`: full machine-readable inventory, including file and line references for literal JSX candidates.
|
||||
|
||||
Static analysis resolves literal translator namespaces and literal message keys. Dynamic key construction, prose embedded in arbitrary JavaScript strings, and the linguistic accuracy of all 22 translations still require targeted review. English fallback remains explicit; copying English into other catalogs would hide untranslated entries and is intentionally avoided.
|
||||
|
||||
## Validation
|
||||
|
||||
Automated tests exercise message syntax, actual translator output, persistent overrides, invalid-message rejection, revision conflicts and reset behavior. A browser fixture mounts the real editor and verifies missing-key editing, validation, failed-save preservation, language switching, successful saves, read-only access and mobile layout. Server actions are simulated in that fixture; authenticated production editing requires a staff session.
|
||||
+43
-58
@@ -1,63 +1,48 @@
|
||||
# ==============================================================================
|
||||
# EpicNext-CMS — Docker image (Node 26.8.1, pnpm 11.24.0, Next.js standalone)
|
||||
# ==============================================================================
|
||||
|
||||
# --- Builder stage ---
|
||||
FROM node:26.8.1-bookworm-slim AS builder
|
||||
|
||||
# pnpm is required and pinned in package.json (packageManager: [email protected]).
|
||||
# Node 26 does not bundle corepack anymore, so install pnpm via npm.
|
||||
RUN npm install -g [email protected]
|
||||
|
||||
# syntax=docker/dockerfile:1
|
||||
# node:alpine = latest Node within the supported LTS major (tracks the newest
|
||||
# patch automatically; currently v26.x, which satisfies package.json's
|
||||
# engines ">=26.8.1 <27").
|
||||
FROM node:alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# First copy only the manifests so dependency layers are cached.
|
||||
COPY pnpm-lock.yaml package.json pnpm-workspace.yaml .npmrc ./
|
||||
RUN pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
# Copy the rest of the source.
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
# Real release id supplied by CI (ci-deploy.sh) so next.config.ts skips git
|
||||
# entirely (there is no .git in the build context). Defaults to "unknown".
|
||||
ARG NEXT_DEPLOYMENT_ID="unknown"
|
||||
ENV NEXT_DEPLOYMENT_ID="$NEXT_DEPLOYMENT_ID"
|
||||
# pnpm install is always pinned to the version in package.json's
|
||||
# `packageManager` field (pnpm auto-selects it on install), so this global
|
||||
# install only needs to exist as a bootstrap and follows the active major.
|
||||
RUN apk add --no-cache git \
|
||||
&& npm install -g pnpm@latest
|
||||
# pnpm-workspace.yaml + .npmrc must be present too: the lockfile records the
|
||||
# overrides from pnpm-workspace.yaml, and --frozen-lockfile rejects a build
|
||||
# where the workspace config is absent (ERR_PNPM_LOCKFILE_CONFIG_MISMATCH).
|
||||
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* .npmrc* ./
|
||||
# pnpm fetch: download all deps into $PNPM_STORE first, so only the lockfile
|
||||
# change (not source changes) invalidates the network-heavy download layer.
|
||||
RUN pnpm fetch --ignore-scripts
|
||||
RUN pnpm install --frozen-lockfile --ignore-scripts --offline
|
||||
COPY . .
|
||||
RUN pnpm run build
|
||||
|
||||
# Build the production bundle.
|
||||
ENV NODE_ENV=production
|
||||
RUN pnpm build
|
||||
|
||||
# Copy runtime dependencies (node_modules) needed by standalone output.
|
||||
RUN pnpm prune --prod
|
||||
|
||||
# --- Runtime stage ---
|
||||
FROM node:26.8.1-bookworm-slim AS runner
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3002
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
|
||||
# Occupied base port on the host; keep PM2-style default.
|
||||
EXPOSE 3002
|
||||
|
||||
# Non-root user for security.
|
||||
RUN groupadd --system --gid 1001 nodejs \
|
||||
&& useradd --system --uid 1001 --gid nodejs nextjs
|
||||
|
||||
FROM node:alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
# Storage directory for runtime uploaded media (persistent volume).
|
||||
# nitro/swf client assets (~3GB) are mounted here as volumes at runtime.
|
||||
RUN mkdir -p /app/storage \
|
||||
/app/public/nitro-assets \
|
||||
/app/public/swf \
|
||||
&& chown -R nextjs:nodejs /app
|
||||
|
||||
# Copy standalone Next.js output (includes a minimal node_modules).
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
# Copy static assets (public files served directly).
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||
# Copy the server-side static build output.
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
# Client assets + runtime uploads live outside the image (mounted volumes).
|
||||
VOLUME ["/app/public/nitro-assets", "/app/public/swf", "/app/storage"]
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
NEXT_TELEMETRY_DISABLED=1 \
|
||||
PORT=3002 \
|
||||
HOSTNAME=0.0.0.0
|
||||
RUN apk add --no-cache tini \
|
||||
&& addgroup -g 33 -S nextjs && adduser -u 33 -S -G nextjs nextjs \
|
||||
&& mkdir -p /app/storage /app/public/nitro-assets /app/public/swf /var/www/Gamedata \
|
||||
&& chown -R 33:33 /app/storage /app/public /var/www/Gamedata
|
||||
COPY --from=builder --chown=nextjs:nextjs /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nextjs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nextjs /app/.next/static ./.next/static
|
||||
USER nextjs
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
EXPOSE 3002
|
||||
# Self-contained healthcheck so `docker run` (ci-deploy) also gets Docker-level
|
||||
# health; docker-compose overrides this with its own probe if needed.
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
||||
CMD ["node", "-e", "fetch('http://127.0.0.1:'+(process.env.PORT||'3002')+'/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||
ENTRYPOINT ["/sbin/tini", "--"]
|
||||
CMD ["node", "server.js"]
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,205 @@
|
||||
[
|
||||
{
|
||||
"id": 132,
|
||||
"sprite_id": 132,
|
||||
"item_name": "floortile",
|
||||
"public_name": "Floor Tile",
|
||||
"type": "s",
|
||||
"width": 1,
|
||||
"length": 1,
|
||||
"stack_height": 0,
|
||||
"allow_stack": 1,
|
||||
"allow_sit": 0,
|
||||
"allow_lay": 0,
|
||||
"allow_walk": 1,
|
||||
"allow_gift": 1,
|
||||
"allow_trade": 1,
|
||||
"allow_recycle": 0,
|
||||
"allow_marketplace_sell": 0,
|
||||
"allow_inventory_stack": 1,
|
||||
"interaction_type": "default",
|
||||
"interaction_modes_count": 0,
|
||||
"vending_ids": "0",
|
||||
"multiheight": "",
|
||||
"customparams": "",
|
||||
"effect_id_male": 0,
|
||||
"effect_id_female": 0,
|
||||
"clothing_on_walk": "",
|
||||
"page_id": "429",
|
||||
"rare": "0"
|
||||
},
|
||||
{
|
||||
"id": 420,
|
||||
"sprite_id": 420,
|
||||
"item_name": "soft_jaggara_norja",
|
||||
"public_name": "Norja-pehmojakkara",
|
||||
"type": "s",
|
||||
"width": 1,
|
||||
"length": 3,
|
||||
"stack_height": 1.7,
|
||||
"allow_stack": 1,
|
||||
"allow_sit": 1,
|
||||
"allow_lay": 0,
|
||||
"allow_walk": 0,
|
||||
"allow_gift": 1,
|
||||
"allow_trade": 1,
|
||||
"allow_recycle": 0,
|
||||
"allow_marketplace_sell": 0,
|
||||
"allow_inventory_stack": 1,
|
||||
"interaction_type": "default",
|
||||
"interaction_modes_count": 1,
|
||||
"vending_ids": "0",
|
||||
"multiheight": "",
|
||||
"customparams": "",
|
||||
"effect_id_male": 0,
|
||||
"effect_id_female": 0,
|
||||
"clothing_on_walk": "",
|
||||
"page_id": "429",
|
||||
"rare": "0"
|
||||
},
|
||||
{
|
||||
"id": 1001,
|
||||
"sprite_id": 1001,
|
||||
"item_name": "Chess",
|
||||
"public_name": "",
|
||||
"type": "i",
|
||||
"width": 1,
|
||||
"length": 1,
|
||||
"stack_height": 1,
|
||||
"allow_stack": 1,
|
||||
"allow_sit": 0,
|
||||
"allow_lay": 0,
|
||||
"allow_walk": 0,
|
||||
"allow_gift": 1,
|
||||
"allow_trade": 1,
|
||||
"allow_recycle": 0,
|
||||
"allow_marketplace_sell": 0,
|
||||
"allow_inventory_stack": 1,
|
||||
"interaction_type": "default",
|
||||
"interaction_modes_count": 0,
|
||||
"vending_ids": "0",
|
||||
"multiheight": "",
|
||||
"customparams": "",
|
||||
"effect_id_male": 0,
|
||||
"effect_id_female": 0,
|
||||
"clothing_on_walk": "",
|
||||
"page_id": "429",
|
||||
"rare": "0"
|
||||
},
|
||||
{
|
||||
"id": 1011,
|
||||
"sprite_id": 1011,
|
||||
"item_name": "TicTacToe",
|
||||
"public_name": "",
|
||||
"type": "i",
|
||||
"width": 1,
|
||||
"length": 1,
|
||||
"stack_height": 1,
|
||||
"allow_stack": 1,
|
||||
"allow_sit": 0,
|
||||
"allow_lay": 0,
|
||||
"allow_walk": 0,
|
||||
"allow_gift": 1,
|
||||
"allow_trade": 1,
|
||||
"allow_recycle": 0,
|
||||
"allow_marketplace_sell": 0,
|
||||
"allow_inventory_stack": 1,
|
||||
"interaction_type": "default",
|
||||
"interaction_modes_count": 0,
|
||||
"vending_ids": "0",
|
||||
"multiheight": "",
|
||||
"customparams": "",
|
||||
"effect_id_male": 0,
|
||||
"effect_id_female": 0,
|
||||
"clothing_on_walk": "",
|
||||
"page_id": "429",
|
||||
"rare": "0"
|
||||
},
|
||||
{
|
||||
"id": 1021,
|
||||
"sprite_id": 1021,
|
||||
"item_name": "BattleShip",
|
||||
"public_name": "",
|
||||
"type": "i",
|
||||
"width": 1,
|
||||
"length": 1,
|
||||
"stack_height": 1,
|
||||
"allow_stack": 1,
|
||||
"allow_sit": 0,
|
||||
"allow_lay": 0,
|
||||
"allow_walk": 0,
|
||||
"allow_gift": 1,
|
||||
"allow_trade": 1,
|
||||
"allow_recycle": 0,
|
||||
"allow_marketplace_sell": 0,
|
||||
"allow_inventory_stack": 1,
|
||||
"interaction_type": "default",
|
||||
"interaction_modes_count": 0,
|
||||
"vending_ids": "0",
|
||||
"multiheight": "",
|
||||
"customparams": "",
|
||||
"effect_id_male": 0,
|
||||
"effect_id_female": 0,
|
||||
"clothing_on_walk": "",
|
||||
"page_id": "429",
|
||||
"rare": "0"
|
||||
},
|
||||
{
|
||||
"id": 1659,
|
||||
"sprite_id": 1659,
|
||||
"item_name": "ticket",
|
||||
"public_name": "Big Ticket Bundle",
|
||||
"type": "s",
|
||||
"width": 1,
|
||||
"length": 1,
|
||||
"stack_height": 1,
|
||||
"allow_stack": 1,
|
||||
"allow_sit": 0,
|
||||
"allow_lay": 0,
|
||||
"allow_walk": 0,
|
||||
"allow_gift": 1,
|
||||
"allow_trade": 1,
|
||||
"allow_recycle": 0,
|
||||
"allow_marketplace_sell": 0,
|
||||
"allow_inventory_stack": 1,
|
||||
"interaction_type": "default",
|
||||
"interaction_modes_count": 0,
|
||||
"vending_ids": "0",
|
||||
"multiheight": "",
|
||||
"customparams": "",
|
||||
"effect_id_male": 0,
|
||||
"effect_id_female": 0,
|
||||
"clothing_on_walk": "",
|
||||
"page_id": "429",
|
||||
"rare": "0"
|
||||
},
|
||||
{
|
||||
"id": 10056,
|
||||
"sprite_id": 10056,
|
||||
"item_name": "Vacuum",
|
||||
"public_name": "laundry_r18_vacuum",
|
||||
"type": "s",
|
||||
"width": 1,
|
||||
"length": 1,
|
||||
"stack_height": 0,
|
||||
"allow_stack": 0,
|
||||
"allow_sit": 0,
|
||||
"allow_lay": 0,
|
||||
"allow_walk": 0,
|
||||
"allow_gift": 1,
|
||||
"allow_trade": 1,
|
||||
"allow_recycle": 0,
|
||||
"allow_marketplace_sell": 0,
|
||||
"allow_inventory_stack": 1,
|
||||
"interaction_type": "default",
|
||||
"interaction_modes_count": 0,
|
||||
"vending_ids": "0",
|
||||
"multiheight": "",
|
||||
"customparams": "",
|
||||
"effect_id_male": 0,
|
||||
"effect_id_female": 0,
|
||||
"clothing_on_walk": "",
|
||||
"page_id": "9966",
|
||||
"rare": "0"
|
||||
}
|
||||
]
|
||||
+166
-9
@@ -1,20 +1,177 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
cms:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
# The host disables Docker iptables (daemon.json: "iptables": false), so
|
||||
# build containers on the bridge network have no outbound NAT/DNS. Build on
|
||||
# the host network instead so pnpm/npm/yarn can reach the registry.
|
||||
network: host
|
||||
container_name: epicnext-cms
|
||||
ports:
|
||||
# Host port -> container port (container always listens on 3002)
|
||||
- "3002:3002"
|
||||
# Runs on the host network so existing 127.0.0.1 refs in .env keep working:
|
||||
# MariaDB (3306), DragonflyDB/Redis (6379), emulator RCON (3003) + API (3001),
|
||||
# and the imaging renderer (8082). The container then listens directly on the
|
||||
# host's 3002 (the same port the current host-side CMS uses).
|
||||
# NOTE: stop the host CMS (next-server on 3002) first, otherwise the port is taken.
|
||||
network_mode: host
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- HOSTNAME=0.0.0.0
|
||||
volumes:
|
||||
# ~3GB client assets live on the host; mount them read-only into the container
|
||||
- ./public/nitro-assets:/app/public/nitro-assets:ro
|
||||
- ./public/swf:/app/public/swf:ro
|
||||
# Runtime uploaded media (persistent on the host)
|
||||
# ── Write targets (runtime imports/uploads, persistent on the host) ──
|
||||
# The CMS writes imported furni/figures/pets/effects here (see
|
||||
# src/lib/services/furni-asset-dirs.ts). These must be RW, and owned by
|
||||
# UID/GID 33 (www-data) on the host so the container user can write to them:
|
||||
# sudo chown -R 33:33 ./public/nitro-assets ./public/swf ./storage
|
||||
- ./public/nitro-assets:/app/public/nitro-assets
|
||||
- ./public/swf:/app/public/swf
|
||||
# Runtime uploaded media (persistent on the host).
|
||||
- ./storage:/app/storage
|
||||
|
||||
# ── Shared gamedata (absolute path the CMS hardcodes & writes to) ──
|
||||
# src/lib/services/furni-asset-dirs.ts: `DEFAULT_GAMEDATA_ROOT =
|
||||
# /var/www/Gamedata`. nginx on the host also serves /gamedata/ from this
|
||||
# same directory, so mount it into the container at the same absolute path.
|
||||
# Must be RW so furniture/badge imports can write mirrors to it.
|
||||
- /var/www/Gamedata:/var/www/Gamedata
|
||||
#
|
||||
# ── node_modules corruption (§ host-sync) ──
|
||||
# pnpm node_modules must NEVER be a host bind-mount: shared-filesystem
|
||||
# sync (Docker Desktop gRPC-FUSE/VirtioFS, Unison, Syncthing) corrupts
|
||||
# pnpm's hardlinked store and .bin shims. The production image already
|
||||
# bakes node_modules in at build time and is NOT bind-mounted here.
|
||||
# For local dev use a *named volume* instead of a host bind:
|
||||
# - node_modules:/app/node_modules
|
||||
# and never share `node_modules` / `pnpm store` via the Docker bind.
|
||||
# On macOS add `:cached`/`:delegated` consistency labels per mount.
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "fetch('http://localhost:3002/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
mem_limit: 2g
|
||||
|
||||
# ── FlareSolverr (Cloudflare bypass for clone sources) ──
|
||||
# Solves Cloudflare/TLS-fingerprint blocks via a headless Chrome browser.
|
||||
# The CMS calls this on http://localhost:8191 for sources that block Node's
|
||||
# TLS fingerprint (e.g. Leet.city). Used by src/lib/services/flare-solver.ts.
|
||||
flaresolverr:
|
||||
image: ghcr.io/flaresolverr/flaresolverr:latest
|
||||
container_name: flaresolverr
|
||||
network_mode: host
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- LOG_LEVEL=info
|
||||
- BROWSER_TIMEOUT=60000
|
||||
- BROWSER_WORKERS=1
|
||||
mem_limit: 2g
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sf", "http://localhost:8191/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
# ── Opt-in: Octane-Renderer (Habbo avatar imager) ──
|
||||
# Serves /imaging on port 3030 (the CMS proxies /imaging to it). Renders
|
||||
# avatars into /var/www/Gamedata/habbo-imaging, so it needs RW access.
|
||||
# Disabled by default — uncomment to run the renderer as a container.
|
||||
# imager:
|
||||
# build:
|
||||
# context: /var/www/Octane-Renderer
|
||||
# container_name: epicnext-octane-renderer
|
||||
# ports:
|
||||
# - "3030:3030"
|
||||
# restart: unless-stopped
|
||||
# volumes:
|
||||
# - /var/www/Gamedata:/var/www/Gamedata
|
||||
|
||||
# ── MariaDB "Turbo" (heavy JSON / bulk-loads) ──
|
||||
# Dedicated MariaDB tuned for >50 MB JSON imports. All tuning lives inline
|
||||
# in the service `command:` (bulk_insert_buffer_size,
|
||||
# innodb_flush_log_at_trx_commit=2, max_allowed_packet=512M, ...) so the
|
||||
# container configures itself — no external .cnf to mount or keep in sync.
|
||||
# Opt-in via profile so `docker compose up` keeps running the standalone
|
||||
# stack as today.
|
||||
#
|
||||
# Start: docker compose --profile db up -d (= pnpm db:up)
|
||||
# Note: host networking binds 127.0.0.1:3306 → STOP the host MariaDB
|
||||
# first (the app's .env DATABASE_URL points at localhost:3306).
|
||||
# Fixes the "Pulling schema from database..." hang: raise
|
||||
# net_read/net_write_timeout (done above) + stop imports while
|
||||
# running `pnpm db:generate` / drizzle-kit introspection.
|
||||
mariadb-turbo:
|
||||
image: mariadb:11
|
||||
container_name: mariadb-turbo
|
||||
profiles: ["db"]
|
||||
network_mode: host
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# mariadb:11 uses MARIADB_*; MYSQL_* also works but is deprecated there.
|
||||
- MARIADB_ROOT_PASSWORD=${MARIADB_ROOT_PASSWORD:-root}
|
||||
- MARIADB_DATABASE=${MARIADB_DATABASE:-habbo}
|
||||
- MARIADB_USER=${MARIADB_USER:-cms}
|
||||
- MARIADB_PASSWORD=${MARIADB_PASSWORD:-cms}
|
||||
- MARIADB_AUTO_UPGRADE=1
|
||||
# MariaDB tunes itself — the Official image appends these flags to mysqld,
|
||||
# no external .cnf file needed.
|
||||
command: [
|
||||
# Charset
|
||||
"--character-set-server=utf8mb4",
|
||||
"--collation-server=utf8mb4_unicode_ci",
|
||||
# 50 MB JSON batches: raise the 16 MB default packet ceiling.
|
||||
"--max-allowed-packet=512M",
|
||||
"--net-buffer-length=1M",
|
||||
# Timeouts — fixes the "Pulling schema from database..." hang
|
||||
# (drizzle-kit introspection no longer starves behind big imports).
|
||||
"--connect-timeout=30",
|
||||
"--wait-timeout=3600",
|
||||
"--interactive-timeout=3600",
|
||||
"--net-read-timeout=600",
|
||||
"--net-write-timeout=600",
|
||||
# InnoDB: durability/performance trade-off for bulk writes.
|
||||
"--innodb-flush-log-at-trx-commit=2",
|
||||
"--innodb-buffer-pool-size=2G",
|
||||
"--innodb-buffer-pool-instances=4",
|
||||
"--innodb-log-file-size=1G",
|
||||
"--innodb-log-buffer-size=64M",
|
||||
"--innodb-flush-method=O_DIRECT",
|
||||
"--innodb-autoextend-increment=512",
|
||||
"--innodb-max-dirty-pages-pct=90",
|
||||
"--bulk-insert-buffer-size=512M",
|
||||
# Raise so the engine nearly never double-writes during a 50 MB load.
|
||||
"--innodb-doublewrite=0",
|
||||
# libaio/native_aio misbehaves in some containers; io_uring path is fine.
|
||||
"--innodb-use-native-aio=0",
|
||||
# Temp tables used when MariaDB scans JSON blobs cannot be indexed.
|
||||
"--tmp-table-size=256M",
|
||||
"--max-heap-table-size=256M",
|
||||
"--read-buffer-size=4M",
|
||||
"--read-rnd-buffer-size=16M",
|
||||
# Save ~1-2 GB RAM; bulk loads don't need the instrumentation.
|
||||
"--performance-schema=OFF",
|
||||
"--skip-name-resolve",
|
||||
]
|
||||
volumes:
|
||||
# Named volume, NOT a host bind — shared-filesystem sync trashes InnoDB
|
||||
# files the same way it trashes pnpm's node_modules. Named volumes live
|
||||
# inside the container filesystem, so 50 MB of JSON writes never cross a
|
||||
# host-sync boundary.
|
||||
- mariadb-turbo-data:/var/lib/mysql
|
||||
healthcheck:
|
||||
# healthcheck.sh ships in the official mariadb image.
|
||||
test: ["CMD-SHELL", "healthcheck.sh --connect --innodb_initialized || mariadb-admin ping --silent"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
mem_limit: 4g
|
||||
|
||||
# Named volumes declared once; used by the MariaDB service above.
|
||||
volumes:
|
||||
mariadb-turbo-data:
|
||||
driver: local
|
||||
@@ -0,0 +1,118 @@
|
||||
# Refactor del catalogo HK — analisi e programma
|
||||
|
||||
Data: 6 settembre 2026. Base verificata: main, commit 9b0ea2fb. Documento di proposta, non implementazione approvata. Il sito /admin/catalog reindirizza al login senza sessione staff: nessuna prova delle mutazioni sul database di produzione. Le criticità indicate sono percorsi verificati nel codice; gli effetti concorrenti richiedono riproduzione controllata.
|
||||
|
||||
## Obiettivo e perimetro
|
||||
|
||||
Un catalogo HK con un solo ambiente di lavoro, regole coerenti e operazioni recuperabili. Conservare stile HK, icone reali, salvataggio diretto, catalogo normale e Builder Club. Nessun ritorno dei Preferiti.
|
||||
|
||||
Inclusi: albero, categorie, offerte, prezzi, bundle, disponibilità, proprietà condivise dei furni, traduzioni, ricerca, anteprima, operazioni massive, manutenzione, collegamenti a Catalog Studio e sincronizzazione Git/hotel.
|
||||
|
||||
Catalog Studio conserva la responsabilità di importare, convertire e riparare .nitro, icone e furnidata. Il refactor collega questi strumenti alla selezione del catalogo; non comporta riscrivere il convertitore, cambiare protocollo dell'emulatore o sostituire il sistema Git/Gitea già esistente.
|
||||
|
||||
## Inventario verificato
|
||||
|
||||
Sono già presenti virtualizzazione dell'albero, trascinamento, multiselezione, griglia/tabella, editor dei prezzi, anteprima negozio, import massivo, traduzioni, manutenzione e coda di export Git. Vanno riutilizzati.
|
||||
|
||||
| File | Righe attuali | Responsabilità da separare |
|
||||
|---|---:|---|
|
||||
| src/app/admin/catalog/[id]/catalog-items-table/catalog-items-table.tsx | 2342 | Rendering, selezione, editor offerta/furno, prezzi massivi, drag and drop, dialoghi |
|
||||
| src/components/admin/catalog-manager/sortable-tree.tsx | 1135 | Lettura albero, mutazioni, ricerca, trascinamento, comandi |
|
||||
| src/components/admin/catalog-manager/inline-editor.tsx | 775 | Fetch, stato modifiche, schede, salvataggio, anteprima |
|
||||
| src/app/admin/catalog/[id]/catalog-page-form.tsx | 738 | Campi, layout, media, salvataggio |
|
||||
| src/app/admin/catalog/[id]/catalog-translate-tab.tsx | 617 | Selezione, proposta traduzioni, applicazione |
|
||||
| src/app/admin/catalog/builder-club/bc-manager.tsx | 608 | Gestione parallela BC |
|
||||
| src/app/admin/catalog/page.tsx | 526 | Query, conteggi, varianti normale/BC, composizione UI |
|
||||
|
||||
Le dimensioni aiutano a trovare i punti di intervento: l'obiettivo non è un limite arbitrario di righe, ma responsabilità verificabili e riutilizzabili.
|
||||
|
||||
## Problemi e interventi
|
||||
|
||||
| Priorità | Evidenza | Intervento |
|
||||
|---|---|---|
|
||||
| P0 | sortable-tree.tsx invia il riordino dei fratelli con Promise.allSettled, una action per riga; catalog.ts aggiorna RCON per ciascuna | Un comando batch con lista completa, validazione, transazione e un solo evento di aggiornamento |
|
||||
| P0 | catalog-items.ts riordina le offerte con update sequenziali fuori transazione | Stesso contratto atomico per l'ordine delle offerte |
|
||||
| P0 | updateCatalogPage accetta parentId direttamente; il controllo cicli è separato in movePage | Validazione comune per creazione, form, spostamento e API; controllare destinazioni inesistenti e concorrenza |
|
||||
| P0 | deletePage normale sposta figli, elimina offerte e pagina separatamente | Transazione, analisi dell'impatto e snapshot ripristinabile |
|
||||
| P0 | updateCatalogItem modifica pagina, offerta e items_base con scritture separate | Transazione e verifica che il furno appartenga all'offerta; scope distinto per proprietà condivise |
|
||||
| P1 | cascadeDelete non mantiene un insieme di nodi visitati; il calcolo profondità BC è ricorsivo senza guardia ai cicli | Lettura tollerante di dati incoerenti, diagnostica e arresto sicuro delle traversate |
|
||||
| P1 | handleEditTab modifica lo stato prima della conferma; chiusura X bypassa la protezione | Un unico controllo delle modifiche per cambio pagina, offerta, scheda, uscita e navigazione |
|
||||
| P1 | loadPage/loadItemsData non annullano o identificano la richiesta precedente | AbortController e identità della selezione; solo la risposta corrente può aggiornare l'editor |
|
||||
| P1 | Il salvataggio ignora il booleano restituito da RCON; Git opera in coda | Distinguere DB salvato, invio hotel riuscito/fallito e stato Git; retry senza risalvare i dati |
|
||||
| P1 | loadCatalogItemsData usa Number(value) || fallback per order_number, offer_id e amount | Definire semantica di zero/null per campo e testare il round trip prima di cambiare i fallback |
|
||||
| P2 | Tutte le offerte e metadati sono caricati insieme; filtro con CAST(page_id AS CHAR) | Misurare query/payload, separare elenco e dettagli, paginazione e adapter compatibile INT/VARCHAR |
|
||||
| P2 | Editor normale/BC e form condividono solo parte delle regole; testi anche letterali | Contratti comuni, differenze BC esplicite, traduzioni e permessi coerenti |
|
||||
|
||||
Riferimenti principali: src/actions/catalog.ts, src/actions/catalog-items.ts, src/actions/catalog-bc.ts, src/lib/services/catalog-tree.ts, src/lib/services/catalog-items-loader.ts, src/app/api/admin/catalog/tree/route.ts, src/components/admin/catalog-manager/catalog-manager-dialog.tsx, src/components/admin/catalog-manager/inline-editor.tsx.
|
||||
|
||||
## Alternative
|
||||
|
||||
1. **Pulizia dei file mantenendo tutti gli editor:** rischio iniziale basso, ma conserva duplicazioni e differenze operative. Utile solo come passaggio iniziale.
|
||||
2. **Refactor progressivo con un editor principale — consigliato:** servizi comuni prima, poi promozione del Visual Manager a pagina. Permette piccoli rilasci e confronti tra vecchio e nuovo percorso.
|
||||
3. **Riscrittura completa:** libertà maggiore, ma più rischio di perdere casi speciali, compatibilità DB e funzioni già presenti. Non giustificata dall'inventario attuale.
|
||||
|
||||
## Architettura proposta
|
||||
|
||||
Modulo src/features/catalog con confini chiari:
|
||||
|
||||
- domain/: tipi Page, Offer, FurnitureReference, CatalogKind; validazione gerarchie, prezzi, bundle e disponibilità; nessuna dipendenza React/DB.
|
||||
- server/queries/: letture albero, elenco offerte, dettaglio e ricerca; output serializzabile esplicito.
|
||||
- server/commands/: create/update/move/reorder/delete; autorizzazione, validazione, transazioni, controllo revisione e audit.
|
||||
- server/repositories/: accesso Drizzle e compatibilità delle colonne; adapter normale/BC senza fingere che tutti i campi coincidano.
|
||||
- client/: stato selezione, modifiche locali, operazioni in corso, caricamento e gestione conflitti.
|
||||
- components/: albero, elenco offerte, editor categoria, editor offerta, dettagli furno, diagnostica, stato sincronizzazione.
|
||||
|
||||
Le route e le action attuali rimangono inizialmente adapter sottili. Un unico risultato di operazione include ID operazione, revisione, elementi modificati, eventuali errori di campo e stato sincronizzazione. Non introdurre nuove librerie prima di verificare i limiti degli strumenti già installati.
|
||||
|
||||
Flusso di scrittura: permesso → validazione → verifica revisione → transazione DB con audit → risposta di salvataggio → aggiornamento hotel/export Git. La durabilità del passaggio DB→coda va garantita con un evento persistito nella transazione o meccanismo equivalente verificato. Un fallimento Git/RCON non deve far ripetere una creazione già committata. Riutilizzare il worker e la coda esistenti, aggiungendo idempotenza dove manca.
|
||||
|
||||
## UX proposta
|
||||
|
||||
Pagina /admin/catalog con barra: Normale/BC, ricerca, nuova categoria, aggiungi furni, stato operazioni. Sotto: categorie a sinistra, offerte al centro, dettagli a destra. Il pannello dettagli si richiude; su schermi piccoli diventa una vista dedicata. Un solo scorrimento per ciascuna area, azioni di salvataggio sempre raggiungibili.
|
||||
|
||||
La URL conserva catalogo, categoria, offerta, vista e ricerca; i campi non salvati restano nello stato locale. Indietro/avanti e ricaricamento devono riaprire il contesto corretto. I vecchi URL dei dettagli continuano a funzionare.
|
||||
|
||||
Tre oggetti riconoscibili:
|
||||
|
||||
- Categoria: percorso, titolo, icona, layout, visibilità e requisiti.
|
||||
- Offerta: prezzo, valuta, quantità, componenti bundle, disponibilità e ordine.
|
||||
- Furno condiviso: classname, sprite, dimensioni e interazioni; mostrare quante offerte lo referenziano prima di una modifica globale.
|
||||
|
||||
Idee operative:
|
||||
|
||||
- Ricerca trasversale per nome, classname, ID pagina/offerta/furno e sprite ID, con percorso nei risultati.
|
||||
- Selettore visuale di categoria e layout; proprietà tecniche nelle Avanzate.
|
||||
- Prezzi con icone reali delle valute; mostrare il prima/dopo delle operazioni massive, arrotondamenti ed elementi esclusi.
|
||||
- Multiselezione con riepilogo di spostamento/eliminazione; dopo un errore mantenere selezionati i falliti.
|
||||
- Anteprima del negozio già esistente integrata nel contesto; non presentarla come prova completa del comportamento del client hotel.
|
||||
- Diagnostica su richiesta: offerta, SQL, furnidata, Nitro e icona separati. Collegamento a Catalog Studio sul furno esatto; nessuna scansione pesante a ogni apertura.
|
||||
- Storico di chi/cosa/quando con differenze e ripristino. Il ripristino controlla revisioni successive: non sovrascrive in silenzio modifiche di altri operatori e non annulla acquisti già avvenuti.
|
||||
- Riepilogo visibile: salvato, invio hotel, Git. Gli errori hanno riferimento al monitor CMS.
|
||||
- Stati vuoti, errori, caricamento e sola lettura distinti; traduzioni complete e uso da tastiera.
|
||||
|
||||
## Programma di lavoro e criteri di uscita
|
||||
|
||||
| Lotto | Consegna | Criterio per proseguire |
|
||||
|---|---|---|
|
||||
| 1. Baseline | Matrice funzioni/route/permessi normale e BC; fixture con bundle, LTD, offerte speciali, zeri/null, alberi incoerenti; misure query e rete | Tutti i flussi esistenti hanno una destinazione nel piano, senza omissioni |
|
||||
| 2. Integrità | Validatori, transazioni di riordino/spostamento/eliminazione, gerarchie sicure, revisioni | Un fallimento intermedio non lascia dati parziali; due operatori non si sovrascrivono |
|
||||
| 3. Servizi condivisi | Query/command/repository e risultato comune; vecchie route come adapter | Vecchie UI superano le stesse prove con il nuovo backend |
|
||||
| 4. Stato editor | Unica gestione delle modifiche, richieste annullabili, risposta coerente con selezione | Annullare l'uscita conserva tutto; cambi rapidi mostrano sempre l'ultima selezione |
|
||||
| 5. Pagina unificata | Visual Manager nella pagina, griglia/tabella condivise, URL, layout adattivo | Parità normale/BC e vecchi link conservati; niente perdita di scroll o azioni nascoste |
|
||||
| 6. Operazioni avanzate | Ricerca, editor bundle, prezzi massivi con differenze, storico e diagnosi contestuale | Gli effetti sono spiegati prima dell'applicazione; retry applica solo ciò che manca |
|
||||
| 7. Sincronizzazione | Stato DB/hotel/Git, operazioni persistenti, retry/idempotenza | Guasto dopo commit e riavvio worker non duplicano né perdono l'operazione |
|
||||
| 8. Prestazioni e rimozione duplicati | Paginazione, caricamento progressivo, accessibilità, eliminazione vecchi componenti | Confronto misurato e prove finali; nessuna route o funzione rimasta senza equivalente |
|
||||
|
||||
I lotti 2 e 7 condividono il contratto delle operazioni: progettare subito evento persistente e idempotenza, anche se la UI di stato arriva dopo. Nessuna stima in giorni finché non sono note dimensioni reali del catalogo, varianti DB e casi speciali attivi. Ogni lotto può richiedere più PR piccole; niente sostituzione monolitica.
|
||||
|
||||
## Verifica e rilascio
|
||||
|
||||
Test unitari delle regole; integrazione su MariaDB per rollback, concorrenza e varianti INT/VARCHAR; browser con permessi lettura/modifica, desktop e schermo ridotto. Simulare doppio submit, timeout, risposta fuori ordine, fallimento RCON, Git non raggiungibile, riavvio dopo commit. Conservare test e componenti esistenti finché la parità non è dimostrata.
|
||||
|
||||
Registrare baseline e risultati per categorie grandi/piccole: richieste per riordino, tempo DB, payload, tempo fino a editor utilizzabile, risposte fallite. Non promettere percentuali senza dati.
|
||||
|
||||
Rilascio progressivo con selezione reversibile del nuovo editor. Il ritorno alla UI precedente deve usare gli stessi servizi corretti. Migrazioni additive e compatibili; il rollback dell'app non deve richiedere la cancellazione di dati. Eliminare le vecchie UI solo dopo parità verificata. Confermare CI, deploy, health e prove staff prima di dichiarare risolto il flusso live.
|
||||
|
||||
## Primo passo consigliato
|
||||
|
||||
Lotti 1 e 2: inventario di compatibilità e correzione delle operazioni a rischio, mantenendo inizialmente l'aspetto corrente. Poi estrarre i servizi e unificare l'editor. È la sequenza che permette di migliorare UX senza portare avanti gli stessi difetti dentro una nuova schermata.
|
||||
@@ -0,0 +1,65 @@
|
||||
# CMS error center and dependency maintenance
|
||||
|
||||
Open **HK > DevOps > CMS error center** (`/admin/devops/cms-errors`).
|
||||
The previous `/admin/devops/errors` page still displays emulator errors.
|
||||
|
||||
## Access and workflow
|
||||
|
||||
- Read: existing `admin.devops.view` permission, checked on the server.
|
||||
- Mark resolved: `admin.devops.edit`, checked again in the server action; recorded in the staff audit log.
|
||||
- Search by event reference, Next.js digest, route, message or deployment version.
|
||||
- Expand a group for its latest stack, sanitized context and occurrence references.
|
||||
- Marking a group resolved does not delete evidence. A later occurrence reopens it.
|
||||
- Refresh is manual so inspecting an expanded error is not interrupted by polling.
|
||||
|
||||
## What is collected
|
||||
|
||||
Pino `logger.error`, `logServerError`, unexpected action errors, Next.js request
|
||||
failures, React error boundaries, uncaught browser errors and unhandled browser
|
||||
promise rejections. API wrapper failures return an `errorId`; Next.js boundary
|
||||
errors can be correlated by their digest. Release identifiers come from the build.
|
||||
Browser reports retain their own client release separately from the receiving server.
|
||||
|
||||
Reports are stored in `storage/cms-errors`, using the existing persistent storage
|
||||
mount. No third-party service, token or new database table is required.
|
||||
Storage does not depend on database availability; viewing the HK and its permission
|
||||
checks still require authentication/database availability. Server console logs
|
||||
remain the fallback when the CMS itself cannot serve requests.
|
||||
|
||||
Retention: seven days; approximately 10 MB/day, 100 queued server writes, and the
|
||||
latest 1,000 events in the viewer. Limits are per CMS process/storage volume;
|
||||
this is intended for the existing single-instance deployment. Daily expiry runs
|
||||
on the next write. The browser endpoint is same-origin, body-size bounded and
|
||||
rate-limited. Browser reports are untrusted observations and cannot grant access.
|
||||
Known credential patterns and URL parameters are redacted; arbitrary object
|
||||
metadata and request bodies are excluded. Avoid putting personal data in error
|
||||
messages: this is pattern-based redaction, not a universal data-loss filter.
|
||||
|
||||
This does not collect historical console logs, process crashes before framework
|
||||
startup, nginx failures, all `console.error` calls, or every handled business
|
||||
validation error. A browser stack may point at minified chunks; private source-map
|
||||
symbolication and distributed traces are not part of this local viewer. A recorded
|
||||
stack is diagnostic evidence, not an automatic root-cause determination.
|
||||
|
||||
## Dependencies
|
||||
|
||||
`pnpm install --frozen-lockfile`, `pnpm deps:audit`, `pnpm typecheck`, `pnpm test`,
|
||||
`pnpm knip`, `pnpm biome:lint`, and `pnpm build` are the verification sequence.
|
||||
`pnpm analyze --output` writes a Next.js bundle analysis (not an application build).
|
||||
|
||||
TinyMCE 8.9 is pinned in pnpm. `pnpm assets:editor` copies its runtime files and
|
||||
license notices to ignored `public/vendor/tinymce`; dev/build run this first.
|
||||
The Docker build includes the generated assets. The editor retains its existing
|
||||
HTML fields and toolbar; validate saved content and preview when upgrading it.
|
||||
|
||||
Lenis has been removed; the public site now uses native scrolling. Other used
|
||||
runtime libraries remain. Vitest and its coverage provider are upgraded together;
|
||||
`clearMocks: false` preserves initialization-time permission contract assertions.
|
||||
|
||||
Renovate uses separate development/UI/Vitest groups with manual merge and a
|
||||
three-day release age. The existing external Gitea bot configuration is retained.
|
||||
Node/pnpm upgrades remain coordinated with Docker and the runner toolchain.
|
||||
Obsolete global overrides were removed; a scoped esbuild override remains because
|
||||
Drizzle Kit's loader still resolves a vulnerable legacy development-server build.
|
||||
The two deprecated esbuild-kit packages remain upstream dependencies of Drizzle
|
||||
Kit; replacing the ORM is not warranted for this tooling issue.
|
||||
@@ -0,0 +1,21 @@
|
||||
# Catalog operations upgrade plan
|
||||
|
||||
User approved bulk editing and complete category duplication, keeping Visual Manager in its button-opened modal.
|
||||
|
||||
Use subagent-driven-development for the independent duplication task; root owns bulk operations and final review.
|
||||
|
||||
- [x] Duplicate category subtree and offers atomically for normal/BC; preview counts, destination/name, fresh IDs, preserve furniture references; fail on stale source, invalid destination, cycles and insert failure. Shared permissions, single export/RCON outcome. UI in Visual Manager.
|
||||
- [x] Extend selected-offer operations to preview and atomically apply prices, currency, and destination. Visibility belongs to categories, so expose it separately with exact affected categories; no fictitious per-offer flag. Preserve unselected and unrelated fields; conflicts prevent overwriting concurrent changes.
|
||||
- [x] Meaningful domain/transaction/action tests, browser fixtures for modal preview/confirm, typecheck/Biome/i18n/Knip; review limits and commit exact scope.
|
||||
|
||||
No production data mutations, added dependencies or schema migrations. Full category duplication shares existing items_base definitions and asset files. Repeated confirmations must be disabled while saving. Preview should be revalidated under locks before write. Existing direct save and permissions remain.
|
||||
|
||||
## Implementation notes
|
||||
|
||||
Bulk editing now works from selected normal-catalog offers and from selection across global searches. Only explicitly chosen prices/currency/destination fields change. Prices support set/add/percentage with integer rounding and range validation; 500 selected offers per transaction. Preview binds rows, changes and category names; concurrent changes reject confirmation. Category visibility remains on the existing category controls, not a fabricated offer property. BC has no offer pricing and does not expose that editor.
|
||||
|
||||
Duplication copies up to 500 categories and 5000 offers, preserving bundle and asset references. New root starts disabled and hidden. Includes and internal offer IDs are remapped. Explicit normal offer IDs use the existing allocator and do not require AUTO_INCREMENT; database collisions roll back the whole copy. Preview binds destination siblings as well as source data so confirmed preview replay is rejected. Dirty editor state blocks copying saved data until drafts are saved/reset.
|
||||
|
||||
No database migration or dependency added. Database transaction tests use mocks and do not prove live MariaDB locking behavior. Browser fixtures use real components with mocked actions, not production writes. Existing allocator is process-local; competing external imports may cause a safe rollback requiring a fresh preview. No automatic retry of uncertain copy commits.
|
||||
|
||||
Verification complete: 1339 unit tests passed, 5 skipped. Typecheck/Knip/i18n and changed-file Biome checked. Real-component browser fixtures passed bulk selection, preview, conflicts, pending guards, mobile bounds and table refresh without phantom drafts; duplication nested inside actual manager verified menus, picker, preview and dirty-state guards. Fixed manager portal layering and invalid menu-label nesting uncovered by those tests. Browser actions are mocked; no live data written or deployment claimed.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Catalog refactor implementation plan
|
||||
|
||||
> For agentic workers: use superpowers:subagent-driven-development for independent changes and review each deliverable.
|
||||
|
||||
Goal: deliver the approved progressive catalog refactor while preserving current features.
|
||||
Architecture: shared domain validation and transactional commands behind existing action contracts; unified editor reuses current views and tools.
|
||||
Stack: Next, React, Drizzle/MariaDB, Zod, Vitest, Playwright; no added dependencies.
|
||||
Spec: docs/CATALOG_REFACTOR_PLAN.md
|
||||
Constraints: preserve normal/BC differences, direct save, real icons, permissions, existing routes, no favorites. No production data mutations for testing.
|
||||
|
||||
- [x] Characterize compatibility and command rules with tests; capture baseline source map.
|
||||
- [x] Domain hierarchy/reorder validation and transactional page commands (normal/BC), deterministic locking, common updates/deletion.
|
||||
- [x] Offer update/reorder atomicity and safe numeric normalization, shared mutation contracts.
|
||||
- [x] Editor cancellation/dirty state protection (agent catalog_editor_state), review and browser verification.
|
||||
- [x] Promote editor as an in-page view with reversible classic view, URL context, responsive panels and coherent tool access.
|
||||
- [ ] Sync outcomes/diagnostics/history integration; safe retry and explicit limits.
|
||||
- [ ] Verify unit tests, database integration if runtime available, browser, typecheck, lint/i18n, full suite; review final scope and remaining environment-only checks.
|
||||
|
||||
Execution notes: changes are progressive, no destructive migration. Existing UI adapters stay until functional parity is tested. Whole-program completion must not be claimed from the first deliverable.
|
||||
|
||||
## First implementation delivery (2026-09-06)
|
||||
|
||||
Completed: shared normal/BC page and offer commands; transactional page reorder/delete/move and offer create/update/reorder; hierarchy validation and optimistic page-save checks; embedded default manager with classic views retained; request cancellation, editor unsaved-change guards and URL selection; bounded global category/offer/furniture search; separate hotel/Git status and hotel retry; export-finalization failures no longer mask committed server actions. Existing permissions and direct-save behavior retained. No added dependency or DB migration.
|
||||
|
||||
Verification: full unit suite 1308 passed / 5 skipped before final retry-classification regression; final focused catalog suite 96 passed. TypeScript, i18n static validation, Knip and Biome on all 54 changed source files pass. Browser fixtures cover editor loading races, retry failures, unsaved changes, URL history, read-only, normal/BC, search and 375px layout. Database calls and mutations are mocked in those fixtures. Full-repository formatter check reports pre-existing Windows CRLF formatting differences; unrelated files were not reformatted.
|
||||
|
||||
Remaining roadmap: per-operation durable dispatch/outbox, category/offer snapshot history and conflict-aware undo, contextual maintenance diagnosis, pagination/performance measurement against representative real data, further decomposition of retained legacy views. Existing coarse audit/export infrastructure remains; the new status file is not a durable outbox and RCON socket success does not prove client application. External furni importer mutation internals remain outside shared editor commands.
|
||||
|
||||
Environment checks still required: real MariaDB locking/rollback integration, staff-authenticated end-to-end smoke test and pipeline/deployment health. No production mutations performed; no production deployment claimed.
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Existing articles remain published. Preserve any publication settings already present.
|
||||
ALTER TABLE website_articles
|
||||
ADD COLUMN IF NOT EXISTS status VARCHAR(20) NOT NULL DEFAULT 'published',
|
||||
ADD COLUMN IF NOT EXISTS publish_at TIMESTAMP NULL DEFAULT NULL,
|
||||
ADD COLUMN IF NOT EXISTS published_at TIMESTAMP NULL DEFAULT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_website_articles_publication
|
||||
ON website_articles (status, publish_at, created_at);
|
||||
@@ -0,0 +1,13 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("health endpoint is reachable", async ({ request }) => {
|
||||
const res = await request.get("/api/health");
|
||||
expect(res.ok()).toBeTruthy();
|
||||
const body = await res.json();
|
||||
expect(body).toHaveProperty("status");
|
||||
});
|
||||
|
||||
test("homepage renders", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page).toHaveTitle(/.+/);
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
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 tseslint from "typescript-eslint";
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: ["node_modules", ".next", "dist", "build"],
|
||||
},
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
{
|
||||
files: ["src/**/*.{ts,tsx}", "pages/**/*.{ts,tsx}", "app/**/*.{ts,tsx}"],
|
||||
plugins: {
|
||||
"unused-imports": unusedImports,
|
||||
security: security,
|
||||
"react-hooks": reactHooks,
|
||||
"@next/next": nextPlugin,
|
||||
},
|
||||
rules: {
|
||||
// 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",
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -12,6 +12,6 @@
|
||||
"scripts/furni-diagnose-now.ts"
|
||||
],
|
||||
"project": ["src/**/*.{ts,tsx,css}", "scripts/**/*.{ts,js}"],
|
||||
"ignoreDependencies": ["@sentry/nextjs", "pino-pretty", "husky"],
|
||||
"ignoreDependencies": ["pino-pretty"],
|
||||
"ignoreBinaries": ["sendmail"]
|
||||
}
|
||||
@@ -27,6 +27,10 @@ const securityHeaders = [
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
env: {
|
||||
NEXT_PUBLIC_CMS_RELEASE:
|
||||
process.env.NEXT_DEPLOYMENT_ID?.trim() || getGitCommit() || "unknown",
|
||||
},
|
||||
deploymentId: process.env.NEXT_DEPLOYMENT_ID?.trim() || getGitCommit(),
|
||||
distDir: process.env.NEXT_DIST_DIR?.trim() || ".next",
|
||||
reactStrictMode: true,
|
||||
|
||||
+38
-21
@@ -7,8 +7,8 @@
|
||||
},
|
||||
"packageManager": "[email protected]+sha512.5cde925b4f075f725eb71fbae18a42ffe784524789f19b61c731cb8721ec28aaee160e01a8d5af4fedb2a42cdbf300efe23db356b0d4a17b4d63e11f8ab7c956",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"dev": "pnpm assets:editor && next dev",
|
||||
"build": "pnpm assets:editor && next build",
|
||||
"start": "next start",
|
||||
"toolchain:check": "node scripts/check-node-toolchain.mjs",
|
||||
"lint": "biome check .",
|
||||
@@ -16,76 +16,93 @@
|
||||
"format": "biome format --write .",
|
||||
"knip": "knip --include files,dependencies,devDependencies,unlisted,binaries",
|
||||
"diag:permissions": "tsx scripts/diagnose-permission-page.ts",
|
||||
"jobs:worker": "tsx scripts/jobs-worker.ts",
|
||||
"jobs:worker": "node --conditions=react-server --import tsx scripts/jobs-worker.ts",
|
||||
"test": "vitest run",
|
||||
"test:e2e": "playwright test",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:introspect": "drizzle-kit introspect",
|
||||
"db:bulk": "tsx scripts/bulk-import-json.ts",
|
||||
"db:up": "docker compose --profile db up -d mariadb-turbo",
|
||||
"db:down": "docker compose --profile db stop mariadb-turbo",
|
||||
"db:schema:generate": "node scripts/generate-drizzle-schema.mjs",
|
||||
"db:migrate": "tsx scripts/apply-migrations.ts",
|
||||
"db:migrate:status": "tsx scripts/apply-migrations.ts --status",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"gamedata:compress": "node scripts/compress-gamedata.mjs",
|
||||
"hk:matrix:check": "tsx scripts/verify-housekeeping-matrix.ts",
|
||||
"test:housekeeping": "vitest run --coverage.enabled=false src/features/housekeeping src/lib/admin-theme-source-audit.test.ts src/lib/admin/authorization-contract.test.ts"
|
||||
"test:housekeeping": "vitest run --coverage.enabled=false src/features/housekeeping src/lib/admin-theme-source-audit.test.ts src/lib/admin/authorization-contract.test.ts",
|
||||
"prepare": "node scripts/prepare-hooks.mjs",
|
||||
"assets:editor": "node scripts/copy-editor-assets.mjs",
|
||||
"deps:audit": "pnpm audit --audit-level=high",
|
||||
"analyze": "next experimental-analyze",
|
||||
"i18n:check": "node scripts/audit-cms-translations.mjs --check",
|
||||
"i18n:audit": "node scripts/audit-cms-translations.mjs"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,ts,jsx,tsx,json}": "biome check --write --no-errors-on-unmatched"
|
||||
"*.{js,ts,jsx,tsx,json}": "biome check --write --no-errors-on-unmatched",
|
||||
"*.{ts,tsx}": "node scripts/check-admin-colors.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "1.7.0",
|
||||
"@base-ui/react": "1.8.0",
|
||||
"@dnd-kit/core": "6.3.1",
|
||||
"@dnd-kit/sortable": "10.0.0",
|
||||
"@dnd-kit/utilities": "3.2.2",
|
||||
"@formatjs/icu-messageformat-parser": "3.5.17",
|
||||
"@hookform/resolvers": "5.9.1",
|
||||
"@tanstack/react-virtual": "3.14.10",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
"cmdk": "1.1.1",
|
||||
"croner": "10.0.1",
|
||||
"dompurify": "3.4.14",
|
||||
"drizzle-orm": "0.45.2",
|
||||
"hash-wasm": "4.12.0",
|
||||
"ioredis": "6.0.0",
|
||||
"isomorphic-dompurify": "^4.1.0",
|
||||
"jpeg-js": "0.4.4",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"jszip": "3.10.1",
|
||||
"lenis": "1.3.26",
|
||||
"lucide-react": "1.38.0",
|
||||
"lucide-react": "1.41.0",
|
||||
"lzma-wasm": "1.0.7",
|
||||
"motion": "13.1.1",
|
||||
"motion": "13.2.0",
|
||||
"music-metadata": "11.15.0",
|
||||
"mysql2": "3.24.2",
|
||||
"next": "16.3.3",
|
||||
"mysql2": "3.24.3",
|
||||
"next": "16.3.4",
|
||||
"next-auth": "5.0.0-beta.32",
|
||||
"next-intl": "4.14.1",
|
||||
"next-intl": "4.14.2",
|
||||
"otplib": "13.5.0",
|
||||
"pino": "10.3.1",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8",
|
||||
"react-hook-form": "7.87.0",
|
||||
"resend": "6.25.0",
|
||||
"resend": "6.26.0",
|
||||
"server-only": "0.0.1",
|
||||
"sharp": "^0.35.4",
|
||||
"sonner": "2.0.8",
|
||||
"tailwind-merge": "3.6.0",
|
||||
"tinymce": "8.9.0",
|
||||
"zod": "4.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.5.11",
|
||||
"@babel/parser": "7.29.8",
|
||||
"@biomejs/biome": "2.5.12",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@tailwindcss/forms": "0.5.11",
|
||||
"@tailwindcss/postcss": "4.3.3",
|
||||
"@tailwindcss/typography": "0.5.20",
|
||||
"@types/node": "26.4.0",
|
||||
"@types/node": "26.4.1",
|
||||
"@types/react": "19.2.18",
|
||||
"@types/react-dom": "19.2.5",
|
||||
"@vitest/coverage-v8": "4.1.11",
|
||||
"@types/react-dom": "19.2.7",
|
||||
"@vitest/coverage-v8": "5.0.0",
|
||||
"drizzle-kit": "0.31.10",
|
||||
"husky": "9.1.7",
|
||||
"knip": "6.33.0",
|
||||
"knip": "6.34.0",
|
||||
"lint-staged": "17.4.1",
|
||||
"pino-pretty": "13.1.3",
|
||||
"postcss": "^8.5.26",
|
||||
"postcss": "8.5.28",
|
||||
"tailwindcss": "4.3.3",
|
||||
"tsx": "4.23.13",
|
||||
"typescript": "7.0.2",
|
||||
"vitest": "4.1.11"
|
||||
"vitest": "5.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? "http://127.0.0.1:3000";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
fullyParallel: true,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
reporter: process.env.CI ? "github" : "list",
|
||||
use: { baseURL, trace: "on-first-retry" },
|
||||
webServer: process.env.PLAYWRIGHT_BASE_URL
|
||||
? undefined
|
||||
: {
|
||||
command: "pnpm dev --port 3000",
|
||||
url: "http://127.0.0.1:3000/api/health",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000,
|
||||
},
|
||||
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
|
||||
});
|
||||
Generated
+952
-389
File diff suppressed because it is too large.
Load diff
+4
-71
@@ -1,80 +1,13 @@
|
||||
# pnpm-workspace.yaml
|
||||
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
sharp: true
|
||||
"@parcel/watcher": true
|
||||
"@swc/core": true
|
||||
bcrypt: true
|
||||
|
||||
minimumReleaseAgeExclude:
|
||||
- "@base-ui/[email protected]"
|
||||
- "@base-ui/[email protected]"
|
||||
- "@biomejs/[email protected]"
|
||||
- "@biomejs/[email protected]"
|
||||
- "@biomejs/[email protected]"
|
||||
- "@biomejs/[email protected]"
|
||||
- "@biomejs/[email protected]"
|
||||
- "@biomejs/[email protected]"
|
||||
- "@biomejs/[email protected]"
|
||||
- "@biomejs/[email protected]"
|
||||
- "@biomejs/[email protected]"
|
||||
- "@hookform/[email protected]"
|
||||
- "@next/[email protected]"
|
||||
- "@next/[email protected]"
|
||||
- "@next/[email protected]"
|
||||
- "@next/[email protected]"
|
||||
- "@next/[email protected]"
|
||||
- "@next/[email protected]"
|
||||
- "@next/[email protected]"
|
||||
- "@next/[email protected]"
|
||||
- "@next/[email protected]"
|
||||
- "@next/[email protected]"
|
||||
- "@next/[email protected]"
|
||||
- "@next/[email protected]"
|
||||
- "@next/[email protected]"
|
||||
- "@next/[email protected]"
|
||||
- "@next/[email protected]"
|
||||
- "@next/[email protected]"
|
||||
- "@next/[email protected]"
|
||||
- "@next/[email protected]"
|
||||
- "@next/[email protected]"
|
||||
- "@next/[email protected]"
|
||||
- "[email protected]"
|
||||
- "[email protected]"
|
||||
- "[email protected]"
|
||||
- "[email protected]"
|
||||
- "[email protected]"
|
||||
- "[email protected]"
|
||||
- "[email protected]"
|
||||
- "[email protected]"
|
||||
- "[email protected]"
|
||||
minimumReleaseAge: 1440
|
||||
|
||||
overrides:
|
||||
glob: "^11.0.3"
|
||||
rimraf: "^6.0.1"
|
||||
uuid: "^11.1.0"
|
||||
fast-uri: "^3.1.3"
|
||||
"@hono/node-server": "^1.19.13"
|
||||
postcss: "^8.5.26"
|
||||
tmp: "^0.2.6"
|
||||
sharp: "^0.35.4"
|
||||
brace-expansion: "^5.0.8"
|
||||
# drizzle-kit still uses an obsolete development-server dependency.
|
||||
"@esbuild-kit/core-utils>esbuild": "^0.25.9"
|
||||
"@types/react": "19.2.18"
|
||||
"@types/react-dom": "19.2.5"
|
||||
# Tijdelijke mitigatie voor drizzle-kit audit
|
||||
esbuild: "^0.25.9"
|
||||
|
||||
allowedDeprecatedVersions:
|
||||
"@esbuild-kit/esm-loader": "*"
|
||||
"@esbuild-kit/core-utils": "*"
|
||||
|
||||
ignoredBuiltDependencies:
|
||||
- "@prisma/engines"
|
||||
- "prisma"
|
||||
|
||||
peerDependencyRules:
|
||||
allowedVersions:
|
||||
nodemailer: "9.0.3"
|
||||
ignoreMissing:
|
||||
- nodemailer
|
||||
"@types/react-dom": "19.2.7"
|
||||
+24
-17
@@ -14,29 +14,36 @@
|
||||
"prConcurrentLimit": 5,
|
||||
"packageRules": [
|
||||
{
|
||||
"description": "Group all dependency updates into a single PR",
|
||||
"matchPackagePatterns": ["*"],
|
||||
"groupName": "All dependencies",
|
||||
"groupSlug": "all",
|
||||
"automerge": true
|
||||
"matchDepTypes": ["devDependencies"],
|
||||
"matchUpdateTypes": ["minor", "patch"],
|
||||
"groupName": "Development tools",
|
||||
"automerge": false
|
||||
},
|
||||
{
|
||||
"matchPackageNames": [
|
||||
"@base-ui/react",
|
||||
"lucide-react",
|
||||
"motion",
|
||||
"@dnd-kit/**"
|
||||
],
|
||||
"matchUpdateTypes": ["minor", "patch"],
|
||||
"groupName": "Interface libraries",
|
||||
"automerge": false
|
||||
},
|
||||
{
|
||||
"matchPackageNames": ["vitest", "@vitest/coverage-v8"],
|
||||
"groupName": "Vitest",
|
||||
"automerge": false
|
||||
},
|
||||
{
|
||||
"description": "Major updates need manual review",
|
||||
"matchUpdateTypes": ["major"],
|
||||
"labels": ["dependencies", "major"],
|
||||
"automerge": false,
|
||||
"assignees": [],
|
||||
"reviewers": []
|
||||
"automerge": false
|
||||
},
|
||||
{
|
||||
"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
|
||||
}
|
||||
]
|
||||
],
|
||||
"minimumReleaseAge": "3 days",
|
||||
"automerge": false
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import * as babel from "@babel/parser";
|
||||
import { parse } from "@formatjs/icu-messageformat-parser";
|
||||
|
||||
const flat = (obj, p = "", r = {}) => {
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
const key = p ? `${p}.${k}` : k;
|
||||
if (v && typeof v === "object") flat(v, key, r);
|
||||
else r[key] = v;
|
||||
}
|
||||
return r;
|
||||
};
|
||||
const messages = Object.fromEntries(
|
||||
fs
|
||||
.readdirSync("src/messages")
|
||||
.filter((n) => n.endsWith(".json"))
|
||||
.map((n) => [
|
||||
n.slice(0, -5),
|
||||
flat(JSON.parse(fs.readFileSync(`src/messages/${n}`, "utf8"))),
|
||||
]),
|
||||
);
|
||||
const base = messages.en,
|
||||
invalid = [],
|
||||
mismatches = [];
|
||||
function vars(ast, r = new Set()) {
|
||||
for (const n of ast) {
|
||||
if ([1, 2, 3, 4, 5, 6, 8].includes(n.type)) r.add(n.value);
|
||||
if (n.options) for (const o of Object.values(n.options)) vars(o.value, r);
|
||||
if (n.children) vars(n.children, r);
|
||||
}
|
||||
return [...r].sort().join(",");
|
||||
}
|
||||
const signatures = {};
|
||||
for (const [k, v] of Object.entries(base)) {
|
||||
try {
|
||||
signatures[k] = vars(parse(v));
|
||||
} catch {}
|
||||
}
|
||||
const locales = Object.entries(messages).map(([locale, m]) => {
|
||||
for (const [k, v] of Object.entries(m)) {
|
||||
try {
|
||||
const sig = vars(parse(v));
|
||||
if (k in signatures && sig !== signatures[k])
|
||||
mismatches.push({
|
||||
locale,
|
||||
key: k,
|
||||
expected: signatures[k],
|
||||
actual: sig,
|
||||
value: v,
|
||||
});
|
||||
} catch (e) {
|
||||
invalid.push({ locale, key: k, value: v, error: e.message });
|
||||
}
|
||||
}
|
||||
return {
|
||||
locale,
|
||||
total: Object.keys(m).length,
|
||||
missing: Object.keys(base).filter((k) => !(k in m)).length,
|
||||
empty: Object.values(m).filter((v) => !v?.trim()).length,
|
||||
sameAsEnglish: Object.keys(base).filter((k) => m[k] === base[k]).length,
|
||||
};
|
||||
});
|
||||
const missingCalls = [],
|
||||
hardcoded = [],
|
||||
unparsedFiles = [];
|
||||
function walk(n, fn) {
|
||||
if (!n || typeof n !== "object") return;
|
||||
fn(n);
|
||||
for (const [k, v] of Object.entries(n)) {
|
||||
if (k === "loc" || k === "extra") continue;
|
||||
if (Array.isArray(v))
|
||||
v.forEach((x) => {
|
||||
walk(x, fn);
|
||||
});
|
||||
else if (v && typeof v === "object") walk(v, fn);
|
||||
}
|
||||
}
|
||||
for (const f of fs
|
||||
.readdirSync("src", { recursive: true })
|
||||
.filter((f) => /\.(tsx|ts)$/.test(f) && !f.includes(".test."))) {
|
||||
let ast;
|
||||
try {
|
||||
ast = babel.parse(fs.readFileSync(path.join("src", f), "utf8"), {
|
||||
sourceType: "module",
|
||||
plugins: ["typescript", "jsx"],
|
||||
});
|
||||
} catch (error) {
|
||||
unparsedFiles.push({ file: f, error: error.message });
|
||||
continue;
|
||||
}
|
||||
const bindings = {};
|
||||
walk(ast, (n) => {
|
||||
if (n.type === "VariableDeclarator" && n.id.type === "Identifier") {
|
||||
let c = n.init;
|
||||
if (c?.type === "AwaitExpression") c = c.argument;
|
||||
if (
|
||||
c?.type === "CallExpression" &&
|
||||
["useTranslations", "getTranslations"].includes(c.callee.name)
|
||||
) {
|
||||
const arg = c.arguments[0];
|
||||
const ns =
|
||||
arg?.type === "StringLiteral"
|
||||
? arg.value
|
||||
: arg?.type === "ObjectExpression"
|
||||
? arg.properties.find((p) => p.key?.name === "namespace")?.value
|
||||
?.value
|
||||
: arg
|
||||
? null
|
||||
: "";
|
||||
if (ns !== null) {
|
||||
bindings[n.id.name] ??= new Set();
|
||||
bindings[n.id.name].add(ns);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
walk(ast, (n) => {
|
||||
if (n.type === "CallExpression") {
|
||||
const name = n.callee.name ?? n.callee.object?.name;
|
||||
const ns = bindings[name];
|
||||
const k = n.arguments[0];
|
||||
if (ns?.size === 1 && k?.type === "StringLiteral") {
|
||||
const full = [...ns][0] ? `${[...ns][0]}.${k.value}` : k.value;
|
||||
if (
|
||||
!(full in base) &&
|
||||
!Object.keys(base).some((b) => b.startsWith(`${full}.`))
|
||||
)
|
||||
missingCalls.push({ file: f, line: n.loc.start.line, key: full });
|
||||
}
|
||||
}
|
||||
if (n.type === "JSXText" && /[A-Za-z]{3}/.test(n.value.trim()))
|
||||
hardcoded.push({
|
||||
file: f,
|
||||
line: n.loc.start.line,
|
||||
text: n.value.trim().replace(/\s+/g, " "),
|
||||
});
|
||||
});
|
||||
}
|
||||
const result = {
|
||||
locales,
|
||||
invalid,
|
||||
mismatches,
|
||||
missingCalls,
|
||||
hardcoded,
|
||||
unparsedFiles,
|
||||
};
|
||||
if (process.argv.includes("--json"))
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
else {
|
||||
console.table(locales);
|
||||
console.log(
|
||||
"Invalid ICU:",
|
||||
invalid.length,
|
||||
"Variable mismatches:",
|
||||
mismatches.length,
|
||||
"Missing static message references:",
|
||||
missingCalls.length,
|
||||
);
|
||||
console.log(
|
||||
"Literal JSX text candidates requiring editorial review:",
|
||||
hardcoded.length,
|
||||
);
|
||||
console.log(
|
||||
"Coverage checks static translator namespaces and literal keys; dynamic keys and semantic translation quality require review.",
|
||||
);
|
||||
if (
|
||||
invalid.length ||
|
||||
mismatches.length ||
|
||||
missingCalls.length ||
|
||||
unparsedFiles.length
|
||||
)
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{ invalid, mismatches, missingCalls, unparsedFiles },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (
|
||||
process.argv.includes("--check") &&
|
||||
(invalid.length ||
|
||||
mismatches.length ||
|
||||
missingCalls.length ||
|
||||
unparsedFiles.length ||
|
||||
locales.some((locale) => locale.empty > 0))
|
||||
)
|
||||
process.exitCode = 1;
|
||||
@@ -0,0 +1,254 @@
|
||||
// Bulk-load >50 MB JSON into MariaDB in resumable chunks (2000 rows/batch).
|
||||
//
|
||||
// Drizzle multi-row INSERTs are wrapped in ONE statement per chunk, so a
|
||||
// 50 MB dump becomes a few dozen statements instead of hundreds of thousands
|
||||
// of round-trips — no connect/packet timeouts, no "Pulling schema from
|
||||
// database..." freeze (that hang is introspection racing a saturated server).
|
||||
//
|
||||
// Usage (via pnpm, as required):
|
||||
// pnpm exec tsx scripts/bulk-import-json.ts \
|
||||
// --file=/var/www/Gamedata/.../FurnitureData.json [--chunk-size=2000]
|
||||
//
|
||||
// Input shapes supported automatically:
|
||||
// A) habbo furnidata_json → { roomitemtypes:{furnitype:[…]}, wallitemtypes:{…} }
|
||||
// B) flat array of objects → [ {…}, {…} ]
|
||||
//
|
||||
// Flags:
|
||||
// --file=<path> JSON file to load (required)
|
||||
// --table=<name> one of: furnidata | docs | texts (default: furnidata)
|
||||
// --category=<x> category value for docs/texts tables
|
||||
// --source=<x> source value for furnidata table (default: habbo)
|
||||
// --chunk-size=N rows per multi-row INSERT (default: 2000)
|
||||
// --limit=N stop after N rows (dry-test without loading everything)
|
||||
// --truncate DELETE all rows for this table's source/category first
|
||||
//
|
||||
// Idempotent by default: rows re-import on their unique key with
|
||||
// ON DUPLICATE KEY UPDATE, so interrupted runs resume safely.
|
||||
|
||||
import "./load-env";
|
||||
import { createHash } from "node:crypto";
|
||||
import { resolve } from "node:path";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { drizzle } from "drizzle-orm/mysql2";
|
||||
import mysql from "mysql2/promise";
|
||||
import {
|
||||
GamedataDocs,
|
||||
GamedataFurnidata,
|
||||
GamedataTexts,
|
||||
} from "@/db/schema-gamedata";
|
||||
import { mysqlConnectionUrl } from "./db-url";
|
||||
|
||||
interface Args {
|
||||
file: string;
|
||||
table: "furnidata" | "docs" | "texts";
|
||||
category: string;
|
||||
source: string;
|
||||
chunkSize: number;
|
||||
limit: number;
|
||||
truncate: boolean;
|
||||
}
|
||||
|
||||
const FURNIDATA_SECTIONS = {
|
||||
roomitemtypes: "s",
|
||||
flooritemtypes: "s",
|
||||
wallitemtypes: "i",
|
||||
effecttypes: "e",
|
||||
} as const;
|
||||
|
||||
function parseArgs(raw: string[]): Args {
|
||||
const get = (name: string) => {
|
||||
const hit = raw.find((a) => a.startsWith(`--${name}=`));
|
||||
return hit?.slice(name.length + 3);
|
||||
};
|
||||
const has = (name: string) => raw.includes(`--${name}`);
|
||||
const file = get("file");
|
||||
if (!file) {
|
||||
console.error(
|
||||
"Usage: pnpm exec tsx scripts/bulk-import-json.ts --file=<path> [--table=furnidata|docs|texts] [--category=<x>] [--source=<x>] [--chunk-size=2000] [--limit=N] [--truncate]",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const table = (get("table") ?? "furnidata") as Args["table"];
|
||||
return {
|
||||
file: resolve(file),
|
||||
table,
|
||||
category: get("category") ?? "default",
|
||||
source: get("source") ?? "habbo",
|
||||
chunkSize: Number(get("chunk-size") ?? "2000"),
|
||||
limit: Number(get("limit") ?? "0"),
|
||||
truncate: has("truncate"),
|
||||
};
|
||||
}
|
||||
|
||||
// A >50 MB file parses fine with a single JSON.parse (V8 string limit is far
|
||||
// higher); streaming row-by-row would slow the seed down for no memory win.
|
||||
async function readJsonFile(file: string): Promise<unknown> {
|
||||
const { readFile } = await import("node:fs/promises");
|
||||
return JSON.parse(await readFile(file, "utf8"));
|
||||
}
|
||||
|
||||
interface NormalizedRow {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// Normalize any supported shape into a flat list of JSON documents.
|
||||
function normalize(json: unknown, table: Args["table"]): NormalizedRow[] {
|
||||
if (table !== "furnidata") {
|
||||
if (Array.isArray(json)) return json as NormalizedRow[];
|
||||
throw new Error(`Expected a top-level array for --table=${table}`);
|
||||
}
|
||||
if (Array.isArray(json)) return json as NormalizedRow[];
|
||||
|
||||
// habbo furnidata_json: { roomitemtypes: { furnitype: [...] }, ... }
|
||||
if (json && typeof json === "object") {
|
||||
const rows: NormalizedRow[] = [];
|
||||
for (const [section, kind] of Object.entries(FURNIDATA_SECTIONS)) {
|
||||
const block = (json as Record<string, unknown>)[section];
|
||||
if (!block || typeof block !== "object") continue;
|
||||
const list = (block as Record<string, unknown>).furnitype;
|
||||
if (!Array.isArray(list)) continue;
|
||||
for (const item of list) rows.push({ ...item, kind });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
throw new Error("Unsupported JSON shape: expected array or furnidata_json");
|
||||
}
|
||||
|
||||
function toFurnidataRow(
|
||||
row: NormalizedRow,
|
||||
source: string,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
source,
|
||||
kind: (row.kind as "s" | "i" | "e") ?? "s",
|
||||
className: String(row.classname ?? row.className ?? ""),
|
||||
publicName: String(row.public_name ?? row.publicName ?? ""),
|
||||
spriteId: Number(row.id ?? 0),
|
||||
payload: JSON.stringify(row),
|
||||
};
|
||||
}
|
||||
|
||||
function toDocsRow(
|
||||
row: NormalizedRow,
|
||||
category: string,
|
||||
): Record<string, unknown> {
|
||||
const payload = JSON.stringify(row);
|
||||
return {
|
||||
category,
|
||||
docKey: String(row.key ?? row.docKey ?? row.id ?? ""),
|
||||
payload,
|
||||
payloadSha1: createHash("sha1").update(payload).digest("hex"),
|
||||
};
|
||||
}
|
||||
|
||||
function toTextsRow(
|
||||
row: NormalizedRow,
|
||||
category: string,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
category,
|
||||
textKey: String(row.key ?? row.id ?? ""),
|
||||
value: String(row.value ?? row.text ?? row),
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url)
|
||||
throw new Error("DATABASE_URL is required (load-env.ts loaded .env)");
|
||||
|
||||
const conn = await mysql.createConnection(mysqlConnectionUrl(url));
|
||||
const db = drizzle(conn);
|
||||
|
||||
// Session bulk-load flags: worst case per chunk is a lost commit, not a
|
||||
// corrupt table. FOREIGN_KEY_CHECKS off keeps each 50 MB chunk off FK
|
||||
// validation work; UNIQUE_CHECK is a per-statement no-op vs ON DUPLICATE.
|
||||
await conn.query("SET SESSION FOREIGN_KEY_CHECKS=0");
|
||||
await conn.query("SET SESSION sql_mode='NO_ENGINE_SUBSTITUTION'");
|
||||
|
||||
const tableRef =
|
||||
args.table === "furnidata"
|
||||
? GamedataFurnidata
|
||||
: args.table === "docs"
|
||||
? GamedataDocs
|
||||
: GamedataTexts;
|
||||
const keyWhere =
|
||||
args.table === "furnidata"
|
||||
? sql`${GamedataFurnidata.source} = ${args.source}`
|
||||
: args.table === "docs"
|
||||
? sql`${GamedataDocs.category} = ${args.category}`
|
||||
: sql`${GamedataTexts.category} = ${args.category}`;
|
||||
|
||||
if (args.truncate) {
|
||||
await db.execute(sql`DELETE FROM ${tableRef} WHERE ${keyWhere}`);
|
||||
console.log(`[bulk] truncated rows for ${args.table}`);
|
||||
}
|
||||
|
||||
console.log(`[bulk] reading ${args.file} …`);
|
||||
const json = await readJsonFile(args.file);
|
||||
const rows = normalize(json, args.table);
|
||||
console.log(`[bulk] parsed ${rows.length} documents (${args.table})`);
|
||||
|
||||
const mapper: (r: NormalizedRow) => Record<string, unknown> =
|
||||
args.table === "furnidata"
|
||||
? (r) => toFurnidataRow(r, args.source)
|
||||
: args.table === "docs"
|
||||
? (r) => toDocsRow(r, args.category)
|
||||
: (r) => toTextsRow(r, args.category);
|
||||
|
||||
const values = rows
|
||||
.slice(0, args.limit || rows.length)
|
||||
.map<Record<string, unknown>>(mapper);
|
||||
const total = values.length;
|
||||
const chunkSize = args.chunkSize;
|
||||
const started = Date.now();
|
||||
let inserted = 0;
|
||||
|
||||
for (let i = 0; i < total; i += chunkSize) {
|
||||
const chunk = values.slice(i, Math.min(i + chunkSize, total));
|
||||
// `tableRef` is a 3-way union; drizzle's insert is typed per single
|
||||
// table, so narrow through a custom shape here (runtime is unaffected).
|
||||
const insert = db.insert(tableRef) as unknown as {
|
||||
values: (rows: typeof chunk) => {
|
||||
onDuplicateKeyUpdate: (opts: {
|
||||
set: Record<string, unknown>;
|
||||
}) => Promise<unknown>;
|
||||
};
|
||||
};
|
||||
await insert.values(chunk).onDuplicateKeyUpdate({
|
||||
// MariaDB `VALUES(col)` = the incoming row value; re-runs overwrite.
|
||||
set: Object.fromEntries(
|
||||
Object.keys(chunk[0] ?? {}).map((k) => [
|
||||
k,
|
||||
sql.raw(`values(\`${k}\`)`),
|
||||
]),
|
||||
),
|
||||
});
|
||||
|
||||
inserted += chunk.length;
|
||||
const remaining = total - i - chunk.length;
|
||||
if (inserted % (chunkSize * 10) < chunkSize || remaining <= 0) {
|
||||
const elapsedSec = (Date.now() - started) / 1000;
|
||||
const rate = Math.round(inserted / Math.max(elapsedSec, 0.001));
|
||||
const etaMin = Math.round(remaining / Math.max(rate, 1) / 60);
|
||||
console.log(
|
||||
`[bulk] ${inserted}/${total} (${Math.round((inserted / total) * 100)}%) ${rate} rows/s, ETA ~${etaMin}m`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const sec = ((Date.now() - started) / 1000).toFixed(1);
|
||||
console.log(`[bulk] DONE: ${inserted}/${total} rows in ${sec}s`);
|
||||
if (total === args.limit && args.limit > 0) {
|
||||
console.log(
|
||||
" NOTE: --limit was set; run again without it for the full dump.",
|
||||
);
|
||||
}
|
||||
await conn.end();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("[bulk] FATAL:", err instanceof Error ? err.message : err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Checks admin source files for hardcoded color utilities that should use
|
||||
* theme CSS variables instead. Exits 1 on violations.
|
||||
*
|
||||
* Usage: node scripts/check-admin-colors.mjs [file ...]
|
||||
* When called with file arguments (by lint-staged), only those files are
|
||||
* checked. Without arguments, scans all ROOTS.
|
||||
*/
|
||||
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { join, relative, resolve } from "node:path";
|
||||
|
||||
const ROOTS = [
|
||||
"src/app",
|
||||
"src/components",
|
||||
"src/lib",
|
||||
"src/features",
|
||||
"src/hooks",
|
||||
];
|
||||
|
||||
const ALLOWLIST = new Set([
|
||||
"src/app/admin/favicon/favicon-generator.tsx",
|
||||
"src/app/admin/import/clone/import-clone-client.tsx",
|
||||
"src/components/admin/catalog/items-shop-preview.tsx",
|
||||
"src/components/admin/media-grid.tsx",
|
||||
// shadcn/ui primitives — hardcoded colors are intentional design tokens
|
||||
"src/components/ui/button.tsx",
|
||||
"src/components/ui/badge.tsx",
|
||||
"src/components/ui/dialog.tsx",
|
||||
]);
|
||||
|
||||
// Patterns that indicate hardcoded theme colors
|
||||
const RULES = [
|
||||
{
|
||||
name: "no-text-white",
|
||||
pattern: /text-white(?:\/\d+)?/g,
|
||||
message:
|
||||
"Use theme text colors (e.g. text-foreground, text-card-foreground)",
|
||||
},
|
||||
{
|
||||
name: "no-bg-white",
|
||||
pattern: /bg-white(?:\/\d+)?/g,
|
||||
message:
|
||||
"Use theme background colors (e.g. bg-background, bg-card, bg-surface)",
|
||||
},
|
||||
{
|
||||
name: "no-text-black",
|
||||
pattern: /text-black(?:\/\d+)?/g,
|
||||
message:
|
||||
"Use theme text colors (e.g. text-foreground, text-card-foreground)",
|
||||
},
|
||||
{
|
||||
name: "no-bg-black-hardcoded",
|
||||
pattern: /bg-black(?:\/\d+)?/g,
|
||||
message:
|
||||
"Use theme overlay vars (e.g. bg-foreground/50) or documented overrides",
|
||||
},
|
||||
{
|
||||
name: "no-gray-palette",
|
||||
pattern:
|
||||
/(?:text|bg|border|ring)-(?:gray|slate|zinc|neutral|stone)-(?:[1-9]00|50)(?:\/\d+)?/g,
|
||||
message: "Use theme CSS variables instead of Tailwind gray palette",
|
||||
},
|
||||
{
|
||||
name: "no-colored-palette",
|
||||
pattern:
|
||||
/(?:text|bg|border|ring)-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:[1-9]00|50)(?:\/\d+)?/g,
|
||||
message:
|
||||
"Use theme CSS variables (e.g. text-destructive, bg-accent) instead",
|
||||
},
|
||||
];
|
||||
|
||||
function sourceFiles(directory) {
|
||||
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const path = join(directory, entry.name);
|
||||
return entry.isDirectory()
|
||||
? sourceFiles(path)
|
||||
: /\.(?:ts|tsx)$/.test(entry.name) &&
|
||||
!entry.name.endsWith(".test.ts") &&
|
||||
!entry.name.endsWith(".test.tsx")
|
||||
? [path]
|
||||
: [];
|
||||
});
|
||||
}
|
||||
|
||||
const cwd = process.cwd();
|
||||
const files =
|
||||
process.argv.length > 2
|
||||
? process.argv.slice(2).map((f) => resolve(f))
|
||||
: ROOTS.flatMap((r) => sourceFiles(r));
|
||||
|
||||
const violations = [];
|
||||
|
||||
for (const file of files) {
|
||||
const normalized = relative(cwd, file).replaceAll("\\", "/");
|
||||
if (ALLOWLIST.has(normalized)) continue;
|
||||
if (normalized.endsWith(".test.ts") || normalized.endsWith(".test.tsx"))
|
||||
continue;
|
||||
if (!/\.(?:ts|tsx)$/.test(normalized)) continue;
|
||||
|
||||
const source = readFileSync(file, "utf8");
|
||||
const lines = source.split("\n");
|
||||
|
||||
for (const rule of RULES) {
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
// skip comments
|
||||
const trimmed = line.trimStart();
|
||||
if (
|
||||
trimmed.startsWith("//") ||
|
||||
trimmed.startsWith("*") ||
|
||||
trimmed.startsWith("/*")
|
||||
)
|
||||
continue;
|
||||
|
||||
for (const match of line.matchAll(rule.pattern)) {
|
||||
violations.push({
|
||||
file: normalized,
|
||||
line: i + 1,
|
||||
match: match[0],
|
||||
rule: rule.name,
|
||||
message: rule.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (violations.length > 0) {
|
||||
console.error("\n🚫 Hardcoded color violations found in admin files:\n");
|
||||
for (const v of violations) {
|
||||
console.error(` ${v.file}:${v.line} ${v.match}`);
|
||||
console.error(` → ${v.message}`);
|
||||
}
|
||||
console.error(
|
||||
`\n${violations.length} violation(s) found. Use theme CSS variables instead.\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log("✅ No hardcoded color violations found.");
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env bash
|
||||
# One lock covers build, migrations, cutover, health checks and smoke tests.
|
||||
set -Eeuo pipefail
|
||||
|
||||
deploy_dir="${CMS_DEPLOY_DIR:-/var/www/atom-nexst}"
|
||||
branch="${DEPLOY_BRANCH:-main}"
|
||||
case "$branch" in main|master) ;; *) echo "Unsupported deployment branch" >&2; exit 1 ;; esac
|
||||
exec 9>"$deploy_dir/.deploy.lock"
|
||||
flock -w 1800 9
|
||||
|
||||
sha="$(git rev-parse HEAD)"
|
||||
[[ "$sha" =~ ^[0-9a-f]{40}$ ]] || { echo "Invalid commit" >&2; exit 1; }
|
||||
image="epicnext-cms:$sha"
|
||||
previous_name=""
|
||||
previous_image=""
|
||||
backup_name="epicnext-cms-rollback"
|
||||
cutover_started=0
|
||||
candidate_attempted=0
|
||||
backup_created=0
|
||||
|
||||
is_current() {
|
||||
local head
|
||||
head="$(git ls-remote --exit-code origin "refs/heads/$branch")" || return 2
|
||||
head="${head%%[[:space:]]*}"
|
||||
if [ "$head" != "$sha" ]; then
|
||||
echo "Skipping superseded commit $sha (branch now at $head)"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
check_current() {
|
||||
local status=0
|
||||
is_current || status=$?
|
||||
case "$status" in 0) ;; 1) exit 0 ;; *) echo "Cannot verify remote branch" >&2; exit 1 ;; esac
|
||||
}
|
||||
|
||||
healthy() {
|
||||
local attempt
|
||||
for attempt in $(seq 1 30); do
|
||||
if curl -sf --max-time 5 http://127.0.0.1:3002/api/health | grep -q '"database":true'; then return 0; fi
|
||||
sleep 3
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
finish() {
|
||||
local status=$?
|
||||
trap - EXIT
|
||||
if [ "$status" -ne 0 ] && [ "$cutover_started" -eq 1 ]; then
|
||||
echo "Deployment failed; restoring previous container" >&2
|
||||
docker logs epicnext-cms-app --tail 50 >&2 || true
|
||||
if [ "$candidate_attempted" -eq 1 ]; then docker rm -f epicnext-cms-app || true; fi
|
||||
if [ "$backup_created" -eq 1 ]; then docker rename "$backup_name" "$previous_name" || true; fi
|
||||
if [ -n "$previous_name" ]; then
|
||||
if docker start "$previous_name" && healthy; then
|
||||
echo "Rollback verified: $previous_image"
|
||||
else
|
||||
echo "ERROR: previous container could not be restored to healthy state" >&2
|
||||
fi
|
||||
else
|
||||
echo "No previous container exists; rollback is unavailable" >&2
|
||||
fi
|
||||
fi
|
||||
exit "$status"
|
||||
}
|
||||
trap finish EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
check_current
|
||||
cp "$deploy_dir/.env" .env
|
||||
pnpm install --frozen-lockfile
|
||||
# Prepare browser before cutover so installation failures cannot interrupt the site.
|
||||
pnpm exec playwright install chromium
|
||||
|
||||
echo "Building $image"
|
||||
# Throw away the previous build cache before each build so disk usage doesn't
|
||||
# grow unbounded across deployments. The current release image (`epicnext-cms`)
|
||||
# is still reused as a base layer via `--cache-from`; only the accumulated
|
||||
# BuildKit intermediate cache is discarded.
|
||||
docker builder prune -af --filter "until=1h" --keep-storage=0 2>/dev/null || true
|
||||
DOCKER_BUILDKIT=1 docker build --network=host --progress=plain --cache-from epicnext-cms:latest \
|
||||
--build-arg NEXT_DEPLOYMENT_ID="$sha" -t "$image" .
|
||||
check_current
|
||||
pnpm db:migrate
|
||||
check_current
|
||||
|
||||
# Prefer the active CI container, or the active legacy compose container.
|
||||
for name in epicnext-cms-app epicnext-cms; do
|
||||
if [ "$(docker inspect --format '{{.State.Running}}' "$name" 2>/dev/null || true)" = true ]; then
|
||||
previous_name="$name"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ -z "$previous_name" ]; then
|
||||
for name in epicnext-cms-app epicnext-cms; do
|
||||
if docker inspect "$name" >/dev/null 2>&1; then previous_name="$name"; break; fi
|
||||
done
|
||||
fi
|
||||
if docker inspect "$backup_name" >/dev/null 2>&1; then
|
||||
echo "Unresolved rollback container exists; refusing to overwrite it" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$previous_name" ]; then
|
||||
previous_image="$(docker inspect --format '{{.Image}}' "$previous_name")"
|
||||
docker tag "$previous_image" epicnext-cms:previous
|
||||
fi
|
||||
# Remove a stopped leftover CI container when the compose container is active.
|
||||
if [ "$previous_name" != epicnext-cms-app ] && docker inspect epicnext-cms-app >/dev/null 2>&1; then
|
||||
docker rm epicnext-cms-app
|
||||
fi
|
||||
|
||||
cutover_started=1
|
||||
if [ -n "$previous_name" ]; then
|
||||
docker stop "$previous_name"
|
||||
docker rename "$previous_name" "$backup_name"
|
||||
backup_created=1
|
||||
fi
|
||||
candidate_attempted=1
|
||||
(
|
||||
set -a
|
||||
# shellcheck disable=SC1091
|
||||
. "$deploy_dir/.env"
|
||||
set +a
|
||||
ENV_ARGS=()
|
||||
while IFS='=' read -r key _; do
|
||||
case "$key" in ''|'#'*|*[!A-Za-z0-9_]* ) continue ;; esac
|
||||
ENV_ARGS+=(-e "$key")
|
||||
done < "$deploy_dir/.env"
|
||||
docker run -d --name epicnext-cms-app --restart always --net=host \
|
||||
"${ENV_ARGS[@]}" \
|
||||
-v "$deploy_dir/public/nitro-assets:/app/public/nitro-assets" \
|
||||
-v "$deploy_dir/public/swf:/app/public/swf" \
|
||||
-v "$deploy_dir/storage:/app/storage" \
|
||||
-v /var/www/Gamedata:/var/www/Gamedata \
|
||||
"$image"
|
||||
)
|
||||
healthy
|
||||
PLAYWRIGHT_BASE_URL=http://127.0.0.1:3002 pnpm test:e2e
|
||||
# Publish the latest alias only after health and browser checks pass.
|
||||
docker tag "$image" epicnext-cms:latest
|
||||
cutover_started=0
|
||||
if [ "$backup_created" -eq 1 ]; then docker rm "$backup_name" || true; fi
|
||||
|
||||
echo "Deployment verified: $sha"
|
||||
# Retain the current and previous releases; do not remove arbitrary named tags.
|
||||
while IFS= read -r tag; do
|
||||
if [[ "$tag" =~ ^epicnext-cms:[0-9a-f]{40}$ ]] && [ "$tag" != "$image" ]; then
|
||||
tagged_image="$(docker image inspect --format '{{.Id}}' "$tag" 2>/dev/null || true)"
|
||||
if [ -n "$tagged_image" ] && [ "$tagged_image" != "$previous_image" ]; then docker image rm "$tag" || true; fi
|
||||
fi
|
||||
done < <(docker image ls --format '{{.Repository}}:{{.Tag}}' epicnext-cms)
|
||||
docker builder prune -af --filter "until=72h" --keep-storage=2g || true
|
||||
docker image prune -f --filter "until=168h" || true
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Pre-compress large gamedata JSON files to .gz so nginx `gzip_static`
|
||||
* serves them via sendfile instead of re-compressing up to 48 MB on every
|
||||
* request (see /etc/nginx/nginx.conf: `gzip_static on`).
|
||||
*
|
||||
* Idempotent: a file is only re-compressed when its source mtime is newer
|
||||
* than the existing .gz (e.g. after a Studio catalog export rewrites it).
|
||||
*
|
||||
* Usage: node scripts/compress-gamedata.mjs
|
||||
* Env: GAMEDATA_ROOT (default /var/www/Gamedata)
|
||||
* GAMEDATA_GZIP_MIN_BYTES (default 1048576)
|
||||
*/
|
||||
|
||||
import { createReadStream, createWriteStream, promises as fs } from "node:fs";
|
||||
import { cpus } from "node:os";
|
||||
import { join, relative } from "node:path";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { createGzip } from "node:zlib";
|
||||
|
||||
const GAMEDATA_ROOT = process.env.GAMEDATA_ROOT ?? "/var/www/Gamedata";
|
||||
const MIN_BYTES = Number(process.env.GAMEDATA_GZIP_MIN_BYTES ?? 1024 * 1024);
|
||||
const GZIP_LEVEL = 9;
|
||||
const TARGET_DIRS = ["config", "bundled/config"];
|
||||
const CONCURRENCY = Math.max(1, Math.min(4, cpus().length));
|
||||
|
||||
function isCompressible(name) {
|
||||
return (
|
||||
name.endsWith(".json") &&
|
||||
!name.endsWith(".gz") &&
|
||||
!name.endsWith(".min.json")
|
||||
);
|
||||
}
|
||||
|
||||
async function collectCandidates() {
|
||||
const files = [];
|
||||
for (const dir of TARGET_DIRS) {
|
||||
const root = join(GAMEDATA_ROOT, dir);
|
||||
let entries;
|
||||
try {
|
||||
entries = await fs.readdir(root, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !isCompressible(entry.name)) continue;
|
||||
const full = join(root, entry.name);
|
||||
const stat = await fs.stat(full);
|
||||
if (stat.size < MIN_BYTES) continue;
|
||||
files.push({ src: full, size: stat.size, mtimeMs: stat.mtimeMs });
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
async function needsCompression({ src, mtimeMs }) {
|
||||
const gz = `${src}.gz`;
|
||||
try {
|
||||
const stat = await fs.stat(gz);
|
||||
return stat.mtimeMs < mtimeMs;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
async function compressOne({ src }) {
|
||||
const gz = `${src}.gz`;
|
||||
const tmp = `${gz}.tmp-${process.pid}`;
|
||||
await pipeline(
|
||||
createReadStream(src),
|
||||
createGzip({ level: GZIP_LEVEL }),
|
||||
createWriteStream(tmp),
|
||||
);
|
||||
await fs.rename(tmp, gz);
|
||||
try {
|
||||
await fs.chmod(gz, 0o644);
|
||||
} catch {
|
||||
// Best-effort; ownership is up to the caller.
|
||||
}
|
||||
return gz;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const candidates = await collectCandidates();
|
||||
const work = [];
|
||||
const skipped = [];
|
||||
for (const candidate of candidates) {
|
||||
if (await needsCompression(candidate)) {
|
||||
work.push(candidate);
|
||||
} else {
|
||||
skipped.push(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
const results = [];
|
||||
let idx = 0;
|
||||
async function worker() {
|
||||
while (idx < work.length) {
|
||||
const item = work[idx++];
|
||||
try {
|
||||
const gz = await compressOne(item);
|
||||
const stat = await fs.stat(gz);
|
||||
results.push(
|
||||
`compressed ${relative(GAMEDATA_ROOT, gz)} (${item.size} -> ${stat.size} bytes, ${Math.round((1 - stat.size / item.size) * 1000) / 10}% smaller)`,
|
||||
);
|
||||
} catch (err) {
|
||||
results.push(
|
||||
`FAILED ${relative(GAMEDATA_ROOT, item.src)}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const workers = Array.from(
|
||||
{ length: Math.min(CONCURRENCY, work.length) },
|
||||
() => worker(),
|
||||
);
|
||||
await Promise.all(workers);
|
||||
|
||||
console.log(
|
||||
`gamedata: ${work.length} to compress, ${skipped.length} up to date`,
|
||||
);
|
||||
for (const line of results) console.log(`gamedata: ${line}`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(
|
||||
`gamedata: FATAL ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { cp, mkdir } from "node:fs/promises";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const source = path.dirname(require.resolve("tinymce/package.json"));
|
||||
const target = path.resolve("public/vendor/tinymce");
|
||||
await mkdir(target, { recursive: true });
|
||||
for (const name of [
|
||||
"tinymce.min.js",
|
||||
"icons",
|
||||
"models",
|
||||
"plugins",
|
||||
"skins",
|
||||
"themes",
|
||||
"license.md",
|
||||
"notices.txt",
|
||||
]) {
|
||||
await cp(path.join(source, name), path.join(target, name), {
|
||||
recursive: true,
|
||||
});
|
||||
}
|
||||
Executable
+139
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env bash
|
||||
# ==============================================================================
|
||||
# docker-preflight.sh — Verify a VPS is ready to run the Dockerized EpicNext-CMS.
|
||||
#
|
||||
# Lets any supplied .env drive the checks. Checks (all idempotent, none mutating):
|
||||
# 1. Docker + compose available and usable.
|
||||
# 2. Required runtime dirs exist (RW for UID 33 where the CMS writes).
|
||||
# 3. Volume owners are UID/GID 33 (www-data) on the host.
|
||||
# 4. .env exists and required host-network services are reachable.
|
||||
# 5. Port 3002 free (or the host CMS that must be stopped).
|
||||
#
|
||||
# Usage: ./scripts/docker-preflight.sh [--fix]
|
||||
# --fix attempts to auto-correct permission/owner issues (chown).
|
||||
#
|
||||
# Exit codes: 0 ready, 1 not ready (or fixed nothing).
|
||||
# ==============================================================================
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$DIR" || exit 1
|
||||
|
||||
FIX=0
|
||||
[ "${1:-}" = "--fix" ] && FIX=1
|
||||
|
||||
ENV_FILE="${ENV_FILE:-$DIR/.env}"
|
||||
fail=0
|
||||
warn=0
|
||||
|
||||
# Ports that are expected to be reachable ON THE HOST (network_mode: host).
|
||||
# These mirror the defaults in .env / the emulator stack; override via env.
|
||||
CMS_PORT="${CMS_PORT:-3002}"
|
||||
MYSQL_PORT="${MYSQL_PORT:-3306}"
|
||||
REDIS_PORT="${REDIS_PORT:-6379}"
|
||||
RCON_PORT="${RCON_PORT:-3003}"
|
||||
API_PORT="${API_PORT:-3001}"
|
||||
IMAGER_PORT="${IMAGER_PORT:-8082}"
|
||||
|
||||
# Relative dirs the CMS writes to, plus the absolute shared gamedata root.
|
||||
RW_DIRS=(
|
||||
"$DIR/public/nitro-assets"
|
||||
"$DIR/public/swf"
|
||||
"$DIR/storage"
|
||||
"/var/www/Gamedata"
|
||||
)
|
||||
|
||||
say() { printf ' %s\n' "$*"; }
|
||||
ok() { printf ' \033[32m[OK] \033[0m%s\n' "$*"; }
|
||||
err() { printf ' \033[31m[FAIL]\033[0m %s\n' "$*"; fail=1; }
|
||||
wrn() { printf ' \033[33m[WARN]\033[0m %s\n' "$*"; warn=1; }
|
||||
doing(){ printf '\n\033[1m%s\033[0m\n' "$*"; }
|
||||
|
||||
doing "1. Docker engine + compose"
|
||||
if command -v docker >/dev/null 2>&1; then
|
||||
ok "docker binary present"
|
||||
if docker info >/dev/null 2>&1; then ok "daemon reachable"; else err "daemon NOT reachable (is it running / does this user have access?)"; fi
|
||||
else
|
||||
err "docker binary missing"
|
||||
fi
|
||||
if docker compose version >/dev/null 2>&1; then
|
||||
ok "docker compose plugin present"
|
||||
else
|
||||
err "docker compose plugin missing (install docker-compose-plugin)"
|
||||
fi
|
||||
|
||||
doing "2. Runtime directories exist + RW for UID 33"
|
||||
for d in "${RW_DIRS[@]}"; do
|
||||
if [ ! -e "$d" ]; then
|
||||
err "missing dir: $d"
|
||||
if [ "$FIX" -eq 1 ]; then
|
||||
mkdir -p "$d" && chown 33:33 "$d" && say " created + chown 33:33 $d" || err " could not create $d"
|
||||
fi
|
||||
continue
|
||||
fi
|
||||
if [ ! -d "$d" ]; then err "not a directory: $d"; continue; fi
|
||||
# Test write as the target owner via a temp file drop (as root), then remove.
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
if touch "$d/.preflight-write-test" 2>/dev/null; then
|
||||
rm -f "$d/.preflight-write-test"
|
||||
ok "writable: $d"
|
||||
else
|
||||
err "not writable: $d (needs owner 33:33)"
|
||||
[ "$FIX" -eq 1 ] && { chown -R 33:33 "$d" && say " chown -R 33:33 $d" || err " chown failed"; }
|
||||
fi
|
||||
else
|
||||
if [ -w "$d" ]; then ok "writable: $d"; else err "not writable: $d"; fi
|
||||
fi
|
||||
done
|
||||
|
||||
doing "3. Volume ownership (UID/GID 33 = www-data)"
|
||||
for d in "${RW_DIRS[@]}"; do
|
||||
[ -e "$d" ] || continue
|
||||
owner="$(stat -c '%u:%g' "$d" 2>/dev/null || true)"
|
||||
if [ "$owner" = "33:33" ]; then
|
||||
ok "owner 33:33: $d"
|
||||
else
|
||||
err "owner ${owner:-unknown} (want 33:33): $d"
|
||||
[ "$FIX" -eq 1 ] && { chown 33:33 "$d" && say " chown 33:33 $d" || err " chown failed"; }
|
||||
fi
|
||||
done
|
||||
|
||||
doing "4. Configuration + required services (.env, host network)"
|
||||
if [ -f "$ENV_FILE" ]; then
|
||||
ok ".env present: $ENV_FILE"
|
||||
### shellcheck disable=SC1090
|
||||
set -a; . "$ENV_FILE"; set +a
|
||||
else
|
||||
err ".env missing: $ENV_FILE (copy your production env here)"
|
||||
fi
|
||||
|
||||
check_port() { # name port
|
||||
if command -v nc >/dev/null 2>&1; then
|
||||
if nc -z 127.0.0.1 "$2" >/dev/null 2>&1; then ok "reachable 127.0.0.1:$2 ($1)"; else err "NOT reachable 127.0.0.1:$2 ($1)"; fi
|
||||
else
|
||||
if (echo >/dev/tcp/127.0.0.1/"$2") >/dev/null 2>&1; then ok "reachable 127.0.0.1:$2 ($1)"; else err "NOT reachable 127.0.0.1:$2 ($1)"; fi
|
||||
fi
|
||||
}
|
||||
check_port "MariaDB" "$MYSQL_PORT"
|
||||
check_port "Redis" "$REDIS_PORT"
|
||||
check_dep() { # name
|
||||
if command -v "$1" >/dev/null 2>&1; then ok "host binary: $1"; else wrn "host binary not found: $1 (may still work via container)"; fi
|
||||
}
|
||||
check_dep git
|
||||
|
||||
doing "5. Port 3002 state (host CMS clash)"
|
||||
if (echo >/dev/tcp/127.0.0.1/"$CMS_PORT") >/dev/null 2>&1; then
|
||||
err "port $CMS_PORT already in use on host — stop the host-side CMS (pm2 stop next) before starting the container"
|
||||
else
|
||||
ok "port $CMS_PORT free"
|
||||
fi
|
||||
|
||||
printf '\n'
|
||||
if [ "$fail" -eq 1 ]; then
|
||||
printf '\033[31m=== NOT READY: %s issue(s) found%s ===\033[0m\n' "$fail" "$([ "$FIX" -eq 1 ] && echo ' after --fix' || echo ' (re-run with --fix to auto-correct)')"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$warn" -eq 1 ]; then printf '\033[33m=== READY (with %s warning(s)) ===\033[0m\n' "$warn"; exit 0; fi
|
||||
printf '\033[32m=== READY ===\033[0m\n'
|
||||
exit 0
|
||||
Executable
+119
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env bash
|
||||
# ==============================================================================
|
||||
# docker-update.sh — Automatic daily update for the Dockerized EpicNext-CMS.
|
||||
#
|
||||
# Steps:
|
||||
# 1. Verify the working tree is clean (uncommitted changes abort).
|
||||
# 2. git pull (fast-forward only).
|
||||
# 3. Run CMS migrations on the HOST (the slim runtime container has no source).
|
||||
# 4. docker compose build (auto-detects pnpm/yarn/npm via lockfile).
|
||||
# 5. docker compose up -d && wait for a healthy container.
|
||||
# 6. Record everything in update.log.
|
||||
#
|
||||
# Exit codes: 0 ok, 1 update skipped, 2 build/deploy failed, 3 health failed.
|
||||
# ==============================================================================
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$DIR" || exit 2
|
||||
|
||||
# Share the CI lock before pulling or touching the live application.
|
||||
exec 9>"$DIR/.deploy.lock"
|
||||
flock -w 1800 9 || exit 2
|
||||
|
||||
LOG_FILE="${LOG_FILE:-$DIR/logs/docker-update.log}"
|
||||
PM2_APP="${PM2_APP:-next}" # host-side CMS that must stay stopped (port 3002)
|
||||
|
||||
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"; }
|
||||
die() { log "ERROR: $*"; exit "${2:-2}"; }
|
||||
|
||||
touch "$LOG_FILE"
|
||||
|
||||
log "=== Start docker-update ==="
|
||||
|
||||
# --- 0. Preflight: verify this VPS is ready (permissions, ports, deps) ---
|
||||
if ! "$DIR/scripts/docker-preflight.sh"; then
|
||||
die "preflight failed — fix issues first (see '--fix' flag)" 1
|
||||
fi
|
||||
log "preflight OK"
|
||||
|
||||
# --- 0b. Guard: uncommitted changes would break git pull / taint deploys ---
|
||||
if ! { git diff --quiet --exit-code && git diff --cached --quiet --exit-code; }; then
|
||||
die "working tree has uncommitted changes; commit or stash first" 1
|
||||
fi
|
||||
|
||||
# --- 1. Pull latest ---
|
||||
git pull --ff-only --quiet 2>>"$LOG_FILE"
|
||||
pull_status=$?
|
||||
if [ $pull_status -ne 0 ]; then
|
||||
die "git pull failed (status $pull_status)" 1
|
||||
fi
|
||||
log "git pull OK: $(git rev-parse --short HEAD)"
|
||||
|
||||
# --- 2. Host-side migrations (idempotent; only applies CMS-owned tables) ---
|
||||
if [ -f pnpm-lock.yaml ] && command -v pnpm >/dev/null 2>&1; then
|
||||
pnpm db:migrate >>"$LOG_FILE" 2>&1 || die "db:migrate (pnpm) failed"
|
||||
elif command -v npm >/dev/null 2>&1; then
|
||||
npm run db:migrate >>"$LOG_FILE" 2>&1 || die "db:migrate (npm) failed"
|
||||
else
|
||||
die "no package manager found for migrations" 2
|
||||
fi
|
||||
log "db:migrate OK"
|
||||
|
||||
# --- 3. Node major gate (patches auto, major upgrades need review) ---
|
||||
# `node:alpine` floats within, then across, Node majors. Patches/minors are
|
||||
# safe to apply silently; a NEW major (e.g. 26 -> 27) is a breaking risk for
|
||||
# native addons / Next compatibility, so require an explicit review before it
|
||||
# goes live. Compare the major of the deployed runtime image vs the floating
|
||||
# tag; abort (not deploy) when they differ.
|
||||
deployed_major="$(docker inspect --format '{{.Config.Image}}' epicnext-cms 2>/dev/null || true)"
|
||||
# Resolve the currently-deployed Node major from its image.
|
||||
if [ -n "$deployed_major" ] && docker image inspect "$deployed_major" >/dev/null 2>&1; then
|
||||
deployed_major="$(docker run --rm --entrypoint sh "$deployed_major" -c 'node -p "process.versions.node.split(\".\")[0]"' 2>/dev/null || true)"
|
||||
fi
|
||||
float_major="$(docker run --rm --entrypoint sh node:alpine -c 'node -p "process.versions.node.split(\".\")[0]"' 2>/dev/null || true)"
|
||||
if [ -n "$deployed_major" ] && [ -n "$float_major" ] && [ "$deployed_major" != "$float_major" ]; then
|
||||
die "Node major change detected (deployed v$deployed_major, floating tag v$float_major). Major upgrades require review; update engines/Dockerfile deliberately first." 1
|
||||
fi
|
||||
log "Node major gate OK (major=${float_major:-?})"
|
||||
|
||||
# --- 4. Rebuild the image ---
|
||||
# Reset the BuildKit cache first so the build doesn't accumulate unbounded
|
||||
# layers on disk across daily rebuilds.
|
||||
docker builder prune -af --filter "until=1h" --keep-storage=0 2>>"$LOG_FILE" || true
|
||||
docker compose build >>"$LOG_FILE" 2>&1 || die "docker compose build failed" 2
|
||||
log "docker compose build OK"
|
||||
|
||||
# --- 5. Recreate the container ---
|
||||
docker compose up -d >>"$LOG_FILE" 2>&1 || die "docker compose up failed" 2
|
||||
log "docker compose up OK"
|
||||
|
||||
# --- 6. Wait for health (up to ~4 min) ---
|
||||
healthy=0
|
||||
for i in $(seq 1 16); do
|
||||
status="$(docker inspect --format='{{.State.Health.Status}}' epicnext-cms 2>/dev/null || true)"
|
||||
case "$status" in
|
||||
healthy) healthy=1; break ;;
|
||||
unhealthy) break ;;
|
||||
esac
|
||||
sleep 15
|
||||
done
|
||||
|
||||
if [ "$healthy" -eq 1 ]; then
|
||||
log "CMS healthy after update (commit $(git rev-parse --short HEAD))"
|
||||
else
|
||||
log "WARNING: container not healthy (status='${status:-unknown}')"
|
||||
# Leave the container running so it can be debugged; report failure exit.
|
||||
exit 3
|
||||
fi
|
||||
|
||||
# --- 7. Make sure the stale host-side PM2 CMS stays stopped ---
|
||||
if command -v pm2 >/dev/null 2>&1 && pm2 jlist >/dev/null 2>&1; then
|
||||
if pm2 list 2>/dev/null | grep -q "${PM2_APP}"; then
|
||||
pm2 stop "$PM2_APP" >/dev/null 2>&1 && log "pm2 '${PM2_APP}' kept stopped (avoids port 3002 clash)"
|
||||
fi
|
||||
fi
|
||||
|
||||
log "=== docker-update finished OK ==="
|
||||
exit 0
|
||||
+52
-2
@@ -1,11 +1,18 @@
|
||||
import "./load-env";
|
||||
import { Cron } from "croner";
|
||||
import { lt, sql } from "drizzle-orm";
|
||||
import { and, eq, lt, lte, sql } from "drizzle-orm";
|
||||
import { env } from "../src/env";
|
||||
import { db, PasswordReset, WebsiteLoginLogs } from "../src/lib/db";
|
||||
import {
|
||||
db,
|
||||
PasswordReset,
|
||||
WebsiteArticles,
|
||||
WebsiteLoginLogs,
|
||||
} from "../src/lib/db";
|
||||
import { logger } from "../src/lib/logger";
|
||||
import { redis } from "../src/lib/redis";
|
||||
import { emulatorOffline, healthDegraded } from "../src/lib/services/alert";
|
||||
import { runCatalogExport } from "../src/lib/services/catalog-git-export";
|
||||
import { invalidateNewsCache } from "../src/lib/services/news-cache";
|
||||
import { rcon } from "../src/lib/services/rcon";
|
||||
|
||||
function captureWorkerError(err: unknown, context: string): void {
|
||||
@@ -224,7 +231,41 @@ async function cleanupOldSessions(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function publishScheduledArticles(): Promise<void> {
|
||||
try {
|
||||
const now = new Date();
|
||||
const result = await db
|
||||
.update(WebsiteArticles)
|
||||
.set({
|
||||
status: "published",
|
||||
publishedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(WebsiteArticles.status, "scheduled"),
|
||||
lte(WebsiteArticles.publishAt, now),
|
||||
),
|
||||
);
|
||||
const [info] = result;
|
||||
const published = info.affectedRows ?? 0;
|
||||
if (published > 0) {
|
||||
await invalidateNewsCache();
|
||||
logger.info(`Published ${published} scheduled article(s)`, {
|
||||
module: "jobs",
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
captureWorkerError(err, "Scheduled article publish failed");
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
new Cron("* * * * *", () => {
|
||||
runCatalogExport().catch((e) =>
|
||||
captureWorkerError(e, "Catalog export failed"),
|
||||
);
|
||||
});
|
||||
logger.info("Worker started", { module: "jobs" });
|
||||
|
||||
if (env.EMULATOR_JAR_PATH && env.EMULATOR_BACKUP_DIR) {
|
||||
@@ -257,6 +298,15 @@ async function main() {
|
||||
});
|
||||
logger.info("Scheduled: ops health probe (every 5 min)", { module: "jobs" });
|
||||
|
||||
new Cron("* * * * *", () => {
|
||||
publishScheduledArticles().catch((e) =>
|
||||
captureWorkerError(e, "ScheduledArticlePublish"),
|
||||
);
|
||||
});
|
||||
logger.info("Scheduled: publish scheduled articles (every minute)", {
|
||||
module: "jobs",
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
backupEmulatorJar(),
|
||||
cleanupOldLogs(),
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
// Production builds and CI do not need Git hooks or dev-only executables.
|
||||
if (process.env.NODE_ENV !== "production" && !process.env.CI) {
|
||||
const { default: husky } = await import("husky");
|
||||
husky();
|
||||
}
|
||||
@@ -2,23 +2,55 @@
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
||||
import { db, WebsiteStaffApplications } from "@/lib/db";
|
||||
import { formPositiveBigInt } from "@/lib/form-data";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { logServerError } from "@/lib/server-log";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
export async function dismissApplication(formData: FormData): Promise<void> {
|
||||
await requirePermission(PERMS.USERS_EDIT);
|
||||
const id = formPositiveBigInt(formData, "id");
|
||||
if (!id) return;
|
||||
|
||||
async function removeApplication(id: bigint): Promise<boolean> {
|
||||
try {
|
||||
await db
|
||||
.delete(WebsiteStaffApplications)
|
||||
.where(eq(WebsiteStaffApplications.id, id));
|
||||
} catch {
|
||||
// already gone / no DB — nothing to do
|
||||
return true;
|
||||
} catch (error) {
|
||||
logServerError("applications.delete_failed", error, { id: String(id) });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function dismissApplication(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermissionRateLimited(PERMS.USERS_EDIT);
|
||||
const id = formPositiveBigInt(formData, "id");
|
||||
if (!id) return;
|
||||
|
||||
await removeApplication(id);
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "application_dismiss",
|
||||
description: `Dismissed staff application #${id}`,
|
||||
targetType: "staff_application",
|
||||
targetId: Number(id),
|
||||
});
|
||||
|
||||
revalidatePath("/admin/applications");
|
||||
}
|
||||
|
||||
export async function approveApplication(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermissionRateLimited(PERMS.USERS_EDIT);
|
||||
const id = formPositiveBigInt(formData, "id");
|
||||
if (!id) return;
|
||||
|
||||
await removeApplication(id);
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "application_approve",
|
||||
description: `Approved staff application #${id}`,
|
||||
targetType: "staff_application",
|
||||
targetId: Number(id),
|
||||
});
|
||||
|
||||
revalidatePath("/admin/applications");
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
rows: [] as unknown[][],
|
||||
insert: vi.fn(),
|
||||
update: vi.fn(),
|
||||
invalidate: vi.fn(),
|
||||
log: vi.fn(() => "news-error-1"),
|
||||
notify: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/admin/guard", () => ({
|
||||
requirePermission: async () => ({ id: 1, username: "staff" }),
|
||||
}));
|
||||
vi.mock("@/lib/permissions", () => ({ PERMS: { NEWS_EDIT: "news.edit" } }));
|
||||
vi.mock("@/lib/services/webhook", () => ({ notify: state.notify }));
|
||||
vi.mock("@/lib/services/news-cache", () => ({
|
||||
invalidateNewsCache: state.invalidate,
|
||||
}));
|
||||
vi.mock("@/lib/logger", () => ({ logger: { error: state.log } }));
|
||||
vi.mock("next-intl/server", () => ({
|
||||
getTranslations: async () => (key: string, args?: { reference: string }) =>
|
||||
key + (args?.reference ?? ""),
|
||||
}));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: (url: string) => {
|
||||
throw Error(`redirect ${url}`);
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/db", async () => ({
|
||||
...(await import("@/db/schema")),
|
||||
db: {
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
where: () => ({ limit: async () => state.rows.shift() ?? [] }),
|
||||
}),
|
||||
}),
|
||||
insert: () => ({ values: state.insert }),
|
||||
update: () => ({
|
||||
set: (value: unknown) => {
|
||||
state.update(value);
|
||||
return { where: async () => [{ affectedRows: 1 }] };
|
||||
},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
import { createArticle, updateArticle } from "./admin-articles";
|
||||
|
||||
function form(extra: Record<string, string> = {}) {
|
||||
const f = new FormData();
|
||||
for (const [k, v] of Object.entries({
|
||||
title: "Test news",
|
||||
slug: "test-news",
|
||||
shortStory: "Summary",
|
||||
fullStory: "Body",
|
||||
status: "published",
|
||||
...extra,
|
||||
}))
|
||||
f.set(k, v);
|
||||
return f;
|
||||
}
|
||||
describe("news saves", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
state.rows = [];
|
||||
state.insert.mockResolvedValue([{ insertId: 1 }]);
|
||||
});
|
||||
it("preserves a recoverable error and logs its reference", async () => {
|
||||
state.insert.mockRejectedValueOnce(Error("Unknown column status"));
|
||||
const result = await createArticle(form());
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
error: "saveFailedReferencenews-error-1",
|
||||
});
|
||||
expect(state.invalidate).not.toHaveBeenCalled();
|
||||
expect(state.log).toHaveBeenCalled();
|
||||
});
|
||||
it("saves drafts without publication notifications", async () => {
|
||||
expect((await createArticle(form({ status: "draft" }))).ok).toBe(true);
|
||||
expect(state.insert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
status: "draft",
|
||||
publishAt: null,
|
||||
publishedAt: null,
|
||||
}),
|
||||
);
|
||||
expect(state.notify).not.toHaveBeenCalled();
|
||||
expect(state.invalidate).toHaveBeenCalledOnce();
|
||||
});
|
||||
it("preserves the slug and clears old scheduling for immediate publication", async () => {
|
||||
state.rows = [[{ slug: "test-news", status: "draft", publishedAt: null }]];
|
||||
expect(
|
||||
(
|
||||
await updateArticle(
|
||||
form({ id: "1", publishAt: "2099-01-01T00:00:00Z" }),
|
||||
)
|
||||
).ok,
|
||||
).toBe(true);
|
||||
expect(state.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
slug: "test-news",
|
||||
publishAt: null,
|
||||
status: "published",
|
||||
}),
|
||||
);
|
||||
expect(state.invalidate).toHaveBeenCalledOnce();
|
||||
});
|
||||
it("rejects invalid scheduled dates before writing", async () => {
|
||||
expect(
|
||||
(await createArticle(form({ status: "scheduled", publishAt: "invalid" })))
|
||||
.ok,
|
||||
).toBe(false);
|
||||
expect(state.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+128
-61
@@ -1,108 +1,167 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { and, eq, ne } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import {
|
||||
ArticleInputError,
|
||||
type ArticleSaveResult,
|
||||
readArticleInput,
|
||||
} from "@/lib/article-input";
|
||||
import {
|
||||
db,
|
||||
WebsiteArticleComments,
|
||||
WebsiteArticleReactions,
|
||||
WebsiteArticles,
|
||||
} from "@/lib/db";
|
||||
import { formPositiveBigInt } from "@/lib/form-data";
|
||||
import { slugify } from "@/lib/format";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { invalidateNewsCache } from "@/lib/services/news-cache";
|
||||
import { notify } from "@/lib/services/webhook";
|
||||
|
||||
async function uniqueSlug(title: string): Promise<string> {
|
||||
async function uniqueSlug(title: string, excludeId?: bigint): Promise<string> {
|
||||
const base = slugify(title);
|
||||
let slug = base;
|
||||
let n = 2;
|
||||
for (;;) {
|
||||
for (let n = 2; ; n++) {
|
||||
const [existing] = await db
|
||||
.select({ id: WebsiteArticles.id })
|
||||
.from(WebsiteArticles)
|
||||
.where(eq(WebsiteArticles.slug, slug))
|
||||
.where(
|
||||
and(
|
||||
eq(WebsiteArticles.slug, slug),
|
||||
excludeId ? ne(WebsiteArticles.id, excludeId) : undefined,
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!existing) return slug;
|
||||
slug = `${base}-${n++}`;
|
||||
const suffix = `-${n}`;
|
||||
slug = base.slice(0, 255 - suffix.length) + suffix;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createArticle(formData: FormData): Promise<void> {
|
||||
async function saveFailure(
|
||||
error: unknown,
|
||||
operation: string,
|
||||
): Promise<ArticleSaveResult> {
|
||||
const t = await getTranslations("pages.admin.articles.form");
|
||||
if (error instanceof ArticleInputError)
|
||||
return { ok: false, error: t(error.code) };
|
||||
const reference = logger.error("News save failed", {
|
||||
module: "news",
|
||||
operation,
|
||||
error,
|
||||
});
|
||||
return { ok: false, error: t("saveFailedReference", { reference }) };
|
||||
}
|
||||
async function refreshNews() {
|
||||
await invalidateNewsCache();
|
||||
revalidatePath("/admin/articles");
|
||||
revalidatePath("/news", "layout");
|
||||
revalidatePath("/me");
|
||||
revalidatePath("/");
|
||||
}
|
||||
export async function createArticle(
|
||||
formData: FormData,
|
||||
): Promise<ArticleSaveResult> {
|
||||
const staff = await requirePermission(PERMS.NEWS_EDIT);
|
||||
const title = String(formData.get("title") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const shortStory = String(formData.get("shortStory") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const fullStory = String(formData.get("fullStory") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const image = String(formData.get("image") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const rawSlug = String(formData.get("slug") ?? "").trim();
|
||||
if (!title) return;
|
||||
|
||||
let input: ReturnType<typeof readArticleInput>;
|
||||
let slug: string;
|
||||
try {
|
||||
input = readArticleInput(formData);
|
||||
const { rawSlug, ...fields } = input;
|
||||
slug = await uniqueSlug(rawSlug || fields.title);
|
||||
const now = new Date();
|
||||
await db.insert(WebsiteArticles).values({
|
||||
slug: rawSlug ? await uniqueSlug(rawSlug) : await uniqueSlug(title),
|
||||
title: title.slice(0, 255),
|
||||
shortStory: shortStory.slice(0, 255),
|
||||
fullStory,
|
||||
image: image.slice(0, 255),
|
||||
...fields,
|
||||
slug,
|
||||
userId: staff.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
publishedAt: fields.status === "published" ? now : null,
|
||||
});
|
||||
} catch {
|
||||
// Database error — re-render unchanged with error.
|
||||
redirect(
|
||||
"/admin/articles/new?error=Database error while creating article. Please try again.",
|
||||
);
|
||||
} catch (error) {
|
||||
return saveFailure(error, "create");
|
||||
}
|
||||
redirect("/admin/articles");
|
||||
// Auxiliary services must not turn a committed insert into an apparent failure.
|
||||
if (input.status === "published")
|
||||
notify({
|
||||
action: "news_publish",
|
||||
actor: staff.username,
|
||||
target: input.title,
|
||||
details: slug,
|
||||
});
|
||||
await refreshNews();
|
||||
return { ok: true, data: { redirectTo: "/admin/articles" } };
|
||||
}
|
||||
|
||||
export async function updateArticle(formData: FormData): Promise<void> {
|
||||
await requirePermission(PERMS.NEWS_EDIT);
|
||||
const id = BigInt(String(formData.get("id")));
|
||||
const rawSlug = String(formData.get("slug") ?? "").trim();
|
||||
export async function updateArticle(
|
||||
formData: FormData,
|
||||
): Promise<ArticleSaveResult> {
|
||||
const staff = await requirePermission(PERMS.NEWS_EDIT);
|
||||
const t = await getTranslations("pages.admin.articles.form");
|
||||
const id = formPositiveBigInt(formData, "id");
|
||||
if (!id) return { ok: false, error: t("articleNotFound") };
|
||||
let input: ReturnType<typeof readArticleInput>;
|
||||
let becamePublished = false;
|
||||
let slug: string;
|
||||
try {
|
||||
input = readArticleInput(formData);
|
||||
const [existing] = await db
|
||||
.select({
|
||||
slug: WebsiteArticles.slug,
|
||||
status: WebsiteArticles.status,
|
||||
publishedAt: WebsiteArticles.publishedAt,
|
||||
})
|
||||
.from(WebsiteArticles)
|
||||
.where(eq(WebsiteArticles.id, id))
|
||||
.limit(1);
|
||||
if (!existing) return { ok: false, error: t("articleNotFound") };
|
||||
const { rawSlug, ...fields } = input;
|
||||
const requestedSlug = rawSlug ? slugify(rawSlug) : existing.slug;
|
||||
slug =
|
||||
requestedSlug === existing.slug
|
||||
? existing.slug
|
||||
: await uniqueSlug(requestedSlug, id);
|
||||
becamePublished =
|
||||
fields.status === "published" && existing.status !== "published";
|
||||
await db
|
||||
.update(WebsiteArticles)
|
||||
.set({
|
||||
title: String(formData.get("title") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
...(rawSlug ? { slug: await uniqueSlug(rawSlug) } : {}),
|
||||
shortStory: String(formData.get("shortStory") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
fullStory: String(formData.get("fullStory") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim(),
|
||||
image: String(formData.get("image") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
...fields,
|
||||
slug,
|
||||
publishedAt:
|
||||
fields.status === "published"
|
||||
? (existing.publishedAt ?? new Date())
|
||||
: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(WebsiteArticles.id, id));
|
||||
} catch {
|
||||
redirect("/admin/articles?error=Update failed");
|
||||
} catch (error) {
|
||||
return saveFailure(error, "update");
|
||||
}
|
||||
if (becamePublished)
|
||||
notify({
|
||||
action: "news_publish",
|
||||
actor: staff.username,
|
||||
target: input.title,
|
||||
details: slug,
|
||||
});
|
||||
await refreshNews();
|
||||
revalidatePath(`/admin/articles/${id}`);
|
||||
redirect("/admin/articles");
|
||||
return { ok: true, data: { redirectTo: "/admin/articles" } };
|
||||
}
|
||||
|
||||
export async function deleteArticle(formData: FormData): Promise<void> {
|
||||
await requirePermission(PERMS.NEWS_EDIT);
|
||||
const id = BigInt(String(formData.get("id")));
|
||||
const staff = await requirePermission(PERMS.NEWS_EDIT);
|
||||
const id = formPositiveBigInt(formData, "id");
|
||||
if (!id) redirect("/admin/articles?error=Missing article id");
|
||||
const [article] = await db
|
||||
.select({ title: WebsiteArticles.title })
|
||||
.from(WebsiteArticles)
|
||||
.where(eq(WebsiteArticles.id, id))
|
||||
.limit(1);
|
||||
try {
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
@@ -113,8 +172,16 @@ export async function deleteArticle(formData: FormData): Promise<void> {
|
||||
.where(eq(WebsiteArticleComments.articleId, id));
|
||||
await tx.delete(WebsiteArticles).where(eq(WebsiteArticles.id, id));
|
||||
});
|
||||
} catch {
|
||||
|
||||
notify({
|
||||
action: "news_delete",
|
||||
actor: staff.username,
|
||||
target: article?.title ?? String(id),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("News deletion failed", { module: "news", error });
|
||||
redirect("/admin/articles?error=Delete failed");
|
||||
}
|
||||
await refreshNews();
|
||||
redirect("/admin/articles");
|
||||
}
|
||||
@@ -16,6 +16,33 @@ import {
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
const GUILD_STATES = [0, 1, 2] as const;
|
||||
const GUILD_FORUM = ["0", "1"] as const;
|
||||
const GUILD_FORUM_ACCESS = [
|
||||
"EVERYONE",
|
||||
"OWNER",
|
||||
"ADMIN",
|
||||
"MEMBER",
|
||||
"NONE",
|
||||
] as const;
|
||||
const GUILD_MOD_ACCESS = ["ADMINS", "OWNER", "MEMBER", "NONE"] as const;
|
||||
|
||||
function parseGuildState(raw: unknown): number | null {
|
||||
const value = Number(raw);
|
||||
return (GUILD_STATES as readonly number[]).includes(value) ? value : null;
|
||||
}
|
||||
|
||||
function parseEnum<T extends string>(
|
||||
raw: unknown,
|
||||
allowed: readonly T[],
|
||||
fallback: T,
|
||||
): T {
|
||||
const value = String(raw ?? fallback).trim();
|
||||
return (allowed as readonly string[]).includes(value)
|
||||
? (value as T)
|
||||
: fallback;
|
||||
}
|
||||
|
||||
/** Disband a guild and clean related membership/forum rows. */
|
||||
export async function disbandGuild(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermissionRateLimited(PERMS.USERS_EDIT);
|
||||
@@ -63,3 +90,65 @@ export async function disbandGuild(formData: FormData): Promise<void> {
|
||||
});
|
||||
revalidatePath("/admin/guilds");
|
||||
}
|
||||
|
||||
export async function updateGuild(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermissionRateLimited(PERMS.USERS_EDIT);
|
||||
const id = Number(formData.get("id"));
|
||||
if (!(id > 0)) return;
|
||||
|
||||
const name = String(formData.get("name") ?? "")
|
||||
.trim()
|
||||
.slice(0, 50);
|
||||
if (!name) return;
|
||||
const description = String(formData.get("description") ?? "")
|
||||
.trim()
|
||||
.slice(0, 250);
|
||||
const state = parseGuildState(formData.get("state"));
|
||||
if (state === null) return;
|
||||
const forum = parseEnum(formData.get("forum"), GUILD_FORUM, "0");
|
||||
const readForum = parseEnum(
|
||||
formData.get("readForum"),
|
||||
GUILD_FORUM_ACCESS,
|
||||
"EVERYONE",
|
||||
);
|
||||
const postMessages = parseEnum(
|
||||
formData.get("postMessages"),
|
||||
GUILD_FORUM_ACCESS,
|
||||
"EVERYONE",
|
||||
);
|
||||
const postThreads = parseEnum(
|
||||
formData.get("postThreads"),
|
||||
GUILD_FORUM_ACCESS,
|
||||
"EVERYONE",
|
||||
);
|
||||
const modForum = parseEnum(
|
||||
formData.get("modForum"),
|
||||
GUILD_MOD_ACCESS,
|
||||
"ADMINS",
|
||||
);
|
||||
|
||||
await db
|
||||
.update(Guilds)
|
||||
.set({
|
||||
name,
|
||||
description,
|
||||
state,
|
||||
forum,
|
||||
readForum,
|
||||
postMessages,
|
||||
postThreads,
|
||||
modForum,
|
||||
})
|
||||
.where(eq(Guilds.id, id));
|
||||
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "guild_update",
|
||||
description: `Updated guild #${id}`,
|
||||
targetType: "guild",
|
||||
targetId: id,
|
||||
});
|
||||
|
||||
revalidatePath("/admin/guilds");
|
||||
revalidatePath(`/admin/guilds/${id}`);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
ADMIN_NAV_CONFIG_KEY,
|
||||
type AdminNavConfig,
|
||||
serializeAdminNavConfig,
|
||||
} from "@/lib/admin-nav-config";
|
||||
import { actionOk, adminAction } from "@/lib/foundation/action";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
const schema = z.object({
|
||||
groupOrder: z.array(z.string()),
|
||||
hiddenGroups: z.array(z.string()),
|
||||
hiddenItems: z.array(z.string()),
|
||||
itemOrder: z.record(z.string(), z.array(z.string())),
|
||||
});
|
||||
|
||||
export const saveAdminNavConfig = adminAction(
|
||||
{
|
||||
permission: PERMS.SETTINGS_EDIT,
|
||||
schema,
|
||||
rateLimitKey: "admin-nav-config-save",
|
||||
rateLimitMax: 30,
|
||||
},
|
||||
async (ctx) => {
|
||||
const config: AdminNavConfig = {
|
||||
groupOrder: ctx.data.groupOrder,
|
||||
hiddenGroups: ctx.data.hiddenGroups,
|
||||
hiddenItems: ctx.data.hiddenItems,
|
||||
itemOrder: ctx.data.itemOrder,
|
||||
};
|
||||
await siteSettings.update(
|
||||
ADMIN_NAV_CONFIG_KEY,
|
||||
serializeAdminNavConfig(config),
|
||||
);
|
||||
revalidatePath("/admin", "layout");
|
||||
revalidatePath("/admin/menu");
|
||||
return actionOk({ saved: true });
|
||||
},
|
||||
);
|
||||
@@ -115,3 +115,88 @@ export async function deleteValue(formData: FormData): Promise<void> {
|
||||
}
|
||||
revalidatePath("/admin/rare-values");
|
||||
}
|
||||
|
||||
export async function updateCategory(formData: FormData): Promise<void> {
|
||||
await requirePermission(PERMS.SHOP_EDIT);
|
||||
const id = formPositiveBigInt(formData, "id");
|
||||
if (!id) return;
|
||||
|
||||
const name = String(formData.get("name") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const badge = String(formData.get("badge") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const priorityRaw = Number(formData.get("priority"));
|
||||
const priority =
|
||||
Number.isFinite(priorityRaw) && priorityRaw > 0
|
||||
? Math.floor(priorityRaw)
|
||||
: 1;
|
||||
if (!name || !badge) return;
|
||||
|
||||
try {
|
||||
await db
|
||||
.update(WebsiteRareValueCategories)
|
||||
.set({ name, badge, priority })
|
||||
.where(eq(WebsiteRareValueCategories.id, id));
|
||||
} catch {
|
||||
// Unique name collision or DB error — ignore.
|
||||
}
|
||||
revalidatePath("/admin/rare-values");
|
||||
}
|
||||
|
||||
export async function updateValue(formData: FormData): Promise<void> {
|
||||
await requirePermission(PERMS.SHOP_EDIT);
|
||||
const id = formPositiveBigInt(formData, "id");
|
||||
if (!id) return;
|
||||
|
||||
const categoryId = formPositiveBigInt(formData, "categoryId");
|
||||
|
||||
const name = String(formData.get("name") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const furnitureIcon = String(formData.get("furnitureIcon") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
if (!name || !furnitureIcon) return;
|
||||
|
||||
const itemIdRaw = Number(formData.get("itemId"));
|
||||
const itemId =
|
||||
Number.isFinite(itemIdRaw) && itemIdRaw > 0 ? Math.floor(itemIdRaw) : null;
|
||||
|
||||
const creditValueRaw = String(formData.get("creditValue") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const currencyValueRaw = String(formData.get("currencyValue") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const currencyType =
|
||||
String(formData.get("currencyType") ?? "diamonds")
|
||||
.trim()
|
||||
.slice(0, 255) || "diamonds";
|
||||
|
||||
try {
|
||||
const sets: Record<string, unknown> = {
|
||||
name,
|
||||
itemId,
|
||||
creditValue: creditValueRaw || null,
|
||||
currencyValue: currencyValueRaw || null,
|
||||
currencyType,
|
||||
furnitureIcon,
|
||||
};
|
||||
if (categoryId) sets.categoryId = categoryId;
|
||||
await db
|
||||
.update(WebsiteRareValues)
|
||||
.set(sets)
|
||||
.where(eq(WebsiteRareValues.id, id));
|
||||
} catch {
|
||||
// DB error — ignore.
|
||||
}
|
||||
revalidatePath("/admin/rare-values");
|
||||
}
|
||||
@@ -83,3 +83,58 @@ export async function deleteVoucher(input: {
|
||||
return actionError("Could not delete voucher");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateVoucher(input: {
|
||||
id: string;
|
||||
code: string;
|
||||
amount: number;
|
||||
maxUses: number;
|
||||
expiresAt?: string;
|
||||
}): Promise<ActionResult> {
|
||||
await requirePermission(PERMS.SHOP_EDIT);
|
||||
|
||||
const id = positiveBigInt(String(input.id ?? "").trim());
|
||||
if (!id) return actionError("Missing voucher id");
|
||||
|
||||
const code = String(input.code ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const amount = Number(input.amount);
|
||||
const maxUsesRaw = Number(input.maxUses);
|
||||
const maxUses =
|
||||
Number.isFinite(maxUsesRaw) && maxUsesRaw > 0 ? Math.floor(maxUsesRaw) : 1;
|
||||
|
||||
if (!code || !(amount > 0)) {
|
||||
return actionError("Code and a positive amount are required");
|
||||
}
|
||||
|
||||
let expiresAt: Date | null = null;
|
||||
const expiresRaw = String(input.expiresAt ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (expiresRaw) {
|
||||
const parsed = new Date(expiresRaw);
|
||||
if (!Number.isNaN(parsed.getTime())) expiresAt = parsed;
|
||||
}
|
||||
|
||||
try {
|
||||
await db
|
||||
.update(WebsiteShopVouchers)
|
||||
.set({
|
||||
code,
|
||||
amount: Math.floor(amount),
|
||||
maxUses,
|
||||
expiresAt,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(WebsiteShopVouchers.id, id));
|
||||
revalidatePath("/admin/vouchers");
|
||||
return actionOk();
|
||||
} catch (error) {
|
||||
logServerError("admin.voucher_update_failed", error, {
|
||||
voucherId: String(id),
|
||||
});
|
||||
return actionError("Could not update voucher (code may already exist)");
|
||||
}
|
||||
}
|
||||
+146
-185
@@ -1,11 +1,24 @@
|
||||
"use server";
|
||||
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { catalogFailure } from "@/features/catalog/server/errors";
|
||||
import {
|
||||
createBcOfferCommand,
|
||||
updateBcOfferCommand,
|
||||
} from "@/features/catalog/server/offer-commands";
|
||||
import {
|
||||
createPageCommand,
|
||||
deletePageCommand,
|
||||
reorderPagesCommand,
|
||||
togglePageCommand,
|
||||
updatePageCommand,
|
||||
} from "@/features/catalog/server/page-commands";
|
||||
import { sendCatalogUpdate } from "@/features/catalog/server/sync-status";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { CatalogItemsBc, CatalogPagesBc, db } from "@/lib/db";
|
||||
import { CatalogItemsBc, db } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { withCatalogExport } from "@/lib/services/catalog-git-queue";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
const BC_PAGE_FIELDS = [
|
||||
@@ -49,42 +62,51 @@ function pickAllowed(
|
||||
|
||||
export async function updateBcPage({
|
||||
id,
|
||||
expected,
|
||||
...fields
|
||||
}: { id: number } & Record<string, unknown>) {
|
||||
}: { id: number; expected?: Record<string, unknown> } & Record<
|
||||
string,
|
||||
unknown
|
||||
>) {
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
const data = pickAllowed(fields, BC_PAGE_FIELDS);
|
||||
if (Object.keys(data).length === 0) {
|
||||
return { ok: false as const, error: "No valid fields to update" };
|
||||
}
|
||||
await db
|
||||
.update(CatalogPagesBc)
|
||||
.set(data as Partial<typeof CatalogPagesBc.$inferInsert>)
|
||||
.where(eq(CatalogPagesBc.id, id));
|
||||
await rcon.updateCatalog();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "bc_page_update",
|
||||
description: `Updated BC catalog page #${id}`,
|
||||
targetType: "catalog_page_bc",
|
||||
targetId: id,
|
||||
return await withCatalogExport(async () => {
|
||||
const data = pickAllowed(fields, BC_PAGE_FIELDS);
|
||||
if (Object.keys(data).length === 0) {
|
||||
return { ok: false as const, error: "No valid fields to update" };
|
||||
}
|
||||
try {
|
||||
await updatePageCommand("bc", id, data, expected);
|
||||
} catch (error) {
|
||||
return { ok: false as const, error: catalogFailure(error).message };
|
||||
}
|
||||
await sendCatalogUpdate();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "bc_page_update",
|
||||
description: `Updated BC catalog page #${id}`,
|
||||
targetType: "catalog_page_bc",
|
||||
targetId: id,
|
||||
});
|
||||
revalidatePath("/admin/catalog/builder-club");
|
||||
return { ok: true as const };
|
||||
});
|
||||
revalidatePath("/admin/catalog/builder-club");
|
||||
return { ok: true as const };
|
||||
}
|
||||
|
||||
export async function deleteBcItem({ id }: { id: number }) {
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
await db.delete(CatalogItemsBc).where(eq(CatalogItemsBc.id, id));
|
||||
await rcon.updateCatalog();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "bc_item_delete",
|
||||
description: `Deleted BC catalog item #${id}`,
|
||||
targetType: "catalog_item_bc",
|
||||
targetId: id,
|
||||
return await withCatalogExport(async () => {
|
||||
await db.delete(CatalogItemsBc).where(eq(CatalogItemsBc.id, id));
|
||||
await sendCatalogUpdate();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "bc_item_delete",
|
||||
description: `Deleted BC catalog item #${id}`,
|
||||
targetType: "catalog_item_bc",
|
||||
targetId: id,
|
||||
});
|
||||
revalidatePath("/admin/catalog/builder-club");
|
||||
return { ok: true as const };
|
||||
});
|
||||
revalidatePath("/admin/catalog/builder-club");
|
||||
return { ok: true as const };
|
||||
}
|
||||
|
||||
export async function updateBcItem({
|
||||
@@ -98,24 +120,27 @@ export async function updateBcItem({
|
||||
extradata?: string;
|
||||
}) {
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
const safe = pickAllowed(data as Record<string, unknown>, BC_ITEM_FIELDS);
|
||||
if (Object.keys(safe).length === 0) {
|
||||
return { ok: false as const, error: "No valid fields to update" };
|
||||
}
|
||||
await db
|
||||
.update(CatalogItemsBc)
|
||||
.set(safe as Partial<typeof CatalogItemsBc.$inferInsert>)
|
||||
.where(eq(CatalogItemsBc.id, id));
|
||||
await rcon.updateCatalog();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "bc_item_update",
|
||||
description: `Updated BC catalog item #${id}`,
|
||||
targetType: "catalog_item_bc",
|
||||
targetId: id,
|
||||
return await withCatalogExport(async () => {
|
||||
const safe = pickAllowed(data as Record<string, unknown>, BC_ITEM_FIELDS);
|
||||
if (Object.keys(safe).length === 0) {
|
||||
return { ok: false as const, error: "No valid fields to update" };
|
||||
}
|
||||
try {
|
||||
await updateBcOfferCommand(id, safe);
|
||||
} catch (error) {
|
||||
return { ok: false as const, error: catalogFailure(error).message };
|
||||
}
|
||||
await sendCatalogUpdate();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "bc_item_update",
|
||||
description: `Updated BC catalog item #${id}`,
|
||||
targetType: "catalog_item_bc",
|
||||
targetId: id,
|
||||
});
|
||||
revalidatePath("/admin/catalog/builder-club");
|
||||
return { ok: true as const };
|
||||
});
|
||||
revalidatePath("/admin/catalog/builder-club");
|
||||
return { ok: true as const };
|
||||
}
|
||||
|
||||
export async function createBcItem({
|
||||
@@ -129,18 +154,19 @@ export async function createBcItem({
|
||||
extradata: string;
|
||||
}) {
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
const [result] = await db.insert(CatalogItemsBc).values({ pageId, ...data });
|
||||
const createdId = Number(result.insertId);
|
||||
await rcon.updateCatalog();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "bc_item_create",
|
||||
description: `Created BC catalog item #${createdId}`,
|
||||
targetType: "catalog_item_bc",
|
||||
targetId: createdId,
|
||||
return await withCatalogExport(async () => {
|
||||
const createdId = await createBcOfferCommand({ pageId, ...data });
|
||||
await sendCatalogUpdate();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "bc_item_create",
|
||||
description: `Created BC catalog item #${createdId}`,
|
||||
targetType: "catalog_item_bc",
|
||||
targetId: createdId,
|
||||
});
|
||||
revalidatePath("/admin/catalog/builder-club");
|
||||
return { ok: true as const, data: { id: createdId } };
|
||||
});
|
||||
revalidatePath("/admin/catalog/builder-club");
|
||||
return { ok: true as const, data: { id: createdId } };
|
||||
}
|
||||
|
||||
export async function toggleBcPage({
|
||||
@@ -151,22 +177,12 @@ export async function toggleBcPage({
|
||||
field: "enabled" | "visible";
|
||||
}) {
|
||||
await requirePermission(PERMS.CATALOG_EDIT);
|
||||
const [page] = await db
|
||||
.select({
|
||||
enabled: CatalogPagesBc.enabled,
|
||||
visible: CatalogPagesBc.visible,
|
||||
})
|
||||
.from(CatalogPagesBc)
|
||||
.where(eq(CatalogPagesBc.id, id))
|
||||
.limit(1);
|
||||
if (!page) return { ok: false as const, error: "Page not found" };
|
||||
await db
|
||||
.update(CatalogPagesBc)
|
||||
.set({ [field]: page[field] === "1" ? "0" : "1" })
|
||||
.where(eq(CatalogPagesBc.id, id));
|
||||
await rcon.updateCatalog();
|
||||
revalidatePath("/admin/catalog/builder-club");
|
||||
return { ok: true as const };
|
||||
return await withCatalogExport(async () => {
|
||||
await togglePageCommand("bc", id, field);
|
||||
await sendCatalogUpdate();
|
||||
revalidatePath("/admin/catalog/builder-club");
|
||||
return { ok: true as const };
|
||||
});
|
||||
}
|
||||
|
||||
export async function createBcPage(input: {
|
||||
@@ -180,52 +196,31 @@ export async function createBcPage(input: {
|
||||
orderNum?: number;
|
||||
}) {
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
const [result] = await db.insert(CatalogPagesBc).values({
|
||||
caption: input.caption,
|
||||
parentId: input.parentId,
|
||||
pageLayout: input.pageLayout ?? "default_3x3",
|
||||
iconColor: input.iconColor ?? 0,
|
||||
iconImage: input.iconImage ?? 0,
|
||||
orderNum: input.orderNum ?? 0,
|
||||
visible: input.visible ?? "1",
|
||||
enabled: input.enabled ?? "1",
|
||||
pageHeadline: "",
|
||||
pageTeaser: "",
|
||||
return await withCatalogExport(async () => {
|
||||
const createdId = await createPageCommand("bc", {
|
||||
caption: input.caption,
|
||||
parentId: input.parentId,
|
||||
pageLayout: input.pageLayout ?? "default_3x3",
|
||||
iconColor: input.iconColor ?? 0,
|
||||
iconImage: input.iconImage ?? 0,
|
||||
orderNum: input.orderNum ?? 0,
|
||||
visible: input.visible ?? "1",
|
||||
enabled: input.enabled ?? "1",
|
||||
pageHeadline: "",
|
||||
pageTeaser: "",
|
||||
});
|
||||
await sendCatalogUpdate();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "bc_page_create",
|
||||
description: `Created BC catalog page "${input.caption}"`,
|
||||
targetType: "catalog_page_bc",
|
||||
targetId: createdId,
|
||||
});
|
||||
revalidatePath("/admin/catalog");
|
||||
revalidatePath("/admin/catalog/builder-club");
|
||||
return { ok: true as const, data: { id: createdId } };
|
||||
});
|
||||
const createdId = Number(result.insertId);
|
||||
await rcon.updateCatalog();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "bc_page_create",
|
||||
description: `Created BC catalog page "${input.caption}"`,
|
||||
targetType: "catalog_page_bc",
|
||||
targetId: createdId,
|
||||
});
|
||||
revalidatePath("/admin/catalog");
|
||||
revalidatePath("/admin/catalog/builder-club");
|
||||
return { ok: true as const, data: { id: createdId } };
|
||||
}
|
||||
|
||||
async function moveBcPage(pageId: number, newParentId: number): Promise<void> {
|
||||
if (newParentId > 0) {
|
||||
let currentId = newParentId;
|
||||
for (let i = 0; i < 50; i++) {
|
||||
if (currentId === pageId) {
|
||||
throw new Error("Cannot move page: would create a circular hierarchy");
|
||||
}
|
||||
const [parent] = await db
|
||||
.select({ parentId: CatalogPagesBc.parentId })
|
||||
.from(CatalogPagesBc)
|
||||
.where(eq(CatalogPagesBc.id, currentId))
|
||||
.limit(1);
|
||||
if (!parent || parent.parentId <= 0) break;
|
||||
currentId = parent.parentId;
|
||||
}
|
||||
}
|
||||
await db
|
||||
.update(CatalogPagesBc)
|
||||
.set({ parentId: newParentId })
|
||||
.where(eq(CatalogPagesBc.id, pageId));
|
||||
}
|
||||
|
||||
export async function reorderBcTreePage(input: {
|
||||
@@ -234,24 +229,16 @@ export async function reorderBcTreePage(input: {
|
||||
newOrderNum: number;
|
||||
}) {
|
||||
await requirePermission(PERMS.CATALOG_EDIT);
|
||||
if (input.newParentId !== undefined) {
|
||||
try {
|
||||
await moveBcPage(input.pageId, input.newParentId);
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false as const,
|
||||
error: err instanceof Error ? err.message : "Invalid move",
|
||||
};
|
||||
}
|
||||
}
|
||||
await db
|
||||
.update(CatalogPagesBc)
|
||||
.set({ orderNum: input.newOrderNum })
|
||||
.where(eq(CatalogPagesBc.id, input.pageId));
|
||||
await rcon.updateCatalog();
|
||||
revalidatePath("/admin/catalog");
|
||||
revalidatePath("/admin/catalog/builder-club");
|
||||
return { ok: true as const, data: {} };
|
||||
return await withCatalogExport(async () => {
|
||||
await updatePageCommand("bc", input.pageId, {
|
||||
parentId: input.newParentId,
|
||||
orderNum: input.newOrderNum,
|
||||
});
|
||||
await sendCatalogUpdate();
|
||||
revalidatePath("/admin/catalog");
|
||||
revalidatePath("/admin/catalog/builder-club");
|
||||
return { ok: true as const, data: {} };
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteBcTreePage(input: {
|
||||
@@ -259,52 +246,26 @@ export async function deleteBcTreePage(input: {
|
||||
mode: "reparent" | "cascade";
|
||||
}) {
|
||||
await requirePermission(PERMS.CATALOG_EDIT);
|
||||
const [page] = await db
|
||||
.select({ parentId: CatalogPagesBc.parentId })
|
||||
.from(CatalogPagesBc)
|
||||
.where(eq(CatalogPagesBc.id, input.pageId))
|
||||
.limit(1);
|
||||
if (!page) return { ok: false as const, error: "Page not found" };
|
||||
return await withCatalogExport(async () => {
|
||||
await deletePageCommand("bc", input.pageId, input.mode);
|
||||
await sendCatalogUpdate();
|
||||
revalidatePath("/admin/catalog");
|
||||
revalidatePath("/admin/catalog/builder-club");
|
||||
return { ok: true as const, data: {} };
|
||||
});
|
||||
}
|
||||
|
||||
if (input.mode === "reparent") {
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(CatalogPagesBc)
|
||||
.set({ parentId: page.parentId })
|
||||
.where(eq(CatalogPagesBc.parentId, input.pageId));
|
||||
await tx
|
||||
.delete(CatalogItemsBc)
|
||||
.where(eq(CatalogItemsBc.pageId, input.pageId));
|
||||
await tx
|
||||
.delete(CatalogPagesBc)
|
||||
.where(eq(CatalogPagesBc.id, input.pageId));
|
||||
});
|
||||
} else {
|
||||
const toDelete: number[] = [input.pageId];
|
||||
const queue: number[] = [input.pageId];
|
||||
while (queue.length > 0) {
|
||||
const children = await db
|
||||
.select({ id: CatalogPagesBc.id })
|
||||
.from(CatalogPagesBc)
|
||||
.where(inArray(CatalogPagesBc.parentId, queue));
|
||||
queue.length = 0;
|
||||
for (const child of children) {
|
||||
toDelete.push(child.id);
|
||||
queue.push(child.id);
|
||||
}
|
||||
}
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.delete(CatalogItemsBc)
|
||||
.where(inArray(CatalogItemsBc.pageId, toDelete));
|
||||
await tx
|
||||
.delete(CatalogPagesBc)
|
||||
.where(inArray(CatalogPagesBc.id, toDelete));
|
||||
});
|
||||
}
|
||||
|
||||
await rcon.updateCatalog();
|
||||
revalidatePath("/admin/catalog");
|
||||
revalidatePath("/admin/catalog/builder-club");
|
||||
return { ok: true as const, data: {} };
|
||||
export async function reorderBcCatalogPages(input: {
|
||||
parentId: number;
|
||||
ids: number[];
|
||||
expectedIds: number[];
|
||||
}) {
|
||||
await requirePermission(PERMS.CATALOG_EDIT);
|
||||
return withCatalogExport(async () => {
|
||||
await reorderPagesCommand("bc", input);
|
||||
await sendCatalogUpdate();
|
||||
revalidatePath("/admin/catalog");
|
||||
revalidatePath("/admin/catalog/builder-club");
|
||||
return { ok: true as const, data: {} };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
"use server";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import type { BulkOfferInput } from "@/features/catalog/domain/bulk-offers";
|
||||
import {
|
||||
applyBulkOffersCommand,
|
||||
listBulkOfferDestinationsCommand,
|
||||
previewBulkOffersCommand,
|
||||
} from "@/features/catalog/server/bulk-offers";
|
||||
import { catalogFailure } from "@/features/catalog/server/errors";
|
||||
import { sendCatalogUpdate } from "@/features/catalog/server/sync-status";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { logAudit } from "@/lib/services/audit";
|
||||
import { withCatalogExport } from "@/lib/services/catalog-git-queue";
|
||||
export async function previewBulkOffers(input: BulkOfferInput) {
|
||||
await requirePermission(PERMS.CATALOG_VIEW);
|
||||
try {
|
||||
return { ok: true as const, data: await previewBulkOffersCommand(input) };
|
||||
} catch (error) {
|
||||
return { ok: false as const, error: catalogFailure(error).message };
|
||||
}
|
||||
}
|
||||
export async function applyBulkOffers(
|
||||
input: BulkOfferInput,
|
||||
fingerprint: string,
|
||||
) {
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
try {
|
||||
return await withCatalogExport(async () => {
|
||||
const data = await applyBulkOffersCommand(input, fingerprint);
|
||||
if (data.changedCount > 0) {
|
||||
await logAudit({
|
||||
userId: staff.id,
|
||||
action: "catalog_offers_bulk_update",
|
||||
target: "catalog",
|
||||
after: {
|
||||
ids: input.ids,
|
||||
changes: input.changes,
|
||||
changedCount: data.changedCount,
|
||||
},
|
||||
}).catch((error) =>
|
||||
logger.error("Catalog bulk update committed; audit write failed", {
|
||||
module: "catalog",
|
||||
error,
|
||||
}),
|
||||
);
|
||||
await sendCatalogUpdate();
|
||||
revalidatePath("/admin/catalog", "layout");
|
||||
}
|
||||
return { ok: true as const, data };
|
||||
});
|
||||
} catch (error) {
|
||||
return { ok: false as const, error: catalogFailure(error).message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getBulkOfferDestinations() {
|
||||
await requirePermission(PERMS.CATALOG_VIEW);
|
||||
try {
|
||||
return {
|
||||
ok: true as const,
|
||||
data: await listBulkOfferDestinationsCommand(),
|
||||
};
|
||||
} catch (error) {
|
||||
return { ok: false as const, error: catalogFailure(error).message };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
"use server";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import type {
|
||||
DuplicateInput,
|
||||
DuplicatePreview,
|
||||
} from "@/features/catalog/domain/duplicate";
|
||||
import {
|
||||
duplicateCategoryCommand,
|
||||
duplicateDestinationsCommand,
|
||||
previewDuplicateCommand,
|
||||
} from "@/features/catalog/server/duplicate-commands";
|
||||
import { catalogFailure } from "@/features/catalog/server/errors";
|
||||
import { sendCatalogUpdate } from "@/features/catalog/server/sync-status";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { withCatalogExport } from "@/lib/services/catalog-git-queue";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
export async function previewCatalogDuplicate(input: DuplicateInput) {
|
||||
await requirePermission(PERMS.CATALOG_VIEW);
|
||||
try {
|
||||
return { ok: true as const, data: await previewDuplicateCommand(input) };
|
||||
} catch (error) {
|
||||
return { ok: false as const, error: catalogFailure(error).message };
|
||||
}
|
||||
}
|
||||
export async function applyCatalogDuplicate(input: DuplicatePreview) {
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
return withCatalogExport(async () => {
|
||||
let data: Awaited<ReturnType<typeof duplicateCategoryCommand>>;
|
||||
try {
|
||||
data = await duplicateCategoryCommand(input);
|
||||
} catch (error) {
|
||||
return { ok: false as const, error: catalogFailure(error).message };
|
||||
}
|
||||
const hotel = await sendCatalogUpdate();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "catalog_page_create",
|
||||
description: `Duplicated ${input.kind} category #${input.sourceId} to #${data.id}: ${data.pages} categories, ${data.offers} offers`,
|
||||
targetType: "catalog_page",
|
||||
targetId: data.id,
|
||||
}).catch((error) => {
|
||||
logger.error("Category duplicated but audit logging failed", {
|
||||
module: "catalog",
|
||||
error,
|
||||
});
|
||||
});
|
||||
revalidatePath("/admin/catalog");
|
||||
return { ok: true as const, data: { ...data, hotel } };
|
||||
});
|
||||
}
|
||||
|
||||
export async function catalogDuplicateDestinations(
|
||||
kind: DuplicateInput["kind"],
|
||||
) {
|
||||
await requirePermission(PERMS.CATALOG_VIEW);
|
||||
try {
|
||||
return {
|
||||
ok: true as const,
|
||||
data: await duplicateDestinationsCommand(kind),
|
||||
};
|
||||
} catch (error) {
|
||||
return { ok: false as const, error: catalogFailure(error).message };
|
||||
}
|
||||
}
|
||||
+259
-321
@@ -2,71 +2,23 @@
|
||||
|
||||
import { eq, inArray, like, or, sql } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { offerPatchSchema } from "@/features/catalog/domain/offer-input";
|
||||
import {
|
||||
createOfferCommand,
|
||||
moveOffersCommand,
|
||||
reorderOffersCommand,
|
||||
updateOfferCommand,
|
||||
} from "@/features/catalog/server/offer-commands";
|
||||
import { sendCatalogUpdate } from "@/features/catalog/server/sync-status";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { CatalogItems, db, ItemsBase } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { logAudit } from "@/lib/services/audit";
|
||||
import { withCatalogExport } from "@/lib/services/catalog-git-queue";
|
||||
import { allocateCatalogItemId } from "@/lib/services/furni-import";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
import { translateItemsSchema } from "@/lib/validators/catalog";
|
||||
|
||||
const CATALOG_ITEM_FIELDS = [
|
||||
"pageId",
|
||||
"itemIds",
|
||||
"catalogName",
|
||||
"costCredits",
|
||||
"costPoints",
|
||||
"pointsType",
|
||||
"amount",
|
||||
"orderNumber",
|
||||
"offerId",
|
||||
"songId",
|
||||
"limitedSells",
|
||||
"limitedStack",
|
||||
"extradata",
|
||||
"haveOffer",
|
||||
"clubOnly",
|
||||
] as const;
|
||||
|
||||
const ITEMS_BASE_FIELDS = [
|
||||
"publicName",
|
||||
"itemName",
|
||||
"type",
|
||||
"width",
|
||||
"length",
|
||||
"stackHeight",
|
||||
"allowStack",
|
||||
"allowSit",
|
||||
"allowLay",
|
||||
"allowWalk",
|
||||
"allowGift",
|
||||
"allowTrade",
|
||||
"allowRecycle",
|
||||
"allowMarketplaceSell",
|
||||
"allowInventoryStack",
|
||||
"interactionType",
|
||||
"interactionModesCount",
|
||||
"vendingIds",
|
||||
"customparams",
|
||||
"effectIdMale",
|
||||
"effectIdFemale",
|
||||
"clothingOnWalk",
|
||||
] as const;
|
||||
|
||||
function pickAllowed(
|
||||
fields: Record<string, unknown>,
|
||||
allowed: readonly string[],
|
||||
): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const key of allowed) {
|
||||
if (Object.hasOwn(fields, key) && fields[key] !== undefined) {
|
||||
out[key] = fields[key];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Raw INSERT — catalog_items.id has no AUTO_INCREMENT on real Habbo DBs; page_id is often VARCHAR. */
|
||||
async function insertCatalogItemRow(data: {
|
||||
pageId: number;
|
||||
@@ -86,8 +38,10 @@ async function insertCatalogItemRow(data: {
|
||||
clubOnly: string;
|
||||
}): Promise<number> {
|
||||
const pageIdStr = String(data.pageId);
|
||||
return allocateCatalogItemId(async (nextId) => {
|
||||
await db.execute(sql`
|
||||
offerPatchSchema.required().parse(data);
|
||||
return allocateCatalogItemId((nextId) =>
|
||||
createOfferCommand("normal", data, async (tx) => {
|
||||
await tx.execute(sql`
|
||||
INSERT INTO catalog_items (
|
||||
id, page_id, item_ids, catalog_name,
|
||||
cost_credits, cost_points, points_type, amount,
|
||||
@@ -101,8 +55,9 @@ async function insertCatalogItemRow(data: {
|
||||
${data.haveOffer}, ${data.clubOnly}
|
||||
)
|
||||
`);
|
||||
return nextId;
|
||||
});
|
||||
return nextId;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function createCatalogItem(data: {
|
||||
@@ -123,32 +78,34 @@ export async function createCatalogItem(data: {
|
||||
clubOnly: "0" | "1";
|
||||
}) {
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
let catalogName = data.catalogName.trim();
|
||||
if (!catalogName) {
|
||||
const firstId = Number.parseInt(data.itemIds.split(";")[0] || "", 10);
|
||||
if (firstId > 0) {
|
||||
const [base] = await db
|
||||
.select({
|
||||
publicName: ItemsBase.publicName,
|
||||
itemName: ItemsBase.itemName,
|
||||
})
|
||||
.from(ItemsBase)
|
||||
.where(eq(ItemsBase.id, firstId))
|
||||
.limit(1);
|
||||
catalogName = base?.publicName || base?.itemName || String(firstId);
|
||||
return await withCatalogExport(async () => {
|
||||
let catalogName = data.catalogName.trim();
|
||||
if (!catalogName) {
|
||||
const firstId = Number.parseInt(data.itemIds.split(";")[0] || "", 10);
|
||||
if (firstId > 0) {
|
||||
const [base] = await db
|
||||
.select({
|
||||
publicName: ItemsBase.publicName,
|
||||
itemName: ItemsBase.itemName,
|
||||
})
|
||||
.from(ItemsBase)
|
||||
.where(eq(ItemsBase.id, firstId))
|
||||
.limit(1);
|
||||
catalogName = base?.publicName || base?.itemName || String(firstId);
|
||||
}
|
||||
}
|
||||
}
|
||||
const id = await insertCatalogItemRow({ ...data, catalogName });
|
||||
await rcon.updateCatalog();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "catalog_item_create",
|
||||
description: `Created catalog item #${id}`,
|
||||
targetType: "catalog_item",
|
||||
targetId: id,
|
||||
const id = await insertCatalogItemRow({ ...data, catalogName });
|
||||
await sendCatalogUpdate();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "catalog_item_create",
|
||||
description: `Created catalog item #${id}`,
|
||||
targetType: "catalog_item",
|
||||
targetId: id,
|
||||
});
|
||||
revalidatePath("/admin/catalog");
|
||||
return { ok: true as const, data: { id } };
|
||||
});
|
||||
revalidatePath("/admin/catalog");
|
||||
return { ok: true as const, data: { id } };
|
||||
}
|
||||
|
||||
/** Bulk create with one RCON refresh at the end. */
|
||||
@@ -165,84 +122,88 @@ export async function bulkCreateCatalogItems({
|
||||
}>;
|
||||
}) {
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
if (rows.length === 0) {
|
||||
return { ok: true as const, data: { created: 0, failed: 0 } };
|
||||
}
|
||||
if (rows.length > 500) {
|
||||
return { ok: false as const, error: "Max 500 items per bulk import" };
|
||||
}
|
||||
|
||||
const baseIds = [...new Set(rows.map((r) => r.baseId))];
|
||||
const bases = await db
|
||||
.select({
|
||||
id: ItemsBase.id,
|
||||
publicName: ItemsBase.publicName,
|
||||
itemName: ItemsBase.itemName,
|
||||
})
|
||||
.from(ItemsBase)
|
||||
.where(inArray(ItemsBase.id, baseIds));
|
||||
const baseMap = new Map(bases.map((b) => [b.id, b]));
|
||||
|
||||
let created = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
const base = baseMap.get(row.baseId);
|
||||
if (!base) {
|
||||
failed++;
|
||||
continue;
|
||||
return await withCatalogExport(async () => {
|
||||
if (rows.length === 0) {
|
||||
return { ok: true as const, data: { created: 0, failed: 0 } };
|
||||
}
|
||||
try {
|
||||
await insertCatalogItemRow({
|
||||
pageId,
|
||||
itemIds: String(row.baseId),
|
||||
catalogName: base.publicName || base.itemName || String(row.baseId),
|
||||
costCredits: row.credits ?? 0,
|
||||
costPoints: row.points ?? 0,
|
||||
pointsType: row.pointsType ?? 0,
|
||||
amount: 1,
|
||||
limitedSells: 0,
|
||||
limitedStack: 0,
|
||||
orderNumber: 1,
|
||||
offerId: -1,
|
||||
songId: 0,
|
||||
haveOffer: "1",
|
||||
clubOnly: "0",
|
||||
extradata: "",
|
||||
if (rows.length > 500) {
|
||||
return { ok: false as const, error: "Max 500 items per bulk import" };
|
||||
}
|
||||
|
||||
const baseIds = [...new Set(rows.map((r) => r.baseId))];
|
||||
const bases = await db
|
||||
.select({
|
||||
id: ItemsBase.id,
|
||||
publicName: ItemsBase.publicName,
|
||||
itemName: ItemsBase.itemName,
|
||||
})
|
||||
.from(ItemsBase)
|
||||
.where(inArray(ItemsBase.id, baseIds));
|
||||
const baseMap = new Map(bases.map((b) => [b.id, b]));
|
||||
|
||||
let created = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
const base = baseMap.get(row.baseId);
|
||||
if (!base) {
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await insertCatalogItemRow({
|
||||
pageId,
|
||||
itemIds: String(row.baseId),
|
||||
catalogName: base.publicName || base.itemName || String(row.baseId),
|
||||
costCredits: row.credits ?? 0,
|
||||
costPoints: row.points ?? 0,
|
||||
pointsType: row.pointsType ?? 0,
|
||||
amount: 1,
|
||||
limitedSells: 0,
|
||||
limitedStack: 0,
|
||||
orderNumber: 1,
|
||||
offerId: -1,
|
||||
songId: 0,
|
||||
haveOffer: "1",
|
||||
clubOnly: "0",
|
||||
extradata: "",
|
||||
});
|
||||
created++;
|
||||
} catch {
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
if (created > 0) {
|
||||
await sendCatalogUpdate();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "catalog_items_bulk_create",
|
||||
description: `Bulk imported ${created} catalog item(s) on page #${pageId}`,
|
||||
targetType: "catalog_page",
|
||||
targetId: pageId,
|
||||
});
|
||||
created++;
|
||||
} catch {
|
||||
failed++;
|
||||
revalidatePath("/admin/catalog");
|
||||
}
|
||||
}
|
||||
|
||||
if (created > 0) {
|
||||
await rcon.updateCatalog();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "catalog_items_bulk_create",
|
||||
description: `Bulk imported ${created} catalog item(s) on page #${pageId}`,
|
||||
targetType: "catalog_page",
|
||||
targetId: pageId,
|
||||
});
|
||||
revalidatePath("/admin/catalog");
|
||||
}
|
||||
|
||||
return { ok: true as const, data: { created, failed } };
|
||||
return { ok: true as const, data: { created, failed } };
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteCatalogItems({ ids }: { ids: number[] }) {
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
await db.delete(CatalogItems).where(inArray(CatalogItems.id, ids));
|
||||
await rcon.updateCatalog();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "catalog_items_delete",
|
||||
description: `Deleted catalog items: ${ids.join(", ")}`,
|
||||
targetType: "catalog_item",
|
||||
return await withCatalogExport(async () => {
|
||||
await db.delete(CatalogItems).where(inArray(CatalogItems.id, ids));
|
||||
await sendCatalogUpdate();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "catalog_items_delete",
|
||||
description: `Deleted catalog items: ${ids.join(", ")}`,
|
||||
targetType: "catalog_item",
|
||||
});
|
||||
revalidatePath("/admin/catalog");
|
||||
return { ok: true as const, data: {} };
|
||||
});
|
||||
revalidatePath("/admin/catalog");
|
||||
return { ok: true as const, data: {} };
|
||||
}
|
||||
|
||||
export async function moveCatalogItems({
|
||||
@@ -253,21 +214,15 @@ export async function moveCatalogItems({
|
||||
targetPageId: number;
|
||||
}) {
|
||||
await requirePermission(PERMS.CATALOG_EDIT);
|
||||
if (ids.length === 0) {
|
||||
return await withCatalogExport(async () => {
|
||||
if (ids.length === 0) {
|
||||
return { ok: true as const, data: {} };
|
||||
}
|
||||
await moveOffersCommand(ids, targetPageId);
|
||||
await sendCatalogUpdate();
|
||||
revalidatePath("/admin/catalog");
|
||||
return { ok: true as const, data: {} };
|
||||
}
|
||||
const pageIdStr = String(targetPageId);
|
||||
await db.execute(sql`
|
||||
UPDATE catalog_items
|
||||
SET page_id = ${pageIdStr}
|
||||
WHERE id IN (${sql.join(
|
||||
ids.map((id) => sql`${id}`),
|
||||
sql`, `,
|
||||
)})
|
||||
`);
|
||||
await rcon.updateCatalog();
|
||||
revalidatePath("/admin/catalog");
|
||||
return { ok: true as const, data: {} };
|
||||
});
|
||||
}
|
||||
|
||||
export async function reorderCatalogItems({
|
||||
@@ -276,15 +231,12 @@ export async function reorderCatalogItems({
|
||||
orders: Array<{ id: number; orderNumber: number }>;
|
||||
}) {
|
||||
await requirePermission(PERMS.CATALOG_EDIT);
|
||||
for (const { id, orderNumber } of orders) {
|
||||
await db
|
||||
.update(CatalogItems)
|
||||
.set({ orderNumber })
|
||||
.where(eq(CatalogItems.id, id));
|
||||
}
|
||||
await rcon.updateCatalog();
|
||||
revalidatePath("/admin/catalog");
|
||||
return { ok: true as const, data: {} };
|
||||
return await withCatalogExport(async () => {
|
||||
await reorderOffersCommand(orders);
|
||||
await sendCatalogUpdate();
|
||||
revalidatePath("/admin/catalog");
|
||||
return { ok: true as const, data: {} };
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateCatalogItem({
|
||||
@@ -297,46 +249,29 @@ export async function updateCatalogItem({
|
||||
baseItem?: { id: number; fields: Record<string, unknown> };
|
||||
}) {
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
const safeCatalog = pickAllowed(catalogFields, CATALOG_ITEM_FIELDS);
|
||||
if (Object.keys(safeCatalog).length === 0 && !baseItem) {
|
||||
return { ok: false as const, error: "No valid fields to update" };
|
||||
}
|
||||
|
||||
// page_id is often VARCHAR — update it via raw SQL when present.
|
||||
const pageIdRaw = safeCatalog.pageId;
|
||||
if (pageIdRaw !== undefined) {
|
||||
const pageIdStr = String(pageIdRaw);
|
||||
await db.execute(sql`
|
||||
UPDATE catalog_items SET page_id = ${pageIdStr} WHERE id = ${id}
|
||||
`);
|
||||
delete safeCatalog.pageId;
|
||||
}
|
||||
|
||||
if (Object.keys(safeCatalog).length > 0) {
|
||||
await db
|
||||
.update(CatalogItems)
|
||||
.set(safeCatalog as Partial<typeof CatalogItems.$inferInsert>)
|
||||
.where(eq(CatalogItems.id, id));
|
||||
}
|
||||
if (baseItem) {
|
||||
const safeBase = pickAllowed(baseItem.fields, ITEMS_BASE_FIELDS);
|
||||
if (Object.keys(safeBase).length > 0) {
|
||||
await db
|
||||
.update(ItemsBase)
|
||||
.set(safeBase as Partial<typeof ItemsBase.$inferInsert>)
|
||||
.where(eq(ItemsBase.id, baseItem.id));
|
||||
return await withCatalogExport(async () => {
|
||||
try {
|
||||
await updateOfferCommand({ id, catalogFields, baseItem });
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false as const,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Unable to update catalog item",
|
||||
};
|
||||
}
|
||||
}
|
||||
await rcon.updateCatalog();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "catalog_item_update",
|
||||
description: `Updated catalog item #${id}`,
|
||||
targetType: "catalog_item",
|
||||
targetId: id,
|
||||
await sendCatalogUpdate();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "catalog_item_update",
|
||||
description: `Updated catalog item #${id}`,
|
||||
targetType: "catalog_item",
|
||||
targetId: id,
|
||||
});
|
||||
revalidatePath("/admin/catalog");
|
||||
return { ok: true as const, data: {} };
|
||||
});
|
||||
revalidatePath("/admin/catalog");
|
||||
return { ok: true as const, data: {} };
|
||||
}
|
||||
|
||||
export async function translateCatalogItems(input: {
|
||||
@@ -344,121 +279,124 @@ export async function translateCatalogItems(input: {
|
||||
items: Array<{ id: number; publicName: string; description?: string }>;
|
||||
}) {
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
const parsed = translateItemsSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
ok: false as const,
|
||||
error: parsed.error.issues[0]?.message ?? "Invalid translate payload",
|
||||
};
|
||||
}
|
||||
const { items } = parsed.data;
|
||||
const { invalidateFurniDataCache } = await import(
|
||||
"@/lib/services/catalog-items-loader"
|
||||
);
|
||||
const { patchFurniEntryNames } = await import("@/lib/services/furni-data");
|
||||
return await withCatalogExport(async () => {
|
||||
const parsed = translateItemsSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
ok: false as const,
|
||||
error: parsed.error.issues[0]?.message ?? "Invalid translate payload",
|
||||
};
|
||||
}
|
||||
const { items } = parsed.data;
|
||||
const { invalidateFurniDataCache } = await import(
|
||||
"@/lib/services/catalog-items-loader"
|
||||
);
|
||||
const { patchFurniEntryNames } = await import("@/lib/services/furni-data");
|
||||
|
||||
let namesUpdated = 0;
|
||||
let descriptionsUpdated = 0;
|
||||
const furniPatches: Array<{
|
||||
classname: string;
|
||||
itemType: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
spriteId?: number;
|
||||
createIfMissing?: boolean;
|
||||
}> = [];
|
||||
let namesUpdated = 0;
|
||||
let descriptionsUpdated = 0;
|
||||
const furniPatches: Array<{
|
||||
classname: string;
|
||||
itemType: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
spriteId?: number;
|
||||
createIfMissing?: boolean;
|
||||
}> = [];
|
||||
|
||||
for (const item of items) {
|
||||
const [base] = await db
|
||||
.select({
|
||||
id: ItemsBase.id,
|
||||
publicName: ItemsBase.publicName,
|
||||
itemName: ItemsBase.itemName,
|
||||
type: ItemsBase.type,
|
||||
spriteId: ItemsBase.spriteId,
|
||||
})
|
||||
.from(ItemsBase)
|
||||
.where(eq(ItemsBase.id, item.id))
|
||||
.limit(1);
|
||||
if (!base) continue;
|
||||
|
||||
const nextName = item.publicName?.trim() ?? "";
|
||||
const nextDesc = item.description ?? "";
|
||||
const nameChanged = nextName !== "" && nextName !== (base.publicName ?? "");
|
||||
|
||||
if (nameChanged) {
|
||||
await db
|
||||
.update(ItemsBase)
|
||||
.set({ publicName: nextName })
|
||||
.where(eq(ItemsBase.id, base.id));
|
||||
const idStr = String(base.id);
|
||||
const related = await db
|
||||
for (const item of items) {
|
||||
const [base] = await db
|
||||
.select({
|
||||
id: CatalogItems.id,
|
||||
catalogName: CatalogItems.catalogName,
|
||||
id: ItemsBase.id,
|
||||
publicName: ItemsBase.publicName,
|
||||
itemName: ItemsBase.itemName,
|
||||
type: ItemsBase.type,
|
||||
spriteId: ItemsBase.spriteId,
|
||||
})
|
||||
.from(CatalogItems)
|
||||
.where(
|
||||
or(
|
||||
eq(CatalogItems.itemIds, idStr),
|
||||
like(CatalogItems.itemIds, `${idStr};%`),
|
||||
like(CatalogItems.itemIds, `%;${idStr};%`),
|
||||
like(CatalogItems.itemIds, `%;${idStr}`),
|
||||
),
|
||||
);
|
||||
for (const row of related) {
|
||||
if (row.catalogName !== nextName) {
|
||||
await db
|
||||
.update(CatalogItems)
|
||||
.set({ catalogName: nextName })
|
||||
.where(eq(CatalogItems.id, row.id));
|
||||
.from(ItemsBase)
|
||||
.where(eq(ItemsBase.id, item.id))
|
||||
.limit(1);
|
||||
if (!base) continue;
|
||||
|
||||
const nextName = item.publicName?.trim() ?? "";
|
||||
const nextDesc = item.description ?? "";
|
||||
const nameChanged =
|
||||
nextName !== "" && nextName !== (base.publicName ?? "");
|
||||
|
||||
if (nameChanged) {
|
||||
await db
|
||||
.update(ItemsBase)
|
||||
.set({ publicName: nextName })
|
||||
.where(eq(ItemsBase.id, base.id));
|
||||
const idStr = String(base.id);
|
||||
const related = await db
|
||||
.select({
|
||||
id: CatalogItems.id,
|
||||
catalogName: CatalogItems.catalogName,
|
||||
})
|
||||
.from(CatalogItems)
|
||||
.where(
|
||||
or(
|
||||
eq(CatalogItems.itemIds, idStr),
|
||||
like(CatalogItems.itemIds, `${idStr};%`),
|
||||
like(CatalogItems.itemIds, `%;${idStr};%`),
|
||||
like(CatalogItems.itemIds, `%;${idStr}`),
|
||||
),
|
||||
);
|
||||
for (const row of related) {
|
||||
if (row.catalogName !== nextName) {
|
||||
await db
|
||||
.update(CatalogItems)
|
||||
.set({ catalogName: nextName })
|
||||
.where(eq(CatalogItems.id, row.id));
|
||||
}
|
||||
}
|
||||
namesUpdated++;
|
||||
}
|
||||
|
||||
if (nextDesc !== "" || nameChanged) {
|
||||
descriptionsUpdated += nextDesc !== "" ? 1 : 0;
|
||||
furniPatches.push({
|
||||
classname: base.itemName,
|
||||
itemType: base.type || "s",
|
||||
name: nextName || base.publicName || base.itemName,
|
||||
description: nextDesc,
|
||||
spriteId: base.spriteId,
|
||||
createIfMissing: true,
|
||||
});
|
||||
}
|
||||
namesUpdated++;
|
||||
}
|
||||
|
||||
if (nextDesc !== "" || nameChanged) {
|
||||
descriptionsUpdated += nextDesc !== "" ? 1 : 0;
|
||||
furniPatches.push({
|
||||
classname: base.itemName,
|
||||
itemType: base.type || "s",
|
||||
name: nextName || base.publicName || base.itemName,
|
||||
description: nextDesc,
|
||||
spriteId: base.spriteId,
|
||||
createIfMissing: true,
|
||||
});
|
||||
const furniResult =
|
||||
furniPatches.length > 0
|
||||
? await patchFurniEntryNames(furniPatches)
|
||||
: { updated: 0, inserted: 0 };
|
||||
if (furniResult.updated > 0 || furniResult.inserted > 0) {
|
||||
invalidateFurniDataCache();
|
||||
}
|
||||
}
|
||||
|
||||
const furniResult =
|
||||
furniPatches.length > 0
|
||||
? await patchFurniEntryNames(furniPatches)
|
||||
: { updated: 0, inserted: 0 };
|
||||
if (furniResult.updated > 0 || furniResult.inserted > 0) {
|
||||
invalidateFurniDataCache();
|
||||
}
|
||||
|
||||
await rcon.updateCatalog();
|
||||
await logAudit({
|
||||
userId: staff.id,
|
||||
action: "items_base_translate",
|
||||
target: "ItemsBase",
|
||||
after: {
|
||||
namesUpdated,
|
||||
descriptionsUpdated,
|
||||
furniDataUpdated: furniResult.updated > 0,
|
||||
furniDataInserted: furniResult.inserted,
|
||||
},
|
||||
await sendCatalogUpdate();
|
||||
await logAudit({
|
||||
userId: staff.id,
|
||||
action: "items_base_translate",
|
||||
target: "ItemsBase",
|
||||
after: {
|
||||
namesUpdated,
|
||||
descriptionsUpdated,
|
||||
furniDataUpdated: furniResult.updated > 0,
|
||||
furniDataInserted: furniResult.inserted,
|
||||
},
|
||||
});
|
||||
revalidatePath("/admin/catalog");
|
||||
return {
|
||||
ok: true as const,
|
||||
data: {
|
||||
namesUpdated,
|
||||
descriptionsUpdated,
|
||||
furniDataUpdated: furniResult.updated,
|
||||
furniDataInserted: furniResult.inserted,
|
||||
updated: items.length,
|
||||
},
|
||||
};
|
||||
});
|
||||
revalidatePath("/admin/catalog");
|
||||
return {
|
||||
ok: true as const,
|
||||
data: {
|
||||
namesUpdated,
|
||||
descriptionsUpdated,
|
||||
furniDataUpdated: furniResult.updated,
|
||||
furniDataInserted: furniResult.inserted,
|
||||
updated: items.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
+117
-101
@@ -1,13 +1,19 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { catalogFailure } from "@/features/catalog/server/errors";
|
||||
import {
|
||||
createPageCommand,
|
||||
deletePageCommand,
|
||||
reorderPagesCommand,
|
||||
togglePageCommand,
|
||||
updatePageCommand,
|
||||
} from "@/features/catalog/server/page-commands";
|
||||
import { sendCatalogUpdate } from "@/features/catalog/server/sync-status";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { CatalogPages, db } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import type { ActionResult } from "@/lib/safe-action-shared";
|
||||
import { deletePage, movePage } from "@/lib/services/catalog-tree";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { withCatalogExport } from "@/lib/services/catalog-git-queue";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
const CATALOG_PAGE_FIELDS = [
|
||||
@@ -45,45 +51,54 @@ function pickPageFields(fields: Record<string, unknown>) {
|
||||
|
||||
export async function updateCatalogPage({
|
||||
id,
|
||||
expected,
|
||||
...fields
|
||||
}: { id: number } & Record<string, unknown>): Promise<ActionResult> {
|
||||
}: { id: number; expected?: Record<string, unknown> } & Record<
|
||||
string,
|
||||
unknown
|
||||
>): Promise<ActionResult> {
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
const data = pickPageFields(fields);
|
||||
if (Object.keys(data).length === 0) {
|
||||
return { ok: false as const, error: "No valid fields to update" };
|
||||
}
|
||||
if (typeof data.caption === "string" && !data.captionSave) {
|
||||
data.captionSave = data.caption.slice(0, 25);
|
||||
}
|
||||
await db
|
||||
.update(CatalogPages)
|
||||
.set(data as Partial<typeof CatalogPages.$inferInsert>)
|
||||
.where(eq(CatalogPages.id, id));
|
||||
await rcon.updateCatalog();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "catalog_page_update",
|
||||
description: `Updated catalog page #${id}`,
|
||||
targetType: "catalog_page",
|
||||
targetId: id,
|
||||
return await withCatalogExport(async () => {
|
||||
const data = pickPageFields(fields);
|
||||
if (Object.keys(data).length === 0) {
|
||||
return { ok: false as const, error: "No valid fields to update" };
|
||||
}
|
||||
if (typeof data.caption === "string" && !data.captionSave) {
|
||||
data.captionSave = data.caption.slice(0, 25);
|
||||
}
|
||||
try {
|
||||
await updatePageCommand("normal", id, data, expected);
|
||||
} catch (error) {
|
||||
return { ok: false as const, error: catalogFailure(error).message };
|
||||
}
|
||||
await sendCatalogUpdate();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "catalog_page_update",
|
||||
description: `Updated catalog page #${id}`,
|
||||
targetType: "catalog_page",
|
||||
targetId: id,
|
||||
});
|
||||
revalidatePath("/admin/catalog");
|
||||
return { ok: true as const, data: {} };
|
||||
});
|
||||
revalidatePath("/admin/catalog");
|
||||
return { ok: true as const, data: {} };
|
||||
}
|
||||
|
||||
export async function deleteCatalogPage({ id }: { id: number }) {
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
await deletePage(id, "reparent");
|
||||
await rcon.updateCatalog();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "catalog_page_delete",
|
||||
description: `Deleted catalog page #${id}`,
|
||||
targetType: "catalog_page",
|
||||
targetId: id,
|
||||
return await withCatalogExport(async () => {
|
||||
await deletePageCommand("normal", id, "reparent");
|
||||
await sendCatalogUpdate();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "catalog_page_delete",
|
||||
description: `Deleted catalog page #${id}`,
|
||||
targetType: "catalog_page",
|
||||
targetId: id,
|
||||
});
|
||||
revalidatePath("/admin/catalog");
|
||||
return { ok: true as const, data: {} };
|
||||
});
|
||||
revalidatePath("/admin/catalog");
|
||||
return { ok: true as const, data: {} };
|
||||
}
|
||||
|
||||
export async function toggleCatalogPage({
|
||||
@@ -94,24 +109,16 @@ export async function toggleCatalogPage({
|
||||
action: "toggleEnabled" | "toggleVisible";
|
||||
}) {
|
||||
await requirePermission(PERMS.CATALOG_EDIT);
|
||||
const [page] = await db
|
||||
.select({
|
||||
enabled: CatalogPages.enabled,
|
||||
visible: CatalogPages.visible,
|
||||
})
|
||||
.from(CatalogPages)
|
||||
.where(eq(CatalogPages.id, id))
|
||||
.limit(1);
|
||||
if (!page) return { ok: false as const, error: "Catalog page not found" };
|
||||
const field = action === "toggleEnabled" ? "enabled" : "visible";
|
||||
const current = action === "toggleEnabled" ? page.enabled : page.visible;
|
||||
await db
|
||||
.update(CatalogPages)
|
||||
.set({ [field]: current === "1" ? "0" : "1" })
|
||||
.where(eq(CatalogPages.id, id));
|
||||
await rcon.updateCatalog();
|
||||
revalidatePath("/admin/catalog");
|
||||
return { ok: true as const, data: {} };
|
||||
return await withCatalogExport(async () => {
|
||||
await togglePageCommand(
|
||||
"normal",
|
||||
id,
|
||||
action === "toggleEnabled" ? "enabled" : "visible",
|
||||
);
|
||||
await sendCatalogUpdate();
|
||||
revalidatePath("/admin/catalog");
|
||||
return { ok: true as const, data: {} };
|
||||
});
|
||||
}
|
||||
|
||||
export async function createCatalogPage(input: {
|
||||
@@ -126,34 +133,35 @@ export async function createCatalogPage(input: {
|
||||
orderNum?: number;
|
||||
}): Promise<ActionResult<{ id: number }>> {
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
const [result] = await db.insert(CatalogPages).values({
|
||||
caption: input.caption,
|
||||
parentId: input.parentId,
|
||||
pageLayout: input.pageLayout ?? "default_3x3",
|
||||
captionSave: input.caption.slice(0, 25),
|
||||
iconColor: input.iconColor ?? 0,
|
||||
iconImage: input.iconImage ?? 0,
|
||||
minRank: input.minRank ?? 1,
|
||||
orderNum: input.orderNum ?? 0,
|
||||
visible: input.visible ?? "1",
|
||||
enabled: input.enabled ?? "1",
|
||||
clubOnly: "0",
|
||||
vipOnly: "0",
|
||||
pageHeadline: "",
|
||||
pageTeaser: "",
|
||||
includes: "",
|
||||
return await withCatalogExport(async () => {
|
||||
const createdId = await createPageCommand("normal", {
|
||||
caption: input.caption,
|
||||
parentId: input.parentId,
|
||||
pageLayout: input.pageLayout ?? "default_3x3",
|
||||
captionSave: input.caption.slice(0, 25),
|
||||
iconColor: input.iconColor ?? 0,
|
||||
iconImage: input.iconImage ?? 0,
|
||||
minRank: input.minRank ?? 1,
|
||||
orderNum: input.orderNum ?? 0,
|
||||
visible: input.visible ?? "1",
|
||||
enabled: input.enabled ?? "1",
|
||||
clubOnly: "0",
|
||||
vipOnly: "0",
|
||||
pageHeadline: "",
|
||||
pageTeaser: "",
|
||||
includes: "",
|
||||
});
|
||||
await sendCatalogUpdate();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "catalog_page_create",
|
||||
description: `Created catalog page "${input.caption}"`,
|
||||
targetType: "catalog_page",
|
||||
targetId: createdId,
|
||||
});
|
||||
revalidatePath("/admin/catalog");
|
||||
return { ok: true as const, data: { id: createdId } };
|
||||
});
|
||||
const createdId = Number(result.insertId);
|
||||
await rcon.updateCatalog();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "catalog_page_create",
|
||||
description: `Created catalog page "${input.caption}"`,
|
||||
targetType: "catalog_page",
|
||||
targetId: createdId,
|
||||
});
|
||||
revalidatePath("/admin/catalog");
|
||||
return { ok: true as const, data: { id: createdId } };
|
||||
}
|
||||
|
||||
export async function reorderTreePage(input: {
|
||||
@@ -162,23 +170,15 @@ export async function reorderTreePage(input: {
|
||||
newOrderNum: number;
|
||||
}) {
|
||||
await requirePermission(PERMS.CATALOG_EDIT);
|
||||
if (input.newParentId !== undefined) {
|
||||
try {
|
||||
await movePage(input.pageId, input.newParentId);
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false as const,
|
||||
error: err instanceof Error ? err.message : "Invalid move",
|
||||
};
|
||||
}
|
||||
}
|
||||
await db
|
||||
.update(CatalogPages)
|
||||
.set({ orderNum: input.newOrderNum })
|
||||
.where(eq(CatalogPages.id, input.pageId));
|
||||
await rcon.updateCatalog();
|
||||
revalidatePath("/admin/catalog");
|
||||
return { ok: true as const, data: {} };
|
||||
return await withCatalogExport(async () => {
|
||||
await updatePageCommand("normal", input.pageId, {
|
||||
parentId: input.newParentId,
|
||||
orderNum: input.newOrderNum,
|
||||
});
|
||||
await sendCatalogUpdate();
|
||||
revalidatePath("/admin/catalog");
|
||||
return { ok: true as const, data: {} };
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteTreePage(input: {
|
||||
@@ -186,8 +186,24 @@ export async function deleteTreePage(input: {
|
||||
mode: "reparent" | "cascade";
|
||||
}) {
|
||||
await requirePermission(PERMS.CATALOG_EDIT);
|
||||
await deletePage(input.pageId, input.mode);
|
||||
await rcon.updateCatalog();
|
||||
revalidatePath("/admin/catalog");
|
||||
return { ok: true as const, data: {} };
|
||||
return await withCatalogExport(async () => {
|
||||
await deletePageCommand("normal", input.pageId, input.mode);
|
||||
await sendCatalogUpdate();
|
||||
revalidatePath("/admin/catalog");
|
||||
return { ok: true as const, data: {} };
|
||||
});
|
||||
}
|
||||
|
||||
export async function reorderCatalogPages(input: {
|
||||
parentId: number;
|
||||
ids: number[];
|
||||
expectedIds: number[];
|
||||
}) {
|
||||
await requirePermission(PERMS.CATALOG_EDIT);
|
||||
return withCatalogExport(async () => {
|
||||
await reorderPagesCommand("normal", input);
|
||||
await sendCatalogUpdate();
|
||||
revalidatePath("/admin/catalog");
|
||||
return { ok: true as const, data: {} };
|
||||
});
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { redirect } from "next/navigation";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db, User, UsersBadges, WebsiteDrawbadges } from "@/lib/db";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
import { logServerError } from "@/lib/server-log";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
@@ -127,7 +128,12 @@ export async function buyBadge(formData: FormData): Promise<void> {
|
||||
}
|
||||
|
||||
// Grant the badge live so it appears immediately for online users.
|
||||
await rcon.giveBadge(userId, code).catch(() => {});
|
||||
await rcon.giveBadge(userId, code).catch((error) =>
|
||||
logServerError("drawbadge.give_failed", error, {
|
||||
userId,
|
||||
code,
|
||||
}),
|
||||
);
|
||||
|
||||
outcome = "bought";
|
||||
boughtCode = code;
|
||||
|
||||
@@ -15,6 +15,7 @@ import { PERMS } from "@/lib/permissions";
|
||||
import { adminAction, authAction } from "@/lib/safe-action";
|
||||
import { ActionError, actionError, actionOk } from "@/lib/safe-action-shared";
|
||||
import { logAudit } from "@/lib/services/audit";
|
||||
import { notify } from "@/lib/services/webhook";
|
||||
import {
|
||||
createEventSchema,
|
||||
eventPrizeSchema,
|
||||
@@ -120,6 +121,11 @@ export const createEvent = adminAction(
|
||||
targetId: eventId,
|
||||
after: { title: ctx.data.title },
|
||||
});
|
||||
notify({
|
||||
action: "event_create",
|
||||
actor: ctx.session.user.username,
|
||||
target: ctx.data.title,
|
||||
});
|
||||
return actionOk({ id: eventId });
|
||||
},
|
||||
);
|
||||
@@ -155,6 +161,11 @@ export const updateEvent = adminAction(
|
||||
before: { title: existing.title, status: existing.status },
|
||||
after: data,
|
||||
});
|
||||
notify({
|
||||
action: "event_update",
|
||||
actor: ctx.session.user.username,
|
||||
target: data.title ?? existing.title,
|
||||
});
|
||||
return actionOk({ id });
|
||||
},
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
actionOk,
|
||||
handleActionError,
|
||||
} from "@/lib/safe-action-shared";
|
||||
import { withCatalogExport } from "@/lib/services/catalog-git-queue";
|
||||
import type {
|
||||
AlignResult,
|
||||
DedupResult,
|
||||
@@ -38,7 +39,9 @@ export async function fixSpriteIdsAction(): Promise<
|
||||
> {
|
||||
try {
|
||||
await guard();
|
||||
return actionOk(await maintenance.fixSpriteIds());
|
||||
return await withCatalogExport(async () => {
|
||||
return actionOk(await maintenance.fixSpriteIds());
|
||||
});
|
||||
} catch (e) {
|
||||
return handleActionError(e);
|
||||
}
|
||||
@@ -49,7 +52,9 @@ export async function fixCatalogOffersAction(): Promise<
|
||||
> {
|
||||
try {
|
||||
await guard();
|
||||
return actionOk(await maintenance.fixCatalogOffers());
|
||||
return await withCatalogExport(async () => {
|
||||
return actionOk(await maintenance.fixCatalogOffers());
|
||||
});
|
||||
} catch (e) {
|
||||
return handleActionError(e);
|
||||
}
|
||||
@@ -60,7 +65,9 @@ export async function reconcileIdsAction(): Promise<
|
||||
> {
|
||||
try {
|
||||
await guard();
|
||||
return actionOk(await maintenance.reconcileIds());
|
||||
return await withCatalogExport(async () => {
|
||||
return actionOk(await maintenance.reconcileIds());
|
||||
});
|
||||
} catch (e) {
|
||||
return handleActionError(e);
|
||||
}
|
||||
@@ -71,7 +78,9 @@ export async function removeDuplicateItemsBaseAction(): Promise<
|
||||
> {
|
||||
try {
|
||||
await guard();
|
||||
return actionOk(await maintenance.removeDuplicates());
|
||||
return await withCatalogExport(async () => {
|
||||
return actionOk(await maintenance.removeDuplicates());
|
||||
});
|
||||
} catch (e) {
|
||||
return handleActionError(e);
|
||||
}
|
||||
@@ -93,7 +102,9 @@ export async function applyAlignIdsAction(): Promise<
|
||||
> {
|
||||
try {
|
||||
await guard();
|
||||
return actionOk(await maintenance.forceItemsBaseIdsToFurnidata(true));
|
||||
return await withCatalogExport(async () => {
|
||||
return actionOk(await maintenance.forceItemsBaseIdsToFurnidata(true));
|
||||
});
|
||||
} catch (e) {
|
||||
return handleActionError(e);
|
||||
}
|
||||
@@ -104,7 +115,9 @@ export async function fixEverythingAction(input?: {
|
||||
}): Promise<ActionResult<maintenance.FixAllResult>> {
|
||||
try {
|
||||
await guard();
|
||||
return actionOk(await maintenance.fixEverything(input ?? {}));
|
||||
return await withCatalogExport(async () => {
|
||||
return actionOk(await maintenance.fixEverything(input ?? {}));
|
||||
});
|
||||
} catch (e) {
|
||||
return handleActionError(e);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
import { moderateOrThrow } from "@/lib/services/moderation";
|
||||
import { createOwnedTicketReply } from "@/lib/services/ticket-replies";
|
||||
import { notify } from "@/lib/services/webhook";
|
||||
|
||||
const ticketSchema = z.object({
|
||||
title: z.string().min(1, "Title is required").max(255),
|
||||
@@ -160,6 +161,15 @@ export async function createTicket(formData: FormData): Promise<void> {
|
||||
updatedAt: now,
|
||||
});
|
||||
outcome = "created";
|
||||
|
||||
const sessionUser = session?.user;
|
||||
if (sessionUser?.name) {
|
||||
notify({
|
||||
action: "ticket_create",
|
||||
actor: sessionUser.name,
|
||||
target: ticketTitle,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { db, WebsiteSetting } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { adminAction } from "@/lib/safe-action";
|
||||
import { actionOk } from "@/lib/safe-action-shared";
|
||||
import { withCatalogExport } from "@/lib/services/catalog-git-queue";
|
||||
import { deleteImportedItem } from "@/lib/services/furni-import";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
@@ -14,10 +15,9 @@ export const deleteImportedFurni = adminAction(
|
||||
{ permission: PERMS.ASSETS_IMPORT, schema: deleteSchema },
|
||||
async (ctx) =>
|
||||
actionOk(
|
||||
(await deleteImportedItem(ctx.data.classname)) as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
(await withCatalogExport(() =>
|
||||
deleteImportedItem(ctx.data.classname),
|
||||
)) as unknown as Record<string, unknown>,
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { env } from "@/env";
|
||||
import { hashPassword } from "@/lib/auth/password";
|
||||
import { db, PasswordReset, User } from "@/lib/db";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
import { logServerError } from "@/lib/server-log";
|
||||
import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha";
|
||||
import { sendMail } from "@/lib/services/email";
|
||||
|
||||
@@ -123,7 +124,11 @@ export async function resetPassword(formData: FormData): Promise<void> {
|
||||
await db
|
||||
.delete(PasswordReset)
|
||||
.where(eq(PasswordReset.email, email))
|
||||
.catch(() => {});
|
||||
.catch((error) =>
|
||||
logServerError("password.reset_delete_tokens_failed", error, {
|
||||
email,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { PERMS } from "@/lib/permissions";
|
||||
import { adminAction, authAction } from "@/lib/safe-action";
|
||||
import { ActionError, actionError, actionOk } from "@/lib/safe-action-shared";
|
||||
import { logAudit } from "@/lib/services/audit";
|
||||
import { notify } from "@/lib/services/webhook";
|
||||
import {
|
||||
createPollSchema,
|
||||
pollQuestionSchema,
|
||||
@@ -38,6 +39,11 @@ export const createPoll = adminAction(
|
||||
targetId: pollId,
|
||||
after: { title: ctx.data.title },
|
||||
});
|
||||
notify({
|
||||
action: "poll_create",
|
||||
actor: ctx.session.user.username,
|
||||
target: ctx.data.title,
|
||||
});
|
||||
return actionOk({ id: pollId });
|
||||
},
|
||||
);
|
||||
|
||||
+18
-1
@@ -7,6 +7,7 @@ import { db, Items, Rooms } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
import { notify } from "@/lib/services/webhook";
|
||||
|
||||
export async function updateRoomItem(payload: Record<string, unknown>) {
|
||||
const staff = await requirePermission(PERMS.ROOMS_EDIT);
|
||||
@@ -75,11 +76,17 @@ export async function roomRconAction({
|
||||
roomId: number;
|
||||
action: string;
|
||||
}) {
|
||||
await requirePermission(PERMS.ROOMS_EDIT);
|
||||
const staff = await requirePermission(PERMS.ROOMS_EDIT);
|
||||
if (action === "reload") {
|
||||
await rcon.send("reloadroom", { room_id: roomId });
|
||||
} else if (action === "kick") {
|
||||
await rcon.send("kickall", { room_id: roomId });
|
||||
notify({
|
||||
action: "kick",
|
||||
actor: staff.username,
|
||||
target: String(roomId),
|
||||
details: action,
|
||||
});
|
||||
} else if (action === "lock") {
|
||||
await rcon.send("updateroom", { room_id: roomId, state: "locked" });
|
||||
} else if (action === "unlock") {
|
||||
@@ -89,6 +96,11 @@ export async function roomRconAction({
|
||||
|
||||
export async function deleteRoom({ id }: { id: number }) {
|
||||
const staff = await requirePermission(PERMS.ROOMS_DELETE);
|
||||
const [room] = await db
|
||||
.select({ name: Rooms.name })
|
||||
.from(Rooms)
|
||||
.where(eq(Rooms.id, id))
|
||||
.limit(1);
|
||||
await db.delete(Rooms).where(eq(Rooms.id, id));
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
@@ -97,6 +109,11 @@ export async function deleteRoom({ id }: { id: number }) {
|
||||
targetType: "room",
|
||||
targetId: id,
|
||||
});
|
||||
notify({
|
||||
action: "room_delete",
|
||||
actor: staff.username,
|
||||
target: room?.name ?? `#${id}`,
|
||||
});
|
||||
revalidatePath("/admin/rooms");
|
||||
}
|
||||
|
||||
|
||||
+7
-1
@@ -6,6 +6,7 @@ import { redirect } from "next/navigation";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db, User, UsersBadges, WebsiteShopArticles } from "@/lib/db";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
import { logServerError } from "@/lib/server-log";
|
||||
import { creditsPerUnit } from "@/lib/services/paypal";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { currencyDb, sendCurrency } from "@/lib/services/send-currency";
|
||||
@@ -183,7 +184,12 @@ export async function buyShopArticle(formData: FormData): Promise<void> {
|
||||
);
|
||||
|
||||
for (const code of badgeCodes) {
|
||||
await rcon.giveBadge(userId, code).catch(() => {});
|
||||
await rcon.giveBadge(userId, code).catch((error) =>
|
||||
logServerError("shop.give_badge_failed", error, {
|
||||
userId,
|
||||
code,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
outcome = "bought";
|
||||
|
||||
+25
-38
@@ -3,60 +3,47 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import * as JSONC from "jsonc-parser";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { z } from "zod";
|
||||
import { SUPPORTED_LOCALES } from "@/i18n/locales";
|
||||
import {
|
||||
CLIENT_TRANSLATION_FILES,
|
||||
getClientTranslationFile,
|
||||
} from "@/lib/client-translation-files";
|
||||
import {
|
||||
CmsTranslationError,
|
||||
saveCmsTranslation,
|
||||
} from "@/lib/cms-translations";
|
||||
import { patchJson5 } from "@/lib/json5-patch";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { adminAction } from "@/lib/safe-action";
|
||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||
|
||||
const saveTranslationsSchema = z.object({
|
||||
locale: z.enum([
|
||||
"en",
|
||||
"it",
|
||||
"nl",
|
||||
"de",
|
||||
"fr",
|
||||
"es",
|
||||
"pt",
|
||||
"pl",
|
||||
"sv",
|
||||
"tr",
|
||||
"ro",
|
||||
"hu",
|
||||
"cs",
|
||||
"sk",
|
||||
"da",
|
||||
"no",
|
||||
"el",
|
||||
"bg",
|
||||
"hr",
|
||||
"sr",
|
||||
"uk",
|
||||
"ru",
|
||||
]),
|
||||
data: z.record(z.string(), z.unknown()),
|
||||
locale: z.enum(SUPPORTED_LOCALES),
|
||||
revision: z.string().regex(/^[a-f0-9]{64}$/),
|
||||
changes: z
|
||||
.record(z.string().max(300), z.string().max(20000).nullable())
|
||||
.refine((value) => Object.keys(value).length <= 5000),
|
||||
});
|
||||
|
||||
export const saveTranslations = adminAction(
|
||||
{ permission: PERMS.SETTINGS_EDIT, schema: saveTranslationsSchema },
|
||||
async (ctx) => {
|
||||
const filePath = path.join(
|
||||
process.cwd(),
|
||||
"src",
|
||||
"messages",
|
||||
`${ctx.data.locale}.json`,
|
||||
);
|
||||
await fs.writeFile(
|
||||
filePath,
|
||||
JSON.stringify(ctx.data.data, null, 2),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
return actionOk();
|
||||
try {
|
||||
const snapshot = await saveCmsTranslation(
|
||||
ctx.data.locale,
|
||||
ctx.data.revision,
|
||||
ctx.data.changes,
|
||||
);
|
||||
revalidatePath("/", "layout");
|
||||
return actionOk({ snapshot });
|
||||
} catch (error) {
|
||||
if (!(error instanceof CmsTranslationError)) throw error;
|
||||
const t = await getTranslations("pages.admin.translations.cms");
|
||||
return { ok: false, error: t(error.code, { key: error.key }) };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
+16
-2
@@ -97,7 +97,7 @@ export const createUser = adminAction(
|
||||
});
|
||||
|
||||
notify({
|
||||
action: "user_edit",
|
||||
action: "user_create",
|
||||
actor: ctx.session.user.username,
|
||||
target: username,
|
||||
targetId: user.id,
|
||||
@@ -450,6 +450,13 @@ export const muteUser = adminAction(
|
||||
after: { duration: ctx.data.duration },
|
||||
});
|
||||
|
||||
notify({
|
||||
action: "hotel_alert",
|
||||
actor: ctx.session.user.username,
|
||||
target: _target.username,
|
||||
details: "Muted",
|
||||
});
|
||||
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
@@ -463,7 +470,7 @@ const unmuteSchema = z.object({
|
||||
export const unmuteUser = adminAction(
|
||||
{ permission: PERMS.USERS_EDIT, schema: unmuteSchema },
|
||||
async (ctx) => {
|
||||
await guardRank(ctx.data.userId, ctx.session.user.rank);
|
||||
const target = await guardRank(ctx.data.userId, ctx.session.user.rank);
|
||||
const success = await rcon.unmuteUser(ctx.data.userId);
|
||||
if (!success)
|
||||
throw new ActionError("Failed to unmute. Is the emulator running?");
|
||||
@@ -475,6 +482,13 @@ export const unmuteUser = adminAction(
|
||||
targetId: ctx.data.userId,
|
||||
});
|
||||
|
||||
notify({
|
||||
action: "hotel_alert",
|
||||
actor: ctx.session.user.username,
|
||||
target: target.username,
|
||||
details: "Unmuted",
|
||||
});
|
||||
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { and, eq, sql } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db, WebsiteShopVouchers, WebsiteUsedShopVouchers } from "@/lib/db";
|
||||
import { formatNumber } from "@/lib/format-date";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { currencyDb, sendCurrency } from "@/lib/services/send-currency";
|
||||
@@ -175,6 +176,6 @@ export async function redeem(
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
message: `Success! Your balance has been increased by ${voucher.amount.toLocaleString()} credits.`,
|
||||
message: `Success! Your balance has been increased by ${formatNumber(voucher.amount)} credits.`,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { desc, eq, inArray } from "drizzle-orm";
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import type { CSSProperties } from "react";
|
||||
@@ -14,6 +15,19 @@ import {
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { resolveHotelName } from "@/lib/hotel-name";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("pages.applyStaff");
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("subtitle"),
|
||||
openGraph: {
|
||||
title: t("title"),
|
||||
description: t("subtitle"),
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Canonical Habbo badge image CDN (same base used by the profile page).
|
||||
const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584";
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import type { CSSProperties } from "react";
|
||||
@@ -8,6 +9,19 @@ import { auth } from "@/lib/auth";
|
||||
import { db, WebsiteStaffApplications, WebsiteTeams } from "@/lib/db";
|
||||
import { resolveHotelName } from "@/lib/hotel-name";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("pages.applyTeam");
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("subtitle"),
|
||||
openGraph: {
|
||||
title: t("title"),
|
||||
description: t("subtitle"),
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Canonical Habbo badge image CDN (same base used by the profile page).
|
||||
const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584";
|
||||
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
import { asc } from "drizzle-orm";
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { db, WebsiteBadges } from "@/lib/db";
|
||||
|
||||
export const metadata = { title: "Badges" };
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("pages.badges");
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("subtitle"),
|
||||
openGraph: {
|
||||
title: t("title"),
|
||||
description: t("subtitle"),
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Canonical Habbo badge image CDN. A badge_key (e.g. "ADM") maps to a .gif here.
|
||||
const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584";
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
import type { Metadata } from "next";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import Link from "@/components/link";
|
||||
import { ContentCard } from "@/components/public/ui";
|
||||
|
||||
export const metadata = { title: "Community" };
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("pages.community");
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("subtitle"),
|
||||
openGraph: {
|
||||
title: t("title"),
|
||||
description: t("subtitle"),
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const LINKS = [
|
||||
{
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import type { Metadata } from "next";
|
||||
import { ContentCard } from "@/components/public/ui";
|
||||
|
||||
// Public API documentation. Static, hand-maintained from the routes that
|
||||
// actually exist under src/app/api — keep this in sync when endpoints change.
|
||||
|
||||
export const metadata = { title: "API" };
|
||||
export const metadata: Metadata = {
|
||||
title: "Developers",
|
||||
description: "Public API documentation for the hotel.",
|
||||
openGraph: {
|
||||
title: "Developers",
|
||||
description: "Public API documentation for the hotel.",
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
|
||||
type Method = "GET" | "POST" | "DELETE";
|
||||
|
||||
@@ -304,9 +313,9 @@ const GROUPS: Group[] = [
|
||||
];
|
||||
|
||||
const METHOD_CLASS: Record<Method, string> = {
|
||||
GET: "bg-green-600/20 text-[var(--color-text)]",
|
||||
GET: "bg-primary/20 text-[var(--color-text)]",
|
||||
POST: "bg-[var(--color-primary)]/18 text-[var(--color-text)]",
|
||||
DELETE: "bg-red-500/22 text-[var(--color-text)]",
|
||||
DELETE: "bg-destructive/22 text-[var(--color-text)]",
|
||||
};
|
||||
|
||||
function AuthTag({ endpoint }: { endpoint: Endpoint }) {
|
||||
@@ -332,7 +341,7 @@ function AuthTag({ endpoint }: { endpoint: Endpoint }) {
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-green-600/20 text-[var(--color-text)]"
|
||||
className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-primary/20 text-[var(--color-text)]"
|
||||
title="No authentication required"
|
||||
>
|
||||
Public
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { buyBadge } from "@/actions/draw-badge";
|
||||
import { ContentCard, EmptyState, StatBlock } from "@/components/public/ui";
|
||||
@@ -10,7 +11,15 @@ import { siteSettings } from "@/lib/services/site-settings";
|
||||
// Reads the live users + website_drawbadges tables and writes credits/badges on
|
||||
// purchase — must never be statically rendered.
|
||||
|
||||
export const metadata = { title: "Draw a Badge" };
|
||||
export const metadata: Metadata = {
|
||||
title: "Draw a Badge",
|
||||
description: "Draw a random badge and add it to your collection.",
|
||||
openGraph: {
|
||||
title: "Draw a Badge",
|
||||
description: "Draw a random badge and add it to your collection.",
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
|
||||
const DEFAULT_PRICE = 50;
|
||||
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
import { Home, RefreshCw } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ErrorScreen } from "@/components/error-screen";
|
||||
import Link from "@/components/link";
|
||||
import { reportBrowserError } from "@/lib/browser-errors";
|
||||
|
||||
/**
|
||||
* Site route-segment error boundary. Renders inside the site shell layout.
|
||||
@@ -19,8 +20,10 @@ export default function SiteErrorPage({
|
||||
}) {
|
||||
const t = useTranslations("pages.error");
|
||||
|
||||
const [reference, setReference] = useState(error.digest);
|
||||
useEffect(() => {
|
||||
console.error(error);
|
||||
setReference(error.digest ?? reportBrowserError(error, "browser.boundary"));
|
||||
if (error.digest) reportBrowserError(error, "browser.boundary");
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
@@ -46,9 +49,9 @@ export default function SiteErrorPage({
|
||||
</>
|
||||
}
|
||||
footer={
|
||||
error.digest ? (
|
||||
reference ? (
|
||||
<p className="error-screen-digest">
|
||||
{t("reference", { digest: error.digest })}
|
||||
{t("reference", { digest: reference })}
|
||||
</p>
|
||||
) : null
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { count, desc, eq, inArray } from "drizzle-orm";
|
||||
import type { Metadata } from "next";
|
||||
import Image from "next/image";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import Link from "@/components/link";
|
||||
@@ -12,7 +13,18 @@ import {
|
||||
import { excerpt, slugify } from "@/lib/format";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
|
||||
export const metadata = { title: "Events" };
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("pages.events");
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("subtitle"),
|
||||
openGraph: {
|
||||
title: t("title"),
|
||||
description: t("subtitle"),
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function EventsPage() {
|
||||
const t = await getTranslations("pages.events");
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
export default function FriendsLoading() {
|
||||
return (
|
||||
<div
|
||||
style={{ display: "flex", justifyContent: "center", padding: "4rem 0" }}
|
||||
>
|
||||
<div
|
||||
className="animate-spin w-8 h-8 border-4 rounded-full"
|
||||
style={{
|
||||
borderColor: "var(--color-primary)",
|
||||
borderTopColor: "transparent",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
|
||||
export default function Loading() {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { asc, eq, inArray, or } from "drizzle-orm";
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import type { CSSProperties } from "react";
|
||||
@@ -9,7 +10,18 @@ import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db, MessengerFriendships, User } from "@/lib/db";
|
||||
|
||||
export const metadata = { title: "Friends" };
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("pages.friends");
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("emptySubtitle"),
|
||||
openGraph: {
|
||||
title: t("title"),
|
||||
description: t("emptySubtitle"),
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type SearchParams = Promise<{ removed?: string; error?: string }>;
|
||||
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
export default function GuildForumLoading() {
|
||||
return (
|
||||
<div
|
||||
style={{ display: "flex", justifyContent: "center", padding: "4rem 0" }}
|
||||
>
|
||||
<div
|
||||
className="animate-spin w-8 h-8 border-4 rounded-full"
|
||||
style={{
|
||||
borderColor: "var(--color-primary)",
|
||||
borderTopColor: "transparent",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
|
||||
export default function Loading() {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
@@ -1,11 +1,23 @@
|
||||
import { desc } from "drizzle-orm";
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import Link from "@/components/link";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { db, Guilds } from "@/lib/db";
|
||||
import { excerpt } from "@/lib/format";
|
||||
|
||||
export const metadata = { title: "Guilds" };
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("pages.guilds");
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("subtitle"),
|
||||
openGraph: {
|
||||
title: t("title"),
|
||||
description: t("subtitle"),
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type GuildCard = {
|
||||
id: number;
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
import { asc } from "drizzle-orm";
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import Link from "@/components/link";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("pages.help");
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("subtitle"),
|
||||
openGraph: {
|
||||
title: t("title"),
|
||||
description: t("subtitle"),
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import {
|
||||
db,
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import dynamic from "next/dynamic";
|
||||
import type { ReactNode } from "react";
|
||||
import MotionPageWrapper from "@/components/motion-page-wrapper";
|
||||
import { Navigation } from "@/components/navigation";
|
||||
import RadioPlayerGate from "@/components/public/radio-player-gate";
|
||||
import { SiteFooter } from "@/components/site-footer";
|
||||
import { SiteHeader } from "@/components/site-header";
|
||||
import { TopHeader } from "@/components/top-header";
|
||||
import { auth } from "@/lib/auth";
|
||||
|
||||
const RadioPlayerGate = dynamic(
|
||||
() => import("@/components/public/radio-player-gate"),
|
||||
{
|
||||
loading: () => (
|
||||
<div className="animate-pulse bg-[var(--admin-surface)] rounded h-12" />
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Public site chrome. Route group `(site)` keeps this off `/admin` and `/client`,
|
||||
* so housekeeping is never constrained by the public max-w-7xl grid.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import Link from "@/components/link";
|
||||
import { ContentCard, EmptyState, RankBadge } from "@/components/public/ui";
|
||||
@@ -10,7 +11,18 @@ import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
|
||||
import { cached } from "@/lib/cache";
|
||||
import { db, User, UsersCurrency, UsersSettings } from "@/lib/db";
|
||||
|
||||
export const metadata = { title: "Leaderboard" };
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("pages.leaderboard");
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("subtitle", { currency: "credits" }),
|
||||
openGraph: {
|
||||
title: t("title"),
|
||||
description: t("subtitle", { currency: "credits" }),
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Currency type ids (see UsersCurrency in src/db/schema.ts):
|
||||
// Credits = -1 (lives on users.credits), Duckets = 0, Diamonds = 5.
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
export default function SiteLoading() {
|
||||
return (
|
||||
<div
|
||||
style={{ display: "flex", justifyContent: "center", padding: "4rem 0" }}
|
||||
>
|
||||
<div
|
||||
className="animate-spin w-8 h-8 border-4 rounded-full"
|
||||
style={{
|
||||
borderColor: "var(--color-primary)",
|
||||
borderTopColor: "transparent",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
|
||||
export default function Loading() {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
@@ -91,8 +91,8 @@ export default async function LoginPage() {
|
||||
}}
|
||||
>
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-green-500" />
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-primary" />
|
||||
</span>
|
||||
{th("online", { count: online, hotel: "" }).trim()}
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
import { desc, eq, inArray } from "drizzle-orm";
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { ContentCard, EmptyState, StatBlock } from "@/components/public/ui";
|
||||
import { db, MarketplaceItems, User } from "@/lib/db";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
|
||||
export const metadata = { title: "Marketplace" };
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("pages.marketplace");
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("subtitle"),
|
||||
openGraph: {
|
||||
title: t("title"),
|
||||
description: t("subtitle"),
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// state == 1 → an active, unsold offer in the Arcturus marketplace.
|
||||
const STATE_ACTIVE = 1;
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function CopyReferralButton({ value }: { value: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const t = useTranslations("pages.myDashboard");
|
||||
|
||||
async function copy() {
|
||||
setFailed(false);
|
||||
setCopied(false);
|
||||
const absolute =
|
||||
typeof window !== "undefined" && value.startsWith("/")
|
||||
? `${window.location.origin}${value}`
|
||||
@@ -20,9 +25,11 @@ export default function CopyReferralButton({ value }: { value: string }) {
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try {
|
||||
document.execCommand("copy");
|
||||
if (!document.execCommand("copy")) throw new Error("copy-failed");
|
||||
} catch {
|
||||
/* give up silently */
|
||||
setFailed(true);
|
||||
document.body.removeChild(ta);
|
||||
return;
|
||||
}
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
@@ -31,17 +38,21 @@ export default function CopyReferralButton({ value }: { value: string }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={copy}
|
||||
className="rounded-xl font-bold text-sm px-5 py-2.5 transition-all duration-200 hover:scale-[1.02] active:scale-95 shrink-0"
|
||||
style={{
|
||||
background: "color-mix(in srgb, var(--color-primary) 12%, transparent)",
|
||||
color: "var(--color-primary-readable, var(--color-primary))",
|
||||
border: `2px solid color-mix(in srgb, var(--color-primary) 20%, transparent)`,
|
||||
}}
|
||||
>
|
||||
{copied ? "Copied!" : "Copy link"}
|
||||
</button>
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copy}
|
||||
className="rounded-xl font-bold text-sm px-5 py-2.5 transition-all duration-200 hover:scale-[1.02] active:scale-95 shrink-0"
|
||||
style={{
|
||||
background:
|
||||
"color-mix(in srgb, var(--color-primary) 12%, transparent)",
|
||||
color: "var(--color-primary-readable, var(--color-primary))",
|
||||
border: `2px solid color-mix(in srgb, var(--color-primary) 20%, transparent)`,
|
||||
}}
|
||||
>
|
||||
{copied ? t("copied") : t("copy")}
|
||||
</button>
|
||||
{failed && <p role="alert">{t("copyFailed")}</p>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
.dashboard {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
padding-bottom: 24px;
|
||||
min-width: 0;
|
||||
}
|
||||
.hero {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 24px;
|
||||
background: linear-gradient(
|
||||
130deg,
|
||||
color-mix(in srgb, var(--color-primary) 10%, var(--color-surface)),
|
||||
var(--color-surface)
|
||||
);
|
||||
}
|
||||
.avatar {
|
||||
flex-shrink: 0;
|
||||
object-fit: contain;
|
||||
image-rendering: pixelated;
|
||||
border-radius: 16px;
|
||||
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
|
||||
}
|
||||
.identity {
|
||||
flex: 1;
|
||||
min-width: 180px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.identity h1 {
|
||||
font-size: clamp(1.6rem, 3vw, 2rem);
|
||||
margin: 4px 0;
|
||||
}
|
||||
.dates {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
margin-top: 12px;
|
||||
}
|
||||
.shortcuts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.shortcut {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 14px;
|
||||
border-radius: 12px;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-readable);
|
||||
text-decoration: none;
|
||||
}
|
||||
.shortcut:hover,
|
||||
.friend:hover,
|
||||
.room:hover {
|
||||
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-surface));
|
||||
}
|
||||
.count {
|
||||
border-radius: 999px;
|
||||
padding: 2px 7px;
|
||||
background: var(--color-danger);
|
||||
color: white;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.wallet {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.currency {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 20px;
|
||||
}
|
||||
.currency > div {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
}
|
||||
.currency strong {
|
||||
font-size: 1.3rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.currency span {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.columns {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(280px, 350px);
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
}
|
||||
.stack {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
min-width: 0;
|
||||
}
|
||||
.friends,
|
||||
.rooms {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.friend,
|
||||
.room {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
color: var(--color-text-readable);
|
||||
text-decoration: none;
|
||||
min-width: 0;
|
||||
}
|
||||
.friend > div,
|
||||
.room {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
}
|
||||
.friend strong,
|
||||
.friend span,
|
||||
.room strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.friend span,
|
||||
.room span {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 20px;
|
||||
margin: 0;
|
||||
}
|
||||
.stats dt {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.stats dd {
|
||||
margin: 4px 0 0;
|
||||
font-weight: 700;
|
||||
}
|
||||
.feedback {
|
||||
margin: 0;
|
||||
border: 1px solid var(--color-border);
|
||||
border-left: 4px solid var(--color-primary);
|
||||
border-radius: 12px;
|
||||
background: var(--color-surface);
|
||||
padding: 16px;
|
||||
}
|
||||
.progressLabel {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.progress {
|
||||
width: 100%;
|
||||
height: 10px;
|
||||
accent-color: var(--color-primary);
|
||||
}
|
||||
.reward {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.referralInput {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.claim {
|
||||
margin-top: 16px;
|
||||
}
|
||||
.claim button {
|
||||
width: 100%;
|
||||
}
|
||||
.rules {
|
||||
border-top: 1px solid var(--color-border);
|
||||
margin-top: 16px;
|
||||
padding-top: 12px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.rules summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
.badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
.badges img {
|
||||
object-fit: contain;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.columns {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.shortcuts {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.hero {
|
||||
padding: 18px;
|
||||
gap: 16px;
|
||||
}
|
||||
.hero > a {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
.currency {
|
||||
padding: 12px;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.currency strong {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
.progress {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
background: var(--color-border);
|
||||
}
|
||||
.progress::-webkit-progress-bar {
|
||||
background: var(--color-border);
|
||||
border-radius: 999px;
|
||||
}
|
||||
.progress::-webkit-progress-value {
|
||||
background: var(--color-primary);
|
||||
border-radius: 999px;
|
||||
}
|
||||
.progress::-moz-progress-bar {
|
||||
background: var(--color-primary);
|
||||
border-radius: 999px;
|
||||
}
|
||||
+301
-677
File diff suppressed because it is too large.
Load diff
@@ -1,15 +1,5 @@
|
||||
export default function MessagesLoading() {
|
||||
return (
|
||||
<div
|
||||
style={{ display: "flex", justifyContent: "center", padding: "4rem 0" }}
|
||||
>
|
||||
<div
|
||||
className="animate-spin w-8 h-8 border-4 rounded-full"
|
||||
style={{
|
||||
borderColor: "var(--color-primary)",
|
||||
borderTopColor: "transparent",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
|
||||
export default function Loading() {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, asc, eq, inArray } from "drizzle-orm";
|
||||
import { and, asc, eq, inArray, or, sql } from "drizzle-orm";
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
@@ -8,8 +8,8 @@ import { toggleReaction } from "@/actions/article-reactions";
|
||||
import Link from "@/components/link";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { SanitizedHtml } from "@/components/shared/sanitized-html";
|
||||
import { articleSlug } from "@/lib/article-input";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { cachedQuery } from "@/lib/cached-db";
|
||||
import {
|
||||
db,
|
||||
User,
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import { excerpt } from "@/lib/format";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { sanitize } from "@/lib/sanitize";
|
||||
import { cacheNews } from "@/lib/services/news-cache";
|
||||
|
||||
type SearchParams = Promise<{
|
||||
comment?: string;
|
||||
@@ -46,17 +47,26 @@ function feedbackStyle(tone: "success" | "error"): CSSProperties {
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
params: Promise<{ slug: string[] }>;
|
||||
}): Promise<Metadata> {
|
||||
const appUrl = process.env.APP_URL ?? "http://localhost:3000";
|
||||
const { slug } = await params;
|
||||
const slug = articleSlug((await params).slug);
|
||||
const [article] = await db
|
||||
.select({
|
||||
title: WebsiteArticles.title,
|
||||
shortStory: WebsiteArticles.shortStory,
|
||||
})
|
||||
.from(WebsiteArticles)
|
||||
.where(eq(WebsiteArticles.slug, slug))
|
||||
.where(
|
||||
and(
|
||||
eq(WebsiteArticles.slug, slug),
|
||||
eq(WebsiteArticles.status, "published"),
|
||||
or(
|
||||
sql`${WebsiteArticles.publishAt} IS NULL`,
|
||||
sql`${WebsiteArticles.publishAt} <= NOW()`,
|
||||
),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
.catch(() => []);
|
||||
if (!article) return { title: "Article" };
|
||||
@@ -86,35 +96,40 @@ export default async function ArticlePage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
params: Promise<{ slug: string[] }>;
|
||||
searchParams: SearchParams;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
const slug = articleSlug((await params).slug);
|
||||
const { comment, reaction, error } = await searchParams;
|
||||
const t = await getTranslations("pages.article");
|
||||
|
||||
const article = await cachedQuery(
|
||||
`article:slug:${slug}`,
|
||||
async () => {
|
||||
const [row] = await db
|
||||
.select({
|
||||
id: WebsiteArticles.id,
|
||||
slug: WebsiteArticles.slug,
|
||||
title: WebsiteArticles.title,
|
||||
shortStory: WebsiteArticles.shortStory,
|
||||
fullStory: WebsiteArticles.fullStory,
|
||||
image: WebsiteArticles.image,
|
||||
createdAt: WebsiteArticles.createdAt,
|
||||
updatedAt: WebsiteArticles.updatedAt,
|
||||
})
|
||||
.from(WebsiteArticles)
|
||||
.where(eq(WebsiteArticles.slug, slug))
|
||||
.limit(1)
|
||||
.catch(() => []);
|
||||
return row ?? null;
|
||||
},
|
||||
60,
|
||||
);
|
||||
const article = await cacheNews(`article:slug:${slug}`, 60_000, async () => {
|
||||
const [row] = await db
|
||||
.select({
|
||||
id: WebsiteArticles.id,
|
||||
slug: WebsiteArticles.slug,
|
||||
title: WebsiteArticles.title,
|
||||
shortStory: WebsiteArticles.shortStory,
|
||||
fullStory: WebsiteArticles.fullStory,
|
||||
image: WebsiteArticles.image,
|
||||
createdAt: WebsiteArticles.createdAt,
|
||||
updatedAt: WebsiteArticles.updatedAt,
|
||||
})
|
||||
.from(WebsiteArticles)
|
||||
.where(
|
||||
and(
|
||||
eq(WebsiteArticles.slug, slug),
|
||||
eq(WebsiteArticles.status, "published"),
|
||||
or(
|
||||
sql`${WebsiteArticles.publishAt} IS NULL`,
|
||||
sql`${WebsiteArticles.publishAt} <= NOW()`,
|
||||
),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
.catch(() => []);
|
||||
return row ?? null;
|
||||
});
|
||||
if (!article) notFound();
|
||||
|
||||
const articleId = article.id;
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
export default function NewsLoading() {
|
||||
return (
|
||||
<div
|
||||
style={{ display: "flex", justifyContent: "center", padding: "4rem 0" }}
|
||||
>
|
||||
<div
|
||||
className="animate-spin w-8 h-8 border-4 rounded-full"
|
||||
style={{
|
||||
borderColor: "var(--color-primary)",
|
||||
borderTopColor: "transparent",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
|
||||
export default function Loading() {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
+33
-7
@@ -1,14 +1,30 @@
|
||||
import { count, desc, eq } from "drizzle-orm";
|
||||
import type { Metadata } from "next";
|
||||
import { headers } from "next/headers";
|
||||
import Image from "next/image";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Home",
|
||||
description:
|
||||
"An online virtual world where you can create your own avatar, make friends, chat, and build your own rooms.",
|
||||
openGraph: {
|
||||
title: "Home",
|
||||
description:
|
||||
"An online virtual world where you can create your own avatar, make friends, chat, and build your own rooms.",
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
|
||||
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 Link from "@/components/link";
|
||||
import { LiveOnlineCount } from "@/components/live-online-count";
|
||||
import { LiveOnlineCounter } from "@/components/live-online-counter";
|
||||
import { Reveal } from "@/components/motion-reveal";
|
||||
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
|
||||
import { SurfaceCard } from "@/components/surface-card";
|
||||
@@ -232,10 +248,10 @@ export default async function Home() {
|
||||
}}
|
||||
>
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-green-500" />
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-primary" />
|
||||
</span>
|
||||
{th("online", { count: online, hotel: hotelName })}
|
||||
<LiveOnlineCount initial={online} hotelName={hotelName} />
|
||||
</div>
|
||||
<h1
|
||||
className="text-2xl font-black leading-[1.05] tracking-tight mb-2 sm:text-3xl md:text-5xl lg:text-6xl"
|
||||
@@ -337,21 +353,25 @@ export default async function Home() {
|
||||
value: users,
|
||||
label: tp("statsCitizens"),
|
||||
icon: "/assets/images/icons/friends.png",
|
||||
live: false,
|
||||
},
|
||||
{
|
||||
value: rooms,
|
||||
label: tp("statsRooms"),
|
||||
icon: "/assets/images/icons/catalog.png",
|
||||
live: false,
|
||||
},
|
||||
{
|
||||
value: online,
|
||||
label: tp("statsOnline"),
|
||||
icon: "/assets/images/icons/navigation/me.png",
|
||||
live: true,
|
||||
},
|
||||
{
|
||||
value: articles.length,
|
||||
label: tp("statsArticles"),
|
||||
icon: "/assets/images/icons/article.gif",
|
||||
live: false,
|
||||
},
|
||||
].map((s) => (
|
||||
<SurfaceCard
|
||||
@@ -359,7 +379,11 @@ export default async function Home() {
|
||||
className="relative p-3 sm:p-5 text-center transition-all duration-300 hover:-translate-y-1 hover:shadow-lg"
|
||||
>
|
||||
<div className="mb-0.5 text-2xl sm:text-3xl font-black tracking-tight md:text-4xl">
|
||||
<AnimatedCounter value={s.value} />
|
||||
{s.live ? (
|
||||
<LiveOnlineCounter initial={s.value} />
|
||||
) : (
|
||||
<AnimatedCounter value={s.value} />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="text-[10px] sm:text-xs font-bold uppercase tracking-widest flex items-center justify-center gap-1 sm:gap-1.5"
|
||||
@@ -454,8 +478,10 @@ export default async function Home() {
|
||||
sizes="(max-width: 640px) 100vw, 400px"
|
||||
className="rounded-xl object-cover transition-all duration-300 group-hover:scale-105"
|
||||
/>
|
||||
<div className="absolute right-2.5 top-2.5 flex h-8 w-8 translate-x-1 items-center justify-center rounded-full bg-black/50 opacity-0 backdrop-blur-sm transition-all duration-300 group-hover:translate-x-0 group-hover:opacity-100">
|
||||
<span className="text-sm font-bold text-white">→</span>
|
||||
<div className="absolute right-2.5 top-2.5 flex h-8 w-8 translate-x-1 items-center justify-center rounded-full bg-foreground/50 opacity-0 backdrop-blur-sm transition-all duration-300 group-hover:translate-x-0 group-hover:opacity-100">
|
||||
<span className="text-sm font-bold text-primary-foreground">
|
||||
→
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="absolute bottom-0 left-0 w-full p-2.5 sm:p-3"
|
||||
@@ -464,7 +490,7 @@ export default async function Home() {
|
||||
"linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.4) 50%, transparent 100%)",
|
||||
}}
|
||||
>
|
||||
<h3 className="truncate text-sm sm:text-base font-bold text-white text-shadow-sm">
|
||||
<h3 className="truncate text-sm sm:text-base font-bold text-primary-foreground text-shadow-sm">
|
||||
{a.title}
|
||||
</h3>
|
||||
<p
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { desc } from "drizzle-orm";
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import {
|
||||
type LightboxPhoto,
|
||||
@@ -9,7 +10,18 @@ import { cached } from "@/lib/cache";
|
||||
import { CameraWeb, db } from "@/lib/db";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
|
||||
export const metadata = { title: "Photos" };
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("pages.photos");
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("subtitle"),
|
||||
openGraph: {
|
||||
title: t("title"),
|
||||
description: t("subtitle"),
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type Photo = {
|
||||
id: number;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { and, count, desc, inArray, isNull, lte, or } from "drizzle-orm";
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import Link from "@/components/link";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
@@ -6,7 +7,18 @@ import { db, WebsitePoll, WebsitePollQuestion } from "@/lib/db";
|
||||
import { excerpt } from "@/lib/format";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
|
||||
export const metadata = { title: "Polls" };
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("pages.polls");
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("subtitle"),
|
||||
openGraph: {
|
||||
title: t("title"),
|
||||
description: t("subtitle"),
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function PollsPage() {
|
||||
const t = await getTranslations("pages.polls");
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
import { asc, eq, inArray } from "drizzle-orm";
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import Link from "@/components/link";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("pages.radio");
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("subtitle"),
|
||||
openGraph: {
|
||||
title: t("title"),
|
||||
description: t("subtitle"),
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
import { ContentCard, EmptyState, OnlineBadge } from "@/components/public/ui";
|
||||
import { db, RadioSchedules, User } from "@/lib/db";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { desc } from "drizzle-orm";
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import Link from "@/components/link";
|
||||
import {
|
||||
@@ -12,7 +13,18 @@ import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
|
||||
import { cached } from "@/lib/cache";
|
||||
import { db, User } from "@/lib/db";
|
||||
|
||||
export const metadata = { title: "Rankings" };
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("pages.rankings");
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("subtitle"),
|
||||
openGraph: {
|
||||
title: t("title"),
|
||||
description: t("subtitle"),
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type TopUser = {
|
||||
username: string;
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
import { asc, count } from "drizzle-orm";
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import Link from "@/components/link";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { db, WebsiteRareValueCategories, WebsiteRareValues } from "@/lib/db";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
export const metadata = { title: "Rare values" };
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("pages.rares");
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("subtitle"),
|
||||
openGraph: {
|
||||
title: t("title"),
|
||||
description: t("subtitle"),
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type CategoryRow = {
|
||||
id: bigint;
|
||||
|
||||
@@ -100,7 +100,7 @@ export default async function RegisterPage() {
|
||||
"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-primary" />
|
||||
{tpr("usersOnline", { count: online })}
|
||||
</div>
|
||||
<h1
|
||||
|
||||
@@ -5,7 +5,15 @@ import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
|
||||
import { SurfaceCard } from "@/components/surface-card";
|
||||
import { db, Rooms, User } from "@/lib/db";
|
||||
|
||||
export const metadata: Metadata = { title: "Search" };
|
||||
export const metadata: Metadata = {
|
||||
title: "Search",
|
||||
description: "Search for users and rooms in the hotel.",
|
||||
openGraph: {
|
||||
title: "Search",
|
||||
description: "Search for users and rooms in the hotel.",
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
|
||||
type SearchParams = Promise<{ q?: string }>;
|
||||
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
export default function ShopLoading() {
|
||||
return (
|
||||
<div
|
||||
style={{ display: "flex", justifyContent: "center", padding: "4rem 0" }}
|
||||
>
|
||||
<div
|
||||
className="animate-spin w-8 h-8 border-4 rounded-full"
|
||||
style={{
|
||||
borderColor: "var(--color-primary)",
|
||||
borderTopColor: "transparent",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
|
||||
export default function Loading() {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import type { CSSProperties } from "react";
|
||||
import { buyShopArticle } from "@/actions/shop";
|
||||
@@ -10,7 +11,18 @@ import { db, WebsiteShopArticles, WebsiteShopCategories } from "@/lib/db";
|
||||
import { excerpt } from "@/lib/format";
|
||||
import { creditsPerUnit } from "@/lib/services/paypal";
|
||||
|
||||
export const metadata = { title: "Shop" };
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("pages.shop");
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("subtitle"),
|
||||
openGraph: {
|
||||
title: t("title"),
|
||||
description: t("subtitle"),
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function feedbackStyle(tone: "success" | "error"): CSSProperties {
|
||||
const accent =
|
||||
|
||||
+103
-67
@@ -1,11 +1,26 @@
|
||||
import { asc, desc, eq, gte } from "drizzle-orm";
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import Link from "@/components/link";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { ProfileImage } from "@/components/shared/profile-image";
|
||||
import { SurfaceCard } from "@/components/surface-card";
|
||||
import { db, User, WebsiteTeams } from "@/lib/db";
|
||||
import { avatarImageUrl } from "@/lib/format";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import styles from "./staff.module.css";
|
||||
|
||||
export const metadata = { title: "Staff" };
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("pages.staff");
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("subtitle"),
|
||||
openGraph: {
|
||||
title: t("title"),
|
||||
description: t("subtitle"),
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type StaffMember = {
|
||||
username: string;
|
||||
@@ -40,7 +55,7 @@ export default async function StaffPage() {
|
||||
.where(gte(User.rank, minStaffRank))
|
||||
.orderBy(desc(User.rank), asc(User.username))
|
||||
.limit(100)
|
||||
.catch((): StaffMember[] => []),
|
||||
.catch((): StaffMember[] | null => null),
|
||||
db
|
||||
.select({
|
||||
id: WebsiteTeams.id,
|
||||
@@ -57,71 +72,92 @@ export default async function StaffPage() {
|
||||
]);
|
||||
|
||||
return (
|
||||
<main className="page-grid">
|
||||
<ContentCard icon="🛡️" title={t("title")} subtitle={t("subtitle")} />
|
||||
|
||||
{teams.length > 0 ? (
|
||||
<ContentCard
|
||||
icon="⭐"
|
||||
title={t("ranksTitle")}
|
||||
subtitle={t("ranksSubtitle")}
|
||||
>
|
||||
<div className="card-grid sm-2 lg-3">
|
||||
{teams.map((team) => (
|
||||
<div key={team.id} className="card">
|
||||
<h3 style={{ margin: "0 0 0.25rem", color: team.staffColor }}>
|
||||
{team.rankName}
|
||||
</h3>
|
||||
{team.jobDescription ? (
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
{team.jobDescription}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ContentCard>
|
||||
) : null}
|
||||
|
||||
<ContentCard
|
||||
icon="👥"
|
||||
title={t("membersTitle")}
|
||||
subtitle={
|
||||
staff.length === 0
|
||||
? t("membersEmptySubtitle")
|
||||
: t("membersSubtitle", { count: staff.length })
|
||||
}
|
||||
padded={staff.length === 0}
|
||||
>
|
||||
{staff.length === 0 ? (
|
||||
<EmptyState icon="🛡️">{t("emptyState")}</EmptyState>
|
||||
) : (
|
||||
<div className="table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("colUsername")}</th>
|
||||
<th>{t("colRank")}</th>
|
||||
<th>{t("colMotto")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{staff.map((u) => (
|
||||
<tr key={u.username}>
|
||||
<td>
|
||||
<Link href={`/u/${u.username}`}>{u.username}</Link>
|
||||
</td>
|
||||
<td>{u.rank}</td>
|
||||
<td className="muted table-cell-truncate">
|
||||
{u.motto || "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<main className={styles.page}>
|
||||
<SurfaceCard className={styles.hero}>
|
||||
<div>
|
||||
<h1>{t("title")}</h1>
|
||||
<p>{t("subtitle")}</p>
|
||||
</div>
|
||||
{staff && staff.length > 0 && (
|
||||
<span className={styles.count}>
|
||||
{t("membersSubtitle", { count: staff.length })}
|
||||
</span>
|
||||
)}
|
||||
</ContentCard>
|
||||
</SurfaceCard>
|
||||
<section aria-labelledby="staff-members">
|
||||
<h2 id="staff-members" className={styles.heading}>
|
||||
{t("membersTitle")}
|
||||
</h2>
|
||||
{staff === null ? (
|
||||
<SurfaceCard className={styles.notice}>
|
||||
<p role="alert">{t("loadError")}</p>
|
||||
<Link href="/staff" className="btn btn-outline">
|
||||
{t("retry")}
|
||||
</Link>
|
||||
</SurfaceCard>
|
||||
) : staff.length === 0 ? (
|
||||
<SurfaceCard className={styles.notice}>
|
||||
<p>{t("emptyState")}</p>
|
||||
</SurfaceCard>
|
||||
) : (
|
||||
<ul className={styles.grid}>
|
||||
{staff.map((member) => (
|
||||
<li key={member.username}>
|
||||
<Link
|
||||
href={`/u/${encodeURIComponent(member.username)}`}
|
||||
className={styles.member}
|
||||
>
|
||||
<ProfileImage
|
||||
src={avatarImageUrl(member.look, {
|
||||
size: "m",
|
||||
direction: 2,
|
||||
headDirection: 3,
|
||||
})}
|
||||
alt={member.username}
|
||||
width={64}
|
||||
height={110}
|
||||
className={styles.avatar}
|
||||
/>
|
||||
<div className={styles.identity}>
|
||||
<h3>{member.username}</h3>
|
||||
<p>{member.motto || t("noMotto")}</p>
|
||||
<span className={styles.profile}>
|
||||
{t("viewProfile")} <span aria-hidden="true">→</span>
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
{teams.length > 0 && (
|
||||
<section aria-labelledby="staff-teams">
|
||||
<h2 id="staff-teams" className={styles.heading}>
|
||||
{t("ranksTitle")}
|
||||
</h2>
|
||||
<p className={styles.subtitle}>{t("ranksSubtitle")}</p>
|
||||
<ul className={styles.grid}>
|
||||
{teams.map((team) => (
|
||||
<li key={team.id}>
|
||||
<SurfaceCard className={styles.team}>
|
||||
<span
|
||||
className={styles.accent}
|
||||
style={{
|
||||
backgroundColor: /^#[0-9a-f]{6}$/i.test(team.staffColor)
|
||||
? team.staffColor
|
||||
: "var(--color-primary)",
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h3>{team.rankName}</h3>
|
||||
{team.jobDescription && <p>{team.jobDescription}</p>}
|
||||
</SurfaceCard>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
.page {
|
||||
display: grid;
|
||||
gap: 28px;
|
||||
min-width: 0;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
.hero {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
padding: 28px;
|
||||
background: linear-gradient(
|
||||
130deg,
|
||||
color-mix(in srgb, var(--color-primary) 10%, var(--color-surface)),
|
||||
var(--color-surface)
|
||||
);
|
||||
}
|
||||
.hero h1 {
|
||||
margin: 0 0 6px;
|
||||
font-size: clamp(1.6rem, 3vw, 2rem);
|
||||
}
|
||||
.hero p,
|
||||
.subtitle {
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
.count {
|
||||
font-size: 0.85rem;
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-readable);
|
||||
}
|
||||
.heading {
|
||||
font-size: 1.15rem;
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
.subtitle {
|
||||
margin: -6px 0 18px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.grid li {
|
||||
min-width: 0;
|
||||
}
|
||||
.member {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
height: 100%;
|
||||
padding: 20px;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 16px;
|
||||
color: var(--color-text-readable);
|
||||
text-decoration: none;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
box-shadow 0.15s;
|
||||
}
|
||||
.member:hover {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 4px 16px
|
||||
color-mix(in srgb, var(--color-primary) 10%, transparent);
|
||||
}
|
||||
.member:focus-visible {
|
||||
outline: 3px solid var(--color-primary);
|
||||
outline-offset: 4px;
|
||||
}
|
||||
.avatar {
|
||||
flex-shrink: 0;
|
||||
image-rendering: pixelated;
|
||||
object-fit: contain;
|
||||
border-radius: 12px;
|
||||
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
|
||||
}
|
||||
.identity {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.identity h3,
|
||||
.team h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
.identity p,
|
||||
.team p {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-muted);
|
||||
white-space: pre-line;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.profile {
|
||||
display: inline-block;
|
||||
margin-top: 16px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.team {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
padding: 22px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.accent {
|
||||
display: block;
|
||||
width: 32px;
|
||||
height: 4px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.notice {
|
||||
padding: 24px;
|
||||
}
|
||||
.notice p {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
@media (max-width: 1000px) {
|
||||
.grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.hero {
|
||||
padding: 22px;
|
||||
}
|
||||
.member {
|
||||
padding: 18px;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.member {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,9 @@ import type { CSSProperties } from "react";
|
||||
import { postGuestbook } from "@/actions/guestbook";
|
||||
import { sendFriendRequest } from "@/actions/social";
|
||||
import Link from "@/components/link";
|
||||
import {
|
||||
ContentCard,
|
||||
EmptyState,
|
||||
OnlineBadge,
|
||||
StatBlock,
|
||||
} from "@/components/public/ui";
|
||||
import { ContentCard, EmptyState, OnlineBadge } from "@/components/public/ui";
|
||||
import { CurrencyIcon } from "@/components/shared/currency-icon";
|
||||
import { ProfileImage } from "@/components/shared/profile-image";
|
||||
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
|
||||
import { auth } from "@/lib/auth";
|
||||
import {
|
||||
@@ -23,10 +20,12 @@ import {
|
||||
User,
|
||||
UsersBadges,
|
||||
UsersCurrency,
|
||||
WebsiteBadges,
|
||||
WebsiteUserGuestbooks,
|
||||
} from "@/lib/db";
|
||||
import { avatarImageUrl } from "@/lib/format";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import styles from "./profile.module.css";
|
||||
|
||||
type SearchParams = Promise<{
|
||||
friend?: string;
|
||||
@@ -94,7 +93,6 @@ export default async function ProfilePage({
|
||||
username: User.username,
|
||||
motto: User.motto,
|
||||
look: User.look,
|
||||
rank: User.rank,
|
||||
credits: User.credits,
|
||||
online: User.online,
|
||||
accountCreated: User.accountCreated,
|
||||
@@ -261,32 +259,26 @@ export default async function ProfilePage({
|
||||
const wallet = [
|
||||
{
|
||||
key: "credits",
|
||||
icon: "💰",
|
||||
label: t("statCredits"),
|
||||
value: user.credits,
|
||||
color: "var(--color-primary-readable, var(--color-primary))",
|
||||
},
|
||||
{
|
||||
key: "duckets",
|
||||
icon: "🪙",
|
||||
label: t("statDuckets"),
|
||||
value: ducketsAmount,
|
||||
color: "var(--color-accent-readable, var(--color-accent))",
|
||||
},
|
||||
{
|
||||
key: "diamonds",
|
||||
icon: "💎",
|
||||
label: t("statDiamonds"),
|
||||
value: diamondsAmount,
|
||||
color: "var(--color-text-readable, var(--color-text))",
|
||||
},
|
||||
];
|
||||
] as const;
|
||||
|
||||
// Resolve friend usernames/looks and the guestbook author usernames/looks in
|
||||
// two parallel queries (the ids come from the queries above).
|
||||
// parallel queries (the ids come from the queries above).
|
||||
const friendIds = Array.from(new Set(friendships.map((f) => f.userTwoId)));
|
||||
const authorIds = Array.from(new Set(guestbook.map((g) => g.userId)));
|
||||
const [friendUsers, authors] = await Promise.all([
|
||||
const [friendUsers, authors, badgeDetails] = await Promise.all([
|
||||
friendIds.length
|
||||
? db
|
||||
.select({
|
||||
@@ -309,15 +301,28 @@ export default async function ProfilePage({
|
||||
.where(inArray(User.id, authorIds))
|
||||
.catch(() => [])
|
||||
: [],
|
||||
badges.length
|
||||
? db
|
||||
.select()
|
||||
.from(WebsiteBadges)
|
||||
.where(
|
||||
inArray(
|
||||
WebsiteBadges.badgeKey,
|
||||
badges.map((b) => b.badgeCode),
|
||||
),
|
||||
)
|
||||
.catch(() => [])
|
||||
: [],
|
||||
]);
|
||||
const friendById = new Map(friendUsers.map((f) => [f.id, f]));
|
||||
const friends = friendships
|
||||
.map((f) => friendById.get(f.userTwoId))
|
||||
.filter((f): f is NonNullable<typeof f> => Boolean(f));
|
||||
const authorById = new Map(authors.map((a) => [a.id, a]));
|
||||
const badgeByCode = new Map(badgeDetails.map((b) => [b.badgeKey, b]));
|
||||
|
||||
return (
|
||||
<main className="page-grid">
|
||||
<main className={`page-grid ${styles.profile}`}>
|
||||
{friend === "sent" ? (
|
||||
<div role="status" style={feedbackStyle("success")}>
|
||||
{t("success.sent")}
|
||||
@@ -335,7 +340,7 @@ export default async function ProfilePage({
|
||||
) : null}
|
||||
|
||||
{/* ── Profile header ──────────────────── */}
|
||||
<ContentCard padded={false}>
|
||||
<ContentCard padded={false} className={styles.hero}>
|
||||
<div
|
||||
className="content-card-body"
|
||||
style={{
|
||||
@@ -346,18 +351,24 @@ export default async function ProfilePage({
|
||||
}}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
<ProfileImage
|
||||
src={avatar}
|
||||
alt={`${user.username} avatar`}
|
||||
alt={user.username}
|
||||
width={100}
|
||||
height={150}
|
||||
className={styles.avatar}
|
||||
/>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div className={styles.identity}>
|
||||
<h1 style={{ margin: "0 0 0.25rem" }}>{user.username}</h1>
|
||||
<p style={{ margin: "0 0 0.6rem" }}>
|
||||
{user.motto || <span className="muted">{t("noMotto")}</span>}
|
||||
</p>
|
||||
<OnlineBadge online={user.online === "1"} />
|
||||
<div className={styles.meta}>
|
||||
<OnlineBadge online={user.online === "1"} />
|
||||
<span>
|
||||
{t("statRegistered")}: {registered}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{isLoggedIn && !isSelf ? (
|
||||
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
||||
@@ -385,330 +396,291 @@ export default async function ProfilePage({
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="stat-grid" style={{ padding: "0 1.1rem 1.1rem" }}>
|
||||
<StatBlock icon="🏅" value={user.rank} label={t("statRank")} />
|
||||
<StatBlock
|
||||
icon="💰"
|
||||
value={user.credits.toLocaleString()}
|
||||
label={t("statCredits")}
|
||||
/>
|
||||
<StatBlock icon="📅" value={registered} label={t("statRegistered")} />
|
||||
</div>
|
||||
</ContentCard>
|
||||
|
||||
{/* ── Wallet / currencies ─────────────── */}
|
||||
<ContentCard icon="💰" title={t("walletTitle")}>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(150px, 1fr))",
|
||||
gap: "0.75rem",
|
||||
}}
|
||||
>
|
||||
{wallet.map((w) => (
|
||||
<div
|
||||
key={w.key}
|
||||
className="card"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.75rem",
|
||||
borderLeft: `4px solid ${w.color}`,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
fontSize: "1.6rem",
|
||||
lineHeight: 1,
|
||||
display: "flex",
|
||||
width: "2.4rem",
|
||||
height: "2.4rem",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: "10px",
|
||||
background: `color-mix(in srgb, ${w.color} 16%, transparent)`,
|
||||
}}
|
||||
>
|
||||
{w.icon}
|
||||
</span>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "1.35rem",
|
||||
fontWeight: 700,
|
||||
color: w.color,
|
||||
}}
|
||||
>
|
||||
{w.value.toLocaleString()}
|
||||
</div>
|
||||
<div className="muted" style={{ fontSize: "0.85rem" }}>
|
||||
{w.label}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ContentCard>
|
||||
|
||||
{/* ── Friends ──────────────────────── */}
|
||||
<ContentCard
|
||||
icon="👥"
|
||||
title={t("friendsTitle")}
|
||||
subtitle={t("friendsSubtitle", { count: friends.length })}
|
||||
padded={friends.length === 0}
|
||||
>
|
||||
{friends.length === 0 ? (
|
||||
<EmptyState icon="👥">{t("friendsEmpty")}</EmptyState>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(72px, 1fr))",
|
||||
gap: "0.75rem",
|
||||
}}
|
||||
>
|
||||
{friends.map((f) => (
|
||||
<Link
|
||||
key={f.id}
|
||||
href={`/u/${encodeURIComponent(f.username)}`}
|
||||
className="card"
|
||||
title={f.username}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: "0.35rem",
|
||||
padding: "0.6rem 0.4rem",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<UserAvatarThumbnail figure={f.look} alt={f.username} />
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.8rem",
|
||||
fontWeight: 600,
|
||||
maxWidth: "100%",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{f.username}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ContentCard>
|
||||
|
||||
{/* ── Rooms ────────────────────────── */}
|
||||
<ContentCard
|
||||
icon="🏠"
|
||||
title={t("roomsTitle")}
|
||||
subtitle={t("roomsSubtitle", { username: user.username })}
|
||||
padded={rooms.length === 0}
|
||||
>
|
||||
{rooms.length === 0 ? (
|
||||
<EmptyState icon="🏠">{t("roomsEmpty")}</EmptyState>
|
||||
) : (
|
||||
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
|
||||
{rooms.map((r) => (
|
||||
<div
|
||||
key={r.id}
|
||||
className="card"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.4rem",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<span aria-hidden style={{ fontSize: "1.2rem" }}>
|
||||
{"🏠"}
|
||||
</span>
|
||||
<strong
|
||||
style={{
|
||||
minWidth: 0,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{r.name || t("roomUntitled")}
|
||||
</strong>
|
||||
</div>
|
||||
<div
|
||||
className="muted"
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
fontSize: "0.85rem",
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
{"👤"} {r.users}/{r.usersMax}
|
||||
</span>
|
||||
<span style={{ textTransform: "capitalize" }}>{r.state}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ContentCard>
|
||||
|
||||
{/* ── Badges ────────────────────── */}
|
||||
<ContentCard
|
||||
icon="🏅"
|
||||
title={t("badgesTitle")}
|
||||
padded={badges.length === 0}
|
||||
>
|
||||
{badges.length === 0 ? (
|
||||
<EmptyState icon="🏅">{t("badgesEmpty")}</EmptyState>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: "0.75rem",
|
||||
alignItems: "center",
|
||||
padding: "1rem",
|
||||
}}
|
||||
>
|
||||
{badges.map((b) => (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
key={b.id}
|
||||
src={`${BADGE_IMG_BASE}/${b.badgeCode}.gif`}
|
||||
alt={b.badgeCode}
|
||||
title={b.badgeCode}
|
||||
width={40}
|
||||
height={40}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ContentCard>
|
||||
|
||||
{/* ── Photos ────────────────────── */}
|
||||
<ContentCard
|
||||
icon="📸"
|
||||
title={t("photosTitle")}
|
||||
padded={photos.length === 0}
|
||||
>
|
||||
{photos.length === 0 ? (
|
||||
<EmptyState icon="📸">{t("photosEmpty")}</EmptyState>
|
||||
) : (
|
||||
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
|
||||
{photos.map((p) => (
|
||||
<div key={p.id} className="card" style={{ padding: "0.5rem" }}>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={p.url}
|
||||
alt={`by ${user.username}`}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "auto",
|
||||
display: "block",
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
/>
|
||||
<p className="muted" style={{ margin: "0.4rem 0 0" }}>
|
||||
{formatDate(new Date(p.timestamp * 1000), "date")}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ContentCard>
|
||||
|
||||
{/* ── Guestbook ───────────────────── */}
|
||||
<ContentCard
|
||||
icon="📝"
|
||||
title={t("guestbookTitle")}
|
||||
subtitle={t("guestbookSubtitle", { username: user.username })}
|
||||
>
|
||||
{isLoggedIn ? (
|
||||
<form
|
||||
action={postGuestbook}
|
||||
className="card"
|
||||
style={{ marginBottom: "1rem" }}
|
||||
>
|
||||
{/* Profile owner id drives the write target; the author is taken from
|
||||
the session inside the action, never from this form. */}
|
||||
<input type="hidden" name="profileId" value={String(user.id)} />
|
||||
<input type="hidden" name="username" value={user.username} />
|
||||
<label htmlFor="gb-message" className="muted">
|
||||
{t("leaveMessage")}
|
||||
</label>
|
||||
<textarea
|
||||
id="gb-message"
|
||||
name="message"
|
||||
required
|
||||
maxLength={255}
|
||||
rows={3}
|
||||
placeholder={t("guestbookPlaceholder", {
|
||||
username: user.username,
|
||||
})}
|
||||
style={{
|
||||
width: "100%",
|
||||
margin: "0.4rem 0 0.75rem",
|
||||
resize: "vertical",
|
||||
}}
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{t("post")}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<p className="muted">{t("loginToPost")}</p>
|
||||
)}
|
||||
|
||||
{guestbook.length === 0 ? (
|
||||
<EmptyState icon="📝">{t("guestbookEmpty")}</EmptyState>
|
||||
) : (
|
||||
<div className="grid" style={{ gap: "0.75rem" }}>
|
||||
{guestbook.map((g) => {
|
||||
const author = authorById.get(g.userId);
|
||||
return (
|
||||
<div
|
||||
key={String(g.id)}
|
||||
className="card"
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "0.75rem",
|
||||
alignItems: "flex-start",
|
||||
}}
|
||||
>
|
||||
{author ? (
|
||||
<UserAvatarThumbnail
|
||||
figure={author.look}
|
||||
alt={author?.username ?? "author"}
|
||||
/>
|
||||
) : null}
|
||||
<div style={{ flex: 1 }}>
|
||||
<p style={{ margin: "0 0 0.25rem" }}>
|
||||
<strong>
|
||||
{author?.username ?? t("unknownUser", { id: g.userId })}
|
||||
</strong>{" "}
|
||||
<span className="muted">
|
||||
{formatDate(g.createdAt, "date", "")}
|
||||
</span>
|
||||
</p>
|
||||
<p style={{ margin: 0 }}>{g.message}</p>
|
||||
<div className={styles.columns}>
|
||||
<aside className={styles.sidebar}>
|
||||
{/* Wallet */}
|
||||
<ContentCard title={t("walletTitle")}>
|
||||
<div className={styles.wallet}>
|
||||
{wallet.map((w) => (
|
||||
<div key={w.key} className={styles.currency}>
|
||||
<CurrencyIcon kind={w.key} size={28} alt="" />
|
||||
<div>
|
||||
<strong>{w.value.toLocaleString()}</strong>
|
||||
<span className="muted">{w.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ContentCard>
|
||||
))}
|
||||
</div>
|
||||
</ContentCard>
|
||||
|
||||
{/* ── Friends ──────────────────────── */}
|
||||
<ContentCard
|
||||
icon="👥"
|
||||
title={t("friendsTitle")}
|
||||
subtitle={t("friendsSubtitle", { count: friends.length })}
|
||||
padded={true}
|
||||
>
|
||||
{friends.length === 0 ? (
|
||||
<EmptyState icon="👥">{t("friendsEmpty")}</EmptyState>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(72px, 1fr))",
|
||||
gap: "0.75rem",
|
||||
}}
|
||||
>
|
||||
{friends.map((f) => (
|
||||
<Link
|
||||
key={f.id}
|
||||
href={`/u/${encodeURIComponent(f.username)}`}
|
||||
className="card"
|
||||
title={f.username}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: "0.35rem",
|
||||
padding: "0.6rem 0.4rem",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<UserAvatarThumbnail figure={f.look} alt={f.username} />
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.8rem",
|
||||
fontWeight: 600,
|
||||
maxWidth: "100%",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{f.username}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ContentCard>
|
||||
|
||||
{/* Badges */}
|
||||
<ContentCard icon="🏅" title={t("badgesTitle")}>
|
||||
{badges.length === 0 ? (
|
||||
<EmptyState icon="🏅">{t("badgesEmpty")}</EmptyState>
|
||||
) : (
|
||||
<div className={styles.badges}>
|
||||
{badges.map((b) => {
|
||||
const details = badgeByCode.get(b.badgeCode);
|
||||
const name = details?.badgeName || b.badgeCode;
|
||||
return (
|
||||
<details key={b.id} className={styles.badge}>
|
||||
<summary title={name}>
|
||||
<img
|
||||
src={
|
||||
BADGE_IMG_BASE +
|
||||
"/" +
|
||||
encodeURIComponent(b.badgeCode) +
|
||||
".gif"
|
||||
}
|
||||
alt={name}
|
||||
width={40}
|
||||
height={40}
|
||||
loading="lazy"
|
||||
/>
|
||||
</summary>
|
||||
<div className={styles.badgeInfo}>
|
||||
<strong>{name}</strong>
|
||||
{details?.badgeDescription && (
|
||||
<p>{details.badgeDescription}</p>
|
||||
)}
|
||||
<small>{b.badgeCode}</small>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ContentCard>
|
||||
</aside>
|
||||
<div className={styles.content}>
|
||||
{/* Rooms */}
|
||||
<ContentCard
|
||||
icon="🏠"
|
||||
title={t("roomsTitle")}
|
||||
subtitle={t("roomsSubtitle", { username: user.username })}
|
||||
>
|
||||
{rooms.length === 0 ? (
|
||||
<EmptyState icon="🏠">{t("roomsEmpty")}</EmptyState>
|
||||
) : (
|
||||
<div className={styles.rooms}>
|
||||
{rooms.map((r) => {
|
||||
const photo = photos.find((photo) => photo.roomId === r.id);
|
||||
return (
|
||||
<Link
|
||||
href={`/room/${r.id}`}
|
||||
key={r.id}
|
||||
className={styles.room}
|
||||
>
|
||||
<ProfileImage
|
||||
src={
|
||||
photo?.url ||
|
||||
"/assets/images/profile/room_placeholder.png"
|
||||
}
|
||||
fallback="/assets/images/profile/room_placeholder.png"
|
||||
alt=""
|
||||
width={320}
|
||||
height={140}
|
||||
className={styles.roomImage}
|
||||
/>
|
||||
<div className={styles.roomBody}>
|
||||
<strong>{r.name || t("roomUntitled")}</strong>
|
||||
{!photo && (
|
||||
<span className="muted">{t("noRoomPreview")}</span>
|
||||
)}
|
||||
<div className={styles.roomMeta}>
|
||||
<span>
|
||||
{r.users}/{r.usersMax}
|
||||
</span>
|
||||
<span>
|
||||
{t.has(`roomStates.${r.state}`)
|
||||
? t(`roomStates.${r.state}`)
|
||||
: r.state}
|
||||
</span>
|
||||
</div>
|
||||
<span className={styles.roomLink}>
|
||||
{t("viewRoom")} →
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ContentCard>
|
||||
|
||||
{/* ── Photos ────────────────────── */}
|
||||
<ContentCard
|
||||
icon="📸"
|
||||
title={t("photosTitle")}
|
||||
padded={photos.length === 0}
|
||||
>
|
||||
{photos.length === 0 ? (
|
||||
<EmptyState icon="📸">{t("photosEmpty")}</EmptyState>
|
||||
) : (
|
||||
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
|
||||
{photos.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="card"
|
||||
style={{ padding: "0.5rem" }}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={p.url}
|
||||
alt={`by ${user.username}`}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "auto",
|
||||
display: "block",
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
/>
|
||||
<p className="muted" style={{ margin: "0.4rem 0 0" }}>
|
||||
{formatDate(new Date(p.timestamp * 1000), "date")}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ContentCard>
|
||||
|
||||
{/* ── Guestbook ───────────────────── */}
|
||||
<ContentCard
|
||||
icon="📝"
|
||||
title={t("guestbookTitle")}
|
||||
subtitle={t("guestbookSubtitle", { username: user.username })}
|
||||
>
|
||||
{isLoggedIn ? (
|
||||
<form
|
||||
action={postGuestbook}
|
||||
className="card"
|
||||
style={{ marginBottom: "1rem" }}
|
||||
>
|
||||
{/* Profile owner id drives the write target; the author is taken from
|
||||
the session inside the action, never from this form. */}
|
||||
<input type="hidden" name="profileId" value={String(user.id)} />
|
||||
<input type="hidden" name="username" value={user.username} />
|
||||
<label htmlFor="gb-message" className="muted">
|
||||
{t("leaveMessage")}
|
||||
</label>
|
||||
<textarea
|
||||
id="gb-message"
|
||||
name="message"
|
||||
required
|
||||
maxLength={255}
|
||||
rows={3}
|
||||
placeholder={t("guestbookPlaceholder", {
|
||||
username: user.username,
|
||||
})}
|
||||
style={{
|
||||
width: "100%",
|
||||
margin: "0.4rem 0 0.75rem",
|
||||
resize: "vertical",
|
||||
}}
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{t("post")}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<p className="muted">{t("loginToPost")}</p>
|
||||
)}
|
||||
|
||||
{guestbook.length === 0 ? (
|
||||
<EmptyState icon="📝">{t("guestbookEmpty")}</EmptyState>
|
||||
) : (
|
||||
<div className="grid" style={{ gap: "0.75rem" }}>
|
||||
{guestbook.map((g) => {
|
||||
const author = authorById.get(g.userId);
|
||||
return (
|
||||
<div
|
||||
key={String(g.id)}
|
||||
className="card"
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "0.75rem",
|
||||
alignItems: "flex-start",
|
||||
}}
|
||||
>
|
||||
{author ? (
|
||||
<UserAvatarThumbnail
|
||||
figure={author.look}
|
||||
alt={author?.username ?? "author"}
|
||||
/>
|
||||
) : null}
|
||||
<div style={{ flex: 1 }}>
|
||||
<p style={{ margin: "0 0 0.25rem" }}>
|
||||
<strong>
|
||||
{author?.username ??
|
||||
t("unknownUser", { id: g.userId })}
|
||||
</strong>{" "}
|
||||
<span className="muted">
|
||||
{formatDate(g.createdAt, "date", "")}
|
||||
</span>
|
||||
</p>
|
||||
<p style={{ margin: 0 }}>{g.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ContentCard>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
.profile {
|
||||
min-width: 0;
|
||||
}
|
||||
.hero {
|
||||
overflow: hidden;
|
||||
border-top: 4px solid var(--color-primary);
|
||||
background: linear-gradient(
|
||||
120deg,
|
||||
color-mix(in srgb, var(--color-primary) 9%, var(--color-surface)),
|
||||
var(--color-surface)
|
||||
);
|
||||
}
|
||||
.avatar {
|
||||
object-fit: contain;
|
||||
image-rendering: pixelated;
|
||||
border-radius: 20px;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--color-primary) 12%,
|
||||
var(--color-surface)
|
||||
);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.identity {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.columns {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 340px) minmax(0, 1fr);
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
.sidebar,
|
||||
.content {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
min-width: 0;
|
||||
}
|
||||
.wallet {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
.currency {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
}
|
||||
.currency > div {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
.currency strong {
|
||||
font-size: 1.25rem;
|
||||
overflow-wrap: anywhere;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.currency span {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.profile [class~="empty-state"] {
|
||||
padding: 18px 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
.profile [class~="empty-state-icon"] {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
.rooms {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 230px), 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.room {
|
||||
display: block;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
.room:hover,
|
||||
.room:focus-visible {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.roomImage {
|
||||
width: 100%;
|
||||
height: 140px;
|
||||
object-fit: contain;
|
||||
background: color-mix(in srgb, var(--color-primary) 6%, var(--color-surface));
|
||||
}
|
||||
.roomBody {
|
||||
padding: 14px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.roomBody > [class~="muted"],
|
||||
.roomMeta {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.roomMeta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.roomLink {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-primary-readable, var(--color-primary));
|
||||
}
|
||||
.badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: start;
|
||||
}
|
||||
.badge {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.badge summary {
|
||||
padding: 6px;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
.badge summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
.badge img {
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
.badge[open] {
|
||||
width: 100%;
|
||||
}
|
||||
.badgeInfo {
|
||||
padding: 0 12px 12px;
|
||||
font-size: 0.85rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.badgeInfo p {
|
||||
margin: 6px 0;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.columns {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
.sidebar,
|
||||
.content {
|
||||
gap: 16px;
|
||||
}
|
||||
.avatar {
|
||||
width: 80px;
|
||||
height: 120px;
|
||||
}
|
||||
}
|
||||
Loaded 100 of 502 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user