Compare commits

..
Author SHA1 Message Date
remco 1e4869bb84 chore(deps): lock file maintenance
CI / check (push) Skipped
CI / tests-unit (push) Skipped
CI / tests-integration (push) Skipped
CI / tests-ui (push) Skipped
CI / preflight (push) Skipped
CI / deploy (pull_request) Skipped
CI / check (pull_request) Failing after 32s
CI / tests-ui (pull_request) Skipped
CI / tests-imaging-browsers (pull_request) Skipped
CI / tests-quality (pull_request) Skipped
CI / preflight (pull_request) Skipped
CI / tests-unit (pull_request) Skipped
CI / tests-integration (pull_request) Skipped
2026-10-25 23:01:20 +00:00
openhands 6c89a7ed46 fix: restore PATH for systemd-run and use AllowedCPUs
CI / tests-imaging-browsers (push) Successful in 1m42s
CI / tests-integration (push) Successful in 1m49s
CI / tests-quality (push) Successful in 57s
CI / tests-ui (push) Successful in 4m46s
CI / preflight (push) Skipped
CI / deploy (push) Failing after 29s
CI / check (push) Successful in 29s
CI / tests-unit (push) Successful in 3m51s
2026-10-23 14:29:01 +02:00
openhands fbf98cd2b5 chore: use half of CPU cores instead of a single core
CI / check (push) Failing after 30s
CI / tests-integration (push) Skipped
CI / tests-ui (push) Skipped
CI / tests-imaging-browsers (push) Skipped
CI / preflight (push) Skipped
CI / tests-unit (push) Skipped
CI / tests-quality (push) Skipped
CI / deploy (push) Skipped
2026-10-23 14:24:45 +02:00
openhands cf448d6ce9 chore: pin capped commands to one CPU core
CI / tests-integration (push) Skipped
CI / tests-ui (push) Skipped
CI / tests-imaging-browsers (push) Skipped
CI / tests-quality (push) Skipped
CI / preflight (push) Skipped
CI / check (push) Failing after 29s
CI / tests-unit (push) Skipped
CI / deploy (push) Skipped
2026-10-23 14:23:24 +02:00
openhands 4768b2b406 fix: limit vitest to one worker to avoid 100% CPU
CI / tests-quality (push) Successful in 49s
CI / tests-imaging-browsers (push) Successful in 1m35s
CI / tests-unit (push) Successful in 4m21s
CI / tests-ui (push) Successful in 4m54s
CI / preflight (push) Skipped
CI / check (push) Successful in 36s
CI / tests-integration (push) Successful in 1m54s
CI / deploy (push) Successful in 20s
2026-10-23 14:21:23 +02:00
openhands af67a28639 fix: restore playwright/cpu-friendly config and deployment memory handling
CI / tests-imaging-browsers (push) Successful in 2m11s
CI / tests-quality (push) Successful in 58s
CI / check (push) Successful in 27s
CI / tests-integration (push) Successful in 1m54s
CI / tests-unit (push) Successful in 1m58s
CI / tests-ui (push) Successful in 5m17s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 26s
2026-10-23 14:18:39 +02:00
openhands a4bc25b6d6 chore(proxy): remove CPU/pids limits, add open_file_cache, fix nginx-sync symlink check
CI / tests-imaging-browsers (push) Successful in 1m59s
CI / tests-integration (push) Successful in 2m4s
CI / tests-quality (push) Successful in 47s
CI / tests-ui (push) Successful in 4m29s
CI / check (push) Successful in 31s
CI / tests-unit (push) Failing after 1m55s
CI / preflight (push) Skipped
CI / deploy (push) Skipped
2026-10-23 14:05:31 +02:00
openhands fb724a35cc feat(security): drop scanner probes and hostile paths early in proxy
CI / check (push) Successful in 30s
CI / tests-imaging-browsers (push) Successful in 3m38s
CI / tests-ui (push) Successful in 5m6s
CI / preflight (push) Skipped
CI / tests-unit (push) Successful in 1m55s
CI / tests-integration (push) Successful in 1m59s
CI / tests-quality (push) Successful in 4m0s
CI / deploy (push) Failing after 4m7s
2026-10-22 22:02:20 +02:00
openhands a878d831aa fix(proxy): reject malformed server-action requests to reduce CPU spikes
CI / tests-imaging-browsers (push) Successful in 3m42s
CI / tests-ui (push) Successful in 4m49s
CI / check (push) Successful in 29s
CI / tests-unit (push) Successful in 1m55s
CI / tests-integration (push) Successful in 1m59s
CI / tests-quality (push) Successful in 50s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 4m12s
2026-10-22 21:46:55 +02:00
openhands 1ad4f9ea28 docs: sync README core stack version
CI / tests-unit (push) Successful in 1m54s
CI / tests-imaging-browsers (push) Successful in 2m29s
CI / tests-quality (push) Successful in 2m30s
CI / deploy (push) Successful in 5m35s
CI / check (push) Successful in 31s
CI / tests-integration (push) Successful in 1m56s
CI / tests-ui (push) Successful in 4m46s
CI / preflight (push) Skipped
2026-10-22 21:28:54 +02:00
openhands 4f6b352691 style: apply biome 2.6 formatting
CI / tests-integration (push) Skipped
CI / tests-ui (push) Skipped
CI / tests-imaging-browsers (push) Skipped
CI / preflight (push) Skipped
CI / check (push) Failing after 18s
CI / tests-unit (push) Skipped
CI / tests-quality (push) Skipped
CI / deploy (push) Skipped
2026-10-22 21:28:00 +02:00
openhands 42fa988d9c chore: revert unintended formatting changes
CI / check (push) Failing after 24s
CI / tests-integration (push) Skipped
CI / tests-ui (push) Skipped
CI / tests-imaging-browsers (push) Skipped
CI / preflight (push) Skipped
CI / deploy (push) Skipped
CI / tests-unit (push) Skipped
CI / tests-quality (push) Skipped
2026-10-22 21:26:08 +02:00
openhands be4dc08c31 chore: update dependencies and biome rules
CI / check (push) Failing after 18s
CI / tests-unit (push) Skipped
CI / tests-integration (push) Skipped
CI / tests-ui (push) Skipped
CI / tests-imaging-browsers (push) Skipped
CI / tests-quality (push) Skipped
CI / preflight (push) Skipped
CI / deploy (push) Skipped
2026-10-22 21:25:00 +02:00
openhands 6ef92f57fc chore: bump Node.js to 26.12.1
CI / check (push) Successful in 32s
CI / tests-unit (push) Successful in 1m48s
CI / tests-imaging-browsers (push) Successful in 3m59s
CI / tests-quality (push) Successful in 1m46s
CI / tests-ui (push) Successful in 4m32s
CI / preflight (push) Skipped
CI / tests-integration (push) Successful in 1m58s
CI / deploy (push) Successful in 3m50s
2026-10-22 21:20:16 +02:00
openhands a16e9da018 fix(media-grid): clear pending copy-url timers on unmount
CI / check (push) Successful in 42s
CI / tests-unit (push) Successful in 2m33s
CI / tests-integration (push) Successful in 2m14s
CI / tests-ui (push) Failing after 2m31s
CI / preflight (push) Skipped
CI / tests-quality (push) Successful in 1m29s
CI / tests-imaging-browsers (push) Successful in 1m52s
CI / deploy (push) Skipped
2026-10-22 18:36:45 +02:00
openhands 3cb5baabfb perf: reduce idle polling frequency in instrumentation (30s to 60s)
CI / tests-integration (push) Successful in 2m26s
CI / tests-quality (push) Successful in 2m7s
CI / tests-imaging-browsers (push) Successful in 2m23s
CI / tests-ui (push) Successful in 6m11s
CI / deploy (push) Successful in 26s
CI / check (push) Successful in 48s
CI / tests-unit (push) Successful in 2m25s
CI / preflight (push) Skipped
2026-10-22 18:34:19 +02:00
openhands e96c408ac1 perf(cache): bound reported-failures set and halve default in-process budget
CI / check (push) Successful in 45s
CI / tests-quality (push) Successful in 1m5s
CI / tests-imaging-browsers (push) Successful in 1m57s
CI / tests-ui (push) Successful in 6m9s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 22s
CI / tests-integration (push) Successful in 2m22s
CI / tests-unit (push) Successful in 2m35s
2026-10-22 18:31:33 +02:00
openhands e4564abc5e fix(auth): remove duplicate headings and subtitle on register page
CI / check (push) Successful in 35s
CI / tests-quality (push) Successful in 1m0s
CI / preflight (push) Skipped
CI / tests-integration (push) Successful in 2m22s
CI / tests-unit (push) Successful in 2m30s
CI / tests-imaging-browsers (push) Successful in 1m35s
CI / tests-ui (push) Successful in 6m17s
CI / deploy (push) Successful in 36s
2026-10-22 18:28:58 +02:00
openhands 30583b8e2e feat(seo): add JSON-LD, OG images and RSS feed for news
CI / tests-integration (push) Successful in 2m29s
CI / tests-imaging-browsers (push) Successful in 2m32s
CI / tests-ui (push) Successful in 5m14s
CI / preflight (push) Skipped
CI / check (push) Successful in 29s
CI / deploy (push) Successful in 2m41s
CI / tests-unit (push) Successful in 2m17s
CI / tests-quality (push) Failing after 2m18s
2026-10-22 18:22:32 +02:00
openhands 97454f361b feat(home): rebuild the landing page on a shared panel system
CI / check (push) Successful in 32s
CI / tests-unit (push) Successful in 2m12s
CI / tests-integration (push) Successful in 2m43s
CI / deploy (push) Successful in 3m1s
CI / tests-ui (push) Successful in 4m22s
CI / tests-imaging-browsers (push) Successful in 2m10s
CI / tests-quality (push) Successful in 1m32s
CI / preflight (push) Skipped
The homepage mixed four different translucent washes (surface 44-60% plus a
backdrop-filter each), rendered the same four counters twice, and showed a
dangling "Welcome to " eyebrow because the localized string expected the hotel
name. A missing theme wallpaper replaced the whole sky gradient with flat
white, and stale CMS image references produced broken images and 404s.

Replace the ad-hoc panels with one opaque, theme-derived `.panel` token set
(surface, border, shadow, header, radius) and a `.panel-float` variant for the
counter strip that straddles the hero edge. The strip is now the single home
of the four headline counters; a failed read renders "unavailable" instead of
silently dropping a cell. Add the fourth feature card (Games) that already had
translated copy, link recent users to their profiles, and fall back to themed
placeholders for news covers and camera photos without an upload.

Add `pages.home.heroEyebrow` to all 25 locales, layer the CMS wallpaper over
`--site-bg-gradient` instead of replacing it, and keep the sky in dark mode.
Add `publicAssetExists` so local uploads that no longer exist are skipped
(wallpaper, help topics) rather than rendered broken. Fix the `/help` topic
icons to use the `/assets/images/help-center/` base the detail page already
used.

Throttle the spotlight glow to one update per animation frame, drop the dead
`.glass-chip` rules, define the missing `.panel-float`, and fix the stat
divider and reduced-motion selectors. Stat labels use the readable text token
so they clear WCAG AA, and the language button's accessible name now contains
its visible label.

Verified: tsc, biome, 3590 unit tests, i18n audit unchanged, axe 0 violations
at 390/768/1024/1280/1440 in light and dark, quality e2e 16/16.
2026-10-22 17:39:30 +02:00
openhands ecd8e5852c perf(build): keep test suites out of the image and cache CI builds durably
CI / tests-unit (push) Successful in 1m53s
CI / tests-integration (push) Successful in 1m54s
CI / tests-quality (push) Successful in 2m19s
CI / tests-ui (push) Successful in 4m31s
CI / preflight (push) Skipped
CI / check (push) Successful in 30s
CI / tests-imaging-browsers (push) Successful in 2m26s
CI / deploy (push) Failing after 3m9s
Three build-time improvements, all measured on this host.

The build context no longer ships the test suites. `next build` type-checks
the whole project, so `e2e/` and `integration/` in the context made the image
build fail on a type error in test code and pulled @playwright/test, vitest,
msw, testcontainers and drizzle-kit into the image. The CI runs those suites
on the host. Type-check time in the build drops from 2.2s to 1.3s, and a
contract test now fails if application code ever imports from those folders.

The CI now exports the build cache to a directory outside the runner
workspace: /var/cache/epicnext-cms-buildcache for the release and a separate
map for branch preflights, so a branch can never feed the release cache. The
workspace is wiped after every job and `docker builder prune` runs after
every release, so the next build started with an empty cache and downloaded
all 493 packages again (measured: reused 0, downloaded 493). With the
durable export the frozen dependency layer is reused (measured: reused 493,
downloaded 0) even after a full prune. A cache directory that is not
writable only produces a warning; the release is never blocked.

Investigated the remaining ~30s of Next.js build tracing: excluding the
dev-only packages with `outputFileTracingExcludes` made it slower (35s vs
30s), because the traced output was already minimal (5 packages). The cost
is Next's own page-config collection and file tracing over 329 routes, not
the dependency set, so no configuration was kept.
2026-10-21 19:29:50 +02:00
openhands 8ca538f99f perf(build): resolve pnpm from the pin and reuse the real pnpm store
CI / check (push) Successful in 29s
CI / tests-integration (push) Successful in 2m13s
CI / tests-unit (push) Successful in 2m28s
CI / tests-quality (push) Successful in 55s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 3m47s
CI / tests-imaging-browsers (push) Successful in 2m29s
CI / tests-ui (push) Successful in 5m10s
The Docker build was slower than it needed to be and could drift away from
the lockfile. Three separate causes, fixed together.

The image installed a pinned [email protected].1 while package.json pins 12.11.2
through `packageManager`. pnpm silently self-managed the difference at
build time: it downloaded the right version into an uncached location and
never verified the sha512 hash. Install corepack instead and let it resolve
version and hash from the pin, so the image can no longer diverge and a
tampered tarball is rejected. The resolved pnpm lives in COREPACK_HOME
inside the image, so the migrations image still runs pnpm offline
(`docker run --entrypoint pnpm ... db:migrate`).

The pnpm store cache-mount pointed at /root/.local/share/pnpm/store, but
pnpm keeps its store in $PNPM_HOME/store/v11. The mount was a no-op: every
build re-downloaded all 493 packages ("reused 0, downloaded 493"). Mounting
the store where pnpm actually keeps it turns that into "reused 493,
downloaded 0".

The build context carried backups/ (232MB of rotating database dumps) and
public/vendor/ (regenerated by `pnpm assets:editor` from node_modules).
Both grew the context and invalidated the `COPY . .` layer on every daily
backup rotation, forcing a full Next.js rebuild. Excluding them takes the
context from 276MB to 23MB.

Compose now also exports the build cache to .docker-cache/cms and imports it
again, so the cache survives `scripts/docker-prune.sh`, which trims the
daemon cache to 4GB after every deploy. Verified by pruning the entire
daemon cache: the next build restored every layer from the local export and
downloaded nothing.

Also: byparr's floating `:latest` tag is now pulled on every start, the
migrations build in scripts/docker-update.sh imports the same local cache,
the README no longer claims the build needs host networking or an
iptables-disabled daemon, the pnpm rationale in renovate.json reflects that
Docker follows the pin automatically, and contract tests pin the new
invariants so the store path cannot drift back.
2026-10-21 18:51:17 +02:00
openhands 1e81cac504 test(ui): isolate the fixture port so parallel runs stop colliding
CI / check (push) Successful in 29s
CI / tests-unit (push) Successful in 2m4s
CI / tests-quality (push) Successful in 55s
CI / tests-ui (push) Successful in 4m31s
CI / tests-imaging-browsers (push) Successful in 1m37s
CI / tests-integration (push) Successful in 2m12s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 3m39s
The self-hosted CI runner packs several jobs onto the shared host network
(capacity 4). The UI suite pinned the fixture server to 127.0.0.1:3107 and set
reuseExistingServer: true, so overlapping runs fought over the bind: one job
won it, and every browser in the other got "connection refused" on all four
projects. Pass one random high port per run through the environment (shared by
the main process and its worker) and only reuse an existing server outside CI.
Verified with the full ui suite: 150 passed, 2 skipped.
2026-10-19 20:16:28 +02:00
openhands 3bc218ba16 chore(deps): dedupe the lockfile to the newest transitive versions
CI / check (push) Successful in 33s
CI / tests-integration (push) Successful in 1m58s
CI / tests-unit (push) Successful in 2m6s
CI / tests-quality (push) Successful in 52s
CI / tests-imaging-browsers (push) Successful in 1m5s
CI / tests-ui (push) Failing after 6m11s
CI / preflight (push) Skipped
CI / deploy (push) Skipped
`pnpm dedupe --check` still flagged strtok3 10.5.0 (via file-type 22.2.0)
against the 10.6.2 already in the graph. Collapse it so the lockfile resolves
every transitive dependency to the highest version its parents allow; the
check is now clean and the full unit suite still passes.
2026-10-19 19:55:41 +02:00
openhands 61f4db2a00 chore(deps): update dependencies to the latest releases
CI / check (push) Successful in 28s
CI / tests-imaging-browsers (push) Successful in 1m42s
CI / tests-integration (push) Successful in 2m30s
CI / tests-unit (push) Successful in 2m55s
CI / tests-quality (push) Successful in 1m20s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 21s
CI / tests-ui (push) Successful in 6m1s
Bump every direct dependency to the newest published version, including the
@hookform/resolvers 5 -> 6 major. The zodResolver API used by the admin room
and user edit forms is unchanged, and typecheck plus the full unit suite pass
on the new resolver.

Highlights: next 16.4.2, react-query 5.104.4, react-virtual 3.14.21, motion
14.4.0, lucide-react 1.59.0, isomorphic-dompurify 4.6.0, resend 6.34.1,
music-metadata 12.2.3, vitest / coverage-v8 5.0.5, postcss 8.5.31 and
@axe-core/playwright 4.14.0. README core-stack versions are re-synced.
2026-10-19 19:52:08 +02:00
openhands 597287fce5 fix(compose): keep the CMS opt-in so it cannot claim the CI port
CI / check (push) Successful in 36s
CI / tests-unit (push) Successful in 2m4s
CI / tests-ui (push) Successful in 4m39s
CI / deploy (push) Successful in 3m20s
CI / tests-imaging-browsers (push) Successful in 1m49s
CI / tests-integration (push) Successful in 1m59s
CI / tests-quality (push) Successful in 42s
CI / preflight (push) Skipped
On a CI-managed host scripts/ci-deploy.sh owns port 3002 through the
blue/green slots, so a compose replica of the cms service could only ever
crash-loop on EADDRINUSE — it never serves traffic, because nginx points at a
slot container. Gate the service behind the `cms` profile so a bare
`docker compose up -d` starts only byparr. Naming the service explicitly
(`docker compose up -d cms`, which is what the installer and updater already
do) still enables the profile automatically, so the compose-only install path
keeps working.

Keep the one port that is actually served (3002) and make the 2 GB ceiling
hard by capping swap at 2g as well. Drop the green compose service (3003):
the green slot only ever exists as a docker run container created by CI, so
compose no longer defines it and the two paths cannot drift apart.

The loopback override contract stays covered: proxy-config.test.mjs now
renders the configuration with --profile cms, and a new build-contract test
pins the profile and the memory limits so neither can be dropped by accident.
2026-10-19 19:00:07 +02:00
openhands 5c428c4526 feat(home): label the top-bar online counter with the hotel name
CI / check (push) Successful in 33s
CI / tests-integration (push) Successful in 1m58s
CI / tests-imaging-browsers (push) Successful in 1m24s
CI / tests-quality (push) Successful in 50s
CI / tests-ui (push) Successful in 4m34s
CI / preflight (push) Skipped
CI / tests-unit (push) Successful in 2m1s
CI / deploy (push) Successful in 3m12s
Use the shared LiveOnlineCount so the landing top bar reads "0 <hotel> online"
instead of a bare number, and drop the hotel name from the left (the logo
stays) so it is not shown twice.
2026-10-18 20:16:23 +02:00
openhands e7a6b2b602 style(home): tidy hero spacing and top-bar online badge
CI / check (push) Successful in 28s
CI / tests-integration (push) Successful in 1m49s
CI / tests-ui (push) Successful in 5m1s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m24s
CI / tests-imaging-browsers (push) Successful in 1m32s
CI / tests-unit (push) Successful in 1m55s
CI / tests-quality (push) Successful in 50s
Drop the orphaned h1 margin left over from the removed hero online chip, and
the stray title attribute on the top-bar online indicator.
2026-10-18 20:09:54 +02:00
openhands 4b25292ecc feat(site): hide global chrome on landing pages
CI / tests-imaging-browsers (push) Successful in 2m7s
CI / tests-ui (push) Successful in 5m14s
CI / preflight (push) Skipped
CI / check (push) Successful in 28s
CI / tests-unit (push) Successful in 2m23s
CI / tests-integration (push) Successful in 2m22s
CI / tests-quality (push) Successful in 52s
CI / deploy (push) Successful in 3m43s
The index, login and register render their own AuthTopBar, so the shared
TopHeader / SiteHeader banner / Navigation duplicated the same chrome. Hide the
global chrome on those three standalone landing pages only.
2026-10-18 19:58:53 +02:00
openhands b40293bd06 feat(home): clean shared top bar with live online counter
CI / check (push) Successful in 28s
CI / tests-imaging-browsers (push) Successful in 1m34s
CI / tests-quality (push) Successful in 50s
CI / tests-ui (push) Successful in 4m39s
CI / preflight (push) Skipped
CI / tests-integration (push) Successful in 1m57s
CI / tests-unit (push) Successful in 2m1s
CI / deploy (push) Successful in 3m29s
The homepage duplicated the online count and recent-user avatars (hero plus
community sidebar), and the public pages each kept their own chrome. Put one
AuthTopBar at the top of index, login and register — logo + hotel name plus a
live online indicator and the language/clock/theme controls — and drop the
duplicated hero rows.
2026-10-18 19:38:32 +02:00
openhands 9cf1eb615c feat(auth): make password length configurable via env (min 5–30, max 30–128)
CI / check (push) Successful in 29s
CI / tests-imaging-browsers (push) Successful in 1m46s
CI / tests-unit (push) Successful in 1m49s
CI / preflight (push) Skipped
CI / tests-integration (push) Successful in 1m47s
CI / tests-quality (push) Successful in 44s
CI / tests-ui (push) Successful in 4m38s
CI / deploy (push) Successful in 3m38s
Add PASSWORD_MIN_LENGTH / PASSWORD_MAX_LENGTH (validated in src/env.ts) and a
shared passwordPolicy helper. The policy drives the zod schemas in register,
password reset and the admin create-user dialog, plus the live requirement
checks and strength meter in the register form.

Locale messages now interpolate the configured minimum via {min} instead of
hard-coding 12.
2026-10-18 19:20:38 +02:00
openhands aef0939ee9 refactor(auth): share the auth landing and extract AuthField
CI / check (push) Successful in 36s
CI / tests-imaging-browsers (push) Successful in 1m36s
CI / tests-integration (push) Successful in 1m52s
CI / tests-unit (push) Successful in 1m57s
CI / tests-quality (push) Successful in 52s
CI / preflight (push) Skipped
CI / tests-ui (push) Successful in 4m46s
CI / deploy (push) Successful in 3m40s
The login and register pages were near-identical after the previous cleanup,
and both forms repeated the same labelled, icon-prefixed input seven times.
Move the data loading and page shell into auth-landing.tsx (loadAuthLanding +
AuthLandingView) and the input markup into a single AuthField, so the pages
become thin compositions and every field shares one focus, icon and aria
implementation. The numbered section headers collapse into AuthSectionHeader.

No behaviour change.
2026-10-18 19:01:40 +02:00
openhands c6e5e0b1a9 feat(auth): unify and polish login, register and homepage auth UI
CI / tests-unit (push) Successful in 2m11s
CI / tests-quality (push) Successful in 52s
CI / tests-ui (push) Successful in 5m7s
CI / preflight (push) Skipped
CI / check (push) Successful in 35s
CI / tests-imaging-browsers (push) Successful in 1m34s
CI / tests-integration (push) Successful in 2m1s
CI / deploy (push) Successful in 4m11s
Login and register each carried their own copy of the promo panel and the
error banner, so the two pages drifted apart and every caller had to wire
role="alert" by hand. Extract a shared AuthPromoCard and AuthAlert, use them
in both forms and for the login notices, and link errors to their fields via
aria-invalid / aria-describedby.

Focus now lands on the 2FA code field the moment it appears, the credential
fields lock while a request is in flight, and invalid inputs get a danger
tint. The homepage top bar was a byte-for-byte duplicate of AuthTopBar; it
now renders the shared component, and the compact homepage login uses the
tighter spacing its comment already promised.
2026-10-18 18:49:18 +02:00
openhands 2249303dbd chore(lint): remove dead eslint-disable directives
CI / check (push) Successful in 30s
CI / tests-imaging-browsers (push) Successful in 1m31s
CI / tests-unit (push) Successful in 1m45s
CI / tests-quality (push) Successful in 47s
CI / tests-ui (push) Successful in 4m24s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 3m39s
CI / tests-integration (push) Successful in 1m49s
The project lints with Biome only, so none of these 78 directives across 57
files ever suppressed anything. Directives that carried the rationale for a
flagged dynamic index access keep that rationale as a plain comment.
2026-10-18 14:50:51 +02:00
openhands 07c5904879 test: make coverage opt-in and ratchet thresholds to the measured floor
Coverage was enabled by default, which forced v8 instrumentation on every run
and made a focused `vitest run <file>` fail the global thresholds that a
single file can never satisfy. `pnpm test:coverage` now opts in explicitly
while `pnpm test` stays fast.

Thresholds ratcheted from 36/29/30/37 to the measured full-suite floor of
37/30/31/38, so a drop in coverage fails again. Also drops a stale comment
about a parent Laravel repo and a dead .next.prev exclude from the config.
2026-10-18 14:50:50 +02:00
openhands 4776a41c01 fix(env): align suggested DB pool size with MariaDB max_connections
The example .env suggested DATABASE_POOL_SIZE=150 and connection_limit=150.
MariaDB defaults max_connections to 151, so a single CMS instance could claim
the server's entire connection budget, starving the emulator and any second
instance. The README already documents a pool size of 25; the example now
matches and notes that DATABASE_POOL_SIZE is the value that actually applies.
2026-10-18 14:50:33 +02:00
openhands a32cf6119b perf(build): measure the homepage and fix optimizePackageImports
/ is the highest-traffic route but was missing from both the public-route
instrumentation and the JS size budgets, so a payload regression there could
never fail the build. It is now wrapped in withPublicPagePerformance and
budgeted alongside the other public routes.

optimizePackageImports listed date-fns, which is not a dependency (a no-op),
while omitting the barrel-heavy packages that are actually imported: motion,
@dnd-kit/core, @dnd-kit/sortable and @tanstack/react-virtual.
2026-10-18 14:50:32 +02:00
openhands bb3799f705 perf(i18n): stop serializing the full catalogue on public pages
The root layout fed the entire ~254 KB message catalogue to
NextIntlClientProvider, which serializes it into the RSC payload of every
page. A public page only reads ~59 KB of it; the ~195 KB difference is staff
tool copy that only /admin, /admin-next and /mod ever render.

The root provider now installs the reduced catalogue and each staff route
group re-installs the full one, since a nested NextIntlClientProvider replaces
the parent message set rather than merging into it. pages.admin.actions stays
public because the shared useServerAction hook runs on public pages too.

Measured: 253 KB -> 59 KB of messages per public page (raw JSON).

Guard tests cover all three halves: the root keeps using the reduced
catalogue, every staff layout installs its own full provider, and no public
tree reads a staff-only namespace.
2026-10-18 14:50:26 +02:00
openhands d28dde459a perf(auth): memoize auth() per request with React cache
A single authenticated render called auth() 4-5 times (access guard, site
layout, both permission loaders, the page itself), and every call re-parsed
the session cookie, decoded and HMAC-verified the JWT and ran the jwt/session
callbacks. Wrapping NextAuth's handler in React's cache scopes that work to
one verification per request.

Outside a render (scripts, jobs worker) React's cache is a pass-through, so
those call sites behave exactly as before.
2026-10-18 14:49:34 +02:00
openhands 095353012a fix(build): suppress circular-chunk warning (server-only, no runtime impact)
CI / tests-imaging-browsers (push) Successful in 1m53s
CI / tests-integration (push) Successful in 2m4s
CI / tests-unit (push) Successful in 2m47s
CI / tests-quality (push) Successful in 1m12s
CI / tests-ui (push) Successful in 5m49s
CI / deploy (push) Successful in 3m29s
CI / check (push) Successful in 32s
CI / preflight (push) Skipped
2026-10-17 19:16:40 +02:00
openhands ef0c425598 fix(redis): skip missing-REDIS_URL warning during production build
CI / check (push) Successful in 40s
CI / tests-integration (push) Successful in 2m15s
CI / tests-imaging-browsers (push) Successful in 1m36s
CI / tests-ui (push) Failing after 6m56s
CI / deploy (push) Skipped
CI / tests-unit (push) Successful in 3m7s
CI / tests-quality (push) Successful in 1m14s
CI / preflight (push) Skipped
2026-10-17 19:03:43 +02:00
openhands 8d3263a8fe feat(i18n): translate dashboard and home/profile strings for nl
CI / tests-integration (push) Successful in 2m19s
CI / tests-unit (push) Successful in 3m9s
CI / tests-quality (push) Successful in 1m32s
CI / tests-ui (push) Successful in 7m6s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 21s
CI / check (push) Successful in 39s
CI / tests-imaging-browsers (push) Successful in 1m51s
2026-10-17 19:03:24 +02:00
openhands 050fcdf25b feat(i18n): wire header strings to translations across locales
CI / tests-ui (push) Successful in 5m45s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 3m10s
CI / check (push) Successful in 34s
CI / tests-imaging-browsers (push) Successful in 2m7s
CI / tests-integration (push) Successful in 2m18s
CI / tests-unit (push) Successful in 3m8s
CI / tests-quality (push) Successful in 1m20s
2026-10-17 18:50:54 +02:00
openhands a40669b130 feat(i18n): add header menu keys to all locales
CI / check (push) Successful in 31s
CI / tests-integration (push) Successful in 2m33s
CI / tests-quality (push) Successful in 1m31s
CI / tests-unit (push) Successful in 3m24s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 3m27s
CI / tests-imaging-browsers (push) Successful in 1m46s
CI / tests-ui (push) Successful in 5m52s
Adds the header.* keys introduced by the header rewrite (enter, search
users, staff, mod/admin panel, command center, logs, logo generator,
dashboard/profile/settings links, credits/duckets/diamonds, ...) to all
25 message files. Values are translated where available and fall back to
English otherwise.

