diff --git a/.dockerignore b/.dockerignore index aff52080..e38d7e3c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -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 diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index e714af68..26892add 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -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:-}), retrying..." - sleep 2 - done - if [ "${HEALTH_OK}" != "1" ]; then - echo "ERROR: Health check failed after deploy (${HEALTH_URL})" >&2 - echo "Last body: ${BODY:-}" >&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 '' - 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 '' - 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 '' - 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 '' - 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 '' - echo "## Changes" - echo '```' - echo "${CHANGELOG}" - echo '```' - echo "" - echo '' - 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 diff --git a/.gitea/workflows/renovate.yaml b/.gitea/workflows/renovate.yaml index 6ab09463..875e93e1 100644 --- a/.gitea/workflows/renovate.yaml +++ b/.gitea/workflows/renovate.yaml @@ -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 diff --git a/.gitignore b/.gitignore index 17cb3763..fa734ee4 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,9 @@ public/tmp/ # Test coverage reports coverage/ + +# Playwright test artifacts +test-results/ +playwright-report/ +blob-report/ +.aider* diff --git a/Dockerfile b/Dockerfile index 32b7ad5d..9c4e0051 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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: pnpm@11.24.0). -# Node 26 does not bundle corepack anymore, so install pnpm via npm. -RUN npm install -g pnpm@11.24.0 +# 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 pnpm@11.25.0 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"] diff --git a/README.md b/README.md index 45462106..ebec2dc6 100644 --- a/README.md +++ b/README.md @@ -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:password@127.0.0.1:3306/epicnext_cms AUTH_SECRET= 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 diff --git a/docker-compose.yml b/docker-compose.yml index 80010c5b..fc560f1e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts new file mode 100644 index 00000000..60b9f1a8 --- /dev/null +++ b/e2e/smoke.spec.ts @@ -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(/.+/); +}); diff --git a/eslint.config.mjs b/eslint.config.mjs deleted file mode 100644 index c1a50205..00000000 --- a/eslint.config.mjs +++ /dev/null @@ -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", - }, - }, -); diff --git a/package.json b/package.json index 8ff7d5a0..4df4ffdd 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..c14dd960 --- /dev/null +++ b/playwright.config.ts @@ -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"] } }], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 63be703e..dd86480b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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(mysql2@3.24.2(@types/node@26.4.0)) @@ -64,6 +61,9 @@ importers: ioredis: specifier: 6.0.0 version: 6.0.0(supports-color@7.2.0) + isomorphic-dompurify: + specifier: ^4.1.0 + version: 4.1.0(@noble/hashes@2.4.0) jpeg-js: specifier: 0.4.4 version: 0.4.4 @@ -92,14 +92,14 @@ importers: specifier: 3.24.2 version: 3.24.2(@types/node@26.4.0) next: - specifier: 16.3.3 - version: 16.3.3(@types/node@26.4.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + specifier: 16.3.4 + version: 16.3.4(@playwright/test@1.62.1)(@types/node@26.4.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) next-auth: specifier: 5.0.0-beta.32 - version: 5.0.0-beta.32(next@16.3.3(@types/node@26.4.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) + version: 5.0.0-beta.32(next@16.3.4(@playwright/test@1.62.1)(@types/node@26.4.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) next-intl: specifier: 4.14.1 - version: 4.14.1(@swc/helpers@0.5.23)(next@16.3.3(@types/node@26.4.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) + version: 4.14.1(@swc/helpers@0.5.23)(next@16.3.4(@playwright/test@1.62.1)(@types/node@26.4.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) 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(tailwindcss@4.3.3) @@ -187,7 +190,7 @@ importers: version: 7.0.2 vitest: specifier: 4.1.11 - version: 4.1.11(@types/node@26.4.0)(@vitest/coverage-v8@4.1.11)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) + version: 4.1.11(@types/node@26.4.0)(@vitest/coverage-v8@4.1.11)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@26.4.0)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) packages: @@ -195,6 +198,14 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} + '@asamuzakjp/css-color@6.0.7': + resolution: {integrity: sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==} + engines: {node: ^22.13.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@8.3.2': + resolution: {integrity: sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==} + engines: {node: ^22.13.0 || >=24.0.0} + '@auth/core@0.41.3': resolution: {integrity: sha512-sJ3JMHHkXMD3aOjopv7mOBTO1Ocw4b0fAEXJBz6k7YHLpYQI6C40jCUPc5fNvUKxXRXNE1/sRISA15UrwWJBTw==} peerDependencies: @@ -321,6 +332,46 @@ packages: '@borewit/text-codec@0.2.2': resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + + '@csstools/color-helpers@6.1.1': + resolution: {integrity: sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.3.0': + 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/css-color-parser@4.2.2': + 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/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.12': + resolution: {integrity: sha512-3vLQK+dXxhBMR2Wx99PTCifE+vHtW2ndZWyla8yK813ev6oGhyn8Lja8jCyGAWTJ+LEYZK7EVtJxrDj8ztevJw==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@dnd-kit/accessibility@3.1.1': resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} peerDependencies: @@ -531,6 +582,15 @@ packages: cpu: [x64] os: [win32] + '@exodus/bytes@1.15.1': + 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/core@1.8.0': 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/env@16.3.3': - resolution: {integrity: sha512-U2eYQRwXj+dsqxV79zFqExDdatnNY/ZWc2nsJU1p/OgT7fd3dXwlF6OjYaFQCfMoeTA19PWq+wVmYgimVA+V+g==} + '@next/env@16.3.4': + resolution: {integrity: sha512-cjWZnUUa6jZq2kFaNe/ZyJdZonOZ/QoN0Zka2nz/FLOrfx14pQuM9c5RaSVkWMqgdt4ksgPAMWPyHSs/CyV48Q==} - '@next/swc-darwin-arm64@16.3.3': - resolution: {integrity: sha512-8Hiv32QJPwdV6KYJ8meR9SBA061tQqnIKTJDocvOXlEQqib0xMFpzArosuffFUUc0sslbh7QQ8a3Yey1QV8EIw==} + '@next/swc-darwin-arm64@16.3.4': + resolution: {integrity: sha512-iBr3I5LZNk5/bgl5//iTgD2tcym14MX0Xo7fD//u9dYAEgGzza1y9oywluPtf74YnOswVdH1908aK9xVz7zQTw==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@16.3.3': - resolution: {integrity: sha512-A1lgKgwVchRYmSe467zdwhxT9040dd8lH+o65sL5Jet8fjB4kegw/rDyPIpYVRb6jAqwXFOJpjIXJLxQKLiE3A==} + '@next/swc-darwin-x64@16.3.4': + resolution: {integrity: sha512-2dpiSyl2Jw/NrBPaU2MAKGSa+2MR82pJIn4Sm5Rjr+gxAeuh0z158Su3Z2O8zn7UNNq+ej4bToed6RcRN/Lydg==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@16.3.3': - resolution: {integrity: sha512-bf0FIssMFueU2dm7vQEWWxk0c8UjKTdW0yzuh0sQsD8pf1+KCLDdaqhYZNMYGmXwEOiHAUzgBKudovIlcvvBjg==} + '@next/swc-linux-arm64-gnu@16.3.4': + resolution: {integrity: sha512-+t+U8HZT+fApePCS5h89CSH3datz29MkzyfCn+6fpsZBG/oiEOhINcb9rtkv6sdpToLGFn2e6146NzaKCXkqrA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@next/swc-linux-arm64-musl@16.3.3': - resolution: {integrity: sha512-W7viwCk9JY/cAkdz/A273rd5bb3RgT/IHwR7Upv90tunjBWNtAAhGhoecHh+teRNRSinuAFmE+l7fwZ4YKkrXg==} + '@next/swc-linux-arm64-musl@16.3.4': + resolution: {integrity: sha512-mx03GNs1ocQA5JQ4FxDMmIsNkdrZh8cuezKCrId28e5/gIPU/l7Kcy2+vmCCzdjnnmXJy+iOAu+7K0QppO6Urg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@next/swc-linux-x64-gnu@16.3.3': - resolution: {integrity: sha512-0W46zw1N3ODpI6n0GeivHvvob1pooozgZVqy65k0mh4/7vr+FbY9+WpHzNVXjHipJf/A3FDheBG19H1s5A25rA==} + '@next/swc-linux-x64-gnu@16.3.4': + resolution: {integrity: sha512-YIhGY6fSMfha52bnVxnzc9zaVBzJg+cqQTOD8tXIBSx4fuv0pVMxQTE0PaS59YhnMOiYiG09IMwxJAf/CFm/Dw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@next/swc-linux-x64-musl@16.3.3': - resolution: {integrity: sha512-H4mBso8ZTMBPtdT0PN0pBx2ayTvQuTuvS6qT13d77yVFJXAPCxkyIhLTmdMaGTJs0krQYI/qpzdHijCeihXhbg==} + '@next/swc-linux-x64-musl@16.3.4': + resolution: {integrity: sha512-+eaaX6axpDb0yF1GCpiERe6njplvdC+nks/fKfcHu3XPGRrald8P3/X7yv7QLdjA51knnxwl9pxdIJsg+w1L+Q==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@next/swc-win32-arm64-msvc@16.3.3': - resolution: {integrity: sha512-cTMUJpcEGmeywofCUfhR+rSsoE33+rVPnPEYNTNdLNlsOeEg/vktOsKUSTb28vUGqD2jkm4Zaskcwn7OCI6FQg==} + '@next/swc-win32-arm64-msvc@16.3.4': + resolution: {integrity: sha512-0jcXW7Xs/uzICrmgV3MhDYDeRy++1CqnpDIerlPIqYO4bhzB4WNbX/aRnQclustsAyTkFKB0z6rbcjmNg5tR8A==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@16.3.3': - resolution: {integrity: sha512-2VR4cTBzHXaBjnGsuH6GyJjENzQOmHeAh11uY1iUhjm3j5dEUrVJuUj+VL78jaGi/Dik8xS76zEj18BsFhlVZQ==} + '@next/swc-win32-x64-msvc@16.3.4': + resolution: {integrity: sha512-vvBzwu1pYQCp92maZCFCIw/XgOTMR5tur9GjakwIo2cmwRTMKajRZZDS9+e4KsUZWKu1E007WUeAFXRRjZeuzw==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -1220,6 +1280,11 @@ packages: '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@playwright/test@1.62.1': + resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==} + engines: {node: '>=20'} + hasBin: true + '@radix-ui/primitive@1.1.7': resolution: {integrity: sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==} @@ -1918,6 +1983,9 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -1965,6 +2033,10 @@ packages: resolution: {integrity: sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g==} engines: {node: '>=18.0'} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -1973,6 +2045,10 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + dateformat@4.6.3: resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} @@ -1985,6 +2061,9 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + denque@2.1.0: 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'} + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + es-module-lexer@2.3.2: resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} @@ -2158,6 +2241,11 @@ packages: react-dom: optional: true + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -2186,6 +2274,10 @@ packages: help-me@5.0.0: resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} @@ -2225,12 +2317,19 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-property@1.0.2: resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==} isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + isomorphic-dompurify@4.1.0: + resolution: {integrity: sha512-vjQwQSxyEkJHO6g+KIDlWj8swAzUB01pJu9GU9cLGHjE10d59GFzJkgtB2Lj5WTOoq9JeHlYjR/FFuqeopPO0Q==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + istanbul-lib-coverage@3.2.2: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} engines: {node: '>=8'} @@ -2260,6 +2359,15 @@ packages: js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + jsdom@30.0.1: + 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 + jsonc-parser@3.3.1: resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} @@ -2444,6 +2552,10 @@ packages: long@5.3.2: resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + lru.min@1.1.4: 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'} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + media-typer@2.0.0: 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 - next@16.3.3: - resolution: {integrity: sha512-tuRTx1nQ/yVw83cwJBo9F+njGUgMn3UHQycreWHB8XsStvvAh1AthbI8/4IpKnFaF58F+iSiHejYOlMQ/eq83g==} + next@16.3.4: + resolution: {integrity: sha512-/Ztf6CeRH+ejEXUrYtqI4gkS66eFIHuSwqi60RgcpWKodxFZx2/dqVCMKBwILfAHXQ+F1b1vAudgj3mnxqtoIA==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -2600,6 +2715,9 @@ packages: pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -2624,6 +2742,16 @@ packages: resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} hasBin: true + playwright-core@1.62.1: + resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.62.1: + resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==} + engines: {node: '>=20'} + hasBin: true + po-parser@2.2.0: resolution: {integrity: sha512-NdTrKgh0oO7+y+RFX8KKhOB438x/j94UGXEFzl/7pHH0JjWSn42iJ9tgmPksIO6n1JKDHmNDcejPJfKspljB9g==} @@ -2655,6 +2783,10 @@ packages: pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} @@ -2717,6 +2849,10 @@ packages: resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} engines: {node: '>=4'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + reselect@5.3.0: resolution: {integrity: sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg==} @@ -2747,6 +2883,10 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.27.0: 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'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tailwind-merge@3.6.0: 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'} + tldts-core@7.4.11: + resolution: {integrity: sha512-CW3WN2rIIE/Of21mulhgnGOwoDyEFNygyIBOONSdyAuSATgMMUCpLeUlB+E8sAwA5xRV9hYPl+kyZ9citHCaKg==} + + tldts@7.4.11: + resolution: {integrity: sha512-aBiNayCfTQxuIJBm06M+xR14cYaYlDlSXZbgsnKzKNxDKUVq7KFwTjwBSsb7m9Y5xO8WfPnBc63WaYFMTGlvqw==} + hasBin: true + token-types@6.1.2: resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} engines: {node: '>=14.16'} + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -2913,6 +3071,10 @@ packages: undici-types@8.3.0: resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + undici@8.10.1: + resolution: {integrity: sha512-YQ3WlbqjYMmNpdvDH64jAgLjxuAR9+649calDWhbshYaeQGO2bR4nI94ORJmwI3J9YhoKQnpyGOK+0zlWS5N5Q==} + engines: {node: '>=22.19.0'} + use-callback-ref@1.3.3: resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} engines: {node: '>=10'} @@ -3030,10 +3192,30 @@ packages: jsdom: optional: true + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + walk-up-path@4.0.0: resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} engines: {node: 20 || >=22} + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + whatwg-url@17.1.0: + resolution: {integrity: sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==} + engines: {node: ^22.14.0 || >=24.0.0} + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} @@ -3045,6 +3227,13 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + yaml@2.9.0: resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} engines: {node: '>= 14.6'} @@ -3057,6 +3246,21 @@ snapshots: '@alloc/quick-lru@5.2.0': {} + '@asamuzakjp/css-color@6.0.7': + dependencies: + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.2.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + lru-cache: 11.5.2 + + '@asamuzakjp/dom-selector@8.3.2': + 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/core@0.41.3': dependencies: '@panva/hkdf': 1.2.1 @@ -3142,6 +3346,34 @@ snapshots: '@borewit/text-codec@0.2.2': {} + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + + '@csstools/color-helpers@6.1.1': {} + + '@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.2.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.1.1 + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.12(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + '@dnd-kit/accessibility@3.1.1(react@19.2.8)': dependencies: react: 19.2.8 @@ -3289,6 +3521,10 @@ snapshots: '@esbuild/win32-x64@0.25.12': optional: true + '@exodus/bytes@1.15.1(@noble/hashes@2.4.0)': + optionalDependencies: + '@noble/hashes': 2.4.0 + '@floating-ui/core@1.8.0': dependencies: '@floating-ui/utils': 0.2.12 @@ -3460,30 +3696,30 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true - '@next/env@16.3.3': {} + '@next/env@16.3.4': {} - '@next/swc-darwin-arm64@16.3.3': + '@next/swc-darwin-arm64@16.3.4': optional: true - '@next/swc-darwin-x64@16.3.3': + '@next/swc-darwin-x64@16.3.4': optional: true - '@next/swc-linux-arm64-gnu@16.3.3': + '@next/swc-linux-arm64-gnu@16.3.4': optional: true - '@next/swc-linux-arm64-musl@16.3.3': + '@next/swc-linux-arm64-musl@16.3.4': optional: true - '@next/swc-linux-x64-gnu@16.3.3': + '@next/swc-linux-x64-gnu@16.3.4': optional: true - '@next/swc-linux-x64-musl@16.3.3': + '@next/swc-linux-x64-musl@16.3.4': optional: true - '@next/swc-win32-arm64-msvc@16.3.3': + '@next/swc-win32-arm64-msvc@16.3.4': optional: true - '@next/swc-win32-x64-msvc@16.3.3': + '@next/swc-win32-x64-msvc@16.3.4': optional: true '@noble/hashes@2.4.0': {} @@ -3697,6 +3933,10 @@ snapshots: '@pinojs/redact@0.4.0': {} + '@playwright/test@1.62.1': + dependencies: + playwright: 1.62.1 + '@radix-ui/primitive@1.1.7': {} '@radix-ui/react-compose-refs@1.1.5(@types/react@19.2.18)(react@19.2.8)': @@ -4153,7 +4393,7 @@ snapshots: obug: 2.1.4 std-env: 4.2.0 tinyrainbow: 3.1.1 - vitest: 4.1.11(@types/node@26.4.0)(@vitest/coverage-v8@4.1.11)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) + vitest: 4.1.11(@types/node@26.4.0)(@vitest/coverage-v8@4.1.11)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@26.4.0)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) '@vitest/expect@4.1.11': dependencies: @@ -4214,6 +4454,10 @@ snapshots: baseline-browser-mapping@2.11.20: {} + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + buffer-from@1.1.2: {} caniuse-lite@1.0.30001810: {} @@ -4252,10 +4496,22 @@ snapshots: croner@10.0.1: {} + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + cssesc@3.0.0: {} csstype@3.2.3: {} + data-urls@7.0.0(@noble/hashes@2.4.0): + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1(@noble/hashes@2.4.0) + transitivePeerDependencies: + - '@noble/hashes' + dateformat@4.6.3: {} debug@4.4.3(supports-color@7.2.0): @@ -4264,6 +4520,8 @@ snapshots: optionalDependencies: supports-color: 7.2.0 + decimal.js@10.6.0: {} + denque@2.1.0: {} detect-libc@2.1.2: {} @@ -4294,6 +4552,8 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.3 + entities@8.0.0: {} + es-module-lexer@2.3.2: {} esbuild@0.25.12: @@ -4368,6 +4628,9 @@ snapshots: react: 19.2.8 react-dom: 19.2.8(react@19.2.8) + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -4389,6 +4652,12 @@ snapshots: help-me@5.0.0: {} + html-encoding-sniffer@6.0.0(@noble/hashes@2.4.0): + dependencies: + '@exodus/bytes': 1.15.1(@noble/hashes@2.4.0) + transitivePeerDependencies: + - '@noble/hashes' + html-escaper@2.0.2: {} husky@9.1.7: {} @@ -4429,10 +4698,20 @@ snapshots: dependencies: is-extglob: 2.1.1 + is-potential-custom-element-name@1.0.1: {} + is-property@1.0.2: {} isarray@1.0.0: {} + isomorphic-dompurify@4.1.0(@noble/hashes@2.4.0): + dependencies: + dompurify: 3.4.14 + jsdom: 30.0.1(@noble/hashes@2.4.0) + transitivePeerDependencies: + - '@noble/hashes' + - canvas + istanbul-lib-coverage@3.2.2: {} istanbul-lib-report@3.0.1: @@ -4456,6 +4735,32 @@ snapshots: js-tokens@10.0.0: {} + jsdom@30.0.1(@noble/hashes@2.4.0): + 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(css-tree@3.2.1) + '@exodus/bytes': 1.15.1(@noble/hashes@2.4.0) + css-tree: 3.2.1 + data-urls: 7.0.0(@noble/hashes@2.4.0) + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0(@noble/hashes@2.4.0) + 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/hashes@2.4.0) + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + jsonc-parser@3.3.1: {} jszip@3.10.1: @@ -4597,6 +4902,8 @@ snapshots: long@5.3.2: {} + lru-cache@11.5.2: {} + lru.min@1.1.4: {} lucide-react@1.38.0(react@19.2.8): @@ -4619,6 +4926,8 @@ snapshots: dependencies: semver: 7.8.5 + mdn-data@2.27.1: {} + media-typer@2.0.0: {} mini-svg-data-uri@1.4.4: {} @@ -4677,15 +4986,15 @@ snapshots: dependencies: content-type: 2.1.0 - next-auth@5.0.0-beta.32(next@16.3.3(@types/node@26.4.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8): + next-auth@5.0.0-beta.32(next@16.3.4(@playwright/test@1.62.1)(@types/node@26.4.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8): dependencies: '@auth/core': 0.41.3 - next: 16.3.3(@types/node@26.4.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + next: 16.3.4(@playwright/test@1.62.1)(@types/node@26.4.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: 19.2.8 next-intl-swc-plugin-extractor@4.14.1: {} - next-intl@4.14.1(@swc/helpers@0.5.23)(next@16.3.3(@types/node@26.4.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8): + next-intl@4.14.1(@swc/helpers@0.5.23)(next@16.3.4(@playwright/test@1.62.1)(@types/node@26.4.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8): dependencies: '@eloqnt/config': 0.0.2 '@eloqnt/format-json': 0.0.3 @@ -4695,16 +5004,16 @@ snapshots: '@swc/core': 1.16.1(@swc/helpers@0.5.23) icu-minify: 4.14.1 negotiator: 1.1.0 - next: 16.3.3(@types/node@26.4.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + next: 16.3.4(@playwright/test@1.62.1)(@types/node@26.4.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) next-intl-swc-plugin-extractor: 4.14.1 react: 19.2.8 use-intl: 4.14.1(react@19.2.8) transitivePeerDependencies: - '@swc/helpers' - next@16.3.3(@types/node@26.4.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + next@16.3.4(@playwright/test@1.62.1)(@types/node@26.4.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): 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(react@19.2.8) styled-jsx: 5.1.6(react@19.2.8) 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/node@26.4.0) transitivePeerDependencies: - '@babel/core' @@ -4798,6 +5108,10 @@ snapshots: pako@1.0.11: {} + parse5@8.0.1: + dependencies: + entities: 8.0.0 + pathe@2.0.3: {} picocolors@1.1.1: {} @@ -4840,6 +5154,14 @@ snapshots: sonic-boom: 4.2.1 thread-stream: 4.2.0 + playwright-core@1.62.1: {} + + playwright@1.62.1: + dependencies: + playwright-core: 1.62.1 + optionalDependencies: + fsevents: 2.3.2 + po-parser@2.2.0: {} postal-mime@2.7.5: {} @@ -4870,6 +5192,8 @@ snapshots: end-of-stream: 1.4.5 once: 1.4.0 + punycode@2.3.1: {} + quick-format-unescaped@4.0.4: {} react-dom@19.2.8(react@19.2.8): @@ -4926,6 +5250,8 @@ snapshots: redis-errors@1.2.0: {} + require-from-string@2.0.2: {} + reselect@5.3.0: {} resend@6.25.0: @@ -4962,6 +5288,10 @@ snapshots: safer-buffer@2.1.2: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.27.0: {} secure-json-parse@4.1.0: {} @@ -5065,6 +5395,8 @@ snapshots: dependencies: has-flag: 4.0.0 + symbol-tree@3.2.4: {} + tailwind-merge@3.6.0: {} tailwindcss@4.3.3: {} @@ -5086,12 +5418,26 @@ snapshots: tinyrainbow@3.1.1: {} + tldts-core@7.4.11: {} + + tldts@7.4.11: + dependencies: + tldts-core: 7.4.11 + token-types@6.1.2: dependencies: '@borewit/text-codec': 0.2.2 '@tokenizer/token': 0.3.0 ieee754: 1.2.1 + tough-cookie@6.0.2: + dependencies: + tldts: 7.4.11 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + tslib@2.8.1: {} tsx@4.23.13: @@ -5129,6 +5475,8 @@ snapshots: undici-types@8.3.0: {} + undici@8.10.1: {} + use-callback-ref@1.3.3(@types/react@19.2.18)(react@19.2.8): dependencies: react: 19.2.8 @@ -5173,7 +5521,7 @@ snapshots: tsx: 4.23.13 yaml: 2.9.0 - vitest@4.1.11(@types/node@26.4.0)(@vitest/coverage-v8@4.1.11)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)): + vitest@4.1.11(@types/node@26.4.0)(@vitest/coverage-v8@4.1.11)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@26.4.0)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.11 '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@26.4.0)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) @@ -5198,11 +5546,36 @@ snapshots: optionalDependencies: '@types/node': 26.4.0 '@vitest/coverage-v8': 4.1.11(vitest@4.1.11) + jsdom: 30.0.1(@noble/hashes@2.4.0) transitivePeerDependencies: - msw + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + walk-up-path@4.0.0: {} + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1(@noble/hashes@2.4.0): + dependencies: + '@exodus/bytes': 1.15.1(@noble/hashes@2.4.0) + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + whatwg-url@17.1.0(@noble/hashes@2.4.0): + dependencies: + '@exodus/bytes': 1.15.1(@noble/hashes@2.4.0) + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 @@ -5212,6 +5585,10 @@ snapshots: wrappy@1.0.2: {} + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + yaml@2.9.0: {} zod@4.5.4: {} diff --git a/scripts/docker-update.sh b/scripts/docker-update.sh new file mode 100755 index 00000000..609bcec9 --- /dev/null +++ b/scripts/docker-update.sh @@ -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 \ No newline at end of file diff --git a/scripts/jobs-worker.ts b/scripts/jobs-worker.ts index c89d137d..97b8f377 100644 --- a/scripts/jobs-worker.ts +++ b/scripts/jobs-worker.ts @@ -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 { } } +async function publishScheduledArticles(): Promise { + 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(), diff --git a/src/actions/admin-applications.ts b/src/actions/admin-applications.ts index 22bce9fb..f57326d9 100644 --- a/src/actions/admin-applications.ts +++ b/src/actions/admin-applications.ts @@ -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 { - await requirePermission(PERMS.USERS_EDIT); - const id = formPositiveBigInt(formData, "id"); - if (!id) return; - +async function removeApplication(id: bigint): Promise { 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 { + 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 { + 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"); } diff --git a/src/actions/admin-articles.ts b/src/actions/admin-articles.ts index 5369ed9c..b9cbf1d1 100644 --- a/src/actions/admin-articles.ts +++ b/src/actions/admin-articles.ts @@ -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 { const base = slugify(title); @@ -43,12 +62,21 @@ export async function createArticle(formData: FormData): Promise { .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 { 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 { export async function updateArticle(formData: FormData): Promise { 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 { .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 { } export async function deleteArticle(formData: FormData): Promise { - 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 { .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"); } diff --git a/src/actions/admin-guilds.ts b/src/actions/admin-guilds.ts index 26c827c5..5d4e4288 100644 --- a/src/actions/admin-guilds.ts +++ b/src/actions/admin-guilds.ts @@ -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( + 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 { const staff = await requirePermissionRateLimited(PERMS.USERS_EDIT); @@ -63,3 +90,65 @@ export async function disbandGuild(formData: FormData): Promise { }); revalidatePath("/admin/guilds"); } + +export async function updateGuild(formData: FormData): Promise { + 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}`); +} diff --git a/src/actions/admin-rare-values.ts b/src/actions/admin-rare-values.ts index 8eac1cdb..9dff2ca4 100644 --- a/src/actions/admin-rare-values.ts +++ b/src/actions/admin-rare-values.ts @@ -115,3 +115,88 @@ export async function deleteValue(formData: FormData): Promise { } revalidatePath("/admin/rare-values"); } + +export async function updateCategory(formData: FormData): Promise { + 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 { + 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 = { + 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"); +} diff --git a/src/actions/admin-vouchers.ts b/src/actions/admin-vouchers.ts index 28feeb58..64021bf3 100644 --- a/src/actions/admin-vouchers.ts +++ b/src/actions/admin-vouchers.ts @@ -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 { + 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)"); + } +} diff --git a/src/actions/draw-badge.ts b/src/actions/draw-badge.ts index 5ab8903e..ff36600e 100644 --- a/src/actions/draw-badge.ts +++ b/src/actions/draw-badge.ts @@ -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 { } // 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; diff --git a/src/actions/events.ts b/src/actions/events.ts index 634812f5..709d969e 100644 --- a/src/actions/events.ts +++ b/src/actions/events.ts @@ -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 }); }, ); diff --git a/src/actions/help-tickets.ts b/src/actions/help-tickets.ts index 798bbf8e..f194c251 100644 --- a/src/actions/help-tickets.ts +++ b/src/actions/help-tickets.ts @@ -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 { updatedAt: now, }); outcome = "created"; + + const sessionUser = session?.user; + if (sessionUser?.name) { + notify({ + action: "ticket_create", + actor: sessionUser.name, + target: ticketTitle, + }); + } } } } diff --git a/src/actions/password-reset.ts b/src/actions/password-reset.ts index fdd5f03b..ac6203b7 100644 --- a/src/actions/password-reset.ts +++ b/src/actions/password-reset.ts @@ -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 { await db .delete(PasswordReset) .where(eq(PasswordReset.email, email)) - .catch(() => {}); + .catch((error) => + logServerError("password.reset_delete_tokens_failed", error, { + email, + }), + ); } } } catch { diff --git a/src/actions/polls.ts b/src/actions/polls.ts index 2731e900..4e306cba 100644 --- a/src/actions/polls.ts +++ b/src/actions/polls.ts @@ -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 }); }, ); diff --git a/src/actions/rooms.ts b/src/actions/rooms.ts index 43381ddd..6b14181b 100644 --- a/src/actions/rooms.ts +++ b/src/actions/rooms.ts @@ -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) { 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"); } diff --git a/src/actions/shop.ts b/src/actions/shop.ts index e3b70a5f..33ec7283 100644 --- a/src/actions/shop.ts +++ b/src/actions/shop.ts @@ -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 { ); 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"; diff --git a/src/actions/users.ts b/src/actions/users.ts index 746c6dca..d4526acc 100644 --- a/src/actions/users.ts +++ b/src/actions/users.ts @@ -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(); }, ); diff --git a/src/app/(site)/apply/staff/page.tsx b/src/app/(site)/apply/staff/page.tsx index 0b917712..9e9fd362 100644 --- a/src/app/(site)/apply/staff/page.tsx +++ b/src/app/(site)/apply/staff/page.tsx @@ -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 { + 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"; diff --git a/src/app/(site)/apply/team/page.tsx b/src/app/(site)/apply/team/page.tsx index 82d4a130..b1699a64 100644 --- a/src/app/(site)/apply/team/page.tsx +++ b/src/app/(site)/apply/team/page.tsx @@ -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 { + 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"; diff --git a/src/app/(site)/badges/page.tsx b/src/app/(site)/badges/page.tsx index 94f927c5..4de670b1 100644 --- a/src/app/(site)/badges/page.tsx +++ b/src/app/(site)/badges/page.tsx @@ -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 { + 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"; diff --git a/src/app/(site)/community/page.tsx b/src/app/(site)/community/page.tsx index c4139f7d..63576e72 100644 --- a/src/app/(site)/community/page.tsx +++ b/src/app/(site)/community/page.tsx @@ -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 { + const t = await getTranslations("pages.community"); + return { + title: t("title"), + description: t("subtitle"), + openGraph: { + title: t("title"), + description: t("subtitle"), + type: "website", + }, + }; +} const LINKS = [ { diff --git a/src/app/(site)/developers/page.tsx b/src/app/(site)/developers/page.tsx index 7b17ebb2..b07f7565 100644 --- a/src/app/(site)/developers/page.tsx +++ b/src/app/(site)/developers/page.tsx @@ -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"; diff --git a/src/app/(site)/draw-badge/page.tsx b/src/app/(site)/draw-badge/page.tsx index 09a7cb8b..e1588657 100644 --- a/src/app/(site)/draw-badge/page.tsx +++ b/src/app/(site)/draw-badge/page.tsx @@ -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; diff --git a/src/app/(site)/events/page.tsx b/src/app/(site)/events/page.tsx index 26e78e65..f916b050 100644 --- a/src/app/(site)/events/page.tsx +++ b/src/app/(site)/events/page.tsx @@ -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 { + 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"); diff --git a/src/app/(site)/friends/page.tsx b/src/app/(site)/friends/page.tsx index b88c2ac4..946b7241 100644 --- a/src/app/(site)/friends/page.tsx +++ b/src/app/(site)/friends/page.tsx @@ -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 { + 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 }>; diff --git a/src/app/(site)/guilds/page.tsx b/src/app/(site)/guilds/page.tsx index e7c9a7b4..8481b979 100644 --- a/src/app/(site)/guilds/page.tsx +++ b/src/app/(site)/guilds/page.tsx @@ -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 { + 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; diff --git a/src/app/(site)/help/page.tsx b/src/app/(site)/help/page.tsx index c8472e1e..077b8bd3 100644 --- a/src/app/(site)/help/page.tsx +++ b/src/app/(site)/help/page.tsx @@ -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 { + 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, diff --git a/src/app/(site)/layout.tsx b/src/app/(site)/layout.tsx index b4984653..26b9c963 100644 --- a/src/app/(site)/layout.tsx +++ b/src/app/(site)/layout.tsx @@ -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: () => ( +
+ ), + }, +); + /** * Public site chrome. Route group `(site)` keeps this off `/admin` and `/client`, * so housekeeping is never constrained by the public max-w-7xl grid. diff --git a/src/app/(site)/leaderboard/page.tsx b/src/app/(site)/leaderboard/page.tsx index ccc21b5f..238b3db5 100644 --- a/src/app/(site)/leaderboard/page.tsx +++ b/src/app/(site)/leaderboard/page.tsx @@ -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 { + 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. diff --git a/src/app/(site)/marketplace/page.tsx b/src/app/(site)/marketplace/page.tsx index f0a1fb61..853ada5a 100644 --- a/src/app/(site)/marketplace/page.tsx +++ b/src/app/(site)/marketplace/page.tsx @@ -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 { + 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; diff --git a/src/app/(site)/news/[...slug]/page.tsx b/src/app/(site)/news/[...slug]/page.tsx index 932c9d2e..11136a44 100644 --- a/src/app/(site)/news/[...slug]/page.tsx +++ b/src/app/(site)/news/[...slug]/page.tsx @@ -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; diff --git a/src/app/(site)/page.tsx b/src/app/(site)/page.tsx index 98e968eb..8572b8c8 100644 --- a/src/app/(site)/page.tsx +++ b/src/app/(site)/page.tsx @@ -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() { - {th("online", { count: online, hotel: hotelName })} +

(
- + {s.live ? ( + + ) : ( + + )}
{ + 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; diff --git a/src/app/(site)/polls/page.tsx b/src/app/(site)/polls/page.tsx index 71becaf0..be741d5d 100644 --- a/src/app/(site)/polls/page.tsx +++ b/src/app/(site)/polls/page.tsx @@ -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 { + 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"); diff --git a/src/app/(site)/radio/page.tsx b/src/app/(site)/radio/page.tsx index 208c360f..1aa6511b 100644 --- a/src/app/(site)/radio/page.tsx +++ b/src/app/(site)/radio/page.tsx @@ -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 { + 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"; diff --git a/src/app/(site)/rankings/page.tsx b/src/app/(site)/rankings/page.tsx index 4ae06ead..7c0c6872 100644 --- a/src/app/(site)/rankings/page.tsx +++ b/src/app/(site)/rankings/page.tsx @@ -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 { + 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; diff --git a/src/app/(site)/rares/page.tsx b/src/app/(site)/rares/page.tsx index 4d5a4df8..747b470b 100644 --- a/src/app/(site)/rares/page.tsx +++ b/src/app/(site)/rares/page.tsx @@ -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 { + 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; diff --git a/src/app/(site)/search/page.tsx b/src/app/(site)/search/page.tsx index f8328f0f..131fa145 100644 --- a/src/app/(site)/search/page.tsx +++ b/src/app/(site)/search/page.tsx @@ -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 }>; diff --git a/src/app/(site)/shop/page.tsx b/src/app/(site)/shop/page.tsx index 6fe79e31..36ab4053 100644 --- a/src/app/(site)/shop/page.tsx +++ b/src/app/(site)/shop/page.tsx @@ -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 { + 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 = diff --git a/src/app/(site)/staff/page.tsx b/src/app/(site)/staff/page.tsx index 83838c6c..eba60771 100644 --- a/src/app/(site)/staff/page.tsx +++ b/src/app/(site)/staff/page.tsx @@ -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 { + 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; diff --git a/src/app/admin/analytics/economy/page.tsx b/src/app/admin/analytics/economy/page.tsx index 901dafe2..6ad56c3d 100644 --- a/src/app/admin/analytics/economy/page.tsx +++ b/src/app/admin/analytics/economy/page.tsx @@ -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: () => ( +
+ ), + }, +); + type EconomyData = { totalCredits: number; totalPixels: number; diff --git a/src/app/admin/analytics/page.tsx b/src/app/admin/analytics/page.tsx index e38a7236..5d838112 100644 --- a/src/app/admin/analytics/page.tsx +++ b/src/app/admin/analytics/page.tsx @@ -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: () => ( +
+ ), + }, +); + +const PeakHoursHeatmap = dynamic( + () => + import("@/components/admin/peak-hours-heatmap").then((m) => ({ + default: m.PeakHoursHeatmap, + })), + { + loading: () => ( +
+ ), + }, +); + type AnalyticsData = { totalUsers: number; onlineUsers: number; diff --git a/src/app/admin/applications/page.tsx b/src/app/admin/applications/page.tsx index 15195315..3b8affa5 100644 --- a/src/app/admin/applications/page.tsx +++ b/src/app/admin/applications/page.tsx @@ -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() {

{a.content}

-
- - -
+ ); })} diff --git a/src/app/admin/catalog/maintenance/page.tsx b/src/app/admin/catalog/maintenance/page.tsx index c5de8160..fc05533b 100644 --- a/src/app/admin/catalog/maintenance/page.tsx +++ b/src/app/admin/catalog/maintenance/page.tsx @@ -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 ; } diff --git a/src/app/admin/guilds/[id]/page.tsx b/src/app/admin/guilds/[id]/page.tsx index 595ad271..e28af501 100644 --- a/src/app/admin/guilds/[id]/page.tsx +++ b/src/app/admin/guilds/[id]/page.tsx @@ -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({

) : null} + {canEdit ? ( +
+

{t("edit")}

+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ ) : null} +
{t("colOwner")}
diff --git a/src/app/admin/media/page.tsx b/src/app/admin/media/page.tsx index 0f558853..fe447023 100644 --- a/src/app/admin/media/page.tsx +++ b/src/app/admin/media/page.tsx @@ -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: () => ( +
+ ), + }, +); + export default async function AdminMediaPage() { const { session, permissions } = await getAdminContext(); if (!canAccess(permissions, PERMS.PAGES_VIEW, session.user.rank)) { diff --git a/src/app/admin/moderation/actions/mod-actions-client.tsx b/src/app/admin/moderation/actions/mod-actions-client.tsx index 323425a4..96ac65ba 100644 --- a/src/app/admin/moderation/actions/mod-actions-client.tsx +++ b/src/app/admin/moderation/actions/mod-actions-client.tsx @@ -104,6 +104,7 @@ export function ModActionsClient() { value={userId} onChange={(e) => setUserId(e.target.value)} placeholder="Enter user ID" + aria-label="User ID" />
@@ -144,6 +145,7 @@ export function ModActionsClient() { onChange={(e) => setMuteDuration(e.target.value)} className="w-24" placeholder="Min" + aria-label="Mute duration in minutes" />
-
- - -
+
+
+ + {t("editCategory")} + +
+ + + + + +
+
+
+ + +
+
@@ -141,32 +180,108 @@ export default async function AdminRareValues() { - {rows.map((v) => ( - - - - - - - - - - ))} + {rows.map((v) => { + const vId = String(v.id); + return ( + + + + + + + + + + ); + })}
{v.name} - {v.itemId ?? "—"} - {v.creditValue ?? "—"}{v.currencyValue ?? "—"} - - {v.currencyType} - - - {v.furnitureIcon} - -
- - -
-
{v.name} + {v.itemId ?? "—"} + {v.creditValue ?? "—"}{v.currencyValue ?? "—"} + + {v.currencyType} + + + {v.furnitureIcon} + +
+
+ + {t("editValue")} + +
+ + + + + + + + + +
+
+
+ + +
+
+
{rows.length === 0 ? ( diff --git a/src/app/admin/studio/maintenance/page.tsx b/src/app/admin/studio/maintenance/page.tsx index eeaaf587..61bc0540 100644 --- a/src/app/admin/studio/maintenance/page.tsx +++ b/src/app/admin/studio/maintenance/page.tsx @@ -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 (
diff --git a/src/app/admin/vouchers/vouchers-client.tsx b/src/app/admin/vouchers/vouchers-client.tsx index b68d94bc..8671e5fd 100644 --- a/src/app/admin/vouchers/vouchers-client.tsx +++ b/src/app/admin/vouchers/vouchers-client.tsx @@ -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(null); + const [editingVoucher, setEditingVoucher] = useState(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 (
@@ -158,6 +197,79 @@ export function VouchersClient({ )} + {canEdit && editingVoucher && ( +
+

+ {t("edit")} — {editingVoucher.code} +

+
+ + + + +
+
+ + +
+
+ )} +

{t("title")} ({vouchers.length}) @@ -216,14 +328,24 @@ export function VouchersClient({ {canEdit && ( - +
+ + +
)} diff --git a/src/app/api/admin/import/clone/batch/route.ts b/src/app/api/admin/import/clone/batch/route.ts index 6c4a32a5..ba2331b1 100644 --- a/src/app/api/admin/import/clone/batch/route.ts +++ b/src/app/api/admin/import/clone/batch/route.ts @@ -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>; - try { - allEntries = await fetchSourceFurnidata(source.furnidataUrl); - } catch (err) { - return apiError( - `Failed to fetch furnidata: ${(err as Error).message}`, - 502, - ); + let allEntries: Awaited> = []; + 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])); diff --git a/src/app/api/admin/import/clone/sync-all/route.ts b/src/app/api/admin/import/clone/sync-all/route.ts index 36af6ece..3c80233b 100644 --- a/src/app/api/admin/import/clone/sync-all/route.ts +++ b/src/app/api/admin/import/clone/sync-all/route.ts @@ -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 diff --git a/src/app/api/admin/search/route.ts b/src/app/api/admin/search/route.ts new file mode 100644 index 00000000..3b6236fe --- /dev/null +++ b/src/app/api/admin/search/route.ts @@ -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 } + > = { + 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 }); + }, +); diff --git a/src/app/api/articles/[slug]/route.ts b/src/app/api/articles/[slug]/route.ts index 79158a3d..8b4cb02c 100644 --- a/src/app/api/articles/[slug]/route.ts +++ b/src/app/api/articles/[slug]/route.ts @@ -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) { diff --git a/src/app/api/articles/route.ts b/src/app/api/articles/route.ts index 722dabf0..6d63f5f1 100644 --- a/src/app/api/articles/route.ts +++ b/src/app/api/articles/route.ts @@ -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), diff --git a/src/app/api/home/route.ts b/src/app/api/home/route.ts index 92593e05..1e4319ce 100644 --- a/src/app/api/home/route.ts +++ b/src/app/api/home/route.ts @@ -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")), diff --git a/src/app/api/online/count/stream/route.ts b/src/app/api/online/count/stream/route.ts new file mode 100644 index 00000000..ea9b590f --- /dev/null +++ b/src/app/api/online/count/stream/route.ts @@ -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 { + 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 { + 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({ + 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", + }, + }); +} diff --git a/src/app/client/client-view.tsx b/src/app/client/client-view.tsx index aa14ba05..2a92fd42 100644 --- a/src/app/client/client-view.tsx +++ b/src/app/client/client-view.tsx @@ -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 ( - - {children} - - ); - } - return ( + + const trigger = href ? ( + setTip(true)} + onMouseLeave={() => setTip(false)} + onFocus={() => setTip(true)} + onBlur={() => setTip(false)} + > + {children} + + ) : ( ); + + return ( + + {trigger} + {tip && ( + + {label} + + )} + + ); } 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(null); + const [onlineUsers, setOnlineUsers] = useState([]); + 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(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
-
- +
+ - Home + {tToolbar("home")} - window.location.reload()} title="Reload"> - - Reload - - - - - - - - Fullscreen - - - -
window.location.reload()} + label={tToolbar("reload")} > - Online users + {tToolbar("reload")} + + + + + + + {isFullscreen ? ( + + {tToolbar("fullscreenExit")} + + + ) : ( + + {tToolbar("fullscreen")} + + + )} + +
+ + {emulatorOk === null ? ( + + ) : emulatorOk ? ( + + ) : ( + + )} + +
+
{ + setShowOnlineUsers(true); + refreshOnlineUsers(); + }} + onMouseLeave={() => setShowOnlineUsers(false)} + > + + + + + + {tToolbar("onlineUsers")} {onlineCount} + {showOnlineUsers && onlineUsers.length > 0 && ( + + {onlineUsers.slice(0, 8).join(", ")} + {onlineUsers.length > 8 + ? ` +${onlineUsers.length - 8} more` + : ""} + + )}
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: grip handle explicitly initiates a drag (mouse+touch) and is not a button */}
- Drag + {tToolbar("dragToMove")} @@ -361,14 +512,75 @@ export function ClientView({
void signOutAndRevokeTicket()} - title="Logout" + label={tToolbar("logout")} > + setBarHidden(true)} + label={tToolbar("hide")} + > + + {tToolbar("hide")} + + +
) : null} + {barHidden ? ( + + ) : null} +
- {t("colUserId")} - {t("colType")} - {t("colReason")} - {t("colExpires")} + {t("colUserId")} + {t("colType")} + {t("colReason")} + {t("colExpires")} diff --git a/src/app/mod/cfh/page.tsx b/src/app/mod/cfh/page.tsx index 24e5b5c7..4c19ad72 100644 --- a/src/app/mod/cfh/page.tsx +++ b/src/app/mod/cfh/page.tsx @@ -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([ diff --git a/src/app/mod/users/page.tsx b/src/app/mod/users/page.tsx index e3bcda42..7e37a595 100644 --- a/src/app/mod/users/page.tsx +++ b/src/app/mod/users/page.tsx @@ -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; diff --git a/src/components/admin/admin-topbar.tsx b/src/components/admin/admin-topbar.tsx index 93c4b37c..c08135f6 100644 --- a/src/components/admin/admin-topbar.tsx +++ b/src/components/admin/admin-topbar.tsx @@ -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({
+
diff --git a/src/components/admin/application-actions.tsx b/src/components/admin/application-actions.tsx new file mode 100644 index 00000000..5f69f536 --- /dev/null +++ b/src/components/admin/application-actions.tsx @@ -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 ( +
+ {confirmDialog} + + +
+ ); +} diff --git a/src/components/admin/article-form.tsx b/src/components/admin/article-form.tsx index f9f2ad27..4ccaaaec 100644 --- a/src/components/admin/article-form.tsx +++ b/src/components/admin/article-form.tsx @@ -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({ />
+
+ + + {status === "scheduled" ? ( + + ) : null} +
+ diff --git a/src/components/admin/catalog-manager/breadcrumb-bar.tsx b/src/components/admin/catalog-manager/breadcrumb-bar.tsx index e2f44817..cbdc8bb0 100644 --- a/src/components/admin/catalog-manager/breadcrumb-bar.tsx +++ b/src/components/admin/catalog-manager/breadcrumb-bar.tsx @@ -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]); diff --git a/src/components/admin/catalog-manager/sortable-tree.tsx b/src/components/admin/catalog-manager/sortable-tree.tsx index b46ba78e..b1283666 100644 --- a/src/components/admin/catalog-manager/sortable-tree.tsx +++ b/src/components/admin/catalog-manager/sortable-tree.tsx @@ -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]); diff --git a/src/components/admin/catalog/quick-add-furni.tsx b/src/components/admin/catalog/quick-add-furni.tsx index 47f2aa18..77a83a63 100644 --- a/src/components/admin/catalog/quick-add-furni.tsx +++ b/src/components/admin/catalog/quick-add-furni.tsx @@ -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]); diff --git a/src/components/admin/media-grid.tsx b/src/components/admin/media-grid.tsx index eb0207cb..ddb52413 100644 --- a/src/components/admin/media-grid.tsx +++ b/src/components/admin/media-grid.tsx @@ -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) { diff --git a/src/components/admin/search-dialog.tsx b/src/components/admin/search-dialog.tsx new file mode 100644 index 00000000..7d96fcfa --- /dev/null +++ b/src/components/admin/search-dialog.tsx @@ -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([]); + const [loading, setLoading] = useState(false); + const debouncedQuery = useDebounce(query, 250); + const abortRef = useRef(null); + + const groupLabels: Record = { + 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 ( + + + + + + + {loading && query.length >= 2 ? ( +
+ … +
+ ) : null} + {t("noResults")} + {Object.entries(grouped).map(([group, items]) => ( + + {items.map((item) => ( + handleSelect(item.url)} + className="flex flex-col items-start gap-0.5 py-2" + > + {item.title} + {item.subtitle ? ( + + {item.subtitle} + + ) : null} + + ))} + + ))} +
+
+
+
+ ); +} + +function groupResults(results: SearchResult[]) { + const grouped: Record = {}; + for (const r of results) { + if (!grouped[r.type]) grouped[r.type] = []; + grouped[r.type].push(r); + } + return grouped; +} diff --git a/src/components/admin/studio/studio-client.tsx b/src/components/admin/studio/studio-client.tsx index 7781b3f3..b2b39999 100644 --- a/src/components/admin/studio/studio-client.tsx +++ b/src/components/admin/studio/studio-client.tsx @@ -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; diff --git a/src/components/animated-counter.tsx b/src/components/animated-counter.tsx index 62977c82..e7972585 100644 --- a/src/components/animated-counter.tsx +++ b/src/components/animated-counter.tsx @@ -12,25 +12,35 @@ export function AnimatedCounter({ }) { const ref = useRef(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 ( diff --git a/src/components/live-online-count.tsx b/src/components/live-online-count.tsx new file mode 100644 index 00000000..ca7e2d38 --- /dev/null +++ b/src/components/live-online-count.tsx @@ -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 })}; +} diff --git a/src/components/live-online-counter.tsx b/src/components/live-online-counter.tsx new file mode 100644 index 00000000..aecb4fdc --- /dev/null +++ b/src/components/live-online-counter.tsx @@ -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 ; +} diff --git a/src/components/mobile-nav.tsx b/src/components/mobile-nav.tsx index 9231e5cc..e442f506 100644 --- a/src/components/mobile-nav.tsx +++ b/src/components/mobile-nav.tsx @@ -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(null); + const menuRef = useRef(null); + const toggleRef = useRef(null); + const prevFocusRef = useRef(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( + '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(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 (
diff --git a/src/db/schema.ts b/src/db/schema.ts index 051a06df..8552db1a 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -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", { diff --git a/src/lib/admin-helpers.test.ts b/src/lib/admin-helpers.test.ts deleted file mode 100644 index d718e6d9..00000000 --- a/src/lib/admin-helpers.test.ts +++ /dev/null @@ -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]); - }); -}); diff --git a/src/lib/ci-workflow-contract.test.ts b/src/lib/ci-workflow-contract.test.ts index 4326b147..23f874f0 100644 --- a/src/lib/ci-workflow-contract.test.ts +++ b/src/lib/ci-workflow-contract.test.ts @@ -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"); }); }); diff --git a/src/lib/deploy-workflow-contract.test.ts b/src/lib/deploy-workflow-contract.test.ts index b2791d8d..7ff2a0eb 100644 --- a/src/lib/deploy-workflow-contract.test.ts +++ b/src/lib/deploy-workflow-contract.test.ts @@ -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"); }); }); diff --git a/src/lib/sanitize.test.ts b/src/lib/sanitize.test.ts new file mode 100644 index 00000000..899daf2d --- /dev/null +++ b/src/lib/sanitize.test.ts @@ -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("

