Merge remote-tracking branch 'origin/main' into codex/housekeeping-rebuild-stepwise
This commit is contained in:
commit
ce4a3ba32c
129 files changed
+4364
-1599
No files matched your search
+6
-2
@@ -7,8 +7,12 @@ storage
|
||||
prod.log
|
||||
update.log
|
||||
.pm2
|
||||
.env
|
||||
# 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
|
||||
+157
-516
@@ -1,547 +1,188 @@
|
||||
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
|
||||
run: |
|
||||
set -e
|
||||
WORK="$(mktemp -d /var/tmp/epicnext-ci.XXXXXX)"
|
||||
cleanup() { rm -rf "${WORK}"; }
|
||||
trap cleanup EXIT
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: ${{ gitea.repository }}
|
||||
token: ${{ gitea.token }}
|
||||
|
||||
echo "--- CI checks (${WORK}) ---"
|
||||
git clone --depth 50 \
|
||||
/docker/gitea/gitea/git/repositories/remco/epicnext-cms.git \
|
||||
"${WORK}"
|
||||
cd "${WORK}"
|
||||
- name: Toolchain check
|
||||
run: node scripts/check-node-toolchain.mjs
|
||||
|
||||
REF="${{ gitea.sha }}"
|
||||
if [ -z "${REF}" ]; then
|
||||
echo "ERROR: missing gitea.sha" >&2
|
||||
exit 1
|
||||
fi
|
||||
git fetch --depth 50 origin "${REF}"
|
||||
git checkout -f "${REF}"
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
node scripts/check-node-toolchain.mjs
|
||||
- name: Lint
|
||||
run: pnpm biome:lint
|
||||
|
||||
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: 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: pnpm test --maxWorkers=1
|
||||
|
||||
- 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
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: ${{ gitea.repository }}
|
||||
token: ${{ gitea.token }}
|
||||
|
||||
- name: Provide production env for build
|
||||
run: |
|
||||
set -e
|
||||
# De Next.js-build bakt NEXT_PUBLIC_* in de client-bundle en
|
||||
# valideert DATABASE_URL/HOTEL_NAME (zie src/env.ts — validatie
|
||||
# overslaan is verboden voor productie). .env staat niet in git,
|
||||
# dus kopieer de productie-.env van de host in de build-context.
|
||||
# Hij belandt alleen in de wegwerp-builder-stage, niet in de
|
||||
# runtime-image (die krijgt env via -e flags bij docker run).
|
||||
cp /var/www/atom-nexst/.env .env
|
||||
|
||||
exec 9>/var/tmp/epic_web_control_deploy.lock
|
||||
flock -n 9 || { echo "ERROR: Another deployment is already running! Cancelling."; exit 1; }
|
||||
- name: Build image
|
||||
run: |
|
||||
# --network=host is vereist: deze host heeft Docker iptables
|
||||
# uitgeschakeld, dus build-containers op het bridge-netwerk
|
||||
# hebben geen outbound internet (npm/pnpm zouden hangen).
|
||||
DOCKER_BUILDKIT=1 docker build \
|
||||
--network=host \
|
||||
--build-arg NODE_OPTIONS="--max-old-space-size=1536" \
|
||||
--cache-from epicnext-cms:latest \
|
||||
-t epicnext-cms:latest .
|
||||
|
||||
echo "--- Deploying ---"
|
||||
- name: Run migrations
|
||||
run: |
|
||||
# Draai DB-migraties van deze commit op de host (de slimme
|
||||
# runtime-image heeft geen source/tsx, zie docker-compose.yml).
|
||||
# Idempotent: al toegepaste migraties worden overgeslagen.
|
||||
# Gebruikt .env uit de eerdere stap (productie-DB). Vóór het
|
||||
# vervangen van de container, zodat het schema klaarstaat.
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm db:migrate
|
||||
|
||||
LIVE="/var/www/atom-nexst"
|
||||
STAGE=""
|
||||
CUTOVER_STARTED=0
|
||||
- name: Deploy container
|
||||
shell: bash
|
||||
run: |
|
||||
# Maak poort 3002 vrij: stop zowel de vorige CI-container als de
|
||||
# compose-container (beide draaien op het host-netwerk).
|
||||
docker stop epicnext-cms-app 2>/dev/null || true
|
||||
docker rm epicnext-cms-app 2>/dev/null || true
|
||||
docker stop epicnext-cms 2>/dev/null || true
|
||||
docker rm epicnext-cms 2>/dev/null || true
|
||||
|
||||
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
|
||||
# Geef de productie-env 1-op-1 door. GEEN env-file-flag: `docker run`
|
||||
# behoudt letterlijke quotes uit het bestand (DATABASE_URL="..." →
|
||||
# ongeldige URL en crash), terwijl de shell ze correct stript.
|
||||
# Sourcen + elke sleutel met -e doorgeven geeft de container exact
|
||||
# dezelfde waarden als waarmee de image gebouwd is.
|
||||
set -a
|
||||
# shellcheck disable=SC1091
|
||||
. /var/www/atom-nexst/.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 < /var/www/atom-nexst/.env
|
||||
|
||||
# Zelfde env + volumes als docker-compose.yml, zodat de CI-container
|
||||
# functioneel gelijk is aan de compose-container die hij vervangt.
|
||||
docker run -d \
|
||||
--name epicnext-cms-app \
|
||||
--restart always \
|
||||
--net=host \
|
||||
"${ENV_ARGS[@]}" \
|
||||
-v /var/www/atom-nexst/public/nitro-assets:/app/public/nitro-assets \
|
||||
-v /var/www/atom-nexst/public/swf:/app/public/swf \
|
||||
-v /var/www/atom-nexst/storage:/app/storage \
|
||||
-v /var/www/Gamedata:/var/www/Gamedata \
|
||||
epicnext-cms:latest
|
||||
|
||||
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
|
||||
- name: Health check
|
||||
run: |
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf --max-time 5 http://127.0.0.1:3002/api/health \
|
||||
| grep -q '"database":true'; then
|
||||
echo "Deploy OK"
|
||||
exit 0
|
||||
fi
|
||||
echo "migrate attempt ${i}/10 failed (likely DB connections), retrying..."
|
||||
sleep 5
|
||||
echo "Waiting... ($i/30)"
|
||||
sleep 3
|
||||
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
|
||||
echo "ERROR: Health check failed" >&2
|
||||
docker logs epicnext-cms-app --tail 50 >&2 || true
|
||||
echo "Rolling back to compose container..." >&2
|
||||
docker stop epicnext-cms-app 2>/dev/null || true
|
||||
docker rm epicnext-cms-app 2>/dev/null || true
|
||||
docker compose -f /var/www/atom-nexst/docker-compose.yml up -d --no-build 2>&1 || true
|
||||
exit 1
|
||||
|
||||
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
|
||||
# ─────────────────────────────────────────────
|
||||
# E2E smoke against the freshly deployed container.
|
||||
# Runs after a successful deploy on main/master only
|
||||
# (on PRs the deploy job is skipped, so this is skipped too).
|
||||
# Public read-only endpoints only — safe against production.
|
||||
# ─────────────────────────────────────────────
|
||||
e2e:
|
||||
needs: deploy
|
||||
if: gitea.event_name == 'push' && (gitea.ref_name == 'main' || gitea.ref_name == 'master')
|
||||
runs-on: self-hosted
|
||||
steps:
|
||||
- name: Build and deploy
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: ${{ gitea.repository }}
|
||||
token: ${{ gitea.token }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Install Playwright browser
|
||||
run: pnpm exec playwright install chromium
|
||||
|
||||
- name: Smoke test deployed app
|
||||
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
|
||||
PLAYWRIGHT_BASE_URL: "http://127.0.0.1:3002"
|
||||
run: pnpm test:e2e
|
||||
@@ -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,9 @@ public/tmp/
|
||||
|
||||
# Test coverage reports
|
||||
coverage/
|
||||
|
||||
# Playwright test artifacts
|
||||
test-results/
|
||||
playwright-report/
|
||||
blob-report/
|
||||
.aider*
|
||||
+71
-24
@@ -1,63 +1,110 @@
|
||||
# ==============================================================================
|
||||
# EpicNext-CMS — Docker image (Node 26.8.1, pnpm 11.24.0, Next.js standalone)
|
||||
# EpicNext-CMS — Docker image (Node 26.8.1, multi-package-manager, Next.js standalone)
|
||||
# ==============================================================================
|
||||
# Supports pnpm (default), npm, and yarn. The build stage detects which package
|
||||
# manager lockfile is present and uses it automatically.
|
||||
# ==============================================================================
|
||||
|
||||
# --- 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]
|
||||
# git is needed by next.config.ts (git rev-parse for deploymentId) and
|
||||
# ca-certificates by package registries. Build runs on host network (see
|
||||
# compose: iptables is disabled), so apt has registry access here.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install all three package managers so the build can pick whichever lockfile exists.
|
||||
RUN npm install -g [email protected] yarn
|
||||
|
||||
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
|
||||
# First copy only the manifests so dependency layers are cached when using pnpm.
|
||||
# For npm/yarn the full context is copied below before install.
|
||||
COPY package.json pnpm-workspace.yaml .npmrc ./
|
||||
|
||||
# Copy the rest of the source.
|
||||
# Copy the rest of the source (brings in whichever lockfile your project uses).
|
||||
COPY . .
|
||||
|
||||
# Build the production bundle.
|
||||
ENV NODE_ENV=production
|
||||
RUN pnpm build
|
||||
# --- Detect package manager & install dependencies ---
|
||||
# Priority: pnpm > yarn > npm
|
||||
# Build arg lets the user force a manager; otherwise it is auto-detected.
|
||||
ARG PACKAGE_MANAGER=
|
||||
|
||||
# Copy runtime dependencies (node_modules) needed by standalone output.
|
||||
RUN pnpm prune --prod
|
||||
RUN if [ "$PACKAGE_MANAGER" = "pnpm" ] || { [ -z "$PACKAGE_MANAGER" ] && [ -f pnpm-lock.yaml ]; }; then \
|
||||
echo ">> Using pnpm" && \
|
||||
pnpm install --frozen-lockfile --ignore-scripts; \
|
||||
elif [ "$PACKAGE_MANAGER" = "yarn" ] || { [ -z "$PACKAGE_MANAGER" ] && [ -f yarn.lock ]; }; then \
|
||||
echo ">> Using yarn" && \
|
||||
yarn install --frozen-lockfile --ignore-scripts; \
|
||||
elif [ "$PACKAGE_MANAGER" = "npm" ] || { [ -z "$PACKAGE_MANAGER" ] && [ -f package-lock.json ]; }; then \
|
||||
echo ">> Using npm" && \
|
||||
npm ci --ignore-scripts; \
|
||||
else \
|
||||
echo "!! No lockfile found — falling back to npm install" && \
|
||||
npm install --ignore-scripts; \
|
||||
fi
|
||||
|
||||
# Build the production bundle.
|
||||
# The .env file is loaded ONLY inside this RUN layer (not persisted as ENV, so no
|
||||
# secrets end up in the image) — Next.js needs NEXT_PUBLIC_* + validated build-time
|
||||
# values (HOTEL_NAME, DATABASE_URL, AUTH_SECRET, ...) at build time.
|
||||
ENV NODE_ENV=production
|
||||
RUN if [ -f .env ]; then set -a && . ./.env && set +a; fi && \
|
||||
if [ "$PACKAGE_MANAGER" = "yarn" ] || { [ -z "$PACKAGE_MANAGER" ] && [ -f yarn.lock ]; }; then \
|
||||
yarn build; \
|
||||
elif [ "$PACKAGE_MANAGER" = "npm" ] || { [ -z "$PACKAGE_MANAGER" ] && [ -f package-lock.json ]; }; then \
|
||||
npm run build; \
|
||||
else \
|
||||
pnpm build; \
|
||||
fi
|
||||
|
||||
# Prune dev dependencies for the runtime image.
|
||||
RUN if [ "$PACKAGE_MANAGER" = "yarn" ] || { [ -z "$PACKAGE_MANAGER" ] && [ -f yarn.lock ]; }; then \
|
||||
yarn install --production --ignore-scripts && rm -rf node_modules/.cache; \
|
||||
elif [ "$PACKAGE_MANAGER" = "npm" ] || { [ -z "$PACKAGE_MANAGER" ] && [ -f package-lock.json ]; }; then \
|
||||
npm prune --production; \
|
||||
else \
|
||||
pnpm prune --prod; \
|
||||
fi
|
||||
|
||||
# --- Runtime stage ---
|
||||
FROM node:26.8.1-bookworm-slim AS runner
|
||||
|
||||
# The CMS writes to bind-mounted host directories (/var/www/Gamedata is owned by
|
||||
# the host's www-data user, UID/GID 33). The node base image already ships a
|
||||
# www-data user with UID/GID 33, which matches that ownership — so we run as
|
||||
# www-data and can write to the shared gamedata directory. If your host owner
|
||||
# differs, override via --build-arg RUN_USER (e.g. --build-arg RUN_USER=1000).
|
||||
ARG RUN_USER=www-data
|
||||
|
||||
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
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Storage directory for runtime uploaded media (persistent volume).
|
||||
# nitro/swf client assets (~3GB) are mounted here as volumes at runtime.
|
||||
# Also create the hardcoded gamedata mount point (/var/www/Gamedata is
|
||||
# bind-mounted at runtime so the CMS can read + write imported assets there).
|
||||
RUN mkdir -p /app/storage \
|
||||
/app/public/nitro-assets \
|
||||
/app/public/swf \
|
||||
&& chown -R nextjs:nodejs /app
|
||||
/var/www/Gamedata \
|
||||
&& chown -R ${RUN_USER} /app /var/www/Gamedata
|
||||
|
||||
# Copy standalone Next.js output (includes a minimal node_modules).
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=${RUN_USER} /app/.next/standalone ./
|
||||
# Copy static assets (public files served directly).
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||
COPY --from=builder --chown=${RUN_USER} /app/public ./public
|
||||
# Copy the server-side static build output.
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
COPY --from=builder --chown=${RUN_USER} /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"]
|
||||
|
||||
USER nextjs
|
||||
USER ${RUN_USER}
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
@@ -10,12 +10,13 @@ Features a premium animated homepage (typewriter hero, floating orbs, scroll cou
|
||||
|
||||
| Component | Version | Notes |
|
||||
| --------------- | -------------- | ---------------------------------------- |
|
||||
| Node.js | 26.7.0 | Current release pinned in `.nvmrc` |
|
||||
| pnpm | >= 10.33.4 | Package manager (npm/yarn not supported) |
|
||||
| Node.js | 26.8.1 | Current release pinned in `.nvmrc` |
|
||||
| pnpm | >= 11.25.0 | Recommended package manager |
|
||||
| npm | >= 11.x | Supported alternative |
|
||||
| yarn | >= 4.x | Supported alternative |
|
||||
| MySQL / MariaDB | 8.0+ / 10.6+ | Shared with the emulator |
|
||||
| DragonflyDB | 1.x+ | Optional — caching, rate limiting, SSE (Redis-protocol compatible) |
|
||||
| Java | 17+ | Required only if building the emulator |
|
||||
| Maven | 3.9+ | Required only if building the emulator |
|
||||
| Docker | 24+ | Optional — for containerized deployment |
|
||||
| DragonflyDB | 1.x+ | Optional — caching, rate limiting, SSE |
|
||||
|
||||
---
|
||||
|
||||
@@ -23,12 +24,28 @@ Features a premium animated homepage (typewriter hero, floating orbs, scroll cou
|
||||
|
||||
### 1. Clone & Install
|
||||
|
||||
Choose your preferred package manager:
|
||||
|
||||
```bash
|
||||
git clone https://gitlab.epicnabbo.nl/remco/EpicNext-Cms.git
|
||||
cd EpicNext-Cms
|
||||
```
|
||||
|
||||
**pnpm (recommended):**
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
**npm:**
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
**yarn:**
|
||||
```bash
|
||||
yarn install
|
||||
```
|
||||
|
||||
### 2. Database Setup
|
||||
|
||||
The CMS shares a database with the Polaris/Arcturus emulator. Use an existing database or create a new one:
|
||||
@@ -39,7 +56,7 @@ CREATE DATABASE IF NOT EXISTS epicnext_cms CHARACTER SET utf8mb4 COLLATE utf8mb4
|
||||
|
||||
The CMS reads emulator-owned tables (`users`, `items`, `rooms`, `bans`, etc.) directly. It never creates, alters, or drops them. The Drizzle schema in `src/db/schema.ts` is generated from the existing database structure and does not modify it.
|
||||
|
||||
> **Note:** The CMS does **not** own the emulator schema — it maps to those tables via Drizzle. Never run `drizzle-kit push` / `migrate` against the shared DB. CMS-owned tables (`website_*`, `radio_*`, etc.) are created via idempotent SQL in `drizzle/migrations/` (`pnpm db:migrate`).
|
||||
> **Note:** The CMS does **not** own the emulator schema — it maps to those tables via Drizzle. Never run `drizzle-kit push` / `migrate` against the shared DB. CMS-owned tables (`website_*`, `radio_*`, etc.) are created via idempotent SQL in `drizzle/migrations/`.
|
||||
|
||||
### 3. Configure Environment
|
||||
|
||||
@@ -53,14 +70,200 @@ Edit `.env` with at minimum:
|
||||
DATABASE_URL=mysql://user:[email protected]:3306/epicnext_cms
|
||||
AUTH_SECRET=<random 32+ character string>
|
||||
HOTEL_NAME=YourHotel
|
||||
APP_URL=http://localhost:3000
|
||||
APP_URL=http://localhost:3002
|
||||
```
|
||||
|
||||
See `.env.example` for all optional variables (RCON, email, DragonflyDB, OAuth, PayPal, etc.).
|
||||
|
||||
### 4. ORM Setup & Type Generation
|
||||
### 4. Run CMS Migrations
|
||||
|
||||
#### Drizzle ORM (Primary Data Layer)
|
||||
```bash
|
||||
# pnpm
|
||||
pnpm db:migrate
|
||||
|
||||
# npm
|
||||
npm run db:migrate
|
||||
|
||||
# yarn
|
||||
yarn db:migrate
|
||||
```
|
||||
|
||||
Creates all CMS-owned tables (`website_*`, `radio_*`, `acl_*`, `admin_audit_log`, etc.) via idempotent SQL files in `drizzle/migrations/`. Emulator tables are never touched.
|
||||
|
||||
### 5. Build & Start
|
||||
|
||||
```bash
|
||||
# Development (hot reload)
|
||||
pnpm dev # or: npm run dev / yarn dev
|
||||
|
||||
# Production
|
||||
pnpm build && pnpm start # or: npm run build && npm start
|
||||
```
|
||||
|
||||
Open `http://localhost:3002` in your browser.
|
||||
|
||||
### 6. First Login
|
||||
|
||||
1. Register an account at `/register`, or log in with an existing emulator account.
|
||||
2. Grant yourself admin access: `UPDATE users SET rank = 7 WHERE username = 'yourname';`
|
||||
3. Visit `/admin` and configure your hotel via **Admin → CMS Settings**.
|
||||
|
||||
---
|
||||
|
||||
## Docker Deployment
|
||||
|
||||
The CMS ships with a multi-stage Dockerfile that automatically detects your package manager (pnpm, npm, or yarn) based on which lockfile is present. Only the **CMS** runs in Docker; the Nitro/Octane client and its renderer stay outside and are served by nginx on the host.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Docker 24+ and Docker Compose v2
|
||||
- `.env` file configured (see step 3 above)
|
||||
- A MySQL/MariaDB database reachable from the container (`DATABASE_URL` host should point to the DB server, not `127.0.0.1` unless it's reachable from inside the container)
|
||||
- (Optional) A shared `Gamedata` directory at `/var/www/Gamedata` with write access (see [Volumes](#volumes) below)
|
||||
|
||||
### Quick Start with Docker
|
||||
|
||||
```bash
|
||||
# 1. Clone and configure
|
||||
git clone https://gitlab.epicnabbo.nl/remco/EpicNext-Cms.git
|
||||
cd EpicNext-Cms
|
||||
cp .env.example .env
|
||||
# Edit .env with your database credentials and settings.
|
||||
# The container runs on the host network, so all 127.0.0.1 references
|
||||
# (DATABASE_URL, REDIS_URL, RCON, imaging) keep pointing at the host as-is.
|
||||
|
||||
# 2. Stop any existing host-side CMS that occupies port 3002 (if present).
|
||||
pm2 stop next 2>/dev/null || true
|
||||
|
||||
# 3. Ensure the write directories are owned by www-data (UID/GID 33) so the
|
||||
# container user can write imports/uploads to them.
|
||||
sudo chown -R 33:33 ./public/nitro-assets ./public/swf ./storage
|
||||
|
||||
# 4. Build and start
|
||||
docker compose up -d --build
|
||||
|
||||
# 5. Run migrations. The slim runtime image has no source/tsx, so run migrations
|
||||
# on the HOST (against the same database) before/after starting the container.
|
||||
pnpm db:migrate # or: npm run db:migrate / yarn db:migrate
|
||||
|
||||
# 6. Check logs
|
||||
docker compose logs -f cms
|
||||
```
|
||||
|
||||
The CMS will be available at `http://localhost:3002`.
|
||||
|
||||
> **Reverse proxy:** This setup is designed to run behind the existing nginx on the host. nginx serves the Nitro/Octane client (`/client/`, `/nitro-client/`) and `/gamedata/` directly from `/var/www/Octane/dist` and `/var/www/Gamedata`, and proxies `/` to the CMS on `127.0.0.1:3002`. Point `APP_URL`/`NEXT_PUBLIC_APP_URL` at the public site URL.
|
||||
|
||||
### Automatic Updates
|
||||
|
||||
Updating is fully scripted — no need to touch Docker or nginx by hand. The
|
||||
script `scripts/docker-update.sh` pulls the latest `main`, runs CMS migrations
|
||||
on the host, rebuilds the image, recreates the container and waits for a healthy
|
||||
status. It also makes sure a stale host-side PM2 CMS (`pm2 stop next`) stays
|
||||
stopped so it can't clash on port 3002.
|
||||
|
||||
**Automatically (recommended):** a nightly cron job is already configured on a
|
||||
production server that installed this setup. It runs the script every night at
|
||||
03:30 and appends to `logs/docker-update.cron.log`:
|
||||
|
||||
```bash
|
||||
crontab -e
|
||||
# 30 3 * * * /var/www/atom-nexst/scripts/docker-update.sh >> /var/www/atom-nexst/logs/docker-update.cron.log 2>&1
|
||||
```
|
||||
|
||||
**Manually** — to update right now (same steps as the cron runs):
|
||||
|
||||
```bash
|
||||
cd /var/www/atom-nexst
|
||||
./scripts/docker-update.sh
|
||||
# log: logs/docker-update.log
|
||||
```
|
||||
|
||||
The script aborts safely (exit 1) if the working tree has uncommitted changes so
|
||||
a `git pull` can never clobber local edits, and leaves the container running if
|
||||
health fails so you can debug it (exit 3). Failed runs are reported in the log;
|
||||
an exit of 0 means the CMS is healthy on the new commit.
|
||||
|
||||
### Docker Commands
|
||||
|
||||
| Command | Description |
|
||||
| ------------------------------------------ | ------------------------------------ |
|
||||
| `docker compose up -d --build` | Build and start in background |
|
||||
| `docker compose down` | Stop and remove containers |
|
||||
| `docker compose logs -f cms` | Follow CMS logs |
|
||||
| `docker compose exec cms sh` | Open a shell in the CMS container |
|
||||
| `docker compose restart cms` | Restart the CMS container |
|
||||
| `docker compose pull && docker compose up -d --build` | Update and redeploy |
|
||||
| `pnpm db:migrate` (on the **host**) | Run database migrations (slim image has no source) |
|
||||
| `./scripts/docker-update.sh` | Full automated update (manual or cron) |
|
||||
|
||||
### How Package Manager Detection Works
|
||||
|
||||
The Dockerfile checks for lockfiles in this order:
|
||||
|
||||
1. **`pnpm-lock.yaml`** → uses pnpm (fastest, recommended)
|
||||
2. **`yarn.lock`** → uses yarn
|
||||
3. **`package-lock.json`** → uses npm
|
||||
4. **No lockfile** → falls back to `npm install`
|
||||
|
||||
This means you can use any package manager on your host machine — the Docker build will automatically match.
|
||||
|
||||
> Override the detection explicitly with `docker compose build --build-arg PACKAGE_MANAGER=pnpm` (or `npm` / `yarn`).
|
||||
|
||||
### Volumes
|
||||
|
||||
Only the CMS runs in Docker. The heavy client assets and gamedata stay on the host and are shared into the container so imports and uploads persist:
|
||||
|
||||
| Container Path | Host Path | Mode | Purpose |
|
||||
| -------------------------- | -------------------------- | ---- | ------------------------------------ |
|
||||
| `/app/public/nitro-assets` | `./public/nitro-assets` | rw | Imported furni/pet/effect assets |
|
||||
| `/app/public/swf` | `./public/swf` | rw | SWF costumes / icons (imports) |
|
||||
| `/app/storage` | `./storage` | rw | Uploaded media (persistent) |
|
||||
| `/var/www/Gamedata` | `/var/www/Gamedata` | rw | Shared gamedata root (hardcoded path)|
|
||||
|
||||
**About the hardcoded `/var/www/Gamedata` path:** `src/lib/services/furni-asset-dirs.ts` defines `DEFAULT_GAMEDATA_ROOT = /var/www/Gamedata` as an absolute on-disk path the CMS reads and mirrors imported assets into. The compose file mounts the host `/var/www/Gamedata` at the identical path inside the container so `existsSync('/var/www/Gamedata')` succeeds and nginx keeps serving `/gamedata/` from the same directory.
|
||||
|
||||
**The Nitro/Octane client and renderer are NOT mounted** — nginx on the host serves them directly:
|
||||
|
||||
| Component | Host path | How it's served |
|
||||
| -------------------- | ----------------------- | ---------------------------------------------------------------------- |
|
||||
| Nitro/Octane client | `/var/www/Octane/dist` | nginx `location ^~ /client/` and `/nitro-client/` alias |
|
||||
| Octane-Renderer | `/var/www/Octane-Renderer` | Optional. Run separately (or as the commented-out `imager` service) proxied by nginx `/imaging` |
|
||||
| Camera uploads | `/var/www/Camera` | nginx `/camera/` alias |
|
||||
|
||||
**Container user & write permissions:** the CMS container runs as `www-data` (UID/GID 33) by default to match the host owner of `/var/www/Gamedata`. Ensure the other write volumes (`./public/nitro-assets`, `./public/swf`, `./storage`) are also owned by `www-data` on the host:
|
||||
|
||||
```bash
|
||||
sudo chown -R 33:33 ./public/nitro-assets ./public/swf ./storage
|
||||
sudo chmod -R o+rX ./public/nitro-assets ./public/swf ./storage
|
||||
```
|
||||
|
||||
If your host user UID differs, override the run user at build time (defaults to `www-data`, which already exists in the base image with UID/GID 33):
|
||||
|
||||
```bash
|
||||
docker compose build --build-arg RUN_USER=1000
|
||||
```
|
||||
|
||||
### Networking
|
||||
|
||||
The CMS container runs with `network_mode: host`. This lets the existing `127.0.0.1` references in `.env` keep working against host services — MariaDB (`3306`), DragonflyDB/Redis (`6379`), the emulator RCON/API (`3003`/`3001`) and the avatar imager — without re-writing any environment variables. The container consequently listens **directly on the host's port 3002**, so stop any previous host-side CMS (e.g. `pm2 stop next`) that occupies that port before starting it.
|
||||
|
||||
The **build** also runs on the host network (`build.network: host`) because this host disables Docker iptables (`/etc/docker/daemon.json`: `"iptables": false`), which would otherwise leave build containers without outbound NAT/DNS when fetching packages.
|
||||
|
||||
### Health Check
|
||||
|
||||
The container includes a health check that hits `/api/health` on port 3002 every 30 seconds (and reports DB/Redis/emulator status). Check status with:
|
||||
|
||||
```bash
|
||||
docker inspect --format='{{.State.Health.Status}}' epicnext-cms
|
||||
```
|
||||
|
||||
|
||||
---
|
||||
|
||||
## ORM Setup & Type Generation
|
||||
|
||||
### Drizzle ORM (Primary Data Layer)
|
||||
|
||||
Drizzle ORM is the runtime data layer. The connection is a singleton in `src/lib/db.ts`:
|
||||
|
||||
@@ -76,57 +279,20 @@ const found = await db.select()
|
||||
|
||||
**Drizzle CLI** (`drizzle-kit`) is used for local development tasks — it is a devDependency and is never bundled in production.
|
||||
|
||||
| Command | What it does |
|
||||
| ------- | ------------ |
|
||||
| `pnpm db:generate` | Draft SQL from Drizzle schema into `drizzle/drafts/` (review + copy into `drizzle/migrations/`) |
|
||||
| `pnpm db:studio` | Open Drizzle Studio (dev only) |
|
||||
| `pnpm db:introspect` | Reverse-engineer an existing DB into a Drizzle schema draft |
|
||||
| `pnpm db:schema:generate` | Regen committed `src/db/schema.ts` from previous schema names + live DB |
|
||||
| Command | What it does |
|
||||
| ---------------------- | ---------------------------------------------------------------------- |
|
||||
| `pnpm db:generate` | Draft SQL from Drizzle schema into `drizzle/drafts/` (review + copy) |
|
||||
| `pnpm db:studio` | Open Drizzle Studio (dev only) |
|
||||
| `pnpm db:introspect` | Reverse-engineer an existing DB into a Drizzle schema draft |
|
||||
| `pnpm db:schema:generate` | Regen committed `src/db/schema.ts` from previous schema + live DB |
|
||||
|
||||
> The CMS does **not** use `drizzle-kit push` or `drizzle-kit migrate` — the database is shared with the emulator. Apply CMS DDL only via `pnpm db:migrate`.
|
||||
|
||||
Use `import { db } from "@/lib/db"` with table definitions from `src/db/schema.ts` for all database access. Types come from the committed Drizzle schema — no separate client code generation is required at build time.
|
||||
|
||||
### 5. Run CMS Migrations
|
||||
|
||||
```bash
|
||||
pnpm db:migrate
|
||||
```
|
||||
|
||||
Creates all CMS-owned tables (`website_*`, `radio_*`, `acl_*`, `admin_audit_log`, etc.) via idempotent SQL files in `drizzle/migrations/`. Emulator tables are never touched.
|
||||
|
||||
Check migration status:
|
||||
|
||||
```bash
|
||||
pnpm db:migrate:status
|
||||
```
|
||||
|
||||
### 6. Build & Start
|
||||
|
||||
```bash
|
||||
# Development (hot reload)
|
||||
pnpm dev
|
||||
|
||||
# Production
|
||||
pnpm build && pnpm start
|
||||
```
|
||||
|
||||
Open `http://localhost:3000` in your browser.
|
||||
|
||||
### 7. First Login
|
||||
|
||||
1. Register an account at `/register`, or log in with an existing emulator account.
|
||||
2. Grant yourself admin access: `UPDATE users SET rank = 7 WHERE username = 'yourname';`
|
||||
3. Visit `/admin` and configure your hotel via **Admin → CMS Settings**.
|
||||
|
||||
---
|
||||
|
||||
## DragonflyDB (caching, rate limiting, SSE)
|
||||
## DragonflyDB (Caching, Rate Limiting, SSE)
|
||||
|
||||
DragonflyDB is a drop-in, Redis-compatible in-memory datastore. The CMS connects to it
|
||||
via `REDIS_URL` using the `ioredis` client, so no application code changes are needed —
|
||||
every Redis command (`PING`, `GET`, `SETEX`, `DEL`, `INCR`, `PEXPIRE`, `PTTL`) works
|
||||
unchanged. It is optional: without it the CMS falls back to in-process memory.
|
||||
DragonflyDB is a drop-in, Redis-compatible in-memory datastore. The CMS connects to it via `REDIS_URL` using the `ioredis` client. It is optional: without it the CMS falls back to in-process memory.
|
||||
|
||||
### Install (Ubuntu/Debian)
|
||||
|
||||
@@ -137,10 +303,10 @@ apt-get install -y /tmp/dragonfly_amd64.deb
|
||||
systemctl enable --now dragonfly
|
||||
```
|
||||
|
||||
This installs a `dragonfly` systemd service and a config file at `/etc/dragonfly/dragonfly.conf`.
|
||||
|
||||
### Configure
|
||||
|
||||
Edit `/etc/dragonfly/dragonfly.conf`:
|
||||
|
||||
```ini
|
||||
--bind=127.0.0.1
|
||||
--port=6379
|
||||
@@ -148,260 +314,91 @@ This installs a `dragonfly` systemd service and a config file at `/etc/dragonfly
|
||||
--version_check=false
|
||||
```
|
||||
|
||||
- `--bind=127.0.0.1` keeps it private on the machine (matches `REDIS_URL=redis://127.0.0.1:6379`).
|
||||
- `--port=6379` is the default Redis port, so `.env` stays unchanged.
|
||||
- `--maxmemory` must be at least `0.25GiB` per CPU thread (e.g. `2gb` on a 6-thread server).
|
||||
- `--version_check=false` disables the periodic outbound update check.
|
||||
- Snapshots are written to `--dir` (`/var/lib/dragonfly/dump-*.dfs`).
|
||||
|
||||
### Point the CMS at it
|
||||
|
||||
```dotenv
|
||||
REDIS_URL=redis://127.0.0.1:6379?connect_timeout=2
|
||||
```
|
||||
|
||||
### Useful commands
|
||||
Verify via `/api/health` — it should report `"redis": true`.
|
||||
|
||||
```bash
|
||||
redis-cli PING # → PONG
|
||||
redis-cli FLUSHALL # clear the cache
|
||||
systemctl status dragonfly # service health
|
||||
```
|
||||
|
||||
Verify everything is wired up via the health endpoint:
|
||||
`/api/health` should report `"redis": true`. The ioredis client automatically reconnects
|
||||
after a DragonflyDB restart.
|
||||
|
||||
> **Note:** if an old Redis install still occupies port 6379, stop it first:
|
||||
> `systemctl disable --now redis-server`.
|
||||
---
|
||||
|
||||
## Nginx Configuration
|
||||
|
||||
The CMS is designed to run behind an nginx reverse proxy. Below is a reference configuration covering SSL termination, WebSocket upgrade, proxy caching, and the Habbo imager integration.
|
||||
The CMS is designed to run behind an nginx reverse proxy. Below is a reference configuration.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- SSL certificates in `/etc/ssl/cert.pem` and `/etc/ssl/key.pem` (or use Let's Encrypt)
|
||||
- Next.js running on `127.0.0.1:3000` (default) or your configured port
|
||||
- Habbo imager (optional) running on `127.0.0.1:3030`
|
||||
- CMS running on `127.0.0.1:3002`
|
||||
|
||||
### Reference Configuration
|
||||
|
||||
Create a file in `/etc/nginx/sites-available/epicnext` and symlink it to `sites-enabled`:
|
||||
|
||||
```nginx
|
||||
# ==========================================
|
||||
# GLOBAL SETTINGS
|
||||
# ==========================================
|
||||
server_tokens off;
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_proxied off;
|
||||
gzip_comp_level 6;
|
||||
gzip_min_length 256;
|
||||
gzip_types text/plain text/css text/javascript application/json
|
||||
application/javascript application/xml application/xml+rss
|
||||
image/svg+xml font/opentype font/ttf font/woff font/woff2;
|
||||
proxy_cache_path /var/cache/nginx/html_cache levels=1:2 keys_zone=html_cache:50m max_size=500m inactive=10m use_temp_path=off;
|
||||
|
||||
# ==========================================
|
||||
# REDIRECT HTTP → HTTPS
|
||||
# ==========================================
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name yourdomain.com www.yourdomain.com;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/epicnext/public;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
server_name yourdomain.com;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
# ==========================================
|
||||
# MAIN HTTPS SERVER
|
||||
# ==========================================
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
http2 on;
|
||||
server_name yourdomain.com www.yourdomain.com;
|
||||
server_name yourdomain.com;
|
||||
|
||||
root /var/www/epicnext/public;
|
||||
index index.html;
|
||||
|
||||
# SSL Certificates
|
||||
ssl_certificate /etc/ssl/cert.pem;
|
||||
ssl_certificate_key /etc/ssl/key.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_prefer_server_ciphers off;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 1d;
|
||||
ssl_session_tickets off;
|
||||
|
||||
# Security Headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
|
||||
client_max_body_size 20m;
|
||||
client_body_timeout 30s;
|
||||
client_header_timeout 10s;
|
||||
keepalive_timeout 15s;
|
||||
send_timeout 10s;
|
||||
|
||||
# Shared Proxy Settings
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_buffers 16 16k;
|
||||
proxy_buffer_size 32k;
|
||||
|
||||
# ------------------------------------------
|
||||
# Static Files
|
||||
# ------------------------------------------
|
||||
location ^~ /nitro-client/ {
|
||||
alias /var/www/Nitro-V3/dist/;
|
||||
expires 7d;
|
||||
add_header Cache-Control "public";
|
||||
access_log off;
|
||||
}
|
||||
|
||||
location = /favicon.ico { expires 1y; access_log off; log_not_found off; try_files $uri =404; }
|
||||
location = /robots.txt { expires 1d; access_log off; log_not_found off; try_files $uri =404; }
|
||||
|
||||
# ------------------------------------------
|
||||
# Next.js Assets (immutable, long cache)
|
||||
# ------------------------------------------
|
||||
location /_next/static/ {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
}
|
||||
|
||||
location /_next/data/ {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
add_header Cache-Control "public, max-age=0, must-revalidate";
|
||||
}
|
||||
|
||||
# ------------------------------------------
|
||||
# API Routes (never cached)
|
||||
# ------------------------------------------
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
}
|
||||
|
||||
# ------------------------------------------
|
||||
# Habbo Imager (optional)
|
||||
# ------------------------------------------
|
||||
# Proxies to a Docker container that renders Habbo avatars.
|
||||
# The imager caches renders to disk, so a long s-maxage is safe.
|
||||
location /imaging {
|
||||
proxy_pass http://127.0.0.1:3030;
|
||||
add_header Cache-Control "public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400" always;
|
||||
}
|
||||
|
||||
# ------------------------------------------
|
||||
# WebSocket (Radio / SSE)
|
||||
# ------------------------------------------
|
||||
location /ws {
|
||||
proxy_pass http://127.0.0.1:3030;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 86400;
|
||||
}
|
||||
|
||||
# ------------------------------------------
|
||||
# Main Page Proxy (with HTML caching)
|
||||
# ------------------------------------------
|
||||
# The CMS middleware sets:
|
||||
# Cache-Control: public, s-maxage=300, stale-while-revalidate=300 (anonymous)
|
||||
# Cache-Control: private, no-store (authenticated)
|
||||
#
|
||||
# nginx caches anonymous responses and serves them directly, bypassing
|
||||
# the Node.js process entirely. Authenticated responses are never cached.
|
||||
#
|
||||
# proxy_cache_valid: cache 200 responses for 60 seconds
|
||||
# proxy_ignore_headers Vary: Next.js emits many Vary headers (rsc,
|
||||
# next-router-*, Accept-Encoding) that would fragment the cache key.
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_pass http://127.0.0.1:3002;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header CF-Connecting-IP $http_cf_connecting_ip;
|
||||
proxy_http_version 1.1;
|
||||
proxy_buffering on;
|
||||
|
||||
proxy_cache html_cache;
|
||||
proxy_cache_valid 200 60s;
|
||||
proxy_cache_key "$host$request_uri";
|
||||
proxy_ignore_headers Vary;
|
||||
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
|
||||
proxy_cache_background_update on;
|
||||
proxy_cache_revalidate on;
|
||||
add_header X-Cache-Status $upstream_cache_status always;
|
||||
}
|
||||
|
||||
# ------------------------------------------
|
||||
# Health Check
|
||||
# ------------------------------------------
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:3002;
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
}
|
||||
|
||||
location /_next/static/ {
|
||||
proxy_pass http://127.0.0.1:3002;
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
}
|
||||
|
||||
location /imaging {
|
||||
proxy_pass http://127.0.0.1:3030;
|
||||
add_header Cache-Control "public, max-age=3600, s-maxage=86400";
|
||||
}
|
||||
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 "OK";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
|
||||
# Block hidden files
|
||||
location ~ /(\.|vendor|storage/logs/|\.(sql|sqlite|sqlite3)$) {
|
||||
deny all;
|
||||
access_log off;
|
||||
log_not_found off;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### HTML Caching
|
||||
|
||||
The CMS uses an **origin-level proxy cache** for anonymous HTML pages. This means:
|
||||
|
||||
- **Anonymous visitors** receive cached HTML directly from nginx (~1ms), skipping the Node.js process entirely.
|
||||
- **Authenticated visitors** always hit Node.js (personalized content).
|
||||
- The cache is **auto-invalidated** after 60 seconds and revalidates in the background.
|
||||
|
||||
The proxy cache zone is defined in the `http` block (above any `server` block):
|
||||
|
||||
```nginx
|
||||
proxy_cache_path /var/cache/nginx/html_cache levels=1:2 keys_zone=html_cache:50m max_size=500m inactive=10m use_temp_path=off;
|
||||
```
|
||||
|
||||
Verify caching works by checking the `X-Cache-Status` response header:
|
||||
|
||||
```bash
|
||||
# First request (MISS = fetched from Node.js, now cached)
|
||||
curl -sI https://yourdomain.com/ | grep X-Cache-Status
|
||||
# → X-Cache-Status: MISS
|
||||
|
||||
# Second request (HIT = served from nginx cache)
|
||||
curl -sI https://yourdomain.com/ | grep X-Cache-Status
|
||||
# → X-Cache-Status: HIT
|
||||
```
|
||||
|
||||
### Key Points
|
||||
|
||||
| Setting | Value | Why |
|
||||
| ------- | ----- | --- |
|
||||
| `proxy_http_version 1.1` | HTTP/1.1 to upstream | Required for keep-alive and chunked transfer |
|
||||
| `proxy_buffering on` | Buffer upstream response | Required for proxy_cache to work with chunked responses |
|
||||
| `proxy_ignore_headers Vary` | Ignore upstream Vary | Next.js emits dynamic Vary headers (rsc, next-router-*) that would fragment the cache |
|
||||
| `proxy_cache_valid 200 60s` | Cache 200s for 60s | Balances freshness with performance |
|
||||
| `proxy_cache_use_stale` | Serve stale on error | Keeps the site available during brief upstream outages |
|
||||
|
||||
---
|
||||
|
||||
## Production Deployment (PM2)
|
||||
@@ -421,30 +418,28 @@ pnpm build
|
||||
pm2 restart next
|
||||
```
|
||||
|
||||
The CMS runs behind an nginx reverse proxy on the default port 3000. Static assets (media uploads) are persisted via `/api/media/*` and survive rebuilds.
|
||||
|
||||
---
|
||||
|
||||
## Scripts
|
||||
|
||||
| Command | Description |
|
||||
| ---------------------- | -------------------------------------------------- |
|
||||
| `pnpm dev` | Start development server (hot reload) |
|
||||
| `pnpm build` | Production build |
|
||||
| `pnpm start` | Start production server |
|
||||
| `pnpm typecheck` | Run TypeScript type checking |
|
||||
| `pnpm test` | Run all tests (Vitest) |
|
||||
| `pnpm db:migrate` | Apply pending SQL migrations |
|
||||
| `pnpm db:migrate:status` | Show migration status |
|
||||
| Command | Description |
|
||||
| ------------------------- | -------------------------------------------------- |
|
||||
| `pnpm dev` | Start development server (hot reload) |
|
||||
| `pnpm build` | Production build |
|
||||
| `pnpm start` | Start production server |
|
||||
| `pnpm typecheck` | Run TypeScript type checking |
|
||||
| `pnpm test` | Run all tests (Vitest) |
|
||||
| `pnpm db:migrate` | Apply pending SQL migrations |
|
||||
| `pnpm db:migrate:status` | Show migration status |
|
||||
| `pnpm db:schema:generate` | Regen `src/db/schema.ts` from prior schema + live DB |
|
||||
| `pnpm db:generate` | Draft SQL via drizzle-kit → `drizzle/drafts/` |
|
||||
| `pnpm db:studio` | Drizzle Studio (dev) |
|
||||
| `pnpm db:introspect` | drizzle-kit introspect (draft) |
|
||||
| `pnpm analyze` | Build + open bundle analyzer |
|
||||
| `pnpm jobs:worker` | Start background task worker (systemd / PM2) |
|
||||
| `pnpm biome:check` | Lint and format code |
|
||||
| `pnpm db:generate` | Draft SQL via drizzle-kit → `drizzle/drafts/` |
|
||||
| `pnpm db:studio` | Drizzle Studio (dev) |
|
||||
| `pnpm db:introspect` | drizzle-kit introspect (draft) |
|
||||
| `pnpm analyze` | Build + open bundle analyzer |
|
||||
| `pnpm jobs:worker` | Start background task worker |
|
||||
| `pnpm biome:check` | Lint and format code |
|
||||
|
||||
**Drizzle Kit notes:** `db:generate` / `db:introspect` write drafts only. Reviewed SQL must be copied into `drizzle/migrations/` as a new numbered file, then applied with `pnpm db:migrate`. Never run `drizzle-kit push` or `drizzle-kit migrate` against production.
|
||||
> Replace `pnpm` with `npm run` or `yarn` for other package managers.
|
||||
|
||||
---
|
||||
|
||||
@@ -454,16 +449,13 @@ The CMS runs behind an nginx reverse proxy on the default port 3000. Static asse
|
||||
| ------------------------------ | -------------------------------------------------------------- |
|
||||
| **React Compiler** | Automatic memoization — reduces unnecessary re-renders |
|
||||
| **View Transitions API** | Native browser transitions between page navigations |
|
||||
| **Lenis Smooth Scroll** | Fluid, customizable scrolling (respects `prefers-reduced-motion`) |
|
||||
| **Server-Sent Events** | Real-time radio now-playing & listeners via SSE (no polling) |
|
||||
| **DragonflyDB Caching** | Caches API responses (home, radio config) up to 30s in DragonflyDB |
|
||||
| **Bundle Analyzer** | Run `pnpm analyze` to visualize and optimize bundle sizes |
|
||||
| **Lenis Smooth Scroll** | Fluid, customizable scrolling |
|
||||
| **Server-Sent Events** | Real-time radio now-playing & listeners via SSE |
|
||||
| **DragonflyDB Caching** | Caches API responses up to 30s in DragonflyDB |
|
||||
| **RCON (TCP Socket)** | Live commands to the emulator (credits, badges, kick, ban) |
|
||||
| **Streaming & Suspense** | Next.js App Router streaming for fast page loads |
|
||||
| **Automatic Image Optimization** | `next/image` with Sharp for resizing and WebP/AVIF |
|
||||
| **CSS-based Animations** | `input-glow`, `btn-shine`, `Reveal` scroll animations, floating orbs |
|
||||
| **Compression** | Gzip compression enabled on all responses |
|
||||
| **Stale Times** | Optimized router cache (30s dynamic, 180s static) |
|
||||
| **PWA** | Service worker with skip-waiting, stale CSS chunk recovery |
|
||||
| **Biome** | Fast linting and formatting (replaces ESLint + Prettier) |
|
||||
|
||||
@@ -478,7 +470,6 @@ The CMS runs behind an nginx reverse proxy on the default port 3000. Static asse
|
||||
├── scripts/
|
||||
│ ├── apply-migrations.ts # SQL migration runner (apply + status)
|
||||
│ ├── jobs-worker.ts # Background task scheduler
|
||||
│ ├── merge-config.cjs # Utility: merge split config files
|
||||
│ └── generate-drizzle-schema.mjs # Regen src/db/schema.ts from schema + live DB
|
||||
├── src/
|
||||
│ ├── db/
|
||||
@@ -491,38 +482,20 @@ The CMS runs behind an nginx reverse proxy on the default port 3000. Static asse
|
||||
│ │ ├── auth/ # NextAuth, password hashing, 2FA, SSO tickets
|
||||
│ │ ├── services/ # RCON, email, currency, PayPal, alerts
|
||||
│ │ ├── db.ts # Drizzle connection singleton (runtime)
|
||||
│ │ ├── cached-db.ts # DragonflyDB-backed query cache helpers
|
||||
│ │ ├── redis.ts # Cache client (ioredis → DragonflyDB)
|
||||
│ │ ├── redis-cache.ts # Caching utility for API routes (uses DragonflyDB)
|
||||
│ │ ├── cache.ts # In-memory cache fallback
|
||||
│ │ ├── motion.ts # Framer Motion animation variants
|
||||
│ │ └── use-event-source.ts # React hook for SSE subscriptions
|
||||
│ │ └── motion.ts # Framer Motion animation variants
|
||||
│ ├── messages/ # i18n translations (en, nl, de, fr, es, it, pt, da, no, sv)
|
||||
│ └── env.ts # Zod-validated environment schema
|
||||
├── public/
|
||||
│ ├── assets/ # Images, icons, fonts
|
||||
│ └── scripts/ # Client-side scripts (theme-init.js)
|
||||
├── update-Nitrov3.sh # Emulator & Nitro updater utility
|
||||
│ ├── nitro-assets/ # Nitro client assets (~3GB, volume-mounted in Docker)
|
||||
│ └── swf/ # SWF client files (volume-mounted in Docker)
|
||||
├── docker-compose.yml # Docker Compose configuration
|
||||
├── Dockerfile # Multi-stage, multi-package-manager Docker build
|
||||
├── next.config.ts # Next.js configuration
|
||||
└── .env.example # Environment template with all options
|
||||
```
|
||||
|
||||
### Database Ownership
|
||||
|
||||
| Component | Type | Migrations |
|
||||
| ------------------------------------------------- | ----------------------- | ----------------------------------- |
|
||||
| Emulator tables (`users`, `items`, `rooms`, etc.) | Existing Polaris schema | None — CMS reads/writes only |
|
||||
| CMS tables (`website_*`, `radio_*`, etc.) | CMS-owned | `drizzle/migrations/*.sql` |
|
||||
| Migration tracking | `cms_migrations` table | Auto-created by migration runner |
|
||||
|
||||
### ORM Architecture
|
||||
|
||||
- **Runtime (Drizzle ORM)**: `@/lib/db` exposes a Drizzle singleton. Schema lives in `src/db/schema.ts`.
|
||||
- **Schema regeneration**: `pnpm db:schema:generate` reuses field/table names from the previous `src/db/schema.ts` and refreshes column types from the live DB.
|
||||
- **Drizzle Kit**: studio / generate / introspect for local tooling; CMS apply path remains `pnpm db:migrate`.
|
||||
|
||||
Use `import { db } from "@/lib/db"` with queries built via `src/db/schema.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Optional Integrations
|
||||
@@ -549,97 +522,35 @@ The radio player uses SSE for real-time updates (10s interval, no polling).
|
||||
Run as a persistent process:
|
||||
|
||||
```bash
|
||||
pnpm jobs:worker
|
||||
pnpm jobs:worker # or: npm run jobs:worker / yarn jobs:worker
|
||||
```
|
||||
|
||||
Scheduled tasks:
|
||||
- **Daily 03:00** — Emulator JAR backup (requires `EMULATOR_JAR_PATH` + `EMULATOR_BACKUP_DIR`)
|
||||
- **Daily 04:00** — Cleanup login logs (>30 days) and expired password reset tokens (>7 days)
|
||||
|
||||
### CAPTCHA
|
||||
|
||||
Supports Cloudflare Turnstile and Google reCAPTCHA. Configure via CMS Settings.
|
||||
|
||||
### Theming
|
||||
|
||||
12 preset themes with fully customizable colors via **Admin → Theme** (`/admin/theme`).
|
||||
|
||||
|
||||
## Furniture Import with Auto-Translation
|
||||
|
||||
The CMS now automatically translates furniture names and descriptions to **13 languages** on every import:
|
||||
|
||||
- **Native languages** (via official Habbo gamedata): Dutch (`nl`), English (`en`), German (`de`), French (`fr`), Spanish (`es`), Turkish (`tr`), Italian (`it`)
|
||||
- **Additional languages** (via LibreTranslate self-hosted Docker at `127.0.0.1:5000`): Portuguese (`pt`), Finnish (`fi`), Polish (`pl`), Russian (`ru`), Arabic (`ar`), Japanese (`ja`)
|
||||
|
||||
### How it works
|
||||
|
||||
1. **Per-import translation** — Every import route (`/admin/import/furni`, batch, batch-regen, clone) triggers translation automatically after the furniture data is imported.
|
||||
|
||||
2. **Translation flow**:
|
||||
- The original (usually English) `classname` and `description` are sent to LibreTranslate
|
||||
- Official Habbo translations take priority for the 7 supported languages
|
||||
- Custom/new meubels fall back to LibreTranslate
|
||||
- Post-processing rules force Habbo‑style terminology (e.g. `bank` → `Bank`, `tafel` → `Tafel`, `stoel` → `Stoel`)
|
||||
|
||||
3. **No manual steps needed** — The translation happens as part of the import API calls. New custom furniture added via import immediately appears with translated names in the catalog for all 13 languages.
|
||||
|
||||
4. **CLI scripts** (optional):
|
||||
- `pnpm translate:full` — translate all furniture data fully (uses `--full` flag)
|
||||
- `pnpm translate:limited [--limit N] [--concurrency N]` — translate limited amount per language
|
||||
- `pnpm build:languages [--full] [--limit N] [--concurrency N]` — build the full localized furnidata JSON files
|
||||
|
||||
### Requirements
|
||||
|
||||
- LibreTranslate Docker container running at `http://127.0.0.1:5000` (or configure a different URL in the environment)
|
||||
- The `LIBRETRANSLATE_URL` environment variable can override the default if needed
|
||||
- For the 7 official languages, no external service is needed — they use the built‑in Habbo gamedata
|
||||
|
||||
### Environment variables
|
||||
|
||||
```dotenv
|
||||
# LibreTranslate endpoint (default: http://127.0.0.1:5000)
|
||||
LIBRETRANSLATE_URL=http://127.0.0.1:5000
|
||||
```
|
||||
|
||||
### Example import flow
|
||||
|
||||
```bash
|
||||
# Single furniture import (triggers translation)
|
||||
POST /admin/import/furni
|
||||
|
||||
# Batch import (triggers translation)
|
||||
POST /admin/import/furni/batch
|
||||
|
||||
# Regenerate localized files
|
||||
POST /admin/import/furni/batch-regen
|
||||
|
||||
# Clone import (triggers translation)
|
||||
POST /admin/import/clone/batch
|
||||
```
|
||||
### AI Content Moderation
|
||||
|
||||
Optional OpenAI-powered moderation for comments and guestbook posts. Set `OPENAI_API_KEY`.
|
||||
|
||||
### FlareSolverr (Cloudflare Bypass)
|
||||
|
||||
Some clone sources (e.g. Leet, Hubbly, Habblet City) are protected by Cloudflare and return challenge pages instead of JSON. FlareSolverr acts as a proxy that solves these challenges automatically.
|
||||
|
||||
**Setup:**
|
||||
Some clone sources are protected by Cloudflare. FlareSolverr solves these challenges automatically.
|
||||
|
||||
```bash
|
||||
docker compose up -d flaresolverr
|
||||
```
|
||||
|
||||
Set the URL in `.env`:
|
||||
|
||||
```
|
||||
```dotenv
|
||||
FLARESOLVERR_URL=http://localhost:8191
|
||||
```
|
||||
|
||||
When a source URL returns a 403 or HTML (CF challenge), the clone import automatically falls back to FlareSolverr. If FlareSolverr is not running or is unreachable, the source is skipped with a warning.
|
||||
### Furniture Import with Auto-Translation
|
||||
|
||||
See `docker-compose.yml` for the FlareSolverr service definition.
|
||||
The CMS automatically translates furniture names and descriptions to **13 languages** on every import:
|
||||
|
||||
- **Native languages** (via official Habbo gamedata): Dutch, English, German, French, Spanish, Turkish, Italian
|
||||
- **Additional languages** (via LibreTranslate Docker at `127.0.0.1:5000`): Portuguese, Finnish, Polish, Russian, Arabic, Japanese
|
||||
|
||||
### Theming
|
||||
|
||||
12 preset themes with fully customizable colors via **Admin → Theme** (`/admin/theme`).
|
||||
|
||||
---
|
||||
|
||||
@@ -656,14 +567,14 @@ pnpm biome:check # Lint and format
|
||||
### Contributing
|
||||
|
||||
1. Ensure typecheck and tests pass: `pnpm typecheck && pnpm test`
|
||||
2. Follow existing code conventions (Server Components where possible, minimal client boundaries)
|
||||
3. Use the `src/lib/motion.ts` animation variants for consistent animations
|
||||
4. SQL migrations in `drizzle/migrations/` must be idempotent
|
||||
5. For new database code, use the Drizzle runtime directly (`import { db } from "@/lib/db"`) — see [ORM Setup](#4-orm-setup--type-generation)
|
||||
6. Avoid `any` — use `eslint-disable` or `biome-ignore` comments only when unavoidable
|
||||
2. Follow existing code conventions (Server Components where possible)
|
||||
3. SQL migrations in `drizzle/migrations/` must be idempotent
|
||||
4. For new database code, use the Drizzle runtime directly (`import { db } from "@/lib/db"`)
|
||||
5. Avoid `any` — use `biome-ignore` comments only when unavoidable
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
CC BY-NC-SA 4.0. See `LICENSE` for details.
|
||||
Test aanpassing voor Gitea Actions
|
||||
+52
-9
@@ -1,20 +1,63 @@
|
||||
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
|
||||
args:
|
||||
# Run as the host owner (www-data = UID/GID 33, already present in the
|
||||
# node base image) of /var/www/Gamedata so the container can read + write
|
||||
# the shared gamedata directory.
|
||||
RUN_USER: "www-data"
|
||||
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
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
# ── 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
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
);
|
||||
+4
-2
@@ -18,6 +18,7 @@
|
||||
"diag:permissions": "tsx scripts/diagnose-permission-page.ts",
|
||||
"jobs:worker": "tsx scripts/jobs-worker.ts",
|
||||
"test": "vitest run",
|
||||
"test:e2e": "playwright test",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "tsx scripts/apply-migrations.ts",
|
||||
@@ -41,10 +42,10 @@
|
||||
"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",
|
||||
@@ -54,7 +55,7 @@
|
||||
"motion": "13.1.1",
|
||||
"music-metadata": "11.15.0",
|
||||
"mysql2": "3.24.2",
|
||||
"next": "16.3.3",
|
||||
"next": "16.3.4",
|
||||
"next-auth": "5.0.0-beta.32",
|
||||
"next-intl": "4.14.1",
|
||||
"otplib": "13.5.0",
|
||||
@@ -71,6 +72,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.5.11",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@tailwindcss/forms": "0.5.11",
|
||||
"@tailwindcss/postcss": "4.3.3",
|
||||
"@tailwindcss/typography": "0.5.20",
|
||||
|
||||
@@ -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
+430
-53
@@ -52,9 +52,6 @@ importers:
|
||||
croner:
|
||||
specifier: 10.0.1
|
||||
version: 10.0.1
|
||||
dompurify:
|
||||
specifier: 3.4.14
|
||||
version: 3.4.14
|
||||
drizzle-orm:
|
||||
specifier: 0.45.2
|
||||
version: 0.45.2([email protected](@types/[email protected]))
|
||||
@@ -64,6 +61,9 @@ importers:
|
||||
ioredis:
|
||||
specifier: 6.0.0
|
||||
version: 6.0.0([email protected])
|
||||
isomorphic-dompurify:
|
||||
specifier: ^4.1.0
|
||||
version: 4.1.0(@noble/[email protected])
|
||||
jpeg-js:
|
||||
specifier: 0.4.4
|
||||
version: 0.4.4
|
||||
@@ -92,14 +92,14 @@ importers:
|
||||
specifier: 3.24.2
|
||||
version: 3.24.2(@types/[email protected])
|
||||
next:
|
||||
specifier: 16.3.3
|
||||
version: 16.3.3(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
specifier: 16.3.4
|
||||
version: 16.3.4(@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
next-auth:
|
||||
specifier: 5.0.0-beta.32
|
||||
version: 5.0.0-beta.32([email protected].3(@types/[email protected])([email protected]([email protected]))([email protected]))([email protected])
|
||||
version: 5.0.0-beta.32([email protected].4(@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected]))([email protected])
|
||||
next-intl:
|
||||
specifier: 4.14.1
|
||||
version: 4.14.1(@swc/[email protected])([email protected].3(@types/[email protected])([email protected]([email protected]))([email protected]))([email protected])
|
||||
version: 4.14.1(@swc/[email protected])([email protected].4(@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected]))([email protected])
|
||||
otplib:
|
||||
specifier: 13.5.0
|
||||
version: 13.5.0
|
||||
@@ -137,6 +137,9 @@ importers:
|
||||
'@biomejs/biome':
|
||||
specifier: 2.5.11
|
||||
version: 2.5.11
|
||||
'@playwright/test':
|
||||
specifier: ^1.62.1
|
||||
version: 1.62.1
|
||||
'@tailwindcss/forms':
|
||||
specifier: 0.5.11
|
||||
version: 0.5.11([email protected])
|
||||
@@ -187,7 +190,7 @@ importers:
|
||||
version: 7.0.2
|
||||
vitest:
|
||||
specifier: 4.1.11
|
||||
version: 4.1.11(@types/[email protected])(@vitest/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
|
||||
version: 4.1.11(@types/[email protected])(@vitest/[email protected])([email protected](@noble/[email protected]))([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
|
||||
|
||||
packages:
|
||||
|
||||
@@ -195,6 +198,14 @@ packages:
|
||||
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
'@asamuzakjp/[email protected]':
|
||||
resolution: {integrity: sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==}
|
||||
engines: {node: ^22.13.0 || >=24.0.0}
|
||||
|
||||
'@asamuzakjp/[email protected]':
|
||||
resolution: {integrity: sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==}
|
||||
engines: {node: ^22.13.0 || >=24.0.0}
|
||||
|
||||
'@auth/[email protected]':
|
||||
resolution: {integrity: sha512-sJ3JMHHkXMD3aOjopv7mOBTO1Ocw4b0fAEXJBz6k7YHLpYQI6C40jCUPc5fNvUKxXRXNE1/sRISA15UrwWJBTw==}
|
||||
peerDependencies:
|
||||
@@ -321,6 +332,46 @@ packages:
|
||||
'@borewit/[email protected]':
|
||||
resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==}
|
||||
|
||||
'@bramus/[email protected]':
|
||||
resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==}
|
||||
hasBin: true
|
||||
|
||||
'@csstools/[email protected]':
|
||||
resolution: {integrity: sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
|
||||
'@csstools/[email protected]':
|
||||
resolution: {integrity: sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
peerDependencies:
|
||||
'@csstools/css-parser-algorithms': ^4.0.0
|
||||
'@csstools/css-tokenizer': ^4.0.0
|
||||
|
||||
'@csstools/[email protected]':
|
||||
resolution: {integrity: sha512-3QKjR/vxyjcSXBLgb6lP0S3MGdvwbmqSsvLPbYdVORqPDc8FX1HAJ0Spk38bxaRXgvENTA47tlhhbb5Z2e8hEg==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
peerDependencies:
|
||||
'@csstools/css-parser-algorithms': ^4.0.0
|
||||
'@csstools/css-tokenizer': ^4.0.0
|
||||
|
||||
'@csstools/[email protected]':
|
||||
resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
peerDependencies:
|
||||
'@csstools/css-tokenizer': ^4.0.0
|
||||
|
||||
'@csstools/[email protected]':
|
||||
resolution: {integrity: sha512-3vLQK+dXxhBMR2Wx99PTCifE+vHtW2ndZWyla8yK813ev6oGhyn8Lja8jCyGAWTJ+LEYZK7EVtJxrDj8ztevJw==}
|
||||
peerDependencies:
|
||||
css-tree: ^3.2.1
|
||||
peerDependenciesMeta:
|
||||
css-tree:
|
||||
optional: true
|
||||
|
||||
'@csstools/[email protected]':
|
||||
resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
|
||||
'@dnd-kit/[email protected]':
|
||||
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
|
||||
peerDependencies:
|
||||
@@ -531,6 +582,15 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@exodus/[email protected]':
|
||||
resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||
peerDependencies:
|
||||
'@noble/hashes': ^1.8.0 || ^2.0.0
|
||||
peerDependenciesMeta:
|
||||
'@noble/hashes':
|
||||
optional: true
|
||||
|
||||
'@floating-ui/[email protected]':
|
||||
resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==}
|
||||
|
||||
@@ -824,57 +884,57 @@ packages:
|
||||
'@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4
|
||||
'@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4
|
||||
|
||||
'@next/[email protected].3':
|
||||
resolution: {integrity: sha512-U2eYQRwXj+dsqxV79zFqExDdatnNY/ZWc2nsJU1p/OgT7fd3dXwlF6OjYaFQCfMoeTA19PWq+wVmYgimVA+V+g==}
|
||||
'@next/[email protected].4':
|
||||
resolution: {integrity: sha512-cjWZnUUa6jZq2kFaNe/ZyJdZonOZ/QoN0Zka2nz/FLOrfx14pQuM9c5RaSVkWMqgdt4ksgPAMWPyHSs/CyV48Q==}
|
||||
|
||||
'@next/[email protected].3':
|
||||
resolution: {integrity: sha512-8Hiv32QJPwdV6KYJ8meR9SBA061tQqnIKTJDocvOXlEQqib0xMFpzArosuffFUUc0sslbh7QQ8a3Yey1QV8EIw==}
|
||||
'@next/[email protected].4':
|
||||
resolution: {integrity: sha512-iBr3I5LZNk5/bgl5//iTgD2tcym14MX0Xo7fD//u9dYAEgGzza1y9oywluPtf74YnOswVdH1908aK9xVz7zQTw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@next/[email protected].3':
|
||||
resolution: {integrity: sha512-A1lgKgwVchRYmSe467zdwhxT9040dd8lH+o65sL5Jet8fjB4kegw/rDyPIpYVRb6jAqwXFOJpjIXJLxQKLiE3A==}
|
||||
'@next/[email protected].4':
|
||||
resolution: {integrity: sha512-2dpiSyl2Jw/NrBPaU2MAKGSa+2MR82pJIn4Sm5Rjr+gxAeuh0z158Su3Z2O8zn7UNNq+ej4bToed6RcRN/Lydg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@next/[email protected].3':
|
||||
resolution: {integrity: sha512-bf0FIssMFueU2dm7vQEWWxk0c8UjKTdW0yzuh0sQsD8pf1+KCLDdaqhYZNMYGmXwEOiHAUzgBKudovIlcvvBjg==}
|
||||
'@next/[email protected].4':
|
||||
resolution: {integrity: sha512-+t+U8HZT+fApePCS5h89CSH3datz29MkzyfCn+6fpsZBG/oiEOhINcb9rtkv6sdpToLGFn2e6146NzaKCXkqrA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@next/[email protected].3':
|
||||
resolution: {integrity: sha512-W7viwCk9JY/cAkdz/A273rd5bb3RgT/IHwR7Upv90tunjBWNtAAhGhoecHh+teRNRSinuAFmE+l7fwZ4YKkrXg==}
|
||||
'@next/[email protected].4':
|
||||
resolution: {integrity: sha512-mx03GNs1ocQA5JQ4FxDMmIsNkdrZh8cuezKCrId28e5/gIPU/l7Kcy2+vmCCzdjnnmXJy+iOAu+7K0QppO6Urg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@next/[email protected].3':
|
||||
resolution: {integrity: sha512-0W46zw1N3ODpI6n0GeivHvvob1pooozgZVqy65k0mh4/7vr+FbY9+WpHzNVXjHipJf/A3FDheBG19H1s5A25rA==}
|
||||
'@next/[email protected].4':
|
||||
resolution: {integrity: sha512-YIhGY6fSMfha52bnVxnzc9zaVBzJg+cqQTOD8tXIBSx4fuv0pVMxQTE0PaS59YhnMOiYiG09IMwxJAf/CFm/Dw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@next/[email protected].3':
|
||||
resolution: {integrity: sha512-H4mBso8ZTMBPtdT0PN0pBx2ayTvQuTuvS6qT13d77yVFJXAPCxkyIhLTmdMaGTJs0krQYI/qpzdHijCeihXhbg==}
|
||||
'@next/[email protected].4':
|
||||
resolution: {integrity: sha512-+eaaX6axpDb0yF1GCpiERe6njplvdC+nks/fKfcHu3XPGRrald8P3/X7yv7QLdjA51knnxwl9pxdIJsg+w1L+Q==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@next/[email protected].3':
|
||||
resolution: {integrity: sha512-cTMUJpcEGmeywofCUfhR+rSsoE33+rVPnPEYNTNdLNlsOeEg/vktOsKUSTb28vUGqD2jkm4Zaskcwn7OCI6FQg==}
|
||||
'@next/[email protected].4':
|
||||
resolution: {integrity: sha512-0jcXW7Xs/uzICrmgV3MhDYDeRy++1CqnpDIerlPIqYO4bhzB4WNbX/aRnQclustsAyTkFKB0z6rbcjmNg5tR8A==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@next/[email protected].3':
|
||||
resolution: {integrity: sha512-2VR4cTBzHXaBjnGsuH6GyJjENzQOmHeAh11uY1iUhjm3j5dEUrVJuUj+VL78jaGi/Dik8xS76zEj18BsFhlVZQ==}
|
||||
'@next/[email protected].4':
|
||||
resolution: {integrity: sha512-vvBzwu1pYQCp92maZCFCIw/XgOTMR5tur9GjakwIo2cmwRTMKajRZZDS9+e4KsUZWKu1E007WUeAFXRRjZeuzw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
@@ -1220,6 +1280,11 @@ packages:
|
||||
'@pinojs/[email protected]':
|
||||
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
|
||||
|
||||
'@playwright/[email protected]':
|
||||
resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==}
|
||||
engines: {node: '>=20'}
|
||||
hasBin: true
|
||||
|
||||
'@radix-ui/[email protected]':
|
||||
resolution: {integrity: sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==}
|
||||
|
||||
@@ -1918,6 +1983,9 @@ packages:
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
||||
|
||||
@@ -1965,6 +2033,10 @@ packages:
|
||||
resolution: {integrity: sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g==}
|
||||
engines: {node: '>=18.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==}
|
||||
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -1973,6 +2045,10 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==}
|
||||
|
||||
@@ -1985,6 +2061,9 @@ packages:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==}
|
||||
engines: {node: '>=0.10'}
|
||||
@@ -2102,6 +2181,10 @@ packages:
|
||||
resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==}
|
||||
|
||||
@@ -2158,6 +2241,11 @@ packages:
|
||||
react-dom:
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
@@ -2186,6 +2274,10 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
|
||||
|
||||
@@ -2225,12 +2317,19 @@ packages:
|
||||
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-vjQwQSxyEkJHO6g+KIDlWj8swAzUB01pJu9GU9cLGHjE10d59GFzJkgtB2Lj5WTOoq9JeHlYjR/FFuqeopPO0Q==}
|
||||
engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -2260,6 +2359,15 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==}
|
||||
engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
|
||||
peerDependencies:
|
||||
canvas: ^3.2.3
|
||||
peerDependenciesMeta:
|
||||
canvas:
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==}
|
||||
|
||||
@@ -2444,6 +2552,10 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==}
|
||||
engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'}
|
||||
@@ -2467,6 +2579,9 @@ packages:
|
||||
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-kOy3OxT2HH39N70UnKgu4NWDZjLOz8W/mfyvniHjRH/DrL3f2pOfvWQ4p60offbbtDAnXWp0v9LfMIqMec269Q==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -2546,8 +2661,8 @@ packages:
|
||||
next: ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0
|
||||
|
||||
[email protected].3:
|
||||
resolution: {integrity: sha512-tuRTx1nQ/yVw83cwJBo9F+njGUgMn3UHQycreWHB8XsStvvAh1AthbI8/4IpKnFaF58F+iSiHejYOlMQ/eq83g==}
|
||||
[email protected].4:
|
||||
resolution: {integrity: sha512-/Ztf6CeRH+ejEXUrYtqI4gkS66eFIHuSwqi60RgcpWKodxFZx2/dqVCMKBwILfAHXQ+F1b1vAudgj3mnxqtoIA==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
@@ -2600,6 +2715,9 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
||||
|
||||
@@ -2624,6 +2742,16 @@ packages:
|
||||
resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==}
|
||||
engines: {node: '>=20'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==}
|
||||
engines: {node: '>=20'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-NdTrKgh0oO7+y+RFX8KKhOB438x/j94UGXEFzl/7pHH0JjWSn42iJ9tgmPksIO6n1JKDHmNDcejPJfKspljB9g==}
|
||||
|
||||
@@ -2655,6 +2783,10 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
|
||||
|
||||
@@ -2717,6 +2849,10 @@ packages:
|
||||
resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg==}
|
||||
|
||||
@@ -2747,6 +2883,10 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
|
||||
engines: {node: '>=v12.22.7'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
|
||||
|
||||
@@ -2856,6 +2996,9 @@ packages:
|
||||
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==}
|
||||
|
||||
@@ -2885,10 +3028,25 @@ packages:
|
||||
resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-CW3WN2rIIE/Of21mulhgnGOwoDyEFNygyIBOONSdyAuSATgMMUCpLeUlB+E8sAwA5xRV9hYPl+kyZ9citHCaKg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-aBiNayCfTQxuIJBm06M+xR14cYaYlDlSXZbgsnKzKNxDKUVq7KFwTjwBSsb7m9Y5xO8WfPnBc63WaYFMTGlvqw==}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==}
|
||||
engines: {node: '>=14.16'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
@@ -2913,6 +3071,10 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-YQ3WlbqjYMmNpdvDH64jAgLjxuAR9+649calDWhbshYaeQGO2bR4nI94ORJmwI3J9YhoKQnpyGOK+0zlWS5N5Q==}
|
||||
engines: {node: '>=22.19.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -3030,10 +3192,30 @@ packages:
|
||||
jsdom:
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==}
|
||||
engines: {node: ^22.14.0 || >=24.0.0}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -3045,6 +3227,13 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==}
|
||||
engines: {node: '>= 14.6'}
|
||||
@@ -3057,6 +3246,21 @@ snapshots:
|
||||
|
||||
'@alloc/[email protected]': {}
|
||||
|
||||
'@asamuzakjp/[email protected]':
|
||||
dependencies:
|
||||
'@csstools/css-calc': 3.3.0(@csstools/[email protected](@csstools/[email protected]))(@csstools/[email protected])
|
||||
'@csstools/css-color-parser': 4.2.2(@csstools/[email protected](@csstools/[email protected]))(@csstools/[email protected])
|
||||
'@csstools/css-parser-algorithms': 4.0.0(@csstools/[email protected])
|
||||
'@csstools/css-tokenizer': 4.0.0
|
||||
lru-cache: 11.5.2
|
||||
|
||||
'@asamuzakjp/[email protected]':
|
||||
dependencies:
|
||||
bidi-js: 1.0.3
|
||||
css-tree: 3.2.1
|
||||
is-potential-custom-element-name: 1.0.1
|
||||
lru-cache: 11.5.2
|
||||
|
||||
'@auth/[email protected]':
|
||||
dependencies:
|
||||
'@panva/hkdf': 1.2.1
|
||||
@@ -3142,6 +3346,34 @@ snapshots:
|
||||
|
||||
'@borewit/[email protected]': {}
|
||||
|
||||
'@bramus/[email protected]':
|
||||
dependencies:
|
||||
css-tree: 3.2.1
|
||||
|
||||
'@csstools/[email protected]': {}
|
||||
|
||||
'@csstools/[email protected](@csstools/[email protected](@csstools/[email protected]))(@csstools/[email protected])':
|
||||
dependencies:
|
||||
'@csstools/css-parser-algorithms': 4.0.0(@csstools/[email protected])
|
||||
'@csstools/css-tokenizer': 4.0.0
|
||||
|
||||
'@csstools/[email protected](@csstools/[email protected](@csstools/[email protected]))(@csstools/[email protected])':
|
||||
dependencies:
|
||||
'@csstools/color-helpers': 6.1.1
|
||||
'@csstools/css-calc': 3.3.0(@csstools/[email protected](@csstools/[email protected]))(@csstools/[email protected])
|
||||
'@csstools/css-parser-algorithms': 4.0.0(@csstools/[email protected])
|
||||
'@csstools/css-tokenizer': 4.0.0
|
||||
|
||||
'@csstools/[email protected](@csstools/[email protected])':
|
||||
dependencies:
|
||||
'@csstools/css-tokenizer': 4.0.0
|
||||
|
||||
'@csstools/[email protected]([email protected])':
|
||||
optionalDependencies:
|
||||
css-tree: 3.2.1
|
||||
|
||||
'@csstools/[email protected]': {}
|
||||
|
||||
'@dnd-kit/[email protected]([email protected])':
|
||||
dependencies:
|
||||
react: 19.2.8
|
||||
@@ -3289,6 +3521,10 @@ snapshots:
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@exodus/[email protected](@noble/[email protected])':
|
||||
optionalDependencies:
|
||||
'@noble/hashes': 2.4.0
|
||||
|
||||
'@floating-ui/[email protected]':
|
||||
dependencies:
|
||||
'@floating-ui/utils': 0.2.12
|
||||
@@ -3460,30 +3696,30 @@ snapshots:
|
||||
'@tybys/wasm-util': 0.10.3
|
||||
optional: true
|
||||
|
||||
'@next/[email protected].3': {}
|
||||
'@next/[email protected].4': {}
|
||||
|
||||
'@next/[email protected].3':
|
||||
'@next/[email protected].4':
|
||||
optional: true
|
||||
|
||||
'@next/[email protected].3':
|
||||
'@next/[email protected].4':
|
||||
optional: true
|
||||
|
||||
'@next/[email protected].3':
|
||||
'@next/[email protected].4':
|
||||
optional: true
|
||||
|
||||
'@next/[email protected].3':
|
||||
'@next/[email protected].4':
|
||||
optional: true
|
||||
|
||||
'@next/[email protected].3':
|
||||
'@next/[email protected].4':
|
||||
optional: true
|
||||
|
||||
'@next/[email protected].3':
|
||||
'@next/[email protected].4':
|
||||
optional: true
|
||||
|
||||
'@next/[email protected].3':
|
||||
'@next/[email protected].4':
|
||||
optional: true
|
||||
|
||||
'@next/[email protected].3':
|
||||
'@next/[email protected].4':
|
||||
optional: true
|
||||
|
||||
'@noble/[email protected]': {}
|
||||
@@ -3697,6 +3933,10 @@ snapshots:
|
||||
|
||||
'@pinojs/[email protected]': {}
|
||||
|
||||
'@playwright/[email protected]':
|
||||
dependencies:
|
||||
playwright: 1.62.1
|
||||
|
||||
'@radix-ui/[email protected]': {}
|
||||
|
||||
'@radix-ui/[email protected](@types/[email protected])([email protected])':
|
||||
@@ -4153,7 +4393,7 @@ snapshots:
|
||||
obug: 2.1.4
|
||||
std-env: 4.2.0
|
||||
tinyrainbow: 3.1.1
|
||||
vitest: 4.1.11(@types/[email protected])(@vitest/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
|
||||
vitest: 4.1.11(@types/[email protected])(@vitest/[email protected])([email protected](@noble/[email protected]))([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
|
||||
|
||||
'@vitest/[email protected]':
|
||||
dependencies:
|
||||
@@ -4214,6 +4454,10 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
require-from-string: 2.0.2
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
@@ -4252,10 +4496,22 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
mdn-data: 2.27.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected](@noble/[email protected]):
|
||||
dependencies:
|
||||
whatwg-mimetype: 5.0.0
|
||||
whatwg-url: 16.0.1(@noble/[email protected])
|
||||
transitivePeerDependencies:
|
||||
- '@noble/hashes'
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]([email protected]):
|
||||
@@ -4264,6 +4520,8 @@ snapshots:
|
||||
optionalDependencies:
|
||||
supports-color: 7.2.0
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
@@ -4294,6 +4552,8 @@ snapshots:
|
||||
graceful-fs: 4.2.11
|
||||
tapable: 2.3.3
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
@@ -4368,6 +4628,9 @@ snapshots:
|
||||
react: 19.2.8
|
||||
react-dom: 19.2.8([email protected])
|
||||
|
||||
[email protected]:
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
optional: true
|
||||
|
||||
@@ -4389,6 +4652,12 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected](@noble/[email protected]):
|
||||
dependencies:
|
||||
'@exodus/bytes': 1.15.1(@noble/[email protected])
|
||||
transitivePeerDependencies:
|
||||
- '@noble/hashes'
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
@@ -4429,10 +4698,20 @@ snapshots:
|
||||
dependencies:
|
||||
is-extglob: 2.1.1
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected](@noble/[email protected]):
|
||||
dependencies:
|
||||
dompurify: 3.4.14
|
||||
jsdom: 30.0.1(@noble/[email protected])
|
||||
transitivePeerDependencies:
|
||||
- '@noble/hashes'
|
||||
- canvas
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
@@ -4456,6 +4735,32 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected](@noble/[email protected]):
|
||||
dependencies:
|
||||
'@asamuzakjp/css-color': 6.0.7
|
||||
'@asamuzakjp/dom-selector': 8.3.2
|
||||
'@bramus/specificity': 2.4.2
|
||||
'@csstools/css-syntax-patches-for-csstree': 1.1.12([email protected])
|
||||
'@exodus/bytes': 1.15.1(@noble/[email protected])
|
||||
css-tree: 3.2.1
|
||||
data-urls: 7.0.0(@noble/[email protected])
|
||||
decimal.js: 10.6.0
|
||||
html-encoding-sniffer: 6.0.0(@noble/[email protected])
|
||||
is-potential-custom-element-name: 1.0.1
|
||||
lru-cache: 11.5.2
|
||||
parse5: 8.0.1
|
||||
saxes: 6.0.0
|
||||
symbol-tree: 3.2.4
|
||||
tough-cookie: 6.0.2
|
||||
undici: 8.10.1
|
||||
w3c-xmlserializer: 5.0.0
|
||||
webidl-conversions: 8.0.1
|
||||
whatwg-mimetype: 5.0.0
|
||||
whatwg-url: 17.1.0(@noble/[email protected])
|
||||
xml-name-validator: 5.0.0
|
||||
transitivePeerDependencies:
|
||||
- '@noble/hashes'
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
@@ -4597,6 +4902,8 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]([email protected]):
|
||||
@@ -4619,6 +4926,8 @@ snapshots:
|
||||
dependencies:
|
||||
semver: 7.8.5
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
@@ -4677,15 +4986,15 @@ snapshots:
|
||||
dependencies:
|
||||
content-type: 2.1.0
|
||||
|
||||
[email protected]([email protected].3(@types/[email protected])([email protected]([email protected]))([email protected]))([email protected]):
|
||||
[email protected]([email protected].4(@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected]))([email protected]):
|
||||
dependencies:
|
||||
'@auth/core': 0.41.3
|
||||
next: 16.3.3(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
next: 16.3.4(@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
react: 19.2.8
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected](@swc/[email protected])([email protected].3(@types/[email protected])([email protected]([email protected]))([email protected]))([email protected]):
|
||||
[email protected](@swc/[email protected])([email protected].4(@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected]))([email protected]):
|
||||
dependencies:
|
||||
'@eloqnt/config': 0.0.2
|
||||
'@eloqnt/format-json': 0.0.3
|
||||
@@ -4695,16 +5004,16 @@ snapshots:
|
||||
'@swc/core': 1.16.1(@swc/[email protected])
|
||||
icu-minify: 4.14.1
|
||||
negotiator: 1.1.0
|
||||
next: 16.3.3(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
next: 16.3.4(@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
next-intl-swc-plugin-extractor: 4.14.1
|
||||
react: 19.2.8
|
||||
use-intl: 4.14.1([email protected])
|
||||
transitivePeerDependencies:
|
||||
- '@swc/helpers'
|
||||
|
||||
[email protected].3(@types/[email protected])([email protected]([email protected]))([email protected]):
|
||||
[email protected].4(@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected]):
|
||||
dependencies:
|
||||
'@next/env': 16.3.3
|
||||
'@next/env': 16.3.4
|
||||
'@swc/helpers': 0.5.23
|
||||
baseline-browser-mapping: 2.11.20
|
||||
caniuse-lite: 1.0.30001810
|
||||
@@ -4713,14 +5022,15 @@ snapshots:
|
||||
react-dom: 19.2.8([email protected])
|
||||
styled-jsx: 5.1.6([email protected])
|
||||
optionalDependencies:
|
||||
'@next/swc-darwin-arm64': 16.3.3
|
||||
'@next/swc-darwin-x64': 16.3.3
|
||||
'@next/swc-linux-arm64-gnu': 16.3.3
|
||||
'@next/swc-linux-arm64-musl': 16.3.3
|
||||
'@next/swc-linux-x64-gnu': 16.3.3
|
||||
'@next/swc-linux-x64-musl': 16.3.3
|
||||
'@next/swc-win32-arm64-msvc': 16.3.3
|
||||
'@next/swc-win32-x64-msvc': 16.3.3
|
||||
'@next/swc-darwin-arm64': 16.3.4
|
||||
'@next/swc-darwin-x64': 16.3.4
|
||||
'@next/swc-linux-arm64-gnu': 16.3.4
|
||||
'@next/swc-linux-arm64-musl': 16.3.4
|
||||
'@next/swc-linux-x64-gnu': 16.3.4
|
||||
'@next/swc-linux-x64-musl': 16.3.4
|
||||
'@next/swc-win32-arm64-msvc': 16.3.4
|
||||
'@next/swc-win32-x64-msvc': 16.3.4
|
||||
'@playwright/test': 1.62.1
|
||||
sharp: 0.35.4(@types/[email protected])
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
@@ -4798,6 +5108,10 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
entities: 8.0.0
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
@@ -4840,6 +5154,14 @@ snapshots:
|
||||
sonic-boom: 4.2.1
|
||||
thread-stream: 4.2.0
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
playwright-core: 1.62.1
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.2
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
@@ -4870,6 +5192,8 @@ snapshots:
|
||||
end-of-stream: 1.4.5
|
||||
once: 1.4.0
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]([email protected]):
|
||||
@@ -4926,6 +5250,8 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
@@ -4962,6 +5288,10 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
xmlchars: 2.2.0
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
@@ -5065,6 +5395,8 @@ snapshots:
|
||||
dependencies:
|
||||
has-flag: 4.0.0
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
@@ -5086,12 +5418,26 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
tldts-core: 7.4.11
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@borewit/text-codec': 0.2.2
|
||||
'@tokenizer/token': 0.3.0
|
||||
ieee754: 1.2.1
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
tldts: 7.4.11
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
punycode: 2.3.1
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
@@ -5129,6 +5475,8 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected](@types/[email protected])([email protected]):
|
||||
dependencies:
|
||||
react: 19.2.8
|
||||
@@ -5173,7 +5521,7 @@ snapshots:
|
||||
tsx: 4.23.13
|
||||
yaml: 2.9.0
|
||||
|
||||
[email protected](@types/[email protected])(@vitest/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])):
|
||||
[email protected](@types/[email protected])(@vitest/[email protected])([email protected](@noble/[email protected]))([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.1.11
|
||||
'@vitest/mocker': 4.1.11([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
|
||||
@@ -5198,11 +5546,36 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/node': 26.4.0
|
||||
'@vitest/coverage-v8': 4.1.11([email protected])
|
||||
jsdom: 30.0.1(@noble/[email protected])
|
||||
transitivePeerDependencies:
|
||||
- msw
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
xml-name-validator: 5.0.0
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected](@noble/[email protected]):
|
||||
dependencies:
|
||||
'@exodus/bytes': 1.15.1(@noble/[email protected])
|
||||
tr46: 6.0.0
|
||||
webidl-conversions: 8.0.1
|
||||
transitivePeerDependencies:
|
||||
- '@noble/hashes'
|
||||
|
||||
[email protected](@noble/[email protected]):
|
||||
dependencies:
|
||||
'@exodus/bytes': 1.15.1(@noble/[email protected])
|
||||
tr46: 6.0.0
|
||||
webidl-conversions: 8.0.1
|
||||
transitivePeerDependencies:
|
||||
- '@noble/hashes'
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
siginfo: 2.0.0
|
||||
@@ -5212,6 +5585,10 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
Executable
+89
@@ -0,0 +1,89 @@
|
||||
#!/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
|
||||
|
||||
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. 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. Rebuild the image ---
|
||||
docker compose build >>"$LOG_FILE" 2>&1 || die "docker compose build failed" 2
|
||||
log "docker compose build OK"
|
||||
|
||||
# --- 4. Recreate the container ---
|
||||
docker compose up -d >>"$LOG_FILE" 2>&1 || die "docker compose up failed" 2
|
||||
log "docker compose up OK"
|
||||
|
||||
# --- 5. 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
|
||||
|
||||
# --- 6. 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
|
||||
+47
-2
@@ -1,8 +1,13 @@
|
||||
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";
|
||||
@@ -224,6 +229,37 @@ 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 as unknown as {
|
||||
affectedRows?: number;
|
||||
rowsAffected?: number;
|
||||
};
|
||||
const published = info.affectedRows ?? info.rowsAffected ?? 0;
|
||||
if (published > 0) {
|
||||
logger.info(`Published ${published} scheduled article(s)`, {
|
||||
module: "jobs",
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
captureWorkerError(err, "Scheduled article publish failed");
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
logger.info("Worker started", { module: "jobs" });
|
||||
|
||||
@@ -257,6 +293,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(),
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -10,8 +10,27 @@ import {
|
||||
WebsiteArticleReactions,
|
||||
WebsiteArticles,
|
||||
} from "@/lib/db";
|
||||
import { formPositiveBigInt } from "@/lib/form-data";
|
||||
import { slugify } from "@/lib/format";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { notify } from "@/lib/services/webhook";
|
||||
|
||||
const ARTICLE_STATUSES = ["published", "scheduled", "draft"] as const;
|
||||
type ArticleStatus = (typeof ARTICLE_STATUSES)[number];
|
||||
|
||||
function parseArticleStatus(raw: unknown): ArticleStatus {
|
||||
const value = String(raw ?? "published").trim();
|
||||
return (ARTICLE_STATUSES as readonly string[]).includes(value)
|
||||
? (value as ArticleStatus)
|
||||
: "published";
|
||||
}
|
||||
|
||||
function parsePublishAt(raw: unknown): Date | null {
|
||||
const value = String(raw ?? "").trim();
|
||||
if (!value) return null;
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
async function uniqueSlug(title: string): Promise<string> {
|
||||
const base = slugify(title);
|
||||
@@ -43,12 +62,21 @@ export async function createArticle(formData: FormData): Promise<void> {
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const rawSlug = String(formData.get("slug") ?? "").trim();
|
||||
const status = parseArticleStatus(formData.get("status"));
|
||||
const rawPublishAt = String(formData.get("publishAt") ?? "").trim();
|
||||
if (!title) return;
|
||||
if (status === "scheduled" && !parsePublishAt(rawPublishAt)) {
|
||||
redirect(
|
||||
"/admin/articles/new?error=Scheduled articles need a valid publish date.",
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const now = new Date();
|
||||
const publishAt = parsePublishAt(rawPublishAt);
|
||||
const slug = rawSlug ? await uniqueSlug(rawSlug) : await uniqueSlug(title);
|
||||
await db.insert(WebsiteArticles).values({
|
||||
slug: rawSlug ? await uniqueSlug(rawSlug) : await uniqueSlug(title),
|
||||
slug,
|
||||
title: title.slice(0, 255),
|
||||
shortStory: shortStory.slice(0, 255),
|
||||
fullStory,
|
||||
@@ -56,6 +84,16 @@ export async function createArticle(formData: FormData): Promise<void> {
|
||||
userId: staff.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
status,
|
||||
publishAt,
|
||||
publishedAt: status === "published" ? now : null,
|
||||
});
|
||||
|
||||
notify({
|
||||
action: "news_publish",
|
||||
actor: staff.username,
|
||||
target: title,
|
||||
details: slug,
|
||||
});
|
||||
} catch {
|
||||
// Database error — re-render unchanged with error.
|
||||
@@ -68,9 +106,28 @@ export async function createArticle(formData: FormData): Promise<void> {
|
||||
|
||||
export async function updateArticle(formData: FormData): Promise<void> {
|
||||
await requirePermission(PERMS.NEWS_EDIT);
|
||||
const id = BigInt(String(formData.get("id")));
|
||||
const id = formPositiveBigInt(formData, "id");
|
||||
if (!id) redirect("/admin/articles?error=Missing article id");
|
||||
const rawSlug = String(formData.get("slug") ?? "").trim();
|
||||
const status = parseArticleStatus(formData.get("status"));
|
||||
const rawPublishAt = String(formData.get("publishAt") ?? "").trim();
|
||||
if (status === "scheduled" && !parsePublishAt(rawPublishAt)) {
|
||||
redirect(
|
||||
"/admin/articles?error=Scheduled articles need a valid publish date.",
|
||||
);
|
||||
}
|
||||
try {
|
||||
const publishAt = parsePublishAt(rawPublishAt);
|
||||
const [existing] = await db
|
||||
.select({
|
||||
status: WebsiteArticles.status,
|
||||
publishedAt: WebsiteArticles.publishedAt,
|
||||
})
|
||||
.from(WebsiteArticles)
|
||||
.where(eq(WebsiteArticles.id, id))
|
||||
.limit(1);
|
||||
const publishedAt =
|
||||
status === "published" ? (existing?.publishedAt ?? new Date()) : null;
|
||||
await db
|
||||
.update(WebsiteArticles)
|
||||
.set({
|
||||
@@ -90,6 +147,9 @@ export async function updateArticle(formData: FormData): Promise<void> {
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
status,
|
||||
publishAt,
|
||||
publishedAt,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(WebsiteArticles.id, id));
|
||||
@@ -101,8 +161,14 @@ export async function updateArticle(formData: FormData): Promise<void> {
|
||||
}
|
||||
|
||||
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,6 +179,12 @@ export async function deleteArticle(formData: FormData): Promise<void> {
|
||||
.where(eq(WebsiteArticleComments.articleId, id));
|
||||
await tx.delete(WebsiteArticles).where(eq(WebsiteArticles.id, id));
|
||||
});
|
||||
|
||||
notify({
|
||||
action: "news_delete",
|
||||
actor: staff.username,
|
||||
target: article?.title ?? String(id),
|
||||
});
|
||||
} catch {
|
||||
redirect("/admin/articles?error=Delete failed");
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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)");
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
actionOk,
|
||||
} from "@/lib/safe-action-shared";
|
||||
import { logAudit } from "@/lib/services/audit";
|
||||
import { notify } from "@/lib/services/webhook";
|
||||
import {
|
||||
createPollSchema,
|
||||
pollQuestionPatchSchema,
|
||||
@@ -53,6 +54,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";
|
||||
|
||||
+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();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,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,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,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,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";
|
||||
@@ -56,7 +56,16 @@ export async function generateMetadata({
|
||||
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" };
|
||||
@@ -108,7 +117,16 @@ export default async function ArticlePage({
|
||||
updatedAt: WebsiteArticles.updatedAt,
|
||||
})
|
||||
.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(() => []);
|
||||
return row ?? null;
|
||||
|
||||
+26
-2
@@ -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";
|
||||
@@ -235,7 +251,7 @@ export default async function Home() {
|
||||
<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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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 =
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
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 { db, User, WebsiteTeams } from "@/lib/db";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
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;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { and, count, eq, gte, sql, sum } from "drizzle-orm";
|
||||
import { ArrowLeftRight } from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getLocale, getTranslations } from "next-intl/server";
|
||||
import { RevenueChart } from "@/components/admin/revenue-chart";
|
||||
import { StatsCard } from "@/components/admin/stats-card";
|
||||
import { CurrencyIcon } from "@/components/shared/currency-icon";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
@@ -16,6 +16,18 @@ import {
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { redisCache } from "@/lib/redis-cache";
|
||||
|
||||
const RevenueChart = dynamic(
|
||||
() =>
|
||||
import("@/components/admin/revenue-chart").then((m) => ({
|
||||
default: m.RevenueChart,
|
||||
})),
|
||||
{
|
||||
loading: () => (
|
||||
<div className="animate-pulse bg-[var(--admin-surface)] rounded h-48" />
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
type EconomyData = {
|
||||
totalCredits: number;
|
||||
totalPixels: number;
|
||||
|
||||
@@ -7,16 +7,39 @@ import {
|
||||
TrendingUp,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getLocale, getTranslations } from "next-intl/server";
|
||||
import { DashboardChart } from "@/components/admin/dashboard-chart";
|
||||
import { PeakHoursHeatmap } from "@/components/admin/peak-hours-heatmap";
|
||||
import { StatsCard } from "@/components/admin/stats-card";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Ban, db, Rooms, RoomTradeLog, User } from "@/lib/db";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { redisCache } from "@/lib/redis-cache";
|
||||
|
||||
const DashboardChart = dynamic(
|
||||
() =>
|
||||
import("@/components/admin/dashboard-chart").then((m) => ({
|
||||
default: m.DashboardChart,
|
||||
})),
|
||||
{
|
||||
loading: () => (
|
||||
<div className="animate-pulse bg-[var(--admin-surface)] rounded h-48" />
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
const PeakHoursHeatmap = dynamic(
|
||||
() =>
|
||||
import("@/components/admin/peak-hours-heatmap").then((m) => ({
|
||||
default: m.PeakHoursHeatmap,
|
||||
})),
|
||||
{
|
||||
loading: () => (
|
||||
<div className="animate-pulse bg-[var(--admin-surface)] rounded h-48" />
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
type AnalyticsData = {
|
||||
totalUsers: number;
|
||||
onlineUsers: number;
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { desc, inArray } from "drizzle-orm";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { dismissApplication } from "@/actions/admin-applications";
|
||||
import { ApplicationActions } from "@/components/admin/application-actions";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import Link from "@/components/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import { db, User, WebsiteStaffApplications } from "@/lib/db";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { logServerError } from "@/lib/server-log";
|
||||
|
||||
type ApplicationRow = {
|
||||
id: bigint;
|
||||
@@ -31,7 +32,8 @@ export default async function AdminApplications() {
|
||||
.from(WebsiteStaffApplications)
|
||||
.orderBy(desc(WebsiteStaffApplications.createdAt))
|
||||
.limit(100);
|
||||
} catch {
|
||||
} catch (error) {
|
||||
logServerError("applications.query_failed", error);
|
||||
applications = [];
|
||||
}
|
||||
|
||||
@@ -45,8 +47,8 @@ export default async function AdminApplications() {
|
||||
.from(User)
|
||||
.where(inArray(User.id, ids));
|
||||
for (const u of users) userMap.set(u.id, u.username);
|
||||
} catch {
|
||||
// no DB — fall back to raw ids
|
||||
} catch (error) {
|
||||
logServerError("applications.user_lookup_failed", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,12 +95,7 @@ export default async function AdminApplications() {
|
||||
<p className="text-sm text-[var(--admin-text)] whitespace-pre-wrap m-0 mb-3">
|
||||
{a.content}
|
||||
</p>
|
||||
<form action={dismissApplication}>
|
||||
<input type="hidden" name="id" value={String(a.id)} />
|
||||
<Button type="submit" variant="destructive">
|
||||
{t("dismiss")}
|
||||
</Button>
|
||||
</form>
|
||||
<ApplicationActions id={String(a.id)} />
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { CatalogMaintenancePanel } from "@/components/admin/catalog/catalog-maintenance-panel";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
|
||||
export default async function CatalogMaintenancePage() {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.CATALOG_VIEW, session.user.rank)) {
|
||||
redirect("/admin");
|
||||
}
|
||||
|
||||
export default function CatalogMaintenancePage() {
|
||||
return <CatalogMaintenancePanel />;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { asc, count, eq, inArray } from "drizzle-orm";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { disbandGuild } from "@/actions/admin-guilds";
|
||||
import { disbandGuild, updateGuild } from "@/actions/admin-guilds";
|
||||
import Link from "@/components/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { db, Guilds, GuildsForumsThreads, GuildsMembers, User } from "@/lib/db";
|
||||
@@ -35,6 +35,10 @@ export default async function AdminGuildDetailPage({
|
||||
dateCreated: Guilds.dateCreated,
|
||||
state: Guilds.state,
|
||||
forum: Guilds.forum,
|
||||
readForum: Guilds.readForum,
|
||||
postMessages: Guilds.postMessages,
|
||||
postThreads: Guilds.postThreads,
|
||||
modForum: Guilds.modForum,
|
||||
})
|
||||
.from(Guilds)
|
||||
.where(eq(Guilds.id, id))
|
||||
@@ -139,6 +143,142 @@ export default async function AdminGuildDetailPage({
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{canEdit ? (
|
||||
<section>
|
||||
<h2 className="admin-section-title">{t("edit")}</h2>
|
||||
<form action={updateGuild} className="space-y-3">
|
||||
<input type="hidden" name="id" value={guild.id} />
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(200px,1fr))] gap-3">
|
||||
<div>
|
||||
<label htmlFor="guild-name" className="text-xs muted">
|
||||
{t("formName")}
|
||||
</label>
|
||||
<input
|
||||
id="guild-name"
|
||||
type="text"
|
||||
name="name"
|
||||
maxLength={50}
|
||||
defaultValue={guild.name}
|
||||
className="w-full rounded border bg-[var(--admin-card)] px-3 py-1.5 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="guild-description" className="text-xs muted">
|
||||
{t("formDescription")}
|
||||
</label>
|
||||
<input
|
||||
id="guild-description"
|
||||
type="text"
|
||||
name="description"
|
||||
maxLength={250}
|
||||
defaultValue={guild.description}
|
||||
className="w-full rounded border bg-[var(--admin-card)] px-3 py-1.5 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="guild-state" className="text-xs muted">
|
||||
{t("formState")}
|
||||
</label>
|
||||
<select
|
||||
id="guild-state"
|
||||
name="state"
|
||||
defaultValue={guild.state}
|
||||
className="w-full rounded border bg-[var(--admin-card)] px-3 py-1.5 text-sm"
|
||||
>
|
||||
<option value="0">0 — Normal</option>
|
||||
<option value="1">1 — Locked</option>
|
||||
<option value="2">2 — Exclusive</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="guild-forum" className="text-xs muted">
|
||||
{t("formForum")}
|
||||
</label>
|
||||
<select
|
||||
id="guild-forum"
|
||||
name="forum"
|
||||
defaultValue={guild.forum}
|
||||
className="w-full rounded border bg-[var(--admin-card)] px-3 py-1.5 text-sm"
|
||||
>
|
||||
<option value="0">0 — Off</option>
|
||||
<option value="1">1 — On</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="guild-readForum" className="text-xs muted">
|
||||
{t("formReadForum")}
|
||||
</label>
|
||||
<select
|
||||
id="guild-readForum"
|
||||
name="readForum"
|
||||
defaultValue={guild.readForum}
|
||||
className="w-full rounded border bg-[var(--admin-card)] px-3 py-1.5 text-sm"
|
||||
>
|
||||
<option value="EVERYONE">EVERYONE</option>
|
||||
<option value="OWNER">OWNER</option>
|
||||
<option value="ADMIN">ADMIN</option>
|
||||
<option value="MEMBER">MEMBER</option>
|
||||
<option value="NONE">NONE</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="guild-postMessages" className="text-xs muted">
|
||||
{t("formPostMessages")}
|
||||
</label>
|
||||
<select
|
||||
id="guild-postMessages"
|
||||
name="postMessages"
|
||||
defaultValue={guild.postMessages}
|
||||
className="w-full rounded border bg-[var(--admin-card)] px-3 py-1.5 text-sm"
|
||||
>
|
||||
<option value="EVERYONE">EVERYONE</option>
|
||||
<option value="OWNER">OWNER</option>
|
||||
<option value="ADMIN">ADMIN</option>
|
||||
<option value="MEMBER">MEMBER</option>
|
||||
<option value="NONE">NONE</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="guild-postThreads" className="text-xs muted">
|
||||
{t("formPostThreads")}
|
||||
</label>
|
||||
<select
|
||||
id="guild-postThreads"
|
||||
name="postThreads"
|
||||
defaultValue={guild.postThreads}
|
||||
className="w-full rounded border bg-[var(--admin-card)] px-3 py-1.5 text-sm"
|
||||
>
|
||||
<option value="EVERYONE">EVERYONE</option>
|
||||
<option value="OWNER">OWNER</option>
|
||||
<option value="ADMIN">ADMIN</option>
|
||||
<option value="MEMBER">MEMBER</option>
|
||||
<option value="NONE">NONE</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="guild-modForum" className="text-xs muted">
|
||||
{t("formModForum")}
|
||||
</label>
|
||||
<select
|
||||
id="guild-modForum"
|
||||
name="modForum"
|
||||
defaultValue={guild.modForum}
|
||||
className="w-full rounded border bg-[var(--admin-card)] px-3 py-1.5 text-sm"
|
||||
>
|
||||
<option value="ADMINS">ADMINS</option>
|
||||
<option value="OWNER">OWNER</option>
|
||||
<option value="MEMBER">MEMBER</option>
|
||||
<option value="NONE">NONE</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="submit" variant="ghost" size="sm">
|
||||
{t("save")}
|
||||
</Button>
|
||||
</form>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(160px,1fr))] gap-3">
|
||||
<div className="admin-card">
|
||||
<div className="text-xs muted">{t("colOwner")}</div>
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
import dynamic from "next/dynamic";
|
||||
import { redirect } from "next/navigation";
|
||||
import { AdminMediaGrid } from "@/components/admin/media-grid";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
|
||||
const AdminMediaGrid = dynamic(
|
||||
() =>
|
||||
import("@/components/admin/media-grid").then((m) => ({
|
||||
default: m.AdminMediaGrid,
|
||||
})),
|
||||
{
|
||||
loading: () => (
|
||||
<div className="animate-pulse bg-[var(--admin-surface)] rounded h-48" />
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
export default async function AdminMediaPage() {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.PAGES_VIEW, session.user.rank)) {
|
||||
|
||||
@@ -104,6 +104,7 @@ export function ModActionsClient() {
|
||||
value={userId}
|
||||
onChange={(e) => setUserId(e.target.value)}
|
||||
placeholder="Enter user ID"
|
||||
aria-label="User ID"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -144,6 +145,7 @@ export function ModActionsClient() {
|
||||
onChange={(e) => setMuteDuration(e.target.value)}
|
||||
className="w-24"
|
||||
placeholder="Min"
|
||||
aria-label="Mute duration in minutes"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -215,6 +217,7 @@ export function ModActionsClient() {
|
||||
value={roomId}
|
||||
onChange={(e) => setRoomId(e.target.value)}
|
||||
placeholder="Enter room ID"
|
||||
aria-label="Room ID"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
|
||||
@@ -13,6 +13,7 @@ import Link from "@/components/link";
|
||||
import { unixNow } from "@/lib/bans";
|
||||
import { Ban, db, StaffActivities, User, WebsiteArticles } from "@/lib/db";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { logServerError } from "@/lib/server-log";
|
||||
|
||||
type RankCount = { rank: number; count: number };
|
||||
|
||||
@@ -37,7 +38,9 @@ export default async function AdminDashboard() {
|
||||
online = onlineRow[0]?.total ?? 0;
|
||||
articles = articlesRow[0]?.total ?? 0;
|
||||
bans = bansRow[0]?.total ?? 0;
|
||||
} catch {}
|
||||
} catch (error) {
|
||||
logServerError("admin.dashboard_stats_failed", error);
|
||||
}
|
||||
const dbOk = users >= 0;
|
||||
|
||||
const rankGroups = await db
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
createValue,
|
||||
deleteCategory,
|
||||
deleteValue,
|
||||
updateCategory,
|
||||
updateValue,
|
||||
} from "@/actions/admin-rare-values";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -120,12 +122,49 @@ export default async function AdminRareValues() {
|
||||
{t("itemCount", { count: rows.length })}
|
||||
</span>
|
||||
</div>
|
||||
<form action={deleteCategory}>
|
||||
<input type="hidden" name="id" value={catId} />
|
||||
<Button type="submit" variant="destructive">
|
||||
{t("deleteCategory")}
|
||||
</Button>
|
||||
</form>
|
||||
<div className="flex gap-2 items-center">
|
||||
<details className="group">
|
||||
<summary className="cursor-pointer list-none text-xs font-bold text-[var(--admin-accent)] hover:underline">
|
||||
{t("editCategory")}
|
||||
</summary>
|
||||
<form
|
||||
action={updateCategory}
|
||||
className="mt-2 flex gap-2 flex-wrap"
|
||||
>
|
||||
<input type="hidden" name="id" value={catId} />
|
||||
<input
|
||||
name="name"
|
||||
defaultValue={cat.name}
|
||||
placeholder={t("categoryForm.namePlaceholder")}
|
||||
required
|
||||
className="flex-1 min-w-[140px]"
|
||||
/>
|
||||
<input
|
||||
name="badge"
|
||||
defaultValue={cat.badge}
|
||||
placeholder={t("badgePlaceholder")}
|
||||
required
|
||||
/>
|
||||
<input
|
||||
name="priority"
|
||||
type="number"
|
||||
min={1}
|
||||
defaultValue={cat.priority}
|
||||
placeholder={t("priorityPlaceholder")}
|
||||
className="w-[110px]"
|
||||
/>
|
||||
<Button type="submit" variant="default">
|
||||
{t("categoryForm.save")}
|
||||
</Button>
|
||||
</form>
|
||||
</details>
|
||||
<form action={deleteCategory}>
|
||||
<input type="hidden" name="id" value={catId} />
|
||||
<Button type="submit" variant="destructive">
|
||||
{t("deleteCategory")}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table style={{ marginTop: "0.75rem" }}>
|
||||
@@ -141,32 +180,108 @@ export default async function AdminRareValues() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((v) => (
|
||||
<tr key={String(v.id)}>
|
||||
<td>{v.name}</td>
|
||||
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
||||
{v.itemId ?? "—"}
|
||||
</td>
|
||||
<td>{v.creditValue ?? "—"}</td>
|
||||
<td>{v.currencyValue ?? "—"}</td>
|
||||
<td>
|
||||
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-accent)]/18 text-[var(--admin-text)]">
|
||||
{v.currencyType}
|
||||
</span>
|
||||
</td>
|
||||
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
||||
{v.furnitureIcon}
|
||||
</td>
|
||||
<td>
|
||||
<form action={deleteValue}>
|
||||
<input type="hidden" name="id" value={String(v.id)} />
|
||||
<Button type="submit" variant="destructive">
|
||||
✕
|
||||
</Button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{rows.map((v) => {
|
||||
const vId = String(v.id);
|
||||
return (
|
||||
<tr key={vId}>
|
||||
<td>{v.name}</td>
|
||||
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
||||
{v.itemId ?? "—"}
|
||||
</td>
|
||||
<td>{v.creditValue ?? "—"}</td>
|
||||
<td>{v.currencyValue ?? "—"}</td>
|
||||
<td>
|
||||
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-accent)]/18 text-[var(--admin-text)]">
|
||||
{v.currencyType}
|
||||
</span>
|
||||
</td>
|
||||
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
||||
{v.furnitureIcon}
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex gap-1 items-center">
|
||||
<details className="group">
|
||||
<summary className="cursor-pointer list-none text-[0.72rem] font-bold text-[var(--admin-accent)] hover:underline">
|
||||
{t("editValue")}
|
||||
</summary>
|
||||
<form
|
||||
action={updateValue}
|
||||
className="mt-2 flex gap-1 flex-wrap items-end"
|
||||
>
|
||||
<input type="hidden" name="id" value={vId} />
|
||||
<input
|
||||
type="hidden"
|
||||
name="categoryId"
|
||||
value={catId}
|
||||
/>
|
||||
<input
|
||||
name="name"
|
||||
defaultValue={v.name}
|
||||
placeholder={t(
|
||||
"itemForm.itemNamePlaceholder",
|
||||
)}
|
||||
required
|
||||
className="min-w-[120px]"
|
||||
/>
|
||||
<input
|
||||
name="itemId"
|
||||
type="number"
|
||||
min={1}
|
||||
defaultValue={v.itemId ?? ""}
|
||||
placeholder={t("itemForm.itemIdPlaceholder")}
|
||||
className="w-[90px]"
|
||||
/>
|
||||
<input
|
||||
name="furnitureIcon"
|
||||
defaultValue={v.furnitureIcon}
|
||||
placeholder={t("itemForm.iconPlaceholder")}
|
||||
required
|
||||
className="min-w-[120px]"
|
||||
/>
|
||||
<input
|
||||
name="creditValue"
|
||||
defaultValue={v.creditValue ?? ""}
|
||||
placeholder={t("itemForm.creditsPlaceholder")}
|
||||
className="w-[110px]"
|
||||
/>
|
||||
<input
|
||||
name="currencyValue"
|
||||
defaultValue={v.currencyValue ?? ""}
|
||||
placeholder={t(
|
||||
"itemForm.currencyValuePlaceholder",
|
||||
)}
|
||||
className="w-[110px]"
|
||||
/>
|
||||
<select
|
||||
name="currencyType"
|
||||
defaultValue={v.currencyType}
|
||||
>
|
||||
{CURRENCY_TYPES.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="default"
|
||||
className="text-xs"
|
||||
>
|
||||
{t("itemForm.save")}
|
||||
</Button>
|
||||
</form>
|
||||
</details>
|
||||
<form action={deleteValue}>
|
||||
<input type="hidden" name="id" value={vId} />
|
||||
<Button type="submit" variant="destructive">
|
||||
✕
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{rows.length === 0 ? (
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { CatalogMaintenancePanel } from "@/components/admin/catalog/catalog-maintenance-panel";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
|
||||
export default async function StudioCatalogMaintenancePage() {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.CATALOG_VIEW, session.user.rank)) {
|
||||
redirect("/admin");
|
||||
}
|
||||
|
||||
export default function StudioCatalogMaintenancePage() {
|
||||
return (
|
||||
<div className="min-h-0">
|
||||
<CatalogMaintenancePanel />
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { createVoucher, deleteVoucher } from "@/actions/admin-vouchers";
|
||||
import {
|
||||
createVoucher,
|
||||
deleteVoucher,
|
||||
updateVoucher,
|
||||
} from "@/actions/admin-vouchers";
|
||||
import { ConfirmDialog } from "@/components/admin/confirm-dialog";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { CurrencyIcon } from "@/components/shared/currency-icon";
|
||||
@@ -33,10 +37,15 @@ export function VouchersClient({
|
||||
const t = useTranslations("pages.admin.vouchers");
|
||||
const { run, isPending } = useServerAction();
|
||||
const [pendingDelete, setPendingDelete] = useState<VoucherRow | null>(null);
|
||||
const [editingVoucher, setEditingVoucher] = useState<VoucherRow | null>(null);
|
||||
const [code, setCode] = useState("");
|
||||
const [amount, setAmount] = useState("");
|
||||
const [maxUses, setMaxUses] = useState("1");
|
||||
const [expiresAt, setExpiresAt] = useState("");
|
||||
const [editCode, setEditCode] = useState("");
|
||||
const [editAmount, setEditAmount] = useState("");
|
||||
const [editMaxUses, setEditMaxUses] = useState("1");
|
||||
const [editExpiresAt, setEditExpiresAt] = useState("");
|
||||
|
||||
function handleCreate(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
@@ -72,6 +81,36 @@ export function VouchersClient({
|
||||
});
|
||||
}
|
||||
|
||||
function handleStartEdit(v: VoucherRow) {
|
||||
setEditingVoucher(v);
|
||||
setEditCode(v.code);
|
||||
setEditAmount(String(v.amount));
|
||||
setEditMaxUses(String(v.maxUses));
|
||||
setEditExpiresAt(
|
||||
v.expiresAt ? new Date(v.expiresAt).toISOString().slice(0, 16) : "",
|
||||
);
|
||||
}
|
||||
|
||||
function handleUpdate(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!editingVoucher || !canEdit) return;
|
||||
run(
|
||||
() =>
|
||||
updateVoucher({
|
||||
id: editingVoucher.id,
|
||||
code: editCode,
|
||||
amount: Number(editAmount),
|
||||
maxUses: Number(editMaxUses),
|
||||
expiresAt: editExpiresAt || undefined,
|
||||
}),
|
||||
{
|
||||
successMessage: t("updated"),
|
||||
errorMessage: t("updateError"),
|
||||
onSuccess: () => setEditingVoucher(null),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||
@@ -158,6 +197,79 @@ export function VouchersClient({
|
||||
</form>
|
||||
)}
|
||||
|
||||
{canEdit && editingVoucher && (
|
||||
<form onSubmit={handleUpdate} className="admin-card mb-6">
|
||||
<h3 style={{ marginTop: 0 }}>
|
||||
{t("edit")} — {editingVoucher.code}
|
||||
</h3>
|
||||
<div className="mb-3 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-sm theme-text-muted dark:theme-text-muted">
|
||||
{t("form.code")}
|
||||
</span>
|
||||
<input
|
||||
value={editCode}
|
||||
onChange={(e) => setEditCode(e.target.value)}
|
||||
placeholder={t("form.codePlaceholder")}
|
||||
maxLength={255}
|
||||
required
|
||||
disabled={isPending}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-sm theme-text-muted dark:theme-text-muted">
|
||||
{t("form.amount")}
|
||||
</span>
|
||||
<input
|
||||
value={editAmount}
|
||||
onChange={(e) => setEditAmount(e.target.value)}
|
||||
type="number"
|
||||
min={1}
|
||||
required
|
||||
disabled={isPending}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-sm theme-text-muted dark:theme-text-muted">
|
||||
{t("form.maxUses")}
|
||||
</span>
|
||||
<input
|
||||
value={editMaxUses}
|
||||
onChange={(e) => setEditMaxUses(e.target.value)}
|
||||
type="number"
|
||||
min={1}
|
||||
required
|
||||
disabled={isPending}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-sm theme-text-muted dark:theme-text-muted">
|
||||
{t("form.expiresAt")}
|
||||
</span>
|
||||
<input
|
||||
value={editExpiresAt}
|
||||
onChange={(e) => setEditExpiresAt(e.target.value)}
|
||||
type="datetime-local"
|
||||
disabled={isPending}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="submit" variant="default" disabled={isPending}>
|
||||
{isPending ? t("form.saving") : t("form.save")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
disabled={isPending}
|
||||
onClick={() => setEditingVoucher(null)}
|
||||
>
|
||||
{t("form.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<section className="mt-6">
|
||||
<h2 className="admin-section-title">
|
||||
{t("title")} ({vouchers.length})
|
||||
@@ -216,14 +328,24 @@ export function VouchersClient({
|
||||
</td>
|
||||
{canEdit && (
|
||||
<td>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
disabled={isPending}
|
||||
onClick={() => setPendingDelete(v)}
|
||||
>
|
||||
{t("delete")}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
disabled={isPending}
|
||||
onClick={() => handleStartEdit(v)}
|
||||
>
|
||||
{t("edit")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
disabled={isPending}
|
||||
onClick={() => setPendingDelete(v)}
|
||||
>
|
||||
{t("delete")}
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
|
||||
@@ -28,6 +28,12 @@ interface BatchItem {
|
||||
classname: string;
|
||||
}
|
||||
|
||||
/** Maximum retries for fetching furnidata before giving up. */
|
||||
const FURNITDATA_MAX_RETRIES = 3;
|
||||
|
||||
/** Base delay between retries (ms). */
|
||||
const FURNITDATA_RETRY_DELAY = 1_000;
|
||||
|
||||
export const POST = withAdmin(
|
||||
{ permission: PERMS.ASSETS_IMPORT },
|
||||
async (request, ctx) => {
|
||||
@@ -44,14 +50,23 @@ export const POST = withAdmin(
|
||||
if (!source) return apiError("Source not found", 404);
|
||||
|
||||
// Fetch furnidata once so we can resolve entries by classname.
|
||||
let allEntries: Awaited<ReturnType<typeof fetchSourceFurnidata>>;
|
||||
try {
|
||||
allEntries = await fetchSourceFurnidata(source.furnidataUrl);
|
||||
} catch (err) {
|
||||
return apiError(
|
||||
`Failed to fetch furnidata: ${(err as Error).message}`,
|
||||
502,
|
||||
);
|
||||
let allEntries: Awaited<ReturnType<typeof fetchSourceFurnidata>> = [];
|
||||
for (let attempt = 1; attempt <= FURNITDATA_MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
allEntries = await fetchSourceFurnidata(source.furnidataUrl);
|
||||
break;
|
||||
} catch (err) {
|
||||
if (attempt < FURNITDATA_MAX_RETRIES) {
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, FURNITDATA_RETRY_DELAY),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
return apiError(
|
||||
`Failed to fetch furnidata after ${FURNITDATA_MAX_RETRIES} attempts: ${(err as Error).message}`,
|
||||
502,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const entryMap = new Map(allEntries.map((e) => [e.classname, e]));
|
||||
|
||||
@@ -2,6 +2,7 @@ import { inArray } from "drizzle-orm";
|
||||
import { withAdmin } from "@/lib/api-handler";
|
||||
import { db, ItemsBase } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { logServerError } from "@/lib/server-log";
|
||||
import { logAudit } from "@/lib/services/audit";
|
||||
import {
|
||||
cloneSingleFurni,
|
||||
@@ -50,7 +51,11 @@ export const POST = withAdmin(
|
||||
const entries = await fetchSourceFurnidata(source.furnidataUrl);
|
||||
const byClassname = new Map(entries.map((e) => [e.classname, e]));
|
||||
sourceFurniDataMap.set(source.id, byClassname);
|
||||
} catch {}
|
||||
} catch (error) {
|
||||
logServerError("clone-import.furnidata_prefetch_failed", error, {
|
||||
source: source.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Collect all missing items using pre-fetched data
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import { eq, like, or } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { withAdmin } from "@/lib/api-handler";
|
||||
import { apiOk } from "@/lib/api-response";
|
||||
import {
|
||||
db,
|
||||
Guilds,
|
||||
Rooms,
|
||||
User,
|
||||
WebsiteArticles,
|
||||
WebsiteRareValues,
|
||||
WebsiteShopArticles,
|
||||
} from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { rateLimit } from "@/lib/rate-limit";
|
||||
|
||||
export type AdminSearchResult = {
|
||||
type: string;
|
||||
id: number | string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
const PER_TYPE = 5;
|
||||
const MAX_TOTAL = 20;
|
||||
const MAX_QUERY_LENGTH = 64;
|
||||
|
||||
/** Escape LIKE wildcards so user input can't widen the match. */
|
||||
export function escapeLike(input: string): string {
|
||||
return input.replace(/[\\%_]/g, (m) => `\\${m}`);
|
||||
}
|
||||
|
||||
export const GET = withAdmin(
|
||||
{ permission: PERMS.ADMIN_DASHBOARD },
|
||||
async (request, context) => {
|
||||
const limited = await rateLimit(
|
||||
`admin-search:${context.session.user.id}`,
|
||||
30,
|
||||
60_000,
|
||||
);
|
||||
if (!limited.ok) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: "Too many requests" },
|
||||
{ status: 429, headers: { "retry-after": String(limited.retryAfter) } },
|
||||
);
|
||||
}
|
||||
|
||||
const q = (request.nextUrl.searchParams.get("q") || "")
|
||||
.trim()
|
||||
.slice(0, MAX_QUERY_LENGTH);
|
||||
if (q.length < 2) {
|
||||
return apiOk({ results: [] });
|
||||
}
|
||||
|
||||
const pattern = `%${escapeLike(q)}%`;
|
||||
const idExact = Number.parseInt(q, 10);
|
||||
const hasId = Number.isFinite(idExact) && String(idExact) === q;
|
||||
|
||||
const [users, articles, rooms, guilds, shopArticles, rareValues] =
|
||||
await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: User.id,
|
||||
title: User.username,
|
||||
subtitle: User.mail,
|
||||
})
|
||||
.from(User)
|
||||
.where(
|
||||
or(
|
||||
like(User.username, pattern),
|
||||
like(User.mail, pattern),
|
||||
...(hasId ? [eq(User.id, idExact)] : []),
|
||||
),
|
||||
)
|
||||
.limit(PER_TYPE),
|
||||
|
||||
db
|
||||
.select({
|
||||
id: WebsiteArticles.id,
|
||||
title: WebsiteArticles.title,
|
||||
subtitle: WebsiteArticles.slug,
|
||||
})
|
||||
.from(WebsiteArticles)
|
||||
.where(
|
||||
or(
|
||||
like(WebsiteArticles.title, pattern),
|
||||
like(WebsiteArticles.slug, pattern),
|
||||
),
|
||||
)
|
||||
.limit(PER_TYPE),
|
||||
|
||||
db
|
||||
.select({
|
||||
id: Rooms.id,
|
||||
title: Rooms.name,
|
||||
subtitle: Rooms.ownerName,
|
||||
})
|
||||
.from(Rooms)
|
||||
.where(
|
||||
or(
|
||||
like(Rooms.name, pattern),
|
||||
...(hasId ? [eq(Rooms.id, idExact)] : []),
|
||||
),
|
||||
)
|
||||
.limit(PER_TYPE),
|
||||
|
||||
db
|
||||
.select({
|
||||
id: Guilds.id,
|
||||
title: Guilds.name,
|
||||
subtitle: Guilds.description,
|
||||
})
|
||||
.from(Guilds)
|
||||
.where(
|
||||
or(
|
||||
like(Guilds.name, pattern),
|
||||
...(hasId ? [eq(Guilds.id, idExact)] : []),
|
||||
),
|
||||
)
|
||||
.limit(PER_TYPE),
|
||||
|
||||
db
|
||||
.select({
|
||||
id: WebsiteShopArticles.id,
|
||||
title: WebsiteShopArticles.name,
|
||||
subtitle: WebsiteShopArticles.info,
|
||||
})
|
||||
.from(WebsiteShopArticles)
|
||||
.where(
|
||||
or(
|
||||
like(WebsiteShopArticles.name, pattern),
|
||||
...(hasId ? [eq(WebsiteShopArticles.id, BigInt(idExact))] : []),
|
||||
),
|
||||
)
|
||||
.limit(PER_TYPE),
|
||||
|
||||
db
|
||||
.select({
|
||||
id: WebsiteRareValues.id,
|
||||
title: WebsiteRareValues.name,
|
||||
subtitle: WebsiteRareValues.itemId,
|
||||
})
|
||||
.from(WebsiteRareValues)
|
||||
.where(
|
||||
or(
|
||||
like(WebsiteRareValues.name, pattern),
|
||||
...(hasId ? [eq(WebsiteRareValues.itemId, idExact)] : []),
|
||||
),
|
||||
)
|
||||
.limit(PER_TYPE),
|
||||
]);
|
||||
|
||||
const groupMap: Record<
|
||||
string,
|
||||
{ type: string; items: Array<AdminSearchResult> }
|
||||
> = {
|
||||
users: { type: "users", items: [] },
|
||||
articles: { type: "articles", items: [] },
|
||||
rooms: { type: "rooms", items: [] },
|
||||
guilds: { type: "guilds", items: [] },
|
||||
shop: { type: "shop", items: [] },
|
||||
rareValues: { type: "rareValues", items: [] },
|
||||
};
|
||||
|
||||
for (const r of users) {
|
||||
groupMap.users.items.push({
|
||||
type: "users",
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
subtitle: r.subtitle || "",
|
||||
url: `/admin/users/show/${r.id}`,
|
||||
});
|
||||
}
|
||||
for (const r of articles) {
|
||||
groupMap.articles.items.push({
|
||||
type: "articles",
|
||||
id: Number(r.id),
|
||||
title: r.title,
|
||||
subtitle: r.subtitle,
|
||||
url: `/admin/articles/${r.id}`,
|
||||
});
|
||||
}
|
||||
for (const r of rooms) {
|
||||
groupMap.rooms.items.push({
|
||||
type: "rooms",
|
||||
id: r.id,
|
||||
title: r.title || `Room #${r.id}`,
|
||||
subtitle: r.subtitle || "",
|
||||
url: `/admin/rooms/${r.id}`,
|
||||
});
|
||||
}
|
||||
for (const r of guilds) {
|
||||
groupMap.guilds.items.push({
|
||||
type: "guilds",
|
||||
id: r.id,
|
||||
title: r.title || `Guild #${r.id}`,
|
||||
subtitle: r.subtitle || "",
|
||||
url: `/admin/guilds/${r.id}`,
|
||||
});
|
||||
}
|
||||
for (const r of shopArticles) {
|
||||
groupMap.shop.items.push({
|
||||
type: "shop",
|
||||
id: Number(r.id),
|
||||
title: r.title,
|
||||
subtitle: r.subtitle || "",
|
||||
url: `/admin/shop/${r.id}`,
|
||||
});
|
||||
}
|
||||
for (const r of rareValues) {
|
||||
groupMap.rareValues.items.push({
|
||||
type: "rareValues",
|
||||
id: Number(r.id),
|
||||
title: r.title,
|
||||
subtitle: r.subtitle != null ? `Item #${r.subtitle}` : "",
|
||||
url: `/admin/rare-values/${r.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
const results: AdminSearchResult[] = [];
|
||||
const order = [
|
||||
"users",
|
||||
"articles",
|
||||
"rooms",
|
||||
"guilds",
|
||||
"shop",
|
||||
"rareValues",
|
||||
];
|
||||
// Round-robin so no single type starves the others when capped.
|
||||
for (let i = 0; results.length < MAX_TOTAL; i++) {
|
||||
let added = false;
|
||||
for (const key of order) {
|
||||
const item = groupMap[key]?.items[i];
|
||||
if (item && results.length < MAX_TOTAL) {
|
||||
results.push(item);
|
||||
added = true;
|
||||
}
|
||||
}
|
||||
if (!added) break;
|
||||
}
|
||||
|
||||
return apiOk({ results });
|
||||
},
|
||||
);
|
||||
@@ -1,4 +1,4 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { and, eq, or, sql } from "drizzle-orm";
|
||||
import { apiJson } from "@/lib/api";
|
||||
import { db, WebsiteArticles } from "@/lib/db";
|
||||
import { apiCacheKey, cacheSafe, redisCache } from "@/lib/redis-cache";
|
||||
@@ -30,7 +30,16 @@ export async function GET(
|
||||
updatedAt: WebsiteArticles.updatedAt,
|
||||
})
|
||||
.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);
|
||||
|
||||
if (!article) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { count, desc } from "drizzle-orm";
|
||||
import { and, count, desc, eq, or, sql } from "drizzle-orm";
|
||||
import { apiJson, pagination } from "@/lib/api";
|
||||
import { db, WebsiteArticles } from "@/lib/db";
|
||||
import { apiCacheKey, cacheSafe, redisCache } from "@/lib/redis-cache";
|
||||
@@ -17,8 +17,18 @@ export async function GET(req: Request) {
|
||||
apiCacheKey(`articles:${page}:${perPage}`),
|
||||
60,
|
||||
async () => {
|
||||
const publishedFilter = and(
|
||||
eq(WebsiteArticles.status, "published"),
|
||||
or(
|
||||
sql`${WebsiteArticles.publishAt} IS NULL`,
|
||||
sql`${WebsiteArticles.publishAt} <= NOW()`,
|
||||
),
|
||||
);
|
||||
const [totalRows, articles] = await Promise.all([
|
||||
db.select({ total: count() }).from(WebsiteArticles),
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(WebsiteArticles)
|
||||
.where(publishedFilter),
|
||||
db
|
||||
.select({
|
||||
id: WebsiteArticles.id,
|
||||
@@ -29,6 +39,7 @@ export async function GET(req: Request) {
|
||||
createdAt: WebsiteArticles.createdAt,
|
||||
})
|
||||
.from(WebsiteArticles)
|
||||
.where(publishedFilter)
|
||||
.orderBy(desc(WebsiteArticles.createdAt))
|
||||
.limit(take)
|
||||
.offset(skip),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { count, desc, eq } from "drizzle-orm";
|
||||
import { and, count, desc, eq, or, sql } from "drizzle-orm";
|
||||
import { env } from "@/env";
|
||||
import { apiJson } from "@/lib/api";
|
||||
import { db, User, WebsiteArticles } from "@/lib/db";
|
||||
@@ -24,6 +24,15 @@ export async function GET(_req: Request) {
|
||||
createdAt: WebsiteArticles.createdAt,
|
||||
})
|
||||
.from(WebsiteArticles)
|
||||
.where(
|
||||
and(
|
||||
eq(WebsiteArticles.status, "published"),
|
||||
or(
|
||||
sql`${WebsiteArticles.publishAt} IS NULL`,
|
||||
sql`${WebsiteArticles.publishAt} <= NOW()`,
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(WebsiteArticles.createdAt))
|
||||
.limit(4),
|
||||
db.select({ total: count() }).from(User).where(eq(User.online, "1")),
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// Server-Sent Events stream of the live online user count for the in-game
|
||||
// client toolbar. Pushes { count } every ~5s so the counter updates in
|
||||
// real-time without the client polling. Closes cleanly when the client
|
||||
// disconnects.
|
||||
//
|
||||
// The first event is sent immediately; subsequent events push the cached
|
||||
// count (10s TTL in the REST endpoint) so repeated reads share one query.
|
||||
|
||||
import { count, eq } from "drizzle-orm";
|
||||
import { cached } from "@/lib/cache";
|
||||
import { db, User } from "@/lib/db";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
|
||||
async function fetchOnlineCount(): Promise<number> {
|
||||
try {
|
||||
return await cached("online_count", 10_000, async () => {
|
||||
const [row] = await db
|
||||
.select({ total: count() })
|
||||
.from(User)
|
||||
.where(eq(User.online, "1"));
|
||||
return row?.total ?? 0;
|
||||
});
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Emulator (RCON) reachability, cached and shared across all subscribers so an
|
||||
// N-client toolbar doesn't fire N pings every interval. Falls back to graceful
|
||||
// degradation on failure so the stream never errors out.
|
||||
async function fetchEmulatorStatus(): Promise<boolean> {
|
||||
try {
|
||||
return await cached("emulator_rcon_status", 15_000, () =>
|
||||
rcon.send("ping", null).catch(() => false),
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
async start(controller) {
|
||||
let closed = false;
|
||||
|
||||
const send = async () => {
|
||||
if (closed) return;
|
||||
const [count, emulator] = await Promise.all([
|
||||
fetchOnlineCount(),
|
||||
fetchEmulatorStatus(),
|
||||
]);
|
||||
try {
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ count, emulator })}\n\n`),
|
||||
);
|
||||
} catch {
|
||||
closed = true;
|
||||
}
|
||||
};
|
||||
|
||||
// Initial event immediately, then on an interval.
|
||||
await send();
|
||||
const interval = setInterval(() => void send(), 5_000);
|
||||
// SSE comment as a keep-alive ping between data events.
|
||||
const ping = setInterval(() => {
|
||||
if (!closed) {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(": ping\n\n"));
|
||||
} catch {
|
||||
closed = true;
|
||||
}
|
||||
}
|
||||
}, 25_000);
|
||||
|
||||
const stop = () => {
|
||||
closed = true;
|
||||
clearInterval(interval);
|
||||
clearInterval(ping);
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
};
|
||||
req.signal.addEventListener("abort", stop);
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"content-type": "text/event-stream; charset=utf-8",
|
||||
"cache-control": "no-store, no-transform",
|
||||
connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
}
|
||||
+308
-96
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { LogOut } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
@@ -12,45 +13,78 @@ import {
|
||||
import { signOutAndRevokeTicket } from "@/actions/sessions";
|
||||
import Link from "@/components/link";
|
||||
import { buildClientLoginUrl } from "@/lib/client-url";
|
||||
import { useEventSource } from "@/lib/use-event-source";
|
||||
|
||||
function ToolbarBtn({
|
||||
onClick,
|
||||
title,
|
||||
label,
|
||||
children,
|
||||
href,
|
||||
}: {
|
||||
onClick?: () => void;
|
||||
title: string;
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
href?: string;
|
||||
}) {
|
||||
// Custom floating tooltip: appears on hover/focus regardless of where the
|
||||
// element is on screen. Kept in one component for a consistent look across
|
||||
// the bar (replaces the browser's slow, untitled `title` bubbles).
|
||||
const [tip, setTip] = useState(false);
|
||||
const cls =
|
||||
"w-7 h-7 sm:w-8 sm:h-8 rounded-lg border-2 cursor-pointer flex items-center justify-center shadow-lg transition-all duration-200 hover:-translate-y-0.5 hover:shadow-xl active:translate-y-0";
|
||||
"group relative w-8 h-8 sm:w-9 sm:h-9 rounded-xl cursor-pointer flex items-center justify-center transition-all duration-200 hover:-translate-y-0.5 hover:brightness-110 active:translate-y-0 active:scale-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:ring-primary";
|
||||
const style = {
|
||||
borderColor: "color-mix(in srgb, var(--color-primary, #eeb425) 75%, black)",
|
||||
border:
|
||||
"1px solid color-mix(in srgb, var(--color-primary, #eeb425) 85%, white)",
|
||||
background:
|
||||
"linear-gradient(135deg, var(--color-primary, #eeb425) 0%, color-mix(in srgb, var(--color-primary, #eeb425) 80%, black) 100%)",
|
||||
"linear-gradient(180deg, color-mix(in srgb, var(--color-primary, #eeb425) 92%, white), var(--color-primary, #eeb425) 55%, color-mix(in srgb, var(--color-primary, #eeb425) 60%, black))",
|
||||
color:
|
||||
"var(--button-text-color-readable, var(--button-text-color, #1a1a2e))",
|
||||
boxShadow:
|
||||
"0 2px 8px rgba(0,0,0,0.25), inset 0 1px 0 rgba(255,255,255,0.35), inset 0 -2px 4px rgba(0,0,0,0.12)",
|
||||
} as const;
|
||||
if (href) {
|
||||
return (
|
||||
<Link href={href} className={cls} style={style} title={title}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return (
|
||||
|
||||
const trigger = href ? (
|
||||
<Link
|
||||
href={href}
|
||||
className={cls}
|
||||
style={style}
|
||||
aria-label={label}
|
||||
onMouseEnter={() => setTip(true)}
|
||||
onMouseLeave={() => setTip(false)}
|
||||
onFocus={() => setTip(true)}
|
||||
onBlur={() => setTip(false)}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
) : (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cls}
|
||||
style={style}
|
||||
title={title}
|
||||
type="button"
|
||||
aria-label={label}
|
||||
onMouseEnter={() => setTip(true)}
|
||||
onMouseLeave={() => setTip(false)}
|
||||
onFocus={() => setTip(true)}
|
||||
onBlur={() => setTip(false)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<span className="relative inline-flex">
|
||||
{trigger}
|
||||
{tip && (
|
||||
<span
|
||||
role="tooltip"
|
||||
className="absolute left-1/2 -translate-x-1/2 -top-8 whitespace-nowrap rounded-md bg-foreground backdrop-blur px-2 py-1 text-[10px] font-bold text-background shadow-lg pointer-events-none"
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ClientView({
|
||||
@@ -64,17 +98,47 @@ export function ClientView({
|
||||
hotelName: string;
|
||||
initialOnline: number;
|
||||
}) {
|
||||
const [_isFullscreen, setIsFullscreen] = useState(false);
|
||||
const tToolbar = useTranslations("toolbar");
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [onlineCount, setOnlineCount] = useState(initialOnline);
|
||||
const [emulatorOk, setEmulatorOk] = useState<boolean | null>(null);
|
||||
const [onlineUsers, setOnlineUsers] = useState<string[]>([]);
|
||||
const [showOnlineUsers, setShowOnlineUsers] = useState(false);
|
||||
const [barHidden, setBarHidden] = useState(false);
|
||||
|
||||
// Real-time online count + emulator status via SSE multicast (replaces the
|
||||
// legacy 15s polling).
|
||||
const { data: onlineStream } = useEventSource<{
|
||||
count: number;
|
||||
emulator: boolean;
|
||||
}>("/api/online/count/stream", { enabled: true });
|
||||
useEffect(() => {
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/online/count");
|
||||
if (res.ok) setOnlineCount((await res.json()).count ?? 0);
|
||||
} catch {}
|
||||
}, 15000);
|
||||
return () => clearInterval(interval);
|
||||
if (!onlineStream) return;
|
||||
if (typeof onlineStream.count === "number")
|
||||
setOnlineCount(onlineStream.count);
|
||||
if (typeof onlineStream.emulator === "boolean")
|
||||
setEmulatorOk(onlineStream.emulator);
|
||||
}, [onlineStream]);
|
||||
|
||||
// "Who's online" list, fetched sparingly (server-side cached 10s). Only a
|
||||
// fresh fetch is issued when hovered and the last fetch is stale — hovering
|
||||
// repeatedly must not spam the endpoint.
|
||||
const onlineUsersRef = useRef<number>(0);
|
||||
const refreshOnlineUsers = useCallback(() => {
|
||||
const now = Date.now();
|
||||
if (now - onlineUsersRef.current < 30_000) return;
|
||||
onlineUsersRef.current = now;
|
||||
fetch("/api/online", { cache: "no-store" })
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((data) => {
|
||||
const usernames = Array.isArray(data?.users)
|
||||
? data.users
|
||||
.map((u: { username?: string }) => u.username)
|
||||
.filter((n: unknown): n is string => typeof n === "string")
|
||||
: [];
|
||||
setOnlineUsers(usernames);
|
||||
})
|
||||
.catch(() => setOnlineUsers([]));
|
||||
}, []);
|
||||
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
@@ -163,16 +227,18 @@ export function ClientView({
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if ((e.target as HTMLElement).closest("button, a")) return;
|
||||
startDrag(e.clientX, e.clientY);
|
||||
if ((e.target as HTMLElement).closest(".toolbar-grip")) {
|
||||
startDrag(e.clientX, e.clientY);
|
||||
}
|
||||
},
|
||||
[startDrag],
|
||||
);
|
||||
|
||||
const handleTouchStart = useCallback(
|
||||
(e: React.TouchEvent) => {
|
||||
if ((e.target as HTMLElement).closest("button, a")) return;
|
||||
startDrag(e.touches[0].clientX, e.touches[0].clientY);
|
||||
if ((e.target as HTMLElement).closest(".toolbar-grip")) {
|
||||
startDrag(e.touches[0].clientX, e.touches[0].clientY);
|
||||
}
|
||||
},
|
||||
[startDrag],
|
||||
);
|
||||
@@ -225,22 +291,24 @@ export function ClientView({
|
||||
return (
|
||||
<>
|
||||
{pos ? (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: draggable toolbar with explicit mouse/touch handlers
|
||||
<div
|
||||
ref={toolbarRef}
|
||||
className={`fixed z-[9999] backdrop-blur-xl bg-foreground/30 rounded-xl border border-white/10 shadow-2xl px-1.5 py-1 select-none ${dragging ? "cursor-grabbing" : "cursor-grab"}`}
|
||||
className="fixed z-[9999] rounded-2xl shadow-2xl px-1.5 py-1.5 select-none backdrop-blur-2xl"
|
||||
style={{
|
||||
top: pos.top,
|
||||
left: pos.left,
|
||||
top: pos?.top ?? 8,
|
||||
left: pos?.left ?? 8,
|
||||
transition: snapTween
|
||||
? "top 0.18s ease-out, left 0.18s ease-out"
|
||||
: undefined,
|
||||
background:
|
||||
"linear-gradient(180deg, rgba(255,255,255,0.10), rgba(255,255,255,0.04))",
|
||||
border: "1px solid rgba(255,255,255,0.14)",
|
||||
boxShadow:
|
||||
"0 8px 32px rgba(0,0,0,0.35), inset 0 1px 0 rgba(255,255,255,0.12), inset 0 0 24px rgba(255,255,255,0.04)",
|
||||
}}
|
||||
onMouseDown={handleMouseDown}
|
||||
onTouchStart={handleTouchStart}
|
||||
>
|
||||
<div className="flex items-center gap-1 sm:gap-2">
|
||||
<ToolbarBtn href="/" title="Home">
|
||||
<div className="flex items-center gap-1 sm:gap-1.5">
|
||||
<ToolbarBtn href="/" label={tToolbar("home")}>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
@@ -251,60 +319,16 @@ export function ClientView({
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
role="img"
|
||||
aria-label="Home"
|
||||
aria-label={tToolbar("home")}
|
||||
>
|
||||
<title>Home</title>
|
||||
<title>{tToolbar("home")}</title>
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||||
<polyline points="9 22 9 12 15 12 15 22" />
|
||||
</svg>
|
||||
</ToolbarBtn>
|
||||
<ToolbarBtn onClick={() => window.location.reload()} title="Reload">
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
role="img"
|
||||
aria-label="Reload"
|
||||
>
|
||||
<title>Reload</title>
|
||||
<polyline points="23 4 23 10 17 10" />
|
||||
<polyline points="1 20 1 14 7 14" />
|
||||
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
|
||||
</svg>
|
||||
</ToolbarBtn>
|
||||
<ToolbarBtn onClick={toggleFullscreen} title="Fullscreen">
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
role="img"
|
||||
aria-label="Fullscreen"
|
||||
>
|
||||
<title>Fullscreen</title>
|
||||
<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3" />
|
||||
</svg>
|
||||
</ToolbarBtn>
|
||||
<div
|
||||
className="h-7 sm:h-8 px-1.5 sm:px-2 rounded-lg border-2 flex items-center justify-center gap-1 shadow-lg select-none"
|
||||
style={{
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-primary, #eeb425) 75%, black)",
|
||||
background:
|
||||
"linear-gradient(135deg, var(--color-primary, #eeb425) 0%, color-mix(in srgb, var(--color-primary, #eeb425) 80%, black) 100%)",
|
||||
color:
|
||||
"var(--button-text-color-readable, var(--button-text-color, #1a1a2e))",
|
||||
}}
|
||||
title="Online users"
|
||||
<ToolbarBtn
|
||||
onClick={() => window.location.reload()}
|
||||
label={tToolbar("reload")}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
@@ -316,27 +340,154 @@ export function ClientView({
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
role="img"
|
||||
aria-label="Online users"
|
||||
aria-label={tToolbar("reload")}
|
||||
>
|
||||
<title>Online users</title>
|
||||
<title>{tToolbar("reload")}</title>
|
||||
<polyline points="23 4 23 10 17 10" />
|
||||
<polyline points="1 20 1 14 7 14" />
|
||||
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
|
||||
</svg>
|
||||
</ToolbarBtn>
|
||||
<ToolbarBtn
|
||||
onClick={toggleFullscreen}
|
||||
label={
|
||||
isFullscreen
|
||||
? tToolbar("fullscreenExit")
|
||||
: tToolbar("fullscreen")
|
||||
}
|
||||
>
|
||||
{isFullscreen ? (
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
role="img"
|
||||
aria-label={tToolbar("fullscreenExit")}
|
||||
>
|
||||
<title>{tToolbar("fullscreenExit")}</title>
|
||||
<path d="M8 3v3a2 2 0 0 1-2 2H3m18 0h-3a2 2 0 0 1-2-2V3m0 18v-3a2 2 0 0 1 2-2h3M3 16h3a2 2 0 0 1 2 2v3" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
role="img"
|
||||
aria-label={tToolbar("fullscreen")}
|
||||
>
|
||||
<title>{tToolbar("fullscreen")}</title>
|
||||
<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3" />
|
||||
</svg>
|
||||
)}
|
||||
</ToolbarBtn>
|
||||
<div
|
||||
className="h-8 sm:h-9 rounded-xl flex items-center justify-center px-2"
|
||||
style={{
|
||||
border:
|
||||
"1px solid color-mix(in srgb, var(--color-primary, #eeb425) 50%, transparent)",
|
||||
background:
|
||||
"color-mix(in srgb, var(--color-primary, #eeb425) 14%, rgba(255,255,255,0.05))",
|
||||
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.20)",
|
||||
}}
|
||||
role="status"
|
||||
aria-label={
|
||||
emulatorOk === null
|
||||
? tToolbar("emulatorUnknown")
|
||||
: emulatorOk
|
||||
? tToolbar("emulatorOnline")
|
||||
: tToolbar("emulatorOffline")
|
||||
}
|
||||
title={
|
||||
emulatorOk === null
|
||||
? tToolbar("emulatorUnknown")
|
||||
: emulatorOk
|
||||
? tToolbar("emulatorOnline")
|
||||
: tToolbar("emulatorOffline")
|
||||
}
|
||||
>
|
||||
<span className="relative flex h-2.5 w-2.5">
|
||||
{emulatorOk === null ? (
|
||||
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-muted-foreground ring-2 ring-muted-foreground/30" />
|
||||
) : emulatorOk ? (
|
||||
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-success ring-2 ring-success/30 shadow-[0_0_8px_rgba(52,211,153,0.8)]" />
|
||||
) : (
|
||||
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-destructive ring-2 ring-destructive/30 shadow-[0_0_8px_rgba(239,68,68,0.8)]" />
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="relative h-8 sm:h-9 px-2 sm:px-2.5 rounded-xl flex items-center justify-center gap-1.5 select-none cursor-pointer transition-all duration-200 hover:-translate-y-0.5 hover:brightness-110 active:translate-y-0"
|
||||
style={{
|
||||
border:
|
||||
"1px solid color-mix(in srgb, var(--color-primary, #eeb425) 85%, white)",
|
||||
background:
|
||||
"linear-gradient(180deg, color-mix(in srgb, var(--color-primary, #eeb425) 92%, white), var(--color-primary, #eeb425) 55%, color-mix(in srgb, var(--color-primary, #eeb425) 60%, black))",
|
||||
color:
|
||||
"var(--button-text-color-readable, var(--button-text-color, #1a1a2e))",
|
||||
boxShadow:
|
||||
"0 2px 8px rgba(0,0,0,0.25), inset 0 1px 0 rgba(255,255,255,0.35), inset 0 -2px 4px rgba(0,0,0,0.12)",
|
||||
}}
|
||||
role="status"
|
||||
aria-label={tToolbar("onlineUsers")}
|
||||
onMouseEnter={() => {
|
||||
setShowOnlineUsers(true);
|
||||
refreshOnlineUsers();
|
||||
}}
|
||||
onMouseLeave={() => setShowOnlineUsers(false)}
|
||||
>
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-background/80 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-background shadow-[0_0_8px_rgba(255,255,255,0.9)]" />
|
||||
</span>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
role="img"
|
||||
aria-label={tToolbar("onlineUsers")}
|
||||
>
|
||||
<title>{tToolbar("onlineUsers")}</title>
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</svg>
|
||||
<span className="text-[10px] sm:text-xs font-bold">
|
||||
{onlineCount}
|
||||
</span>
|
||||
{showOnlineUsers && onlineUsers.length > 0 && (
|
||||
<span className="absolute left-1/2 -translate-x-1/2 -top-10 max-w-[200px] rounded-md bg-foreground backdrop-blur px-2 py-1 text-[10px] font-bold text-background shadow-lg pointer-events-none whitespace-normal text-center">
|
||||
{onlineUsers.slice(0, 8).join(", ")}
|
||||
{onlineUsers.length > 8
|
||||
? ` +${onlineUsers.length - 8} more`
|
||||
: ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: grip handle explicitly initiates a drag (mouse+touch) and is not a button */}
|
||||
<div
|
||||
className="h-7 sm:h-8 px-1 rounded-lg border-2 flex items-center justify-center shadow-lg opacity-50"
|
||||
className="toolbar-grip h-8 sm:h-9 px-1.5 rounded-xl flex items-center justify-center cursor-grab active:cursor-grabbing touch-none opacity-60 hover:opacity-100 transition-opacity duration-200"
|
||||
style={{
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-primary, #eeb425) 75%, black)",
|
||||
background:
|
||||
"linear-gradient(135deg, var(--color-primary, #eeb425) 0%, color-mix(in srgb, var(--color-primary, #eeb425) 80%, black) 100%)",
|
||||
color:
|
||||
"var(--button-text-color-readable, var(--button-text-color, #1a1a2e))",
|
||||
border: "1px solid rgba(255,255,255,0.18)",
|
||||
background: "rgba(255,255,255,0.06)",
|
||||
color: "rgba(255,255,255,0.85)",
|
||||
}}
|
||||
title="Drag to move"
|
||||
title={tToolbar("dragToMove")}
|
||||
onMouseDown={handleMouseDown}
|
||||
onTouchStart={handleTouchStart}
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
@@ -348,9 +499,9 @@ export function ClientView({
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
role="img"
|
||||
aria-label="Drag"
|
||||
aria-label={tToolbar("dragToMove")}
|
||||
>
|
||||
<title>Drag</title>
|
||||
<title>{tToolbar("dragToMove")}</title>
|
||||
<circle cx="9" cy="5" r="1" />
|
||||
<circle cx="15" cy="5" r="1" />
|
||||
<circle cx="9" cy="12" r="1" />
|
||||
@@ -361,14 +512,75 @@ export function ClientView({
|
||||
</div>
|
||||
<ToolbarBtn
|
||||
onClick={() => void signOutAndRevokeTicket()}
|
||||
title="Logout"
|
||||
label={tToolbar("logout")}
|
||||
>
|
||||
<LogOut size={14} />
|
||||
</ToolbarBtn>
|
||||
<ToolbarBtn
|
||||
onClick={() => setBarHidden(true)}
|
||||
label={tToolbar("hide")}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
role="img"
|
||||
aria-label={tToolbar("hide")}
|
||||
>
|
||||
<title>{tToolbar("hide")}</title>
|
||||
<path d="M9 18l6-6-6-6" />
|
||||
</svg>
|
||||
</ToolbarBtn>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{barHidden ? (
|
||||
<button
|
||||
type="button"
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
setBarHidden(false);
|
||||
}}
|
||||
className="fixed z-[9999] h-8 rounded-xl flex items-center justify-center px-2 cursor-pointer transition-all duration-200 opacity-80 hover:opacity-100 hover:-translate-y-0.5 active:translate-y-0 active:scale-90"
|
||||
style={{
|
||||
top: pos?.top ?? 8,
|
||||
left: pos?.left ?? 8,
|
||||
border:
|
||||
"1px solid color-mix(in srgb, var(--color-primary, #eeb425) 85%, white)",
|
||||
background:
|
||||
"linear-gradient(180deg, color-mix(in srgb, var(--color-primary, #eeb425) 92%, white), var(--color-primary, #eeb425) 55%, color-mix(in srgb, var(--color-primary, #eeb425) 60%, black))",
|
||||
color:
|
||||
"var(--button-text-color-readable, var(--button-text-color, #1a1a2e))",
|
||||
boxShadow:
|
||||
"0 2px 8px rgba(0,0,0,0.25), inset 0 1px 0 rgba(255,255,255,0.35), inset 0 -2px 4px rgba(0,0,0,0.12)",
|
||||
}}
|
||||
aria-label={tToolbar("show")}
|
||||
title={tToolbar("show")}
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
role="img"
|
||||
aria-label={tToolbar("show")}
|
||||
>
|
||||
<title>{tToolbar("show")}</title>
|
||||
<path d="M15 18l-6-6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className="relative w-screen h-screen overflow-hidden"
|
||||
style={{ backgroundColor: "var(--color-background)" }}
|
||||
|
||||
@@ -74,10 +74,10 @@ export default async function ModBansPage() {
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("colUserId")}</th>
|
||||
<th>{t("colType")}</th>
|
||||
<th>{t("colReason")}</th>
|
||||
<th>{t("colExpires")}</th>
|
||||
<th scope="col">{t("colUserId")}</th>
|
||||
<th scope="col">{t("colType")}</th>
|
||||
<th scope="col">{t("colReason")}</th>
|
||||
<th scope="col">{t("colExpires")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
@@ -15,6 +15,7 @@ import { requireModPermission } from "@/lib/admin/guard";
|
||||
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||
import { db, SupportTickets, User } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { logServerError } from "@/lib/server-log";
|
||||
|
||||
const SORT_COLS = {
|
||||
id: SupportTickets.id,
|
||||
@@ -55,7 +56,8 @@ export default async function ModCfhListPage({
|
||||
.where(like(User.username, `%${q}%`))
|
||||
.limit(50);
|
||||
userIds = users.map((u) => u.id);
|
||||
} catch {
|
||||
} catch (error) {
|
||||
logServerError("cfh.user_search_failed", error, { query: q });
|
||||
userIds = [];
|
||||
}
|
||||
|
||||
@@ -91,7 +93,10 @@ export default async function ModCfhListPage({
|
||||
.from(SupportTickets)
|
||||
.where(where)
|
||||
.then((rows) => rows[0]?.total ?? 0)
|
||||
.catch(() => 0);
|
||||
.catch((error) => {
|
||||
logServerError("cfh.count_failed", error);
|
||||
return 0;
|
||||
});
|
||||
const pagination = calcPagination(total, parsed.page, parsed.perPage);
|
||||
|
||||
const tickets = await db
|
||||
@@ -101,7 +106,10 @@ export default async function ModCfhListPage({
|
||||
.orderBy(finalOrder)
|
||||
.offset(pagination.offset)
|
||||
.limit(pagination.perPage)
|
||||
.catch(() => []);
|
||||
.catch((error) => {
|
||||
logServerError("cfh.query_failed", error);
|
||||
return [];
|
||||
});
|
||||
|
||||
const userIds = [
|
||||
...new Set([
|
||||
|
||||
@@ -8,6 +8,7 @@ import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||
import { db, User } from "@/lib/db";
|
||||
import { getAvatarUrl } from "@/lib/imager";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { logServerError } from "@/lib/server-log";
|
||||
import type { DataTableColumn } from "@/types/common";
|
||||
|
||||
type ModUserRow = {
|
||||
@@ -139,7 +140,10 @@ export default async function ModUsersPage({
|
||||
.from(User)
|
||||
.where(where)
|
||||
.then((rows) => rows[0]?.total ?? 0)
|
||||
.catch(() => 0),
|
||||
.catch((error) => {
|
||||
logServerError("users.count_failed", error);
|
||||
return 0;
|
||||
}),
|
||||
db
|
||||
.select({
|
||||
id: User.id,
|
||||
@@ -155,7 +159,10 @@ export default async function ModUsersPage({
|
||||
.orderBy(finalOrder)
|
||||
.offset((parsed.page - 1) * parsed.perPage)
|
||||
.limit(parsed.perPage)
|
||||
.catch(() => [] as ModUserRow[]),
|
||||
.catch((error) => {
|
||||
logServerError("users.query_failed", error);
|
||||
return [] as ModUserRow[];
|
||||
}),
|
||||
]);
|
||||
|
||||
const total = totals;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { SearchDialog } from "@/components/admin/search-dialog";
|
||||
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||
import { ThemeSwitcher } from "@/components/theme-switcher";
|
||||
import { findAdminHub } from "@/lib/admin-nav";
|
||||
@@ -29,6 +30,7 @@ export function AdminTopbar({
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<SearchDialog />
|
||||
<div className="flex items-center gap-1.5 lg:hidden">
|
||||
<LanguageSwitcher variant="admin" />
|
||||
<ThemeSwitcher variant="admin" />
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useTransition } from "react";
|
||||
import {
|
||||
approveApplication,
|
||||
dismissApplication,
|
||||
} from "@/actions/admin-applications";
|
||||
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function ApplicationActions({ id }: { id: string }) {
|
||||
const t = useTranslations("pages.admin.applications");
|
||||
const router = useRouter();
|
||||
const [pending, startTransition] = useTransition();
|
||||
const { confirm, dialog: confirmDialog } = useConfirmDialog();
|
||||
|
||||
async function handleApprove() {
|
||||
const ok = await confirm({
|
||||
title: t("approve"),
|
||||
description: t("confirmApprove"),
|
||||
confirmLabel: t("approve"),
|
||||
variant: "default",
|
||||
});
|
||||
if (!ok) return;
|
||||
startTransition(async () => {
|
||||
const fd = new FormData();
|
||||
fd.set("id", id);
|
||||
await approveApplication(fd);
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
async function handleReject() {
|
||||
const ok = await confirm({
|
||||
title: t("reject"),
|
||||
description: t("confirmReject"),
|
||||
confirmLabel: t("reject"),
|
||||
variant: "danger",
|
||||
});
|
||||
if (!ok) return;
|
||||
startTransition(async () => {
|
||||
const fd = new FormData();
|
||||
fd.set("id", id);
|
||||
await dismissApplication(fd);
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
{confirmDialog}
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
size="sm"
|
||||
disabled={pending}
|
||||
onClick={handleApprove}
|
||||
>
|
||||
{t("approve")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={pending}
|
||||
onClick={handleReject}
|
||||
>
|
||||
{t("reject")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,8 @@ export function ArticleForm({
|
||||
image?: string;
|
||||
shortStory?: string;
|
||||
fullStory?: string;
|
||||
status?: string;
|
||||
publishAt?: string;
|
||||
};
|
||||
}) {
|
||||
const t = useTranslations("pages.admin.articles.form");
|
||||
@@ -26,6 +28,7 @@ export function ArticleForm({
|
||||
const [slugTouched, setSlugTouched] = useState(Boolean(defaultValues?.slug));
|
||||
const [image, setImage] = useState(defaultValues?.image ?? "");
|
||||
const [imageError, setImageError] = useState(false);
|
||||
const [status, setStatus] = useState(defaultValues?.status ?? "published");
|
||||
|
||||
const suggestedSlug = useMemo(() => slugify(title), [title]);
|
||||
|
||||
@@ -132,6 +135,39 @@ export function ArticleForm({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
|
||||
{t("status")}
|
||||
</span>
|
||||
<select
|
||||
name="status"
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
className="input input-bordered mt-1 w-full"
|
||||
>
|
||||
<option value="draft">{t("statusDraft")}</option>
|
||||
<option value="scheduled">{t("statusScheduled")}</option>
|
||||
<option value="published">{t("statusPublished")}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{status === "scheduled" ? (
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
|
||||
{t("publishAt")}
|
||||
</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
name="publishAt"
|
||||
defaultValue={defaultValues?.publishAt ?? ""}
|
||||
required
|
||||
className="input input-bordered mt-1 w-full"
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<button type="submit" className="btn btn-primary justify-self-start">
|
||||
{t("save")}
|
||||
</button>
|
||||
|
||||
@@ -24,7 +24,9 @@ export function BreadcrumbBar() {
|
||||
dispatch({ type: "SET_ANCESTORS", ancestors: d.ancestors ?? [] });
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
.catch((error) =>
|
||||
console.error("[Breadcrumb] Failed to fetch ancestors:", error),
|
||||
);
|
||||
return () => ac.abort();
|
||||
}, [selectedPageId, dispatch, catQs]);
|
||||
|
||||
|
||||
@@ -667,7 +667,9 @@ export function SortableTree({
|
||||
ids: pages.map((p) => p.id),
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
.catch((error) =>
|
||||
console.error("[SortableTree] Failed to load children:", error),
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [activeTabId, childrenMap, nodes, dispatch, loadChildren]);
|
||||
|
||||
@@ -81,7 +81,7 @@ export function QuickAddFurni({
|
||||
)
|
||||
.then((r) => r.json())
|
||||
.then((data) => setResults(data.results ?? []))
|
||||
.catch(() => {})
|
||||
.catch((error) => console.error("[QuickAdd] Furni search failed:", error))
|
||||
.finally(() => setLoading(false));
|
||||
return () => ac.abort();
|
||||
}, [debounced, open]);
|
||||
|
||||
@@ -101,7 +101,7 @@ export function AdminMediaGrid() {
|
||||
setCopied(url);
|
||||
setTimeout(() => setCopied(null), 1500);
|
||||
})
|
||||
.catch(() => {});
|
||||
.catch((error) => console.error("[Media] Clipboard copy failed:", error));
|
||||
}
|
||||
|
||||
async function remove(name: string) {
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
"use client";
|
||||
|
||||
import { SearchIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { AdminSearchResult } from "@/app/api/admin/search/route";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||
import { useDebounce } from "@/hooks/use-debounce";
|
||||
|
||||
type SearchResult = AdminSearchResult;
|
||||
|
||||
type SearchResponse = {
|
||||
ok: boolean;
|
||||
results: SearchResult[];
|
||||
};
|
||||
|
||||
export function SearchDialog() {
|
||||
const t = useTranslations("pages.admin.search");
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<SearchResult[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const debouncedQuery = useDebounce(query, 250);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const groupLabels: Record<string, string> = {
|
||||
users: t("users"),
|
||||
articles: t("articles"),
|
||||
rooms: t("rooms"),
|
||||
guilds: t("guilds"),
|
||||
shop: t("shop"),
|
||||
rareValues: t("rareValues"),
|
||||
};
|
||||
|
||||
const fetchResults = useCallback(async (q: string) => {
|
||||
abortRef.current?.abort();
|
||||
if (q.length < 2) {
|
||||
setResults([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/search?q=${encodeURIComponent(q)}`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const data: SearchResponse = await res.json();
|
||||
if (abortRef.current !== controller) return;
|
||||
if (data.ok) setResults(data.results);
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === "AbortError") return;
|
||||
if (abortRef.current === controller) setResults([]);
|
||||
} finally {
|
||||
if (abortRef.current === controller) setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchResults(debouncedQuery);
|
||||
}, [debouncedQuery, fetchResults]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => abortRef.current?.abort();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
||||
e.preventDefault();
|
||||
setOpen((prev) => !prev);
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, []);
|
||||
|
||||
function handleSelect(url: string) {
|
||||
setOpen(false);
|
||||
setQuery("");
|
||||
setResults([]);
|
||||
router.push(url);
|
||||
}
|
||||
|
||||
const grouped = groupResults(results);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="flex items-center gap-2 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] px-3 py-1.5 text-xs text-[var(--admin-text-muted)] hover:border-[var(--admin-accent)]/40 hover:text-[var(--admin-text)] transition-colors cursor-pointer"
|
||||
>
|
||||
<SearchIcon size={14} />
|
||||
<span className="hidden sm:inline">{t("placeholder")}</span>
|
||||
<kbd className="pointer-events-none hidden sm:inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium opacity-60">
|
||||
⌘K
|
||||
</kbd>
|
||||
</button>
|
||||
<DialogContent className="sm:max-w-lg p-0 gap-0 overflow-hidden">
|
||||
<Command
|
||||
className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground"
|
||||
shouldFilter={false}
|
||||
>
|
||||
<CommandInput
|
||||
placeholder={t("placeholder")}
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
className="h-12"
|
||||
/>
|
||||
<CommandList className="max-h-[400px]">
|
||||
{loading && query.length >= 2 ? (
|
||||
<div className="py-6 text-center text-sm text-muted-foreground">
|
||||
…
|
||||
</div>
|
||||
) : null}
|
||||
<CommandEmpty>{t("noResults")}</CommandEmpty>
|
||||
{Object.entries(grouped).map(([group, items]) => (
|
||||
<CommandGroup key={group} heading={groupLabels[group] ?? group}>
|
||||
{items.map((item) => (
|
||||
<CommandItem
|
||||
key={`${item.type}-${item.id}`}
|
||||
value={`${item.type}-${item.id}`}
|
||||
onSelect={() => handleSelect(item.url)}
|
||||
className="flex flex-col items-start gap-0.5 py-2"
|
||||
>
|
||||
<span className="text-sm font-medium">{item.title}</span>
|
||||
{item.subtitle ? (
|
||||
<span className="text-xs text-muted-foreground truncate w-full">
|
||||
{item.subtitle}
|
||||
</span>
|
||||
) : null}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function groupResults(results: SearchResult[]) {
|
||||
const grouped: Record<string, SearchResult[]> = {};
|
||||
for (const r of results) {
|
||||
if (!grouped[r.type]) grouped[r.type] = [];
|
||||
grouped[r.type].push(r);
|
||||
}
|
||||
return grouped;
|
||||
}
|
||||
@@ -466,13 +466,30 @@ export function StudioClient({
|
||||
signal: abort.signal,
|
||||
});
|
||||
|
||||
if (!res.ok || !res.body) {
|
||||
toast.error(`Batch chunk mislukt (${res.status})`);
|
||||
continue;
|
||||
if (!res.ok) {
|
||||
const errMsg =
|
||||
res.status === 502
|
||||
? "Furnidata ophalen mislukt – controleer de hotel-URL en netwerk"
|
||||
: res.status === 400
|
||||
? "Ongeldige input voor batch-import"
|
||||
: `Batch chunk mislukt (${res.status})`;
|
||||
toast.error(errMsg);
|
||||
// Toon foutmelding uit SSE-stream als die al onderweg is
|
||||
if (res.body && "getReader" in res.body) {
|
||||
void res.body.getReader();
|
||||
toast.error(
|
||||
"Foutmelding stream niet beschikbaar – de import loopt voort, fouten worden in de stream getoond",
|
||||
);
|
||||
} else {
|
||||
toast.error(
|
||||
"SSE-stream niet beschikbaar – foutmelding overgeslagen",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await readSseStream(
|
||||
res.body,
|
||||
res.body!,
|
||||
(evt) => {
|
||||
if (evt.type === "item_progress") {
|
||||
const cn = String(evt.classname ?? "");
|
||||
@@ -805,9 +822,20 @@ export function StudioClient({
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as Error).name === "AbortError") {
|
||||
toast.info("Batch import cancelled");
|
||||
toast.info("Batch import geannuleerd door gebruiker");
|
||||
} else if ((err as Error).name === "TimeoutError") {
|
||||
toast.error(
|
||||
"Import tijdelijkstop gedurende te lange tijd – probeer het opnieuw of verlaag de concurrentie",
|
||||
);
|
||||
} else if (err instanceof TypeError && err.message?.includes("fetch")) {
|
||||
toast.error(
|
||||
"Netwerkfout tijdens upload – controleer je internetverbinding en probeer opnieuw",
|
||||
);
|
||||
} else {
|
||||
toast.error("Connection lost during batch import");
|
||||
toast.error(
|
||||
"Onverwende fout tijdens import: " +
|
||||
(err instanceof Error ? err.message : String(err)),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
batchAbortRef.current = null;
|
||||
|
||||
@@ -12,25 +12,35 @@ export function AnimatedCounter({
|
||||
}) {
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
const isInView = useInView(ref, { once: true, margin: "-50px" });
|
||||
const started = useRef(false);
|
||||
const [display, setDisplay] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInView) return;
|
||||
|
||||
if (started.current) {
|
||||
setDisplay(value);
|
||||
return;
|
||||
}
|
||||
started.current = true;
|
||||
|
||||
const duration = 1200;
|
||||
const steps = 30;
|
||||
const increment = value / steps;
|
||||
let current = 0;
|
||||
let step = 0;
|
||||
const id = setInterval(() => {
|
||||
step++;
|
||||
current = Math.min(Math.round(increment * step), value);
|
||||
setDisplay(current);
|
||||
if (step >= steps) {
|
||||
clearInterval(id);
|
||||
const start = performance.now();
|
||||
let raf = 0;
|
||||
|
||||
const tick = (now: number) => {
|
||||
const progress = Math.min((now - start) / duration, 1);
|
||||
const eased = 1 - (1 - progress) ** 3;
|
||||
setDisplay(Math.round(eased * value));
|
||||
if (progress < 1) {
|
||||
raf = requestAnimationFrame(tick);
|
||||
} else {
|
||||
setDisplay(value);
|
||||
}
|
||||
}, duration / steps);
|
||||
return () => clearInterval(id);
|
||||
};
|
||||
|
||||
raf = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [isInView, value]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEventSource } from "@/lib/use-event-source";
|
||||
|
||||
/**
|
||||
* Live online user counter. Subscribes to the SSE online-count stream so the
|
||||
* displayed count updates in real-time (multicast) instead of only reflecting
|
||||
* the server-rendered value at page load.
|
||||
*/
|
||||
export function LiveOnlineCount({
|
||||
initial,
|
||||
hotelName,
|
||||
}: {
|
||||
initial: number;
|
||||
hotelName: string;
|
||||
}) {
|
||||
const t = useTranslations("header");
|
||||
const [count, setCount] = useState(initial);
|
||||
|
||||
const { data: onlineStream } = useEventSource<{ count: number }>(
|
||||
"/api/online/count/stream",
|
||||
{ enabled: true },
|
||||
);
|
||||
useEffect(() => {
|
||||
if (onlineStream && typeof onlineStream.count === "number") {
|
||||
setCount(onlineStream.count);
|
||||
}
|
||||
}, [onlineStream]);
|
||||
|
||||
return <>{t("online", { count, hotel: hotelName })}</>;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { AnimatedCounter } from "@/components/animated-counter";
|
||||
import { useEventSource } from "@/lib/use-event-source";
|
||||
|
||||
/**
|
||||
* Live animated online-count number. Subscribes to the SSE online-count stream
|
||||
* so the stat updates in real-time (multicast) instead of only the initial SSR
|
||||
* value, while keeping the entrance counter animation.
|
||||
*/
|
||||
export function LiveOnlineCounter({
|
||||
initial,
|
||||
suffix = "",
|
||||
}: {
|
||||
initial: number;
|
||||
suffix?: string;
|
||||
}) {
|
||||
const [count, setCount] = useState(initial);
|
||||
|
||||
const { data: onlineStream } = useEventSource<{ count: number }>(
|
||||
"/api/online/count/stream",
|
||||
{ enabled: true },
|
||||
);
|
||||
useEffect(() => {
|
||||
if (onlineStream && typeof onlineStream.count === "number") {
|
||||
setCount(onlineStream.count);
|
||||
}
|
||||
}, [onlineStream]);
|
||||
|
||||
return <AnimatedCounter value={count} suffix={suffix} />;
|
||||
}
|
||||
@@ -2,7 +2,14 @@
|
||||
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import Image from "next/image";
|
||||
import { type ReactNode, useRef, useState } from "react";
|
||||
import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { mobileMenuVariants } from "@/lib/motion";
|
||||
|
||||
interface MobileNavProps {
|
||||
@@ -20,10 +27,96 @@ export function MobileNav({
|
||||
}: MobileNavProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const detailsRef = useRef<HTMLDivElement>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const toggleRef = useRef<HTMLButtonElement>(null);
|
||||
const prevFocusRef = useRef<HTMLElement | null>(null);
|
||||
const menuId = useId();
|
||||
|
||||
const handleEscape = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && open) {
|
||||
setOpen(false);
|
||||
(prevFocusRef.current ?? toggleRef.current)?.focus();
|
||||
}
|
||||
},
|
||||
[open],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
return () => document.removeEventListener("keydown", handleEscape);
|
||||
}, [handleEscape]);
|
||||
|
||||
// Initial focus, scroll-lock, click-outside close, focus restore.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
prevFocusRef.current =
|
||||
document.activeElement instanceof HTMLElement
|
||||
? document.activeElement
|
||||
: null;
|
||||
menuRef.current
|
||||
?.querySelector<HTMLElement>(
|
||||
'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])',
|
||||
)
|
||||
?.focus();
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
|
||||
function handlePointerDown(e: PointerEvent) {
|
||||
if (
|
||||
menuRef.current &&
|
||||
!menuRef.current.contains(e.target as Node) &&
|
||||
!toggleRef.current?.contains(e.target as Node)
|
||||
) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("pointerdown", handlePointerDown);
|
||||
return () => {
|
||||
document.body.style.overflow = prevOverflow;
|
||||
document.removeEventListener("pointerdown", handlePointerDown);
|
||||
(prevFocusRef.current ?? toggleRef.current)?.focus();
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const menu = menuRef.current;
|
||||
if (!menu) return;
|
||||
|
||||
const focusableSelector =
|
||||
'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
function handleTab(e: KeyboardEvent) {
|
||||
if (e.key !== "Tab" || !menu) return;
|
||||
const focusable = Array.from(
|
||||
menu.querySelectorAll<HTMLElement>(focusableSelector),
|
||||
);
|
||||
if (focusable.length === 0) return;
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
menu.addEventListener("keydown", handleTab);
|
||||
return () => menu.removeEventListener("keydown", handleTab);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div ref={detailsRef} className="group relative md:hidden">
|
||||
<button
|
||||
ref={toggleRef}
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="list-none cursor-pointer flex items-center justify-center w-11 h-11 rounded-xl shrink-0
|
||||
@@ -33,6 +126,8 @@ export function MobileNav({
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-primary-readable,var(--color-primary))] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-navbar)]"
|
||||
aria-label={open ? closeLabel : menuLabel}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="dialog"
|
||||
aria-controls={menuId}
|
||||
>
|
||||
<motion.svg
|
||||
className="w-6 h-6"
|
||||
@@ -63,6 +158,10 @@ export function MobileNav({
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
id={menuId}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={brandLabel || "Navigation menu"}
|
||||
className="absolute left-0 top-full mt-2 w-[min(88vw,360px)] z-50 origin-top-left"
|
||||
style={{ backgroundColor: "transparent" }}
|
||||
variants={mobileMenuVariants}
|
||||
@@ -71,8 +170,9 @@ export function MobileNav({
|
||||
exit="exit"
|
||||
>
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="flex flex-col gap-y-1 p-2 rounded-2xl border shadow-xl max-h-[calc(100dvh-5rem)] overflow-y-auto overscroll-contain
|
||||
bg-[var(--color-dropdown,var(--color-surface,#fff))]"
|
||||
bg-[var(--color-dropdown,var(--color-surface,#fff))]"
|
||||
style={{
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ThemeSwitcher } from "@/components/theme-switcher";
|
||||
import { db, MessengerFriendrequests, MessengerOffline } from "@/lib/db";
|
||||
import { resolveHotelName } from "@/lib/hotel-name";
|
||||
import { canAccess, getApiAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { logServerError } from "@/lib/server-log";
|
||||
|
||||
export async function Navigation({ session }: { session: Session | null }) {
|
||||
const t = await getTranslations("nav");
|
||||
@@ -58,7 +59,11 @@ export async function Navigation({ session }: { session: Session | null }) {
|
||||
]);
|
||||
unreadMessages = unreadRows[0]?.total ?? 0;
|
||||
pendingFriendRequests = pendingRows[0]?.total ?? 0;
|
||||
} catch {}
|
||||
} catch (error) {
|
||||
logServerError("navigation.messenger_counts_failed", error, {
|
||||
userId: id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -171,7 +171,7 @@ export default function RadioPlayer() {
|
||||
.then((data) => {
|
||||
if (data !== null) setConfig(parseConfig(data));
|
||||
})
|
||||
.catch(() => {});
|
||||
.catch((error) => console.error("[Radio] Config fetch failed:", error));
|
||||
}, []);
|
||||
|
||||
// Keep the <audio> volume in sync with the slider.
|
||||
|
||||
@@ -15,7 +15,11 @@ export function PwaRegister() {
|
||||
}
|
||||
});
|
||||
|
||||
navigator.serviceWorker.register(`/sw.js?${SW_VERSION}`).catch(() => {});
|
||||
navigator.serviceWorker
|
||||
.register(`/sw.js?${SW_VERSION}`)
|
||||
.catch((error) =>
|
||||
console.error("[PWA] Service worker registration failed:", error),
|
||||
);
|
||||
}, []);
|
||||
return null;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import { sanitize } from "@/lib/sanitize";
|
||||
|
||||
interface SanitizedHtmlProps {
|
||||
html: string;
|
||||
@@ -11,7 +12,7 @@ export function SanitizedHtml({ html, className, style }: SanitizedHtmlProps) {
|
||||
<div
|
||||
className={className}
|
||||
style={style}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
dangerouslySetInnerHTML={{ __html: sanitize(html) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,13 @@
|
||||
import { count, eq } from "drizzle-orm";
|
||||
import Image from "next/image";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import Link from "@/components/link";
|
||||
import { LiveOnlineCount } from "@/components/live-online-count";
|
||||
import { cached } from "@/lib/cache";
|
||||
import { db, User } from "@/lib/db";
|
||||
import { resolveHotelName } from "@/lib/hotel-name";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
export async function SiteHeader() {
|
||||
const t = await getTranslations("header");
|
||||
const [hotelName, header, logo] = await Promise.all([
|
||||
resolveHotelName(),
|
||||
siteSettings.get("cms_header", "/assets/images/background.png"),
|
||||
@@ -140,7 +139,7 @@ export async function SiteHeader() {
|
||||
className="text-xs sm:text-sm font-bold tracking-wide"
|
||||
style={{ color: "rgba(255,255,255,0.9)" }}
|
||||
>
|
||||
{t("online", { count: online, hotel: hotelName })}
|
||||
<LiveOnlineCount initial={online} hotelName={hotelName} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { count, eq, inArray } from "drizzle-orm";
|
||||
import Image from "next/image";
|
||||
import type { Session } from "next-auth";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { signOutAndRevokeTicket } from "@/actions/sessions";
|
||||
import { HeaderUserIdentity } from "@/components/header-user-identity";
|
||||
import Link from "@/components/link";
|
||||
import { LiveOnlineCount } from "@/components/live-online-count";
|
||||
import { RoomQuickEntry } from "@/components/room-quick-entry";
|
||||
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
|
||||
import { cached } from "@/lib/cache";
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "@/lib/db";
|
||||
import { resolveHotelName } from "@/lib/hotel-name";
|
||||
import { canAccess, getApiAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { logServerError } from "@/lib/server-log";
|
||||
|
||||
function Currency({
|
||||
icon,
|
||||
@@ -76,7 +77,9 @@ export async function TopHeader({ session }: { session: Session | null }) {
|
||||
if (c.type === 0) duckets = c.amount;
|
||||
if (c.type === 5) diamonds = c.amount;
|
||||
}
|
||||
} catch {}
|
||||
} catch (error) {
|
||||
logServerError("top-header.wallet_failed", error, { userId: id });
|
||||
}
|
||||
|
||||
let showAdmin = false;
|
||||
let showMod = false;
|
||||
@@ -100,7 +103,6 @@ export async function TopHeader({ session }: { session: Session | null }) {
|
||||
const textColor =
|
||||
"var(--color-navbar-text-readable, var(--color-navbar-text, var(--color-text)))";
|
||||
|
||||
const t = await getTranslations("header");
|
||||
const hotelName = await resolveHotelName();
|
||||
|
||||
let online: number;
|
||||
@@ -112,7 +114,8 @@ export async function TopHeader({ session }: { session: Session | null }) {
|
||||
.where(eq(User.online, "1"));
|
||||
return row?.total ?? 0;
|
||||
});
|
||||
} catch {
|
||||
} catch (error) {
|
||||
logServerError("top-header.online_count_failed", error);
|
||||
online = 0;
|
||||
}
|
||||
|
||||
@@ -131,7 +134,9 @@ export async function TopHeader({ session }: { session: Session | null }) {
|
||||
]);
|
||||
unreadMessages = unreadRows[0]?.total ?? 0;
|
||||
pendingFriendRequests = pendingRows[0]?.total ?? 0;
|
||||
} catch {}
|
||||
} catch (error) {
|
||||
logServerError("top-header.messenger_counts_failed", error, { userId: id });
|
||||
}
|
||||
|
||||
const friendRequests = await db
|
||||
.select({ userFromId: MessengerFriendrequests.userFromId })
|
||||
@@ -173,7 +178,7 @@ export async function TopHeader({ session }: { session: Session | null }) {
|
||||
className="text-[10px] sm:text-sm font-bold tracking-wide"
|
||||
style={{ color: "rgba(255,255,255,0.9)" }}
|
||||
>
|
||||
{t("online", { count: online, hotel: hotelName })}
|
||||
<LiveOnlineCount initial={online} hotelName={hotelName} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -661,6 +661,9 @@ export const WebsiteArticles = mysqlTable("website_articles", {
|
||||
image: varchar("image", { length: 255 }).notNull(),
|
||||
createdAt: timestamp("created_at"),
|
||||
updatedAt: timestamp("updated_at"),
|
||||
status: varchar("status", { length: 20 }).notNull().default("published"),
|
||||
publishAt: timestamp("publish_at"),
|
||||
publishedAt: timestamp("published_at"),
|
||||
});
|
||||
|
||||
export const WebsiteLanguages = mysqlTable("website_languages", {
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
calcPagination,
|
||||
PER_PAGE_OPTIONS,
|
||||
parseListParams,
|
||||
} from "./admin-helpers";
|
||||
|
||||
describe("parseListParams", () => {
|
||||
it("parses defaults", () => {
|
||||
const p = parseListParams(new URLSearchParams());
|
||||
expect(p).toEqual({
|
||||
search: "",
|
||||
perPage: 20,
|
||||
page: 1,
|
||||
sort: undefined,
|
||||
order: "desc",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses explicit values", () => {
|
||||
const p = parseListParams(
|
||||
new URLSearchParams("search=sofa&perPage=50&page=3&sort=name&order=asc"),
|
||||
);
|
||||
expect(p.search).toBe("sofa");
|
||||
expect(p.perPage).toBe(50);
|
||||
expect(p.page).toBe(3);
|
||||
expect(p.sort).toBe("name");
|
||||
expect(p.order).toBe("asc");
|
||||
});
|
||||
|
||||
it("clamps perPage to [1, 100]", () => {
|
||||
expect(parseListParams(new URLSearchParams("perPage=0")).perPage).toBe(1);
|
||||
expect(parseListParams(new URLSearchParams("perPage=1000")).perPage).toBe(
|
||||
100,
|
||||
);
|
||||
});
|
||||
|
||||
it("clamps page to >= 1", () => {
|
||||
expect(parseListParams(new URLSearchParams("page=0")).page).toBe(1);
|
||||
expect(parseListParams(new URLSearchParams("page=-5")).page).toBe(1);
|
||||
});
|
||||
|
||||
it("defaults order to desc when not asc", () => {
|
||||
expect(parseListParams(new URLSearchParams("order=asc")).order).toBe("asc");
|
||||
expect(parseListParams(new URLSearchParams("order=desc")).order).toBe(
|
||||
"desc",
|
||||
);
|
||||
expect(parseListParams(new URLSearchParams("order=bogus")).order).toBe(
|
||||
"desc",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("calcPagination", () => {
|
||||
it("computes last page, offset and clamps the page", () => {
|
||||
const p = calcPagination(50, 3, 20);
|
||||
expect(p.lastPage).toBe(3);
|
||||
expect(p.page).toBe(3);
|
||||
expect(p.offset).toBe(40);
|
||||
});
|
||||
|
||||
it("clamps page beyond last page", () => {
|
||||
const p = calcPagination(10, 99, 20);
|
||||
expect(p.lastPage).toBe(1);
|
||||
expect(p.page).toBe(1);
|
||||
expect(p.offset).toBe(0);
|
||||
});
|
||||
|
||||
it("handles zero total with lastPage 1", () => {
|
||||
const p = calcPagination(0, 1, 20);
|
||||
expect(p.lastPage).toBe(1);
|
||||
expect(p.offset).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PER_PAGE_OPTIONS", () => {
|
||||
it("exposes the selectable page sizes", () => {
|
||||
expect(PER_PAGE_OPTIONS).toEqual([10, 20, 50]);
|
||||
});
|
||||
});
|
||||
@@ -2,45 +2,66 @@ import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("CI workflow", () => {
|
||||
const workflow = readFileSync(
|
||||
resolve(process.cwd(), ".gitea/workflows/ci.yaml"),
|
||||
"utf8",
|
||||
).replace(/\r\n/g, "\n");
|
||||
const workflow = readFileSync(
|
||||
resolve(process.cwd(), ".gitea/workflows/ci.yaml"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
it("runs check then production deploy on push to main", () => {
|
||||
describe("CI workflow", () => {
|
||||
it("has check and deploy jobs", () => {
|
||||
expect(workflow).toContain("check:");
|
||||
expect(workflow).toContain("deploy:");
|
||||
});
|
||||
|
||||
it("deploy depends on check", () => {
|
||||
expect(workflow).toContain("needs: check");
|
||||
});
|
||||
|
||||
it("deploy only runs on push to main/master", () => {
|
||||
expect(workflow).toContain("gitea.event_name == 'push'");
|
||||
expect(workflow).toContain("gitea.ref_name == 'main'");
|
||||
});
|
||||
|
||||
it("check runs lint, typecheck, and test", () => {
|
||||
expect(workflow).toContain("pnpm biome:lint");
|
||||
expect(workflow).toContain("pnpm typecheck");
|
||||
expect(workflow).toContain("pnpm test");
|
||||
expect(workflow).toContain("needs: check");
|
||||
expect(workflow).toContain(
|
||||
"gitea.event_name == 'push' && gitea.ref_name == 'main'",
|
||||
});
|
||||
|
||||
it("check job runs on self-hosted runner", () => {
|
||||
const checkJob = workflow.slice(
|
||||
workflow.indexOf("check:"),
|
||||
workflow.indexOf("\n deploy:"),
|
||||
);
|
||||
expect(workflow).toContain("worktree add --detach");
|
||||
expect(workflow).toContain("/api/health");
|
||||
expect(checkJob).toContain("runs-on: self-hosted");
|
||||
});
|
||||
|
||||
it("deploy job runs on self-hosted runner", () => {
|
||||
const deployJob = workflow.slice(workflow.indexOf("\n deploy:"));
|
||||
expect(deployJob).toContain("runs-on: self-hosted");
|
||||
});
|
||||
|
||||
it("check includes toolchain check", () => {
|
||||
expect(workflow).toContain("scripts/check-node-toolchain.mjs");
|
||||
});
|
||||
|
||||
it("uses only valid pnpm install flags (no --jobs)", () => {
|
||||
expect(workflow).toContain("pnpm install --frozen-lockfile");
|
||||
expect(workflow).not.toContain("--jobs");
|
||||
});
|
||||
|
||||
it("does not use github context", () => {
|
||||
expect(workflow).not.toContain("github.ref");
|
||||
});
|
||||
|
||||
it("uses Gitea SHA for check checkout", () => {
|
||||
expect(workflow).toContain("gitea.sha");
|
||||
expect(workflow).toContain("SKIP_ENV_VALIDATION=1");
|
||||
it("has tag trigger", () => {
|
||||
expect(workflow).toContain('tags: ["v*"]');
|
||||
});
|
||||
|
||||
it("keeps tag release in the same workflow", () => {
|
||||
expect(workflow).toContain('tags:\n - "v*"');
|
||||
expect(workflow).toContain("Creating release for");
|
||||
expect(workflow).toContain("startsWith(gitea.ref_name, 'v')");
|
||||
});
|
||||
|
||||
it("applies CMS migrations on tag release before build", () => {
|
||||
const release = workflow.slice(workflow.indexOf("\n release:"));
|
||||
expect(release).toContain("pnpm db:migrate");
|
||||
expect(release).not.toContain("pnpm prisma:generate");
|
||||
expect(release).toContain("pnpm db:migrate");
|
||||
expect(release).toContain("src/db/schema.ts");
|
||||
const migrateAt = release.indexOf("pnpm db:migrate");
|
||||
const buildAt = release.indexOf("pnpm build");
|
||||
expect(migrateAt).toBeGreaterThan(-1);
|
||||
expect(buildAt).toBeGreaterThan(migrateAt);
|
||||
it("has e2e job that smoke-tests the deployed app", () => {
|
||||
expect(workflow).toContain("e2e:");
|
||||
expect(workflow).toContain("needs: deploy");
|
||||
expect(workflow).toContain("pnpm test:e2e");
|
||||
expect(workflow).toContain("PLAYWRIGHT_BASE_URL");
|
||||
});
|
||||
});
|
||||
@@ -1,200 +1,97 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const liveVar = "${" + "LIVE}";
|
||||
const stageVar = "${" + "STAGE}";
|
||||
const userVar = "${" + "DEPLOY_USER}";
|
||||
const groupVar = "${" + "DEPLOY_GROUP}";
|
||||
const cutoverStartedVar = "${" + "CUTOVER_STARTED}";
|
||||
const workflow = readFileSync(
|
||||
resolve(process.cwd(), ".gitea/workflows/ci.yaml"),
|
||||
"utf8",
|
||||
);
|
||||
const deployStart = workflow.indexOf("\n deploy:");
|
||||
const e2eStart = workflow.indexOf("\n e2e:");
|
||||
const deployJob =
|
||||
deployStart > -1
|
||||
? workflow.slice(deployStart, e2eStart > deployStart ? e2eStart : undefined)
|
||||
: "";
|
||||
|
||||
function toContainLiteral(workflow: string, literal: string) {
|
||||
return workflow.indexOf(literal) >= 0;
|
||||
}
|
||||
describe("deploy job", () => {
|
||||
it("runs on self-hosted for Docker access", () => {
|
||||
expect(deployJob).toContain("runs-on: self-hosted");
|
||||
});
|
||||
|
||||
describe("production deploy workflow", () => {
|
||||
const workflow = readFileSync(
|
||||
resolve(process.cwd(), ".gitea/workflows/ci.yaml"),
|
||||
"utf8",
|
||||
);
|
||||
const deployStart = workflow.indexOf("\n deploy:");
|
||||
const afterDeploy = workflow.indexOf("\n release:", deployStart + 1);
|
||||
const deployJob =
|
||||
afterDeploy > deployStart
|
||||
? workflow.slice(deployStart, afterDeploy)
|
||||
: workflow.slice(deployStart);
|
||||
it("has checkout step", () => {
|
||||
expect(deployJob).toContain("actions/checkout@v4");
|
||||
});
|
||||
|
||||
it("is gated behind the check job", () => {
|
||||
expect(deployJob).toContain("needs: check");
|
||||
expect(deployJob).toContain(
|
||||
"gitea.event_name == 'push' && gitea.ref_name == 'main'",
|
||||
it("builds Docker image with BuildKit", () => {
|
||||
expect(deployJob).toContain("DOCKER_BUILDKIT=1");
|
||||
expect(deployJob).toContain("docker build");
|
||||
expect(deployJob).toContain("-t epicnext-cms:latest");
|
||||
});
|
||||
|
||||
it("builds on host network for registry access", () => {
|
||||
expect(deployJob).toContain("--network=host");
|
||||
});
|
||||
|
||||
it("stops old container before starting new one", () => {
|
||||
expect(deployJob).toContain("docker stop epicnext-cms-app");
|
||||
expect(deployJob).toContain("docker rm epicnext-cms-app");
|
||||
});
|
||||
|
||||
it("starts container with correct settings", () => {
|
||||
expect(deployJob).toContain("--restart always");
|
||||
expect(deployJob).toContain("--net=host");
|
||||
expect(deployJob).toContain("--name epicnext-cms-app");
|
||||
});
|
||||
|
||||
it("provides production .env to the build", () => {
|
||||
expect(deployJob).toContain("cp /var/www/atom-nexst/.env .env");
|
||||
});
|
||||
|
||||
it("runs container with production env and volumes", () => {
|
||||
expect(deployJob).toContain(". /var/www/atom-nexst/.env");
|
||||
// biome-ignore lint/suspicious/noTemplateCurlyInString: intentional literal shell snippet
|
||||
expect(deployJob).toContain('"${ENV_ARGS[@]}"');
|
||||
expect(deployJob).toContain("/var/www/Gamedata:/var/www/Gamedata");
|
||||
expect(deployJob).toContain("/app/storage");
|
||||
});
|
||||
|
||||
it("does not use --env-file (it keeps literal quotes)", () => {
|
||||
expect(deployJob).not.toContain("--env-file");
|
||||
});
|
||||
|
||||
it("frees port 3002 by stopping the compose container", () => {
|
||||
expect(deployJob).toContain("docker stop epicnext-cms 2>/dev/null || true");
|
||||
});
|
||||
|
||||
it("rolls back to compose on health check failure", () => {
|
||||
expect(deployJob).toContain("docker compose");
|
||||
});
|
||||
|
||||
it("runs database migrations before replacing the container", () => {
|
||||
expect(deployJob).toContain("pnpm db:migrate");
|
||||
expect(deployJob.indexOf("pnpm db:migrate")).toBeLessThan(
|
||||
deployJob.indexOf("docker stop epicnext-cms-app"),
|
||||
);
|
||||
});
|
||||
|
||||
it("builds in a stage worktree while preserving live .env and storage", () => {
|
||||
expect(deployJob).toContain("worktree add --detach");
|
||||
expect(deployJob).toContain("/var/tmp/atom-nexst-stage-");
|
||||
expect(toContainLiteral(deployJob, `ln -sfn "${liveVar}/.env"`)).toBe(true);
|
||||
expect(deployJob).toContain("-e storage");
|
||||
expect(deployJob).toContain("DATABASE_POOL_SIZE=");
|
||||
expect(deployJob).toContain("REDIS_URL is unset");
|
||||
expect(deployJob).not.toContain("SKIP_ENV_VALIDATION=1");
|
||||
expect(deployJob).toContain("pnpm install --frozen-lockfile");
|
||||
it("runs health check", () => {
|
||||
expect(deployJob).toContain("/api/health");
|
||||
expect(deployJob).toContain('"database":true');
|
||||
});
|
||||
|
||||
it("preserves runtime furni assets when cleaning the live checkout", () => {
|
||||
const cleanStart = deployJob.indexOf("git clean -fd \\");
|
||||
const commandLines: string[] = [];
|
||||
for (const line of deployJob.slice(cleanStart).split(/\r?\n/)) {
|
||||
commandLines.push(line);
|
||||
if (!line.trimEnd().endsWith("\\")) break;
|
||||
}
|
||||
const cleanLines = commandLines.join(" ");
|
||||
const excludes = Array.from(
|
||||
cleanLines.matchAll(/-e\s+([^\s\\]+)/g),
|
||||
).flatMap(([, pattern]) => ["-e", pattern]);
|
||||
const work = mkdtempSync(resolve(tmpdir(), "epicnext-deploy-clean-"));
|
||||
const runtimeFiles = [
|
||||
"public/swf/dcr/hof_furni/icons/runtime_icon.png",
|
||||
"public/swf/dcr/hof_furni/swf/runtime.swf",
|
||||
"public/nitro-assets/bundled/furniture/runtime.nitro",
|
||||
];
|
||||
|
||||
try {
|
||||
spawnSync("git", ["init", "--quiet"], { cwd: work });
|
||||
for (const file of [...runtimeFiles, "remove-me.tmp"]) {
|
||||
const absolute = resolve(work, file);
|
||||
mkdirSync(resolve(absolute, ".."), { recursive: true });
|
||||
writeFileSync(absolute, "runtime");
|
||||
}
|
||||
|
||||
const clean = spawnSync("git", ["clean", "-fd", ...excludes], {
|
||||
cwd: work,
|
||||
encoding: "utf8",
|
||||
});
|
||||
|
||||
expect(clean.status, clean.stderr).toBe(0);
|
||||
for (const file of runtimeFiles) {
|
||||
expect(existsSync(resolve(work, file)), file).toBe(true);
|
||||
}
|
||||
expect(existsSync(resolve(work, "remove-me.tmp"))).toBe(false);
|
||||
} finally {
|
||||
rmSync(work, { recursive: true, force: true });
|
||||
}
|
||||
it("cleans up old images", () => {
|
||||
expect(deployJob).toContain("docker image prune -f");
|
||||
});
|
||||
|
||||
it("reclaims ownership before git operations so www-data files can be overwritten", () => {
|
||||
const chownCmd = `sudo chown -R "${userVar}:${groupVar}"`;
|
||||
expect(toContainLiteral(workflow, chownCmd)).toBe(true);
|
||||
const reclaimAt = deployJob.indexOf(chownCmd);
|
||||
expect(
|
||||
toContainLiteral(deployJob, `git -C "${liveVar}" fetch origin --prune`),
|
||||
).toBe(true);
|
||||
const fetchAt = deployJob.indexOf(
|
||||
`git -C "${liveVar}" fetch origin --prune`,
|
||||
);
|
||||
expect(reclaimAt).toBeGreaterThan(-1);
|
||||
expect(fetchAt).toBeGreaterThan(reclaimAt);
|
||||
});
|
||||
|
||||
it("avoids nuclear src wipe and verifies live src after cutover reset", () => {
|
||||
expect(deployJob).not.toContain("rm -rf src");
|
||||
expect(deployJob).not.toContain("Nuclear-replacing src/");
|
||||
expect(deployJob).toContain("no-skip-worktree");
|
||||
expect(deployJob).toContain("no-assume-unchanged");
|
||||
expect(deployJob).toContain("Verified live src/ matches HEAD");
|
||||
expect(deployJob).toContain("ls-files -v");
|
||||
expect(deployJob).not.toContain("git ls-files -z");
|
||||
expect(deployJob).toContain("pnpm typecheck");
|
||||
});
|
||||
|
||||
it("migrates while the current app is online, then swaps the built artifact", () => {
|
||||
expect(deployJob).toContain("mv .next .next.prev");
|
||||
expect(toContainLiteral(deployJob, `mv "${stageVar}/.next" .next`)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
toContainLiteral(deployJob, `mv "${stageVar}/node_modules" node_modules`),
|
||||
).toBe(true);
|
||||
expect(deployJob).toContain("mv node_modules node_modules.prev");
|
||||
expect(deployJob).toContain("Rolling back .next to previous artifact");
|
||||
expect(deployJob).toContain(
|
||||
"Rolling back node_modules to previous artifact",
|
||||
);
|
||||
const buildAt = deployJob.indexOf("pnpm build");
|
||||
const stopAt = deployJob.indexOf("pm2 stop next");
|
||||
const migrateAt = deployJob.indexOf("pnpm db:migrate");
|
||||
const resetAt = deployJob.indexOf("git reset --hard origin/main");
|
||||
const startLabelAt = deployJob.indexOf("Starting PM2");
|
||||
const startAt = deployJob.indexOf(
|
||||
"pm2 start pnpm --name next -- start",
|
||||
startLabelAt,
|
||||
);
|
||||
expect(buildAt).toBeGreaterThan(-1);
|
||||
expect(migrateAt).toBeGreaterThan(buildAt);
|
||||
expect(resetAt).toBeGreaterThan(migrateAt);
|
||||
expect(stopAt).toBeGreaterThan(resetAt);
|
||||
expect(startLabelAt).toBeGreaterThan(stopAt);
|
||||
expect(startAt).toBeGreaterThan(startLabelAt);
|
||||
expect(deployJob.match(/pm2 stop next/g)).toHaveLength(1);
|
||||
expect(deployJob.slice(stopAt, startAt)).not.toContain("sleep ");
|
||||
});
|
||||
|
||||
it("does not restart the healthy app when deployment fails before cutover", () => {
|
||||
const handlerStart = deployJob.indexOf("error_handler() {");
|
||||
const handlerEnd = deployJob.indexOf("trap 'error_handler", handlerStart);
|
||||
const handler = deployJob.slice(handlerStart, handlerEnd);
|
||||
const cutoverGuardAt = handler.indexOf(
|
||||
`if [ "${cutoverStartedVar}" = "1" ]; then`,
|
||||
);
|
||||
const restartAt = handler.indexOf("pm2 restart next", cutoverGuardAt);
|
||||
const stageCleanupAt = handler.indexOf(`if [ -n "${stageVar}"`, restartAt);
|
||||
|
||||
expect(handlerStart).toBeGreaterThan(-1);
|
||||
expect(cutoverGuardAt).toBeGreaterThan(-1);
|
||||
expect(restartAt).toBeGreaterThan(cutoverGuardAt);
|
||||
expect(stageCleanupAt).toBeGreaterThan(restartAt);
|
||||
expect(handler.slice(restartAt, stageCleanupAt)).toContain(
|
||||
"\n fi",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not override onlyBuiltDependencies (uses pnpm-workspace.yaml)", () => {
|
||||
expect(workflow).not.toContain("PNPM_CONFIG_ONLY_BUILT_DEPENDENCIES");
|
||||
});
|
||||
|
||||
it("runs typecheck before build in the stage", () => {
|
||||
const typecheckAt = deployJob.indexOf("pnpm typecheck");
|
||||
const buildAt = deployJob.indexOf("pnpm build");
|
||||
expect(typecheckAt).toBeGreaterThan(-1);
|
||||
expect(buildAt).toBeGreaterThan(typecheckAt);
|
||||
it("does not run pnpm test in deploy", () => {
|
||||
expect(deployJob).not.toContain("pnpm test");
|
||||
});
|
||||
|
||||
it("exports APP_VERSION from git for the deployment ID fallback", () => {
|
||||
const exportCmd = `export APP_VERSION="$(git -C "${liveVar}" rev-parse --short origin/main)"`;
|
||||
expect(toContainLiteral(workflow, exportCmd)).toBe(true);
|
||||
it("shows docker logs on failure", () => {
|
||||
expect(deployJob).toContain("docker logs epicnext-cms-app");
|
||||
});
|
||||
|
||||
it("runs an HTTP health check before declaring deploy success", () => {
|
||||
expect(workflow).toContain("/api/health");
|
||||
expect(workflow).toContain('"database":true');
|
||||
expect(deployJob).toContain("export PORT=");
|
||||
expect(deployJob).toContain("free_tcp_port");
|
||||
const startAt = deployJob.indexOf("pm2 start pnpm --name next -- start");
|
||||
const healthAt = deployJob.indexOf("/api/health");
|
||||
const successAt = deployJob.indexOf("--- Deployed successfully ---");
|
||||
expect(startAt).toBeGreaterThan(-1);
|
||||
expect(healthAt).toBeGreaterThan(startAt);
|
||||
expect(successAt).toBeGreaterThan(healthAt);
|
||||
it("exits with error on health check failure", () => {
|
||||
expect(deployJob).toContain("exit 1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { sanitize } from "./sanitize";
|
||||
|
||||
describe("sanitize", () => {
|
||||
it("returns empty string for nullish input", () => {
|
||||
expect(sanitize(null)).toBe("");
|
||||
expect(sanitize(undefined)).toBe("");
|
||||
expect(sanitize("")).toBe("");
|
||||
});
|
||||
|
||||
it("keeps safe formatting tags", () => {
|
||||
const out = sanitize("<p>Hello <strong>world</strong></p>");
|
||||
expect(out).toContain("<strong>world</strong>");
|
||||
});
|
||||
|
||||
it("strips event handlers", () => {
|
||||
const out = sanitize('<img src="x.gif" onerror="alert(1)">');
|
||||
expect(out).not.toContain("onerror");
|
||||
});
|
||||
|
||||
it("strips javascript: URLs", () => {
|
||||
const out = sanitize('<a href="javascript:alert(1)">click</a>');
|
||||
expect(out).not.toContain("javascript:");
|
||||
});
|
||||
|
||||
it("strips svg onload vectors", () => {
|
||||
const out = sanitize('<svg onload="alert(1)"><circle r="10"/></svg>');
|
||||
expect(out).not.toContain("onload");
|
||||
expect(out).not.toContain("<svg");
|
||||
});
|
||||
|
||||
it("strips script tags", () => {
|
||||
const out = sanitize("<p>hi</p><script>alert(1)</script>");
|
||||
expect(out).not.toContain("<script");
|
||||
expect(out).toContain("hi");
|
||||
});
|
||||
});
|
||||
+47
-3
@@ -1,8 +1,52 @@
|
||||
import DOMPurify from "dompurify";
|
||||
import DOMPurify from "isomorphic-dompurify";
|
||||
|
||||
const sanitizeHtml = (html: string) => DOMPurify.sanitize(html);
|
||||
const ALLOWED_TAGS = [
|
||||
"a",
|
||||
"b",
|
||||
"blockquote",
|
||||
"br",
|
||||
"code",
|
||||
"em",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"hr",
|
||||
"i",
|
||||
"img",
|
||||
"li",
|
||||
"ol",
|
||||
"p",
|
||||
"pre",
|
||||
"strong",
|
||||
"table",
|
||||
"tbody",
|
||||
"td",
|
||||
"th",
|
||||
"thead",
|
||||
"tr",
|
||||
"u",
|
||||
"ul",
|
||||
];
|
||||
|
||||
const ALLOWED_ATTR = [
|
||||
"href",
|
||||
"src",
|
||||
"alt",
|
||||
"title",
|
||||
"target",
|
||||
"rel",
|
||||
"colspan",
|
||||
"rowspan",
|
||||
];
|
||||
|
||||
export function sanitize(html: string | null | undefined): string {
|
||||
if (!html) return "";
|
||||
return sanitizeHtml(html);
|
||||
return DOMPurify.sanitize(html, {
|
||||
ALLOWED_TAGS,
|
||||
ALLOWED_ATTR,
|
||||
ALLOW_DATA_ATTR: false,
|
||||
FORBID_TAGS: ["style", "script", "svg", "math", "form", "input", "button"],
|
||||
USE_PROFILES: { html: true },
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { existsSync, promises as fs } from "node:fs";
|
||||
import { logServerError } from "@/lib/server-log";
|
||||
import { resolveFigureSwfUrl } from "@/lib/services/figure-source";
|
||||
import { listFigureLibraries } from "@/lib/services/figuremap";
|
||||
import { getGamedataRoot } from "@/lib/services/furni-asset-dirs";
|
||||
@@ -140,7 +141,9 @@ export async function deleteImportedFigure(
|
||||
): Promise<{ deletedFile: boolean }> {
|
||||
const p = await nitroPathFor(lib);
|
||||
if (existsSync(/*turbopackIgnore: true*/ p)) {
|
||||
await fs.unlink(/*turbopackIgnore: true*/ p).catch(() => {});
|
||||
await fs
|
||||
.unlink(/*turbopackIgnore: true*/ p)
|
||||
.catch((error) => logServerError("figure.delete_failed", error, { lib }));
|
||||
return { deletedFile: true };
|
||||
}
|
||||
return { deletedFile: false };
|
||||
|
||||
@@ -6,6 +6,7 @@ import { and, eq, type SQL, sql } from "drizzle-orm";
|
||||
import { CatalogPages, db, ItemsBase } from "@/lib/db";
|
||||
import { officialHabboEnrichmentWarning } from "@/lib/habbo-gamedata-hotel";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { logServerError } from "@/lib/server-log";
|
||||
import {
|
||||
DEFAULT_FURNI_NITRO_DIR,
|
||||
getFurniAssetDirs,
|
||||
@@ -479,7 +480,9 @@ export async function allocateCatalogItemId<T>(
|
||||
catalogIdSeedChain = new Promise<void>((r) => {
|
||||
settle = r;
|
||||
});
|
||||
await prev.catch(() => {});
|
||||
await prev.catch((error) =>
|
||||
logServerError("furni.catalog_id_chain_failed", error),
|
||||
);
|
||||
try {
|
||||
if (
|
||||
catalogNextId === null ||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import "server-only";
|
||||
|
||||
import { desc } from "drizzle-orm";
|
||||
import { and, desc, eq, or, sql } from "drizzle-orm";
|
||||
import { cached } from "@/lib/cache";
|
||||
import { db, WebsiteArticles } from "@/lib/db";
|
||||
|
||||
@@ -42,6 +42,15 @@ export async function getNewsList(limit: number): Promise<NewsListItem[]> {
|
||||
createdAt: WebsiteArticles.createdAt,
|
||||
})
|
||||
.from(WebsiteArticles)
|
||||
.where(
|
||||
and(
|
||||
eq(WebsiteArticles.status, "published"),
|
||||
or(
|
||||
sql`${WebsiteArticles.publishAt} IS NULL`,
|
||||
sql`${WebsiteArticles.publishAt} <= NOW()`,
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(WebsiteArticles.createdAt))
|
||||
.limit(FETCH_LIMIT),
|
||||
);
|
||||
|
||||
Loaded 100 of 129 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user