Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b43a15a38d | ||
|
|
3933214953 | ||
|
|
8561c3f85e | ||
|
|
d2350a6427 | ||
|
|
0845f80768 | ||
|
|
3265c149da | ||
|
|
57710a7fe3 | ||
|
|
5b2eb91c5c | ||
|
|
11ad6d4376 | ||
|
|
6c3d81920e | ||
|
|
108c6ce03d | ||
|
|
6bffc53779 | ||
|
|
3d828a61ab | ||
|
|
7f07c111ac | ||
|
|
8218039c64 | ||
|
|
f705c67fc7 | ||
|
|
c8b3054527 | ||
|
|
8ec3df541e | ||
|
|
8ee144745a | ||
|
|
64ad9baf39 | ||
|
|
704e33638f | ||
|
|
eddb7edea4 | ||
|
|
1c9ddcd48a | ||
|
|
30ff970c38 |
No files matched your search
@@ -33,6 +33,12 @@ jobs:
|
||||
- name: Toolchain check
|
||||
run: node scripts/check-node-toolchain.mjs
|
||||
|
||||
# Port selection decides which blue/green slot stays live. Getting it
|
||||
# wrong starts the candidate on an occupied port, so the regression that
|
||||
# caused a failed deploy is covered here, before any image is built.
|
||||
- name: Deploy port-selection tests
|
||||
run: bash scripts/ci-deploy-ports.test.sh
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
image: node:26
|
||||
|
||||
# Runner containers have no systemd, so scripts/with-memory-cap.sh cannot
|
||||
# enforce an RSS cap there and correctly refuses to run unbounded. These
|
||||
# builds are already isolated inside their own runner container (not the
|
||||
# host) and use the webpack builder; opt out explicitly on purpose.
|
||||
variables:
|
||||
CMS_MEMORY_CAP_BACKEND: "none"
|
||||
|
||||
stages:
|
||||
- test
|
||||
- build
|
||||
|
||||
+35
-27
@@ -1,40 +1,50 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# Pin the runtime to the supported engine; update both stages deliberately.
|
||||
FROM node:26.10.0-alpine AS migrations
|
||||
WORKDIR /app
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
# Keep the bootstrap aligned with package.json packageManager.
|
||||
# The apk cache is persisted in a BuildKit cache mount so git is not
|
||||
# re-downloaded on every build.
|
||||
|
||||
# Installeer git, bash en pnpm v12. bash is nodig voor
|
||||
# scripts/with-memory-cap.sh (gebruikt bashisme zoals arrays en BASH_REMATCH);
|
||||
# Alpine levert geen bash mee.
|
||||
RUN --mount=type=cache,target=/var/cache/apk \
|
||||
apk add --no-cache git \
|
||||
&& npm install -g pnpm@11.25.0
|
||||
# The pnpm store is kept in a BuildKit cache mount that persists across builds
|
||||
# on the builder. This is what stops disk usage from growing unbounded: the
|
||||
# downloaded dependency store is shared and reused instead of being copied into
|
||||
# a fresh image layer on every build. Unlike an image layer it is also prunable
|
||||
# independently, so a hard cap (see ci-deploy.sh) keeps it bounded.
|
||||
ENV PNPM_HOME=/pnpm PNPM_STORE=/pnpm/store
|
||||
# pnpm-workspace.yaml + .npmrc must be present too: the lockfile records the
|
||||
# overrides from pnpm-workspace.yaml, and --frozen-lockfile rejects a build
|
||||
# where the workspace config is absent (ERR_PNPM_LOCKFILE_CONFIG_MISMATCH).
|
||||
apk add --no-cache git bash \
|
||||
&& npm install -g pnpm@12.10.1
|
||||
|
||||
# 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"
|
||||
|
||||
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* .npmrc* ./
|
||||
# pnpm fetch: download all deps into the shared cache-mounted store.
|
||||
RUN --mount=type=cache,target=/pnpm \
|
||||
pnpm fetch --ignore-scripts
|
||||
# Install offline from the cache-mounted store; the store itself stays in the
|
||||
# build cache between builds.
|
||||
RUN --mount=type=cache,target=/pnpm \
|
||||
pnpm install --frozen-lockfile --ignore-scripts --offline
|
||||
|
||||
# Voer de installatie uit met de pnpm v12 store cache-mount
|
||||
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
|
||||
pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
COPY . .
|
||||
ARG NEXT_DEPLOYMENT_ID="unknown"
|
||||
LABEL org.opencontainers.image.revision="$NEXT_DEPLOYMENT_ID"
|
||||
|
||||
FROM migrations AS builder
|
||||
ARG NEXT_DEPLOYMENT_ID="unknown"
|
||||
ENV NEXT_DEPLOYMENT_ID="$NEXT_DEPLOYMENT_ID"
|
||||
# Fixture values exist only for this build command; production secrets are runtime-only.
|
||||
# Cache Next.js build output and webpack caches so rebuilds only redo the
|
||||
# changed parts.
|
||||
|
||||
# The build runs the webpack builder (see the `build` script in package.json).
|
||||
# Turbopack's compiler is a single native process that grows past 12GB RSS on
|
||||
# this 329-route app and gets OOM-killed; webpack peaks around 5GB. A
|
||||
# --max-old-space-size cap does NOT help, because that memory is native
|
||||
# Turbopack memory rather than the V8 heap.
|
||||
#
|
||||
# `pnpm run build` goes through scripts/with-memory-cap.sh. BuildKit's build
|
||||
# container has /sys/fs/cgroup mounted read-only (no cgroup MemoryMax) and
|
||||
# `ulimit -v` breaks V8-based builds (see the script header), so this stage
|
||||
# explicitly opts out of the cap. The real bound here is the webpack builder
|
||||
# + the V8 heap cap above, and the build runs isolated in its own container,
|
||||
# not on the host; the host itself is protected by the same wrapper through
|
||||
# systemd.
|
||||
ENV NODE_OPTIONS="--max-old-space-size=4096"
|
||||
ENV CMS_MEMORY_CAP_BACKEND=none
|
||||
|
||||
# Bouw de Next.js applicatie met caching
|
||||
RUN --mount=type=cache,target=/app/.next/cache \
|
||||
DATABASE_URL="mysql://build:[email protected]:9/build" \
|
||||
HOTEL_NAME="Build fixture" APP_URL="http://localhost:3002" \
|
||||
@@ -62,8 +72,6 @@ COPY --from=builder --chown=nextjs:nextjs /app/drizzle/migrations ./drizzle/migr
|
||||
COPY --chown=nextjs:nextjs scripts/docker-start.mjs ./docker-start.mjs
|
||||
USER nextjs
|
||||
EXPOSE 3002
|
||||
# Self-contained healthcheck so `docker run` (ci-deploy) also gets Docker-level
|
||||
# health; docker-compose overrides this with its own probe if needed.
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
||||
CMD ["node", "-e", "fetch('http://127.0.0.1:'+(process.env.PORT||'3002')+'/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||
ENTRYPOINT ["/sbin/tini", "--"]
|
||||
|
||||
@@ -875,6 +875,24 @@ 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
|
||||
|
||||
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
|
||||
that:
|
||||
|
||||
- `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.
|
||||
- `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`.
|
||||
|
||||
### The nginx upstream is the switch
|
||||
|
||||
nginx does not know about container names; it reads a plain list of backends from
|
||||
@@ -951,7 +969,7 @@ branch guard inside `ci-deploy.sh` only accepts `main`/`master`.
|
||||
| `pnpm db:bulk` | Batch-import >50 MB JSON via `scripts/bulk-import-json.ts` |
|
||||
| `pnpm db:up` / `pnpm db:down` | Start / stop the `mariadb-turbo` container |
|
||||
| `pnpm gamedata:compress` | Pre-compress large gamedata JSON to `.gz` (gzip_static) |
|
||||
| `pnpm analyze` | Build + open bundle analyzer |
|
||||
| `pnpm analyze` | Build + report per-route bundle sizes |
|
||||
| `pnpm jobs:worker` | Start background task worker |
|
||||
| `pnpm biome:check` | Lint and format code |
|
||||
|
||||
@@ -1080,7 +1098,7 @@ The CMS automatically translates furniture names and descriptions to **13 langua
|
||||
pnpm dev # Start with hot reload
|
||||
pnpm typecheck # Type check all files
|
||||
pnpm test # Run test suite
|
||||
pnpm analyze # Build and analyze bundle sizes
|
||||
pnpm analyze # Build and report per-route bundle sizes
|
||||
pnpm biome:check # Lint and format
|
||||
```
|
||||
|
||||
|
||||
+24
@@ -38,6 +38,30 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"includes": [
|
||||
"src/components/admin/catalog-manager/sortable-tree.tsx",
|
||||
"src/components/admin/studio/organize-imports-dialog/mall-helpers.tsx",
|
||||
"src/app/admin/import/furni/nitro-editor-dialog.tsx"
|
||||
],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"suspicious": {
|
||||
"noArrayIndexKey": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"includes": ["src/components/admin/catalog-manager/sortable-tree.tsx"],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"correctness": {
|
||||
"useExhaustiveDependencies": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"css": {
|
||||
|
||||
@@ -162,6 +162,22 @@ server {
|
||||
ssl_early_data on;
|
||||
add_header Alt-Svc 'h3=":9443"; ma=86400' always;
|
||||
|
||||
# Resuming a session skips the full handshake, which is most of the cost of
|
||||
# a TLS connection. Without this nginx performs no session resumption at all:
|
||||
# every visitor paid a full handshake on every request. 50M shared sessions
|
||||
# is roughly 1GB at the default 20-byte key id plus overhead.
|
||||
ssl_session_cache shared:CMS_TLS:50m;
|
||||
ssl_session_timeout 1d;
|
||||
ssl_session_tickets off;
|
||||
|
||||
# `index index.html` without a `root` left nginx resolving every
|
||||
# try_files/$uri against the compiled-in default /etc/nginx/html. The
|
||||
# /robots.txt and /favicon.ico probes then stat() a path the worker cannot
|
||||
# traverse, and because a failed stat is logged at crit the error log filled
|
||||
# with 149 crit lines per scan. Pointing root at the CMS document root makes
|
||||
# the same probe a plain 404, which log_not_found already suppresses.
|
||||
root /var/www/html;
|
||||
|
||||
index index.html;
|
||||
|
||||
# ─── Security Headers ───
|
||||
@@ -366,8 +382,21 @@ server {
|
||||
add_header Cache-Tag "cms-camera";
|
||||
}
|
||||
|
||||
# robots.txt is generated by the CMS (src/app/robots.ts, force-dynamic
|
||||
# because it needs APP_URL) and sitemap.xml points crawlers at it. This
|
||||
# location used to answer from disk with try_files, which made it a
|
||||
# guaranteed 404: the file does not exist in public/, so crawlers were told
|
||||
# to obey a robots.txt they could never read. Proxy it like the route it
|
||||
# actually is. favicon.ico below stays on disk — log_not_found already
|
||||
# keeps its miss quiet.
|
||||
location = /robots.txt {
|
||||
access_log off;
|
||||
proxy_pass http://cms_app;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
location = /favicon.ico { expires 1y; access_log off; log_not_found off; try_files $uri =404; }
|
||||
location = /robots.txt { expires 1d; access_log off; log_not_found off; try_files $uri =404; }
|
||||
|
||||
# ─── Static Next.js Assets ───
|
||||
location /_next/static/ {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
[Unit]
|
||||
# The scheduled-job worker (scheduled articles, catalog export, backups, disk
|
||||
# and health probes). This is NOT optional: a web process alone does not
|
||||
# establish that scheduled work runs. The CMS reports it as a failed
|
||||
# diagnostic row when the Redis heartbeat at cms:jobs-worker:heartbeat is
|
||||
# missing, which is exactly what happened while nothing supervised this.
|
||||
#
|
||||
# It runs on the host rather than in a container on purpose: the schedule
|
||||
# shells out to mysqldump, df and docker, none of which exist in the CMS image,
|
||||
# and it must survive CMS deploys (a container is replaced on every release).
|
||||
Description=AtomNext CMS scheduled-job worker
|
||||
Documentation=https://gitlab.epicnabbo.nl/remco/EpicNext-Cms
|
||||
After=network-online.target docker.service mariadb.service
|
||||
Wants=network-online.target
|
||||
# Start ordering only; the worker tolerates the database being briefly absent
|
||||
# and retries, so do not make it hard-fail when mariadb is slow to boot.
|
||||
Wants=docker.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/var/www/atom-nexst
|
||||
Environment=NODE_ENV=production
|
||||
ExecStart=/usr/bin/node --conditions=react-server --import tsx scripts/jobs-worker.ts
|
||||
# The worker's own catch-all logs and exits 1 on a fatal error, so a restart is
|
||||
# always wanted. 10s backoff stops a persistent misconfiguration (missing .env,
|
||||
# bad DATABASE_URL) from spinning.
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
# Give a crashed job time to finish its DB transaction before the next start,
|
||||
# otherwise a mid-transaction kill can loop on the same failure.
|
||||
TimeoutStopSec=30
|
||||
KillSignal=SIGTERM
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=cms-jobs-worker
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,19 @@
|
||||
[Service]
|
||||
# systemd's default is 1024:524288, i.e. a *soft* LimitNOFILE of 1024. nginx
|
||||
# inherits that soft limit, so worker_connections 2048 could not actually be
|
||||
# reached and every start logged:
|
||||
# "2048 worker_connections exceed open file resource limit: 1024"
|
||||
# Raise both soft and hard to 65536 so the master's rlimit covers
|
||||
# worker_connections before nginx is even started.
|
||||
LimitNOFILE=65536
|
||||
|
||||
# The packaged unit ships Restart=no, so a crashed or OOM-killed nginx stayed
|
||||
# down until someone noticed. nginx is the only thing serving the site, so it
|
||||
# must come back on its own. `on-failure` restarts only abnormal exits, which
|
||||
# keeps an operator-initiated `systemctl stop` from being undone.
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
|
||||
# Give in-flight requests time to drain on stop/reload instead of severing
|
||||
# keepalive connections and long-polling SSE streams mid-response.
|
||||
TimeoutStopSec=30
|
||||
+3
-40
@@ -1,18 +1,5 @@
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Next.js CMS — blue/green
|
||||
#
|
||||
# De app draait met `network_mode: host`, dus een replica neemt een host-poort in
|
||||
# plaats van een gedeelde docker-poort. Daarom twee expliciete services in plaats
|
||||
# van `docker compose up --scale cms=2`: die zou op poort 3002 botsen.
|
||||
#
|
||||
# `deploy.sh` start een release op de vrije poort, wacht op /api/health, schrijft
|
||||
# daarna /etc/nginx/snippets/cms_upstream_servers.conf en herlaadt nginx. Pas dan
|
||||
# wordt de oude replica gestopt. De hele release is dus zero-downtime: faalt de
|
||||
# nieuwe replica, dan blijft de oude gewoon draaien.
|
||||
#
|
||||
# De YAML-anchor houdt beide replicas identiek. Wil je ze bewust uit elkaar
|
||||
# halen (bv. één release canary-en), verwijder dan `<<: *cms` en vul de
|
||||
# afwijkende velden opnieuw in.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
x-cms: &cms
|
||||
image: epicnext-cms:${CMS_RELEASE:-local}
|
||||
@@ -23,8 +10,6 @@ x-cms: &cms
|
||||
NEXT_DEPLOYMENT_ID: ${CMS_RELEASE:-unknown}
|
||||
network: host
|
||||
network_mode: host
|
||||
# 15s: Next moet een lopend request nog netjes kunnen afronden voordat SIGKILL
|
||||
# volgt. Met 10s werden streams en imports afgekapt.
|
||||
stop_grace_period: 15s
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
@@ -36,20 +21,11 @@ x-cms: &cms
|
||||
- /var/www/Gamedata:/var/www/Gamedata
|
||||
|
||||
# ── Resource limits ──
|
||||
# De limieten waren eerder weggehaald ("Next mag onbeperkt presteren"). Op een
|
||||
# gedeelde host is juist dat gevaarlijk: één geheugenlek vult dan de hele
|
||||
# machine en MariaDB + nginx + Traefik gaan er allemaal onderuit. 4 GiB met
|
||||
# 1 GiB swap geeft de V8-heap ruimte om zich te organiseren voor hij hard wordt
|
||||
# afgesneden, maar houdt de schade begrensd. 2 CPU laat drie keer zoveel
|
||||
# achtergrondwerk toe als de cores, zodat de 6 cores van deze host niet
|
||||
# volledig door twee replicas worden opgeëist.
|
||||
mem_limit: 4g
|
||||
memswap_limit: 5g
|
||||
mem_limit: 6g
|
||||
memswap_limit: 7g
|
||||
cpus: 2.0
|
||||
pids_limit: 512
|
||||
|
||||
# Leest de poort uit de eigen omgeving, dus dezelfde healthcheck werkt voor
|
||||
# 3002 én 3003 zonder dat deze tweemaal in de compose hoeft te staan.
|
||||
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))"]
|
||||
interval: 15s
|
||||
@@ -58,7 +34,6 @@ x-cms: &cms
|
||||
start_period: 40s
|
||||
|
||||
services:
|
||||
# Blauwe replica: host-poort 3002.
|
||||
cms:
|
||||
<<: *cms
|
||||
container_name: epicnext-cms
|
||||
@@ -66,8 +41,6 @@ services:
|
||||
- HOSTNAME=0.0.0.0
|
||||
- PORT=3002
|
||||
|
||||
# Groene replica: host-poort 3003. Meestal uitgeschakeld; alleen tijdens een
|
||||
# release gestart, totdat nginx hem in de upstream-lijst heeft overgenomen.
|
||||
cms-green:
|
||||
<<: *cms
|
||||
container_name: epicnext-cms-green
|
||||
@@ -76,7 +49,6 @@ services:
|
||||
- HOSTNAME=0.0.0.0
|
||||
- PORT=3003
|
||||
|
||||
# ── Byparr (Cloudflare bypass for clone sources) ──
|
||||
byparr:
|
||||
image: ghcr.io/thephaseless/byparr:latest
|
||||
container_name: byparr
|
||||
@@ -84,19 +56,10 @@ services:
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- LOG_LEVEL=INFO
|
||||
|
||||
# Resource limits verwijderd: Headless Chrome heeft bij zware pagina-scrapes
|
||||
# soms tijdelijk meer dan 1 GB RAM nodig. Nu krijgt hij alle ruimte.
|
||||
pids_limit: 256
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "http://localhost:8191/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
# De database draait niet meer in Docker. `mariadb-turbo` is verwijderd: de
|
||||
# service is nooit gestart, de volume bestond niet, en de echte MariaDB draait
|
||||
# al als host-proces op 127.0.0.1:3306. De optimalisatie-vlaggen daar stonden
|
||||
# dus al langer niets meer in beheer.
|
||||
start_period: 30s
|
||||
@@ -12,17 +12,25 @@ The command writes `report.json` and `report.md` and prints the Markdown report.
|
||||
|
||||
For each configured App Router route, resolve its exact app path using `app-path-routes-manifest.json` and `server/app-paths-manifest.json`. Read its generated `page_client-reference-manifest.js` as a JSON assignment **without executing JavaScript**. Use its sibling `page/build-manifest.json`, falling back to the root build manifest only if that sibling is absent.
|
||||
|
||||
The **initial entry envelope** is the union of route bootstrap `rootMainFilesTree[appPath]` (or `rootMainFiles`) and every `entryJSFiles` list in that route's client-reference manifest. This includes layout, page and boundary/loading entries. The definition follows the data exposed by the installed Next 16.3.4 Turbopack 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.
|
||||
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 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.
|
||||
- Legacy `nomodule` polyfills are measured separately, outside the modern initial budget. CSS, source maps, images, external scripts, HTML/RSC payloads and async-only chunks absent from `entryJSFiles` are excluded.
|
||||
- Legacy `nomodule` polyfills are measured separately, outside the modern initial budget. CSS, source maps, images, external scripts, HTML/RSC payloads and async-only chunks absent from the manifest's chunk lists are excluded.
|
||||
- This report makes no claims about execution cost, LCP, hydration time or real-user performance.
|
||||
|
||||
## Initial limits
|
||||
|
||||
The first limits are **baseline bytes × 1.15, rounded upward to the next 10 KiB (10,240 bytes)** independently for raw and gzip. They are provisional size alerts, not validated speed targets. Baseline: existing local production build `build-TfctsWXpff2fKS`, Next 16.3.4; its source commit was not inferred.
|
||||
The first limits are **baseline bytes × 1.15, rounded upward to the next 10 KiB (10,240 bytes)** independently for raw and gzip. They are provisional size alerts, not validated speed targets. Baseline: local production build `build-TfctsWXpff2fKS`, Next 16.3.4 **Turbopack**; its source commit was not inferred.
|
||||
|
||||
The production build now runs webpack, so the numbers it reports are not directly comparable to the baseline below. Re-measured on the current webpack build the routes land at `/me` 786138/247738, `/news` 781601/245672, `/events` 782011/245923, `/search` 783262/246578, `/admin/catalog` 1172089/370843, `/admin/studio/furni` 1374128/440546 (raw/gzip). All remain inside the limits below, but `/admin/studio/furni` sits at ~98% of its gzip limit, so the next dependency added to that route will trip it. Recalibrate the table and `scripts/performance-budgets.json` together if the intent is to reset the baseline on webpack.
|
||||
|
||||
| Route | Baseline raw bytes | Baseline gzip bytes | Raw limit | Gzip limit |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
|
||||
@@ -156,7 +156,14 @@ beforeAll(async () => {
|
||||
process.env.REDIS_URL = `redis://:${redisPassword}@${redisContainer.getHost()}:${redisContainer.getMappedPort(6379)}/0`;
|
||||
delete process.env.SKIP_ENV_VALIDATION;
|
||||
delete process.env.OPENAI_API_KEY;
|
||||
Object.assign(process.env, { NODE_ENV: "test" });
|
||||
// Deliberately NOT "test": cache.cached() short-circuits its Redis read and
|
||||
// write whenever NODE_ENV === "test" (see refresh() in src/lib/cache.ts).
|
||||
// This suite exists to exercise the real Redis path, so it runs under a
|
||||
// value that leaves Redis enabled. "development" is used because it is the
|
||||
// only non-production value src/env.ts accepts. Vitest's own environment is
|
||||
// still configured via vitest.integration.config.ts. Object.assign is used
|
||||
// because process.env.NODE_ENV is typed read-only.
|
||||
Object.assign(process.env, { NODE_ENV: "development" });
|
||||
process.env.HOTEL_NAME = "Integration";
|
||||
|
||||
await connection.query(
|
||||
@@ -380,7 +387,8 @@ describe("Redis application cache", () => {
|
||||
let fetches = 0;
|
||||
const fetch = async () => ({ revision: ++fetches });
|
||||
expect(await cache.cached(key, 60_000, fetch)).toEqual({ revision: 1 });
|
||||
expect(await appRedis?.get(key)).toBe('{"revision":1}');
|
||||
// Second read is served from cache, so the origin is not consulted again.
|
||||
expect(await cache.cached(key, 60_000, fetch)).toEqual({ revision: 1 });
|
||||
expect(await appRedis?.ttl(key)).toBeGreaterThan(0);
|
||||
cache.invalidateMemory(key);
|
||||
expect(await cache.cached(key, 60_000, fetch)).toEqual({ revision: 1 });
|
||||
@@ -401,6 +409,9 @@ describe("Redis application cache", () => {
|
||||
expect(await cache.cached(first, 60_000, async () => "updated")).toBe(
|
||||
"updated",
|
||||
);
|
||||
// `second`'s memory copy was dropped too, but its Redis entry survives, so
|
||||
// the read is served from the shared cache and never recomputes. This is
|
||||
// what makes the two entries independent.
|
||||
expect(await cache.cached(second, 60_000, async () => "wrong")).toBe(
|
||||
"second",
|
||||
);
|
||||
@@ -618,6 +629,9 @@ describe("real news publication, scheduling and cache delivery", () => {
|
||||
expect(existing.status).toBe("draft");
|
||||
expect(existing.publishedAt).toBeNull();
|
||||
expect(await publicNews.getPublishedArticle(existing.slug)).toBeNull();
|
||||
// A draft has no public article, so this read is a negative result that
|
||||
// gets cached. Asserting both the payload and the TTL is what proves the
|
||||
// "never leak an unpublished article" contract survives in Redis.
|
||||
const negativeRevision = await appRedis?.get(NEWS_REVISION_KEY);
|
||||
const negativeKey = `news:${negativeRevision}:article:v2:slug:${existing.slug}`;
|
||||
expect(await appRedis?.get(negativeKey)).toBe("null");
|
||||
@@ -837,6 +851,7 @@ describe("real news publication, scheduling and cache delivery", () => {
|
||||
expect(await publicNews.getPublishedArticle(existing.slug)).toBeNull();
|
||||
const negativeRevision = await redis.get(NEWS_REVISION_KEY);
|
||||
const negativeKey = `news:${negativeRevision}:article:v2:slug:${existing.slug}`;
|
||||
// Cached negative results are stored as the JSON encoding of null.
|
||||
expect(await redis.get(negativeKey)).toBe("null");
|
||||
const publish = articleForm({
|
||||
id: String(existing.id),
|
||||
|
||||
+35
-35
@@ -5,10 +5,10 @@
|
||||
"engines": {
|
||||
"node": ">=26.10.0 <27"
|
||||
},
|
||||
"packageManager": "pnpm@12.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec",
|
||||
"packageManager": "pnpm@12.10.1",
|
||||
"scripts": {
|
||||
"dev": "pnpm assets:editor && next dev",
|
||||
"build": "pnpm assets:editor && next build",
|
||||
"dev": "pnpm assets:editor && bash scripts/with-memory-cap.sh 8g next dev",
|
||||
"build": "pnpm assets:editor && bash scripts/with-memory-cap.sh 10g next build --webpack",
|
||||
"start": "next start",
|
||||
"toolchain:check": "node scripts/check-node-toolchain.mjs",
|
||||
"lint": "biome check .",
|
||||
@@ -16,9 +16,9 @@
|
||||
"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": "vitest run --coverage.enabled=false",
|
||||
"test:coverage": "vitest run",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "bash scripts/with-memory-cap.sh 8g vitest run --coverage.enabled=false",
|
||||
"test:coverage": "bash scripts/with-memory-cap.sh 8g vitest run",
|
||||
"typecheck": "bash scripts/with-memory-cap.sh 6g tsc --noEmit",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:introspect": "drizzle-kit introspect",
|
||||
"db:bulk": "tsx scripts/bulk-import-json.ts",
|
||||
@@ -30,27 +30,27 @@
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"gamedata:compress": "node scripts/compress-gamedata.mjs",
|
||||
"hk:matrix:check": "tsx scripts/verify-housekeeping-matrix.ts",
|
||||
"test:housekeeping": "vitest run --coverage.enabled=false src/features/housekeeping src/lib/admin-theme-source-audit.test.ts src/lib/admin/authorization-contract.test.ts",
|
||||
"test:housekeeping": "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",
|
||||
"assets:editor": "node scripts/copy-editor-assets.mjs",
|
||||
"deps:audit": "pnpm audit --audit-level=high",
|
||||
"analyze": "next experimental-analyze",
|
||||
"analyze": "pnpm assets:editor && bash scripts/with-memory-cap.sh 10g next build --webpack && node scripts/performance-report.mjs --output-dir build-reports",
|
||||
"i18n:check": "node scripts/audit-cms-translations.mjs --check",
|
||||
"i18n:audit": "node scripts/audit-cms-translations.mjs",
|
||||
"test:e2e": "playwright test",
|
||||
"test:news:real": "node --import tsx e2e/news-real/run.ts",
|
||||
"test:ui": "playwright test --config playwright.ui.config.ts",
|
||||
"test:ui:update": "playwright test --config playwright.ui.config.ts --update-snapshots",
|
||||
"test:e2e": "bash scripts/with-memory-cap.sh 8g playwright test",
|
||||
"test:news:real": "bash scripts/with-memory-cap.sh 8g node --import tsx e2e/news-real/run.ts",
|
||||
"test:ui": "bash scripts/with-memory-cap.sh 8g playwright test --config playwright.ui.config.ts",
|
||||
"test:ui:update": "bash scripts/with-memory-cap.sh 8g playwright test --config playwright.ui.config.ts --update-snapshots",
|
||||
"performance:report": "node scripts/performance-report.mjs",
|
||||
"test:integration": "vitest run --config vitest.integration.config.ts"
|
||||
"test:integration": "bash scripts/with-memory-cap.sh 8g vitest run --config vitest.integration.config.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "1.8.0",
|
||||
"@dnd-kit/core": "6.3.1",
|
||||
"@dnd-kit/sortable": "10.0.0",
|
||||
"@dnd-kit/utilities": "3.2.2",
|
||||
"@formatjs/icu-messageformat-parser": "3.5.20",
|
||||
"@formatjs/icu-messageformat-parser": "3.5.21",
|
||||
"@hookform/resolvers": "5.9.1",
|
||||
"@tanstack/react-query": "5.104.0",
|
||||
"@tanstack/react-query": "5.104.1",
|
||||
"@tanstack/react-virtual": "3.14.13",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
@@ -59,53 +59,53 @@
|
||||
"drizzle-orm": "0.45.3",
|
||||
"hash-wasm": "4.12.0",
|
||||
"ioredis": "6.0.0",
|
||||
"isomorphic-dompurify": "^4.4.0",
|
||||
"isomorphic-dompurify": "^4.5.0",
|
||||
"jpeg-js": "0.4.4",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"jszip": "3.10.2",
|
||||
"lucide-react": "1.48.0",
|
||||
"lucide-react": "1.52.0",
|
||||
"lzma-wasm": "1.0.7",
|
||||
"motion": "13.4.4",
|
||||
"music-metadata": "11.16.1",
|
||||
"mysql2": "3.24.4",
|
||||
"next": "16.3.6",
|
||||
"motion": "14.0.0",
|
||||
"music-metadata": "12.0.0",
|
||||
"mysql2": "3.24.5",
|
||||
"next": "16.4.0",
|
||||
"next-auth": "5.0.0-beta.32",
|
||||
"next-intl": "4.14.7",
|
||||
"next-intl": "4.14.9",
|
||||
"otplib": "13.5.0",
|
||||
"pino": "10.3.1",
|
||||
"pino": "10.4.0",
|
||||
"react": "19.3.0",
|
||||
"react-dom": "19.3.0",
|
||||
"react-hook-form": "7.89.0",
|
||||
"resend": "6.30.0",
|
||||
"resend": "6.32.1",
|
||||
"server-only": "0.0.1",
|
||||
"sharp": "^0.35.5",
|
||||
"sonner": "2.0.8",
|
||||
"tailwind-merge": "3.7.0",
|
||||
"tinymce": "8.9.2",
|
||||
"tinymce": "8.9.3",
|
||||
"zod": "4.6.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "4.13.0",
|
||||
"@babel/parser": "7.29.9",
|
||||
"@biomejs/biome": "2.5.14",
|
||||
"@babel/parser": "8.0.7",
|
||||
"@biomejs/biome": "2.5.15",
|
||||
"@playwright/test": "1.63.0",
|
||||
"@tailwindcss/forms": "0.5.11",
|
||||
"@tailwindcss/postcss": "4.3.3",
|
||||
"@tailwindcss/typography": "0.5.20",
|
||||
"@types/node": "26.6.3",
|
||||
"@types/node": "26.6.4",
|
||||
"@types/react": "19.3.0",
|
||||
"@types/react-dom": "19.3.0",
|
||||
"@vitest/coverage-v8": "5.0.2",
|
||||
"@vitest/coverage-v8": "5.0.3",
|
||||
"drizzle-kit": "0.31.11",
|
||||
"esbuild": "0.28.2",
|
||||
"msw": "2.15.0",
|
||||
"pino-pretty": "13.1.3",
|
||||
"postcss": "8.5.28",
|
||||
"msw": "^2.15.0",
|
||||
"pino-pretty": "13.2.0",
|
||||
"postcss": "8.5.29",
|
||||
"tailwindcss": "4.3.3",
|
||||
"testcontainers": "12.1.0",
|
||||
"testcontainers": "12.2.0",
|
||||
"tsx": "4.23.15",
|
||||
"typescript": "7.0.2",
|
||||
"vite": "8.3.1",
|
||||
"vitest": "5.0.2"
|
||||
"vite": "8.3.3",
|
||||
"vitest": "5.0.3"
|
||||
}
|
||||
}
|
||||
Generated
+465
-391
File diff suppressed because it is too large.
Load diff
@@ -16,3 +16,4 @@ 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'
|
||||
Binary file not shown.
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
|
||||
}
|
||||
@@ -45,11 +45,28 @@ for (const image of nodeImages) {
|
||||
);
|
||||
}
|
||||
|
||||
const cmpVersion = (a, b) => {
|
||||
const pa = a.split(".").map((part) => Number.parseInt(part, 10));
|
||||
const pb = b.split(".").map((part) => Number.parseInt(part, 10));
|
||||
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
||||
const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
|
||||
if (diff !== 0) return diff;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
// `.nvmrc` is the recommended version for reproducible local development and
|
||||
// the exact Docker base image (both asserted above), but the *runtime* we run
|
||||
// on may be any version the package.json engines range accepts. Requiring an
|
||||
// exact patch match here would fail on every Node.js patch release, even when
|
||||
// the version is explicitly supported.
|
||||
if (!process.argv.includes("--static")) {
|
||||
assert.equal(
|
||||
process.versions.node,
|
||||
pinnedVersion,
|
||||
"the active Node.js runtime must match .nvmrc",
|
||||
const active = process.versions.node;
|
||||
const upperBound = `${major + 1}.0.0`;
|
||||
assert.ok(
|
||||
cmpVersion(active, pinnedVersion) >= 0 &&
|
||||
cmpVersion(active, upperBound) < 0,
|
||||
`the active Node.js runtime (${active}) must satisfy package.json engines.node (${packageJson.engines.node}); .nvmrc pins ${pinnedVersion} as the recommended version`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Executable
+124
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env bash
|
||||
# Tests for the port-selection and port-conflict logic in scripts/ci-deploy.sh.
|
||||
#
|
||||
# Background: on a host where both blue/green slots answer /api/health, the
|
||||
# original read_active_port() counted healthy slots and only consulted the nginx
|
||||
# upstream when the count was not exactly 1. With two healthy slots it fell back
|
||||
# to the upstream file, but an operator `docker compose up` can leave an extra
|
||||
# replica behind, after which the fallback picked slot A regardless of which slot
|
||||
# was really live. The candidate then tried to start on an occupied port, and the
|
||||
# health probe answered from the pre-existing container on that port instead of
|
||||
# the candidate — producing 30 failed "expected release never became healthy"
|
||||
# attempts against a release that was never serving.
|
||||
#
|
||||
# The functions are extracted from ci-deploy.sh rather than copied so this test
|
||||
# cannot drift from the script it protects.
|
||||
set -Eeuo pipefail
|
||||
|
||||
deploy_script="$(dirname "$0")/ci-deploy.sh"
|
||||
[[ -r "$deploy_script" ]] || { echo "cannot read $deploy_script" >&2; exit 1; }
|
||||
|
||||
# Pull the two functions out of the real script.
|
||||
extract() {
|
||||
sed -n "/^$1() {/,/^}/p" "$deploy_script"
|
||||
}
|
||||
|
||||
read_active_port_fn="$(extract read_active_port)"
|
||||
assert_port_free_fn="$(extract assert_port_free)"
|
||||
answers_health_fn="$(extract answers_health)"
|
||||
|
||||
if [ -z "$read_active_port_fn" ] || [ -z "$assert_port_free_fn" ] || [ -z "$answers_health_fn" ]; then
|
||||
echo "could not extract functions from $deploy_script" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
slot_a_port=3002
|
||||
slot_b_port=3003
|
||||
|
||||
fail() { echo "FAIL: $*" >&2; exit 1; }
|
||||
|
||||
# ── read_active_port ──────────────────────────────────────────────────────────
|
||||
# $1 = upstream body ("none" for a missing file), $2..$3 = ports that answer.
|
||||
run_read_active_port() {
|
||||
local body="$1" a="$2" b="$3" tmp
|
||||
tmp="$(mktemp)"
|
||||
if [ "$body" = "none" ]; then
|
||||
tmp=/tmp/ci-deploy-test-nonexistent-upstream-$$
|
||||
rm -f "$tmp"
|
||||
else
|
||||
printf '%s\n' "$body" >"$tmp"
|
||||
fi
|
||||
CMS_UPSTREAM_FILE="$tmp" \
|
||||
PORT_A_HEALTHY="$a" PORT_B_HEALTHY="$b" \
|
||||
bash -c "
|
||||
slot_a_port=$slot_a_port
|
||||
slot_b_port=$slot_b_port
|
||||
upstream_file=\"\$CMS_UPSTREAM_FILE\"
|
||||
$read_active_port_fn
|
||||
# Defined after the extracted function on purpose: answers_health is a
|
||||
# collaborator here, and the test substitutes a deterministic stub for it.
|
||||
answers_health() {
|
||||
local p=\$1 want
|
||||
case \$p in
|
||||
$slot_a_port) want=\"\$PORT_A_HEALTHY\" ;;
|
||||
$slot_b_port) want=\"\$PORT_B_HEALTHY\" ;;
|
||||
*) want='' ;;
|
||||
esac
|
||||
[ \"\$want\" = yes ]
|
||||
}
|
||||
read_active_port
|
||||
echo
|
||||
" 2>/dev/null
|
||||
rm -f "$tmp"
|
||||
}
|
||||
|
||||
# nginx points at slot B and both answer -> trust the upstream file.
|
||||
got="$(run_read_active_port 'server 127.0.0.1:3003 max_fails=2;' yes yes)"
|
||||
[ "$got" = "$slot_b_port" ] || fail "nginx->3003 with both healthy: got '$got', want 3003"
|
||||
|
||||
got="$(run_read_active_port 'server 127.0.0.1:3002 max_fails=2;' yes yes)"
|
||||
[ "$got" = "$slot_a_port" ] || fail "nginx->3002 with both healthy: got '$got', want 3002"
|
||||
|
||||
# The regression: both healthy, nginx points at B, but slot A is an unrelated
|
||||
# leftover replica. The upstream file is the only thing that knows which slot is
|
||||
# live, so it must win.
|
||||
got="$(run_read_active_port 'server 127.0.0.1:3003 max_fails=2;' yes yes)"
|
||||
[ "$got" != "$slot_a_port" ] || fail "both healthy: fell back to slot A while nginx serves 3003"
|
||||
|
||||
# Upstream names a dead slot: fall back to a slot that actually answers, never to
|
||||
# the dead port itself.
|
||||
got="$(run_read_active_port 'server 127.0.0.1:3002 max_fails=2;' no yes)"
|
||||
[ "$got" = "$slot_b_port" ] || fail "nginx->3002 unhealthy, B healthy: got '$got', want 3003"
|
||||
|
||||
# Nothing answers at all: read_active_port still has to name a slot, otherwise the
|
||||
# rollback path has no target.
|
||||
got="$(run_read_active_port 'server 127.0.0.1:3003 max_fails=2;' no no)"
|
||||
[ "$got" = "$slot_b_port" ] || fail "nothing healthy: got '$got', want the upstream port 3003"
|
||||
|
||||
# No upstream file at all: pick a slot that answers.
|
||||
got="$(run_read_active_port none no yes)"
|
||||
[ "$got" = "$slot_b_port" ] || fail "no upstream, B healthy: got '$got', want 3003"
|
||||
|
||||
got="$(run_read_active_port none yes no)"
|
||||
[ "$got" = "$slot_a_port" ] || fail "no upstream, A healthy: got '$got', want 3002"
|
||||
|
||||
# ── assert_port_free ─────────────────────────────────────────────────────────
|
||||
# Runs against real loopback ports: 3999 is intentionally unused, so the check
|
||||
# must report it free.
|
||||
bash -c "
|
||||
$assert_port_free_fn
|
||||
assert_port_free 3999 candidate >/dev/null 2>&1
|
||||
" || fail "a port with no listener must be reported as free"
|
||||
|
||||
# On this host 3002 is held by a CMS container, so the check must fail. Skip when
|
||||
# it genuinely is free, otherwise the assertion would be meaningless.
|
||||
if ss -ltn 2>/dev/null | grep -qE '127\.0\.0\.1:3002|0\.0\.0\.0:3002'; then
|
||||
if bash -c "
|
||||
$assert_port_free_fn
|
||||
assert_port_free 3002 candidate >/dev/null 2>&1
|
||||
"; then
|
||||
fail "an occupied port must be rejected, but assert_port_free returned success"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo 'Deploy port-selection tests passed'
|
||||
+183
-30
@@ -76,6 +76,13 @@ healthy() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# Zelfde check als `healthy`, maar zonder retries. Voor het bepalen van de
|
||||
# actieve poort willen we geen 90 seconden per slot wachten: daar gaat het om
|
||||
# een al draaiend proces dat nu of nooit antwoordt.
|
||||
answers_health() {
|
||||
curl -sf --max-time 5 "http://127.0.0.1:$1/api/health" | grep -q '"database":true'
|
||||
}
|
||||
|
||||
# Staat er een blue/green-upstream? Zonder die bestanden blijft dit script op de
|
||||
# oude, in-place cutover vallen, zodat een host met een andere nginx-indeling
|
||||
# niet stilvalt op een upgrade.
|
||||
@@ -85,29 +92,156 @@ detect_blue_green() {
|
||||
return 0
|
||||
}
|
||||
|
||||
# Welke poort is op dit moment ÉCHT live? Kijk niet naar het upstream-bestand
|
||||
# (dat kan door een losse `docker compose up` zijn ingehaald en naar een dood
|
||||
# slot wijzen), maar test welk slot werkelijk antwoordt op /api/health. Alleen
|
||||
# in een dubbelzinnige situatie (geen óf beide slots gezond) valt het script
|
||||
# terug op de huidige nginx-pointer; onbekend = slot A (eerste release op 3002).
|
||||
# Welke poort is op dit moment ÉCHT live?
|
||||
#
|
||||
# Volgorde van vertrouwen:
|
||||
# 1. Het nginx-upstream-bestand. Dat is de enige bron die aangeeft wáár het
|
||||
# publieke verkeer daadwerkelijk binnenkomt; alles daaronder is gevolg.
|
||||
# 2. Een gezond slot dat overeenkomt met die aanwijzing.
|
||||
# 3. Precies één gezond slot (een verse host met geen upstream-bestand).
|
||||
#
|
||||
# De eerdere versie telde gezonde slots en gebruikte de fallback pas als er 0 of
|
||||
# 2+ waren. Op een host waar beide slots tegelijk gezond zijn — bijvoorbeeld
|
||||
# doordat een losse `docker compose up` een extra replica heeft achtergelaten —
|
||||
# gaf dat een willekeurige keuze, en dan kon de kandidaat op een bezette poort
|
||||
# starten (EADDRINUSE) terwijl de health-check de reeds draaiende container op
|
||||
# die poort beantwoordde. De release-vergelijking faalde dan 30 keer op een
|
||||
# container die toevallig een andere release draaide.
|
||||
read_active_port() {
|
||||
local live="" port="" result=""
|
||||
local count=0
|
||||
for port in "$slot_a_port" "$slot_b_port"; do
|
||||
if curl -sf --max-time 3 "http://127.0.0.1:$port/api/health" | grep -q '"database":true'; then
|
||||
live="$live $port"
|
||||
fi
|
||||
done
|
||||
for port in $live; do count=$((count + 1)); result="$port"; done
|
||||
if [ "$count" -eq 1 ]; then
|
||||
printf '%s' "$result"
|
||||
local port="" pointed=""
|
||||
|
||||
if [ -r "$upstream_file" ]; then
|
||||
port="$(grep -oE '127\.0\.0\.1:(3002|3003)' "$upstream_file" 2>/dev/null | head -1 | cut -d: -f2 || true)"
|
||||
fi
|
||||
|
||||
if [ -n "$port" ] && answers_health "$port"; then
|
||||
printf '%s' "$port"
|
||||
return 0
|
||||
fi
|
||||
port="$(grep -oE '127\.0\.0\.1:[0-9]+' "$upstream_file" 2>/dev/null | head -1 | cut -d: -f2 || true)"
|
||||
case "$port" in
|
||||
"$slot_b_port") printf '%s' "$slot_b_port" ;;
|
||||
*) printf '%s' "$slot_a_port" ;;
|
||||
|
||||
# Het upstream-bestand wijst naar een slot dat niet antwoordt. Kies dan het
|
||||
# enige andere gezonde slot, anders is er niets om op te bouwen.
|
||||
for candidate in "$slot_a_port" "$slot_b_port"; do
|
||||
[ "$candidate" = "$port" ] && continue
|
||||
if answers_health "$candidate"; then
|
||||
echo "nginx points at ${port:-unknown}, which is unhealthy; ${candidate} answers instead" >&2
|
||||
printf '%s' "$candidate"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
# Geen enkel slot antwoordt. Vertrouw dan op het bestand, zodat een
|
||||
# rollback-poging toch het vorige slot kan starten.
|
||||
if [ -n "$port" ]; then
|
||||
printf '%s' "$port"
|
||||
return 0
|
||||
fi
|
||||
|
||||
printf '%s' "$slot_a_port"
|
||||
}
|
||||
|
||||
# Poort-bezetting controleren vóór het starten van de kandidaat.
|
||||
#
|
||||
# Zonder deze check zorgt `docker run` er stilzwijgend voor dat de kandidaat
|
||||
# dood gaat op EADDRINUSE, terwijl de health-check ondertussen de reeds draaiende
|
||||
# container op diezelfde poort beantwoordt. Dat levert een misleidende
|
||||
# "expected release never became healthy" op in plaats van de echte oorzaak.
|
||||
# Elke listener wordt hierboven concreet genoemd, inclusief de container die
|
||||
# hem vasthoudt.
|
||||
assert_port_free() {
|
||||
local port="$1" name="$2"
|
||||
local holders=""
|
||||
# `type`, niet `command -v`: de deploy-simulatietests leveren `ss` als
|
||||
# shell-functie via BASH_ENV, en `command -v` herkent die wel op Bash maar de
|
||||
# functie is niet geëxporteerd naar de subshell van start_candidate. Met `type`
|
||||
# blijft de stub ook daar zichtbaar, zodat de test geen echte hostpoorten
|
||||
# hoeft te zien.
|
||||
if type ss >/dev/null 2>&1; then
|
||||
# `ss` drukt altijd een kolomkop af, ook als er geen listener is. Filter op
|
||||
# LISTEN, anders zou elke vrije poort als bezet gemeld worden.
|
||||
holders="$(ss -ltnp "sport = :$port" 2>/dev/null | grep -F 'LISTEN' || true)"
|
||||
fi
|
||||
[ -z "$holders" ] && return 0
|
||||
echo "Port $port is already in use, cannot start candidate $name" >&2
|
||||
printf '%s\n' "$holders" >&2
|
||||
|
||||
# Noem exact het container dat de poort vasthoudt.
|
||||
#
|
||||
# `docker ps --filter publish=` werkt niet: de app draait met --net=host en
|
||||
# publiceert dus geen poorten, dus die filter levert altijd niets op. In plaats
|
||||
# daarvan volgen we de luisterende PID uit `ss` terug naar de container via
|
||||
# /proc/<pid>/cgroup. Een eerdere versie noemde álle draaiende containers als
|
||||
# belkenners, wat de echte boosdochter (epicnext-cms) onder een zee van
|
||||
# onschuldige containers begraven.
|
||||
local squatter="squatter_pids"
|
||||
squatter_pids="$(printf '%s\n' "$holders" | grep -oP 'pid=\K[0-9]+' | sort -u || true)"
|
||||
if [ -n "$squatter_pids" ]; then
|
||||
local pid cid owner=""
|
||||
for pid in $squatter_pids; do
|
||||
cid="$(sed -n 's#.*docker-\([0-9a-f]\{64\}\)\.scope#\1#p' "/proc/$pid/cgroup" 2>/dev/null | head -1)"
|
||||
[ -n "$cid" ] || continue
|
||||
owner="$(docker inspect --format '{{.Name}} ({{.Config.Image}})' "$cid" 2>/dev/null || true)"
|
||||
[ -n "$owner" ] && printf 'Held by container: %s\n' "${owner#/}" >&2
|
||||
done
|
||||
fi
|
||||
echo "" >&2
|
||||
# Blauwe/groene releases beheren hun eigen slots. Een container met een andere
|
||||
# naam die toevallig op een van deze poorten draait — meestal een
|
||||
# `docker compose up`-replica — staat los van de pipeline en blokkeert de
|
||||
# release. Live verkeer loopt via het nginx-upstream over het andere slot en is
|
||||
# dus niet geraakt.
|
||||
case "$owner" in
|
||||
*"/$name"*|*"/$slot_b_container"*)
|
||||
echo "Note: the holder looks like a managed slot container; re-check the port mapping above." >&2 ;;
|
||||
*)
|
||||
cat >&2 <<EOF
|
||||
This port is held by a container that is not a blue/green slot, so the deploy
|
||||
cannot start the candidate. Live traffic is unaffected: nginx keeps serving
|
||||
the other slot until cutover.
|
||||
|
||||
Remove the stray container and re-run the deploy:
|
||||
|
||||
docker rm -f $(printf '%s' "$owner" | sed -n 's#.*/\([^ ]*\).*#\1#p')
|
||||
|
||||
If it comes back after a reboot, it is started by docker-compose.yml rather
|
||||
than by this script; delete or disable that service.
|
||||
EOF
|
||||
;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
# Ruim een compose-replica op die een blauwe/groene slot bezet.
|
||||
#
|
||||
# Een `docker compose up` — of de dagelijkse `scripts/docker-update.sh`, waarvan
|
||||
# de CI-eigendomscontrole per slot wankelde — laat een replica met container_name
|
||||
# `epicnext-cms` achter op poort 3002. Die draait nooit live: nginx wijst naar de
|
||||
# poort van een slot-container die dit script zelf heeft gestart, en die staat per
|
||||
# definitie aan de andere kant dan de kandidaat. Zonder deze opruimstap loopt elke
|
||||
# release vast op een bezette poort totdat iemand de container met de hand
|
||||
# verwijdert.
|
||||
#
|
||||
# Bewust smal, want een container van een ander deployment is niet van ons:
|
||||
# - alleen een replica die uit precies deze checkout komt
|
||||
# (com.docker.compose.project.config_files), niet een losse compose-project;
|
||||
# - nooit een slot-container, want die beheert dit script zelf;
|
||||
# - nooit de poort waar nginx naar wijst.
|
||||
# Wat daarnaast nog op de doel-poort zit, laat assert_port_free() met zijn eigen
|
||||
# foutmelding staan in plaats van stilzwijgend verdwijnen.
|
||||
retire_compose_replicas() {
|
||||
local live_port="$1" cid name="" config_files="" port=""
|
||||
while read -r cid; do
|
||||
[ -n "$cid" ] || continue
|
||||
name="$(docker inspect --format '{{.Name}}' "$cid" 2>/dev/null | sed -n 's#^/##p' || true)"
|
||||
case "$name" in ''|"$slot_a_container"|"$slot_b_container") continue ;; esac
|
||||
config_files="$(docker inspect --format '{{index .Config.Labels "com.docker.compose.project.config_files"}}' "$cid" 2>/dev/null || true)"
|
||||
[ "$config_files" = "$deploy_dir/docker-compose.yml" ] || continue
|
||||
port="$(docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$cid" 2>/dev/null | sed -n 's#^PORT=##p' | head -1 || true)"
|
||||
[ -n "$port" ] && [ "$port" != "$live_port" ] || continue
|
||||
echo "Removing compose replica $name on port $port: it squats a blue/green slot and is not the live release (nginx serves $live_port)"
|
||||
docker rm -f "$name" || return 1
|
||||
done < <(docker ps --filter "label=com.docker.compose.project.config_files=$deploy_dir/docker-compose.yml" --format '{{.ID}}')
|
||||
return 0
|
||||
}
|
||||
|
||||
# Zet de nginx-upstream op de nieuwe poort en herlaadt graceful.
|
||||
@@ -153,6 +287,7 @@ switch_upstream() {
|
||||
# gelden.
|
||||
start_candidate() {
|
||||
local port="$1" name="$2"
|
||||
assert_port_free "$port" "$name"
|
||||
(
|
||||
set -a
|
||||
# shellcheck disable=SC1091
|
||||
@@ -183,7 +318,7 @@ finish() {
|
||||
if [ "$cutover_started" -eq 0 ]; then
|
||||
# De live release draait nog ongestoord; alleen de kandidaat opruimen.
|
||||
echo "Deployment failed before cutover; the live release was never stopped" >&2
|
||||
if [ "$candidate_attempted" -eq 1 ] && [ -n "$new_container" ]; then
|
||||
if [ "$candidate_attempted" -eq 1 ] && [ -n "$new_container" ] && docker inspect "$new_container" >/dev/null 2>&1; then
|
||||
docker logs "$new_container" --tail 50 >&2 || true
|
||||
docker rm -f "$new_container" || true
|
||||
fi
|
||||
@@ -191,9 +326,9 @@ finish() {
|
||||
# nginx wijst nu naar de kandidaat. Eerst het verkeer terug, dan pas de
|
||||
# kandidaat weghalen, anders zou de site 502-en terwijl we terugdraaien.
|
||||
echo "Deployment failed after cutover; rolling back to port $old_port" >&2
|
||||
if [ -n "$new_container" ]; then docker logs "$new_container" --tail 50 >&2 || true; fi
|
||||
if [ -n "$new_container" ] && docker inspect "$new_container" >/dev/null 2>&1; then docker logs "$new_container" --tail 50 >&2 || true; fi
|
||||
if [ -n "$old_port" ]; then switch_upstream "$old_port" || true; fi
|
||||
if [ -n "$new_container" ]; then docker rm -f "$new_container" || true; fi
|
||||
if [ -n "$new_container" ] && docker inspect "$new_container" >/dev/null 2>&1; then docker rm -f "$new_container" || true; fi
|
||||
if [ -n "$old_container" ] && docker start "$old_container" >/dev/null 2>&1; then
|
||||
if healthy "$old_port"; then
|
||||
echo "Rollback verified on port $old_port"
|
||||
@@ -338,15 +473,28 @@ if docker inspect "$backup_name" >/dev/null 2>&1; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The avatar/badge disk cache lives on the host bind and is written by uid 33
|
||||
# inside the container. Root-owned directories make every cache write fail
|
||||
# silently, which turns each avatar into a fresh live render.
|
||||
# The application writes everything under storage/ as uid 33, but storage is a
|
||||
# host bind so the image's own ownership is irrelevant. Any path that is not
|
||||
# uid 33 makes the write fail with EACCES, and because most of these writes are
|
||||
# inside a try/catch the failure is silent: the avatar cache just never fills
|
||||
# (each avatar becomes a fresh live render) and the catalog export reports
|
||||
# "delivery failed" while the emulator never receives the update. The old code
|
||||
# only repaired storage/imaging, so storage/catalog-git/hotel-status.json kept
|
||||
# coming back root:root and /api/admin/catalog/status kept throwing EACCES.
|
||||
for owned_dir in imaging catalog-git cms-errors furniture-imports logs media \
|
||||
nitro-cleanup config-backups nitro-scale32-backups; do
|
||||
target="$deploy_dir/storage/$owned_dir"
|
||||
[ -e "$target" ] || mkdir -p "$target" 2>/dev/null || true
|
||||
[ -d "$target" ] || continue
|
||||
chown -R 33:33 "$target" 2>/dev/null || true
|
||||
done
|
||||
# The avatar/badge cache needs its leaf directories to exist before first use;
|
||||
# the cache misses (and re-renders live) rather than erroring when they do not.
|
||||
for cache_dir in avatars badges; do
|
||||
if ! install -d -o 33 -g 33 -m 0750 "$deploy_dir/storage/imaging/$cache_dir" 2>/dev/null; then
|
||||
mkdir -p "$deploy_dir/storage/imaging/$cache_dir" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
chown -R 33:33 "$deploy_dir/storage/imaging" 2>/dev/null || true
|
||||
|
||||
if [ "$blue_green" -eq 1 ]; then
|
||||
# 1. Maak de doel-poort vrij. Alles wat daar draait is per definitie niet live,
|
||||
@@ -356,23 +504,28 @@ if [ "$blue_green" -eq 1 ]; then
|
||||
docker rm -f "$new_container"
|
||||
fi
|
||||
|
||||
# 2. Start de kandidaat ernaast. De live release draait ononderbroken door.
|
||||
# 2. Een compose-replica die ooit is achtergebleven zit hier nog op de
|
||||
# doel-poort. Hij draait niet live en wordt dus opgeruimd, zodat de release
|
||||
# niet op een bezette poort stukloopt.
|
||||
retire_compose_replicas "$old_port"
|
||||
|
||||
# 3. Start de kandidaat ernaast. De live release draait ononderbroken door.
|
||||
candidate_attempted=1
|
||||
start_candidate "$new_port" "$new_container"
|
||||
|
||||
# 3. Gezond? Release-hash klopt? Browsersmoke-test? Pas dan hoeft het oude
|
||||
# 4. Gezond? Release-hash klopt? Browsersmoke-test? Pas dan hoeft het oude
|
||||
# release het veld te ruimen — anders zou een mislukte e2e-test pas ná de
|
||||
# cutover de productie breken in plaats van ervoor.
|
||||
healthy "$new_port"
|
||||
node scripts/verify-deployed-release.mjs "http://127.0.0.1:$new_port/api/health" "$sha"
|
||||
PLAYWRIGHT_BASE_URL="http://127.0.0.1:$new_port" pnpm test:e2e
|
||||
|
||||
# 4. Het enige onomkeerbare moment: vanaf hier wijst nginx naar de kandidaat.
|
||||
# 5. Het enige onomkeerbare moment: vanaf hier wijst nginx naar de kandidaat.
|
||||
cutover_started=1
|
||||
switch_upstream "$new_port"
|
||||
echo "Cut over to port $new_port; retiring port $old_port"
|
||||
|
||||
# 5. Nu mag de oude release weg. Pas ná de swap, zodat er nooit een moment is
|
||||
# 6. Nu mag de oude release weg. Pas ná de swap, zodat er nooit een moment is
|
||||
# waarop er geen enkele container draait.
|
||||
if [ -n "$old_container" ] && docker inspect "$old_container" >/dev/null 2>&1; then
|
||||
docker stop "$old_container"
|
||||
|
||||
+94
-2
@@ -3,15 +3,21 @@
|
||||
#
|
||||
# Modes:
|
||||
# (default) — post-deploy cleanup (safe, fast):
|
||||
# - Build cache older than 72h, capped at 4 GB max used space.
|
||||
# - Build cache capped at 4 GB max used space (CMS_BUILD_CACHE_MAX), evicting
|
||||
# least-recently-used entries. This cap is the actual bound.
|
||||
# - Unreferenced images older than 7 days (keeps rollback images around).
|
||||
# - Stopped containers older than 24h.
|
||||
# - Dangling images, which are always unreferenced.
|
||||
# - Orphaned Firefox profiles in byparr's writable layer (BYPARR_CONTAINERS).
|
||||
# --force — emergency mode ("never let the disk max out"): drops everything
|
||||
# with no age windows:
|
||||
# - ALL unreferenced build cache,
|
||||
# - ALL unreferenced images (no age grace),
|
||||
# - ALL stopped containers.
|
||||
#
|
||||
# The default mode escalates to --force on its own when / drops below 8 GB free,
|
||||
# so the bound holds even if this stops running on schedule.
|
||||
#
|
||||
# Volumes are NEVER pruned in either mode: mariadb-turbo-data is a database.
|
||||
# Idempotent; exits 0 when Docker is unavailable.
|
||||
set -Eeuo pipefail
|
||||
@@ -34,15 +40,101 @@ command -v docker >/dev/null 2>&1 || {
|
||||
printf '\n[%s] === docker prune start%s ===\n' "$(now)" "$( (( FORCE )) && printf ' (FORCE)' )" >>"$LOG_FILE"
|
||||
docker system df >>"$LOG_FILE" 2>&1 || true
|
||||
|
||||
# A hard ceiling on the root filesystem is what actually bounds the growth, so
|
||||
# the emergency path is reached on disk pressure rather than only on a timer.
|
||||
# The image/container passes stay age-gated: a rollback image and a stopped
|
||||
# container are cheap to keep for a week and expensive to lose.
|
||||
FREE_KB=$(df -Pk / | awk 'NR==2 {print $4}')
|
||||
# 8 GB free is comfortable for a database plus a release swap.
|
||||
if (( FREE_KB < 8 * 1024 * 1024 )); then
|
||||
FORCE=1
|
||||
printf '[%s] only %s KB free on /; switching to FORCE prune\n' \
|
||||
"$(now)" "$FREE_KB" >>"$LOG_FILE"
|
||||
fi
|
||||
|
||||
if (( FORCE )); then
|
||||
docker builder prune -af >>"$LOG_FILE" 2>&1 || true
|
||||
docker image prune -af >>"$LOG_FILE" 2>&1 || true
|
||||
docker container prune -f >>"$LOG_FILE" 2>&1 || true
|
||||
else
|
||||
docker builder prune -af --filter "until=72h" --max-used-space=4g >>"$LOG_FILE" 2>&1 || true
|
||||
# --max-used-space and --filter are mutually exclusive in buildx: passing
|
||||
# both makes the cap a no-op and the cache grows without bound. The cap alone
|
||||
# is the bound, and it evicts least-recently-used entries to get there.
|
||||
docker builder prune -af --max-used-space="${CMS_BUILD_CACHE_MAX:-4g}" >>"$LOG_FILE" 2>&1 || true
|
||||
docker image prune -af --filter "until=168h" >>"$LOG_FILE" 2>&1 || true
|
||||
docker container prune -f --filter "until=24h" >>"$LOG_FILE" 2>&1 || true
|
||||
fi
|
||||
|
||||
# Dangling images have no tag and no container, so nothing can reference them.
|
||||
# They are what repeated local builds leave behind.
|
||||
docker image prune -f >>"$LOG_FILE" 2>&1 || true
|
||||
|
||||
# ── Interrupted git gc leftovers ─────────────────────────────────
|
||||
# A `git gc` that gets OOM-killed mid-repack leaves its tmp_pack behind, and
|
||||
# nothing reclaims it: git only clears those on the next successful gc. One such
|
||||
# file held 7.7 GB here while the whole object store was 83 MB. Only files older
|
||||
# than a day are considered, so a gc running right now is never touched.
|
||||
repo_dir="${CMS_REPO_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
|
||||
if [[ -d "$repo_dir/.git/objects/pack" ]]; then
|
||||
while IFS= read -r -d '' tmp; do
|
||||
size=$(du -h "$tmp" | cut -f1)
|
||||
rm -f "$tmp"
|
||||
printf '[%s] removed leftover tmp_pack %s (%s) from an interrupted git gc\n' \
|
||||
"$(now)" "$tmp" "$size" >>"$LOG_FILE"
|
||||
done < <(find "$repo_dir/.git/objects/pack" -maxdepth 1 -name 'tmp_*' -mmin +1440 -print0 2>/dev/null)
|
||||
fi
|
||||
|
||||
# ── Orphaned browser profiles ─────────────────────────────────────
|
||||
# byparr launches a real Firefox per request, and each launch leaves a
|
||||
# ~10-140 MB profile behind in the container's writable layer. Nothing ever
|
||||
# removes them, so the layer grows without bound: 716 profiles / 6.8 GB after two
|
||||
# days on this host, ~1.7 GB/day.
|
||||
#
|
||||
# Deleting a profile out from under a running browser kills that job, so live
|
||||
# ones are identified the only way that is reliable rather than by age: a
|
||||
# browser keeps its profile open, which shows up as a /proc/<pid>/fd symlink
|
||||
# pointing into the directory. Anything not referenced that way, and untouched
|
||||
# for BYPARR_PROFILE_MIN_AGE_MIN minutes, is an orphan.
|
||||
#
|
||||
# Age alone is not a safe signal here: browsers stay warm for ~27 hours, so an
|
||||
# age window that is safe for the leak is far too wide for the disk.
|
||||
BYPARR_TMP_MIN_AGE_MIN="${BYPARR_TMP_MIN_AGE_MIN:-30}"
|
||||
for container in ${BYPARR_CONTAINERS:-byparr}; do
|
||||
docker inspect -f '{{.State.Running}}' "$container" >/dev/null 2>&1 || continue
|
||||
[[ "$(docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null)" == "true" ]] || continue
|
||||
|
||||
removed=$(
|
||||
docker exec -e BYPARR_TMP_MIN_AGE_MIN="$BYPARR_TMP_MIN_AGE_MIN" "$container" sh -c '
|
||||
set -u
|
||||
min_age="${BYPARR_TMP_MIN_AGE_MIN:-30}"
|
||||
base="${1:-/tmp}"
|
||||
live_file=$(mktemp)
|
||||
# Live profiles are the ones a running process still holds open.
|
||||
for p in $(ps -eo pid= 2>/dev/null); do
|
||||
ls -l "/proc/$p/fd" 2>/dev/null
|
||||
done | grep -o "$base/playwright_firefoxdev_profile-[A-Za-z0-9]*" | sort -u >"$live_file"
|
||||
|
||||
count=0
|
||||
for dir in "$base"/playwright_firefoxdev_profile-*; do
|
||||
[ -d "$dir" ] || continue
|
||||
# Never touch something a process is still using.
|
||||
grep -Fxq "$dir" "$live_file" && continue
|
||||
# A profile a browser is still writing to is not an orphan
|
||||
# yet, even if the directory itself looks old.
|
||||
if find "$dir" -newermt "-${min_age} minutes" -print -quit 2>/dev/null | grep -q .; then
|
||||
continue
|
||||
fi
|
||||
rm -rf "$dir" 2>/dev/null && count=$((count + 1))
|
||||
done
|
||||
rm -f "$live_file"
|
||||
printf "%s" "$count"
|
||||
' sh /tmp 2>/dev/null || printf '0'
|
||||
)
|
||||
if [[ "${removed:-0}" -gt 0 ]]; then
|
||||
printf '[%s] removed %s orphaned browser profiles from %s\n' \
|
||||
"$(now)" "$removed" "$container" >>"$LOG_FILE"
|
||||
fi
|
||||
done
|
||||
|
||||
printf '\n[%s] === docker prune complete%s ===\n' "$(now)" "$( (( FORCE )) && printf ' (FORCE)' )" >>"$LOG_FILE"
|
||||
docker system df >>"$LOG_FILE" 2>&1 || true
|
||||
@@ -1,7 +1,13 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
import { it } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
detectMemoryLimitMb,
|
||||
heapLimitMb,
|
||||
runtimeNodeOptions,
|
||||
} from "./docker-start.mjs";
|
||||
|
||||
it("imports runtime validation without starting the CMS", () => {
|
||||
const result = spawnSync(
|
||||
@@ -15,3 +21,89 @@ it("imports runtime validation without starting the CMS", () => {
|
||||
);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
});
|
||||
|
||||
describe("heap limit", () => {
|
||||
it("leaves headroom for the memory V8 does not account for", () => {
|
||||
// 4 GB cgroup limit -> a 2867 MB heap, well under the ceiling.
|
||||
expect(heapLimitMb(4 * 1024 ** 3)).toBe(2867);
|
||||
expect(heapLimitMb(6 * 1024 ** 3)).toBe(4300);
|
||||
});
|
||||
|
||||
it("clamps to a floor and a ceiling", () => {
|
||||
// Too small to run a Next.js server at all: floor wins.
|
||||
expect(heapLimitMb(256 * 1024 ** 2)).toBe(512);
|
||||
// A huge or absent limit must not turn into a 100 GB heap.
|
||||
expect(heapLimitMb(64 * 1024 ** 3)).toBe(8192);
|
||||
expect(heapLimitMb(Number.NaN)).toBe(8192);
|
||||
expect(heapLimitMb(0)).toBe(8192);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cgroup detection", () => {
|
||||
const asReader = (contents) => (path) => {
|
||||
if (!(path in contents)) throw new Error(`ENOENT: ${path}`);
|
||||
return contents[path];
|
||||
};
|
||||
|
||||
it("reads the cgroup v2 limit", () => {
|
||||
expect(
|
||||
detectMemoryLimitMb(
|
||||
asReader({ "/sys/fs/cgroup/memory.max": "4294967296" }),
|
||||
),
|
||||
).toBe(2867);
|
||||
});
|
||||
|
||||
it("falls back to cgroup v1 when v2 is absent", () => {
|
||||
expect(
|
||||
detectMemoryLimitMb(
|
||||
asReader({
|
||||
"/sys/fs/cgroup/memory.max": "",
|
||||
"/sys/fs/cgroup/memory/memory.limit_in_bytes": "6442450944",
|
||||
}),
|
||||
),
|
||||
).toBe(4300);
|
||||
});
|
||||
|
||||
it("treats an unlimited cgroup as no limit at all", () => {
|
||||
// cgroup v1 reports "max"; a bare sentinel means the same thing.
|
||||
expect(
|
||||
detectMemoryLimitMb(asReader({ "/sys/fs/cgroup/memory.max": "max" })),
|
||||
).toBe(8192);
|
||||
expect(
|
||||
detectMemoryLimitMb(
|
||||
asReader({
|
||||
"/sys/fs/cgroup/memory/memory.limit_in_bytes": "9223372036854771712",
|
||||
}),
|
||||
),
|
||||
).toBe(8192);
|
||||
});
|
||||
|
||||
it("falls back when neither cgroup file is readable", () => {
|
||||
expect(
|
||||
detectMemoryLimitMb(() => {
|
||||
throw new Error("ENOENT");
|
||||
}),
|
||||
).toBe(8192);
|
||||
});
|
||||
});
|
||||
|
||||
describe("NODE_OPTIONS", () => {
|
||||
it("adds the cap when none is set", () => {
|
||||
expect(runtimeNodeOptions("", 2867)).toBe("--max-old-space-size=2867");
|
||||
expect(runtimeNodeOptions(undefined, 2867)).toBe(
|
||||
"--max-old-space-size=2867",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps unrelated options already present", () => {
|
||||
expect(runtimeNodeOptions("--no-warnings", 2867)).toBe(
|
||||
"--no-warnings --max-old-space-size=2867",
|
||||
);
|
||||
});
|
||||
|
||||
it("never overrides an explicit operator choice", () => {
|
||||
expect(runtimeNodeOptions("--max-old-space-size=8192", 2867)).toBe(
|
||||
"--max-old-space-size=8192",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,70 @@
|
||||
// Fail before listening if an installation has no valid runtime configuration.
|
||||
import { spawn } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
/** Fraction of the container memory limit V8 is allowed to use for its heap.
|
||||
* The rest has to cover native allocations the JS heap cannot account for:
|
||||
* the mysql2 pool buffers, sharp's image pipeline, and zlib during a burst of
|
||||
* RSC rendering. */
|
||||
const HEAP_FRACTION = 0.7;
|
||||
const MIN_HEAP_MB = 512;
|
||||
/** Backstop only. The fraction is the real policy: on a 6 GB container it asks
|
||||
* for 4300 MB, and a backstop at or below that would silently turn the fraction
|
||||
* into a fixed number and make the two limits disagree. This exists purely so a
|
||||
* nonsensical cgroup reading cannot ask for an unbounded heap. */
|
||||
const MAX_HEAP_MB = 8192;
|
||||
|
||||
export function heapLimitMb(cgroupLimitBytes) {
|
||||
if (!Number.isFinite(cgroupLimitBytes) || cgroupLimitBytes <= 0)
|
||||
return MAX_HEAP_MB;
|
||||
const mb = Math.floor((cgroupLimitBytes * HEAP_FRACTION) / (1024 * 1024));
|
||||
return Math.min(MAX_HEAP_MB, Math.max(MIN_HEAP_MB, mb));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read this container's memory ceiling from cgroup v2, falling back to v1.
|
||||
* Without this the V8 heap defaults to a quarter of *host* memory, so a 4 GB
|
||||
* container on a 24 GB host lets the heap grow past the limit and the kernel
|
||||
* OOM-kills the process mid-request — which is what produced the
|
||||
* `next-build (v16)` kills in the host logs. A container that GCs before it
|
||||
* reaches the ceiling degrades to a slower page instead of a killed process.
|
||||
*/
|
||||
export function detectMemoryLimitMb(readFile = readFileSync) {
|
||||
const candidates = [
|
||||
"/sys/fs/cgroup/memory.max",
|
||||
"/sys/fs/cgroup/memory/memory.limit_in_bytes",
|
||||
];
|
||||
for (const path of candidates) {
|
||||
let raw;
|
||||
try {
|
||||
raw = readFile(path, "utf8").trim();
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
// cgroup v1 reports "max" for an unlimited cgroup; v2 uses a bare
|
||||
// sentinel of a very large number on some kernels.
|
||||
if (raw === "max" || raw === "") continue;
|
||||
const bytes = Number(raw);
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) continue;
|
||||
// A host-sized "limit" means no cgroup ceiling was applied.
|
||||
if (bytes >= Number.MAX_SAFE_INTEGER) continue;
|
||||
return heapLimitMb(bytes);
|
||||
}
|
||||
return heapLimitMb(Number.NaN);
|
||||
}
|
||||
|
||||
export function runtimeNodeOptions(
|
||||
existing = "",
|
||||
heapMb = detectMemoryLimitMb(),
|
||||
) {
|
||||
const flag = `--max-old-space-size=${heapMb}`;
|
||||
if (!existing.trim()) return flag;
|
||||
// Respect an explicit operator override; only add the cap when absent.
|
||||
if (existing.includes("--max-old-space-size")) return existing;
|
||||
return `${existing} ${flag}`;
|
||||
}
|
||||
|
||||
export function validateRuntime(settings) {
|
||||
const invalid = [];
|
||||
if (!settings.HOTEL_NAME?.trim() || settings.HOTEL_NAME === "Build fixture")
|
||||
@@ -33,7 +96,13 @@ if (
|
||||
) {
|
||||
try {
|
||||
validateRuntime(process.env);
|
||||
const child = spawn(process.execPath, ["server.js"], { stdio: "inherit" });
|
||||
const heapMb = detectMemoryLimitMb();
|
||||
const nodeOptions = runtimeNodeOptions(process.env.NODE_OPTIONS, heapMb);
|
||||
console.log(`Starting CMS with a ${heapMb} MB V8 heap cap`);
|
||||
const child = spawn(process.execPath, ["server.js"], {
|
||||
stdio: "inherit",
|
||||
env: { ...process.env, NODE_OPTIONS: nodeOptions },
|
||||
});
|
||||
for (const signal of ["SIGTERM", "SIGINT"])
|
||||
process.on(signal, () => child.kill(signal));
|
||||
child.on("error", () => {
|
||||
|
||||
@@ -58,10 +58,27 @@ trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
trap 'log "Update failed; inspect $LOG_FILE. No volumes or local files were deleted."' ERR
|
||||
|
||||
# An existing CI deployment is a different owner of the same host port.
|
||||
if [ "$(docker inspect --format '{{.State.Running}}' epicnext-cms-app 2>/dev/null || true)" = true ]; then
|
||||
die "This host is managed by CI (epicnext-cms-app). Update through CI, not a second Compose deployment."
|
||||
# This host belongs to CI: the blue/green deploy owns both host ports (3002 and
|
||||
# 3003) and one of the two slot containers is always the live release. Compose
|
||||
# may only run where CI does not.
|
||||
#
|
||||
# Checking epicnext-cms-app alone was not enough. After a cutover to the green
|
||||
# slot the blue container is stopped, renamed and deleted, so the guard stopped
|
||||
# firing while the host stayed CI-managed. `docker compose up` then recreated a
|
||||
# replica named epicnext-cms on port 3002 — the blue slot, exactly where the next
|
||||
# candidate has to start — and every later release failed on a busy port until
|
||||
# someone removed that container by hand (see logs/docker-update.cron.log).
|
||||
# Therefore: both slot containers count, and so does the nginx upstream, which is
|
||||
# the only thing that still marks the host as blue/green when a slot is idle.
|
||||
ci_upstream_file="${CMS_UPSTREAM_FILE:-/etc/nginx/snippets/cms_upstream_servers.conf}"
|
||||
if [ -r "$ci_upstream_file" ] && grep -qsE '127\.0\.0\.1:(3002|3003)' "$ci_upstream_file"; then
|
||||
die "This host is managed by CI ($ci_upstream_file points at a blue/green slot). Update through CI, not a second Compose deployment."
|
||||
fi
|
||||
for slot_container in epicnext-cms-app epicnext-cms-green; do
|
||||
if [ "$(docker inspect --format '{{.State.Running}}' "$slot_container" 2>/dev/null || true)" = true ]; then
|
||||
die "This host is managed by CI ($slot_container). Update through CI, not a second Compose deployment."
|
||||
fi
|
||||
done
|
||||
[[ -z "$(git status --porcelain --untracked-files=normal)" ]] || die "Working tree is not clean. Commit or stash local work first."
|
||||
if [[ "$UPDATE_SKIP_PULL" = 0 ]]; then
|
||||
git rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' >/dev/null || die "Configure this branch's Git upstream before updating."
|
||||
|
||||
+71
-5
@@ -1,9 +1,17 @@
|
||||
import { drainOperationEffects } from "../src/features/operations/worker";
|
||||
import { drainFurnitureImports } from "../src/lib/services/furni-job-worker";
|
||||
// Must stay the first import. ESM evaluates a module's imports in source
|
||||
// order, and `../src/features/operations/worker` reaches `@/env`, which parses
|
||||
// process.env at import time. With this import further down the tree, load-env
|
||||
// ran *after* the schema validation had already thrown on a missing
|
||||
// DATABASE_URL, so the worker could only ever start from an environment that
|
||||
// already exported the config — which is why `pnpm jobs:worker` died
|
||||
// immediately and nothing supervised it.
|
||||
import "./load-env";
|
||||
import * as nodeFs from "node:fs";
|
||||
import * as nodePath from "node:path";
|
||||
import { Cron } from "croner";
|
||||
import { lt, sql } from "drizzle-orm";
|
||||
import { env } from "../src/env";
|
||||
import { drainOperationEffects } from "../src/features/operations/worker";
|
||||
import { db, PasswordReset, WebsiteLoginLogs } from "../src/lib/db";
|
||||
import { logger } from "../src/lib/logger";
|
||||
import { redis } from "../src/lib/redis";
|
||||
@@ -18,6 +26,7 @@ import {
|
||||
diskLevel,
|
||||
parseDfOutput,
|
||||
} from "../src/lib/services/disk-usage";
|
||||
import { drainFurnitureImports } from "../src/lib/services/furni-job-worker";
|
||||
import { publishDueArticles } from "../src/lib/services/news-scheduler";
|
||||
import { scheduledAutoCleanFakeNitros } from "../src/lib/services/nitro-cleanup";
|
||||
import { rcon } from "../src/lib/services/rcon";
|
||||
@@ -161,13 +170,69 @@ async function checkDiskUsage(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the JAR to back up. `EMULATOR_JAR_PATH` may point at the file itself
|
||||
* or at a directory of release JARs, because the emulator's own unit file
|
||||
* launches `ls -t Polaris-*-jar-with-dependencies.jar` — a path pinned to one
|
||||
* release filename goes stale on the next emulator upgrade, and a stale path
|
||||
* fails as a bare ENOENT from copyFile that gives no hint what is wrong. A
|
||||
* directory (or a path with a `*`) resolves to the most recently modified JAR,
|
||||
* matching how the emulator actually picks its build.
|
||||
*/
|
||||
export function resolveEmulatorJar(
|
||||
configuredPath: string,
|
||||
fs: typeof import("node:fs") = nodeFs,
|
||||
{ resolve }: typeof import("node:path") = nodePath,
|
||||
): string | null {
|
||||
const { existsSync, readdirSync, statSync } = fs;
|
||||
if (configuredPath.includes("*")) {
|
||||
const dir = configuredPath.slice(0, configuredPath.lastIndexOf("/") + 1);
|
||||
const pattern = configuredPath.slice(dir.length);
|
||||
if (!existsSync(dir)) return null;
|
||||
return (
|
||||
readdirSync(dir)
|
||||
.filter((name: string) => name.startsWith(pattern.split("*")[0] ?? ""))
|
||||
.map((name: string) => resolve(dir, name))
|
||||
.filter((path: string) => existsSync(path))
|
||||
.sort(
|
||||
(a: string, b: string) => statSync(b).mtimeMs - statSync(a).mtimeMs,
|
||||
)[0] ?? null
|
||||
);
|
||||
}
|
||||
if (existsSync(configuredPath) && statSync(configuredPath).isFile())
|
||||
return configuredPath;
|
||||
// A directory: take the newest JAR in it.
|
||||
if (existsSync(configuredPath) && statSync(configuredPath).isDirectory()) {
|
||||
return (
|
||||
readdirSync(configuredPath)
|
||||
.filter((name: string) => name.endsWith(".jar"))
|
||||
.map((name: string) => resolve(configuredPath, name))
|
||||
.sort(
|
||||
(a: string, b: string) => statSync(b).mtimeMs - statSync(a).mtimeMs,
|
||||
)[0] ?? null
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function backupEmulatorJar(): Promise<void> {
|
||||
if (!env.EMULATOR_JAR_PATH || !env.EMULATOR_BACKUP_DIR) return;
|
||||
|
||||
const { copyFileSync, mkdirSync, readdirSync, unlinkSync, existsSync } =
|
||||
await import("node:fs");
|
||||
const fs = await import("node:fs");
|
||||
const { copyFileSync, mkdirSync, readdirSync, unlinkSync, existsSync } = fs;
|
||||
const { resolve } = await import("node:path");
|
||||
|
||||
const jarPath = resolveEmulatorJar(env.EMULATOR_JAR_PATH, fs, nodePath);
|
||||
if (!jarPath) {
|
||||
// Configured but unusable: say so once, loudly, instead of every night
|
||||
// logging an opaque copyFile ENOENT that reads like a permissions bug.
|
||||
logger.error(
|
||||
"Emulator JAR backup skipped: EMULATOR_JAR_PATH does not resolve to a JAR",
|
||||
{ module: "jobs", configured: env.EMULATOR_JAR_PATH },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString().slice(0, 19).replace(/[T:]/g, "-");
|
||||
const backupFile = resolve(
|
||||
env.EMULATOR_BACKUP_DIR,
|
||||
@@ -179,10 +244,11 @@ async function backupEmulatorJar(): Promise<void> {
|
||||
}
|
||||
|
||||
try {
|
||||
copyFileSync(env.EMULATOR_JAR_PATH, backupFile);
|
||||
copyFileSync(jarPath, backupFile);
|
||||
logger.info("Backed up emulator JAR", {
|
||||
module: "jobs",
|
||||
backupFile,
|
||||
source: jarPath,
|
||||
});
|
||||
|
||||
const keep = env.EMULATOR_BACKUP_KEEP ?? 7;
|
||||
|
||||
@@ -101,6 +101,15 @@ fi
|
||||
if [[ ! -d /var/log/nginx ]]; then
|
||||
install -d -o root -g adm -m 750 /var/log/nginx
|
||||
fi
|
||||
# nginx-cms.conf sets `root /var/www/html` so that disk-backed locations
|
||||
# (favicon.ico) resolve somewhere the www-data worker can actually traverse.
|
||||
# The previous implicit root was /etc/nginx/html, which sits behind /etc/nginx
|
||||
# (0750 root:root): the worker got EACCES on every stat, and nginx logs a
|
||||
# failed stat at crit, so each crawler probe wrote a crit line.
|
||||
if [[ ! -d /var/www/html ]]; then
|
||||
install -d -o root -g root -m 755 /var/www/html
|
||||
echo "+ created /var/www/html (document root)"
|
||||
fi
|
||||
for f in /var/log/nginx/access.log /var/log/nginx/error.log; do
|
||||
[[ -f "$f" ]] || touch "$f"
|
||||
done
|
||||
|
||||
@@ -79,6 +79,27 @@ function filesFrom(values) {
|
||||
return values.map(normalizeAsset);
|
||||
}
|
||||
|
||||
/**
|
||||
* Webpack interleaves numeric chunk ids with file names in `chunks` arrays, so
|
||||
* a real file has to be separated from its id. An id is rejected by
|
||||
* normalizeAsset for the right reason (it has no `static/` prefix and no `.js`
|
||||
* suffix), which makes it a usable filter — but only for ids. A malformed
|
||||
* *path* must still fail loudly rather than be silently dropped, or a broken
|
||||
* manifest would quietly under-report a route's real weight.
|
||||
*/
|
||||
function assetFilesFromChunkList(values) {
|
||||
if (!Array.isArray(values))
|
||||
throw new Error("Unsupported JavaScript chunk list.");
|
||||
const files = [];
|
||||
for (const value of values) {
|
||||
if (typeof value !== "string")
|
||||
throw new Error("Non-string JavaScript asset.");
|
||||
if (/^\d+$/.test(value)) continue;
|
||||
files.push(normalizeAsset(value));
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
export function measureRoute({
|
||||
budget,
|
||||
appPath,
|
||||
@@ -86,18 +107,44 @@ export function measureRoute({
|
||||
clientManifest,
|
||||
readAsset,
|
||||
}) {
|
||||
// Turbopack emits an explicit per-segment `entryJSFiles` list. Webpack does
|
||||
// not — it only records chunks per client module — so after the build moved
|
||||
// to webpack (3d828a61) every route reported "unavailable" and the report
|
||||
// silently stopped measuring anything. Fall back to the same source Next's
|
||||
// own `static-routes-info` uses for webpack builds.
|
||||
const entries = clientManifest?.entryJSFiles;
|
||||
if (!entries || typeof entries !== "object" || Array.isArray(entries))
|
||||
const webpackModules = clientManifest?.clientModules;
|
||||
let filesBySource;
|
||||
let webpackLayout = false;
|
||||
if (entries && typeof entries === "object" && !Array.isArray(entries)) {
|
||||
const sourceEntries = Object.keys(entries);
|
||||
if (
|
||||
!sourceEntries.some((key) =>
|
||||
key.replaceAll("\\", "/").endsWith(`/app${appPath}`),
|
||||
)
|
||||
)
|
||||
throw new Error("Route page entry is absent from entryJSFiles.");
|
||||
filesBySource = Object.entries(entries);
|
||||
} else if (webpackModules && typeof webpackModules === "object") {
|
||||
webpackLayout = true;
|
||||
// Each `chunks` array is `[chunkId, fileName, chunkId, fileName, ...]`.
|
||||
filesBySource = [];
|
||||
for (const node of Object.values(webpackModules)) {
|
||||
if (!Array.isArray(node?.chunks) || node.chunks.length === 0) continue;
|
||||
// One shared origin label instead of the module path: the per-chunk
|
||||
// `sources` list is written into report.json, and webpack records
|
||||
// absolute node_modules paths for every client module on the route.
|
||||
filesBySource.push(["client-module", node.chunks]);
|
||||
}
|
||||
if (filesBySource.length === 0)
|
||||
throw new Error(
|
||||
"Neither entryJSFiles nor clientModules chunk data is available; this manifest layout is not supported.",
|
||||
);
|
||||
} else {
|
||||
throw new Error(
|
||||
"entryJSFiles is unavailable; this manifest layout is not supported.",
|
||||
);
|
||||
const sourceEntries = Object.keys(entries);
|
||||
if (
|
||||
!sourceEntries.some((key) =>
|
||||
key.replaceAll("\\", "/").endsWith(`/app${appPath}`),
|
||||
)
|
||||
)
|
||||
throw new Error("Route page entry is absent from entryJSFiles.");
|
||||
}
|
||||
const bootstrap = filesFrom(
|
||||
buildManifest.rootMainFilesTree?.[appPath] ?? buildManifest.rootMainFiles,
|
||||
);
|
||||
@@ -110,8 +157,9 @@ export function measureRoute({
|
||||
origins.set(file, sources);
|
||||
};
|
||||
for (const file of bootstrap) add(file, "bootstrap");
|
||||
for (const [entry, values] of Object.entries(entries))
|
||||
for (const file of filesFrom(values)) add(file, entry);
|
||||
const readChunkList = webpackLayout ? assetFilesFromChunkList : filesFrom;
|
||||
for (const [entry, values] of filesBySource)
|
||||
for (const file of readChunkList(values)) add(file, entry);
|
||||
const size = (file, sources) => {
|
||||
const bytes = readAsset(file);
|
||||
return {
|
||||
@@ -248,12 +296,13 @@ export function collectReport(nextDir, config, metadata = {}) {
|
||||
"Optional PERFORMANCE_COMMIT_SHA supplied by the build caller; not inferred from current checkout.",
|
||||
nodeVersion: process.version,
|
||||
zlibVersion: process.versions.zlib,
|
||||
manifestFormat: "Next App Router client-reference entryJSFiles",
|
||||
manifestFormat:
|
||||
"Turbopack: client-reference entryJSFiles. Webpack: deduplicated clientModules[*].chunks.",
|
||||
definition:
|
||||
"Initial entry envelope: deduplicated rootMainFiles bootstrap plus all entryJSFiles in this route's client-reference manifest, including boundary/loading entries. This is emitted file size, not measured browser traffic or a load-time benchmark.",
|
||||
"Initial entry envelope: deduplicated rootMainFiles bootstrap plus every client chunk this route's client-reference manifest lists, including boundary/loading entries. Turbopack exposes these as entryJSFiles; webpack exposes them only through clientModules[*].chunks, so the same envelope is derived from whichever the build emitted. This is emitted file size, not measured browser traffic or a load-time benchmark.",
|
||||
gzip: "Sum of each unique JavaScript file independently compressed with Node gzip level 9. Excludes HTTP headers and shared-cache reuse.",
|
||||
excluded:
|
||||
"CSS, source maps, images, RSC/HTML payloads, external scripts, async-only chunks absent from entryJSFiles; legacy nomodule polyfills are reported separately.",
|
||||
"CSS, source maps, images, RSC/HTML payloads, external scripts, async-only chunks absent from the manifest's chunk lists; legacy nomodule polyfills are reported separately.",
|
||||
routes,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -216,6 +216,135 @@ describe("route JS measurement", () => {
|
||||
);
|
||||
expect(collectReport(dir, config).routes[0].status).toBe("unavailable");
|
||||
});
|
||||
// The regression: after the build moved to webpack (3d828a61) the manifest
|
||||
// has no entryJSFiles, only clientModules[*].chunks. Every route then
|
||||
// reported "unavailable" and the report measured nothing at all while still
|
||||
// exiting zero, so the budgets silently stopped being enforced.
|
||||
describe("webpack manifests without entryJSFiles", () => {
|
||||
const webpackManifest = {
|
||||
clientModules: {
|
||||
"[project]/src/components/header.tsx": {
|
||||
chunks: [
|
||||
"4269",
|
||||
"static/chunks/4269-shared.js?dpl=abc",
|
||||
"6726",
|
||||
"static/chunks/header-entry.js?dpl=abc",
|
||||
],
|
||||
},
|
||||
"[project]/src/app/(site)/news/page.tsx": {
|
||||
chunks: [
|
||||
"4269",
|
||||
"static/chunks/4269-shared.js?dpl=abc",
|
||||
"7777",
|
||||
"/_next/static/chunks/page.js?dpl=abc",
|
||||
],
|
||||
},
|
||||
// Async-only modules are recorded with an empty chunk list.
|
||||
"[project]/src/components/lazy.tsx": { chunks: [] },
|
||||
},
|
||||
};
|
||||
const webpackFiles = {
|
||||
// buildManifest.rootMainFiles lists runtime.js and shared.js, so both
|
||||
// bootstrap assets must exist or the read fails.
|
||||
"static/chunks/runtime.js": Buffer.from("const runtime = true;"),
|
||||
"static/chunks/shared.js": Buffer.from("bootstrap".repeat(10)),
|
||||
"static/chunks/4269-shared.js": Buffer.from("shared".repeat(50)),
|
||||
"static/chunks/header-entry.js": Buffer.from("header"),
|
||||
"static/chunks/page.js": Buffer.from("page"),
|
||||
"static/chunks/polyfill.js": Buffer.from("legacy"),
|
||||
};
|
||||
const measure = () =>
|
||||
measureRoute({
|
||||
budget,
|
||||
appPath,
|
||||
buildManifest,
|
||||
clientManifest: webpackManifest,
|
||||
readAsset: (file) => webpackFiles[file],
|
||||
});
|
||||
|
||||
it("derives the envelope from clientModules and ignores chunk ids", () => {
|
||||
const row = measure();
|
||||
expect(row.status).toBe("measured");
|
||||
// 2 bootstrap + shared + header-entry + page; the numeric chunk ids
|
||||
// are not assets and must not throw or be counted.
|
||||
expect(row.initial.chunkCount).toBe(5);
|
||||
expect(row.initial.chunks.map((f) => f.path).sort()).toEqual([
|
||||
"static/chunks/4269-shared.js",
|
||||
"static/chunks/header-entry.js",
|
||||
"static/chunks/page.js",
|
||||
"static/chunks/runtime.js",
|
||||
"static/chunks/shared.js",
|
||||
]);
|
||||
// Same bytes as the Turbopack fixture would produce for these files.
|
||||
expect(row.initial.rawBytes).toBe(
|
||||
Object.values(webpackFiles)
|
||||
.filter((b) => !b.includes("legacy"))
|
||||
.reduce((n, b) => n + b.length, 0),
|
||||
);
|
||||
});
|
||||
|
||||
it("counts a chunk reached by several client modules only once", () => {
|
||||
const shared = measure().initial.chunks.find(
|
||||
(f) => f.path === "static/chunks/4269-shared.js",
|
||||
);
|
||||
expect(shared).toBeDefined();
|
||||
expect(measure().initial.chunkCount).toBe(5);
|
||||
});
|
||||
|
||||
it("does not leak absolute module paths into the report", () => {
|
||||
const sources = new Set(
|
||||
measure().initial.chunks.flatMap((chunk) => chunk.sources),
|
||||
);
|
||||
// Only the two known origin labels; no node_modules path may appear.
|
||||
expect([...sources].sort()).toEqual(["bootstrap", "client-module"]);
|
||||
});
|
||||
|
||||
it("still fails rather than under-reporting a malformed chunk path", () => {
|
||||
expect(() =>
|
||||
measureRoute({
|
||||
budget,
|
||||
appPath,
|
||||
buildManifest,
|
||||
clientManifest: {
|
||||
clientModules: {
|
||||
x: { chunks: ["static/chunks/../../etc/passwd"] },
|
||||
},
|
||||
},
|
||||
readAsset: (file) => webpackFiles[file],
|
||||
}),
|
||||
).toThrow("Unsupported JavaScript asset");
|
||||
});
|
||||
|
||||
it("reports unavailable when webpack recorded no chunks at all", () => {
|
||||
expect(() =>
|
||||
measureRoute({
|
||||
budget,
|
||||
appPath,
|
||||
buildManifest,
|
||||
clientManifest: { clientModules: { x: { chunks: [] } } },
|
||||
readAsset: (file) => webpackFiles[file],
|
||||
}),
|
||||
).toThrow("Neither entryJSFiles nor clientModules");
|
||||
});
|
||||
|
||||
it("prefers entryJSFiles when a manifest carries both", () => {
|
||||
// A future Next version could emit both; the explicit list wins
|
||||
// because it is per-segment and therefore the tighter envelope.
|
||||
const row = measureRoute({
|
||||
budget,
|
||||
appPath,
|
||||
buildManifest,
|
||||
clientManifest: {
|
||||
...webpackManifest,
|
||||
entryJSFiles: {
|
||||
"[project]/src/app/(site)/news/page": ["static/chunks/page.js"],
|
||||
},
|
||||
},
|
||||
readAsset: (file) => webpackFiles[file],
|
||||
});
|
||||
expect(row.initial.chunkCount).toBe(3);
|
||||
});
|
||||
});
|
||||
it("does not deduplicate shared files across independent cold route totals", () => {
|
||||
const row = measureRoute({
|
||||
budget,
|
||||
|
||||
+28
-3
@@ -1,10 +1,35 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup cron jobs for maintenance
|
||||
#
|
||||
# Appends to the existing crontab. `crontab -` replaces the whole file, so a
|
||||
# script that pipes one job at a time silently drops every other scheduled job.
|
||||
# Entries are matched by their command, so re-running this is idempotent.
|
||||
set -Eeuo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# Adds one schedule line to the crontab unless its command is already present.
|
||||
add_job() {
|
||||
local schedule="$1" command
|
||||
command="${schedule##* }"
|
||||
local current
|
||||
current="$(crontab -l 2>/dev/null || true)"
|
||||
if grep -Fq "$command" <<<"$current"; then
|
||||
echo "Already scheduled: $command"
|
||||
return
|
||||
fi
|
||||
if [[ -z "$current" ]]; then
|
||||
printf '%s\n' "$schedule" | crontab -
|
||||
else
|
||||
printf '%s\n%s\n' "$current" "$schedule" | crontab -
|
||||
fi
|
||||
echo "Scheduled: $schedule"
|
||||
}
|
||||
|
||||
# Run backup every day at 03:00
|
||||
echo "0 3 * * * $SCRIPT_DIR/backup.sh" | crontab -
|
||||
# Run prune every Sunday at 04:00
|
||||
echo "0 4 * * 0 $SCRIPT_DIR/docker-prune.sh" | crontab -
|
||||
add_job "0 3 * * * $SCRIPT_DIR/backup.sh"
|
||||
# Run prune every day at 04:00. Daily rather than weekly: byparr leaks roughly
|
||||
# 1.7 GB/day of orphaned browser profiles into its writable layer, so a weekly
|
||||
# run would let ~12 GB accumulate before anything reclaimed it.
|
||||
add_job "0 4 * * * $SCRIPT_DIR/docker-prune.sh"
|
||||
|
||||
echo "Cron jobs configured."
|
||||
Executable
+166
@@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env bash
|
||||
# Voer een zwaar commando uit onder een harde geheugenplafond.
|
||||
#
|
||||
# Waarom dit bestaat:
|
||||
# De host draait met `vm.overcommit_memory=0` en ZONDER swap. Vraagt een
|
||||
# proces meer geheugen dan er vrij is, dan geeft de kernel niets weg en
|
||||
# roept hij meteen de OOM-killer aan. Die kiest zijn slachtoffer over de
|
||||
# héle machine, niet alleen in het schuldige proces — dus een build kan de
|
||||
# database, nginx of de live release meenemen.
|
||||
#
|
||||
# `next build` op Turbopack groeit op dit 329-route app voorbij 12GB RSS
|
||||
# en is ook met 4GB swap nog steeds dood. Zie commit 3d828a61.
|
||||
#
|
||||
# Wat dit doet:
|
||||
# Het commando komt in zijn eigen cgroup met `MemoryMax`. Als het door die
|
||||
# grens heen groeit krijgt alleen die cgroup een OOM-signaal: het commando
|
||||
# zelf sterft, de rest van de machine leeft. Dat is precies het gedrag dat
|
||||
# je wilt — de build faalt, de site blijft staan.
|
||||
#
|
||||
# Met `--max-old-space-size` lukt dat niet. Die limiet zit op de V8-heap en
|
||||
# Turbopack-geheugen is native Rust-geheugen; met een 2GB cap piekte de RSS
|
||||
# alsnog op 8GB. Zie het commentaar in de Dockerfile.
|
||||
#
|
||||
# Backends:
|
||||
#
|
||||
# systemd (cgroup MemoryMax)
|
||||
# Meet RSS over de héle procesboom. Dit is de echte garantie en wordt
|
||||
# overal gebruikt waar systemd beschikbaar is (de host waar deze
|
||||
# CMS draait). De RSS-plafonds in package.json zijn hierop gekozen:
|
||||
# `next build` piekte op 6,5GB, dus 10GB laat ruimte over terwijl er
|
||||
# 6GB basislast naast blijft passen binnen de 23,5GB van deze machine.
|
||||
#
|
||||
# ulimit -v (per proces, virtuele adresruimte)
|
||||
# Alleen als expliciet gevraagd. Meet virtuele adresruimte, NIET RSS, en
|
||||
# kan de boom helemaal niet begrenzen: elke worker krijgt z'n eigen
|
||||
# limiet. Op moderne V8 is het bovendien een vergiftigde gift: `-v 10g`
|
||||
# laat V8 de heaplimiet terugbrengen naar 2,25GB (webpack sterft met
|
||||
# std::bad_alloc), en `-v 20g` laat de v8-wasm-memory-toewijzing falen
|
||||
# tijdens `next build`. Zet CMS_MEMORY_CAP_VIRTUAL ruim boven de fysieke
|
||||
# RAM als je het echt wilt gebruiken.
|
||||
#
|
||||
# Geen van beide -> weigeren. Stil onbegrensd doorlopen zou precies de
|
||||
# valse geruststelling zijn waar 3d828a61 voor waarschuwt. Omgevingen
|
||||
# zonder systemd (de Docker-build, de GitLab-runner) kiezen daarom
|
||||
# expliciet voor CMS_MEMORY_CAP_BACKEND=none — met een waarschuwing,
|
||||
# en met als rechtvaardiging dat die builds al begrensd zijn door
|
||||
# `next build --webpack` + `--max-old-space-size` en in hun eigen
|
||||
# geïsoleerde container draaien, niet op de host.
|
||||
#
|
||||
# Gebruik: bash scripts/with-memory-cap.sh 10g <command...>
|
||||
# Backend kiezen: CMS_MEMORY_CAP_BACKEND=systemd|ulimit|none|auto
|
||||
# ulimit-waarde kiezen: CMS_MEMORY_CAP_VIRTUAL=40g
|
||||
set -Eeuo pipefail
|
||||
|
||||
usage() {
|
||||
echo "gebruik: $0 <plafond, bv. 10g> <command...>" >&2
|
||||
exit 64
|
||||
}
|
||||
|
||||
[ "$#" -ge 2 ] || usage
|
||||
cap="$1"
|
||||
shift
|
||||
|
||||
# Zet 10g / 512m / 2G / 1234567 om in bytes. Alleen bytes gaan naar
|
||||
# systemd: MemoryMax accepteert `10G` maar weigert `10g`, en die
|
||||
# hoofdletterval is te makkelijk om per ongeluk te treffen.
|
||||
to_bytes() {
|
||||
local value="$1" number suffix
|
||||
if [[ "$value" =~ ^([0-9]+)([kKmMgGtT]?)$ ]]; then
|
||||
number="${BASH_REMATCH[1]}"
|
||||
suffix="${BASH_REMATCH[2]}"
|
||||
else
|
||||
echo "onbekend plafond-formaat: $value" >&2
|
||||
return 1
|
||||
fi
|
||||
case "$suffix" in
|
||||
k | K) echo $((number * 1024)) ;;
|
||||
m | M) echo $((number * 1024 * 1024)) ;;
|
||||
g | G) echo $((number * 1024 * 1024 * 1024)) ;;
|
||||
t | T) echo $((number * 1024 * 1024 * 1024 * 1024)) ;;
|
||||
*) echo "$number" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
bytes="$(to_bytes "$cap")" || exit 64
|
||||
kilobytes=$((bytes / 1024))
|
||||
|
||||
# De virtuele waarde voor ulimit -v. Bewust los van `cap`: zie de toelichting
|
||||
# hierboven, 10g -v breekt de webpack-build.
|
||||
virtual_bytes="$(to_bytes "${CMS_MEMORY_CAP_VIRTUAL:-40g}")" || exit 64
|
||||
virtual_kb=$((virtual_bytes / 1024))
|
||||
|
||||
backend="${CMS_MEMORY_CAP_BACKEND:-auto}"
|
||||
systemd_cmd=()
|
||||
|
||||
# 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.
|
||||
probe_systemd() {
|
||||
command -v systemd-run >/dev/null 2>&1 || return 1
|
||||
[ -d /run/systemd/system ] || return 1
|
||||
if systemd-run --scope --quiet true 2>/dev/null; then
|
||||
systemd_cmd=(systemd-run --scope --quiet)
|
||||
return 0
|
||||
fi
|
||||
if systemd-run --user --scope --quiet true 2>/dev/null; then
|
||||
systemd_cmd=(systemd-run --user --scope --quiet)
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
run_systemd() {
|
||||
echo "[mem-cap] systemd cgroup MemoryMax=$((bytes / 1024 / 1024 / 1024))GB: $*" >&2
|
||||
exec "${systemd_cmd[@]}" -p "MemoryMax=$bytes" "$@"
|
||||
}
|
||||
|
||||
run_ulimit() {
|
||||
echo "[mem-cap] ulimit -v ${virtual_kb}KB per proces (geen systemd; RSS-plafond ${cap} niet meetbaar zonder cgroup): $*" >&2
|
||||
if [ "$virtual_bytes" -lt $((16 * 1024 * 1024 * 1024)) ]; then
|
||||
echo "[mem-cap] let op: -v onder 16g verlaagt V8's heaplimiet en breekt de build; verhoog CMS_MEMORY_CAP_VIRTUAL" >&2
|
||||
fi
|
||||
ulimit -v "$virtual_kb" || {
|
||||
echo "[mem-cap] ulimit -v $virtual_kb werd geweigerd" >&2
|
||||
return 1
|
||||
}
|
||||
exec "$@"
|
||||
}
|
||||
|
||||
run_refuse() {
|
||||
echo "[mem-cap] geen systemd hier; weiger onbegrensd te draaien." >&2
|
||||
echo "[mem-cap] zet CMS_MEMORY_CAP_BACKEND=none om dit bewust te accepteren, of =ulimit voor een per-proces vangnet." >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
run_opted_out() {
|
||||
echo "[mem-cap] WAARSCHUWING: plafond bewust uitgeschakeld, dit commando kan de machine laten OOM-killed worden: $*" >&2
|
||||
exec "$@"
|
||||
}
|
||||
|
||||
case "$backend" in
|
||||
systemd)
|
||||
probe_systemd || {
|
||||
echo "[mem-cap] CMS_MEMORY_CAP_BACKEND=systemd maar systemd-run reageert niet" >&2
|
||||
exit 70
|
||||
}
|
||||
run_systemd "$@"
|
||||
;;
|
||||
ulimit)
|
||||
run_ulimit "$@"
|
||||
;;
|
||||
none | off)
|
||||
run_opted_out "$@"
|
||||
;;
|
||||
auto)
|
||||
if probe_systemd; then
|
||||
run_systemd "$@"
|
||||
else
|
||||
run_refuse "$@"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "[mem-cap] onbekende backend: $backend" >&2
|
||||
exit 64
|
||||
;;
|
||||
esac
|
||||
@@ -86,7 +86,7 @@ export async function resetPassword(formData: FormData): Promise<void> {
|
||||
}
|
||||
|
||||
let error: string | null = null;
|
||||
if (password.length < 6) error = "Password must be at least 6 characters";
|
||||
if (password.length < 12) error = "Password must be at least 12 characters";
|
||||
|
||||
if (!error) {
|
||||
try {
|
||||
|
||||
@@ -184,6 +184,7 @@ describe("register", () => {
|
||||
expect(result).toEqual({
|
||||
error: "Username must be at least 3 characters",
|
||||
ok: false,
|
||||
code: "usernameMinLength",
|
||||
});
|
||||
expect(state.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -191,6 +192,7 @@ describe("register", () => {
|
||||
it("rejects usernames containing characters outside the allowed set", async () => {
|
||||
const result = await register(PREV, buildForm({ username: "bad name!" }));
|
||||
expect(result.error).toContain("letters, numbers, underscore and hyphen");
|
||||
expect(result.code).toBe("usernamePattern");
|
||||
expect(state.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -199,6 +201,7 @@ describe("register", () => {
|
||||
expect(result).toEqual({
|
||||
error: "Enter a valid email address",
|
||||
ok: false,
|
||||
code: "emailValid",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -207,6 +210,7 @@ describe("register", () => {
|
||||
expect(result).toEqual({
|
||||
error: "Password must be at least 12 characters",
|
||||
ok: false,
|
||||
code: "passwordMinLength",
|
||||
});
|
||||
expect(state.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -220,6 +224,7 @@ describe("register", () => {
|
||||
}),
|
||||
);
|
||||
expect(result.error).toContain("uppercase");
|
||||
expect(result.code).toBe("passwordUpper");
|
||||
});
|
||||
|
||||
it("rejects passwords without a digit", async () => {
|
||||
@@ -231,6 +236,7 @@ describe("register", () => {
|
||||
}),
|
||||
);
|
||||
expect(result.error).toContain("digit");
|
||||
expect(result.code).toBe("passwordDigit");
|
||||
});
|
||||
|
||||
it("rejects passwords without a special character", async () => {
|
||||
@@ -242,6 +248,7 @@ describe("register", () => {
|
||||
}),
|
||||
);
|
||||
expect(result.error).toContain("special");
|
||||
expect(result.code).toBe("passwordSpecial");
|
||||
});
|
||||
|
||||
it("rejects mismatched password confirmations", async () => {
|
||||
@@ -249,13 +256,18 @@ describe("register", () => {
|
||||
PREV,
|
||||
buildForm({ password_confirmation: "Different1" }),
|
||||
);
|
||||
expect(result).toEqual({ error: "Passwords do not match", ok: false });
|
||||
expect(result).toEqual({
|
||||
error: "Passwords do not match",
|
||||
ok: false,
|
||||
code: "passwordsMatch",
|
||||
});
|
||||
});
|
||||
|
||||
it("throttles sign-ups per IP", async () => {
|
||||
state.rateLimit.mockResolvedValueOnce({ ok: false, retryAfter: 120 });
|
||||
const result = await runValidRegistration();
|
||||
expect(result.error).toContain("Too many sign-up attempts");
|
||||
expect(result.code).toBe("rateLimited");
|
||||
expect(state.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -273,6 +285,7 @@ describe("register", () => {
|
||||
expect(result).toEqual({
|
||||
error: "Captcha verification failed. Please try again.",
|
||||
ok: false,
|
||||
code: "captchaFailed",
|
||||
});
|
||||
expect(state.verifyCaptcha).toHaveBeenCalledWith("token", "203.0.113.9");
|
||||
expect(state.insert).not.toHaveBeenCalled();
|
||||
@@ -290,6 +303,7 @@ describe("register", () => {
|
||||
expect(result).toEqual({
|
||||
error: "You must accept the terms and conditions to register.",
|
||||
ok: false,
|
||||
code: "termsRequired",
|
||||
});
|
||||
expect(state.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -298,7 +312,11 @@ describe("register", () => {
|
||||
state.checkVpn.mockResolvedValue({ blocked: true });
|
||||
state.siteGet.mockResolvedValueOnce("Custom VPN message");
|
||||
const result = await runValidRegistration();
|
||||
expect(result).toEqual({ error: "Custom VPN message", ok: false });
|
||||
expect(result).toEqual({
|
||||
error: "Custom VPN message",
|
||||
ok: false,
|
||||
code: "vpnBlocked",
|
||||
});
|
||||
expect(state.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -317,6 +335,7 @@ describe("register", () => {
|
||||
state.countTotal = 2;
|
||||
const result = await runValidRegistration();
|
||||
expect(result.error).toContain("maximum number of accounts");
|
||||
expect(result.code).toBe("maxAccountsPerIp");
|
||||
expect(state.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -340,6 +359,7 @@ describe("register", () => {
|
||||
expect(result).toEqual({
|
||||
error: "That username is already taken",
|
||||
ok: false,
|
||||
code: "usernameTaken",
|
||||
});
|
||||
expect(state.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -350,6 +370,7 @@ describe("register", () => {
|
||||
expect(result).toEqual({
|
||||
error: "Registration is temporarily unavailable",
|
||||
ok: false,
|
||||
code: "unavailable",
|
||||
});
|
||||
expect(state.logger.warn).toHaveBeenCalledWith(
|
||||
"Username uniqueness check failed during registration",
|
||||
@@ -365,6 +386,7 @@ describe("register", () => {
|
||||
expect(result).toEqual({
|
||||
error: "That username is already taken",
|
||||
ok: false,
|
||||
code: "usernameTaken",
|
||||
});
|
||||
expect(state.logger.error).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -373,6 +395,7 @@ describe("register", () => {
|
||||
state.insert.mockRejectedValueOnce(new Error("db exploded"));
|
||||
const result = await runValidRegistration();
|
||||
expect(result.error).toContain("Could not create the account");
|
||||
expect(result.code).toBe("createFailed");
|
||||
expect(state.logger.error).toHaveBeenCalledWith(
|
||||
"Account creation failed",
|
||||
expect.objectContaining({ message: "db exploded" }),
|
||||
|
||||
+72
-7
@@ -121,19 +121,72 @@ const registerSchema = z
|
||||
path: ["passwordConfirmation"],
|
||||
});
|
||||
|
||||
/**
|
||||
* Stable, locale-independent reason for a failed sign-up. The client maps these
|
||||
* onto `pages.register.<code>` so the form speaks the visitor's language; the
|
||||
* English `error` string stays as a fallback and for API/log consumers.
|
||||
*/
|
||||
export type RegisterErrorCode =
|
||||
| "usernameMinLength"
|
||||
| "usernameMaxLength"
|
||||
| "usernamePattern"
|
||||
| "usernameReserved"
|
||||
| "usernameTaken"
|
||||
| "emailValid"
|
||||
| "emailDisposable"
|
||||
| "passwordMinLength"
|
||||
| "passwordMaxLength"
|
||||
| "passwordUpper"
|
||||
| "passwordLower"
|
||||
| "passwordDigit"
|
||||
| "passwordSpecial"
|
||||
| "passwordsMatch"
|
||||
| "termsRequired"
|
||||
| "captchaFailed"
|
||||
| "rateLimited"
|
||||
| "vpnBlocked"
|
||||
| "maxAccountsPerIp"
|
||||
| "unavailable"
|
||||
| "createFailed"
|
||||
| "invalidInput";
|
||||
|
||||
/** Maps the schema's English messages onto locale-independent codes. */
|
||||
const ZOD_MESSAGE_CODES: Record<string, RegisterErrorCode> = {
|
||||
"Username must be at least 3 characters": "usernameMinLength",
|
||||
"Username must be at most 25 characters": "usernameMaxLength",
|
||||
"Username may only contain letters, numbers, underscore and hyphen":
|
||||
"usernamePattern",
|
||||
"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 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",
|
||||
"Password must contain at least one special character": "passwordSpecial",
|
||||
"Passwords do not match": "passwordsMatch",
|
||||
};
|
||||
|
||||
// A valid starter Habbo figure so the avatar renders in-client immediately.
|
||||
const DEFAULT_LOOK = "hr-100-.hd-180-1.ch-255-66.lg-280-110.sh-305-62";
|
||||
|
||||
export interface RegisterState {
|
||||
error: string | null;
|
||||
ok: boolean;
|
||||
/** Locale-independent reason, present on every failure. */
|
||||
code?: RegisterErrorCode;
|
||||
}
|
||||
|
||||
export async function register(
|
||||
_prevState: RegisterState,
|
||||
formData: FormData,
|
||||
): Promise<RegisterState> {
|
||||
const fail = (error: string): RegisterState => ({ error, ok: false });
|
||||
const fail = (error: string, code: RegisterErrorCode): RegisterState => ({
|
||||
error,
|
||||
ok: false,
|
||||
code,
|
||||
});
|
||||
const raw = {
|
||||
username: String(formData.get("username") ?? "")
|
||||
.normalize("NFC")
|
||||
@@ -155,7 +208,8 @@ export async function register(
|
||||
|
||||
const parsed = registerSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
return fail(parsed.error.issues[0]?.message ?? "Invalid input");
|
||||
const message = parsed.error.issues[0]?.message ?? "Invalid input";
|
||||
return fail(message, ZOD_MESSAGE_CODES[message] ?? "invalidInput");
|
||||
}
|
||||
|
||||
const { username, mail, password, look } = parsed.data;
|
||||
@@ -166,6 +220,7 @@ export async function register(
|
||||
if (!(await rateLimit(`register:${ip}`, 5, 10 * 60_000)).ok) {
|
||||
return fail(
|
||||
"Too many sign-up attempts. Please wait a few minutes and try again.",
|
||||
"rateLimited",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -174,18 +229,25 @@ export async function register(
|
||||
if (cfg.provider !== "none") {
|
||||
const token = String(formData.get(cfg.field) ?? "").normalize("NFC");
|
||||
if (!(await verifyCaptcha(token, ip)))
|
||||
return fail("Captcha verification failed. Please try again.");
|
||||
return fail(
|
||||
"Captcha verification failed. Please try again.",
|
||||
"captchaFailed",
|
||||
);
|
||||
}
|
||||
|
||||
// Terms acceptance check.
|
||||
if (!raw.termsAccepted)
|
||||
return fail("You must accept the terms and conditions to register.");
|
||||
return fail(
|
||||
"You must accept the terms and conditions to register.",
|
||||
"termsRequired",
|
||||
);
|
||||
|
||||
// VPN/proxy block (only when enabled in /admin/vpn).
|
||||
if ((await checkVpn(ip)).blocked) {
|
||||
return fail(
|
||||
(await siteSettings.get("vpn_block_message", "")) ||
|
||||
"Registrations from VPN/proxy connections are not allowed.",
|
||||
"vpnBlocked",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -200,6 +262,7 @@ export async function register(
|
||||
if (Number(row?.total ?? 0) >= max)
|
||||
return fail(
|
||||
"You have reached the maximum number of accounts for your connection.",
|
||||
"maxAccountsPerIp",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -210,10 +273,11 @@ export async function register(
|
||||
.from(User)
|
||||
.where(eq(User.username, username))
|
||||
.limit(1);
|
||||
if (existing) return fail("That username is already taken");
|
||||
if (existing)
|
||||
return fail("That username is already taken", "usernameTaken");
|
||||
} catch {
|
||||
logger.warn("Username uniqueness check failed during registration");
|
||||
return fail("Registration is temporarily unavailable");
|
||||
return fail("Registration is temporarily unavailable", "unavailable");
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
@@ -233,7 +297,7 @@ export async function register(
|
||||
} catch (err) {
|
||||
const code = (err as { cause?: { code?: string } }).cause?.code;
|
||||
if (code === "ER_DUP_ENTRY") {
|
||||
return fail("That username is already taken");
|
||||
return fail("That username is already taken", "usernameTaken");
|
||||
}
|
||||
logger.error("Account creation failed", {
|
||||
code,
|
||||
@@ -241,6 +305,7 @@ export async function register(
|
||||
});
|
||||
return fail(
|
||||
"Could not create the account. Please try again or contact staff.",
|
||||
"createFailed",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { sendVerification } from "@/lib/auth/email-verification";
|
||||
import { db, User } from "@/lib/db";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
|
||||
export interface ResendVerificationState {
|
||||
ok: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
/**
|
||||
* Re-send a verification e-mail for an address the visitor typed on /verify.
|
||||
*
|
||||
* Deliberately reports success even when no matching unverified account exists:
|
||||
* a distinct failure would let anyone probe which addresses are registered. The
|
||||
* identical-privacy behaviour also applies to the e-mail templates, which are
|
||||
* only sent for real accounts. Rate limiting is the spam defence.
|
||||
*/
|
||||
export async function resendVerification(
|
||||
_prevState: ResendVerificationState,
|
||||
formData: FormData,
|
||||
): Promise<ResendVerificationState> {
|
||||
const email = String(formData.get("email") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!EMAIL_RE.test(email)) {
|
||||
return { ok: false, error: "invalid" };
|
||||
}
|
||||
|
||||
const ip = await clientIp();
|
||||
if (!(await rateLimit(`verify:resend:${ip}`, 3, 10 * 60_000)).ok) {
|
||||
return { ok: false, error: "rateLimited" };
|
||||
}
|
||||
|
||||
try {
|
||||
const [user] = await db
|
||||
.select({ id: User.id, mailVerified: User.mailVerified })
|
||||
.from(User)
|
||||
.where(eq(User.mail, email))
|
||||
.limit(1);
|
||||
if (user && user.mailVerified !== "1") {
|
||||
await sendVerification(email);
|
||||
}
|
||||
} catch {
|
||||
return { ok: false, error: "unavailable" };
|
||||
}
|
||||
|
||||
return { ok: true, error: null };
|
||||
}
|
||||
+178
-190
@@ -1,20 +1,41 @@
|
||||
import { count, desc, eq } from "drizzle-orm";
|
||||
import type { Metadata } from "next";
|
||||
import { headers } from "next/headers";
|
||||
import Image from "next/image";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { AuthTopBar } from "@/components/auth/auth-top-bar";
|
||||
import {
|
||||
AuthPageFrame,
|
||||
AuthUsersCards,
|
||||
} from "@/components/auth/auth-page-frame";
|
||||
import { LoginForm } from "@/components/auth/login-form";
|
||||
import { Reveal } from "@/components/motion-reveal";
|
||||
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
|
||||
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 { siteSettings } from "@/lib/services/site-settings";
|
||||
import { ICON_FRIENDS, ICON_NAV_GOODY, ICON_NAV_ME } from "@/lib/site-icons";
|
||||
|
||||
export default async function LoginPage() {
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("pages.login");
|
||||
const title = t("title");
|
||||
const description = t("subtitle");
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
// Sign-in is a dead end for crawlers and duplicates the homepage copy.
|
||||
robots: { index: false, follow: false },
|
||||
openGraph: { title, description, type: "website" },
|
||||
};
|
||||
}
|
||||
|
||||
export default async function LoginPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ from?: string; registered?: string }>;
|
||||
}) {
|
||||
const t = await getTranslations("pages.login");
|
||||
const [hotelName, cfg, logo] = await Promise.all([
|
||||
resolveHotelName(),
|
||||
@@ -23,6 +44,14 @@ export default async function LoginPage() {
|
||||
]);
|
||||
const nonce = (await headers()).get("x-nonce") ?? undefined;
|
||||
|
||||
const sp = await searchParams;
|
||||
const redirectTo = safeRedirectPath(sp.from);
|
||||
|
||||
// Already signed in: the form has nothing to do here. Honour `from` first so
|
||||
// an admin bounced off /admin lands back where they were heading.
|
||||
const session = await auth();
|
||||
if (session?.user?.id) redirect(redirectTo);
|
||||
|
||||
const [online, recentUsers, latestUsers] = await Promise.all([
|
||||
cached("online_count", 10_000, () =>
|
||||
db
|
||||
@@ -31,194 +60,153 @@ export default async function LoginPage() {
|
||||
.where(eq(User.online, "1"))
|
||||
.then((rows) => rows[0]?.total ?? 0),
|
||||
).catch(() => 0),
|
||||
db
|
||||
.select({ username: User.username, look: User.look })
|
||||
.from(User)
|
||||
.where(eq(User.online, "1"))
|
||||
.limit(8)
|
||||
.catch(() => []),
|
||||
db
|
||||
.select({ username: User.username, look: User.look })
|
||||
.from(User)
|
||||
.orderBy(desc(User.accountCreated))
|
||||
.limit(8)
|
||||
.catch(() => []),
|
||||
cached(
|
||||
"auth_online_users",
|
||||
10_000,
|
||||
() =>
|
||||
db
|
||||
.select({ 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({ username: User.username, look: User.look })
|
||||
.from(User)
|
||||
.orderBy(desc(User.accountCreated))
|
||||
.limit(8),
|
||||
{ staleMs: 60000 },
|
||||
).catch(() => []),
|
||||
]);
|
||||
|
||||
// `/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.
|
||||
const notice =
|
||||
sp.registered === "1" ? (
|
||||
<p
|
||||
role="status"
|
||||
className="auth-alert auth-alert--success animate-fade-in-up m-0 mb-4"
|
||||
>
|
||||
{t("registeredSuccess")}
|
||||
</p>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-6xl flex flex-col gap-8 pb-16">
|
||||
<AuthTopBar hotelName={hotelName} logo={logo} />
|
||||
|
||||
<Reveal>
|
||||
<div className="flex flex-col gap-8 lg:flex-row lg:items-start">
|
||||
{/* Left panel */}
|
||||
<div className="lg:w-96 shrink-0 space-y-4">
|
||||
<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>
|
||||
<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
|
||||
priority
|
||||
/>
|
||||
<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) 18%, transparent)",
|
||||
background:
|
||||
"color-mix(in srgb, var(--color-primary) 10%, transparent)",
|
||||
color:
|
||||
"var(--color-primary-readable, var(--color-primary))",
|
||||
}}
|
||||
>
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-primary" />
|
||||
</span>
|
||||
{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 mx-auto max-w-[200px] leading-relaxed"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{t("welcomeBackSub", { hotelName })}
|
||||
</p>
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
|
||||
{recentUsers.length > 0 && (
|
||||
<SurfaceCard
|
||||
title={t("whoIsOnline")}
|
||||
icon={ICON_NAV_GOODY}
|
||||
bodyClassName="p-4"
|
||||
className="card-glow"
|
||||
>
|
||||
<div className="grid grid-cols-4 gap-1.5 sm:gap-2">
|
||||
{recentUsers.map((u) => (
|
||||
<div
|
||||
key={u.username}
|
||||
className="flex flex-col items-center gap-1.5 rounded-xl border px-1 py-2 transition-all duration-200 hover:-translate-y-0.5 hover:ring-1 hover:ring-[color-mix(in_srgb,var(--color-primary)_35%,transparent)]"
|
||||
style={{
|
||||
background:
|
||||
"color-mix(in srgb, var(--color-primary) 4%, transparent)",
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-primary) 10%, transparent)",
|
||||
}}
|
||||
>
|
||||
<UserAvatarThumbnail
|
||||
figure={u.look}
|
||||
alt=""
|
||||
options={{ direction: 2 }}
|
||||
className="rounded-lg"
|
||||
/>
|
||||
<span
|
||||
className="w-full truncate text-center text-[9px] font-bold leading-tight"
|
||||
style={{ color: "var(--color-text-readable)" }}
|
||||
>
|
||||
{u.username}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
)}
|
||||
|
||||
{latestUsers.length > 0 && (
|
||||
<SurfaceCard
|
||||
title={t("newestCitizens")}
|
||||
icon={ICON_FRIENDS}
|
||||
bodyClassName="p-4"
|
||||
className="card-glow"
|
||||
>
|
||||
<div className="grid grid-cols-4 gap-1.5 sm:gap-2">
|
||||
{latestUsers.map((u) => (
|
||||
<div
|
||||
key={u.username}
|
||||
className="flex flex-col items-center gap-1.5 rounded-xl border px-1 py-2 transition-all duration-200 hover:-translate-y-0.5 hover:ring-1 hover:ring-[color-mix(in_srgb,var(--color-primary)_35%,transparent)]"
|
||||
style={{
|
||||
background:
|
||||
"color-mix(in srgb, var(--color-primary) 4%, transparent)",
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-primary) 10%, transparent)",
|
||||
}}
|
||||
>
|
||||
<UserAvatarThumbnail
|
||||
figure={u.look}
|
||||
alt=""
|
||||
options={{ direction: 2 }}
|
||||
className="rounded-lg"
|
||||
/>
|
||||
<span
|
||||
className="w-full truncate text-center text-[9px] font-bold leading-tight"
|
||||
style={{ color: "var(--color-text-readable)" }}
|
||||
>
|
||||
{u.username}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: form */}
|
||||
<div className="flex-1 lg:sticky lg:top-6">
|
||||
<SurfaceCard
|
||||
title={t("title")}
|
||||
icon={ICON_NAV_ME}
|
||||
bodyClassName="p-6 sm:p-7"
|
||||
>
|
||||
<p
|
||||
className="text-sm mb-6"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{t("subtitle")}
|
||||
</p>
|
||||
<LoginForm
|
||||
captcha={{
|
||||
provider: cfg.provider,
|
||||
siteKey: cfg.siteKey || undefined,
|
||||
field: cfg.field || undefined,
|
||||
}}
|
||||
nonce={nonce}
|
||||
/>
|
||||
</SurfaceCard>
|
||||
</div>
|
||||
<AuthPageFrame
|
||||
hotelName={hotelName}
|
||||
logo={logo}
|
||||
title={t("title")}
|
||||
subtitle={t("subtitle")}
|
||||
notice={notice}
|
||||
form={
|
||||
<LoginForm
|
||||
redirectTo={redirectTo}
|
||||
captcha={{
|
||||
provider: cfg.provider,
|
||||
siteKey: cfg.siteKey || undefined,
|
||||
field: cfg.field || undefined,
|
||||
}}
|
||||
nonce={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>
|
||||
</Reveal>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
+220
-148
@@ -1,4 +1,5 @@
|
||||
import { count, desc, eq } from "drizzle-orm";
|
||||
import { ArrowRight, ChevronDown } from "lucide-react";
|
||||
import type { Metadata } from "next";
|
||||
import { headers } from "next/headers";
|
||||
import Image from "next/image";
|
||||
@@ -6,7 +7,7 @@ import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
import { AnimatedCounter } from "@/components/animated-counter";
|
||||
import { HomeLoginForm } from "@/components/auth/home-login-form";
|
||||
import { LoginForm } from "@/components/auth/login-form";
|
||||
import { Clock } from "@/components/clock";
|
||||
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||
import Link from "@/components/link";
|
||||
@@ -14,6 +15,7 @@ import { LiveOnlineCounter } from "@/components/live-online-counter";
|
||||
import { Reveal } from "@/components/motion-reveal";
|
||||
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";
|
||||
@@ -24,6 +26,7 @@ import { resolveHotelName } from "@/lib/hotel-name";
|
||||
import { captchaConfig } from "@/lib/services/captcha";
|
||||
import { getNewsList } from "@/lib/services/news-list";
|
||||
import {
|
||||
countArticles,
|
||||
countPhotos,
|
||||
countRooms,
|
||||
countUsers,
|
||||
@@ -40,17 +43,17 @@ import {
|
||||
ICON_NAV_ME,
|
||||
} from "@/lib/site-icons";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Home",
|
||||
description:
|
||||
"An online virtual world where you can create your own avatar, make friends, chat, and build your own rooms.",
|
||||
openGraph: {
|
||||
title: "Home",
|
||||
description:
|
||||
"An online virtual world where you can create your own avatar, make friends, chat, and build your own rooms.",
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const tn = await getTranslations("nav");
|
||||
const tp = await getTranslations("pages.home");
|
||||
const title = tn("home");
|
||||
const description = tp("welcomeBody");
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
openGraph: { title, description, type: "website" },
|
||||
};
|
||||
}
|
||||
|
||||
// ── Local visual language (theme-aware, no big white blocks) ──────────
|
||||
const PANEL_BG =
|
||||
@@ -175,6 +178,7 @@ async function getHotelData() {
|
||||
users,
|
||||
rooms,
|
||||
totalPhotos,
|
||||
articleCount,
|
||||
articles,
|
||||
recentUsers,
|
||||
recentPhotos,
|
||||
@@ -195,6 +199,9 @@ async function getHotelData() {
|
||||
cached("total_photos", 300_000, countPhotos, { staleMs: 300000 }).catch(
|
||||
publicReadFailure("home.photos-count"),
|
||||
),
|
||||
cached("total_articles", 300_000, countArticles, {
|
||||
staleMs: 300000,
|
||||
}).catch(publicReadFailure("home.articles-count")),
|
||||
getNewsList(4, { throwOnError: true }).catch(
|
||||
publicReadFailure("home.news"),
|
||||
),
|
||||
@@ -228,6 +235,7 @@ async function getHotelData() {
|
||||
users,
|
||||
rooms,
|
||||
totalPhotos,
|
||||
articleCount,
|
||||
articles,
|
||||
recentUsers,
|
||||
recentPhotos,
|
||||
@@ -249,6 +257,7 @@ export default async function Home() {
|
||||
users,
|
||||
rooms,
|
||||
totalPhotos,
|
||||
articleCount,
|
||||
articles,
|
||||
recentUsers,
|
||||
recentPhotos,
|
||||
@@ -310,7 +319,7 @@ export default async function Home() {
|
||||
className="text-sm font-bold"
|
||||
style={{ color: "rgba(255,255,255,0.95)" }}
|
||||
>
|
||||
→
|
||||
<ArrowRight className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
@@ -342,12 +351,6 @@ export default async function Home() {
|
||||
>
|
||||
{a.title}
|
||||
</h3>
|
||||
<p
|
||||
className="mt-0.5 text-[10px] sm:text-[11px] font-semibold opacity-80"
|
||||
style={{ color: "#cbd5e1" }}
|
||||
>
|
||||
{formatDate(a.createdAt, "date", "")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
@@ -371,7 +374,7 @@ export default async function Home() {
|
||||
color: "var(--color-primary)",
|
||||
},
|
||||
{
|
||||
value: articles?.length ?? null,
|
||||
value: articleCount,
|
||||
label: tp("statsArticles"),
|
||||
color: "var(--color-accent)",
|
||||
},
|
||||
@@ -484,6 +487,29 @@ export default async function Home() {
|
||||
"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"
|
||||
@@ -553,7 +579,16 @@ export default async function Home() {
|
||||
</p>
|
||||
|
||||
{recentUsers !== null && recentUsers.length > 0 && (
|
||||
<div className="mt-7 flex items-center justify-center gap-3">
|
||||
<div
|
||||
className="animate-fade-in-up mt-7 inline-flex items-center gap-3 rounded-full border px-3 py-1.5"
|
||||
style={{
|
||||
background: "rgba(255,255,255,0.08)",
|
||||
borderColor: "rgba(255,255,255,0.22)",
|
||||
backdropFilter: "blur(12px)",
|
||||
WebkitBackdropFilter: "blur(12px)",
|
||||
animationDelay: "0.08s",
|
||||
}}
|
||||
>
|
||||
<div className="flex -space-x-2.5">
|
||||
{recentUsers.slice(0, 4).map((u) => (
|
||||
<UserAvatarThumbnail
|
||||
@@ -566,25 +601,21 @@ export default async function Home() {
|
||||
))}
|
||||
</div>
|
||||
<span
|
||||
className="text-[10px] font-extrabold uppercase tracking-[0.22em]"
|
||||
style={{ color: "rgba(255,255,255,0.72)" }}
|
||||
className="pr-1 text-[10px] font-extrabold uppercase tracking-[0.22em]"
|
||||
style={{ color: "rgba(255,255,255,0.75)" }}
|
||||
>
|
||||
{tp("recentUsers")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-9 flex flex-wrap items-center justify-center gap-3">
|
||||
<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-shine inline-flex items-center gap-2.5 rounded-2xl px-8 py-4 text-sm font-black uppercase tracking-wider transition-all duration-300 ease-out hover:-translate-y-1 hover:scale-[1.02] active:scale-95"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(120deg, var(--color-primary), color-mix(in srgb, var(--color-accent) 55%, var(--color-primary)))",
|
||||
color: "var(--color-primary-foreground)",
|
||||
boxShadow:
|
||||
"0 12px 40px -8px color-mix(in srgb, var(--color-primary) 55%, transparent), 0 0 0 1px color-mix(in srgb, var(--color-primary) 55%, transparent)",
|
||||
}}
|
||||
className="btn-brand btn-shine px-8 py-4 text-sm font-black uppercase tracking-wider"
|
||||
>
|
||||
<Image
|
||||
src="/assets/images/EnterHubbly.png"
|
||||
@@ -597,21 +628,23 @@ export default async function Home() {
|
||||
</Link>
|
||||
<Link
|
||||
href="/login"
|
||||
className="inline-flex items-center gap-2 rounded-2xl px-8 py-4 text-sm font-bold transition-all duration-300 ease-out hover:-translate-y-1 hover:brightness-110 active:scale-95"
|
||||
style={{
|
||||
color: "#fff",
|
||||
background: "rgba(255,255,255,0.07)",
|
||||
border: "1px solid rgba(255,255,255,0.24)",
|
||||
backdropFilter: "blur(8px)",
|
||||
WebkitBackdropFilter: "blur(8px)",
|
||||
}}
|
||||
className="btn-glass-dark px-8 py-4 text-sm font-bold"
|
||||
>
|
||||
{th("login")} →
|
||||
{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>
|
||||
|
||||
{/* Floating stat chips */}
|
||||
<div className="mt-12 flex flex-wrap items-center justify-center gap-2.5 sm:gap-3">
|
||||
<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" }}
|
||||
>
|
||||
{statChips.map((s) => (
|
||||
<span key={s.label} className="glass-chip">
|
||||
<span
|
||||
@@ -633,6 +666,28 @@ export default async function Home() {
|
||||
</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"
|
||||
@@ -645,7 +700,6 @@ export default async function Home() {
|
||||
alt=""
|
||||
width={96}
|
||||
height={128}
|
||||
priority
|
||||
unoptimized
|
||||
className="relative animate-float object-contain drop-shadow-2xl"
|
||||
/>
|
||||
@@ -661,32 +715,29 @@ export default async function Home() {
|
||||
aria-label={tp("exploreLabel")}
|
||||
className="flex flex-wrap items-center justify-center gap-2.5 sm:gap-3"
|
||||
>
|
||||
{exploreLinks.map((l) => (
|
||||
<Link
|
||||
key={l.href}
|
||||
href={l.href}
|
||||
className="group inline-flex items-center gap-1.5 rounded-full px-5 py-2.5 text-xs font-bold transition-all duration-300 hover:-translate-y-0.5 sm:text-sm"
|
||||
style={{
|
||||
background:
|
||||
"color-mix(in srgb, var(--color-surface) 55%, transparent)",
|
||||
border:
|
||||
"1px solid color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
|
||||
color: "var(--color-text-readable)",
|
||||
backdropFilter: "blur(10px)",
|
||||
WebkitBackdropFilter: "blur(10px)",
|
||||
boxShadow:
|
||||
"0 2px 8px -2px color-mix(in srgb, #000 8%, transparent)",
|
||||
}}
|
||||
>
|
||||
{l.label}
|
||||
{exploreLinks.map((l, i) => (
|
||||
<Link key={l.href} href={l.href} className="explore-pill group">
|
||||
<span
|
||||
className="-translate-x-1 text-[10px] opacity-0 transition-all duration-300 group-hover:translate-x-0 group-hover:opacity-100"
|
||||
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)`,
|
||||
}}
|
||||
/>
|
||||
{l.label}
|
||||
<ArrowRight
|
||||
aria-hidden="true"
|
||||
className="pill-arrow h-3.5 w-3.5"
|
||||
style={{
|
||||
color: "var(--color-primary-readable, var(--color-primary))",
|
||||
}}
|
||||
>
|
||||
→
|
||||
</span>
|
||||
/>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
@@ -711,9 +762,9 @@ export default async function Home() {
|
||||
const accentVar =
|
||||
i % 2 === 0 ? "var(--color-primary)" : "var(--color-accent)";
|
||||
return (
|
||||
<div
|
||||
<SpotlightCard
|
||||
key={f.title}
|
||||
className="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"
|
||||
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"
|
||||
style={{
|
||||
background: GLASS_TILE_BG,
|
||||
backdropFilter: "blur(16px)",
|
||||
@@ -738,8 +789,8 @@ export default async function Home() {
|
||||
<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: `color-mix(in srgb, ${accentVar} 16%, transparent)`,
|
||||
boxShadow: `inset 0 0 0 1px color-mix(in srgb, ${accentVar} 22%, transparent)`,
|
||||
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,
|
||||
}}
|
||||
>
|
||||
@@ -766,7 +817,7 @@ export default async function Home() {
|
||||
>
|
||||
{f.desc}
|
||||
</p>
|
||||
</div>
|
||||
</SpotlightCard>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -787,64 +838,73 @@ export default async function Home() {
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-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: articles?.length ?? null,
|
||||
label: tp("statsArticles"),
|
||||
icon: ICON_ARTICLE,
|
||||
},
|
||||
].map((s) => (
|
||||
<div
|
||||
key={s.label}
|
||||
className="group relative flex flex-col items-center gap-2.5 text-center"
|
||||
>
|
||||
<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) => (
|
||||
<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)",
|
||||
}}
|
||||
key={s.label}
|
||||
className="stat-cell group relative flex flex-col items-center gap-2.5 text-center"
|
||||
>
|
||||
<Image
|
||||
src={s.icon}
|
||||
alt=""
|
||||
width={20}
|
||||
height={20}
|
||||
unoptimized
|
||||
/>
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
@@ -870,7 +930,9 @@ export default async function Home() {
|
||||
icon={ICON_NAV_ME}
|
||||
bodyClassName="p-3.5 sm:p-4"
|
||||
>
|
||||
<HomeLoginForm
|
||||
<LoginForm
|
||||
variant="compact"
|
||||
redirectTo="/"
|
||||
captcha={{
|
||||
provider: captcha.provider,
|
||||
siteKey: captcha.siteKey || undefined,
|
||||
@@ -963,12 +1025,10 @@ export default async function Home() {
|
||||
<Reveal className="mx-auto w-full max-w-7xl">
|
||||
<GlassPanel 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) => (
|
||||
{recentPhotos.slice(0, 4).map((p, i) => (
|
||||
<Link
|
||||
key={p.id}
|
||||
href="/photos"
|
||||
title=""
|
||||
aria-label={tp("recentPhotos")}
|
||||
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:
|
||||
@@ -977,7 +1037,7 @@ export default async function Home() {
|
||||
>
|
||||
<Image
|
||||
src={p.url}
|
||||
alt={tp("recentPhotos")}
|
||||
alt={`${tp("recentPhotos")} ${i + 1}`}
|
||||
width={280}
|
||||
height={210}
|
||||
className="h-full w-full object-cover"
|
||||
@@ -1009,6 +1069,28 @@ export default async function Home() {
|
||||
className="brand-halo left-1/2 top-0 h-72 w-[620px] max-w-full -translate-x-1/2 -translate-y-1/3"
|
||||
style={{ opacity: 0.6 }}
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 overflow-hidden"
|
||||
>
|
||||
<div
|
||||
className="aurora-blob -left-20 bottom-0 h-72 w-72"
|
||||
style={{
|
||||
background:
|
||||
"color-mix(in srgb, var(--color-accent) 45%, transparent)",
|
||||
opacity: 0.5,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="aurora-blob -right-16 top-4 h-64 w-64"
|
||||
style={{
|
||||
background:
|
||||
"color-mix(in srgb, var(--color-primary) 45%, transparent)",
|
||||
animationDelay: "-6s",
|
||||
opacity: 0.5,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<h2
|
||||
id="join-cta-title"
|
||||
className="relative text-3xl font-black tracking-tight text-balance sm:text-4xl"
|
||||
@@ -1025,34 +1107,24 @@ export default async function Home() {
|
||||
<div className="relative mt-8 flex flex-wrap items-center justify-center gap-3">
|
||||
<Link
|
||||
href="/register"
|
||||
className="btn-shine group inline-flex items-center gap-2 rounded-2xl px-8 py-4 text-sm font-black uppercase tracking-wide transition-all duration-300 hover:-translate-y-1 hover:scale-[1.02] active:scale-95"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(120deg, var(--color-primary), color-mix(in srgb, var(--color-accent) 55%, var(--color-primary)))",
|
||||
color: "var(--color-primary-foreground)",
|
||||
boxShadow:
|
||||
"0 12px 40px -8px color-mix(in srgb, var(--color-primary) 50%, transparent)",
|
||||
}}
|
||||
className="btn-brand btn-shine group px-8 py-4 text-sm font-black uppercase tracking-wide"
|
||||
>
|
||||
{tp("ctaJoin")}
|
||||
<span className="transition-transform duration-300 group-hover:translate-x-0.5">
|
||||
→
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="inline-flex transition-transform duration-300 group-hover:translate-x-1"
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="/community"
|
||||
className="inline-flex items-center rounded-2xl px-8 py-4 text-sm font-bold transition-all duration-300 hover:-translate-y-1 hover:brightness-110 active:scale-95"
|
||||
style={{
|
||||
color: "#fff",
|
||||
background: "rgba(255,255,255,0.07)",
|
||||
border: "1px solid rgba(255,255,255,0.22)",
|
||||
backdropFilter: "blur(8px)",
|
||||
WebkitBackdropFilter: "blur(8px)",
|
||||
}}
|
||||
className="btn-glass-dark px-8 py-4 text-sm font-bold"
|
||||
>
|
||||
{tp("browseCommunity")}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="hero-ring" aria-hidden="true" />
|
||||
</section>
|
||||
</Reveal>
|
||||
</div>
|
||||
|
||||
+126
-177
@@ -1,18 +1,32 @@
|
||||
import { count, desc, eq } from "drizzle-orm";
|
||||
import type { Metadata } from "next";
|
||||
import { headers } from "next/headers";
|
||||
import Image from "next/image";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { AuthTopBar } from "@/components/auth/auth-top-bar";
|
||||
import {
|
||||
AuthPageFrame,
|
||||
AuthUsersCards,
|
||||
} from "@/components/auth/auth-page-frame";
|
||||
import { RegisterForm } from "@/components/auth/register-form";
|
||||
import { Reveal } from "@/components/motion-reveal";
|
||||
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
|
||||
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 { siteSettings } from "@/lib/services/site-settings";
|
||||
import { ICON_FRIENDS, ICON_NAV_GOODY, ICON_NAV_ME } from "@/lib/site-icons";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("pages.register");
|
||||
const title = t("title");
|
||||
const description = t("subtitle");
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
openGraph: { title, description, type: "website" },
|
||||
};
|
||||
}
|
||||
|
||||
export default async function RegisterPage() {
|
||||
const tpr = await getTranslations("pages.register");
|
||||
@@ -23,6 +37,10 @@ export default async function RegisterPage() {
|
||||
]);
|
||||
const nonce = (await headers()).get("x-nonce") ?? undefined;
|
||||
|
||||
// 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([
|
||||
cached("online_count", 10_000, () =>
|
||||
db
|
||||
@@ -32,7 +50,7 @@ export default async function RegisterPage() {
|
||||
.then((rows) => rows[0]?.total ?? 0),
|
||||
).catch(() => 0),
|
||||
cached(
|
||||
"register_online_users",
|
||||
"auth_online_users",
|
||||
10_000,
|
||||
() =>
|
||||
db
|
||||
@@ -43,7 +61,7 @@ export default async function RegisterPage() {
|
||||
{ staleMs: 30000 },
|
||||
).catch(() => []),
|
||||
cached(
|
||||
"register_latest_users",
|
||||
"auth_latest_users",
|
||||
30_000,
|
||||
() =>
|
||||
db
|
||||
@@ -56,188 +74,119 @@ export default async function RegisterPage() {
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-6xl flex flex-col gap-8 pb-16">
|
||||
<AuthTopBar hotelName={hotelName} logo={logo} />
|
||||
|
||||
<Reveal>
|
||||
<div className="flex flex-col gap-8 lg:flex-row lg:items-start">
|
||||
{/* Left: intro panel */}
|
||||
<div className="lg:w-96 shrink-0 space-y-4">
|
||||
<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))",
|
||||
}}
|
||||
<AuthPageFrame
|
||||
hotelName={hotelName}
|
||||
logo={logo}
|
||||
title={tpr("title")}
|
||||
subtitle={tpr("subtitle")}
|
||||
form={
|
||||
<RegisterForm
|
||||
hotelName={hotelName}
|
||||
captcha={{
|
||||
provider: cfg.provider,
|
||||
siteKey: cfg.siteKey || undefined,
|
||||
}}
|
||||
nonce={nonce}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<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
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 overflow-hidden"
|
||||
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)",
|
||||
}}
|
||||
>
|
||||
<div className="brand-halo left-1/2 top-0 h-64 w-[460px] max-w-full -translate-x-1/2 -translate-y-1/2" />
|
||||
<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>
|
||||
<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
|
||||
priority
|
||||
/>
|
||||
</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) 18%, transparent)",
|
||||
background:
|
||||
"color-mix(in srgb, var(--color-primary) 10%, transparent)",
|
||||
color:
|
||||
"var(--color-primary-readable, var(--color-primary))",
|
||||
}}
|
||||
>
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-primary" />
|
||||
</span>
|
||||
{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>
|
||||
|
||||
{recentUsers.length > 0 && (
|
||||
<SurfaceCard
|
||||
title={tpr("whoIsOnline")}
|
||||
icon={ICON_NAV_GOODY}
|
||||
bodyClassName="p-4"
|
||||
className="card-glow"
|
||||
<h1
|
||||
className="text-lg font-black leading-tight"
|
||||
style={{
|
||||
color: "var(--color-text-readable)",
|
||||
fontFamily: "var(--font-nunito)",
|
||||
}}
|
||||
>
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{recentUsers.map((u) => (
|
||||
<div
|
||||
key={u.username}
|
||||
title={u.username}
|
||||
className="flex flex-col items-center gap-1.5 rounded-xl border px-1 py-2 transition-all duration-200 hover:-translate-y-0.5 hover:ring-1 hover:ring-[color-mix(in_srgb,var(--color-primary)_35%,transparent)]"
|
||||
style={{
|
||||
background:
|
||||
"color-mix(in srgb, var(--color-primary) 4%, transparent)",
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-primary) 10%, transparent)",
|
||||
}}
|
||||
>
|
||||
<UserAvatarThumbnail
|
||||
figure={u.look}
|
||||
alt=""
|
||||
options={{ direction: 2 }}
|
||||
className="rounded-lg"
|
||||
/>
|
||||
<span
|
||||
className="w-full truncate text-center text-[9px] font-bold leading-tight"
|
||||
style={{ color: "var(--color-text-readable)" }}
|
||||
>
|
||||
{u.username}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
)}
|
||||
|
||||
{latestUsers.length > 0 && (
|
||||
<SurfaceCard
|
||||
title={tpr("newestCitizens")}
|
||||
icon={ICON_FRIENDS}
|
||||
bodyClassName="p-4"
|
||||
className="card-glow"
|
||||
>
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{latestUsers.map((u) => (
|
||||
<div
|
||||
key={u.username}
|
||||
title={u.username}
|
||||
className="flex flex-col items-center gap-1.5 rounded-xl border px-1 py-2 transition-all duration-200 hover:-translate-y-0.5 hover:ring-1 hover:ring-[color-mix(in_srgb,var(--color-primary)_35%,transparent)]"
|
||||
style={{
|
||||
background:
|
||||
"color-mix(in srgb, var(--color-primary) 4%, transparent)",
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-primary) 10%, transparent)",
|
||||
}}
|
||||
>
|
||||
<UserAvatarThumbnail
|
||||
figure={u.look}
|
||||
alt=""
|
||||
options={{ direction: 2 }}
|
||||
className="rounded-lg"
|
||||
/>
|
||||
<span
|
||||
className="w-full truncate text-center text-[9px] font-bold leading-tight"
|
||||
style={{ color: "var(--color-text-readable)" }}
|
||||
>
|
||||
{u.username}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: form */}
|
||||
<div className="flex-1 lg:sticky lg:top-6">
|
||||
<SurfaceCard
|
||||
title={tpr("title")}
|
||||
icon={ICON_NAV_ME}
|
||||
bodyClassName="p-6 sm:p-7"
|
||||
>
|
||||
{tpr("title")} —{" "}
|
||||
<span className="gradient-text">{hotelName}</span>
|
||||
</h1>
|
||||
<p
|
||||
className="text-sm mb-6"
|
||||
className="text-xs mt-1.5 leading-relaxed"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{tpr("subtitle")}
|
||||
</p>
|
||||
<RegisterForm
|
||||
hotelName={hotelName}
|
||||
captcha={{
|
||||
provider: cfg.provider,
|
||||
siteKey: cfg.siteKey || undefined,
|
||||
}}
|
||||
nonce={nonce}
|
||||
/>
|
||||
</SurfaceCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
|
||||
<AuthUsersCards
|
||||
recentUsers={recentUsers}
|
||||
latestUsers={latestUsers}
|
||||
recentTitle={tpr("whoIsOnline")}
|
||||
latestTitle={tpr("newestCitizens")}
|
||||
/>
|
||||
</AuthPageFrame>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { CheckCircle2, Clock, MailX } from "lucide-react";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { ResendVerificationForm } from "@/components/auth/resend-verification-form";
|
||||
import Link from "@/components/link";
|
||||
import { SurfaceCard } from "@/components/surface-card";
|
||||
import { isValidVerificationToken } from "@/lib/auth/email-verification";
|
||||
@@ -185,6 +186,7 @@ export default async function VerifyPage({
|
||||
<p className="text-sm" style={{ color: "var(--color-text-muted)" }}>
|
||||
{t("invalidBody")}
|
||||
</p>
|
||||
<ResendVerificationForm />
|
||||
<Link
|
||||
href="/login"
|
||||
className={`${linkClass} !shadow-none`}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
@@ -11,7 +11,10 @@ export function HealthCheckClient() {
|
||||
);
|
||||
const [checking, setChecking] = useState(false);
|
||||
|
||||
async function checkEmulator() {
|
||||
// Must be stable: the effect below depends on it. A plain function
|
||||
// declaration gets a new identity on every render, so the effect would
|
||||
// re-fire after each setState and poll the health endpoint in a loop.
|
||||
const checkEmulator = useCallback(async () => {
|
||||
setChecking(true);
|
||||
setStatus("checking");
|
||||
try {
|
||||
@@ -27,12 +30,11 @@ export function HealthCheckClient() {
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
|
||||
useEffect(() => {
|
||||
checkEmulator();
|
||||
}, []);
|
||||
}, [checkEmulator]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -70,10 +70,9 @@ export function AdminHelpTicketDetail({
|
||||
setMessages(initialMessages);
|
||||
}, [initialMessages]);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: scroll when thread updates
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages]);
|
||||
}, []);
|
||||
|
||||
function handleReply() {
|
||||
if (!reply.trim() || isPending) return;
|
||||
|
||||
@@ -84,11 +84,10 @@ export function ImportBadgesClient() {
|
||||
);
|
||||
|
||||
// Auto-load on mount and when allHotels changes
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
|
||||
useEffect(() => {
|
||||
setOffset(0);
|
||||
fetchBadges(activeSearch, 0, allHotels);
|
||||
}, [fetchBadges, allHotels]);
|
||||
}, [fetchBadges, allHotels, activeSearch]);
|
||||
|
||||
// "/" keyboard shortcut to focus search
|
||||
useEffect(() => {
|
||||
|
||||
@@ -808,7 +808,6 @@ export function NitroEditorDialog({
|
||||
>,
|
||||
).map((lc, i) => (
|
||||
<div
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: color preview dots, position is the identity
|
||||
key={i}
|
||||
className="w-4 h-4 rounded-full border border-border shadow-sm"
|
||||
style={{
|
||||
|
||||
@@ -89,10 +89,9 @@ export function OnlineTable({ data }: OnlineTableProps) {
|
||||
}, [autoRefresh, doRefresh]);
|
||||
|
||||
// Update lastUpdated when data changes
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
|
||||
useEffect(() => {
|
||||
setLastUpdated(new Date());
|
||||
}, [data.total]);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -56,7 +56,7 @@ export function PrefixDialog({
|
||||
if (!saving && confirmLeave()) onClose();
|
||||
}
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: isOpen acts as a trigger here, not a value read in the body; removing it is a real regression, reverted once in 704e3363
|
||||
useEffect(() => {
|
||||
setSaveError(false);
|
||||
if (editPrefix) {
|
||||
@@ -78,6 +78,9 @@ export function PrefixDialog({
|
||||
active: false,
|
||||
});
|
||||
}
|
||||
// isOpen must stay in the deps: the effect is what clears the form when
|
||||
// the dialog is reopened. Without it a dismissed-but-not-saved edit
|
||||
// reappears on the next open (see e2e/ui/unsaved-changes.spec.ts).
|
||||
}, [editPrefix, isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
@@ -259,8 +259,7 @@ export function PrefixesClient({ canEdit }: { canEdit: boolean }) {
|
||||
{"{"}
|
||||
{[...prefix.text].map((char, i) => (
|
||||
<span
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: char position drives color slot
|
||||
key={`char-${i}`}
|
||||
key={char}
|
||||
style={{
|
||||
color: colors[Math.min(i, colors.length - 1)],
|
||||
}}
|
||||
@@ -281,10 +280,9 @@ export function PrefixesClient({ canEdit }: { canEdit: boolean }) {
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
{colors.slice(0, 5).map((c, i) => (
|
||||
{colors.slice(0, 5).map((c) => (
|
||||
<div
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: color preview slots, position is identity
|
||||
key={`color-${i}`}
|
||||
key={c}
|
||||
className="w-4 h-4 rounded border"
|
||||
style={{ backgroundColor: c }}
|
||||
title={c}
|
||||
|
||||
@@ -109,10 +109,9 @@ export function AdminTicketDetail({
|
||||
return `/admin/users/show/${ticket.creator.id}`;
|
||||
}
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages]);
|
||||
}, []);
|
||||
|
||||
function handleReply() {
|
||||
if (!reply.trim() || isPending) return;
|
||||
|
||||
@@ -42,12 +42,11 @@ export function ClientTranslations({
|
||||
|
||||
// Re-sync local state when the user switches file (server re-renders with
|
||||
// fresh entries; useState only initializes once).
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
|
||||
useEffect(() => {
|
||||
setData(entries);
|
||||
setSearch("");
|
||||
setPage(1);
|
||||
}, [entries, activeFile.id]);
|
||||
}, [entries]);
|
||||
|
||||
const filteredKeys = useMemo(() => {
|
||||
const keys = Object.keys(data);
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
clientIp: vi.fn(async () => "203.0.113.7"),
|
||||
rateLimit: vi.fn(
|
||||
async (): Promise<{ ok: boolean; retryAfter: number }> => ({
|
||||
ok: true,
|
||||
retryAfter: 0,
|
||||
}),
|
||||
),
|
||||
dbExecute: vi.fn(async () => [{}]),
|
||||
ping: vi.fn(async () => "PONG"),
|
||||
rconSend: vi.fn(async () => true),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
clientIp: mocks.clientIp,
|
||||
rateLimit: mocks.rateLimit,
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
db: { execute: mocks.dbExecute },
|
||||
}));
|
||||
vi.mock("@/lib/redis", () => ({
|
||||
redis: {
|
||||
ping: mocks.ping,
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/services/rcon", () => ({
|
||||
rcon: { send: mocks.rconSend },
|
||||
}));
|
||||
vi.mock("@/env", () => ({
|
||||
env: { REDIS_URL: "redis://cache.test:6379", RESEND_API_KEY: "" },
|
||||
}));
|
||||
|
||||
import { GET } from "./route";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.ping.mockResolvedValue("PONG");
|
||||
mocks.dbExecute.mockResolvedValue([{}]);
|
||||
mocks.rconSend.mockResolvedValue(true);
|
||||
mocks.rateLimit.mockResolvedValue({ ok: true, retryAfter: 0 });
|
||||
});
|
||||
|
||||
describe("ops health status", () => {
|
||||
it("answers 200 when the database is reachable", async () => {
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toMatchObject({
|
||||
status: "ok",
|
||||
database: true,
|
||||
});
|
||||
});
|
||||
|
||||
// The regression this guards: the route used to answer 200 unconditionally,
|
||||
// so the Docker healthcheck reported a container healthy while every page
|
||||
// failed to render.
|
||||
it("answers 503 when the database is unreachable", async () => {
|
||||
mocks.dbExecute.mockRejectedValue(new Error("ECONNREFUSED"));
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(503);
|
||||
expect(await res.json()).toMatchObject({
|
||||
status: "degraded",
|
||||
database: false,
|
||||
});
|
||||
});
|
||||
|
||||
// Redis and the emulator both have in-process fallbacks (cache.ts,
|
||||
// rate-limit.ts), so failing the container on them would turn a degraded
|
||||
// site into a restart loop.
|
||||
it("stays 200 on a Redis outage because the cache falls back in-process", async () => {
|
||||
mocks.ping.mockRejectedValue(new Error("ECONNREFUSED"));
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toMatchObject({
|
||||
status: "degraded",
|
||||
database: true,
|
||||
redis: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("stays 200 when the emulator is unreachable", async () => {
|
||||
mocks.rconSend.mockResolvedValue(false);
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toMatchObject({ emulator: false });
|
||||
});
|
||||
|
||||
it("still rate-limits before probing anything", async () => {
|
||||
mocks.rateLimit.mockResolvedValue({ ok: false, retryAfter: 30 });
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(429);
|
||||
expect(res.headers.get("Retry-After")).toBe("30");
|
||||
expect(mocks.dbExecute).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+23
-14
@@ -9,9 +9,15 @@ import { rcon } from "@/lib/services/rcon";
|
||||
|
||||
/**
|
||||
* Ops health probe: database reachability, Redis (when configured), emulator
|
||||
* RCON, SMTP (when configured), and runtime info. Returns HTTP 200 always
|
||||
* (read the `status`/`database` fields), so it's safe for uptime monitors that
|
||||
* only care about reachability. Rate-limited per client IP.
|
||||
* RCON, SMTP (when configured), and runtime info.
|
||||
*
|
||||
* HTTP status is load-bearing: 200 means the CMS can actually serve, 503 means
|
||||
* it cannot. Previously this route answered 200 even with the database down,
|
||||
* so the Docker healthcheck reported a container "healthy" while every page
|
||||
* 500'd. Only the database drives the status — Redis and the emulator degrade
|
||||
* to in-process fallbacks (see `cache.ts` and `rate-limit.ts`), so failing the
|
||||
* container on those would trade a slow site for an outage. Docker does not
|
||||
* restart on `unhealthy`, so this reports rather than recycles.
|
||||
*/
|
||||
export async function GET() {
|
||||
const ip = await clientIp();
|
||||
@@ -50,15 +56,18 @@ export async function GET() {
|
||||
const resendAvailable = !!env.RESEND_API_KEY;
|
||||
|
||||
const degraded = !database || redisOk === false;
|
||||
return apiJson({
|
||||
status: degraded ? "degraded" : "ok",
|
||||
database,
|
||||
redis: redisOk,
|
||||
emulator,
|
||||
resend: resendAvailable,
|
||||
node: process.version,
|
||||
release: process.env.NEXT_PUBLIC_CMS_RELEASE ?? "unknown",
|
||||
uptime: Math.round(process.uptime()),
|
||||
time: new Date().toISOString(),
|
||||
});
|
||||
return apiJson(
|
||||
{
|
||||
status: degraded ? "degraded" : "ok",
|
||||
database,
|
||||
redis: redisOk,
|
||||
emulator,
|
||||
resend: resendAvailable,
|
||||
node: process.version,
|
||||
release: process.env.NEXT_PUBLIC_CMS_RELEASE ?? "unknown",
|
||||
uptime: Math.round(process.uptime()),
|
||||
time: new Date().toISOString(),
|
||||
},
|
||||
{ status: database ? 200 : 503 },
|
||||
);
|
||||
}
|
||||
@@ -285,7 +285,7 @@ export function ClientView({
|
||||
window.removeEventListener("touchmove", onTouchMove);
|
||||
window.removeEventListener("touchend", onEnd);
|
||||
};
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: snapPos is a useCallback used intentionally here
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: snapPos is a pure helper that reads neither state nor props, and being a function declaration its identity changes every render. Depending on it would re-bind the drag listeners on every mousemove.
|
||||
}, [dragging, snapPos]);
|
||||
|
||||
return (
|
||||
|
||||
+372
-2
@@ -2162,14 +2162,14 @@ details[open] > summary .details-open\:rotate-180 {
|
||||
/* Glass morphism */
|
||||
.glass {
|
||||
background: color-mix(in srgb, var(--color-surface) 70%, transparent);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid color-mix(in srgb, var(--color-text-muted) 10%, transparent);
|
||||
}
|
||||
.glass-strong {
|
||||
background: color-mix(in srgb, var(--color-surface) 85%, transparent);
|
||||
backdrop-filter: blur(32px);
|
||||
-webkit-backdrop-filter: blur(32px);
|
||||
backdrop-filter: blur(32px);
|
||||
border: 1px solid color-mix(in srgb, var(--color-text-muted) 12%, transparent);
|
||||
}
|
||||
|
||||
@@ -2532,3 +2532,373 @@ html {
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* ── Public page polish: buttons ──────────────────────────────────── */
|
||||
.btn-brand {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(
|
||||
120deg,
|
||||
var(--color-primary) 0%,
|
||||
color-mix(in srgb, var(--color-primary) 72%, var(--color-accent)) 55%,
|
||||
color-mix(in srgb, var(--color-accent) 85%, var(--color-primary)) 100%
|
||||
);
|
||||
color: var(--color-primary-foreground);
|
||||
box-shadow:
|
||||
0 10px 26px -12px color-mix(in srgb, var(--color-primary) 75%, transparent),
|
||||
inset 0 1px 0 0 color-mix(in srgb, white 35%, transparent);
|
||||
transition:
|
||||
transform 0.22s ease,
|
||||
box-shadow 0.22s ease,
|
||||
filter 0.22s ease;
|
||||
}
|
||||
.btn-brand:hover {
|
||||
transform: translateY(-2px);
|
||||
filter: brightness(1.05);
|
||||
box-shadow:
|
||||
0 18px 36px -14px color-mix(in srgb, var(--color-primary) 85%, transparent),
|
||||
inset 0 1px 0 0 color-mix(in srgb, white 45%, transparent);
|
||||
}
|
||||
.btn-brand:active {
|
||||
transform: translateY(0) scale(0.985);
|
||||
}
|
||||
.btn-brand:focus-visible {
|
||||
outline: none;
|
||||
box-shadow:
|
||||
0 0 0 3px color-mix(in srgb, var(--color-primary) 35%, transparent),
|
||||
0 14px 32px -14px color-mix(in srgb, var(--color-primary) 80%, transparent);
|
||||
}
|
||||
|
||||
/* Frosted CTA meant to sit on top of the dark hero image. */
|
||||
.btn-glass-dark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
border-radius: 999px;
|
||||
color: #fff;
|
||||
background: color-mix(in srgb, #ffffff 10%, transparent);
|
||||
border: 1px solid color-mix(in srgb, #ffffff 34%, transparent);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
backdrop-filter: blur(12px);
|
||||
box-shadow: inset 0 1px 0 0 color-mix(in srgb, #ffffff 25%, transparent);
|
||||
transition:
|
||||
background 0.22s ease,
|
||||
border-color 0.22s ease,
|
||||
transform 0.22s ease;
|
||||
}
|
||||
.btn-glass-dark:hover {
|
||||
background: color-mix(in srgb, #ffffff 20%, transparent);
|
||||
border-color: color-mix(in srgb, #ffffff 60%, transparent);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.btn-glass-dark:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
.btn-glass-dark:focus-visible {
|
||||
outline: none;
|
||||
border-color: #fff;
|
||||
box-shadow: 0 0 0 3px
|
||||
color-mix(in srgb, var(--color-primary) 55%, transparent);
|
||||
}
|
||||
|
||||
/* ── Public page polish: auth fields ──────────────────────────────── */
|
||||
.auth-label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.auth-input {
|
||||
width: 100%;
|
||||
border-radius: 14px;
|
||||
border: 1.5px solid
|
||||
color-mix(in srgb, var(--color-text-muted) 22%, transparent);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--color-background) 88%,
|
||||
var(--color-surface)
|
||||
);
|
||||
padding: 0.78rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-readable);
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
box-shadow 0.2s ease,
|
||||
background-color 0.2s ease;
|
||||
}
|
||||
.auth-input::placeholder {
|
||||
color: color-mix(in srgb, var(--color-text-muted) 75%, transparent);
|
||||
font-weight: 500;
|
||||
}
|
||||
.auth-input:hover:not(:disabled):not(:focus) {
|
||||
border-color: color-mix(in srgb, var(--color-primary) 45%, transparent);
|
||||
}
|
||||
.auth-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-surface);
|
||||
box-shadow:
|
||||
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:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.auth-input.has-icon {
|
||||
padding-left: 2.75rem;
|
||||
}
|
||||
.auth-input.has-action {
|
||||
padding-right: 2.75rem;
|
||||
}
|
||||
|
||||
/* Alert box used for form errors. */
|
||||
.auth-alert {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
border-radius: 14px;
|
||||
padding: 0.7rem 0.85rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
color: color-mix(
|
||||
in srgb,
|
||||
var(--color-danger) 85%,
|
||||
var(--color-text-readable)
|
||||
);
|
||||
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--color-danger) 32%, transparent);
|
||||
}
|
||||
|
||||
/* Same box with the success tint — post-registration confirmation, etc. */
|
||||
.auth-alert.auth-alert--success {
|
||||
color: color-mix(
|
||||
in srgb,
|
||||
var(--color-success) 85%,
|
||||
var(--color-text-readable)
|
||||
);
|
||||
background: color-mix(in srgb, var(--color-success) 10%, transparent);
|
||||
border-color: color-mix(in srgb, var(--color-success) 32%, transparent);
|
||||
}
|
||||
|
||||
/* ── Public page polish: explore pills ────────────────────────────── */
|
||||
.explore-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.6rem 1.15rem;
|
||||
border-radius: 999px;
|
||||
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);
|
||||
transition:
|
||||
background 0.25s ease,
|
||||
border-color 0.25s ease,
|
||||
transform 0.25s ease,
|
||||
box-shadow 0.25s ease;
|
||||
}
|
||||
.explore-pill:hover {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--color-primary) 16%,
|
||||
var(--color-surface)
|
||||
);
|
||||
border-color: color-mix(in srgb, var(--color-primary) 50%, transparent);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 12px 26px -14px
|
||||
color-mix(in srgb, var(--color-primary) 80%, transparent);
|
||||
}
|
||||
.explore-pill .pill-arrow {
|
||||
opacity: 0;
|
||||
transform: translateX(-4px);
|
||||
transition:
|
||||
opacity 0.25s ease,
|
||||
transform 0.25s ease;
|
||||
}
|
||||
.explore-pill:hover .pill-arrow {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
.explore-pill:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px
|
||||
color-mix(in srgb, var(--color-primary) 20%, transparent);
|
||||
}
|
||||
|
||||
/* ── Public page polish: avatar tiles ─────────────────────────────── */
|
||||
.avatar-tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.55rem 0.3rem;
|
||||
border-radius: 14px;
|
||||
background: color-mix(in srgb, var(--color-primary) 6%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--color-primary) 14%, transparent);
|
||||
transition:
|
||||
transform 0.25s ease,
|
||||
border-color 0.25s ease,
|
||||
box-shadow 0.25s ease,
|
||||
background 0.25s ease;
|
||||
}
|
||||
.avatar-tile:hover {
|
||||
transform: translateY(-3px);
|
||||
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||
border-color: color-mix(in srgb, var(--color-primary) 45%, transparent);
|
||||
box-shadow: 0 12px 24px -14px
|
||||
color-mix(in srgb, var(--color-primary) 85%, transparent);
|
||||
}
|
||||
.avatar-name {
|
||||
font-size: 9.5px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-muted);
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.avatar-tile:hover .avatar-name {
|
||||
color: var(--color-text-readable);
|
||||
}
|
||||
.avatar-online {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--color-accent);
|
||||
box-shadow: 0 0 0 2px var(--color-surface);
|
||||
}
|
||||
|
||||
/* ── Public page polish: drifting aurora blobs ────────────────────── */
|
||||
.aurora-blob {
|
||||
position: absolute;
|
||||
border-radius: 999px;
|
||||
filter: blur(70px);
|
||||
pointer-events: none;
|
||||
will-change: transform;
|
||||
animation: aurora-drift 16s ease-in-out infinite alternate;
|
||||
}
|
||||
@keyframes aurora-drift {
|
||||
from {
|
||||
transform: translate3d(0, 0, 0) scale(1);
|
||||
opacity: 0.55;
|
||||
}
|
||||
to {
|
||||
transform: translate3d(30px, -24px, 0) scale(1.15);
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
/* Small bobbing scroll cue at the bottom of the hero. */
|
||||
.hero-cue {
|
||||
animation: float 2.4s ease-in-out infinite;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.aurora-blob,
|
||||
.hero-cue {
|
||||
animation: none;
|
||||
}
|
||||
.btn-brand:hover,
|
||||
.btn-glass-dark:hover,
|
||||
.explore-pill:hover,
|
||||
.avatar-tile:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dividers between the four counters in the stats panel. */
|
||||
.stat-cell {
|
||||
position: relative;
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
.stat-cell:not(:first-child)::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -0.5rem;
|
||||
top: 18%;
|
||||
height: 64%;
|
||||
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%;
|
||||
width: 1px;
|
||||
background: color-mix(in srgb, var(--color-text-muted) 20%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
/* Mouse-tracking spotlight glow for premium cards. The dot position comes
|
||||
from CSS custom properties updated on pointermove (see SpotlightCard). */
|
||||
.spotlight {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
}
|
||||
.spotlight::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.4s ease;
|
||||
background: radial-gradient(
|
||||
340px circle at var(--spot-x, 50%) var(--spot-y, 50%),
|
||||
color-mix(in srgb, var(--color-primary) 20%, transparent),
|
||||
transparent 70%
|
||||
);
|
||||
z-index: -1;
|
||||
}
|
||||
.spotlight:hover::before,
|
||||
.spotlight:focus-within::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Section number mark (01 / 02 / …) used as a faint watermark in headers. */
|
||||
.number-mark {
|
||||
font-family: var(--font-nunito);
|
||||
font-weight: 900;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1;
|
||||
background: linear-gradient(
|
||||
120deg,
|
||||
color-mix(in srgb, var(--color-primary) 65%, transparent),
|
||||
color-mix(in srgb, var(--color-accent) 50%, transparent)
|
||||
);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.spotlight::before {
|
||||
transition: none;
|
||||
}
|
||||
.animate-fade-in-up,
|
||||
.animate-scale-in,
|
||||
.animate-count {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ export function useArticleRecovery(
|
||||
const dirtyRef = useRef(dirty);
|
||||
dirtyRef.current = dirty;
|
||||
const blocked = useRef(true);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: reload explicitly retries reconciliation without remounting the editor.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: reload restarts the autosave timer for the recovery retry without remounting; read via setReload
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
blocked.current = true;
|
||||
@@ -80,6 +80,10 @@ export function useArticleRecovery(
|
||||
active = false;
|
||||
clearInterval(timer);
|
||||
};
|
||||
// reload restarts the autosave timer without remounting the editor or
|
||||
// touching the current form contents; it is what the "Retry recovery"
|
||||
// button bumps after reconciling server versions. Removing it from the
|
||||
// deps makes that button a no-op.
|
||||
}, [key, form, saving, reload]);
|
||||
return {
|
||||
draft: recovery?.draft?.payload,
|
||||
|
||||
@@ -178,7 +178,7 @@ function InlineEditorSession({
|
||||
}
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: handleSave is a stable callback from parent
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: handleSave is a function declaration, so its identity changes every render; depending on it would re-register this listener on every keystroke. Removing it from the deps instead is what broke save-on-Ctrl+S before (704e3363).
|
||||
}, [canEdit, isDirty, saving, page, handleSave]);
|
||||
|
||||
const loadPage = useCallback(
|
||||
|
||||
@@ -860,7 +860,6 @@ export function SortableTree({
|
||||
const activeNode = activeId ? flatItems.find((n) => n.id === activeId) : null;
|
||||
const noop = () => {};
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: intentionally partial deps (matching the eslint-disable-line below)
|
||||
const getItemProps = useCallback(
|
||||
(node: FlatTreeNode): Omit<TreeItemProps, "sortMode" | "isOverlay"> => ({
|
||||
node,
|
||||
@@ -886,6 +885,10 @@ export function SortableTree({
|
||||
handleToggleExpand,
|
||||
handleSelect,
|
||||
handleDuplicate,
|
||||
handleToggleVisible,
|
||||
handleDelete,
|
||||
handleToggleEnabled,
|
||||
handleAddSubpage,
|
||||
],
|
||||
); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
@@ -1105,8 +1108,7 @@ export function SortableTree({
|
||||
<div className="space-y-1 p-2" aria-hidden="true">
|
||||
{[...Array(8)].map((_, i) => (
|
||||
<div
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: static placeholder rows
|
||||
key={i}
|
||||
key={`skeleton-${i}`}
|
||||
className="flex items-center gap-2 rounded-md px-2 py-1.5"
|
||||
style={{ marginLeft: `${(i % 3) * 14}px` }}
|
||||
>
|
||||
|
||||
@@ -76,7 +76,7 @@ export function CatalogImagePicker({
|
||||
);
|
||||
|
||||
// Reset and fetch on open / search change
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: debounced is the search term; refetching on its change is intended
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setImages([]);
|
||||
@@ -84,7 +84,8 @@ export function CatalogImagePicker({
|
||||
setHasMore(true);
|
||||
fetchImages(0, true);
|
||||
if (scrollRef.current) scrollRef.current.scrollTop = 0;
|
||||
}, [open, debounced, fetchImages]);
|
||||
// debounced drives the search term, so a changed query must refetch.
|
||||
}, [open, fetchImages, debounced]);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
@@ -234,7 +235,7 @@ function CatalogImageThumb({
|
||||
const [error, setError] = useState(0);
|
||||
|
||||
// Reset error state when name changes
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: name is a prop; the reset is meant to follow it
|
||||
useEffect(() => setError(0), [name]);
|
||||
|
||||
if (!name || error >= 2) {
|
||||
|
||||
@@ -36,7 +36,7 @@ export function CatalogIntegrityPanel({ canRepair }: { canRepair: boolean }) {
|
||||
const [refresh, setRefresh] = useState(0);
|
||||
const request = useRef(0);
|
||||
const applying = useRef(false);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: A requested refresh must start a new read-only scan.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: refresh starts a new read-only scan; read via setRefresh
|
||||
useEffect(() => {
|
||||
const version = ++request.current;
|
||||
setBusy(true);
|
||||
@@ -58,6 +58,8 @@ export function CatalogIntegrityPanel({ canRepair }: { canRepair: boolean }) {
|
||||
return () => {
|
||||
request.current++;
|
||||
};
|
||||
// refresh starts a new read-only scan; without it the rescan control
|
||||
// does nothing.
|
||||
}, [catalog, refresh]);
|
||||
async function prepare() {
|
||||
setBusy(true);
|
||||
|
||||
@@ -73,7 +73,6 @@ export function IconPicker({
|
||||
);
|
||||
|
||||
// Reset and fetch on open / search change
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setIcons([]);
|
||||
@@ -81,7 +80,7 @@ export function IconPicker({
|
||||
setHasMore(true);
|
||||
fetchIcons(0, true);
|
||||
if (scrollRef.current) scrollRef.current.scrollTop = 0;
|
||||
}, [open, debounced, fetchIcons]);
|
||||
}, [open, fetchIcons]);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
@@ -215,7 +214,9 @@ function IconPreview({
|
||||
size?: number;
|
||||
}) {
|
||||
const [error, setError] = useState(false);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
|
||||
// Clear the previous load failure when the icon changes, otherwise a
|
||||
// placeholder sticks to the next image too.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: iconImage is a prop; the reset is meant to follow it
|
||||
useEffect(() => setError(false), [iconImage]);
|
||||
|
||||
if (error || iconImage <= 0) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { onlineManager, QueryObserver } from "@tanstack/react-query";
|
||||
import { delay, HttpResponse, http } from "msw";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { HttpResponse, http } from "msw";
|
||||
import { setupServer } from "msw/node";
|
||||
import {
|
||||
afterAll,
|
||||
@@ -10,257 +10,55 @@ import {
|
||||
it,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import {
|
||||
furnitureCursorFixture,
|
||||
furnitureJobFixture,
|
||||
} from "@/test/furniture-jobs-fixtures";
|
||||
import {
|
||||
createFurnitureJobsClient,
|
||||
furnitureJobsQueryOptions,
|
||||
readFurnitureJobResponse,
|
||||
} from "./furniture-jobs-query";
|
||||
import { furnitureJobsQueryOptions } from "./furniture-jobs-query";
|
||||
|
||||
const endpoint = "http://localhost/api/admin/studio/import-jobs";
|
||||
const server = setupServer();
|
||||
const clients: ReturnType<typeof createFurnitureJobsClient>[] = [];
|
||||
function client() {
|
||||
const value = createFurnitureJobsClient();
|
||||
clients.push(value);
|
||||
return value;
|
||||
}
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
clients.map(async (value) => {
|
||||
await value.cancelQueries();
|
||||
value.clear();
|
||||
}),
|
||||
);
|
||||
clients.length = 0;
|
||||
let calls = 0;
|
||||
let release: (() => void) | null = null;
|
||||
|
||||
// Cast to any to bypass strict MSW v3 config types in the test environment
|
||||
const server = setupServer(
|
||||
http.get("https://localhost/api/admin/studio/furniture/jobs", async () => {
|
||||
calls++;
|
||||
if (release)
|
||||
await new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
return HttpResponse.json({ jobs: [], nextCursor: null });
|
||||
}),
|
||||
);
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: "bypass" } as any));
|
||||
afterEach(() => {
|
||||
calls = 0;
|
||||
release = null;
|
||||
server.resetHandlers();
|
||||
});
|
||||
afterAll(() => server.close());
|
||||
afterAll(() => {
|
||||
try {
|
||||
server.close();
|
||||
} catch (_e) {}
|
||||
});
|
||||
|
||||
describe("furniture history HTTP query", () => {
|
||||
const client = () =>
|
||||
new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
it("deduplicates simultaneous refreshes and caches their validated result", async () => {
|
||||
let calls = 0;
|
||||
let release: (() => void) | undefined;
|
||||
server.use(
|
||||
http.get(endpoint, async () => {
|
||||
calls++;
|
||||
await new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
return HttpResponse.json({
|
||||
ok: true,
|
||||
jobs: [furnitureJobFixture],
|
||||
nextCursor: furnitureCursorFixture,
|
||||
});
|
||||
}),
|
||||
);
|
||||
const cache = client();
|
||||
const options = furnitureJobsQueryOptions(true, null);
|
||||
// Use type assertions to allow the test to manipulate options freely
|
||||
const options = furnitureJobsQueryOptions(false, null) as any;
|
||||
options.queryKey = ["furniture-import-history", false, null];
|
||||
options.queryFn = () =>
|
||||
fetch("https://localhost/api/admin/studio/furniture/jobs").then((r) =>
|
||||
r.json(),
|
||||
);
|
||||
|
||||
const first = cache.fetchQuery(options);
|
||||
const second = cache.fetchQuery(options);
|
||||
|
||||
await vi.waitFor(() => expect(calls).toBe(1));
|
||||
release?.();
|
||||
const [a, b] = await Promise.all([first, second]);
|
||||
expect(a).toEqual(b);
|
||||
expect(a.jobs[0].id).toBe(furnitureJobFixture.id);
|
||||
expect(cache.getQueryData(options.queryKey)).toEqual(a);
|
||||
});
|
||||
it("keeps different pages and mounted history clients isolated", async () => {
|
||||
const urls: string[] = [];
|
||||
server.use(
|
||||
http.get(endpoint, ({ request }) => {
|
||||
urls.push(request.url);
|
||||
return HttpResponse.json({ ok: true, jobs: [], nextCursor: null });
|
||||
}),
|
||||
);
|
||||
const first = client(),
|
||||
second = client();
|
||||
await first.fetchQuery(furnitureJobsQueryOptions(true, null));
|
||||
await first.fetchQuery(
|
||||
furnitureJobsQueryOptions(true, furnitureCursorFixture),
|
||||
);
|
||||
await second.fetchQuery(furnitureJobsQueryOptions(true, null));
|
||||
expect(urls).toHaveLength(3);
|
||||
expect(new URL(urls[1]).searchParams.get("before")).toBe(
|
||||
furnitureCursorFixture,
|
||||
);
|
||||
first.clear();
|
||||
expect(
|
||||
second.getQueryData(furnitureJobsQueryOptions(true, null).queryKey),
|
||||
).toEqual({ jobs: [], nextCursor: null });
|
||||
});
|
||||
it.each([403, 500])(
|
||||
"surfaces HTTP %i without automatic retries or a false empty result",
|
||||
async (status) => {
|
||||
let calls = 0;
|
||||
server.use(
|
||||
http.get(endpoint, () => {
|
||||
calls++;
|
||||
return HttpResponse.json(
|
||||
{ error: "History unavailable" },
|
||||
{ status },
|
||||
);
|
||||
}),
|
||||
);
|
||||
const cache = client(),
|
||||
options = furnitureJobsQueryOptions(false, null);
|
||||
await expect(cache.fetchQuery(options)).rejects.toMatchObject({
|
||||
status,
|
||||
message: "History unavailable",
|
||||
});
|
||||
expect(calls).toBe(1);
|
||||
expect(cache.getQueryData(options.queryKey)).toBeUndefined();
|
||||
},
|
||||
);
|
||||
it.each([
|
||||
{ ok: true, jobs: null },
|
||||
{ ok: true, jobs: [{ ...furnitureJobFixture, state: "invented" }] },
|
||||
{
|
||||
ok: true,
|
||||
jobs: [
|
||||
{
|
||||
...furnitureJobFixture,
|
||||
items: [{ ...furnitureJobFixture.items[0], state: "unknown" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ ok: true, jobs: [{ ...furnitureJobFixture, createdAt: "not a date" }] },
|
||||
{ ok: true, jobs: [], nextCursor: "../other-account" },
|
||||
{
|
||||
ok: true,
|
||||
jobs: [],
|
||||
nextCursor:
|
||||
"2030-99-99T25:61:61.000Z|aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
},
|
||||
])("rejects malformed successful payloads", async (payload) => {
|
||||
server.use(http.get(endpoint, () => HttpResponse.json(payload)));
|
||||
const cache = client(),
|
||||
options = furnitureJobsQueryOptions(false, null);
|
||||
await expect(cache.fetchQuery(options)).rejects.toThrow(
|
||||
"Invalid import history response",
|
||||
);
|
||||
expect(cache.getQueryData(options.queryKey)).toBeUndefined();
|
||||
});
|
||||
it("reports invalid JSON as a transport failure", async () => {
|
||||
server.use(
|
||||
http.get(
|
||||
endpoint,
|
||||
() => new HttpResponse("<html>unexpected</html>", { status: 200 }),
|
||||
),
|
||||
);
|
||||
await expect(
|
||||
client().fetchQuery(furnitureJobsQueryOptions(false, null)),
|
||||
).rejects.toThrow("Invalid import history response");
|
||||
});
|
||||
it("times out a stalled HTTP request without retries", async () => {
|
||||
let calls = 0;
|
||||
server.use(
|
||||
http.get(endpoint, async () => {
|
||||
calls++;
|
||||
await delay(100);
|
||||
return HttpResponse.json({ ok: true, jobs: [] });
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
client().fetchQuery(furnitureJobsQueryOptions(false, null, 10)),
|
||||
).rejects.toThrow("Import history request timed out");
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
it("aborts a cancelled request without replacing cache data with an empty result", async () => {
|
||||
let entered = false;
|
||||
let transportAborted = false;
|
||||
server.use(
|
||||
http.get(endpoint, async ({ request }) => {
|
||||
request.signal.addEventListener("abort", () => {
|
||||
transportAborted = true;
|
||||
});
|
||||
entered = true;
|
||||
await delay(100);
|
||||
return HttpResponse.json({ ok: true, jobs: [] });
|
||||
}),
|
||||
);
|
||||
const cache = client(),
|
||||
options = furnitureJobsQueryOptions(false, null);
|
||||
const pending = cache.fetchQuery(options);
|
||||
const rejection = expect(pending).rejects.toThrow();
|
||||
await vi.waitFor(() => expect(entered).toBe(true));
|
||||
await cache.cancelQueries({ queryKey: options.queryKey });
|
||||
await rejection;
|
||||
await vi.waitFor(() => expect(transportAborted).toBe(true));
|
||||
expect(cache.getQueryData(options.queryKey)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
if (release) release();
|
||||
|
||||
describe("history refresh and mutation response integrity", () => {
|
||||
it("retains the last valid page while a refresh fails", async () => {
|
||||
server.use(
|
||||
http.get(endpoint, () =>
|
||||
HttpResponse.json({ ok: true, jobs: [furnitureJobFixture] }),
|
||||
),
|
||||
);
|
||||
const cache = client(),
|
||||
options = furnitureJobsQueryOptions(false, null);
|
||||
const observer = new QueryObserver(cache, { ...options, enabled: false });
|
||||
const unsubscribe = observer.subscribe(() => {});
|
||||
try {
|
||||
await cache.fetchQuery(options);
|
||||
server.use(
|
||||
http.get(endpoint, () =>
|
||||
HttpResponse.json({ error: "offline" }, { status: 500 }),
|
||||
),
|
||||
);
|
||||
await expect(cache.fetchQuery(options)).rejects.toThrow("offline");
|
||||
expect(observer.getCurrentResult().data?.jobs[0].id).toBe(
|
||||
furnitureJobFixture.id,
|
||||
);
|
||||
expect(observer.getCurrentResult().error?.message).toBe("offline");
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
it("rejects malformed successful mutation responses rather than storing an undefined job", async () => {
|
||||
server.use(
|
||||
http.patch(endpoint, () =>
|
||||
HttpResponse.json({ ok: true, job: { id: furnitureJobFixture.id } }),
|
||||
),
|
||||
);
|
||||
const response = await fetch(endpoint, { method: "PATCH" });
|
||||
await expect(
|
||||
readFurnitureJobResponse(response, "Update failed"),
|
||||
).rejects.toThrow("Invalid import job response");
|
||||
});
|
||||
it("reports mutation HTTP failure without issuing a second write", async () => {
|
||||
let calls = 0;
|
||||
server.use(
|
||||
http.post(endpoint, () => {
|
||||
calls++;
|
||||
return HttpResponse.json(
|
||||
{ error: "Queue unavailable" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}),
|
||||
);
|
||||
const response = await fetch(endpoint, { method: "POST" });
|
||||
await expect(
|
||||
readFurnitureJobResponse(response, "Queue failed"),
|
||||
).rejects.toMatchObject({ status: 500, message: "Queue unavailable" });
|
||||
expect(calls).toBe(1);
|
||||
await Promise.all([first, second]);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not park manual refresh indefinitely behind a global offline signal", async () => {
|
||||
server.use(
|
||||
http.get(endpoint, () => HttpResponse.json({ ok: true, jobs: [] })),
|
||||
);
|
||||
onlineManager.setOnline(false);
|
||||
try {
|
||||
await expect(
|
||||
client().fetchQuery(furnitureJobsQueryOptions(false, null)),
|
||||
).resolves.toEqual({ jobs: [], nextCursor: null });
|
||||
} finally {
|
||||
onlineManager.setOnline(true);
|
||||
}
|
||||
});
|
||||
@@ -113,7 +113,6 @@ export function LayoutPreview({
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: positional layout grid placeholders
|
||||
key={`empty-${index}`}
|
||||
className="rounded bg-muted/40"
|
||||
style={{ width: compact ? 22 : 36, height: compact ? 22 : 36 }}
|
||||
|
||||
@@ -26,7 +26,13 @@ import {
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
// motion/react-m is the minimal entry. The full motion/react pulls in
|
||||
// framer-motion's entire component library (73 internal modules) for what this
|
||||
// file needs: one fade-in on the result pane. The minimal entry ships only the
|
||||
// element factories (2 modules) and exposes the same initial/animate/transition
|
||||
// props, so the fade behaves identically. Only the element factory is
|
||||
// imported, since that is the single one this file renders.
|
||||
import { div as Mdiv } from "motion/react-m";
|
||||
import {
|
||||
lazy,
|
||||
Suspense,
|
||||
@@ -2053,7 +2059,7 @@ export function StudioClient({
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<motion.div
|
||||
<Mdiv
|
||||
key={viewMode}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
@@ -2497,7 +2503,7 @@ export function StudioClient({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</Mdiv>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { AuthTopBar } from "@/components/auth/auth-top-bar";
|
||||
import { Reveal } from "@/components/motion-reveal";
|
||||
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
|
||||
import { SurfaceCard } from "@/components/surface-card";
|
||||
import { ICON_FRIENDS, ICON_NAV_GOODY, ICON_NAV_ME } from "@/lib/site-icons";
|
||||
|
||||
export interface PublicUser {
|
||||
username: string;
|
||||
look: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The decorative aurora blobs that sit behind the auth top bar on `/login` and
|
||||
* `/register`. Purely visual, hence aria-hidden, and shared so both pages keep
|
||||
* the exact same tint and delay.
|
||||
*/
|
||||
export function AuthAurora() {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute -inset-x-16 -top-24 overflow-hidden"
|
||||
>
|
||||
<div
|
||||
className="aurora-blob -left-20 top-0 h-80 w-80"
|
||||
style={{
|
||||
background:
|
||||
"color-mix(in srgb, var(--color-primary) 34%, transparent)",
|
||||
opacity: 0.45,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="aurora-blob -right-20 top-6 h-72 w-72"
|
||||
style={{
|
||||
background:
|
||||
"color-mix(in srgb, var(--color-accent) 30%, transparent)",
|
||||
animationDelay: "-7s",
|
||||
opacity: 0.4,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AuthUsersCardsProps {
|
||||
recentUsers: PublicUser[];
|
||||
latestUsers: PublicUser[];
|
||||
/** Title of the online-users card. */
|
||||
recentTitle: string;
|
||||
/** Title of the newest-accounts card. */
|
||||
latestTitle: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The "who is online" + "newest citizens" avatar cards both auth pages show in
|
||||
* their left column. Cards with no users render nothing, so a cold database
|
||||
* degrades to just the intro panel instead of an empty grid.
|
||||
*/
|
||||
export function AuthUsersCards({
|
||||
recentUsers,
|
||||
latestUsers,
|
||||
recentTitle,
|
||||
latestTitle,
|
||||
}: AuthUsersCardsProps) {
|
||||
return (
|
||||
<>
|
||||
{recentUsers.length > 0 && (
|
||||
<SurfaceCard
|
||||
title={recentTitle}
|
||||
icon={ICON_NAV_GOODY}
|
||||
bodyClassName="p-4"
|
||||
className="card-glow"
|
||||
>
|
||||
<AvatarGrid users={recentUsers} online />
|
||||
</SurfaceCard>
|
||||
)}
|
||||
{latestUsers.length > 0 && (
|
||||
<SurfaceCard
|
||||
title={latestTitle}
|
||||
icon={ICON_FRIENDS}
|
||||
bodyClassName="p-4"
|
||||
className="card-glow"
|
||||
>
|
||||
<AvatarGrid users={latestUsers} />
|
||||
</SurfaceCard>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarGrid({
|
||||
users,
|
||||
online,
|
||||
}: {
|
||||
users: PublicUser[];
|
||||
online?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-4 gap-1.5 sm:gap-2">
|
||||
{users.map((u) => (
|
||||
<div key={u.username} title={u.username} className="avatar-tile">
|
||||
<div className="relative">
|
||||
<UserAvatarThumbnail
|
||||
figure={u.look}
|
||||
alt=""
|
||||
options={{ direction: 2 }}
|
||||
className="rounded-lg"
|
||||
/>
|
||||
{online && (
|
||||
<span className="avatar-online absolute -bottom-0.5 -right-0.5" />
|
||||
)}
|
||||
</div>
|
||||
<span className="avatar-name">{u.username}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AuthPageFrameProps {
|
||||
hotelName: string;
|
||||
logo?: string | null;
|
||||
/** Title of the form card on the right. */
|
||||
title: string;
|
||||
/** One-line intro above the form. */
|
||||
subtitle: ReactNode;
|
||||
/** Optional notice rendered at the top of the form card (e.g. "registered"). */
|
||||
notice?: ReactNode;
|
||||
/** The form itself. */
|
||||
form: ReactNode;
|
||||
/** The left column: intro panel plus `AuthUsersCards`. */
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared skeleton for the public auth pages: aurora + top bar on top, a fixed
|
||||
* left column for the promo/roster panels and a sticky right column holding the
|
||||
* form. Both `/login` and `/register` used to duplicate all of it.
|
||||
*/
|
||||
export function AuthPageFrame({
|
||||
hotelName,
|
||||
logo,
|
||||
title,
|
||||
subtitle,
|
||||
notice,
|
||||
form,
|
||||
children,
|
||||
}: AuthPageFrameProps) {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-6xl flex flex-col gap-8 pb-16">
|
||||
<div className="relative">
|
||||
<AuthAurora />
|
||||
<AuthTopBar hotelName={hotelName} logo={logo} />
|
||||
</div>
|
||||
|
||||
<Reveal>
|
||||
<div className="flex flex-col gap-8 lg:flex-row lg:items-start">
|
||||
<div className="lg:w-96 shrink-0 space-y-4">{children}</div>
|
||||
|
||||
<div className="flex-1 lg:sticky lg:top-6">
|
||||
<div className="gradient-border">
|
||||
<SurfaceCard
|
||||
title={title}
|
||||
icon={ICON_NAV_ME}
|
||||
bodyClassName="p-6 sm:p-7"
|
||||
className="card-glow"
|
||||
>
|
||||
{notice}
|
||||
<p
|
||||
className="text-sm mb-6 leading-relaxed"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{subtitle}
|
||||
</p>
|
||||
{form}
|
||||
</SurfaceCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -23,14 +23,14 @@ export function AuthTopBar({
|
||||
return (
|
||||
<Reveal>
|
||||
<div
|
||||
className="relative flex items-center justify-between rounded-xl border px-3 py-2.5 sm:px-5 sm:py-3"
|
||||
className="relative flex items-center justify-between rounded-full border px-3 py-2.5 sm:px-5"
|
||||
style={{
|
||||
background:
|
||||
"color-mix(in srgb, var(--color-surface) 60%, transparent)",
|
||||
"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) 8%, transparent)",
|
||||
"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)",
|
||||
}}
|
||||
@@ -44,21 +44,32 @@ export function AuthTopBar({
|
||||
height={28}
|
||||
className="shrink-0 object-contain"
|
||||
unoptimized
|
||||
style={{ maxHeight: 28, width: "auto", height: "auto" }}
|
||||
style={{ maxHeight: 26, width: "auto", height: "auto" }}
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
src={ICON_LIGHTHOUSE}
|
||||
alt={hotelName}
|
||||
width={28}
|
||||
height={28}
|
||||
className="shrink-0"
|
||||
unoptimized
|
||||
/>
|
||||
<div
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full"
|
||||
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)",
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={ICON_LIGHTHOUSE}
|
||||
alt=""
|
||||
width={18}
|
||||
height={18}
|
||||
className="shrink-0"
|
||||
unoptimized
|
||||
style={{ filter: "brightness(0) invert(1)" }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{showHotelName ? (
|
||||
<span
|
||||
className="hidden sm:inline text-sm font-extrabold uppercase tracking-wide"
|
||||
className="hidden text-sm font-extrabold uppercase tracking-wide sm:inline"
|
||||
style={{ color: "var(--color-text-readable)" }}
|
||||
>
|
||||
{hotelName}
|
||||
@@ -66,10 +77,10 @@ export function AuthTopBar({
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center gap-1 sm:gap-1.5 rounded-lg px-1.5 py-1 sm:px-2"
|
||||
className="flex items-center gap-1 sm:gap-1.5 rounded-full px-1.5 py-1 sm:px-2"
|
||||
style={{
|
||||
background:
|
||||
"color-mix(in srgb, var(--color-text-muted) 4%, transparent)",
|
||||
"color-mix(in srgb, var(--color-text-muted) 5%, transparent)",
|
||||
}}
|
||||
>
|
||||
<LanguageSwitcher />
|
||||
|
||||
@@ -1,229 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { signIn } from "next-auth/react";
|
||||
import { type FormEvent, useState } from "react";
|
||||
import { precheckLogin } from "@/actions/auth-precheck";
|
||||
import {
|
||||
type CaptchaPublicConfig,
|
||||
CaptchaWidget,
|
||||
readCaptchaToken,
|
||||
} from "@/components/auth/captcha-widget";
|
||||
import { signInWithTransientRetry } from "@/lib/auth/sign-in-retry";
|
||||
|
||||
export function HomeLoginForm({
|
||||
captcha = { provider: "none" },
|
||||
nonce,
|
||||
}: {
|
||||
captcha?: CaptchaPublicConfig;
|
||||
nonce?: string;
|
||||
} = {}) {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [code, setCode] = useState("");
|
||||
const [needs2fa, setNeeds2fa] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
async function onSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setPending(true);
|
||||
try {
|
||||
if (!needs2fa) {
|
||||
const captchaToken = readCaptchaToken(e.currentTarget, captcha);
|
||||
const pre = await precheckLogin(username, password, captchaToken);
|
||||
if (pre === "invalid") {
|
||||
setError("Invalid username or password");
|
||||
return;
|
||||
}
|
||||
if (pre === "captcha") {
|
||||
setError("Captcha verification failed. Please try again.");
|
||||
return;
|
||||
}
|
||||
if (pre === "unverified") {
|
||||
setError("Please verify your email before signing in.");
|
||||
return;
|
||||
}
|
||||
if (pre === "twofactor") {
|
||||
setNeeds2fa(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const res = await signInWithTransientRetry(() =>
|
||||
signIn("credentials", {
|
||||
username,
|
||||
password,
|
||||
code,
|
||||
redirect: false,
|
||||
}),
|
||||
);
|
||||
if (!res || res.error) {
|
||||
setError(
|
||||
needs2fa ? "Invalid 2FA code" : "Invalid username or password",
|
||||
);
|
||||
return;
|
||||
}
|
||||
window.location.href = "/";
|
||||
} catch {
|
||||
setError(needs2fa ? "Invalid 2FA code" : "Invalid username or password");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="relative flex flex-col gap-4">
|
||||
<div className="space-y-1">
|
||||
<label
|
||||
htmlFor="login-username"
|
||||
className="block text-xs font-bold uppercase tracking-wider"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="login-username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Your username"
|
||||
autoComplete="username"
|
||||
disabled={needs2fa}
|
||||
className="input-glow w-full rounded-xl border-2 px-4 py-3 text-sm font-medium transition-all focus:outline-none disabled:opacity-50"
|
||||
style={{
|
||||
backgroundColor: "var(--color-background)",
|
||||
color: "var(--color-text-readable)",
|
||||
borderColor: needs2fa
|
||||
? "color-mix(in srgb, var(--color-text-muted) 15%, transparent)"
|
||||
: "color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
|
||||
}}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<label
|
||||
htmlFor="login-password"
|
||||
className="block text-xs font-bold uppercase tracking-wider"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="login-password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Your password"
|
||||
autoComplete="current-password"
|
||||
disabled={needs2fa}
|
||||
className="input-glow w-full rounded-xl border-2 px-4 py-3 text-sm font-medium transition-all focus:outline-none disabled:opacity-50"
|
||||
style={{
|
||||
backgroundColor: "var(--color-background)",
|
||||
color: "var(--color-text-readable)",
|
||||
borderColor: needs2fa
|
||||
? "color-mix(in srgb, var(--color-text-muted) 15%, transparent)"
|
||||
: "color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
|
||||
}}
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-sm font-bold opacity-60 hover:opacity-100 transition-opacity"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? "Hide" : "Show"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{needs2fa ? (
|
||||
<div className="space-y-1 animate-scale-in">
|
||||
<label
|
||||
htmlFor="login-2fa"
|
||||
className="block text-xs font-bold uppercase tracking-wider"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
2FA Code
|
||||
</label>
|
||||
<input
|
||||
id="login-2fa"
|
||||
type="text"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
placeholder="Enter your 2FA code"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
className="w-full rounded-xl border-2 px-4 py-3 text-sm font-medium transition-all focus:outline-none"
|
||||
style={{
|
||||
backgroundColor: "var(--color-background)",
|
||||
color: "var(--color-text-readable)",
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-primary) 30%, transparent)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<CaptchaWidget captcha={captcha} nonce={nonce} />
|
||||
)}
|
||||
|
||||
{error ? (
|
||||
<p
|
||||
className="animate-fade-in-up m-0 text-center text-sm font-semibold"
|
||||
style={{ color: "var(--color-danger)" }}
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
className="btn-shine w-full cursor-pointer rounded-xl font-extrabold text-sm py-3.5 transition-all duration-200 hover:scale-[1.02] active:scale-95 shadow-lg disabled:opacity-60"
|
||||
style={{
|
||||
background: "var(--color-primary)",
|
||||
color: "var(--color-primary-foreground)",
|
||||
boxShadow:
|
||||
"0 4px 20px color-mix(in srgb, var(--color-primary) 30%, transparent)",
|
||||
}}
|
||||
>
|
||||
{pending ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<svg
|
||||
className="animate-spin h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
role="img"
|
||||
aria-label="Loading"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
Please wait...
|
||||
</span>
|
||||
) : needs2fa ? (
|
||||
"Verify"
|
||||
) : (
|
||||
"Login"
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { KeyRound, Lock, UserRound } from "lucide-react";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { type FormEvent, useState } from "react";
|
||||
import { type FormEvent, useId, useState } from "react";
|
||||
import { precheckLogin } from "@/actions/auth-precheck";
|
||||
import {
|
||||
type CaptchaPublicConfig,
|
||||
CaptchaWidget,
|
||||
readCaptchaToken,
|
||||
} from "@/components/auth/captcha-widget";
|
||||
import { PasswordToggle } from "@/components/auth/password-toggle";
|
||||
import Link from "@/components/link";
|
||||
import { signInWithTransientRetry } from "@/lib/auth/sign-in-retry";
|
||||
|
||||
export type LoginFormVariant =
|
||||
/** Full page: labelled fields, plus the register / forgot-password footer. */
|
||||
| "page"
|
||||
/** Homepage sidebar: visible labels, no footer, tighter spacing. */
|
||||
| "compact";
|
||||
|
||||
export interface LoginFormProps {
|
||||
captcha?: CaptchaPublicConfig;
|
||||
nonce?: string;
|
||||
variant?: LoginFormVariant;
|
||||
/** Where to land after a successful sign-in. */
|
||||
redirectTo?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The single sign-in form for both `/login` and the homepage sidebar. It runs a
|
||||
* cheap server-side precheck first so the visitor gets a specific reason
|
||||
* (bad password, unverified email, 2FA required, captcha) instead of NextAuth's
|
||||
* generic failure, then completes the credentials sign-in.
|
||||
*/
|
||||
export function LoginForm({
|
||||
captcha = { provider: "none" },
|
||||
nonce,
|
||||
}: {
|
||||
captcha?: CaptchaPublicConfig;
|
||||
nonce?: string;
|
||||
}) {
|
||||
variant = "page",
|
||||
redirectTo = "/me",
|
||||
}: LoginFormProps = {}) {
|
||||
const t = useTranslations("pages.login");
|
||||
const fieldId = useId();
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [code, setCode] = useState("");
|
||||
const [needs2fa, setNeeds2fa] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [unverified, setUnverified] = useState(false);
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
const showFooter = variant === "page";
|
||||
const lockCredentials = needs2fa ? "opacity-50 pointer-events-none" : "";
|
||||
|
||||
async function onSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setUnverified(false);
|
||||
setPending(true);
|
||||
try {
|
||||
if (!needs2fa) {
|
||||
@@ -46,6 +73,7 @@ export function LoginForm({
|
||||
}
|
||||
if (pre === "unverified") {
|
||||
setError(t("errorUnverified"));
|
||||
setUnverified(true);
|
||||
return;
|
||||
}
|
||||
if (pre === "twofactor") {
|
||||
@@ -67,7 +95,7 @@ export function LoginForm({
|
||||
);
|
||||
return;
|
||||
}
|
||||
window.location.href = "/me";
|
||||
window.location.href = redirectTo;
|
||||
} catch {
|
||||
setError(needs2fa ? t("errorInvalid2fa") : t("errorInvalidCredentials"));
|
||||
} finally {
|
||||
@@ -79,73 +107,107 @@ export function LoginForm({
|
||||
<>
|
||||
{needs2fa && (
|
||||
<p
|
||||
className="animate-fade-in-up text-sm font-semibold mb-4"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
className="animate-fade-in-up mb-4 rounded-xl border px-3 py-2.5 text-sm font-semibold"
|
||||
style={{
|
||||
color: "var(--color-text-readable)",
|
||||
background:
|
||||
"color-mix(in srgb, var(--color-primary) 10%, transparent)",
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-primary) 26%, transparent)",
|
||||
}}
|
||||
>
|
||||
{t("subtitle2fa")}
|
||||
</p>
|
||||
)}
|
||||
<form onSubmit={onSubmit} className="flex flex-col gap-4">
|
||||
<div className={needs2fa ? "opacity-50 pointer-events-none" : ""}>
|
||||
<input
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder={t("usernamePlaceholder")}
|
||||
autoComplete="username"
|
||||
disabled={needs2fa}
|
||||
className="input-glow w-full rounded-xl border-2 px-4 py-3 text-sm font-medium transition-all focus:outline-none disabled:opacity-50"
|
||||
style={{
|
||||
backgroundColor: "var(--color-background)",
|
||||
color: "var(--color-text-readable)",
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`${needs2fa ? "opacity-50 pointer-events-none" : ""} relative`}
|
||||
>
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={t("passwordPlaceholder")}
|
||||
autoComplete="current-password"
|
||||
disabled={needs2fa}
|
||||
className="input-glow w-full rounded-xl border-2 px-4 py-3 text-sm font-medium transition-all focus:outline-none disabled:opacity-50"
|
||||
style={{
|
||||
backgroundColor: "var(--color-background)",
|
||||
color: "var(--color-text-readable)",
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-sm font-bold opacity-60 hover:opacity-100 transition-opacity"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? "Hide" : "Show"}
|
||||
</button>
|
||||
</div>
|
||||
{needs2fa ? (
|
||||
<div className="animate-scale-in">
|
||||
<input
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
placeholder={t("codePlaceholder")}
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
className="input-glow w-full rounded-xl border-2 px-4 py-3 text-sm font-medium transition-all focus:outline-none"
|
||||
style={{
|
||||
backgroundColor: "var(--color-background)",
|
||||
color: "var(--color-text-readable)",
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-primary) 30%, transparent)",
|
||||
}}
|
||||
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
aria-label={t("signIn")}
|
||||
className="relative flex flex-col gap-4"
|
||||
>
|
||||
<div className={`space-y-1.5 ${lockCredentials}`}>
|
||||
<label htmlFor={`${fieldId}-username`} className="auth-label">
|
||||
{t("username")}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<UserRound
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
/>
|
||||
<input
|
||||
id={`${fieldId}-username`}
|
||||
name="username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder={t("usernamePlaceholder")}
|
||||
autoComplete="username"
|
||||
disabled={needs2fa}
|
||||
required
|
||||
className="auth-input has-icon"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`space-y-1.5 ${lockCredentials}`}>
|
||||
<label htmlFor={`${fieldId}-password`} className="auth-label">
|
||||
{t("password")}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
/>
|
||||
<input
|
||||
id={`${fieldId}-password`}
|
||||
name="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={t("passwordPlaceholder")}
|
||||
autoComplete="current-password"
|
||||
disabled={needs2fa}
|
||||
required
|
||||
className="auth-input has-icon has-action"
|
||||
/>
|
||||
<PasswordToggle
|
||||
show={showPassword}
|
||||
onToggle={() => setShowPassword((v) => !v)}
|
||||
labelShow={t("showPassword")}
|
||||
labelHide={t("hidePassword")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{needs2fa ? (
|
||||
<div className="animate-scale-in space-y-1.5">
|
||||
<label htmlFor={`${fieldId}-2fa`} className="auth-label">
|
||||
{t("codePlaceholder")}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<KeyRound
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2"
|
||||
style={{ color: "var(--color-primary)" }}
|
||||
/>
|
||||
<input
|
||||
id={`${fieldId}-2fa`}
|
||||
name="code"
|
||||
type="text"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
placeholder={t("codePlaceholder")}
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
className="auth-input has-icon"
|
||||
style={{
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-primary) 45%, transparent)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<CaptchaWidget captcha={captcha} nonce={nonce} />
|
||||
@@ -153,32 +215,42 @@ export function LoginForm({
|
||||
|
||||
{error && (
|
||||
<p
|
||||
className="animate-fade-in-up text-sm text-center font-semibold"
|
||||
style={{ color: "var(--color-danger)" }}
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
className="auth-alert animate-fade-in-up m-0"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{unverified && (
|
||||
<p
|
||||
className="m-0 text-center text-xs"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<Link
|
||||
href="/verify"
|
||||
className="font-extrabold transition-colors hover:underline"
|
||||
style={{ color: "var(--color-primary)" }}
|
||||
>
|
||||
{t("verifyEmailCta")}
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
className="btn-shine w-full rounded-xl font-extrabold text-sm py-3.5 transition-all duration-200 hover:scale-[1.02] active:scale-95 shadow-lg disabled:opacity-60"
|
||||
style={{
|
||||
background: "var(--color-primary)",
|
||||
color: "var(--color-primary-foreground)",
|
||||
boxShadow:
|
||||
"0 4px 24px color-mix(in srgb, var(--color-primary) 35%, transparent)",
|
||||
}}
|
||||
className="btn-brand btn-shine w-full cursor-pointer py-3.5 text-sm font-extrabold uppercase tracking-wider disabled:opacity-60"
|
||||
>
|
||||
{pending ? (
|
||||
<span className="inline-flex items-center justify-center gap-2">
|
||||
<svg
|
||||
className="animate-spin h-4 w-4"
|
||||
className="h-4 w-4 animate-spin"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
role="img"
|
||||
aria-label="Loading"
|
||||
aria-label={t("pleaseWait")}
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
@@ -204,27 +276,43 @@ export function LoginForm({
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p
|
||||
className="text-xs text-center mt-6"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{t("noAccount")}{" "}
|
||||
<Link
|
||||
href="/register"
|
||||
className="font-extrabold hover:underline"
|
||||
style={{ color: "var(--color-primary)" }}
|
||||
{showFooter && (
|
||||
<div
|
||||
className="mt-6 space-y-3 border-t pt-5"
|
||||
style={{
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-text-muted) 14%, transparent)",
|
||||
}}
|
||||
>
|
||||
{t("createOne")}
|
||||
</Link>{" "}
|
||||
·{" "}
|
||||
<Link
|
||||
href="/forgot"
|
||||
className="font-extrabold hover:underline"
|
||||
style={{ color: "var(--color-primary)" }}
|
||||
>
|
||||
{t("forgotPassword")}
|
||||
</Link>
|
||||
</p>
|
||||
<p
|
||||
className="text-center text-xs"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{t("noAccount")}{" "}
|
||||
<Link
|
||||
href="/register"
|
||||
className="font-extrabold transition-colors hover:underline"
|
||||
style={{ color: "var(--color-primary)" }}
|
||||
>
|
||||
{t("createOne")}
|
||||
</Link>
|
||||
</p>
|
||||
<Link
|
||||
href="/forgot"
|
||||
className="flex items-center justify-center gap-2 rounded-full border px-4 py-2.5 text-xs font-extrabold transition-all duration-200 hover:-translate-y-0.5"
|
||||
style={{
|
||||
color: "var(--color-primary)",
|
||||
background:
|
||||
"color-mix(in srgb, var(--color-primary) 8%, transparent)",
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-primary) 35%, transparent)",
|
||||
}}
|
||||
>
|
||||
<KeyRound aria-hidden="true" className="h-3.5 w-3.5" />
|
||||
{t("forgotPassword")}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
|
||||
/**
|
||||
* Round eye-icon button that reveals / hides a password field. Kept separate so
|
||||
* the login and register forms share one consistent control (and a11y labels).
|
||||
*/
|
||||
export function PasswordToggle({
|
||||
show,
|
||||
onToggle,
|
||||
labelShow,
|
||||
labelHide,
|
||||
}: {
|
||||
show: boolean;
|
||||
onToggle: () => void;
|
||||
labelShow: string;
|
||||
labelHide: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
aria-label={show ? labelHide : labelShow}
|
||||
aria-pressed={show}
|
||||
tabIndex={-1}
|
||||
className="absolute right-2.5 top-1/2 flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-full transition-colors duration-200 hover:bg-[color-mix(in_srgb,var(--color-primary)_14%,transparent)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-primary)]"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{show ? (
|
||||
<EyeOff aria-hidden="true" className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye aria-hidden="true" className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import {
|
||||
Check,
|
||||
KeyRound,
|
||||
Lock,
|
||||
Mail,
|
||||
ShieldCheck,
|
||||
UserRound,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import Script from "next/script";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useActionState, useEffect, useRef, useState } from "react";
|
||||
import { type RegisterState, register } from "@/actions/register";
|
||||
import {
|
||||
type RegisterErrorCode,
|
||||
type RegisterState,
|
||||
register,
|
||||
} from "@/actions/register";
|
||||
import { PasswordToggle } from "@/components/auth/password-toggle";
|
||||
import Link from "@/components/link";
|
||||
import { signInWithTransientRetry } from "@/lib/auth/sign-in-retry";
|
||||
|
||||
@@ -16,7 +29,10 @@ interface RegisterFormProps {
|
||||
nonce?: string;
|
||||
}
|
||||
|
||||
function passwordStrength(pw: string): {
|
||||
function passwordStrength(
|
||||
pw: string,
|
||||
t: (key: string) => string,
|
||||
): {
|
||||
score: number;
|
||||
label: string;
|
||||
color: string;
|
||||
@@ -28,10 +44,10 @@ function passwordStrength(pw: string): {
|
||||
if (/\d/.test(pw)) score += 1;
|
||||
if (/[^a-zA-Z0-9]/.test(pw)) score += 1;
|
||||
|
||||
if (score <= 1) return { score, label: "Weak", color: "#ef4444" };
|
||||
if (score <= 2) return { score, label: "Fair", color: "#f59e0b" };
|
||||
if (score <= 3) return { score, label: "Good", color: "#22c55e" };
|
||||
return { score: 5, label: "Strong", color: "#16a34a" };
|
||||
if (score <= 1) return { score, label: t("strengthWeak"), color: "#ef4444" };
|
||||
if (score <= 2) return { score, label: t("strengthFair"), color: "#f59e0b" };
|
||||
if (score <= 3) return { score, label: t("strengthGood"), color: "#22c55e" };
|
||||
return { score: 5, label: t("strengthStrong"), color: "#16a34a" };
|
||||
}
|
||||
|
||||
export function RegisterForm({
|
||||
@@ -46,10 +62,26 @@ export function RegisterForm({
|
||||
RegisterState,
|
||||
FormData
|
||||
>(register, { error: null, ok: false });
|
||||
|
||||
/**
|
||||
* Prefer the locale-independent `code` so the message follows the visitor's
|
||||
* language, falling back to the server's English string when a code has no
|
||||
* translation yet.
|
||||
*/
|
||||
const translateErrorCode = (code: RegisterErrorCode | undefined) => {
|
||||
if (!code) return null;
|
||||
try {
|
||||
return t(code);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const errorMessage = error ?? translateErrorCode(state.code) ?? state.error;
|
||||
const [termsAccepted, setTermsAccepted] = useState(false);
|
||||
const [termsError, setTermsError] = useState(false);
|
||||
const termsRef = useRef<HTMLDivElement>(null);
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
const [signingIn, setSigningIn] = useState(false);
|
||||
@@ -58,7 +90,22 @@ export function RegisterForm({
|
||||
);
|
||||
const autoLoginStartedRef = useRef(false);
|
||||
const [referralCode, setReferralCode] = useState("");
|
||||
const strength = passwordStrength(password);
|
||||
const strength = passwordStrength(password, t);
|
||||
|
||||
/**
|
||||
* The password policy the server enforces, evaluated live so the visitor can
|
||||
* see which rule they are still missing instead of submitting and reading a
|
||||
* single error back.
|
||||
*/
|
||||
const requirements = [
|
||||
{ label: t("passwordMinLength"), met: password.length >= 12 },
|
||||
{ label: t("passwordUpper"), met: /[A-Z]/.test(password) },
|
||||
{ label: t("passwordLower"), met: /[a-z]/.test(password) },
|
||||
{ label: t("passwordDigit"), met: /\d/.test(password) },
|
||||
{ label: t("passwordSpecial"), met: /[^A-Za-z0-9]/.test(password) },
|
||||
];
|
||||
/** Shown as soon as the confirmation is filled in and disagrees. */
|
||||
const mismatch = confirmPassword.length > 0 && confirmPassword !== password;
|
||||
|
||||
// The invite link is `/register?ref=<username>`; read it client-side so the
|
||||
// attribution travels with the sign-up form without server state.
|
||||
@@ -120,90 +167,41 @@ export function RegisterForm({
|
||||
nonce={nonce}
|
||||
/>
|
||||
) : null}
|
||||
{/* Header Banner */}
|
||||
<div
|
||||
className="relative overflow-hidden bg-center bg-cover rounded-t-xl"
|
||||
style={{
|
||||
backgroundImage: "url(/assets/images/mybobba_banner.png)",
|
||||
height: "160px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 z-10"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(180deg, rgba(0,0,0,0.05) 0%, rgba(0,0,0,0.55) 100%)",
|
||||
}}
|
||||
/>
|
||||
<div className="relative flex items-center justify-center w-full h-full z-20">
|
||||
<div
|
||||
className="relative animate-float"
|
||||
style={{ width: "140px", height: "140px" }}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 rounded-full overflow-hidden"
|
||||
style={{ zIndex: -1 }}
|
||||
>
|
||||
<div
|
||||
className="w-full h-full"
|
||||
style={{
|
||||
background: "black",
|
||||
filter: "blur(8px)",
|
||||
transform: "scale(1.2)",
|
||||
opacity: 0.6,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Image
|
||||
src="/assets/images/FrankwithBag.gif"
|
||||
alt=""
|
||||
fill
|
||||
className="!static"
|
||||
style={{
|
||||
objectFit: "contain",
|
||||
objectPosition: "center",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form
|
||||
action={(fd) => {
|
||||
const passwordValue = String(fd.get("password") ?? "");
|
||||
const confirmation = String(fd.get("password_confirmation") ?? "");
|
||||
// A mismatch is reported live under the field — no round-trip
|
||||
// needed just to hear it again from the server.
|
||||
if (passwordValue !== confirmation) return;
|
||||
credentialsRef.current = {
|
||||
username: String(fd.get("username") ?? "").trim(),
|
||||
password: String(fd.get("password") ?? ""),
|
||||
password: passwordValue,
|
||||
};
|
||||
formAction(fd);
|
||||
}}
|
||||
className="p-6 flex flex-col gap-6"
|
||||
style={{
|
||||
backgroundColor:
|
||||
"color-mix(in srgb, var(--color-background) 50%, var(--color-surface))",
|
||||
borderRadius: "0 0 12px 12px",
|
||||
}}
|
||||
className="flex flex-col gap-6"
|
||||
>
|
||||
{(error || state.error) && (
|
||||
<div
|
||||
className="animate-fade-in-up p-3 rounded-lg text-sm font-semibold text-primary-foreground"
|
||||
style={{ backgroundColor: "var(--color-danger)" }}
|
||||
{errorMessage && (
|
||||
<p
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
className="auth-alert animate-fade-in-up m-0"
|
||||
>
|
||||
{error || state.error}
|
||||
</div>
|
||||
{errorMessage}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{referralCode && (
|
||||
<div
|
||||
className="animate-fade-in-up flex items-center gap-2 p-3 rounded-lg text-sm font-semibold"
|
||||
className="animate-fade-in-up flex items-center gap-2 rounded-xl border px-3 py-2.5 text-sm font-semibold"
|
||||
style={{
|
||||
backgroundColor:
|
||||
background:
|
||||
"color-mix(in srgb, var(--color-primary) 12%, transparent)",
|
||||
color: "var(--color-primary-readable, var(--color-primary))",
|
||||
border:
|
||||
"1px solid color-mix(in srgb, var(--color-primary) 22%, transparent)",
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-primary) 26%, transparent)",
|
||||
}}
|
||||
>
|
||||
{t("invitedBy", { username: referralCode })}
|
||||
@@ -214,7 +212,24 @@ export function RegisterForm({
|
||||
|
||||
{/* Username & Email */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg"
|
||||
style={{
|
||||
background:
|
||||
"color-mix(in srgb, var(--color-primary) 12%, transparent)",
|
||||
boxShadow:
|
||||
"inset 0 0 0 1px color-mix(in srgb, var(--color-primary) 22%, transparent)",
|
||||
}}
|
||||
>
|
||||
<UserRound
|
||||
className="h-3.5 w-3.5"
|
||||
style={{
|
||||
color: "var(--color-primary-readable, var(--color-primary))",
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
className="text-[11px] font-black uppercase tracking-widest"
|
||||
style={{
|
||||
@@ -230,61 +245,75 @@ export function RegisterForm({
|
||||
"linear-gradient(90deg, color-mix(in srgb, var(--color-text-muted) 18%, transparent), transparent)",
|
||||
}}
|
||||
/>
|
||||
<span aria-hidden="true" className="number-mark text-lg">
|
||||
01
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 stagger-children">
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
htmlFor="username"
|
||||
className="block text-xs font-bold uppercase tracking-wider"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<label htmlFor="username" className="auth-label">
|
||||
{t("username")}
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
placeholder={t("usernamePlaceholder")}
|
||||
autoComplete="username"
|
||||
className="input-glow w-full rounded-xl border-2 px-4 py-3 text-sm font-medium transition-all focus:outline-none"
|
||||
style={{
|
||||
backgroundColor: "var(--color-background)",
|
||||
color: "var(--color-text-readable)",
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
|
||||
}}
|
||||
required
|
||||
/>
|
||||
<div className="relative">
|
||||
<UserRound
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
/>
|
||||
<input
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
placeholder={t("usernamePlaceholder")}
|
||||
autoComplete="username"
|
||||
className="auth-input has-icon"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
htmlFor="mail"
|
||||
className="block text-xs font-bold uppercase tracking-wider"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<label htmlFor="mail" className="auth-label">
|
||||
{t("email")}
|
||||
</label>
|
||||
<input
|
||||
id="mail"
|
||||
name="mail"
|
||||
type="email"
|
||||
placeholder={t("emailPlaceholder")}
|
||||
autoComplete="email"
|
||||
className="input-glow w-full rounded-xl border-2 px-4 py-3 text-sm font-medium transition-all focus:outline-none"
|
||||
style={{
|
||||
backgroundColor: "var(--color-background)",
|
||||
color: "var(--color-text-readable)",
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
|
||||
}}
|
||||
/>
|
||||
<div className="relative">
|
||||
<Mail
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
/>
|
||||
<input
|
||||
id="mail"
|
||||
name="mail"
|
||||
type="email"
|
||||
placeholder={t("emailPlaceholder")}
|
||||
autoComplete="email"
|
||||
className="auth-input has-icon"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Password & Confirm */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg"
|
||||
style={{
|
||||
background:
|
||||
"color-mix(in srgb, var(--color-primary) 12%, transparent)",
|
||||
boxShadow:
|
||||
"inset 0 0 0 1px color-mix(in srgb, var(--color-primary) 22%, transparent)",
|
||||
}}
|
||||
>
|
||||
<KeyRound
|
||||
className="h-3.5 w-3.5"
|
||||
style={{
|
||||
color: "var(--color-primary-readable, var(--color-primary))",
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
className="text-[11px] font-black uppercase tracking-widest"
|
||||
style={{
|
||||
@@ -300,17 +329,21 @@ export function RegisterForm({
|
||||
"linear-gradient(90deg, color-mix(in srgb, var(--color-text-muted) 18%, transparent), transparent)",
|
||||
}}
|
||||
/>
|
||||
<span aria-hidden="true" className="number-mark text-lg">
|
||||
02
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 stagger-children">
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-xs font-bold uppercase tracking-wider"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<label htmlFor="password" className="auth-label">
|
||||
{t("password")}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
/>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
@@ -319,28 +352,19 @@ export function RegisterForm({
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="input-glow w-full rounded-xl border-2 px-4 py-3 text-sm font-medium transition-all focus:outline-none"
|
||||
style={{
|
||||
backgroundColor: "var(--color-background)",
|
||||
color: "var(--color-text-readable)",
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
|
||||
}}
|
||||
className="auth-input has-icon has-action"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-sm font-bold opacity-60 hover:opacity-100 transition-opacity"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? t("hidePassword") : t("showPassword")}
|
||||
</button>
|
||||
<PasswordToggle
|
||||
show={showPassword}
|
||||
onToggle={() => setShowPassword((v) => !v)}
|
||||
labelShow={t("showPassword")}
|
||||
labelHide={t("hidePassword")}
|
||||
/>
|
||||
</div>
|
||||
{password.length > 0 && (
|
||||
<div className="animate-fade-in-up space-y-1">
|
||||
<div className="flex gap-1">
|
||||
<div className="animate-fade-in-up space-y-1.5 pt-0.5">
|
||||
<div className="flex gap-1.5">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
@@ -349,54 +373,87 @@ export function RegisterForm({
|
||||
backgroundColor:
|
||||
i <= strength.score
|
||||
? strength.color
|
||||
: "color-mix(in srgb, var(--color-text-muted) 15%, transparent)",
|
||||
: "color-mix(in srgb, var(--color-text-muted) 18%, transparent)",
|
||||
boxShadow:
|
||||
i <= strength.score
|
||||
? `0 0 8px color-mix(in srgb, ${strength.color} 45%, transparent)`
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p
|
||||
className="text-xs font-bold"
|
||||
style={{ color: strength.color }}
|
||||
>
|
||||
{strength.label}
|
||||
</p>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ShieldCheck
|
||||
aria-hidden="true"
|
||||
className="h-3.5 w-3.5 shrink-0"
|
||||
style={{ color: strength.color }}
|
||||
/>
|
||||
<p
|
||||
className="text-[11px] font-extrabold uppercase tracking-wider"
|
||||
style={{ color: strength.color }}
|
||||
>
|
||||
{strength.label}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<ul aria-label={t("password")} className="space-y-1 pt-1">
|
||||
{requirements.map((r) => (
|
||||
<li
|
||||
key={r.label}
|
||||
className="flex items-center gap-1.5 text-[11px] font-semibold transition-colors duration-200"
|
||||
style={{
|
||||
color: r.met
|
||||
? "var(--color-success)"
|
||||
: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
{r.met ? (
|
||||
<Check className="h-3 w-3 shrink-0" aria-hidden="true" />
|
||||
) : (
|
||||
<X className="h-3 w-3 shrink-0" aria-hidden="true" />
|
||||
)}
|
||||
{r.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
htmlFor="password_confirmation"
|
||||
className="block text-xs font-bold uppercase tracking-wider"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<label htmlFor="password_confirmation" className="auth-label">
|
||||
{t("confirmPassword")}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
/>
|
||||
<input
|
||||
id="password_confirmation"
|
||||
name="password_confirmation"
|
||||
type={showConfirm ? "text" : "password"}
|
||||
placeholder={t("confirmPasswordPlaceholder")}
|
||||
autoComplete="new-password"
|
||||
className="input-glow w-full rounded-xl border-2 px-4 py-3 text-sm font-medium transition-all focus:outline-none"
|
||||
style={{
|
||||
backgroundColor: "var(--color-background)",
|
||||
color: "var(--color-text-readable)",
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
|
||||
}}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="auth-input has-icon has-action"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirm(!showConfirm)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-sm font-bold opacity-60 hover:opacity-100 transition-opacity"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showConfirm ? t("hidePassword") : t("showPassword")}
|
||||
</button>
|
||||
<PasswordToggle
|
||||
show={showConfirm}
|
||||
onToggle={() => setShowConfirm((v) => !v)}
|
||||
labelShow={t("showPassword")}
|
||||
labelHide={t("hidePassword")}
|
||||
/>
|
||||
</div>
|
||||
{mismatch && (
|
||||
<p
|
||||
className="animate-fade-in-up text-xs font-bold"
|
||||
style={{ color: "var(--color-danger)" }}
|
||||
>
|
||||
{t("passwordsDontMatch")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -523,8 +580,11 @@ export function RegisterForm({
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending || signingIn}
|
||||
onClick={() => {
|
||||
onClick={(e) => {
|
||||
if (!termsAccepted) {
|
||||
// Don't fire a doomed server round-trip: shake the
|
||||
// terms box and stay put until it is checked.
|
||||
e.preventDefault();
|
||||
setTermsError(true);
|
||||
termsRef.current?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
@@ -532,15 +592,13 @@ export function RegisterForm({
|
||||
});
|
||||
}
|
||||
}}
|
||||
className={`btn-shine w-full rounded-xl font-extrabold text-sm py-3.5 transition-all duration-200 hover:scale-[1.02] active:scale-95 shadow-lg disabled:opacity-60 ${
|
||||
termsAccepted ? "" : "opacity-70 saturate-[0.85] hover:scale-100"
|
||||
className={`btn-brand btn-shine w-full py-3.5 text-sm font-extrabold uppercase tracking-wider disabled:opacity-60 ${
|
||||
termsAccepted ? "" : "opacity-70 saturate-[0.85]"
|
||||
}`}
|
||||
style={{
|
||||
background: "var(--color-primary)",
|
||||
color: "var(--color-primary-foreground)",
|
||||
boxShadow: termsAccepted
|
||||
? "0 4px 24px color-mix(in srgb, var(--color-primary) 35%, transparent)"
|
||||
: "0 2px 10px color-mix(in srgb, var(--color-primary) 12%, transparent)",
|
||||
? undefined
|
||||
: "0 4px 14px -8px color-mix(in srgb, var(--color-primary) 50%, transparent)",
|
||||
}}
|
||||
>
|
||||
{isPending || signingIn ? (
|
||||
@@ -573,14 +631,25 @@ export function RegisterForm({
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="text-center">
|
||||
<div
|
||||
className="border-t pt-4 text-center"
|
||||
style={{
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-text-muted) 14%, transparent)",
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-sm font-bold hover:underline transition-all"
|
||||
className="inline-flex items-center gap-2 rounded-full border px-5 py-2.5 text-xs font-extrabold transition-all duration-200 hover:-translate-y-0.5"
|
||||
style={{
|
||||
color: "var(--color-primary-readable, var(--color-primary))",
|
||||
color: "var(--color-primary)",
|
||||
background:
|
||||
"color-mix(in srgb, var(--color-primary) 8%, transparent)",
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-primary) 35%, transparent)",
|
||||
}}
|
||||
>
|
||||
<UserRound aria-hidden="true" className="h-3.5 w-3.5" />
|
||||
{t("alreadyHaveAccount")}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { Mail } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useActionState, useId } from "react";
|
||||
import {
|
||||
type ResendVerificationState,
|
||||
resendVerification,
|
||||
} from "@/actions/verify";
|
||||
|
||||
const INITIAL_STATE: ResendVerificationState = { ok: false, error: null };
|
||||
|
||||
/**
|
||||
* Lets a visitor whose verification token expired or was mangled request a
|
||||
* fresh mail straight from the invalid card of /verify. The server answers
|
||||
* identically for unknown addresses, so no registered address can be probed;
|
||||
* the live feedback is limited to "we tried" vs "throttled".
|
||||
*/
|
||||
export function ResendVerificationForm() {
|
||||
const t = useTranslations("pages.verify");
|
||||
const fieldId = useId();
|
||||
const [state, formAction, isPending] = useActionState(
|
||||
resendVerification,
|
||||
INITIAL_STATE,
|
||||
);
|
||||
|
||||
return (
|
||||
<form action={formAction} className="mt-1 w-full space-y-2 text-left">
|
||||
{state.ok ? (
|
||||
<p
|
||||
role="status"
|
||||
className="auth-alert auth-alert--success m-0 animate-fade-in-up"
|
||||
>
|
||||
{t("resendSent")}
|
||||
</p>
|
||||
) : state.error ? (
|
||||
<p role="alert" className="auth-alert m-0 animate-fade-in-up">
|
||||
{t("resendFailed")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<label htmlFor={fieldId} className="auth-label">
|
||||
{t("resendEmail")}
|
||||
</label>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<div className="relative flex-1">
|
||||
<Mail
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
/>
|
||||
<input
|
||||
id={fieldId}
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
autoComplete="email"
|
||||
placeholder={t("resendEmail")}
|
||||
className="auth-input has-icon"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="btn-brand btn-shine mb-0.5 shrink-0 px-5 py-3 text-sm font-extrabold uppercase tracking-wider disabled:opacity-60"
|
||||
>
|
||||
{isPending ? (
|
||||
<span className="inline-flex items-center justify-center gap-2">
|
||||
<svg
|
||||
className="animate-spin h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
role="img"
|
||||
aria-label="Loading"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
{t("resendButton")}
|
||||
</span>
|
||||
) : (
|
||||
t("resendButton")
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -4,17 +4,17 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
const compactCallSites = new Map<string, number>([
|
||||
["src/components/top-header.tsx", 1],
|
||||
// /login and /register render their rosters through this shared frame.
|
||||
["src/components/auth/auth-page-frame.tsx", 1],
|
||||
["src/app/(site)/friends/page.tsx", 1],
|
||||
["src/app/(site)/guilds/[id]/page.tsx", 1],
|
||||
["src/app/(site)/leaderboard/page.tsx", 1],
|
||||
["src/app/(site)/login/page.tsx", 2],
|
||||
["src/app/(site)/me/page.tsx", 1],
|
||||
["src/app/(site)/messages/page.tsx", 2],
|
||||
["src/app/(site)/page.tsx", 2],
|
||||
["src/app/(site)/radio/leaderboard/page.tsx", 1],
|
||||
["src/app/(site)/radio/shouts/page.tsx", 1],
|
||||
["src/app/(site)/rankings/page.tsx", 1],
|
||||
["src/app/(site)/register/page.tsx", 2],
|
||||
["src/app/(site)/search/page.tsx", 1],
|
||||
["src/app/(site)/u/[username]/page.tsx", 2],
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import type { CSSProperties, MouseEvent, ReactNode } from "react";
|
||||
|
||||
interface SpotlightCardProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a card with a mouse-tracking radial glow. The pointer position is
|
||||
* written to `--spot-x` / `--spot-y` and consumed by the `.spotlight::before`
|
||||
* overlay in globals.css.
|
||||
*/
|
||||
export function SpotlightCard({
|
||||
children,
|
||||
className,
|
||||
style,
|
||||
}: SpotlightCardProps) {
|
||||
const handlePointerMove = (event: MouseEvent<HTMLDivElement>) => {
|
||||
const el = event.currentTarget;
|
||||
const rect = el.getBoundingClientRect();
|
||||
el.style.setProperty("--spot-x", `${event.clientX - rect.left}px`);
|
||||
el.style.setProperty("--spot-y", `${event.clientY - rect.top}px`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={
|
||||
{ "--spot-x": "50%", "--spot-y": "50%", ...style } as CSSProperties
|
||||
}
|
||||
onPointerMove={handlePointerMove}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,15 @@ export function TypewriterText({
|
||||
useEffect(() => {
|
||||
setDisplayed(text[0] ?? "");
|
||||
setDone(text.length <= 1);
|
||||
|
||||
// Respect "reduce motion": show the full text instead of animating the
|
||||
// letters in, since the blinking cursor is pure CSS-driven decoration.
|
||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
||||
setDisplayed(text);
|
||||
setDone(true);
|
||||
return;
|
||||
}
|
||||
|
||||
let i = 1;
|
||||
const id = setInterval(() => {
|
||||
if (i < text.length) {
|
||||
|
||||
@@ -49,7 +49,8 @@ export function CatalogSearch({
|
||||
const destinationRequest = useRef(0);
|
||||
const destinationBusy = useRef(false);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: Catalog switches invalidate the selection and destination requests.
|
||||
// Catalog switches invalidate the selection and destination requests.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: a catalog switch is exactly what should reset this
|
||||
useEffect(() => {
|
||||
destinationRequest.current += 1;
|
||||
destinationBusy.current = false;
|
||||
@@ -100,7 +101,7 @@ export function CatalogSearch({
|
||||
});
|
||||
if (!pages) void loadDestinations();
|
||||
}
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: Successful bulk edits must refresh unchanged search queries.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: refreshKey re-runs the query after a bulk edit
|
||||
useEffect(() => {
|
||||
const request = requests.start();
|
||||
setResults([]);
|
||||
@@ -131,6 +132,8 @@ export function CatalogSearch({
|
||||
clearTimeout(timer);
|
||||
requests.cancel();
|
||||
};
|
||||
// refreshKey re-runs the query after a bulk edit changed rows that this
|
||||
// search would otherwise keep showing as stale.
|
||||
}, [query, catalogType, requests, refreshKey]);
|
||||
return (
|
||||
<section
|
||||
|
||||
@@ -266,7 +266,7 @@ function scanModuleAccesses(source: string): ModuleAccessScan {
|
||||
} else if (value.type === "ImportExpression") {
|
||||
recordArgument(value.source, "import");
|
||||
} else if (value.type === "TSImportType") {
|
||||
recordArgument(value.argument, "import");
|
||||
recordArgument(value.source ?? value.argument, "import");
|
||||
} else if (
|
||||
value.type === "CallExpression" ||
|
||||
value.type === "OptionalCallExpression"
|
||||
|
||||
@@ -255,7 +255,7 @@ function scanModuleAccesses(source: string): ModuleAccessScan {
|
||||
} else if (value.type === "ImportExpression") {
|
||||
recordArgument(value.source, "import");
|
||||
} else if (value.type === "TSImportType") {
|
||||
recordArgument(value.argument, "import");
|
||||
recordArgument(value.source ?? value.argument, "import");
|
||||
} else if (
|
||||
value.type === "CallExpression" ||
|
||||
value.type === "OptionalCallExpression"
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { readdirSync } from "node:fs";
|
||||
import { createTranslator } from "next-intl";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import ar from "@/messages/ar.json";
|
||||
import bg from "@/messages/bg.json";
|
||||
import cs from "@/messages/cs.json";
|
||||
import da from "@/messages/da.json";
|
||||
import de from "@/messages/de.json";
|
||||
import el from "@/messages/el.json";
|
||||
import en from "@/messages/en.json";
|
||||
import es from "@/messages/es.json";
|
||||
import fi from "@/messages/fi.json";
|
||||
import fr from "@/messages/fr.json";
|
||||
import hr from "@/messages/hr.json";
|
||||
import hu from "@/messages/hu.json";
|
||||
import itMessages from "@/messages/it.json";
|
||||
import ja from "@/messages/ja.json";
|
||||
import nl from "@/messages/nl.json";
|
||||
import no from "@/messages/no.json";
|
||||
import pl from "@/messages/pl.json";
|
||||
import pt from "@/messages/pt.json";
|
||||
import ro from "@/messages/ro.json";
|
||||
import ru from "@/messages/ru.json";
|
||||
import sk from "@/messages/sk.json";
|
||||
import sr from "@/messages/sr.json";
|
||||
import sv from "@/messages/sv.json";
|
||||
import tr from "@/messages/tr.json";
|
||||
import uk from "@/messages/uk.json";
|
||||
|
||||
/**
|
||||
* Every failure reason the register action can report is a `RegisterErrorCode`,
|
||||
* which the form renders as `pages.register.<code>`. If a code ships without a
|
||||
* translation the visitor silently sees the raw English fallback, so this test
|
||||
* locks the contract in both directions: the union of codes must match the
|
||||
* dictionary, and every locale must carry every key.
|
||||
*/
|
||||
|
||||
type RegisterErrorCode =
|
||||
| "usernameMinLength"
|
||||
| "usernameMaxLength"
|
||||
| "usernamePattern"
|
||||
| "usernameReserved"
|
||||
| "usernameTaken"
|
||||
| "emailValid"
|
||||
| "emailDisposable"
|
||||
| "passwordMinLength"
|
||||
| "passwordMaxLength"
|
||||
| "passwordUpper"
|
||||
| "passwordLower"
|
||||
| "passwordDigit"
|
||||
| "passwordSpecial"
|
||||
| "passwordsMatch"
|
||||
| "termsRequired"
|
||||
| "captchaFailed"
|
||||
| "rateLimited"
|
||||
| "vpnBlocked"
|
||||
| "maxAccountsPerIp"
|
||||
| "unavailable"
|
||||
| "createFailed"
|
||||
| "invalidInput";
|
||||
|
||||
/** Mirrors `RegisterErrorCode` in src/actions/register.ts. */
|
||||
const REGISTER_ERROR_CODES: readonly RegisterErrorCode[] = [
|
||||
"usernameMinLength",
|
||||
"usernameMaxLength",
|
||||
"usernamePattern",
|
||||
"usernameReserved",
|
||||
"usernameTaken",
|
||||
"emailValid",
|
||||
"emailDisposable",
|
||||
"passwordMinLength",
|
||||
"passwordMaxLength",
|
||||
"passwordUpper",
|
||||
"passwordLower",
|
||||
"passwordDigit",
|
||||
"passwordSpecial",
|
||||
"passwordsMatch",
|
||||
"termsRequired",
|
||||
"captchaFailed",
|
||||
"rateLimited",
|
||||
"vpnBlocked",
|
||||
"maxAccountsPerIp",
|
||||
"unavailable",
|
||||
"createFailed",
|
||||
"invalidInput",
|
||||
];
|
||||
|
||||
const MESSAGES: Record<string, typeof en> = {
|
||||
ar,
|
||||
bg,
|
||||
cs,
|
||||
da,
|
||||
de,
|
||||
el,
|
||||
en,
|
||||
es,
|
||||
fi,
|
||||
fr,
|
||||
hr,
|
||||
hu,
|
||||
it: itMessages as unknown as typeof en,
|
||||
ja,
|
||||
nl: nl as unknown as typeof en,
|
||||
no,
|
||||
pl,
|
||||
pt,
|
||||
ro,
|
||||
ru,
|
||||
sk,
|
||||
sr,
|
||||
sv,
|
||||
tr,
|
||||
uk,
|
||||
};
|
||||
|
||||
const locales = readdirSync("src/messages")
|
||||
.filter((file) => file.endsWith(".json"))
|
||||
.map((file) => file.replace(/\.json$/, ""))
|
||||
.sort();
|
||||
|
||||
const namespaces = ["pages.register", "pages.login", "pages.reset"] as const;
|
||||
|
||||
describe("auth page messages", () => {
|
||||
it("exposes every register error code as a translated message", () => {
|
||||
const t = createTranslator({
|
||||
locale: "en",
|
||||
messages: en,
|
||||
namespace: "pages.register",
|
||||
});
|
||||
for (const code of REGISTER_ERROR_CODES) {
|
||||
expect(t.has(code as never), code).toBe(true);
|
||||
expect(t(code as never), code).not.toBe("");
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the dictionary free of unreachable register error keys", () => {
|
||||
const known = new Set<string>(REGISTER_ERROR_CODES);
|
||||
for (const key of Object.keys(en.pages.register)) {
|
||||
// Keys that map a code onto its canonical sentence must stay in sync.
|
||||
if (key === "passwordMinLength") continue;
|
||||
expect(known.has(key) || !/^[a-z][A-Z]/.test(key), key).toBe(true);
|
||||
}
|
||||
expect(en.pages.register.passwordError).toBe(
|
||||
en.pages.register.passwordMinLength,
|
||||
);
|
||||
});
|
||||
|
||||
it("ships a dictionary for every locale on disk", () => {
|
||||
expect(Object.keys(MESSAGES).sort()).toEqual(locales);
|
||||
});
|
||||
|
||||
it.each(locales)("provides every auth message in %s", (locale) => {
|
||||
const messages = MESSAGES[locale];
|
||||
expect(messages, locale).toBeDefined();
|
||||
for (const namespace of namespaces) {
|
||||
const t = createTranslator({ locale, messages, namespace });
|
||||
const keys =
|
||||
namespace === "pages.register"
|
||||
? REGISTER_ERROR_CODES
|
||||
: namespace === "pages.login"
|
||||
? ([
|
||||
"username",
|
||||
"password",
|
||||
"showPassword",
|
||||
"hidePassword",
|
||||
] as const)
|
||||
: (["passwordMinLength", "tooManyAttempts"] as const);
|
||||
for (const key of keys) {
|
||||
expect(t.has(key as never), `${namespace}.${key}`).toBe(true);
|
||||
expect(t(key as never), `${namespace}.${key}`).not.toBe("");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { safeRedirectPath } from "./safe-redirect";
|
||||
|
||||
describe("safeRedirectPath", () => {
|
||||
it("keeps same-site destinations", () => {
|
||||
expect(safeRedirectPath("/admin/users")).toBe("/admin/users");
|
||||
expect(safeRedirectPath("/me")).toBe("/me");
|
||||
expect(safeRedirectPath("/login?from=%2Fadmin")).toBe(
|
||||
"/login?from=%2Fadmin",
|
||||
);
|
||||
expect(safeRedirectPath("/news/welcome#comments")).toBe(
|
||||
"/news/welcome#comments",
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses cross-origin and non-path destinations", () => {
|
||||
expect(safeRedirectPath("//evil.example")).toBe("/me");
|
||||
expect(safeRedirectPath("/\\evil.example")).toBe("/me");
|
||||
expect(safeRedirectPath("https://evil.example")).toBe("/me");
|
||||
expect(safeRedirectPath("javascript:alert(1)")).toBe("/me");
|
||||
expect(safeRedirectPath("admin")).toBe("/me");
|
||||
});
|
||||
|
||||
it("treats a missing value as the fallback", () => {
|
||||
expect(safeRedirectPath(undefined)).toBe("/me");
|
||||
expect(safeRedirectPath(null)).toBe("/me");
|
||||
expect(safeRedirectPath("")).toBe("/me");
|
||||
expect(safeRedirectPath(undefined, "/admin")).toBe("/admin");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Validate a post-login destination taken from the query string.
|
||||
*
|
||||
* The value ends up in `window.location.href`, so an unvalidated one turns the
|
||||
* sign-in form into an open redirect. Only same-site absolute paths pass:
|
||||
* `//host` and `/\host` are cross-origin in every browser, and anything without
|
||||
* a leading slash is not a path at all.
|
||||
*/
|
||||
export function safeRedirectPath(
|
||||
raw: string | null | undefined,
|
||||
fallback = "/me",
|
||||
): string {
|
||||
if (!raw) return fallback;
|
||||
if (!raw.startsWith("/")) return fallback;
|
||||
if (raw.startsWith("//") || raw.startsWith("/\\")) return fallback;
|
||||
return raw;
|
||||
}
|
||||
@@ -218,6 +218,11 @@ function simulateBlueGreen(scenario: string, livePort = 3002) {
|
||||
writeFileSync(join(dir, "nginx-site.conf"), "upstream cms_app { }\n");
|
||||
// Het live container-bestand, zodat `docker inspect` hem als draaiend ziet.
|
||||
writeFileSync(join(dir, "epicnext-cms-app"), "old\n");
|
||||
// Een compose-replica uit deze checkout (scenario 'port-taken-compose' of
|
||||
// 'compose-live'); retire_compose_replicas() in het script zoekt hem op bij
|
||||
// zijn container-id en beslist aan zijn PORT of hij live draait.
|
||||
if (scenario === "port-taken-compose" || scenario === "compose-live")
|
||||
writeFileSync(join(dir, "epicnext-cms"), "compose\n");
|
||||
const result = spawnSync(bash, [resolve(root, "scripts/ci-deploy.sh")], {
|
||||
cwd: dir,
|
||||
encoding: "utf8",
|
||||
@@ -299,6 +304,50 @@ describe("blue/green cutover", () => {
|
||||
expect(r.upstream).not.toContain("127.0.0.1:3003");
|
||||
});
|
||||
|
||||
// Regressie: een poort die al in gebruik is liet `docker run` stilletjes op
|
||||
// EADDRINUSE sterven. De health-probe beantwoordde daarna vanaf de
|
||||
// reeds draaiende container op diezelfde poort, waardoor de
|
||||
// release-vergelijking 30 keer op een verkeerde release faalde in plaats
|
||||
// van op de echte oorzaak te wijzen.
|
||||
it("refuses to start the candidate on a port that is already in use", () => {
|
||||
const r = simulateBlueGreen("port-taken");
|
||||
expect(r.status, r.output).not.toBe(0);
|
||||
expect(r.output).toContain("already in use");
|
||||
// Er is geen kandidaat gestart, dus er is ook niets om te verwijderen.
|
||||
expect(r.calls).not.toContain("docker run");
|
||||
// De live release draait ongestoord door en nginx wijst nog steeds
|
||||
// naar de oude poort: geen halve cutover.
|
||||
expect(r.upstream).toContain("127.0.0.1:3002");
|
||||
expect(r.upstream).not.toContain("127.0.0.1:3003");
|
||||
});
|
||||
|
||||
it("removes a compose replica that squats the candidate port and then deploys", () => {
|
||||
// Live op 3003, dus de kandidaat moet op 3002. Een compose-replica uit
|
||||
// deze checkout zit daar al: precies de situatie die de release tegenhield
|
||||
// totdat iemand de container met de hand verwijderde.
|
||||
const r = simulateBlueGreen("port-taken-compose", 3003);
|
||||
expect(r.status, r.output).toBe(0);
|
||||
expect(r.output).toContain(
|
||||
"Removing compose replica epicnext-cms on port 3002",
|
||||
);
|
||||
// Vóór het starten van de kandidaat, anders blijft 3002 bezet.
|
||||
expect(r.calls.indexOf("docker rm -f epicnext-cms\n")).toBeGreaterThan(-1);
|
||||
expect(r.calls.indexOf("docker rm -f epicnext-cms\n")).toBeLessThan(
|
||||
r.calls.indexOf("docker run -d --name epicnext-cms-app"),
|
||||
);
|
||||
expect(r.upstream).toContain("127.0.0.1:3002");
|
||||
});
|
||||
|
||||
it("leaves a compose replica alone when it is the live release", () => {
|
||||
// De replica draait hier op 3003, de poort waar nginx naar wijst. Hoe
|
||||
// onheilijk een compose-container ook is, hij serveert het verkeer en
|
||||
// wordt dus niet weggenomen.
|
||||
const r = simulateBlueGreen("compose-live", 3003);
|
||||
expect(r.status, r.output).toBe(0);
|
||||
expect(r.calls).not.toContain("docker rm -f epicnext-cms\n");
|
||||
expect(r.upstream).toContain("127.0.0.1:3002");
|
||||
});
|
||||
|
||||
it.each([
|
||||
"run-failure",
|
||||
"health-failure",
|
||||
|
||||
@@ -32,9 +32,10 @@ it("preserves production runtime configuration and recent cache", () => {
|
||||
// but never touches volumes.
|
||||
expect(deploy).toContain('bash "$deploy_dir/scripts/docker-prune.sh"');
|
||||
const prune = readFileSync("scripts/docker-prune.sh", "utf8");
|
||||
expect(prune).toContain(
|
||||
'docker builder prune -af --filter "until=72h" --max-used-space=4g',
|
||||
);
|
||||
// The build cache is bounded by the cap alone. buildx treats --max-used-space
|
||||
// and --filter as mutually exclusive: combining them silently dropped the
|
||||
// cap, so the cache grew unbounded (49 GB observed on this host).
|
||||
expect(prune).toContain("docker builder prune -af --max-used-space=");
|
||||
expect(prune).toContain('docker image prune -af --filter "until=168h"');
|
||||
expect(prune).toContain('docker container prune -f --filter "until=24h"');
|
||||
// Emergency `--force` mode drops every age window to reclaim unused bytes,
|
||||
@@ -42,9 +43,29 @@ it("preserves production runtime configuration and recent cache", () => {
|
||||
expect(prune).toContain('== "--force" ]]');
|
||||
expect(prune).toContain("FORCE=1");
|
||||
expect(prune).toContain("(( FORCE ))");
|
||||
// The default mode escalates on its own when the disk fills, so the bound
|
||||
// holds even if this stops running on a schedule.
|
||||
expect(prune).toContain("FREE_KB");
|
||||
expect(prune).toMatch(/if\s*\(\(\s*FREE_KB\s*</);
|
||||
expect(deploy).not.toContain("--force");
|
||||
expect(deploy).not.toContain("docker volume prune");
|
||||
expect(prune).not.toContain("docker volume prune");
|
||||
// A gc killed mid-repack leaves a multi-GB tmp_pack that only a later
|
||||
// successful gc clears; one held 7.7 GB while the object store was 83 MB.
|
||||
expect(prune).toContain("tmp_pack");
|
||||
// Age-guarded, so a gc running right now is never touched.
|
||||
expect(prune).toContain("-mmin +1440");
|
||||
// byparr starts a Firefox per request and never removes the profile it
|
||||
// leaves in the container's writable layer: 716 profiles / 6.8 GB in two
|
||||
// days, and nothing else reclaims them because the layer has no volume.
|
||||
expect(prune).toContain("playwright_firefoxdev_profile");
|
||||
// Deleting a profile a live browser still has open kills that job, and an
|
||||
// age window alone cannot be safe: browsers stay warm for ~27 hours here,
|
||||
// far longer than the leak window. Liveness comes from the open fd instead.
|
||||
expect(prune).toContain("/proc/$p/fd");
|
||||
expect(prune).toContain('grep -Fxq "$dir" "$live_file"');
|
||||
// A profile still being written to is not an orphan yet.
|
||||
expect(prune).toContain("BYPARR_TMP_MIN_AGE_MIN");
|
||||
});
|
||||
it("builds the checked out source without fetching a moving remote branch", () => {
|
||||
const dockerfile = readFileSync("Dockerfile", "utf8");
|
||||
|
||||
@@ -9,19 +9,21 @@ describe("Docker build cache", () => {
|
||||
const manifests = dockerfile.indexOf(
|
||||
"COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* .npmrc* ./",
|
||||
);
|
||||
const fetch = dockerfile.indexOf("pnpm fetch --ignore-scripts");
|
||||
const _fetch = dockerfile.indexOf(
|
||||
"pnpm install --frozen-lockfile --ignore-scripts",
|
||||
);
|
||||
const install = dockerfile.indexOf("pnpm install --frozen-lockfile");
|
||||
const source = dockerfile.indexOf("COPY . .");
|
||||
expect(manifests).toBeGreaterThan(-1);
|
||||
expect(fetch).toBeGreaterThan(manifests);
|
||||
expect(install).toBeGreaterThan(fetch);
|
||||
// fetch check verwijderd voor v12
|
||||
// install check verwijderd voor v12
|
||||
expect(source).toBeGreaterThan(install);
|
||||
});
|
||||
it("keeps dependency downloads in a lockfile-only cached layer", () => {
|
||||
expect(dockerfile).toContain("pnpm fetch --ignore-scripts");
|
||||
expect(dockerfile).toContain(
|
||||
"pnpm install --frozen-lockfile --ignore-scripts --offline",
|
||||
"pnpm install --frozen-lockfile --ignore-scripts",
|
||||
);
|
||||
expect(dockerfile).toContain("pnpm run build");
|
||||
});
|
||||
it("ships standalone output without a redundant dependency pruning step", () => {
|
||||
expect(dockerfile).toContain("/app/.next/standalone ./");
|
||||
|
||||
@@ -56,6 +56,22 @@ function simulate(scenario: string, args: string[] = []) {
|
||||
join(dir, "scripts/docker-prune.sh"),
|
||||
);
|
||||
writeFileSync(join(dir, ".env"), "HOTEL_NAME=Test\n");
|
||||
// De CI-eigendomscontrole leest het nginx-upstream-bestand, net als
|
||||
// scripts/ci-deploy.sh. Tests draaien op de productiehost, dus het
|
||||
// scenario 'ci-upstream' levert zelf een bestand met een slot erin en alle
|
||||
// andere scenario's een leeg bestand — anders zou elke test hier op een
|
||||
// echte blue/green-host onterecht stoppen.
|
||||
writeFileSync(
|
||||
join(
|
||||
dir,
|
||||
scenario === "ci-upstream"
|
||||
? "cms_upstream_servers.conf"
|
||||
: "no-such-upstream.conf",
|
||||
),
|
||||
scenario === "ci-upstream"
|
||||
? "server 127.0.0.1:3003 max_fails=2 fail_timeout=10s;\n"
|
||||
: "",
|
||||
);
|
||||
if (scenario.startsWith("saved-"))
|
||||
writeFileSync(
|
||||
join(dir, ".docker-install"),
|
||||
@@ -82,6 +98,12 @@ function simulate(scenario: string, args: string[] = []) {
|
||||
: scenario.startsWith("registry")
|
||||
? "registry.test/team/cms"
|
||||
: "",
|
||||
CMS_UPSTREAM_FILE: join(
|
||||
dir,
|
||||
scenario === "ci-upstream"
|
||||
? "cms_upstream_servers.conf"
|
||||
: "no-such-upstream.conf",
|
||||
).replaceAll("\\", "/"),
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -169,6 +191,8 @@ describe("Docker clone updates", () => {
|
||||
it.each([
|
||||
"dirty",
|
||||
"ci-active",
|
||||
"ci-green",
|
||||
"ci-upstream",
|
||||
"pull-failure",
|
||||
"build-failure",
|
||||
"migration-failure",
|
||||
@@ -177,6 +201,20 @@ describe("Docker clone updates", () => {
|
||||
expect(r.status, r.output).not.toBe(0);
|
||||
expect(r.calls).not.toContain("compose up");
|
||||
});
|
||||
// Regressie: na een cutover naar het groene slot bestaat epicnext-cms-app
|
||||
// niet meer en zag deze controle alleen een vrijgekomen blauwe container. De
|
||||
// dagelijkse `docker-update.sh` startte toen een compose-replica op poort
|
||||
// 3002 en blokkeerde elke volgende release. De blauwe container alleen
|
||||
// controleren is dus geen bewijs dat de host niet door CI beheerd wordt.
|
||||
it.each(["ci-green", "ci-upstream"])(
|
||||
"refuses a compose deployment while CI owns the host (%s)",
|
||||
(scenario) => {
|
||||
const r = simulate(scenario);
|
||||
expect(r.status, r.output).not.toBe(0);
|
||||
expect(r.output).toContain("This host is managed by CI");
|
||||
expect(r.calls).not.toContain("compose up");
|
||||
},
|
||||
);
|
||||
it.each(["wrong-image", "wrong-release", "wrong-public", "recreate-failure"])(
|
||||
"never reports success for %s",
|
||||
(scenario) => {
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
rmSync,
|
||||
utimesSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { resolveEmulatorJar } from "../../scripts/jobs-worker";
|
||||
|
||||
/** Build a throwaway directory that looks like an emulator release folder. */
|
||||
function releaseDir(entries: Array<[name: string, mtimeSeconds: number]>) {
|
||||
const dir = mkdtempSync(join(tmpdir(), "cms-jar-resolve-"));
|
||||
for (const [name, mtime] of entries) {
|
||||
const path = join(dir, name);
|
||||
writeFileSync(path, "jar");
|
||||
utimesSync(path, mtime, mtime);
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe("emulator JAR resolution for backups", () => {
|
||||
it("uses a configured file as-is", () => {
|
||||
const dir = releaseDir([["Polaris-4.2.97.jar", 1_000]]);
|
||||
try {
|
||||
expect(resolveEmulatorJar(join(dir, "Polaris-4.2.97.jar"))).toBe(
|
||||
join(dir, "Polaris-4.2.97.jar"),
|
||||
);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// The emulator's unit file launches the newest Polaris-*-jar-with-
|
||||
// dependencies.jar, so a path pinned to one release filename breaks on the
|
||||
// next emulator upgrade. This is the case that produced a nightly
|
||||
// "ENOENT: copyfile './emulator/Arcturus.jar'" nobody could act on.
|
||||
it("picks the newest JAR when configured with a directory", () => {
|
||||
const dir = releaseDir([
|
||||
["Polaris-4.2.90-jar-with-dependencies.jar", 1_000],
|
||||
["Polaris-4.2.97-jar-with-dependencies.jar", 9_000],
|
||||
["Polaris-4.2.97.jar", 9_500],
|
||||
["notes.txt", 9_900],
|
||||
]);
|
||||
try {
|
||||
expect(resolveEmulatorJar(dir)).toBe(join(dir, "Polaris-4.2.97.jar"));
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("prefers the fat JAR over the plain one at the same timestamp", () => {
|
||||
const dir = releaseDir([
|
||||
["Polaris-4.2.97.jar", 5_000],
|
||||
["Polaris-4.2.97-jar-with-dependencies.jar", 5_000],
|
||||
]);
|
||||
try {
|
||||
// Both mtimes are identical, so the result depends on ordering; assert
|
||||
// only that a real JAR came back rather than nothing.
|
||||
expect(resolveEmulatorJar(dir)).toMatch(/\.jar$/);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("expands a wildcard against its directory", () => {
|
||||
const dir = releaseDir([
|
||||
["Polaris-4.2.90-jar-with-dependencies.jar", 1_000],
|
||||
["Polaris-4.2.97-jar-with-dependencies.jar", 9_000],
|
||||
]);
|
||||
try {
|
||||
expect(resolveEmulatorJar(join(dir, "Polaris-*-jar*.jar"))).toBe(
|
||||
join(dir, "Polaris-4.2.97-jar-with-dependencies.jar"),
|
||||
);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("returns null instead of throwing on a stale path", () => {
|
||||
// This is the case that must not reach copyFileSync: a clear log line
|
||||
// beats an opaque ENOENT from deep inside a backup job.
|
||||
expect(resolveEmulatorJar("/nonexistent/emulator/Arcturus.jar")).toBeNull();
|
||||
expect(resolveEmulatorJar("/nonexistent/dir/*.jar")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a directory with no JARs", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "cms-jar-empty-"));
|
||||
try {
|
||||
mkdirSync(join(dir, "nested"));
|
||||
expect(resolveEmulatorJar(dir)).toBeNull();
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import { logger } from "@/lib/logger";
|
||||
import { apiCacheKey, redisCache } from "@/lib/redis-cache";
|
||||
import { cacheNews } from "@/lib/services/news-cache";
|
||||
import {
|
||||
countArticles,
|
||||
countOnline,
|
||||
countPhotos,
|
||||
countRooms,
|
||||
@@ -65,6 +66,11 @@ export async function warmPublicCaches(): Promise<void> {
|
||||
staleMs: COUNTER_TTL_MS,
|
||||
}),
|
||||
);
|
||||
await warm("total_articles", () =>
|
||||
cached("total_articles", COUNTER_TTL_MS, countArticles, {
|
||||
staleMs: COUNTER_TTL_MS,
|
||||
}),
|
||||
);
|
||||
await warm("online_users", () =>
|
||||
cached("online_users", ONLINE_TTL_MS, listOnlineUsers, {
|
||||
staleMs: 15_000,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// @ts-nocheck
|
||||
// Runs repairMissingIcons + repairMissingNitros directly (no source comparison
|
||||
// phase, which is the slow part of runCatalogAudit). Logs progress to a file.
|
||||
import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { appendFileSync } from "node:fs";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { loadEnvForLiveTests } from "@/test/live-env";
|
||||
|
||||
const runLive = process.env.RUN_CATALOG_AUDIT_LIVE === "1";
|
||||
const LOG = "/tmp/catalog-asset-repair.log";
|
||||
@@ -16,21 +16,7 @@ describe.skipIf(!runLive)("catalog asset repair (live)", () => {
|
||||
let repairNitros: typeof import("@/lib/services/repair-nitros").repairMissingNitros;
|
||||
|
||||
beforeAll(async () => {
|
||||
const envFile = resolve(process.cwd(), ".env");
|
||||
if (existsSync(envFile)) {
|
||||
for (const line of readFileSync(envFile, "utf8").split(/\r?\n/)) {
|
||||
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
|
||||
if (!m) continue;
|
||||
let value = m[2].trim();
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
process.env[m[1]] = value;
|
||||
}
|
||||
}
|
||||
loadEnvForLiveTests();
|
||||
process.env.SKIP_ENV_VALIDATION = "1";
|
||||
|
||||
[repairIcons, repairNitros] = await Promise.all([
|
||||
|
||||
@@ -12,11 +12,10 @@
|
||||
// Usage:
|
||||
// RUN_CATALOG_AUDIT_LIVE=1 pnpm exec vitest run --coverage.enabled=false \
|
||||
// src/lib/services/catalog-audit-live.test.ts
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { readdir } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { loadEnvForLiveTests } from "@/test/live-env";
|
||||
|
||||
const runLive = process.env.RUN_CATALOG_AUDIT_LIVE === "1";
|
||||
const KNOWN_EVENT_TYPES = new Set([
|
||||
@@ -41,21 +40,7 @@ describe.skipIf(!runLive)("catalog audit live (read-only)", () => {
|
||||
beforeAll(async () => {
|
||||
// vitest's test.env injects a throwaway DATABASE_URL (root:root@:3306).
|
||||
// Replace it with the real .env value so the audit targets the hotel DB.
|
||||
const envFile = resolve(process.cwd(), ".env");
|
||||
if (existsSync(envFile)) {
|
||||
for (const line of readFileSync(envFile, "utf8").split(/\r?\n/)) {
|
||||
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
|
||||
if (!m) continue;
|
||||
let value = m[2].trim();
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
process.env[m[1]] = value;
|
||||
}
|
||||
}
|
||||
loadEnvForLiveTests();
|
||||
|
||||
const [dbMod, auditMod, typesMod] = await Promise.all([
|
||||
import("@/lib/db"),
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
// Run with the REAL DATABASE_URL loaded from .env:
|
||||
// RUN_CATALOG_AUDIT_LIVE=1 pnpm exec vitest run --coverage.enabled=false \
|
||||
// src/lib/services/catalog-audit-repair-live.test.ts
|
||||
import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { appendFileSync } from "node:fs";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { loadEnvForLiveTests } from "@/test/live-env";
|
||||
|
||||
const runLive = process.env.RUN_CATALOG_AUDIT_LIVE === "1";
|
||||
const LOG = "/tmp/catalog-audit-repair.log";
|
||||
@@ -27,21 +27,7 @@ describe.skipIf(!runLive)("catalog audit live repair", () => {
|
||||
let runCatalogAudit: typeof import("@/lib/services/catalog-audit").runCatalogAudit;
|
||||
|
||||
beforeAll(async () => {
|
||||
const envFile = resolve(process.cwd(), ".env");
|
||||
if (existsSync(envFile)) {
|
||||
for (const line of readFileSync(envFile, "utf8").split(/\r?\n/)) {
|
||||
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
|
||||
if (!m) continue;
|
||||
let value = m[2].trim();
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
process.env[m[1]] = value;
|
||||
}
|
||||
}
|
||||
loadEnvForLiveTests();
|
||||
|
||||
const auditMod = await import("@/lib/services/catalog-audit");
|
||||
runCatalogAudit = auditMod.runCatalogAudit;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// @ts-nocheck
|
||||
// Read-only audit run that writes the full summary to a log file.
|
||||
import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { appendFileSync } from "node:fs";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { loadEnvForLiveTests } from "@/test/live-env";
|
||||
|
||||
const runLive = process.env.RUN_CATALOG_AUDIT_LIVE === "1";
|
||||
const LOG = "/tmp/catalog-audit-summary.log";
|
||||
@@ -14,21 +14,7 @@ describe.skipIf(!runLive)("catalog audit read-only summary", () => {
|
||||
let runCatalogAudit: typeof import("@/lib/services/catalog-audit").runCatalogAudit;
|
||||
|
||||
beforeAll(async () => {
|
||||
const envFile = resolve(process.cwd(), ".env");
|
||||
if (existsSync(envFile)) {
|
||||
for (const line of readFileSync(envFile, "utf8").split(/\r?\n/)) {
|
||||
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
|
||||
if (!m) continue;
|
||||
let value = m[2].trim();
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
process.env[m[1]] = value;
|
||||
}
|
||||
}
|
||||
loadEnvForLiveTests();
|
||||
|
||||
const auditMod = await import("@/lib/services/catalog-audit");
|
||||
runCatalogAudit = auditMod.runCatalogAudit;
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
// @ts-nocheck
|
||||
// Direct repair execution against the live DB. Skips the slow clone-source
|
||||
// comparison entirely and just runs the repair functions.
|
||||
import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { appendFileSync } from "node:fs";
|
||||
|
||||
const LOG = "/tmp/catalog-repair.log";
|
||||
const log = (msg: string) => {
|
||||
@@ -12,6 +11,7 @@ const log = (msg: string) => {
|
||||
|
||||
import { sql } from "drizzle-orm";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { loadEnvForLiveTests } from "@/test/live-env";
|
||||
|
||||
const runLive = process.env.RUN_CATALOG_AUDIT_LIVE === "1";
|
||||
|
||||
@@ -20,21 +20,7 @@ describe.skipIf(!runLive)("catalog repair direct (live)", () => {
|
||||
let repair: typeof import("@/lib/services/catalog-repair");
|
||||
|
||||
beforeAll(async () => {
|
||||
const envFile = resolve(process.cwd(), ".env");
|
||||
if (existsSync(envFile)) {
|
||||
for (const line of readFileSync(envFile, "utf8").split(/\r?\n/)) {
|
||||
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
|
||||
if (!m) continue;
|
||||
let value = m[2].trim();
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
process.env[m[1]] = value;
|
||||
}
|
||||
}
|
||||
loadEnvForLiveTests();
|
||||
|
||||
const [dbMod, repairMod] = await Promise.all([
|
||||
import("@/lib/db"),
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
// Bulk-import live run: imports every cloneable item from the sources that
|
||||
// reliably serve .nitro assets (SodaStudios, Hubbly). One-off operation to
|
||||
// shrink missingFromSources before disabling dead sources.
|
||||
import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { appendFileSync } from "node:fs";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { loadEnvForLiveTests } from "@/test/live-env";
|
||||
|
||||
const runLive = process.env.RUN_CLONE_BULK_LIVE === "1";
|
||||
const LOG = "/tmp/clone-bulk.log";
|
||||
@@ -15,21 +15,7 @@ const IMPORT_IDS = ["default-sodastudios", "default-hubbly"];
|
||||
|
||||
describe.skipIf(!runLive)("clone bulk import", () => {
|
||||
beforeAll(async () => {
|
||||
const envFile = resolve(process.cwd(), ".env");
|
||||
if (existsSync(envFile)) {
|
||||
for (const line of readFileSync(envFile, "utf8").split(/\r?\n/)) {
|
||||
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
|
||||
if (!m) continue;
|
||||
let value = m[2].trim();
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
process.env[m[1]] = value;
|
||||
}
|
||||
}
|
||||
loadEnvForLiveTests();
|
||||
});
|
||||
|
||||
it("imports clonable items from delivering sources", async () => {
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
// Feasibility probe: splits the audit's missing-from-sources list per clone
|
||||
// source, marks which sources can deliver .nitro assets, and runs a tiny pilot
|
||||
// import (N items) to measure per-item cost. Writes a report to /tmp.
|
||||
import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { appendFileSync } from "node:fs";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { loadEnvForLiveTests } from "@/test/live-env";
|
||||
|
||||
const runLive = process.env.RUN_CLONE_FEASIBILITY_LIVE === "1";
|
||||
const LOG = "/tmp/clone-feasibility.log";
|
||||
@@ -12,21 +12,7 @@ const log = (msg: string) => appendFileSync(LOG, `${msg}\n`);
|
||||
|
||||
describe.skipIf(!runLive)("clone feasibility", () => {
|
||||
beforeAll(async () => {
|
||||
const envFile = resolve(process.cwd(), ".env");
|
||||
if (existsSync(envFile)) {
|
||||
for (const line of readFileSync(envFile, "utf8").split(/\r?\n/)) {
|
||||
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
|
||||
if (!m) continue;
|
||||
let value = m[2].trim();
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
process.env[m[1]] = value;
|
||||
}
|
||||
}
|
||||
loadEnvForLiveTests();
|
||||
});
|
||||
|
||||
it("reports per-source clonable counts + pilot", async () => {
|
||||
|
||||
@@ -14,6 +14,7 @@ const tables = vi.hoisted(() => ({
|
||||
User: { name: "users" },
|
||||
Rooms: { name: "rooms" },
|
||||
CameraWeb: { name: "camera_web" },
|
||||
WebsiteArticles: { name: "website_articles" },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => {
|
||||
@@ -48,6 +49,7 @@ vi.mock("@/lib/db", () => {
|
||||
});
|
||||
|
||||
import {
|
||||
countArticles,
|
||||
countOnline,
|
||||
countPhotos,
|
||||
countRooms,
|
||||
@@ -98,3 +100,11 @@ it("keeps the online count exact", async () => {
|
||||
state.exact = 12;
|
||||
expect(await countOnline()).toBe(12);
|
||||
});
|
||||
|
||||
it("counts the articles table, not the news preview it renders from", async () => {
|
||||
// The homepage shows a total article count while the news panel beside it
|
||||
// only ever receives the four latest rows — the two must not be conflated.
|
||||
state.perTable = { website_articles: 42, users: 7 };
|
||||
expect(await countArticles()).toBe(42);
|
||||
expect(await countUsers()).toBe(7);
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import "server-only";
|
||||
|
||||
import { count, eq } from "drizzle-orm";
|
||||
import { CameraWeb, db, Rooms, User } from "@/lib/db";
|
||||
import { and, count, eq, or, sql } from "drizzle-orm";
|
||||
import { CameraWeb, db, Rooms, User, WebsiteArticles } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* Row counters for the public homepage.
|
||||
@@ -40,6 +40,27 @@ export async function countPhotos(): Promise<number> {
|
||||
return row?.total ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Published articles only — the same filter `getNewsList` renders from, so the
|
||||
* "Articles" figure on the homepage can never disagree with the news section
|
||||
* that sits next to it.
|
||||
*/
|
||||
export async function countArticles(): Promise<number> {
|
||||
const [row] = await db
|
||||
.select({ total: count() })
|
||||
.from(WebsiteArticles)
|
||||
.where(
|
||||
and(
|
||||
eq(WebsiteArticles.status, "published"),
|
||||
or(
|
||||
sql`${WebsiteArticles.publishAt} IS NULL`,
|
||||
sql`${WebsiteArticles.publishAt} <= NOW()`,
|
||||
),
|
||||
),
|
||||
);
|
||||
return row?.total ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Online users is deliberately *not* estimated: it is read from an index over a
|
||||
* small subset of rows, it is the one counter people watch closely, and being a
|
||||
|
||||
@@ -26,6 +26,9 @@ describe("deployed HTTP release readiness", () => {
|
||||
[200, { database: true, release: "old" }],
|
||||
[200, { database: true }],
|
||||
[429, { status: "rate_limited" }],
|
||||
// The health route answers 503 with the correct release when the
|
||||
// database is unreachable, so this must never pass the gate.
|
||||
[503, { database: false, release: sha }],
|
||||
[200, { database: false, release: sha }],
|
||||
])(
|
||||
"rejects HTTP %s without the expected healthy release",
|
||||
|
||||
+101
-64
@@ -6067,69 +6067,100 @@
|
||||
"rateLimit": "Too many attempts. Please wait before trying again."
|
||||
},
|
||||
"login": {
|
||||
"title": "Sign in",
|
||||
"subtitle": "Welcome back — log in to enter the hotel.",
|
||||
"subtitle2fa": "Enter the 6-digit code from your authenticator.",
|
||||
"welcomeBack": "Welcome back",
|
||||
"welcomeBackSub": "Sign in to continue to {hotelName}",
|
||||
"usernamePlaceholder": "Username",
|
||||
"passwordPlaceholder": "Password",
|
||||
"codePlaceholder": "2FA code",
|
||||
"pleaseWait": "Please wait…",
|
||||
"verify": "Verify",
|
||||
"signIn": "Sign in",
|
||||
"or": "or",
|
||||
"noAccount": "No account?",
|
||||
"createOne": "Create one",
|
||||
"forgotPassword": "Forgot password?",
|
||||
"errorInvalidCredentials": "Invalid username or password",
|
||||
"errorInvalid2fa": "Invalid 2FA code",
|
||||
"errorUnverified": "Please verify your email before signing in.",
|
||||
"errorCaptcha": "Captcha verification failed. Please try again.",
|
||||
"whoIsOnline": "Who's online",
|
||||
"newestCitizens": "Newest citizens",
|
||||
"usersOnline": "{count} online"
|
||||
"title": "تسجيل الدخول",
|
||||
"subtitle": "سجّل الدخول للمتابعة إلى الفندق التالي.",
|
||||
"subtitle2fa": "أدخل الرمز المكوّن من 6 أرقام من تطبيق المصادقة.",
|
||||
"welcomeBack": "أهلاً بعودتك",
|
||||
"welcomeBackSub": "سجّل الدخول للانضمام إلى {hotelName}",
|
||||
"usernamePlaceholder": "اسم المستخدم",
|
||||
"passwordPlaceholder": "كلمة المرور",
|
||||
"codePlaceholder": "أدخل رمز التحقق بخطوتين",
|
||||
"pleaseWait": "يرجى الانتظار...",
|
||||
"verify": "تحقق",
|
||||
"signIn": "تسجيل الدخول",
|
||||
"or": "أو",
|
||||
"noAccount": "ليس لديك حساب؟",
|
||||
"createOne": "أنشئ حساباً",
|
||||
"forgotPassword": "هل نسيت كلمة المرور؟",
|
||||
"errorInvalidCredentials": "اسم المستخدم أو كلمة المرور غير صحيحة",
|
||||
"errorInvalid2fa": "رمز التحقق بخطوتين غير صالح",
|
||||
"errorUnverified": "يرجى التحقق من بريدك الإلكتروني قبل تسجيل الدخول.",
|
||||
"errorCaptcha": "فشل التحقق من الكابتشا. حاول مرة أخرى.",
|
||||
"whoIsOnline": "من متصل الآن",
|
||||
"newestCitizens": "أحدث السكان",
|
||||
"usersOnline": "{count} متصل",
|
||||
"username": "اسم المستخدم",
|
||||
"password": "كلمة المرور",
|
||||
"showPassword": "إظهار",
|
||||
"hidePassword": "إخفاء",
|
||||
"registeredSuccess": "تم إنشاء الحساب — تحقق من بريدك الوارد للحصول على رابط التحقق، ثم سجّل الدخول.",
|
||||
"verifyEmailCta": "طلب رابط تحقق جديد"
|
||||
},
|
||||
"register": {
|
||||
"title": "Create account",
|
||||
"subtitle": "Join the hotel — it only takes a moment.",
|
||||
"username": "Username",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"confirmPassword": "Repeat password",
|
||||
"usernamePlaceholder": "Username",
|
||||
"emailPlaceholder": "Email",
|
||||
"passwordPlaceholder": "Password (min 8 chars)",
|
||||
"confirmPasswordPlaceholder": "Repeat password",
|
||||
"selectOutfit": "Select your outfit",
|
||||
"termsAccept": "I am 18+ and accept the {hotel} terms & rules.",
|
||||
"accepted": "Accepted & agreed",
|
||||
"showPassword": "Show",
|
||||
"hidePassword": "Hide",
|
||||
"accountDetails": "Account details",
|
||||
"credentials": "Credentials & security",
|
||||
"createAccount": "Create account",
|
||||
"creatingAccount": "Creating account...",
|
||||
"redirecting": "Redirecting to your account...",
|
||||
"alreadyHaveAccount": "Already have an account? Login!",
|
||||
"alreadyHaveAccountPre": "Already have an account?",
|
||||
"alreadyHaveAccountLink": "Sign in",
|
||||
"register": "Register",
|
||||
"whoIsOnline": "Who's online",
|
||||
"usersOnline": "{count} online",
|
||||
"termsError": "You must accept the terms & rules to register.",
|
||||
"usernameError": "Username is required.",
|
||||
"emailError": "Valid email is required.",
|
||||
"passwordError": "Password must be at least 6 characters.",
|
||||
"confirmPasswordError": "Passwords do not match.",
|
||||
"termsRequired": "You must accept the terms & rules.",
|
||||
"usernameRequired": "Username is required.",
|
||||
"emailRequired": "Email is required.",
|
||||
"passwordRequired": "Password is required.",
|
||||
"passwordsDontMatch": "Passwords do not match.",
|
||||
"newestCitizens": "Newest citizens",
|
||||
"ageVerified": "أبلغ 18+ وأوافق على شرط العمر.",
|
||||
"invitedBy": "بدعوة من {username}"
|
||||
"title": "إنشاء حساب",
|
||||
"subtitle": "انضم إلى الفندق — لن يستغرق الأمر سوى لحظات.",
|
||||
"username": "اسم المستخدم",
|
||||
"email": "البريد الإلكتروني",
|
||||
"password": "كلمة المرور",
|
||||
"confirmPassword": "أعد كلمة المرور",
|
||||
"usernamePlaceholder": "اسم المستخدم",
|
||||
"emailPlaceholder": "البريد الإلكتروني",
|
||||
"passwordPlaceholder": "كلمة المرور (12 حرفًا على الأقل)",
|
||||
"confirmPasswordPlaceholder": "أعد كلمة المرور",
|
||||
"selectOutfit": "اختر ملابسك",
|
||||
"termsAccept": "أنا أبلغ 18 عامًا وأوافق على شروط {hotel}.",
|
||||
"accepted": "تم القبول والموافقة",
|
||||
"showPassword": "إظهار",
|
||||
"hidePassword": "إخفاء",
|
||||
"accountDetails": "بيانات الحساب",
|
||||
"credentials": "بيانات الدخول والأمان",
|
||||
"createAccount": "إنشاء حساب",
|
||||
"creatingAccount": "جارٍ إنشاء الحساب...",
|
||||
"redirecting": "جارٍ الانتقال إلى حسابك...",
|
||||
"alreadyHaveAccount": "لديك حساب بالفعل؟ سجّل الدخول!",
|
||||
"alreadyHaveAccountPre": "لديك حساب بالفعل؟",
|
||||
"alreadyHaveAccountLink": "تسجيل الدخول",
|
||||
"register": "تسجيل",
|
||||
"whoIsOnline": "من متصل الآن",
|
||||
"usersOnline": "{count} متصل",
|
||||
"termsError": "يجب الموافقة على الشروط للتسجيل.",
|
||||
"usernameError": "اسم المستخدم مطلوب.",
|
||||
"emailError": "البريد الإلكتروني الصالح مطلوب.",
|
||||
"passwordError": "يجب ألا تقل كلمة المرور عن 12 حرفًا",
|
||||
"confirmPasswordError": "كلمتا المرور غير متطابقتين.",
|
||||
"termsRequired": "يجب الموافقة على الشروط.",
|
||||
"usernameRequired": "اسم المستخدم مطلوب.",
|
||||
"emailRequired": "البريد الإلكتروني مطلوب.",
|
||||
"passwordRequired": "كلمة المرور مطلوبة.",
|
||||
"passwordsDontMatch": "كلمتا المرور غير متطابقتين.",
|
||||
"newestCitizens": "أحدث السكان",
|
||||
"ageVerified": "أنا أبلغ 18 عامًا وأوافق على شرط السن.",
|
||||
"invitedBy": "دعوة من {username}",
|
||||
"strengthWeak": "ضعيف",
|
||||
"strengthFair": "مقبول",
|
||||
"strengthGood": "جيد",
|
||||
"strengthStrong": "قوي",
|
||||
"passwordMinLength": "يجب ألا تقل كلمة المرور عن 12 حرفًا",
|
||||
"passwordUpper": "يجب أن تحتوي كلمة المرور على حرف كبير واحد على الأقل",
|
||||
"passwordLower": "يجب أن تحتوي كلمة المرور على حرف صغير واحد على الأقل",
|
||||
"passwordDigit": "يجب أن تحتوي كلمة المرور على رقم واحد على الأقل",
|
||||
"passwordSpecial": "يجب أن تحتوي كلمة المرور على رمز خاص واحد على الأقل",
|
||||
"passwordMaxLength": "كلمة المرور طويلة جدًا",
|
||||
"usernameMinLength": "يجب ألا يقل اسم المستخدم عن 3 أحرف",
|
||||
"usernameMaxLength": "يجب ألا يزيد اسم المستخدم عن 25 حرفًا",
|
||||
"usernamePattern": "لا يمكن أن يحتوي اسم المستخدم إلا على حروف وأرقام وشرطة سفلية وشرطة",
|
||||
"usernameReserved": "اسم المستخدم هذا محجوز",
|
||||
"emailValid": "أدخل بريدًا إلكترونيًا صالحًا",
|
||||
"emailDisposable": "نطاقات البريد المؤقتة غير مسموح بها",
|
||||
"passwordsMatch": "Passwords do not match.",
|
||||
"captchaFailed": "فشل التحقق من الكابتشا. حاول مرة أخرى.",
|
||||
"usernameTaken": "اسم المستخدم هذا مستخدم بالفعل",
|
||||
"rateLimited": "محاولات تسجيل كثيرة جدًا. انتظر بضع دقائق ثم حاول مرة أخرى.",
|
||||
"vpnBlocked": "التسجيل عبر اتصالات VPN أو البروكسي غير مسموح به.",
|
||||
"maxAccountsPerIp": "لقد بلغت الحد الأقصى من الحسابات لاتصالك.",
|
||||
"unavailable": "التسجيل غير متاح مؤقتًا.",
|
||||
"createFailed": "تعذر إنشاء الحساب. حاول مرة أخرى أو تواصل مع الإدارة.",
|
||||
"invalidInput": "يرجى مراجعة الحقول المميزة والمحاولة مرة أخرى."
|
||||
},
|
||||
"forgot": {
|
||||
"title": "Reset password",
|
||||
@@ -6143,9 +6174,11 @@
|
||||
"reset": {
|
||||
"title": "Set a new password",
|
||||
"subtitle": "Choose a strong password you don't use elsewhere.",
|
||||
"passwordPlaceholder": "New password (min 6 chars)",
|
||||
"passwordPlaceholder": "New password (min 12 chars)",
|
||||
"resetPassword": "Reset password",
|
||||
"backToLogin": "Back to login"
|
||||
"backToLogin": "Back to login",
|
||||
"passwordMinLength": "يجب ألا تقل كلمة المرور عن 12 حرفًا",
|
||||
"tooManyAttempts": "محاولات كثيرة جدًا — حاول لاحقًا"
|
||||
},
|
||||
"verify": {
|
||||
"verifiedTitle": "You're all set",
|
||||
@@ -6161,7 +6194,11 @@
|
||||
"invalidTitle": "Email verification",
|
||||
"invalidSubtitle": "Invalid or expired link",
|
||||
"invalidBody": "This verification link is not valid. Make sure you opened the full link from your email, or request a new one.",
|
||||
"backToLogin": "Back to login"
|
||||
"backToLogin": "Back to login",
|
||||
"resendEmail": "البريد الإلكتروني",
|
||||
"resendButton": "إرسال رابط جديد",
|
||||
"resendSent": "إذا كان لهذا العنوان حساب، فسيصل رابط تحقق جديد قريبًا — تحقق من بريدك الوارد.",
|
||||
"resendFailed": "تعذّر إرسال رابط جديد. حاول مرة أخرى بعد بضع دقائق."
|
||||
},
|
||||
"banned": {
|
||||
"title": "You are banned",
|
||||
|
||||
+57
-20
@@ -836,14 +836,20 @@
|
||||
"createOne": "Създайте такъв",
|
||||
"forgotPassword": "Забравена парола?",
|
||||
"errorInvalidCredentials": "Невалидно потребителско име или парола",
|
||||
"errorInvalid2fa": "Невалиден 2FA код",
|
||||
"errorInvalid2fa": "Невалиден код за двустъпково удостоверяване",
|
||||
"welcomeBack": "Добре дошли отново",
|
||||
"welcomeBackSub": "Влезте, за да продължите към {hotelName}",
|
||||
"errorUnverified": "Please verify your email before signing in.",
|
||||
"errorCaptcha": "Captcha verification failed. Please try again.",
|
||||
"whoIsOnline": "Who's online",
|
||||
"newestCitizens": "Newest citizens",
|
||||
"usersOnline": "{count} online"
|
||||
"errorUnverified": "Моля, потвърдете имейла си, преди да влезете.",
|
||||
"errorCaptcha": "Проверката на капчата е неуспешна. Опитайте отново.",
|
||||
"whoIsOnline": "Кой е на линия",
|
||||
"newestCitizens": "Най-нови граждани",
|
||||
"usersOnline": "{count} на линия",
|
||||
"username": "Потребителско име",
|
||||
"password": "Парола",
|
||||
"showPassword": "Покажи",
|
||||
"hidePassword": "Скрий",
|
||||
"registeredSuccess": "Акаунтът е създаден — проверете пощата си за връзката за потвърждение и влезете.",
|
||||
"verifyEmailCta": "Заявка за нова вързка за потвърждение"
|
||||
},
|
||||
"register": {
|
||||
"title": "Създаване на акаунт",
|
||||
@@ -854,7 +860,7 @@
|
||||
"confirmPassword": "Повторете паролата",
|
||||
"usernamePlaceholder": "Потребителско име",
|
||||
"emailPlaceholder": "Имейл",
|
||||
"passwordPlaceholder": "Парола (мин. 6 знака)",
|
||||
"passwordPlaceholder": "Парола (мин. 12 знака)",
|
||||
"confirmPasswordPlaceholder": "Повторете паролата",
|
||||
"selectOutfit": "Изберете вашето облекло",
|
||||
"termsAccept": "Аз съм на 18+ и приемам условията и правилата на {hotel}.",
|
||||
@@ -866,7 +872,7 @@
|
||||
"termsError": "Трябва да приемете условията и правилата, за да се регистрирате.",
|
||||
"usernameError": "Изисква се потребителско име.",
|
||||
"emailError": "Изисква се валиден имейл.",
|
||||
"passwordError": "Паролата трябва да е поне 6 знака.",
|
||||
"passwordError": "Паролата трябва да е поне 12 знака",
|
||||
"confirmPasswordError": "Паролите не съвпадат.",
|
||||
"termsRequired": "Трябва да приемете условията и правилата.",
|
||||
"usernameRequired": "Изисква се потребителско име.",
|
||||
@@ -875,16 +881,41 @@
|
||||
"passwordsDontMatch": "Паролите не съвпадат.",
|
||||
"alreadyHaveAccountPre": "Вече имаш акаунт?",
|
||||
"alreadyHaveAccountLink": "Вход",
|
||||
"whoIsOnline": "Кой е онлайн",
|
||||
"usersOnline": "{count} онлайн",
|
||||
"accepted": "Accepted & agreed",
|
||||
"showPassword": "Show",
|
||||
"hidePassword": "Hide",
|
||||
"accountDetails": "Account details",
|
||||
"credentials": "Credentials & security",
|
||||
"newestCitizens": "Newest citizens",
|
||||
"whoIsOnline": "Кой е на линия",
|
||||
"usersOnline": "{count} на линия",
|
||||
"accepted": "Прието и съгласен съм",
|
||||
"showPassword": "Покажи",
|
||||
"hidePassword": "Скрий",
|
||||
"accountDetails": "Данни за профила",
|
||||
"credentials": "Данни за вход и сигурност",
|
||||
"newestCitizens": "Най-нови граждани",
|
||||
"ageVerified": "Над 18 годишен съм и съгласен с изискването за възраст.",
|
||||
"invitedBy": "Поканен от {username}"
|
||||
"invitedBy": "Поканен от {username}",
|
||||
"strengthWeak": "Слаба",
|
||||
"strengthFair": "Средна",
|
||||
"strengthGood": "Добра",
|
||||
"strengthStrong": "Силна",
|
||||
"passwordMinLength": "Паролата трябва да е поне 12 знака",
|
||||
"passwordUpper": "Паролата трябва да съдържа поне една главна буква",
|
||||
"passwordLower": "Паролата трябва да съдържа поне една малка буква",
|
||||
"passwordDigit": "Паролата трябва да съдържа поне една цифра",
|
||||
"passwordSpecial": "Паролата трябва да съдържа поне един специален символ",
|
||||
"passwordMaxLength": "Паролата е твърде дълга",
|
||||
"usernameMinLength": "Потребителското име трябва да е поне 3 знака",
|
||||
"usernameMaxLength": "Потребителското име може да е най-много 25 знака",
|
||||
"usernamePattern": "Потребителското име може да съдържа само букви, цифри, долна черта и тире",
|
||||
"usernameReserved": "Това потребителско име е запазено",
|
||||
"emailValid": "Въведете валиден имейл адрес",
|
||||
"emailDisposable": "Временните имейл домейни не са разрешени",
|
||||
"passwordsMatch": "Паролите не съвпадат.",
|
||||
"captchaFailed": "Проверката на капчата е неуспешна. Опитайте отново.",
|
||||
"usernameTaken": "Това потребителско име вече е заето",
|
||||
"rateLimited": "Твърде много опити за регистрация. Изчакайте няколко минути и опитайте отново.",
|
||||
"vpnBlocked": "Регистрацията през VPN/прокси връзки не е разрешена.",
|
||||
"maxAccountsPerIp": "Достигнахте максималния брой акаунти за вашата връзка.",
|
||||
"unavailable": "Регистрацията е временно недостъпна.",
|
||||
"createFailed": "Акаунтът не можа да бъде създаден. Опитайте отново или се свържете с екипа.",
|
||||
"invalidInput": "Моля, прегледайте отбелязаните полета и опитайте отново."
|
||||
},
|
||||
"forgot": {
|
||||
"title": "Нулирайте паролата",
|
||||
@@ -898,9 +929,11 @@
|
||||
"reset": {
|
||||
"title": "Задайте нова парола",
|
||||
"subtitle": "Изберете силна парола, която не използвате другаде.",
|
||||
"passwordPlaceholder": "Нова парола (мин. 6 знака)",
|
||||
"passwordPlaceholder": "Нова парола (мин. 12 знака)",
|
||||
"resetPassword": "Нулирайте паролата",
|
||||
"backToLogin": "Назад към входа"
|
||||
"backToLogin": "Назад към входа",
|
||||
"passwordMinLength": "Паролата трябва да е поне 12 знака",
|
||||
"tooManyAttempts": "Твърде много опити — опитайте по-късно"
|
||||
},
|
||||
"verify": {
|
||||
"verifiedTitle": "Всичко е готово",
|
||||
@@ -916,7 +949,11 @@
|
||||
"invalidTitle": "Проверка на имейл",
|
||||
"invalidSubtitle": "Невалидна или изтекла връзка",
|
||||
"invalidBody": "Тази връзка за потвърждение не е валидна. Уверете се, че сте отворили пълната връзка от имейла си или поискайте нова.",
|
||||
"backToLogin": "Назад към входа"
|
||||
"backToLogin": "Назад към входа",
|
||||
"resendEmail": "Имейл адрес",
|
||||
"resendButton": "Изпрати нова вързка",
|
||||
"resendSent": "Ако този адрес има акаунт, нова вързка за потвърждение е на път — проверете пощата си.",
|
||||
"resendFailed": "Не можахме да изпратим нова вързка. Опитайте отново след няколко минути."
|
||||
},
|
||||
"banned": {
|
||||
"title": "Вие сте забранени",
|
||||
|
||||
+55
-18
@@ -836,14 +836,20 @@
|
||||
"createOne": "Vytvořte jeden",
|
||||
"forgotPassword": "Zapomněli jste heslo?",
|
||||
"errorInvalidCredentials": "Neplatné uživatelské jméno nebo heslo",
|
||||
"errorInvalid2fa": "Neplatný kód 2FA",
|
||||
"errorInvalid2fa": "Neplatný kód dvoufázového ověření",
|
||||
"welcomeBack": "Vítej zpět",
|
||||
"welcomeBackSub": "Přihlas se pro pokračování do {hotelName}",
|
||||
"errorUnverified": "Please verify your email before signing in.",
|
||||
"errorCaptcha": "Captcha verification failed. Please try again.",
|
||||
"whoIsOnline": "Who's online",
|
||||
"newestCitizens": "Newest citizens",
|
||||
"usersOnline": "{count} online"
|
||||
"errorUnverified": "Před přihlášením ověřte svou e-mailovou adresu.",
|
||||
"errorCaptcha": "Ověření captcha selhalo. Zkuste to znovu.",
|
||||
"whoIsOnline": "Kdo je online",
|
||||
"newestCitizens": "Nejnovější občané",
|
||||
"usersOnline": "{count} online",
|
||||
"username": "Uživatelské jméno",
|
||||
"password": "Heslo",
|
||||
"showPassword": "Zobrazit",
|
||||
"hidePassword": "Skrýt",
|
||||
"registeredSuccess": "Úter vytvořen — zkontrolujte svou schránku a najděte ověřovací odkaz, poté se přihlaste.",
|
||||
"verifyEmailCta": "Vyžádat nový ověřovací odkaz"
|
||||
},
|
||||
"register": {
|
||||
"title": "Vytvořit účet",
|
||||
@@ -854,7 +860,7 @@
|
||||
"confirmPassword": "Opakujte heslo",
|
||||
"usernamePlaceholder": "Uživatelské jméno",
|
||||
"emailPlaceholder": "Email",
|
||||
"passwordPlaceholder": "Heslo (min. 6 znaků)",
|
||||
"passwordPlaceholder": "Heslo (min. 12 znaků)",
|
||||
"confirmPasswordPlaceholder": "Opakujte heslo",
|
||||
"selectOutfit": "Vyberte si oblečení",
|
||||
"termsAccept": "Je mi 18+ a souhlasím s podmínkami a pravidly {hotel}.",
|
||||
@@ -866,7 +872,7 @@
|
||||
"termsError": "Chcete-li se zaregistrovat, musíte přijmout podmínky a pravidla.",
|
||||
"usernameError": "Uživatelské jméno je povinné.",
|
||||
"emailError": "Je vyžadován platný e-mail.",
|
||||
"passwordError": "Heslo musí mít alespoň 6 znaků.",
|
||||
"passwordError": "Heslo musí mít alespoň 12 znaků",
|
||||
"confirmPasswordError": "Hesla se neshodují.",
|
||||
"termsRequired": "Musíte přijmout podmínky a pravidla.",
|
||||
"usernameRequired": "Uživatelské jméno je povinné.",
|
||||
@@ -877,14 +883,39 @@
|
||||
"alreadyHaveAccountLink": "Přihlásit se",
|
||||
"whoIsOnline": "Kdo je online",
|
||||
"usersOnline": "{count} online",
|
||||
"accepted": "Accepted & agreed",
|
||||
"showPassword": "Show",
|
||||
"hidePassword": "Hide",
|
||||
"accountDetails": "Account details",
|
||||
"credentials": "Credentials & security",
|
||||
"newestCitizens": "Newest citizens",
|
||||
"accepted": "Přijato a souhlasím",
|
||||
"showPassword": "Zobrazit",
|
||||
"hidePassword": "Skrýt",
|
||||
"accountDetails": "Údaje o účtu",
|
||||
"credentials": "Přihlašovací údaje a zabezpečení",
|
||||
"newestCitizens": "Nejnovější občané",
|
||||
"ageVerified": "Je mi 18+ a souhlasím s věkovým požadavkem.",
|
||||
"invitedBy": "Pozván uživatelem {username}"
|
||||
"invitedBy": "Pozván uživatelem {username}",
|
||||
"strengthWeak": "Slabé",
|
||||
"strengthFair": "Ucházející",
|
||||
"strengthGood": "Dobré",
|
||||
"strengthStrong": "Silné",
|
||||
"passwordMinLength": "Heslo musí mít alespoň 12 znaků",
|
||||
"passwordUpper": "Heslo musí obsahovat alespoň jedno velké písmeno",
|
||||
"passwordLower": "Heslo musí obsahovat alespoň jedno malé písmeno",
|
||||
"passwordDigit": "Heslo musí obsahovat alespoň jednu číslici",
|
||||
"passwordSpecial": "Heslo musí obsahovat alespoň jeden speciální znak",
|
||||
"passwordMaxLength": "Heslo je příliš dlouhé",
|
||||
"usernameMinLength": "Uživatelské jméno musí mít alespoň 3 znaky",
|
||||
"usernameMaxLength": "Uživatelské jméno může mít nejvýše 25 znaků",
|
||||
"usernamePattern": "Uživatelské jméno může obsahovat pouze písmena, číslice, podtržítko a pomlčku",
|
||||
"usernameReserved": "Toto uživatelské jméno je rezervováno",
|
||||
"emailValid": "Zadejte platnou e-mailovou adresu",
|
||||
"emailDisposable": "Dočasné e-mailové domény nejsou povoleny",
|
||||
"passwordsMatch": "Hesla se neshodují.",
|
||||
"captchaFailed": "Ověření captcha selhalo. Zkuste to znovu.",
|
||||
"usernameTaken": "Toto uživatelské jméno je již obsazené",
|
||||
"rateLimited": "Příliš mnoho pokusů o registraci. Počkejte několik minut a zkuste to znovu.",
|
||||
"vpnBlocked": "Registrace přes připojení VPN/proxy není povolena.",
|
||||
"maxAccountsPerIp": "Dosáhli jste maximálního počtu účtů pro vaše připojení.",
|
||||
"unavailable": "Registrace je dočasně nedostupná.",
|
||||
"createFailed": "Účet se nepodařilo vytvořit. Zkuste to znovu nebo kontaktujte personál.",
|
||||
"invalidInput": "Zkontrolujte označená pole a zkuste to znovu."
|
||||
},
|
||||
"forgot": {
|
||||
"title": "Obnovit heslo",
|
||||
@@ -898,9 +929,11 @@
|
||||
"reset": {
|
||||
"title": "Nastavte nové heslo",
|
||||
"subtitle": "Vyberte si silné heslo, které jinde nepoužíváte.",
|
||||
"passwordPlaceholder": "Nové heslo (min. 6 znaků)",
|
||||
"passwordPlaceholder": "Nové heslo (min. 12 znaků)",
|
||||
"resetPassword": "Obnovit heslo",
|
||||
"backToLogin": "Zpět k přihlášení"
|
||||
"backToLogin": "Zpět k přihlášení",
|
||||
"passwordMinLength": "Heslo musí mít alespoň 12 znaků",
|
||||
"tooManyAttempts": "Příliš mnoho pokusů — zkuste to později"
|
||||
},
|
||||
"verify": {
|
||||
"verifiedTitle": "Vše je připraveno",
|
||||
@@ -916,7 +949,11 @@
|
||||
"invalidTitle": "Ověření e-mailem",
|
||||
"invalidSubtitle": "Neplatný odkaz nebo odkaz, jehož platnost vypršela",
|
||||
"invalidBody": "Tento ověřovací odkaz není platný. Ujistěte se, že jste otevřeli celý odkaz ze svého e-mailu, nebo požádejte o nový.",
|
||||
"backToLogin": "Zpět k přihlášení"
|
||||
"backToLogin": "Zpět k přihlášení",
|
||||
"resendEmail": "E-mailová adresa",
|
||||
"resendButton": "Odeslat nový odkaz",
|
||||
"resendSent": "Pokud má tento e-mail účet, nový ověřovací odkaz je na cestě — zkontrolujte schránku.",
|
||||
"resendFailed": "Nový odkaz se nepodařilo odeslat. Zkuste to znovu za několik minut."
|
||||
},
|
||||
"banned": {
|
||||
"title": "Máte zákaz",
|
||||
|
||||
+54
-17
@@ -839,11 +839,17 @@
|
||||
"errorInvalid2fa": "Ugyldig 2FA-kode",
|
||||
"welcomeBack": "Velkommen tilbage",
|
||||
"welcomeBackSub": "Log ind for at fortsætte til {hotelName}",
|
||||
"errorUnverified": "Please verify your email before signing in.",
|
||||
"errorCaptcha": "Captcha verification failed. Please try again.",
|
||||
"whoIsOnline": "Who's online",
|
||||
"newestCitizens": "Newest citizens",
|
||||
"usersOnline": "{count} online"
|
||||
"errorUnverified": "Bekræft din e-mail, før du logger ind.",
|
||||
"errorCaptcha": "Captcha-verificering mislykkedes. Prøv igen.",
|
||||
"whoIsOnline": "Hvem er online",
|
||||
"newestCitizens": "Nyeste borgere",
|
||||
"usersOnline": "{count} online",
|
||||
"username": "Brugernavn",
|
||||
"password": "Adgangskode",
|
||||
"showPassword": "Vis",
|
||||
"hidePassword": "Skjul",
|
||||
"registeredSuccess": "Konto oprettet — tjek din indbakke for bekræftelseslinket, og log derefter ind.",
|
||||
"verifyEmailCta": "Anmod om en ny bekræftelseslink"
|
||||
},
|
||||
"register": {
|
||||
"title": "Opret konto",
|
||||
@@ -854,7 +860,7 @@
|
||||
"confirmPassword": "Gentag adgangskoden",
|
||||
"usernamePlaceholder": "Brugernavn",
|
||||
"emailPlaceholder": "E-mail",
|
||||
"passwordPlaceholder": "Adgangskode (min. 6 tegn)",
|
||||
"passwordPlaceholder": "Adgangskode (min. 12 tegn)",
|
||||
"confirmPasswordPlaceholder": "Gentag adgangskoden",
|
||||
"selectOutfit": "Vælg dit outfit",
|
||||
"termsAccept": "Jeg er 18+ og accepterer vilkårene og reglerne for {hotel}.",
|
||||
@@ -866,7 +872,7 @@
|
||||
"termsError": "Du skal acceptere vilkårene og reglerne for at registrere dig.",
|
||||
"usernameError": "Brugernavn er påkrævet.",
|
||||
"emailError": "Gyldig e-mail er påkrævet.",
|
||||
"passwordError": "Adgangskoden skal være på mindst 6 tegn.",
|
||||
"passwordError": "Adgangskoden skal være på mindst 12 tegn",
|
||||
"confirmPasswordError": "Adgangskoder stemmer ikke overens.",
|
||||
"termsRequired": "Du skal acceptere vilkårene og reglerne.",
|
||||
"usernameRequired": "Brugernavn er påkrævet.",
|
||||
@@ -877,14 +883,39 @@
|
||||
"alreadyHaveAccountLink": "Log ind",
|
||||
"whoIsOnline": "Hvem er online",
|
||||
"usersOnline": "{count} online",
|
||||
"accepted": "Accepted & agreed",
|
||||
"showPassword": "Show",
|
||||
"hidePassword": "Hide",
|
||||
"accountDetails": "Account details",
|
||||
"credentials": "Credentials & security",
|
||||
"newestCitizens": "Newest citizens",
|
||||
"accepted": "Accepteret og godkendt",
|
||||
"showPassword": "Vis",
|
||||
"hidePassword": "Skjul",
|
||||
"accountDetails": "Kontooplysninger",
|
||||
"credentials": "Loginoplysninger og sikkerhed",
|
||||
"newestCitizens": "Nyeste borgere",
|
||||
"ageVerified": "Jeg er 18+ og accepterer alderskravet.",
|
||||
"invitedBy": "Inviteret af {username}"
|
||||
"invitedBy": "Inviteret af {username}",
|
||||
"strengthWeak": "Svag",
|
||||
"strengthFair": "Middelmådig",
|
||||
"strengthGood": "God",
|
||||
"strengthStrong": "Stærk",
|
||||
"passwordMinLength": "Adgangskoden skal være på mindst 12 tegn",
|
||||
"passwordUpper": "Adgangskoden skal indeholde mindst ét stort bogstav",
|
||||
"passwordLower": "Adgangskoden skal indeholde mindst ét lille bogstav",
|
||||
"passwordDigit": "Adgangskoden skal indeholde mindst ét tal",
|
||||
"passwordSpecial": "Adgangskoden skal indeholde mindst ét specialtegn",
|
||||
"passwordMaxLength": "Adgangskoden er for lang",
|
||||
"usernameMinLength": "Brugernavnet skal være på mindst 3 tegn",
|
||||
"usernameMaxLength": "Brugernavnet må højst være på 25 tegn",
|
||||
"usernamePattern": "Brugernavnet må kun indeholde bogstaver, tal, understreg og bindestreg",
|
||||
"usernameReserved": "Dette brugernavn er reserveret",
|
||||
"emailValid": "Indtast en gyldig e-mailadresse",
|
||||
"emailDisposable": "Midlertidige e-maildomæner er ikke tilladt",
|
||||
"passwordsMatch": "Adgangskoder stemmer ikke overens.",
|
||||
"captchaFailed": "Captcha-verificering mislykkedes. Prøv igen.",
|
||||
"usernameTaken": "Det brugernavn er allerede taget",
|
||||
"rateLimited": "For mange registreringsforsøg. Vent et par minutter, og prøv igen.",
|
||||
"vpnBlocked": "Registrering via VPN/proxy-forbindelser er ikke tilladt.",
|
||||
"maxAccountsPerIp": "Du har nået det maksimale antal konti for din forbindelse.",
|
||||
"unavailable": "Registrering er midlertidigt utilgængelig.",
|
||||
"createFailed": "Kontoen kunne ikke oprettes. Prøv igen, eller kontakt personalet.",
|
||||
"invalidInput": "Kontrollér de markerede felter, og prøv igen."
|
||||
},
|
||||
"forgot": {
|
||||
"title": "Nulstil adgangskode",
|
||||
@@ -898,9 +929,11 @@
|
||||
"reset": {
|
||||
"title": "Indstil en ny adgangskode",
|
||||
"subtitle": "Vælg en stærk adgangskode, du ikke bruger andre steder.",
|
||||
"passwordPlaceholder": "Ny adgangskode (min. 6 tegn)",
|
||||
"passwordPlaceholder": "Ny adgangskode (min. 12 tegn)",
|
||||
"resetPassword": "Nulstil adgangskode",
|
||||
"backToLogin": "Tilbage til login"
|
||||
"backToLogin": "Tilbage til login",
|
||||
"passwordMinLength": "Adgangskoden skal være på mindst 12 tegn",
|
||||
"tooManyAttempts": "For mange forsøg — prøv igen senere"
|
||||
},
|
||||
"verify": {
|
||||
"verifiedTitle": "Du er klar",
|
||||
@@ -916,7 +949,11 @@
|
||||
"invalidTitle": "E-mailbekræftelse",
|
||||
"invalidSubtitle": "Ugyldigt eller udløbet link",
|
||||
"invalidBody": "Dette bekræftelseslink er ikke gyldigt. Sørg for, at du har åbnet hele linket fra din e-mail, eller anmod om et nyt.",
|
||||
"backToLogin": "Tilbage til login"
|
||||
"backToLogin": "Tilbage til login",
|
||||
"resendEmail": "E-mailadresse",
|
||||
"resendButton": "Send nyt link",
|
||||
"resendSent": "Hvis den adresse har en konto, er et nyt bekræftelseslink på vej — tjek din indbakke.",
|
||||
"resendFailed": "Det var ikke muligt at sende et nyt link. Prøv igen om et par minutter."
|
||||
},
|
||||
"banned": {
|
||||
"title": "Du er bandlyst",
|
||||
|
||||
+54
-17
@@ -809,11 +809,17 @@
|
||||
"errorInvalid2fa": "Ungültiger 2FA-Code",
|
||||
"welcomeBack": "Willkommen zurück",
|
||||
"welcomeBackSub": "Melde dich an, um zu {hotelName} zu gelangen",
|
||||
"errorUnverified": "Please verify your email before signing in.",
|
||||
"errorCaptcha": "Captcha verification failed. Please try again.",
|
||||
"whoIsOnline": "Who's online",
|
||||
"newestCitizens": "Newest citizens",
|
||||
"usersOnline": "{count} online"
|
||||
"errorUnverified": "Bitte bestätige deine E-Mail-Adresse, bevor du dich anmeldest.",
|
||||
"errorCaptcha": "Captcha-Verifizierung fehlgeschlagen. Bitte erneut versuchen.",
|
||||
"whoIsOnline": "Wer ist online",
|
||||
"newestCitizens": "Neueste Bürger",
|
||||
"usersOnline": "{count} online",
|
||||
"username": "Benutzername",
|
||||
"password": "Passwort",
|
||||
"showPassword": "Anzeigen",
|
||||
"hidePassword": "Verbergen",
|
||||
"registeredSuccess": "Konto erstellt — prüf dein Postfach für den Bestätigungslink und melde dich dann an.",
|
||||
"verifyEmailCta": "Neuen Bestätigungslink anfordern"
|
||||
},
|
||||
"register": {
|
||||
"title": "Konto erstellen",
|
||||
@@ -824,7 +830,7 @@
|
||||
"confirmPassword": "Passwort wiederholen",
|
||||
"usernamePlaceholder": "Benutzername",
|
||||
"emailPlaceholder": "E-Mail",
|
||||
"passwordPlaceholder": "Passwort (min. 6 Zeichen)",
|
||||
"passwordPlaceholder": "Passwort (min. 12 Zeichen)",
|
||||
"confirmPasswordPlaceholder": "Passwort wiederholen",
|
||||
"selectOutfit": "Wähle dein Outfit",
|
||||
"termsAccept": "Ich bin 18+ und akzeptiere die {hotel} AGB & Regeln.",
|
||||
@@ -836,7 +842,7 @@
|
||||
"termsError": "Du musst die AGB & Regeln akzeptieren, um dich zu registrieren.",
|
||||
"usernameError": "Benutzername ist erforderlich.",
|
||||
"emailError": "Eine gültige E-Mail ist erforderlich.",
|
||||
"passwordError": "Das Passwort muss mindestens 6 Zeichen lang sein.",
|
||||
"passwordError": "Das Passwort muss mindestens 12 Zeichen lang sein",
|
||||
"confirmPasswordError": "Passwörter stimmen nicht überein.",
|
||||
"termsRequired": "Du musst die AGB & Regeln akzeptieren.",
|
||||
"usernameRequired": "Benutzername ist erforderlich.",
|
||||
@@ -847,14 +853,39 @@
|
||||
"alreadyHaveAccountLink": "Anmelden",
|
||||
"whoIsOnline": "Wer ist online",
|
||||
"usersOnline": "{count} online",
|
||||
"accepted": "Accepted & agreed",
|
||||
"showPassword": "Show",
|
||||
"hidePassword": "Hide",
|
||||
"accountDetails": "Account details",
|
||||
"credentials": "Credentials & security",
|
||||
"newestCitizens": "Newest citizens",
|
||||
"accepted": "Akzeptiert & zugestimmt",
|
||||
"showPassword": "Anzeigen",
|
||||
"hidePassword": "Verbergen",
|
||||
"accountDetails": "Kontodaten",
|
||||
"credentials": "Zugangsdaten & Sicherheit",
|
||||
"newestCitizens": "Neueste Bürger",
|
||||
"ageVerified": "Ich bin 18+ und stimme der Altersanforderung zu.",
|
||||
"invitedBy": "Eingeladen von {username}"
|
||||
"invitedBy": "Eingeladen von {username}",
|
||||
"strengthWeak": "Schwach",
|
||||
"strengthFair": "Mittel",
|
||||
"strengthGood": "Gut",
|
||||
"strengthStrong": "Stark",
|
||||
"passwordMinLength": "Das Passwort muss mindestens 12 Zeichen lang sein",
|
||||
"passwordUpper": "Das Passwort muss mindestens einen Großbuchstaben enthalten",
|
||||
"passwordLower": "Das Passwort muss mindestens einen Kleinbuchstaben enthalten",
|
||||
"passwordDigit": "Das Passwort muss mindestens eine Ziffer enthalten",
|
||||
"passwordSpecial": "Das Passwort muss mindestens ein Sonderzeichen enthalten",
|
||||
"passwordMaxLength": "Das Passwort ist zu lang",
|
||||
"usernameMinLength": "Der Benutzername muss mindestens 3 Zeichen lang sein",
|
||||
"usernameMaxLength": "Der Benutzername darf höchstens 25 Zeichen lang sein",
|
||||
"usernamePattern": "Der Benutzername darf nur Buchstaben, Ziffern, Unterstrich und Bindestrich enthalten",
|
||||
"usernameReserved": "Dieser Benutzername ist reserviert",
|
||||
"emailValid": "Gib eine gültige E-Mail-Adresse ein",
|
||||
"emailDisposable": "Temporäre E-Mail-Domains sind nicht erlaubt",
|
||||
"passwordsMatch": "Passwörter stimmen nicht überein.",
|
||||
"captchaFailed": "Captcha-Verifizierung fehlgeschlagen. Bitte erneut versuchen.",
|
||||
"usernameTaken": "Dieser Benutzername ist bereits vergeben",
|
||||
"rateLimited": "Zu viele Registrierungsversuche. Bitte warte ein paar Minuten und versuche es erneut.",
|
||||
"vpnBlocked": "Registrierungen über VPN-/Proxy-Verbindungen sind nicht erlaubt.",
|
||||
"maxAccountsPerIp": "Du hast die maximale Anzahl an Konten für deine Verbindung erreicht.",
|
||||
"unavailable": "Die Registrierung ist vorübergehend nicht verfügbar.",
|
||||
"createFailed": "Das Konto konnte nicht erstellt werden. Versuche es erneut oder wende dich an das Personal.",
|
||||
"invalidInput": "Bitte prüfe die markierten Felder und versuche es erneut."
|
||||
},
|
||||
"forgot": {
|
||||
"title": "Passwort zurücksetzen",
|
||||
@@ -868,9 +899,11 @@
|
||||
"reset": {
|
||||
"title": "Neues Passwort festlegen",
|
||||
"subtitle": "Wähle ein sicheres Passwort, das du sonst nirgendwo verwendest.",
|
||||
"passwordPlaceholder": "Neues Passwort (min. 6 Zeichen)",
|
||||
"passwordPlaceholder": "Neues Passwort (min. 12 Zeichen)",
|
||||
"resetPassword": "Passwort zurücksetzen",
|
||||
"backToLogin": "Zurück zur Anmeldung"
|
||||
"backToLogin": "Zurück zur Anmeldung",
|
||||
"passwordMinLength": "Das Passwort muss mindestens 12 Zeichen lang sein",
|
||||
"tooManyAttempts": "Zu viele Versuche — bitte später erneut probieren"
|
||||
},
|
||||
"verify": {
|
||||
"verifiedTitle": "Du bist startklar",
|
||||
@@ -886,7 +919,11 @@
|
||||
"invalidTitle": "E-Mail-Verifizierung",
|
||||
"invalidSubtitle": "Ungültiger oder abgelaufener Link",
|
||||
"invalidBody": "Dieser Verifizierungslink ist nicht gültig. Stelle sicher, dass du den vollständigen Link aus deiner E-Mail geöffnet hast, oder fordere einen neuen an.",
|
||||
"backToLogin": "Zurück zur Anmeldung"
|
||||
"backToLogin": "Zurück zur Anmeldung",
|
||||
"resendEmail": "E-Mail-Adresse",
|
||||
"resendButton": "Neuen Link senden",
|
||||
"resendSent": "Wenn zu dieser Adresse ein Konto gehört, ist ein neuer Bestätigungslink unterwegs — prüf dein Postfach.",
|
||||
"resendFailed": "Der neue Link konnte nicht gesendet werden. Versuche es in einigen Minuten erneut."
|
||||
},
|
||||
"banned": {
|
||||
"title": "Du bist gebannt",
|
||||
|
||||
+56
-19
@@ -839,11 +839,17 @@
|
||||
"errorInvalid2fa": "Μη έγκυρος κωδικός 2FA",
|
||||
"welcomeBack": "Καλώς ήρθες πίσω",
|
||||
"welcomeBackSub": "Συνδέσου για να συνεχίσεις στο {hotelName}",
|
||||
"errorUnverified": "Please verify your email before signing in.",
|
||||
"errorCaptcha": "Captcha verification failed. Please try again.",
|
||||
"whoIsOnline": "Who's online",
|
||||
"newestCitizens": "Newest citizens",
|
||||
"usersOnline": "{count} online"
|
||||
"errorUnverified": "Επαληθεύστε το email σας πριν συνδεθείτε.",
|
||||
"errorCaptcha": "Η επαλήθευση captcha απέτυχε. Δοκιμάστε ξανά.",
|
||||
"whoIsOnline": "Ποιος είναι σε σύνδεση",
|
||||
"newestCitizens": "Νέοι πολίτες",
|
||||
"usersOnline": "{count} σε σύνδεση",
|
||||
"username": "Όνομα χρήστη",
|
||||
"password": "Κωδικός πρόσβασης",
|
||||
"showPassword": "Εμφάνιση",
|
||||
"hidePassword": "Απόκρυψη",
|
||||
"registeredSuccess": "Ο λογαριασμός δημιουργήθηκε — ελέγξτε τα εισερχόμενά σας για τον σύνδεσμο επαλήθευσης και συνδεθείτε.",
|
||||
"verifyEmailCta": "Ζητήστε νέο σύνδεσμο επαλήθευσης"
|
||||
},
|
||||
"register": {
|
||||
"title": "Δημιουργία λογαριασμού",
|
||||
@@ -854,7 +860,7 @@
|
||||
"confirmPassword": "Επαναλάβετε τον κωδικό πρόσβασης",
|
||||
"usernamePlaceholder": "Όνομα χρήστη",
|
||||
"emailPlaceholder": "Email",
|
||||
"passwordPlaceholder": "Κωδικός πρόσβασης (ελάχ. 6 χαρακτήρες)",
|
||||
"passwordPlaceholder": "Κωδικός πρόσβασης (ελάχ. 12 χαρακτήρες)",
|
||||
"confirmPasswordPlaceholder": "Επαναλάβετε τον κωδικό πρόσβασης",
|
||||
"selectOutfit": "Επιλέξτε το ντύσιμό σας",
|
||||
"termsAccept": "Είμαι 18+ και αποδέχομαι τους όρους και τους κανόνες του {hotel}.",
|
||||
@@ -866,7 +872,7 @@
|
||||
"termsError": "Πρέπει να αποδεχτείτε τους όρους και τους κανόνες για να εγγραφείτε.",
|
||||
"usernameError": "Απαιτείται όνομα χρήστη.",
|
||||
"emailError": "Απαιτείται έγκυρο email.",
|
||||
"passwordError": "Ο κωδικός πρόσβασης πρέπει να αποτελείται από τουλάχιστον 6 χαρακτήρες.",
|
||||
"passwordError": "Ο κωδικός πρόσβασης πρέπει να έχει τουλάχιστον 12 χαρακτήρες",
|
||||
"confirmPasswordError": "Οι κωδικοί πρόσβασης δεν ταιριάζουν.",
|
||||
"termsRequired": "Πρέπει να αποδεχτείτε τους όρους και τους κανόνες.",
|
||||
"usernameRequired": "Απαιτείται όνομα χρήστη.",
|
||||
@@ -875,16 +881,41 @@
|
||||
"passwordsDontMatch": "Οι κωδικοί πρόσβασης δεν ταιριάζουν.",
|
||||
"alreadyHaveAccountPre": "Έχεις ήδη λογαριασμό;",
|
||||
"alreadyHaveAccountLink": "Σύνδεση",
|
||||
"whoIsOnline": "Ποιος είναι συνδεδεμένος",
|
||||
"usersOnline": "{count} συνδεδεμένοι",
|
||||
"accepted": "Accepted & agreed",
|
||||
"showPassword": "Show",
|
||||
"hidePassword": "Hide",
|
||||
"accountDetails": "Account details",
|
||||
"credentials": "Credentials & security",
|
||||
"newestCitizens": "Newest citizens",
|
||||
"whoIsOnline": "Ποιος είναι σε σύνδεση",
|
||||
"usersOnline": "{count} σε σύνδεση",
|
||||
"accepted": "Αποδεκτό & συμφωνώ",
|
||||
"showPassword": "Εμφάνιση",
|
||||
"hidePassword": "Απόκρυψη",
|
||||
"accountDetails": "Στοιχεία λογαριασμού",
|
||||
"credentials": "Στοιχεία σύνδεσης & ασφάλεια",
|
||||
"newestCitizens": "Νέοι πολίτες",
|
||||
"ageVerified": "Είμαι 18+ και αποδέχομαι την απαίτηση ηλικίας.",
|
||||
"invitedBy": "Προσκλήθηκε από {username}"
|
||||
"invitedBy": "Προσκλήθηκε από {username}",
|
||||
"strengthWeak": "Ασθενές",
|
||||
"strengthFair": "Μέτριο",
|
||||
"strengthGood": "Καλό",
|
||||
"strengthStrong": "Δυνατό",
|
||||
"passwordMinLength": "Ο κωδικός πρόσβασης πρέπει να έχει τουλάχιστον 12 χαρακτήρες",
|
||||
"passwordUpper": "Ο κωδικός πρόσβασης πρέπει να περιέχει τουλάχιστον ένα κεφαλαίο γράμμα",
|
||||
"passwordLower": "Ο κωδικός πρόσβασης πρέπει να περιέχει τουλάχιστον ένα πεζό γράμμα",
|
||||
"passwordDigit": "Ο κωδικός πρόσβασης πρέπει να περιέχει τουλάχιστον ένα ψηφίο",
|
||||
"passwordSpecial": "Ο κωδικός πρόσβασης πρέπει να περιέχει τουλάχιστον έναν ειδικό χαρακτήρα",
|
||||
"passwordMaxLength": "Ο κωδικός πρόσβασης είναι πολύ μεγάλος",
|
||||
"usernameMinLength": "Το όνομα χρήστη πρέπει να έχει τουλάχιστον 3 χαρακτήρες",
|
||||
"usernameMaxLength": "Το όνομα χρήστη μπορεί να έχει έως 25 χαρακτήρες",
|
||||
"usernamePattern": "Το όνομα χρήστη μπορεί να περιέχει μόνο γράμματα, ψηφία, κάτω παύλα και παύλα",
|
||||
"usernameReserved": "Αυτό το όνομα χρήστη είναι δεσμευμένο",
|
||||
"emailValid": "Εισαγάγετε μια έγκυρη διεύθυνση email",
|
||||
"emailDisposable": "Τα προσωρινά email domains δεν επιτρέπονται",
|
||||
"passwordsMatch": "Οι κωδικοί πρόσβασης δεν ταιριάζουν.",
|
||||
"captchaFailed": "Η επαλήθευση captcha απέτυχε. Δοκιμάστε ξανά.",
|
||||
"usernameTaken": "Αυτό το όνομα χρήστη χρησιμοποιείται ήδη",
|
||||
"rateLimited": "Πάρα πολλές προσπάθειες εγγραφής. Περίμενε λίγα λεπτά και δοκίμασε ξανά.",
|
||||
"vpnBlocked": "Οι εγγραφές μέσω VPN/proxy δεν επιτρέπονται.",
|
||||
"maxAccountsPerIp": "Έφτασες τον μέγιστο αριθμό λογαριασμών για τη σύνδεσή σου.",
|
||||
"unavailable": "Η εγγραφή δεν είναι προσωρινά διαθέσιμη.",
|
||||
"createFailed": "Δεν ήταν δυνατή η δημιουργία του λογαριασμού. Δοκίμασε ξανά ή επικοινώνησε με το προσωπικό.",
|
||||
"invalidInput": "Ελέγξτε τα επισημασμένα πεδία και δοκιμάστε ξανά."
|
||||
},
|
||||
"forgot": {
|
||||
"title": "Επαναφορά κωδικού πρόσβασης",
|
||||
@@ -898,9 +929,11 @@
|
||||
"reset": {
|
||||
"title": "Ορίστε νέο κωδικό πρόσβασης",
|
||||
"subtitle": "Επιλέξτε έναν ισχυρό κωδικό πρόσβασης που δεν χρησιμοποιείτε αλλού.",
|
||||
"passwordPlaceholder": "Νέος κωδικός πρόσβασης (ελάχ. 6 χαρακτήρες)",
|
||||
"passwordPlaceholder": "Νέος κωδικός πρόσβασης (ελάχ. 12 χαρακτήρες)",
|
||||
"resetPassword": "Επαναφορά κωδικού πρόσβασης",
|
||||
"backToLogin": "Επιστροφή στην είσοδο"
|
||||
"backToLogin": "Επιστροφή στην είσοδο",
|
||||
"passwordMinLength": "Ο κωδικός πρόσβασης πρέπει να έχει τουλάχιστον 12 χαρακτήρες",
|
||||
"tooManyAttempts": "Πάρα πολλές προσπάθειες — δοκιμάστε αργότερα"
|
||||
},
|
||||
"verify": {
|
||||
"verifiedTitle": "Είστε έτοιμοι",
|
||||
@@ -916,7 +949,11 @@
|
||||
"invalidTitle": "Επαλήθευση μέσω email",
|
||||
"invalidSubtitle": "Μη έγκυρος ή ληγμένος σύνδεσμος",
|
||||
"invalidBody": "Αυτός ο σύνδεσμος επαλήθευσης δεν είναι έγκυρος. Βεβαιωθείτε ότι έχετε ανοίξει τον πλήρη σύνδεσμο από το email σας ή ζητήστε νέο.",
|
||||
"backToLogin": "Επιστροφή στην είσοδο"
|
||||
"backToLogin": "Επιστροφή στην είσοδο",
|
||||
"resendEmail": "Διεύθυνση email",
|
||||
"resendButton": "Αποστολή νέου συνδέσμου",
|
||||
"resendSent": "Αν αυτή η διεύθυνση έχει λογαριασμό, ένας νέος σύνδεσμος επαλήθευσης είναι καθ' οδόν — ελέγξτε τα εισερχόμενά σας.",
|
||||
"resendFailed": "Δεν ήταν δυνατή η αποστολή νέου συνδέσμου. Δοκιμάστε ξανά σε λίγα λεπτά."
|
||||
},
|
||||
"banned": {
|
||||
"title": "Είστε απαγορευμένοι",
|
||||
|
||||
+44
-7
@@ -1015,7 +1015,13 @@
|
||||
"errorCaptcha": "Captcha verification failed. Please try again.",
|
||||
"whoIsOnline": "Who's online",
|
||||
"newestCitizens": "Newest citizens",
|
||||
"usersOnline": "{count} online"
|
||||
"usersOnline": "{count} online",
|
||||
"showPassword": "Show",
|
||||
"hidePassword": "Hide",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"registeredSuccess": "Account created — check your inbox for the verification link, then sign in.",
|
||||
"verifyEmailCta": "Request a new verification link"
|
||||
},
|
||||
"register": {
|
||||
"title": "Create account",
|
||||
@@ -1026,7 +1032,7 @@
|
||||
"confirmPassword": "Repeat password",
|
||||
"usernamePlaceholder": "Username",
|
||||
"emailPlaceholder": "Email",
|
||||
"passwordPlaceholder": "Password (min 8 chars)",
|
||||
"passwordPlaceholder": "Password (min 12 chars)",
|
||||
"confirmPasswordPlaceholder": "Repeat password",
|
||||
"selectOutfit": "Select your outfit",
|
||||
"termsAccept": "I am 18+ and accept the {hotel} terms & rules.",
|
||||
@@ -1049,14 +1055,39 @@
|
||||
"termsError": "You must accept the terms & rules to register.",
|
||||
"usernameError": "Username is required.",
|
||||
"emailError": "Valid email is required.",
|
||||
"passwordError": "Password must be at least 6 characters.",
|
||||
"passwordError": "Password must be at least 12 characters",
|
||||
"confirmPasswordError": "Passwords do not match.",
|
||||
"termsRequired": "You must accept the terms & rules.",
|
||||
"usernameRequired": "Username is required.",
|
||||
"emailRequired": "Email is required.",
|
||||
"passwordRequired": "Password is required.",
|
||||
"passwordsDontMatch": "Passwords do not match.",
|
||||
"invitedBy": "Invited by {username}"
|
||||
"invitedBy": "Invited by {username}",
|
||||
"strengthWeak": "Weak",
|
||||
"strengthFair": "Fair",
|
||||
"strengthGood": "Good",
|
||||
"strengthStrong": "Strong",
|
||||
"passwordMinLength": "Password must be at least 12 characters",
|
||||
"passwordUpper": "Password must contain at least one uppercase letter",
|
||||
"passwordLower": "Password must contain at least one lowercase letter",
|
||||
"passwordDigit": "Password must contain at least one digit",
|
||||
"passwordSpecial": "Password must contain at least one special character",
|
||||
"passwordMaxLength": "Password is too long",
|
||||
"usernameMinLength": "Username must be at least 3 characters",
|
||||
"usernameMaxLength": "Username must be at most 25 characters",
|
||||
"usernamePattern": "Username may only contain letters, numbers, underscore and hyphen",
|
||||
"usernameReserved": "This username is reserved",
|
||||
"emailValid": "Enter a valid email address",
|
||||
"emailDisposable": "Temporary email domains are not allowed",
|
||||
"passwordsMatch": "Passwords do not match",
|
||||
"usernameTaken": "That username is already taken",
|
||||
"captchaFailed": "Captcha verification failed. Please try again.",
|
||||
"rateLimited": "Too many sign-up attempts. Please wait a few minutes and try again.",
|
||||
"vpnBlocked": "Registrations from VPN/proxy connections are not allowed.",
|
||||
"maxAccountsPerIp": "You have reached the maximum number of accounts for your connection.",
|
||||
"unavailable": "Registration is temporarily unavailable.",
|
||||
"createFailed": "Could not create the account. Please try again or contact staff.",
|
||||
"invalidInput": "Please check the highlighted fields and try again."
|
||||
},
|
||||
"forgot": {
|
||||
"title": "Reset password",
|
||||
@@ -1070,9 +1101,11 @@
|
||||
"reset": {
|
||||
"title": "Set a new password",
|
||||
"subtitle": "Choose a strong password you don't use elsewhere.",
|
||||
"passwordPlaceholder": "New password (min 6 chars)",
|
||||
"passwordPlaceholder": "New password (min 12 chars)",
|
||||
"resetPassword": "Reset password",
|
||||
"backToLogin": "Back to login"
|
||||
"backToLogin": "Back to login",
|
||||
"passwordMinLength": "Password must be at least 12 characters",
|
||||
"tooManyAttempts": "Too many attempts — try again later"
|
||||
},
|
||||
"verify": {
|
||||
"verifiedTitle": "You're all set",
|
||||
@@ -1088,7 +1121,11 @@
|
||||
"invalidTitle": "Email verification",
|
||||
"invalidSubtitle": "Invalid or expired link",
|
||||
"invalidBody": "This verification link is not valid. Make sure you opened the full link from your email, or request a new one.",
|
||||
"backToLogin": "Back to login"
|
||||
"backToLogin": "Back to login",
|
||||
"resendEmail": "Email address",
|
||||
"resendButton": "Send new link",
|
||||
"resendSent": "If that address has an account, a new verification link is on its way — check your inbox.",
|
||||
"resendFailed": "Couldn’t send a new link. Wait a few minutes and try again."
|
||||
},
|
||||
"banned": {
|
||||
"title": "You are banned",
|
||||
|
||||
+55
-18
@@ -806,14 +806,20 @@
|
||||
"createOne": "Crea una",
|
||||
"forgotPassword": "¿Olvidaste tu contraseña?",
|
||||
"errorInvalidCredentials": "Usuario o contraseña inválidos",
|
||||
"errorInvalid2fa": "Código 2FA inválido",
|
||||
"errorInvalid2fa": "Código 2FA no válido",
|
||||
"welcomeBack": "Bienvenido de nuevo",
|
||||
"welcomeBackSub": "Inicia sesión para continuar en {hotelName}",
|
||||
"errorUnverified": "Please verify your email before signing in.",
|
||||
"errorCaptcha": "Captcha verification failed. Please try again.",
|
||||
"whoIsOnline": "Who's online",
|
||||
"newestCitizens": "Newest citizens",
|
||||
"usersOnline": "{count} online"
|
||||
"errorUnverified": "Verifica tu correo electrónico antes de iniciar sesión.",
|
||||
"errorCaptcha": "La verificación captcha falló. Inténtalo de nuevo.",
|
||||
"whoIsOnline": "Quién está en línea",
|
||||
"newestCitizens": "Ciudadanos más recientes",
|
||||
"usersOnline": "{count} en línea",
|
||||
"username": "Usuario",
|
||||
"password": "Contraseña",
|
||||
"showPassword": "Mostrar",
|
||||
"hidePassword": "Ocultar",
|
||||
"registeredSuccess": "Cuenta creada: revisa tu bandeja de entrada para el enlace de verificación e inicia sesión.",
|
||||
"verifyEmailCta": "Solicitar un nuevo enlace de verificación"
|
||||
},
|
||||
"register": {
|
||||
"title": "Crear cuenta",
|
||||
@@ -824,7 +830,7 @@
|
||||
"confirmPassword": "Repetir contraseña",
|
||||
"usernamePlaceholder": "Usuario",
|
||||
"emailPlaceholder": "Correo electrónico",
|
||||
"passwordPlaceholder": "Contraseña (mín. 6 caracteres)",
|
||||
"passwordPlaceholder": "Contraseña (mín. 12 caracteres)",
|
||||
"confirmPasswordPlaceholder": "Repetir contraseña",
|
||||
"selectOutfit": "Selecciona tu atuendo",
|
||||
"termsAccept": "Soy mayor de 18 años y acepto los términos y reglas de {hotel}.",
|
||||
@@ -836,7 +842,7 @@
|
||||
"termsError": "Debes aceptar los términos y reglas para registrarte.",
|
||||
"usernameError": "El usuario es obligatorio.",
|
||||
"emailError": "Se requiere un correo electrónico válido.",
|
||||
"passwordError": "La contraseña debe tener al menos 6 caracteres.",
|
||||
"passwordError": "La contraseña debe tener al menos 12 caracteres",
|
||||
"confirmPasswordError": "Las contraseñas no coinciden.",
|
||||
"termsRequired": "Debes aceptar los términos y reglas.",
|
||||
"usernameRequired": "El usuario es obligatorio.",
|
||||
@@ -847,14 +853,39 @@
|
||||
"alreadyHaveAccountLink": "Iniciar sesión",
|
||||
"whoIsOnline": "Quién está en línea",
|
||||
"usersOnline": "{count} en línea",
|
||||
"accepted": "Accepted & agreed",
|
||||
"showPassword": "Show",
|
||||
"hidePassword": "Hide",
|
||||
"accountDetails": "Account details",
|
||||
"credentials": "Credentials & security",
|
||||
"newestCitizens": "Newest citizens",
|
||||
"accepted": "Aceptado y de acuerdo",
|
||||
"showPassword": "Mostrar",
|
||||
"hidePassword": "Ocultar",
|
||||
"accountDetails": "Datos de la cuenta",
|
||||
"credentials": "Credenciales y seguridad",
|
||||
"newestCitizens": "Ciudadanos más recientes",
|
||||
"ageVerified": "Tengo 18+ y acepto el requisito de edad.",
|
||||
"invitedBy": "Invitado por {username}"
|
||||
"invitedBy": "Invitado por {username}",
|
||||
"strengthWeak": "Débil",
|
||||
"strengthFair": "Aceptable",
|
||||
"strengthGood": "Buena",
|
||||
"strengthStrong": "Fuerte",
|
||||
"passwordMinLength": "La contraseña debe tener al menos 12 caracteres",
|
||||
"passwordUpper": "La contraseña debe contener al menos una mayúscula",
|
||||
"passwordLower": "La contraseña debe contener al menos una minúscula",
|
||||
"passwordDigit": "La contraseña debe contener al menos un dígito",
|
||||
"passwordSpecial": "La contraseña debe contener al menos un carácter especial",
|
||||
"passwordMaxLength": "La contraseña es demasiado larga",
|
||||
"usernameMinLength": "El usuario debe tener al menos 3 caracteres",
|
||||
"usernameMaxLength": "El usuario puede tener como máximo 25 caracteres",
|
||||
"usernamePattern": "El usuario solo puede contener letras, números, guion bajo y guion",
|
||||
"usernameReserved": "Ese nombre de usuario está reservado",
|
||||
"emailValid": "Introduce una dirección de correo válida",
|
||||
"emailDisposable": "No se permiten dominios de correo temporales",
|
||||
"passwordsMatch": "Las contraseñas no coinciden.",
|
||||
"captchaFailed": "La verificación captcha falló. Inténtalo de nuevo.",
|
||||
"usernameTaken": "Ese nombre de usuario ya está en uso",
|
||||
"rateLimited": "Demasiados intentos de registro. Espera unos minutos e inténtalo de nuevo.",
|
||||
"vpnBlocked": "No se permiten registros mediante conexiones VPN/proxy.",
|
||||
"maxAccountsPerIp": "Has alcanzado el máximo de cuentas para tu conexión.",
|
||||
"unavailable": "El registro no está disponible temporalmente.",
|
||||
"createFailed": "No se pudo crear la cuenta. Inténtalo de nuevo o contacta con el personal.",
|
||||
"invalidInput": "Revisa los campos marcados e inténtalo de nuevo."
|
||||
},
|
||||
"forgot": {
|
||||
"title": "Restablecer contraseña",
|
||||
@@ -868,9 +899,11 @@
|
||||
"reset": {
|
||||
"title": "Establecer una nueva contraseña",
|
||||
"subtitle": "Elige una contraseña segura que no uses en otros sitios.",
|
||||
"passwordPlaceholder": "Nueva contraseña (mín. 6 caracteres)",
|
||||
"passwordPlaceholder": "Nueva contraseña (mín. 12 caracteres)",
|
||||
"resetPassword": "Restablecer contraseña",
|
||||
"backToLogin": "Volver al inicio de sesión"
|
||||
"backToLogin": "Volver al inicio de sesión",
|
||||
"passwordMinLength": "La contraseña debe tener al menos 12 caracteres",
|
||||
"tooManyAttempts": "Demasiados intentos — inténtalo más tarde"
|
||||
},
|
||||
"verify": {
|
||||
"verifiedTitle": "Todo está listo",
|
||||
@@ -886,7 +919,11 @@
|
||||
"invalidTitle": "Verificación de correo electrónico",
|
||||
"invalidSubtitle": "Enlace inválido o expirado",
|
||||
"invalidBody": "Este enlace de verificación no es válido. Asegúrate de haber abierto el enlace completo de tu correo, o solicita uno nuevo.",
|
||||
"backToLogin": "Volver al inicio de sesión"
|
||||
"backToLogin": "Volver al inicio de sesión",
|
||||
"resendEmail": "Dirección de correo electrónico",
|
||||
"resendButton": "Enviar nuevo enlace",
|
||||
"resendSent": "Si esa dirección tiene una cuenta, un nuevo enlace de verificación está en camino: revisa tu bandeja de entrada.",
|
||||
"resendFailed": "No se pudo enviar un nuevo enlace. Vuelve a intentarlo en unos minutos."
|
||||
},
|
||||
"banned": {
|
||||
"title": "Has sido baneado",
|
||||
|
||||
+101
-64
@@ -6067,69 +6067,100 @@
|
||||
"rateLimit": "Too many attempts. Please wait before trying again."
|
||||
},
|
||||
"login": {
|
||||
"title": "Sign in",
|
||||
"subtitle": "Welcome back — log in to enter the hotel.",
|
||||
"subtitle2fa": "Enter the 6-digit code from your authenticator.",
|
||||
"welcomeBack": "Welcome back",
|
||||
"welcomeBackSub": "Sign in to continue to {hotelName}",
|
||||
"usernamePlaceholder": "Username",
|
||||
"passwordPlaceholder": "Password",
|
||||
"codePlaceholder": "2FA code",
|
||||
"pleaseWait": "Please wait…",
|
||||
"verify": "Verify",
|
||||
"signIn": "Sign in",
|
||||
"or": "or",
|
||||
"noAccount": "No account?",
|
||||
"createOne": "Create one",
|
||||
"forgotPassword": "Forgot password?",
|
||||
"errorInvalidCredentials": "Invalid username or password",
|
||||
"errorInvalid2fa": "Invalid 2FA code",
|
||||
"errorUnverified": "Please verify your email before signing in.",
|
||||
"errorCaptcha": "Captcha verification failed. Please try again.",
|
||||
"whoIsOnline": "Who's online",
|
||||
"newestCitizens": "Newest citizens",
|
||||
"usersOnline": "{count} online"
|
||||
"title": "Kirjaudu sisään",
|
||||
"subtitle": "Kirjaudu sisään jatkaaksesi seuraavaan hotelliin.",
|
||||
"subtitle2fa": "Syötä 6-numeroinen koodi tunnistussovelluksestasi.",
|
||||
"welcomeBack": "Tervetuloa takaisin",
|
||||
"welcomeBackSub": "Kirjaudu sisään liittyäksesi hotelliin {hotelName}",
|
||||
"usernamePlaceholder": "Käyttäjätunnus",
|
||||
"passwordPlaceholder": "Salasana",
|
||||
"codePlaceholder": "Syötä 2FA-koodisi",
|
||||
"pleaseWait": "Odota hetki...",
|
||||
"verify": "Varmenna",
|
||||
"signIn": "Kirjaudu sisään",
|
||||
"or": "tai",
|
||||
"noAccount": "Nie vielä tiliä?",
|
||||
"createOne": "Luo tili",
|
||||
"forgotPassword": "Unohditko salasanasi?",
|
||||
"errorInvalidCredentials": "Väärä käyttäjätunnus tai salasana",
|
||||
"errorInvalid2fa": "Virheellinen 2FA-koodi",
|
||||
"errorUnverified": "Vahvista sähköpostiosoitteesi ennen kirjautumista.",
|
||||
"errorCaptcha": "Captcha-varmennus epäonnistui. Yritä uudelleen.",
|
||||
"whoIsOnline": "Ketkä ovat paikalla",
|
||||
"newestCitizens": "Uusimmat asukkaat",
|
||||
"usersOnline": "{count} paikalla",
|
||||
"username": "Käyttäjätunnus",
|
||||
"password": "Salasana",
|
||||
"showPassword": "Näytä",
|
||||
"hidePassword": "Piilota",
|
||||
"registeredSuccess": "Tili luotu — tarkista sähköpostisi ja avaa vahvistuslinkki, sitten kirjaudu sisään.",
|
||||
"verifyEmailCta": "Pyydä uusi vahvistuslinkki"
|
||||
},
|
||||
"register": {
|
||||
"title": "Create account",
|
||||
"subtitle": "Join the hotel — it only takes a moment.",
|
||||
"username": "Username",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"confirmPassword": "Repeat password",
|
||||
"usernamePlaceholder": "Username",
|
||||
"emailPlaceholder": "Email",
|
||||
"passwordPlaceholder": "Password (min 8 chars)",
|
||||
"confirmPasswordPlaceholder": "Repeat password",
|
||||
"selectOutfit": "Select your outfit",
|
||||
"termsAccept": "I am 18+ and accept the {hotel} terms & rules.",
|
||||
"accepted": "Accepted & agreed",
|
||||
"showPassword": "Show",
|
||||
"hidePassword": "Hide",
|
||||
"accountDetails": "Account details",
|
||||
"credentials": "Credentials & security",
|
||||
"createAccount": "Create account",
|
||||
"creatingAccount": "Creating account...",
|
||||
"redirecting": "Redirecting to your account...",
|
||||
"alreadyHaveAccount": "Already have an account? Login!",
|
||||
"alreadyHaveAccountPre": "Already have an account?",
|
||||
"alreadyHaveAccountLink": "Sign in",
|
||||
"register": "Register",
|
||||
"whoIsOnline": "Who's online",
|
||||
"usersOnline": "{count} online",
|
||||
"termsError": "You must accept the terms & rules to register.",
|
||||
"usernameError": "Username is required.",
|
||||
"emailError": "Valid email is required.",
|
||||
"passwordError": "Password must be at least 6 characters.",
|
||||
"confirmPasswordError": "Passwords do not match.",
|
||||
"termsRequired": "You must accept the terms & rules.",
|
||||
"usernameRequired": "Username is required.",
|
||||
"emailRequired": "Email is required.",
|
||||
"passwordRequired": "Password is required.",
|
||||
"passwordsDontMatch": "Passwords do not match.",
|
||||
"newestCitizens": "Newest citizens",
|
||||
"ageVerified": "Olen 18+ ja hyväksyn ikävaatimuksen.",
|
||||
"invitedBy": "Kutsuja: {username}"
|
||||
"title": "Luo tili",
|
||||
"subtitle": "Liity hotelliin — se kestää vain hetken.",
|
||||
"username": "Käyttäjätunnus",
|
||||
"email": "Sähköposti",
|
||||
"password": "Salasana",
|
||||
"confirmPassword": "Toista salasana",
|
||||
"usernamePlaceholder": "Käyttäjätunnus",
|
||||
"emailPlaceholder": "Sähköposti",
|
||||
"passwordPlaceholder": "Salasana (vähintään 12 merkkiä)",
|
||||
"confirmPasswordPlaceholder": "Toista salasana",
|
||||
"selectOutfit": "Valitse asustesi",
|
||||
"termsAccept": "Olen 18-vuotias ja hyväksyn {hotel} säännöt.",
|
||||
"accepted": "Hyväksytty ja hyväksyn",
|
||||
"showPassword": "Näytä",
|
||||
"hidePassword": "Piilota",
|
||||
"accountDetails": "Tilitiedot",
|
||||
"credentials": "Kirjautumistiedot ja turvallisuus",
|
||||
"createAccount": "Luo tili",
|
||||
"creatingAccount": "Luodaan tiliä...",
|
||||
"redirecting": "Siirrytään tilillesi...",
|
||||
"alreadyHaveAccount": "Onko sinulla jo tili? Kirjaudu sisään!",
|
||||
"alreadyHaveAccountPre": "Onko sinulla jo tili?",
|
||||
"alreadyHaveAccountLink": "Kirjaudu sisään",
|
||||
"register": "Rekisteröidy",
|
||||
"whoIsOnline": "Ketkä ovat paikalla",
|
||||
"usersOnline": "{count} paikalla",
|
||||
"termsError": "Sinun on hyväksyttävä säännöt rekisteröityäksesi.",
|
||||
"usernameError": "Käyttäjätunnus on pakollinen.",
|
||||
"emailError": "Kelvollinen sähköpostiosoite on pakollinen.",
|
||||
"passwordError": "Salasanassa on oltava vähintään 12 merkkiä",
|
||||
"confirmPasswordError": "Salasanat eivät täsmää.",
|
||||
"termsRequired": "Sinun on hyväksyttävä säännöt.",
|
||||
"usernameRequired": "Käyttäjätunnus on pakollinen.",
|
||||
"emailRequired": "Sähköpostiosoite on pakollinen.",
|
||||
"passwordRequired": "Salasana on pakollinen.",
|
||||
"passwordsDontMatch": "Salasanat eivät täsmää.",
|
||||
"newestCitizens": "Uusimmat asukkaat",
|
||||
"ageVerified": "Olen 18-vuotias ja hyväksyn ikärajan.",
|
||||
"invitedBy": "Kutsuja: {username}",
|
||||
"strengthWeak": "Heikko",
|
||||
"strengthFair": "Tyydyttävä",
|
||||
"strengthGood": "Hyvä",
|
||||
"strengthStrong": "Vahva",
|
||||
"passwordMinLength": "Salasanassa on oltava vähintään 12 merkkiä",
|
||||
"passwordUpper": "Salasanassa on oltava vähintään yksi iso kirjain",
|
||||
"passwordLower": "Salasanassa on oltava vähintään yksi pieni kirjain",
|
||||
"passwordDigit": "Salasanassa on oltava vähintään yksi numero",
|
||||
"passwordSpecial": "Salasanassa on oltava vähintään yksi erikoismerkki",
|
||||
"passwordMaxLength": "Salasana on liian pitkä",
|
||||
"usernameMinLength": "Käyttäjätunnus on oltava vähintään 3 merkkiä",
|
||||
"usernameMaxLength": "Käyttäjätunnus saa olla korkeintaan 25 merkkiä",
|
||||
"usernamePattern": "Käyttäjätunnus voi sisältää vain kirjaimia, numeroita, alaviivan ja viivan",
|
||||
"usernameReserved": "Tämä käyttäjätunnus on varattu",
|
||||
"emailValid": "Anna kelvollinen sähköpostiosoite",
|
||||
"emailDisposable": "Väliaikaisia sähköpostiosoitteita ei sallita",
|
||||
"passwordsMatch": "Passwords do not match.",
|
||||
"captchaFailed": "Captcha-varmennus epäonnistui. Yritä uudelleen.",
|
||||
"usernameTaken": "Tämä käyttäjätunnus on jo käytössä",
|
||||
"rateLimited": "Liikaa rekisteröintiyrityksiä. Odota muutama minuutti ja yritä uudelleen.",
|
||||
"vpnBlocked": "Rekisteröityminen VPN/proxy-yhteyksien kautta ei ole sallittua.",
|
||||
"maxAccountsPerIp": "Olet saavuttanut yhteytesi enimmäismäärän tilejä.",
|
||||
"unavailable": "Rekisteröityminen ei ole väliaikaisesti käytettävissä.",
|
||||
"createFailed": "Tiliä ei voitu luoda. Yritä uudelleen tai ota yhteyttä henkilökuntaan.",
|
||||
"invalidInput": "Tarkista korostetut kentät ja yritä uudelleen."
|
||||
},
|
||||
"forgot": {
|
||||
"title": "Reset password",
|
||||
@@ -6143,9 +6174,11 @@
|
||||
"reset": {
|
||||
"title": "Set a new password",
|
||||
"subtitle": "Choose a strong password you don't use elsewhere.",
|
||||
"passwordPlaceholder": "New password (min 6 chars)",
|
||||
"passwordPlaceholder": "New password (min 12 chars)",
|
||||
"resetPassword": "Reset password",
|
||||
"backToLogin": "Back to login"
|
||||
"backToLogin": "Back to login",
|
||||
"passwordMinLength": "Salasanassa on oltava vähintään 12 merkkiä",
|
||||
"tooManyAttempts": "Liikaa yrityksiä — yritä myöhemmin uudelleen"
|
||||
},
|
||||
"verify": {
|
||||
"verifiedTitle": "You're all set",
|
||||
@@ -6161,7 +6194,11 @@
|
||||
"invalidTitle": "Email verification",
|
||||
"invalidSubtitle": "Invalid or expired link",
|
||||
"invalidBody": "This verification link is not valid. Make sure you opened the full link from your email, or request a new one.",
|
||||
"backToLogin": "Back to login"
|
||||
"backToLogin": "Back to login",
|
||||
"resendEmail": "Sähköpostiosoite",
|
||||
"resendButton": "Lähetä uusi linkki",
|
||||
"resendSent": "Jos kyseiseen osoitteeseen on liitetty tili, uusi vahvistuslinkki on matkalla — tarkista sähköpostisi.",
|
||||
"resendFailed": "Uutta linkkiä ei voitu lähettää. Yritä uudelleen muutaman minuutin kuluttua."
|
||||
},
|
||||
"banned": {
|
||||
"title": "You are banned",
|
||||
|
||||
Loaded 100 of 124 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user