Compare commits
45
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 |
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).
|
||||
|
||||
@@ -29,16 +29,16 @@ jobs:
|
||||
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}"
|
||||
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`
|
||||
@@ -697,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)
|
||||
|
||||
@@ -722,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
|
||||
@@ -752,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
|
||||
@@ -778,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`).
|
||||
@@ -800,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
|
||||
|
||||
@@ -836,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
|
||||
@@ -872,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
|
||||
@@ -895,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):
|
||||
|
||||
@@ -960,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
|
||||
|
||||
@@ -977,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`.
|
||||
@@ -1035,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
|
||||
@@ -1067,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
|
||||
|
||||
@@ -1105,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.
|
||||
@@ -1134,40 +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 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/` |
|
||||
| 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.
|
||||
>
|
||||
@@ -1179,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) |
|
||||
|
||||
---
|
||||
|
||||
@@ -1249,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`
|
||||
|
||||
@@ -1289,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;
|
||||
|
||||
+47
-18
@@ -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
|
||||
@@ -69,4 +98,4 @@ services:
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
start_period: 30s
|
||||
+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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -34,16 +33,15 @@ Baselines were last recalibrated against a local webpack production build (Next
|
||||
|
||||
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).
|
||||
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
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 +1 @@
|
||||
Draft SQL from `pnpm db:generate` (drizzle-kit). Never apply these automatically — copy reviewed statements into drizzle/migrations/ as numbered CMS migrations, then `pnpm db:migrate`.
|
||||
Draft SQL from `pnpm db:generate` (drizzle-kit). Never apply these automatically — copy reviewed statements into drizzle/migrations/ as numbered CMS migrations, then `pnpm db:migrate`.
|
||||
+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 [
|
||||
{
|
||||
|
||||
+20
-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",
|
||||
@@ -34,7 +34,7 @@
|
||||
"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",
|
||||
@@ -55,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",
|
||||
@@ -66,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",
|
||||
@@ -83,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",
|
||||
@@ -92,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",
|
||||
@@ -102,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"] } }],
|
||||
});
|
||||
+20
-4
@@ -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",
|
||||
@@ -65,7 +81,7 @@ export default defineConfig({
|
||||
command: "node e2e/ui/fixture-server.mjs",
|
||||
url: `${baseURL}/health`,
|
||||
timeout: 120000,
|
||||
reuseExistingServer: true,
|
||||
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"
|
||||
+1
-1
@@ -61,7 +61,7 @@
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"description": "De pnpm packageManager-pin is afgestemd op Docker en de runner-toolchain; niet automatisch bumpen",
|
||||
"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
|
||||
}
|
||||
|
||||
+41
-41
@@ -4,30 +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. |
|
||||
| `catalog-sync.sh` | Interactive catalog update against a clone source (see below). |
|
||||
| `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
|
||||
@@ -68,24 +69,24 @@ 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 |
|
||||
| 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
|
||||
@@ -94,7 +95,6 @@ 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.
|
||||
@@ -106,7 +106,7 @@ Safety notes:
|
||||
- `--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
|
||||
- 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.
|
||||
|
||||
+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
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
"schemaVersion": 1,
|
||||
"mode": "enforce",
|
||||
"routes": [
|
||||
{
|
||||
"route": "/",
|
||||
"maxInitialRawBytes": 716800,
|
||||
"maxInitialGzipBytes": 225280
|
||||
},
|
||||
{
|
||||
"route": "/me",
|
||||
"maxInitialRawBytes": 716800,
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+10
-4
@@ -5,6 +5,7 @@ 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";
|
||||
@@ -83,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
|
||||
@@ -105,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")
|
||||
@@ -160,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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
import { getLocale, getMessages } from "next-intl/server";
|
||||
import type { ReactNode } from "react";
|
||||
import { env } from "@/env";
|
||||
import { isHousekeepingPreviewEnabled } from "@/features/housekeeping/foundation/preview-gate";
|
||||
@@ -27,5 +29,13 @@ export default async function AdminNextLayout({
|
||||
notFound();
|
||||
}
|
||||
|
||||
return children;
|
||||
// Staff tree: re-install the full catalogue the root layout scopes down to
|
||||
// the public subset (nested providers replace the parent set).
|
||||
const [locale, messages] = await Promise.all([getLocale(), getMessages()]);
|
||||
|
||||
return (
|
||||
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||
{children}
|
||||
</NextIntlClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -49,7 +49,6 @@ export function AdsTable({ ads }: { ads: AdRow[] }) {
|
||||
{ads.map((ad) => (
|
||||
<tr key={ad.id}>
|
||||
<td>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={ad.image}
|
||||
alt=""
|
||||
|
||||
@@ -103,7 +103,6 @@ export default async function AdminCalendarCampaign({
|
||||
|
||||
<div className="admin-card flex items-center gap-5 mb-6">
|
||||
{campaign.image ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
className="w-24 h-24 object-cover"
|
||||
src={campaign.image}
|
||||
|
||||
@@ -98,7 +98,6 @@ export default async function AdminCalendar() {
|
||||
className="admin-card hover article"
|
||||
>
|
||||
{c.image ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img className="article-img" src={c.image} alt={c.name} />
|
||||
) : (
|
||||
<div className="article-img" />
|
||||
|
||||
@@ -87,7 +87,7 @@ export default async function AdminAntiDdosPage() {
|
||||
const ip = key.replace("antiddos:block:", "");
|
||||
return {
|
||||
ip,
|
||||
ttlMs: ttlMs > 0 ? ttlMs : 0,
|
||||
ttlMs: Math.max(ttlMs, 0),
|
||||
count: violationCounts.get(ip) ?? 0,
|
||||
source: "gate" as const,
|
||||
};
|
||||
|
||||
@@ -96,7 +96,6 @@ export default async function AdminGuildDetailPage({
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
{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=""
|
||||
|
||||
@@ -155,7 +155,6 @@ export default async function AdminGuildsPage({
|
||||
<td className="muted">{g.id}</td>
|
||||
<td>
|
||||
{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=""
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { LogOut } from "lucide-react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
import { getLocale, getMessages, getTranslations } from "next-intl/server";
|
||||
import type { ReactNode } from "react";
|
||||
import { AdminHubChrome } from "@/components/admin/admin-hub-chrome";
|
||||
import { AdminMobileWrapper } from "@/components/admin/admin-mobile-wrapper";
|
||||
@@ -40,8 +41,13 @@ export default async function AdminLayout({
|
||||
if (!u?.twoFactorConfirmedAt) redirect("/settings/2fa?error=staffrequired");
|
||||
}
|
||||
|
||||
// The root layout ships the reduced catalogue so public pages do not carry
|
||||
// the ~195 KB of staff-tool strings. Nested providers replace the parent
|
||||
// set rather than merging, so staff route groups re-install the full one.
|
||||
const [locale, messages] = await Promise.all([getLocale(), getMessages()]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||
{csrfToken ? <meta name="csrf-token" content={csrfToken} /> : null}
|
||||
<AdminMobileWrapper sidebar={<Sidebar staff={staff} />}>
|
||||
<div
|
||||
@@ -56,7 +62,7 @@ export default async function AdminLayout({
|
||||
</div>
|
||||
</AdminMobileWrapper>
|
||||
<AdminToaster />
|
||||
</>
|
||||
</NextIntlClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -94,7 +94,6 @@ export default async function AdminPhotos({
|
||||
className="admin-card"
|
||||
style={{ padding: 0, overflow: "hidden" }}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={p.url}
|
||||
alt={t("photoAlt", { id: String(p.id) })}
|
||||
|
||||
@@ -63,7 +63,7 @@ export default async function AdminRadioHistoryPage() {
|
||||
|
||||
const liveCount = history.filter((h) => !h.endedAt).length;
|
||||
const peakListeners = history.reduce(
|
||||
(max, h) => (h.listenersCount > max ? h.listenersCount : max),
|
||||
(max, h) => Math.max(h.listenersCount, max),
|
||||
0,
|
||||
);
|
||||
const uniqueDjs = new Set(history.map((h) => String(h.userId))).size;
|
||||
|
||||
@@ -62,7 +62,7 @@ function isRecord(v: unknown): v is Record<string, unknown> {
|
||||
}
|
||||
|
||||
function pickString(obj: Record<string, unknown>, key: string): string | null {
|
||||
// eslint-disable-next-line security/detect-object-injection -- only called with hardcoded keys
|
||||
// only called with hardcoded keys
|
||||
const v = obj[key];
|
||||
return typeof v === "string" && v.trim() !== "" ? v.trim() : null;
|
||||
}
|
||||
@@ -112,7 +112,7 @@ function findNumberDeep(
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (!isRecord(value)) return null;
|
||||
for (const key of keys) {
|
||||
// eslint-disable-next-line security/detect-object-injection -- keys from hardcoded array
|
||||
// keys from hardcoded array
|
||||
const v = value[key];
|
||||
if (typeof v === "number" && Number.isFinite(v)) return v;
|
||||
if (
|
||||
|
||||
@@ -577,7 +577,6 @@ export function FurniClient({ room, items = [], canEdit }: Props) {
|
||||
)}
|
||||
<TableCell>
|
||||
{icon ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={icon}
|
||||
alt={item.name}
|
||||
@@ -733,7 +732,6 @@ export function FurniClient({ room, items = [], canEdit }: Props) {
|
||||
>
|
||||
<TableCell>
|
||||
{icon ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={icon}
|
||||
alt={g.base.name}
|
||||
|
||||
@@ -45,8 +45,8 @@ import {
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
import { adminFetch } from "@/lib/admin-fetch";
|
||||
import { logger } from "@/lib/browser-log";
|
||||
import { invalidateSongPickerCache } from "@/lib/client-cache/song-picker-cache";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { parseTraxChannels } from "@/lib/trax-format";
|
||||
import { TraxPlayer } from "./trax-player";
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import { Loader2, Play, Square } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { logger } from "@/lib/browser-log";
|
||||
import { TRAX_TICK_SECONDS, type TraxChannel } from "@/lib/trax-format";
|
||||
|
||||
const TICK_SECONDS = TRAX_TICK_SECONDS;
|
||||
|
||||
@@ -493,7 +493,7 @@ export default async function AdminTheme({
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: 4,
|
||||
// eslint-disable-next-line security/detect-object-injection -- k from fixed theme color key list
|
||||
// k from fixed theme color key list
|
||||
background: theme.settings[k] ?? "#64748b",
|
||||
boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.2)",
|
||||
}}
|
||||
|
||||
@@ -356,7 +356,7 @@ export async function GET(req: Request) {
|
||||
]);
|
||||
|
||||
const rarity = Object.fromEntries(
|
||||
// eslint-disable-next-line security/detect-object-injection -- rk from rarityKeys const, i is array index
|
||||
// rk from rarityKeys const, i is array index
|
||||
rarityKeys.map((rk, i) => [rk, rarityBoards[i]]),
|
||||
) as Record<BadgeRarityKey, BoardCore>;
|
||||
|
||||
@@ -398,7 +398,7 @@ export async function GET(req: Request) {
|
||||
totalBadgesViewer = tb;
|
||||
achievementViewer = ach;
|
||||
rarityViewer = Object.fromEntries(
|
||||
// eslint-disable-next-line security/detect-object-injection -- rk from rarityKeys const, i is array index
|
||||
// rk from rarityKeys const, i is array index
|
||||
rarityKeys.map((rk, i) => [rk, rvs[i]]),
|
||||
) as Record<BadgeRarityKey, Partial<BadgeLeaderboardEntry> | undefined>;
|
||||
}
|
||||
@@ -414,7 +414,7 @@ export async function GET(req: Request) {
|
||||
viewerEntry: achievementViewer,
|
||||
},
|
||||
rarity: Object.fromEntries(
|
||||
// eslint-disable-next-line security/detect-object-injection -- rk from rarityKeys const
|
||||
// rk from rarityKeys const
|
||||
rarityKeys.map((rk) => [
|
||||
rk,
|
||||
{ ...global.rarity[rk], viewerEntry: rarityViewer[rk] },
|
||||
|
||||
@@ -110,7 +110,7 @@ export async function GET(req: Request) {
|
||||
async () =>
|
||||
type === "credits"
|
||||
? await loadCreditsRows()
|
||||
: // eslint-disable-next-line security/detect-object-injection -- type validated to "diamonds"|"duckets"
|
||||
: // type validated to "diamonds"|"duckets"
|
||||
await loadCurrencyRows(CURRENCY_TYPE[type]),
|
||||
// Rankings are re-sorted on every read; serving the previous order for a
|
||||
// minute keeps a busy leaderboard off the database.
|
||||
|
||||
@@ -51,7 +51,6 @@ export async function GET(
|
||||
headers: SECURITY_HEADERS,
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
if (!existsSync(filePath)) {
|
||||
return new NextResponse("Not found", {
|
||||
status: 404,
|
||||
@@ -59,7 +58,6 @@ export async function GET(
|
||||
});
|
||||
}
|
||||
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
const bytes = await readFile(filePath);
|
||||
const mime: Record<string, string> = {
|
||||
".png": "image/png",
|
||||
@@ -76,7 +74,7 @@ export async function GET(
|
||||
headers: {
|
||||
...SECURITY_HEADERS,
|
||||
...(ext === ".svg" ? { "Content-Disposition": "attachment" } : {}),
|
||||
// eslint-disable-next-line security/detect-object-injection -- ext validated against ALLOWED_EXT
|
||||
// ext validated against ALLOWED_EXT
|
||||
"Content-Type": mime[ext] ?? "application/octet-stream",
|
||||
"Cache-Control": "public, max-age=3600, must-revalidate",
|
||||
},
|
||||
|
||||
@@ -12,11 +12,9 @@ import { PERMS } from "@/lib/permissions";
|
||||
*/
|
||||
export const GET = withAdmin({ permission: PERMS.PAGES_EDIT }, async () => {
|
||||
const dir = MEDIA_ROOT;
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
if (!existsSync(dir)) {
|
||||
return apiOk({ files: [] });
|
||||
}
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
const files = readdirSync(dir)
|
||||
.filter((f) => /\.(png|jpg|jpeg|gif|webp|svg|bmp)$/i.test(f))
|
||||
.map((f) => {
|
||||
|
||||
@@ -35,7 +35,7 @@ function findCount(value: unknown, depth = 0): number | null {
|
||||
"count",
|
||||
];
|
||||
for (const key of keys) {
|
||||
// eslint-disable-next-line security/detect-object-injection -- keys from hardcoded array
|
||||
// keys from hardcoded array
|
||||
const v = value[key];
|
||||
if (typeof v === "number" && Number.isFinite(v)) return v;
|
||||
if (
|
||||
|
||||
+194
-84
@@ -500,9 +500,14 @@ summary,
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Site background: Habbo sky ── */
|
||||
/* ── Site background: Habbo sky ──
|
||||
The sky lives in a custom property so the Theme Manager can layer a CMS
|
||||
wallpaper *on top of* it (`background-image: url(...), var(--site-bg-gradient)`)
|
||||
instead of replacing it. Painting the wallpaper as the only layer meant a
|
||||
missing upload silently erased the whole gradient and left the site on flat
|
||||
white. */
|
||||
.site-bg {
|
||||
background: linear-gradient(
|
||||
--site-bg-gradient: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--color-primary) 4%, #b3d9f7) 0%,
|
||||
color-mix(in srgb, var(--color-primary) 6%, #d4e8fa) 25%,
|
||||
@@ -510,7 +515,10 @@ summary,
|
||||
color-mix(in srgb, var(--color-primary) 10%, #f0e8d8) 75%,
|
||||
color-mix(in srgb, var(--color-primary) 14%, var(--color-background)) 100%
|
||||
);
|
||||
background-image: var(--site-bg-gradient);
|
||||
background-attachment: fixed;
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
position: relative;
|
||||
}
|
||||
/* Habbo-style clouds */
|
||||
@@ -711,13 +719,16 @@ html.dark .cloud {
|
||||
);
|
||||
}
|
||||
html.dark .site-bg {
|
||||
background: linear-gradient(
|
||||
--site-bg-gradient: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--color-primary) 6%, #0a0e1a) 0%,
|
||||
color-mix(in srgb, var(--color-primary) 8%, #10152a) 30%,
|
||||
color-mix(in srgb, var(--color-primary) 10%, #161c30) 60%,
|
||||
color-mix(in srgb, var(--color-primary) 12%, #0f141f) 100%
|
||||
);
|
||||
/* Re-declaring `background-image` here would out-specify the wallpaper
|
||||
layer injected by ThemeVars (body.site-bg) and silently drop it in dark
|
||||
mode. The gradient is picked up through `--site-bg-gradient` instead. */
|
||||
background-attachment: fixed;
|
||||
}
|
||||
html.dark .site-bg::before {
|
||||
@@ -2081,9 +2092,9 @@ body:has([data-admin]) {
|
||||
|
||||
/* Admin forms / boxes follow admin tokens in both light and dark. */
|
||||
body:has([data-admin])
|
||||
input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not(
|
||||
[type="file"]
|
||||
):not([type="color"]),
|
||||
input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not(
|
||||
[type="file"]
|
||||
):not([type="color"]),
|
||||
body:has([data-admin]) select,
|
||||
body:has([data-admin]) textarea {
|
||||
background-color: var(--admin-surface-elevated);
|
||||
@@ -2559,17 +2570,6 @@ details[open] > summary .details-open\:rotate-180 {
|
||||
/* ── Classic-landing polish (theme-aware) ────────────────────────────
|
||||
Keeps the Habbo sky and theme colors; adds subtle depth + glow to the
|
||||
restored landing pages. Everything is driven by theme CSS variables. */
|
||||
.brand-halo {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
border-radius: 999px;
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
color-mix(in srgb, var(--color-primary) 30%, transparent) 0%,
|
||||
transparent 70%
|
||||
);
|
||||
filter: blur(20px);
|
||||
}
|
||||
.card-glow {
|
||||
transition:
|
||||
box-shadow 0.3s ease,
|
||||
@@ -2647,6 +2647,123 @@ details[open] > summary .details-open\:rotate-180 {
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
/* ── Landing panel system ────────────────────────────────────────────
|
||||
One recipe for every card on the public pages. The old panels each mixed
|
||||
their own translucent washes (surface 44–60% + backdrop blur), which read as
|
||||
a milky smear over the sky gradient, gave the same four numbers two
|
||||
different-looking boxes, and cost a backdrop snapshot per panel on every
|
||||
scroll. These tokens are opaque, theme-derived and shared. */
|
||||
:root {
|
||||
--panel-surface: color-mix(
|
||||
in srgb,
|
||||
var(--color-surface) 93%,
|
||||
var(--color-primary)
|
||||
);
|
||||
--panel-surface-alt: color-mix(
|
||||
in srgb,
|
||||
var(--color-surface) 88%,
|
||||
var(--color-accent)
|
||||
);
|
||||
--panel-surface-sunken: color-mix(
|
||||
in srgb,
|
||||
var(--color-surface) 96%,
|
||||
var(--color-background)
|
||||
);
|
||||
--panel-border: color-mix(in srgb, var(--color-text-muted) 16%, transparent);
|
||||
--panel-border-strong: color-mix(
|
||||
in srgb,
|
||||
var(--color-primary) 32%,
|
||||
transparent
|
||||
);
|
||||
--panel-shadow:
|
||||
0 1px 2px color-mix(in srgb, #0b1220 7%, transparent),
|
||||
0 18px 40px -26px color-mix(in srgb, #0b1220 55%, transparent);
|
||||
--panel-header-bg: color-mix(
|
||||
in srgb,
|
||||
var(--color-primary) 8%,
|
||||
var(--color-surface)
|
||||
);
|
||||
--panel-header-border: color-mix(
|
||||
in srgb,
|
||||
var(--color-text-muted) 12%,
|
||||
transparent
|
||||
);
|
||||
--panel-radius: 18px;
|
||||
}
|
||||
html.dark {
|
||||
--panel-surface: color-mix(
|
||||
in srgb,
|
||||
var(--color-surface) 88%,
|
||||
var(--color-background)
|
||||
);
|
||||
--panel-surface-alt: color-mix(
|
||||
in srgb,
|
||||
var(--color-surface) 82%,
|
||||
var(--color-background)
|
||||
);
|
||||
--panel-surface-sunken: color-mix(
|
||||
in srgb,
|
||||
var(--color-surface) 92%,
|
||||
var(--color-background)
|
||||
);
|
||||
--panel-border: color-mix(in srgb, var(--color-text-muted) 24%, transparent);
|
||||
--panel-border-strong: color-mix(
|
||||
in srgb,
|
||||
var(--color-primary) 46%,
|
||||
transparent
|
||||
);
|
||||
--panel-shadow:
|
||||
0 1px 2px rgb(0 0 0 / 0.4), 0 24px 48px -28px rgb(0 0 0 / 0.85);
|
||||
--panel-header-bg: color-mix(
|
||||
in srgb,
|
||||
var(--color-primary) 13%,
|
||||
var(--color-surface)
|
||||
);
|
||||
--panel-header-border: color-mix(
|
||||
in srgb,
|
||||
var(--color-text-muted) 20%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
/* The standard card. `rounded-[--panel-radius]` is spelled out in the
|
||||
component, this class only owns colour, border and depth. */
|
||||
.panel {
|
||||
background: var(--panel-surface);
|
||||
border: 1px solid var(--panel-border);
|
||||
box-shadow: var(--panel-shadow);
|
||||
}
|
||||
/* A panel that floats over the hero, so it needs a deeper shadow. */
|
||||
.panel-float {
|
||||
box-shadow:
|
||||
0 2px 6px color-mix(in srgb, #0b1220 10%, transparent),
|
||||
0 34px 64px -30px color-mix(in srgb, #0b1220 62%, transparent);
|
||||
}
|
||||
html.dark .panel-float {
|
||||
box-shadow:
|
||||
0 2px 6px rgb(0 0 0 / 0.5),
|
||||
0 36px 70px -30px rgb(0 0 0 / 0.9);
|
||||
}
|
||||
/* A tile that sits *inside* a panel — icon chips, stat wells, list rows. */
|
||||
.panel-sunken {
|
||||
background: var(--panel-surface-sunken);
|
||||
border: 1px solid color-mix(in srgb, var(--color-text-muted) 10%, transparent);
|
||||
}
|
||||
.panel-header {
|
||||
background: var(--panel-header-bg);
|
||||
border-bottom: 1px solid var(--panel-header-border);
|
||||
}
|
||||
/* Icon chip inside a panel. `--panel-tint` is set per instance. */
|
||||
.panel-tile {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--panel-tint, var(--color-primary)) 15%,
|
||||
transparent
|
||||
);
|
||||
box-shadow: inset 0 0 0 1px
|
||||
color-mix(in srgb, var(--panel-tint, var(--color-primary)) 26%, transparent);
|
||||
}
|
||||
|
||||
/* ── Smooth motion (landing) ───────────────────────────────────────── */
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
@@ -2668,6 +2785,15 @@ html {
|
||||
|
||||
/* Gentle breathing pulse for the brand glows behind Frank / the hero. */
|
||||
.brand-halo {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
border-radius: 999px;
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
color-mix(in srgb, var(--color-primary) 30%, transparent) 0%,
|
||||
transparent 70%
|
||||
);
|
||||
filter: blur(20px);
|
||||
animation: hero-halo-pulse 6s ease-in-out infinite;
|
||||
will-change: transform;
|
||||
}
|
||||
@@ -2691,8 +2817,7 @@ html {
|
||||
.brand-halo {
|
||||
animation: none;
|
||||
}
|
||||
.hero-ring,
|
||||
.glass-chip {
|
||||
.hero-ring {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -2734,48 +2859,6 @@ html {
|
||||
}
|
||||
}
|
||||
|
||||
/* Frosted floating stat chips that bob gently on the hero.
|
||||
No backdrop-filter while animating: sampling the backdrop every frame is
|
||||
expensive, so the chips use a higher-opacity fill instead. */
|
||||
.glass-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.45rem 0.85rem;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--color-surface) 72%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--color-text-muted) 14%, transparent);
|
||||
box-shadow:
|
||||
0 10px 26px -10px color-mix(in srgb, #000 30%, transparent),
|
||||
inset 0 1px 0 0 color-mix(in srgb, #fff 12%, transparent);
|
||||
animation: float 5s ease-in-out infinite;
|
||||
will-change: transform;
|
||||
}
|
||||
.glass-chip:nth-child(2) {
|
||||
animation-delay: -1.7s;
|
||||
}
|
||||
.glass-chip:nth-child(3) {
|
||||
animation-delay: -3.4s;
|
||||
}
|
||||
.glass-chip-dot {
|
||||
width: 0.45rem;
|
||||
height: 0.45rem;
|
||||
border-radius: 999px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.glass-chip-value {
|
||||
font-weight: 900;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1;
|
||||
}
|
||||
.glass-chip-label {
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* ── Public page polish: buttons ──────────────────────────────────── */
|
||||
.btn-brand {
|
||||
position: relative;
|
||||
@@ -2893,6 +2976,20 @@ html {
|
||||
0 0 0 3px color-mix(in srgb, var(--color-primary) 16%, transparent),
|
||||
0 0 24px -8px color-mix(in srgb, var(--color-primary) 60%, transparent);
|
||||
}
|
||||
.auth-input[aria-invalid="true"]:not(:focus) {
|
||||
border-color: color-mix(in srgb, var(--color-danger) 60%, transparent);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--color-danger) 6%,
|
||||
color-mix(in srgb, var(--color-background) 88%, var(--color-surface))
|
||||
);
|
||||
}
|
||||
.auth-input[aria-invalid="true"]:focus {
|
||||
border-color: var(--color-danger);
|
||||
box-shadow:
|
||||
0 0 0 3px color-mix(in srgb, var(--color-danger) 18%, transparent),
|
||||
0 0 24px -8px color-mix(in srgb, var(--color-danger) 55%, transparent);
|
||||
}
|
||||
.auth-input:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
@@ -2943,10 +3040,9 @@ html {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-readable);
|
||||
background: color-mix(in srgb, var(--color-surface) 55%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--color-text-muted) 16%, transparent);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
backdrop-filter: blur(10px);
|
||||
background: var(--panel-surface);
|
||||
border: 1px solid var(--panel-border);
|
||||
box-shadow: 0 1px 2px color-mix(in srgb, #0b1220 6%, transparent);
|
||||
transition:
|
||||
background 0.25s ease,
|
||||
border-color 0.25s ease,
|
||||
@@ -2956,13 +3052,13 @@ html {
|
||||
.explore-pill:hover {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--color-primary) 16%,
|
||||
var(--color-surface)
|
||||
var(--color-primary) 14%,
|
||||
var(--panel-surface)
|
||||
);
|
||||
border-color: color-mix(in srgb, var(--color-primary) 50%, transparent);
|
||||
border-color: var(--panel-border-strong);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 12px 26px -14px
|
||||
color-mix(in srgb, var(--color-primary) 80%, transparent);
|
||||
box-shadow: 0 14px 28px -16px
|
||||
color-mix(in srgb, var(--color-primary) 85%, transparent);
|
||||
}
|
||||
.explore-pill .pill-arrow {
|
||||
opacity: 0;
|
||||
@@ -3065,30 +3161,44 @@ html {
|
||||
}
|
||||
}
|
||||
|
||||
/* Dividers between the four counters in the stats panel. */
|
||||
/* Hairlines between the headline counters.
|
||||
The strip is two-up on narrow screens and four-up from `lg`. The old rules
|
||||
only knew the four-up layout: on mobile they drew a single vertical line
|
||||
between the two rows instead of between the two columns, and none between
|
||||
the columns at all. Both rules are drawn from the same token so the grid
|
||||
always reads as four separate figures. */
|
||||
.stat-cell {
|
||||
position: relative;
|
||||
}
|
||||
.stat-cell::before,
|
||||
.stat-cell::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
background: color-mix(in srgb, var(--color-text-muted) 20%, transparent);
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
/* Four across: a vertical rule in front of every cell but the first. */
|
||||
.stat-cell:not(:first-child)::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -0.5rem;
|
||||
top: 18%;
|
||||
height: 64%;
|
||||
left: -0.75rem;
|
||||
top: 8%;
|
||||
height: 84%;
|
||||
width: 1px;
|
||||
background: color-mix(in srgb, var(--color-text-muted) 20%, transparent);
|
||||
}
|
||||
}
|
||||
@media (max-width: 1023px) {
|
||||
.stat-cell:nth-child(3)::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -0.5rem;
|
||||
top: 10%;
|
||||
height: 80%;
|
||||
/* Two across: a vertical rule between the two columns of each row… */
|
||||
.stat-cell:nth-child(even)::before {
|
||||
left: -0.75rem;
|
||||
top: 8%;
|
||||
height: 84%;
|
||||
width: 1px;
|
||||
background: color-mix(in srgb, var(--color-text-muted) 20%, transparent);
|
||||
}
|
||||
/* …and a horizontal rule between the two rows. */
|
||||
.stat-cell:nth-child(n + 3)::after {
|
||||
left: 6%;
|
||||
right: 6%;
|
||||
top: -0.75rem;
|
||||
height: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+25
-4
@@ -13,6 +13,7 @@ import { ThemeVars } from "@/components/theme-vars";
|
||||
import { enforceSiteAccess } from "@/lib/access-guard";
|
||||
import { getDefaultFaviconDataUri } from "@/lib/default-favicon";
|
||||
import { resolveHotelName } from "@/lib/hotel-name";
|
||||
import { publicClientMessages } from "@/lib/i18n-client-messages";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import "./globals.css";
|
||||
|
||||
@@ -37,7 +38,10 @@ const pixelify = Pixelify_Sans({
|
||||
// export const instant = false;
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const appUrl = process.env.APP_URL ?? "http://localhost:3000";
|
||||
const appUrl = (process.env.APP_URL ?? "http://localhost:3000").replace(
|
||||
/\/+$/,
|
||||
"",
|
||||
);
|
||||
const hotel = await resolveHotelName();
|
||||
const customFavicon = await siteSettings.get("cms_favicon");
|
||||
const faviconUrl = customFavicon ?? getDefaultFaviconDataUri();
|
||||
@@ -49,15 +53,28 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
title: { default: `${hotel} - Hotel`, template: `%s · ${hotel}` },
|
||||
description: `${hotel} — a Habbo retro hotel. Hang out, collect furni and meet friends.`,
|
||||
metadataBase: new URL(appUrl),
|
||||
alternates: { canonical: appUrl },
|
||||
alternates: {
|
||||
canonical: appUrl,
|
||||
types: { "application/rss+xml": `${appUrl}/news/rss.xml` },
|
||||
},
|
||||
icons,
|
||||
openGraph: {
|
||||
title: `${hotel} - Hotel`,
|
||||
description: `${hotel} — a Habbo retro hotel. Hang out, collect furni and meet friends.`,
|
||||
type: "website",
|
||||
siteName: hotel,
|
||||
images: [
|
||||
{
|
||||
url: `${appUrl}/assets/images/next_header.jpg`,
|
||||
alt: `${hotel} - Hotel`,
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: `${hotel} - Hotel`,
|
||||
images: [`${appUrl}/assets/images/next_header.jpg`],
|
||||
},
|
||||
twitter: { card: "summary_large_image", title: `${hotel} - Hotel` },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -68,7 +85,11 @@ export default async function RootLayout({
|
||||
}) {
|
||||
await enforceSiteAccess();
|
||||
const locale = await getLocale();
|
||||
const messages = await getMessages();
|
||||
// Only the namespaces non-staff routes can reach: the full catalogue is
|
||||
// ~254 KB and would otherwise be serialised into every public page's RSC
|
||||
// payload. `/admin`, `/admin-next` and `/mod` install a nested provider
|
||||
// seeded with the full set (see i18n-client-messages).
|
||||
const messages = publicClientMessages(await getMessages());
|
||||
const defaultDark = await siteSettings.getBool("default_dark", false);
|
||||
const nitroUrl = await siteSettings.get("nitro_client_url", "");
|
||||
const nonce = (await headers()).get("x-nonce") ?? undefined;
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
import { getLocale, getMessages, getTranslations } from "next-intl/server";
|
||||
import type { ReactNode } from "react";
|
||||
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||
import Link from "@/components/link";
|
||||
@@ -81,8 +82,13 @@ export default async function ModLayout({ children }: { children: ReactNode }) {
|
||||
{ href: "/mod/team", label: t("nav.team"), icon: Users, show: showTeam },
|
||||
].filter((item) => item.show);
|
||||
|
||||
// The root layout ships the reduced catalogue so public pages do not carry
|
||||
// the ~195 KB of staff-tool strings. Nested providers replace the parent
|
||||
// set rather than merging, so staff route groups re-install the full one.
|
||||
const [locale, messages] = await Promise.all([getLocale(), getMessages()]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||
{csrfToken ? <meta name="csrf-token" content={csrfToken} /> : null}
|
||||
<div
|
||||
data-admin
|
||||
@@ -140,6 +146,6 @@ export default async function ModLayout({ children }: { children: ReactNode }) {
|
||||
</header>
|
||||
<main className="mx-auto max-w-6xl p-4 sm:p-6">{children}</main>
|
||||
</div>
|
||||
</>
|
||||
</NextIntlClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -45,7 +45,6 @@ export function ArticleCard({
|
||||
<div className="admin-card overflow-hidden flex flex-col">
|
||||
{confirmDialog}
|
||||
<div className="relative w-full aspect-[16/9] bg-[var(--admin-canvas)]">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={image || ""}
|
||||
alt={title}
|
||||
|
||||
@@ -296,7 +296,6 @@ export function ArticleForm({
|
||||
/>
|
||||
</div>
|
||||
{image && !imageError ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={image}
|
||||
alt={t("imagePreview")}
|
||||
|
||||
@@ -30,9 +30,9 @@ import {
|
||||
suggestCatalogLayoutFromBaseItems,
|
||||
suggestCategoryName,
|
||||
} from "@/features/catalog/domain/organize";
|
||||
import { logger } from "@/lib/browser-log";
|
||||
import { CATALOG_LAYOUTS, type CatalogLayout } from "@/lib/catalog-layouts";
|
||||
import { getLocalIconUrl } from "@/lib/furni/classname";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LiveCatalogPreview } from "./live-catalog-preview";
|
||||
|
||||
@@ -715,7 +715,6 @@ function FurniThumb({
|
||||
className="flex items-center justify-center rounded bg-muted/50"
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={primary}
|
||||
alt={item.itemName}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { ChevronRight, Home } from "lucide-react";
|
||||
import { Fragment, useEffect } from "react";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { logger } from "@/lib/browser-log";
|
||||
import { useCatalogManager } from "./use-catalog-store";
|
||||
|
||||
export function BreadcrumbBar({
|
||||
|
||||
@@ -62,7 +62,7 @@ import { requestCatalogNavigation } from "@/features/catalog/client/use-catalog-
|
||||
import { useLiveCatalog } from "@/features/catalog/client/use-live-catalog";
|
||||
import { DuplicateCategoryDialog } from "@/features/catalog/components/duplicate-category-dialog";
|
||||
import type { DuplicateInput } from "@/features/catalog/domain/duplicate";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { logger } from "@/lib/browser-log";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
type FlatTreeNode,
|
||||
@@ -893,7 +893,7 @@ export function SortableTree({
|
||||
handleToggleEnabled,
|
||||
handleAddSubpage,
|
||||
],
|
||||
); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -34,7 +34,6 @@ export function FurniIcon({
|
||||
|
||||
return (
|
||||
<div className="w-10 h-10 rounded bg-muted/50 flex items-center justify-center overflow-hidden">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={localUrl}
|
||||
alt={classname}
|
||||
|
||||
@@ -11,8 +11,8 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { logger } from "@/lib/browser-log";
|
||||
import { catalogueIconUrl } from "@/lib/catalog-assets";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface IconPickerProps {
|
||||
|
||||
@@ -13,8 +13,8 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
import { logger } from "@/lib/browser-log";
|
||||
import { getHabboCdnIconUrl, getLocalIconUrl } from "@/lib/furni/classname";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
interface FurniResult {
|
||||
id: number;
|
||||
@@ -231,7 +231,6 @@ function FurniThumb({
|
||||
|
||||
return (
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded bg-muted/50">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={primary}
|
||||
alt={classname}
|
||||
|
||||
@@ -192,7 +192,6 @@ export function ShopFurniIcon({
|
||||
}
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={primary}
|
||||
alt={classname}
|
||||
|
||||
@@ -4,8 +4,8 @@ import { useTranslations } from "next-intl";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { deleteMedia, uploadMedia } from "@/actions/admin-media";
|
||||
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
|
||||
import { logger } from "@/lib/browser-log";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { logger } from "@/lib/logger";
|
||||
import type { MediaFile } from "@/lib/media-files";
|
||||
import { loadMediaFiles, validImageUrl } from "@/lib/media-files";
|
||||
|
||||
@@ -33,6 +33,7 @@ export function AdminMediaGrid() {
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const copyTimers = useRef<Set<ReturnType<typeof setTimeout>>>(new Set());
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -40,6 +41,12 @@ export function AdminMediaGrid() {
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
for (const t of copyTimers.current) clearTimeout(t);
|
||||
copyTimers.current.clear();
|
||||
};
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
@@ -226,7 +233,6 @@ export function AdminMediaGrid() {
|
||||
}}
|
||||
>
|
||||
<div className="relative">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={validImageUrl(f.url)}
|
||||
alt={f.name}
|
||||
|
||||
@@ -156,7 +156,6 @@ export function MediaPicker({
|
||||
e.currentTarget.style.borderColor = "transparent";
|
||||
}}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={validImageUrl(f.url)}
|
||||
alt={f.name}
|
||||
|
||||
@@ -52,13 +52,13 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { applyCatalogDelta } from "@/features/catalog/client/use-live-catalog";
|
||||
import { logger } from "@/lib/browser-log";
|
||||
import {
|
||||
CATALOG_LAYOUTS,
|
||||
type CatalogLayout,
|
||||
isValidCatalogLayout,
|
||||
LAYOUT_DESCRIPTIONS,
|
||||
} from "@/lib/catalog-layouts";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const MAX_ITEMS_PER_RUN = 500;
|
||||
|
||||
@@ -51,7 +51,6 @@ export function FurniThumb({
|
||||
className="flex items-center justify-center rounded bg-muted/50"
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={primary}
|
||||
alt={item.itemName}
|
||||
|
||||
@@ -405,7 +405,7 @@ export function ThemeManagerPanel({
|
||||
width: 14,
|
||||
height: 14,
|
||||
borderRadius: 3,
|
||||
// eslint-disable-next-line security/detect-object-injection -- k from fixed key list
|
||||
// k from fixed key list
|
||||
background: palette.light[k],
|
||||
boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.15)",
|
||||
marginLeft: -3,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface AuthAlertProps {
|
||||
children: ReactNode;
|
||||
/** Tint of the box: red for errors, green for confirmations. */
|
||||
variant?: "error" | "success";
|
||||
/** Extra classes, e.g. spacing (`mb-4`) or entrance animation. */
|
||||
className?: string;
|
||||
/** Optional id so inputs can point at the message via `aria-describedby`. */
|
||||
id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The single status/error banner for the auth forms. Uses `role="alert"` for
|
||||
* failures (screen readers announce them immediately) and `role="status"` for
|
||||
* confirmations, so the two are wired correctly instead of every caller
|
||||
* repeating the markup and risking an unannounced message.
|
||||
*/
|
||||
export function AuthAlert({
|
||||
children,
|
||||
variant = "error",
|
||||
className = "",
|
||||
id,
|
||||
}: AuthAlertProps) {
|
||||
return (
|
||||
<p
|
||||
id={id}
|
||||
role={variant === "error" ? "alert" : "status"}
|
||||
className={`auth-alert animate-fade-in-up m-0 ${
|
||||
variant === "success" ? "auth-alert--success" : ""
|
||||
} ${className}`}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { CSSProperties, ReactNode, Ref } from "react";
|
||||
|
||||
const ICON_CLASS =
|
||||
"pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2";
|
||||
|
||||
export interface AuthFieldProps {
|
||||
id: string;
|
||||
name: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
type?: string;
|
||||
/** Controlled value; omit both `value` and `onChange` for an uncontrolled field. */
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
placeholder?: string;
|
||||
autoComplete?: string;
|
||||
inputMode?:
|
||||
| "none"
|
||||
| "text"
|
||||
| "tel"
|
||||
| "url"
|
||||
| "email"
|
||||
| "numeric"
|
||||
| "decimal"
|
||||
| "search";
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
/** Marks the field with `aria-invalid` and a danger border. */
|
||||
invalid?: boolean;
|
||||
/** id of the element describing this field (e.g. an error banner). */
|
||||
describedBy?: string;
|
||||
iconColor?: string;
|
||||
inputStyle?: CSSProperties;
|
||||
/** Absolutely positioned control inside the input, e.g. `PasswordToggle`. */
|
||||
trailing?: ReactNode;
|
||||
/** Content rendered below the field: strength meter, policy hints, mismatch. */
|
||||
children?: ReactNode;
|
||||
/** Extra classes on the wrapping `space-y` container. */
|
||||
className?: string;
|
||||
inputRef?: Ref<HTMLInputElement>;
|
||||
}
|
||||
|
||||
/**
|
||||
* One labelled, icon-prefixed auth input. Login and register repeat this markup
|
||||
* seven times; centralising it keeps the icon, focus and `aria-describedby`
|
||||
* behaviour identical everywhere.
|
||||
*/
|
||||
export function AuthField({
|
||||
id,
|
||||
name,
|
||||
label,
|
||||
icon: Icon,
|
||||
type = "text",
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
autoComplete,
|
||||
inputMode,
|
||||
required,
|
||||
disabled,
|
||||
minLength,
|
||||
maxLength,
|
||||
invalid,
|
||||
describedBy,
|
||||
iconColor = "var(--color-text-muted)",
|
||||
inputStyle,
|
||||
trailing,
|
||||
children,
|
||||
className = "",
|
||||
inputRef,
|
||||
}: AuthFieldProps) {
|
||||
return (
|
||||
<div className={`space-y-1.5 ${className}`}>
|
||||
<label htmlFor={id} className="auth-label">
|
||||
{label}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Icon
|
||||
aria-hidden="true"
|
||||
className={ICON_CLASS}
|
||||
style={{ color: iconColor }}
|
||||
/>
|
||||
<input
|
||||
ref={inputRef}
|
||||
id={id}
|
||||
name={name}
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={onChange ? (e) => onChange(e.target.value) : undefined}
|
||||
placeholder={placeholder}
|
||||
autoComplete={autoComplete}
|
||||
inputMode={inputMode}
|
||||
required={required}
|
||||
disabled={disabled}
|
||||
minLength={minLength}
|
||||
maxLength={maxLength}
|
||||
aria-invalid={invalid ? true : undefined}
|
||||
aria-describedby={describedBy}
|
||||
className={`auth-input has-icon ${trailing ? "has-action" : ""}`}
|
||||
style={inputStyle}
|
||||
/>
|
||||
{trailing}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loaded 100 of 176 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user