The working tree files had been re-serialized with escaped unicode
(\uXXXX); this commit restores literal UTF-8 so the diff shows only the
content change instead of a full-file rewrite.
2026-10-17 18:25:47 +02:00
openhands c15177a52e fix(build): keep the server logger out of client bundles
CI / check (push) Successful in 33s
CI / tests-imaging-browsers (push) Successful in 1m50s
CI / tests-integration (push) Successful in 2m28s
CI / tests-ui (push) Successful in 5m56s
CI / preflight (push) Skipped
CI / tests-unit (push) Successful in 3m6s
CI / tests-quality (push) Successful in 1m40s
CI / deploy (push) Successful in 3m58s
The production webpack build failed with UnhandledSchemeError: client
components imported @/lib/logger, which binds pino, AsyncLocalStorage
(node:async_hooks), node:crypto and node:fs — and validates server env on
import. The UI fixture aliased those modules, so the harness and tests
never hit it.

- add src/lib/browser-log.ts: browser-safe logger with the same interface;
  errors are forwarded to /api/diagnostics/errors via reportBrowserError
- guard src/lib/logger.ts with server-only and drop its now-dead browser
  branches
- switch the 11 client components/hooks to @/lib/browser-log

Verified with the Dockerfile's production build (pnpm run build) and the
full vitest suite.
2026-10-17 17:49:53 +02:00
176 changed files with 4453 additions and 2693 deletions

No files matched your search

