Compare commits
68
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e4869bb84 | ||
|
|
6c89a7ed46 | ||
|
|
fbf98cd2b5 | ||
|
|
cf448d6ce9 | ||
|
|
4768b2b406 | ||
|
|
af67a28639 | ||
|
|
a4bc25b6d6 | ||
|
|
fb724a35cc | ||
|
|
a878d831aa | ||
|
|
1ad4f9ea28 | ||
|
|
4f6b352691 | ||
|
|
42fa988d9c | ||
|
|
be4dc08c31 | ||
|
|
6ef92f57fc | ||
|
|
a16e9da018 | ||
|
|
3cb5baabfb | ||
|
|
e96c408ac1 | ||
|
|
e4564abc5e | ||
|
|
30583b8e2e | ||
|
|
97454f361b | ||
|
|
ecd8e5852c | ||
|
|
8ca538f99f | ||
|
|
1e81cac504 | ||
|
|
3bc218ba16 | ||
|
|
61f4db2a00 | ||
|
|
597287fce5 | ||
|
|
5c428c4526 | ||
|
|
e7a6b2b602 | ||
|
|
4b25292ecc | ||
|
|
b40293bd06 | ||
|
|
9cf1eb615c | ||
|
|
aef0939ee9 | ||
|
|
c6e5e0b1a9 | ||
|
|
2249303dbd | ||
|
|
07c5904879 | ||
|
|
4776a41c01 | ||
|
|
a32cf6119b | ||
|
|
bb3799f705 | ||
|
|
d28dde459a | ||
|
|
095353012a | ||
|
|
ef0c425598 | ||
|
|
8d3263a8fe | ||
|
|
050fcdf25b | ||
|
|
a40669b130 | ||
|
|
c15177a52e | ||
|
|
fb470123c3 | ||
|
|
1dd584cd82 | ||
|
|
c9b363d60d | ||
|
|
e870beb71c | ||
|
|
728999b2de | ||
|
|
8d8dab5817 | ||
|
|
59a45d2165 | ||
|
|
6895eaab49 | ||
|
|
8557370d91 | ||
|
|
16a902bf14 | ||
|
|
dc4cca653b | ||
|
|
9894240f06 | ||
|
|
43590f1509 | ||
|
|
b58f3215f7 | ||
|
|
3a296551f6 | ||
|
|
e70b96e3a7 | ||
|
|
480343d5a5 | ||
|
|
77a0d1010c | ||
|
|
f56b345598 | ||
|
|
a4bcd40e7b | ||
|
|
4b575d3a1d | ||
|
|
61107848f8 | ||
|
|
1e3406a7ae |
No files matched your search
@@ -26,6 +26,25 @@ bun.lockb
|
||||
public/nitro-assets
|
||||
public/swf
|
||||
|
||||
# Testbronnen draaien op de host (pnpm test:ui / test:integration / test:e2e),
|
||||
# de image heeft ze niet nodig. In de image zouden ze alleen de typecheck van
|
||||
# `next build` meebepalen en de image van @playwright/test, vitest, msw,
|
||||
# testcontainers en drizzle-kit afhankelijk maken.
|
||||
e2e/
|
||||
integration/
|
||||
|
||||
# Databasebackups (~232MB, dagelijkse rotatie): horen niet in de buildcontext.
|
||||
# Ze maakten elke context groot en invalideerden bij elke rotatie de
|
||||
# `COPY . .`-laag, waardoor de Next-build volledig opnieuw draaide.
|
||||
backups/
|
||||
|
||||
# Wordt tijdens de build opnieuw gegenereerd door `pnpm assets:editor`
|
||||
# (bron: node_modules/tinymce) en staat niet in git.
|
||||
public/vendor/
|
||||
|
||||
# Lokale buildcache voor `docker compose build` (cache_from/cache_to).
|
||||
.docker-cache/
|
||||
|
||||
.deploy.lock
|
||||
logs
|
||||
|
||||
|
||||
+11
-2
@@ -3,8 +3,13 @@
|
||||
# ==============================================================================
|
||||
|
||||
# --- DATABASE (High Performance Pooling & Strict Timeouts) ---
|
||||
DATABASE_URL="mysql://user:password@localhost:3306/dbname?charset=utf8mb4&connection_limit=150&connect_timeout=5"
|
||||
DATABASE_POOL_SIZE=150
|
||||
# The pool size is governed by DATABASE_POOL_SIZE (src/lib/db.ts); the
|
||||
# `connection_limit` in the URL is only honoured by tools that read the URL
|
||||
# directly (drizzle-kit, scripts). Keep both in step and keep them well below
|
||||
# MariaDB's `max_connections` (151 by default) or the emulator and any second
|
||||
# CMS instance get "Too many connections". README §performance: pool size 25.
|
||||
DATABASE_URL="mysql://user:password@localhost:3306/dbname?charset=utf8mb4&connection_limit=25&connect_timeout=5"
|
||||
DATABASE_POOL_SIZE=25
|
||||
DATABASE_IDLE_TIMEOUT_MS=60000
|
||||
DATABASE_CONNECT_TIMEOUT_MS=5000
|
||||
|
||||
@@ -59,6 +64,10 @@ AUTH_SECRET=your-super-secret-auth-key-change-this-min-32-chars
|
||||
APP_KEY=base64:your-app-key-here=
|
||||
# Bcrypt cost factor for new password hashes.
|
||||
BCRYPT_COST=12
|
||||
# Password-length policy for every new password (register, password reset and
|
||||
# staff-created accounts). Minimum: 5-30. Maximum: 30-128.
|
||||
PASSWORD_MIN_LENGTH=12
|
||||
PASSWORD_MAX_LENGTH=128
|
||||
|
||||
# --- ANTI-DDOS (app-layer gate, production only) ---
|
||||
# On by default in production. Set to "false" to disable (not recommended).
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
name: Renovate
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "17 4 * * 1-5"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
debug:
|
||||
description: "Debug-loglevel"
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
# Niet overlappen met zichzelf; een tweede start wacht tot de eerste klaar is.
|
||||
concurrency:
|
||||
group: renovate
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
RENOVATE_VERSION: 44.164.10 # renovate: datasource=npm depName=renovate
|
||||
|
||||
jobs:
|
||||
renovate:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Run Renovate
|
||||
env:
|
||||
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
|
||||
LOG_LEVEL: ${{ github.event.inputs.debug == 'true' && 'debug' || 'info' }}
|
||||
shell: bash
|
||||
run: |
|
||||
docker run --rm \
|
||||
--memory=6g \
|
||||
--memory-swap=6g \
|
||||
-e RENOVATE_PLATFORM=gitea \
|
||||
-e RENOVATE_ENDPOINT=https://gitlab.epicnabbo.nl/ \
|
||||
-e RENOVATE_TOKEN \
|
||||
-e RENOVATE_REPOSITORIES='["remco/EpicNext-Cms"]' \
|
||||
-e RENOVATE_AUTODISCOVER=false \
|
||||
-e RENOVATE_BINARY_SOURCE=install \
|
||||
-e LOG_LEVEL \
|
||||
-e RENOVATE_ONBOARDING=true \
|
||||
-e RENOVATE_REQUIRE_CONFIG=required \
|
||||
"renovate/renovate:${RENOVATE_VERSION}"
|
||||
@@ -57,6 +57,9 @@ blob-report/
|
||||
build-reports/
|
||||
!/docs/performance-budgets.md
|
||||
|
||||
# Local buildcache (docker compose cache_from/cache_to)
|
||||
.docker-cache/
|
||||
|
||||
# One-off local snapshots (o.a. catalog-integrity/pre-fix.json): runtime-werk,
|
||||
# geen bron. Per map opgeslagen om een incident terug te kunnen lezen.
|
||||
backups/
|
||||
+9
-9
@@ -26,15 +26,15 @@ This source change alone does not configure or deploy the production service.
|
||||
|
||||
## Exported content
|
||||
|
||||
| Source | Repository destination |
|
||||
| --- | --- |
|
||||
| Configured Nitro bundles, including furniture, figures, effects and pets | `Gamedata/bundled` |
|
||||
| Configured furni icons | `Gamedata/icons` |
|
||||
| Existing badge/catalog images | `Gamedata/c_images` |
|
||||
| FurnitureData and supported public game-data JSON files | `Gamedata/config` |
|
||||
| Existing localized FurnitureData files | `catalogue version 2 ( Final (Dev)/langs furnidata` |
|
||||
| `items_base`, `catalog_pages`, `catalog_items` | `catalogue version 2 ( Final (Dev)/sqls` |
|
||||
| `catalog_pages_bc`, `catalog_items_bc`, when present | Same SQL directory |
|
||||
| Source | Repository destination |
|
||||
| ------------------------------------------------------------------------ | --------------------------------------------------- |
|
||||
| Configured Nitro bundles, including furniture, figures, effects and pets | `Gamedata/bundled` |
|
||||
| Configured furni icons | `Gamedata/icons` |
|
||||
| Existing badge/catalog images | `Gamedata/c_images` |
|
||||
| FurnitureData and supported public game-data JSON files | `Gamedata/config` |
|
||||
| Existing localized FurnitureData files | `catalogue version 2 ( Final (Dev)/langs furnidata` |
|
||||
| `items_base`, `catalog_pages`, `catalog_items` | `catalogue version 2 ( Final (Dev)/sqls` |
|
||||
| `catalog_pages_bc`, `catalog_items_bc`, when present | Same SQL directory |
|
||||
|
||||
SQL is read in one consistent, read-only InnoDB transaction. Dumps contain table
|
||||
definitions and deterministic upserts with hexadecimal UTF-8 string literals.
|
||||
|
||||
+28
-6
@@ -1,23 +1,45 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
FROM node:26.10.0-alpine AS migrations
|
||||
FROM node:26.12.1-alpine AS migrations
|
||||
WORKDIR /app
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# Installeer git, bash en pnpm v12. bash is nodig voor
|
||||
# Installeer git, bash en corepack. bash is nodig voor
|
||||
# scripts/with-memory-cap.sh (gebruikt bashisme zoals arrays en BASH_REMATCH);
|
||||
# Alpine levert geen bash mee.
|
||||
#
|
||||
# pnpm komt niet meer uit een losgepind npm-pakket, maar uit corepack: die
|
||||
# leest de versie én de sha512-hash uit de `packageManager`-pin van
|
||||
# package.json. Daarmee kan de image nooit uit sync raken met het lockfile
|
||||
# (vroeger stond hier [email protected] terwijl package.json 12.11.2 voorschreef)
|
||||
# en wordt de gedownloade pnpm-binary ook in de runtime-image bewaard.
|
||||
RUN --mount=type=cache,target=/var/cache/apk \
|
||||
--mount=type=cache,target=/root/.npm \
|
||||
apk add --no-cache git bash \
|
||||
&& npm install -g [email protected]
|
||||
&& npm install -g [email protected] \
|
||||
&& corepack enable pnpm
|
||||
|
||||
# corepack bewaart de gedownloade pnpm buiten de cache-mount, zodat de runtime
|
||||
# (`docker run --entrypoint pnpm <migrations-image> db:migrate`) pnpm ook
|
||||
# zonder netwerk kan gebruiken.
|
||||
ENV COREPACK_HOME="/usr/local/share/corepack"
|
||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
|
||||
|
||||
# Stel het PATH zo in dat Alpine pnpm gegarandeerd overal herkent
|
||||
ENV PNPM_HOME="/usr/local/share/pnpm"
|
||||
ENV PATH="$PNPM_HOME:/usr/local/bin:$PATH"
|
||||
|
||||
# Alleen package.json: de `packageManager`-pin bepaalt welke pnpm corepack
|
||||
# installeert. Deze laag blijft gecachet wanneer alleen de lockfile verandert.
|
||||
COPY package.json ./
|
||||
RUN corepack install
|
||||
|
||||
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* .npmrc* ./
|
||||
|
||||
# Voer de installatie uit met de pnpm v12 store cache-mount
|
||||
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
|
||||
# Voer de installatie uit met de pnpm v12 store cache-mount. De mount moet op
|
||||
# de store staan die pnpm ook echt gebruikt ($PNPM_HOME/store/v11); met het
|
||||
# oude pad (/root/.local/share/pnpm/store) cache'te de mount niets en
|
||||
# downloadde elke build alle packages opnieuw.
|
||||
RUN --mount=type=cache,target=/usr/local/share/pnpm/store \
|
||||
pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
COPY . .
|
||||
@@ -52,7 +74,7 @@ RUN --mount=type=cache,target=/app/.next/cache \
|
||||
pnpm run build \
|
||||
&& PERFORMANCE_COMMIT_SHA="$NEXT_DEPLOYMENT_ID" node scripts/performance-report.mjs --output-dir build-reports
|
||||
|
||||
FROM node:26.10.0-alpine AS runner
|
||||
FROM node:26.12.1-alpine AS runner
|
||||
ARG NEXT_DEPLOYMENT_ID="unknown"
|
||||
LABEL org.opencontainers.image.revision="$NEXT_DEPLOYMENT_ID"
|
||||
WORKDIR /app
|
||||
|
||||
@@ -8,17 +8,17 @@ Features a premium animated homepage (typewriter hero, floating orbs, scroll cou
|
||||
|
||||
## System Requirements
|
||||
|
||||
| Component | Version | Notes |
|
||||
| --------------- | -------------- | ---------------------------------------- |
|
||||
| Node.js | 26.10.0 | Pinned in `.nvmrc` (`>=26.10.0 <27`) |
|
||||
| pnpm | 12.11.2 | Pinned via `packageManager` |
|
||||
| npm | >= 11.x | Supported alternative |
|
||||
| yarn | >= 4.x | Supported alternative |
|
||||
| MySQL / MariaDB | 8.0+ / 10.6+ | Shared with the emulator |
|
||||
| Docker | 24+ | Optional — for containerized deployment |
|
||||
| Valkey | 8.x+ | Optional — caching, rate limiting, SSE |
|
||||
| Component | Version | Notes |
|
||||
| --------------- | ------------ | --------------------------------------- |
|
||||
| Node.js | 26.12.1 | Pinned in `.nvmrc` (`>=26.12.1 <27`) |
|
||||
| pnpm | 12.11.2 | Pinned via `packageManager` |
|
||||
| npm | >= 11.x | Supported alternative |
|
||||
| yarn | >= 4.x | Supported alternative |
|
||||
| MySQL / MariaDB | 8.0+ / 10.6+ | Shared with the emulator |
|
||||
| Docker | 24+ | Optional — for containerized deployment |
|
||||
| Valkey | 8.x+ | Optional — caching, rate limiting, SSE |
|
||||
|
||||
Core stack: **Next.js 16.4.1** (App Router) · **React 19.3.1** · **TypeScript 7.0.2** · **Drizzle ORM 0.45.4** · **Zod 4.6.7** · **Vitest 5.0.4** · **Biome 2.5.15** · **Playwright 1.64.1**.
|
||||
Core stack: **Next.js 16.4.2** (App Router) · **React 19.3.1** · **TypeScript 7.0.2** · **Drizzle ORM 0.45.4** · **Zod 4.6.7** · **Vitest 5.0.5** · **Biome 2.6.2** · **Playwright 1.64.1**.
|
||||
|
||||
> Builds must run through the `pnpm` scripts. This host has no swap and
|
||||
> `vm.overcommit_memory=0`, so an uncapped `next build` gets OOM-killed by the
|
||||
@@ -292,14 +292,14 @@ cannot be changed simply by injecting a new variable into an old container.
|
||||
|
||||
### Docker commands
|
||||
|
||||
| Command | Purpose |
|
||||
| --- | --- |
|
||||
| Command | Purpose |
|
||||
| ------------------------------- | ----------------------------------------- |
|
||||
| `bash scripts/docker-update.sh` | Pull, build, migrate, recreate and verify |
|
||||
| `docker compose logs -f cms` | View CMS logs |
|
||||
| `docker compose exec cms sh` | Open a shell in the running CMS |
|
||||
| `docker compose restart cms` | Restart the existing release |
|
||||
| `docker compose down` | Stop services; does not update code |
|
||||
| `pnpm db:up` / `pnpm db:down` | Manage the optional MariaDB service |
|
||||
| `docker compose logs -f cms` | View CMS logs |
|
||||
| `docker compose exec cms sh` | Open a shell in the running CMS |
|
||||
| `docker compose restart cms` | Restart the existing release |
|
||||
| `docker compose down` | Stop services; does not update code |
|
||||
| `pnpm db:up` / `pnpm db:down` | Manage the optional MariaDB service |
|
||||
|
||||
For a manual build, export `CMS_RELEASE=$(git rev-parse HEAD)` before
|
||||
`docker compose build cms`; deployment and migration verification remain your
|
||||
@@ -307,26 +307,65 @@ responsibility. Docker uses the committed pnpm lockfile and pinned Node version.
|
||||
The build cache can stay enabled: copying changed source invalidates the
|
||||
application build layer. Deleting all Docker cache is not an update mechanism.
|
||||
|
||||
#### Build toolchain and cache
|
||||
|
||||
The image never installs a pinned pnpm of its own. The Dockerfile installs
|
||||
[corepack](https://nodejs.org/api/corepack.html) and lets it resolve the pnpm
|
||||
version _and_ its sha256/sha512 hash from the `packageManager` field in
|
||||
`package.json`. One pin therefore drives the Docker build, the CI runner and
|
||||
the host — the image can no longer drift away from the lockfile, and a tampered
|
||||
package tarball is rejected instead of silently installed. Bumping pnpm is a
|
||||
single edit in `package.json` (plus the README version table, checked by
|
||||
`pnpm readme:versions:check`).
|
||||
|
||||
Two caches make repeated builds cheap:
|
||||
- **pnpm store** — a BuildKit cache mount on `$PNPM_HOME/store` keeps the
|
||||
downloaded packages between builds. A lockfile change then re-uses the store
|
||||
(`reused 493, downloaded 0`) instead of re-downloading every package.
|
||||
- **Local build cache** — `docker compose build` exports the build cache to
|
||||
`.docker-cache/cms` and imports it again on the next build. Unlike the
|
||||
daemon's own cache, this survives `scripts/docker-prune.sh`, which trims the
|
||||
daemon cache to 4 GB after every deploy. The directory is ignored by both
|
||||
Git and the build context; delete it to force a cold build.
|
||||
|
||||
The CI has the same problem on a bigger scale: the runner workspace is wiped
|
||||
after every job and `scripts/docker-prune.sh` trims the daemon cache after
|
||||
every release, so the next build started with an empty cache and downloaded all
|
||||
493 packages again (measured: `reused 0, downloaded 493`). `scripts/ci-deploy.sh`
|
||||
and `scripts/ci-preflight.sh` therefore export the build cache to a directory
|
||||
outside the workspace — `/var/cache/epicnext-cms-buildcache` for the release and
|
||||
`/var/cache/epicnext-cms-preflight-cache` for branch preflights, so a branch can
|
||||
never feed the release cache. With that export in place the same build re-uses
|
||||
the frozen dependency layer (`reused 493, downloaded 0`) even after a full
|
||||
`docker builder prune`. Set `CMS_BUILD_CACHE_DIR` to another path to move the
|
||||
cache, or to an empty value to switch it off.
|
||||
|
||||
The build context excludes `e2e/` and `integration/`. `next build` type-checks
|
||||
the whole project, so shipping the test suites inside the image made a type
|
||||
error in test code break the image build and pulled `@playwright/test`, `vitest`,
|
||||
`msw` and `testcontainers` into it. The CI runs those suites on the host
|
||||
(`pnpm test:ui`, `pnpm test:integration`).
|
||||
|
||||
### Volumes
|
||||
|
||||
Only the CMS (and the optional avatar imager container, see [Avatar Imaging](#avatar-imaging)) run 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)|
|
||||
| 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 |
|
||||
| Avatar imager | `/docker/Polaris-imager`| Docker container `avatar-imaging-pixinode` on port `8082` — see [Avatar Imaging](#avatar-imaging) |
|
||||
| Camera uploads | `/var/www/Camera` | nginx `/camera/` alias |
|
||||
| Component | Host path | How it's served |
|
||||
| ------------------- | ------------------------ | ------------------------------------------------------------------------------------------------- |
|
||||
| Nitro/Octane client | `/var/www/Octane/dist` | nginx `location ^~ /client/` and `/nitro-client/` alias |
|
||||
| Avatar imager | `/docker/Polaris-imager` | Docker container `avatar-imaging-pixinode` on port `8082` — see [Avatar Imaging](#avatar-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:
|
||||
|
||||
@@ -346,7 +385,13 @@ docker compose build --build-arg RUN_USER=1000
|
||||
|
||||
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`), Valkey/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.
|
||||
The **build** deliberately does _not_ use host networking. BuildKit (v0.26, Docker 29)
|
||||
refuses to grant host networking unless every caller passes
|
||||
`--allow=network.host`, and `docker compose build` has no such flag — asking for
|
||||
`build.network: host` therefore turned every rebuild into an immediate
|
||||
"additional privileges requested" failure. The build only needs outbound
|
||||
internet (apk, corepack/pnpm, next/font/google), which the default bridge
|
||||
provides.
|
||||
|
||||
### Health Check
|
||||
|
||||
@@ -365,7 +410,6 @@ pnpm db:up # docker compose --profile db up -d (starts mariadb-turbo)
|
||||
```
|
||||
|
||||
Key settings (see the `command:` block in `docker-compose.yml`):
|
||||
|
||||
- `max_allowed_packet = 512M` — 50 MB JSON batches no longer hit the 16 MB packet ceiling.
|
||||
- `innodb_flush_log_at_trx_commit = 2` + `innodb_doublewrite = 0` — durability relaxed for bulk writes.
|
||||
- `innodb_buffer_pool_size = 2G`, `innodb_log_file_size = 1G`, `bulk_insert_buffer_size = 512M`.
|
||||
@@ -378,7 +422,7 @@ The datadir lives in a **named volume** (`mariadb-turbo-data`), never a host bin
|
||||
|
||||
#### node_modules & host-sync corruption
|
||||
|
||||
pnpm's store is hard-linked and its `.bin` shims are symlinks, so **node_modules must never be a host bind-mount** (Docker Desktop gRPC-FUSE/VirtioFS, Unison and Syncthing all corrupt it). The production build bakes `node_modules` into the image at build time; it is never bind-mounted. For local dev, mount a *named volume* (`node_modules:/app/node_modules`) instead of the host directory, and keep `node_modules` / the pnpm store out of any shared-filesystem bind.
|
||||
pnpm's store is hard-linked and its `.bin` shims are symlinks, so **node_modules must never be a host bind-mount** (Docker Desktop gRPC-FUSE/VirtioFS, Unison and Syncthing all corrupt it). The production build bakes `node_modules` into the image at build time; it is never bind-mounted. For local dev, mount a _named volume_ (`node_modules:/app/node_modules`) instead of the host directory, and keep `node_modules` / the pnpm store out of any shared-filesystem bind.
|
||||
|
||||
#### Loading a 50 MB JSON dump
|
||||
|
||||
@@ -393,8 +437,7 @@ pnpm db:bulk --file=/data/products.json --table=docs --category=furni
|
||||
pnpm db:bulk --file=/data/external_texts.json --table=texts --category=default
|
||||
```
|
||||
|
||||
The importer (`scripts/bulk-import-json.ts`) maps rows onto `src/db/schema-gamedata.ts`, batches them into **2000-row multi-row INSERTs** (one statement per chunk), uses `ON DUPLICATE KEY UPDATE` so re-runs are idempotent and interrupted loads resume, and prints rows/s + ETA. See ["ORM Setup & Type Generation"](#orm-setup--type-generation) → *Bulk JSON storage* for the design.
|
||||
|
||||
The importer (`scripts/bulk-import-json.ts`) maps rows onto `src/db/schema-gamedata.ts`, batches them into **2000-row multi-row INSERTs** (one statement per chunk), uses `ON DUPLICATE KEY UPDATE` so re-runs are idempotent and interrupted loads resume, and prints rows/s + ETA. See ["ORM Setup & Type Generation"](#orm-setup--type-generation) → _Bulk JSON storage_ for the design.
|
||||
|
||||
---
|
||||
|
||||
@@ -409,50 +452,47 @@ import { db } from "@/lib/db";
|
||||
import { users } from "@/db/schema";
|
||||
import { eq } from "drizzle-orm/expressions";
|
||||
|
||||
const found = await db.select()
|
||||
.from(users)
|
||||
.where(eq(users.username, "hello"));
|
||||
const found = await db.select().from(users).where(eq(users.username, "hello"));
|
||||
```
|
||||
|
||||
**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) |
|
||||
| `pnpm db:studio` | Open Drizzle Studio (dev only) |
|
||||
| `pnpm db:introspect` | Reverse-engineer an existing DB into a Drizzle schema draft |
|
||||
| `pnpm db:bulk` | Batch-import >50 MB JSON (2000-row chunks, resumable) via `scripts/bulk-import-json.ts` |
|
||||
| `pnpm db:schema:generate` | Regen committed `src/db/schema.ts` from previous schema + 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:bulk` | Batch-import >50 MB JSON (2000-row chunks, resumable) via `scripts/bulk-import-json.ts` |
|
||||
| `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`.
|
||||
|
||||
#### Bulk JSON storage (MariaDB)
|
||||
|
||||
Emulator/hotel JSON dumps (furnidata, external texts, product data) are often >50 MB. MariaDB's `JSON` type is an alias for `LONGTEXT` and can **never be indexed directly**, so `src/db/schema-gamedata.ts` follows the "promote + index" pattern:
|
||||
|
||||
- The raw JSON document stays intact in a `longtext` / `mediumtext` column (`payload`, `value`).
|
||||
- The fields you actually `WHERE` / `ORDER BY` on are promoted into real columns (`sprite_id`, `class_name`, `kind`, `title`, `text_key`) and indexed.
|
||||
- Optional **VIRTUAL generated columns** extract indexed fields from the payload with `JSON_EXTRACT` (MariaDB supports secondary indexes on virtual columns, 10.2+), so no duplicate data has to be written by the importer.
|
||||
|
||||
Three tables are exported:
|
||||
|
||||
| Table | Purpose | Unique key |
|
||||
| -------------------- | ---------------------------------------------- | --------------------------- |
|
||||
| `gamedata_furnidata` | One row per furni/clothing item (habbo furnidata_json shape) | `(source, sprite_id)` |
|
||||
| `gamedata_docs` | Generic JSON document store (per-key documents) | `(category, doc_key)` |
|
||||
| `gamedata_texts` | External-texts style key/value pairs | `(category, text_key)` |
|
||||
| Table | Purpose | Unique key |
|
||||
| -------------------- | ------------------------------------------------------------ | ---------------------- |
|
||||
| `gamedata_furnidata` | One row per furni/clothing item (habbo furnidata_json shape) | `(source, sprite_id)` |
|
||||
| `gamedata_docs` | Generic JSON document store (per-key documents) | `(category, doc_key)` |
|
||||
| `gamedata_texts` | External-texts style key/value pairs | `(category, text_key)` |
|
||||
|
||||
The bulk importer (`scripts/bulk-import-json.ts`, run via `pnpm db:bulk`) is DB-driven and fast precisely because each chunk is a *single* multi-row `INSERT … VALUES () ()… ON DUPLICATE KEY UPDATE`, so a 50 MB dump is a few dozen statements instead of hundreds of thousands of round-trips. Flags:
|
||||
The bulk importer (`scripts/bulk-import-json.ts`, run via `pnpm db:bulk`) is DB-driven and fast precisely because each chunk is a _single_ multi-row `INSERT … VALUES () ()… ON DUPLICATE KEY UPDATE`, so a 50 MB dump is a few dozen statements instead of hundreds of thousands of round-trips. Flags:
|
||||
|
||||
| Flag | Default | Description |
|
||||
| -------------------- | ----------- | ------------------------------------------------- |
|
||||
| `--file=<path>` | — (required)| JSON file (habbo furnidata_json or flat array) |
|
||||
| `--table=<name>` | `furnidata` | One of `furnidata` \| `docs` \| `texts` |
|
||||
| `--source=<x>` | `habbo` | `source` value for `furnidata` |
|
||||
| `--category=<x>` | `default` | `category` value for `docs` / `texts` |
|
||||
| `--chunk-size=N` | `2000` | Rows per multi-row INSERT |
|
||||
| `--limit=N` | `0` | Stop after N rows (dry-test) |
|
||||
| `--truncate` | off | DELETE rows for this source/category first |
|
||||
| Flag | Default | Description |
|
||||
| ---------------- | ------------ | ---------------------------------------------- |
|
||||
| `--file=<path>` | — (required) | JSON file (habbo furnidata_json or flat array) |
|
||||
| `--table=<name>` | `furnidata` | One of `furnidata` \| `docs` \| `texts` |
|
||||
| `--source=<x>` | `habbo` | `source` value for `furnidata` |
|
||||
| `--category=<x>` | `default` | `category` value for `docs` / `texts` |
|
||||
| `--chunk-size=N` | `2000` | Rows per multi-row INSERT |
|
||||
| `--limit=N` | `0` | Stop after N rows (dry-test) |
|
||||
| `--truncate` | off | DELETE rows for this source/category first |
|
||||
|
||||
The script sets `FOREIGN_KEY_CHECKS=0` for the session and is safe to interrupt: each chunk commits on its own, and `ON DUPLICATE KEY UPDATE` makes re-runs overwrite instead of appending.
|
||||
|
||||
@@ -595,7 +635,7 @@ URLs look like `https://<hotel>/imaging/avatarimage?figure=hd-180-1.ch-210-66&im
|
||||
|
||||
### Requirements (host)
|
||||
|
||||
- Docker (daemon with `build.network: host`, same as the CMS build — this host disables Docker iptables).
|
||||
- Docker (daemon with BuildKit; the CMS build runs on the default bridge, not on host networking — see [Networking](#networking)).
|
||||
- An nginx that serves `/gamedata/` (FigureData/FigureMap/EffectMap…) and `/gamedata/bundled` (furniture bundle assets — `.hab` by default, the extension imports write and this deployment's client requests, plus any legacy `.nitro` still on disk; choose the written extension with `FURNI_BUNDLE_EXT`) over HTTP so the renderer can fetch them. The compose file points at `host.docker.internal:8081`.
|
||||
|
||||
### Configuration — `/docker/Polaris-imager/.env`
|
||||
@@ -639,9 +679,20 @@ A production issue that this section documents: **the `avatar-imaging-pixinode`
|
||||
|
||||
Since then the CMS-side avatar proxies (`/api/imaging/avatar` and `/imaging`) are resilient: when the configured upstream (the Polaris container on `8082`) fails or times out, the request is retried against Habbo's public renderer (`https://www.habbo.com/habbo-imaging/avatarimage`), with the `effect` parameter stripped and `img_format` forced to `png`, since the public renderer supports neither. The response is then marked with `X-Imager-Source: primary|fallback` and a shorter `Cache-Control` TTL when served from fallback, so the configured imager is retried soon instead of being masked for hours. No fallback is attempted when the configured upstream already is the Habbo public renderer. As a last line of defence every avatar `<img>` on the site falls back to a grayscale silhouette placeholder instead of a broken-image glyph.
|
||||
|
||||
### Figure string validation (`src/app/api/imaging/avatar/route.ts`)
|
||||
### Figure string validation
|
||||
|
||||
The CMS-side route re-validates the incoming figure against `/^[a-z]{2}-\d+(-\d+)?(\.[a-z]{2}-\d+(-\d+)?)*$/i`. Real Habbo figure strings use `type-id-color` parts separated by dots (`hd-180-1.ch-210-66`), so each part allows an optional second number. Invalid input is rejected with `400` before any render happens.
|
||||
The URL builder (`src/lib/imager.ts`) and the proxy routes share one rule set
|
||||
(`src/lib/avatar-figure.ts`): a figure must look like `part-id` segments
|
||||
separated by dots (`hd-180-1.ch-210-66`). Some clients append extra segments the
|
||||
renderer still accepts (e.g. a trailing `.0`), so the pattern is deliberately
|
||||
loose; over-strict validation once turned valid avatars into 400s.
|
||||
|
||||
Before a URL is built, the stored look is normalised: anything the imager would
|
||||
reject — an empty part slot such as the legacy `hr-100-.hd-180-1` default, a
|
||||
path-traversal attempt, an over-long value — is replaced with
|
||||
`DEFAULT_AVATAR_FIGURE`. That stops legacy accounts from rendering broken
|
||||
avatars without weakening the route, which still answers `400` to a malformed
|
||||
figure (`src/app/api/imaging/avatar/route.ts`).
|
||||
|
||||
---
|
||||
|
||||
@@ -686,13 +737,13 @@ For bulk-written tables (game-data JSON, imports) a containerized **`mariadb-tur
|
||||
|
||||
### 3. Asset caching headers
|
||||
|
||||
| Path | Cache-Control |
|
||||
| ------------------------------ | -------------------------------------------------------------- |
|
||||
| `/swf/**`, `/nitro-assets/**` | `public, max-age=604800, stale-while-revalidate=2592000` |
|
||||
| `/gamedata/**`, `/client/**` | `public` + `expires 7–30 d` (nginx) |
|
||||
| `/imaging/*` | `public, max-age=300` |
|
||||
| `/camera/**` | `public, max-age=31536000, immutable` |
|
||||
| `/_next/static/**` | `public, max-age=31536000, immutable` |
|
||||
| Path | Cache-Control |
|
||||
| ----------------------------- | -------------------------------------------------------- |
|
||||
| `/swf/**`, `/nitro-assets/**` | `public, max-age=604800, stale-while-revalidate=2592000` |
|
||||
| `/gamedata/**`, `/client/**` | `public` + `expires 7–30 d` (nginx) |
|
||||
| `/imaging/*` | `public, max-age=300` |
|
||||
| `/camera/**` | `public, max-age=31536000, immutable` |
|
||||
| `/_next/static/**` | `public, max-age=31536000, immutable` |
|
||||
|
||||
### 4. Cache layers (Redis + CDN)
|
||||
|
||||
@@ -711,16 +762,15 @@ handlers, and its response helper then drops the `Cache-Control` set by
|
||||
free of `auth()`, `cookies()`, `headers()` and request data, so the edge may
|
||||
cache them on their behalf.
|
||||
|
||||
| Class | Endpoints | TTL |
|
||||
| --- | --- | --- |
|
||||
| 1 | `/api/online`, `/api/online/count` | 10s |
|
||||
| 2 | `/api/photos`, `/api/leaderboard`, `/api/radio/current-dj`, `/api/radio/points/leaderboard` | 60s |
|
||||
| 3 | `/api/staff`, `/api/teams`, `/api/guilds`, `/api/shop`, `/api/shop/categories`, `/api/values`, `/api/values/categories`, `/api/values/[0-9]+` | 300s |
|
||||
| Class | Endpoints | TTL |
|
||||
| ----- | --------------------------------------------------------------------------------------------------------------------------------------------- | ---- |
|
||||
| 1 | `/api/online`, `/api/online/count` | 10s |
|
||||
| 2 | `/api/photos`, `/api/leaderboard`, `/api/radio/current-dj`, `/api/radio/points/leaderboard` | 60s |
|
||||
| 3 | `/api/staff`, `/api/teams`, `/api/guilds`, `/api/shop`, `/api/shop/categories`, `/api/values`, `/api/values/categories`, `/api/values/[0-9]+` | 300s |
|
||||
|
||||
`/api/radio/shouts` is intentionally excluded: it is live chat.
|
||||
|
||||
Three details are easy to get wrong and are worth keeping intact:
|
||||
|
||||
- **`proxy_ignore_headers "Cache-Control" "Vary";` is what makes the cache work.**
|
||||
`proxy_hide_header` only strips the header from the response to the client; the
|
||||
cache module has already read the upstream's `no-store` by then and stores
|
||||
@@ -741,21 +791,21 @@ with a redirect to `/me` and reads `headers()` for a per-request CSP nonce.
|
||||
|
||||
### Files changed for stability and caching
|
||||
|
||||
| File | Change |
|
||||
| --- | --- |
|
||||
| `/etc/nginx/nginx.conf` | `worker_connections 4096`, `multi_accept on`, TLS 1.2/1.3 only, `proxy_cache_path` for the micro-cache |
|
||||
| `/etc/nginx/sites-enabled/cms.conf` | single correct `Cache-Control` per path, micro-cache on the whitelist, session/Authorization bypass, SSE block with buffering off, `upstream cms_app` + `proxy_next_upstream` |
|
||||
| `/etc/nginx/snippets/cms_upstream_servers.conf` | backend list, rewritten by `ci-deploy.sh` during a cutover |
|
||||
| `src/app/api/online/count/stream/route.ts` | `X-Accel-Buffering: no` |
|
||||
| `src/app/api/radio/stream/route.ts` | `X-Accel-Buffering: no` |
|
||||
| `src/app/api/admin/import/{audit,furni/route,furni/batch,furni/batch-regen,furni/repair-icons}/route.ts` | `X-Accel-Buffering: no` |
|
||||
| `src/app/api/admin/studio/nitro-cleanup{,/rebuild}/route.ts` | `X-Accel-Buffering: no` |
|
||||
| `scripts/ci-deploy.sh` | resource limits, blue/green cutover, `switch_upstream`, rollback split by cutover state |
|
||||
| `deploy.sh` | reduced to a wrapper around `ci-deploy.sh` |
|
||||
| `docker-compose.yml` | two replica services from a shared anchor, resource limits, port-aware healthcheck, dead `mariadb-turbo` removed |
|
||||
| `src/lib/services/cache-warmup.ts` | corrected a comment that described delays the code never had |
|
||||
| `src/lib/ci-deploy.test.ts`, `src/test/ci-deploy-harness.sh` | blue/green coverage; nginx paths are injectable so the in-place path stays tested |
|
||||
| `ecosystem.config.cjs` | removed (PM2 supervised no process, and nothing referenced it) |
|
||||
| File | Change |
|
||||
| -------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `/etc/nginx/nginx.conf` | `worker_connections 4096`, `multi_accept on`, TLS 1.2/1.3 only, `proxy_cache_path` for the micro-cache |
|
||||
| `/etc/nginx/sites-enabled/cms.conf` | single correct `Cache-Control` per path, micro-cache on the whitelist, session/Authorization bypass, SSE block with buffering off, `upstream cms_app` + `proxy_next_upstream` |
|
||||
| `/etc/nginx/snippets/cms_upstream_servers.conf` | backend list, rewritten by `ci-deploy.sh` during a cutover |
|
||||
| `src/app/api/online/count/stream/route.ts` | `X-Accel-Buffering: no` |
|
||||
| `src/app/api/radio/stream/route.ts` | `X-Accel-Buffering: no` |
|
||||
| `src/app/api/admin/import/{audit,furni/route,furni/batch,furni/batch-regen,furni/repair-icons}/route.ts` | `X-Accel-Buffering: no` |
|
||||
| `src/app/api/admin/studio/nitro-cleanup{,/rebuild}/route.ts` | `X-Accel-Buffering: no` |
|
||||
| `scripts/ci-deploy.sh` | resource limits, blue/green cutover, `switch_upstream`, rollback split by cutover state |
|
||||
| `deploy.sh` | reduced to a wrapper around `ci-deploy.sh` |
|
||||
| `docker-compose.yml` | two replica services from a shared anchor, resource limits, port-aware healthcheck, dead `mariadb-turbo` removed |
|
||||
| `src/lib/services/cache-warmup.ts` | corrected a comment that described delays the code never had |
|
||||
| `src/lib/ci-deploy.test.ts`, `src/test/ci-deploy-harness.sh` | blue/green coverage; nginx paths are injectable so the in-place path stays tested |
|
||||
| `ecosystem.config.cjs` | removed (PM2 supervised no process, and nothing referenced it) |
|
||||
|
||||
> The nginx files live on the host only, not in this repository. Rebuilding the
|
||||
> host loses the cache configuration — keep a copy under version control if that
|
||||
@@ -767,7 +817,6 @@ with a redirect to `/me` and reads `headers()` for a per-request CSP nonce.
|
||||
|
||||
Two layers work together to keep the origin alive during misuse, floods, and
|
||||
repeat-offender attacks:
|
||||
|
||||
1. **App-layer gate** (production only) — per-IP request buckets per category
|
||||
(pages / API / auth), a whole-site safety valve, and escalation tiers that
|
||||
temporarily hard-block offending IPs (`src/lib/ddos-guard.ts`).
|
||||
@@ -789,7 +838,7 @@ repeat-offender attacks:
|
||||
- A Cloudflare API token with the following zone permissions:
|
||||
- `Zone > Zone > Read`
|
||||
- `Zone > Firewall > Edit`
|
||||
Create one at **My Profile → API Tokens → Create Token** (custom token).
|
||||
Create one at **My Profile → API Tokens → Create Token** (custom token).
|
||||
|
||||
### 1. Environment variables
|
||||
|
||||
@@ -825,7 +874,6 @@ Restart the container after changing them.
|
||||
|
||||
Open **DevOps → Anti-DDoS protection**
|
||||
(`/admin/devops/antiddos`, requires `settings.view`) and:
|
||||
|
||||
- Check the **Cloudflare** card shows the request arrived proxied, and the
|
||||
green **API configured** badge.
|
||||
- Click **Verify connection** — it confirms the token can read the zone and
|
||||
@@ -861,19 +909,18 @@ read one thing, read the precedence table below.
|
||||
### Where the captcha is configured (and who wins)
|
||||
|
||||
There are two places, and the **admin panel always wins**:
|
||||
|
||||
1. **Admin panel** — *Admin → CMS Settings → Captcha* (stored in the database).
|
||||
1. **Admin panel** — _Admin → CMS Settings → Captcha_ (stored in the database).
|
||||
2. **Environment variables** — used **only** as a fallback when the admin panel
|
||||
has no value.
|
||||
|
||||
Resolution order, highest first:
|
||||
|
||||
| Admin panel `captcha_provider` | Env `CAPTCHA_PROVIDER` | What happens |
|
||||
| --- | --- | --- |
|
||||
| `turnstile` | anything | **Turnstile** (panel wins) |
|
||||
| `none` (set explicitly) | `turnstile` | **Off** — an explicit panel value wins |
|
||||
| *empty / never set* | `turnstile` | **Turnstile** (env fallback) |
|
||||
| *empty / never set* | *empty / never set* | **Off** |
|
||||
| Admin panel `captcha_provider` | Env `CAPTCHA_PROVIDER` | What happens |
|
||||
| ------------------------------ | ---------------------- | -------------------------------------- |
|
||||
| `turnstile` | anything | **Turnstile** (panel wins) |
|
||||
| `none` (set explicitly) | `turnstile` | **Off** — an explicit panel value wins |
|
||||
| _empty / never set_ | `turnstile` | **Turnstile** (env fallback) |
|
||||
| _empty / never set_ | _empty / never set_ | **Off** |
|
||||
|
||||
In short: **enable it in the admin panel and the env vars are ignored; leave the
|
||||
panel empty and the env vars take over.** An explicit `none` in the panel turns
|
||||
@@ -884,7 +931,7 @@ the captcha **off** even when `CAPTCHA_PROVIDER=turnstile` is set.
|
||||
1. Create a Turnstile widget in the
|
||||
[Cloudflare dashboard](https://dash.cloudflare.com/?to=/:account/turnstile):
|
||||
**Add widget**, name it, and add the hostnames your hotel is served on
|
||||
(e.g. `epicnabbo.nl`, `www.epicnabbo.nl`). *Managed* mode is fine. Copy the
|
||||
(e.g. `epicnabbo.nl`, `www.epicnabbo.nl`). _Managed_ mode is fine. Copy the
|
||||
**Site Key** and the **Secret Key**.
|
||||
2. Put them in your real `.env` (never commit this file):
|
||||
|
||||
@@ -949,16 +996,18 @@ port rather than sharing a published docker port. `docker compose up --scale
|
||||
cms=2` therefore cannot work here: the replicas would collide on 3002. Instead
|
||||
there are two fixed slots, and each release lands in the idle one:
|
||||
|
||||
| Slot | Host port | Container name |
|
||||
| --- | --- | --- |
|
||||
| A | 3002 | `epicnext-cms-app` |
|
||||
| B | 3003 | `epicnext-cms-green` |
|
||||
| Slot | Host port | Container name |
|
||||
| ---- | --------- | -------------------- |
|
||||
| A | 3002 | `epicnext-cms-app` |
|
||||
| B | 3003 | `epicnext-cms-green` |
|
||||
|
||||
`docker-compose.yml` defines both services (`cms`, and `cms-green` behind the
|
||||
`green` profile) from one shared YAML anchor so they cannot drift apart. Note
|
||||
that the CI deploy path deliberately uses `docker run` rather than compose, so
|
||||
the resource limits are declared in **both** places — limits that only existed
|
||||
in compose would never apply to a real release.
|
||||
`docker-compose.yml` defines a single `cms` service (the blue slot on 3002)
|
||||
behind the `cms` profile, so a bare `docker compose up -d` never starts it. The
|
||||
green slot (`epicnext-cms-green`, port 3003) only ever exists as a `docker run`
|
||||
container created by the CI deploy; compose does not define it, so the two paths
|
||||
cannot drift apart. Note that the CI deploy path deliberately uses `docker run`
|
||||
rather than compose, so the resource limits are declared in **both** places —
|
||||
limits that only existed in compose would never apply to a real release.
|
||||
|
||||
### Compose on a CI host
|
||||
|
||||
@@ -966,15 +1015,19 @@ Compose and CI both want port 3002, so only one of them can own a host. A stray
|
||||
compose replica (`docker compose up`, or the daily `scripts/docker-update.sh`
|
||||
cron) parked an `epicnext-cms` container on the blue slot while nginx served the
|
||||
green slot, and every later release stopped on "Port 3002 is already in use" —
|
||||
after the build, the migrations and the browser gate. Two guards now prevent
|
||||
after the build, the migrations and the browser gate. Three guards now prevent
|
||||
that:
|
||||
|
||||
- The `cms` service sits behind the `cms` profile, so a bare `docker compose up
|
||||
-d` starts only byparr and can never claim 3002. The service starts only when
|
||||
it is named explicitly (`docker compose up -d cms`), which is what the
|
||||
installer/updater already do; Compose enables the profile automatically for an
|
||||
explicitly named service.
|
||||
- `scripts/docker-update.sh` refuses to run on a CI host. It used to test only
|
||||
`epicnext-cms-app`, but after a cutover to the green slot that container is
|
||||
stopped and deleted, so the guard stopped firing while the host stayed
|
||||
CI-managed. It now checks both slot containers and the nginx upstream, and
|
||||
reports the skip with exit 0 instead of failing the cron.
|
||||
- `ci-deploy.sh` retires a compose replica of *this* checkout
|
||||
- `ci-deploy.sh` retires a compose replica of _this_ checkout
|
||||
(`com.docker.compose.project.config_files`) from the candidate port before
|
||||
starting the candidate — but never a slot container, and never the port nginx
|
||||
currently serves. Anything else still fails loudly in `assert_port_free`.
|
||||
@@ -1024,7 +1077,7 @@ means a single leak can starve MariaDB, nginx and Traefik.
|
||||
|
||||
> **Watch this on the first heavy import.** The 4 GiB ceiling is not yet
|
||||
> exercised by a real bulk import. If the container is OOM-killed during a large
|
||||
> furniture import, raise `mem_limit` in `docker-compose.yml` *and* `mem_limit` /
|
||||
> furniture import, raise `mem_limit` in `docker-compose.yml` _and_ `mem_limit` /
|
||||
> `mem_swap_limit` in `scripts/ci-deploy.sh` together.
|
||||
|
||||
### Manual deploys
|
||||
@@ -1056,18 +1109,18 @@ cap larger than the free memory from dragging the whole host into the global
|
||||
OOM-killer. `MemorySwapMax=0` is set explicitly (this host has no swap). If
|
||||
fewer than 1 GB would remain, the script refuses to start.
|
||||
|
||||
| Script | Ceiling | Covers |
|
||||
| --------------------- | ------- | --------------------------------------- |
|
||||
| `pnpm dev` | 8 GB | Dev server |
|
||||
| `pnpm build` | 10 GB | Production build |
|
||||
| `pnpm analyze` | 10 GB | Build + bundle-size report |
|
||||
| `pnpm test` | 8 GB | Vitest |
|
||||
| `pnpm test:coverage` | 8 GB | Vitest with coverage |
|
||||
| `pnpm test:ui` | 8 GB | Playwright |
|
||||
| `pnpm test:e2e` | 8 GB | Playwright |
|
||||
| `pnpm test:e2e:quality` | 8 GB | Playwright (advisory) |
|
||||
| `pnpm test:integration` | 8 GB | Vitest integration config |
|
||||
| `pnpm typecheck` | 6 GB | `tsc --noEmit` |
|
||||
| Script | Ceiling | Covers |
|
||||
| ----------------------- | ------- | -------------------------- |
|
||||
| `pnpm dev` | 8 GB | Dev server |
|
||||
| `pnpm build` | 10 GB | Production build |
|
||||
| `pnpm analyze` | 10 GB | Build + bundle-size report |
|
||||
| `pnpm test` | 8 GB | Vitest |
|
||||
| `pnpm test:coverage` | 8 GB | Vitest with coverage |
|
||||
| `pnpm test:ui` | 8 GB | Playwright |
|
||||
| `pnpm test:e2e` | 8 GB | Playwright |
|
||||
| `pnpm test:e2e:quality` | 8 GB | Playwright (advisory) |
|
||||
| `pnpm test:integration` | 8 GB | Vitest integration config |
|
||||
| `pnpm typecheck` | 6 GB | `tsc --noEmit` |
|
||||
|
||||
### Running the builder by hand
|
||||
|
||||
@@ -1094,18 +1147,18 @@ Use the capped build instead:
|
||||
```
|
||||
|
||||
Fix: use `pnpm build`. If you are genuinely inside an isolated environment
|
||||
where the container *is* the boundary (the Docker build, a CI runner), set
|
||||
where the container _is_ the boundary (the Docker build, a CI runner), set
|
||||
`CMS_MEMORY_CAPPED=1` to opt out deliberately.
|
||||
|
||||
### Backends
|
||||
|
||||
`scripts/with-memory-cap.sh` picks its mechanism automatically:
|
||||
|
||||
| `CMS_MEMORY_CAP_BACKEND` | Mechanism | Notes |
|
||||
| ------------------------ | ------------------------------------------- | ------------------------------------------------------- |
|
||||
| `auto` *(default)* | systemd cgroup `MemoryMax` | Real RSS bound over the whole process tree. Used here. |
|
||||
| `ulimit` | `ulimit -v`, per process | Virtual address space, **not** RSS. Fallback only. |
|
||||
| `none` | None — warning only | Docker build and GitLab runner, each already isolated. |
|
||||
| `CMS_MEMORY_CAP_BACKEND` | Mechanism | Notes |
|
||||
| ------------------------ | -------------------------- | ------------------------------------------------------ |
|
||||
| `auto` _(default)_ | systemd cgroup `MemoryMax` | Real RSS bound over the whole process tree. Used here. |
|
||||
| `ulimit` | `ulimit -v`, per process | Virtual address space, **not** RSS. Fallback only. |
|
||||
| `none` | None — warning only | Docker build and GitLab runner, each already isolated. |
|
||||
|
||||
On a host **without** systemd and without `CMS_MEMORY_CAP_BACKEND=none`, the
|
||||
script refuses to run rather than proceeding unbounded.
|
||||
@@ -1123,39 +1176,40 @@ the headroom left for everything else on the machine; the effective ceiling is
|
||||
|
||||
## Scripts
|
||||
|
||||
| Command | Description |
|
||||
| ------------------------- | -------------------------------------------------- |
|
||||
| `pnpm dev` | Start development server (hot reload) |
|
||||
| `pnpm build` | Production build (memory-capped) |
|
||||
| `pnpm start` | Start production server |
|
||||
| `pnpm typecheck` | Run TypeScript type checking |
|
||||
| `pnpm test` | Run all tests (Vitest) |
|
||||
| `pnpm test:coverage` | Run all tests with coverage thresholds enforced |
|
||||
| `pnpm test:ui` | Playwright UI tests (`playwright.ui.config.ts`) |
|
||||
| `pnpm test:ui:update` | Playwright UI tests, updating snapshots |
|
||||
| `pnpm test:e2e` | Playwright end-to-end tests (blocking deploy gate) |
|
||||
| `pnpm test:e2e:quality` | Advisory content/latency checks (non-blocking) |
|
||||
| `pnpm test:integration` | Integration tests (own Vitest config) |
|
||||
| `pnpm test:housekeeping` | Housekeeping feature tests |
|
||||
| `pnpm lint` | Lint and format check (Biome) |
|
||||
| `pnpm biome:lint` | Alias of `pnpm lint` |
|
||||
| `pnpm format` | Format files in place (Biome) |
|
||||
| `pnpm toolchain:check` | Verify the Node toolchain matches `.nvmrc` |
|
||||
| `pnpm i18n:check` | Audit CMS translation coverage |
|
||||
| `pnpm deps:audit` | Audit dependencies for high-severity advisories |
|
||||
| `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 db:bulk` | Batch-import >50 MB JSON via `scripts/bulk-import-json.ts` |
|
||||
| `pnpm db:up` / `pnpm db:down` | Start / stop the `mariadb-turbo` container |
|
||||
| `pnpm gamedata:compress` | Pre-compress large gamedata JSON to `.gz` (gzip_static) |
|
||||
| `pnpm analyze` | Build + report per-route bundle sizes |
|
||||
| `pnpm performance:report` | Re-render the performance report from a build |
|
||||
| `pnpm jobs:worker` | Start background task worker |
|
||||
| `pnpm assets:editor` | Copy TinyMCE editor assets into `public/` |
|
||||
| Command | Description |
|
||||
| ----------------------------- | ---------------------------------------------------------- |
|
||||
| `pnpm dev` | Start development server (hot reload) |
|
||||
| `pnpm build` | Production build (memory-capped) |
|
||||
| `pnpm start` | Start production server |
|
||||
| `pnpm typecheck` | Run TypeScript type checking |
|
||||
| `pnpm test` | Run all tests (Vitest) |
|
||||
| `pnpm test:coverage` | Run all tests with coverage thresholds enforced |
|
||||
| `pnpm test:ui` | Playwright UI tests (`playwright.ui.config.ts`) |
|
||||
| `pnpm test:ui:update` | Playwright UI tests, updating snapshots |
|
||||
| `pnpm test:e2e` | Playwright end-to-end tests (blocking deploy gate) |
|
||||
| `pnpm test:e2e:quality` | Advisory content/latency checks (non-blocking) |
|
||||
| `pnpm test:integration` | Integration tests (own Vitest config) |
|
||||
| `pnpm test:housekeeping` | Housekeeping feature tests |
|
||||
| `pnpm lint` | Lint and format check (Biome) |
|
||||
| `pnpm biome:lint` | Alias of `pnpm lint` |
|
||||
| `pnpm format` | Format files in place (Biome) |
|
||||
| `pnpm toolchain:check` | Verify the Node toolchain matches `.nvmrc` |
|
||||
| `pnpm i18n:check` | Audit CMS translation coverage |
|
||||
| `pnpm deps:audit` | Audit dependencies for high-severity advisories |
|
||||
| `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 db:bulk` | Batch-import >50 MB JSON via `scripts/bulk-import-json.ts` |
|
||||
| `pnpm catalog:sync` | Sync the catalog against a clone source (interactive) |
|
||||
| `pnpm db:up` / `pnpm db:down` | Start / stop the `mariadb-turbo` container |
|
||||
| `pnpm gamedata:compress` | Pre-compress large gamedata JSON to `.gz` (gzip_static) |
|
||||
| `pnpm analyze` | Build + report per-route bundle sizes |
|
||||
| `pnpm performance:report` | Re-render the performance report from a build |
|
||||
| `pnpm jobs:worker` | Start background task worker |
|
||||
| `pnpm assets:editor` | Copy TinyMCE editor assets into `public/` |
|
||||
|
||||
> Replace `pnpm` with `npm run` or `yarn` for other package managers.
|
||||
>
|
||||
@@ -1167,20 +1221,20 @@ the headroom left for everything else on the machine; the effective ceiling is
|
||||
|
||||
## Performance Features
|
||||
|
||||
| Feature | Description |
|
||||
| ------------------------------ | -------------------------------------------------------------- |
|
||||
| **React Compiler** | Automatic memoization — reduces unnecessary re-renders |
|
||||
| **View Transitions API** | Native browser transitions between page navigations |
|
||||
| **Lenis Smooth Scroll** | Fluid, customizable scrolling |
|
||||
| **Server-Sent Events** | Real-time radio now-playing & listeners via SSE |
|
||||
| **Valkey Caching** | Caches API responses up to 30s in Valkey |
|
||||
| **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 |
|
||||
| **Compression** | Gzip compression enabled on all responses |
|
||||
| **gzip_static (gamedata)** | 48 MB FurnitureData JSON served from pre-built `.gz` — no per-request CPU |
|
||||
| **PWA** | Service worker with skip-waiting, stale CSS chunk recovery |
|
||||
| **Biome** | Fast linting and formatting (replaces ESLint + Prettier) |
|
||||
| Feature | Description |
|
||||
| -------------------------------- | ------------------------------------------------------------------------- |
|
||||
| **React Compiler** | Automatic memoization — reduces unnecessary re-renders |
|
||||
| **View Transitions API** | Native browser transitions between page navigations |
|
||||
| **Lenis Smooth Scroll** | Fluid, customizable scrolling |
|
||||
| **Server-Sent Events** | Real-time radio now-playing & listeners via SSE |
|
||||
| **Valkey Caching** | Caches API responses up to 30s in Valkey |
|
||||
| **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 |
|
||||
| **Compression** | Gzip compression enabled on all responses |
|
||||
| **gzip_static (gamedata)** | 48 MB FurnitureData JSON served from pre-built `.gz` — no per-request CPU |
|
||||
| **PWA** | Service worker with skip-waiting, stale CSS chunk recovery |
|
||||
| **Biome** | Fast linting and formatting (replaces ESLint + Prettier) |
|
||||
|
||||
---
|
||||
|
||||
@@ -1237,7 +1291,6 @@ The `/client` page loads the Nitro client. Configure the client URL via **Admin
|
||||
### Radio
|
||||
|
||||
Supports any streaming radio (e.g., Azuracast). Configure endpoints via CMS Settings:
|
||||
|
||||
- `radio_now_playing_api_url`
|
||||
- `radio_listeners_api_url`
|
||||
|
||||
@@ -1277,7 +1330,6 @@ BYPARR_URL=http://localhost:8191
|
||||
### Furniture Import with Auto-Translation
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -25,6 +25,10 @@
|
||||
},
|
||||
"style": {
|
||||
"noDescendingSpecificity": "off"
|
||||
},
|
||||
"correctness": {
|
||||
"noComponentHookFactories": "off",
|
||||
"usePlaywrightValidDescribeCallback": "off"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -6,4 +6,10 @@ services:
|
||||
HOSTNAME: 127.0.0.1
|
||||
PORT: "3002"
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3002/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"node",
|
||||
"-e",
|
||||
"fetch('http://127.0.0.1:3002/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))",
|
||||
]
|
||||
@@ -39,6 +39,13 @@ http {
|
||||
server_tokens off;
|
||||
keepalive_timeout 30s;
|
||||
|
||||
# Gamedata/icons are served from disk by alias locations. Caching the
|
||||
# stat/open results avoids a syscall storm on every room load.
|
||||
open_file_cache max=10000 inactive=30s;
|
||||
open_file_cache_valid 60s;
|
||||
open_file_cache_min_uses 2;
|
||||
open_file_cache_errors off;
|
||||
|
||||
client_max_body_size 64m;
|
||||
client_body_buffer_size 16k;
|
||||
client_header_buffer_size 1k;
|
||||
|
||||
+46
-17
@@ -1,5 +1,20 @@
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Next.js CMS — blue/green
|
||||
# Next.js CMS — single slot on port 3002
|
||||
#
|
||||
# ── Eigenaar van poort 3002 ─────────────────────────────────────────────────
|
||||
# Deze repo kent twee deploy-paden die allebei op poort 3002 willen:
|
||||
# * `scripts/ci-deploy.sh` (blue/green, docker run) — de eigenaar op een
|
||||
# CI-host. Elke push naar main bouwt daar een nieuwe image, draait de
|
||||
# migrations en zet nginx pas om na health + e2e + imaging.
|
||||
# * deze compose-`cms`-service — bedoeld voor een LOSSE host zonder CI.
|
||||
#
|
||||
# Ze kunnen niet naast elkaar draaien: wie op 3002 zit, blokkeert de ander met
|
||||
# `EADDRINUSE`. Daarom staat `cms` achter een profile. Een bloot
|
||||
# `docker compose up -d` start dus alleen byparr en kan nooit de live CI-release
|
||||
# van de site drukken. De service start alleen als je hem expliciet noemt
|
||||
# (`docker compose up -d cms`, `docker compose build cms`, ...): Compose schakelt
|
||||
# het profile van een expliciet genoemde service vanzelf in. Op een CI-host wil
|
||||
# je dat dus juist NOOIT doen.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
x-cms: &cms
|
||||
image: epicnext-cms:${CMS_RELEASE:-local}
|
||||
@@ -14,8 +29,20 @@ x-cms: &cms
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
NEXT_DEPLOYMENT_ID: ${CMS_RELEASE:-unknown}
|
||||
# Runtime host networking IS required: blue/green needs per-release host ports
|
||||
# (3002/3003) and nginx reaches the slot over 127.0.0.1.
|
||||
# Lokale buildcache op schijf. `scripts/docker-prune.sh` brengt de
|
||||
# daemon-cache na elke deploy terug tot 4GB, waardoor de lagen van de
|
||||
# vorige release verdwijnen. Deze export overleeft die prune: een rebuild
|
||||
# na een lockfile-wijziging hergebruikt dan de al gedownloade packages
|
||||
# (pnpm store) in plaats van ze opnieuw te downloaden.
|
||||
# De map ligt binnen de buildcontext, zodat buildx een ontbrekende
|
||||
# cachemap stilzwijgend negeert i.p.v. de build te laten falen; zowel
|
||||
# .dockerignore als .gitignore sluiten de map uit.
|
||||
cache_from:
|
||||
- type=local,src=.docker-cache/cms
|
||||
cache_to:
|
||||
- type=local,dest=.docker-cache/cms,mode=max
|
||||
# Runtime host networking IS required: nginx reaches the CMS over 127.0.0.1
|
||||
# on the single port that is actually served (3002).
|
||||
network_mode: host
|
||||
stop_grace_period: 15s
|
||||
restart: unless-stopped
|
||||
@@ -28,13 +55,17 @@ x-cms: &cms
|
||||
- /var/www/Gamedata:/var/www/Gamedata
|
||||
|
||||
# ── Resource limits ──
|
||||
mem_limit: 6g
|
||||
memswap_limit: 7g
|
||||
cpus: 2.0
|
||||
pids_limit: 512
|
||||
mem_limit: 4g
|
||||
memswap_limit: 5g
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:'+(process.env.PORT||'3002')+'/api/health').then(r=>{process.exit(r.ok?0:1)}).catch(()=>process.exit(1))"]
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"node",
|
||||
"-e",
|
||||
"fetch('http://127.0.0.1:'+(process.env.PORT||'3002')+'/api/health').then(r=>{process.exit(r.ok?0:1)}).catch(()=>process.exit(1))",
|
||||
]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
@@ -43,21 +74,19 @@ x-cms: &cms
|
||||
services:
|
||||
cms:
|
||||
<<: *cms
|
||||
# Alleen starten als je deze host bewust compose-only beheert.
|
||||
profiles: ["cms"]
|
||||
container_name: epicnext-cms
|
||||
environment:
|
||||
- HOSTNAME=0.0.0.0
|
||||
- PORT=3002
|
||||
|
||||
cms-green:
|
||||
<<: *cms
|
||||
container_name: epicnext-cms-green
|
||||
profiles: ["green"]
|
||||
environment:
|
||||
- HOSTNAME=0.0.0.0
|
||||
- PORT=3003
|
||||
|
||||
byparr:
|
||||
image: ghcr.io/thephaseless/byparr:latest
|
||||
image: ghcr.io/thephaseless/byparr:v3.0.4
|
||||
# `latest` is een drijvende tag. Zonder pull_policy haalt compose de
|
||||
# bestaande image nooit opnieuw op en blijft byparr hangen op de eerste
|
||||
# versie die ooit werd binnengehaald.
|
||||
pull_policy: always
|
||||
container_name: byparr
|
||||
network_mode: host
|
||||
restart: unless-stopped
|
||||
|
||||
+14
-15
@@ -2,20 +2,20 @@
|
||||
|
||||
## Operator changes
|
||||
|
||||
| Area | Behavior and location |
|
||||
| --- | --- |
|
||||
| Release | Deployment checks HTTP health, release identity and Chromium pages before marking the running image verified. Registry publication reuses that verified digest on the shared runner; independent hosts build and verify their own image. |
|
||||
| Shared HK | Dialogs scroll within the available viewport. Table column preferences persist per page in the current browser session; filters already remain in the URL. No favorites added. |
|
||||
| Catalog Studio | Dedicated detail drawer and five separate completeness states: SQL, offers, furnidata, icon and Nitro. Sprite/type conflicts are consistently excluded from import and linked to audit. Missing source files are never represented as available. |
|
||||
| Operations | Command center includes permission-filtered error groups, personal import failures, open support tickets and news drafts. A failed source is shown separately from an empty source. |
|
||||
| Error center | Retained occurrence counts, first/last times, identified users, release counts, self-assignment and recognized local links. Assignment requires edit permission and is audited. |
|
||||
| News | Private server-backed autosave, recover/discard/retry, prior saved revisions restored as a draft, and concurrent-edit detection. New status/search filters and pagination make older drafts reachable. |
|
||||
| Jobs | Cancellation finishes the current item and stops pending items. Completed work stays completed. Uncertain interrupted mutations are excluded from retry. Live lease checks stop known stale worker writes. |
|
||||
| Installation | `/admin/devops/installation` shows release, DB latency, Redis, emulator, storage permissions, migration history and worker heartbeat. Renderer defaults are recognized. Registry access is explicitly unverified from the web process. |
|
||||
| Public dashboard | Current/next published event, clearer unread-message action, useful empty/error states and mobile layout refinements. |
|
||||
| Audit | Exact actor/action and UTC date filters, readable recorded before/after values and permission-protected CSV of the filtered page, capped at 100 rows. |
|
||||
| Performance | Active import polling remains 5 seconds; idle polling is 30 seconds and pauses in hidden tabs. History returns at most 30 owned jobs and initially renders 50 items per job. Health probes are deduplicated within a render; diagnostics report observed probe duration. |
|
||||
| Text | New messages are translated in English, Italian and Dutch. Other locales have explicit English fallback strings. Existing translation debt is not reported as resolved. |
|
||||
| Area | Behavior and location |
|
||||
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Release | Deployment checks HTTP health, release identity and Chromium pages before marking the running image verified. Registry publication reuses that verified digest on the shared runner; independent hosts build and verify their own image. |
|
||||
| Shared HK | Dialogs scroll within the available viewport. Table column preferences persist per page in the current browser session; filters already remain in the URL. No favorites added. |
|
||||
| Catalog Studio | Dedicated detail drawer and five separate completeness states: SQL, offers, furnidata, icon and Nitro. Sprite/type conflicts are consistently excluded from import and linked to audit. Missing source files are never represented as available. |
|
||||
| Operations | Command center includes permission-filtered error groups, personal import failures, open support tickets and news drafts. A failed source is shown separately from an empty source. |
|
||||
| Error center | Retained occurrence counts, first/last times, identified users, release counts, self-assignment and recognized local links. Assignment requires edit permission and is audited. |
|
||||
| News | Private server-backed autosave, recover/discard/retry, prior saved revisions restored as a draft, and concurrent-edit detection. New status/search filters and pagination make older drafts reachable. |
|
||||
| Jobs | Cancellation finishes the current item and stops pending items. Completed work stays completed. Uncertain interrupted mutations are excluded from retry. Live lease checks stop known stale worker writes. |
|
||||
| Installation | `/admin/devops/installation` shows release, DB latency, Redis, emulator, storage permissions, migration history and worker heartbeat. Renderer defaults are recognized. Registry access is explicitly unverified from the web process. |
|
||||
| Public dashboard | Current/next published event, clearer unread-message action, useful empty/error states and mobile layout refinements. |
|
||||
| Audit | Exact actor/action and UTC date filters, readable recorded before/after values and permission-protected CSV of the filtered page, capped at 100 rows. |
|
||||
| Performance | Active import polling remains 5 seconds; idle polling is 30 seconds and pauses in hidden tabs. History returns at most 30 owned jobs and initially renders 50 items per job. Health probes are deduplicated within a render; diagnostics report observed probe duration. |
|
||||
| Text | New messages are translated in English, Italian and Dutch. Other locales have explicit English fallback strings. Existing translation debt is not reported as resolved. |
|
||||
|
||||
## Deployment requirements
|
||||
|
||||
@@ -37,7 +37,6 @@
|
||||
## Verification
|
||||
|
||||
Local verification on 2026-09-09:
|
||||
|
||||
- Production build succeeded with fixture configuration and an intentionally unavailable database. Build-time fallback logs are expected in this check.
|
||||
- Full Vitest run: 254 files passed, 4 skipped; 1,466 tests passed, 6 skipped. Coverage thresholds passed (19.89% lines); this does not imply exhaustive coverage.
|
||||
- Global Biome rules/import checks passed across 1,328 files; modified source/locales were formatted separately to avoid unrelated Windows line-ending changes.
|
||||
|
||||
@@ -56,9 +56,18 @@ Lenis has been removed; the public site now uses native scrolling. Other used
|
||||
runtime libraries remain. Vitest and its coverage provider are upgraded together;
|
||||
`clearMocks: false` preserves initialization-time permission contract assertions.
|
||||
|
||||
Dependency updates are manual: Renovate has been removed, so there is no bot
|
||||
opening upgrade pull requests. Node/pnpm upgrades remain coordinated with
|
||||
Docker and the runner toolchain.
|
||||
Dependency updates run through self-hosted Renovate: `renovate.json` plus
|
||||
`.gitea/workflows/renovate.yaml` run the pinned `renovate/renovate` image on the
|
||||
host runner every weekday at 04:17 (and via `workflow_dispatch`). Minor and patch
|
||||
updates are grouped into one PR and automerged only after CI is green
|
||||
(`platformAutomerge: false`, so Renovate waits on the checks itself); major
|
||||
updates require approval on the dependency dashboard. The job authenticates with
|
||||
the `renovate-bot` account token stored as the repo Actions secret
|
||||
`RENOVATE_TOKEN`; rotation only requires overwriting that secret (the bot
|
||||
password lives in `/root/.gitea-renovate-bot.env` on the host). `minimumReleaseAge`
|
||||
is 3 days in `renovate.json` and 60 hours in `pnpm-workspace.yaml`, so neither the
|
||||
installer nor the bot picks up a fresh release on day zero. Node/pnpm upgrades
|
||||
remain coordinated with Docker and the runner toolchain.
|
||||
Obsolete global overrides were removed; a scoped esbuild override remains because
|
||||
Drizzle Kit's loader still resolves a vulnerable legacy development-server build.
|
||||
The two deprecated esbuild-kit packages remain upstream dependencies of Drizzle
|
||||
|
||||
@@ -81,7 +81,7 @@ The direct template intentionally records the CDN/edge socket address when place
|
||||
|
||||
### Local verification and deployment boundary
|
||||
|
||||
`pnpm exec vitest run --coverage.enabled=false scripts/proxy-config.test.mjs` runs the installed Docker Compose CLI against a disposable clone configuration, without contacting Docker Engine, building images or reading the real `.env`. It verifies selection through `.env`, loopback/port override precedence, the IPv4 healthcheck and preservation of mounts, release selection and host networking. It explicitly skips when Compose is unavailable. This is not a container-start or network-isolation test.
|
||||
`pnpm exec vitest run --coverage.enabled=false scripts/proxy-config.test.mjs` runs the installed Docker Compose CLI against a disposable clone configuration, without contacting Docker Engine, building images or reading the real `.env`. It verifies selection through `.env`, loopback/port override precedence, the IPv4 healthcheck and preservation of mounts, release selection and host networking. The `cms` service is behind the `cms` profile (so a bare `docker compose up -d` cannot claim port 3002 on a CI-managed host), so the test renders the configuration with `--profile cms`. It explicitly skips when Compose is unavailable. This is not a container-start or network-isolation test.
|
||||
|
||||
`pnpm test:integration` additionally starts disposable Nginx containers from the actual templates, supplies a temporary test certificate, and sends real HTTPS requests with forged identity headers. It checks direct-mode replacement even with an inherited real-IP rule, rejection of untrusted peers, and acceptance through an explicitly trusted peer. This requires Docker Engine and the OpenSSL CLI and does not read deployment credentials. The templates must still pass `nginx -t` on the intended host after its hostname/certificate substitution, then the listener and trusted-header checks above; the disposable fixture cannot certify that host or its firewall.
|
||||
|
||||
|
||||
+15
-15
@@ -1,4 +1,4 @@
|
||||
# Informational route JavaScript budgets
|
||||
# Route JavaScript budgets (enforced)
|
||||
|
||||
Run after the existing production build; no second build or server is needed:
|
||||
|
||||
@@ -14,12 +14,11 @@ For each configured App Router route, resolve its exact app path using `app-path
|
||||
|
||||
The **initial entry envelope** is the union of route bootstrap `rootMainFilesTree[appPath]` (or `rootMainFiles`) and every client chunk that route's client-reference manifest lists. This includes layout, page and boundary/loading entries.
|
||||
|
||||
The manifest exposes those chunks differently per bundler. Turbopack emits an explicit per-segment `entryJSFiles` map; webpack emits no such field and records chunks only per client module, as `clientModules[*].chunks`, in `[chunkId, fileName, chunkId, fileName, …]` order. The report reads `entryJSFiles` when present and otherwise derives the same envelope from `clientModules`, which is the source Next's own `static-routes-info` uses. Numeric chunk ids are skipped; a malformed chunk *path* still fails rather than being dropped, so a broken manifest cannot quietly under-report a route.
|
||||
The manifest exposes those chunks differently per bundler. Turbopack emits an explicit per-segment `entryJSFiles` map; webpack emits no such field and records chunks only per client module, as `clientModules[*].chunks`, in `[chunkId, fileName, chunkId, fileName, …]` order. The report reads `entryJSFiles` when present and otherwise derives the same envelope from `clientModules`, which is the source Next's own `static-routes-info` uses. Numeric chunk ids are skipped; a malformed chunk _path_ still fails rather than being dropped, so a broken manifest cannot quietly under-report a route.
|
||||
|
||||
> The build runs webpack (`next build --webpack`), so the `clientModules` path is the live one. An earlier revision only read `entryJSFiles`, and after the switch to webpack every route reported `unavailable` while the command still exited 0 — the budgets were silently not being measured. When a bundler switch changes the manifest layout again, re-check this section rather than trusting a clean exit.
|
||||
|
||||
The definition follows the data exposed by the installed Next 16.3.8 build and the `getLinkAndScriptTags` / `getRequiredScripts` renderer helpers; it is deliberately a build-artifact envelope, not a browser network trace. Conditional rendering, redirects, streaming and browser caches can change actual requests.
|
||||
|
||||
- Raw bytes are filesystem byte lengths of unique JavaScript assets in that envelope.
|
||||
- Gzip bytes are the **sum of independent gzip level 9 compressions** of those files using the recorded Node/zlib runtime. They are not gzip of concatenated source, nor observed CDN transfer sizes.
|
||||
- Deployment query strings and `/_next/` prefixes are normalized before deduplication. Shared files count once per route; each route is measured independently, with no misleading cross-route total.
|
||||
@@ -28,20 +27,21 @@ The definition follows the data exposed by the installed Next 16.3.8 build and t
|
||||
|
||||
## Initial limits
|
||||
|
||||
The first limits are **baseline bytes × 1.15, rounded upward to the next 10 KiB (10,240 bytes)** independently for raw and gzip. They are provisional size alerts, not validated speed targets. Baseline: local production build `build-TfctsWXpff2fKS`, Next 16.3.4 **Turbopack**; its source commit was not inferred.
|
||||
The limits are **baseline bytes × 1.15, rounded upward to the next 10 KiB (10,240 bytes)** independently for raw and gzip. They are size guardrails, not validated speed targets.
|
||||
|
||||
The production build now runs webpack, so the numbers it reports are not directly comparable to the baseline below. Re-measured on the current webpack build the routes land at `/me` 786138/247738, `/news` 781601/245672, `/events` 782011/245923, `/search` 783262/246578, `/admin/catalog` 1172089/370843, `/admin/studio/furni` 1374128/440546 (raw/gzip). All remain inside the limits below, but `/admin/studio/furni` sits at ~98% of its gzip limit, so the next dependency added to that route will trip it. Recalibrate the table and `scripts/performance-budgets.json` together if the intent is to reset the baseline on webpack.
|
||||
Baselines were last recalibrated against a local webpack production build (Next 16.4.1, `next build --webpack`) and cross-checked against the report the Docker image build emits: `/me` 621460/195411, `/news` 616712/193199, `/events` 617121/193450, `/search` 618536/194216, `/admin/catalog` 1049094/330369, `/admin/studio/furni` 1397566/447765 (raw/gzip). The earlier Turbopack numbers (previous revision) were **not** comparable and were dropped.
|
||||
|
||||
| Route | Baseline raw bytes | Baseline gzip bytes | Raw limit | Gzip limit |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
| `/me` | 767156 | 238571 | 890880 | 276480 |
|
||||
| `/news` | 765367 | 237599 | 880640 | 276480 |
|
||||
| `/events` | 765851 | 237964 | 890880 | 276480 |
|
||||
| `/search` | 765851 | 237964 | 890880 | 276480 |
|
||||
| `/admin/catalog` | 1654898 | 492619 | 1904640 | 573440 |
|
||||
| `/admin/studio/furni` | 1241312 | 391541 | 1433600 | 450560 |
|
||||
The image build compiles the app, then runs `scripts/performance-report.mjs` in the same `RUN` layer (`Dockerfile:48-53`). In `mode: enforce` a failing route therefore fails the image build, which blocks both the PR `preflight` job and the `deploy` job. Recalibrate the table and `scripts/performance-budgets.json` **together** in the same commit whenever the envelope legitimately grows (new route dependency, Next upgrade).
|
||||
|
||||
Configured limits are positive integer bytes; `null` explicitly means observe-only. `scripts/performance-budgets.json` remains `mode: informational`. Exceeding a limit produces `over-budget` and a warning, with exit code 0. Missing production `BUILD_ID`, unsupported manifests, missing routes or missing referenced assets produce `unavailable` with a reason and **no partial/zero total**, also exit code 0. Malformed budget configuration or an unwritable output directory fails the command. This keeps initial CI reporting non-blocking while preventing invalid configuration from quietly disabling limits.
|
||||
| Route | Baseline raw bytes | Baseline gzip bytes | Raw limit | Gzip limit |
|
||||
| --------------------- | -----------------: | ------------------: | --------: | ---------: |
|
||||
| `/me` | 621460 | 195411 | 716800 | 225280 |
|
||||
| `/news` | 616712 | 193199 | 716800 | 225280 |
|
||||
| `/events` | 617121 | 193450 | 716800 | 225280 |
|
||||
| `/search` | 618536 | 194216 | 716800 | 225280 |
|
||||
| `/admin/catalog` | 1049094 | 330369 | 1208320 | 389120 |
|
||||
| `/admin/studio/furni` | 1397566 | 447765 | 1607680 | 522240 |
|
||||
|
||||
Synthetic tests cover shared-chunk deduplication, exact byte/gzip calculations, route bootstrap selection, missing data, safe parsing and CLI exit behavior. Run `pnpm exec vitest run --coverage.enabled=false scripts/performance-report.test.mjs`.
|
||||
Configured limits are positive integer bytes; `null` explicitly means observe-only. `scripts/performance-budgets.json` runs `mode: enforce`: an `over-budget` route prints `Performance budgets enforced: <route> (over-budget)` and exits non-zero. Missing production `BUILD_ID`, unsupported manifests, missing routes or missing referenced assets produce `unavailable` with a reason and **no partial/zero total**; under `enforce` an `unavailable` route also fails the command, so a bundler change that breaks measurement cannot pass quietly. Malformed budget configuration or an unwritable output directory always fails the command. Set `mode` back to `informational` only while recalibrating, never as a permanent state.
|
||||
|
||||
Synthetic tests cover shared-chunk deduplication, exact byte/gzip calculations, route bootstrap selection, missing data, safe parsing and CLI exit behavior in both modes. Run `pnpm exec vitest run --coverage.enabled=false scripts/performance-report.test.mjs`.
|
||||
@@ -1,5 +1,11 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { avatarPath, countBytes, looksLikeImage } from "./imaging-target";
|
||||
import {
|
||||
avatarPath,
|
||||
countBytes,
|
||||
fetchThroughEdge,
|
||||
getThroughEdge,
|
||||
looksLikeImage,
|
||||
} from "./imaging-target";
|
||||
|
||||
// Endpoints exercised here are identical whether the CMS is reached directly
|
||||
// or through the edge proxy, so this spec runs unchanged in the deploy gate and
|
||||
@@ -17,7 +23,8 @@ const IMAGE_SOURCES = new Set([
|
||||
test("renders an animated APNG for a figure with an effect", async ({
|
||||
request,
|
||||
}) => {
|
||||
const response = await request.get(
|
||||
const response = await getThroughEdge(
|
||||
request,
|
||||
avatarPath({ img_format: "apng", effect: "14" }),
|
||||
);
|
||||
expect(response.status()).toBe(200);
|
||||
@@ -35,7 +42,10 @@ test("renders an animated APNG for a figure with an effect", async ({
|
||||
test("serves a static PNG when no animation is requested", async ({
|
||||
request,
|
||||
}) => {
|
||||
const response = await request.get(avatarPath({ img_format: "png" }));
|
||||
const response = await getThroughEdge(
|
||||
request,
|
||||
avatarPath({ img_format: "png" }),
|
||||
);
|
||||
expect(response.status()).toBe(200);
|
||||
expect(response.headers()["content-type"]).toContain("image/png");
|
||||
const body = await response.body();
|
||||
@@ -45,7 +55,10 @@ test("serves a static PNG when no animation is requested", async ({
|
||||
test("advertises a cacheable response and a known render source", async ({
|
||||
request,
|
||||
}) => {
|
||||
const response = await request.get(avatarPath({ img_format: "png" }));
|
||||
const response = await getThroughEdge(
|
||||
request,
|
||||
avatarPath({ img_format: "png" }),
|
||||
);
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
const source = response.headers()["x-imager-source"];
|
||||
@@ -63,22 +76,32 @@ test("advertises a cacheable response and a known render source", async ({
|
||||
});
|
||||
|
||||
test("answers conditional requests with 304", async ({ request }) => {
|
||||
const first = await request.get(avatarPath({ img_format: "png" }));
|
||||
const first = await getThroughEdge(
|
||||
request,
|
||||
avatarPath({ img_format: "png" }),
|
||||
);
|
||||
expect(first.status()).toBe(200);
|
||||
const etag = first.headers()["etag"];
|
||||
const etag = first.headers().etag;
|
||||
expect(etag).toBeTruthy();
|
||||
|
||||
const second = await request.get(avatarPath({ img_format: "png" }), {
|
||||
headers: { "if-none-match": etag as string },
|
||||
});
|
||||
const second = await getThroughEdge(
|
||||
request,
|
||||
avatarPath({ img_format: "png" }),
|
||||
{
|
||||
headers: { "if-none-match": etag as string },
|
||||
},
|
||||
);
|
||||
expect(second.status()).toBe(304);
|
||||
expect(second.headers()["etag"]).toBe(etag);
|
||||
expect(second.headers().etag).toBe(etag);
|
||||
});
|
||||
|
||||
test("serves the rendered avatar behind permissive CORS", async ({
|
||||
request,
|
||||
}) => {
|
||||
const response = await request.get(avatarPath({ img_format: "png" }));
|
||||
const response = await getThroughEdge(
|
||||
request,
|
||||
avatarPath({ img_format: "png" }),
|
||||
);
|
||||
expect(response.status()).toBe(200);
|
||||
expect(response.headers()["access-control-allow-origin"]).toBe("*");
|
||||
});
|
||||
@@ -86,7 +109,9 @@ test("serves the rendered avatar behind permissive CORS", async ({
|
||||
test("preflight requests advertise GET and revalidation", async ({
|
||||
request,
|
||||
}) => {
|
||||
const response = await request.fetch(avatarPath(), { method: "OPTIONS" });
|
||||
const response = await fetchThroughEdge(request, avatarPath(), {
|
||||
method: "OPTIONS",
|
||||
});
|
||||
expect(response.status()).toBe(204);
|
||||
expect(response.headers()["access-control-allow-origin"]).toBe("*");
|
||||
expect(response.headers()["access-control-allow-methods"]).toContain("GET");
|
||||
@@ -113,7 +138,10 @@ test.describe("rejects malformed figures", () => {
|
||||
|
||||
for (const { name, query } of cases) {
|
||||
test(name, async ({ request }) => {
|
||||
const response = await request.get(`/api/imaging/avatar${query}`);
|
||||
const response = await getThroughEdge(
|
||||
request,
|
||||
`/api/imaging/avatar${query}`,
|
||||
);
|
||||
expect(response.status()).toBe(400);
|
||||
});
|
||||
}
|
||||
@@ -131,7 +159,7 @@ test("collapses a burst of concurrent requests for one figure", async ({
|
||||
head_direction: "1",
|
||||
});
|
||||
const responses = await Promise.all(
|
||||
Array.from({ length: 6 }, () => request.get(path)),
|
||||
Array.from({ length: 6 }, () => getThroughEdge(request, path)),
|
||||
);
|
||||
for (const response of responses) {
|
||||
expect(response.status()).toBe(200);
|
||||
@@ -143,14 +171,14 @@ test("collapses a burst of concurrent requests for one figure", async ({
|
||||
test("the badge endpoint serves an image and revalidates", async ({
|
||||
request,
|
||||
}) => {
|
||||
const first = await request.get("/api/imaging/badge?code=ADM");
|
||||
const first = await getThroughEdge(request, "/api/imaging/badge?code=ADM");
|
||||
expect(first.status()).toBe(200);
|
||||
expect(first.headers()["content-type"]).toContain("image/");
|
||||
expect(first.headers()["access-control-allow-origin"]).toBe("*");
|
||||
const etag = first.headers()["etag"];
|
||||
const etag = first.headers().etag;
|
||||
expect(etag).toBeTruthy();
|
||||
|
||||
const second = await request.get("/api/imaging/badge?code=ADM", {
|
||||
const second = await getThroughEdge(request, "/api/imaging/badge?code=ADM", {
|
||||
headers: { "if-none-match": etag as string },
|
||||
});
|
||||
expect(second.status()).toBe(304);
|
||||
@@ -159,7 +187,8 @@ test("the badge endpoint serves an image and revalidates", async ({
|
||||
test("the badge endpoint can resolve from a local source", async ({
|
||||
request,
|
||||
}) => {
|
||||
const response = await request.get(
|
||||
const response = await getThroughEdge(
|
||||
request,
|
||||
"/api/imaging/badge?code=ADM&source=local",
|
||||
);
|
||||
expect(response.status()).toBe(200);
|
||||
@@ -177,16 +206,23 @@ test.describe("rejects malformed badge codes", () => {
|
||||
|
||||
for (const { name, query } of cases) {
|
||||
test(name, async ({ request }) => {
|
||||
const response = await request.get(`/api/imaging/badge${query}`);
|
||||
const response = await getThroughEdge(
|
||||
request,
|
||||
`/api/imaging/badge${query}`,
|
||||
);
|
||||
expect(response.status()).toBe(400);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("the badge preflight advertises GET", async ({ request }) => {
|
||||
const response = await request.fetch("/api/imaging/badge?code=ADM", {
|
||||
method: "OPTIONS",
|
||||
});
|
||||
const response = await fetchThroughEdge(
|
||||
request,
|
||||
"/api/imaging/badge?code=ADM",
|
||||
{
|
||||
method: "OPTIONS",
|
||||
},
|
||||
);
|
||||
expect(response.status()).toBe(204);
|
||||
expect(response.headers()["access-control-allow-methods"]).toContain("GET");
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { APIRequestContext, APIResponse } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Where the imaging specs are pointed.
|
||||
*
|
||||
@@ -12,6 +14,56 @@
|
||||
*/
|
||||
export const IS_EDGE = process.env.IMAGING_EDGE === "1";
|
||||
|
||||
/**
|
||||
* Failures that mean "the request never reached the app".
|
||||
*
|
||||
* Cloudflare answers the 520-527 family when its own handshake to the origin
|
||||
* fails, and gateways add 502/503/504. Retrying those is not papering over a
|
||||
* bug: the assertion under test is about the CMS, and a 525 says nothing about
|
||||
* it. A genuinely broken edge still exhausts the retries and fails the test.
|
||||
*/
|
||||
const TRANSIENT_EDGE_STATUSES = new Set([
|
||||
502, 503, 504, 520, 521, 522, 523, 524, 525, 526, 527,
|
||||
]);
|
||||
const EDGE_RETRY_ATTEMPTS = 3;
|
||||
const EDGE_RETRY_DELAY_MS = 500;
|
||||
|
||||
async function withEdgeRetry(
|
||||
send: () => Promise<APIResponse>,
|
||||
): Promise<APIResponse> {
|
||||
let response = await send();
|
||||
for (
|
||||
let attempt = 1;
|
||||
attempt < EDGE_RETRY_ATTEMPTS &&
|
||||
TRANSIENT_EDGE_STATUSES.has(response.status());
|
||||
attempt += 1
|
||||
) {
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, EDGE_RETRY_DELAY_MS * attempt),
|
||||
);
|
||||
response = await send();
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
/** GET that survives a transient edge/transport failure. */
|
||||
export function getThroughEdge(
|
||||
request: APIRequestContext,
|
||||
url: string,
|
||||
options: Parameters<APIRequestContext["get"]>[1] = {},
|
||||
): Promise<APIResponse> {
|
||||
return withEdgeRetry(() => request.get(url, options));
|
||||
}
|
||||
|
||||
/** POST/OPTIONS/… counterpart of `getThroughEdge`. */
|
||||
export function fetchThroughEdge(
|
||||
request: APIRequestContext,
|
||||
url: string,
|
||||
options: Parameters<APIRequestContext["fetch"]>[1] = {},
|
||||
): Promise<APIResponse> {
|
||||
return withEdgeRetry(() => request.fetch(url, options));
|
||||
}
|
||||
|
||||
// The exact figure that regressed in production: the game client appends a
|
||||
// trailing `.<n>` segment that the renderer ignores, but the previous
|
||||
// validation regex rejected with a 400.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import {
|
||||
avatarPath,
|
||||
getThroughEdge,
|
||||
IMAGER_PATH,
|
||||
IS_EDGE,
|
||||
looksLikeImage,
|
||||
@@ -17,7 +18,10 @@ test.skip(
|
||||
);
|
||||
|
||||
test("the edge serves the imager endpoint as an image", async ({ request }) => {
|
||||
const response = await request.get(IMAGER_PATH({ img_format: "png" }));
|
||||
const response = await getThroughEdge(
|
||||
request,
|
||||
IMAGER_PATH({ img_format: "png" }),
|
||||
);
|
||||
expect(response.status()).toBe(200);
|
||||
expect(response.headers()["content-type"]).toContain("image/");
|
||||
expect(looksLikeImage(await response.body())).toBe(true);
|
||||
@@ -26,14 +30,17 @@ test("the edge serves the imager endpoint as an image", async ({ request }) => {
|
||||
test("the CMS avatar endpoint is reachable through the edge", async ({
|
||||
request,
|
||||
}) => {
|
||||
const response = await request.get(avatarPath({ img_format: "png" }));
|
||||
const response = await getThroughEdge(
|
||||
request,
|
||||
avatarPath({ img_format: "png" }),
|
||||
);
|
||||
expect(response.status()).toBe(200);
|
||||
expect(response.headers()["content-type"]).toContain("image/");
|
||||
expect(looksLikeImage(await response.body())).toBe(true);
|
||||
});
|
||||
|
||||
test("the edge does not shadow the CMS badge endpoint", async ({ request }) => {
|
||||
const response = await request.get("/api/imaging/badge?code=ADM");
|
||||
const response = await getThroughEdge(request, "/api/imaging/badge?code=ADM");
|
||||
expect(response.status()).toBe(200);
|
||||
expect(response.headers()["content-type"]).toContain("image/");
|
||||
});
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -116,9 +116,14 @@ test("saving while recovery is pending enables recovery for subsequent edits", a
|
||||
test("unmount flush retains an edit before the autosave interval", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Install the fake clock before navigation so every page timer is faked,
|
||||
// then jump forward to a fixed pause point. Using wall-clock `new Date()`
|
||||
// for both calls raced under load: the fake clock ticks in real time
|
||||
// between the calls and can pass the target ("Cannot fast-forward to the
|
||||
// past"). The 60s jump only fires no-op persists on a clean form.
|
||||
await page.clock.install({ time: new Date("2030-01-01T12:00:00Z") });
|
||||
await page.goto("/admin/articles/recovery");
|
||||
await page.clock.install();
|
||||
await page.clock.pauseAt(new Date());
|
||||
await page.clock.pauseAt(new Date("2030-01-01T12:01:00Z"));
|
||||
await page.locator('input[name="title"]').fill("Immediate final edit");
|
||||
await page
|
||||
.getByRole("button", { name: "Close editor fixture", exact: true })
|
||||
|
||||
+148
-50
@@ -1,4 +1,5 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, readdir, readFile, unlink, writeFile } from "node:fs/promises";
|
||||
import { createServer } from "node:http";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -23,57 +24,154 @@ const actions = new Set([
|
||||
"@/actions/article-recovery",
|
||||
"@/actions/admin-media",
|
||||
]);
|
||||
const bundle = await build({
|
||||
entryPoints: [path.join(fixtures, "entry.tsx")],
|
||||
bundle: true,
|
||||
write: false,
|
||||
outdir: path.join(root, "test-results/ui-fixture"),
|
||||
format: "iife",
|
||||
jsx: "automatic",
|
||||
alias: { "@": path.join(root, "src") },
|
||||
define: { "process.env.NODE_ENV": '"production"' },
|
||||
plugins: [
|
||||
{
|
||||
name: "server-contracts",
|
||||
setup(build) {
|
||||
build.onResolve(
|
||||
{ filter: /^next\/navigation$|^next\/link$|^@\/actions\// },
|
||||
(args) => {
|
||||
if (args.path === "next/navigation")
|
||||
return { path: path.join(fixtures, "navigation.ts") };
|
||||
if (args.path === "next/link")
|
||||
return { path: path.join(fixtures, "next-link.tsx") };
|
||||
if (actions.has(args.path))
|
||||
return { path: path.join(fixtures, "server-actions.ts") };
|
||||
throw Error(`Unexpected server action in UI fixture: ${args.path}`);
|
||||
},
|
||||
);
|
||||
build.onResolve(
|
||||
{ filter: /^@\/lib\/db$|^mysql2|^next\/headers$/ },
|
||||
(args) => {
|
||||
throw Error(
|
||||
`Database or server dependency forbidden in UI fixture: ${args.path}`,
|
||||
const nodeStubs = path.join(root, "e2e/ui/node-stubs.mjs");
|
||||
const cacheDir = path.join(root, "node_modules/.cache/nexst-ui-fixture");
|
||||
const startedAt = Date.now();
|
||||
|
||||
async function listFiles(dir) {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
const files = [];
|
||||
for (const entry of entries) {
|
||||
const target = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) files.push(...(await listFiles(target)));
|
||||
else files.push(target);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
async function fixtureCacheKey() {
|
||||
const files = [
|
||||
...(await listFiles(path.join(root, "src"))),
|
||||
...(await listFiles(fixtures)),
|
||||
nodeStubs,
|
||||
path.join(root, "e2e/ui/process-shim.mjs"),
|
||||
fileURLToPath(import.meta.url),
|
||||
path.join(root, "package.json"),
|
||||
].sort();
|
||||
const hash = createHash("sha256");
|
||||
for (const file of files) {
|
||||
hash.update(file.slice(root.length));
|
||||
hash.update(await readFile(file));
|
||||
}
|
||||
return hash.digest("hex");
|
||||
}
|
||||
|
||||
async function readCache(key) {
|
||||
try {
|
||||
const [js, css] = await Promise.all([
|
||||
readFile(path.join(cacheDir, `${key}.app.js`), "utf8"),
|
||||
readFile(path.join(cacheDir, `${key}.styles.css`), "utf8"),
|
||||
]);
|
||||
return { js, css };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeCache(key, js, css) {
|
||||
await mkdir(cacheDir, { recursive: true });
|
||||
await Promise.all([
|
||||
writeFile(path.join(cacheDir, `${key}.app.js`), js),
|
||||
writeFile(path.join(cacheDir, `${key}.styles.css`), css),
|
||||
]);
|
||||
const stale = await readdir(cacheDir);
|
||||
await Promise.all(
|
||||
stale
|
||||
.filter((name) => !name.startsWith(key))
|
||||
.map((name) => unlink(path.join(cacheDir, name))),
|
||||
);
|
||||
}
|
||||
|
||||
async function buildFixture() {
|
||||
const [bundle, globalCss] = await Promise.all([
|
||||
build({
|
||||
entryPoints: [path.join(fixtures, "entry.tsx")],
|
||||
bundle: true,
|
||||
write: false,
|
||||
outdir: path.join(root, "test-results/ui-fixture"),
|
||||
format: "iife",
|
||||
jsx: "automatic",
|
||||
alias: {
|
||||
"@": path.join(root, "src"),
|
||||
"node:async_hooks": nodeStubs,
|
||||
"node:child_process": nodeStubs,
|
||||
"node:crypto": nodeStubs,
|
||||
"node:dns/promises": nodeStubs,
|
||||
"node:fs": nodeStubs,
|
||||
"node:fs/promises": nodeStubs,
|
||||
"node:http": nodeStubs,
|
||||
"node:net": nodeStubs,
|
||||
"node:os": nodeStubs,
|
||||
"node:path": nodeStubs,
|
||||
"node:url": nodeStubs,
|
||||
"node:util": nodeStubs,
|
||||
},
|
||||
define: {
|
||||
"process.env.NODE_ENV": '"production"',
|
||||
"process.env.SKIP_ENV_VALIDATION": '"1"',
|
||||
},
|
||||
inject: [path.join(root, "e2e/ui/process-shim.mjs")],
|
||||
plugins: [
|
||||
{
|
||||
name: "server-contracts",
|
||||
setup(build) {
|
||||
build.onResolve(
|
||||
{ filter: /^next\/navigation$|^next\/link$|^@\/actions\// },
|
||||
(args) => {
|
||||
if (args.path === "next/navigation")
|
||||
return { path: path.join(fixtures, "navigation.ts") };
|
||||
if (args.path === "next/link")
|
||||
return { path: path.join(fixtures, "next-link.tsx") };
|
||||
if (actions.has(args.path))
|
||||
return { path: path.join(fixtures, "server-actions.ts") };
|
||||
throw Error(
|
||||
`Unexpected server action in UI fixture: ${args.path}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
build.onResolve(
|
||||
{ filter: /^@\/lib\/db$|^mysql2|^next\/headers$/ },
|
||||
(args) => {
|
||||
throw Error(
|
||||
`Database or server dependency forbidden in UI fixture: ${args.path}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const globalCss = (
|
||||
await postcss([tailwind({ base: root })]).process(
|
||||
await readFile(path.join(root, "src/app/globals.css"), "utf8"),
|
||||
{ from: path.join(root, "src/app/globals.css") },
|
||||
)
|
||||
).css;
|
||||
const css =
|
||||
globalCss +
|
||||
"\n" +
|
||||
bundle.outputFiles
|
||||
.filter((file) => file.path.endsWith(".css"))
|
||||
.map((file) => file.text)
|
||||
.join("\n");
|
||||
const js = bundle.outputFiles.find((file) => file.path.endsWith(".js")).text;
|
||||
},
|
||||
],
|
||||
}),
|
||||
postcss([tailwind({ base: root })])
|
||||
.process(await readFile(path.join(root, "src/app/globals.css"), "utf8"), {
|
||||
from: path.join(root, "src/app/globals.css"),
|
||||
})
|
||||
.then((result) => result.css),
|
||||
]);
|
||||
const js = bundle.outputFiles.find((file) => file.path.endsWith(".js")).text;
|
||||
const css =
|
||||
globalCss +
|
||||
"\n" +
|
||||
bundle.outputFiles
|
||||
.filter((file) => file.path.endsWith(".css"))
|
||||
.map((file) => file.text)
|
||||
.join("\n");
|
||||
return { js, css };
|
||||
}
|
||||
|
||||
const cacheKey = await fixtureCacheKey();
|
||||
let assets = await readCache(cacheKey);
|
||||
if (assets) {
|
||||
console.log(
|
||||
`[fixture] reusing cached bundle ${cacheKey.slice(0, 12)} (${Date.now() - startedAt}ms)`,
|
||||
);
|
||||
} else {
|
||||
assets = await buildFixture();
|
||||
await writeCache(cacheKey, assets.js, assets.css);
|
||||
console.log(
|
||||
`[fixture] built bundle ${cacheKey.slice(0, 12)} in ${Date.now() - startedAt}ms`,
|
||||
);
|
||||
}
|
||||
const { js, css } = assets;
|
||||
const html =
|
||||
'<!doctype html><html lang="en" class="dark"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="csrf-token" content="' +
|
||||
"a".repeat(64) +
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
// Stubs for Node.js built-ins used by server-only modules that leak into the
|
||||
// UI fixture bundle. These keep esbuild from failing on `node:*` imports
|
||||
// while providing shape-compatible exports so the bundle stays loadable.
|
||||
// Nothing here performs real I/O; it only exists so module evaluation in the
|
||||
// browser does not crash on import of server-only helpers.
|
||||
|
||||
export class AsyncLocalStorage {
|
||||
enterWith() {}
|
||||
exit() {}
|
||||
getStore() {
|
||||
return undefined;
|
||||
}
|
||||
run(_store, work) {
|
||||
return work();
|
||||
}
|
||||
}
|
||||
|
||||
const cryptoApi = {
|
||||
createHash: () => ({
|
||||
update: () => ({
|
||||
digest: () => "",
|
||||
}),
|
||||
}),
|
||||
randomUUID: () => "00000000-0000-0000-0000-000000000000",
|
||||
randomBytes: (size) => new Uint8Array(size),
|
||||
createHmac: () => ({
|
||||
update: () => ({
|
||||
digest: () => "",
|
||||
}),
|
||||
}),
|
||||
timingSafeEqual: () => true,
|
||||
createCipheriv: () => ({
|
||||
update: () => new Uint8Array(0),
|
||||
final: () => new Uint8Array(0),
|
||||
}),
|
||||
createDecipheriv: () => ({
|
||||
update: () => new Uint8Array(0),
|
||||
final: () => new Uint8Array(0),
|
||||
}),
|
||||
webcrypto: globalThis.crypto,
|
||||
};
|
||||
|
||||
export const {
|
||||
createHash,
|
||||
randomUUID,
|
||||
randomBytes,
|
||||
createHmac,
|
||||
timingSafeEqual,
|
||||
createCipheriv,
|
||||
createDecipheriv,
|
||||
webcrypto,
|
||||
} = cryptoApi;
|
||||
|
||||
export const appendFile = async () => {};
|
||||
export const mkdir = async () => {};
|
||||
export const readdir = async () => [];
|
||||
export const readFile = async () => new Uint8Array(0);
|
||||
export const rename = async () => {};
|
||||
export const stat = async () => ({
|
||||
isDirectory: () => false,
|
||||
isFile: () => true,
|
||||
size: 0,
|
||||
mtimeMs: 0,
|
||||
});
|
||||
export const unlink = async () => {};
|
||||
export const writeFile = async () => {};
|
||||
export const rm = async () => {};
|
||||
export const mkdtemp = async () => "/tmp/fixture-stub";
|
||||
|
||||
export const promises = {
|
||||
appendFile,
|
||||
mkdir,
|
||||
readdir,
|
||||
readFile,
|
||||
rename,
|
||||
stat,
|
||||
unlink,
|
||||
writeFile,
|
||||
rm,
|
||||
mkdtemp,
|
||||
};
|
||||
|
||||
export const existsSync = () => false;
|
||||
export const readFileSync = () => new Uint8Array(0);
|
||||
export const writeFileSync = () => {};
|
||||
export const readdirSync = () => [];
|
||||
export const mkdirSync = () => {};
|
||||
export const rmSync = () => {};
|
||||
export const mkdtempSync = () => "/tmp/fixture-stub";
|
||||
export const appendFileSync = () => {};
|
||||
export const statSync = () => ({
|
||||
isDirectory: () => false,
|
||||
isFile: () => true,
|
||||
size: 0,
|
||||
});
|
||||
|
||||
const pathApi = {
|
||||
join: (...parts) => parts.join("/"),
|
||||
resolve: (...parts) => parts.join("/"),
|
||||
dirname: (value) => value.split("/").slice(0, -1).join("/"),
|
||||
basename: (value) => value.split("/").pop() ?? "",
|
||||
extname: (value) => {
|
||||
const base = value.split("/").pop() ?? "";
|
||||
const index = base.lastIndexOf(".");
|
||||
return index > 0 ? base.slice(index) : "";
|
||||
},
|
||||
relative: (_from, to) => to,
|
||||
normalize: (value) => value,
|
||||
isAbsolute: (value) => value.startsWith("/"),
|
||||
sep: "/",
|
||||
delimiter: ":",
|
||||
};
|
||||
|
||||
export const {
|
||||
join,
|
||||
resolve,
|
||||
dirname,
|
||||
basename,
|
||||
extname,
|
||||
relative,
|
||||
normalize,
|
||||
isAbsolute,
|
||||
sep,
|
||||
delimiter,
|
||||
} = pathApi;
|
||||
|
||||
export const path = pathApi;
|
||||
|
||||
const osApi = {
|
||||
hostname: () => "localhost",
|
||||
tmpdir: () => "/tmp",
|
||||
platform: () => "linux",
|
||||
homedir: () => "/tmp",
|
||||
};
|
||||
|
||||
export const { hostname, tmpdir, platform, homedir } = osApi;
|
||||
|
||||
export const promisify = (fn) => fn;
|
||||
export const isIP = () => 0;
|
||||
export const lookup = async () => ({ address: "127.0.0.1", family: 4 });
|
||||
export const execFile = () => {};
|
||||
export const execFileSync = () => "";
|
||||
export const spawnSync = () => ({ status: 0, stdout: "", stderr: "" });
|
||||
export const fileURLToPath = (value) => String(value);
|
||||
export const createServer = () => ({
|
||||
listen: () => {},
|
||||
close: () => {},
|
||||
on: () => {},
|
||||
address: () => ({ port: 0 }),
|
||||
});
|
||||
|
||||
// Single default export: all node:* aliases point at this one file, and the
|
||||
// merged object keeps `import crypto from "node:crypto"`, `import path from
|
||||
// "node:path"` and `import os from "node:os"` usable after aliasing.
|
||||
export default {
|
||||
...cryptoApi,
|
||||
...pathApi,
|
||||
...osApi,
|
||||
appendFile,
|
||||
mkdir,
|
||||
readdir,
|
||||
readFile,
|
||||
rename,
|
||||
stat,
|
||||
unlink,
|
||||
writeFile,
|
||||
rm,
|
||||
mkdtemp,
|
||||
promises,
|
||||
existsSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
readdirSync,
|
||||
mkdirSync,
|
||||
rmSync,
|
||||
mkdtempSync,
|
||||
appendFileSync,
|
||||
statSync,
|
||||
promisify,
|
||||
isIP,
|
||||
lookup,
|
||||
execFile,
|
||||
execFileSync,
|
||||
spawnSync,
|
||||
fileURLToPath,
|
||||
createServer,
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
// Minimal `process` shim injected into the UI fixture bundle. Server-only
|
||||
// modules that leak into the bundle reference the `process` global at module
|
||||
// scope (release ids, paths, etc). esbuild's `inject` rewrites those free
|
||||
// identifier references to import from this file so evaluation cannot crash
|
||||
// in the browser.
|
||||
|
||||
export const process = {
|
||||
env: { NODE_ENV: "production" },
|
||||
platform: "linux",
|
||||
arch: "x64",
|
||||
version: "v20.0.0",
|
||||
versions: { node: "20.0.0" },
|
||||
argv: [],
|
||||
cwd: () => "/",
|
||||
nextTick: (fn, ...args) => setTimeout(() => fn(...args), 0),
|
||||
on: () => {},
|
||||
once: () => {},
|
||||
emit: () => {},
|
||||
exit: () => {},
|
||||
hrtime: Object.assign(() => [0, 0], { bigint: () => 0n }),
|
||||
memoryUsage: () => ({ heapUsed: 0, rss: 0 }),
|
||||
pid: 0,
|
||||
};
|
||||
|
||||
export default process;
|
||||
+78
-30
@@ -1,23 +1,42 @@
|
||||
import AxeBuilder from "@axe-core/playwright";
|
||||
import { expect, type Page, test } from "@playwright/test";
|
||||
import { test as base, expect, type Page } from "@playwright/test";
|
||||
|
||||
type Fixtures = { visualSnapshots: boolean };
|
||||
|
||||
// Visual baselines are committed for Chromium only; Firefox and WebKit are
|
||||
// excluded because their pixel output is not expected to match the Chromium
|
||||
// baselines. Functional and accessibility assertions still run everywhere.
|
||||
const test = base.extend<Fixtures>({
|
||||
visualSnapshots: async ({ browserName }, use) => {
|
||||
await use(browserName === "chromium");
|
||||
},
|
||||
});
|
||||
|
||||
async function accessible(page: Page, sandboxPreview = false) {
|
||||
const builder = new AxeBuilder({ page });
|
||||
// A sandbox without allow-scripts cannot run axe; its accessible name is asserted separately.
|
||||
if (sandboxPreview) builder.exclude("iframe[sandbox]");
|
||||
const result = await builder
|
||||
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
|
||||
.analyze();
|
||||
expect(
|
||||
result.violations.map(({ id, impact, nodes }) => ({
|
||||
id,
|
||||
impact,
|
||||
nodes: nodes.map((node) => ({
|
||||
target: node.target,
|
||||
summary: node.failureSummary,
|
||||
})),
|
||||
})),
|
||||
).toEqual([]);
|
||||
// WebKit can briefly report stale colors (e.g. dark-on-dark contrast)
|
||||
// right after interactions while style invalidation settles, so poll
|
||||
// until the audit is clean or the timeout expires.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const builder = new AxeBuilder({ page });
|
||||
// A sandbox without allow-scripts cannot run axe; its accessible name is asserted separately.
|
||||
if (sandboxPreview) builder.exclude("iframe[sandbox]");
|
||||
const result = await builder
|
||||
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
|
||||
.analyze();
|
||||
return result.violations.map(({ id, impact, nodes }) => ({
|
||||
id,
|
||||
impact,
|
||||
nodes: nodes.map((node) => ({
|
||||
target: node.target,
|
||||
summary: node.failureSummary,
|
||||
})),
|
||||
}));
|
||||
},
|
||||
{ timeout: 15000 },
|
||||
)
|
||||
.toEqual([]);
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page, baseURL, request }) => {
|
||||
@@ -35,6 +54,7 @@ test.beforeEach(async ({ page, baseURL, request }) => {
|
||||
|
||||
test("settings switches have names and remain keyboard operable", async ({
|
||||
page,
|
||||
visualSnapshots,
|
||||
}) => {
|
||||
await page.goto("/admin/settings");
|
||||
const toggle = page.getByRole("switch", {
|
||||
@@ -46,8 +66,10 @@ test("settings switches have names and remain keyboard operable", async ({
|
||||
await page.keyboard.press("Space");
|
||||
await expect(toggle).toBeChecked();
|
||||
await accessible(page);
|
||||
const group = page.locator("section").filter({ has: toggle });
|
||||
await expect(group).toHaveScreenshot("settings-switches.png");
|
||||
if (visualSnapshots) {
|
||||
const group = page.locator("section").filter({ has: toggle });
|
||||
await expect(group).toHaveScreenshot("settings-switches.png");
|
||||
}
|
||||
const response = page.waitForResponse((response) =>
|
||||
response.url().endsWith("/fixture/actions/settings"),
|
||||
);
|
||||
@@ -62,6 +84,7 @@ test("settings switches have names and remain keyboard operable", async ({
|
||||
|
||||
test("article form and preview dialog pass accessibility with focus return", async ({
|
||||
page,
|
||||
visualSnapshots,
|
||||
}) => {
|
||||
await page.goto("/admin/articles/new");
|
||||
await expect(page.locator("iframe.tox-edit-area__iframe")).toBeVisible();
|
||||
@@ -81,27 +104,47 @@ test("article form and preview dialog pass accessibility with focus return", asy
|
||||
await expect(dialog).toHaveAccessibleName("Article preview");
|
||||
await expect(dialog.locator("iframe")).toHaveAttribute("title", /.+/);
|
||||
await accessible(page, true);
|
||||
await expect(dialog).toHaveScreenshot("article-preview.png");
|
||||
if (visualSnapshots) {
|
||||
await expect(dialog).toHaveScreenshot("article-preview.png");
|
||||
}
|
||||
// Keyboard events inside the sandbox do not bubble to the parent dialog.
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(
|
||||
dialog.getByRole("button", { name: "Close", exact: true }),
|
||||
).toBeFocused();
|
||||
// Wait until the dialog transferred focus to the preview iframe, then Tab
|
||||
// must move focus to the dialog's Close button. Firefox can swallow a Tab
|
||||
// while the sandboxed srcdoc frame is still loading, so retry until the
|
||||
// focus lands where it belongs instead of racing the frame load.
|
||||
const previewFrame = dialog.locator("iframe");
|
||||
await expect(previewFrame).toBeFocused();
|
||||
const closeButton = dialog.getByRole("button", {
|
||||
name: "Close",
|
||||
exact: true,
|
||||
});
|
||||
await expect
|
||||
.poll(async () => {
|
||||
await page.keyboard.press("Tab");
|
||||
return closeButton.evaluate((el) => el === document.activeElement);
|
||||
})
|
||||
.toBe(true);
|
||||
await expect(closeButton).toBeFocused();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(dialog).not.toBeVisible();
|
||||
await expect(preview).toBeFocused();
|
||||
await expect(page.getByRole("main")).toHaveScreenshot("article-form.png");
|
||||
if (visualSnapshots) {
|
||||
await expect(page.getByRole("main")).toHaveScreenshot("article-form.png");
|
||||
}
|
||||
});
|
||||
|
||||
test("support table filters and saved-view popover stay accessible", async ({
|
||||
page,
|
||||
visualSnapshots,
|
||||
}) => {
|
||||
await page.goto("/admin/tickets/desk");
|
||||
await expect(
|
||||
page.getByRole("link", { name: "Account assistance" }),
|
||||
).toBeVisible();
|
||||
await accessible(page);
|
||||
await expect(page.getByRole("main")).toHaveScreenshot("ticket-table.png");
|
||||
if (visualSnapshots) {
|
||||
await expect(page.getByRole("main")).toHaveScreenshot("ticket-table.png");
|
||||
}
|
||||
await page
|
||||
.getByRole("button", { name: "Assigned to me", exact: true })
|
||||
.click();
|
||||
@@ -109,13 +152,16 @@ test("support table filters and saved-view popover stay accessible", async ({
|
||||
await page.getByRole("button", { name: "Saved views", exact: true }).click();
|
||||
await expect(page.getByRole("textbox", { name: "View name" })).toBeVisible();
|
||||
await accessible(page);
|
||||
await expect(page.getByRole("main")).toHaveScreenshot(
|
||||
"ticket-saved-views.png",
|
||||
);
|
||||
if (visualSnapshots) {
|
||||
await expect(page.getByRole("main")).toHaveScreenshot(
|
||||
"ticket-saved-views.png",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("queued synchronization survives reload without duplicate submission", async ({
|
||||
page,
|
||||
visualSnapshots,
|
||||
}) => {
|
||||
await page.goto("/admin/import/official");
|
||||
const requests: string[] = [];
|
||||
@@ -134,7 +180,9 @@ test("queued synchronization survives reload without duplicate submission", asyn
|
||||
await page.locator("details details > summary").first().click();
|
||||
await expect(page.getByText("fixture_chair", { exact: true })).toBeVisible();
|
||||
await accessible(page);
|
||||
await expect(page.getByRole("main")).toHaveScreenshot("sync-history.png");
|
||||
if (visualSnapshots) {
|
||||
await expect(page.getByRole("main")).toHaveScreenshot("sync-history.png");
|
||||
}
|
||||
});
|
||||
|
||||
test("article editor fits a narrow mobile card without horizontal overflow", async ({
|
||||
|
||||
+24
-1
@@ -158,7 +158,15 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
|
||||
experimental: {
|
||||
optimizePackageImports: ["lucide-react", "date-fns"],
|
||||
// Barrel-heavy packages: without this every import pulls the whole
|
||||
// module graph into the chunk that first touches it.
|
||||
optimizePackageImports: [
|
||||
"lucide-react",
|
||||
"motion",
|
||||
"@dnd-kit/core",
|
||||
"@dnd-kit/sortable",
|
||||
"@tanstack/react-virtual",
|
||||
],
|
||||
useTypeScriptCli: true,
|
||||
hideLogsAfterAbort: true,
|
||||
},
|
||||
@@ -167,6 +175,21 @@ const nextConfig: NextConfig = {
|
||||
formats: ["image/avif", "image/webp"],
|
||||
},
|
||||
|
||||
// The SWF/Nitro image pipeline (src/lib/services/swf, src/lib/services/image-*)
|
||||
// pulls shared helpers into several server chunks that webpack reports as a
|
||||
// circular dependency between chunks with runtime. The cycle only affects
|
||||
// server-side module caching of already-loaded chunks and has no runtime
|
||||
// impact, so suppress the noisy warning instead of refactoring the shared
|
||||
// helpers (which would risk the hot image path). Revisit if a real deadlock
|
||||
// appears.
|
||||
webpack(config) {
|
||||
config.ignoreWarnings = [
|
||||
...(config.ignoreWarnings ?? []),
|
||||
/Circular dependency between chunks with runtime/,
|
||||
];
|
||||
return config;
|
||||
},
|
||||
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
|
||||
+22
-20
@@ -3,7 +3,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=26.10.0 <27"
|
||||
"node": ">=26.12.1 <27"
|
||||
},
|
||||
"packageManager": "[email protected]+sha512.25a51652a196785af5f28bda6dfe8dc582ebefa4ac1d180221ce1390231c5fb76637822ea4b6e4633ce74acdcb0a77f725af11bad38177fdeddaee818a955cb9",
|
||||
"scripts": {
|
||||
@@ -18,8 +18,8 @@
|
||||
"format": "biome format --write .",
|
||||
"diag:permissions": "tsx scripts/diagnose-permission-page.ts",
|
||||
"jobs:worker": "node --conditions=react-server --import tsx scripts/jobs-worker.ts",
|
||||
"test": "bash scripts/with-memory-cap.sh 8g vitest run --coverage.enabled=false",
|
||||
"test:coverage": "bash scripts/with-memory-cap.sh 8g vitest run",
|
||||
"test": "bash scripts/with-memory-cap.sh 8g vitest run",
|
||||
"test:coverage": "bash scripts/with-memory-cap.sh 8g vitest run --coverage.enabled=true",
|
||||
"typecheck": "bash scripts/with-memory-cap.sh 6g tsc --noEmit",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:introspect": "drizzle-kit introspect",
|
||||
@@ -29,10 +29,12 @@
|
||||
"db:schema:generate": "node scripts/generate-drizzle-schema.mjs",
|
||||
"db:migrate": "tsx scripts/apply-migrations.ts",
|
||||
"db:migrate:status": "tsx scripts/apply-migrations.ts --status",
|
||||
"catalog:sync": "bash scripts/catalog-sync.sh",
|
||||
"catalog:sync:cli": "node --conditions=react-server --import tsx scripts/catalog-sync.ts",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"gamedata:compress": "node scripts/compress-gamedata.mjs",
|
||||
"hk:matrix:check": "tsx scripts/verify-housekeeping-matrix.ts",
|
||||
"test:housekeeping": "bash scripts/with-memory-cap.sh 8g vitest run --coverage.enabled=false src/features/housekeeping src/lib/admin-theme-source-audit.test.ts src/lib/admin/authorization-contract.test.ts",
|
||||
"test:housekeeping": "bash scripts/with-memory-cap.sh 8g vitest run src/features/housekeeping src/lib/admin-theme-source-audit.test.ts src/lib/admin/authorization-contract.test.ts",
|
||||
"assets:editor": "node scripts/copy-editor-assets.mjs",
|
||||
"deps:audit": "pnpm audit --audit-level=high",
|
||||
"analyze": "pnpm assets:editor && bash scripts/with-memory-cap.sh 10g next build --webpack && node scripts/performance-report.mjs --output-dir build-reports",
|
||||
@@ -53,10 +55,10 @@
|
||||
"@dnd-kit/core": "6.3.1",
|
||||
"@dnd-kit/sortable": "10.0.0",
|
||||
"@dnd-kit/utilities": "3.2.2",
|
||||
"@formatjs/icu-messageformat-parser": "3.5.21",
|
||||
"@hookform/resolvers": "5.9.1",
|
||||
"@tanstack/react-query": "5.104.3",
|
||||
"@tanstack/react-virtual": "3.14.19",
|
||||
"@formatjs/icu-messageformat-parser": "3.5.22",
|
||||
"@hookform/resolvers": "6.0.0",
|
||||
"@tanstack/react-query": "5.104.5",
|
||||
"@tanstack/react-virtual": "3.14.22",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
"cmdk": "1.1.1",
|
||||
@@ -64,16 +66,16 @@
|
||||
"drizzle-orm": "0.45.4",
|
||||
"hash-wasm": "4.12.0",
|
||||
"ioredis": "6.0.0",
|
||||
"isomorphic-dompurify": "^4.5.0",
|
||||
"isomorphic-dompurify": "^4.6.0",
|
||||
"jpeg-js": "0.4.4",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"jszip": "3.10.2",
|
||||
"lucide-react": "1.57.0",
|
||||
"lucide-react": "1.61.0",
|
||||
"lzma-wasm": "1.0.7",
|
||||
"motion": "14.2.0",
|
||||
"music-metadata": "12.2.1",
|
||||
"motion": "14.6.0",
|
||||
"music-metadata": "12.2.3",
|
||||
"mysql2": "3.24.6",
|
||||
"next": "16.4.1",
|
||||
"next": "16.4.2",
|
||||
"next-auth": "5.0.0-beta.32",
|
||||
"next-intl": "4.14.9",
|
||||
"otplib": "13.5.0",
|
||||
@@ -81,7 +83,7 @@
|
||||
"react": "19.3.1",
|
||||
"react-dom": "19.3.1",
|
||||
"react-hook-form": "7.90.0",
|
||||
"resend": "6.33.0",
|
||||
"resend": "6.34.1",
|
||||
"server-only": "0.0.1",
|
||||
"sharp": "^0.35.5",
|
||||
"sonner": "2.0.8",
|
||||
@@ -90,9 +92,9 @@
|
||||
"zod": "4.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "4.13.0",
|
||||
"@axe-core/playwright": "4.14.0",
|
||||
"@babel/parser": "8.0.7",
|
||||
"@biomejs/biome": "2.5.15",
|
||||
"@biomejs/biome": "2.6.2",
|
||||
"@playwright/test": "1.64.1",
|
||||
"@tailwindcss/forms": "0.5.11",
|
||||
"@tailwindcss/postcss": "4.3.3",
|
||||
@@ -100,17 +102,17 @@
|
||||
"@types/node": "26.6.5",
|
||||
"@types/react": "19.3.0",
|
||||
"@types/react-dom": "19.3.0",
|
||||
"@vitest/coverage-v8": "5.0.4",
|
||||
"@vitest/coverage-v8": "5.0.5",
|
||||
"drizzle-kit": "0.31.11",
|
||||
"esbuild": "0.28.2",
|
||||
"msw": "^3.0.3",
|
||||
"pino-pretty": "13.2.0",
|
||||
"postcss": "8.5.30",
|
||||
"postcss": "8.5.31",
|
||||
"tailwindcss": "4.3.3",
|
||||
"testcontainers": "12.2.1",
|
||||
"tsx": "4.23.15",
|
||||
"typescript": "7.0.2",
|
||||
"vite": "8.3.4",
|
||||
"vitest": "5.0.4"
|
||||
"vite": "8.3.5",
|
||||
"vitest": "5.0.5"
|
||||
}
|
||||
}
|
||||
+26
-9
@@ -1,22 +1,39 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
// Deploy gate: keep this suite deterministic and light. One worker, one browser
|
||||
// engine, no live dev server and no parallelism — that is what keeps the host
|
||||
// from pinning at 100% CPU while the specs run.
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
testIgnore: ["**/ui/**", "**/news-real/**", "**/quality/**"],
|
||||
testIgnore: ["**/ui/**", "**/imaging/**", "**/news-real/**", "**/quality/**"],
|
||||
forbidOnly: !!process.env.CI,
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
retries: 1,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
timeout: 30000,
|
||||
expect: { timeout: 10000 },
|
||||
globalTimeout: 10 * 60 * 1000,
|
||||
maxFailures: process.env.CI ? 10 : undefined,
|
||||
reporter: [["list"], ["html", { open: "never" }]],
|
||||
expect: {
|
||||
toHaveScreenshot: {
|
||||
animations: "disabled",
|
||||
caret: "hide",
|
||||
maxDiffPixelRatio: 0.02,
|
||||
},
|
||||
},
|
||||
use: {
|
||||
baseURL: process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:3002",
|
||||
trace: "retain-on-failure",
|
||||
screenshot: "only-on-failure",
|
||||
video: "off",
|
||||
launchOptions: {
|
||||
args: [
|
||||
"--disable-gpu",
|
||||
"--disable-software-rasterizer",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-background-networking",
|
||||
"--disable-default-apps",
|
||||
"--disable-extensions",
|
||||
"--disable-sync",
|
||||
"--metrics-recording-only",
|
||||
"--mute-audio",
|
||||
"--no-first-run",
|
||||
],
|
||||
},
|
||||
},
|
||||
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
|
||||
});
|
||||
+31
-7
@@ -1,8 +1,24 @@
|
||||
import { randomInt } from "node:crypto";
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
const port = Number(process.env.UI_TEST_PORT || 3107);
|
||||
if (!Number.isInteger(port) || port < 1024 || port > 65535)
|
||||
throw Error("Invalid UI_TEST_PORT");
|
||||
// The fixture server binds a loopback port. The CI runner packs several jobs
|
||||
// onto the shared host network (capacity > 1), so a fixed port makes concurrent
|
||||
// runs fight over the bind: one wins, the other's browser then gets
|
||||
// "connection refused". Pick one random high port for the whole run and pass it
|
||||
// to every worker through the environment, unless a port is pinned explicitly
|
||||
// (e.g. a developer reusing a manually started fixture server).
|
||||
function fixturePort() {
|
||||
const requested = Number(process.env.UI_TEST_PORT);
|
||||
if (Number.isInteger(requested) && requested >= 1024 && requested <= 65535)
|
||||
return requested;
|
||||
const shared = Number(process.env.PW_UI_TEST_PORT);
|
||||
if (Number.isInteger(shared) && shared >= 1024 && shared <= 65535)
|
||||
return shared;
|
||||
const port = randomInt(30000, 50000);
|
||||
process.env.PW_UI_TEST_PORT = String(port);
|
||||
return port;
|
||||
}
|
||||
const port = fixturePort();
|
||||
const baseURL = `http://127.0.0.1:${port}`;
|
||||
export default defineConfig({
|
||||
testDir: "./e2e/ui",
|
||||
@@ -39,11 +55,11 @@ export default defineConfig({
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "desktop",
|
||||
name: "desktop-chromium",
|
||||
use: { browserName: "chromium", viewport: { width: 1280, height: 900 } },
|
||||
},
|
||||
{
|
||||
name: "mobile",
|
||||
name: "mobile-chromium",
|
||||
use: {
|
||||
browserName: "chromium",
|
||||
viewport: { width: 390, height: 844 },
|
||||
@@ -52,12 +68,20 @@ export default defineConfig({
|
||||
hasTouch: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "desktop-firefox",
|
||||
use: { browserName: "firefox", viewport: { width: 1280, height: 900 } },
|
||||
},
|
||||
{
|
||||
name: "desktop-webkit",
|
||||
use: { browserName: "webkit", viewport: { width: 1280, height: 900 } },
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: "node e2e/ui/fixture-server.mjs",
|
||||
url: `${baseURL}/health`,
|
||||
timeout: 60000,
|
||||
reuseExistingServer: false,
|
||||
timeout: 120000,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
env: { UI_TEST_PORT: String(port) },
|
||||
},
|
||||
});
|
||||
Generated
+453
-425
File diff suppressed because it is too large.
Load diff
+8
-8
@@ -1,9 +1,9 @@
|
||||
packages:
|
||||
- '.'
|
||||
- "."
|
||||
|
||||
allowBuilds:
|
||||
'@parcel/watcher': true
|
||||
'@swc/core': true
|
||||
"@parcel/watcher": true
|
||||
"@swc/core": true
|
||||
cpu-features: true
|
||||
esbuild: true
|
||||
msw: true
|
||||
@@ -13,12 +13,12 @@ allowBuilds:
|
||||
minimumReleaseAge: 60
|
||||
|
||||
overrides:
|
||||
glob: '^11.0.0'
|
||||
'@esbuild-kit/core-utils': 'npm:tsx@^4.23.15'
|
||||
'@esbuild-kit/esm-loader': 'npm:tsx@^4.23.15'
|
||||
source-map-js: '1.2.2'
|
||||
glob: "^11.0.0"
|
||||
"@esbuild-kit/core-utils": "npm:tsx@^4.23.15"
|
||||
"@esbuild-kit/esm-loader": "npm:tsx@^4.23.15"
|
||||
source-map-js: "1.2.2"
|
||||
# GHSA advisory: postcss-selector-parser <7.1.6. Pulled in only by
|
||||
# @tailwindcss/typography (build-time prose selectors), which pins 6.0.10
|
||||
# exactly and has no upstream bump. The API subset it uses is unchanged in
|
||||
# v7, so force the patched major.
|
||||
postcss-selector-parser: '^7.1.6'
|
||||
postcss-selector-parser: "^7.1.6"
|
||||
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": [
|
||||
"config:recommended",
|
||||
":timezone(Europe/Amsterdam)",
|
||||
":semanticCommits",
|
||||
"workarounds:all"
|
||||
],
|
||||
"labels": ["dependencies"],
|
||||
"dependencyDashboard": true,
|
||||
"dependencyDashboardOSVVulnerabilitySummary": "all",
|
||||
"osvVulnerabilityAlerts": true,
|
||||
"configMigration": true,
|
||||
"automergeStrategy": "squash",
|
||||
"schedule": [
|
||||
"after 4:00 am and before 5:00 am on monday through friday",
|
||||
"at 4:17 am on monday through friday"
|
||||
],
|
||||
"timezone": "Europe/Amsterdam",
|
||||
"prConcurrentLimit": 5,
|
||||
"prHourlyLimit": 3,
|
||||
"minimumReleaseAge": "3 days",
|
||||
"lockFileMaintenance": {
|
||||
"enabled": true
|
||||
},
|
||||
"packageRules": [
|
||||
{
|
||||
"description": "Minor en patch: één groeps-PR, automerge pas na groene CI",
|
||||
"matchUpdateTypes": ["minor", "patch", "pin", "digest"],
|
||||
"groupName": "all non-major dependencies",
|
||||
"automerge": true,
|
||||
"platformAutomerge": false
|
||||
},
|
||||
{
|
||||
"description": "Major updates vragen goedkeuring via het dependency-dashboard",
|
||||
"matchUpdateTypes": ["major"],
|
||||
"labels": ["major"],
|
||||
"dependencyDashboardApproval": true
|
||||
},
|
||||
{
|
||||
"description": "Beveiligingsfixes (OSV) niet tegenhouden door de wachttijd en in een eigen PR",
|
||||
"matchCategories": ["security"],
|
||||
"minimumReleaseAge": null,
|
||||
"groupName": "security fixes",
|
||||
"addLabels": ["security"]
|
||||
},
|
||||
{
|
||||
"description": "Docker-compose draait op :latest; geen digest-pin-ruis",
|
||||
"matchManagers": ["docker-compose"],
|
||||
"pinDigests": false
|
||||
},
|
||||
{
|
||||
"description": "Node-upgrades worden handmatig gecoördineerd over .nvmrc, package.json engines, @types/node, de Docker-basisimage en de runner; Renovate mag .nvmrc niet los bumpen (check-node-toolchain faalt anders)",
|
||||
"matchManagers": ["nvm"],
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"description": "De Node-basisimage in de Dockerfile hoort bij dezelfde gecoördineerde Node-upgrade als .nvmrc",
|
||||
"matchManagers": ["dockerfile"],
|
||||
"matchPackageNames": ["node"],
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"description": "De pnpm packageManager-pin blijft handmatig: Docker (corepack) en de runner volgen de pin automatisch, maar een bump raakt ook de lockfile en de README-versietabel (scripts/sync-readme-versions.mjs)",
|
||||
"matchPackageNames": ["pnpm"],
|
||||
"enabled": false
|
||||
}
|
||||
]
|
||||
}
|
||||
+86
-20
@@ -4,29 +4,31 @@ This directory contains utility scripts for managing the EpicNext-Cms project.
|
||||
|
||||
## Scripts Overview
|
||||
|
||||
| Script | Description |
|
||||
| :--- | :--- |
|
||||
| `dashboard.sh` | Main interactive menu to run all other scripts. |
|
||||
| `deploy.sh` | Safely redeploys the application by freeing the port first. |
|
||||
| `logs.sh` | View logs for cms and mariadb services. |
|
||||
| `backup.sh` | Creates a database backup in `/backups`. |
|
||||
| `db-restore.sh` | Restores a database backup. |
|
||||
| `db-optimize.sh` | Optimizes database tables. |
|
||||
| `docker-prune.sh` | Cleans up unused Docker resources. |
|
||||
| `verify-deploy.sh` | Checks if the CMS is reachable after deployment. |
|
||||
| `check-env.sh` | Validates `.env` against `.env.example`. |
|
||||
| `check-security.sh` | Runs `pnpm audit` and checks for image updates. |
|
||||
| `monitor.sh` | Shows system disk/container resource usage. |
|
||||
| `perf-report.sh` | Runs performance profiling. |
|
||||
| `setup-dev.sh` | Configures development environment. |
|
||||
| `check-updates.sh` | Checks for package and image updates. |
|
||||
| Script | Description |
|
||||
| :------------------------- | :---------------------------------------------------------------- |
|
||||
| `dashboard.sh` | Main interactive menu to run all other scripts. |
|
||||
| `deploy.sh` | Safely redeploys the application by freeing the port first. |
|
||||
| `logs.sh` | View logs for cms and mariadb services. |
|
||||
| `backup.sh` | Creates a database backup in `/backups`. |
|
||||
| `db-restore.sh` | Restores a database backup. |
|
||||
| `db-optimize.sh` | Optimizes database tables. |
|
||||
| `docker-prune.sh` | Cleans up unused Docker resources. |
|
||||
| `verify-deploy.sh` | Checks if the CMS is reachable after deployment. |
|
||||
| `check-env.sh` | Validates `.env` against `.env.example`. |
|
||||
| `check-security.sh` | Runs `pnpm audit` and checks for image updates. |
|
||||
| `monitor.sh` | Shows system disk/container resource usage. |
|
||||
| `perf-report.sh` | Runs performance profiling. |
|
||||
| `setup-dev.sh` | Configures development environment. |
|
||||
| `check-updates.sh` | Checks for package and image updates. |
|
||||
| `sync-readme-versions.mjs` | Syncs the versions in `README.md` with `package.json` / `.nvmrc`. |
|
||||
| `maintenance.sh` | Toggles maintenance mode flag. |
|
||||
| `doctor.sh` | Runs a system diagnostic report. |
|
||||
| `setup-cron.sh` | Configures automated cron jobs. |
|
||||
| `alert.sh` | Sends notifications to a configured webhook. |
|
||||
| `maintenance.sh` | Toggles maintenance mode flag. |
|
||||
| `doctor.sh` | Runs a system diagnostic report. |
|
||||
| `setup-cron.sh` | Configures automated cron jobs. |
|
||||
| `alert.sh` | Sends notifications to a configured webhook. |
|
||||
| `catalog-sync.sh` | Interactive catalog update against a clone source (see below). |
|
||||
|
||||
## Usage
|
||||
|
||||
Run any script directly, or use the dashboard:
|
||||
```bash
|
||||
./scripts/dashboard.sh
|
||||
@@ -46,3 +48,67 @@ It manages the `pnpm` and `Node.js` rows of the System Requirements table and
|
||||
the "Core stack" line. The CI `check` job runs the `--check` variant, so a
|
||||
version change cannot reach `main` with a stale README — the pipeline fails
|
||||
until you run `pnpm readme:versions`.
|
||||
|
||||
### Updating the catalog (`catalog-sync.sh`)
|
||||
|
||||
Compares this hotel's catalog against a clone source (Habbo and the retros) and
|
||||
fixes what is broken: missing furniture icons, missing `.nitro` bundles,
|
||||
`FurnitureData.json` drift, orphaned/duplicate catalog rows, and items that
|
||||
exist in `items_base` but have no catalog offer.
|
||||
|
||||
```bash
|
||||
pnpm catalog:sync # interactive menu
|
||||
./scripts/catalog-sync.sh --list-sources # which sites can be compared
|
||||
./scripts/catalog-sync.sh --all --sources=default-habbo-com
|
||||
./scripts/catalog-sync.sh --all --sql --apply-sql # catalogs changes are written
|
||||
```
|
||||
|
||||
The menu asks which sources to compare (Habbo (COM) is the preset) and what may
|
||||
be written. With no arguments it needs a terminal; with any flag it skips the
|
||||
menu, so it is usable from cron.
|
||||
|
||||
Every step the engine exposes is available as a flag:
|
||||
|
||||
| Flag | What it does |
|
||||
| :--------------------------------------------- | :------------------------------------------------------------------------- |
|
||||
| `--sources=<a,b>` | Compare against these sources (id or display name) |
|
||||
| `--icons` | Re-download missing furniture icons |
|
||||
| `--nitros` | Re-download missing bundles (stored as `.hab`) |
|
||||
| `--furnidata` | Repair `FurnitureData.json` (add missing, drop duplicates) |
|
||||
| `--structure` | Repair orphaned / duplicate catalog rows |
|
||||
| `--check-ids` | Check `FurnitureData.json` for spriteId conflicts |
|
||||
| `--sql` / `--apply-sql` | Generate / apply the catalog SQL |
|
||||
| `--organize-sql` | Also emit the `catalog_pages` INSERTs into the SQL file |
|
||||
| `--import-new` | Import furniture the source has and this hotel lacks |
|
||||
| `--only=<a,b>`, `--limit=N`, `--concurrency=N` | Narrow and tune the import |
|
||||
| `--reconcile` | Align `FurnitureData.json` with `items_base` after importing |
|
||||
| `--fix-sprite-ids` | Correct `items_base.sprite_id` drift |
|
||||
| `--rebuild-offers` | Rebuild catalog `offer_id` mirrors |
|
||||
| `--align-ids` | Move `items_base` ids onto their furnidata ids (report; `--apply` runs it) |
|
||||
| `--rename-bundles` | Rename legacy `.nitro` bundles to `.hab` (report; `--apply` runs it) |
|
||||
| `--everything` | Every repair + import step, but still none of the destructive ones |
|
||||
|
||||
`--import-new` is the step that actually adds furniture: `cloneSingleFurni`
|
||||
downloads the icon and the bundle (sources serve `.nitro`, the local copy is
|
||||
stored as `.hab`), appends the `FurnitureData.json` entry **and** creates the
|
||||
catalog page plus the offer, exactly like the app's own import. It skips
|
||||
anything already present, so it is safe to re-run.
|
||||
|
||||
Safety notes:
|
||||
- **An audit writes nothing** — no repair flag means read-only.
|
||||
- `--dry-run` together with `--import-new` lists what would be imported and
|
||||
imports nothing; use it first on a source before importing thousands of items.
|
||||
- Generating the catalog SQL creates the `catalog_pages` sub-pages it references
|
||||
(same as the app's import), so a "generate only" run is not entirely
|
||||
read-only — only the `catalog_items` rows wait for `--apply-sql`. The SQL also
|
||||
lands in `storage/catalog-sync/*.sql` (gitignored) so it can be reviewed or
|
||||
replayed by hand.
|
||||
- `--apply-sql` is the only flag that changes the catalog; `--apply` authorises
|
||||
the two heavyweight child scripts (`align-db-ids`, `migrate-nitro-to-hab`).
|
||||
Both only report without it.
|
||||
- The engine is the same one the admin _Catalog audit_ page streams
|
||||
(`runCatalogAudit`), so a terminal run and an in-admin run agree.
|
||||
- `--json` prints one machine-readable report; `--fail-on-errors` exits `3` when
|
||||
error-severity issues remain, which is what a scheduled check should use.
|
||||
- Items that no compared source can provide stay missing: the run reports them
|
||||
instead of inventing an icon or a bundle.
|
||||
Executable
+210
@@ -0,0 +1,210 @@
|
||||
#!/usr/bin/env bash
|
||||
# Update the hotel catalog from a clone source (Habbo, retros, …).
|
||||
#
|
||||
# Two ways to use it:
|
||||
# ./scripts/catalog-sync.sh interactive menu
|
||||
# ./scripts/catalog-sync.sh --all --sql same flags as the CLI, no menu
|
||||
#
|
||||
# The menu asks which sites to compare (Habbo is the preset) and what may be
|
||||
# written. An audit is read-only; only a repair option touches the catalog, and
|
||||
# applying the generated catalog SQL always needs its own choice.
|
||||
|
||||
cd "$(dirname "$0")/.." || exit 1
|
||||
CLI="scripts/catalog-sync.ts"
|
||||
# The react-server export condition resolves `server-only` to its no-op build,
|
||||
# exactly like `pnpm jobs:worker` — the audit engine imports server modules.
|
||||
RUN=(node --conditions=react-server --import tsx "$CLI")
|
||||
|
||||
# Any flag passed straight through means "no menu", so the same entry point
|
||||
# works for cron/CI where only the flag path is safe.
|
||||
if [ $# -gt 0 ]; then
|
||||
exec "${RUN[@]}" "$@"
|
||||
fi
|
||||
|
||||
if [ ! -t 0 ]; then
|
||||
echo "Not a terminal. Use the non-interactive form, e.g.:" >&2
|
||||
echo " $0 --all --sources=default-habbo-com" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== Catalog sync ==="
|
||||
echo
|
||||
|
||||
# ── Step 1: which sites to compare ───────────────────────────────────────────
|
||||
# Ask the CLI for the configured sources so the menu always matches the live
|
||||
# `clone_sources` setting (and its defaults) instead of a hardcoded list.
|
||||
mapfile -t SOURCE_ROWS < <("${RUN[@]}" --list-sources --tsv)
|
||||
if [ "${#SOURCE_ROWS[@]}" -eq 0 ]; then
|
||||
echo "No clone sources are configured." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
IDS=()
|
||||
NAMES=()
|
||||
echo "Compare this catalog against:"
|
||||
for i in "${!SOURCE_ROWS[@]}"; do
|
||||
IFS=$'\t' read -r SOURCE_ID SOURCE_NAME _SOURCE_URL <<<"${SOURCE_ROWS[$i]}"
|
||||
IDS+=("$SOURCE_ID")
|
||||
NAMES+=("$SOURCE_NAME")
|
||||
printf " %2d) %-24s %s\n" "$((i + 1))" "$SOURCE_NAME" "$SOURCE_ID"
|
||||
done
|
||||
echo
|
||||
echo " a) all sources above"
|
||||
echo " Enter) preset: ${NAMES[0]}"
|
||||
|
||||
read -r -p "Sources [a, 1-${#IDS[@]}, Enter]: " SOURCE_CHOICE
|
||||
|
||||
SELECTED_FLAG=""
|
||||
case "$SOURCE_CHOICE" in
|
||||
"" | "Habbo" | "habbo" | "habbo.com")
|
||||
# Habbo is the first default preset (Habbo (COM)).
|
||||
SELECTED_FLAG="--sources=${IDS[0]}"
|
||||
SOURCE_LABEL="${NAMES[0]}"
|
||||
;;
|
||||
a | A | all)
|
||||
SOURCE_LABEL="all sources"
|
||||
;;
|
||||
*)
|
||||
SELECTED_IDS=()
|
||||
IFS=', ' read -r -a CHOICES <<<"$SOURCE_CHOICE"
|
||||
for CHOICE in "${CHOICES[@]}"; do
|
||||
[ -z "$CHOICE" ] && continue
|
||||
case "$CHOICE" in
|
||||
*[!0-9]* | "")
|
||||
echo "Invalid choice: '$CHOICE'" >&2
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
if [ "$CHOICE" -lt 1 ] || [ "$CHOICE" -gt "${#IDS[@]}" ]; then
|
||||
echo "Out of range: $CHOICE" >&2
|
||||
exit 1
|
||||
fi
|
||||
SELECTED_IDS+=("${IDS[$((CHOICE - 1))]}")
|
||||
;;
|
||||
esac
|
||||
done
|
||||
if [ "${#SELECTED_IDS[@]}" -eq 0 ]; then
|
||||
echo "No sources selected." >&2
|
||||
exit 1
|
||||
fi
|
||||
SELECTED_FLAG="--sources=$(IFS=,; echo "${SELECTED_IDS[*]}")"
|
||||
SOURCE_LABEL="${#SELECTED_IDS[@]} source(s)"
|
||||
;;
|
||||
esac
|
||||
|
||||
# ── Step 2: what may be changed ──────────────────────────────────────────────
|
||||
cat <<EOF
|
||||
|
||||
What should be done? (comparing with: $SOURCE_LABEL)
|
||||
1) Audit only — nothing is written
|
||||
2) Repair furniture icons + nitro bundles
|
||||
3) 2 + FurnitureData.json + catalog structure
|
||||
4) 3 + generate the catalog SQL (saved, not applied)
|
||||
5) 4 + APPLY the catalog SQL to the catalog
|
||||
6) 5 + import the furniture the source has and this hotel lacks
|
||||
(downloads icon + bundle, adds FurnitureData.json, creates the catalog
|
||||
page and the offer for every new item)
|
||||
7) 6 + reconcile FurnitureData.json / sprite ids / offer ids
|
||||
8) 7 + align items_base ids and rename legacy .nitro bundles (heaviest)
|
||||
d) Dry run — only report what 6 would import
|
||||
q) Cancel
|
||||
EOF
|
||||
|
||||
read -r -p "Action [1]: " ACTION_CHOICE
|
||||
ACTION_CHOICE="${ACTION_CHOICE:-1}"
|
||||
|
||||
ACTIONS=(audit)
|
||||
CONFIRM=""
|
||||
# Empty for the audit-only branch on purpose: no repair flags are passed.
|
||||
ACTION_FLAGS=()
|
||||
# Prints a warning when the chosen source only serves furnidata (no .nitro
|
||||
# bundles), so the import step would find nothing to download.
|
||||
warn_compare_only_sources() {
|
||||
case "$SOURCE_LABEL" in
|
||||
"Habbo (COM)" | "Habbo (NL)" | "Habbo (DE)" | "Habbo (FR)" | \
|
||||
"Habbo (ES)" | "Habbo (FI)" | "Habbo (BR)" | "Habbo (TR)" | \
|
||||
"Habbo (IT - GitHub mirror)" | "YabboHotel" | "Wibbo" | "Hubba.cc")
|
||||
echo
|
||||
echo "Note: ${SOURCE_LABEL} only serves furnidata — there are no .nitro"
|
||||
echo "bundles to import, so the import step can only COMPARE."
|
||||
echo "To actually import furniture, pick a source that serves bundles"
|
||||
echo "(Habblet City, VirtualCity, SodaStudios, Habboon, Leet, Hubbly) or 'a'."
|
||||
;;
|
||||
esac
|
||||
}
|
||||
case "$ACTION_CHOICE" in
|
||||
1)
|
||||
ACTIONS=(audit "[nothing written]")
|
||||
;;
|
||||
2)
|
||||
ACTIONS=(audit "icons + nitro bundles downloaded")
|
||||
ACTION_FLAGS=(--icons --nitros)
|
||||
;;
|
||||
3)
|
||||
ACTIONS=(audit "icons + nitro bundles + FurnitureData.json + structure")
|
||||
ACTION_FLAGS=(--all)
|
||||
;;
|
||||
4)
|
||||
ACTIONS=(audit "everything + catalog SQL generated")
|
||||
ACTION_FLAGS=(--all --sql)
|
||||
;;
|
||||
5)
|
||||
ACTIONS=(audit "everything + catalog SQL APPLIED")
|
||||
ACTION_FLAGS=(--all --sql --apply-sql)
|
||||
CONFIRM="This writes to the catalog. Continue? [y/N]: "
|
||||
;;
|
||||
6)
|
||||
ACTIONS=(audit "everything + catalog SQL + import new furniture")
|
||||
ACTION_FLAGS=(--all --sql --apply-sql --import-new)
|
||||
CONFIRM="This writes to the catalog AND imports every missing item. Continue? [y/N]: "
|
||||
;;
|
||||
7)
|
||||
ACTIONS=(audit "everything + import + reconcile")
|
||||
ACTION_FLAGS=(
|
||||
--all --sql --apply-sql --import-new --reconcile --fix-sprite-ids
|
||||
--rebuild-offers
|
||||
)
|
||||
CONFIRM="This writes to the catalog AND imports every missing item. Continue? [y/N]: "
|
||||
;;
|
||||
8)
|
||||
ACTIONS=(audit "everything + import + reconcile + id alignment + rename")
|
||||
ACTION_FLAGS=(
|
||||
--all --sql --apply-sql --import-new --reconcile --fix-sprite-ids
|
||||
--rebuild-offers --align-ids --rename-bundles --apply
|
||||
)
|
||||
read -r -p " This rewrites items_base ids and renames bundle files. Type RUN to confirm: " REPLY
|
||||
if [ "$REPLY" != "RUN" ]; then
|
||||
echo "Cancelled."
|
||||
exit 0
|
||||
fi
|
||||
;;
|
||||
d | D)
|
||||
ACTIONS=(audit "report only — nothing is imported")
|
||||
ACTION_FLAGS=(--import-new --dry-run)
|
||||
;;
|
||||
q | Q)
|
||||
echo "Cancelled."
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Invalid action: '$ACTION_CHOICE'" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo
|
||||
printf 'Running: %s against %s\n' "${ACTIONS[1]}" "$SOURCE_LABEL"
|
||||
case "$ACTION_FLAGS" in
|
||||
*--import-new*) warn_compare_only_sources ;;
|
||||
esac
|
||||
if [ -n "$CONFIRM" ]; then
|
||||
read -r -p "$CONFIRM" REPLY
|
||||
if [ "$REPLY" != "y" ] && [ "$REPLY" != "Y" ]; then
|
||||
echo "Cancelled."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
echo
|
||||
# shellcheck disable=SC2086
|
||||
"${RUN[@]}" $SELECTED_FLAG "${ACTION_FLAGS[@]}"
|
||||
@@ -0,0 +1,259 @@
|
||||
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
canImportBundles,
|
||||
DEFAULT_CATALOG_SYNC_FLAGS,
|
||||
describeActions,
|
||||
parseCatalogSyncArgs,
|
||||
selectImportCandidates,
|
||||
writeGeneratedSql,
|
||||
} from "./catalog-sync";
|
||||
|
||||
const entry = (classname: string) =>
|
||||
({ classname, name: classname, itemType: "s" }) as never;
|
||||
|
||||
describe("catalog-sync argument parsing", () => {
|
||||
it("defaults to a read-only audit with no filters", () => {
|
||||
expect(parseCatalogSyncArgs([])).toEqual({
|
||||
...DEFAULT_CATALOG_SYNC_FLAGS,
|
||||
sourceIds: [],
|
||||
});
|
||||
expect(describeActions(parseCatalogSyncArgs([]))).toBe("audit");
|
||||
});
|
||||
|
||||
it("accepts comma-separated source ids", () => {
|
||||
const flags = parseCatalogSyncArgs([
|
||||
"--sources=default-habbo-com,default-wibbo",
|
||||
]);
|
||||
expect(flags.sourceIds).toEqual(["default-habbo-com", "default-wibbo"]);
|
||||
});
|
||||
|
||||
it("accepts display names, including ones with spaces", () => {
|
||||
const flags = parseCatalogSyncArgs(["--sources=Habbo (COM), Wibbo"]);
|
||||
expect(flags.sourceIds).toEqual(["Habbo (COM)", "Wibbo"]);
|
||||
});
|
||||
|
||||
it("trims and drops empty source entries", () => {
|
||||
expect(parseCatalogSyncArgs(["--sources= a , ,b,"]).sourceIds).toEqual([
|
||||
"a",
|
||||
"b",
|
||||
]);
|
||||
});
|
||||
|
||||
it("turns --all into every repair step", () => {
|
||||
const flags = parseCatalogSyncArgs(["--all"]);
|
||||
expect(flags.repairIcons).toBe(true);
|
||||
expect(flags.repairNitros).toBe(true);
|
||||
expect(flags.repairFurniData).toBe(true);
|
||||
expect(flags.repairStructure).toBe(true);
|
||||
expect(flags.checkFurniDataIds).toBe(true);
|
||||
// Generating/applying SQL is never implied by a repair.
|
||||
expect(flags.generateSql).toBe(false);
|
||||
expect(flags.applySql).toBe(false);
|
||||
});
|
||||
|
||||
it("treats applying SQL as implying generation", () => {
|
||||
expect(parseCatalogSyncArgs(["--apply-sql"]).generateSql).toBe(true);
|
||||
expect(parseCatalogSyncArgs(["--organize-sql"]).generateSql).toBe(true);
|
||||
expect(describeActions(parseCatalogSyncArgs(["--apply-sql"]))).toBe(
|
||||
"audit + apply-sql",
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts the alias spellings of the repair flags", () => {
|
||||
const flags = parseCatalogSyncArgs([
|
||||
"--repair-icons",
|
||||
"--repair-nitros",
|
||||
"--repair-furnidata",
|
||||
"--repair-structure",
|
||||
]);
|
||||
expect(flags.repairIcons).toBe(true);
|
||||
expect(flags.repairNitros).toBe(true);
|
||||
expect(flags.repairFurniData).toBe(true);
|
||||
expect(flags.repairStructure).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores a bare -- separator", () => {
|
||||
expect(parseCatalogSyncArgs(["--", "--icons"]).repairIcons).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an unknown flag", () => {
|
||||
expect(() => parseCatalogSyncArgs(["--nope"])).toThrow(/Unknown flag/);
|
||||
});
|
||||
|
||||
it("rejects --sources without a value", () => {
|
||||
expect(() => parseCatalogSyncArgs(["--sources"])).toThrow(/needs a value/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("catalog-sync import selection", () => {
|
||||
it("skips furniture this hotel already has", () => {
|
||||
const candidates = selectImportCandidates(
|
||||
[entry("chair"), entry("table"), entry("") as never],
|
||||
new Set(["chair"]),
|
||||
);
|
||||
expect(candidates.map((c) => c.classname)).toEqual(["table"]);
|
||||
});
|
||||
|
||||
it("is case-insensitive against the present set", () => {
|
||||
const candidates = selectImportCandidates(
|
||||
[entry("Chair"), entry("table")],
|
||||
new Set(["chair"]),
|
||||
);
|
||||
expect(candidates.map((c) => c.classname)).toEqual(["table"]);
|
||||
});
|
||||
|
||||
it("honours the --only filter, also case-insensitively", () => {
|
||||
const candidates = selectImportCandidates(
|
||||
[entry("Chair"), entry("table"), entry("lamp")],
|
||||
new Set(),
|
||||
{ only: ["chair"] },
|
||||
);
|
||||
expect(candidates.map((c) => c.classname)).toEqual(["Chair"]);
|
||||
});
|
||||
|
||||
it("caps the list with --limit", () => {
|
||||
const candidates = selectImportCandidates(
|
||||
[entry("a"), entry("b"), entry("c")],
|
||||
new Set(),
|
||||
{ limit: 2 },
|
||||
);
|
||||
expect(candidates.map((c) => c.classname)).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("returns everything when no limit is given", () => {
|
||||
expect(
|
||||
selectImportCandidates([entry("a"), entry("b")], new Set(), {
|
||||
limit: 0,
|
||||
}).length,
|
||||
).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("catalog-sync import feasibility", () => {
|
||||
it("imports from a source that serves bundles", () => {
|
||||
expect(
|
||||
canImportBundles({
|
||||
id: "default-habblet-city",
|
||||
name: "Habblet City",
|
||||
furnidataUrl: "https://www.habblet.city/gamedata/furnidata.json",
|
||||
nitroBaseUrl: "https://www.habblet.city/nitro/bundled/furniture",
|
||||
iconBaseUrl: "",
|
||||
hotel: "com",
|
||||
} as never),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
// The Habbo presets have no nitro/icon base URL: their furnidata can be
|
||||
// compared, but there is nothing to download from.
|
||||
it("does not import from a compare-only source", () => {
|
||||
expect(
|
||||
canImportBundles({
|
||||
id: "default-habbo-com",
|
||||
name: "Habbo (COM)",
|
||||
furnidataUrl: "https://www.habbo.com/gamedata/furnidata_json/1",
|
||||
nitroBaseUrl: "",
|
||||
iconBaseUrl: "",
|
||||
hotel: "com",
|
||||
} as never),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("catalog-sync step composition", () => {
|
||||
it("--everything turns on every step except the writing ones", () => {
|
||||
const flags = parseCatalogSyncArgs(["--everything"]);
|
||||
expect(describeActions(flags)).toBe(
|
||||
[
|
||||
"audit",
|
||||
"icons",
|
||||
"nitros",
|
||||
"furnidata",
|
||||
"structure",
|
||||
"id-check",
|
||||
"import",
|
||||
"reconcile",
|
||||
"sprite-ids",
|
||||
"offers",
|
||||
].join(" + "),
|
||||
);
|
||||
// Destructive steps stay opt-in even inside --everything.
|
||||
expect(flags.applySql).toBe(false);
|
||||
expect(flags.apply).toBe(false);
|
||||
expect(flags.alignIds).toBe(false);
|
||||
expect(flags.renameBundles).toBe(false);
|
||||
});
|
||||
|
||||
it("marks an import as a dry run", () => {
|
||||
expect(
|
||||
describeActions(parseCatalogSyncArgs(["--import-new", "--dry-run"])),
|
||||
).toContain("import (dry)");
|
||||
});
|
||||
|
||||
it("marks the child steps as reporting unless --apply is given", () => {
|
||||
expect(
|
||||
describeActions(
|
||||
parseCatalogSyncArgs(["--align-ids", "--rename-bundles"]),
|
||||
),
|
||||
).toContain("align-ids");
|
||||
expect(
|
||||
describeActions(
|
||||
parseCatalogSyncArgs(["--align-ids", "--rename-bundles", "--apply"]),
|
||||
),
|
||||
).toContain("rename=apply-bundles");
|
||||
});
|
||||
|
||||
it("parses the import tuning flags", () => {
|
||||
const flags = parseCatalogSyncArgs([
|
||||
"--import-new",
|
||||
"--limit=50",
|
||||
"--concurrency=8",
|
||||
"--only=chair,table",
|
||||
]);
|
||||
expect(flags.importLimit).toBe(50);
|
||||
expect(flags.importConcurrency).toBe(8);
|
||||
expect(flags.importOnly).toEqual(["chair", "table"]);
|
||||
});
|
||||
|
||||
it("rejects a non-numeric --limit", () => {
|
||||
expect(() => parseCatalogSyncArgs(["--limit=abc"])).toThrow(/integer/);
|
||||
});
|
||||
|
||||
it("rejects --dry-run without an import to preview", () => {
|
||||
expect(() => parseCatalogSyncArgs(["--dry-run"])).toThrow(
|
||||
/together with --import-new/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generated catalog SQL output", () => {
|
||||
let cwd = "";
|
||||
|
||||
beforeEach(() => {
|
||||
cwd = process.cwd();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
process.chdir(cwd);
|
||||
});
|
||||
|
||||
it("writes the SQL under storage/catalog-sync with a trailing newline", async () => {
|
||||
let target = "";
|
||||
try {
|
||||
const dir = mkdtempSync(join(tmpdir(), "catalog-sync-sql-"));
|
||||
target = dir;
|
||||
process.chdir(dir);
|
||||
const file = await writeGeneratedSql("INSERT INTO `catalog_items` (1);");
|
||||
expect(file.startsWith("storage/catalog-sync/")).toBe(true);
|
||||
expect(file.endsWith(".sql")).toBe(true);
|
||||
expect(readFileSync(join(dir, file), "utf8")).toBe(
|
||||
"INSERT INTO `catalog_items` (1);\n",
|
||||
);
|
||||
} finally {
|
||||
if (target) rmSync(target, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,815 @@
|
||||
// Command-line driver for the catalog audit/repair pipeline.
|
||||
//
|
||||
// The same engine the admin "Catalog audit" UI streams from (`runCatalogAudit`),
|
||||
// for the terminal: audit the hotel catalog against one or more clone sources
|
||||
// (Habbo and the retros), fix the furniture that is broken, and bring missing
|
||||
// items into the catalog — optionally writing and applying the catalog SQL.
|
||||
//
|
||||
// Usage:
|
||||
// pnpm catalog:sync # interactive menu (scripts/catalog-sync.sh)
|
||||
// pnpm exec tsx scripts/catalog-sync.ts --list-sources
|
||||
// pnpm exec tsx scripts/catalog-sync.ts --sources="Habbo (COM)" # audit only
|
||||
// pnpm exec tsx scripts/catalog-sync.ts --all --sources=default-habbo-com
|
||||
// pnpm exec tsx scripts/catalog-sync.ts --all --sql --apply-sql # + write/apply SQL
|
||||
//
|
||||
// Flags:
|
||||
// --list-sources print the configured clone sources and exit
|
||||
// --tsv with the above, print `id\tname\tfurnidataUrl` rows
|
||||
// --sources=<a,b> compare against these sources (id or name, comma-separated)
|
||||
// --icons re-download missing furniture icons from the sources
|
||||
// --nitros re-download missing .nitro / swf bundles (stored as .hab)
|
||||
// --furnidata repair FurnitureData.json (add missing, drop duplicates)
|
||||
// --structure repair orphaned / duplicate catalog rows
|
||||
// --check-ids check FurnitureData.json for spriteId conflicts
|
||||
// --sql generate the catalog SQL for items missing an offer
|
||||
// --apply-sql apply the generated SQL (implies --sql)
|
||||
// --organize-sql order the generated SQL by catalog page (implies --sql)
|
||||
// --all --icons --nitros --furnidata --structure --check-ids
|
||||
// --import-new import furniture the source has and this hotel lacks
|
||||
// --only=<a,b> restrict the import to these classnames
|
||||
// --limit=N stop the import after N items
|
||||
// --concurrency=N parallel imports (default 3)
|
||||
// --reconcile align FurnitureData.json with items_base after importing
|
||||
// --fix-sprite-ids correct items_base.sprite_id drift
|
||||
// --rebuild-offers rebuild catalog offer_id mirrors
|
||||
// --align-ids move items_base ids onto their furnidata ids (report;
|
||||
// add --apply to let the script execute it)
|
||||
// --rename-bundles rename legacy .nitro bundles to .hab (report; --apply runs it)
|
||||
// --json print one machine-readable JSON report
|
||||
// --dry-run with --import-new: list what would be imported, import nothing
|
||||
// --fail-on-errors exit 3 when error-severity issues remain
|
||||
//
|
||||
// Audit is read-only by default: nothing is written until one of the repair
|
||||
// flags is passed. One nuance: generating the catalog SQL also creates the
|
||||
// `catalog_pages` category sub-pages it references (exactly like the app's own
|
||||
// import does) — only the `catalog_items` rows wait for `--apply-sql`. Use
|
||||
// `--organize-sql` to also emit those page INSERTs into the .sql file.
|
||||
|
||||
import "./load-env";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { LOCAL_BUNDLE_EXT } from "@/lib/furni/classname";
|
||||
import type { AuditEvent, AuditSummary } from "@/lib/services/catalog-audit";
|
||||
import type { SourceFurni } from "@/lib/services/clone-import";
|
||||
import type { CloneSource } from "@/lib/services/clone-sources";
|
||||
|
||||
/** Where generated catalog SQL is written (`storage/` is gitignored). */
|
||||
function sqlOutputDir(): string {
|
||||
return join(process.cwd(), "storage", "catalog-sync");
|
||||
}
|
||||
|
||||
export type SourceRef = CloneSource;
|
||||
|
||||
export type AuditIssue = NonNullable<AuditEvent["issues"]>[number];
|
||||
export type MissingFromSource = NonNullable<
|
||||
AuditEvent["missingFromSources"]
|
||||
>[number];
|
||||
|
||||
export interface AuditReport {
|
||||
sources: SourceRef[];
|
||||
summary?: AuditSummary;
|
||||
issues: AuditIssue[];
|
||||
missingFromSources: MissingFromSource[];
|
||||
sqlFile?: string;
|
||||
imported?: ImportOutcome;
|
||||
}
|
||||
|
||||
export interface ImportOutcome {
|
||||
source: string;
|
||||
candidates: number;
|
||||
imported: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
failures: Array<{ classname: string; error: string }>;
|
||||
/** Sources that were skipped because they cannot serve bundles. */
|
||||
skippedSources: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a source can actually be imported from.
|
||||
*
|
||||
* The clone import downloads `${nitroBaseUrl}/${classname}.nitro` and gives up
|
||||
* when that is not reachable. Habbo's own entries carry no nitro/icon base URLs
|
||||
* (their furnidata is served, their assets are not), so they are compare-only:
|
||||
* perfect for finding out what this hotel lacks, useless for actually fetching
|
||||
* it. The SWF-based path that works against `images.habbo.com` lives in
|
||||
* `furni-import.ts`, not in the clone importer this CLI uses.
|
||||
*/
|
||||
export function canImportBundles(source: SourceRef): boolean {
|
||||
return Boolean(source.nitroBaseUrl?.trim());
|
||||
}
|
||||
|
||||
export interface CatalogSyncFlags {
|
||||
listSources: boolean;
|
||||
tsv: boolean;
|
||||
sourceIds: string[];
|
||||
repairIcons: boolean;
|
||||
repairNitros: boolean;
|
||||
repairFurniData: boolean;
|
||||
repairStructure: boolean;
|
||||
checkFurniDataIds: boolean;
|
||||
generateSql: boolean;
|
||||
applySql: boolean;
|
||||
organizeSql: boolean;
|
||||
json: boolean;
|
||||
failOnErrors: boolean;
|
||||
importNew: boolean;
|
||||
dryRun: boolean;
|
||||
importLimit: number;
|
||||
importConcurrency: number;
|
||||
importOnly: string[];
|
||||
reconcile: boolean;
|
||||
fixSpriteIds: boolean;
|
||||
rebuildOffers: boolean;
|
||||
alignIds: boolean;
|
||||
renameBundles: boolean;
|
||||
apply: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_CATALOG_SYNC_FLAGS: Omit<CatalogSyncFlags, "sourceIds"> = {
|
||||
listSources: false,
|
||||
tsv: false,
|
||||
repairIcons: false,
|
||||
repairNitros: false,
|
||||
repairFurniData: false,
|
||||
repairStructure: false,
|
||||
checkFurniDataIds: false,
|
||||
generateSql: false,
|
||||
applySql: false,
|
||||
organizeSql: false,
|
||||
json: false,
|
||||
failOnErrors: false,
|
||||
importNew: false,
|
||||
dryRun: false,
|
||||
importLimit: 0,
|
||||
importConcurrency: 3,
|
||||
importOnly: [],
|
||||
reconcile: false,
|
||||
fixSpriteIds: false,
|
||||
rebuildOffers: false,
|
||||
alignIds: false,
|
||||
renameBundles: false,
|
||||
apply: false,
|
||||
};
|
||||
|
||||
const BOOLEAN_FLAGS: Record<string, keyof CatalogSyncFlags> = {
|
||||
"--list-sources": "listSources",
|
||||
"--tsv": "tsv",
|
||||
"--icons": "repairIcons",
|
||||
"--repair-icons": "repairIcons",
|
||||
"--nitros": "repairNitros",
|
||||
"--repair-nitros": "repairNitros",
|
||||
"--furnidata": "repairFurniData",
|
||||
"--repair-furnidata": "repairFurniData",
|
||||
"--structure": "repairStructure",
|
||||
"--repair-structure": "repairStructure",
|
||||
"--check-ids": "checkFurniDataIds",
|
||||
"--sql": "generateSql",
|
||||
"--apply-sql": "applySql",
|
||||
"--organize-sql": "organizeSql",
|
||||
"--json": "json",
|
||||
"--fail-on-errors": "failOnErrors",
|
||||
"--import-new": "importNew",
|
||||
"--dry-run": "dryRun",
|
||||
"--reconcile": "reconcile",
|
||||
"--fix-sprite-ids": "fixSpriteIds",
|
||||
"--rebuild-offers": "rebuildOffers",
|
||||
"--align-ids": "alignIds",
|
||||
"--rename-bundles": "renameBundles",
|
||||
// Authorises the two heavyweight child steps that rewrite stored data.
|
||||
"--apply": "apply",
|
||||
};
|
||||
|
||||
const NUMBER_FLAGS: Record<string, keyof CatalogSyncFlags> = {
|
||||
"--limit": "importLimit",
|
||||
"--concurrency": "importConcurrency",
|
||||
};
|
||||
|
||||
const LIST_FLAGS: Record<string, keyof CatalogSyncFlags> = {
|
||||
"--only": "importOnly",
|
||||
};
|
||||
|
||||
export function parseCatalogSyncArgs(raw: string[]): CatalogSyncFlags {
|
||||
const flags: CatalogSyncFlags = {
|
||||
...DEFAULT_CATALOG_SYNC_FLAGS,
|
||||
sourceIds: [],
|
||||
};
|
||||
|
||||
for (const arg of raw) {
|
||||
if (arg === "--") continue;
|
||||
|
||||
if (arg === "--all") {
|
||||
flags.repairIcons = true;
|
||||
flags.repairNitros = true;
|
||||
flags.repairFurniData = true;
|
||||
flags.repairStructure = true;
|
||||
flags.checkFurniDataIds = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--everything") {
|
||||
flags.repairIcons = true;
|
||||
flags.repairNitros = true;
|
||||
flags.repairFurniData = true;
|
||||
flags.repairStructure = true;
|
||||
flags.checkFurniDataIds = true;
|
||||
flags.importNew = true;
|
||||
flags.reconcile = true;
|
||||
flags.fixSpriteIds = true;
|
||||
flags.rebuildOffers = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const separator = arg.indexOf("=");
|
||||
const name = separator >= 0 ? arg.slice(0, separator) : arg;
|
||||
|
||||
const booleanFlag = BOOLEAN_FLAGS[name];
|
||||
if (booleanFlag) {
|
||||
flags[booleanFlag] = true as never;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (name === "--sources" || name === "--source") {
|
||||
if (separator < 0)
|
||||
throw new Error(
|
||||
`${name} needs a value, e.g. --sources=default-habbo-com`,
|
||||
);
|
||||
const value = arg.slice(separator + 1);
|
||||
for (const part of value
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean))
|
||||
flags.sourceIds.push(part);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (NUMBER_FLAGS[name]) {
|
||||
if (separator < 0)
|
||||
throw new Error(`${name} needs a value, e.g. ${name}=10`);
|
||||
const parsed = Number(arg.slice(separator + 1));
|
||||
if (!Number.isInteger(parsed) || parsed < 0)
|
||||
throw new Error(`${name} needs a non-negative integer`);
|
||||
flags[NUMBER_FLAGS[name]] = parsed as never;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (LIST_FLAGS[name]) {
|
||||
if (separator < 0)
|
||||
throw new Error(`${name} needs a value, e.g. ${name}=chair,table`);
|
||||
const value = arg.slice(separator + 1);
|
||||
for (const part of value
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean))
|
||||
flags.importOnly.push(part);
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Unknown flag: ${arg} (run with --help for the supported flags)`,
|
||||
);
|
||||
}
|
||||
|
||||
// Applying SQL implies generating it, and organising it implies generating.
|
||||
if (flags.applySql) flags.generateSql = true;
|
||||
if (flags.organizeSql) flags.generateSql = true;
|
||||
|
||||
// A dry run without an import step has nothing to preview.
|
||||
if (flags.dryRun && !flags.importNew)
|
||||
throw new Error("--dry-run only makes sense together with --import-new");
|
||||
|
||||
if (flags.importNew && flags.applySql) {
|
||||
// Both write; that is allowed but it is worth making the operator think.
|
||||
logNote?.("--import-new and --apply-sql will both write to the catalog");
|
||||
}
|
||||
return flags;
|
||||
}
|
||||
|
||||
let logNote: ((line: string) => void) | undefined;
|
||||
|
||||
/**
|
||||
* Pick the furniture to import from a source's furnidata.
|
||||
*
|
||||
* Pure so the filtering rules (skip what we already have, `--only` filter,
|
||||
* `--limit` cap) can be tested without a database.
|
||||
*/
|
||||
export function selectImportCandidates(
|
||||
entries: SourceFurni[],
|
||||
present: Set<string>,
|
||||
options: { only?: string[]; limit?: number } = {},
|
||||
): SourceFurni[] {
|
||||
const normalize = (value: string) => value.trim().toLowerCase();
|
||||
const only = options.only?.length
|
||||
? new Set(options.only.map(normalize))
|
||||
: null;
|
||||
|
||||
const candidates = entries.filter((entry) => {
|
||||
if (!entry.classname) return false;
|
||||
if (present.has(normalize(entry.classname))) return false;
|
||||
if (only && !only.has(normalize(entry.classname))) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const limit = options.limit ?? 0;
|
||||
return limit > 0 ? candidates.slice(0, limit) : candidates;
|
||||
}
|
||||
|
||||
/** Human-readable list of what the run will do. */
|
||||
export function describeActions(flags: CatalogSyncFlags): string {
|
||||
const actions = ["audit"];
|
||||
if (flags.repairIcons) actions.push("icons");
|
||||
if (flags.repairNitros) actions.push("nitros");
|
||||
if (flags.repairFurniData) actions.push("furnidata");
|
||||
if (flags.repairStructure) actions.push("structure");
|
||||
if (flags.checkFurniDataIds) actions.push("id-check");
|
||||
if (flags.importNew) actions.push(flags.dryRun ? "import (dry)" : "import");
|
||||
if (flags.reconcile) actions.push("reconcile");
|
||||
if (flags.fixSpriteIds) actions.push("sprite-ids");
|
||||
if (flags.rebuildOffers) actions.push("offers");
|
||||
if (flags.alignIds) actions.push(`align-ids${flags.apply ? "=apply" : ""}`);
|
||||
if (flags.renameBundles)
|
||||
actions.push(`rename${flags.apply ? "=apply" : "-dry"}-bundles`);
|
||||
if (flags.applySql) actions.push("apply-sql");
|
||||
else if (flags.generateSql) actions.push("generate-sql");
|
||||
if (flags.organizeSql) actions.push("organised");
|
||||
return actions.join(" + ");
|
||||
}
|
||||
|
||||
/** True when the file is being executed, not imported by a test. */
|
||||
function isEntryPoint(): boolean {
|
||||
const entry = process.argv[1];
|
||||
return (
|
||||
Boolean(entry) && import.meta.url === pathToFileURL(String(entry)).href
|
||||
);
|
||||
}
|
||||
|
||||
async function loadSourceCatalogue(): Promise<SourceRef[]> {
|
||||
const { listSources } = await import("@/lib/services/clone-sources");
|
||||
const sources = await listSources();
|
||||
// Full source objects: the import step needs the icon/nitro base URLs too.
|
||||
return sources.filter((source) => source.furnidataUrl);
|
||||
}
|
||||
|
||||
async function commandListSources(flags: CatalogSyncFlags): Promise<number> {
|
||||
const sources = await loadSourceCatalogue();
|
||||
|
||||
if (flags.tsv) {
|
||||
for (const source of sources)
|
||||
console.log([source.id, source.name, source.furnidataUrl].join("\t"));
|
||||
return 0;
|
||||
}
|
||||
|
||||
console.log(`Configured clone sources (${sources.length}):`);
|
||||
for (const source of sources)
|
||||
console.log(` ${source.id}\t${source.name}\t${source.furnidataUrl}`);
|
||||
console.log(
|
||||
"\nCompare with: --sources=<id> (comma-separated; display names work too)",
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Persist the generated SQL so it can be reviewed or replayed by hand. */
|
||||
export async function writeGeneratedSql(sql: string): Promise<string> {
|
||||
await mkdir(sqlOutputDir(), { recursive: true });
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const file = join(sqlOutputDir(), `catalog-${stamp}.sql`);
|
||||
await writeFile(file, sql.endsWith("\n") ? sql : `${sql}\n`, "utf8");
|
||||
return file.replace(`${process.cwd()}/`, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one of the repo's existing standalone maintenance scripts as a child
|
||||
* process, with the same Node conditions this CLI needs.
|
||||
*/
|
||||
function runChildScript(
|
||||
script: string,
|
||||
args: string[],
|
||||
log: (line: string) => void,
|
||||
): number {
|
||||
const nodeArgs = [
|
||||
"--conditions=react-server",
|
||||
"--import",
|
||||
"tsx",
|
||||
script,
|
||||
...args,
|
||||
];
|
||||
log(`· running scripts/${script} ${args.join(" ")}`.trimEnd());
|
||||
const result = spawnSync(process.execPath, nodeArgs, {
|
||||
cwd: process.cwd(),
|
||||
stdio: "inherit",
|
||||
env: process.env,
|
||||
});
|
||||
if (result.error) {
|
||||
console.error(`! ${script} failed to start: ${result.error.message}`);
|
||||
return 1;
|
||||
}
|
||||
return result.status ?? 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import furniture that exists at the compared source but not in this hotel.
|
||||
*
|
||||
* `cloneSingleFurni` downloads the bundle + icon, stores the bundle as
|
||||
* `LOCAL_BUNDLE_EXT`, appends the furnidata entry and creates the catalog page
|
||||
* plus the offer itself — so an import is not usable for furniture you want to
|
||||
* keep out of the catalog.
|
||||
*/
|
||||
async function stepImportNew(
|
||||
flags: CatalogSyncFlags,
|
||||
sources: SourceRef[],
|
||||
log: (line: string) => void,
|
||||
): Promise<ImportOutcome | undefined> {
|
||||
const { getClonableClassnames, fetchSourceFurnidata } = await import(
|
||||
"@/lib/services/clone-import"
|
||||
);
|
||||
const { cloneSingleFurni } = await import("@/lib/services/clone-import");
|
||||
|
||||
let planned = 0;
|
||||
let imported = 0;
|
||||
let skipped = 0;
|
||||
let failed = 0;
|
||||
const failures: Array<{ classname: string; error: string }> = [];
|
||||
const deferredEntries: Array<{
|
||||
entry: Record<string, unknown>;
|
||||
itemType: string;
|
||||
}> = [];
|
||||
let lastSource = "";
|
||||
const skippedSources: string[] = [];
|
||||
|
||||
for (const source of sources) {
|
||||
if (!canImportBundles(source)) {
|
||||
log(
|
||||
`! ${source.name} serves no .nitro bundles — it can be compared against, but nothing can be imported from it`,
|
||||
);
|
||||
skippedSources.push(source.name);
|
||||
continue;
|
||||
}
|
||||
if (flags.importLimit > 0 && planned >= flags.importLimit) break;
|
||||
const remaining = flags.importLimit
|
||||
? flags.importLimit - planned
|
||||
: Number.POSITIVE_INFINITY;
|
||||
|
||||
log(`· listing furniture missing at ${source.name}…`);
|
||||
const clonable = new Set(await getClonableClassnames(source));
|
||||
const entries = await fetchSourceFurnidata(source.furnidataUrl);
|
||||
const candidates = selectImportCandidates(entries, clonable, {
|
||||
only: flags.importOnly,
|
||||
limit: Number.isFinite(remaining) ? remaining : 0,
|
||||
});
|
||||
if (candidates.length === 0) {
|
||||
log(` nothing new to import from ${source.name}`);
|
||||
continue;
|
||||
}
|
||||
lastSource = source.name;
|
||||
|
||||
if (flags.dryRun) {
|
||||
log(
|
||||
` ${candidates.length} would be imported from ${source.name}${flags.importOnly.length ? " (filtered by --only)" : ""}`,
|
||||
);
|
||||
for (const entry of candidates.slice(0, 20))
|
||||
log(` ${entry.classname} — ${entry.name}`);
|
||||
if (candidates.length > 20)
|
||||
log(` … and ${candidates.length - 20} more`);
|
||||
planned += candidates.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
planned += candidates.length;
|
||||
const started = Date.now();
|
||||
let done = 0;
|
||||
let index = 0;
|
||||
|
||||
const worker = async () => {
|
||||
while (index < candidates.length) {
|
||||
const entry = candidates[index++];
|
||||
const result = await cloneSingleFurni({
|
||||
source,
|
||||
entry,
|
||||
deferFurniData: true,
|
||||
});
|
||||
done += 1;
|
||||
if (result.ok) imported += 1;
|
||||
else if (result.skipped) skipped += 1;
|
||||
else {
|
||||
failed += 1;
|
||||
failures.push({
|
||||
classname: entry.classname,
|
||||
error: result.error ?? "unknown error",
|
||||
});
|
||||
}
|
||||
if (result.furniDataEntry)
|
||||
deferredEntries.push(
|
||||
result.furniDataEntry as {
|
||||
entry: Record<string, unknown>;
|
||||
itemType: string;
|
||||
},
|
||||
);
|
||||
|
||||
if (done % 10 === 0 || done === candidates.length) {
|
||||
const rate = Math.max(done / ((Date.now() - started) / 1000), 1);
|
||||
const etaMin = Math.round((candidates.length - done) / rate / 60);
|
||||
log(
|
||||
` ${source.name}: ${done}/${candidates.length} ${rate.toFixed(1)}/s, ETA ~${etaMin}m`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const workers = Math.max(
|
||||
1,
|
||||
Math.min(flags.importConcurrency, candidates.length),
|
||||
);
|
||||
await Promise.all(Array.from({ length: workers }, worker));
|
||||
}
|
||||
|
||||
if (!flags.dryRun && deferredEntries.length > 0) {
|
||||
const { appendFurniEntriesBulk } = await import(
|
||||
"@/lib/services/furni-data"
|
||||
);
|
||||
await appendFurniEntriesBulk(deferredEntries);
|
||||
log(`· FurnitureData.json: +${deferredEntries.length} entries (one write)`);
|
||||
}
|
||||
|
||||
return {
|
||||
source: lastSource,
|
||||
candidates: planned,
|
||||
imported,
|
||||
skipped,
|
||||
failed,
|
||||
failures: failures.slice(0, 25),
|
||||
skippedSources,
|
||||
};
|
||||
}
|
||||
|
||||
/** Keep FurnitureData.json consistent with items_base after the import. */
|
||||
async function stepReconcile(log: (line: string) => void): Promise<void> {
|
||||
const { reconcileFurniDataWithItemsBase } = await import(
|
||||
"@/lib/services/furni-data"
|
||||
);
|
||||
const result = await reconcileFurniDataWithItemsBase();
|
||||
log(
|
||||
`· reconcile: ${result.fixedIds} sprite id(s), ${result.fixedOfferIds} offer id(s), ${result.normalizedIds} normalised, ${result.fixedConflicts} conflict(s) resolved, ${result.missing} still missing`,
|
||||
);
|
||||
}
|
||||
|
||||
/** items_base.sprite_id must equal its id wherever furnidata carries the item. */
|
||||
async function stepFixSpriteIds(log: (line: string) => void): Promise<void> {
|
||||
const { verifyAndFixSpriteIds } = await import("@/lib/services/furni-data");
|
||||
const result = await verifyAndFixSpriteIds();
|
||||
log(`· sprite ids: ${result.checked} checked, ${result.fixed} corrected`);
|
||||
}
|
||||
|
||||
/** offer_id mirrors the row id, never a literal -1. */
|
||||
async function stepRebuildOffers(log: (line: string) => void): Promise<void> {
|
||||
const { rebuildCatalogOfferIds } = await import("@/lib/services/furni-data");
|
||||
const result = await rebuildCatalogOfferIds();
|
||||
log(`· offer ids: ${JSON.stringify(result)}`);
|
||||
}
|
||||
|
||||
export async function commandRun(flags: CatalogSyncFlags): Promise<number> {
|
||||
const catalogue = await loadSourceCatalogue();
|
||||
|
||||
// Resolve the selection up-front so a typo fails before anything is written.
|
||||
const known = new Set<string>();
|
||||
for (const source of catalogue) {
|
||||
known.add(source.id);
|
||||
known.add(source.name);
|
||||
}
|
||||
const unknown = flags.sourceIds.filter((id) => !known.has(id));
|
||||
if (unknown.length > 0) {
|
||||
console.error(
|
||||
`Unknown source: ${unknown.join(", ")}\nRun --list-sources to see the configured sites.`,
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const selected = flags.sourceIds.length
|
||||
? catalogue.filter(
|
||||
(source) =>
|
||||
flags.sourceIds.includes(source.id) ||
|
||||
flags.sourceIds.includes(source.name),
|
||||
)
|
||||
: catalogue;
|
||||
|
||||
const { runCatalogAudit } = await import("@/lib/services/catalog-audit");
|
||||
|
||||
const report: AuditReport = {
|
||||
sources: selected,
|
||||
issues: [],
|
||||
missingFromSources: [],
|
||||
};
|
||||
const quiet = flags.json;
|
||||
const log = (line: string) => {
|
||||
if (!quiet) console.log(line);
|
||||
};
|
||||
const started = Date.now();
|
||||
let pendingSql: string | undefined;
|
||||
|
||||
log(
|
||||
`Catalog sync — ${selected.length} source(s), ${describeActions(flags)} — bundles stored as ${LOCAL_BUNDLE_EXT} (FURNI_BUNDLE_EXT)` +
|
||||
(flags.applySql ? " [SQL WILL BE APPLIED]" : ""),
|
||||
);
|
||||
|
||||
// 1) Move items_base ids onto their furnidata ids before anything else, so
|
||||
// every later step sees stable ids. The script only reports without
|
||||
// `--apply`.
|
||||
if (flags.alignIds) {
|
||||
runChildScript(
|
||||
"scripts/align-db-ids-with-furnidata.ts",
|
||||
flags.apply ? ["--apply"] : [],
|
||||
log,
|
||||
);
|
||||
}
|
||||
|
||||
// 2) Audit + repair of what is already here.
|
||||
|
||||
await runCatalogAudit(
|
||||
(event) => {
|
||||
switch (event.type) {
|
||||
case "progress":
|
||||
log(`· ${event.message ?? ""}`);
|
||||
break;
|
||||
case "items_loaded":
|
||||
log(`· items_base: ${event.total} rows`);
|
||||
break;
|
||||
case "catalog_loaded":
|
||||
log(`· catalog_items: ${event.total} rows`);
|
||||
break;
|
||||
case "sql_ready":
|
||||
log(`· catalog SQL generated: ${event.sqlCount ?? 0} statement(s)`);
|
||||
pendingSql = event.sql;
|
||||
break;
|
||||
case "sql_applied":
|
||||
log(
|
||||
`· catalog SQL applied: ${event.applied ?? 0} (failed ${event.sqlFailed ?? 0})`,
|
||||
);
|
||||
break;
|
||||
case "furnidata_repair_complete":
|
||||
log(
|
||||
`· FurnitureData.json: +${event.furniData?.added ?? 0} entries, ${event.furniData?.removedDuplicates ?? 0} duplicate(s) removed`,
|
||||
);
|
||||
break;
|
||||
case "structure_repair_complete":
|
||||
log(
|
||||
`· structure: ${event.structure?.orphanedRemoved ?? 0} orphaned removed, ${event.structure?.duplicatesMerged ?? 0} duplicate(s) merged, ${event.structure?.remappedReferences ?? 0} reference(s) remapped`,
|
||||
);
|
||||
break;
|
||||
case "repair_complete":
|
||||
log(
|
||||
`· asset repair: ${event.repair?.succeeded ?? 0} ok, ${event.repair?.failed ?? 0} failed, ${event.repair?.skipped ?? 0} skipped`,
|
||||
);
|
||||
break;
|
||||
case "batch_complete":
|
||||
report.issues = event.issues ?? [];
|
||||
report.missingFromSources = event.missingFromSources ?? [];
|
||||
report.summary = event.summary;
|
||||
break;
|
||||
case "error":
|
||||
console.error(`! ${event.message ?? "audit error"}`);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
{
|
||||
sourceIds: flags.sourceIds,
|
||||
repair: flags.repairIcons,
|
||||
repairNitros: flags.repairNitros,
|
||||
sql: flags.generateSql,
|
||||
applySql: flags.applySql,
|
||||
organizeSql: flags.organizeSql,
|
||||
repairFurniData: flags.repairFurniData,
|
||||
repairStructure: flags.repairStructure,
|
||||
checkFurniDataIds: flags.checkFurniDataIds,
|
||||
},
|
||||
);
|
||||
|
||||
if (pendingSql?.trim()) report.sqlFile = await writeGeneratedSql(pendingSql);
|
||||
|
||||
// 3) Import what the source has and this hotel does not.
|
||||
if (flags.importNew) {
|
||||
if (selected.length === 0) log("· no source selected, nothing to import");
|
||||
else report.imported = await stepImportNew(flags, selected, log);
|
||||
}
|
||||
|
||||
// 4) Post-import consistency, then the legacy bundle rename.
|
||||
if (flags.reconcile) await stepReconcile(log);
|
||||
if (flags.fixSpriteIds) await stepFixSpriteIds(log);
|
||||
if (flags.rebuildOffers) await stepRebuildOffers(log);
|
||||
if (flags.renameBundles) {
|
||||
runChildScript(
|
||||
"scripts/migrate-nitro-to-hab.ts",
|
||||
flags.apply ? ["--yes"] : ["--dry-run"],
|
||||
log,
|
||||
);
|
||||
}
|
||||
|
||||
if (flags.json) {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{ flags, elapsedMs: Date.now() - started, ...report },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
printSummary(report, started);
|
||||
if (report.sqlFile) console.log(`Catalog SQL: ${report.sqlFile}`);
|
||||
}
|
||||
|
||||
const errors = report.issues.filter((issue) => issue.severity === "error");
|
||||
if (flags.failOnErrors && errors.length > 0) {
|
||||
if (!quiet) console.error(`${errors.length} error(s) remain unresolved.`);
|
||||
return 3;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function printSummary(report: AuditReport, started: number): void {
|
||||
const summary = report.summary;
|
||||
const seconds = ((Date.now() - started) / 1000).toFixed(1);
|
||||
|
||||
console.log(`\nDone in ${seconds}s`);
|
||||
if (!summary) {
|
||||
console.log("No summary was produced (the audit stopped early).");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(` items_base ${summary.totalItemsBase}`);
|
||||
console.log(` catalog_items ${summary.totalCatalogItems}`);
|
||||
console.log(` missing catalog entry ${summary.missingCatalog}`);
|
||||
console.log(` orphaned catalog rows ${summary.orphanedCatalog}`);
|
||||
console.log(` duplicate classnames ${summary.duplicates}`);
|
||||
console.log(
|
||||
` missing icons ${summary.missingIcon} (repaired ${summary.iconsRepaired}, still missing ${summary.iconsStillMissing})`,
|
||||
);
|
||||
console.log(
|
||||
` missing nitro bundles ${summary.missingNitro} (repaired ${summary.nitrosRepaired}, still missing ${summary.nitrosStillMissing})`,
|
||||
);
|
||||
console.log(
|
||||
` catalog SQL ${summary.catalogSqlCount} stmt, applied ${summary.catalogSqlApplied}`,
|
||||
);
|
||||
console.log(` furnidata added ${summary.furniDataAdded}`);
|
||||
console.log(
|
||||
` not restorable ${summary.unrepairableCount} (no icon/bundle on any compared source)`,
|
||||
);
|
||||
|
||||
if (report.imported) {
|
||||
const outcome = report.imported;
|
||||
console.log(
|
||||
` imported from source ${outcome.imported} new (${outcome.skipped} already present, ${outcome.failed} failed)`,
|
||||
);
|
||||
if (outcome.skippedSources.length > 0)
|
||||
console.log(
|
||||
` compared only ${outcome.skippedSources.join(", ")} (no .nitro bundles to import from)`,
|
||||
);
|
||||
for (const failure of outcome.failures)
|
||||
console.log(` ${failure.classname}: ${failure.error}`);
|
||||
}
|
||||
|
||||
if (report.missingFromSources.length > 0) {
|
||||
const bySource = new Map<string, number>();
|
||||
for (const entry of report.missingFromSources)
|
||||
bySource.set(entry.sourceName, (bySource.get(entry.sourceName) ?? 0) + 1);
|
||||
console.log(
|
||||
`\n on compared sources but not in this hotel: ${report.missingFromSources.length}`,
|
||||
);
|
||||
for (const [name, count] of bySource)
|
||||
console.log(` ${name.padEnd(24)} ${count}`);
|
||||
}
|
||||
|
||||
const errors = report.issues.filter((issue) => issue.severity === "error");
|
||||
if (errors.length > 0) {
|
||||
console.log(`\n unresolved errors: ${errors.length}`);
|
||||
for (const issue of errors.slice(0, 20))
|
||||
console.log(` ${issue.classname ?? issue.type}: ${issue.message}`);
|
||||
if (errors.length > 20) console.log(` … and ${errors.length - 20} more`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<number> {
|
||||
const flags = parseCatalogSyncArgs(process.argv.slice(2));
|
||||
try {
|
||||
if (flags.listSources) return await commandListSources(flags);
|
||||
return await commandRun(flags);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Catalog sync failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (isEntryPoint()) {
|
||||
main().then(
|
||||
(code) => {
|
||||
process.exit(code);
|
||||
},
|
||||
(error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
},
|
||||
);
|
||||
}
|
||||
+19
-6
@@ -42,13 +42,10 @@ old_container=""
|
||||
new_container=""
|
||||
blue_green=0
|
||||
|
||||
# Resource limits voor de container. Zonder deze limieten kan één geheugenlek de
|
||||
# hele host vullen, met MariaDB, nginx en Traefik als slachtoffer. 2 CPU laat
|
||||
# achtergrondwerk toe zonder de hele kernel aan één release te geven.
|
||||
# CPU en pids zijn bewust verwijderd; memory blijft gelimiteerd op 4g/5g swap
|
||||
# om een geheugenlek niet de host te laten vullen (MariaDB, nginx, Traefik).
|
||||
mem_limit="4g"
|
||||
mem_swap_limit="5g"
|
||||
cpu_limit="2"
|
||||
pids_limit="512"
|
||||
|
||||
is_current() {
|
||||
local head
|
||||
@@ -300,7 +297,6 @@ start_candidate() {
|
||||
done < "$deploy_dir/.env"
|
||||
docker run -d --name "$name" --restart always --net=host \
|
||||
--memory="$mem_limit" --memory-swap="$mem_swap_limit" \
|
||||
--cpus="$cpu_limit" --pids-limit="$pids_limit" \
|
||||
"${ENV_ARGS[@]}" -e "PORT=$port" -e HOSTNAME=0.0.0.0 \
|
||||
-v "$deploy_dir/public/nitro-assets:/app/public/nitro-assets" \
|
||||
-v "$deploy_dir/public/swf:/app/public/swf" \
|
||||
@@ -416,9 +412,26 @@ pnpm install --frozen-lockfile
|
||||
pnpm exec playwright install chromium firefox webkit
|
||||
|
||||
echo "Building $image"
|
||||
# Duurzame buildcache buiten de werkboom. De CI-werkboom wordt na elke job
|
||||
# gewist en `docker builder prune` gooit de buildcache weg, waarna de hele
|
||||
# pnpm-laag opnieuw gedownload moet worden. Een lokale cache-export overleeft
|
||||
# allebei. Een lege CMS_BUILD_CACHE_DIR schakelt de cache uit.
|
||||
build_cache_dir="${CMS_BUILD_CACHE_DIR-/var/cache/epicnext-cms-buildcache}"
|
||||
build_cache_flags=()
|
||||
if [ -n "$build_cache_dir" ]; then
|
||||
if mkdir -p "$build_cache_dir" 2>/dev/null && [ -w "$build_cache_dir" ]; then
|
||||
build_cache_flags=(
|
||||
--cache-from "type=local,src=$build_cache_dir"
|
||||
--cache-to "type=local,dest=$build_cache_dir,mode=max"
|
||||
)
|
||||
else
|
||||
echo "Warning: buildcache $build_cache_dir is niet beschrijfbaar; build zonder hergebruikte cache." >&2
|
||||
fi
|
||||
fi
|
||||
# No --network=host: BuildKit only grants it via --allow=network.host, and the
|
||||
# build needs nothing but outbound internet (apk, pnpm, next/font/google).
|
||||
DOCKER_BUILDKIT=1 docker build --progress=plain --cache-from epicnext-cms:latest \
|
||||
${build_cache_flags[@]+"${build_cache_flags[@]}"} \
|
||||
--build-arg NEXT_DEPLOYMENT_ID="$sha" -t "$image" .
|
||||
check_current
|
||||
# Read reports from the already-built image; do not start an extra application.
|
||||
|
||||
@@ -39,6 +39,23 @@ pnpm exec playwright install chromium
|
||||
export NEWS_E2E_IMAGE="$image"
|
||||
export NEWS_E2E_RELEASE="$sha"
|
||||
build_attempted=1
|
||||
# Duurzame buildcache buiten de tijdelijke werkmap, zodat de lagen van een
|
||||
# vorige preflight hergebruikt worden in plaats van opnieuw gedownload. Eigen
|
||||
# map, gescheiden van de release-cache: een branch-preflight mag nooit de cache
|
||||
# van de release-build voeden. Een lege CMS_BUILD_CACHE_DIR schakelt het uit.
|
||||
build_cache_dir="${CMS_BUILD_CACHE_DIR-/var/cache/epicnext-cms-preflight-cache}"
|
||||
build_cache_flags=()
|
||||
if [ -n "$build_cache_dir" ]; then
|
||||
if mkdir -p "$build_cache_dir" 2>/dev/null && [ -w "$build_cache_dir" ]; then
|
||||
build_cache_flags=(
|
||||
--cache-from "type=local,src=$build_cache_dir"
|
||||
--cache-to "type=local,dest=$build_cache_dir,mode=max"
|
||||
)
|
||||
else
|
||||
echo "Warning: buildcache $build_cache_dir is niet beschrijfbaar; build zonder hergebruikte cache." >&2
|
||||
fi
|
||||
fi
|
||||
DOCKER_BUILDKIT=1 docker build --progress=plain \
|
||||
${build_cache_flags[@]+"${build_cache_flags[@]}"} \
|
||||
--build-arg NEXT_DEPLOYMENT_ID="$sha" -t "$image" .
|
||||
NEWS_E2E_IMAGE="$image" NEWS_E2E_RELEASE="$sha" node --import tsx e2e/news-real/run.ts
|
||||
@@ -126,7 +126,9 @@ if [[ -n "${CMS_IMAGE_REPOSITORY:-}" ]]; then
|
||||
docker tag "$app_reference" "epicnext-cms:$CMS_RELEASE"
|
||||
docker tag "$remote_migration_image" "$migration_image"
|
||||
else
|
||||
docker build --target migrations --build-arg NEXT_DEPLOYMENT_ID="$CMS_RELEASE" -t "$migration_image" . >>"$LOG_FILE" 2>&1
|
||||
docker build --target migrations --build-arg NEXT_DEPLOYMENT_ID="$CMS_RELEASE" \
|
||||
--cache-from type=local,src=.docker-cache/cms \
|
||||
-t "$migration_image" . >>"$LOG_FILE" 2>&1
|
||||
docker compose build --build-arg NEXT_DEPLOYMENT_ID="$CMS_RELEASE" cms >>"$LOG_FILE" 2>&1
|
||||
fi
|
||||
expected_image="$(docker image inspect --format '{{.Id}}' "epicnext-cms:$CMS_RELEASE")"
|
||||
|
||||
+15
-4
@@ -78,13 +78,24 @@ else
|
||||
if install_file "$PROXY_DIR/cms_upstream_servers.conf" "$NGINX_DIR/snippets/cms_upstream_servers.conf"; then changed=1; fi
|
||||
fi
|
||||
|
||||
if [[ ! -f "$NGINX_DIR/sites-enabled/cms.conf" ]]; then
|
||||
# sites-enabled/cms.conf must be a symlink, not a copy. A regular file here
|
||||
# silently shadows sites-available/cms.conf, so nginx keeps serving stale
|
||||
# config even though the sync updated sites-available.
|
||||
enabled="$NGINX_DIR/sites-enabled/cms.conf"
|
||||
if [[ -L "$enabled" && "$(readlink "$enabled")" == "../sites-available/cms.conf" ]]; then
|
||||
echo "= $enabled symlink up to date"
|
||||
else
|
||||
if [[ "$MODE" == "check" ]]; then
|
||||
echo "- sites-enabled/cms.conf missing"
|
||||
echo "- $enabled is not the expected symlink"
|
||||
changed=1
|
||||
else
|
||||
ln -sf ../sites-available/cms.conf "$NGINX_DIR/sites-enabled/cms.conf"
|
||||
echo "+ linked sites-enabled/cms.conf"
|
||||
mkdir -p "$NGINX_DIR/sites-enabled"
|
||||
if [[ -e "$enabled" || -L "$enabled" ]]; then
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
cp -a "$enabled" "$BACKUP_DIR/cms-enabled.conf"
|
||||
fi
|
||||
ln -sfn ../sites-available/cms.conf "$enabled"
|
||||
echo "+ linked $enabled"
|
||||
changed=1
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -1,36 +1,41 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"mode": "informational",
|
||||
"mode": "enforce",
|
||||
"routes": [
|
||||
{
|
||||
"route": "/",
|
||||
"maxInitialRawBytes": 716800,
|
||||
"maxInitialGzipBytes": 225280
|
||||
},
|
||||
{
|
||||
"route": "/me",
|
||||
"maxInitialRawBytes": 890880,
|
||||
"maxInitialGzipBytes": 276480
|
||||
"maxInitialRawBytes": 716800,
|
||||
"maxInitialGzipBytes": 225280
|
||||
},
|
||||
{
|
||||
"route": "/news",
|
||||
"maxInitialRawBytes": 880640,
|
||||
"maxInitialGzipBytes": 276480
|
||||
"maxInitialRawBytes": 716800,
|
||||
"maxInitialGzipBytes": 225280
|
||||
},
|
||||
{
|
||||
"route": "/events",
|
||||
"maxInitialRawBytes": 890880,
|
||||
"maxInitialGzipBytes": 276480
|
||||
"maxInitialRawBytes": 716800,
|
||||
"maxInitialGzipBytes": 225280
|
||||
},
|
||||
{
|
||||
"route": "/search",
|
||||
"maxInitialRawBytes": 890880,
|
||||
"maxInitialGzipBytes": 276480
|
||||
"maxInitialRawBytes": 716800,
|
||||
"maxInitialGzipBytes": 225280
|
||||
},
|
||||
{
|
||||
"route": "/admin/catalog",
|
||||
"maxInitialRawBytes": 1904640,
|
||||
"maxInitialGzipBytes": 573440
|
||||
"maxInitialRawBytes": 1208320,
|
||||
"maxInitialGzipBytes": 389120
|
||||
},
|
||||
{
|
||||
"route": "/admin/studio/furni",
|
||||
"maxInitialRawBytes": 1433600,
|
||||
"maxInitialGzipBytes": 450560
|
||||
"maxInitialRawBytes": 1607680,
|
||||
"maxInitialGzipBytes": 522240
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -7,12 +7,12 @@ const LIMIT_KEYS = ["route", "maxInitialRawBytes", "maxInitialGzipBytes"];
|
||||
export function validateBudgets(config) {
|
||||
if (
|
||||
config?.schemaVersion !== 1 ||
|
||||
config.mode !== "informational" ||
|
||||
(config.mode !== "informational" && config.mode !== "enforce") ||
|
||||
!Array.isArray(config.routes) ||
|
||||
config.routes.length === 0
|
||||
)
|
||||
throw new Error(
|
||||
"Budget config requires schemaVersion 1, mode informational and nonempty routes.",
|
||||
"Budget config requires schemaVersion 1, mode informational or enforce, and nonempty routes.",
|
||||
);
|
||||
if (
|
||||
Object.keys(config).some(
|
||||
@@ -288,7 +288,7 @@ export function collectReport(nextDir, config, metadata = {}) {
|
||||
});
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
mode: "informational",
|
||||
mode: config.mode,
|
||||
generatedAt: new Date().toISOString(),
|
||||
buildId,
|
||||
commitSha: metadata.commitSha ?? null,
|
||||
@@ -308,6 +308,10 @@ export function collectReport(nextDir, config, metadata = {}) {
|
||||
}
|
||||
|
||||
export function markdownReport(report) {
|
||||
const modeLine =
|
||||
report.mode === "enforce"
|
||||
? `Build: ${report.buildId ?? "unavailable"}. Declared commit: ${report.commitSha ?? "not supplied"}. Mode: enforce; an over-budget or unavailable route fails the command and the image build.`
|
||||
: `Build: ${report.buildId ?? "unavailable"}. Declared commit: ${report.commitSha ?? "not supplied"}. Mode: informational; exceeded budgets and unavailable metrics do not fail CI.`;
|
||||
const lines = [
|
||||
"# Route JavaScript size report",
|
||||
"",
|
||||
@@ -315,7 +319,7 @@ export function markdownReport(report) {
|
||||
"",
|
||||
report.gzip,
|
||||
"",
|
||||
`Build: ${report.buildId ?? "unavailable"}. Declared commit: ${report.commitSha ?? "not supplied"}. Mode: informational; exceeded budgets and unavailable metrics do not fail CI.`,
|
||||
modeLine,
|
||||
"",
|
||||
"| Route | State | Unique JS | Raw bytes | Gzip bytes | Raw budget | Gzip budget |",
|
||||
"| --- | --- | ---: | ---: | ---: | ---: | ---: |",
|
||||
@@ -370,6 +374,17 @@ export function main(argv = process.argv.slice(2)) {
|
||||
console.warn(
|
||||
`Performance warning: ${row.route} ${row.status}${row.reason ? `: ${row.reason}` : ""}`,
|
||||
);
|
||||
if (report.mode === "enforce") {
|
||||
const failed = report.routes.filter((row) => row.status !== "measured");
|
||||
if (failed.length > 0) {
|
||||
console.error(
|
||||
`Performance budgets enforced: ${failed
|
||||
.map((row) => `${row.route} (${row.status})`)
|
||||
.join(", ")}`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
return report;
|
||||
}
|
||||
if (
|
||||
|
||||
@@ -373,7 +373,10 @@ describe("route JS measurement", () => {
|
||||
expect(() =>
|
||||
validateBudgets({ ...config, routes: [budget, budget] }),
|
||||
).toThrow();
|
||||
expect(() => validateBudgets({ ...config, mode: "enforce" })).toThrow();
|
||||
expect(validateBudgets({ ...config, mode: "enforce" }).mode).toBe(
|
||||
"enforce",
|
||||
);
|
||||
expect(() => validateBudgets({ ...config, mode: "strict" })).toThrow();
|
||||
expect(() => validateBudgets({ ...config, typo: true })).toThrow();
|
||||
});
|
||||
it("emits warnings and artifacts with exit zero when a build is unavailable", () => {
|
||||
@@ -423,4 +426,51 @@ describe("route JS measurement", () => {
|
||||
expect(markdown).toContain("not measured browser traffic");
|
||||
expect(markdown).toContain("Raw bytes");
|
||||
});
|
||||
describe("enforce mode", () => {
|
||||
const run = (dir, budgets) => {
|
||||
const write = (relative, value) => {
|
||||
const file = path.join(dir, relative);
|
||||
mkdirSync(path.dirname(file), { recursive: true });
|
||||
writeFileSync(file, JSON.stringify(value));
|
||||
};
|
||||
write("budgets.json", budgets);
|
||||
return spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
"scripts/performance-report.mjs",
|
||||
"--next-dir",
|
||||
dir,
|
||||
"--config",
|
||||
path.join(dir, "budgets.json"),
|
||||
"--output-dir",
|
||||
path.join(dir, "reports"),
|
||||
],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
};
|
||||
it("passes when every route is measured within budget", () => {
|
||||
const enforce = { ...config, mode: "enforce" };
|
||||
const runResult = run(fixture().dir, enforce);
|
||||
expect(runResult.status).toBe(0);
|
||||
expect(runResult.stdout).toContain("Mode: enforce");
|
||||
expect(runResult.stderr).not.toContain("Performance budgets enforced");
|
||||
});
|
||||
it("fails when a route is over budget", () => {
|
||||
const enforce = {
|
||||
schemaVersion: 1,
|
||||
mode: "enforce",
|
||||
routes: [{ ...budget, maxInitialRawBytes: 1, maxInitialGzipBytes: 1 }],
|
||||
};
|
||||
const runResult = run(fixture().dir, enforce);
|
||||
expect(runResult.status).toBe(1);
|
||||
expect(runResult.stderr).toContain("Performance budgets enforced");
|
||||
expect(runResult.stderr).toContain("/news (over-budget)");
|
||||
});
|
||||
it("fails when a route cannot be measured, so broken manifests do not pass quietly", () => {
|
||||
const enforce = { ...config, mode: "enforce" };
|
||||
const runResult = run(path.join(fixture().dir, "missing"), enforce);
|
||||
expect(runResult.status).toBe(1);
|
||||
expect(runResult.stderr).toContain("/news (unavailable)");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -18,7 +18,7 @@ const hasCompose =
|
||||
}).status === 0;
|
||||
|
||||
it.skipIf(!hasCompose)(
|
||||
"loads the loopback profile from .env for bare Compose commands without changing mounts or host networking",
|
||||
"loads the loopback override from .env for the cms service without changing mounts or host networking",
|
||||
() => {
|
||||
const directory = mkdtempSync(path.join(tmpdir(), "cms-proxy-config-"));
|
||||
try {
|
||||
@@ -50,9 +50,12 @@ it.skipIf(!hasCompose)(
|
||||
["CMS_RELEASE", "HOSTNAME", "PORT"].includes(key)
|
||||
)
|
||||
delete environment[key];
|
||||
// `cms` is behind the `cms` profile so a bare `docker compose up -d`
|
||||
// can never squat port 3002 on a CI-managed host. Selecting the
|
||||
// profile here keeps the loopback/port-override contract under test.
|
||||
const result = spawnSync(
|
||||
"docker",
|
||||
["compose", "config", "--format", "json"],
|
||||
["compose", "--profile", "cms", "config", "--format", "json"],
|
||||
{ cwd: directory, env: environment, encoding: "utf8", timeout: 15_000 },
|
||||
);
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
|
||||
@@ -118,6 +118,13 @@ systemd_cmd=()
|
||||
# starten. De markering maakt dat afdwingbaar in next.config.ts.
|
||||
export CMS_MEMORY_CAPPED=1
|
||||
|
||||
# Gebruik hoogstens de helft van de cores, zodat de machine niet op 100% CPU
|
||||
# piekt, maar behoud voldoende parallelle kracht op (Ryzen) hosts.
|
||||
cpu_count="$(nproc 2>/dev/null || echo 1)"
|
||||
worker_cores=$((cpu_count / 2))
|
||||
[ "$worker_cores" -lt 1 ] && worker_cores=1
|
||||
cpu_affinity="0-$((worker_cores - 1))"
|
||||
|
||||
# Echt proberen, niet alleen uitzoeken of het bestand bestaat: `systemd-run`
|
||||
# zonder rechten faalt met "Access denied", en dat moet dan een nette
|
||||
# terugval naar ulimit worden in plaats van een kapotte build.
|
||||
@@ -174,7 +181,7 @@ run_systemd() {
|
||||
# expliciet: deze host heeft geen swap.
|
||||
local high=$((effective * 95 / 100))
|
||||
echo "[mem-cap] systemd cgroup MemoryHigh=$((high / 1024 / 1024))MB MemoryMax=$((effective / 1024 / 1024))MB MemorySwapMax=0: $*" >&2
|
||||
exec "${systemd_cmd[@]}" -p "MemoryHigh=$high" -p "MemoryMax=$effective" -p "MemorySwapMax=0" "$@"
|
||||
exec "${systemd_cmd[@]}" -E "PATH=$PATH" -p "MemoryHigh=$high" -p "MemoryMax=$effective" -p "MemorySwapMax=0" -p "AllowedCPUs=$cpu_affinity" "$@"
|
||||
}
|
||||
|
||||
run_ulimit() {
|
||||
@@ -186,7 +193,7 @@ run_ulimit() {
|
||||
echo "[mem-cap] ulimit -v $virtual_kb werd geweigerd" >&2
|
||||
return 1
|
||||
}
|
||||
exec "$@"
|
||||
exec taskset -c "$cpu_affinity" "$@"
|
||||
}
|
||||
|
||||
run_refuse() {
|
||||
@@ -197,7 +204,7 @@ run_refuse() {
|
||||
|
||||
run_opted_out() {
|
||||
echo "[mem-cap] WAARSCHUWING: plafond bewust uitgeschakeld, dit commando kan de machine laten OOM-killed worden: $*" >&2
|
||||
exec "$@"
|
||||
exec taskset -c "$cpu_affinity" "$@"
|
||||
}
|
||||
|
||||
case "$backend" in
|
||||
|
||||
@@ -60,7 +60,6 @@ export async function uploadBadge(formData: FormData): Promise<void> {
|
||||
try {
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
const gif = await toBadgeGif(buffer);
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await writeFile(target, gif);
|
||||
} catch {
|
||||
back("error", "Could not process or write the badge file");
|
||||
|
||||
@@ -32,7 +32,7 @@ async function upsertSetting(key: string, value: string): Promise<void> {
|
||||
.values({
|
||||
key,
|
||||
value,
|
||||
// eslint-disable-next-line security/detect-object-injection -- key is one of 3 known const values
|
||||
// key is one of 3 known const values
|
||||
comment: COMMENTS[key] ?? null,
|
||||
})
|
||||
.onDuplicateKeyUpdate({ set: { value } });
|
||||
|
||||
@@ -71,10 +71,10 @@ export async function savePoints(formData: FormData): Promise<void> {
|
||||
POINTS_KEYS.map((key) =>
|
||||
db
|
||||
.insert(WebsiteSetting)
|
||||
// eslint-disable-next-line security/detect-object-injection -- key from POINTS_KEYS const
|
||||
// key from POINTS_KEYS const
|
||||
.values({ key, value: values[key], comment: "Radio points" })
|
||||
.onDuplicateKeyUpdate({
|
||||
// eslint-disable-next-line security/detect-object-injection -- key from POINTS_KEYS const
|
||||
// key from POINTS_KEYS const
|
||||
set: { value: values[key] },
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -112,7 +112,7 @@ export async function saveTheme(formData: FormData): Promise<void> {
|
||||
export async function applyPreset(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
|
||||
const name = String(formData.get("preset") ?? "").normalize("NFC");
|
||||
// eslint-disable-next-line security/detect-object-injection -- guarded by null check below
|
||||
// guarded by null check below
|
||||
const preset = PRESETS[name];
|
||||
if (!preset) redirect("/admin/theme");
|
||||
|
||||
|
||||
+205
-100
@@ -24,7 +24,10 @@ import { CatalogItems, db, ItemsBase, queryRows } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { logAudit } from "@/lib/services/audit";
|
||||
import { withCatalogExport } from "@/lib/services/catalog-git-queue";
|
||||
import { allocateCatalogItemId } from "@/lib/services/furni-import";
|
||||
import {
|
||||
allocateCatalogItemId,
|
||||
reserveCatalogItemIds,
|
||||
} from "@/lib/services/furni-import";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
import { translateItemsSchema } from "@/lib/validators/catalog";
|
||||
|
||||
@@ -133,6 +136,138 @@ export async function createCatalogItem(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk insert catalog items in ONE transaction instead of N per-item
|
||||
* transactions + N INSERT queries. Same result as calling insertCatalogItemRow
|
||||
* per row, but far faster for large batches. Side effects (audit, revalidate,
|
||||
* RCON refresh) are left to the caller, which wraps this in withCatalogExport.
|
||||
*/
|
||||
export async function bulkInsertCatalogItems(data: {
|
||||
pageId: number;
|
||||
staffId: number;
|
||||
rows: Array<{
|
||||
baseId: number;
|
||||
credits?: number;
|
||||
points?: number;
|
||||
pointsType?: number;
|
||||
amount?: number;
|
||||
}>;
|
||||
}): Promise<{ created: number; failed: number }> {
|
||||
const { pageId, staffId, rows } = data;
|
||||
if (rows.length === 0) return { created: 0, failed: 0 };
|
||||
|
||||
const baseIds = [...new Set(rows.map((r) => r.baseId))];
|
||||
const bases = await db
|
||||
.select({
|
||||
id: ItemsBase.id,
|
||||
publicName: ItemsBase.publicName,
|
||||
itemName: ItemsBase.itemName,
|
||||
})
|
||||
.from(ItemsBase)
|
||||
.where(inArray(ItemsBase.id, baseIds));
|
||||
const baseMap = new Map(bases.map((b) => [b.id, b]));
|
||||
|
||||
const ids = await reserveCatalogItemIds(rows.length);
|
||||
|
||||
const maxOrder = await queryRows<{ maxOrder: number }>(sql`
|
||||
SELECT COALESCE(MAX(order_number), 0) AS maxOrder FROM catalog_items WHERE page_id = ${String(pageId)}
|
||||
`);
|
||||
let nextOrder = Number(maxOrder[0]?.maxOrder ?? 0) + 1;
|
||||
|
||||
const values: Array<{
|
||||
id: number;
|
||||
pageId: string;
|
||||
itemIds: string;
|
||||
catalogName: string;
|
||||
costCredits: number;
|
||||
costPoints: number;
|
||||
pointsType: number;
|
||||
amount: number;
|
||||
orderNumber: number;
|
||||
offerId: number;
|
||||
songId: number;
|
||||
limitedSells: number;
|
||||
limitedStack: number;
|
||||
extradata: string;
|
||||
haveOffer: string;
|
||||
clubOnly: string;
|
||||
}> = [];
|
||||
|
||||
let failed = 0;
|
||||
let validIdx = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
const base = baseMap.get(row.baseId);
|
||||
if (!base) {
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
values.push({
|
||||
id: ids[validIdx],
|
||||
pageId: String(pageId),
|
||||
itemIds: String(row.baseId),
|
||||
catalogName: base.publicName || base.itemName || String(row.baseId),
|
||||
costCredits: row.credits ?? 0,
|
||||
costPoints: row.points ?? 0,
|
||||
pointsType: row.pointsType ?? 0,
|
||||
amount: row.amount ?? 1,
|
||||
orderNumber: nextOrder++,
|
||||
offerId: -1,
|
||||
songId: 0,
|
||||
limitedSells: 0,
|
||||
limitedStack: 0,
|
||||
extradata: "",
|
||||
haveOffer: "1",
|
||||
clubOnly: "0",
|
||||
});
|
||||
validIdx++;
|
||||
}
|
||||
|
||||
if (values.length === 0) return { created: 0, failed };
|
||||
|
||||
const allFurnitureIds = [...new Set(values.map((v) => Number(v.itemIds)))];
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.execute(
|
||||
sql`SELECT id FROM catalog_pages WHERE id=${String(pageId)} FOR UPDATE`,
|
||||
);
|
||||
if (allFurnitureIds.length) {
|
||||
await tx.execute(
|
||||
sql`SELECT id FROM items_base WHERE id IN (${sql.join(allFurnitureIds, sql`, `)}) ORDER BY id FOR UPDATE`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const v of values) {
|
||||
await tx.execute(sql`
|
||||
INSERT INTO catalog_items (
|
||||
id, page_id, item_ids, catalog_name,
|
||||
cost_credits, cost_points, points_type, amount,
|
||||
order_number, offer_id, song_id,
|
||||
limited_sells, limited_stack, extradata, have_offer, club_only
|
||||
) VALUES (
|
||||
${v.id}, ${v.pageId}, ${v.itemIds}, ${v.catalogName},
|
||||
${v.costCredits}, ${v.costPoints}, ${v.pointsType}, ${v.amount},
|
||||
${v.orderNumber}, ${v.offerId}, ${v.songId},
|
||||
${v.limitedSells}, ${v.limitedStack}, ${v.extradata},
|
||||
${v.haveOffer}, ${v.clubOnly}
|
||||
)
|
||||
`);
|
||||
}
|
||||
});
|
||||
|
||||
await sendCatalogUpdate();
|
||||
await logStaffActivity({
|
||||
staffId,
|
||||
action: "catalog_items_bulk_create",
|
||||
description: `Bulk inserted ${values.length} catalog item(s) on page #${pageId}`,
|
||||
targetType: "catalog_page",
|
||||
targetId: pageId,
|
||||
});
|
||||
revalidatePath("/admin/catalog");
|
||||
|
||||
return { created: values.length, failed };
|
||||
}
|
||||
|
||||
/** Bulk create with one RCON refresh at the end. */
|
||||
export async function bulkCreateCatalogItems({
|
||||
pageId,
|
||||
@@ -155,67 +290,17 @@ export async function bulkCreateCatalogItems({
|
||||
return { ok: false as const, error: "Max 500 items per bulk import" };
|
||||
}
|
||||
|
||||
const baseIds = [...new Set(rows.map((r) => r.baseId))];
|
||||
const bases = await db
|
||||
.select({
|
||||
id: ItemsBase.id,
|
||||
publicName: ItemsBase.publicName,
|
||||
itemName: ItemsBase.itemName,
|
||||
})
|
||||
.from(ItemsBase)
|
||||
.where(inArray(ItemsBase.id, baseIds));
|
||||
const baseMap = new Map(bases.map((b) => [b.id, b]));
|
||||
|
||||
let created = 0;
|
||||
let failed = 0;
|
||||
|
||||
// Find the next order number on the target page to avoid collisions.
|
||||
const maxOrder = await queryRows<{ maxOrder: number }>(sql`
|
||||
SELECT COALESCE(MAX(order_number), 0) AS maxOrder FROM catalog_items WHERE page_id = ${String(pageId)}
|
||||
`);
|
||||
let nextOrder = Number(maxOrder[0]?.maxOrder ?? 0) + 1;
|
||||
|
||||
for (const row of rows) {
|
||||
const base = baseMap.get(row.baseId);
|
||||
if (!base) {
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await insertCatalogItemRow({
|
||||
pageId,
|
||||
itemIds: String(row.baseId),
|
||||
catalogName: base.publicName || base.itemName || String(row.baseId),
|
||||
costCredits: row.credits ?? 0,
|
||||
costPoints: row.points ?? 0,
|
||||
pointsType: row.pointsType ?? 0,
|
||||
amount: 1,
|
||||
limitedSells: 0,
|
||||
limitedStack: 0,
|
||||
orderNumber: nextOrder++,
|
||||
offerId: -1,
|
||||
songId: 0,
|
||||
haveOffer: "1",
|
||||
clubOnly: "0",
|
||||
extradata: "",
|
||||
});
|
||||
created++;
|
||||
} catch {
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
if (created > 0) {
|
||||
await sendCatalogUpdate();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "catalog_items_bulk_create",
|
||||
description: `Bulk imported ${created} catalog item(s) on page #${pageId}`,
|
||||
targetType: "catalog_page",
|
||||
targetId: pageId,
|
||||
});
|
||||
revalidatePath("/admin/catalog");
|
||||
}
|
||||
const { created, failed } = await bulkInsertCatalogItems({
|
||||
pageId,
|
||||
staffId: staff.id,
|
||||
rows: rows.map((r) => ({
|
||||
baseId: r.baseId,
|
||||
credits: r.credits,
|
||||
points: r.points,
|
||||
pointsType: r.pointsType,
|
||||
amount: 1,
|
||||
})),
|
||||
});
|
||||
|
||||
return { ok: true as const, data: { created, failed } };
|
||||
});
|
||||
@@ -504,6 +589,19 @@ export async function translateCatalogItems(input: {
|
||||
"@/lib/services/furni-data-i18n"
|
||||
);
|
||||
|
||||
const itemIds = items.map((i) => i.id);
|
||||
const allBases = await db
|
||||
.select({
|
||||
id: ItemsBase.id,
|
||||
publicName: ItemsBase.publicName,
|
||||
itemName: ItemsBase.itemName,
|
||||
type: ItemsBase.type,
|
||||
spriteId: ItemsBase.spriteId,
|
||||
})
|
||||
.from(ItemsBase)
|
||||
.where(inArray(ItemsBase.id, itemIds));
|
||||
const baseById = new Map(allBases.map((b) => [b.id, b]));
|
||||
|
||||
let namesUpdated = 0;
|
||||
let descriptionsUpdated = 0;
|
||||
const furniPatches: Array<{
|
||||
@@ -519,19 +617,14 @@ export async function translateCatalogItems(input: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
}> = [];
|
||||
const nameUpdates: Array<{ id: number; nextName: string }> = [];
|
||||
const catalogNameUpdates: Array<{
|
||||
idStr: string;
|
||||
nextName: string;
|
||||
}> = [];
|
||||
|
||||
for (const item of items) {
|
||||
const [base] = await db
|
||||
.select({
|
||||
id: ItemsBase.id,
|
||||
publicName: ItemsBase.publicName,
|
||||
itemName: ItemsBase.itemName,
|
||||
type: ItemsBase.type,
|
||||
spriteId: ItemsBase.spriteId,
|
||||
})
|
||||
.from(ItemsBase)
|
||||
.where(eq(ItemsBase.id, item.id))
|
||||
.limit(1);
|
||||
const base = baseById.get(item.id);
|
||||
if (!base) continue;
|
||||
|
||||
const nextName = item.publicName?.trim() ?? "";
|
||||
@@ -540,33 +633,11 @@ export async function translateCatalogItems(input: {
|
||||
nextName !== "" && nextName !== (base.publicName ?? "");
|
||||
|
||||
if (nameChanged) {
|
||||
await db
|
||||
.update(ItemsBase)
|
||||
.set({ publicName: nextName })
|
||||
.where(eq(ItemsBase.id, base.id));
|
||||
const idStr = String(base.id);
|
||||
const related = await db
|
||||
.select({
|
||||
id: CatalogItems.id,
|
||||
catalogName: CatalogItems.catalogName,
|
||||
})
|
||||
.from(CatalogItems)
|
||||
.where(
|
||||
or(
|
||||
eq(CatalogItems.itemIds, idStr),
|
||||
like(CatalogItems.itemIds, `${idStr};%`),
|
||||
like(CatalogItems.itemIds, `%;${idStr};%`),
|
||||
like(CatalogItems.itemIds, `%;${idStr}`),
|
||||
),
|
||||
);
|
||||
for (const row of related) {
|
||||
if (row.catalogName !== nextName) {
|
||||
await db
|
||||
.update(CatalogItems)
|
||||
.set({ catalogName: nextName })
|
||||
.where(eq(CatalogItems.id, row.id));
|
||||
}
|
||||
}
|
||||
nameUpdates.push({ id: base.id, nextName });
|
||||
catalogNameUpdates.push({
|
||||
idStr: String(base.id),
|
||||
nextName,
|
||||
});
|
||||
namesUpdated++;
|
||||
}
|
||||
|
||||
@@ -589,6 +660,40 @@ export async function translateCatalogItems(input: {
|
||||
}
|
||||
}
|
||||
|
||||
// Batch ItemsBase name updates.
|
||||
for (const { id, nextName } of nameUpdates) {
|
||||
await db
|
||||
.update(ItemsBase)
|
||||
.set({ publicName: nextName })
|
||||
.where(eq(ItemsBase.id, id));
|
||||
}
|
||||
|
||||
// Batch CatalogItems name updates: one SELECT + UPDATE per idStr.
|
||||
for (const { idStr, nextName } of catalogNameUpdates) {
|
||||
const related = await db
|
||||
.select({
|
||||
id: CatalogItems.id,
|
||||
catalogName: CatalogItems.catalogName,
|
||||
})
|
||||
.from(CatalogItems)
|
||||
.where(
|
||||
or(
|
||||
eq(CatalogItems.itemIds, idStr),
|
||||
like(CatalogItems.itemIds, `${idStr};%`),
|
||||
like(CatalogItems.itemIds, `%;${idStr};%`),
|
||||
like(CatalogItems.itemIds, `%;${idStr}`),
|
||||
),
|
||||
);
|
||||
for (const row of related) {
|
||||
if (row.catalogName !== nextName) {
|
||||
await db
|
||||
.update(CatalogItems)
|
||||
.set({ catalogName: nextName })
|
||||
.where(eq(CatalogItems.id, row.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const furniResult =
|
||||
furniPatches.length > 0
|
||||
? await patchFurniEntryNames(furniPatches)
|
||||
|
||||
+15
-47
@@ -2,7 +2,10 @@
|
||||
|
||||
import { inArray, sql } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { insertCatalogItemRow } from "@/actions/catalog-items";
|
||||
import {
|
||||
bulkInsertCatalogItems,
|
||||
insertCatalogItemRow,
|
||||
} from "@/actions/catalog-items";
|
||||
import { catalogFailure } from "@/features/catalog/server/errors";
|
||||
import {
|
||||
createPageCommand,
|
||||
@@ -279,52 +282,17 @@ export async function createAutoCategory(input: {
|
||||
}
|
||||
|
||||
const rows = input.offerRows ?? [];
|
||||
const baseIds = [...new Set(rows.map((r) => r.baseId))];
|
||||
const bases = await db
|
||||
.select({
|
||||
id: ItemsBase.id,
|
||||
publicName: ItemsBase.publicName,
|
||||
itemName: ItemsBase.itemName,
|
||||
})
|
||||
.from(ItemsBase)
|
||||
.where(inArray(ItemsBase.id, baseIds));
|
||||
const baseMap = new Map(bases.map((b) => [b.id, b]));
|
||||
|
||||
const maxOrder = await queryRows<{ maxOrder: number }>(sql`
|
||||
SELECT COALESCE(MAX(order_number), 0) AS maxOrder FROM catalog_items WHERE page_id = ${String(pageId)}
|
||||
`);
|
||||
let nextOrder = Number(maxOrder[0]?.maxOrder ?? 0) + 1;
|
||||
|
||||
let created = 0;
|
||||
for (const row of rows) {
|
||||
const base = baseMap.get(row.baseId);
|
||||
if (!base) continue;
|
||||
try {
|
||||
await insertCatalogItemRow({
|
||||
pageId,
|
||||
itemIds: String(row.baseId),
|
||||
catalogName: base.publicName || base.itemName || String(row.baseId),
|
||||
costCredits: row.credits ?? 0,
|
||||
costPoints: row.points ?? 0,
|
||||
pointsType: row.pointsType ?? 0,
|
||||
amount: row.amount ?? 1,
|
||||
limitedSells: 0,
|
||||
limitedStack: 0,
|
||||
orderNumber: nextOrder++,
|
||||
offerId: -1,
|
||||
songId: 0,
|
||||
haveOffer: "1",
|
||||
clubOnly: "0",
|
||||
extradata: "",
|
||||
});
|
||||
created++;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[createAutoCategory] failed to insert item #${row.baseId}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
const { created } = await bulkInsertCatalogItems({
|
||||
pageId,
|
||||
staffId: staff.id,
|
||||
rows: rows.map((r) => ({
|
||||
baseId: r.baseId,
|
||||
credits: r.credits,
|
||||
points: r.points,
|
||||
pointsType: r.pointsType,
|
||||
amount: r.amount,
|
||||
})),
|
||||
});
|
||||
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { redirect } from "next/navigation";
|
||||
import { env } from "@/env";
|
||||
import { invalidateLoginCache } from "@/lib/auth/login-core";
|
||||
import { hashPassword } from "@/lib/auth/password";
|
||||
import { passwordPolicy } from "@/lib/auth/password-policy";
|
||||
import { revokeUserCredentials } from "@/lib/auth/session-revocation";
|
||||
import { db, PasswordReset, User } from "@/lib/db";
|
||||
import { logger } from "@/lib/logger";
|
||||
@@ -109,7 +110,11 @@ export async function resetPassword(formData: FormData): Promise<void> {
|
||||
}
|
||||
|
||||
let error: "password" | "invalid" | "failed" | null = null;
|
||||
if (password.length < 12) error = "password";
|
||||
if (
|
||||
password.length < passwordPolicy.minLength ||
|
||||
password.length > passwordPolicy.maxLength
|
||||
)
|
||||
error = "password";
|
||||
|
||||
if (!error) {
|
||||
try {
|
||||
|
||||
+15
-8
@@ -5,6 +5,8 @@ import { after } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { sendVerification } from "@/lib/auth/email-verification";
|
||||
import { hashPassword } from "@/lib/auth/password";
|
||||
import { passwordPolicy } from "@/lib/auth/password-policy";
|
||||
import { DEFAULT_AVATAR_FIGURE } from "@/lib/avatar-figure";
|
||||
import { invalidateKey } from "@/lib/cached-db";
|
||||
import { db, User } from "@/lib/db";
|
||||
import { logger } from "@/lib/logger";
|
||||
@@ -82,6 +84,11 @@ function hasDisposableEmailDomain(email: string): boolean {
|
||||
return domain ? DISPOSABLE_EMAIL_DOMAINS.has(domain) : false;
|
||||
}
|
||||
|
||||
// Rendered into the schema below and mapped back to a locale-independent code
|
||||
// via `ZOD_MESSAGE_CODES`, so the length always reflects the configured policy.
|
||||
const PASSWORD_MIN_MESSAGE = `Password must be at least ${passwordPolicy.minLength} characters`;
|
||||
const PASSWORD_MAX_MESSAGE = "Password is too long";
|
||||
|
||||
const registerSchema = z
|
||||
.object({
|
||||
username: z
|
||||
@@ -104,8 +111,8 @@ const registerSchema = z
|
||||
),
|
||||
password: z
|
||||
.string()
|
||||
.min(12, "Password must be at least 12 characters") // Increased min length
|
||||
.max(128, "Password is too long") // Added max length
|
||||
.min(passwordPolicy.minLength, PASSWORD_MIN_MESSAGE)
|
||||
.max(passwordPolicy.maxLength, PASSWORD_MAX_MESSAGE)
|
||||
.regex(/[A-Z]/, "Password must contain at least one uppercase letter")
|
||||
.regex(/[a-z]/, "Password must contain at least one lowercase letter")
|
||||
.regex(/[0-9]/, "Password must contain at least one digit")
|
||||
@@ -159,8 +166,8 @@ const ZOD_MESSAGE_CODES: Record<string, RegisterErrorCode> = {
|
||||
"This username is reserved": "usernameReserved",
|
||||
"Enter a valid email address": "emailValid",
|
||||
"Temporary email domains are not allowed": "emailDisposable",
|
||||
"Password must be at least 12 characters": "passwordMinLength",
|
||||
"Password is too long": "passwordMaxLength",
|
||||
[PASSWORD_MIN_MESSAGE]: "passwordMinLength",
|
||||
[PASSWORD_MAX_MESSAGE]: "passwordMaxLength",
|
||||
"Password must contain at least one uppercase letter": "passwordUpper",
|
||||
"Password must contain at least one lowercase letter": "passwordLower",
|
||||
"Password must contain at least one digit": "passwordDigit",
|
||||
@@ -169,10 +176,10 @@ const ZOD_MESSAGE_CODES: Record<string, RegisterErrorCode> = {
|
||||
};
|
||||
|
||||
// A valid starter Habbo figure so the avatar renders in-client immediately.
|
||||
// The hair part needs a colour slot (`hr-100-1`, not `hr-100-`) or the imager
|
||||
// rejects the whole figure with 400 "Invalid figure format" and every avatar
|
||||
// for the user renders broken.
|
||||
const DEFAULT_LOOK = "hr-100-1.hd-180-1.ch-255-66.lg-280-110.sh-305-62";
|
||||
// Shared with the imager URL builder, which normalises malformed stored looks
|
||||
// to the same figure (the hair part needs a colour slot: `hr-100-1`, not
|
||||
// `hr-100-`).
|
||||
const DEFAULT_LOOK = DEFAULT_AVATAR_FIGURE;
|
||||
|
||||
export interface RegisterState {
|
||||
error: string | null;
|
||||
|
||||
@@ -31,9 +31,7 @@ export async function saveFavicon(
|
||||
}
|
||||
|
||||
const buffer = upload.bytes;
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await mkdir(baseDir, { recursive: true });
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await writeFile(filePath, buffer);
|
||||
|
||||
const url = `/api/media/favicon/${filename}`;
|
||||
@@ -46,7 +44,6 @@ export async function saveFavicon(
|
||||
const oldPath = path.resolve(baseDir, oldName);
|
||||
if (oldPath.startsWith(baseDir + path.sep)) {
|
||||
try {
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await unlink(oldPath);
|
||||
} catch {
|
||||
/* ignore if file doesn't exist */
|
||||
@@ -85,7 +82,6 @@ export async function deleteFavicon(): Promise<{
|
||||
const oldPath = path.resolve(baseDir, oldName);
|
||||
if (oldPath.startsWith(baseDir + path.sep)) {
|
||||
try {
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await unlink(oldPath);
|
||||
} catch {
|
||||
/* ignore */
|
||||
|
||||
@@ -29,9 +29,7 @@ export async function saveLogo(
|
||||
}
|
||||
|
||||
const buffer = upload.bytes;
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await mkdir(baseDir, { recursive: true });
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await writeFile(filePath, buffer);
|
||||
|
||||
const url = `/api/media/logo/${filename}`;
|
||||
|
||||
@@ -278,14 +278,12 @@ export async function uploadBackground(formData: FormData): Promise<void> {
|
||||
|
||||
let uploaded = false;
|
||||
try {
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await mkdir(MEDIA_ROOT, { recursive: true });
|
||||
const ext = file.name.split(".").pop() ?? "png";
|
||||
const name = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
|
||||
const bytes = await file.arrayBuffer();
|
||||
const filePath = resolveMediaPath(name);
|
||||
if (filePath.startsWith(MEDIA_ROOT + path.sep)) {
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await writeFile(filePath, Buffer.from(bytes));
|
||||
await writeSetting("background_image", `/api/media/${name}`);
|
||||
await writeSetting(
|
||||
@@ -319,7 +317,7 @@ export async function applyPresetManager(formData: FormData): Promise<void> {
|
||||
const name = String(formData.get("preset") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
// eslint-disable-next-line security/detect-object-injection -- guarded by null check below
|
||||
// guarded by null check below
|
||||
const preset = PRESETS[name];
|
||||
if (!preset) redirect(MANAGER_PATH);
|
||||
|
||||
|
||||
@@ -142,7 +142,6 @@ export default async function ApplyStaffPage({
|
||||
<article key={String(position.id)} className="content-card">
|
||||
<div className="content-card-head">
|
||||
{team?.badge ? (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
src={`${BADGE_IMG_BASE}=${encodeURIComponent(team.badge)}`}
|
||||
alt={rankName}
|
||||
|
||||
@@ -120,7 +120,6 @@ export default async function ApplyTeamPage({
|
||||
<article key={String(team.id)} className="content-card">
|
||||
<div className="content-card-head">
|
||||
{team.badge ? (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
src={`${BADGE_IMG_BASE}=${encodeURIComponent(team.badge)}`}
|
||||
alt={team.rankName}
|
||||
|
||||
@@ -48,7 +48,6 @@ export default async function BadgesPage() {
|
||||
alignItems: "flex-start",
|
||||
}}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={`${BADGE_IMG_BASE}=${encodeURIComponent(badge.badgeKey)}`}
|
||||
alt={badge.badgeName}
|
||||
|
||||
@@ -190,7 +190,6 @@ export default async function DrawBadgePage({
|
||||
{b.badgeUrl ? (
|
||||
// Badge images are arbitrary external URLs stored by the
|
||||
// draw-badge tool, so a plain <img> is correct here.
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={b.badgeUrl}
|
||||
alt=""
|
||||
|
||||
@@ -149,7 +149,6 @@ export default async function GuildPage({
|
||||
style={{ display: "flex", gap: "1.25rem", alignItems: "flex-start" }}
|
||||
>
|
||||
{guild.badge ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={`https://habbo-stories-content.s3.amazonaws.com/badge-part-images/${guild.badge}.gif`}
|
||||
alt={`${guild.name} badge`}
|
||||
|
||||
@@ -74,7 +74,6 @@ export default async function GuildsPage() {
|
||||
}}
|
||||
>
|
||||
{g.badge ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={`https://habbo-stories-content.s3.amazonaws.com/badge-part-images/${g.badge}.gif`}
|
||||
alt={`${g.name} badge`}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ContentCard } from "@/components/public/ui";
|
||||
import { SanitizedHtml } from "@/components/shared/sanitized-html";
|
||||
import { db, WebsiteHelpCenterCategories } from "@/lib/db";
|
||||
import { resolveHotelName } from "@/lib/hotel-name";
|
||||
import { publicAssetExists } from "@/lib/public-asset";
|
||||
import { sanitize } from "@/lib/sanitize";
|
||||
|
||||
// Asset path matches AtomCMS's Blade view: asset('/assets/images/help-center/<image_url>').
|
||||
@@ -99,7 +100,10 @@ export default async function HelpCategoryPage({
|
||||
const title = withHotel(cat.name, hotelName);
|
||||
const content = withHotel(cat.content, hotelName);
|
||||
const buttonText = withHotel(cat.buttonText, hotelName);
|
||||
const imageSrc = cat.imageUrl ? `${HELP_IMAGE_BASE}/${cat.imageUrl}` : "";
|
||||
const imageSrc =
|
||||
cat.imageUrl && publicAssetExists(`${HELP_IMAGE_BASE}/${cat.imageUrl}`)
|
||||
? `${HELP_IMAGE_BASE}/${cat.imageUrl}`
|
||||
: "";
|
||||
const hasButton = Boolean(cat.buttonText && cat.buttonText.trim() !== "");
|
||||
|
||||
return (
|
||||
@@ -110,7 +114,6 @@ export default async function HelpCategoryPage({
|
||||
|
||||
<ContentCard icon="❓" title={title} subtitle={t("topicSubtitle")}>
|
||||
{imageSrc ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt=""
|
||||
|
||||
@@ -23,6 +23,10 @@ import {
|
||||
WebsiteRuleCategories,
|
||||
WebsiteRules,
|
||||
} from "@/lib/db";
|
||||
import { publicAssetExists } from "@/lib/public-asset";
|
||||
|
||||
// Asset path matches AtomCMS's Blade view: asset('/assets/images/help-center/<image_url>').
|
||||
const HELP_IMAGE_BASE = "/assets/images/help-center";
|
||||
|
||||
type HelpCategory = {
|
||||
id: bigint;
|
||||
@@ -149,67 +153,71 @@ export default async function HelpCenterPage() {
|
||||
<EmptyState icon="🧭">{t("topicsEmpty")}</EmptyState>
|
||||
) : (
|
||||
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
|
||||
{categories.map((c) => (
|
||||
<article key={String(c.id)} className="card hover">
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "0.85rem",
|
||||
alignItems: "flex-start",
|
||||
}}
|
||||
>
|
||||
{c.imageUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={c.imageUrl}
|
||||
alt=""
|
||||
width={48}
|
||||
height={48}
|
||||
style={{ flexShrink: 0, objectFit: "contain" }}
|
||||
/>
|
||||
) : null}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<h3 style={{ margin: "0 0 0.35rem", fontSize: "1rem" }}>
|
||||
{c.name}
|
||||
</h3>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
{c.content}
|
||||
</p>
|
||||
{c.buttonUrl && c.buttonText ? (
|
||||
<div style={{ marginTop: "0.85rem" }}>
|
||||
{isExternal(c.buttonUrl) ? (
|
||||
<a
|
||||
className="btn btn-outline"
|
||||
href={c.buttonUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={{
|
||||
background: c.buttonColor,
|
||||
borderColor: c.buttonBorderColor,
|
||||
color: "#3a2c05",
|
||||
}}
|
||||
>
|
||||
{c.buttonText}
|
||||
</a>
|
||||
) : (
|
||||
<Link
|
||||
className="btn btn-outline"
|
||||
href={c.buttonUrl}
|
||||
style={{
|
||||
background: c.buttonColor,
|
||||
borderColor: c.buttonBorderColor,
|
||||
color: "#3a2c05",
|
||||
}}
|
||||
>
|
||||
{c.buttonText}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
{categories.map((c) => {
|
||||
const imageSrc = c.imageUrl
|
||||
? `${HELP_IMAGE_BASE}/${c.imageUrl}`
|
||||
: "";
|
||||
return (
|
||||
<article key={String(c.id)} className="card hover">
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "0.85rem",
|
||||
alignItems: "flex-start",
|
||||
}}
|
||||
>
|
||||
{imageSrc && publicAssetExists(imageSrc) ? (
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt=""
|
||||
width={48}
|
||||
height={48}
|
||||
style={{ flexShrink: 0, objectFit: "contain" }}
|
||||
/>
|
||||
) : null}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<h3 style={{ margin: "0 0 0.35rem", fontSize: "1rem" }}>
|
||||
{c.name}
|
||||
</h3>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
{c.content}
|
||||
</p>
|
||||
{c.buttonUrl && c.buttonText ? (
|
||||
<div style={{ marginTop: "0.85rem" }}>
|
||||
{isExternal(c.buttonUrl) ? (
|
||||
<a
|
||||
className="btn btn-outline"
|
||||
href={c.buttonUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={{
|
||||
background: c.buttonColor,
|
||||
borderColor: c.buttonBorderColor,
|
||||
color: "#3a2c05",
|
||||
}}
|
||||
>
|
||||
{c.buttonText}
|
||||
</a>
|
||||
) : (
|
||||
<Link
|
||||
className="btn btn-outline"
|
||||
href={c.buttonUrl}
|
||||
style={{
|
||||
background: c.buttonColor,
|
||||
borderColor: c.buttonBorderColor,
|
||||
color: "#3a2c05",
|
||||
}}
|
||||
>
|
||||
{c.buttonText}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ContentCard>
|
||||
|
||||
+27
-11
@@ -1,4 +1,5 @@
|
||||
import dynamic from "next/dynamic";
|
||||
import { headers } from "next/headers";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
import { getLocale, getMessages } from "next-intl/server";
|
||||
import type { ReactNode } from "react";
|
||||
@@ -34,28 +35,43 @@ export default async function SiteLayout({
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [session, locale, messages] = await Promise.all([
|
||||
const [session, locale, messages, h] = await Promise.all([
|
||||
auth(),
|
||||
getLocale(),
|
||||
getMessages(),
|
||||
headers(),
|
||||
]);
|
||||
const clientMessages = publicClientMessages(messages);
|
||||
|
||||
// The landing pages render their own AuthTopBar (logo + live online), so the
|
||||
// global site banner / navigation is hidden there to keep them clean and
|
||||
// avoid showing the same chrome twice. Everything else keeps the chrome.
|
||||
const pathname = h.get("x-pathname");
|
||||
const isLandingPage =
|
||||
pathname === "/" || pathname === "/login" || pathname === "/register";
|
||||
|
||||
return (
|
||||
<NextIntlClientProvider locale={locale} messages={clientMessages}>
|
||||
<CloudsField />
|
||||
{/* Site chrome is public: hiding it all for anonymous visitors used to
|
||||
strand them — from /news, /leaderboard or /shop there was no way to
|
||||
reach any other page at all. */}
|
||||
<div data-theme-block="top_header">
|
||||
<TopHeader session={session} />
|
||||
</div>
|
||||
<div data-theme-block="site_header">
|
||||
<SiteHeader />
|
||||
</div>
|
||||
<div data-theme-block="navigation">
|
||||
<Navigation session={session} />
|
||||
</div>
|
||||
reach any other page at all. Only the standalone landing pages opt
|
||||
out, because they carry their own header. */}
|
||||
{!isLandingPage && (
|
||||
<div data-theme-block="top_header">
|
||||
<TopHeader session={session} />
|
||||
</div>
|
||||
)}
|
||||
{!isLandingPage && (
|
||||
<div data-theme-block="site_header">
|
||||
<SiteHeader />
|
||||
</div>
|
||||
)}
|
||||
{!isLandingPage && (
|
||||
<div data-theme-block="navigation">
|
||||
<Navigation session={session} />
|
||||
</div>
|
||||
)}
|
||||
<main>
|
||||
<div
|
||||
data-theme-block="content_grid"
|
||||
|
||||
@@ -171,7 +171,7 @@ export default async function LeaderboardPage({
|
||||
const rows = await (active === "credits"
|
||||
? loadCreditsRows()
|
||||
: active === "diamonds" || active === "duckets"
|
||||
? // eslint-disable-next-line security/detect-object-injection -- active validated as "diamonds"|"duckets" in this branch
|
||||
? // active validated as "diamonds"|"duckets" in this branch
|
||||
loadCurrencyRows(CURRENCY_TYPE[active])
|
||||
: loadSettingsRows(
|
||||
SETTINGS_FIELD[active as keyof typeof SETTINGS_FIELD],
|
||||
|
||||
+26
-164
@@ -1,24 +1,14 @@
|
||||
import { 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";
|
||||
import { AuthAlert } from "@/components/auth/auth-alert";
|
||||
import {
|
||||
AuthPageFrame,
|
||||
AuthUsersCards,
|
||||
} from "@/components/auth/auth-page-frame";
|
||||
AuthLandingView,
|
||||
loadAuthLanding,
|
||||
} from "@/components/auth/auth-landing";
|
||||
import { LoginForm } from "@/components/auth/login-form";
|
||||
import { SurfaceCard } from "@/components/surface-card";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { safeRedirectPath } from "@/lib/auth/safe-redirect";
|
||||
import { cached } from "@/lib/cache";
|
||||
|
||||
import { db, User } from "@/lib/db";
|
||||
import { resolveHotelName } from "@/lib/hotel-name";
|
||||
import { captchaConfig } from "@/lib/services/captcha";
|
||||
import { cachedOnlineCount } from "@/lib/services/public-counters";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("pages.login");
|
||||
@@ -43,13 +33,6 @@ export default async function LoginPage({
|
||||
}>;
|
||||
}) {
|
||||
const t = await getTranslations("pages.login");
|
||||
const [hotelName, cfg, logo] = await Promise.all([
|
||||
resolveHotelName(),
|
||||
captchaConfig(),
|
||||
siteSettings.get("cms_logo", ""),
|
||||
]);
|
||||
const nonce = (await headers()).get("x-nonce") ?? undefined;
|
||||
|
||||
const sp = await searchParams;
|
||||
const redirectTo = safeRedirectPath(sp.from);
|
||||
|
||||
@@ -58,165 +41,44 @@ export default async function LoginPage({
|
||||
const session = await auth();
|
||||
if (session?.user?.id) redirect(redirectTo);
|
||||
|
||||
const [online, recentUsers, latestUsers] = await Promise.all([
|
||||
cachedOnlineCount().catch(() => 0),
|
||||
cached(
|
||||
"auth_online_users",
|
||||
10_000,
|
||||
() =>
|
||||
db
|
||||
.select({ id: User.id, username: User.username, look: User.look })
|
||||
.from(User)
|
||||
.where(eq(User.online, "1"))
|
||||
.limit(8),
|
||||
{ staleMs: 30000 },
|
||||
).catch(() => []),
|
||||
cached(
|
||||
"auth_latest_users",
|
||||
30_000,
|
||||
() =>
|
||||
db
|
||||
.select({ id: User.id, username: User.username, look: User.look })
|
||||
.from(User)
|
||||
.orderBy(desc(User.accountCreated))
|
||||
.limit(8),
|
||||
{ staleMs: 60000 },
|
||||
).catch(() => []),
|
||||
]);
|
||||
const data = await loadAuthLanding();
|
||||
|
||||
// `/login?registered=1` is where the sign-up form lands when it could not
|
||||
// auto sign-in (e-mail verification still pending). Without this notice the
|
||||
// visitor would only see an empty login form and no sign their account
|
||||
// exists.
|
||||
// exists. `?reset=1` comes from a successful password reset; saying so
|
||||
// matters because a silent form reads like the reset failed.
|
||||
const notice =
|
||||
sp.registered === "1" ? (
|
||||
<p
|
||||
role="status"
|
||||
className="auth-alert auth-alert--success animate-fade-in-up m-0 mb-4"
|
||||
>
|
||||
<AuthAlert variant="success" className="mb-4">
|
||||
{t("registeredSuccess")}
|
||||
</p>
|
||||
</AuthAlert>
|
||||
) : sp.reset === "1" ? (
|
||||
// `?reset=1` comes from a successful password reset; saying so matters
|
||||
// because the visitor just changed their password and a silent form
|
||||
// reads like the reset failed.
|
||||
<p
|
||||
role="status"
|
||||
className="auth-alert auth-alert--success animate-fade-in-up m-0 mb-4"
|
||||
>
|
||||
<AuthAlert variant="success" className="mb-4">
|
||||
{t("passwordChanged")}
|
||||
</p>
|
||||
</AuthAlert>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<AuthPageFrame
|
||||
hotelName={hotelName}
|
||||
logo={logo}
|
||||
title={t("title")}
|
||||
subtitle={t("subtitle")}
|
||||
notice={notice}
|
||||
<AuthLandingView
|
||||
data={data}
|
||||
copy={{
|
||||
title: t("title"),
|
||||
subtitle: t("subtitle"),
|
||||
notice,
|
||||
promoTitle: t("welcomeBack"),
|
||||
promoSubtitle: t("welcomeBackSub", { hotelName: data.hotelName }),
|
||||
usersOnlineLabel: t("usersOnline", { count: data.online }),
|
||||
whoIsOnlineLabel: t("whoIsOnline"),
|
||||
newestCitizensLabel: t("newestCitizens"),
|
||||
}}
|
||||
form={
|
||||
<LoginForm
|
||||
redirectTo={redirectTo}
|
||||
captcha={{
|
||||
provider: cfg.provider,
|
||||
siteKey: cfg.siteKey || undefined,
|
||||
field: cfg.field || undefined,
|
||||
}}
|
||||
nonce={nonce}
|
||||
captcha={data.captcha}
|
||||
nonce={data.nonce}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<SurfaceCard
|
||||
className="card-glow relative overflow-hidden p-6 text-center"
|
||||
style={{
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-primary) 20%, transparent)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 10%, transparent), color-mix(in srgb, var(--color-accent) 10%, transparent))",
|
||||
}}
|
||||
/>
|
||||
<div aria-hidden="true" className="absolute inset-0 overflow-hidden">
|
||||
<div className="brand-halo left-1/2 top-0 h-64 w-[460px] max-w-full -translate-x-1/2 -translate-y-1/2" />
|
||||
<div
|
||||
className="aurora-blob -left-16 top-10 h-56 w-56"
|
||||
style={{
|
||||
background:
|
||||
"color-mix(in srgb, var(--color-primary) 45%, transparent)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="aurora-blob -right-16 bottom-0 h-56 w-56"
|
||||
style={{
|
||||
background:
|
||||
"color-mix(in srgb, var(--color-accent) 40%, transparent)",
|
||||
animationDelay: "-8s",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="brand-halo left-1/2 top-8 h-52 w-52 -translate-x-1/2" />
|
||||
<Image
|
||||
src="/assets/images/FrankwithBag.gif"
|
||||
alt="Frank"
|
||||
width={130}
|
||||
height={170}
|
||||
className="relative object-contain mx-auto drop-shadow-xl animate-float"
|
||||
unoptimized
|
||||
/>
|
||||
<div
|
||||
className="inline-flex items-center gap-2 px-3.5 py-1 rounded-full text-xs font-bold uppercase tracking-wider mt-4 mb-3 border"
|
||||
style={{
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-primary) 30%, transparent)",
|
||||
background:
|
||||
"color-mix(in srgb, var(--color-surface) 72%, transparent)",
|
||||
color: "var(--color-primary-readable, var(--color-primary))",
|
||||
boxShadow:
|
||||
"0 8px 20px -12px color-mix(in srgb, var(--color-primary) 70%, transparent)",
|
||||
}}
|
||||
>
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span
|
||||
className="animate-ping absolute inline-flex h-full w-full rounded-full opacity-75"
|
||||
style={{ background: "var(--color-primary)" }}
|
||||
/>
|
||||
<span
|
||||
className="relative inline-flex rounded-full h-2 w-2"
|
||||
style={{ background: "var(--color-primary)" }}
|
||||
/>
|
||||
</span>
|
||||
{t("usersOnline", { count: online })}
|
||||
</div>
|
||||
<h1
|
||||
className="text-xl font-black"
|
||||
style={{
|
||||
color: "var(--color-text-readable)",
|
||||
fontFamily: "var(--font-nunito)",
|
||||
}}
|
||||
>
|
||||
{t("welcomeBack")}
|
||||
</h1>
|
||||
<p
|
||||
className="text-xs mt-1.5 mx-auto max-w-[220px] leading-relaxed"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{t("welcomeBackSub", { hotelName })}
|
||||
</p>
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
|
||||
<AuthUsersCards
|
||||
recentUsers={recentUsers}
|
||||
latestUsers={latestUsers}
|
||||
recentTitle={t("whoIsOnline")}
|
||||
latestTitle={t("newestCitizens")}
|
||||
/>
|
||||
</AuthPageFrame>
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -5,15 +5,22 @@ import type { CSSProperties } from "react";
|
||||
import { postComment } from "@/actions/article-comments";
|
||||
import { toggleReaction } from "@/actions/article-reactions";
|
||||
import Link from "@/components/link";
|
||||
import { JsonLd } from "@/components/public/json-ld";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { SanitizedHtml } from "@/components/shared/sanitized-html";
|
||||
import { articleSlug } from "@/lib/article-input";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { excerpt } from "@/lib/format";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { resolveHotelName } from "@/lib/hotel-name";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { withPublicPagePerformance } from "@/lib/public-page-performance";
|
||||
import { sanitize } from "@/lib/sanitize";
|
||||
import {
|
||||
absoluteAssetUrl,
|
||||
appBaseUrl,
|
||||
buildArticleStructuredData,
|
||||
} from "@/lib/seo";
|
||||
import {
|
||||
getArticleComments,
|
||||
getArticleReactions,
|
||||
@@ -48,24 +55,44 @@ export async function generateMetadata({
|
||||
}: {
|
||||
params: Promise<{ slug: string[] }>;
|
||||
}): Promise<Metadata> {
|
||||
const appUrl = process.env.APP_URL ?? "http://localhost:3000";
|
||||
const appUrl = appBaseUrl();
|
||||
const slug = articleSlug((await params).slug);
|
||||
const article = await getPublishedArticle(slug).catch(() => {
|
||||
logger.error("Public article metadata failed", { module: "news" });
|
||||
return null;
|
||||
});
|
||||
if (!article) return { title: "Article" };
|
||||
const description = excerpt(article.shortStory, 160);
|
||||
const url = `${appUrl}/news/${encodeURIComponent(slug)}`;
|
||||
const image =
|
||||
absoluteAssetUrl(article.image, appUrl) ??
|
||||
`${appUrl}/assets/images/next_header.jpg`;
|
||||
const publishedAt = (article.publishedAt ?? article.publishAt)?.toISOString();
|
||||
const modifiedAt = (
|
||||
article.updatedAt ??
|
||||
article.publishedAt ??
|
||||
article.publishAt
|
||||
)?.toISOString();
|
||||
return {
|
||||
title: article.title,
|
||||
description: excerpt(article.shortStory, 160),
|
||||
alternates: { canonical: `${appUrl}/news/${slug}` },
|
||||
description,
|
||||
alternates: { canonical: url },
|
||||
openGraph: {
|
||||
title: article.title,
|
||||
description: excerpt(article.shortStory, 160),
|
||||
description,
|
||||
type: "article",
|
||||
url: `${appUrl}/news/${slug}`,
|
||||
url,
|
||||
images: [{ url: image, alt: article.title }],
|
||||
...(publishedAt ? { publishedTime: publishedAt } : {}),
|
||||
...(modifiedAt ? { modifiedTime: modifiedAt } : {}),
|
||||
section: "News",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: article.title,
|
||||
description,
|
||||
images: [image],
|
||||
},
|
||||
twitter: { card: "summary", title: article.title },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -117,7 +144,7 @@ async function ArticlePage({
|
||||
|
||||
const requestedPage =
|
||||
comment === "posted" && !commentsPage ? "last" : Number(commentsPage ?? 1);
|
||||
const [reactions, commentsResult] = await Promise.all([
|
||||
const [reactions, commentsResult, hotelName] = await Promise.all([
|
||||
getArticleReactions(articleId, sessionUserId).catch(() => {
|
||||
logger.error("Public article reactions failed", {
|
||||
module: "news",
|
||||
@@ -132,11 +159,27 @@ async function ArticlePage({
|
||||
});
|
||||
return null;
|
||||
}),
|
||||
resolveHotelName(),
|
||||
]);
|
||||
const reactionCounts = reactions?.counts ?? new Map<string, number>();
|
||||
const myReaction = reactions?.myReaction ?? null;
|
||||
const comments = commentsResult?.rows ?? [];
|
||||
const articleHref = `/news/${encodeURIComponent(slug)}`;
|
||||
const structuredDataUrl = `${appBaseUrl()}/news/${encodeURIComponent(slug)}`;
|
||||
const structuredData = buildArticleStructuredData({
|
||||
hotelName,
|
||||
url: structuredDataUrl,
|
||||
title: article.title,
|
||||
description: excerpt(article.shortStory, 160),
|
||||
image: absoluteAssetUrl(article.image, appBaseUrl()),
|
||||
logo: absoluteAssetUrl(article.image, appBaseUrl()),
|
||||
publishedAt: (article.publishedAt ?? article.publishAt)?.toISOString(),
|
||||
modifiedAt: (
|
||||
article.updatedAt ??
|
||||
article.publishedAt ??
|
||||
article.publishAt
|
||||
)?.toISOString(),
|
||||
});
|
||||
const commentsHref = (page: number) =>
|
||||
`${articleHref}?commentsPage=${page}#comments`;
|
||||
const retryHref = commentsPage
|
||||
@@ -160,6 +203,7 @@ async function ArticlePage({
|
||||
|
||||
return (
|
||||
<section className="page-grid">
|
||||
<JsonLd data={structuredData} />
|
||||
{comment === "posted" ? (
|
||||
<div role="status" style={feedbackStyle("success")}>
|
||||
{t("success.posted")}
|
||||
@@ -191,7 +235,6 @@ async function ArticlePage({
|
||||
}
|
||||
>
|
||||
{article.image ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={article.image}
|
||||
alt=""
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { resolveHotelName } from "@/lib/hotel-name";
|
||||
import { buildNewsRss } from "@/lib/news-rss";
|
||||
import { appBaseUrl } from "@/lib/seo";
|
||||
import { getNewsList } from "@/lib/services/news-list";
|
||||
|
||||
// Built per request so the feed never needs a database connection during
|
||||
// `next build`, matching the sitemap and robots routes.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
const [hotelName, items] = await Promise.all([
|
||||
resolveHotelName(),
|
||||
getNewsList(30),
|
||||
]);
|
||||
|
||||
return new Response(
|
||||
buildNewsRss(items, { hotelName, appUrl: appBaseUrl() }),
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/rss+xml; charset=utf-8",
|
||||
"Cache-Control": "public, s-maxage=300, stale-while-revalidate=600",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
+556
-664
@@ -7,22 +7,26 @@ import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
import { AnimatedCounter } from "@/components/animated-counter";
|
||||
import { AuthTopBar } from "@/components/auth/auth-top-bar";
|
||||
import { LoginForm } from "@/components/auth/login-form";
|
||||
import { Clock } from "@/components/clock";
|
||||
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||
import Link from "@/components/link";
|
||||
import { LiveOnlineCounter } from "@/components/live-online-counter";
|
||||
import { Reveal } from "@/components/motion-reveal";
|
||||
import { JsonLd } from "@/components/public/json-ld";
|
||||
import { PublicLoadError } from "@/components/public/load-error";
|
||||
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
|
||||
import { SpotlightCard } from "@/components/spotlight-card";
|
||||
import { ThemeSwitcher } from "@/components/theme-switcher";
|
||||
import { TypewriterText } from "@/components/typewriter-text";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { cached } from "@/lib/cache";
|
||||
import { CameraWeb, db, User } from "@/lib/db";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { resolveHotelName } from "@/lib/hotel-name";
|
||||
import { withPublicPagePerformance } from "@/lib/public-page-performance";
|
||||
import {
|
||||
absoluteAssetUrl,
|
||||
appBaseUrl,
|
||||
buildWebsiteStructuredData,
|
||||
} from "@/lib/seo";
|
||||
import { captchaConfig } from "@/lib/services/captcha";
|
||||
import { getNewsList } from "@/lib/services/news-list";
|
||||
import { loadProfilePrivacyMap } from "@/lib/services/profile-privacy";
|
||||
@@ -40,9 +44,9 @@ import {
|
||||
ICON_CAMERA,
|
||||
ICON_CATALOG,
|
||||
ICON_FRIENDS,
|
||||
ICON_LIGHTHOUSE,
|
||||
ICON_NAV_GOODY,
|
||||
ICON_NAV_ME,
|
||||
ICON_STAR,
|
||||
} from "@/lib/site-icons";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
@@ -50,33 +54,102 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
const tp = await getTranslations("pages.home");
|
||||
const title = tn("home");
|
||||
const description = tp("welcomeBody");
|
||||
const appUrl = appBaseUrl();
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
openGraph: { title, description, type: "website" },
|
||||
alternates: {
|
||||
canonical: appUrl,
|
||||
types: { "application/rss+xml": `${appUrl}/news/rss.xml` },
|
||||
},
|
||||
openGraph: { title, description, type: "website", url: appUrl },
|
||||
};
|
||||
}
|
||||
|
||||
// ── Local visual language (theme-aware, no big white blocks) ──────────
|
||||
const PANEL_BG =
|
||||
"linear-gradient(160deg, color-mix(in srgb, var(--color-primary) 12%, transparent) 0%, color-mix(in srgb, var(--color-surface) 58%, transparent) 52%, color-mix(in srgb, var(--color-accent) 9%, transparent) 100%)";
|
||||
/* ── Local visual language ────────────────────────────────────────────
|
||||
The landing panels used to carry their own translucent washes
|
||||
(surface 44–60 % + a backdrop blur each). Four different milky smears over
|
||||
the sky gradient, and a backdrop snapshot per panel on every scroll. The
|
||||
shared `.panel*` classes in globals.css now own the surface recipe, so the
|
||||
boxes read as one system and cost nothing to paint. */
|
||||
|
||||
const PANEL_STYLE: CSSProperties = {
|
||||
background: PANEL_BG,
|
||||
backdropFilter: "blur(18px)",
|
||||
WebkitBackdropFilter: "blur(18px)",
|
||||
borderColor: "color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
|
||||
boxShadow:
|
||||
"0 0 0 0.5px color-mix(in srgb, var(--color-text-muted) 6%, transparent), 0 24px 60px -30px color-mix(in srgb, #000 28%, transparent)",
|
||||
};
|
||||
// The hero scrim. A photo always needs a dark wash for the copy to stay
|
||||
// readable, so this one stays a fixed dark gradient in both themes.
|
||||
const HERO_SCRIM =
|
||||
"linear-gradient(180deg, rgba(8,11,24,0.80) 0%, rgba(8,11,24,0.58) 38%, rgba(8,11,24,0.42) 62%, rgba(8,11,24,0.60) 85%, color-mix(in srgb, var(--color-background) 92%, transparent) 100%)";
|
||||
|
||||
const HERO_COPY = "rgba(255,255,255,0.80)";
|
||||
const HERO_COPY_STRONG = "rgba(255,255,255,0.96)";
|
||||
|
||||
const DARK_BAND =
|
||||
"linear-gradient(120deg, color-mix(in srgb, var(--color-primary) 30%, #0a0e1e) 0%, color-mix(in srgb, var(--color-accent) 26%, #0a0e1e) 52%, color-mix(in srgb, var(--color-primary) 20%, #0a0e1e) 100%)";
|
||||
|
||||
const GLASS_TILE_BG =
|
||||
"linear-gradient(155deg, color-mix(in srgb, var(--color-surface) 60%, transparent) 0%, color-mix(in srgb, var(--color-surface) 44%, transparent) 60%, color-mix(in srgb, var(--color-accent) 6%, transparent) 100%)";
|
||||
/** Accent used for the nth surface of a repeating set. */
|
||||
function tintFor(index: number): string {
|
||||
return index % 2 === 0 ? "var(--color-primary)" : "var(--color-accent)";
|
||||
}
|
||||
|
||||
interface GlassPanelProps {
|
||||
interface PanelHeaderProps {
|
||||
title: string;
|
||||
icon?: string;
|
||||
actionHref?: string;
|
||||
actionLabel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The header row every landing panel shares: tinted icon chip, title and an
|
||||
* optional "see everything" link.
|
||||
*/
|
||||
function PanelHeader({
|
||||
title,
|
||||
icon,
|
||||
actionHref,
|
||||
actionLabel,
|
||||
}: PanelHeaderProps) {
|
||||
return (
|
||||
<div className="panel-header flex items-center justify-between gap-3 px-5 py-3.5">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
{icon && (
|
||||
<div
|
||||
className="panel-tile flex h-9 w-9 shrink-0 items-center justify-center rounded-xl"
|
||||
style={{ "--panel-tint": "var(--color-primary)" } as CSSProperties}
|
||||
>
|
||||
<Image
|
||||
src={icon}
|
||||
alt=""
|
||||
width={18}
|
||||
height={18}
|
||||
unoptimized
|
||||
className="opacity-90"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<h2
|
||||
className="truncate text-sm font-extrabold tracking-wide"
|
||||
style={{
|
||||
fontFamily: "var(--font-nunito)",
|
||||
color: "var(--color-text-readable)",
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
{actionHref && actionLabel && (
|
||||
<Link
|
||||
href={actionHref}
|
||||
className="shrink-0 text-xs font-bold transition-all duration-300 hover:translate-x-0.5"
|
||||
style={{
|
||||
color: "var(--color-primary-readable, var(--color-primary))",
|
||||
}}
|
||||
>
|
||||
{actionLabel}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PanelProps {
|
||||
children: ReactNode;
|
||||
title?: string;
|
||||
icon?: string;
|
||||
@@ -86,7 +159,8 @@ interface GlassPanelProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function GlassPanel({
|
||||
/** A themed surface with an optional header. */
|
||||
function Panel({
|
||||
children,
|
||||
title,
|
||||
icon,
|
||||
@@ -94,71 +168,22 @@ function GlassPanel({
|
||||
actionLabel,
|
||||
bodyClassName,
|
||||
className,
|
||||
}: GlassPanelProps) {
|
||||
}: PanelProps) {
|
||||
const hasHeader = Boolean(title || icon);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`card-glow card-hairline relative overflow-hidden rounded-2xl border ${
|
||||
className={`panel card-glow card-hairline relative overflow-hidden rounded-[var(--panel-radius)] ${
|
||||
className ?? ""
|
||||
}`}
|
||||
style={PANEL_STYLE}
|
||||
>
|
||||
{hasHeader && (
|
||||
<div
|
||||
className="flex items-center justify-between gap-3 border-b px-5 py-4"
|
||||
style={{
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-text-muted) 8%, transparent)",
|
||||
background:
|
||||
"linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 8%, transparent), transparent 70%)",
|
||||
}}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
{icon && (
|
||||
<div
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl"
|
||||
style={{
|
||||
background:
|
||||
"color-mix(in srgb, var(--color-primary) 14%, transparent)",
|
||||
boxShadow:
|
||||
"inset 0 0 0 1px color-mix(in srgb, var(--color-primary) 18%, transparent)",
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={icon}
|
||||
alt=""
|
||||
width={18}
|
||||
height={18}
|
||||
unoptimized
|
||||
className="opacity-90"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{title && (
|
||||
<h2
|
||||
className="truncate text-sm font-extrabold tracking-wide"
|
||||
style={{
|
||||
fontFamily: "var(--font-nunito)",
|
||||
color: "var(--color-text-readable)",
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
</div>
|
||||
{actionHref && actionLabel && (
|
||||
<Link
|
||||
href={actionHref}
|
||||
className="shrink-0 text-xs font-bold transition-all duration-300 hover:translate-x-0.5"
|
||||
style={{
|
||||
color: "var(--color-primary-readable, var(--color-primary))",
|
||||
}}
|
||||
>
|
||||
{actionLabel}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<PanelHeader
|
||||
title={title ?? ""}
|
||||
icon={icon}
|
||||
actionHref={actionHref}
|
||||
actionLabel={actionLabel}
|
||||
/>
|
||||
)}
|
||||
{hasHeader ? (
|
||||
<div className={bodyClassName ?? "p-5"}>{children}</div>
|
||||
@@ -169,6 +194,56 @@ function GlassPanel({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cover image for a news article. Articles without an upload used to render a
|
||||
* broken <Image>, so they fall back to a themed plate instead.
|
||||
*/
|
||||
function ArticleCover({
|
||||
src,
|
||||
alt,
|
||||
featured,
|
||||
}: {
|
||||
src: string;
|
||||
alt: string;
|
||||
featured: boolean;
|
||||
}) {
|
||||
if (!src) {
|
||||
return (
|
||||
<div
|
||||
className="flex h-full w-full items-center justify-center"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(140deg, color-mix(in srgb, var(--color-primary) 26%, var(--color-surface)), color-mix(in srgb, var(--color-accent) 18%, var(--color-surface)))",
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={ICON_ARTICLE}
|
||||
alt=""
|
||||
width={featured ? 56 : 40}
|
||||
height={featured ? 56 : 40}
|
||||
unoptimized
|
||||
className="opacity-70"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Image
|
||||
src={src}
|
||||
alt={alt}
|
||||
fill
|
||||
sizes={
|
||||
featured
|
||||
? "(max-width: 768px) 100vw, 60vw"
|
||||
: "(max-width: 640px) 100vw, 20vw"
|
||||
}
|
||||
className="object-cover transition-transform duration-700 ease-out group-hover:scale-[1.06]"
|
||||
unoptimized
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
async function getHotelData() {
|
||||
const [
|
||||
hotelName,
|
||||
@@ -238,7 +313,7 @@ async function getHotelData() {
|
||||
};
|
||||
}
|
||||
|
||||
export default async function Home() {
|
||||
async function Home() {
|
||||
const session = await auth();
|
||||
if (session?.user?.id) redirect("/me");
|
||||
|
||||
@@ -300,435 +375,321 @@ export default async function Home() {
|
||||
desc: tp("featuresCommunityDesc"),
|
||||
icon: ICON_NAV_GOODY,
|
||||
},
|
||||
{
|
||||
title: tp("featuresGames"),
|
||||
desc: tp("featuresGamesDesc"),
|
||||
icon: ICON_STAR,
|
||||
},
|
||||
];
|
||||
|
||||
type NewsArticle = NonNullable<typeof articles>[number];
|
||||
|
||||
const renderArticleCard = (a: NewsArticle, featured = false) => (
|
||||
<Link
|
||||
key={a.slug}
|
||||
href={`/news/${a.slug}`}
|
||||
className="group relative w-full overflow-hidden rounded-2xl transition-all duration-500 hover:-translate-y-1 hover:shadow-2xl"
|
||||
style={{
|
||||
height: featured
|
||||
? "clamp(240px, 28vw, 320px)"
|
||||
: "clamp(180px, 20vw, 230px)",
|
||||
backgroundColor: "var(--color-text-muted)",
|
||||
border:
|
||||
"1px solid color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
|
||||
}}
|
||||
>
|
||||
<div className="relative h-full w-full overflow-hidden rounded-2xl">
|
||||
<Image
|
||||
src={a.image}
|
||||
alt={a.title}
|
||||
fill
|
||||
sizes={featured ? "100vw" : "(max-width: 640px) 100vw, 33vw"}
|
||||
className="rounded-2xl object-cover transition-transform duration-700 ease-out group-hover:scale-108"
|
||||
unoptimized
|
||||
/>
|
||||
<div
|
||||
className="absolute right-2.5 top-2.5 flex h-8 w-8 translate-x-1 items-center justify-center rounded-full opacity-0 backdrop-blur-sm transition-all duration-300 group-hover:translate-x-0 group-hover:opacity-100"
|
||||
style={{
|
||||
background: "color-mix(in srgb, #000 45%, transparent)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="text-sm font-bold"
|
||||
style={{ color: "rgba(255,255,255,0.95)" }}
|
||||
const renderArticleCard = (a: NewsArticle, featured = false) => {
|
||||
const published = a.createdAt ? formatDate(a.createdAt, "date", "") : "";
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={a.slug}
|
||||
href={`/news/${a.slug}`}
|
||||
className="group relative block w-full overflow-hidden rounded-[var(--panel-radius)] border transition-all duration-500 hover:-translate-y-1 hover:shadow-2xl"
|
||||
style={{
|
||||
height: featured
|
||||
? "clamp(240px, 28vw, 320px)"
|
||||
: "clamp(180px, 20vw, 230px)",
|
||||
backgroundColor: "var(--color-text-muted)",
|
||||
borderColor: "var(--panel-border)",
|
||||
}}
|
||||
>
|
||||
<div className="relative h-full w-full overflow-hidden rounded-[var(--panel-radius)]">
|
||||
<ArticleCover src={a.image} alt={a.title} featured={featured} />
|
||||
<div
|
||||
className="absolute right-2.5 top-2.5 flex h-8 w-8 translate-x-1 items-center justify-center rounded-full opacity-0 backdrop-blur-sm transition-all duration-300 group-hover:translate-x-0 group-hover:opacity-100"
|
||||
style={{ background: "color-mix(in srgb, #000 45%, transparent)" }}
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="absolute left-2.5 top-2.5 rounded-full px-2 py-0.5 text-[10px] font-bold backdrop-blur-sm"
|
||||
style={{
|
||||
background: "color-mix(in srgb, #000 45%, transparent)",
|
||||
color: "#fff",
|
||||
}}
|
||||
>
|
||||
{formatDate(a.createdAt, "date", "")}
|
||||
</div>
|
||||
<div
|
||||
className="absolute bottom-0 left-0 w-full p-3 sm:p-4"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.4) 50%, transparent 100%)",
|
||||
}}
|
||||
>
|
||||
{featured && (
|
||||
<p className="mb-1 text-[10px] font-bold uppercase tracking-widest opacity-90">
|
||||
{tp("latestArticle")}
|
||||
</p>
|
||||
<span
|
||||
className="text-sm font-bold"
|
||||
style={{ color: HERO_COPY_STRONG }}
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
</div>
|
||||
{published && (
|
||||
<div
|
||||
className="absolute left-2.5 top-2.5 rounded-full px-2 py-0.5 text-[10px] font-bold backdrop-blur-sm"
|
||||
style={{
|
||||
background: "color-mix(in srgb, #000 45%, transparent)",
|
||||
color: "#fff",
|
||||
}}
|
||||
>
|
||||
{published}
|
||||
</div>
|
||||
)}
|
||||
<h3
|
||||
className={`line-clamp-2 font-bold text-shadow-sm ${
|
||||
featured ? "text-base sm:text-2xl" : "text-sm sm:text-base"
|
||||
}`}
|
||||
style={{ color: "rgba(255,255,255,0.95)" }}
|
||||
<div
|
||||
className="absolute bottom-0 left-0 w-full p-3 sm:p-4"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.4) 50%, transparent 100%)",
|
||||
}}
|
||||
>
|
||||
{a.title}
|
||||
</h3>
|
||||
{featured && (
|
||||
<p className="mb-1 text-[10px] font-bold uppercase tracking-widest opacity-90">
|
||||
{tp("latestArticle")}
|
||||
</p>
|
||||
)}
|
||||
<h3
|
||||
className={`line-clamp-2 font-bold text-shadow-sm ${
|
||||
featured ? "text-base sm:text-2xl" : "text-sm sm:text-base"
|
||||
}`}
|
||||
style={{ color: HERO_COPY_STRONG }}
|
||||
>
|
||||
{a.title}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
const exploreLinks = [
|
||||
{ href: "/community", label: tn("community") },
|
||||
{ href: "/news", label: tn("articles") },
|
||||
{ href: "/events", label: tn("events") },
|
||||
{ href: "/photos", label: tn("photos") },
|
||||
{ href: "/radio", label: tn("radio") },
|
||||
{ href: "/shop", label: tn("shop") },
|
||||
{ href: "/rankings", label: tn("rankings") },
|
||||
];
|
||||
|
||||
const statChips = [
|
||||
{ value: users, label: tp("statsCitizens"), color: "var(--color-primary)" },
|
||||
{ value: rooms, label: tp("statsRooms"), color: "var(--color-accent)" },
|
||||
{
|
||||
value: totalPhotos,
|
||||
label: tp("statsPhotos"),
|
||||
color: "var(--color-primary)",
|
||||
},
|
||||
{
|
||||
value: articleCount,
|
||||
label: tp("statsArticles"),
|
||||
color: "var(--color-accent)",
|
||||
},
|
||||
].filter((s) => s.value !== null) as Array<{
|
||||
value: number;
|
||||
label: string;
|
||||
color: string;
|
||||
}>;
|
||||
// One source of truth for the four headline counters. They used to be
|
||||
// rendered twice — as four small chips in the hero and again as a full
|
||||
// panel further down — which made the page feel padded and repeated.
|
||||
// A failed read stays visible as "unavailable" instead of silently
|
||||
// shrinking the row, so a broken counter can never be mistaken for zero.
|
||||
const statItems = [
|
||||
{ value: users, label: tp("statsCitizens"), icon: ICON_FRIENDS },
|
||||
{ value: rooms, label: tp("statsRooms"), icon: ICON_CATALOG },
|
||||
{ value: totalPhotos, label: tp("statsPhotos"), icon: ICON_CAMERA },
|
||||
{ value: articleCount, label: tp("statsArticles"), icon: ICON_ARTICLE },
|
||||
];
|
||||
|
||||
const structuredData = buildWebsiteStructuredData({
|
||||
hotelName,
|
||||
url: appBaseUrl(),
|
||||
logo: absoluteAssetUrl(logo, appBaseUrl()),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-14 pb-16 pt-2 md:gap-20 md:pb-24">
|
||||
{/* ── Top bar ── */}
|
||||
<Reveal className="mx-auto w-full max-w-7xl">
|
||||
<div
|
||||
className="flex items-center justify-between rounded-full border px-3 py-2.5 sm:px-5"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(90deg, color-mix(in srgb, var(--color-surface) 62%, transparent), color-mix(in srgb, var(--color-primary) 7%, transparent))",
|
||||
backdropFilter: "blur(14px)",
|
||||
WebkitBackdropFilter: "blur(14px)",
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-text-muted) 10%, transparent)",
|
||||
boxShadow:
|
||||
"0 1px 3px rgba(0,0,0,0.04), 0 0 0 0.5px color-mix(in srgb, var(--color-text-muted) 6%, transparent)",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
{logo ? (
|
||||
<Image
|
||||
src={logo}
|
||||
alt={hotelName}
|
||||
width={100}
|
||||
height={28}
|
||||
className="shrink-0 object-contain"
|
||||
unoptimized
|
||||
style={{ maxHeight: 26, width: "auto", height: "auto" }}
|
||||
/>
|
||||
) : (
|
||||
<JsonLd data={structuredData} />
|
||||
{/* ── Top bar: logo + live online + language/clock/theme ── */}
|
||||
<div className="mx-auto w-full max-w-7xl">
|
||||
<AuthTopBar hotelName={hotelName} logo={logo} online={online} />
|
||||
</div>
|
||||
|
||||
{/* ── Hero + the headline counters, which straddle the hero edge ── */}
|
||||
<div className="relative w-full">
|
||||
<Reveal className="-mx-3 sm:-mx-4 md:-mx-6">
|
||||
<div className="relative overflow-hidden">
|
||||
<Image
|
||||
src="/assets/images/next_header.jpg"
|
||||
alt=""
|
||||
fill
|
||||
priority
|
||||
sizes="100vw"
|
||||
className="ken-burns"
|
||||
style={{ objectFit: "cover", objectPosition: "center 20%" }}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{ background: HERO_SCRIM }}
|
||||
/>
|
||||
{/* Drifting brand-tinted aurora behind the copy */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 overflow-hidden"
|
||||
>
|
||||
<div
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full"
|
||||
className="aurora-blob -left-24 top-1/4 h-[420px] w-[420px]"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(135deg, var(--color-primary), color-mix(in srgb, var(--color-accent) 60%, var(--color-primary)))",
|
||||
boxShadow:
|
||||
"0 4px 16px -4px color-mix(in srgb, var(--color-primary) 60%, transparent)",
|
||||
"color-mix(in srgb, var(--color-primary) 35%, transparent)",
|
||||
opacity: 0.5,
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={ICON_LIGHTHOUSE}
|
||||
alt=""
|
||||
width={18}
|
||||
height={18}
|
||||
className="shrink-0"
|
||||
unoptimized
|
||||
style={{ filter: "brightness(0) invert(1)" }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<span
|
||||
className="hidden text-sm font-extrabold uppercase tracking-wide sm:inline"
|
||||
style={{ color: "var(--color-text-readable)" }}
|
||||
>
|
||||
{hotelName}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center gap-1 sm:gap-1.5 rounded-full px-1.5 py-1"
|
||||
style={{
|
||||
background:
|
||||
"color-mix(in srgb, var(--color-text-muted) 5%, transparent)",
|
||||
}}
|
||||
>
|
||||
<LanguageSwitcher />
|
||||
<div
|
||||
className="hidden w-px h-5 sm:block"
|
||||
style={{
|
||||
background:
|
||||
"color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
|
||||
}}
|
||||
/>
|
||||
<Clock />
|
||||
<div
|
||||
className="w-px h-5"
|
||||
style={{
|
||||
background:
|
||||
"color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
|
||||
}}
|
||||
/>
|
||||
<ThemeSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* ── Cinematic hero ── */}
|
||||
<Reveal className="-mx-3 sm:-mx-4 md:-mx-6">
|
||||
<div className="relative overflow-hidden">
|
||||
<Image
|
||||
src="/assets/images/next_header.jpg"
|
||||
alt=""
|
||||
fill
|
||||
priority
|
||||
sizes="100vw"
|
||||
className="ken-burns"
|
||||
style={{ objectFit: "cover", objectPosition: "center 20%" }}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(180deg, rgba(8,11,24,0.74) 0%, rgba(8,11,24,0.55) 38%, rgba(8,11,24,0.38) 62%, rgba(8,11,24,0.55) 85%, color-mix(in srgb, var(--color-background) 94%, transparent) 100%)",
|
||||
}}
|
||||
/>
|
||||
{/* Drifting brand-tinted aurora behind the copy */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 overflow-hidden"
|
||||
>
|
||||
<div
|
||||
className="aurora-blob -left-24 top-1/4 h-[420px] w-[420px]"
|
||||
style={{
|
||||
background:
|
||||
"color-mix(in srgb, var(--color-primary) 35%, transparent)",
|
||||
opacity: 0.5,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="aurora-blob -right-24 top-1/3 h-[380px] w-[380px]"
|
||||
style={{
|
||||
background:
|
||||
"color-mix(in srgb, var(--color-accent) 32%, transparent)",
|
||||
animationDelay: "-7s",
|
||||
opacity: 0.45,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="brand-halo left-1/2 top-1/2 h-[420px] w-[720px] max-w-full -translate-x-1/2 -translate-y-1/2"
|
||||
style={{ opacity: 0.5, zIndex: 1 }}
|
||||
/>
|
||||
<div className="relative z-10 mx-auto flex min-h-[540px] w-full max-w-6xl flex-col items-center justify-center px-4 py-24 text-center sm:min-h-[580px] lg:min-h-[640px]">
|
||||
<span
|
||||
className="glass-chip gap-2.5 px-4 py-2"
|
||||
style={{
|
||||
fontWeight: 800,
|
||||
letterSpacing: "0.14em",
|
||||
textTransform: "uppercase",
|
||||
}}
|
||||
>
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span
|
||||
className="absolute inline-flex h-full w-full animate-ping rounded-full opacity-70"
|
||||
style={{ background: "var(--color-primary)" }}
|
||||
/>
|
||||
<span
|
||||
className="relative inline-flex h-2 w-2 rounded-full"
|
||||
style={{ background: "var(--color-primary)" }}
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
className="text-[10px] font-black sm:text-xs"
|
||||
style={{ color: "var(--color-text-readable)" }}
|
||||
>
|
||||
{tp("onlineNow")}
|
||||
</span>
|
||||
{online === null ? (
|
||||
<span className="text-xs">{te("unavailable")}</span>
|
||||
) : (
|
||||
<span
|
||||
className="text-sm sm:text-base"
|
||||
style={{
|
||||
color:
|
||||
"var(--color-primary-readable, var(--color-primary))",
|
||||
fontWeight: 900,
|
||||
}}
|
||||
>
|
||||
<LiveOnlineCounter initial={online} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
<h1
|
||||
className="mt-7 sm:mt-8"
|
||||
style={{ fontFamily: "var(--font-nunito)" }}
|
||||
>
|
||||
<span
|
||||
className="mb-3 block text-xs font-extrabold uppercase tracking-[0.32em] sm:text-sm"
|
||||
style={{ color: "rgba(255,255,255,0.72)" }}
|
||||
>
|
||||
{tp("welcome", { hotelName: "" })}
|
||||
</span>
|
||||
<span className="gradient-text drop-shadow-[0_2px_24px_rgba(0,0,0,0.55)] block text-5xl font-black leading-[1.02] tracking-tight sm:text-6xl md:text-7xl lg:text-8xl">
|
||||
<TypewriterText text={hotelName} />
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<p
|
||||
className="mt-6 max-w-xl text-sm font-semibold leading-relaxed sm:text-base md:text-lg"
|
||||
style={{ color: "rgba(255,255,255,0.78)" }}
|
||||
>
|
||||
{tp("welcomeBody")}
|
||||
</p>
|
||||
|
||||
{recentUsers !== null && recentUsers.length > 0 && (
|
||||
/>
|
||||
<div
|
||||
className="animate-fade-in-up mt-7 inline-flex items-center gap-3 rounded-full border px-3 py-1.5"
|
||||
className="aurora-blob -right-24 top-1/3 h-[380px] w-[380px]"
|
||||
style={{
|
||||
background: "rgba(255,255,255,0.08)",
|
||||
borderColor: "rgba(255,255,255,0.22)",
|
||||
backdropFilter: "blur(12px)",
|
||||
WebkitBackdropFilter: "blur(12px)",
|
||||
animationDelay: "0.08s",
|
||||
background:
|
||||
"color-mix(in srgb, var(--color-accent) 32%, transparent)",
|
||||
animationDelay: "-7s",
|
||||
opacity: 0.45,
|
||||
}}
|
||||
>
|
||||
<div className="flex -space-x-2.5">
|
||||
{recentUsers.slice(0, 4).map((u) => (
|
||||
<UserAvatarThumbnail
|
||||
key={u.username}
|
||||
figure={u.look}
|
||||
alt={u.username}
|
||||
options={{ direction: 2 }}
|
||||
className="h-9 w-9 rounded-full ring-2 ring-white/70 shadow-lg"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span
|
||||
className="pr-1 text-[10px] font-extrabold uppercase tracking-[0.22em]"
|
||||
style={{ color: "rgba(255,255,255,0.75)" }}
|
||||
>
|
||||
{tp("recentUsers")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="animate-fade-in-up mt-9 flex flex-wrap items-center justify-center gap-3"
|
||||
style={{ animationDelay: "0.2s" }}
|
||||
>
|
||||
<Link
|
||||
href="/register"
|
||||
className="btn-brand btn-shine px-8 py-4 text-sm font-black uppercase tracking-wider"
|
||||
>
|
||||
<Image
|
||||
src="/assets/images/EnterHubbly.png"
|
||||
alt=""
|
||||
width={20}
|
||||
height={20}
|
||||
unoptimized
|
||||
/>
|
||||
{tp("register")}
|
||||
</Link>
|
||||
<Link
|
||||
href="/login"
|
||||
className="btn-glass-dark px-8 py-4 text-sm font-bold"
|
||||
>
|
||||
{th("login")}
|
||||
aria-hidden="true"
|
||||
className="brand-halo left-1/2 top-1/2 h-[420px] w-[720px] max-w-full -translate-x-1/2 -translate-y-1/2"
|
||||
style={{ opacity: 0.5, zIndex: 1 }}
|
||||
/>
|
||||
<div className="relative z-10 mx-auto flex min-h-[540px] w-full max-w-6xl flex-col items-center justify-center px-4 py-24 text-center sm:min-h-[580px] lg:min-h-[640px]">
|
||||
<h1 style={{ fontFamily: "var(--font-nunito)" }}>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="inline-flex transition-transform duration-300 group-hover:translate-x-1"
|
||||
className="mb-3 block text-xs font-extrabold uppercase tracking-[0.32em] sm:text-sm"
|
||||
style={{ color: "rgba(255,255,255,0.72)" }}
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
{tp("heroEyebrow")}
|
||||
</span>
|
||||
</Link>
|
||||
<span className="gradient-text drop-shadow-[0_2px_24px_rgba(0,0,0,0.55)] block text-5xl font-black leading-[1.02] tracking-tight sm:text-6xl md:text-7xl lg:text-8xl">
|
||||
<TypewriterText text={hotelName} />
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<p
|
||||
className="mt-6 max-w-xl text-sm font-semibold leading-relaxed sm:text-base md:text-lg"
|
||||
style={{ color: HERO_COPY }}
|
||||
>
|
||||
{tp("welcomeBody")}
|
||||
</p>
|
||||
|
||||
<div
|
||||
className="animate-fade-in-up mt-9 flex flex-wrap items-center justify-center gap-3"
|
||||
style={{ animationDelay: "0.2s" }}
|
||||
>
|
||||
<Link
|
||||
href="/register"
|
||||
className="btn-brand btn-shine px-8 py-4 text-sm font-black uppercase tracking-wider"
|
||||
>
|
||||
<Image
|
||||
src="/assets/images/EnterHubbly.png"
|
||||
alt=""
|
||||
width={20}
|
||||
height={20}
|
||||
unoptimized
|
||||
/>
|
||||
{tp("register")}
|
||||
</Link>
|
||||
<Link
|
||||
href="/login"
|
||||
className="btn-glass-dark group px-8 py-4 text-sm font-bold"
|
||||
>
|
||||
{th("login")}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="inline-flex transition-transform duration-300 group-hover:translate-x-1"
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Floating stat chips */}
|
||||
{/* Scroll cue */}
|
||||
<div
|
||||
className="animate-fade-in-up mt-12 flex flex-wrap items-center justify-center gap-2.5 sm:gap-3"
|
||||
style={{ animationDelay: "0.32s" }}
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute bottom-6 left-1/2 hidden -translate-x-1/2 sm:block"
|
||||
style={{ zIndex: 2 }}
|
||||
>
|
||||
{statChips.map((s) => (
|
||||
<span key={s.label} className="glass-chip">
|
||||
<div
|
||||
className="hero-cue flex h-10 w-10 items-center justify-center rounded-full"
|
||||
style={{
|
||||
background: "rgba(255,255,255,0.09)",
|
||||
border: "1px solid rgba(255,255,255,0.3)",
|
||||
backdropFilter: "blur(10px)",
|
||||
WebkitBackdropFilter: "blur(10px)",
|
||||
}}
|
||||
>
|
||||
<ChevronDown
|
||||
className="h-5 w-5"
|
||||
style={{ color: "rgba(255,255,255,0.88)" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Floating mascot — decorative, hidden on smaller screens. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute bottom-8 right-8 hidden lg:block xl:right-14"
|
||||
style={{ zIndex: 2 }}
|
||||
>
|
||||
<div className="brand-halo left-1/2 top-1/2 h-44 w-44 -translate-x-1/2 -translate-y-1/2 xl:h-52 xl:w-52" />
|
||||
<Image
|
||||
src="/assets/images/FrankwithBag.gif"
|
||||
alt=""
|
||||
width={96}
|
||||
height={128}
|
||||
unoptimized
|
||||
className="relative animate-float object-contain drop-shadow-2xl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="hero-ring" aria-hidden="true" />
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* The headline counters. They straddle the hero edge so the
|
||||
numbers land above the fold instead of in a second block
|
||||
further down the page. */}
|
||||
<Reveal className="relative z-20 mx-auto -mt-14 w-full max-w-4xl px-3 sm:-mt-16 sm:px-4 md:px-6">
|
||||
<div className="panel panel-float relative overflow-hidden rounded-[var(--panel-radius)] px-2 py-5 sm:px-4 sm:py-6">
|
||||
<div className="grid grid-cols-2 gap-y-6 lg:grid-cols-4 lg:gap-y-0">
|
||||
{statItems.map((s, i) => (
|
||||
<div
|
||||
key={s.label}
|
||||
className="stat-cell group flex flex-col items-center gap-2 text-center"
|
||||
>
|
||||
<div
|
||||
className="panel-tile flex h-10 w-10 items-center justify-center rounded-xl transition-transform duration-300 group-hover:scale-110"
|
||||
style={
|
||||
{
|
||||
"--panel-tint": tintFor(i),
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<Image
|
||||
src={s.icon}
|
||||
alt=""
|
||||
width={20}
|
||||
height={20}
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="gradient-text text-3xl font-black tracking-tight sm:text-4xl"
|
||||
style={{ fontVariantNumeric: "tabular-nums" }}
|
||||
>
|
||||
{s.value === null ? (
|
||||
<span
|
||||
className="text-base font-bold"
|
||||
style={{
|
||||
color:
|
||||
"var(--color-text-muted-readable, var(--color-text-muted))",
|
||||
}}
|
||||
>
|
||||
{te("unavailable")}
|
||||
</span>
|
||||
) : (
|
||||
<AnimatedCounter value={s.value} />
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className="glass-chip-dot"
|
||||
style={{ background: s.color }}
|
||||
/>
|
||||
<span
|
||||
className="glass-chip-value"
|
||||
className="rounded-full px-2.5 py-1 text-[11px] font-bold uppercase tracking-widest"
|
||||
style={{
|
||||
background:
|
||||
"color-mix(in srgb, var(--color-text-muted) 9%, transparent)",
|
||||
color: "var(--color-text-readable)",
|
||||
fontSize: "1rem",
|
||||
}}
|
||||
>
|
||||
<AnimatedCounter value={s.value} />
|
||||
{s.label}
|
||||
</span>
|
||||
<span className="glass-chip-label">{s.label}</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scroll cue */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute bottom-6 left-1/2 hidden -translate-x-1/2 sm:block"
|
||||
style={{ zIndex: 2 }}
|
||||
>
|
||||
<div
|
||||
className="hero-cue flex h-10 w-10 items-center justify-center rounded-full"
|
||||
style={{
|
||||
background: "rgba(255,255,255,0.09)",
|
||||
border: "1px solid rgba(255,255,255,0.3)",
|
||||
backdropFilter: "blur(10px)",
|
||||
WebkitBackdropFilter: "blur(10px)",
|
||||
}}
|
||||
>
|
||||
<ChevronDown
|
||||
className="h-5 w-5"
|
||||
style={{ color: "rgba(255,255,255,0.88)" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Floating mascot — decorative, hidden on smaller screens. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute bottom-8 right-8 hidden lg:block xl:right-14"
|
||||
style={{ zIndex: 2 }}
|
||||
>
|
||||
<div className="brand-halo left-1/2 top-1/2 h-44 w-44 -translate-x-1/2 -translate-y-1/2 xl:h-52 xl:w-52" />
|
||||
<Image
|
||||
src="/assets/images/FrankwithBag.gif"
|
||||
alt=""
|
||||
width={96}
|
||||
height={128}
|
||||
unoptimized
|
||||
className="relative animate-float object-contain drop-shadow-2xl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="hero-ring" aria-hidden="true" />
|
||||
</div>
|
||||
</Reveal>
|
||||
</Reveal>
|
||||
</div>
|
||||
|
||||
{/* ── Quick explore ── */}
|
||||
<Reveal className="-mt-3 mx-auto w-full max-w-7xl">
|
||||
<Reveal className="-mt-6 mx-auto w-full max-w-7xl">
|
||||
<nav
|
||||
aria-label={tp("exploreLabel")}
|
||||
className="flex flex-wrap items-center justify-center gap-2.5 sm:gap-3"
|
||||
@@ -739,13 +700,8 @@ export default async function Home() {
|
||||
aria-hidden="true"
|
||||
className="h-1.5 w-1.5 shrink-0 rounded-full"
|
||||
style={{
|
||||
background:
|
||||
i % 2 === 0
|
||||
? "var(--color-primary)"
|
||||
: "var(--color-accent)",
|
||||
boxShadow: `0 0 8px color-mix(in srgb, ${
|
||||
i % 2 === 0 ? "var(--color-primary)" : "var(--color-accent)"
|
||||
} 70%, transparent)`,
|
||||
background: tintFor(i),
|
||||
boxShadow: `0 0 8px color-mix(in srgb, ${tintFor(i)} 70%, transparent)`,
|
||||
}}
|
||||
/>
|
||||
{l.label}
|
||||
@@ -764,165 +720,71 @@ export default async function Home() {
|
||||
{/* ── Features ── */}
|
||||
<Reveal className="mx-auto w-full max-w-7xl">
|
||||
<div className="flex flex-col gap-6 sm:gap-8">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<span className="eyebrow">{tp("sectionFeaturesLabel")}</span>
|
||||
<h2
|
||||
className="section-title mt-2 text-2xl sm:text-3xl md:text-4xl"
|
||||
style={{ letterSpacing: "-0.02em" }}
|
||||
>
|
||||
{tp("sectionFeaturesTitle")}
|
||||
</h2>
|
||||
</div>
|
||||
<div>
|
||||
<span className="eyebrow">{tp("sectionFeaturesLabel")}</span>
|
||||
<h2
|
||||
className="section-title mt-2 text-2xl sm:text-3xl md:text-4xl"
|
||||
style={{ letterSpacing: "-0.02em" }}
|
||||
>
|
||||
{tp("sectionFeaturesTitle")}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3 sm:gap-5">
|
||||
{features.map((f, i) => {
|
||||
const accentVar =
|
||||
i % 2 === 0 ? "var(--color-primary)" : "var(--color-accent)";
|
||||
return (
|
||||
<SpotlightCard
|
||||
key={f.title}
|
||||
className="spotlight card-glow card-hairline group relative overflow-hidden rounded-2xl border p-6 transition-all duration-500 hover:-translate-y-1.5 hover:shadow-2xl sm:p-7"
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-5 lg:grid-cols-4">
|
||||
{features.map((f, i) => (
|
||||
<SpotlightCard
|
||||
key={f.title}
|
||||
className="panel card-glow card-hairline group relative overflow-hidden rounded-[var(--panel-radius)] p-6 transition-all duration-500 hover:-translate-y-1.5 hover:shadow-2xl sm:p-7"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute right-5 top-4 select-none text-5xl font-black leading-none opacity-[0.07] transition-opacity duration-500 group-hover:opacity-20"
|
||||
style={{
|
||||
background: GLASS_TILE_BG,
|
||||
backdropFilter: "blur(16px)",
|
||||
WebkitBackdropFilter: "blur(16px)",
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
|
||||
background:
|
||||
"linear-gradient(120deg, var(--color-primary), var(--color-accent))",
|
||||
WebkitBackgroundClip: "text",
|
||||
backgroundClip: "text",
|
||||
color: "transparent",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute right-5 top-4 select-none text-5xl font-black leading-none opacity-[0.07] transition-opacity duration-500 group-hover:opacity-20"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(120deg, var(--color-primary), var(--color-accent))",
|
||||
WebkitBackgroundClip: "text",
|
||||
backgroundClip: "text",
|
||||
color: "transparent",
|
||||
}}
|
||||
>
|
||||
{`0${i + 1}`}
|
||||
</span>
|
||||
<div
|
||||
className="relative flex h-12 w-12 items-center justify-center rounded-2xl transition-transform duration-300 group-hover:scale-110 group-hover:-rotate-3"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, color-mix(in srgb, ${accentVar} 30%, transparent), color-mix(in srgb, ${accentVar} 12%, transparent))`,
|
||||
boxShadow: `inset 0 0 0 1px color-mix(in srgb, ${accentVar} 30%, transparent), 0 8px 18px -10px color-mix(in srgb, ${accentVar} 70%, transparent)`,
|
||||
borderRadius: 14,
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={f.icon}
|
||||
alt=""
|
||||
width={24}
|
||||
height={24}
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
<h3
|
||||
className="relative mt-5 text-base font-extrabold sm:text-lg"
|
||||
style={{
|
||||
color: "var(--color-text-readable)",
|
||||
fontFamily: "var(--font-nunito)",
|
||||
}}
|
||||
>
|
||||
{f.title}
|
||||
</h3>
|
||||
<p
|
||||
className="relative mt-1.5 text-sm leading-relaxed"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{f.desc}
|
||||
</p>
|
||||
</SpotlightCard>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* ── Stats ── */}
|
||||
<Reveal className="mx-auto w-full max-w-7xl">
|
||||
<div className="flex flex-col gap-6 sm:gap-8">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<span className="eyebrow">{tp("sectionStatsLabel")}</span>
|
||||
<h2
|
||||
className="section-title mt-2 text-2xl sm:text-3xl md:text-4xl"
|
||||
style={{ letterSpacing: "-0.02em" }}
|
||||
>
|
||||
{tp("sectionStatsTitle")}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="card-hairline overflow-hidden rounded-2xl border"
|
||||
style={PANEL_STYLE}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-8 p-6 sm:p-8 lg:grid-cols-4">
|
||||
{[
|
||||
{
|
||||
value: users,
|
||||
label: tp("statsCitizens"),
|
||||
icon: ICON_FRIENDS,
|
||||
},
|
||||
{ value: rooms, label: tp("statsRooms"), icon: ICON_CATALOG },
|
||||
{
|
||||
value: totalPhotos,
|
||||
label: tp("statsPhotos"),
|
||||
icon: ICON_CAMERA,
|
||||
},
|
||||
{
|
||||
value: articleCount,
|
||||
label: tp("statsArticles"),
|
||||
icon: ICON_ARTICLE,
|
||||
},
|
||||
].map((s) => (
|
||||
{`0${i + 1}`}
|
||||
</span>
|
||||
<div
|
||||
key={s.label}
|
||||
className="stat-cell group relative flex flex-col items-center gap-2.5 text-center"
|
||||
className="panel-tile relative flex h-12 w-12 items-center justify-center rounded-2xl transition-transform duration-300 group-hover:scale-110 group-hover:-rotate-3"
|
||||
style={
|
||||
{
|
||||
"--panel-tint": tintFor(i),
|
||||
borderRadius: 14,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="flex h-10 w-10 items-center justify-center rounded-xl transition-transform duration-300 group-hover:scale-110"
|
||||
style={{
|
||||
background:
|
||||
"color-mix(in srgb, var(--color-primary) 14%, transparent)",
|
||||
boxShadow:
|
||||
"inset 0 0 0 1px color-mix(in srgb, var(--color-primary) 18%, transparent)",
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={s.icon}
|
||||
alt=""
|
||||
width={20}
|
||||
height={20}
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="gradient-text text-4xl font-black tracking-tight sm:text-5xl"
|
||||
style={{ fontVariantNumeric: "tabular-nums" }}
|
||||
>
|
||||
{s.value === null ? (
|
||||
<span className="text-base">{te("unavailable")}</span>
|
||||
) : (
|
||||
<AnimatedCounter value={s.value} />
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className="rounded-full px-3 py-1 text-[10px] font-bold uppercase tracking-widest"
|
||||
style={{
|
||||
background:
|
||||
"color-mix(in srgb, var(--color-text-muted) 8%, transparent)",
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
{s.label}
|
||||
</span>
|
||||
<Image
|
||||
src={f.icon}
|
||||
alt=""
|
||||
width={24}
|
||||
height={24}
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<h3
|
||||
className="relative mt-5 text-base font-extrabold sm:text-lg"
|
||||
style={{
|
||||
color: "var(--color-text-readable)",
|
||||
fontFamily: "var(--font-nunito)",
|
||||
}}
|
||||
>
|
||||
{f.title}
|
||||
</h3>
|
||||
<p
|
||||
className="relative mt-1.5 text-sm leading-relaxed"
|
||||
style={{
|
||||
color:
|
||||
"var(--color-text-muted-readable, var(--color-text-muted))",
|
||||
}}
|
||||
>
|
||||
{f.desc}
|
||||
</p>
|
||||
</SpotlightCard>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
@@ -930,20 +792,18 @@ export default async function Home() {
|
||||
{/* ── Community ── */}
|
||||
<Reveal className="mx-auto w-full max-w-7xl">
|
||||
<div className="flex flex-col gap-6 sm:gap-8">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<span className="eyebrow">{tp("sectionCommunityLabel")}</span>
|
||||
<h2
|
||||
className="section-title mt-2 text-2xl sm:text-3xl md:text-4xl"
|
||||
style={{ letterSpacing: "-0.02em" }}
|
||||
>
|
||||
{tp("sectionCommunityTitle")}
|
||||
</h2>
|
||||
</div>
|
||||
<div>
|
||||
<span className="eyebrow">{tp("sectionCommunityLabel")}</span>
|
||||
<h2
|
||||
className="section-title mt-2 text-2xl sm:text-3xl md:text-4xl"
|
||||
style={{ letterSpacing: "-0.02em" }}
|
||||
>
|
||||
{tp("sectionCommunityTitle")}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 md:flex-row">
|
||||
<div className="flex w-full flex-col gap-4 md:w-1/3 md:flex-shrink-0">
|
||||
<GlassPanel
|
||||
<Panel
|
||||
title={th("login")}
|
||||
icon={ICON_NAV_ME}
|
||||
bodyClassName="p-3.5 sm:p-4"
|
||||
@@ -958,15 +818,15 @@ export default async function Home() {
|
||||
}}
|
||||
nonce={nonce}
|
||||
/>
|
||||
</GlassPanel>
|
||||
</Panel>
|
||||
|
||||
{recentUsers === null ? (
|
||||
<GlassPanel title={tp("recentUsers")} bodyClassName="p-4">
|
||||
<Panel title={tp("recentUsers")} bodyClassName="p-4">
|
||||
<PublicLoadError href="/" />
|
||||
</GlassPanel>
|
||||
</Panel>
|
||||
) : (
|
||||
recentUsers.length > 0 && (
|
||||
<GlassPanel
|
||||
<Panel
|
||||
title={tp("recentUsers")}
|
||||
icon={ICON_NAV_GOODY}
|
||||
actionHref="/community"
|
||||
@@ -975,34 +835,45 @@ export default async function Home() {
|
||||
>
|
||||
<div className="grid grid-cols-4 gap-1.5 sm:gap-2">
|
||||
{recentUsers.map((u) => (
|
||||
<div
|
||||
<Link
|
||||
key={u.username}
|
||||
href={`/u/${encodeURIComponent(u.username)}`}
|
||||
title={u.username}
|
||||
className="flex flex-col items-center gap-0.5 rounded-lg px-1 py-1.5 transition-all duration-200 hover:bg-[color-mix(in_srgb,var(--color-primary)_6%,transparent)] hover:ring-1 hover:ring-[color-mix(in_srgb,var(--color-primary)_35%,transparent)]"
|
||||
className="group flex flex-col items-center gap-1 rounded-xl border border-transparent p-1.5 transition-all duration-200 hover:border-[color-mix(in_srgb,var(--color-primary)_32%,transparent)] hover:bg-[color-mix(in_srgb,var(--color-primary)_8%,transparent)]"
|
||||
>
|
||||
<UserAvatarThumbnail
|
||||
figure={u.look}
|
||||
alt={u.username}
|
||||
options={{ direction: 2 }}
|
||||
className="rounded-md"
|
||||
/>
|
||||
<span className="relative">
|
||||
<UserAvatarThumbnail
|
||||
figure={u.look}
|
||||
alt={u.username}
|
||||
options={{ direction: 2 }}
|
||||
className="rounded-md"
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full"
|
||||
style={{
|
||||
background: "var(--color-accent)",
|
||||
boxShadow: "0 0 0 2px var(--panel-surface)",
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
className="max-w-[64px] truncate text-center text-[9px] sm:text-[10px] font-bold leading-tight"
|
||||
style={{ color: "var(--color-text-readable)" }}
|
||||
>
|
||||
{u.username}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</GlassPanel>
|
||||
</Panel>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* News */}
|
||||
<div className="flex w-full flex-col md:w-2/3">
|
||||
<GlassPanel
|
||||
<Panel
|
||||
title={tp("latestNews")}
|
||||
icon={ICON_ARTICLE}
|
||||
actionHref="/news"
|
||||
@@ -1020,12 +891,15 @@ export default async function Home() {
|
||||
) : (
|
||||
<p
|
||||
className="py-8 text-center text-sm"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
style={{
|
||||
color:
|
||||
"var(--color-text-muted-readable, var(--color-text-muted))",
|
||||
}}
|
||||
>
|
||||
{tp("noArticles")}
|
||||
</p>
|
||||
)}
|
||||
</GlassPanel>
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1034,37 +908,53 @@ export default async function Home() {
|
||||
{/* ── Recent photos ── */}
|
||||
{recentPhotos === null ? (
|
||||
<Reveal className="mx-auto w-full max-w-7xl">
|
||||
<GlassPanel title={tp("recentPhotos")} bodyClassName="p-4">
|
||||
<Panel title={tp("recentPhotos")} bodyClassName="p-4">
|
||||
<PublicLoadError href="/" />
|
||||
</GlassPanel>
|
||||
</Panel>
|
||||
</Reveal>
|
||||
) : (
|
||||
recentPhotos.length > 0 && (
|
||||
<Reveal className="mx-auto w-full max-w-7xl">
|
||||
<GlassPanel title={tp("recentPhotos")} icon={ICON_CAMERA}>
|
||||
<Panel title={tp("recentPhotos")} icon={ICON_CAMERA}>
|
||||
<div className="mx-auto grid max-w-5xl grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{recentPhotos.slice(0, 4).map((p, i) => (
|
||||
<Link
|
||||
key={p.id}
|
||||
href="/photos"
|
||||
className="block aspect-[4/3] overflow-hidden rounded-2xl border transition-all duration-300 ease-out hover:scale-[1.03] hover:shadow-xl hover:ring-2 hover:ring-[color-mix(in_srgb,var(--color-primary)_45%,transparent)]"
|
||||
style={{
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-text-muted) 8%, transparent)",
|
||||
}}
|
||||
style={{ borderColor: "var(--panel-border)" }}
|
||||
>
|
||||
<Image
|
||||
src={p.url}
|
||||
alt={`${tp("recentPhotos")} ${i + 1}`}
|
||||
width={280}
|
||||
height={210}
|
||||
className="h-full w-full object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
{p.url ? (
|
||||
<Image
|
||||
src={p.url}
|
||||
alt={`${tp("recentPhotos")} ${i + 1}`}
|
||||
width={280}
|
||||
height={210}
|
||||
className="h-full w-full object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="flex h-full w-full items-center justify-center"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(140deg, color-mix(in srgb, var(--color-primary) 22%, var(--color-surface)), color-mix(in srgb, var(--color-accent) 16%, var(--color-surface)))",
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={ICON_CAMERA}
|
||||
alt=""
|
||||
width={32}
|
||||
height={32}
|
||||
unoptimized
|
||||
className="opacity-70"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</GlassPanel>
|
||||
</Panel>
|
||||
</Reveal>
|
||||
)
|
||||
)}
|
||||
@@ -1148,3 +1038,5 @@ export default async function Home() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default withPublicPagePerformance("/", Home);
|
||||
@@ -108,7 +108,6 @@ export default async function RareCategoryPage({
|
||||
}}
|
||||
>
|
||||
{badgeSrc ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={badgeSrc}
|
||||
alt={`${cat.name} badge`}
|
||||
@@ -150,7 +149,6 @@ export default async function RareCategoryPage({
|
||||
<tr key={String(r.id)}>
|
||||
<td>
|
||||
{iconSrc ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={iconSrc}
|
||||
alt=""
|
||||
|
||||
@@ -88,7 +88,6 @@ export default async function RareValuesPage() {
|
||||
>
|
||||
<div className="content-card-head">
|
||||
{badgeSrc ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={badgeSrc}
|
||||
alt={`${c.name} badge`}
|
||||
|
||||
@@ -1,23 +1,13 @@
|
||||
import { 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";
|
||||
import {
|
||||
AuthPageFrame,
|
||||
AuthUsersCards,
|
||||
} from "@/components/auth/auth-page-frame";
|
||||
AuthLandingView,
|
||||
loadAuthLanding,
|
||||
} from "@/components/auth/auth-landing";
|
||||
import { RegisterForm } from "@/components/auth/register-form";
|
||||
import { SurfaceCard } from "@/components/surface-card";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { cached } from "@/lib/cache";
|
||||
|
||||
import { db, User } from "@/lib/db";
|
||||
import { resolveHotelName } from "@/lib/hotel-name";
|
||||
import { captchaConfig } from "@/lib/services/captcha";
|
||||
import { cachedOnlineCount } from "@/lib/services/public-counters";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import { passwordPolicy } from "@/lib/auth/password-policy";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("pages.register");
|
||||
@@ -26,163 +16,41 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
robots: { index: false, follow: false },
|
||||
openGraph: { title, description, type: "website" },
|
||||
};
|
||||
}
|
||||
|
||||
export default async function RegisterPage() {
|
||||
const tpr = await getTranslations("pages.register");
|
||||
const [hotelName, cfg, logo] = await Promise.all([
|
||||
resolveHotelName(),
|
||||
captchaConfig(),
|
||||
siteSettings.get("cms_logo", ""),
|
||||
]);
|
||||
const nonce = (await headers()).get("x-nonce") ?? undefined;
|
||||
const t = await getTranslations("pages.register");
|
||||
|
||||
// An account already exists in this browser — there is nothing to sign up.
|
||||
const session = await auth();
|
||||
if (session?.user?.id) redirect("/me");
|
||||
|
||||
const [online, recentUsers, latestUsers] = await Promise.all([
|
||||
cachedOnlineCount().catch(() => 0),
|
||||
cached(
|
||||
"auth_online_users",
|
||||
10_000,
|
||||
() =>
|
||||
db
|
||||
.select({ id: User.id, username: User.username, look: User.look })
|
||||
.from(User)
|
||||
.where(eq(User.online, "1"))
|
||||
.limit(8),
|
||||
{ staleMs: 30000 },
|
||||
).catch(() => []),
|
||||
cached(
|
||||
"auth_latest_users",
|
||||
30_000,
|
||||
() =>
|
||||
db
|
||||
.select({ id: User.id, username: User.username, look: User.look })
|
||||
.from(User)
|
||||
.orderBy(desc(User.accountCreated))
|
||||
.limit(8),
|
||||
{ staleMs: 60000 },
|
||||
).catch(() => []),
|
||||
]);
|
||||
const data = await loadAuthLanding();
|
||||
|
||||
return (
|
||||
<AuthPageFrame
|
||||
hotelName={hotelName}
|
||||
logo={logo}
|
||||
title={tpr("title")}
|
||||
subtitle={tpr("subtitle")}
|
||||
<AuthLandingView
|
||||
data={data}
|
||||
copy={{
|
||||
title: t("title"),
|
||||
subtitle: t("subtitle"),
|
||||
promoTitle: t("subtitle"),
|
||||
promoSubtitle: undefined,
|
||||
usersOnlineLabel: t("usersOnline", { count: data.online }),
|
||||
whoIsOnlineLabel: t("whoIsOnline"),
|
||||
newestCitizensLabel: t("newestCitizens"),
|
||||
}}
|
||||
form={
|
||||
<RegisterForm
|
||||
hotelName={hotelName}
|
||||
captcha={{
|
||||
provider: cfg.provider,
|
||||
siteKey: cfg.siteKey || undefined,
|
||||
}}
|
||||
nonce={nonce}
|
||||
hotelName={data.hotelName}
|
||||
captcha={data.captcha}
|
||||
nonce={data.nonce}
|
||||
passwordMinLength={passwordPolicy.minLength}
|
||||
passwordMaxLength={passwordPolicy.maxLength}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<SurfaceCard
|
||||
className="card-glow relative overflow-hidden p-6"
|
||||
style={{
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-primary) 20%, transparent)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 10%, transparent), color-mix(in srgb, var(--color-accent) 10%, transparent))",
|
||||
}}
|
||||
/>
|
||||
<div aria-hidden="true" className="absolute inset-0 overflow-hidden">
|
||||
<div className="brand-halo left-1/2 top-0 h-64 w-[460px] max-w-full -translate-x-1/2 -translate-y-1/2" />
|
||||
<div
|
||||
className="aurora-blob -left-16 top-6 h-56 w-56"
|
||||
style={{
|
||||
background:
|
||||
"color-mix(in srgb, var(--color-primary) 45%, transparent)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="aurora-blob -right-16 bottom-0 h-56 w-56"
|
||||
style={{
|
||||
background:
|
||||
"color-mix(in srgb, var(--color-accent) 40%, transparent)",
|
||||
animationDelay: "-8s",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="shrink-0 animate-float">
|
||||
<div className="brand-halo left-1/2 top-1/2 h-52 w-52 -translate-x-1/2 -translate-y-1/2" />
|
||||
<Image
|
||||
src="/assets/images/FrankwithBag.gif"
|
||||
alt="Frank"
|
||||
width={110}
|
||||
height={145}
|
||||
className="relative object-contain drop-shadow-xl"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div
|
||||
className="inline-flex items-center gap-2 px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider mb-2 border"
|
||||
style={{
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-primary) 30%, transparent)",
|
||||
background:
|
||||
"color-mix(in srgb, var(--color-surface) 72%, transparent)",
|
||||
color: "var(--color-primary-readable, var(--color-primary))",
|
||||
boxShadow:
|
||||
"0 8px 20px -12px color-mix(in srgb, var(--color-primary) 70%, transparent)",
|
||||
}}
|
||||
>
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span
|
||||
className="animate-ping absolute inline-flex h-full w-full rounded-full opacity-75"
|
||||
style={{ background: "var(--color-primary)" }}
|
||||
/>
|
||||
<span
|
||||
className="relative inline-flex rounded-full h-2 w-2"
|
||||
style={{ background: "var(--color-primary)" }}
|
||||
/>
|
||||
</span>
|
||||
{tpr("usersOnline", { count: online })}
|
||||
</div>
|
||||
<h1
|
||||
className="text-lg font-black leading-tight"
|
||||
style={{
|
||||
color: "var(--color-text-readable)",
|
||||
fontFamily: "var(--font-nunito)",
|
||||
}}
|
||||
>
|
||||
{tpr("title")} —{" "}
|
||||
<span className="gradient-text">{hotelName}</span>
|
||||
</h1>
|
||||
<p
|
||||
className="text-xs mt-1.5 leading-relaxed"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{tpr("subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
|
||||
<AuthUsersCards
|
||||
recentUsers={recentUsers}
|
||||
latestUsers={latestUsers}
|
||||
recentTitle={tpr("whoIsOnline")}
|
||||
latestTitle={tpr("newestCitizens")}
|
||||
/>
|
||||
</AuthPageFrame>
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { getTranslations } from "next-intl/server";
|
||||
import { resetPassword } from "@/actions/password-reset";
|
||||
import Link from "@/components/link";
|
||||
import { ContentCard } from "@/components/public/ui";
|
||||
import { passwordPolicy } from "@/lib/auth/password-policy";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("pages.reset");
|
||||
@@ -41,8 +42,12 @@ export default async function ResetPage({
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder={t("passwordPlaceholder")}
|
||||
placeholder={t("passwordPlaceholder", {
|
||||
min: passwordPolicy.minLength,
|
||||
})}
|
||||
autoComplete="new-password"
|
||||
minLength={passwordPolicy.minLength}
|
||||
maxLength={passwordPolicy.maxLength}
|
||||
required
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
@@ -58,7 +63,7 @@ export default async function ResetPage({
|
||||
marginBottom: 0,
|
||||
}}
|
||||
>
|
||||
{t(errorKey)}
|
||||
{t(errorKey, { min: passwordPolicy.minLength })}
|
||||
</p>
|
||||
) : null}
|
||||
<p
|
||||
|
||||
@@ -16,7 +16,7 @@ const STATE_LABELS: Record<string, { label: string; icon: string }> = {
|
||||
};
|
||||
|
||||
function describeState(state: string): { label: string; icon: string } {
|
||||
// eslint-disable-next-line security/detect-object-injection -- STATE_LABELS has known keys, fallback provided
|
||||
// STATE_LABELS has known keys, fallback provided
|
||||
return STATE_LABELS[state] ?? { label: state || "Unknown", icon: "🚪" };
|
||||
}
|
||||
|
||||
|
||||
@@ -249,7 +249,6 @@ export default async function ShopPage({
|
||||
}}
|
||||
>
|
||||
{a.iconUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={a.iconUrl}
|
||||
alt=""
|
||||
|
||||
@@ -366,7 +366,6 @@ async function ProfilePage({
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<ProfileImage
|
||||
src={avatar}
|
||||
alt={user.username}
|
||||
|
||||
Loaded 100 of 235 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user