Hello world

"); + expect(out).toContain("world"); + }); + + it("strips event handlers", () => { + const out = sanitize(''); + expect(out).not.toContain("onerror"); + }); + + it("strips javascript: URLs", () => { + const out = sanitize('click'); + expect(out).not.toContain("javascript:"); + }); + + it("strips svg onload vectors", () => { + const out = sanitize(''); + expect(out).not.toContain("onload"); + expect(out).not.toContain(" { + const out = sanitize("

hi

"); + expect(out).not.toContain(" 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 }, + }); } diff --git a/src/lib/services/figure-import.ts b/src/lib/services/figure-import.ts index 2c1df0dc..f6ce11d4 100644 --- a/src/lib/services/figure-import.ts +++ b/src/lib/services/figure-import.ts @@ -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 }; diff --git a/src/lib/services/furni-import.ts b/src/lib/services/furni-import.ts index 960995f2..757d7e39 100644 --- a/src/lib/services/furni-import.ts +++ b/src/lib/services/furni-import.ts @@ -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( catalogIdSeedChain = new Promise((r) => { settle = r; }); - await prev.catch(() => {}); + await prev.catch((error) => + logServerError("furni.catalog_id_chain_failed", error), + ); try { if ( catalogNextId === null || diff --git a/src/lib/services/news-list.ts b/src/lib/services/news-list.ts index 141c29d8..22910d97 100644 --- a/src/lib/services/news-list.ts +++ b/src/lib/services/news-list.ts @@ -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 { 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), ); diff --git a/src/lib/services/repair-icons.ts b/src/lib/services/repair-icons.ts index af699f5d..f1ff34e6 100644 --- a/src/lib/services/repair-icons.ts +++ b/src/lib/services/repair-icons.ts @@ -2,6 +2,7 @@ import { existsSync, promises as fs } from "node:fs"; import os from "node:os"; import { sql } from "drizzle-orm"; import { db } from "@/lib/db"; +import { logServerError } from "@/lib/server-log"; import { extractFurniIconPng } from "@/lib/services/clone-icon"; import { type CloneSource, @@ -154,7 +155,10 @@ export async function repairMissingIcons( if (gamedataRoot) { const badgeFiles = await fs .readdir(getRuntimePath(gamedataRoot, "album1584")) - .catch(() => [] as string[]); + .catch((error) => { + logServerError("repair_icons.readdir_album_failed", error); + return [] as string[]; + }); const albumBadgeSet = new Set( badgeFiles.filter((f) => f.endsWith(".gif")).map((f) => f.slice(0, -4)), ); @@ -376,7 +380,11 @@ export async function repairMissingIcons( `extract from ${source.name} .nitro failed: ${(err as Error).message}`, ); } finally { - await fs.unlink(nitroTmp).catch(() => {}); + await fs + .unlink(nitroTmp) + .catch((error) => + logServerError("repair_icons.cleanup_failed", error), + ); } if (ok) break; } diff --git a/src/lib/services/upload-import.ts b/src/lib/services/upload-import.ts index 85bcca68..216f5ef6 100644 --- a/src/lib/services/upload-import.ts +++ b/src/lib/services/upload-import.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { eq, sql } from "drizzle-orm"; import { db, ItemsBase } from "@/lib/db"; import { autoDetectInteraction } from "@/lib/furni/auto-interaction"; +import { logServerError } from "@/lib/server-log"; import { getFurniAssetWriteTargets } from "@/lib/services/furni-asset-dirs"; import { appendFurniEntry, buildFurniEntry } from "@/lib/services/furni-data"; import { @@ -96,7 +97,9 @@ async function allocateItemsBaseId( itemsBaseIdSeedChain = new Promise((r) => { settle = r; }); - await prev.catch(() => {}); + await prev.catch((error) => + logServerError("upload.items_base_id_chain_failed", error), + ); try { if ( itemsBaseNextId === null || @@ -365,10 +368,25 @@ export async function uploadSingleFurni(params: { return nextId; }); } catch (err) { - await fs.unlink(nitroPath).catch(() => {}); - if (iconPath) await fs.unlink(iconPath).catch(() => {}); + await fs + .unlink(nitroPath) + .catch((error) => + logServerError("upload.cleanup_nitro_failed", error, { classname }), + ); + if (iconPath) + await fs + .unlink(iconPath) + .catch((error) => + logServerError("upload.cleanup_icon_failed", error, { classname }), + ); await Promise.all( - mirroredPaths.map((file) => fs.unlink(file).catch(() => {})), + mirroredPaths.map((file) => + fs + .unlink(file) + .catch((error) => + logServerError("upload.cleanup_mirror_failed", error, { file }), + ), + ), ); return { ok: false, diff --git a/src/lib/services/webhook.ts b/src/lib/services/webhook.ts index b115522c..9a46666f 100644 --- a/src/lib/services/webhook.ts +++ b/src/lib/services/webhook.ts @@ -2,6 +2,7 @@ import { eq } from "drizzle-orm"; import { env } from "@/env"; import { db, WebsiteSetting } from "@/lib/db"; import { logger } from "@/lib/logger"; +import { logServerError } from "@/lib/server-log"; export type { WebhookAction } from "@/types/admin"; @@ -126,6 +127,14 @@ async function sendTelegram(payload: WebhookPayload): Promise { /** Fire-and-forget notification to Discord + Telegram */ export function notify(payload: WebhookPayload): void { - sendDiscord(payload).catch(() => {}); - sendTelegram(payload).catch(() => {}); + sendDiscord(payload).catch((error) => + logServerError("webhook.discord_notify_failed", error, { + action: payload.action, + }), + ); + sendTelegram(payload).catch((error) => + logServerError("webhook.telegram_notify_failed", error, { + action: payload.action, + }), + ); } diff --git a/src/messages/ar.json b/src/messages/ar.json index 6e1234f5..027db8d7 100644 --- a/src/messages/ar.json +++ b/src/messages/ar.json @@ -663,5 +663,19 @@ } } } + }, + "toolbar": { + "home": "الرئيسية", + "reload": "إعادة تحميل", + "fullscreen": "ملء الشاشة", + "logout": "تسجيل الخروج", + "onlineUsers": "المستخدمون المتواجدون", + "emulatorOnline": "المحاكي متصل", + "emulatorOffline": "المحاكي غير متصل", + "dragToMove": "اسحب للتحريك", + "hide": "إخفاء الشريط", + "fullscreenExit": "الخروج من ملء الشاشة", + "show": "إظهار الشريط", + "emulatorUnknown": "حالة المحاكي غير معروفة" } } diff --git a/src/messages/bg.json b/src/messages/bg.json index 8cd6c6d4..b5936425 100644 --- a/src/messages/bg.json +++ b/src/messages/bg.json @@ -3916,5 +3916,19 @@ "button": "Verify email", "fallback": "If the button doesn't work, paste this link into your browser:" } + }, + "toolbar": { + "home": "Начало", + "reload": "Презареждане", + "fullscreen": "Цял екран", + "logout": "Изход", + "onlineUsers": "Потребители онлайн", + "emulatorOnline": "Емулаторът е онлайн", + "emulatorOffline": "Емулаторът е офлайн", + "dragToMove": "Влачете за преместване", + "hide": "Скрий лентата", + "fullscreenExit": "Изход от цял екран", + "show": "Покажи лентата", + "emulatorUnknown": "Състоянието на емулатора е неизвестно" } } diff --git a/src/messages/cs.json b/src/messages/cs.json index b88947b6..1a9c25ae 100644 --- a/src/messages/cs.json +++ b/src/messages/cs.json @@ -3916,5 +3916,19 @@ "button": "Verify email", "fallback": "If the button doesn't work, paste this link into your browser:" } + }, + "toolbar": { + "home": "Domů", + "reload": "Obnovit", + "fullscreen": "Celá obrazovka", + "logout": "Odhlásit", + "onlineUsers": "Uživatelé online", + "emulatorOnline": "Emulátor online", + "emulatorOffline": "Emulátor offline", + "dragToMove": "Přetažením přesunete", + "hide": "Skrýt lištu", + "fullscreenExit": "Ukončit celou obrazovku", + "show": "Zobrazit lištu", + "emulatorUnknown": "Stav emulátoru neznámý" } } diff --git a/src/messages/da.json b/src/messages/da.json index f6f20804..507b95e6 100644 --- a/src/messages/da.json +++ b/src/messages/da.json @@ -3916,5 +3916,19 @@ "button": "Verify email", "fallback": "If the button doesn't work, paste this link into your browser:" } + }, + "toolbar": { + "home": "Hjem", + "reload": "Genindlæs", + "fullscreen": "Fuldskærm", + "logout": "Log ud", + "onlineUsers": "Brugere online", + "emulatorOnline": "Emulator online", + "emulatorOffline": "Emulator offline", + "dragToMove": "Træk for at flytte", + "hide": "Skjul værktøjslinje", + "fullscreenExit": "Afslut fuldskærm", + "show": "Vis værktøjslinje", + "emulatorUnknown": "Emulatorstatus ukendt" } } diff --git a/src/messages/de.json b/src/messages/de.json index 3b1e597e..bb4f1768 100644 --- a/src/messages/de.json +++ b/src/messages/de.json @@ -3917,5 +3917,19 @@ "button": "Verify email", "fallback": "If the button doesn't work, paste this link into your browser:" } + }, + "toolbar": { + "home": "Startseite", + "reload": "Neu laden", + "fullscreen": "Vollbild", + "logout": "Abmelden", + "onlineUsers": "Nutzer online", + "emulatorOnline": "Emulator online", + "emulatorOffline": "Emulator offline", + "dragToMove": "Ziehen zum Verschieben", + "hide": "Leiste ausblenden", + "fullscreenExit": "Vollbild beenden", + "show": "Leiste anzeigen", + "emulatorUnknown": "Emulatorstatus unbekannt" } } diff --git a/src/messages/el.json b/src/messages/el.json index acf03bf5..ccebaf01 100644 --- a/src/messages/el.json +++ b/src/messages/el.json @@ -3916,5 +3916,19 @@ "button": "Verify email", "fallback": "If the button doesn't work, paste this link into your browser:" } + }, + "toolbar": { + "home": "Αρχική", + "reload": "Επαναφόρτωση", + "fullscreen": "Πλήρης οθόνη", + "logout": "Αποσύνδεση", + "onlineUsers": "Χρήστες συνδεδεμένοι", + "emulatorOnline": "Ο εξομοιωτής είναι online", + "emulatorOffline": "Ο εξομοιωτής είναι offline", + "dragToMove": "Σύρετε για μετακίνηση", + "hide": "Απόκρυψη γραμμής", + "fullscreenExit": "Έξοδος από πλήρη οθόνη", + "show": "Εμφάνιση γραμμής", + "emulatorUnknown": "Άγνωστη κατάσταση εξομοιωτή" } } diff --git a/src/messages/en.json b/src/messages/en.json index 2dd9584b..309798f8 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -1069,7 +1069,17 @@ "commandLog": "Command log", "tradeLog": "Trade log", "tools": "Tools", - "studio": "Studio" + "studio": "Studio", + "search": { + "placeholder": "Search…", + "noResults": "No results found", + "users": "Users", + "articles": "Articles", + "rooms": "Rooms", + "guilds": "Guilds", + "shop": "Shop", + "hint": "Start typing to search across users, articles, rooms, guilds and shop items" + } }, "dashboard": { "title": "Dashboard", @@ -1120,7 +1130,13 @@ "saving": "Saving…", "deleteArticle": "Delete article", "deleteConfirm": "This action cannot be undone. The article will be permanently removed.", - "cancel": "Cancel" + "cancel": "Cancel", + "status": "Status", + "statusDraft": "Draft", + "statusScheduled": "Scheduled", + "statusPublished": "Published", + "publishAt": "Publish at", + "publishedAt": "Published at" } }, "events": { @@ -1893,7 +1909,20 @@ "noMembers": "No members.", "back": "Back to guilds", "viewPublic": "Open public page", - "disband": "Disband guild" + "disband": "Disband guild", + "edit": "Edit guild", + "save": "Save", + "saving": "Saving…", + "updated": "Guild updated", + "updateError": "Could not update guild", + "formName": "Name", + "formDescription": "Description", + "formState": "State", + "formForum": "Forum", + "formReadForum": "Read forum", + "formPostMessages": "Post messages", + "formPostThreads": "Post threads", + "formModForum": "Moderate forum" }, "transactions": { "title": "Transactions", @@ -1943,8 +1972,10 @@ "confirmDelete": "Delete voucher \"{code}\"?", "created": "Voucher created", "deleted": "Voucher deleted", + "updated": "Voucher updated", "createError": "Could not create voucher", "deleteError": "Could not delete voucher", + "updateError": "Could not update voucher", "statusActive": "Active", "statusUsedUp": "Used up", "statusExpired": "Expired", @@ -2010,6 +2041,7 @@ "items": "Items", "addCategory": "Add category", "editCategory": "Edit category", + "editValue": "Edit value", "deleteCategory": "Delete category", "noCategories": "No rare value categories yet", "confirmDeleteCategory": "Delete category \"{name}\" and all its items?", @@ -3920,5 +3952,19 @@ } } } + }, + "toolbar": { + "home": "Home", + "reload": "Reload", + "fullscreen": "Fullscreen", + "logout": "Logout", + "onlineUsers": "Online users", + "emulatorOnline": "Emulator online", + "emulatorOffline": "Emulator offline", + "dragToMove": "Drag to move", + "hide": "Hide toolbar", + "fullscreenExit": "Exit fullscreen", + "show": "Show toolbar", + "emulatorUnknown": "Emulator status unknown" } } diff --git a/src/messages/es.json b/src/messages/es.json index ee33ae74..23c5074a 100644 --- a/src/messages/es.json +++ b/src/messages/es.json @@ -3917,5 +3917,19 @@ "button": "Verify email", "fallback": "If the button doesn't work, paste this link into your browser:" } + }, + "toolbar": { + "home": "Inicio", + "reload": "Recargar", + "fullscreen": "Pantalla completa", + "logout": "Cerrar sesión", + "onlineUsers": "Usuarios en línea", + "emulatorOnline": "Emulador en línea", + "emulatorOffline": "Emulador fuera de línea", + "dragToMove": "Arrastra para mover", + "hide": "Ocultar barra", + "fullscreenExit": "Salir de pantalla completa", + "show": "Mostrar barra", + "emulatorUnknown": "Estado del emulador desconocido" } } diff --git a/src/messages/fi.json b/src/messages/fi.json index 02bad898..f52567fd 100644 --- a/src/messages/fi.json +++ b/src/messages/fi.json @@ -663,5 +663,19 @@ } } } + }, + "toolbar": { + "home": "Koti", + "reload": "Lataa uudelleen", + "fullscreen": "Koko näyttö", + "logout": "Kirjaudu ulos", + "onlineUsers": "Käyttäjät verkossa", + "emulatorOnline": "Emulaattori verkossa", + "emulatorOffline": "Emulaattori ei verkossa", + "dragToMove": "Vedä liikuttaaksesi", + "hide": "Piilota työkalupalkki", + "fullscreenExit": "Lopeta koko näyttö", + "show": "Näytä työkalupalkki", + "emulatorUnknown": "Emulaattorin tila tuntematon" } } diff --git a/src/messages/fr.json b/src/messages/fr.json index 25f00f51..066267e9 100644 --- a/src/messages/fr.json +++ b/src/messages/fr.json @@ -3917,5 +3917,19 @@ "button": "Verify email", "fallback": "If the button doesn't work, paste this link into your browser:" } + }, + "toolbar": { + "home": "Accueil", + "reload": "Recharger", + "fullscreen": "Plein écran", + "logout": "Déconnexion", + "onlineUsers": "Utilisateurs en ligne", + "emulatorOnline": "Émulateur en ligne", + "emulatorOffline": "Émulateur hors ligne", + "dragToMove": "Glissez pour déplacer", + "hide": "Masquer la barre", + "fullscreenExit": "Quitter le plein écran", + "show": "Afficher la barre", + "emulatorUnknown": "État de l'émulateur inconnu" } } diff --git a/src/messages/hr.json b/src/messages/hr.json index 78d3177b..b59fcf86 100644 --- a/src/messages/hr.json +++ b/src/messages/hr.json @@ -3916,5 +3916,19 @@ "button": "Verify email", "fallback": "If the button doesn't work, paste this link into your browser:" } + }, + "toolbar": { + "home": "Početna", + "reload": "Ponovno učitaj", + "fullscreen": "Cijeli zaslon", + "logout": "Odjava", + "onlineUsers": "Korisnici online", + "emulatorOnline": "Emulator je online", + "emulatorOffline": "Emulator je offline", + "dragToMove": "Povucite za pomicanje", + "hide": "Sakrij traku", + "fullscreenExit": "Izađi iz cijelog zaslona", + "show": "Prikaži traku", + "emulatorUnknown": "Status emulatora nepoznat" } } diff --git a/src/messages/hu.json b/src/messages/hu.json index d5006a6f..d369cef2 100644 --- a/src/messages/hu.json +++ b/src/messages/hu.json @@ -3916,5 +3916,19 @@ "button": "Verify email", "fallback": "If the button doesn't work, paste this link into your browser:" } + }, + "toolbar": { + "home": "Kezdőlap", + "reload": "Újratöltés", + "fullscreen": "Teljes képernyő", + "logout": "Kijelentkezés", + "onlineUsers": "Online felhasználók", + "emulatorOnline": "Emulátor online", + "emulatorOffline": "Emulátor offline", + "dragToMove": "Húzás a mozgatáshoz", + "hide": "Sáv elrejtése", + "fullscreenExit": "Kilépés a teljes képernyőből", + "show": "Sáv megjelenítése", + "emulatorUnknown": "Az emulátor állapota ismeretlen" } } diff --git a/src/messages/it.json b/src/messages/it.json index b502722d..a863bdcd 100644 --- a/src/messages/it.json +++ b/src/messages/it.json @@ -3917,5 +3917,19 @@ } } } + }, + "toolbar": { + "home": "Home", + "reload": "Ricarica", + "fullscreen": "Schermo intero", + "logout": "Esci", + "onlineUsers": "Utenti online", + "emulatorOnline": "Emulatore online", + "emulatorOffline": "Emulatore offline", + "dragToMove": "Trascina per spostare", + "hide": "Nascondi barra", + "fullscreenExit": "Esci dalla schermo intero", + "show": "Mostra barra", + "emulatorUnknown": "Stato dell'emulatore sconosciuto" } } diff --git a/src/messages/ja.json b/src/messages/ja.json index 86e7fe45..87defe42 100644 --- a/src/messages/ja.json +++ b/src/messages/ja.json @@ -663,5 +663,19 @@ } } } + }, + "toolbar": { + "home": "ホーム", + "reload": "再読み込み", + "fullscreen": "全画面", + "logout": "ログアウト", + "onlineUsers": "オンラインのユーザー", + "emulatorOnline": "エミュレータオンライン", + "emulatorOffline": "エミュレータオフライン", + "dragToMove": "ドラッグして移動", + "hide": "ツールバーを隠す", + "fullscreenExit": "全画面を終了", + "show": "ツールバーを表示", + "emulatorUnknown": "エミュレータの状態が不明です" } } diff --git a/src/messages/nl.json b/src/messages/nl.json index ef516fae..056e2868 100644 --- a/src/messages/nl.json +++ b/src/messages/nl.json @@ -3919,5 +3919,19 @@ } } } + }, + "toolbar": { + "home": "Home", + "reload": "Vernieuwen", + "fullscreen": "Volledig scherm", + "logout": "Uitloggen", + "onlineUsers": "Gebruikers online", + "emulatorOnline": "Emulator online", + "emulatorOffline": "Emulator offline", + "dragToMove": "Sleep om te verplaatsen", + "hide": "Werkbalk verbergen", + "fullscreenExit": "Volledig scherm afsluiten", + "show": "Werkbalk tonen", + "emulatorUnknown": "Emulatorstatus onbekend" } } diff --git a/src/messages/no.json b/src/messages/no.json index e858951d..53e09c57 100644 --- a/src/messages/no.json +++ b/src/messages/no.json @@ -3916,5 +3916,19 @@ "button": "Verify email", "fallback": "If the button doesn't work, paste this link into your browser:" } + }, + "toolbar": { + "home": "Hjem", + "reload": "Last inn på nytt", + "fullscreen": "Fullskjerm", + "logout": "Logg ut", + "onlineUsers": "Brukere på nett", + "emulatorOnline": "Emulator på nett", + "emulatorOffline": "Emulator frakoblet", + "dragToMove": "Dra for å flytte", + "hide": "Skjul verktøylinje", + "fullscreenExit": "Avslutt fullskjerm", + "show": "Vis verktøylinje", + "emulatorUnknown": "Emulatorstatus ukjent" } } diff --git a/src/messages/pl.json b/src/messages/pl.json index dcd80726..10f491eb 100644 --- a/src/messages/pl.json +++ b/src/messages/pl.json @@ -3917,5 +3917,19 @@ "button": "Verify email", "fallback": "If the button doesn't work, paste this link into your browser:" } + }, + "toolbar": { + "home": "Start", + "reload": "Przeładuj", + "fullscreen": "Pełny ekran", + "logout": "Wyloguj", + "onlineUsers": "Użytkownicy online", + "emulatorOnline": "Emulator online", + "emulatorOffline": "Emulator offline", + "dragToMove": "Przeciągnij, aby przenieść", + "hide": "Ukryj pasek", + "fullscreenExit": "Zamknij pełny ekran", + "show": "Pokaż pasek", + "emulatorUnknown": "Status emulatora nieznany" } } diff --git a/src/messages/pt.json b/src/messages/pt.json index 42cba6c9..b77c7569 100644 --- a/src/messages/pt.json +++ b/src/messages/pt.json @@ -3917,5 +3917,19 @@ "button": "Verify email", "fallback": "If the button doesn't work, paste this link into your browser:" } + }, + "toolbar": { + "home": "Início", + "reload": "Recarregar", + "fullscreen": "Tela cheia", + "logout": "Sair", + "onlineUsers": "Usuários online", + "emulatorOnline": "Emulador online", + "emulatorOffline": "Emulador offline", + "dragToMove": "Arraste para mover", + "hide": "Ocultar barra", + "fullscreenExit": "Sair da tela cheia", + "show": "Mostrar barra", + "emulatorUnknown": "Estado do emulador desconhecido" } } diff --git a/src/messages/ro.json b/src/messages/ro.json index 2c192832..771a511a 100644 --- a/src/messages/ro.json +++ b/src/messages/ro.json @@ -3916,5 +3916,19 @@ "button": "Verify email", "fallback": "If the button doesn't work, paste this link into your browser:" } + }, + "toolbar": { + "home": "Acasă", + "reload": "Reîncarcă", + "fullscreen": "Ecran complet", + "logout": "Deconectare", + "onlineUsers": "Utilizatori online", + "emulatorOnline": "Emulatorul este online", + "emulatorOffline": "Emulatorul este offline", + "dragToMove": "Trageți pentru a muta", + "hide": "Ascunde bara", + "fullscreenExit": "Ieșiți din ecran complet", + "show": "Afișați bara", + "emulatorUnknown": "Starea emulatorului necunoscută" } } diff --git a/src/messages/ru.json b/src/messages/ru.json index 4e957184..330e00a6 100644 --- a/src/messages/ru.json +++ b/src/messages/ru.json @@ -3915,5 +3915,19 @@ "button": "Verify email", "fallback": "If the button doesn't work, paste this link into your browser:" } + }, + "toolbar": { + "home": "Главная", + "reload": "Перезагрузить", + "fullscreen": "Полный экран", + "logout": "Выйти", + "onlineUsers": "Пользователи онлайн", + "emulatorOnline": "Эмулятор онлайн", + "emulatorOffline": "Эмулятор офлайн", + "dragToMove": "Перетащите, чтобы переместить", + "hide": "Скрыть панель", + "fullscreenExit": "Выйти из полноэкранного режима", + "show": "Показать панель", + "emulatorUnknown": "Состояние эмулятора неизвестно" } } diff --git a/src/messages/sk.json b/src/messages/sk.json index e3a6cebc..01e8eb03 100644 --- a/src/messages/sk.json +++ b/src/messages/sk.json @@ -3916,5 +3916,19 @@ "button": "Verify email", "fallback": "If the button doesn't work, paste this link into your browser:" } + }, + "toolbar": { + "home": "Domov", + "reload": "Obnoviť", + "fullscreen": "Celá obrazovka", + "logout": "Odhlásiť", + "onlineUsers": "Používatelia online", + "emulatorOnline": "Emulátor online", + "emulatorOffline": "Emulátor offline", + "dragToMove": "Potiahnite na presun", + "hide": "Skryť panel", + "fullscreenExit": "Ukončiť celú obrazovku", + "show": "Zobraziť panel", + "emulatorUnknown": "Stav emulátora neznámy" } } diff --git a/src/messages/sr.json b/src/messages/sr.json index 9ed30dea..19ec6264 100644 --- a/src/messages/sr.json +++ b/src/messages/sr.json @@ -3916,5 +3916,19 @@ "button": "Verify email", "fallback": "If the button doesn't work, paste this link into your browser:" } + }, + "toolbar": { + "home": "Почетна", + "reload": "Освежи", + "fullscreen": "Цео екран", + "logout": "Одјава", + "onlineUsers": "Корисници на мрежи", + "emulatorOnline": "Емулатор онлајн", + "emulatorOffline": "Емулатор офлајн", + "dragToMove": "Превуците да померите", + "hide": "Сакриј траку", + "fullscreenExit": "Изађи из целог екрана", + "show": "Прикажи траку", + "emulatorUnknown": "Статус емулатора непознат" } } diff --git a/src/messages/sv.json b/src/messages/sv.json index f6eb3245..5d987d17 100644 --- a/src/messages/sv.json +++ b/src/messages/sv.json @@ -3916,5 +3916,19 @@ "button": "Verify email", "fallback": "If the button doesn't work, paste this link into your browser:" } + }, + "toolbar": { + "home": "Hem", + "reload": "Ladda om", + "fullscreen": "Helskärm", + "logout": "Logga ut", + "onlineUsers": "Användare online", + "emulatorOnline": "Emulator online", + "emulatorOffline": "Emulator offline", + "dragToMove": "Dra för att flytta", + "hide": "Dölj verktygsfält", + "fullscreenExit": "Avsluta helskärm", + "show": "Visa verktygsfält", + "emulatorUnknown": "Emulatorstatus okänd" } } diff --git a/src/messages/tr.json b/src/messages/tr.json index f3397350..3d8b86e5 100644 --- a/src/messages/tr.json +++ b/src/messages/tr.json @@ -3917,5 +3917,19 @@ "button": "Verify email", "fallback": "If the button doesn't work, paste this link into your browser:" } + }, + "toolbar": { + "home": "Ana sayfa", + "reload": "Yenile", + "fullscreen": "Tam ekran", + "logout": "Çıkış", + "onlineUsers": "Çevrimiçi kullanıcılar", + "emulatorOnline": "Emülatör çevrimiçi", + "emulatorOffline": "Emülatör çevrimdışı", + "dragToMove": "Taşımak için sürükleyin", + "hide": "Araç çubuğunu gizle", + "fullscreenExit": "Tam ekrandan çık", + "show": "Araç çubuğunu göster", + "emulatorUnknown": "Emülatör durumu bilinmiyor" } } diff --git a/src/messages/uk.json b/src/messages/uk.json index 17e5d408..67660c51 100644 --- a/src/messages/uk.json +++ b/src/messages/uk.json @@ -3916,5 +3916,19 @@ "button": "Verify email", "fallback": "If the button doesn't work, paste this link into your browser:" } + }, + "toolbar": { + "home": "Головна", + "reload": "Перезавантажити", + "fullscreen": "Повний екран", + "logout": "Вийти", + "onlineUsers": "Користувачі онлайн", + "emulatorOnline": "Емулятор онлайн", + "emulatorOffline": "Емулятор офлайн", + "dragToMove": "Перетягніть, щоб перемістити", + "hide": "Сховати панель", + "fullscreenExit": "Вийти з повноекранного режиму", + "show": "Показати панель", + "emulatorUnknown": "Стан емулятора невідомий" } } diff --git a/vitest.config.ts b/vitest.config.ts index d11f9789..ec90cee7 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -29,10 +29,10 @@ export default defineConfig({ provider: "v8", reporter: ["text", "lcov", "html"], thresholds: { - statements: 10, - branches: 7, - functions: 9, - lines: 10, + statements: 12, + branches: 9, + functions: 10, + lines: 13, }, include: ["src/**/*.{ts,tsx}"], exclude: [