+19
View File
@@ -26,6 +26,25 @@ bun.lockb
public/nitro-assets
public/swf
# Testbronnen draaien op de host (pnpm test:ui / test:integration / test:e2e),
# de image heeft ze niet nodig. In de image zouden ze alleen de typecheck van
# `next build` meebepalen en de image van @playwright/test, vitest, msw,
# testcontainers en drizzle-kit afhankelijk maken.
e2e/
integration/
# Databasebackups (~232MB, dagelijkse rotatie): horen niet in de buildcontext.
# Ze maakten elke context groot en invalideerden bij elke rotatie de
# `COPY . .`-laag, waardoor de Next-build volledig opnieuw draaide.
backups/
# Wordt tijdens de build opnieuw gegenereerd door `pnpm assets:editor`
# (bron: node_modules/tinymce) en staat niet in git.
public/vendor/
# Lokale buildcache voor `docker compose build` (cache_from/cache_to).
.docker-cache/
.deploy.lock
logs
+11 -2
View File
@@ -3,8 +3,13 @@
# ==============================================================================
# --- DATABASE (High Performance Pooling & Strict Timeouts) ---
DATABASE_URL="mysql://user:password@localhost:3306/dbname?charset=utf8mb4&connection_limit=150&connect_timeout=5"
DATABASE_POOL_SIZE=150
# The pool size is governed by DATABASE_POOL_SIZE (src/lib/db.ts); the
# `connection_limit` in the URL is only honoured by tools that read the URL
# directly (drizzle-kit, scripts). Keep both in step and keep them well below
# MariaDB's `max_connections` (151 by default) or the emulator and any second
# CMS instance get "Too many connections". README §performance: pool size 25.
DATABASE_URL="mysql://user:password@localhost:3306/dbname?charset=utf8mb4&connection_limit=25&connect_timeout=5"
DATABASE_POOL_SIZE=25
DATABASE_IDLE_TIMEOUT_MS=60000
DATABASE_CONNECT_TIMEOUT_MS=5000
@@ -59,6 +64,10 @@ AUTH_SECRET=your-super-secret-auth-key-change-this-min-32-chars
APP_KEY=base64:your-app-key-here=
# Bcrypt cost factor for new password hashes.
BCRYPT_COST=12
# Password-length policy for every new password (register, password reset and
# staff-created accounts). Minimum: 5-30. Maximum: 30-128.
PASSWORD_MIN_LENGTH=12
PASSWORD_MAX_LENGTH=128
# --- ANTI-DDOS (app-layer gate, production only) ---
# On by default in production. Set to "false" to disable (not recommended).
+13 -13
View File
@@ -29,16 +29,16 @@ jobs:
LOG_LEVEL: ${{ github.event.inputs.debug == 'true' && 'debug' || 'info' }}
shell: bash
run: |
docker run --rm \
--memory=6g \
--memory-swap=6g \
-e RENOVATE_PLATFORM=gitea \
-e RENOVATE_ENDPOINT=https://gitlab.epicnabbo.nl/ \
-e RENOVATE_TOKEN \
-e RENOVATE_REPOSITORIES='["remco/EpicNext-Cms"]' \
-e RENOVATE_AUTODISCOVER=false \
-e RENOVATE_BINARY_SOURCE=install \
-e LOG_LEVEL \
-e RENOVATE_ONBOARDING=true \
-e RENOVATE_REQUIRE_CONFIG=required \
"renovate/renovate:${RENOVATE_VERSION}"
docker run --rm \
--memory=6g \
--memory-swap=6g \
-e RENOVATE_PLATFORM=gitea \
-e RENOVATE_ENDPOINT=https://gitlab.epicnabbo.nl/ \
-e RENOVATE_TOKEN \
-e RENOVATE_REPOSITORIES='["remco/EpicNext-Cms"]' \
-e RENOVATE_AUTODISCOVER=false \
-e RENOVATE_BINARY_SOURCE=install \
-e LOG_LEVEL \
-e RENOVATE_ONBOARDING=true \
-e RENOVATE_REQUIRE_CONFIG=required \
"renovate/renovate:${RENOVATE_VERSION}"
+3
View File
@@ -57,6 +57,9 @@ blob-report/
build-reports/
!/docs/performance-budgets.md
# Local buildcache (docker compose cache_from/cache_to)
.docker-cache/
# One-off local snapshots (o.a. catalog-integrity/pre-fix.json): runtime-werk,
# geen bron. Per map opgeslagen om een incident terug te kunnen lezen.
backups/
+1 -1
View File
@@ -1 +1 @@
26.10.0
26.12.1
+9 -9
View File
@@ -26,15 +26,15 @@ This source change alone does not configure or deploy the production service.
## Exported content
| Source | Repository destination |
| --- | --- |
| Configured Nitro bundles, including furniture, figures, effects and pets | `Gamedata/bundled` |
| Configured furni icons | `Gamedata/icons` |
| Existing badge/catalog images | `Gamedata/c_images` |
| FurnitureData and supported public game-data JSON files | `Gamedata/config` |
| Existing localized FurnitureData files | `catalogue version 2 ( Final (Dev)/langs furnidata` |
| `items_base`, `catalog_pages`, `catalog_items` | `catalogue version 2 ( Final (Dev)/sqls` |
| `catalog_pages_bc`, `catalog_items_bc`, when present | Same SQL directory |
| Source | Repository destination |
| ------------------------------------------------------------------------ | --------------------------------------------------- |
| Configured Nitro bundles, including furniture, figures, effects and pets | `Gamedata/bundled` |
| Configured furni icons | `Gamedata/icons` |
| Existing badge/catalog images | `Gamedata/c_images` |
| FurnitureData and supported public game-data JSON files | `Gamedata/config` |
| Existing localized FurnitureData files | `catalogue version 2 ( Final (Dev)/langs furnidata` |
| `items_base`, `catalog_pages`, `catalog_items` | `catalogue version 2 ( Final (Dev)/sqls` |
| `catalog_pages_bc`, `catalog_items_bc`, when present | Same SQL directory |
SQL is read in one consistent, read-only InnoDB transaction. Dumps contain table
definitions and deterministic upserts with hexadecimal UTF-8 string literals.
+28 -6
View File
@@ -1,23 +1,45 @@
# syntax=docker/dockerfile:1
FROM node:26.10.0-alpine AS migrations
FROM node:26.12.1-alpine AS migrations
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
# Installeer git, bash en pnpm v12. bash is nodig voor
# Installeer git, bash en corepack. bash is nodig voor
# scripts/with-memory-cap.sh (gebruikt bashisme zoals arrays en BASH_REMATCH);
# Alpine levert geen bash mee.
#
# pnpm komt niet meer uit een losgepind npm-pakket, maar uit corepack: die
# leest de versie én de sha512-hash uit de `packageManager`-pin van
# package.json. Daarmee kan de image nooit uit sync raken met het lockfile
# (vroeger stond hier [email protected] terwijl package.json 12.11.2 voorschreef)
# en wordt de gedownloade pnpm-binary ook in de runtime-image bewaard.
RUN --mount=type=cache,target=/var/cache/apk \
--mount=type=cache,target=/root/.npm \
apk add --no-cache git bash \
&& npm install -g [email protected]
&& npm install -g [email protected] \
&& corepack enable pnpm
# corepack bewaart de gedownloade pnpm buiten de cache-mount, zodat de runtime
# (`docker run --entrypoint pnpm <migrations-image> db:migrate`) pnpm ook
# zonder netwerk kan gebruiken.
ENV COREPACK_HOME="/usr/local/share/corepack"
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
# Stel het PATH zo in dat Alpine pnpm gegarandeerd overal herkent
ENV PNPM_HOME="/usr/local/share/pnpm"
ENV PATH="$PNPM_HOME:/usr/local/bin:$PATH"
# Alleen package.json: de `packageManager`-pin bepaalt welke pnpm corepack
# installeert. Deze laag blijft gecachet wanneer alleen de lockfile verandert.
COPY package.json ./
RUN corepack install
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* .npmrc* ./
# Voer de installatie uit met de pnpm v12 store cache-mount
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
# Voer de installatie uit met de pnpm v12 store cache-mount. De mount moet op
# de store staan die pnpm ook echt gebruikt ($PNPM_HOME/store/v11); met het
# oude pad (/root/.local/share/pnpm/store) cache'te de mount niets en
# downloadde elke build alle packages opnieuw.
RUN --mount=type=cache,target=/usr/local/share/pnpm/store \
pnpm install --frozen-lockfile --ignore-scripts
COPY . .
@@ -52,7 +74,7 @@ RUN --mount=type=cache,target=/app/.next/cache \
pnpm run build \
&& PERFORMANCE_COMMIT_SHA="$NEXT_DEPLOYMENT_ID" node scripts/performance-report.mjs --output-dir build-reports
FROM node:26.10.0-alpine AS runner
FROM node:26.12.1-alpine AS runner
ARG NEXT_DEPLOYMENT_ID="unknown"
LABEL org.opencontainers.image.revision="$NEXT_DEPLOYMENT_ID"
WORKDIR /app
+221 -181
View File
@@ -8,17 +8,17 @@ Features a premium animated homepage (typewriter hero, floating orbs, scroll cou
## System Requirements
| Component | Version | Notes |
| --------------- | -------------- | ---------------------------------------- |
| Node.js | 26.10.0 | Pinned in `.nvmrc` (`>=26.10.0 <27`) |
| pnpm | 12.11.2 | Pinned via `packageManager` |
| npm | >= 11.x | Supported alternative |
| yarn | >= 4.x | Supported alternative |
| MySQL / MariaDB | 8.0+ / 10.6+ | Shared with the emulator |
| Docker | 24+ | Optional — for containerized deployment |
| Valkey | 8.x+ | Optional — caching, rate limiting, SSE |
| Component | Version | Notes |
| --------------- | ------------ | --------------------------------------- |
| Node.js | 26.12.1 | Pinned in `.nvmrc` (`>=26.12.1 <27`) |
| pnpm | 12.11.2 | Pinned via `packageManager` |
| npm | >= 11.x | Supported alternative |
| yarn | >= 4.x | Supported alternative |
| MySQL / MariaDB | 8.0+ / 10.6+ | Shared with the emulator |
| Docker | 24+ | Optional — for containerized deployment |
| Valkey | 8.x+ | Optional — caching, rate limiting, SSE |
Core stack: **Next.js 16.4.1** (App Router) · **React 19.3.1** · **TypeScript 7.0.2** · **Drizzle ORM 0.45.4** · **Zod 4.6.7** · **Vitest 5.0.4** · **Biome 2.5.15** · **Playwright 1.64.1**.
Core stack: **Next.js 16.4.2** (App Router) · **React 19.3.1** · **TypeScript 7.0.2** · **Drizzle ORM 0.45.4** · **Zod 4.6.7** · **Vitest 5.0.5** · **Biome 2.6.2** · **Playwright 1.64.1**.
> Builds must run through the `pnpm` scripts. This host has no swap and
> `vm.overcommit_memory=0`, so an uncapped `next build` gets OOM-killed by the
@@ -292,14 +292,14 @@ cannot be changed simply by injecting a new variable into an old container.
### Docker commands
| Command | Purpose |
| --- | --- |
| Command | Purpose |
| ------------------------------- | ----------------------------------------- |
| `bash scripts/docker-update.sh` | Pull, build, migrate, recreate and verify |
| `docker compose logs -f cms` | View CMS logs |
| `docker compose exec cms sh` | Open a shell in the running CMS |
| `docker compose restart cms` | Restart the existing release |
| `docker compose down` | Stop services; does not update code |
| `pnpm db:up` / `pnpm db:down` | Manage the optional MariaDB service |
| `docker compose logs -f cms` | View CMS logs |
| `docker compose exec cms sh` | Open a shell in the running CMS |
| `docker compose restart cms` | Restart the existing release |
| `docker compose down` | Stop services; does not update code |
| `pnpm db:up` / `pnpm db:down` | Manage the optional MariaDB service |
For a manual build, export `CMS_RELEASE=$(git rev-parse HEAD)` before
`docker compose build cms`; deployment and migration verification remain your
@@ -307,26 +307,65 @@ responsibility. Docker uses the committed pnpm lockfile and pinned Node version.
The build cache can stay enabled: copying changed source invalidates the
application build layer. Deleting all Docker cache is not an update mechanism.
#### Build toolchain and cache
The image never installs a pinned pnpm of its own. The Dockerfile installs
[corepack](https://nodejs.org/api/corepack.html) and lets it resolve the pnpm
version _and_ its sha256/sha512 hash from the `packageManager` field in
`package.json`. One pin therefore drives the Docker build, the CI runner and
the host — the image can no longer drift away from the lockfile, and a tampered
package tarball is rejected instead of silently installed. Bumping pnpm is a
single edit in `package.json` (plus the README version table, checked by
`pnpm readme:versions:check`).
Two caches make repeated builds cheap:
- **pnpm store** — a BuildKit cache mount on `$PNPM_HOME/store` keeps the
downloaded packages between builds. A lockfile change then re-uses the store
(`reused 493, downloaded 0`) instead of re-downloading every package.
- **Local build cache** — `docker compose build` exports the build cache to
`.docker-cache/cms` and imports it again on the next build. Unlike the
daemon's own cache, this survives `scripts/docker-prune.sh`, which trims the
daemon cache to 4 GB after every deploy. The directory is ignored by both
Git and the build context; delete it to force a cold build.
The CI has the same problem on a bigger scale: the runner workspace is wiped
after every job and `scripts/docker-prune.sh` trims the daemon cache after
every release, so the next build started with an empty cache and downloaded all
493 packages again (measured: `reused 0, downloaded 493`). `scripts/ci-deploy.sh`
and `scripts/ci-preflight.sh` therefore export the build cache to a directory
outside the workspace — `/var/cache/epicnext-cms-buildcache` for the release and
`/var/cache/epicnext-cms-preflight-cache` for branch preflights, so a branch can
never feed the release cache. With that export in place the same build re-uses
the frozen dependency layer (`reused 493, downloaded 0`) even after a full
`docker builder prune`. Set `CMS_BUILD_CACHE_DIR` to another path to move the
cache, or to an empty value to switch it off.
The build context excludes `e2e/` and `integration/`. `next build` type-checks
the whole project, so shipping the test suites inside the image made a type
error in test code break the image build and pulled `@playwright/test`, `vitest`,
`msw` and `testcontainers` into it. The CI runs those suites on the host
(`pnpm test:ui`, `pnpm test:integration`).
### Volumes
Only the CMS (and the optional avatar imager container, see [Avatar Imaging](#avatar-imaging)) run in Docker. The heavy client assets and gamedata stay on the host and are shared into the container so imports and uploads persist:
| Container Path | Host Path | Mode | Purpose |
| -------------------------- | -------------------------- | ---- | ------------------------------------ |
| `/app/public/nitro-assets` | `./public/nitro-assets` | rw | Imported furni/pet/effect assets |
| `/app/public/swf` | `./public/swf` | rw | SWF costumes / icons (imports) |
| `/app/storage` | `./storage` | rw | Uploaded media (persistent) |
| `/var/www/Gamedata` | `/var/www/Gamedata` | rw | Shared gamedata root (hardcoded path)|
| Container Path | Host Path | Mode | Purpose |
| -------------------------- | ----------------------- | ---- | ------------------------------------- |
| `/app/public/nitro-assets` | `./public/nitro-assets` | rw | Imported furni/pet/effect assets |
| `/app/public/swf` | `./public/swf` | rw | SWF costumes / icons (imports) |
| `/app/storage` | `./storage` | rw | Uploaded media (persistent) |
| `/var/www/Gamedata` | `/var/www/Gamedata` | rw | Shared gamedata root (hardcoded path) |
**About the hardcoded `/var/www/Gamedata` path:** `src/lib/services/furni-asset-dirs.ts` defines `DEFAULT_GAMEDATA_ROOT = /var/www/Gamedata` as an absolute on-disk path the CMS reads and mirrors imported assets into. The compose file mounts the host `/var/www/Gamedata` at the identical path inside the container so `existsSync('/var/www/Gamedata')` succeeds and nginx keeps serving `/gamedata/` from the same directory.
**The Nitro/Octane client and renderer are NOT mounted** — nginx on the host serves them directly:
| Component | Host path | How it's served |
| -------------------- | ----------------------- | ---------------------------------------------------------------------- |
| Nitro/Octane client | `/var/www/Octane/dist` | nginx `location ^~ /client/` and `/nitro-client/` alias |
| Avatar imager | `/docker/Polaris-imager`| Docker container `avatar-imaging-pixinode` on port `8082` — see [Avatar Imaging](#avatar-imaging) |
| Camera uploads | `/var/www/Camera` | nginx `/camera/` alias |
| Component | Host path | How it's served |
| ------------------- | ------------------------ | ------------------------------------------------------------------------------------------------- |
| Nitro/Octane client | `/var/www/Octane/dist` | nginx `location ^~ /client/` and `/nitro-client/` alias |
| Avatar imager | `/docker/Polaris-imager` | Docker container `avatar-imaging-pixinode` on port `8082` — see [Avatar Imaging](#avatar-imaging) |
| Camera uploads | `/var/www/Camera` | nginx `/camera/` alias |
**Container user & write permissions:** the CMS container runs as `www-data` (UID/GID 33) by default to match the host owner of `/var/www/Gamedata`. Ensure the other write volumes (`./public/nitro-assets`, `./public/swf`, `./storage`) are also owned by `www-data` on the host:
@@ -346,7 +385,13 @@ docker compose build --build-arg RUN_USER=1000
The CMS container runs with `network_mode: host`. This lets the existing `127.0.0.1` references in `.env` keep working against host services — MariaDB (`3306`), Valkey/Redis (`6379`), the emulator RCON/API (`3003`/`3001`) and the avatar imager — without re-writing any environment variables. The container consequently listens **directly on the host's port 3002**, so stop any previous host-side CMS (e.g. `pm2 stop next`) that occupies that port before starting it.
The **build** also runs on the host network (`build.network: host`) because this host disables Docker iptables (`/etc/docker/daemon.json`: `"iptables": false`), which would otherwise leave build containers without outbound NAT/DNS when fetching packages.
The **build** deliberately does _not_ use host networking. BuildKit (v0.26, Docker 29)
refuses to grant host networking unless every caller passes
`--allow=network.host`, and `docker compose build` has no such flag — asking for
`build.network: host` therefore turned every rebuild into an immediate
"additional privileges requested" failure. The build only needs outbound
internet (apk, corepack/pnpm, next/font/google), which the default bridge
provides.
### Health Check
@@ -365,7 +410,6 @@ pnpm db:up # docker compose --profile db up -d (starts mariadb-turbo)
```
Key settings (see the `command:` block in `docker-compose.yml`):
- `max_allowed_packet = 512M` — 50 MB JSON batches no longer hit the 16 MB packet ceiling.
- `innodb_flush_log_at_trx_commit = 2` + `innodb_doublewrite = 0` — durability relaxed for bulk writes.
- `innodb_buffer_pool_size = 2G`, `innodb_log_file_size = 1G`, `bulk_insert_buffer_size = 512M`.
@@ -378,7 +422,7 @@ The datadir lives in a **named volume** (`mariadb-turbo-data`), never a host bin
#### node_modules & host-sync corruption
pnpm's store is hard-linked and its `.bin` shims are symlinks, so **node_modules must never be a host bind-mount** (Docker Desktop gRPC-FUSE/VirtioFS, Unison and Syncthing all corrupt it). The production build bakes `node_modules` into the image at build time; it is never bind-mounted. For local dev, mount a *named volume* (`node_modules:/app/node_modules`) instead of the host directory, and keep `node_modules` / the pnpm store out of any shared-filesystem bind.
pnpm's store is hard-linked and its `.bin` shims are symlinks, so **node_modules must never be a host bind-mount** (Docker Desktop gRPC-FUSE/VirtioFS, Unison and Syncthing all corrupt it). The production build bakes `node_modules` into the image at build time; it is never bind-mounted. For local dev, mount a _named volume_ (`node_modules:/app/node_modules`) instead of the host directory, and keep `node_modules` / the pnpm store out of any shared-filesystem bind.
#### Loading a 50 MB JSON dump
@@ -393,8 +437,7 @@ pnpm db:bulk --file=/data/products.json --table=docs --category=furni
pnpm db:bulk --file=/data/external_texts.json --table=texts --category=default
```
The importer (`scripts/bulk-import-json.ts`) maps rows onto `src/db/schema-gamedata.ts`, batches them into **2000-row multi-row INSERTs** (one statement per chunk), uses `ON DUPLICATE KEY UPDATE` so re-runs are idempotent and interrupted loads resume, and prints rows/s + ETA. See ["ORM Setup & Type Generation"](#orm-setup--type-generation) → *Bulk JSON storage* for the design.
The importer (`scripts/bulk-import-json.ts`) maps rows onto `src/db/schema-gamedata.ts`, batches them into **2000-row multi-row INSERTs** (one statement per chunk), uses `ON DUPLICATE KEY UPDATE` so re-runs are idempotent and interrupted loads resume, and prints rows/s + ETA. See ["ORM Setup & Type Generation"](#orm-setup--type-generation) → _Bulk JSON storage_ for the design.
---
@@ -409,50 +452,47 @@ import { db } from "@/lib/db";
import { users } from "@/db/schema";
import { eq } from "drizzle-orm/expressions";
const found = await db.select()
.from(users)
.where(eq(users.username, "hello"));
const found = await db.select().from(users).where(eq(users.username, "hello"));
```
**Drizzle CLI** (`drizzle-kit`) is used for local development tasks — it is a devDependency and is never bundled in production.
| Command | What it does |
| ---------------------- | ---------------------------------------------------------------------- |
| `pnpm db:generate` | Draft SQL from Drizzle schema into `drizzle/drafts/` (review + copy) |
| `pnpm db:studio` | Open Drizzle Studio (dev only) |
| `pnpm db:introspect` | Reverse-engineer an existing DB into a Drizzle schema draft |
| `pnpm db:bulk` | Batch-import >50 MB JSON (2000-row chunks, resumable) via `scripts/bulk-import-json.ts` |
| `pnpm db:schema:generate` | Regen committed `src/db/schema.ts` from previous schema + live DB |
| Command | What it does |
| ------------------------- | --------------------------------------------------------------------------------------- |
| `pnpm db:generate` | Draft SQL from Drizzle schema into `drizzle/drafts/` (review + copy) |
| `pnpm db:studio` | Open Drizzle Studio (dev only) |
| `pnpm db:introspect` | Reverse-engineer an existing DB into a Drizzle schema draft |
| `pnpm db:bulk` | Batch-import >50 MB JSON (2000-row chunks, resumable) via `scripts/bulk-import-json.ts` |
| `pnpm db:schema:generate` | Regen committed `src/db/schema.ts` from previous schema + live DB |
> The CMS does **not** use `drizzle-kit push` or `drizzle-kit migrate` — the database is shared with the emulator. Apply CMS DDL only via `pnpm db:migrate`.
#### Bulk JSON storage (MariaDB)
Emulator/hotel JSON dumps (furnidata, external texts, product data) are often >50 MB. MariaDB's `JSON` type is an alias for `LONGTEXT` and can **never be indexed directly**, so `src/db/schema-gamedata.ts` follows the "promote + index" pattern:
- The raw JSON document stays intact in a `longtext` / `mediumtext` column (`payload`, `value`).
- The fields you actually `WHERE` / `ORDER BY` on are promoted into real columns (`sprite_id`, `class_name`, `kind`, `title`, `text_key`) and indexed.
- Optional **VIRTUAL generated columns** extract indexed fields from the payload with `JSON_EXTRACT` (MariaDB supports secondary indexes on virtual columns, 10.2+), so no duplicate data has to be written by the importer.
Three tables are exported:
| Table | Purpose | Unique key |
| -------------------- | ---------------------------------------------- | --------------------------- |
| `gamedata_furnidata` | One row per furni/clothing item (habbo furnidata_json shape) | `(source, sprite_id)` |
| `gamedata_docs` | Generic JSON document store (per-key documents) | `(category, doc_key)` |
| `gamedata_texts` | External-texts style key/value pairs | `(category, text_key)` |
| Table | Purpose | Unique key |
| -------------------- | ------------------------------------------------------------ | ---------------------- |
| `gamedata_furnidata` | One row per furni/clothing item (habbo furnidata_json shape) | `(source, sprite_id)` |
| `gamedata_docs` | Generic JSON document store (per-key documents) | `(category, doc_key)` |
| `gamedata_texts` | External-texts style key/value pairs | `(category, text_key)` |
The bulk importer (`scripts/bulk-import-json.ts`, run via `pnpm db:bulk`) is DB-driven and fast precisely because each chunk is a *single* multi-row `INSERT … VALUES () ()… ON DUPLICATE KEY UPDATE`, so a 50 MB dump is a few dozen statements instead of hundreds of thousands of round-trips. Flags:
The bulk importer (`scripts/bulk-import-json.ts`, run via `pnpm db:bulk`) is DB-driven and fast precisely because each chunk is a _single_ multi-row `INSERT … VALUES () ()… ON DUPLICATE KEY UPDATE`, so a 50 MB dump is a few dozen statements instead of hundreds of thousands of round-trips. Flags:
| Flag | Default | Description |
| -------------------- | ----------- | ------------------------------------------------- |
| `--file=<path>` | — (required)| JSON file (habbo furnidata_json or flat array) |
| `--table=<name>` | `furnidata` | One of `furnidata` \| `docs` \| `texts` |
| `--source=<x>` | `habbo` | `source` value for `furnidata` |
| `--category=<x>` | `default` | `category` value for `docs` / `texts` |
| `--chunk-size=N` | `2000` | Rows per multi-row INSERT |
| `--limit=N` | `0` | Stop after N rows (dry-test) |
| `--truncate` | off | DELETE rows for this source/category first |
| Flag | Default | Description |
| ---------------- | ------------ | ---------------------------------------------- |
| `--file=<path>` | — (required) | JSON file (habbo furnidata_json or flat array) |
| `--table=<name>` | `furnidata` | One of `furnidata` \| `docs` \| `texts` |
| `--source=<x>` | `habbo` | `source` value for `furnidata` |
| `--category=<x>` | `default` | `category` value for `docs` / `texts` |
| `--chunk-size=N` | `2000` | Rows per multi-row INSERT |
| `--limit=N` | `0` | Stop after N rows (dry-test) |
| `--truncate` | off | DELETE rows for this source/category first |
The script sets `FOREIGN_KEY_CHECKS=0` for the session and is safe to interrupt: each chunk commits on its own, and `ON DUPLICATE KEY UPDATE` makes re-runs overwrite instead of appending.
@@ -595,7 +635,7 @@ URLs look like `https://<hotel>/imaging/avatarimage?figure=hd-180-1.ch-210-66&im
### Requirements (host)
- Docker (daemon with `build.network: host`, same as the CMS build — this host disables Docker iptables).
- Docker (daemon with BuildKit; the CMS build runs on the default bridge, not on host networking — see [Networking](#networking)).
- An nginx that serves `/gamedata/` (FigureData/FigureMap/EffectMap…) and `/gamedata/bundled` (furniture bundle assets — `.hab` by default, the extension imports write and this deployment's client requests, plus any legacy `.nitro` still on disk; choose the written extension with `FURNI_BUNDLE_EXT`) over HTTP so the renderer can fetch them. The compose file points at `host.docker.internal:8081`.
### Configuration — `/docker/Polaris-imager/.env`
@@ -697,13 +737,13 @@ For bulk-written tables (game-data JSON, imports) a containerized **`mariadb-tur
### 3. Asset caching headers
| Path | Cache-Control |
| ------------------------------ | -------------------------------------------------------------- |
| `/swf/**`, `/nitro-assets/**` | `public, max-age=604800, stale-while-revalidate=2592000` |
| `/gamedata/**`, `/client/**` | `public` + `expires 7–30 d` (nginx) |
| `/imaging/*` | `public, max-age=300` |
| `/camera/**` | `public, max-age=31536000, immutable` |
| `/_next/static/**` | `public, max-age=31536000, immutable` |
| Path | Cache-Control |
| ----------------------------- | -------------------------------------------------------- |
| `/swf/**`, `/nitro-assets/**` | `public, max-age=604800, stale-while-revalidate=2592000` |
| `/gamedata/**`, `/client/**` | `public` + `expires 7–30 d` (nginx) |
| `/imaging/*` | `public, max-age=300` |
| `/camera/**` | `public, max-age=31536000, immutable` |
| `/_next/static/**` | `public, max-age=31536000, immutable` |
### 4. Cache layers (Redis + CDN)
@@ -722,16 +762,15 @@ handlers, and its response helper then drops the `Cache-Control` set by
free of `auth()`, `cookies()`, `headers()` and request data, so the edge may
cache them on their behalf.
| Class | Endpoints | TTL |
| --- | --- | --- |
| 1 | `/api/online`, `/api/online/count` | 10s |
| 2 | `/api/photos`, `/api/leaderboard`, `/api/radio/current-dj`, `/api/radio/points/leaderboard` | 60s |
| 3 | `/api/staff`, `/api/teams`, `/api/guilds`, `/api/shop`, `/api/shop/categories`, `/api/values`, `/api/values/categories`, `/api/values/[0-9]+` | 300s |
| Class | Endpoints | TTL |
| ----- | --------------------------------------------------------------------------------------------------------------------------------------------- | ---- |
| 1 | `/api/online`, `/api/online/count` | 10s |
| 2 | `/api/photos`, `/api/leaderboard`, `/api/radio/current-dj`, `/api/radio/points/leaderboard` | 60s |
| 3 | `/api/staff`, `/api/teams`, `/api/guilds`, `/api/shop`, `/api/shop/categories`, `/api/values`, `/api/values/categories`, `/api/values/[0-9]+` | 300s |
`/api/radio/shouts` is intentionally excluded: it is live chat.
Three details are easy to get wrong and are worth keeping intact:
- **`proxy_ignore_headers "Cache-Control" "Vary";` is what makes the cache work.**
`proxy_hide_header` only strips the header from the response to the client; the
cache module has already read the upstream's `no-store` by then and stores
@@ -752,21 +791,21 @@ with a redirect to `/me` and reads `headers()` for a per-request CSP nonce.
### Files changed for stability and caching
| File | Change |
| --- | --- |
| `/etc/nginx/nginx.conf` | `worker_connections 4096`, `multi_accept on`, TLS 1.2/1.3 only, `proxy_cache_path` for the micro-cache |
| `/etc/nginx/sites-enabled/cms.conf` | single correct `Cache-Control` per path, micro-cache on the whitelist, session/Authorization bypass, SSE block with buffering off, `upstream cms_app` + `proxy_next_upstream` |
| `/etc/nginx/snippets/cms_upstream_servers.conf` | backend list, rewritten by `ci-deploy.sh` during a cutover |
| `src/app/api/online/count/stream/route.ts` | `X-Accel-Buffering: no` |
| `src/app/api/radio/stream/route.ts` | `X-Accel-Buffering: no` |
| `src/app/api/admin/import/{audit,furni/route,furni/batch,furni/batch-regen,furni/repair-icons}/route.ts` | `X-Accel-Buffering: no` |
| `src/app/api/admin/studio/nitro-cleanup{,/rebuild}/route.ts` | `X-Accel-Buffering: no` |
| `scripts/ci-deploy.sh` | resource limits, blue/green cutover, `switch_upstream`, rollback split by cutover state |
| `deploy.sh` | reduced to a wrapper around `ci-deploy.sh` |
| `docker-compose.yml` | two replica services from a shared anchor, resource limits, port-aware healthcheck, dead `mariadb-turbo` removed |
| `src/lib/services/cache-warmup.ts` | corrected a comment that described delays the code never had |
| `src/lib/ci-deploy.test.ts`, `src/test/ci-deploy-harness.sh` | blue/green coverage; nginx paths are injectable so the in-place path stays tested |
| `ecosystem.config.cjs` | removed (PM2 supervised no process, and nothing referenced it) |
| File | Change |
| -------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/etc/nginx/nginx.conf` | `worker_connections 4096`, `multi_accept on`, TLS 1.2/1.3 only, `proxy_cache_path` for the micro-cache |
| `/etc/nginx/sites-enabled/cms.conf` | single correct `Cache-Control` per path, micro-cache on the whitelist, session/Authorization bypass, SSE block with buffering off, `upstream cms_app` + `proxy_next_upstream` |
| `/etc/nginx/snippets/cms_upstream_servers.conf` | backend list, rewritten by `ci-deploy.sh` during a cutover |
| `src/app/api/online/count/stream/route.ts` | `X-Accel-Buffering: no` |
| `src/app/api/radio/stream/route.ts` | `X-Accel-Buffering: no` |
| `src/app/api/admin/import/{audit,furni/route,furni/batch,furni/batch-regen,furni/repair-icons}/route.ts` | `X-Accel-Buffering: no` |
| `src/app/api/admin/studio/nitro-cleanup{,/rebuild}/route.ts` | `X-Accel-Buffering: no` |
| `scripts/ci-deploy.sh` | resource limits, blue/green cutover, `switch_upstream`, rollback split by cutover state |
| `deploy.sh` | reduced to a wrapper around `ci-deploy.sh` |
| `docker-compose.yml` | two replica services from a shared anchor, resource limits, port-aware healthcheck, dead `mariadb-turbo` removed |
| `src/lib/services/cache-warmup.ts` | corrected a comment that described delays the code never had |
| `src/lib/ci-deploy.test.ts`, `src/test/ci-deploy-harness.sh` | blue/green coverage; nginx paths are injectable so the in-place path stays tested |
| `ecosystem.config.cjs` | removed (PM2 supervised no process, and nothing referenced it) |
> The nginx files live on the host only, not in this repository. Rebuilding the
> host loses the cache configuration — keep a copy under version control if that
@@ -778,7 +817,6 @@ with a redirect to `/me` and reads `headers()` for a per-request CSP nonce.
Two layers work together to keep the origin alive during misuse, floods, and
repeat-offender attacks:
1. **App-layer gate** (production only) — per-IP request buckets per category
(pages / API / auth), a whole-site safety valve, and escalation tiers that
temporarily hard-block offending IPs (`src/lib/ddos-guard.ts`).
@@ -800,7 +838,7 @@ repeat-offender attacks:
- A Cloudflare API token with the following zone permissions:
- `Zone > Zone > Read`
- `Zone > Firewall > Edit`
Create one at **My Profile → API Tokens → Create Token** (custom token).
Create one at **My Profile → API Tokens → Create Token** (custom token).
### 1. Environment variables
@@ -836,7 +874,6 @@ Restart the container after changing them.
Open **DevOps → Anti-DDoS protection**
(`/admin/devops/antiddos`, requires `settings.view`) and:
- Check the **Cloudflare** card shows the request arrived proxied, and the
green **API configured** badge.
- Click **Verify connection** — it confirms the token can read the zone and
@@ -872,19 +909,18 @@ read one thing, read the precedence table below.
### Where the captcha is configured (and who wins)
There are two places, and the **admin panel always wins**:
1. **Admin panel** — *Admin → CMS Settings → Captcha* (stored in the database).
1. **Admin panel** — _Admin → CMS Settings → Captcha_ (stored in the database).
2. **Environment variables** — used **only** as a fallback when the admin panel
has no value.
Resolution order, highest first:
| Admin panel `captcha_provider` | Env `CAPTCHA_PROVIDER` | What happens |
| --- | --- | --- |
| `turnstile` | anything | **Turnstile** (panel wins) |
| `none` (set explicitly) | `turnstile` | **Off** — an explicit panel value wins |
| *empty / never set* | `turnstile` | **Turnstile** (env fallback) |
| *empty / never set* | *empty / never set* | **Off** |
| Admin panel `captcha_provider` | Env `CAPTCHA_PROVIDER` | What happens |
| ------------------------------ | ---------------------- | -------------------------------------- |
| `turnstile` | anything | **Turnstile** (panel wins) |
| `none` (set explicitly) | `turnstile` | **Off** — an explicit panel value wins |
| _empty / never set_ | `turnstile` | **Turnstile** (env fallback) |
| _empty / never set_ | _empty / never set_ | **Off** |
In short: **enable it in the admin panel and the env vars are ignored; leave the
panel empty and the env vars take over.** An explicit `none` in the panel turns
@@ -895,7 +931,7 @@ the captcha **off** even when `CAPTCHA_PROVIDER=turnstile` is set.
1. Create a Turnstile widget in the
[Cloudflare dashboard](https://dash.cloudflare.com/?to=/:account/turnstile):
**Add widget**, name it, and add the hostnames your hotel is served on
(e.g. `epicnabbo.nl`, `www.epicnabbo.nl`). *Managed* mode is fine. Copy the
(e.g. `epicnabbo.nl`, `www.epicnabbo.nl`). _Managed_ mode is fine. Copy the
**Site Key** and the **Secret Key**.
2. Put them in your real `.env` (never commit this file):
@@ -960,16 +996,18 @@ port rather than sharing a published docker port. `docker compose up --scale
cms=2` therefore cannot work here: the replicas would collide on 3002. Instead
there are two fixed slots, and each release lands in the idle one:
| Slot | Host port | Container name |
| --- | --- | --- |
| A | 3002 | `epicnext-cms-app` |
| B | 3003 | `epicnext-cms-green` |
| Slot | Host port | Container name |
| ---- | --------- | -------------------- |
| A | 3002 | `epicnext-cms-app` |
| B | 3003 | `epicnext-cms-green` |
`docker-compose.yml` defines both services (`cms`, and `cms-green` behind the
`green` profile) from one shared YAML anchor so they cannot drift apart. Note
that the CI deploy path deliberately uses `docker run` rather than compose, so
the resource limits are declared in **both** places — limits that only existed
in compose would never apply to a real release.
`docker-compose.yml` defines a single `cms` service (the blue slot on 3002)
behind the `cms` profile, so a bare `docker compose up -d` never starts it. The
green slot (`epicnext-cms-green`, port 3003) only ever exists as a `docker run`
container created by the CI deploy; compose does not define it, so the two paths
cannot drift apart. Note that the CI deploy path deliberately uses `docker run`
rather than compose, so the resource limits are declared in **both** places —
limits that only existed in compose would never apply to a real release.
### Compose on a CI host
@@ -977,15 +1015,19 @@ Compose and CI both want port 3002, so only one of them can own a host. A stray
compose replica (`docker compose up`, or the daily `scripts/docker-update.sh`
cron) parked an `epicnext-cms` container on the blue slot while nginx served the
green slot, and every later release stopped on "Port 3002 is already in use" —
after the build, the migrations and the browser gate. Two guards now prevent
after the build, the migrations and the browser gate. Three guards now prevent
that:
- The `cms` service sits behind the `cms` profile, so a bare `docker compose up
-d` starts only byparr and can never claim 3002. The service starts only when
it is named explicitly (`docker compose up -d cms`), which is what the
installer/updater already do; Compose enables the profile automatically for an
explicitly named service.
- `scripts/docker-update.sh` refuses to run on a CI host. It used to test only
`epicnext-cms-app`, but after a cutover to the green slot that container is
stopped and deleted, so the guard stopped firing while the host stayed
CI-managed. It now checks both slot containers and the nginx upstream, and
reports the skip with exit 0 instead of failing the cron.
- `ci-deploy.sh` retires a compose replica of *this* checkout
- `ci-deploy.sh` retires a compose replica of _this_ checkout
(`com.docker.compose.project.config_files`) from the candidate port before
starting the candidate — but never a slot container, and never the port nginx
currently serves. Anything else still fails loudly in `assert_port_free`.
@@ -1035,7 +1077,7 @@ means a single leak can starve MariaDB, nginx and Traefik.
> **Watch this on the first heavy import.** The 4 GiB ceiling is not yet
> exercised by a real bulk import. If the container is OOM-killed during a large
> furniture import, raise `mem_limit` in `docker-compose.yml` *and* `mem_limit` /
> furniture import, raise `mem_limit` in `docker-compose.yml` _and_ `mem_limit` /
> `mem_swap_limit` in `scripts/ci-deploy.sh` together.
### Manual deploys
@@ -1067,18 +1109,18 @@ cap larger than the free memory from dragging the whole host into the global
OOM-killer. `MemorySwapMax=0` is set explicitly (this host has no swap). If
fewer than 1 GB would remain, the script refuses to start.
| Script | Ceiling | Covers |
| --------------------- | ------- | --------------------------------------- |
| `pnpm dev` | 8 GB | Dev server |
| `pnpm build` | 10 GB | Production build |
| `pnpm analyze` | 10 GB | Build + bundle-size report |
| `pnpm test` | 8 GB | Vitest |
| `pnpm test:coverage` | 8 GB | Vitest with coverage |
| `pnpm test:ui` | 8 GB | Playwright |
| `pnpm test:e2e` | 8 GB | Playwright |
| `pnpm test:e2e:quality` | 8 GB | Playwright (advisory) |
| `pnpm test:integration` | 8 GB | Vitest integration config |
| `pnpm typecheck` | 6 GB | `tsc --noEmit` |
| Script | Ceiling | Covers |
| ----------------------- | ------- | -------------------------- |
| `pnpm dev` | 8 GB | Dev server |
| `pnpm build` | 10 GB | Production build |
| `pnpm analyze` | 10 GB | Build + bundle-size report |
| `pnpm test` | 8 GB | Vitest |
| `pnpm test:coverage` | 8 GB | Vitest with coverage |
| `pnpm test:ui` | 8 GB | Playwright |
| `pnpm test:e2e` | 8 GB | Playwright |
| `pnpm test:e2e:quality` | 8 GB | Playwright (advisory) |
| `pnpm test:integration` | 8 GB | Vitest integration config |
| `pnpm typecheck` | 6 GB | `tsc --noEmit` |
### Running the builder by hand
@@ -1105,18 +1147,18 @@ Use the capped build instead:
```
Fix: use `pnpm build`. If you are genuinely inside an isolated environment
where the container *is* the boundary (the Docker build, a CI runner), set
where the container _is_ the boundary (the Docker build, a CI runner), set
`CMS_MEMORY_CAPPED=1` to opt out deliberately.
### Backends
`scripts/with-memory-cap.sh` picks its mechanism automatically:
| `CMS_MEMORY_CAP_BACKEND` | Mechanism | Notes |
| ------------------------ | ------------------------------------------- | ------------------------------------------------------- |
| `auto` *(default)* | systemd cgroup `MemoryMax` | Real RSS bound over the whole process tree. Used here. |
| `ulimit` | `ulimit -v`, per process | Virtual address space, **not** RSS. Fallback only. |
| `none` | None — warning only | Docker build and GitLab runner, each already isolated. |
| `CMS_MEMORY_CAP_BACKEND` | Mechanism | Notes |
| ------------------------ | -------------------------- | ------------------------------------------------------ |
| `auto` _(default)_ | systemd cgroup `MemoryMax` | Real RSS bound over the whole process tree. Used here. |
| `ulimit` | `ulimit -v`, per process | Virtual address space, **not** RSS. Fallback only. |
| `none` | None — warning only | Docker build and GitLab runner, each already isolated. |
On a host **without** systemd and without `CMS_MEMORY_CAP_BACKEND=none`, the
script refuses to run rather than proceeding unbounded.
@@ -1134,40 +1176,40 @@ the headroom left for everything else on the machine; the effective ceiling is
## Scripts
| Command | Description |
| ------------------------- | -------------------------------------------------- |
| `pnpm dev` | Start development server (hot reload) |
| `pnpm build` | Production build (memory-capped) |
| `pnpm start` | Start production server |
| `pnpm typecheck` | Run TypeScript type checking |
| `pnpm test` | Run all tests (Vitest) |
| `pnpm test:coverage` | Run all tests with coverage thresholds enforced |
| `pnpm test:ui` | Playwright UI tests (`playwright.ui.config.ts`) |
| `pnpm test:ui:update` | Playwright UI tests, updating snapshots |
| `pnpm test:e2e` | Playwright end-to-end tests (blocking deploy gate) |
| `pnpm test:e2e:quality` | Advisory content/latency checks (non-blocking) |
| `pnpm test:integration` | Integration tests (own Vitest config) |
| `pnpm test:housekeeping` | Housekeeping feature tests |
| `pnpm lint` | Lint and format check (Biome) |
| `pnpm biome:lint` | Alias of `pnpm lint` |
| `pnpm format` | Format files in place (Biome) |
| `pnpm toolchain:check` | Verify the Node toolchain matches `.nvmrc` |
| `pnpm i18n:check` | Audit CMS translation coverage |
| `pnpm deps:audit` | Audit dependencies for high-severity advisories |
| `pnpm db:migrate` | Apply pending SQL migrations |
| `pnpm db:migrate:status` | Show migration status |
| `pnpm db:schema:generate` | Regen `src/db/schema.ts` from prior schema + live DB |
| `pnpm db:generate` | Draft SQL via drizzle-kit → `drizzle/drafts/` |
| `pnpm db:studio` | Drizzle Studio (dev) |
| `pnpm db:introspect` | drizzle-kit introspect (draft) |
| `pnpm db:bulk` | Batch-import >50 MB JSON via `scripts/bulk-import-json.ts` |
| `pnpm catalog:sync` | Sync the catalog against a clone source (interactive) |
| `pnpm db:up` / `pnpm db:down` | Start / stop the `mariadb-turbo` container |
| `pnpm gamedata:compress` | Pre-compress large gamedata JSON to `.gz` (gzip_static) |
| `pnpm analyze` | Build + report per-route bundle sizes |
| `pnpm performance:report` | Re-render the performance report from a build |
| `pnpm jobs:worker` | Start background task worker |
| `pnpm assets:editor` | Copy TinyMCE editor assets into `public/` |
| Command | Description |
| ----------------------------- | ---------------------------------------------------------- |
| `pnpm dev` | Start development server (hot reload) |
| `pnpm build` | Production build (memory-capped) |
| `pnpm start` | Start production server |
| `pnpm typecheck` | Run TypeScript type checking |
| `pnpm test` | Run all tests (Vitest) |
| `pnpm test:coverage` | Run all tests with coverage thresholds enforced |
| `pnpm test:ui` | Playwright UI tests (`playwright.ui.config.ts`) |
| `pnpm test:ui:update` | Playwright UI tests, updating snapshots |
| `pnpm test:e2e` | Playwright end-to-end tests (blocking deploy gate) |
| `pnpm test:e2e:quality` | Advisory content/latency checks (non-blocking) |
| `pnpm test:integration` | Integration tests (own Vitest config) |
| `pnpm test:housekeeping` | Housekeeping feature tests |
| `pnpm lint` | Lint and format check (Biome) |
| `pnpm biome:lint` | Alias of `pnpm lint` |
| `pnpm format` | Format files in place (Biome) |
| `pnpm toolchain:check` | Verify the Node toolchain matches `.nvmrc` |
| `pnpm i18n:check` | Audit CMS translation coverage |
| `pnpm deps:audit` | Audit dependencies for high-severity advisories |
| `pnpm db:migrate` | Apply pending SQL migrations |
| `pnpm db:migrate:status` | Show migration status |
| `pnpm db:schema:generate` | Regen `src/db/schema.ts` from prior schema + live DB |
| `pnpm db:generate` | Draft SQL via drizzle-kit → `drizzle/drafts/` |
| `pnpm db:studio` | Drizzle Studio (dev) |
| `pnpm db:introspect` | drizzle-kit introspect (draft) |
| `pnpm db:bulk` | Batch-import >50 MB JSON via `scripts/bulk-import-json.ts` |
| `pnpm catalog:sync` | Sync the catalog against a clone source (interactive) |
| `pnpm db:up` / `pnpm db:down` | Start / stop the `mariadb-turbo` container |
| `pnpm gamedata:compress` | Pre-compress large gamedata JSON to `.gz` (gzip_static) |
| `pnpm analyze` | Build + report per-route bundle sizes |
| `pnpm performance:report` | Re-render the performance report from a build |
| `pnpm jobs:worker` | Start background task worker |
| `pnpm assets:editor` | Copy TinyMCE editor assets into `public/` |
> Replace `pnpm` with `npm run` or `yarn` for other package managers.
>
@@ -1179,20 +1221,20 @@ the headroom left for everything else on the machine; the effective ceiling is
## Performance Features
| Feature | Description |
| ------------------------------ | -------------------------------------------------------------- |
| **React Compiler** | Automatic memoization — reduces unnecessary re-renders |
| **View Transitions API** | Native browser transitions between page navigations |
| **Lenis Smooth Scroll** | Fluid, customizable scrolling |
| **Server-Sent Events** | Real-time radio now-playing & listeners via SSE |
| **Valkey Caching** | Caches API responses up to 30s in Valkey |
| **RCON (TCP Socket)** | Live commands to the emulator (credits, badges, kick, ban) |
| **Streaming & Suspense** | Next.js App Router streaming for fast page loads |
| **Automatic Image Optimization** | `next/image` with Sharp for resizing and WebP/AVIF |
| **Compression** | Gzip compression enabled on all responses |
| **gzip_static (gamedata)** | 48 MB FurnitureData JSON served from pre-built `.gz` — no per-request CPU |
| **PWA** | Service worker with skip-waiting, stale CSS chunk recovery |
| **Biome** | Fast linting and formatting (replaces ESLint + Prettier) |
| Feature | Description |
| -------------------------------- | ------------------------------------------------------------------------- |
| **React Compiler** | Automatic memoization — reduces unnecessary re-renders |
| **View Transitions API** | Native browser transitions between page navigations |
| **Lenis Smooth Scroll** | Fluid, customizable scrolling |
| **Server-Sent Events** | Real-time radio now-playing & listeners via SSE |
| **Valkey Caching** | Caches API responses up to 30s in Valkey |
| **RCON (TCP Socket)** | Live commands to the emulator (credits, badges, kick, ban) |
| **Streaming & Suspense** | Next.js App Router streaming for fast page loads |
| **Automatic Image Optimization** | `next/image` with Sharp for resizing and WebP/AVIF |
| **Compression** | Gzip compression enabled on all responses |
| **gzip_static (gamedata)** | 48 MB FurnitureData JSON served from pre-built `.gz` — no per-request CPU |
| **PWA** | Service worker with skip-waiting, stale CSS chunk recovery |
| **Biome** | Fast linting and formatting (replaces ESLint + Prettier) |
---
@@ -1249,7 +1291,6 @@ The `/client` page loads the Nitro client. Configure the client URL via **Admin
### Radio
Supports any streaming radio (e.g., Azuracast). Configure endpoints via CMS Settings:
- `radio_now_playing_api_url`
- `radio_listeners_api_url`
@@ -1289,7 +1330,6 @@ BYPARR_URL=http://localhost:8191
### Furniture Import with Auto-Translation
The CMS automatically translates furniture names and descriptions to **13 languages** on every import:
- **Native languages** (via official Habbo gamedata): Dutch, English, German, French, Spanish, Turkish, Italian
- **Additional languages** (via LibreTranslate Docker at `127.0.0.1:5000`): Portuguese, Finnish, Polish, Russian, Arabic, Japanese
+4
View File
@@ -25,6 +25,10 @@
},
"style": {
"noDescendingSpecificity": "off"
},
"correctness": {
"noComponentHookFactories": "off",
"usePlaywrightValidDescribeCallback": "off"
}
}
},
+7 -1
View File
@@ -6,4 +6,10 @@ services:
HOSTNAME: 127.0.0.1
PORT: "3002"
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3002/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
test:
[
"CMD",
"node",
"-e",
"fetch('http://127.0.0.1:3002/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))",
]
+7
View File
@@ -39,6 +39,13 @@ http {
server_tokens off;
keepalive_timeout 30s;
# Gamedata/icons are served from disk by alias locations. Caching the
# stat/open results avoids a syscall storm on every room load.
open_file_cache max=10000 inactive=30s;
open_file_cache_valid 60s;
open_file_cache_min_uses 2;
open_file_cache_errors off;
client_max_body_size 64m;
client_body_buffer_size 16k;
client_header_buffer_size 1k;
+47 -18
View File
@@ -1,5 +1,20 @@
# ─────────────────────────────────────────────────────────────────────────────
# Next.js CMS — blue/green
# Next.js CMS — single slot on port 3002
#
# ── Eigenaar van poort 3002 ─────────────────────────────────────────────────
# Deze repo kent twee deploy-paden die allebei op poort 3002 willen:
# * `scripts/ci-deploy.sh` (blue/green, docker run) — de eigenaar op een
# CI-host. Elke push naar main bouwt daar een nieuwe image, draait de
# migrations en zet nginx pas om na health + e2e + imaging.
# * deze compose-`cms`-service — bedoeld voor een LOSSE host zonder CI.
#
# Ze kunnen niet naast elkaar draaien: wie op 3002 zit, blokkeert de ander met
# `EADDRINUSE`. Daarom staat `cms` achter een profile. Een bloot
# `docker compose up -d` start dus alleen byparr en kan nooit de live CI-release
# van de site drukken. De service start alleen als je hem expliciet noemt
# (`docker compose up -d cms`, `docker compose build cms`, ...): Compose schakelt
# het profile van een expliciet genoemde service vanzelf in. Op een CI-host wil
# je dat dus juist NOOIT doen.
# ─────────────────────────────────────────────────────────────────────────────
x-cms: &cms
image: epicnext-cms:${CMS_RELEASE:-local}
@@ -14,8 +29,20 @@ x-cms: &cms
dockerfile: Dockerfile
args:
NEXT_DEPLOYMENT_ID: ${CMS_RELEASE:-unknown}
# Runtime host networking IS required: blue/green needs per-release host ports
# (3002/3003) and nginx reaches the slot over 127.0.0.1.
# Lokale buildcache op schijf. `scripts/docker-prune.sh` brengt de
# daemon-cache na elke deploy terug tot 4GB, waardoor de lagen van de
# vorige release verdwijnen. Deze export overleeft die prune: een rebuild
# na een lockfile-wijziging hergebruikt dan de al gedownloade packages
# (pnpm store) in plaats van ze opnieuw te downloaden.
# De map ligt binnen de buildcontext, zodat buildx een ontbrekende
# cachemap stilzwijgend negeert i.p.v. de build te laten falen; zowel
# .dockerignore als .gitignore sluiten de map uit.
cache_from:
- type=local,src=.docker-cache/cms
cache_to:
- type=local,dest=.docker-cache/cms,mode=max
# Runtime host networking IS required: nginx reaches the CMS over 127.0.0.1
# on the single port that is actually served (3002).
network_mode: host
stop_grace_period: 15s
restart: unless-stopped
@@ -28,13 +55,17 @@ x-cms: &cms
- /var/www/Gamedata:/var/www/Gamedata
# ── Resource limits ──
mem_limit: 6g
memswap_limit: 7g
cpus: 2.0
pids_limit: 512
mem_limit: 4g
memswap_limit: 5g
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:'+(process.env.PORT||'3002')+'/api/health').then(r=>{process.exit(r.ok?0:1)}).catch(()=>process.exit(1))"]
test:
[
"CMD",
"node",
"-e",
"fetch('http://127.0.0.1:'+(process.env.PORT||'3002')+'/api/health').then(r=>{process.exit(r.ok?0:1)}).catch(()=>process.exit(1))",
]
interval: 15s
timeout: 5s
retries: 3
@@ -43,21 +74,19 @@ x-cms: &cms
services:
cms:
<<: *cms
# Alleen starten als je deze host bewust compose-only beheert.
profiles: ["cms"]
container_name: epicnext-cms
environment:
- HOSTNAME=0.0.0.0
- PORT=3002
cms-green:
<<: *cms
container_name: epicnext-cms-green
profiles: ["green"]
environment:
- HOSTNAME=0.0.0.0
- PORT=3003
byparr:
image: ghcr.io/thephaseless/byparr:latest
image: ghcr.io/thephaseless/byparr:v3.0.4
# `latest` is een drijvende tag. Zonder pull_policy haalt compose de
# bestaande image nooit opnieuw op en blijft byparr hangen op de eerste
# versie die ooit werd binnengehaald.
pull_policy: always
container_name: byparr
network_mode: host
restart: unless-stopped
@@ -69,4 +98,4 @@ services:
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
start_period: 30s
+14 -15
View File
@@ -2,20 +2,20 @@
## Operator changes
| Area | Behavior and location |
| --- | --- |
| Release | Deployment checks HTTP health, release identity and Chromium pages before marking the running image verified. Registry publication reuses that verified digest on the shared runner; independent hosts build and verify their own image. |
| Shared HK | Dialogs scroll within the available viewport. Table column preferences persist per page in the current browser session; filters already remain in the URL. No favorites added. |
| Catalog Studio | Dedicated detail drawer and five separate completeness states: SQL, offers, furnidata, icon and Nitro. Sprite/type conflicts are consistently excluded from import and linked to audit. Missing source files are never represented as available. |
| Operations | Command center includes permission-filtered error groups, personal import failures, open support tickets and news drafts. A failed source is shown separately from an empty source. |
| Error center | Retained occurrence counts, first/last times, identified users, release counts, self-assignment and recognized local links. Assignment requires edit permission and is audited. |
| News | Private server-backed autosave, recover/discard/retry, prior saved revisions restored as a draft, and concurrent-edit detection. New status/search filters and pagination make older drafts reachable. |
| Jobs | Cancellation finishes the current item and stops pending items. Completed work stays completed. Uncertain interrupted mutations are excluded from retry. Live lease checks stop known stale worker writes. |
| Installation | `/admin/devops/installation` shows release, DB latency, Redis, emulator, storage permissions, migration history and worker heartbeat. Renderer defaults are recognized. Registry access is explicitly unverified from the web process. |
| Public dashboard | Current/next published event, clearer unread-message action, useful empty/error states and mobile layout refinements. |
| Audit | Exact actor/action and UTC date filters, readable recorded before/after values and permission-protected CSV of the filtered page, capped at 100 rows. |
| Performance | Active import polling remains 5 seconds; idle polling is 30 seconds and pauses in hidden tabs. History returns at most 30 owned jobs and initially renders 50 items per job. Health probes are deduplicated within a render; diagnostics report observed probe duration. |
| Text | New messages are translated in English, Italian and Dutch. Other locales have explicit English fallback strings. Existing translation debt is not reported as resolved. |
| Area | Behavior and location |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Release | Deployment checks HTTP health, release identity and Chromium pages before marking the running image verified. Registry publication reuses that verified digest on the shared runner; independent hosts build and verify their own image. |
| Shared HK | Dialogs scroll within the available viewport. Table column preferences persist per page in the current browser session; filters already remain in the URL. No favorites added. |
| Catalog Studio | Dedicated detail drawer and five separate completeness states: SQL, offers, furnidata, icon and Nitro. Sprite/type conflicts are consistently excluded from import and linked to audit. Missing source files are never represented as available. |
| Operations | Command center includes permission-filtered error groups, personal import failures, open support tickets and news drafts. A failed source is shown separately from an empty source. |
| Error center | Retained occurrence counts, first/last times, identified users, release counts, self-assignment and recognized local links. Assignment requires edit permission and is audited. |
| News | Private server-backed autosave, recover/discard/retry, prior saved revisions restored as a draft, and concurrent-edit detection. New status/search filters and pagination make older drafts reachable. |
| Jobs | Cancellation finishes the current item and stops pending items. Completed work stays completed. Uncertain interrupted mutations are excluded from retry. Live lease checks stop known stale worker writes. |
| Installation | `/admin/devops/installation` shows release, DB latency, Redis, emulator, storage permissions, migration history and worker heartbeat. Renderer defaults are recognized. Registry access is explicitly unverified from the web process. |
| Public dashboard | Current/next published event, clearer unread-message action, useful empty/error states and mobile layout refinements. |
| Audit | Exact actor/action and UTC date filters, readable recorded before/after values and permission-protected CSV of the filtered page, capped at 100 rows. |
| Performance | Active import polling remains 5 seconds; idle polling is 30 seconds and pauses in hidden tabs. History returns at most 30 owned jobs and initially renders 50 items per job. Health probes are deduplicated within a render; diagnostics report observed probe duration. |
| Text | New messages are translated in English, Italian and Dutch. Other locales have explicit English fallback strings. Existing translation debt is not reported as resolved. |
## Deployment requirements
@@ -37,7 +37,6 @@
## Verification
Local verification on 2026-09-09:
- Production build succeeded with fixture configuration and an intentionally unavailable database. Build-time fallback logs are expected in this check.
- Full Vitest run: 254 files passed, 4 skipped; 1,466 tests passed, 6 skipped. Coverage thresholds passed (19.89% lines); this does not imply exhaustive coverage.
- Global Biome rules/import checks passed across 1,328 files; modified source/locales were formatted separately to avoid unrelated Windows line-ending changes.
+1 -1
View File
@@ -81,7 +81,7 @@ The direct template intentionally records the CDN/edge socket address when place
### Local verification and deployment boundary
`pnpm exec vitest run --coverage.enabled=false scripts/proxy-config.test.mjs` runs the installed Docker Compose CLI against a disposable clone configuration, without contacting Docker Engine, building images or reading the real `.env`. It verifies selection through `.env`, loopback/port override precedence, the IPv4 healthcheck and preservation of mounts, release selection and host networking. It explicitly skips when Compose is unavailable. This is not a container-start or network-isolation test.
`pnpm exec vitest run --coverage.enabled=false scripts/proxy-config.test.mjs` runs the installed Docker Compose CLI against a disposable clone configuration, without contacting Docker Engine, building images or reading the real `.env`. It verifies selection through `.env`, loopback/port override precedence, the IPv4 healthcheck and preservation of mounts, release selection and host networking. The `cms` service is behind the `cms` profile (so a bare `docker compose up -d` cannot claim port 3002 on a CI-managed host), so the test renders the configuration with `--profile cms`. It explicitly skips when Compose is unavailable. This is not a container-start or network-isolation test.
`pnpm test:integration` additionally starts disposable Nginx containers from the actual templates, supplies a temporary test certificate, and sends real HTTPS requests with forged identity headers. It checks direct-mode replacement even with an inherited real-IP rule, rejection of untrusted peers, and acceptance through an explicitly trusted peer. This requires Docker Engine and the OpenSSL CLI and does not read deployment credentials. The templates must still pass `nginx -t` on the intended host after its hostname/certificate substitution, then the listener and trusted-header checks above; the disposable fixture cannot certify that host or its firewall.
+9 -11
View File
@@ -14,12 +14,11 @@ For each configured App Router route, resolve its exact app path using `app-path
The **initial entry envelope** is the union of route bootstrap `rootMainFilesTree[appPath]` (or `rootMainFiles`) and every client chunk that route's client-reference manifest lists. This includes layout, page and boundary/loading entries.
The manifest exposes those chunks differently per bundler. Turbopack emits an explicit per-segment `entryJSFiles` map; webpack emits no such field and records chunks only per client module, as `clientModules[*].chunks`, in `[chunkId, fileName, chunkId, fileName, …]` order. The report reads `entryJSFiles` when present and otherwise derives the same envelope from `clientModules`, which is the source Next's own `static-routes-info` uses. Numeric chunk ids are skipped; a malformed chunk *path* still fails rather than being dropped, so a broken manifest cannot quietly under-report a route.
The manifest exposes those chunks differently per bundler. Turbopack emits an explicit per-segment `entryJSFiles` map; webpack emits no such field and records chunks only per client module, as `clientModules[*].chunks`, in `[chunkId, fileName, chunkId, fileName, …]` order. The report reads `entryJSFiles` when present and otherwise derives the same envelope from `clientModules`, which is the source Next's own `static-routes-info` uses. Numeric chunk ids are skipped; a malformed chunk _path_ still fails rather than being dropped, so a broken manifest cannot quietly under-report a route.
> The build runs webpack (`next build --webpack`), so the `clientModules` path is the live one. An earlier revision only read `entryJSFiles`, and after the switch to webpack every route reported `unavailable` while the command still exited 0 — the budgets were silently not being measured. When a bundler switch changes the manifest layout again, re-check this section rather than trusting a clean exit.
The definition follows the data exposed by the installed Next 16.3.8 build and the `getLinkAndScriptTags` / `getRequiredScripts` renderer helpers; it is deliberately a build-artifact envelope, not a browser network trace. Conditional rendering, redirects, streaming and browser caches can change actual requests.
- Raw bytes are filesystem byte lengths of unique JavaScript assets in that envelope.
- Gzip bytes are the **sum of independent gzip level 9 compressions** of those files using the recorded Node/zlib runtime. They are not gzip of concatenated source, nor observed CDN transfer sizes.
- Deployment query strings and `/_next/` prefixes are normalized before deduplication. Shared files count once per route; each route is measured independently, with no misleading cross-route total.
@@ -34,16 +33,15 @@ Baselines were last recalibrated against a local webpack production build (Next
The image build compiles the app, then runs `scripts/performance-report.mjs` in the same `RUN` layer (`Dockerfile:48-53`). In `mode: enforce` a failing route therefore fails the image build, which blocks both the PR `preflight` job and the `deploy` job. Recalibrate the table and `scripts/performance-budgets.json` **together** in the same commit whenever the envelope legitimately grows (new route dependency, Next upgrade).
| Route | Baseline raw bytes | Baseline gzip bytes | Raw limit | Gzip limit |
| --- | ---: | ---: | ---: | ---: |
| `/me` | 621460 | 195411 | 716800 | 225280 |
| `/news` | 616712 | 193199 | 716800 | 225280 |
| `/events` | 617121 | 193450 | 716800 | 225280 |
| `/search` | 618536 | 194216 | 716800 | 225280 |
| `/admin/catalog` | 1049094 | 330369 | 1208320 | 389120 |
| `/admin/studio/furni` | 1397566 | 447765 | 1607680 | 522240 |
| Route | Baseline raw bytes | Baseline gzip bytes | Raw limit | Gzip limit |
| --------------------- | -----------------: | ------------------: | --------: | ---------: |
| `/me` | 621460 | 195411 | 716800 | 225280 |
| `/news` | 616712 | 193199 | 716800 | 225280 |
| `/events` | 617121 | 193450 | 716800 | 225280 |
| `/search` | 618536 | 194216 | 716800 | 225280 |
| `/admin/catalog` | 1049094 | 330369 | 1208320 | 389120 |
| `/admin/studio/furni` | 1397566 | 447765 | 1607680 | 522240 |
Configured limits are positive integer bytes; `null` explicitly means observe-only. `scripts/performance-budgets.json` runs `mode: enforce`: an `over-budget` route prints `Performance budgets enforced: <route> (over-budget)` and exits non-zero. Missing production `BUILD_ID`, unsupported manifests, missing routes or missing referenced assets produce `unavailable` with a reason and **no partial/zero total**; under `enforce` an `unavailable` route also fails the command, so a bundler change that breaks measurement cannot pass quietly. Malformed budget configuration or an unwritable output directory always fails the command. Set `mode` back to `informational` only while recalibrating, never as a permanent state.
Synthetic tests cover shared-chunk deduplication, exact byte/gzip calculations, route bootstrap selection, missing data, safe parsing and CLI exit behavior in both modes. Run `pnpm exec vitest run --coverage.enabled=false scripts/performance-report.test.mjs`.
+1 -1
View File
@@ -1 +1 @@
Draft SQL from `pnpm db:generate` (drizzle-kit). Never apply these automatically — copy reviewed statements into drizzle/migrations/ as numbered CMS migrations, then `pnpm db:migrate`.
Draft SQL from `pnpm db:generate` (drizzle-kit). Never apply these automatically — copy reviewed statements into drizzle/migrations/ as numbered CMS migrations, then `pnpm db:migrate`.
+24 -1
View File
@@ -158,7 +158,15 @@ const nextConfig: NextConfig = {
},
experimental: {
optimizePackageImports: ["lucide-react", "date-fns"],
// Barrel-heavy packages: without this every import pulls the whole
// module graph into the chunk that first touches it.
optimizePackageImports: [
"lucide-react",
"motion",
"@dnd-kit/core",
"@dnd-kit/sortable",
"@tanstack/react-virtual",
],
useTypeScriptCli: true,
hideLogsAfterAbort: true,
},
@@ -167,6 +175,21 @@ const nextConfig: NextConfig = {
formats: ["image/avif", "image/webp"],
},
// The SWF/Nitro image pipeline (src/lib/services/swf, src/lib/services/image-*)
// pulls shared helpers into several server chunks that webpack reports as a
// circular dependency between chunks with runtime. The cycle only affects
// server-side module caching of already-loaded chunks and has no runtime
// impact, so suppress the noisy warning instead of refactoring the shared
// helpers (which would risk the hot image path). Revisit if a real deadlock
// appears.
webpack(config) {
config.ignoreWarnings = [
...(config.ignoreWarnings ?? []),
/Circular dependency between chunks with runtime/,
];
return config;
},
async headers() {
return [
{
+20 -20
View File
@@ -3,7 +3,7 @@
"private": true,
"type": "module",
"engines": {
"node": ">=26.10.0 <27"
"node": ">=26.12.1 <27"
},
"packageManager": "[email protected]+sha512.25a51652a196785af5f28bda6dfe8dc582ebefa4ac1d180221ce1390231c5fb76637822ea4b6e4633ce74acdcb0a77f725af11bad38177fdeddaee818a955cb9",
"scripts": {
@@ -18,8 +18,8 @@
"format": "biome format --write .",
"diag:permissions": "tsx scripts/diagnose-permission-page.ts",
"jobs:worker": "node --conditions=react-server --import tsx scripts/jobs-worker.ts",
"test": "bash scripts/with-memory-cap.sh 8g vitest run --coverage.enabled=false",
"test:coverage": "bash scripts/with-memory-cap.sh 8g vitest run",
"test": "bash scripts/with-memory-cap.sh 8g vitest run",
"test:coverage": "bash scripts/with-memory-cap.sh 8g vitest run --coverage.enabled=true",
"typecheck": "bash scripts/with-memory-cap.sh 6g tsc --noEmit",
"db:generate": "drizzle-kit generate",
"db:introspect": "drizzle-kit introspect",
@@ -34,7 +34,7 @@
"db:studio": "drizzle-kit studio",
"gamedata:compress": "node scripts/compress-gamedata.mjs",
"hk:matrix:check": "tsx scripts/verify-housekeeping-matrix.ts",
"test:housekeeping": "bash scripts/with-memory-cap.sh 8g vitest run --coverage.enabled=false src/features/housekeeping src/lib/admin-theme-source-audit.test.ts src/lib/admin/authorization-contract.test.ts",
"test:housekeeping": "bash scripts/with-memory-cap.sh 8g vitest run src/features/housekeeping src/lib/admin-theme-source-audit.test.ts src/lib/admin/authorization-contract.test.ts",
"assets:editor": "node scripts/copy-editor-assets.mjs",
"deps:audit": "pnpm audit --audit-level=high",
"analyze": "pnpm assets:editor && bash scripts/with-memory-cap.sh 10g next build --webpack && node scripts/performance-report.mjs --output-dir build-reports",
@@ -55,10 +55,10 @@
"@dnd-kit/core": "6.3.1",
"@dnd-kit/sortable": "10.0.0",
"@dnd-kit/utilities": "3.2.2",
"@formatjs/icu-messageformat-parser": "3.5.21",
"@hookform/resolvers": "5.9.1",
"@tanstack/react-query": "5.104.3",
"@tanstack/react-virtual": "3.14.19",
"@formatjs/icu-messageformat-parser": "3.5.22",
"@hookform/resolvers": "6.0.0",
"@tanstack/react-query": "5.104.5",
"@tanstack/react-virtual": "3.14.22",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"cmdk": "1.1.1",
@@ -66,16 +66,16 @@
"drizzle-orm": "0.45.4",
"hash-wasm": "4.12.0",
"ioredis": "6.0.0",
"isomorphic-dompurify": "^4.5.0",
"isomorphic-dompurify": "^4.6.0",
"jpeg-js": "0.4.4",
"jsonc-parser": "3.3.1",
"jszip": "3.10.2",
"lucide-react": "1.57.0",
"lucide-react": "1.61.0",
"lzma-wasm": "1.0.7",
"motion": "14.2.0",
"music-metadata": "12.2.1",
"motion": "14.6.0",
"music-metadata": "12.2.3",
"mysql2": "3.24.6",
"next": "16.4.1",
"next": "16.4.2",
"next-auth": "5.0.0-beta.32",
"next-intl": "4.14.9",
"otplib": "13.5.0",
@@ -83,7 +83,7 @@
"react": "19.3.1",
"react-dom": "19.3.1",
"react-hook-form": "7.90.0",
"resend": "6.33.0",
"resend": "6.34.1",
"server-only": "0.0.1",
"sharp": "^0.35.5",
"sonner": "2.0.8",
@@ -92,9 +92,9 @@
"zod": "4.6.7"
},
"devDependencies": {
"@axe-core/playwright": "4.13.0",
"@axe-core/playwright": "4.14.0",
"@babel/parser": "8.0.7",
"@biomejs/biome": "2.5.15",
"@biomejs/biome": "2.6.2",
"@playwright/test": "1.64.1",
"@tailwindcss/forms": "0.5.11",
"@tailwindcss/postcss": "4.3.3",
@@ -102,17 +102,17 @@
"@types/node": "26.6.5",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@vitest/coverage-v8": "5.0.4",
"@vitest/coverage-v8": "5.0.5",
"drizzle-kit": "0.31.11",
"esbuild": "0.28.2",
"msw": "^3.0.3",
"pino-pretty": "13.2.0",
"postcss": "8.5.30",
"postcss": "8.5.31",
"tailwindcss": "4.3.3",
"testcontainers": "12.2.1",
"tsx": "4.23.15",
"typescript": "7.0.2",
"vite": "8.3.4",
"vitest": "5.0.4"
"vite": "8.3.5",
"vitest": "5.0.5"
}
}
+26 -9
View File
@@ -1,22 +1,39 @@
import { defineConfig, devices } from "@playwright/test";
// Deploy gate: keep this suite deterministic and light. One worker, one browser
// engine, no live dev server and no parallelism — that is what keeps the host
// from pinning at 100% CPU while the specs run.
export default defineConfig({
testDir: "./e2e",
testIgnore: ["**/ui/**", "**/news-real/**", "**/quality/**"],
testIgnore: ["**/ui/**", "**/imaging/**", "**/news-real/**", "**/quality/**"],
forbidOnly: !!process.env.CI,
fullyParallel: false,
workers: 1,
retries: 1,
retries: process.env.CI ? 1 : 0,
timeout: 30000,
expect: { timeout: 10000 },
globalTimeout: 10 * 60 * 1000,
maxFailures: process.env.CI ? 10 : undefined,
reporter: [["list"], ["html", { open: "never" }]],
expect: {
toHaveScreenshot: {
animations: "disabled",
caret: "hide",
maxDiffPixelRatio: 0.02,
},
},
use: {
baseURL: process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:3002",
trace: "retain-on-failure",
screenshot: "only-on-failure",
video: "off",
launchOptions: {
args: [
"--disable-gpu",
"--disable-software-rasterizer",
"--disable-dev-shm-usage",
"--disable-background-networking",
"--disable-default-apps",
"--disable-extensions",
"--disable-sync",
"--metrics-recording-only",
"--mute-audio",
"--no-first-run",
],
},
},
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
});
+20 -4
View File
@@ -1,8 +1,24 @@
import { randomInt } from "node:crypto";
import { defineConfig } from "@playwright/test";
const port = Number(process.env.UI_TEST_PORT || 3107);
if (!Number.isInteger(port) || port < 1024 || port > 65535)
throw Error("Invalid UI_TEST_PORT");
// The fixture server binds a loopback port. The CI runner packs several jobs
// onto the shared host network (capacity > 1), so a fixed port makes concurrent
// runs fight over the bind: one wins, the other's browser then gets
// "connection refused". Pick one random high port for the whole run and pass it
// to every worker through the environment, unless a port is pinned explicitly
// (e.g. a developer reusing a manually started fixture server).
function fixturePort() {
const requested = Number(process.env.UI_TEST_PORT);
if (Number.isInteger(requested) && requested >= 1024 && requested <= 65535)
return requested;
const shared = Number(process.env.PW_UI_TEST_PORT);
if (Number.isInteger(shared) && shared >= 1024 && shared <= 65535)
return shared;
const port = randomInt(30000, 50000);
process.env.PW_UI_TEST_PORT = String(port);
return port;
}
const port = fixturePort();
const baseURL = `http://127.0.0.1:${port}`;
export default defineConfig({
testDir: "./e2e/ui",
@@ -65,7 +81,7 @@ export default defineConfig({
command: "node e2e/ui/fixture-server.mjs",
url: `${baseURL}/health`,
timeout: 120000,
reuseExistingServer: true,
reuseExistingServer: !process.env.CI,
env: { UI_TEST_PORT: String(port) },
},
});
+453 -425
View File
File diff suppressed because it is too large. Load diff
+8 -8
View File
@@ -1,9 +1,9 @@
packages:
- '.'
- "."
allowBuilds:
'@parcel/watcher': true
'@swc/core': true
"@parcel/watcher": true
"@swc/core": true
cpu-features: true
esbuild: true
msw: true
@@ -13,12 +13,12 @@ allowBuilds:
minimumReleaseAge: 60
overrides:
glob: '^11.0.0'
'@esbuild-kit/core-utils': 'npm:tsx@^4.23.15'
'@esbuild-kit/esm-loader': 'npm:tsx@^4.23.15'
source-map-js: '1.2.2'
glob: "^11.0.0"
"@esbuild-kit/core-utils": "npm:tsx@^4.23.15"
"@esbuild-kit/esm-loader": "npm:tsx@^4.23.15"
source-map-js: "1.2.2"
# GHSA advisory: postcss-selector-parser <7.1.6. Pulled in only by
# @tailwindcss/typography (build-time prose selectors), which pins 6.0.10
# exactly and has no upstream bump. The API subset it uses is unchanged in
# v7, so force the patched major.
postcss-selector-parser: '^7.1.6'
postcss-selector-parser: "^7.1.6"
+1 -1
View File
@@ -61,7 +61,7 @@
"enabled": false
},
{
"description": "De pnpm packageManager-pin is afgestemd op Docker en de runner-toolchain; niet automatisch bumpen",
"description": "De pnpm packageManager-pin blijft handmatig: Docker (corepack) en de runner volgen de pin automatisch, maar een bump raakt ook de lockfile en de README-versietabel (scripts/sync-readme-versions.mjs)",
"matchPackageNames": ["pnpm"],
"enabled": false
}
+41 -41
View File
@@ -4,30 +4,31 @@ This directory contains utility scripts for managing the EpicNext-Cms project.
## Scripts Overview
| Script | Description |
| :--- | :--- |
| `dashboard.sh` | Main interactive menu to run all other scripts. |
| `deploy.sh` | Safely redeploys the application by freeing the port first. |
| `logs.sh` | View logs for cms and mariadb services. |
| `backup.sh` | Creates a database backup in `/backups`. |
| `db-restore.sh` | Restores a database backup. |
| `db-optimize.sh` | Optimizes database tables. |
| `docker-prune.sh` | Cleans up unused Docker resources. |
| `verify-deploy.sh` | Checks if the CMS is reachable after deployment. |
| `check-env.sh` | Validates `.env` against `.env.example`. |
| `check-security.sh` | Runs `pnpm audit` and checks for image updates. |
| `monitor.sh` | Shows system disk/container resource usage. |
| `perf-report.sh` | Runs performance profiling. |
| `setup-dev.sh` | Configures development environment. |
| `check-updates.sh` | Checks for package and image updates. |
| Script | Description |
| :------------------------- | :---------------------------------------------------------------- |
| `dashboard.sh` | Main interactive menu to run all other scripts. |
| `deploy.sh` | Safely redeploys the application by freeing the port first. |
| `logs.sh` | View logs for cms and mariadb services. |
| `backup.sh` | Creates a database backup in `/backups`. |
| `db-restore.sh` | Restores a database backup. |
| `db-optimize.sh` | Optimizes database tables. |
| `docker-prune.sh` | Cleans up unused Docker resources. |
| `verify-deploy.sh` | Checks if the CMS is reachable after deployment. |
| `check-env.sh` | Validates `.env` against `.env.example`. |
| `check-security.sh` | Runs `pnpm audit` and checks for image updates. |
| `monitor.sh` | Shows system disk/container resource usage. |
| `perf-report.sh` | Runs performance profiling. |
| `setup-dev.sh` | Configures development environment. |
| `check-updates.sh` | Checks for package and image updates. |
| `sync-readme-versions.mjs` | Syncs the versions in `README.md` with `package.json` / `.nvmrc`. |
| `maintenance.sh` | Toggles maintenance mode flag. |
| `doctor.sh` | Runs a system diagnostic report. |
| `setup-cron.sh` | Configures automated cron jobs. |
| `alert.sh` | Sends notifications to a configured webhook. |
| `catalog-sync.sh` | Interactive catalog update against a clone source (see below). |
| `maintenance.sh` | Toggles maintenance mode flag. |
| `doctor.sh` | Runs a system diagnostic report. |
| `setup-cron.sh` | Configures automated cron jobs. |
| `alert.sh` | Sends notifications to a configured webhook. |
| `catalog-sync.sh` | Interactive catalog update against a clone source (see below). |
## Usage
Run any script directly, or use the dashboard:
```bash
./scripts/dashboard.sh
@@ -68,24 +69,24 @@ menu, so it is usable from cron.
Every step the engine exposes is available as a flag:
| Flag | What it does |
| :--- | :--- |
| `--sources=<a,b>` | Compare against these sources (id or display name) |
| `--icons` | Re-download missing furniture icons |
| `--nitros` | Re-download missing bundles (stored as `.hab`) |
| `--furnidata` | Repair `FurnitureData.json` (add missing, drop duplicates) |
| `--structure` | Repair orphaned / duplicate catalog rows |
| `--check-ids` | Check `FurnitureData.json` for spriteId conflicts |
| `--sql` / `--apply-sql` | Generate / apply the catalog SQL |
| `--organize-sql` | Also emit the `catalog_pages` INSERTs into the SQL file |
| `--import-new` | Import furniture the source has and this hotel lacks |
| `--only=<a,b>`, `--limit=N`, `--concurrency=N` | Narrow and tune the import |
| `--reconcile` | Align `FurnitureData.json` with `items_base` after importing |
| `--fix-sprite-ids` | Correct `items_base.sprite_id` drift |
| `--rebuild-offers` | Rebuild catalog `offer_id` mirrors |
| `--align-ids` | Move `items_base` ids onto their furnidata ids (report; `--apply` runs it) |
| `--rename-bundles` | Rename legacy `.nitro` bundles to `.hab` (report; `--apply` runs it) |
| `--everything` | Every repair + import step, but still none of the destructive ones |
| Flag | What it does |
| :--------------------------------------------- | :------------------------------------------------------------------------- |
| `--sources=<a,b>` | Compare against these sources (id or display name) |
| `--icons` | Re-download missing furniture icons |
| `--nitros` | Re-download missing bundles (stored as `.hab`) |
| `--furnidata` | Repair `FurnitureData.json` (add missing, drop duplicates) |
| `--structure` | Repair orphaned / duplicate catalog rows |
| `--check-ids` | Check `FurnitureData.json` for spriteId conflicts |
| `--sql` / `--apply-sql` | Generate / apply the catalog SQL |
| `--organize-sql` | Also emit the `catalog_pages` INSERTs into the SQL file |
| `--import-new` | Import furniture the source has and this hotel lacks |
| `--only=<a,b>`, `--limit=N`, `--concurrency=N` | Narrow and tune the import |
| `--reconcile` | Align `FurnitureData.json` with `items_base` after importing |
| `--fix-sprite-ids` | Correct `items_base.sprite_id` drift |
| `--rebuild-offers` | Rebuild catalog `offer_id` mirrors |
| `--align-ids` | Move `items_base` ids onto their furnidata ids (report; `--apply` runs it) |
| `--rename-bundles` | Rename legacy `.nitro` bundles to `.hab` (report; `--apply` runs it) |
| `--everything` | Every repair + import step, but still none of the destructive ones |
`--import-new` is the step that actually adds furniture: `cloneSingleFurni`
downloads the icon and the bundle (sources serve `.nitro`, the local copy is
@@ -94,7 +95,6 @@ catalog page plus the offer, exactly like the app's own import. It skips
anything already present, so it is safe to re-run.
Safety notes:
- **An audit writes nothing** — no repair flag means read-only.
- `--dry-run` together with `--import-new` lists what would be imported and
imports nothing; use it first on a source before importing thousands of items.
@@ -106,7 +106,7 @@ Safety notes:
- `--apply-sql` is the only flag that changes the catalog; `--apply` authorises
the two heavyweight child scripts (`align-db-ids`, `migrate-nitro-to-hab`).
Both only report without it.
- The engine is the same one the admin *Catalog audit* page streams
- The engine is the same one the admin _Catalog audit_ page streams
(`runCatalogAudit`), so a terminal run and an in-admin run agree.
- `--json` prints one machine-readable report; `--fail-on-errors` exits `3` when
error-severity issues remain, which is what a scheduled check should use.
+19 -6
View File
@@ -42,13 +42,10 @@ old_container=""
new_container=""
blue_green=0
# Resource limits voor de container. Zonder deze limieten kan één geheugenlek de
# hele host vullen, met MariaDB, nginx en Traefik als slachtoffer. 2 CPU laat
# achtergrondwerk toe zonder de hele kernel aan één release te geven.
# CPU en pids zijn bewust verwijderd; memory blijft gelimiteerd op 4g/5g swap
# om een geheugenlek niet de host te laten vullen (MariaDB, nginx, Traefik).
mem_limit="4g"
mem_swap_limit="5g"
cpu_limit="2"
pids_limit="512"
is_current() {
local head
@@ -300,7 +297,6 @@ start_candidate() {
done < "$deploy_dir/.env"
docker run -d --name "$name" --restart always --net=host \
--memory="$mem_limit" --memory-swap="$mem_swap_limit" \
--cpus="$cpu_limit" --pids-limit="$pids_limit" \
"${ENV_ARGS[@]}" -e "PORT=$port" -e HOSTNAME=0.0.0.0 \
-v "$deploy_dir/public/nitro-assets:/app/public/nitro-assets" \
-v "$deploy_dir/public/swf:/app/public/swf" \
@@ -416,9 +412,26 @@ pnpm install --frozen-lockfile
pnpm exec playwright install chromium firefox webkit
echo "Building $image"
# Duurzame buildcache buiten de werkboom. De CI-werkboom wordt na elke job
# gewist en `docker builder prune` gooit de buildcache weg, waarna de hele
# pnpm-laag opnieuw gedownload moet worden. Een lokale cache-export overleeft
# allebei. Een lege CMS_BUILD_CACHE_DIR schakelt de cache uit.
build_cache_dir="${CMS_BUILD_CACHE_DIR-/var/cache/epicnext-cms-buildcache}"
build_cache_flags=()
if [ -n "$build_cache_dir" ]; then
if mkdir -p "$build_cache_dir" 2>/dev/null && [ -w "$build_cache_dir" ]; then
build_cache_flags=(
--cache-from "type=local,src=$build_cache_dir"
--cache-to "type=local,dest=$build_cache_dir,mode=max"
)
else
echo "Warning: buildcache $build_cache_dir is niet beschrijfbaar; build zonder hergebruikte cache." >&2
fi
fi
# No --network=host: BuildKit only grants it via --allow=network.host, and the
# build needs nothing but outbound internet (apk, pnpm, next/font/google).
DOCKER_BUILDKIT=1 docker build --progress=plain --cache-from epicnext-cms:latest \
${build_cache_flags[@]+"${build_cache_flags[@]}"} \
--build-arg NEXT_DEPLOYMENT_ID="$sha" -t "$image" .
check_current
# Read reports from the already-built image; do not start an extra application.
+17
View File
@@ -39,6 +39,23 @@ pnpm exec playwright install chromium
export NEWS_E2E_IMAGE="$image"
export NEWS_E2E_RELEASE="$sha"
build_attempted=1
# Duurzame buildcache buiten de tijdelijke werkmap, zodat de lagen van een
# vorige preflight hergebruikt worden in plaats van opnieuw gedownload. Eigen
# map, gescheiden van de release-cache: een branch-preflight mag nooit de cache
# van de release-build voeden. Een lege CMS_BUILD_CACHE_DIR schakelt het uit.
build_cache_dir="${CMS_BUILD_CACHE_DIR-/var/cache/epicnext-cms-preflight-cache}"
build_cache_flags=()
if [ -n "$build_cache_dir" ]; then
if mkdir -p "$build_cache_dir" 2>/dev/null && [ -w "$build_cache_dir" ]; then
build_cache_flags=(
--cache-from "type=local,src=$build_cache_dir"
--cache-to "type=local,dest=$build_cache_dir,mode=max"
)
else
echo "Warning: buildcache $build_cache_dir is niet beschrijfbaar; build zonder hergebruikte cache." >&2
fi
fi
DOCKER_BUILDKIT=1 docker build --progress=plain \
${build_cache_flags[@]+"${build_cache_flags[@]}"} \
--build-arg NEXT_DEPLOYMENT_ID="$sha" -t "$image" .
NEWS_E2E_IMAGE="$image" NEWS_E2E_RELEASE="$sha" node --import tsx e2e/news-real/run.ts
+3 -1
View File
@@ -126,7 +126,9 @@ if [[ -n "${CMS_IMAGE_REPOSITORY:-}" ]]; then
docker tag "$app_reference" "epicnext-cms:$CMS_RELEASE"
docker tag "$remote_migration_image" "$migration_image"
else
docker build --target migrations --build-arg NEXT_DEPLOYMENT_ID="$CMS_RELEASE" -t "$migration_image" . >>"$LOG_FILE" 2>&1
docker build --target migrations --build-arg NEXT_DEPLOYMENT_ID="$CMS_RELEASE" \
--cache-from type=local,src=.docker-cache/cms \
-t "$migration_image" . >>"$LOG_FILE" 2>&1
docker compose build --build-arg NEXT_DEPLOYMENT_ID="$CMS_RELEASE" cms >>"$LOG_FILE" 2>&1
fi
expected_image="$(docker image inspect --format '{{.Id}}' "epicnext-cms:$CMS_RELEASE")"
+15 -4
View File
@@ -78,13 +78,24 @@ else
if install_file "$PROXY_DIR/cms_upstream_servers.conf" "$NGINX_DIR/snippets/cms_upstream_servers.conf"; then changed=1; fi
fi
if [[ ! -f "$NGINX_DIR/sites-enabled/cms.conf" ]]; then
# sites-enabled/cms.conf must be a symlink, not a copy. A regular file here
# silently shadows sites-available/cms.conf, so nginx keeps serving stale
# config even though the sync updated sites-available.
enabled="$NGINX_DIR/sites-enabled/cms.conf"
if [[ -L "$enabled" && "$(readlink "$enabled")" == "../sites-available/cms.conf" ]]; then
echo "= $enabled symlink up to date"
else
if [[ "$MODE" == "check" ]]; then
echo "- sites-enabled/cms.conf missing"
echo "- $enabled is not the expected symlink"
changed=1
else
ln -sf ../sites-available/cms.conf "$NGINX_DIR/sites-enabled/cms.conf"
echo "+ linked sites-enabled/cms.conf"
mkdir -p "$NGINX_DIR/sites-enabled"
if [[ -e "$enabled" || -L "$enabled" ]]; then
mkdir -p "$BACKUP_DIR"
cp -a "$enabled" "$BACKUP_DIR/cms-enabled.conf"
fi
ln -sfn ../sites-available/cms.conf "$enabled"
echo "+ linked $enabled"
changed=1
fi
fi
+5
View File
@@ -2,6 +2,11 @@
"schemaVersion": 1,
"mode": "enforce",
"routes": [
{
"route": "/",
"maxInitialRawBytes": 716800,
"maxInitialGzipBytes": 225280
},
{
"route": "/me",
"maxInitialRawBytes": 716800,
+5 -2
View File
@@ -18,7 +18,7 @@ const hasCompose =
}).status === 0;
it.skipIf(!hasCompose)(
"loads the loopback profile from .env for bare Compose commands without changing mounts or host networking",
"loads the loopback override from .env for the cms service without changing mounts or host networking",
() => {
const directory = mkdtempSync(path.join(tmpdir(), "cms-proxy-config-"));
try {
@@ -50,9 +50,12 @@ it.skipIf(!hasCompose)(
["CMS_RELEASE", "HOSTNAME", "PORT"].includes(key)
)
delete environment[key];
// `cms` is behind the `cms` profile so a bare `docker compose up -d`
// can never squat port 3002 on a CI-managed host. Selecting the
// profile here keeps the loopback/port-override contract under test.
const result = spawnSync(
"docker",
["compose", "config", "--format", "json"],
["compose", "--profile", "cms", "config", "--format", "json"],
{ cwd: directory, env: environment, encoding: "utf8", timeout: 15_000 },
);
expect(result.status, result.stderr).toBe(0);
+10 -3
View File
@@ -118,6 +118,13 @@ systemd_cmd=()
# starten. De markering maakt dat afdwingbaar in next.config.ts.
export CMS_MEMORY_CAPPED=1
# Gebruik hoogstens de helft van de cores, zodat de machine niet op 100% CPU
# piekt, maar behoud voldoende parallelle kracht op (Ryzen) hosts.
cpu_count="$(nproc 2>/dev/null || echo 1)"
worker_cores=$((cpu_count / 2))
[ "$worker_cores" -lt 1 ] && worker_cores=1
cpu_affinity="0-$((worker_cores - 1))"
# Echt proberen, niet alleen uitzoeken of het bestand bestaat: `systemd-run`
# zonder rechten faalt met "Access denied", en dat moet dan een nette
# terugval naar ulimit worden in plaats van een kapotte build.
@@ -174,7 +181,7 @@ run_systemd() {
# expliciet: deze host heeft geen swap.
local high=$((effective * 95 / 100))
echo "[mem-cap] systemd cgroup MemoryHigh=$((high / 1024 / 1024))MB MemoryMax=$((effective / 1024 / 1024))MB MemorySwapMax=0: $*" >&2
exec "${systemd_cmd[@]}" -p "MemoryHigh=$high" -p "MemoryMax=$effective" -p "MemorySwapMax=0" "$@"
exec "${systemd_cmd[@]}" -E "PATH=$PATH" -p "MemoryHigh=$high" -p "MemoryMax=$effective" -p "MemorySwapMax=0" -p "AllowedCPUs=$cpu_affinity" "$@"
}
run_ulimit() {
@@ -186,7 +193,7 @@ run_ulimit() {
echo "[mem-cap] ulimit -v $virtual_kb werd geweigerd" >&2
return 1
}
exec "$@"
exec taskset -c "$cpu_affinity" "$@"
}
run_refuse() {
@@ -197,7 +204,7 @@ run_refuse() {
run_opted_out() {
echo "[mem-cap] WAARSCHUWING: plafond bewust uitgeschakeld, dit commando kan de machine laten OOM-killed worden: $*" >&2
exec "$@"
exec taskset -c "$cpu_affinity" "$@"
}
case "$backend" in
-1
View File
@@ -60,7 +60,6 @@ export async function uploadBadge(formData: FormData): Promise<void> {
try {
const buffer = Buffer.from(await file.arrayBuffer());
const gif = await toBadgeGif(buffer);
// eslint-disable-next-line security/detect-non-literal-fs-filename
await writeFile(target, gif);
} catch {
back("error", "Could not process or write the badge file");
+1 -1
View File
@@ -32,7 +32,7 @@ async function upsertSetting(key: string, value: string): Promise<void> {
.values({
key,
value,
// eslint-disable-next-line security/detect-object-injection -- key is one of 3 known const values
// key is one of 3 known const values
comment: COMMENTS[key] ?? null,
})
.onDuplicateKeyUpdate({ set: { value } });
+2 -2
View File
@@ -71,10 +71,10 @@ export async function savePoints(formData: FormData): Promise<void> {
POINTS_KEYS.map((key) =>
db
.insert(WebsiteSetting)
// eslint-disable-next-line security/detect-object-injection -- key from POINTS_KEYS const
// key from POINTS_KEYS const
.values({ key, value: values[key], comment: "Radio points" })
.onDuplicateKeyUpdate({
// eslint-disable-next-line security/detect-object-injection -- key from POINTS_KEYS const
// key from POINTS_KEYS const
set: { value: values[key] },
}),
),
+1 -1
View File
@@ -112,7 +112,7 @@ export async function saveTheme(formData: FormData): Promise<void> {
export async function applyPreset(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
const name = String(formData.get("preset") ?? "").normalize("NFC");
// eslint-disable-next-line security/detect-object-injection -- guarded by null check below
// guarded by null check below
const preset = PRESETS[name];
if (!preset) redirect("/admin/theme");
+6 -1
View File
@@ -6,6 +6,7 @@ import { redirect } from "next/navigation";
import { env } from "@/env";
import { invalidateLoginCache } from "@/lib/auth/login-core";
import { hashPassword } from "@/lib/auth/password";
import { passwordPolicy } from "@/lib/auth/password-policy";
import { revokeUserCredentials } from "@/lib/auth/session-revocation";
import { db, PasswordReset, User } from "@/lib/db";
import { logger } from "@/lib/logger";
@@ -109,7 +110,11 @@ export async function resetPassword(formData: FormData): Promise<void> {
}
let error: "password" | "invalid" | "failed" | null = null;
if (password.length < 12) error = "password";
if (
password.length < passwordPolicy.minLength ||
password.length > passwordPolicy.maxLength
)
error = "password";
if (!error) {
try {
+10 -4
View File
@@ -5,6 +5,7 @@ import { after } from "next/server";
import { z } from "zod";
import { sendVerification } from "@/lib/auth/email-verification";
import { hashPassword } from "@/lib/auth/password";
import { passwordPolicy } from "@/lib/auth/password-policy";
import { DEFAULT_AVATAR_FIGURE } from "@/lib/avatar-figure";
import { invalidateKey } from "@/lib/cached-db";
import { db, User } from "@/lib/db";
@@ -83,6 +84,11 @@ function hasDisposableEmailDomain(email: string): boolean {
return domain ? DISPOSABLE_EMAIL_DOMAINS.has(domain) : false;
}
// Rendered into the schema below and mapped back to a locale-independent code
// via `ZOD_MESSAGE_CODES`, so the length always reflects the configured policy.
const PASSWORD_MIN_MESSAGE = `Password must be at least ${passwordPolicy.minLength} characters`;
const PASSWORD_MAX_MESSAGE = "Password is too long";
const registerSchema = z
.object({
username: z
@@ -105,8 +111,8 @@ const registerSchema = z
),
password: z
.string()
.min(12, "Password must be at least 12 characters") // Increased min length
.max(128, "Password is too long") // Added max length
.min(passwordPolicy.minLength, PASSWORD_MIN_MESSAGE)
.max(passwordPolicy.maxLength, PASSWORD_MAX_MESSAGE)
.regex(/[A-Z]/, "Password must contain at least one uppercase letter")
.regex(/[a-z]/, "Password must contain at least one lowercase letter")
.regex(/[0-9]/, "Password must contain at least one digit")
@@ -160,8 +166,8 @@ const ZOD_MESSAGE_CODES: Record<string, RegisterErrorCode> = {
"This username is reserved": "usernameReserved",
"Enter a valid email address": "emailValid",
"Temporary email domains are not allowed": "emailDisposable",
"Password must be at least 12 characters": "passwordMinLength",
"Password is too long": "passwordMaxLength",
[PASSWORD_MIN_MESSAGE]: "passwordMinLength",
[PASSWORD_MAX_MESSAGE]: "passwordMaxLength",
"Password must contain at least one uppercase letter": "passwordUpper",
"Password must contain at least one lowercase letter": "passwordLower",
"Password must contain at least one digit": "passwordDigit",
-4
View File
@@ -31,9 +31,7 @@ export async function saveFavicon(
}
const buffer = upload.bytes;
// eslint-disable-next-line security/detect-non-literal-fs-filename
await mkdir(baseDir, { recursive: true });
// eslint-disable-next-line security/detect-non-literal-fs-filename
await writeFile(filePath, buffer);
const url = `/api/media/favicon/${filename}`;
@@ -46,7 +44,6 @@ export async function saveFavicon(
const oldPath = path.resolve(baseDir, oldName);
if (oldPath.startsWith(baseDir + path.sep)) {
try {
// eslint-disable-next-line security/detect-non-literal-fs-filename
await unlink(oldPath);
} catch {
/* ignore if file doesn't exist */
@@ -85,7 +82,6 @@ export async function deleteFavicon(): Promise<{
const oldPath = path.resolve(baseDir, oldName);
if (oldPath.startsWith(baseDir + path.sep)) {
try {
// eslint-disable-next-line security/detect-non-literal-fs-filename
await unlink(oldPath);
} catch {
/* ignore */
-2
View File
@@ -29,9 +29,7 @@ export async function saveLogo(
}
const buffer = upload.bytes;
// eslint-disable-next-line security/detect-non-literal-fs-filename
await mkdir(baseDir, { recursive: true });
// eslint-disable-next-line security/detect-non-literal-fs-filename
await writeFile(filePath, buffer);
const url = `/api/media/logo/${filename}`;
+1 -3
View File
@@ -278,14 +278,12 @@ export async function uploadBackground(formData: FormData): Promise<void> {
let uploaded = false;
try {
// eslint-disable-next-line security/detect-non-literal-fs-filename
await mkdir(MEDIA_ROOT, { recursive: true });
const ext = file.name.split(".").pop() ?? "png";
const name = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
const bytes = await file.arrayBuffer();
const filePath = resolveMediaPath(name);
if (filePath.startsWith(MEDIA_ROOT + path.sep)) {
// eslint-disable-next-line security/detect-non-literal-fs-filename
await writeFile(filePath, Buffer.from(bytes));
await writeSetting("background_image", `/api/media/${name}`);
await writeSetting(
@@ -319,7 +317,7 @@ export async function applyPresetManager(formData: FormData): Promise<void> {
const name = String(formData.get("preset") ?? "")
.normalize("NFC")
.trim();
// eslint-disable-next-line security/detect-object-injection -- guarded by null check below
// guarded by null check below
const preset = PRESETS[name];
if (!preset) redirect(MANAGER_PATH);
-1
View File
@@ -142,7 +142,6 @@ export default async function ApplyStaffPage({
<article key={String(position.id)} className="content-card">
<div className="content-card-head">
{team?.badge ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={`${BADGE_IMG_BASE}=${encodeURIComponent(team.badge)}`}
alt={rankName}
-1
View File
@@ -120,7 +120,6 @@ export default async function ApplyTeamPage({
<article key={String(team.id)} className="content-card">
<div className="content-card-head">
{team.badge ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={`${BADGE_IMG_BASE}=${encodeURIComponent(team.badge)}`}
alt={team.rankName}
-1
View File
@@ -48,7 +48,6 @@ export default async function BadgesPage() {
alignItems: "flex-start",
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={`${BADGE_IMG_BASE}=${encodeURIComponent(badge.badgeKey)}`}
alt={badge.badgeName}
-1
View File
@@ -190,7 +190,6 @@ export default async function DrawBadgePage({
{b.badgeUrl ? (
// Badge images are arbitrary external URLs stored by the
// draw-badge tool, so a plain <img> is correct here.
// eslint-disable-next-line @next/next/no-img-element
<img
src={b.badgeUrl}
alt=""
-1
View File
@@ -149,7 +149,6 @@ export default async function GuildPage({
style={{ display: "flex", gap: "1.25rem", alignItems: "flex-start" }}
>
{guild.badge ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`https://habbo-stories-content.s3.amazonaws.com/badge-part-images/${guild.badge}.gif`}
alt={`${guild.name} badge`}
-1
View File
@@ -74,7 +74,6 @@ export default async function GuildsPage() {
}}
>
{g.badge ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`https://habbo-stories-content.s3.amazonaws.com/badge-part-images/${g.badge}.gif`}
alt={`${g.name} badge`}
+5 -2
View File
@@ -6,6 +6,7 @@ import { ContentCard } from "@/components/public/ui";
import { SanitizedHtml } from "@/components/shared/sanitized-html";
import { db, WebsiteHelpCenterCategories } from "@/lib/db";
import { resolveHotelName } from "@/lib/hotel-name";
import { publicAssetExists } from "@/lib/public-asset";
import { sanitize } from "@/lib/sanitize";
// Asset path matches AtomCMS's Blade view: asset('/assets/images/help-center/<image_url>').
@@ -99,7 +100,10 @@ export default async function HelpCategoryPage({
const title = withHotel(cat.name, hotelName);
const content = withHotel(cat.content, hotelName);
const buttonText = withHotel(cat.buttonText, hotelName);
const imageSrc = cat.imageUrl ? `${HELP_IMAGE_BASE}/${cat.imageUrl}` : "";
const imageSrc =
cat.imageUrl && publicAssetExists(`${HELP_IMAGE_BASE}/${cat.imageUrl}`)
? `${HELP_IMAGE_BASE}/${cat.imageUrl}`
: "";
const hasButton = Boolean(cat.buttonText && cat.buttonText.trim() !== "");
return (
@@ -110,7 +114,6 @@ export default async function HelpCategoryPage({
<ContentCard icon="❓" title={title} subtitle={t("topicSubtitle")}>
{imageSrc ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={imageSrc}
alt=""
+67 -59
View File
@@ -23,6 +23,10 @@ import {
WebsiteRuleCategories,
WebsiteRules,
} from "@/lib/db";
import { publicAssetExists } from "@/lib/public-asset";
// Asset path matches AtomCMS's Blade view: asset('/assets/images/help-center/<image_url>').
const HELP_IMAGE_BASE = "/assets/images/help-center";
type HelpCategory = {
id: bigint;
@@ -149,67 +153,71 @@ export default async function HelpCenterPage() {
<EmptyState icon="🧭">{t("topicsEmpty")}</EmptyState>
) : (
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
{categories.map((c) => (
<article key={String(c.id)} className="card hover">
<div
style={{
display: "flex",
gap: "0.85rem",
alignItems: "flex-start",
}}
>
{c.imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={c.imageUrl}
alt=""
width={48}
height={48}
style={{ flexShrink: 0, objectFit: "contain" }}
/>
) : null}
<div style={{ flex: 1, minWidth: 0 }}>
<h3 style={{ margin: "0 0 0.35rem", fontSize: "1rem" }}>
{c.name}
</h3>
<p className="muted" style={{ margin: 0 }}>
{c.content}
</p>
{c.buttonUrl && c.buttonText ? (
<div style={{ marginTop: "0.85rem" }}>
{isExternal(c.buttonUrl) ? (
<a
className="btn btn-outline"
href={c.buttonUrl}
target="_blank"
rel="noreferrer"
style={{
background: c.buttonColor,
borderColor: c.buttonBorderColor,
color: "#3a2c05",
}}
>
{c.buttonText}
</a>
) : (
<Link
className="btn btn-outline"
href={c.buttonUrl}
style={{
background: c.buttonColor,
borderColor: c.buttonBorderColor,
color: "#3a2c05",
}}
>
{c.buttonText}
</Link>
)}
</div>
{categories.map((c) => {
const imageSrc = c.imageUrl
? `${HELP_IMAGE_BASE}/${c.imageUrl}`
: "";
return (
<article key={String(c.id)} className="card hover">
<div
style={{
display: "flex",
gap: "0.85rem",
alignItems: "flex-start",
}}
>
{imageSrc && publicAssetExists(imageSrc) ? (
<img
src={imageSrc}
alt=""
width={48}
height={48}
style={{ flexShrink: 0, objectFit: "contain" }}
/>
) : null}
<div style={{ flex: 1, minWidth: 0 }}>
<h3 style={{ margin: "0 0 0.35rem", fontSize: "1rem" }}>
{c.name}
</h3>
<p className="muted" style={{ margin: 0 }}>
{c.content}
</p>
{c.buttonUrl && c.buttonText ? (
<div style={{ marginTop: "0.85rem" }}>
{isExternal(c.buttonUrl) ? (
<a
className="btn btn-outline"
href={c.buttonUrl}
target="_blank"
rel="noreferrer"
style={{
background: c.buttonColor,
borderColor: c.buttonBorderColor,
color: "#3a2c05",
}}
>
{c.buttonText}
</a>
) : (
<Link
className="btn btn-outline"
href={c.buttonUrl}
style={{
background: c.buttonColor,
borderColor: c.buttonBorderColor,
color: "#3a2c05",
}}
>
{c.buttonText}
</Link>
)}
</div>
) : null}
</div>
</div>
</div>
</article>
))}
</article>
);
})}
</div>
)}
</ContentCard>
+27 -11
View File
@@ -1,4 +1,5 @@
import dynamic from "next/dynamic";
import { headers } from "next/headers";
import { NextIntlClientProvider } from "next-intl";
import { getLocale, getMessages } from "next-intl/server";
import type { ReactNode } from "react";
@@ -34,28 +35,43 @@ export default async function SiteLayout({
}: {
children: ReactNode;
}) {
const [session, locale, messages] = await Promise.all([
const [session, locale, messages, h] = await Promise.all([
auth(),
getLocale(),
getMessages(),
headers(),
]);
const clientMessages = publicClientMessages(messages);
// The landing pages render their own AuthTopBar (logo + live online), so the
// global site banner / navigation is hidden there to keep them clean and
// avoid showing the same chrome twice. Everything else keeps the chrome.
const pathname = h.get("x-pathname");
const isLandingPage =
pathname === "/" || pathname === "/login" || pathname === "/register";
return (
<NextIntlClientProvider locale={locale} messages={clientMessages}>
<CloudsField />
{/* Site chrome is public: hiding it all for anonymous visitors used to
strand them — from /news, /leaderboard or /shop there was no way to
reach any other page at all. */}
<div data-theme-block="top_header">
<TopHeader session={session} />
</div>
<div data-theme-block="site_header">
<SiteHeader />
</div>
<div data-theme-block="navigation">
<Navigation session={session} />
</div>
reach any other page at all. Only the standalone landing pages opt
out, because they carry their own header. */}
{!isLandingPage && (
<div data-theme-block="top_header">
<TopHeader session={session} />
</div>
)}
{!isLandingPage && (
<div data-theme-block="site_header">
<SiteHeader />
</div>
)}
{!isLandingPage && (
<div data-theme-block="navigation">
<Navigation session={session} />
</div>
)}
<main>
<div
data-theme-block="content_grid"
+1 -1
View File
@@ -171,7 +171,7 @@ export default async function LeaderboardPage({
const rows = await (active === "credits"
? loadCreditsRows()
: active === "diamonds" || active === "duckets"
? // eslint-disable-next-line security/detect-object-injection -- active validated as "diamonds"|"duckets" in this branch
? // active validated as "diamonds"|"duckets" in this branch
loadCurrencyRows(CURRENCY_TYPE[active])
: loadSettingsRows(
SETTINGS_FIELD[active as keyof typeof SETTINGS_FIELD],
+26 -164
View File
@@ -1,24 +1,14 @@
import { desc, eq } from "drizzle-orm";
import type { Metadata } from "next";
import { headers } from "next/headers";
import Image from "next/image";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { AuthAlert } from "@/components/auth/auth-alert";
import {
AuthPageFrame,
AuthUsersCards,
} from "@/components/auth/auth-page-frame";
AuthLandingView,
loadAuthLanding,
} from "@/components/auth/auth-landing";
import { LoginForm } from "@/components/auth/login-form";
import { SurfaceCard } from "@/components/surface-card";
import { auth } from "@/lib/auth";
import { safeRedirectPath } from "@/lib/auth/safe-redirect";
import { cached } from "@/lib/cache";
import { db, User } from "@/lib/db";
import { resolveHotelName } from "@/lib/hotel-name";
import { captchaConfig } from "@/lib/services/captcha";
import { cachedOnlineCount } from "@/lib/services/public-counters";
import { siteSettings } from "@/lib/services/site-settings";
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("pages.login");
@@ -43,13 +33,6 @@ export default async function LoginPage({
}>;
}) {
const t = await getTranslations("pages.login");
const [hotelName, cfg, logo] = await Promise.all([
resolveHotelName(),
captchaConfig(),
siteSettings.get("cms_logo", ""),
]);
const nonce = (await headers()).get("x-nonce") ?? undefined;
const sp = await searchParams;
const redirectTo = safeRedirectPath(sp.from);
@@ -58,165 +41,44 @@ export default async function LoginPage({
const session = await auth();
if (session?.user?.id) redirect(redirectTo);
const [online, recentUsers, latestUsers] = await Promise.all([
cachedOnlineCount().catch(() => 0),
cached(
"auth_online_users",
10_000,
() =>
db
.select({ id: User.id, username: User.username, look: User.look })
.from(User)
.where(eq(User.online, "1"))
.limit(8),
{ staleMs: 30000 },
).catch(() => []),
cached(
"auth_latest_users",
30_000,
() =>
db
.select({ id: User.id, username: User.username, look: User.look })
.from(User)
.orderBy(desc(User.accountCreated))
.limit(8),
{ staleMs: 60000 },
).catch(() => []),
]);
const data = await loadAuthLanding();
// `/login?registered=1` is where the sign-up form lands when it could not
// auto sign-in (e-mail verification still pending). Without this notice the
// visitor would only see an empty login form and no sign their account
// exists.
// exists. `?reset=1` comes from a successful password reset; saying so
// matters because a silent form reads like the reset failed.
const notice =
sp.registered === "1" ? (
<p
role="status"
className="auth-alert auth-alert--success animate-fade-in-up m-0 mb-4"
>
<AuthAlert variant="success" className="mb-4">
{t("registeredSuccess")}
</p>
</AuthAlert>
) : sp.reset === "1" ? (
// `?reset=1` comes from a successful password reset; saying so matters
// because the visitor just changed their password and a silent form
// reads like the reset failed.
<p
role="status"
className="auth-alert auth-alert--success animate-fade-in-up m-0 mb-4"
>
<AuthAlert variant="success" className="mb-4">
{t("passwordChanged")}
</p>
</AuthAlert>
) : undefined;
return (
<AuthPageFrame
hotelName={hotelName}
logo={logo}
title={t("title")}
subtitle={t("subtitle")}
notice={notice}
<AuthLandingView
data={data}
copy={{
title: t("title"),
subtitle: t("subtitle"),
notice,
promoTitle: t("welcomeBack"),
promoSubtitle: t("welcomeBackSub", { hotelName: data.hotelName }),
usersOnlineLabel: t("usersOnline", { count: data.online }),
whoIsOnlineLabel: t("whoIsOnline"),
newestCitizensLabel: t("newestCitizens"),
}}
form={
<LoginForm
redirectTo={redirectTo}
captcha={{
provider: cfg.provider,
siteKey: cfg.siteKey || undefined,
field: cfg.field || undefined,
}}
nonce={nonce}
captcha={data.captcha}
nonce={data.nonce}
/>
}
>
<SurfaceCard
className="card-glow relative overflow-hidden p-6 text-center"
style={{
borderColor:
"color-mix(in srgb, var(--color-primary) 20%, transparent)",
}}
>
<div
className="absolute inset-0"
style={{
background:
"linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 10%, transparent), color-mix(in srgb, var(--color-accent) 10%, transparent))",
}}
/>
<div aria-hidden="true" className="absolute inset-0 overflow-hidden">
<div className="brand-halo left-1/2 top-0 h-64 w-[460px] max-w-full -translate-x-1/2 -translate-y-1/2" />
<div
className="aurora-blob -left-16 top-10 h-56 w-56"
style={{
background:
"color-mix(in srgb, var(--color-primary) 45%, transparent)",
}}
/>
<div
className="aurora-blob -right-16 bottom-0 h-56 w-56"
style={{
background:
"color-mix(in srgb, var(--color-accent) 40%, transparent)",
animationDelay: "-8s",
}}
/>
</div>
<div className="relative">
<div className="brand-halo left-1/2 top-8 h-52 w-52 -translate-x-1/2" />
<Image
src="/assets/images/FrankwithBag.gif"
alt="Frank"
width={130}
height={170}
className="relative object-contain mx-auto drop-shadow-xl animate-float"
unoptimized
/>
<div
className="inline-flex items-center gap-2 px-3.5 py-1 rounded-full text-xs font-bold uppercase tracking-wider mt-4 mb-3 border"
style={{
borderColor:
"color-mix(in srgb, var(--color-primary) 30%, transparent)",
background:
"color-mix(in srgb, var(--color-surface) 72%, transparent)",
color: "var(--color-primary-readable, var(--color-primary))",
boxShadow:
"0 8px 20px -12px color-mix(in srgb, var(--color-primary) 70%, transparent)",
}}
>
<span className="relative flex h-2 w-2">
<span
className="animate-ping absolute inline-flex h-full w-full rounded-full opacity-75"
style={{ background: "var(--color-primary)" }}
/>
<span
className="relative inline-flex rounded-full h-2 w-2"
style={{ background: "var(--color-primary)" }}
/>
</span>
{t("usersOnline", { count: online })}
</div>
<h1
className="text-xl font-black"
style={{
color: "var(--color-text-readable)",
fontFamily: "var(--font-nunito)",
}}
>
{t("welcomeBack")}
</h1>
<p
className="text-xs mt-1.5 mx-auto max-w-[220px] leading-relaxed"
style={{ color: "var(--color-text-muted)" }}
>
{t("welcomeBackSub", { hotelName })}
</p>
</div>
</SurfaceCard>
<AuthUsersCards
recentUsers={recentUsers}
latestUsers={latestUsers}
recentTitle={t("whoIsOnline")}
latestTitle={t("newestCitizens")}
/>
</AuthPageFrame>
/>
);
}
+51 -8
View File
@@ -5,15 +5,22 @@ import type { CSSProperties } from "react";
import { postComment } from "@/actions/article-comments";
import { toggleReaction } from "@/actions/article-reactions";
import Link from "@/components/link";
import { JsonLd } from "@/components/public/json-ld";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { SanitizedHtml } from "@/components/shared/sanitized-html";
import { articleSlug } from "@/lib/article-input";
import { auth } from "@/lib/auth";
import { excerpt } from "@/lib/format";
import { formatDate } from "@/lib/format-date";
import { resolveHotelName } from "@/lib/hotel-name";
import { logger } from "@/lib/logger";
import { withPublicPagePerformance } from "@/lib/public-page-performance";
import { sanitize } from "@/lib/sanitize";
import {
absoluteAssetUrl,
appBaseUrl,
buildArticleStructuredData,
} from "@/lib/seo";
import {
getArticleComments,
getArticleReactions,
@@ -48,24 +55,44 @@ export async function generateMetadata({
}: {
params: Promise<{ slug: string[] }>;
}): Promise<Metadata> {
const appUrl = process.env.APP_URL ?? "http://localhost:3000";
const appUrl = appBaseUrl();
const slug = articleSlug((await params).slug);
const article = await getPublishedArticle(slug).catch(() => {
logger.error("Public article metadata failed", { module: "news" });
return null;
});
if (!article) return { title: "Article" };
const description = excerpt(article.shortStory, 160);
const url = `${appUrl}/news/${encodeURIComponent(slug)}`;
const image =
absoluteAssetUrl(article.image, appUrl) ??
`${appUrl}/assets/images/next_header.jpg`;
const publishedAt = (article.publishedAt ?? article.publishAt)?.toISOString();
const modifiedAt = (
article.updatedAt ??
article.publishedAt ??
article.publishAt
)?.toISOString();
return {
title: article.title,
description: excerpt(article.shortStory, 160),
alternates: { canonical: `${appUrl}/news/${slug}` },
description,
alternates: { canonical: url },
openGraph: {
title: article.title,
description: excerpt(article.shortStory, 160),
description,
type: "article",
url: `${appUrl}/news/${slug}`,
url,
images: [{ url: image, alt: article.title }],
...(publishedAt ? { publishedTime: publishedAt } : {}),
...(modifiedAt ? { modifiedTime: modifiedAt } : {}),
section: "News",
},
twitter: {
card: "summary_large_image",
title: article.title,
description,
images: [image],
},
twitter: { card: "summary", title: article.title },
};
}
@@ -117,7 +144,7 @@ async function ArticlePage({
const requestedPage =
comment === "posted" && !commentsPage ? "last" : Number(commentsPage ?? 1);
const [reactions, commentsResult] = await Promise.all([
const [reactions, commentsResult, hotelName] = await Promise.all([
getArticleReactions(articleId, sessionUserId).catch(() => {
logger.error("Public article reactions failed", {
module: "news",
@@ -132,11 +159,27 @@ async function ArticlePage({
});
return null;
}),
resolveHotelName(),
]);
const reactionCounts = reactions?.counts ?? new Map<string, number>();
const myReaction = reactions?.myReaction ?? null;
const comments = commentsResult?.rows ?? [];
const articleHref = `/news/${encodeURIComponent(slug)}`;
const structuredDataUrl = `${appBaseUrl()}/news/${encodeURIComponent(slug)}`;
const structuredData = buildArticleStructuredData({
hotelName,
url: structuredDataUrl,
title: article.title,
description: excerpt(article.shortStory, 160),
image: absoluteAssetUrl(article.image, appBaseUrl()),
logo: absoluteAssetUrl(article.image, appBaseUrl()),
publishedAt: (article.publishedAt ?? article.publishAt)?.toISOString(),
modifiedAt: (
article.updatedAt ??
article.publishedAt ??
article.publishAt
)?.toISOString(),
});
const commentsHref = (page: number) =>
`${articleHref}?commentsPage=${page}#comments`;
const retryHref = commentsPage
@@ -160,6 +203,7 @@ async function ArticlePage({
return (
<section className="page-grid">
<JsonLd data={structuredData} />
{comment === "posted" ? (
<div role="status" style={feedbackStyle("success")}>
{t("success.posted")}
@@ -191,7 +235,6 @@ async function ArticlePage({
}
>
{article.image ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={article.image}
alt=""
+25
View File
@@ -0,0 +1,25 @@
import { resolveHotelName } from "@/lib/hotel-name";
import { buildNewsRss } from "@/lib/news-rss";
import { appBaseUrl } from "@/lib/seo";
import { getNewsList } from "@/lib/services/news-list";
// Built per request so the feed never needs a database connection during
// `next build`, matching the sitemap and robots routes.
export const dynamic = "force-dynamic";
export async function GET() {
const [hotelName, items] = await Promise.all([
resolveHotelName(),
getNewsList(30),
]);
return new Response(
buildNewsRss(items, { hotelName, appUrl: appBaseUrl() }),
{
headers: {
"Content-Type": "application/rss+xml; charset=utf-8",
"Cache-Control": "public, s-maxage=300, stale-while-revalidate=600",
},
},
);
}
+556 -664
View File
@@ -7,22 +7,26 @@ import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import type { CSSProperties, ReactNode } from "react";
import { AnimatedCounter } from "@/components/animated-counter";
import { AuthTopBar } from "@/components/auth/auth-top-bar";
import { LoginForm } from "@/components/auth/login-form";
import { Clock } from "@/components/clock";
import { LanguageSwitcher } from "@/components/language-switcher";
import Link from "@/components/link";
import { LiveOnlineCounter } from "@/components/live-online-counter";
import { Reveal } from "@/components/motion-reveal";
import { JsonLd } from "@/components/public/json-ld";
import { PublicLoadError } from "@/components/public/load-error";
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
import { SpotlightCard } from "@/components/spotlight-card";
import { ThemeSwitcher } from "@/components/theme-switcher";
import { TypewriterText } from "@/components/typewriter-text";
import { auth } from "@/lib/auth";
import { cached } from "@/lib/cache";
import { CameraWeb, db, User } from "@/lib/db";
import { formatDate } from "@/lib/format-date";
import { resolveHotelName } from "@/lib/hotel-name";
import { withPublicPagePerformance } from "@/lib/public-page-performance";
import {
absoluteAssetUrl,
appBaseUrl,
buildWebsiteStructuredData,
} from "@/lib/seo";
import { captchaConfig } from "@/lib/services/captcha";
import { getNewsList } from "@/lib/services/news-list";
import { loadProfilePrivacyMap } from "@/lib/services/profile-privacy";
@@ -40,9 +44,9 @@ import {
ICON_CAMERA,
ICON_CATALOG,
ICON_FRIENDS,
ICON_LIGHTHOUSE,
ICON_NAV_GOODY,
ICON_NAV_ME,
ICON_STAR,
} from "@/lib/site-icons";
export async function generateMetadata(): Promise<Metadata> {
@@ -50,33 +54,102 @@ export async function generateMetadata(): Promise<Metadata> {
const tp = await getTranslations("pages.home");
const title = tn("home");
const description = tp("welcomeBody");
const appUrl = appBaseUrl();
return {
title,
description,
openGraph: { title, description, type: "website" },
alternates: {
canonical: appUrl,
types: { "application/rss+xml": `${appUrl}/news/rss.xml` },
},
openGraph: { title, description, type: "website", url: appUrl },
};
}
// ── Local visual language (theme-aware, no big white blocks) ──────────
const PANEL_BG =
"linear-gradient(160deg, color-mix(in srgb, var(--color-primary) 12%, transparent) 0%, color-mix(in srgb, var(--color-surface) 58%, transparent) 52%, color-mix(in srgb, var(--color-accent) 9%, transparent) 100%)";
/* ── Local visual language ────────────────────────────────────────────
The landing panels used to carry their own translucent washes
(surface 44–60 % + a backdrop blur each). Four different milky smears over
the sky gradient, and a backdrop snapshot per panel on every scroll. The
shared `.panel*` classes in globals.css now own the surface recipe, so the
boxes read as one system and cost nothing to paint. */
const PANEL_STYLE: CSSProperties = {
background: PANEL_BG,
backdropFilter: "blur(18px)",
WebkitBackdropFilter: "blur(18px)",
borderColor: "color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
boxShadow:
"0 0 0 0.5px color-mix(in srgb, var(--color-text-muted) 6%, transparent), 0 24px 60px -30px color-mix(in srgb, #000 28%, transparent)",
};
// The hero scrim. A photo always needs a dark wash for the copy to stay
// readable, so this one stays a fixed dark gradient in both themes.
const HERO_SCRIM =
"linear-gradient(180deg, rgba(8,11,24,0.80) 0%, rgba(8,11,24,0.58) 38%, rgba(8,11,24,0.42) 62%, rgba(8,11,24,0.60) 85%, color-mix(in srgb, var(--color-background) 92%, transparent) 100%)";
const HERO_COPY = "rgba(255,255,255,0.80)";
const HERO_COPY_STRONG = "rgba(255,255,255,0.96)";
const DARK_BAND =
"linear-gradient(120deg, color-mix(in srgb, var(--color-primary) 30%, #0a0e1e) 0%, color-mix(in srgb, var(--color-accent) 26%, #0a0e1e) 52%, color-mix(in srgb, var(--color-primary) 20%, #0a0e1e) 100%)";
const GLASS_TILE_BG =
"linear-gradient(155deg, color-mix(in srgb, var(--color-surface) 60%, transparent) 0%, color-mix(in srgb, var(--color-surface) 44%, transparent) 60%, color-mix(in srgb, var(--color-accent) 6%, transparent) 100%)";
/** Accent used for the nth surface of a repeating set. */
function tintFor(index: number): string {
return index % 2 === 0 ? "var(--color-primary)" : "var(--color-accent)";
}
interface GlassPanelProps {
interface PanelHeaderProps {
title: string;
icon?: string;
actionHref?: string;
actionLabel?: string;
}
/**
* The header row every landing panel shares: tinted icon chip, title and an
* optional "see everything" link.
*/
function PanelHeader({
title,
icon,
actionHref,
actionLabel,
}: PanelHeaderProps) {
return (
<div className="panel-header flex items-center justify-between gap-3 px-5 py-3.5">
<div className="flex min-w-0 items-center gap-2.5">
{icon && (
<div
className="panel-tile flex h-9 w-9 shrink-0 items-center justify-center rounded-xl"
style={{ "--panel-tint": "var(--color-primary)" } as CSSProperties}
>
<Image
src={icon}
alt=""
width={18}
height={18}
unoptimized
className="opacity-90"
/>
</div>
)}
<h2
className="truncate text-sm font-extrabold tracking-wide"
style={{
fontFamily: "var(--font-nunito)",
color: "var(--color-text-readable)",
}}
>
{title}
</h2>
</div>
{actionHref && actionLabel && (
<Link
href={actionHref}
className="shrink-0 text-xs font-bold transition-all duration-300 hover:translate-x-0.5"
style={{
color: "var(--color-primary-readable, var(--color-primary))",
}}
>
{actionLabel}
</Link>
)}
</div>
);
}
interface PanelProps {
children: ReactNode;
title?: string;
icon?: string;
@@ -86,7 +159,8 @@ interface GlassPanelProps {
className?: string;
}
function GlassPanel({
/** A themed surface with an optional header. */
function Panel({
children,
title,
icon,
@@ -94,71 +168,22 @@ function GlassPanel({
actionLabel,
bodyClassName,
className,
}: GlassPanelProps) {
}: PanelProps) {
const hasHeader = Boolean(title || icon);
return (
<div
className={`card-glow card-hairline relative overflow-hidden rounded-2xl border ${
className={`panel card-glow card-hairline relative overflow-hidden rounded-[var(--panel-radius)] ${
className ?? ""
}`}
style={PANEL_STYLE}
>
{hasHeader && (
<div
className="flex items-center justify-between gap-3 border-b px-5 py-4"
style={{
borderColor:
"color-mix(in srgb, var(--color-text-muted) 8%, transparent)",
background:
"linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 8%, transparent), transparent 70%)",
}}
>
<div className="flex min-w-0 items-center gap-2.5">
{icon && (
<div
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl"
style={{
background:
"color-mix(in srgb, var(--color-primary) 14%, transparent)",
boxShadow:
"inset 0 0 0 1px color-mix(in srgb, var(--color-primary) 18%, transparent)",
}}
>
<Image
src={icon}
alt=""
width={18}
height={18}
unoptimized
className="opacity-90"
/>
</div>
)}
{title && (
<h2
className="truncate text-sm font-extrabold tracking-wide"
style={{
fontFamily: "var(--font-nunito)",
color: "var(--color-text-readable)",
}}
>
{title}
</h2>
)}
</div>
{actionHref && actionLabel && (
<Link
href={actionHref}
className="shrink-0 text-xs font-bold transition-all duration-300 hover:translate-x-0.5"
style={{
color: "var(--color-primary-readable, var(--color-primary))",
}}
>
{actionLabel}
</Link>
)}
</div>
<PanelHeader
title={title ?? ""}
icon={icon}
actionHref={actionHref}
actionLabel={actionLabel}
/>
)}
{hasHeader ? (
<div className={bodyClassName ?? "p-5"}>{children}</div>
@@ -169,6 +194,56 @@ function GlassPanel({
);
}
/**
* Cover image for a news article. Articles without an upload used to render a
* broken <Image>, so they fall back to a themed plate instead.
*/
function ArticleCover({
src,
alt,
featured,
}: {
src: string;
alt: string;
featured: boolean;
}) {
if (!src) {
return (
<div
className="flex h-full w-full items-center justify-center"
style={{
background:
"linear-gradient(140deg, color-mix(in srgb, var(--color-primary) 26%, var(--color-surface)), color-mix(in srgb, var(--color-accent) 18%, var(--color-surface)))",
}}
>
<Image
src={ICON_ARTICLE}
alt=""
width={featured ? 56 : 40}
height={featured ? 56 : 40}
unoptimized
className="opacity-70"
/>
</div>
);
}
return (
<Image
src={src}
alt={alt}
fill
sizes={
featured
? "(max-width: 768px) 100vw, 60vw"
: "(max-width: 640px) 100vw, 20vw"
}
className="object-cover transition-transform duration-700 ease-out group-hover:scale-[1.06]"
unoptimized
/>
);
}
async function getHotelData() {
const [
hotelName,
@@ -238,7 +313,7 @@ async function getHotelData() {
};
}
export default async function Home() {
async function Home() {
const session = await auth();
if (session?.user?.id) redirect("/me");
@@ -300,435 +375,321 @@ export default async function Home() {
desc: tp("featuresCommunityDesc"),
icon: ICON_NAV_GOODY,
},
{
title: tp("featuresGames"),
desc: tp("featuresGamesDesc"),
icon: ICON_STAR,
},
];
type NewsArticle = NonNullable<typeof articles>[number];
const renderArticleCard = (a: NewsArticle, featured = false) => (
<Link
key={a.slug}
href={`/news/${a.slug}`}
className="group relative w-full overflow-hidden rounded-2xl transition-all duration-500 hover:-translate-y-1 hover:shadow-2xl"
style={{
height: featured
? "clamp(240px, 28vw, 320px)"
: "clamp(180px, 20vw, 230px)",
backgroundColor: "var(--color-text-muted)",
border:
"1px solid color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
}}
>
<div className="relative h-full w-full overflow-hidden rounded-2xl">
<Image
src={a.image}
alt={a.title}
fill
sizes={featured ? "100vw" : "(max-width: 640px) 100vw, 33vw"}
className="rounded-2xl object-cover transition-transform duration-700 ease-out group-hover:scale-108"
unoptimized
/>
<div
className="absolute right-2.5 top-2.5 flex h-8 w-8 translate-x-1 items-center justify-center rounded-full opacity-0 backdrop-blur-sm transition-all duration-300 group-hover:translate-x-0 group-hover:opacity-100"
style={{
background: "color-mix(in srgb, #000 45%, transparent)",
}}
>
<span
className="text-sm font-bold"
style={{ color: "rgba(255,255,255,0.95)" }}
const renderArticleCard = (a: NewsArticle, featured = false) => {
const published = a.createdAt ? formatDate(a.createdAt, "date", "") : "";
return (
<Link
key={a.slug}
href={`/news/${a.slug}`}
className="group relative block w-full overflow-hidden rounded-[var(--panel-radius)] border transition-all duration-500 hover:-translate-y-1 hover:shadow-2xl"
style={{
height: featured
? "clamp(240px, 28vw, 320px)"
: "clamp(180px, 20vw, 230px)",
backgroundColor: "var(--color-text-muted)",
borderColor: "var(--panel-border)",
}}
>
<div className="relative h-full w-full overflow-hidden rounded-[var(--panel-radius)]">
<ArticleCover src={a.image} alt={a.title} featured={featured} />
<div
className="absolute right-2.5 top-2.5 flex h-8 w-8 translate-x-1 items-center justify-center rounded-full opacity-0 backdrop-blur-sm transition-all duration-300 group-hover:translate-x-0 group-hover:opacity-100"
style={{ background: "color-mix(in srgb, #000 45%, transparent)" }}
>
<ArrowRight className="h-4 w-4" aria-hidden="true" />
</span>
</div>
<div
className="absolute left-2.5 top-2.5 rounded-full px-2 py-0.5 text-[10px] font-bold backdrop-blur-sm"
style={{
background: "color-mix(in srgb, #000 45%, transparent)",
color: "#fff",
}}
>
{formatDate(a.createdAt, "date", "")}
</div>
<div
className="absolute bottom-0 left-0 w-full p-3 sm:p-4"
style={{
background:
"linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.4) 50%, transparent 100%)",
}}
>
{featured && (
<p className="mb-1 text-[10px] font-bold uppercase tracking-widest opacity-90">
{tp("latestArticle")}
</p>
<span
className="text-sm font-bold"
style={{ color: HERO_COPY_STRONG }}
>
<ArrowRight className="h-4 w-4" aria-hidden="true" />
</span>
</div>
{published && (
<div
className="absolute left-2.5 top-2.5 rounded-full px-2 py-0.5 text-[10px] font-bold backdrop-blur-sm"
style={{
background: "color-mix(in srgb, #000 45%, transparent)",
color: "#fff",
}}
>
{published}
</div>
)}
<h3
className={`line-clamp-2 font-bold text-shadow-sm ${
featured ? "text-base sm:text-2xl" : "text-sm sm:text-base"
}`}
style={{ color: "rgba(255,255,255,0.95)" }}
<div
className="absolute bottom-0 left-0 w-full p-3 sm:p-4"
style={{
background:
"linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.4) 50%, transparent 100%)",
}}
>
{a.title}
</h3>
{featured && (
<p className="mb-1 text-[10px] font-bold uppercase tracking-widest opacity-90">
{tp("latestArticle")}
</p>
)}
<h3
className={`line-clamp-2 font-bold text-shadow-sm ${
featured ? "text-base sm:text-2xl" : "text-sm sm:text-base"
}`}
style={{ color: HERO_COPY_STRONG }}
>
{a.title}
</h3>
</div>
</div>
</div>
</Link>
);
</Link>
);
};
const exploreLinks = [
{ href: "/community", label: tn("community") },
{ href: "/news", label: tn("articles") },
{ href: "/events", label: tn("events") },
{ href: "/photos", label: tn("photos") },
{ href: "/radio", label: tn("radio") },
{ href: "/shop", label: tn("shop") },
{ href: "/rankings", label: tn("rankings") },
];
const statChips = [
{ value: users, label: tp("statsCitizens"), color: "var(--color-primary)" },
{ value: rooms, label: tp("statsRooms"), color: "var(--color-accent)" },
{
value: totalPhotos,
label: tp("statsPhotos"),
color: "var(--color-primary)",
},
{
value: articleCount,
label: tp("statsArticles"),
color: "var(--color-accent)",
},
].filter((s) => s.value !== null) as Array<{
value: number;
label: string;
color: string;
}>;
// One source of truth for the four headline counters. They used to be
// rendered twice — as four small chips in the hero and again as a full
// panel further down — which made the page feel padded and repeated.
// A failed read stays visible as "unavailable" instead of silently
// shrinking the row, so a broken counter can never be mistaken for zero.
const statItems = [
{ value: users, label: tp("statsCitizens"), icon: ICON_FRIENDS },
{ value: rooms, label: tp("statsRooms"), icon: ICON_CATALOG },
{ value: totalPhotos, label: tp("statsPhotos"), icon: ICON_CAMERA },
{ value: articleCount, label: tp("statsArticles"), icon: ICON_ARTICLE },
];
const structuredData = buildWebsiteStructuredData({
hotelName,
url: appBaseUrl(),
logo: absoluteAssetUrl(logo, appBaseUrl()),
});
return (
<div className="flex w-full flex-col gap-14 pb-16 pt-2 md:gap-20 md:pb-24">
{/* ── Top bar ── */}
<Reveal className="mx-auto w-full max-w-7xl">
<div
className="flex items-center justify-between rounded-full border px-3 py-2.5 sm:px-5"
style={{
background:
"linear-gradient(90deg, color-mix(in srgb, var(--color-surface) 62%, transparent), color-mix(in srgb, var(--color-primary) 7%, transparent))",
backdropFilter: "blur(14px)",
WebkitBackdropFilter: "blur(14px)",
borderColor:
"color-mix(in srgb, var(--color-text-muted) 10%, transparent)",
boxShadow:
"0 1px 3px rgba(0,0,0,0.04), 0 0 0 0.5px color-mix(in srgb, var(--color-text-muted) 6%, transparent)",
}}
>
<div className="flex items-center gap-2 sm:gap-3">
{logo ? (
<Image
src={logo}
alt={hotelName}
width={100}
height={28}
className="shrink-0 object-contain"
unoptimized
style={{ maxHeight: 26, width: "auto", height: "auto" }}
/>
) : (
<JsonLd data={structuredData} />
{/* ── Top bar: logo + live online + language/clock/theme ── */}
<div className="mx-auto w-full max-w-7xl">
<AuthTopBar hotelName={hotelName} logo={logo} online={online} />
</div>
{/* ── Hero + the headline counters, which straddle the hero edge ── */}
<div className="relative w-full">
<Reveal className="-mx-3 sm:-mx-4 md:-mx-6">
<div className="relative overflow-hidden">
<Image
src="/assets/images/next_header.jpg"
alt=""
fill
priority
sizes="100vw"
className="ken-burns"
style={{ objectFit: "cover", objectPosition: "center 20%" }}
/>
<div
className="absolute inset-0"
style={{ background: HERO_SCRIM }}
/>
{/* Drifting brand-tinted aurora behind the copy */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 overflow-hidden"
>
<div
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full"
className="aurora-blob -left-24 top-1/4 h-[420px] w-[420px]"
style={{
background:
"linear-gradient(135deg, var(--color-primary), color-mix(in srgb, var(--color-accent) 60%, var(--color-primary)))",
boxShadow:
"0 4px 16px -4px color-mix(in srgb, var(--color-primary) 60%, transparent)",
"color-mix(in srgb, var(--color-primary) 35%, transparent)",
opacity: 0.5,
}}
>
<Image
src={ICON_LIGHTHOUSE}
alt=""
width={18}
height={18}
className="shrink-0"
unoptimized
style={{ filter: "brightness(0) invert(1)" }}
/>
</div>
)}
<span
className="hidden text-sm font-extrabold uppercase tracking-wide sm:inline"
style={{ color: "var(--color-text-readable)" }}
>
{hotelName}
</span>
</div>
<div
className="flex items-center gap-1 sm:gap-1.5 rounded-full px-1.5 py-1"
style={{
background:
"color-mix(in srgb, var(--color-text-muted) 5%, transparent)",
}}
>
<LanguageSwitcher />
<div
className="hidden w-px h-5 sm:block"
style={{
background:
"color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
}}
/>
<Clock />
<div
className="w-px h-5"
style={{
background:
"color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
}}
/>
<ThemeSwitcher />
</div>
</div>
</Reveal>
{/* ── Cinematic hero ── */}
<Reveal className="-mx-3 sm:-mx-4 md:-mx-6">
<div className="relative overflow-hidden">
<Image
src="/assets/images/next_header.jpg"
alt=""
fill
priority
sizes="100vw"
className="ken-burns"
style={{ objectFit: "cover", objectPosition: "center 20%" }}
/>
<div
className="absolute inset-0"
style={{
background:
"linear-gradient(180deg, rgba(8,11,24,0.74) 0%, rgba(8,11,24,0.55) 38%, rgba(8,11,24,0.38) 62%, rgba(8,11,24,0.55) 85%, color-mix(in srgb, var(--color-background) 94%, transparent) 100%)",
}}
/>
{/* Drifting brand-tinted aurora behind the copy */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 overflow-hidden"
>
<div
className="aurora-blob -left-24 top-1/4 h-[420px] w-[420px]"
style={{
background:
"color-mix(in srgb, var(--color-primary) 35%, transparent)",
opacity: 0.5,
}}
/>
<div
className="aurora-blob -right-24 top-1/3 h-[380px] w-[380px]"
style={{
background:
"color-mix(in srgb, var(--color-accent) 32%, transparent)",
animationDelay: "-7s",
opacity: 0.45,
}}
/>
</div>
<div
aria-hidden="true"
className="brand-halo left-1/2 top-1/2 h-[420px] w-[720px] max-w-full -translate-x-1/2 -translate-y-1/2"
style={{ opacity: 0.5, zIndex: 1 }}
/>
<div className="relative z-10 mx-auto flex min-h-[540px] w-full max-w-6xl flex-col items-center justify-center px-4 py-24 text-center sm:min-h-[580px] lg:min-h-[640px]">
<span
className="glass-chip gap-2.5 px-4 py-2"
style={{
fontWeight: 800,
letterSpacing: "0.14em",
textTransform: "uppercase",
}}
>
<span className="relative flex h-2 w-2">
<span
className="absolute inline-flex h-full w-full animate-ping rounded-full opacity-70"
style={{ background: "var(--color-primary)" }}
/>
<span
className="relative inline-flex h-2 w-2 rounded-full"
style={{ background: "var(--color-primary)" }}
/>
</span>
<span
className="text-[10px] font-black sm:text-xs"
style={{ color: "var(--color-text-readable)" }}
>
{tp("onlineNow")}
</span>
{online === null ? (
<span className="text-xs">{te("unavailable")}</span>
) : (
<span
className="text-sm sm:text-base"
style={{
color:
"var(--color-primary-readable, var(--color-primary))",
fontWeight: 900,
}}
>
<LiveOnlineCounter initial={online} />
</span>
)}
</span>
<h1
className="mt-7 sm:mt-8"
style={{ fontFamily: "var(--font-nunito)" }}
>
<span
className="mb-3 block text-xs font-extrabold uppercase tracking-[0.32em] sm:text-sm"
style={{ color: "rgba(255,255,255,0.72)" }}
>
{tp("welcome", { hotelName: "" })}
</span>
<span className="gradient-text drop-shadow-[0_2px_24px_rgba(0,0,0,0.55)] block text-5xl font-black leading-[1.02] tracking-tight sm:text-6xl md:text-7xl lg:text-8xl">
<TypewriterText text={hotelName} />
</span>
</h1>
<p
className="mt-6 max-w-xl text-sm font-semibold leading-relaxed sm:text-base md:text-lg"
style={{ color: "rgba(255,255,255,0.78)" }}
>
{tp("welcomeBody")}
</p>
{recentUsers !== null && recentUsers.length > 0 && (
/>
<div
className="animate-fade-in-up mt-7 inline-flex items-center gap-3 rounded-full border px-3 py-1.5"
className="aurora-blob -right-24 top-1/3 h-[380px] w-[380px]"
style={{
background: "rgba(255,255,255,0.08)",
borderColor: "rgba(255,255,255,0.22)",
backdropFilter: "blur(12px)",
WebkitBackdropFilter: "blur(12px)",
animationDelay: "0.08s",
background:
"color-mix(in srgb, var(--color-accent) 32%, transparent)",
animationDelay: "-7s",
opacity: 0.45,
}}
>
<div className="flex -space-x-2.5">
{recentUsers.slice(0, 4).map((u) => (
<UserAvatarThumbnail
key={u.username}
figure={u.look}
alt={u.username}
options={{ direction: 2 }}
className="h-9 w-9 rounded-full ring-2 ring-white/70 shadow-lg"
/>
))}
</div>
<span
className="pr-1 text-[10px] font-extrabold uppercase tracking-[0.22em]"
style={{ color: "rgba(255,255,255,0.75)" }}
>
{tp("recentUsers")}
</span>
</div>
)}
/>
</div>
<div
className="animate-fade-in-up mt-9 flex flex-wrap items-center justify-center gap-3"
style={{ animationDelay: "0.2s" }}
>
<Link
href="/register"
className="btn-brand btn-shine px-8 py-4 text-sm font-black uppercase tracking-wider"
>
<Image
src="/assets/images/EnterHubbly.png"
alt=""
width={20}
height={20}
unoptimized
/>
{tp("register")}
</Link>
<Link
href="/login"
className="btn-glass-dark px-8 py-4 text-sm font-bold"
>
{th("login")}
aria-hidden="true"
className="brand-halo left-1/2 top-1/2 h-[420px] w-[720px] max-w-full -translate-x-1/2 -translate-y-1/2"
style={{ opacity: 0.5, zIndex: 1 }}
/>
<div className="relative z-10 mx-auto flex min-h-[540px] w-full max-w-6xl flex-col items-center justify-center px-4 py-24 text-center sm:min-h-[580px] lg:min-h-[640px]">
<h1 style={{ fontFamily: "var(--font-nunito)" }}>
<span
aria-hidden="true"
className="inline-flex transition-transform duration-300 group-hover:translate-x-1"
className="mb-3 block text-xs font-extrabold uppercase tracking-[0.32em] sm:text-sm"
style={{ color: "rgba(255,255,255,0.72)" }}
>
<ArrowRight className="h-4 w-4" />
{tp("heroEyebrow")}
</span>
</Link>
<span className="gradient-text drop-shadow-[0_2px_24px_rgba(0,0,0,0.55)] block text-5xl font-black leading-[1.02] tracking-tight sm:text-6xl md:text-7xl lg:text-8xl">
<TypewriterText text={hotelName} />
</span>
</h1>
<p
className="mt-6 max-w-xl text-sm font-semibold leading-relaxed sm:text-base md:text-lg"
style={{ color: HERO_COPY }}
>
{tp("welcomeBody")}
</p>
<div
className="animate-fade-in-up mt-9 flex flex-wrap items-center justify-center gap-3"
style={{ animationDelay: "0.2s" }}
>
<Link
href="/register"
className="btn-brand btn-shine px-8 py-4 text-sm font-black uppercase tracking-wider"
>
<Image
src="/assets/images/EnterHubbly.png"
alt=""
width={20}
height={20}
unoptimized
/>
{tp("register")}
</Link>
<Link
href="/login"
className="btn-glass-dark group px-8 py-4 text-sm font-bold"
>
{th("login")}
<span
aria-hidden="true"
className="inline-flex transition-transform duration-300 group-hover:translate-x-1"
>
<ArrowRight className="h-4 w-4" />
</span>
</Link>
</div>
</div>
{/* Floating stat chips */}
{/* Scroll cue */}
<div
className="animate-fade-in-up mt-12 flex flex-wrap items-center justify-center gap-2.5 sm:gap-3"
style={{ animationDelay: "0.32s" }}
aria-hidden="true"
className="pointer-events-none absolute bottom-6 left-1/2 hidden -translate-x-1/2 sm:block"
style={{ zIndex: 2 }}
>
{statChips.map((s) => (
<span key={s.label} className="glass-chip">
<div
className="hero-cue flex h-10 w-10 items-center justify-center rounded-full"
style={{
background: "rgba(255,255,255,0.09)",
border: "1px solid rgba(255,255,255,0.3)",
backdropFilter: "blur(10px)",
WebkitBackdropFilter: "blur(10px)",
}}
>
<ChevronDown
className="h-5 w-5"
style={{ color: "rgba(255,255,255,0.88)" }}
/>
</div>
</div>
{/* Floating mascot — decorative, hidden on smaller screens. */}
<div
aria-hidden="true"
className="pointer-events-none absolute bottom-8 right-8 hidden lg:block xl:right-14"
style={{ zIndex: 2 }}
>
<div className="brand-halo left-1/2 top-1/2 h-44 w-44 -translate-x-1/2 -translate-y-1/2 xl:h-52 xl:w-52" />
<Image
src="/assets/images/FrankwithBag.gif"
alt=""
width={96}
height={128}
unoptimized
className="relative animate-float object-contain drop-shadow-2xl"
/>
</div>
<div className="hero-ring" aria-hidden="true" />
</div>
</Reveal>
{/* The headline counters. They straddle the hero edge so the
numbers land above the fold instead of in a second block
further down the page. */}
<Reveal className="relative z-20 mx-auto -mt-14 w-full max-w-4xl px-3 sm:-mt-16 sm:px-4 md:px-6">
<div className="panel panel-float relative overflow-hidden rounded-[var(--panel-radius)] px-2 py-5 sm:px-4 sm:py-6">
<div className="grid grid-cols-2 gap-y-6 lg:grid-cols-4 lg:gap-y-0">
{statItems.map((s, i) => (
<div
key={s.label}
className="stat-cell group flex flex-col items-center gap-2 text-center"
>
<div
className="panel-tile flex h-10 w-10 items-center justify-center rounded-xl transition-transform duration-300 group-hover:scale-110"
style={
{
"--panel-tint": tintFor(i),
} as CSSProperties
}
>
<Image
src={s.icon}
alt=""
width={20}
height={20}
unoptimized
/>
</div>
<div
className="gradient-text text-3xl font-black tracking-tight sm:text-4xl"
style={{ fontVariantNumeric: "tabular-nums" }}
>
{s.value === null ? (
<span
className="text-base font-bold"
style={{
color:
"var(--color-text-muted-readable, var(--color-text-muted))",
}}
>
{te("unavailable")}
</span>
) : (
<AnimatedCounter value={s.value} />
)}
</div>
<span
className="glass-chip-dot"
style={{ background: s.color }}
/>
<span
className="glass-chip-value"
className="rounded-full px-2.5 py-1 text-[11px] font-bold uppercase tracking-widest"
style={{
background:
"color-mix(in srgb, var(--color-text-muted) 9%, transparent)",
color: "var(--color-text-readable)",
fontSize: "1rem",
}}
>
<AnimatedCounter value={s.value} />
{s.label}
</span>
<span className="glass-chip-label">{s.label}</span>
</span>
</div>
))}
</div>
</div>
{/* Scroll cue */}
<div
aria-hidden="true"
className="pointer-events-none absolute bottom-6 left-1/2 hidden -translate-x-1/2 sm:block"
style={{ zIndex: 2 }}
>
<div
className="hero-cue flex h-10 w-10 items-center justify-center rounded-full"
style={{
background: "rgba(255,255,255,0.09)",
border: "1px solid rgba(255,255,255,0.3)",
backdropFilter: "blur(10px)",
WebkitBackdropFilter: "blur(10px)",
}}
>
<ChevronDown
className="h-5 w-5"
style={{ color: "rgba(255,255,255,0.88)" }}
/>
</div>
</div>
{/* Floating mascot — decorative, hidden on smaller screens. */}
<div
aria-hidden="true"
className="pointer-events-none absolute bottom-8 right-8 hidden lg:block xl:right-14"
style={{ zIndex: 2 }}
>
<div className="brand-halo left-1/2 top-1/2 h-44 w-44 -translate-x-1/2 -translate-y-1/2 xl:h-52 xl:w-52" />
<Image
src="/assets/images/FrankwithBag.gif"
alt=""
width={96}
height={128}
unoptimized
className="relative animate-float object-contain drop-shadow-2xl"
/>
</div>
<div className="hero-ring" aria-hidden="true" />
</div>
</Reveal>
</Reveal>
</div>
{/* ── Quick explore ── */}
<Reveal className="-mt-3 mx-auto w-full max-w-7xl">
<Reveal className="-mt-6 mx-auto w-full max-w-7xl">
<nav
aria-label={tp("exploreLabel")}
className="flex flex-wrap items-center justify-center gap-2.5 sm:gap-3"
@@ -739,13 +700,8 @@ export default async function Home() {
aria-hidden="true"
className="h-1.5 w-1.5 shrink-0 rounded-full"
style={{
background:
i % 2 === 0
? "var(--color-primary)"
: "var(--color-accent)",
boxShadow: `0 0 8px color-mix(in srgb, ${
i % 2 === 0 ? "var(--color-primary)" : "var(--color-accent)"
} 70%, transparent)`,
background: tintFor(i),
boxShadow: `0 0 8px color-mix(in srgb, ${tintFor(i)} 70%, transparent)`,
}}
/>
{l.label}
@@ -764,165 +720,71 @@ export default async function Home() {
{/* ── Features ── */}
<Reveal className="mx-auto w-full max-w-7xl">
<div className="flex flex-col gap-6 sm:gap-8">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<span className="eyebrow">{tp("sectionFeaturesLabel")}</span>
<h2
className="section-title mt-2 text-2xl sm:text-3xl md:text-4xl"
style={{ letterSpacing: "-0.02em" }}
>
{tp("sectionFeaturesTitle")}
</h2>
</div>
<div>
<span className="eyebrow">{tp("sectionFeaturesLabel")}</span>
<h2
className="section-title mt-2 text-2xl sm:text-3xl md:text-4xl"
style={{ letterSpacing: "-0.02em" }}
>
{tp("sectionFeaturesTitle")}
</h2>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3 sm:gap-5">
{features.map((f, i) => {
const accentVar =
i % 2 === 0 ? "var(--color-primary)" : "var(--color-accent)";
return (
<SpotlightCard
key={f.title}
className="spotlight card-glow card-hairline group relative overflow-hidden rounded-2xl border p-6 transition-all duration-500 hover:-translate-y-1.5 hover:shadow-2xl sm:p-7"
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-5 lg:grid-cols-4">
{features.map((f, i) => (
<SpotlightCard
key={f.title}
className="panel card-glow card-hairline group relative overflow-hidden rounded-[var(--panel-radius)] p-6 transition-all duration-500 hover:-translate-y-1.5 hover:shadow-2xl sm:p-7"
>
<span
aria-hidden="true"
className="pointer-events-none absolute right-5 top-4 select-none text-5xl font-black leading-none opacity-[0.07] transition-opacity duration-500 group-hover:opacity-20"
style={{
background: GLASS_TILE_BG,
backdropFilter: "blur(16px)",
WebkitBackdropFilter: "blur(16px)",
borderColor:
"color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
background:
"linear-gradient(120deg, var(--color-primary), var(--color-accent))",
WebkitBackgroundClip: "text",
backgroundClip: "text",
color: "transparent",
}}
>
<span
aria-hidden="true"
className="pointer-events-none absolute right-5 top-4 select-none text-5xl font-black leading-none opacity-[0.07] transition-opacity duration-500 group-hover:opacity-20"
style={{
background:
"linear-gradient(120deg, var(--color-primary), var(--color-accent))",
WebkitBackgroundClip: "text",
backgroundClip: "text",
color: "transparent",
}}
>
{`0${i + 1}`}
</span>
<div
className="relative flex h-12 w-12 items-center justify-center rounded-2xl transition-transform duration-300 group-hover:scale-110 group-hover:-rotate-3"
style={{
background: `linear-gradient(135deg, color-mix(in srgb, ${accentVar} 30%, transparent), color-mix(in srgb, ${accentVar} 12%, transparent))`,
boxShadow: `inset 0 0 0 1px color-mix(in srgb, ${accentVar} 30%, transparent), 0 8px 18px -10px color-mix(in srgb, ${accentVar} 70%, transparent)`,
borderRadius: 14,
}}
>
<Image
src={f.icon}
alt=""
width={24}
height={24}
unoptimized
/>
</div>
<h3
className="relative mt-5 text-base font-extrabold sm:text-lg"
style={{
color: "var(--color-text-readable)",
fontFamily: "var(--font-nunito)",
}}
>
{f.title}
</h3>
<p
className="relative mt-1.5 text-sm leading-relaxed"
style={{ color: "var(--color-text-muted)" }}
>
{f.desc}
</p>
</SpotlightCard>
);
})}
</div>
</div>
</Reveal>
{/* ── Stats ── */}
<Reveal className="mx-auto w-full max-w-7xl">
<div className="flex flex-col gap-6 sm:gap-8">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<span className="eyebrow">{tp("sectionStatsLabel")}</span>
<h2
className="section-title mt-2 text-2xl sm:text-3xl md:text-4xl"
style={{ letterSpacing: "-0.02em" }}
>
{tp("sectionStatsTitle")}
</h2>
</div>
</div>
<div
className="card-hairline overflow-hidden rounded-2xl border"
style={PANEL_STYLE}
>
<div className="grid grid-cols-2 gap-x-4 gap-y-8 p-6 sm:p-8 lg:grid-cols-4">
{[
{
value: users,
label: tp("statsCitizens"),
icon: ICON_FRIENDS,
},
{ value: rooms, label: tp("statsRooms"), icon: ICON_CATALOG },
{
value: totalPhotos,
label: tp("statsPhotos"),
icon: ICON_CAMERA,
},
{
value: articleCount,
label: tp("statsArticles"),
icon: ICON_ARTICLE,
},
].map((s) => (
{`0${i + 1}`}
</span>
<div
key={s.label}
className="stat-cell group relative flex flex-col items-center gap-2.5 text-center"
className="panel-tile relative flex h-12 w-12 items-center justify-center rounded-2xl transition-transform duration-300 group-hover:scale-110 group-hover:-rotate-3"
style={
{
"--panel-tint": tintFor(i),
borderRadius: 14,
} as CSSProperties
}
>
<div
className="flex h-10 w-10 items-center justify-center rounded-xl transition-transform duration-300 group-hover:scale-110"
style={{
background:
"color-mix(in srgb, var(--color-primary) 14%, transparent)",
boxShadow:
"inset 0 0 0 1px color-mix(in srgb, var(--color-primary) 18%, transparent)",
}}
>
<Image
src={s.icon}
alt=""
width={20}
height={20}
unoptimized
/>
</div>
<div
className="gradient-text text-4xl font-black tracking-tight sm:text-5xl"
style={{ fontVariantNumeric: "tabular-nums" }}
>
{s.value === null ? (
<span className="text-base">{te("unavailable")}</span>
) : (
<AnimatedCounter value={s.value} />
)}
</div>
<span
className="rounded-full px-3 py-1 text-[10px] font-bold uppercase tracking-widest"
style={{
background:
"color-mix(in srgb, var(--color-text-muted) 8%, transparent)",
color: "var(--color-text-muted)",
}}
>
{s.label}
</span>
<Image
src={f.icon}
alt=""
width={24}
height={24}
unoptimized
/>
</div>
))}
</div>
<h3
className="relative mt-5 text-base font-extrabold sm:text-lg"
style={{
color: "var(--color-text-readable)",
fontFamily: "var(--font-nunito)",
}}
>
{f.title}
</h3>
<p
className="relative mt-1.5 text-sm leading-relaxed"
style={{
color:
"var(--color-text-muted-readable, var(--color-text-muted))",
}}
>
{f.desc}
</p>
</SpotlightCard>
))}
</div>
</div>
</Reveal>
@@ -930,20 +792,18 @@ export default async function Home() {
{/* ── Community ── */}
<Reveal className="mx-auto w-full max-w-7xl">
<div className="flex flex-col gap-6 sm:gap-8">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<span className="eyebrow">{tp("sectionCommunityLabel")}</span>
<h2
className="section-title mt-2 text-2xl sm:text-3xl md:text-4xl"
style={{ letterSpacing: "-0.02em" }}
>
{tp("sectionCommunityTitle")}
</h2>
</div>
<div>
<span className="eyebrow">{tp("sectionCommunityLabel")}</span>
<h2
className="section-title mt-2 text-2xl sm:text-3xl md:text-4xl"
style={{ letterSpacing: "-0.02em" }}
>
{tp("sectionCommunityTitle")}
</h2>
</div>
<div className="flex flex-col gap-4 md:flex-row">
<div className="flex w-full flex-col gap-4 md:w-1/3 md:flex-shrink-0">
<GlassPanel
<Panel
title={th("login")}
icon={ICON_NAV_ME}
bodyClassName="p-3.5 sm:p-4"
@@ -958,15 +818,15 @@ export default async function Home() {
}}
nonce={nonce}
/>
</GlassPanel>
</Panel>
{recentUsers === null ? (
<GlassPanel title={tp("recentUsers")} bodyClassName="p-4">
<Panel title={tp("recentUsers")} bodyClassName="p-4">
<PublicLoadError href="/" />
</GlassPanel>
</Panel>
) : (
recentUsers.length > 0 && (
<GlassPanel
<Panel
title={tp("recentUsers")}
icon={ICON_NAV_GOODY}
actionHref="/community"
@@ -975,34 +835,45 @@ export default async function Home() {
>
<div className="grid grid-cols-4 gap-1.5 sm:gap-2">
{recentUsers.map((u) => (
<div
<Link
key={u.username}
href={`/u/${encodeURIComponent(u.username)}`}
title={u.username}
className="flex flex-col items-center gap-0.5 rounded-lg px-1 py-1.5 transition-all duration-200 hover:bg-[color-mix(in_srgb,var(--color-primary)_6%,transparent)] hover:ring-1 hover:ring-[color-mix(in_srgb,var(--color-primary)_35%,transparent)]"
className="group flex flex-col items-center gap-1 rounded-xl border border-transparent p-1.5 transition-all duration-200 hover:border-[color-mix(in_srgb,var(--color-primary)_32%,transparent)] hover:bg-[color-mix(in_srgb,var(--color-primary)_8%,transparent)]"
>
<UserAvatarThumbnail
figure={u.look}
alt={u.username}
options={{ direction: 2 }}
className="rounded-md"
/>
<span className="relative">
<UserAvatarThumbnail
figure={u.look}
alt={u.username}
options={{ direction: 2 }}
className="rounded-md"
/>
<span
aria-hidden="true"
className="absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full"
style={{
background: "var(--color-accent)",
boxShadow: "0 0 0 2px var(--panel-surface)",
}}
/>
</span>
<span
className="max-w-[64px] truncate text-center text-[9px] sm:text-[10px] font-bold leading-tight"
style={{ color: "var(--color-text-readable)" }}
>
{u.username}
</span>
</div>
</Link>
))}
</div>
</GlassPanel>
</Panel>
)
)}
</div>
{/* News */}
<div className="flex w-full flex-col md:w-2/3">
<GlassPanel
<Panel
title={tp("latestNews")}
icon={ICON_ARTICLE}
actionHref="/news"
@@ -1020,12 +891,15 @@ export default async function Home() {
) : (
<p
className="py-8 text-center text-sm"
style={{ color: "var(--color-text-muted)" }}
style={{
color:
"var(--color-text-muted-readable, var(--color-text-muted))",
}}
>
{tp("noArticles")}
</p>
)}
</GlassPanel>
</Panel>
</div>
</div>
</div>
@@ -1034,37 +908,53 @@ export default async function Home() {
{/* ── Recent photos ── */}
{recentPhotos === null ? (
<Reveal className="mx-auto w-full max-w-7xl">
<GlassPanel title={tp("recentPhotos")} bodyClassName="p-4">
<Panel title={tp("recentPhotos")} bodyClassName="p-4">
<PublicLoadError href="/" />
</GlassPanel>
</Panel>
</Reveal>
) : (
recentPhotos.length > 0 && (
<Reveal className="mx-auto w-full max-w-7xl">
<GlassPanel title={tp("recentPhotos")} icon={ICON_CAMERA}>
<Panel title={tp("recentPhotos")} icon={ICON_CAMERA}>
<div className="mx-auto grid max-w-5xl grid-cols-2 gap-3 sm:grid-cols-4">
{recentPhotos.slice(0, 4).map((p, i) => (
<Link
key={p.id}
href="/photos"
className="block aspect-[4/3] overflow-hidden rounded-2xl border transition-all duration-300 ease-out hover:scale-[1.03] hover:shadow-xl hover:ring-2 hover:ring-[color-mix(in_srgb,var(--color-primary)_45%,transparent)]"
style={{
borderColor:
"color-mix(in srgb, var(--color-text-muted) 8%, transparent)",
}}
style={{ borderColor: "var(--panel-border)" }}
>
<Image
src={p.url}
alt={`${tp("recentPhotos")} ${i + 1}`}
width={280}
height={210}
className="h-full w-full object-cover"
unoptimized
/>
{p.url ? (
<Image
src={p.url}
alt={`${tp("recentPhotos")} ${i + 1}`}
width={280}
height={210}
className="h-full w-full object-cover"
unoptimized
/>
) : (
<span
className="flex h-full w-full items-center justify-center"
style={{
background:
"linear-gradient(140deg, color-mix(in srgb, var(--color-primary) 22%, var(--color-surface)), color-mix(in srgb, var(--color-accent) 16%, var(--color-surface)))",
}}
>
<Image
src={ICON_CAMERA}
alt=""
width={32}
height={32}
unoptimized
className="opacity-70"
/>
</span>
)}
</Link>
))}
</div>
</GlassPanel>
</Panel>
</Reveal>
)
)}
@@ -1148,3 +1038,5 @@ export default async function Home() {
</div>
);
}
export default withPublicPagePerformance("/", Home);
-2
View File
@@ -108,7 +108,6 @@ export default async function RareCategoryPage({
}}
>
{badgeSrc ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={badgeSrc}
alt={`${cat.name} badge`}
@@ -150,7 +149,6 @@ export default async function RareCategoryPage({
<tr key={String(r.id)}>
<td>
{iconSrc ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={iconSrc}
alt=""
-1
View File
@@ -88,7 +88,6 @@ export default async function RareValuesPage() {
>
<div className="content-card-head">
{badgeSrc ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={badgeSrc}
alt={`${c.name} badge`}
+24 -156
View File
@@ -1,23 +1,13 @@
import { desc, eq } from "drizzle-orm";
import type { Metadata } from "next";
import { headers } from "next/headers";
import Image from "next/image";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import {
AuthPageFrame,
AuthUsersCards,
} from "@/components/auth/auth-page-frame";
AuthLandingView,
loadAuthLanding,
} from "@/components/auth/auth-landing";
import { RegisterForm } from "@/components/auth/register-form";
import { SurfaceCard } from "@/components/surface-card";
import { auth } from "@/lib/auth";
import { cached } from "@/lib/cache";
import { db, User } from "@/lib/db";
import { resolveHotelName } from "@/lib/hotel-name";
import { captchaConfig } from "@/lib/services/captcha";
import { cachedOnlineCount } from "@/lib/services/public-counters";
import { siteSettings } from "@/lib/services/site-settings";
import { passwordPolicy } from "@/lib/auth/password-policy";
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("pages.register");
@@ -26,163 +16,41 @@ export async function generateMetadata(): Promise<Metadata> {
return {
title,
description,
robots: { index: false, follow: false },
openGraph: { title, description, type: "website" },
};
}
export default async function RegisterPage() {
const tpr = await getTranslations("pages.register");
const [hotelName, cfg, logo] = await Promise.all([
resolveHotelName(),
captchaConfig(),
siteSettings.get("cms_logo", ""),
]);
const nonce = (await headers()).get("x-nonce") ?? undefined;
const t = await getTranslations("pages.register");
// An account already exists in this browser — there is nothing to sign up.
const session = await auth();
if (session?.user?.id) redirect("/me");
const [online, recentUsers, latestUsers] = await Promise.all([
cachedOnlineCount().catch(() => 0),
cached(
"auth_online_users",
10_000,
() =>
db
.select({ id: User.id, username: User.username, look: User.look })
.from(User)
.where(eq(User.online, "1"))
.limit(8),
{ staleMs: 30000 },
).catch(() => []),
cached(
"auth_latest_users",
30_000,
() =>
db
.select({ id: User.id, username: User.username, look: User.look })
.from(User)
.orderBy(desc(User.accountCreated))
.limit(8),
{ staleMs: 60000 },
).catch(() => []),
]);
const data = await loadAuthLanding();
return (
<AuthPageFrame
hotelName={hotelName}
logo={logo}
title={tpr("title")}
subtitle={tpr("subtitle")}
<AuthLandingView
data={data}
copy={{
title: t("title"),
subtitle: t("subtitle"),
promoTitle: t("subtitle"),
promoSubtitle: undefined,
usersOnlineLabel: t("usersOnline", { count: data.online }),
whoIsOnlineLabel: t("whoIsOnline"),
newestCitizensLabel: t("newestCitizens"),
}}
form={
<RegisterForm
hotelName={hotelName}
captcha={{
provider: cfg.provider,
siteKey: cfg.siteKey || undefined,
}}
nonce={nonce}
hotelName={data.hotelName}
captcha={data.captcha}
nonce={data.nonce}
passwordMinLength={passwordPolicy.minLength}
passwordMaxLength={passwordPolicy.maxLength}
/>
}
>
<SurfaceCard
className="card-glow relative overflow-hidden p-6"
style={{
borderColor:
"color-mix(in srgb, var(--color-primary) 20%, transparent)",
}}
>
<div
className="absolute inset-0"
style={{
background:
"linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 10%, transparent), color-mix(in srgb, var(--color-accent) 10%, transparent))",
}}
/>
<div aria-hidden="true" className="absolute inset-0 overflow-hidden">
<div className="brand-halo left-1/2 top-0 h-64 w-[460px] max-w-full -translate-x-1/2 -translate-y-1/2" />
<div
className="aurora-blob -left-16 top-6 h-56 w-56"
style={{
background:
"color-mix(in srgb, var(--color-primary) 45%, transparent)",
}}
/>
<div
className="aurora-blob -right-16 bottom-0 h-56 w-56"
style={{
background:
"color-mix(in srgb, var(--color-accent) 40%, transparent)",
animationDelay: "-8s",
}}
/>
</div>
<div className="relative">
<div className="flex items-center gap-4">
<div className="shrink-0 animate-float">
<div className="brand-halo left-1/2 top-1/2 h-52 w-52 -translate-x-1/2 -translate-y-1/2" />
<Image
src="/assets/images/FrankwithBag.gif"
alt="Frank"
width={110}
height={145}
className="relative object-contain drop-shadow-xl"
unoptimized
/>
</div>
<div className="flex-1">
<div
className="inline-flex items-center gap-2 px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider mb-2 border"
style={{
borderColor:
"color-mix(in srgb, var(--color-primary) 30%, transparent)",
background:
"color-mix(in srgb, var(--color-surface) 72%, transparent)",
color: "var(--color-primary-readable, var(--color-primary))",
boxShadow:
"0 8px 20px -12px color-mix(in srgb, var(--color-primary) 70%, transparent)",
}}
>
<span className="relative flex h-2 w-2">
<span
className="animate-ping absolute inline-flex h-full w-full rounded-full opacity-75"
style={{ background: "var(--color-primary)" }}
/>
<span
className="relative inline-flex rounded-full h-2 w-2"
style={{ background: "var(--color-primary)" }}
/>
</span>
{tpr("usersOnline", { count: online })}
</div>
<h1
className="text-lg font-black leading-tight"
style={{
color: "var(--color-text-readable)",
fontFamily: "var(--font-nunito)",
}}
>
{tpr("title")} —{" "}
<span className="gradient-text">{hotelName}</span>
</h1>
<p
className="text-xs mt-1.5 leading-relaxed"
style={{ color: "var(--color-text-muted)" }}
>
{tpr("subtitle")}
</p>
</div>
</div>
</div>
</SurfaceCard>
<AuthUsersCards
recentUsers={recentUsers}
latestUsers={latestUsers}
recentTitle={tpr("whoIsOnline")}
latestTitle={tpr("newestCitizens")}
/>
</AuthPageFrame>
/>
);
}
+7 -2
View File
@@ -3,6 +3,7 @@ import { getTranslations } from "next-intl/server";
import { resetPassword } from "@/actions/password-reset";
import Link from "@/components/link";
import { ContentCard } from "@/components/public/ui";
import { passwordPolicy } from "@/lib/auth/password-policy";
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("pages.reset");
@@ -41,8 +42,12 @@ export default async function ResetPage({
<input
name="password"
type="password"
placeholder={t("passwordPlaceholder")}
placeholder={t("passwordPlaceholder", {
min: passwordPolicy.minLength,
})}
autoComplete="new-password"
minLength={passwordPolicy.minLength}
maxLength={passwordPolicy.maxLength}
required
/>
<button type="submit" className="btn btn-primary">
@@ -58,7 +63,7 @@ export default async function ResetPage({
marginBottom: 0,
}}
>
{t(errorKey)}
{t(errorKey, { min: passwordPolicy.minLength })}
</p>
) : null}
<p
+1 -1
View File
@@ -16,7 +16,7 @@ const STATE_LABELS: Record<string, { label: string; icon: string }> = {
};
function describeState(state: string): { label: string; icon: string } {
// eslint-disable-next-line security/detect-object-injection -- STATE_LABELS has known keys, fallback provided
// STATE_LABELS has known keys, fallback provided
return STATE_LABELS[state] ?? { label: state || "Unknown", icon: "🚪" };
}
-1
View File
@@ -249,7 +249,6 @@ export default async function ShopPage({
}}
>
{a.iconUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={a.iconUrl}
alt=""
-1
View File
@@ -366,7 +366,6 @@ async function ProfilePage({
flexWrap: "wrap",
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<ProfileImage
src={avatar}
alt={user.username}
+11 -1
View File
@@ -1,4 +1,6 @@
import { notFound } from "next/navigation";
import { NextIntlClientProvider } from "next-intl";
import { getLocale, getMessages } from "next-intl/server";
import type { ReactNode } from "react";
import { env } from "@/env";
import { isHousekeepingPreviewEnabled } from "@/features/housekeeping/foundation/preview-gate";
@@ -27,5 +29,13 @@ export default async function AdminNextLayout({
notFound();
}
return children;
// Staff tree: re-install the full catalogue the root layout scopes down to
// the public subset (nested providers replace the parent set).
const [locale, messages] = await Promise.all([getLocale(), getMessages()]);
return (
<NextIntlClientProvider locale={locale} messages={messages}>
{children}
</NextIntlClientProvider>
);
}
-1
View File
@@ -49,7 +49,6 @@ export function AdsTable({ ads }: { ads: AdRow[] }) {
{ads.map((ad) => (
<tr key={ad.id}>
<td>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={ad.image}
alt=""
-1
View File
@@ -103,7 +103,6 @@ export default async function AdminCalendarCampaign({
<div className="admin-card flex items-center gap-5 mb-6">
{campaign.image ? (
// eslint-disable-next-line @next/next/no-img-element
<img
className="w-24 h-24 object-cover"
src={campaign.image}
-1
View File
@@ -98,7 +98,6 @@ export default async function AdminCalendar() {
className="admin-card hover article"
>
{c.image ? (
// eslint-disable-next-line @next/next/no-img-element
<img className="article-img" src={c.image} alt={c.name} />
) : (
<div className="article-img" />
+1 -1
View File
@@ -87,7 +87,7 @@ export default async function AdminAntiDdosPage() {
const ip = key.replace("antiddos:block:", "");
return {
ip,
ttlMs: ttlMs > 0 ? ttlMs : 0,
ttlMs: Math.max(ttlMs, 0),
count: violationCounts.get(ip) ?? 0,
source: "gate" as const,
};
-1
View File
@@ -96,7 +96,6 @@ export default async function AdminGuildDetailPage({
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex items-center gap-4">
{guild.badge ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`https://habbo-stories-content.s3.amazonaws.com/badge-part-images/${guild.badge}.gif`}
alt=""
-1
View File
@@ -155,7 +155,6 @@ export default async function AdminGuildsPage({
<td className="muted">{g.id}</td>
<td>
{g.badge ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`https://habbo-stories-content.s3.amazonaws.com/badge-part-images/${g.badge}.gif`}
alt=""
+9 -3
View File
@@ -1,7 +1,8 @@
import { eq } from "drizzle-orm";
import { LogOut } from "lucide-react";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { NextIntlClientProvider } from "next-intl";
import { getLocale, getMessages, getTranslations } from "next-intl/server";
import type { ReactNode } from "react";
import { AdminHubChrome } from "@/components/admin/admin-hub-chrome";
import { AdminMobileWrapper } from "@/components/admin/admin-mobile-wrapper";
@@ -40,8 +41,13 @@ export default async function AdminLayout({
if (!u?.twoFactorConfirmedAt) redirect("/settings/2fa?error=staffrequired");
}
// The root layout ships the reduced catalogue so public pages do not carry
// the ~195 KB of staff-tool strings. Nested providers replace the parent
// set rather than merging, so staff route groups re-install the full one.
const [locale, messages] = await Promise.all([getLocale(), getMessages()]);
return (
<>
<NextIntlClientProvider locale={locale} messages={messages}>
{csrfToken ? <meta name="csrf-token" content={csrfToken} /> : null}
<AdminMobileWrapper sidebar={<Sidebar staff={staff} />}>
<div
@@ -56,7 +62,7 @@ export default async function AdminLayout({
</div>
</AdminMobileWrapper>
<AdminToaster />
</>
</NextIntlClientProvider>
);
}
-1
View File
@@ -94,7 +94,6 @@ export default async function AdminPhotos({
className="admin-card"
style={{ padding: 0, overflow: "hidden" }}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={p.url}
alt={t("photoAlt", { id: String(p.id) })}
+1 -1
View File
@@ -63,7 +63,7 @@ export default async function AdminRadioHistoryPage() {
const liveCount = history.filter((h) => !h.endedAt).length;
const peakListeners = history.reduce(
(max, h) => (h.listenersCount > max ? h.listenersCount : max),
(max, h) => Math.max(h.listenersCount, max),
0,
);
const uniqueDjs = new Set(history.map((h) => String(h.userId))).size;
+2 -2
View File
@@ -62,7 +62,7 @@ function isRecord(v: unknown): v is Record<string, unknown> {
}
function pickString(obj: Record<string, unknown>, key: string): string | null {
// eslint-disable-next-line security/detect-object-injection -- only called with hardcoded keys
// only called with hardcoded keys
const v = obj[key];
return typeof v === "string" && v.trim() !== "" ? v.trim() : null;
}
@@ -112,7 +112,7 @@ function findNumberDeep(
if (typeof value === "number" && Number.isFinite(value)) return value;
if (!isRecord(value)) return null;
for (const key of keys) {
// eslint-disable-next-line security/detect-object-injection -- keys from hardcoded array
// keys from hardcoded array
const v = value[key];
if (typeof v === "number" && Number.isFinite(v)) return v;
if (
@@ -577,7 +577,6 @@ export function FurniClient({ room, items = [], canEdit }: Props) {
)}
<TableCell>
{icon ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={icon}
alt={item.name}
@@ -733,7 +732,6 @@ export function FurniClient({ room, items = [], canEdit }: Props) {
>
<TableCell>
{icon ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={icon}
alt={g.base.name}
+1 -1
View File
@@ -45,8 +45,8 @@ import {
import { Textarea } from "@/components/ui/textarea";
import { useServerAction } from "@/hooks/use-server-action";
import { adminFetch } from "@/lib/admin-fetch";
import { logger } from "@/lib/browser-log";
import { invalidateSongPickerCache } from "@/lib/client-cache/song-picker-cache";
import { logger } from "@/lib/logger";
import { parseTraxChannels } from "@/lib/trax-format";
import { TraxPlayer } from "./trax-player";
+1 -1
View File
@@ -19,7 +19,7 @@ import { Loader2, Play, Square } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { logger } from "@/lib/logger";
import { logger } from "@/lib/browser-log";
import { TRAX_TICK_SECONDS, type TraxChannel } from "@/lib/trax-format";
const TICK_SECONDS = TRAX_TICK_SECONDS;
+1 -1
View File
@@ -493,7 +493,7 @@ export default async function AdminTheme({
width: 20,
height: 20,
borderRadius: 4,
// eslint-disable-next-line security/detect-object-injection -- k from fixed theme color key list
// k from fixed theme color key list
background: theme.settings[k] ?? "#64748b",
boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.2)",
}}
+3 -3
View File
@@ -356,7 +356,7 @@ export async function GET(req: Request) {
]);
const rarity = Object.fromEntries(
// eslint-disable-next-line security/detect-object-injection -- rk from rarityKeys const, i is array index
// rk from rarityKeys const, i is array index
rarityKeys.map((rk, i) => [rk, rarityBoards[i]]),
) as Record<BadgeRarityKey, BoardCore>;
@@ -398,7 +398,7 @@ export async function GET(req: Request) {
totalBadgesViewer = tb;
achievementViewer = ach;
rarityViewer = Object.fromEntries(
// eslint-disable-next-line security/detect-object-injection -- rk from rarityKeys const, i is array index
// rk from rarityKeys const, i is array index
rarityKeys.map((rk, i) => [rk, rvs[i]]),
) as Record<BadgeRarityKey, Partial<BadgeLeaderboardEntry> | undefined>;
}
@@ -414,7 +414,7 @@ export async function GET(req: Request) {
viewerEntry: achievementViewer,
},
rarity: Object.fromEntries(
// eslint-disable-next-line security/detect-object-injection -- rk from rarityKeys const
// rk from rarityKeys const
rarityKeys.map((rk) => [
rk,
{ ...global.rarity[rk], viewerEntry: rarityViewer[rk] },
+1 -1
View File
@@ -110,7 +110,7 @@ export async function GET(req: Request) {
async () =>
type === "credits"
? await loadCreditsRows()
: // eslint-disable-next-line security/detect-object-injection -- type validated to "diamonds"|"duckets"
: // type validated to "diamonds"|"duckets"
await loadCurrencyRows(CURRENCY_TYPE[type]),
// Rankings are re-sorted on every read; serving the previous order for a
// minute keeps a busy leaderboard off the database.
+1 -3
View File
@@ -51,7 +51,6 @@ export async function GET(
headers: SECURITY_HEADERS,
});
}
// eslint-disable-next-line security/detect-non-literal-fs-filename
if (!existsSync(filePath)) {
return new NextResponse("Not found", {
status: 404,
@@ -59,7 +58,6 @@ export async function GET(
});
}
// eslint-disable-next-line security/detect-non-literal-fs-filename
const bytes = await readFile(filePath);
const mime: Record<string, string> = {
".png": "image/png",
@@ -76,7 +74,7 @@ export async function GET(
headers: {
...SECURITY_HEADERS,
...(ext === ".svg" ? { "Content-Disposition": "attachment" } : {}),
// eslint-disable-next-line security/detect-object-injection -- ext validated against ALLOWED_EXT
// ext validated against ALLOWED_EXT
"Content-Type": mime[ext] ?? "application/octet-stream",
"Cache-Control": "public, max-age=3600, must-revalidate",
},
-2
View File
@@ -12,11 +12,9 @@ import { PERMS } from "@/lib/permissions";
*/
export const GET = withAdmin({ permission: PERMS.PAGES_EDIT }, async () => {
const dir = MEDIA_ROOT;
// eslint-disable-next-line security/detect-non-literal-fs-filename
if (!existsSync(dir)) {
return apiOk({ files: [] });
}
// eslint-disable-next-line security/detect-non-literal-fs-filename
const files = readdirSync(dir)
.filter((f) => /\.(png|jpg|jpeg|gif|webp|svg|bmp)$/i.test(f))
.map((f) => {
+1 -1
View File
@@ -35,7 +35,7 @@ function findCount(value: unknown, depth = 0): number | null {
"count",
];
for (const key of keys) {
// eslint-disable-next-line security/detect-object-injection -- keys from hardcoded array
// keys from hardcoded array
const v = value[key];
if (typeof v === "number" && Number.isFinite(v)) return v;
if (
+194 -84
View File
@@ -500,9 +500,14 @@ summary,
}
}
/* ── Site background: Habbo sky ── */
/* ── Site background: Habbo sky ──
The sky lives in a custom property so the Theme Manager can layer a CMS
wallpaper *on top of* it (`background-image: url(...), var(--site-bg-gradient)`)
instead of replacing it. Painting the wallpaper as the only layer meant a
missing upload silently erased the whole gradient and left the site on flat
white. */
.site-bg {
background: linear-gradient(
--site-bg-gradient: linear-gradient(
180deg,
color-mix(in srgb, var(--color-primary) 4%, #b3d9f7) 0%,
color-mix(in srgb, var(--color-primary) 6%, #d4e8fa) 25%,
@@ -510,7 +515,10 @@ summary,
color-mix(in srgb, var(--color-primary) 10%, #f0e8d8) 75%,
color-mix(in srgb, var(--color-primary) 14%, var(--color-background)) 100%
);
background-image: var(--site-bg-gradient);
background-attachment: fixed;
background-size: cover;
background-repeat: no-repeat;
position: relative;
}
/* Habbo-style clouds */
@@ -711,13 +719,16 @@ html.dark .cloud {
);
}
html.dark .site-bg {
background: linear-gradient(
--site-bg-gradient: linear-gradient(
180deg,
color-mix(in srgb, var(--color-primary) 6%, #0a0e1a) 0%,
color-mix(in srgb, var(--color-primary) 8%, #10152a) 30%,
color-mix(in srgb, var(--color-primary) 10%, #161c30) 60%,
color-mix(in srgb, var(--color-primary) 12%, #0f141f) 100%
);
/* Re-declaring `background-image` here would out-specify the wallpaper
layer injected by ThemeVars (body.site-bg) and silently drop it in dark
mode. The gradient is picked up through `--site-bg-gradient` instead. */
background-attachment: fixed;
}
html.dark .site-bg::before {
@@ -2081,9 +2092,9 @@ body:has([data-admin]) {
/* Admin forms / boxes follow admin tokens in both light and dark. */
body:has([data-admin])
input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not(
[type="file"]
):not([type="color"]),
input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not(
[type="file"]
):not([type="color"]),
body:has([data-admin]) select,
body:has([data-admin]) textarea {
background-color: var(--admin-surface-elevated);
@@ -2559,17 +2570,6 @@ details[open] > summary .details-open\:rotate-180 {
/* ── Classic-landing polish (theme-aware) ────────────────────────────
Keeps the Habbo sky and theme colors; adds subtle depth + glow to the
restored landing pages. Everything is driven by theme CSS variables. */
.brand-halo {
position: absolute;
pointer-events: none;
border-radius: 999px;
background: radial-gradient(
circle,
color-mix(in srgb, var(--color-primary) 30%, transparent) 0%,
transparent 70%
);
filter: blur(20px);
}
.card-glow {
transition:
box-shadow 0.3s ease,
@@ -2647,6 +2647,123 @@ details[open] > summary .details-open\:rotate-180 {
letter-spacing: -0.02em;
}
/* ── Landing panel system ────────────────────────────────────────────
One recipe for every card on the public pages. The old panels each mixed
their own translucent washes (surface 44–60% + backdrop blur), which read as
a milky smear over the sky gradient, gave the same four numbers two
different-looking boxes, and cost a backdrop snapshot per panel on every
scroll. These tokens are opaque, theme-derived and shared. */
:root {
--panel-surface: color-mix(
in srgb,
var(--color-surface) 93%,
var(--color-primary)
);
--panel-surface-alt: color-mix(
in srgb,
var(--color-surface) 88%,
var(--color-accent)
);
--panel-surface-sunken: color-mix(
in srgb,
var(--color-surface) 96%,
var(--color-background)
);
--panel-border: color-mix(in srgb, var(--color-text-muted) 16%, transparent);
--panel-border-strong: color-mix(
in srgb,
var(--color-primary) 32%,
transparent
);
--panel-shadow:
0 1px 2px color-mix(in srgb, #0b1220 7%, transparent),
0 18px 40px -26px color-mix(in srgb, #0b1220 55%, transparent);
--panel-header-bg: color-mix(
in srgb,
var(--color-primary) 8%,
var(--color-surface)
);
--panel-header-border: color-mix(
in srgb,
var(--color-text-muted) 12%,
transparent
);
--panel-radius: 18px;
}
html.dark {
--panel-surface: color-mix(
in srgb,
var(--color-surface) 88%,
var(--color-background)
);
--panel-surface-alt: color-mix(
in srgb,
var(--color-surface) 82%,
var(--color-background)
);
--panel-surface-sunken: color-mix(
in srgb,
var(--color-surface) 92%,
var(--color-background)
);
--panel-border: color-mix(in srgb, var(--color-text-muted) 24%, transparent);
--panel-border-strong: color-mix(
in srgb,
var(--color-primary) 46%,
transparent
);
--panel-shadow:
0 1px 2px rgb(0 0 0 / 0.4), 0 24px 48px -28px rgb(0 0 0 / 0.85);
--panel-header-bg: color-mix(
in srgb,
var(--color-primary) 13%,
var(--color-surface)
);
--panel-header-border: color-mix(
in srgb,
var(--color-text-muted) 20%,
transparent
);
}
/* The standard card. `rounded-[--panel-radius]` is spelled out in the
component, this class only owns colour, border and depth. */
.panel {
background: var(--panel-surface);
border: 1px solid var(--panel-border);
box-shadow: var(--panel-shadow);
}
/* A panel that floats over the hero, so it needs a deeper shadow. */
.panel-float {
box-shadow:
0 2px 6px color-mix(in srgb, #0b1220 10%, transparent),
0 34px 64px -30px color-mix(in srgb, #0b1220 62%, transparent);
}
html.dark .panel-float {
box-shadow:
0 2px 6px rgb(0 0 0 / 0.5),
0 36px 70px -30px rgb(0 0 0 / 0.9);
}
/* A tile that sits *inside* a panel — icon chips, stat wells, list rows. */
.panel-sunken {
background: var(--panel-surface-sunken);
border: 1px solid color-mix(in srgb, var(--color-text-muted) 10%, transparent);
}
.panel-header {
background: var(--panel-header-bg);
border-bottom: 1px solid var(--panel-header-border);
}
/* Icon chip inside a panel. `--panel-tint` is set per instance. */
.panel-tile {
background: color-mix(
in srgb,
var(--panel-tint, var(--color-primary)) 15%,
transparent
);
box-shadow: inset 0 0 0 1px
color-mix(in srgb, var(--panel-tint, var(--color-primary)) 26%, transparent);
}
/* ── Smooth motion (landing) ───────────────────────────────────────── */
html {
scroll-behavior: smooth;
@@ -2668,6 +2785,15 @@ html {
/* Gentle breathing pulse for the brand glows behind Frank / the hero. */
.brand-halo {
position: absolute;
pointer-events: none;
border-radius: 999px;
background: radial-gradient(
circle,
color-mix(in srgb, var(--color-primary) 30%, transparent) 0%,
transparent 70%
);
filter: blur(20px);
animation: hero-halo-pulse 6s ease-in-out infinite;
will-change: transform;
}
@@ -2691,8 +2817,7 @@ html {
.brand-halo {
animation: none;
}
.hero-ring,
.glass-chip {
.hero-ring {
animation: none;
}
}
@@ -2734,48 +2859,6 @@ html {
}
}
/* Frosted floating stat chips that bob gently on the hero.
No backdrop-filter while animating: sampling the backdrop every frame is
expensive, so the chips use a higher-opacity fill instead. */
.glass-chip {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.45rem 0.85rem;
border-radius: 999px;
background: color-mix(in srgb, var(--color-surface) 72%, transparent);
border: 1px solid color-mix(in srgb, var(--color-text-muted) 14%, transparent);
box-shadow:
0 10px 26px -10px color-mix(in srgb, #000 30%, transparent),
inset 0 1px 0 0 color-mix(in srgb, #fff 12%, transparent);
animation: float 5s ease-in-out infinite;
will-change: transform;
}
.glass-chip:nth-child(2) {
animation-delay: -1.7s;
}
.glass-chip:nth-child(3) {
animation-delay: -3.4s;
}
.glass-chip-dot {
width: 0.45rem;
height: 0.45rem;
border-radius: 999px;
flex-shrink: 0;
}
.glass-chip-value {
font-weight: 900;
font-variant-numeric: tabular-nums;
line-height: 1;
}
.glass-chip-label {
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--color-text-muted);
}
/* ── Public page polish: buttons ──────────────────────────────────── */
.btn-brand {
position: relative;
@@ -2893,6 +2976,20 @@ html {
0 0 0 3px color-mix(in srgb, var(--color-primary) 16%, transparent),
0 0 24px -8px color-mix(in srgb, var(--color-primary) 60%, transparent);
}
.auth-input[aria-invalid="true"]:not(:focus) {
border-color: color-mix(in srgb, var(--color-danger) 60%, transparent);
background: color-mix(
in srgb,
var(--color-danger) 6%,
color-mix(in srgb, var(--color-background) 88%, var(--color-surface))
);
}
.auth-input[aria-invalid="true"]:focus {
border-color: var(--color-danger);
box-shadow:
0 0 0 3px color-mix(in srgb, var(--color-danger) 18%, transparent),
0 0 24px -8px color-mix(in srgb, var(--color-danger) 55%, transparent);
}
.auth-input:disabled {
opacity: 0.55;
cursor: not-allowed;
@@ -2943,10 +3040,9 @@ html {
font-size: 0.85rem;
font-weight: 700;
color: var(--color-text-readable);
background: color-mix(in srgb, var(--color-surface) 55%, transparent);
border: 1px solid color-mix(in srgb, var(--color-text-muted) 16%, transparent);
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
background: var(--panel-surface);
border: 1px solid var(--panel-border);
box-shadow: 0 1px 2px color-mix(in srgb, #0b1220 6%, transparent);
transition:
background 0.25s ease,
border-color 0.25s ease,
@@ -2956,13 +3052,13 @@ html {
.explore-pill:hover {
background: color-mix(
in srgb,
var(--color-primary) 16%,
var(--color-surface)
var(--color-primary) 14%,
var(--panel-surface)
);
border-color: color-mix(in srgb, var(--color-primary) 50%, transparent);
border-color: var(--panel-border-strong);
transform: translateY(-2px);
box-shadow: 0 12px 26px -14px
color-mix(in srgb, var(--color-primary) 80%, transparent);
box-shadow: 0 14px 28px -16px
color-mix(in srgb, var(--color-primary) 85%, transparent);
}
.explore-pill .pill-arrow {
opacity: 0;
@@ -3065,30 +3161,44 @@ html {
}
}
/* Dividers between the four counters in the stats panel. */
/* Hairlines between the headline counters.
The strip is two-up on narrow screens and four-up from `lg`. The old rules
only knew the four-up layout: on mobile they drew a single vertical line
between the two rows instead of between the two columns, and none between
the columns at all. Both rules are drawn from the same token so the grid
always reads as four separate figures. */
.stat-cell {
position: relative;
}
.stat-cell::before,
.stat-cell::after {
content: "";
position: absolute;
background: color-mix(in srgb, var(--color-text-muted) 20%, transparent);
}
@media (min-width: 1024px) {
/* Four across: a vertical rule in front of every cell but the first. */
.stat-cell:not(:first-child)::before {
content: "";
position: absolute;
left: -0.5rem;
top: 18%;
height: 64%;
left: -0.75rem;
top: 8%;
height: 84%;
width: 1px;
background: color-mix(in srgb, var(--color-text-muted) 20%, transparent);
}
}
@media (max-width: 1023px) {
.stat-cell:nth-child(3)::before {
content: "";
position: absolute;
left: -0.5rem;
top: 10%;
height: 80%;
/* Two across: a vertical rule between the two columns of each row… */
.stat-cell:nth-child(even)::before {
left: -0.75rem;
top: 8%;
height: 84%;
width: 1px;
background: color-mix(in srgb, var(--color-text-muted) 20%, transparent);
}
/* …and a horizontal rule between the two rows. */
.stat-cell:nth-child(n + 3)::after {
left: 6%;
right: 6%;
top: -0.75rem;
height: 1px;
}
}
+25 -4
View File
@@ -13,6 +13,7 @@ import { ThemeVars } from "@/components/theme-vars";
import { enforceSiteAccess } from "@/lib/access-guard";
import { getDefaultFaviconDataUri } from "@/lib/default-favicon";
import { resolveHotelName } from "@/lib/hotel-name";
import { publicClientMessages } from "@/lib/i18n-client-messages";
import { siteSettings } from "@/lib/services/site-settings";
import "./globals.css";
@@ -37,7 +38,10 @@ const pixelify = Pixelify_Sans({
// export const instant = false;
export async function generateMetadata(): Promise<Metadata> {
const appUrl = process.env.APP_URL ?? "http://localhost:3000";
const appUrl = (process.env.APP_URL ?? "http://localhost:3000").replace(
/\/+$/,
"",
);
const hotel = await resolveHotelName();
const customFavicon = await siteSettings.get("cms_favicon");
const faviconUrl = customFavicon ?? getDefaultFaviconDataUri();
@@ -49,15 +53,28 @@ export async function generateMetadata(): Promise<Metadata> {
title: { default: `${hotel} - Hotel`, template: `%s · ${hotel}` },
description: `${hotel} — a Habbo retro hotel. Hang out, collect furni and meet friends.`,
metadataBase: new URL(appUrl),
alternates: { canonical: appUrl },
alternates: {
canonical: appUrl,
types: { "application/rss+xml": `${appUrl}/news/rss.xml` },
},
icons,
openGraph: {
title: `${hotel} - Hotel`,
description: `${hotel} — a Habbo retro hotel. Hang out, collect furni and meet friends.`,
type: "website",
siteName: hotel,
images: [
{
url: `${appUrl}/assets/images/next_header.jpg`,
alt: `${hotel} - Hotel`,
},
],
},
twitter: {
card: "summary_large_image",
title: `${hotel} - Hotel`,
images: [`${appUrl}/assets/images/next_header.jpg`],
},
twitter: { card: "summary_large_image", title: `${hotel} - Hotel` },
};
}
@@ -68,7 +85,11 @@ export default async function RootLayout({
}) {
await enforceSiteAccess();
const locale = await getLocale();
const messages = await getMessages();
// Only the namespaces non-staff routes can reach: the full catalogue is
// ~254 KB and would otherwise be serialised into every public page's RSC
// payload. `/admin`, `/admin-next` and `/mod` install a nested provider
// seeded with the full set (see i18n-client-messages).
const messages = publicClientMessages(await getMessages());
const defaultDark = await siteSettings.getBool("default_dark", false);
const nitroUrl = await siteSettings.get("nitro_client_url", "");
const nonce = (await headers()).get("x-nonce") ?? undefined;
+9 -3
View File
@@ -10,7 +10,8 @@ import {
Users,
} from "lucide-react";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { NextIntlClientProvider } from "next-intl";
import { getLocale, getMessages, getTranslations } from "next-intl/server";
import type { ReactNode } from "react";
import { LanguageSwitcher } from "@/components/language-switcher";
import Link from "@/components/link";
@@ -81,8 +82,13 @@ export default async function ModLayout({ children }: { children: ReactNode }) {
{ href: "/mod/team", label: t("nav.team"), icon: Users, show: showTeam },
].filter((item) => item.show);
// The root layout ships the reduced catalogue so public pages do not carry
// the ~195 KB of staff-tool strings. Nested providers replace the parent
// set rather than merging, so staff route groups re-install the full one.
const [locale, messages] = await Promise.all([getLocale(), getMessages()]);
return (
<>
<NextIntlClientProvider locale={locale} messages={messages}>
{csrfToken ? <meta name="csrf-token" content={csrfToken} /> : null}
<div
data-admin
@@ -140,6 +146,6 @@ export default async function ModLayout({ children }: { children: ReactNode }) {
</header>
<main className="mx-auto max-w-6xl p-4 sm:p-6">{children}</main>
</div>
</>
</NextIntlClientProvider>
);
}
-1
View File
@@ -45,7 +45,6 @@ export function ArticleCard({
<div className="admin-card overflow-hidden flex flex-col">
{confirmDialog}
<div className="relative w-full aspect-[16/9] bg-[var(--admin-canvas)]">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={image || ""}
alt={title}
-1
View File
@@ -296,7 +296,6 @@ export function ArticleForm({
/>
</div>
{image && !imageError ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={image}
alt={t("imagePreview")}
@@ -30,9 +30,9 @@ import {
suggestCatalogLayoutFromBaseItems,
suggestCategoryName,
} from "@/features/catalog/domain/organize";
import { logger } from "@/lib/browser-log";
import { CATALOG_LAYOUTS, type CatalogLayout } from "@/lib/catalog-layouts";
import { getLocalIconUrl } from "@/lib/furni/classname";
import { logger } from "@/lib/logger";
import { cn } from "@/lib/utils";
import { LiveCatalogPreview } from "./live-catalog-preview";
@@ -715,7 +715,6 @@ function FurniThumb({
className="flex items-center justify-center rounded bg-muted/50"
style={{ width: size, height: size }}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={primary}
alt={item.itemName}
@@ -2,7 +2,7 @@
import { ChevronRight, Home } from "lucide-react";
import { Fragment, useEffect } from "react";
import { logger } from "@/lib/logger";
import { logger } from "@/lib/browser-log";
import { useCatalogManager } from "./use-catalog-store";
export function BreadcrumbBar({
@@ -62,7 +62,7 @@ import { requestCatalogNavigation } from "@/features/catalog/client/use-catalog-
import { useLiveCatalog } from "@/features/catalog/client/use-live-catalog";
import { DuplicateCategoryDialog } from "@/features/catalog/components/duplicate-category-dialog";
import type { DuplicateInput } from "@/features/catalog/domain/duplicate";
import { logger } from "@/lib/logger";
import { logger } from "@/lib/browser-log";
import { cn } from "@/lib/utils";
import {
type FlatTreeNode,
@@ -893,7 +893,7 @@ export function SortableTree({
handleToggleEnabled,
handleAddSubpage,
],
); // eslint-disable-line react-hooks/exhaustive-deps
);
return (
<div
@@ -34,7 +34,6 @@ export function FurniIcon({
return (
<div className="w-10 h-10 rounded bg-muted/50 flex items-center justify-center overflow-hidden">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={localUrl}
alt={classname}
+1 -1
View File
@@ -11,8 +11,8 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { logger } from "@/lib/browser-log";
import { catalogueIconUrl } from "@/lib/catalog-assets";
import { logger } from "@/lib/logger";
import { cn } from "@/lib/utils";
interface IconPickerProps {
@@ -13,8 +13,8 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { useServerAction } from "@/hooks/use-server-action";
import { logger } from "@/lib/browser-log";
import { getHabboCdnIconUrl, getLocalIconUrl } from "@/lib/furni/classname";
import { logger } from "@/lib/logger";
interface FurniResult {
id: number;
@@ -231,7 +231,6 @@ function FurniThumb({
return (
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded bg-muted/50">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={primary}
alt={classname}
@@ -192,7 +192,6 @@ export function ShopFurniIcon({
}
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={primary}
alt={classname}
+8 -2
View File
@@ -4,8 +4,8 @@ import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { deleteMedia, uploadMedia } from "@/actions/admin-media";
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
import { logger } from "@/lib/browser-log";
import { formatDate } from "@/lib/format-date";
import { logger } from "@/lib/logger";
import type { MediaFile } from "@/lib/media-files";
import { loadMediaFiles, validImageUrl } from "@/lib/media-files";
@@ -33,6 +33,7 @@ export function AdminMediaGrid() {
const [copied, setCopied] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
const copyTimers = useRef<Set<ReturnType<typeof setTimeout>>>(new Set());
const load = useCallback(async () => {
setLoading(true);
@@ -40,6 +41,12 @@ export function AdminMediaGrid() {
setLoading(false);
}, []);
useEffect(() => {
return () => {
for (const t of copyTimers.current) clearTimeout(t);
copyTimers.current.clear();
};
}, []);
useEffect(() => {
load();
}, [load]);
@@ -226,7 +233,6 @@ export function AdminMediaGrid() {
}}
>
<div className="relative">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={validImageUrl(f.url)}
alt={f.name}
-1
View File
@@ -156,7 +156,6 @@ export function MediaPicker({
e.currentTarget.style.borderColor = "transparent";
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={validImageUrl(f.url)}
alt={f.name}
@@ -52,13 +52,13 @@ import {
SelectValue,
} from "@/components/ui/select";
import { applyCatalogDelta } from "@/features/catalog/client/use-live-catalog";
import { logger } from "@/lib/browser-log";
import {
CATALOG_LAYOUTS,
type CatalogLayout,
isValidCatalogLayout,
LAYOUT_DESCRIPTIONS,
} from "@/lib/catalog-layouts";
import { logger } from "@/lib/logger";
import { cn } from "@/lib/utils";
const MAX_ITEMS_PER_RUN = 500;
@@ -51,7 +51,6 @@ export function FurniThumb({
className="flex items-center justify-center rounded bg-muted/50"
style={{ width: size, height: size }}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={primary}
alt={item.itemName}
@@ -405,7 +405,7 @@ export function ThemeManagerPanel({
width: 14,
height: 14,
borderRadius: 3,
// eslint-disable-next-line security/detect-object-injection -- k from fixed key list
// k from fixed key list
background: palette.light[k],
boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.15)",
marginLeft: -3,
+38
View File
@@ -0,0 +1,38 @@
"use client";
import type { ReactNode } from "react";
interface AuthAlertProps {
children: ReactNode;
/** Tint of the box: red for errors, green for confirmations. */
variant?: "error" | "success";
/** Extra classes, e.g. spacing (`mb-4`) or entrance animation. */
className?: string;
/** Optional id so inputs can point at the message via `aria-describedby`. */
id?: string;
}
/**
* The single status/error banner for the auth forms. Uses `role="alert"` for
* failures (screen readers announce them immediately) and `role="status"` for
* confirmations, so the two are wired correctly instead of every caller
* repeating the markup and risking an unannounced message.
*/
export function AuthAlert({
children,
variant = "error",
className = "",
id,
}: AuthAlertProps) {
return (
<p
id={id}
role={variant === "error" ? "alert" : "status"}
className={`auth-alert animate-fade-in-up m-0 ${
variant === "success" ? "auth-alert--success" : ""
} ${className}`}
>
{children}
</p>
);
}
+112
View File
@@ -0,0 +1,112 @@
"use client";
import type { LucideIcon } from "lucide-react";
import type { CSSProperties, ReactNode, Ref } from "react";
const ICON_CLASS =
"pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2";
export interface AuthFieldProps {
id: string;
name: string;
label: string;
icon: LucideIcon;
type?: string;
/** Controlled value; omit both `value` and `onChange` for an uncontrolled field. */
value?: string;
onChange?: (value: string) => void;
placeholder?: string;
autoComplete?: string;
inputMode?:
| "none"
| "text"
| "tel"
| "url"
| "email"
| "numeric"
| "decimal"
| "search";
required?: boolean;
disabled?: boolean;
minLength?: number;
maxLength?: number;
/** Marks the field with `aria-invalid` and a danger border. */
invalid?: boolean;
/** id of the element describing this field (e.g. an error banner). */
describedBy?: string;
iconColor?: string;
inputStyle?: CSSProperties;
/** Absolutely positioned control inside the input, e.g. `PasswordToggle`. */
trailing?: ReactNode;
/** Content rendered below the field: strength meter, policy hints, mismatch. */
children?: ReactNode;
/** Extra classes on the wrapping `space-y` container. */
className?: string;
inputRef?: Ref<HTMLInputElement>;
}
/**
* One labelled, icon-prefixed auth input. Login and register repeat this markup
* seven times; centralising it keeps the icon, focus and `aria-describedby`
* behaviour identical everywhere.
*/
export function AuthField({
id,
name,
label,
icon: Icon,
type = "text",
value,
onChange,
placeholder,
autoComplete,
inputMode,
required,
disabled,
minLength,
maxLength,
invalid,
describedBy,
iconColor = "var(--color-text-muted)",
inputStyle,
trailing,
children,
className = "",
inputRef,
}: AuthFieldProps) {
return (
<div className={`space-y-1.5 ${className}`}>
<label htmlFor={id} className="auth-label">
{label}
</label>
<div className="relative">
<Icon
aria-hidden="true"
className={ICON_CLASS}
style={{ color: iconColor }}
/>
<input
ref={inputRef}
id={id}
name={name}
type={type}
value={value}
onChange={onChange ? (e) => onChange(e.target.value) : undefined}
placeholder={placeholder}
autoComplete={autoComplete}
inputMode={inputMode}
required={required}
disabled={disabled}
minLength={minLength}
maxLength={maxLength}
aria-invalid={invalid ? true : undefined}
aria-describedby={describedBy}
className={`auth-input has-icon ${trailing ? "has-action" : ""}`}
style={inputStyle}
/>
{trailing}
</div>
{children}
</div>
);
}
Loaded 100 of 176 files, more files were not shown because too many files have changed in this diff. Show more