Compare commits

..
46 Commits
Author SHA1 Message Date
openhands 9560ddc212 Fix taskset missing in container build by installing util-linux and adding fallback
CI / tests-ui (push) Successful in 5m6s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 4m15s
CI / check (push) Successful in 50s
CI / tests-quality (push) Successful in 55s
CI / tests-imaging-browsers (push) Successful in 2m8s
CI / tests-integration (push) Successful in 1m53s
CI / tests-unit (push) Successful in 4m18s
2026-10-23 21:15:29 +02:00
openhands c0a5079ede Restore MySQL/MariaDB Drizzle ORM driver for strict type safety and schema compatibility
CI / check (push) Successful in 35s
CI / tests-imaging-browsers (push) Successful in 2m14s
CI / tests-quality (push) Successful in 1m20s
CI / tests-integration (push) Successful in 1m47s
CI / tests-ui (push) Successful in 5m16s
CI / deploy (push) Failing after 48s
CI / tests-unit (push) Successful in 4m2s
CI / preflight (push) Skipped
2026-10-23 21:03:50 +02:00
openhands a8172ce88e Fix Gitea workflow action reference for upload-artifact
CI / tests-unit (push) Skipped
CI / tests-integration (push) Skipped
CI / tests-imaging-browsers (push) Skipped
CI / preflight (push) Skipped
CI / deploy (push) Skipped
CI / check (push) Failing after 33s
CI / tests-ui (push) Skipped
CI / tests-quality (push) Skipped
2026-10-23 20:53:22 +02:00
openhands 9416317c5b Format files according to Biome standards
CI / tests-unit (push) Skipped
CI / tests-integration (push) Skipped
CI / tests-imaging-browsers (push) Skipped
CI / preflight (push) Skipped
CI / check (push) Failing after 40s
CI / tests-ui (push) Skipped
CI / tests-quality (push) Skipped
CI / deploy (push) Skipped
2026-10-23 20:40:25 +02:00
openhands e17576856f Fix ResultSetHeader casting for postgres result compatibility
CI / check (push) Failing after 25s
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
CI / tests-unit (push) Skipped
2026-10-23 20:16:35 +02:00
openhands 201296e837 Add Production Deployment & Zero-Downtime Releases section to README
CI / tests-imaging-browsers (push) Skipped
CI / preflight (push) Skipped
CI / tests-unit (push) Skipped
CI / tests-ui (push) Skipped
CI / tests-quality (push) Skipped
CI / deploy (push) Skipped
CI / check (push) Failing after 26s
CI / tests-integration (push) Skipped
2026-10-23 20:13:35 +02:00
openhands 2ae8127476 Add Testing and Quality Assurance section to README
CI / check (push) Failing after 30s
CI / tests-unit (push) Skipped
CI / preflight (push) Skipped
CI / tests-integration (push) Skipped
CI / tests-imaging-browsers (push) Skipped
CI / tests-quality (push) Skipped
CI / tests-ui (push) Skipped
CI / deploy (push) Skipped
2026-10-23 20:13:04 +02:00
openhands 95bc7da83e Add professional documentation for multi-engine database architecture in README
CI / tests-integration (push) Skipped
CI / tests-imaging-browsers (push) Skipped
CI / tests-quality (push) Skipped
CI / check (push) Failing after 26s
CI / tests-unit (push) Skipped
CI / tests-ui (push) Skipped
CI / preflight (push) Skipped
CI / deploy (push) Skipped
2026-10-23 20:09:35 +02:00
openhands 9fd675a6a0 Clarify database setup for MySQL and PostgreSQL in README
CI / tests-ui (push) Skipped
CI / tests-imaging-browsers (push) Skipped
CI / tests-quality (push) Skipped
CI / deploy (push) Skipped
CI / check (push) Failing after 26s
CI / tests-unit (push) Skipped
CI / tests-integration (push) Skipped
CI / preflight (push) Skipped
2026-10-23 20:08:44 +02:00
openhands 5080ca586d Document PostgreSQL support in README
CI / preflight (push) Skipped
CI / deploy (push) Skipped
CI / check (push) Failing after 28s
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
2026-10-23 20:08:12 +02:00
openhands 76493a9df8 Refresh git repository status 2026-10-23 20:03:54 +02:00
openhands 9cff32a04b Add PostgreSQL support via pg and node-postgres drizzle adapter 2026-10-23 19:58:06 +02:00
openhands 79b0b8894d ci: enable pnpm via corepack before install 2026-10-23 16:44:09 +02:00
openhands 6b9ed8d15e ci: set up pinned Node.js before toolchain checks 2026-10-23 16:42:36 +02: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 / preflight (push) Skipped
CI / deploy (push) Successful in 21s
CI / tests-quality (push) Successful in 1m20s
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
100 changed files with 7322 additions and 3940 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
+5
View File
@@ -8,6 +8,7 @@
# 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 supports both MySQL/MariaDB ("mysql://...") and PostgreSQL ("postgres://...").
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
@@ -64,6 +65,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).
File diff suppressed because it is too large. Load diff
+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
+1 -1
View File
@@ -27,7 +27,7 @@ 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` |
+29 -7
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 \
apk add --no-cache git bash \
&& npm install -g [email protected]
--mount=type=cache,target=/root/.npm \
apk add --no-cache git bash util-linux \
&& 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
+157 -60
View File
File diff suppressed because it is too large. Load diff
+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;
+46 -17
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
+1 -2
View File
@@ -3,7 +3,7 @@
## 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. |
@@ -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.
+2 -4
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.
@@ -35,7 +34,7 @@ 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 |
@@ -46,4 +45,3 @@ The image build compiles the app, then runs `scripts/performance-report.mjs` in
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`.
+6 -2
View File
@@ -6,12 +6,16 @@ import { defineConfig } from "drizzle-kit";
// This DB is shared with the Arcturus emulator — NEVER run `drizzle-kit migrate`
// or `push` against it. CMS DDL stays in drizzle/migrations/*.sql applied by
// `pnpm db:migrate`. Use `pnpm db:generate` only for draft SQL under drizzle/drafts/.
const dbUrl = process.env.DATABASE_URL ?? "";
const isPostgres =
dbUrl.startsWith("postgres://") || dbUrl.startsWith("postgresql://");
export default defineConfig({
dialect: "mysql",
dialect: isPostgres ? "postgresql" : "mysql",
schema: "./src/db/schema.ts",
out: "./drizzle/drafts",
dbCredentials: {
url: process.env.DATABASE_URL ?? "",
url: dbUrl,
},
strict: true,
verbose: true,
+19 -17
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": {
@@ -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,24 +66,25 @@
"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",
"pg": "^8.23.1",
"pino": "10.4.0",
"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,27 +93,28 @@
"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",
"@tailwindcss/typography": "0.5.20",
"@types/node": "26.6.5",
"@types/pg": "^8.23.1",
"@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) },
},
});
+4788 -2549
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
}
+4 -4
View File
@@ -5,7 +5,7 @@ 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. |
@@ -28,6 +28,7 @@ This directory contains utility scripts for managing the EpicNext-Cms project.
| `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
@@ -69,7 +70,7 @@ 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`) |
@@ -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 -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);
+16 -1
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,11 @@ run_ulimit() {
echo "[mem-cap] ulimit -v $virtual_kb werd geweigerd" >&2
return 1
}
if command -v taskset >/dev/null 2>&1; then
exec taskset -c "$cpu_affinity" "$@"
else
exec "$@"
fi
}
run_refuse() {
@@ -197,7 +208,11 @@ run_refuse() {
run_opted_out() {
echo "[mem-cap] WAARSCHUWING: plafond bewust uitgeschakeld, dit commando kan de machine laten OOM-killed worden: $*" >&2
if command -v taskset >/dev/null 2>&1; then
exec taskset -c "$cpu_affinity" "$@"
else
exec "$@"
fi
}
case "$backend" in
+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",
+5 -1
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 (
+13 -4
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,7 +153,11 @@ export default async function HelpCenterPage() {
<EmptyState icon="🧭">{t("topicsEmpty")}</EmptyState>
) : (
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
{categories.map((c) => (
{categories.map((c) => {
const imageSrc = c.imageUrl
? `${HELP_IMAGE_BASE}/${c.imageUrl}`
: "";
return (
<article key={String(c.id)} className="card hover">
<div
style={{
@@ -158,9 +166,9 @@ export default async function HelpCenterPage() {
alignItems: "flex-start",
}}
>
{c.imageUrl ? (
{imageSrc && publicAssetExists(imageSrc) ? (
<img
src={c.imageUrl}
src={imageSrc}
alt=""
width={48}
height={48}
@@ -208,7 +216,8 @@ export default async function HelpCenterPage() {
</div>
</div>
</article>
))}
);
})}
</div>
)}
</ContentCard>
+18 -2
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. */}
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"
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+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",
},
},
);
}
+307 -418
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+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
@@ -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 -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;
+193 -83
View File
File diff suppressed because it is too large. Load diff
+19 -3
View File
@@ -38,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();
@@ -50,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` },
};
}
+7
View File
@@ -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]);
+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>
);
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+4 -1
View File
@@ -128,6 +128,8 @@ function AvatarGrid({
interface AuthPageFrameProps {
hotelName: string;
logo?: string | null;
/** Live online count shown in the top bar; omit to hide it. */
online?: number;
/** Title of the form card on the right. */
title: string;
/** One-line intro above the form. */
@@ -148,6 +150,7 @@ interface AuthPageFrameProps {
export function AuthPageFrame({
hotelName,
logo,
online,
title,
subtitle,
notice,
@@ -158,7 +161,7 @@ export function AuthPageFrame({
<div className="mx-auto w-full max-w-6xl flex flex-col gap-8 pb-16">
<div className="relative">
<AuthAurora />
<AuthTopBar hotelName={hotelName} logo={logo} />
<AuthTopBar hotelName={hotelName} logo={logo} online={online} />
</div>
<Reveal>
File diff suppressed because it is too large. Load diff
+37 -23
View File
@@ -1,41 +1,38 @@
import Image from "next/image";
import { Clock } from "@/components/clock";
import { LanguageSwitcher } from "@/components/language-switcher";
import { LiveOnlineCount } from "@/components/live-online-count";
import { Reveal } from "@/components/motion-reveal";
import { ThemeSwitcher } from "@/components/theme-switcher";
import { ICON_LIGHTHOUSE } from "@/lib/site-icons";
/**
* Shared frosted-glass top bar for the public auth pages (login, register,
* etc.). Shows the hotel logo + name on the left and language / clock / theme
* controls on the right. Defaults mirror the homepage top bar so auth pages
* share one consistent look.
* Shared frosted-glass top bar for the public pages (index, login, register).
* Shows the hotel logo on the left and the live "online" counter (which carries
* the hotel name), language / clock / theme controls on the right. One
* component so every public page keeps the exact same chrome instead of
* duplicating it.
*/
export function AuthTopBar({
hotelName,
logo,
showHotelName = true,
online,
}: {
hotelName: string;
logo?: string | null;
showHotelName?: boolean;
/** Live online count; omit (or pass null) to hide the indicator. */
online?: number | null;
}) {
return (
<Reveal>
<div
className="relative flex items-center justify-between rounded-full border px-3 py-2.5 sm:px-5"
className="panel relative flex items-center justify-between gap-3 rounded-full 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)",
"linear-gradient(90deg, var(--panel-surface), color-mix(in srgb, var(--color-primary) 7%, var(--panel-surface)))",
}}
>
<div className="flex items-center gap-2 sm:gap-3">
<div className="flex min-w-0 items-center gap-2 sm:gap-3">
{logo ? (
<Image
src={logo}
@@ -67,14 +64,6 @@ export function AuthTopBar({
/>
</div>
)}
{showHotelName ? (
<span
className="hidden text-sm font-extrabold uppercase tracking-wide sm:inline"
style={{ color: "var(--color-text-readable)" }}
>
{hotelName}
</span>
) : null}
</div>
<div
className="flex items-center gap-1 sm:gap-1.5 rounded-full px-1.5 py-1 sm:px-2"
@@ -83,6 +72,31 @@ export function AuthTopBar({
"color-mix(in srgb, var(--color-text-muted) 5%, transparent)",
}}
>
{typeof online === "number" && (
<span
className="flex items-center gap-1.5 px-1"
style={{ color: "var(--color-text-readable)" }}
>
<span className="relative flex h-2 w-2 shrink-0">
<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-xs font-black tabular-nums"
style={{
color: "var(--color-primary-readable, var(--color-primary))",
}}
>
<LiveOnlineCount initial={online} hotelName={hotelName} />
</span>
</span>
)}
<LanguageSwitcher />
<div
className="hidden sm:block w-px h-5"
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -267,7 +267,7 @@ export function LanguageSwitcher({
type="button"
onClick={() => setOpen(!open)}
disabled={pending}
aria-label="Change language"
aria-label={`Change language — ${current.code.toUpperCase()}`}
title="Language"
aria-expanded={open}
aria-haspopup="listbox"
+21
View File
@@ -0,0 +1,21 @@
/**
* Renders schema.org JSON-LD. The payload is data, not executable script, but
* `<` is escaped so an article title can never terminate the tag early.
*/
export function JsonLd({
data,
nonce,
}: {
data: Record<string, unknown> | Record<string, unknown>[];
nonce?: string;
}) {
return (
<script
type="application/ld+json"
nonce={nonce}
dangerouslySetInnerHTML={{
__html: JSON.stringify(data).replace(/</g, "\\u003c"),
}}
/>
);
}
@@ -11,7 +11,7 @@ const compactCallSites = new Map<string, number>([
["src/app/(site)/leaderboard/page.tsx", 1],
["src/app/(site)/me/page.tsx", 1],
["src/app/(site)/messages/page.tsx", 2],
["src/app/(site)/page.tsx", 2],
["src/app/(site)/page.tsx", 1],
["src/app/(site)/radio/leaderboard/page.tsx", 1],
["src/app/(site)/radio/shouts/page.tsx", 1],
["src/app/(site)/rankings/page.tsx", 1],
+36 -4
View File
@@ -1,6 +1,12 @@
"use client";
import type { CSSProperties, MouseEvent, ReactNode } from "react";
import {
type CSSProperties,
type MouseEvent,
type ReactNode,
useEffect,
useRef,
} from "react";
interface SpotlightCardProps {
children: ReactNode;
@@ -12,22 +18,48 @@ interface SpotlightCardProps {
* Wraps a card with a mouse-tracking radial glow. The pointer position is
* written to `--spot-x` / `--spot-y` and consumed by the `.spotlight::before`
* overlay in globals.css.
*
* The pointer move handler is throttled to one update per animation frame:
* a pointermove fires far more often than the browser paints, and every
* extra write re-styles the card (and its ::before gradient) for nothing.
*/
export function SpotlightCard({
children,
className,
style,
}: SpotlightCardProps) {
const frame = useRef(0);
const pending = useRef<{ x: number; y: number } | null>(null);
// Drop the queued frame if the card is removed mid-gesture.
useEffect(
() => () => {
if (frame.current) cancelAnimationFrame(frame.current);
},
[],
);
const handlePointerMove = (event: MouseEvent<HTMLDivElement>) => {
const el = event.currentTarget;
const rect = el.getBoundingClientRect();
el.style.setProperty("--spot-x", `${event.clientX - rect.left}px`);
el.style.setProperty("--spot-y", `${event.clientY - rect.top}px`);
pending.current = {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
};
if (frame.current) return;
frame.current = requestAnimationFrame(() => {
frame.current = 0;
const point = pending.current;
if (!point) return;
el.style.setProperty("--spot-x", `${point.x}px`);
el.style.setProperty("--spot-y", `${point.y}px`);
});
};
return (
<div
className={className}
className={className ? `spotlight ${className}` : "spotlight"}
style={
{ "--spot-x": "50%", "--spot-y": "50%", ...style } as CSSProperties
}
+13 -2
View File
@@ -1,4 +1,5 @@
import type { CSSProperties } from "react";
import { publicAssetExists } from "@/lib/public-asset";
import { siteSettings } from "@/lib/services/site-settings";
import {
BACKGROUND_DEFAULTS,
@@ -240,9 +241,19 @@ export async function ThemeVars({ nonce }: { nonce?: string } = {}) {
? Math.min(parseInt(backgroundOverlayOpacity as string, 10), 90)
: 0;
// The wallpaper is painted as the *first* background layer on top of the
// theme gradient (`--site-bg-gradient`). It used to be the only layer,
// which meant a missing upload wiped the sky entirely and left the site on
// flat white. Layering keeps the gradient as a guaranteed fallback.
//
// A local wallpaper that no longer exists on disk (deleted upload, stale
// theme setting) would also fire a 404 on every page view, so only emit the
// layer when the file is actually there. Remote URLs are trusted as-is.
const backgroundCss =
backgroundImage && isSafeBackgroundUrl(backgroundImage)
? `body.site-bg{background-image:url("${backgroundImage}");background-position:${safeBgPosition};background-size:${safeBgSize};background-repeat:${safeBgRepeat}}`
backgroundImage &&
isSafeBackgroundUrl(backgroundImage) &&
publicAssetExists(backgroundImage)
? `body.site-bg{background-image:url("${backgroundImage}"),var(--site-bg-gradient);background-position:${safeBgPosition},center top;background-size:${safeBgSize},cover;background-repeat:${safeBgRepeat},no-repeat}`
: "";
const backdropStyle: CSSProperties | undefined = safeOverlay
+5
View File
@@ -71,6 +71,11 @@ const schema = z
// bcrypt cost factor used for new password hashes.
BCRYPT_COST: z.coerce.number().int().min(4).max(31).default(12),
// Password-length policy for every new password (register, password reset
// and staff-created accounts). The minimum may be set between 5 and 30
// characters, the maximum between 30 and 128, so min always fits under max.
PASSWORD_MIN_LENGTH: z.coerce.number().int().min(5).max(30).default(12),
PASSWORD_MAX_LENGTH: z.coerce.number().int().min(30).max(128).default(128),
// Filesystem dir the badge uploader writes <code>.gif into (the emulator's
// badge image folder, e.g. .../assets/c_images/album1584). Upload is disabled
// when unset.
+2 -2
View File
@@ -32,12 +32,12 @@ export async function register() {
);
const timer = setInterval(() => {
void drainFurnitureImports();
}, 30000);
}, 60000);
timer.unref();
const { sweepExpiredCloudflareBlocks } = await import("@/lib/cloudflare-api");
const cloudflareSweep = setInterval(() => {
void sweepExpiredCloudflareBlocks();
}, 30000);
}, 60000);
cloudflareSweep.unref();
// A deploy starts with an empty in-process cache, so prime the hot public
+27
View File
@@ -0,0 +1,27 @@
import { env } from "@/env";
/** Hard floor/ceiling so a bad env value can never lock accounts out. */
const ABSOLUTE_MIN_LENGTH = 5;
const ABSOLUTE_MAX_LENGTH = 128;
function toLength(value: unknown, fallback: number): number {
const parsed = typeof value === "number" ? value : Number(value);
return Number.isFinite(parsed) ? Math.trunc(parsed) : fallback;
}
/**
* Effective password-length policy. `PASSWORD_MIN_LENGTH` / `PASSWORD_MAX_LENGTH`
* drive the bounds; the values are clamped to a sane window so a typo in the env
* can neither accept empty passwords nor reject every possible one.
*/
export const passwordPolicy = (() => {
const minLength = Math.min(
ABSOLUTE_MAX_LENGTH,
Math.max(ABSOLUTE_MIN_LENGTH, toLength(env.PASSWORD_MIN_LENGTH, 12)),
);
const maxLength = Math.max(
minLength,
Math.min(ABSOLUTE_MAX_LENGTH, toLength(env.PASSWORD_MAX_LENGTH, 128)),
);
return { minLength, maxLength };
})();
+14 -3
View File
@@ -49,7 +49,7 @@ function readPositiveInt(name: string, fallback: number): number {
// In-process budget. Entries are evicted least-recently-*used* (see getMemory),
// so a hot key only leaves when a hotter one takes its place. The default is
// generous because the layer exists to keep origin calls off the hot path.
const MAX_MEMORY_ENTRIES = readPositiveInt("CACHE_MEMORY_MAX_ENTRIES", 2_000);
const MAX_MEMORY_ENTRIES = readPositiveInt("CACHE_MEMORY_MAX_ENTRIES", 1_000);
// Single-flight: a key being (re)computed is awaited by concurrent callers
// instead of each starting its own `fn()` (cache-stampede protection).
@@ -62,9 +62,20 @@ const generations = new Map<string, number>();
const MAX_GENERATION_ENTRIES = MAX_MEMORY_ENTRIES * 2;
// Keys whose most recent background refresh failed, so a persistently broken
// origin reports once instead of on every read of a hot key.
// origin reports once instead of on every read of a hot key. Bounded so a
// high-cardinality stream of failing keys can never grow this without limit.
const MAX_REPORTED_FAILURES = 500;
const reportedFailures = new Set<string>();
function recordReportedFailure(key: string): void {
if (reportedFailures.has(key)) return;
if (reportedFailures.size >= MAX_REPORTED_FAILURES) {
const oldest = reportedFailures.values().next().value;
if (oldest !== undefined) reportedFailures.delete(oldest);
}
reportedFailures.add(key);
}
/** Drop a key from the in-process cache (used when an upstream value changes). */
export function invalidateMemory(key: string): void {
generations.set(key, (generations.get(key) ?? 0) + 1);
@@ -180,7 +191,7 @@ export async function cached<T>(
// read: a broken origin would otherwise flood the log from a
// single hot key.
if (reportedFailures.has(key)) return;
reportedFailures.add(key);
recordReportedFailure(key);
logger.warn("[cache] background refresh failed", {
key,
error: String(error),
+24
View File
@@ -29,6 +29,7 @@ function simulate(
scenario: string,
previous = "epicnext-cms-app",
both = false,
extraEnv: Record<string, string> = {},
) {
const dir = mkdtempSync(join(tmpdir(), "cms-deploy-test-"));
try {
@@ -58,6 +59,9 @@ function simulate(
TEST_SHA: sha,
SCENARIO: scenario,
DEPLOY_BRANCH: "main",
// Standaard uit: de tests rimen geen cache-map aan op de host.
CMS_BUILD_CACHE_DIR: "",
...extraEnv,
},
});
if (result.error) throw result.error;
@@ -180,6 +184,24 @@ describe("deployment transaction", () => {
expect(result.calls).not.toContain("pnpm db:migrate");
expect(result.calls).not.toContain("docker run");
});
// `docker builder prune` (na elke release) gooit de buildcache weg. Zonder
// duurzame export downloadt de volgende build alle packages opnieuw.
it("bouwt met een duurzame buildcache buiten de werkboom", () => {
const cacheDirectory = mkdtempSync(join(tmpdir(), "cms-deploy-cache-"));
try {
const cache = join(cacheDirectory, "cache");
const result = simulate("success", "epicnext-cms-app", false, {
CMS_BUILD_CACHE_DIR: cache,
});
expect(result.status, result.output).toBe(0);
expect(result.calls).toContain(`--cache-from type=local,src=${cache}`);
expect(result.calls).toContain(
`--cache-to type=local,dest=${cache},mode=max`,
);
} finally {
rmSync(cacheDirectory, { recursive: true, force: true });
}
});
});
describe("legacy and CI container coexistence", () => {
@@ -248,6 +270,8 @@ function simulateBlueGreen(scenario: string, livePort = 3002) {
TEST_SHA: sha,
SCENARIO: scenario,
DEPLOY_BRANCH: "main",
// Standaard uit: de tests rimen geen cache-map aan op de host.
CMS_BUILD_CACHE_DIR: "",
},
});
if (result.error) throw result.error;
+22 -1
View File
@@ -25,7 +25,7 @@ const bash =
.find(existsSync) ?? "bash")
: "bash";
const sha = "d".repeat(40);
function simulate(scenario: string) {
function simulate(scenario: string, extraEnv: Record<string, string> = {}) {
const directory = mkdtempSync(join(tmpdir(), "cms-preflight-test-"));
try {
writeFileSync(
@@ -48,6 +48,9 @@ function simulate(scenario: string) {
SCENARIO: scenario,
CMS_DEPLOY_DIR: "/must-not-read-production",
DATABASE_URL: "must-not-use-production",
// Standaard uit: de tests rimen geen cache-map aan op de host.
CMS_BUILD_CACHE_DIR: "",
...extraEnv,
},
});
if (result.error) throw result.error;
@@ -103,6 +106,24 @@ describe("isolated branch preflight", () => {
expect(result.remaining).toEqual([]);
});
it("hergebruikt een duurzame buildcache buiten de tijdelijke werkmap", () => {
const cacheDirectory = mkdtempSync(join(tmpdir(), "cms-preflight-cache-"));
try {
const cache = join(cacheDirectory, "cache");
const result = simulate("success", { CMS_BUILD_CACHE_DIR: cache });
expect(result.status, result.output).toBe(0);
expect(result.calls).toContain(`--cache-from type=local,src=${cache}`);
expect(result.calls).toContain(
`--cache-to type=local,dest=${cache},mode=max`,
);
// De cache mag nooit in de tijdelijke werkmap van de preflight
// belanden: die wordt na de run geleegd.
expect(result.remaining).toEqual([]);
} finally {
rmSync(cacheDirectory, { recursive: true, force: true });
}
});
it("stops after a failed build and cleans only its own attempted image", () => {
const result = simulate("build-failure");
expect(result.status).not.toBe(0);
+21 -1
View File
@@ -35,9 +35,29 @@ const HOSTILE_PATH_SEGMENTS = new Set([
"server-status",
".env",
".git",
"cmd.exe", // Windows command execution
"shell", // Generic web shell indicator
"jndi", // Log4j exploit attempts
"tomcat", // Tomcat exploitation attempts
"web-inf", // Java web app directory exposure
"passwd", // Linux password file attempts
"etc", // Linux configuration directory traversal
]);
const HOSTILE_PATH_EXTENSIONS = [".php", ".asp", ".aspx", ".jsp", ".cgi"];
const HOSTILE_PATH_EXTENSIONS = [
".php",
".asp",
".aspx",
".jsp",
".cgi",
".env", // Direct .env file access
".bak", // Backup files
".sql", // Database dumps
".zip", // Archives that may contain sensitive data
".rar", // Archives that may contain sensitive data
".7z", // Archives that may contain sensitive data
".txt", // Generic text files that might contain sensitive data
];
/**
* Cheap scanner/exploit triage. These paths are never routes in this app, so a
+20
View File
@@ -80,3 +80,23 @@ it("no longer publishes container images in CI", () => {
expect(workflow).not.toContain("publish-container");
expect(workflow).not.toContain("publish-container.sh");
});
it("keeps een duurzame buildcache buiten de CI-werkboom", () => {
const preflight = readFileSync("scripts/ci-preflight.sh", "utf8");
for (const script of [deploy, preflight]) {
// `docker builder prune` gooit de buildcache weg en de CI-werkboom wordt
// na elke job gewist. Zonder duurzame export downloadt de volgende build
// alle 493 packages opnieuw; gemeten: reused 0 / downloaded 493.
expect(script).toContain('--cache-from "type=local,src=$build_cache_dir"');
expect(script).toContain(
'--cache-to "type=local,dest=$build_cache_dir,mode=max"',
);
expect(script).toContain("CMS_BUILD_CACHE_DIR");
// Zonder schrijftoegang bouwen we gewoon zonder cache; dat mag de
// release niet blokkeren.
expect(script).toContain("build zonder hergebruikte cache");
}
// Eigen map voor de preflight: een branch mag de release-cache nooit voeden.
expect(deploy).toContain("/var/cache/epicnext-cms-buildcache");
expect(preflight).toContain("/var/cache/epicnext-cms-preflight-cache");
});
+84 -1
View File
@@ -1,5 +1,6 @@
// @ts-nocheck
import { readFileSync } from "node:fs";
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const dockerfile = readFileSync("Dockerfile", "utf8");
@@ -47,6 +48,17 @@ it("passes a compiled release to both the application build and final image", ()
expect(compose).toContain("NEXT_DEPLOYMENT_ID: ${CMS_RELEASE:-unknown}");
});
it("keeps the compose CMS opt-in and inside the 4 GB limit", () => {
const compose = readFileSync("docker-compose.yml", "utf8");
// A bare `docker compose up -d` starts only byparr. On a CI-managed host the
// CMS owns port 3002 through ci-deploy.sh; letting compose claim the same
// port again produces an endless EADDRINUSE crash loop.
expect(compose).toContain('profiles: ["cms"]');
expect(compose).not.toContain("cms-green");
expect(compose).toContain("mem_limit: 4g");
expect(compose).toContain("memswap_limit: 5g");
});
it("builds with fixtures and excludes installation secrets from every stage", () => {
const ignored = readFileSync(".dockerignore", "utf8");
expect(ignored).toMatch(/^\.env$/m);
@@ -76,3 +88,74 @@ it("does not prerender installation metadata into a shared image", () => {
);
}
});
it("resolves pnpm from the packageManager pin instead of a hardcoded version", () => {
// Een vastgepind pnpm-pakket in de Dockerfile loopt altijd uit sync met de
// packageManager-pin in package.json (12.10.1 vs 12.11.2).
expect(dockerfile).not.toMatch(/npm install -g pnpm@/);
expect(dockerfile).toContain("corepack enable pnpm");
expect(dockerfile).toContain("corepack install");
// De gedownloade pnpm moet in de image staan, anders kan de runtime
// (`docker run --entrypoint pnpm <migrations-image> db:migrate`) pnpm niet
// gebruiken. Een cache-mount verdwijnt na de build.
const corepackHome = dockerfile.match(/ENV COREPACK_HOME="([^"]+)"/)?.[1];
expect(corepackHome).toBeTruthy();
expect(dockerfile).not.toContain(`--mount=type=cache,target=${corepackHome}`);
});
it("caches the pnpm store on the path pnpm actually uses", () => {
const pnpmHome = dockerfile.match(/ENV PNPM_HOME="([^"]+)"/)?.[1];
expect(pnpmHome).toBeTruthy();
const cacheMounts = [
...dockerfile.matchAll(/--mount=type=cache,target=([^\s\\]+)/g),
].map((match) => match[1]);
const storeMounts = cacheMounts.filter((target) => target.includes("pnpm"));
expect(storeMounts.length).toBeGreaterThan(0);
// Met een ander pad (bv. /root/.local/share/pnpm/store) cache't de mount
// niets en downloadt elke build alle packages opnieuw.
for (const target of storeMounts) {
expect(target).toBe(`${pnpmHome}/store`);
}
});
it("keeps volatile and regenerated files out of the build context", () => {
const ignored = readFileSync(".dockerignore", "utf8");
// `backups/` (dagelijkse rotatie, ~232MB) maakte elke buildcontext groot
// en invalideerde bij elke rotatie de `COPY . .`-laag.
expect(ignored).toMatch(/^backups\/?$/m);
// public/vendor/tinymce wordt tijdens de build opnieuw gegenereerd door
// `pnpm assets:editor` en staat niet in git.
expect(ignored).toMatch(/^public\/vendor\/?$/m);
});
it("keeps de testsuites buiten de buildcontext", () => {
const ignored = readFileSync(".dockerignore", "utf8");
// `next build` typecheckt het hele project. Met e2e/ en integration/ in de
// context faalt de image-build op een typefout in testcode en sleurt hij
// @playwright/test, vitest, msw en testcontainers mee. De CI draait die
// suites op de host (pnpm test:ui / test:integration).
expect(ignored).toMatch(/^e2e\/?$/m);
expect(ignored).toMatch(/^integration\/?$/m);
// Geen applicatiecode mag naar die mappen importeren: dan zou de build in
// de image breken zodra ze uit de context verdwijnen.
const offenders: string[] = [];
for (const entry of readdirSync("src", { recursive: true })) {
if (!/\.tsx?$/.test(entry)) continue;
const source = readFileSync(join("src", entry), "utf8");
if (/from\s+["'][^"']*\/(?:e2e|integration)\//.test(source)) {
offenders.push(entry);
}
}
expect(offenders).toEqual([]);
});
it("keeps a local build cache that survives the daemon cache prune", () => {
const compose = readFileSync("docker-compose.yml", "utf8");
expect(compose).toContain("cache_from:");
expect(compose).toContain("cache_to:");
expect(compose).toContain("type=local");
// De cache-map mag niet in de buildcontext of in git terechtkomen.
expect(readFileSync(".dockerignore", "utf8")).toMatch(/^\.docker-cache\/?$/m);
expect(readFileSync(".gitignore", "utf8")).toMatch(/^\.docker-cache\/?$/m);
});
+72
View File
@@ -0,0 +1,72 @@
import { absoluteAssetUrl, escapeXml } from "@/lib/seo";
export interface NewsRssItem {
slug: string;
title: string;
shortStory: string;
image: string;
createdAt: Date | null;
}
function imageMime(url: string): string {
const extension = url.split("?")[0]?.split(".").pop()?.toLowerCase();
switch (extension) {
case "png":
return "image/png";
case "gif":
return "image/gif";
case "webp":
return "image/webp";
case "avif":
return "image/avif";
default:
return "image/jpeg";
}
}
/** Build a standards-compliant RSS 2.0 feed for the public news archive. */
export function buildNewsRss(
items: NewsRssItem[],
options: { hotelName: string; appUrl: string; now?: Date },
): string {
const base = options.appUrl.replace(/\/+$/, "");
const now = options.now ?? new Date();
const entries = items
.map((item) => {
const url = `${base}/news/${encodeURIComponent(item.slug)}`;
const image = absoluteAssetUrl(item.image, base);
return [
"<item>",
`<title>${escapeXml(item.title)}</title>`,
`<link>${escapeXml(url)}</link>`,
`<guid isPermaLink="true">${escapeXml(url)}</guid>`,
item.createdAt
? `<pubDate>${item.createdAt.toUTCString()}</pubDate>`
: "",
`<description>${escapeXml(item.shortStory)}</description>`,
image
? `<enclosure url="${escapeXml(image)}" type="${imageMime(image)}" length="0" />`
: "",
"</item>",
]
.filter(Boolean)
.join("");
})
.join("");
return [
'<?xml version="1.0" encoding="UTF-8"?>',
'<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">',
"<channel>",
`<title>${escapeXml(`${options.hotelName} — News`)}</title>`,
`<link>${escapeXml(`${base}/news`)}</link>`,
`<description>${escapeXml(`Latest news from ${options.hotelName}`)}</description>`,
"<language>en</language>",
`<atom:link href="${escapeXml(`${base}/news/rss.xml`)}" rel="self" type="application/rss+xml" />`,
`<lastBuildDate>${now.toUTCString()}</lastBuildDate>`,
entries,
"</channel>",
"</rss>",
].join("");
}
+51
View File
@@ -0,0 +1,51 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { publicAssetExists } from "./public-asset";
describe("publicAssetExists", () => {
const originalCwd = process.cwd();
let root: string;
beforeAll(() => {
root = mkdtempSync(join(tmpdir(), "public-asset-"));
mkdirSync(join(root, "public", "assets"), { recursive: true });
writeFileSync(join(root, "public", "assets", "present.png"), "");
process.chdir(root);
});
afterAll(() => {
process.chdir(originalCwd);
rmSync(root, { recursive: true, force: true });
});
it("accepts a local file that exists", () => {
expect(publicAssetExists("/assets/present.png")).toBe(true);
});
it("ignores query strings and hashes", () => {
expect(publicAssetExists("/assets/present.png?v=2#x")).toBe(true);
});
it("rejects a local file that is missing", () => {
expect(publicAssetExists("/assets/gone.png")).toBe(false);
});
it("rejects empty input", () => {
expect(publicAssetExists(null)).toBe(false);
expect(publicAssetExists(undefined)).toBe(false);
expect(publicAssetExists("")).toBe(false);
});
it("trusts remote and protocol-relative URLs", () => {
expect(publicAssetExists("https://cdn.example.com/a.png")).toBe(true);
expect(publicAssetExists("http://cdn.example.com/a.png")).toBe(true);
expect(publicAssetExists("//cdn.example.com/a.png")).toBe(true);
});
it("rejects non-http schemes", () => {
expect(publicAssetExists("javascript:alert(1)")).toBe(false);
expect(publicAssetExists("data:image/png;base64,AAAA")).toBe(false);
});
});
+26
View File
@@ -0,0 +1,26 @@
import { existsSync } from "node:fs";
import { join } from "node:path";
/**
* True when a site-relative URL points at a file that really exists under
* `public/`. CMS records can keep pointing at an upload that has since been
* deleted, and a stale reference renders a broken image (or a 404 background
* layer) on every page view. Server-only: it stats the filesystem.
*
* Remote URLs (`http:`, `https:`, protocol-relative) are trusted as-is because
* we cannot stat them, and data/blob URIs are rejected outright.
*/
export function publicAssetExists(url: string | null | undefined): boolean {
if (!url) return false;
if (url.startsWith("//")) return true;
if (/^[a-z][a-z0-9+.-]*:/i.test(url)) {
return /^https?:/i.test(url);
}
if (!url.startsWith("/")) return true;
try {
const file = decodeURIComponent(url.split(/[?#]/)[0] ?? "");
return existsSync(join(process.cwd(), "public", file));
} catch {
return false;
}
}
+120
View File
File diff suppressed because it is too large. Load diff
+5 -5
View File
@@ -12,7 +12,7 @@ describe("createUserSchema", () => {
const result = createUserSchema.safeParse({
username: "TestUser",
mail: "[email protected]",
password: "secret123",
password: "secret123456",
});
expect(result.success).toBe(true);
if (result.success) {
@@ -26,7 +26,7 @@ describe("createUserSchema", () => {
createUserSchema.safeParse({
username: "ab",
mail: "[email protected]",
password: "secret123",
password: "secret123456",
}).success,
).toBe(false);
});
@@ -36,7 +36,7 @@ describe("createUserSchema", () => {
createUserSchema.safeParse({
username: "a".repeat(21),
mail: "[email protected]",
password: "secret123",
password: "secret123456",
}).success,
).toBe(false);
});
@@ -46,7 +46,7 @@ describe("createUserSchema", () => {
createUserSchema.safeParse({
username: "user@name",
mail: "[email protected]",
password: "secret123",
password: "secret123456",
}).success,
).toBe(false);
});
@@ -56,7 +56,7 @@ describe("createUserSchema", () => {
createUserSchema.safeParse({
username: "TestUser",
mail: "not-an-email",
password: "secret123",
password: "secret123456",
}).success,
).toBe(false);
});
+11 -1
View File
@@ -1,4 +1,5 @@
import { z } from "zod";
import { passwordPolicy } from "@/lib/auth/password-policy";
export const createUserSchema = z.object({
username: z
@@ -10,7 +11,16 @@ export const createUserSchema = z.object({
"Username can only contain letters, numbers, _ and -",
),
mail: z.string().email("Invalid email address"),
password: z.string().min(6, "Password must be at least 6 characters"),
password: z
.string()
.min(
passwordPolicy.minLength,
`Password must be at least ${passwordPolicy.minLength} characters`,
)
.max(
passwordPolicy.maxLength,
`Password must be at most ${passwordPolicy.maxLength} characters`,
),
rank: z.coerce.number().int().min(1).max(7).default(1),
motto: z.string().max(127).default(""),
});
+6 -5
View File
@@ -5353,6 +5353,7 @@
},
"home": {
"welcome": "Welcome to {hotelName}",
"heroEyebrow": "أهلاً بك",
"welcomeSubtitle": "Your retro hotel, now powered by Next.js",
"welcomeBody": "Hang out, collect furni, and meet friends.",
"enterHotel": "Enter {hotelName}",
@@ -6132,7 +6133,7 @@
"confirmPassword": "أعد كلمة المرور",
"usernamePlaceholder": "اسم المستخدم",
"emailPlaceholder": "البريد الإلكتروني",
"passwordPlaceholder": "كلمة المرور (12 حرفًا على الأقل)",
"passwordPlaceholder": "كلمة المرور ({min} حرفًا على الأقل)",
"confirmPasswordPlaceholder": "أعد كلمة المرور",
"selectOutfit": "اختر ملابسك",
"termsAccept": "أنا أبلغ 18 عامًا وأوافق على شروط {hotel}.",
@@ -6153,7 +6154,7 @@
"termsError": "يجب الموافقة على الشروط للتسجيل.",
"usernameError": "اسم المستخدم مطلوب.",
"emailError": "البريد الإلكتروني الصالح مطلوب.",
"passwordError": "يجب ألا تقل كلمة المرور عن 12 حرفًا",
"passwordError": "يجب ألا تقل كلمة المرور عن {min} حرفًا",
"confirmPasswordError": "كلمتا المرور غير متطابقتين.",
"termsRequired": "يجب الموافقة على الشروط.",
"usernameRequired": "اسم المستخدم مطلوب.",
@@ -6167,7 +6168,7 @@
"strengthFair": "مقبول",
"strengthGood": "جيد",
"strengthStrong": "قوي",
"passwordMinLength": "يجب ألا تقل كلمة المرور عن 12 حرفًا",
"passwordMinLength": "يجب ألا تقل كلمة المرور عن {min} حرفًا",
"passwordUpper": "يجب أن تحتوي كلمة المرور على حرف كبير واحد على الأقل",
"passwordLower": "يجب أن تحتوي كلمة المرور على حرف صغير واحد على الأقل",
"passwordDigit": "يجب أن تحتوي كلمة المرور على رقم واحد على الأقل",
@@ -6202,10 +6203,10 @@
"reset": {
"title": "Set a new password",
"subtitle": "Choose a strong password you don't use elsewhere.",
"passwordPlaceholder": "New password (min 12 chars)",
"passwordPlaceholder": "New password (min {min} chars)",
"resetPassword": "Reset password",
"backToLogin": "Back to login",
"passwordMinLength": "يجب ألا تقل كلمة المرور عن 12 حرفًا",
"passwordMinLength": "يجب ألا تقل كلمة المرور عن {min} حرفًا",
"tooManyAttempts": "محاولات كثيرة جدًا — حاول لاحقًا",
"invalidLink": "رابط إعادة التعيين غير صالح أو منتهي الصلاحية. اطلب رابطًا جديدًا.",
"failed": "تعذّرت إعادة تعيين كلمة المرور. حاول مرة أخرى."
+6 -5
View File
@@ -232,6 +232,7 @@
},
"home": {
"welcome": "Добре дошли в {hotelName}",
"heroEyebrow": "Добре дошли",
"welcomeSubtitle": "Вашият ретро хотел, сега захранван от Next.js",
"welcomeBody": "Излизайте, събирайте мебели и се срещайте с приятели.",
"enterHotel": "Въведете {hotelName}",
@@ -885,7 +886,7 @@
"confirmPassword": "Повторете паролата",
"usernamePlaceholder": "Потребителско име",
"emailPlaceholder": "Имейл",
"passwordPlaceholder": "Парола (мин. 12 знака)",
"passwordPlaceholder": "Парола (мин. {min} знака)",
"confirmPasswordPlaceholder": "Повторете паролата",
"selectOutfit": "Изберете вашето облекло",
"termsAccept": "Аз съм на 18+ и приемам условията и правилата на {hotel}.",
@@ -897,7 +898,7 @@
"termsError": "Трябва да приемете условията и правилата, за да се регистрирате.",
"usernameError": "Изисква се потребителско име.",
"emailError": "Изисква се валиден имейл.",
"passwordError": "Паролата трябва да е поне 12 знака",
"passwordError": "Паролата трябва да е поне {min} знака",
"confirmPasswordError": "Паролите не съвпадат.",
"termsRequired": "Трябва да приемете условията и правилата.",
"usernameRequired": "Изисква се потребителско име.",
@@ -920,7 +921,7 @@
"strengthFair": "Средна",
"strengthGood": "Добра",
"strengthStrong": "Силна",
"passwordMinLength": "Паролата трябва да е поне 12 знака",
"passwordMinLength": "Паролата трябва да е поне {min} знака",
"passwordUpper": "Паролата трябва да съдържа поне една главна буква",
"passwordLower": "Паролата трябва да съдържа поне една малка буква",
"passwordDigit": "Паролата трябва да съдържа поне една цифра",
@@ -955,10 +956,10 @@
"reset": {
"title": "Задайте нова парола",
"subtitle": "Изберете силна парола, която не използвате другаде.",
"passwordPlaceholder": "Нова парола (мин. 12 знака)",
"passwordPlaceholder": "Нова парола (мин. {min} знака)",
"resetPassword": "Нулирайте паролата",
"backToLogin": "Назад към входа",
"passwordMinLength": "Паролата трябва да е поне 12 знака",
"passwordMinLength": "Паролата трябва да е поне {min} знака",
"tooManyAttempts": "Твърде много опити — опитайте по-късно",
"invalidLink": "Този линк за нулиране е невалиден или е изтекъл. Поискайте нов.",
"failed": "Нулирането на паролата не бе успешно. Моля, опитайте отново."
+6 -5
View File
@@ -232,6 +232,7 @@
},
"home": {
"welcome": "Vítejte v {hotelName}",
"heroEyebrow": "Vítejte",
"welcomeSubtitle": "Váš retro hotel, nyní poháněný Next.js",
"welcomeBody": "Setkejte se, sbírejte nábytek a setkejte se s přáteli.",
"enterHotel": "Zadejte {hotelName}",
@@ -885,7 +886,7 @@
"confirmPassword": "Opakujte heslo",
"usernamePlaceholder": "Uživatelské jméno",
"emailPlaceholder": "Email",
"passwordPlaceholder": "Heslo (min. 12 znaků)",
"passwordPlaceholder": "Heslo (min. {min} znaků)",
"confirmPasswordPlaceholder": "Opakujte heslo",
"selectOutfit": "Vyberte si oblečení",
"termsAccept": "Je mi 18+ a souhlasím s podmínkami a pravidly {hotel}.",
@@ -897,7 +898,7 @@
"termsError": "Chcete-li se zaregistrovat, musíte přijmout podmínky a pravidla.",
"usernameError": "Uživatelské jméno je povinné.",
"emailError": "Je vyžadován platný e-mail.",
"passwordError": "Heslo musí mít alespoň 12 znaků",
"passwordError": "Heslo musí mít alespoň {min} znaků",
"confirmPasswordError": "Hesla se neshodují.",
"termsRequired": "Musíte přijmout podmínky a pravidla.",
"usernameRequired": "Uživatelské jméno je povinné.",
@@ -920,7 +921,7 @@
"strengthFair": "Ucházející",
"strengthGood": "Dobré",
"strengthStrong": "Silné",
"passwordMinLength": "Heslo musí mít alespoň 12 znaků",
"passwordMinLength": "Heslo musí mít alespoň {min} znaků",
"passwordUpper": "Heslo musí obsahovat alespoň jedno velké písmeno",
"passwordLower": "Heslo musí obsahovat alespoň jedno malé písmeno",
"passwordDigit": "Heslo musí obsahovat alespoň jednu číslici",
@@ -955,10 +956,10 @@
"reset": {
"title": "Nastavte nové heslo",
"subtitle": "Vyberte si silné heslo, které jinde nepoužíváte.",
"passwordPlaceholder": "Nové heslo (min. 12 znaků)",
"passwordPlaceholder": "Nové heslo (min. {min} znaků)",
"resetPassword": "Obnovit heslo",
"backToLogin": "Zpět k přihlášení",
"passwordMinLength": "Heslo musí mít alespoň 12 znaků",
"passwordMinLength": "Heslo musí mít alespoň {min} znaků",
"tooManyAttempts": "Příliš mnoho pokusů — zkuste to později",
"invalidLink": "Tento odkaz pro obnovu je neplatný nebo vypršel. Vyžádejte si nový.",
"failed": "Heslo nebylo možné obnovit. Zkuste to prosím znovu."
+6 -5
View File
@@ -232,6 +232,7 @@
},
"home": {
"welcome": "Velkommen til {hotelName}",
"heroEyebrow": "Velkommen",
"welcomeSubtitle": "Dit retrohotel, nu drevet af Next.js",
"welcomeBody": "Hæng ud, saml møbler og mød venner.",
"enterHotel": "Indtast {hotelName}",
@@ -885,7 +886,7 @@
"confirmPassword": "Gentag adgangskoden",
"usernamePlaceholder": "Brugernavn",
"emailPlaceholder": "E-mail",
"passwordPlaceholder": "Adgangskode (min. 12 tegn)",
"passwordPlaceholder": "Adgangskode (min. {min} tegn)",
"confirmPasswordPlaceholder": "Gentag adgangskoden",
"selectOutfit": "Vælg dit outfit",
"termsAccept": "Jeg er 18+ og accepterer vilkårene og reglerne for {hotel}.",
@@ -897,7 +898,7 @@
"termsError": "Du skal acceptere vilkårene og reglerne for at registrere dig.",
"usernameError": "Brugernavn er påkrævet.",
"emailError": "Gyldig e-mail er påkrævet.",
"passwordError": "Adgangskoden skal være på mindst 12 tegn",
"passwordError": "Adgangskoden skal være på mindst {min} tegn",
"confirmPasswordError": "Adgangskoder stemmer ikke overens.",
"termsRequired": "Du skal acceptere vilkårene og reglerne.",
"usernameRequired": "Brugernavn er påkrævet.",
@@ -920,7 +921,7 @@
"strengthFair": "Middelmådig",
"strengthGood": "God",
"strengthStrong": "Stærk",
"passwordMinLength": "Adgangskoden skal være på mindst 12 tegn",
"passwordMinLength": "Adgangskoden skal være på mindst {min} tegn",
"passwordUpper": "Adgangskoden skal indeholde mindst ét stort bogstav",
"passwordLower": "Adgangskoden skal indeholde mindst ét lille bogstav",
"passwordDigit": "Adgangskoden skal indeholde mindst ét tal",
@@ -955,10 +956,10 @@
"reset": {
"title": "Indstil en ny adgangskode",
"subtitle": "Vælg en stærk adgangskode, du ikke bruger andre steder.",
"passwordPlaceholder": "Ny adgangskode (min. 12 tegn)",
"passwordPlaceholder": "Ny adgangskode (min. {min} tegn)",
"resetPassword": "Nulstil adgangskode",
"backToLogin": "Tilbage til login",
"passwordMinLength": "Adgangskoden skal være på mindst 12 tegn",
"passwordMinLength": "Adgangskoden skal være på mindst {min} tegn",
"tooManyAttempts": "For mange forsøg — prøv igen senere",
"invalidLink": "Dette nulstillingslink er ugyldigt eller udløbet. Anmod om et nyt.",
"failed": "Adgangskoden kunne ikke nulstilles. Prøv igen."
+6 -5
View File
@@ -232,6 +232,7 @@
},
"home": {
"welcome": "Willkommen bei {hotelName}",
"heroEyebrow": "Willkommen",
"welcomeSubtitle": "Dein Retro-Hotel, jetzt mit Next.js",
"welcomeBody": "Abhängen, Möbel sammeln und Freunde treffen.",
"enterHotel": "{hotelName} betreten",
@@ -855,7 +856,7 @@
"confirmPassword": "Passwort wiederholen",
"usernamePlaceholder": "Benutzername",
"emailPlaceholder": "E-Mail",
"passwordPlaceholder": "Passwort (min. 12 Zeichen)",
"passwordPlaceholder": "Passwort (min. {min} Zeichen)",
"confirmPasswordPlaceholder": "Passwort wiederholen",
"selectOutfit": "Wähle dein Outfit",
"termsAccept": "Ich bin 18+ und akzeptiere die {hotel} AGB & Regeln.",
@@ -867,7 +868,7 @@
"termsError": "Du musst die AGB & Regeln akzeptieren, um dich zu registrieren.",
"usernameError": "Benutzername ist erforderlich.",
"emailError": "Eine gültige E-Mail ist erforderlich.",
"passwordError": "Das Passwort muss mindestens 12 Zeichen lang sein",
"passwordError": "Das Passwort muss mindestens {min} Zeichen lang sein",
"confirmPasswordError": "Passwörter stimmen nicht überein.",
"termsRequired": "Du musst die AGB & Regeln akzeptieren.",
"usernameRequired": "Benutzername ist erforderlich.",
@@ -890,7 +891,7 @@
"strengthFair": "Mittel",
"strengthGood": "Gut",
"strengthStrong": "Stark",
"passwordMinLength": "Das Passwort muss mindestens 12 Zeichen lang sein",
"passwordMinLength": "Das Passwort muss mindestens {min} Zeichen lang sein",
"passwordUpper": "Das Passwort muss mindestens einen Großbuchstaben enthalten",
"passwordLower": "Das Passwort muss mindestens einen Kleinbuchstaben enthalten",
"passwordDigit": "Das Passwort muss mindestens eine Ziffer enthalten",
@@ -925,10 +926,10 @@
"reset": {
"title": "Neues Passwort festlegen",
"subtitle": "Wähle ein sicheres Passwort, das du sonst nirgendwo verwendest.",
"passwordPlaceholder": "Neues Passwort (min. 12 Zeichen)",
"passwordPlaceholder": "Neues Passwort (min. {min} Zeichen)",
"resetPassword": "Passwort zurücksetzen",
"backToLogin": "Zurück zur Anmeldung",
"passwordMinLength": "Das Passwort muss mindestens 12 Zeichen lang sein",
"passwordMinLength": "Das Passwort muss mindestens {min} Zeichen lang sein",
"tooManyAttempts": "Zu viele Versuche — bitte später erneut probieren",
"invalidLink": "Dieser Zurücksetzungslink ist ungültig oder abgelaufen. Fordere einen neuen an.",
"failed": "Das Passwort konnte nicht zurückgesetzt werden. Bitte versuche es erneut."
+6 -5
View File
@@ -232,6 +232,7 @@
},
"home": {
"welcome": "Καλώς ήρθατε στο {hotelName}",
"heroEyebrow": "Καλώς ήρθατε",
"welcomeSubtitle": "Το ρετρό ξενοδοχείο σας, τώρα με την υποστήριξη του Next.js",
"welcomeBody": "Κάντε παρέα, μαζέψτε έπιπλα και συναντήστε φίλους.",
"enterHotel": "Εισαγάγετε {hotelName}",
@@ -885,7 +886,7 @@
"confirmPassword": "Επαναλάβετε τον κωδικό πρόσβασης",
"usernamePlaceholder": "Όνομα χρήστη",
"emailPlaceholder": "Email",
"passwordPlaceholder": "Κωδικός πρόσβασης (ελάχ. 12 χαρακτήρες)",
"passwordPlaceholder": "Κωδικός πρόσβασης (ελάχ. {min} χαρακτήρες)",
"confirmPasswordPlaceholder": "Επαναλάβετε τον κωδικό πρόσβασης",
"selectOutfit": "Επιλέξτε το ντύσιμό σας",
"termsAccept": "Είμαι 18+ και αποδέχομαι τους όρους και τους κανόνες του {hotel}.",
@@ -897,7 +898,7 @@
"termsError": "Πρέπει να αποδεχτείτε τους όρους και τους κανόνες για να εγγραφείτε.",
"usernameError": "Απαιτείται όνομα χρήστη.",
"emailError": "Απαιτείται έγκυρο email.",
"passwordError": "Ο κωδικός πρόσβασης πρέπει να έχει τουλάχιστον 12 χαρακτήρες",
"passwordError": "Ο κωδικός πρόσβασης πρέπει να έχει τουλάχιστον {min} χαρακτήρες",
"confirmPasswordError": "Οι κωδικοί πρόσβασης δεν ταιριάζουν.",
"termsRequired": "Πρέπει να αποδεχτείτε τους όρους και τους κανόνες.",
"usernameRequired": "Απαιτείται όνομα χρήστη.",
@@ -920,7 +921,7 @@
"strengthFair": "Μέτριο",
"strengthGood": "Καλό",
"strengthStrong": "Δυνατό",
"passwordMinLength": "Ο κωδικός πρόσβασης πρέπει να έχει τουλάχιστον 12 χαρακτήρες",
"passwordMinLength": "Ο κωδικός πρόσβασης πρέπει να έχει τουλάχιστον {min} χαρακτήρες",
"passwordUpper": "Ο κωδικός πρόσβασης πρέπει να περιέχει τουλάχιστον ένα κεφαλαίο γράμμα",
"passwordLower": "Ο κωδικός πρόσβασης πρέπει να περιέχει τουλάχιστον ένα πεζό γράμμα",
"passwordDigit": "Ο κωδικός πρόσβασης πρέπει να περιέχει τουλάχιστον ένα ψηφίο",
@@ -955,10 +956,10 @@
"reset": {
"title": "Ορίστε νέο κωδικό πρόσβασης",
"subtitle": "Επιλέξτε έναν ισχυρό κωδικό πρόσβασης που δεν χρησιμοποιείτε αλλού.",
"passwordPlaceholder": "Νέος κωδικός πρόσβασης (ελάχ. 12 χαρακτήρες)",
"passwordPlaceholder": "Νέος κωδικός πρόσβασης (ελάχ. {min} χαρακτήρες)",
"resetPassword": "Επαναφορά κωδικού πρόσβασης",
"backToLogin": "Επιστροφή στην είσοδο",
"passwordMinLength": "Ο κωδικός πρόσβασης πρέπει να έχει τουλάχιστον 12 χαρακτήρες",
"passwordMinLength": "Ο κωδικός πρόσβασης πρέπει να έχει τουλάχιστον {min} χαρακτήρες",
"tooManyAttempts": "Πάρα πολλές προσπάθειες — δοκιμάστε αργότερα",
"invalidLink": "Αυτός ο σύνδεσμος επαναφοράς δεν είναι έγκυρος ή έχει λήξει. Ζητήστε νέο.",
"failed": "Δεν ήταν δυνατή η επαναφορά του κωδικού. Δοκιμάστε ξανά."
+6 -5
View File
@@ -278,6 +278,7 @@
},
"home": {
"welcome": "Welcome to {hotelName}",
"heroEyebrow": "Welcome",
"welcomeSubtitle": "Your retro hotel, now powered by Next.js",
"welcomeBody": "Hang out, collect furni, and meet friends.",
"enterHotel": "Enter {hotelName}",
@@ -1058,7 +1059,7 @@
"confirmPassword": "Repeat password",
"usernamePlaceholder": "Username",
"emailPlaceholder": "Email",
"passwordPlaceholder": "Password (min 12 chars)",
"passwordPlaceholder": "Password (min {min} chars)",
"confirmPasswordPlaceholder": "Repeat password",
"selectOutfit": "Select your outfit",
"termsAccept": "I am 18+ and accept the {hotel} terms & rules.",
@@ -1081,7 +1082,7 @@
"termsError": "You must accept the terms & rules to register.",
"usernameError": "Username is required.",
"emailError": "Valid email is required.",
"passwordError": "Password must be at least 12 characters",
"passwordError": "Password must be at least {min} characters",
"confirmPasswordError": "Passwords do not match.",
"termsRequired": "You must accept the terms & rules.",
"usernameRequired": "Username is required.",
@@ -1093,7 +1094,7 @@
"strengthFair": "Fair",
"strengthGood": "Good",
"strengthStrong": "Strong",
"passwordMinLength": "Password must be at least 12 characters",
"passwordMinLength": "Password must be at least {min} characters",
"passwordUpper": "Password must contain at least one uppercase letter",
"passwordLower": "Password must contain at least one lowercase letter",
"passwordDigit": "Password must contain at least one digit",
@@ -1128,10 +1129,10 @@
"reset": {
"title": "Set a new password",
"subtitle": "Choose a strong password you don't use elsewhere.",
"passwordPlaceholder": "New password (min 12 chars)",
"passwordPlaceholder": "New password (min {min} chars)",
"resetPassword": "Reset password",
"backToLogin": "Back to login",
"passwordMinLength": "Password must be at least 12 characters",
"passwordMinLength": "Password must be at least {min} characters",
"tooManyAttempts": "Too many attempts — try again later",
"invalidLink": "This reset link is invalid or has expired. Request a new one.",
"failed": "The password could not be reset. Please try again."
+6 -5
View File
@@ -232,6 +232,7 @@
},
"home": {
"welcome": "Bienvenido a {hotelName}",
"heroEyebrow": "Bienvenido",
"welcomeSubtitle": "Tu hotel retro, ahora impulsado por Next.js",
"welcomeBody": "Pasa el rato, colecciona furni y conoce amigos.",
"enterHotel": "Entrar a {hotelName}",
@@ -855,7 +856,7 @@
"confirmPassword": "Repetir contraseña",
"usernamePlaceholder": "Usuario",
"emailPlaceholder": "Correo electrónico",
"passwordPlaceholder": "Contraseña (mín. 12 caracteres)",
"passwordPlaceholder": "Contraseña (mín. {min} caracteres)",
"confirmPasswordPlaceholder": "Repetir contraseña",
"selectOutfit": "Selecciona tu atuendo",
"termsAccept": "Soy mayor de 18 años y acepto los términos y reglas de {hotel}.",
@@ -867,7 +868,7 @@
"termsError": "Debes aceptar los términos y reglas para registrarte.",
"usernameError": "El usuario es obligatorio.",
"emailError": "Se requiere un correo electrónico válido.",
"passwordError": "La contraseña debe tener al menos 12 caracteres",
"passwordError": "La contraseña debe tener al menos {min} caracteres",
"confirmPasswordError": "Las contraseñas no coinciden.",
"termsRequired": "Debes aceptar los términos y reglas.",
"usernameRequired": "El usuario es obligatorio.",
@@ -890,7 +891,7 @@
"strengthFair": "Aceptable",
"strengthGood": "Buena",
"strengthStrong": "Fuerte",
"passwordMinLength": "La contraseña debe tener al menos 12 caracteres",
"passwordMinLength": "La contraseña debe tener al menos {min} caracteres",
"passwordUpper": "La contraseña debe contener al menos una mayúscula",
"passwordLower": "La contraseña debe contener al menos una minúscula",
"passwordDigit": "La contraseña debe contener al menos un dígito",
@@ -925,10 +926,10 @@
"reset": {
"title": "Establecer una nueva contraseña",
"subtitle": "Elige una contraseña segura que no uses en otros sitios.",
"passwordPlaceholder": "Nueva contraseña (mín. 12 caracteres)",
"passwordPlaceholder": "Nueva contraseña (mín. {min} caracteres)",
"resetPassword": "Restablecer contraseña",
"backToLogin": "Volver al inicio de sesión",
"passwordMinLength": "La contraseña debe tener al menos 12 caracteres",
"passwordMinLength": "La contraseña debe tener al menos {min} caracteres",
"tooManyAttempts": "Demasiados intentos — inténtalo más tarde",
"invalidLink": "Este enlace de restablecimiento no es válido o ha caducado. Solicita uno nuevo.",
"failed": "No se pudo restablecer la contraseña. Vuelve a intentarlo."
+6 -5
View File
@@ -5353,6 +5353,7 @@
},
"home": {
"welcome": "Welcome to {hotelName}",
"heroEyebrow": "Tervetuloa",
"welcomeSubtitle": "Your retro hotel, now powered by Next.js",
"welcomeBody": "Hang out, collect furni, and meet friends.",
"enterHotel": "Enter {hotelName}",
@@ -6132,7 +6133,7 @@
"confirmPassword": "Toista salasana",
"usernamePlaceholder": "Käyttäjätunnus",
"emailPlaceholder": "Sähköposti",
"passwordPlaceholder": "Salasana (vähintään 12 merkkiä)",
"passwordPlaceholder": "Salasana (vähintään {min} merkkiä)",
"confirmPasswordPlaceholder": "Toista salasana",
"selectOutfit": "Valitse asustesi",
"termsAccept": "Olen 18-vuotias ja hyväksyn {hotel} säännöt.",
@@ -6153,7 +6154,7 @@
"termsError": "Sinun on hyväksyttävä säännöt rekisteröityäksesi.",
"usernameError": "Käyttäjätunnus on pakollinen.",
"emailError": "Kelvollinen sähköpostiosoite on pakollinen.",
"passwordError": "Salasanassa on oltava vähintään 12 merkkiä",
"passwordError": "Salasanassa on oltava vähintään {min} merkkiä",
"confirmPasswordError": "Salasanat eivät täsmää.",
"termsRequired": "Sinun on hyväksyttävä säännöt.",
"usernameRequired": "Käyttäjätunnus on pakollinen.",
@@ -6167,7 +6168,7 @@
"strengthFair": "Tyydyttävä",
"strengthGood": "Hyvä",
"strengthStrong": "Vahva",
"passwordMinLength": "Salasanassa on oltava vähintään 12 merkkiä",
"passwordMinLength": "Salasanassa on oltava vähintään {min} merkkiä",
"passwordUpper": "Salasanassa on oltava vähintään yksi iso kirjain",
"passwordLower": "Salasanassa on oltava vähintään yksi pieni kirjain",
"passwordDigit": "Salasanassa on oltava vähintään yksi numero",
@@ -6202,10 +6203,10 @@
"reset": {
"title": "Set a new password",
"subtitle": "Choose a strong password you don't use elsewhere.",
"passwordPlaceholder": "New password (min 12 chars)",
"passwordPlaceholder": "New password (min {min} chars)",
"resetPassword": "Reset password",
"backToLogin": "Back to login",
"passwordMinLength": "Salasanassa on oltava vähintään 12 merkkiä",
"passwordMinLength": "Salasanassa on oltava vähintään {min} merkkiä",
"tooManyAttempts": "Liikaa yrityksiä — yritä myöhemmin uudelleen",
"invalidLink": "Tämä palautuslinkki on virheellinen tai vanhentunut. Pyydä uusi.",
"failed": "Salasanaa ei voitu vaihtaa. Yritä uudelleen."
+6 -5
View File
@@ -232,6 +232,7 @@
},
"home": {
"welcome": "Bienvenue sur {hotelName}",
"heroEyebrow": "Bienvenue",
"welcomeSubtitle": "Votre hôtel rétro, désormais propulsé par Next.js",
"welcomeBody": "Traînez, collectionnez des meubles et rencontrez des amis.",
"enterHotel": "Entrer dans {hotelName}",
@@ -855,7 +856,7 @@
"confirmPassword": "Répéter le mot de passe",
"usernamePlaceholder": "Nom d'utilisateur",
"emailPlaceholder": "E-mail",
"passwordPlaceholder": "Mot de passe (min 12 car.)",
"passwordPlaceholder": "Mot de passe (min {min} car.)",
"confirmPasswordPlaceholder": "Répéter le mot de passe",
"selectOutfit": "Sélectionnez votre tenue",
"termsAccept": "J'ai 18 ans ou plus et j'accepte les conditions et règles de {hotel}.",
@@ -867,7 +868,7 @@
"termsError": "Vous devez accepter les conditions et règles pour vous inscrire.",
"usernameError": "Le nom d'utilisateur est requis.",
"emailError": "Un e-mail valide est requis.",
"passwordError": "Le mot de passe doit contenir au moins 12 caractères",
"passwordError": "Le mot de passe doit contenir au moins {min} caractères",
"confirmPasswordError": "Les mots de passe ne correspondent pas.",
"termsRequired": "Vous devez accepter les conditions et règles.",
"usernameRequired": "Le nom d'utilisateur est requis.",
@@ -890,7 +891,7 @@
"strengthFair": "Moyen",
"strengthGood": "Bon",
"strengthStrong": "Fort",
"passwordMinLength": "Le mot de passe doit contenir au moins 12 caractères",
"passwordMinLength": "Le mot de passe doit contenir au moins {min} caractères",
"passwordUpper": "Le mot de passe doit contenir au moins une majuscule",
"passwordLower": "Le mot de passe doit contenir au moins une minuscule",
"passwordDigit": "Le mot de passe doit contenir au moins un chiffre",
@@ -925,10 +926,10 @@
"reset": {
"title": "Définir un nouveau mot de passe",
"subtitle": "Choisissez un mot de passe fort que vous n'utilisez pas ailleurs.",
"passwordPlaceholder": "Nouveau mot de passe (min 12 car.)",
"passwordPlaceholder": "Nouveau mot de passe (min {min} car.)",
"resetPassword": "Réinitialiser le mot de passe",
"backToLogin": "Retour à la connexion",
"passwordMinLength": "Le mot de passe doit contenir au moins 12 caractères",
"passwordMinLength": "Le mot de passe doit contenir au moins {min} caractères",
"tooManyAttempts": "Trop de tentatives — réessayez plus tard",
"invalidLink": "Ce lien de réinitialisation est invalide ou expiré. Demandez-en un nouveau.",
"failed": "Le mot de passe n'a pas pu être réinitialisé. Veuillez réessayer."
+6 -5
View File
@@ -232,6 +232,7 @@
},
"home": {
"welcome": "Dobro došli u {hotelName}",
"heroEyebrow": "Dobrodošli",
"welcomeSubtitle": "Vaš retro hotel, sada pokreće Next.js",
"welcomeBody": "Družite se, skupljajte namještaj i upoznajte prijatelje.",
"enterHotel": "Unesite {hotelName}",
@@ -885,7 +886,7 @@
"confirmPassword": "Ponovi lozinku",
"usernamePlaceholder": "Korisničko ime",
"emailPlaceholder": "E-mail",
"passwordPlaceholder": "Lozinka (najmanje 12 znakova)",
"passwordPlaceholder": "Lozinka (najmanje {min} znakova)",
"confirmPasswordPlaceholder": "Ponovi lozinku",
"selectOutfit": "Odaberite svoju odjeću",
"termsAccept": "Imam 18+ godina i prihvaćam odredbe i pravila hotela {hotel}.",
@@ -897,7 +898,7 @@
"termsError": "Za registraciju morate prihvatiti uvjete i pravila.",
"usernameError": "Korisničko ime je potrebno.",
"emailError": "Potrebna je valjana adresa e-pošte.",
"passwordError": "Lozinka mora imati najmanje 12 znakova.",
"passwordError": "Lozinka mora imati najmanje {min} znakova.",
"confirmPasswordError": "Lozinke se ne podudaraju.",
"termsRequired": "Morate prihvatiti uvjete i pravila.",
"usernameRequired": "Korisničko ime je potrebno.",
@@ -920,7 +921,7 @@
"strengthFair": "Osrednja",
"strengthGood": "Dobra",
"strengthStrong": "Jaka",
"passwordMinLength": "Lozinka mora imati najmanje 12 znakova",
"passwordMinLength": "Lozinka mora imati najmanje {min} znakova",
"passwordUpper": "Lozinka mora sadržavati najmanje jedno veliko slovo",
"passwordLower": "Lozinka mora sadržavati najmanje jedno malo slovo",
"passwordDigit": "Lozinka mora sadržavati najmanje jednu znamenku",
@@ -955,10 +956,10 @@
"reset": {
"title": "Postavite novu lozinku",
"subtitle": "Odaberite jaku lozinku koju ne koristite drugdje.",
"passwordPlaceholder": "Nova lozinka (najmanje 12 znakova)",
"passwordPlaceholder": "Nova lozinka (najmanje {min} znakova)",
"resetPassword": "Resetiraj lozinku",
"backToLogin": "Natrag na prijavu",
"passwordMinLength": "Lozinka mora imati najmanje 12 znakova",
"passwordMinLength": "Lozinka mora imati najmanje {min} znakova",
"tooManyAttempts": "Previše pokušaja — pokušajte kasnije",
"invalidLink": "Ova poveznica za ponovno postavljanje nije valjana ili je istekla. Zatražite novu.",
"failed": "Lozinku nije bilo moguće ponovno postaviti. Pokušajte ponovo."
+6 -5
View File
@@ -232,6 +232,7 @@
},
"home": {
"welcome": "Üdvözöljük itt: {hotelName}",
"heroEyebrow": "Üdvözöljük",
"welcomeSubtitle": "Az Ön retro szállodája, amelyet most a Next.js üzemeltet",
"welcomeBody": "Tárcsázz, gyűjts bútorokat és találkozz barátokkal.",
"enterHotel": "Írja be a következőt: {hotelName}",
@@ -885,7 +886,7 @@
"confirmPassword": "Ismételje meg a jelszót",
"usernamePlaceholder": "Felhasználónév",
"emailPlaceholder": "E-mail",
"passwordPlaceholder": "Jelszó (minimum 12 karakter)",
"passwordPlaceholder": "Jelszó (minimum {min} karakter)",
"confirmPasswordPlaceholder": "Ismételje meg a jelszót",
"selectOutfit": "Válassza ki az öltözékét",
"termsAccept": "18 évesnél idősebb vagyok, és elfogadom a {hotel} feltételeit és szabályait.",
@@ -897,7 +898,7 @@
"termsError": "A regisztrációhoz el kell fogadnia a feltételeket és szabályokat.",
"usernameError": "Felhasználónév megadása kötelező.",
"emailError": "Érvényes e-mail-cím szükséges.",
"passwordError": "A jelszónak legalább 12 karakter hosszúnak kell lennie",
"passwordError": "A jelszónak legalább {min} karakter hosszúnak kell lennie",
"confirmPasswordError": "A jelszavak nem egyeznek.",
"termsRequired": "El kell fogadnia a feltételeket és szabályokat.",
"usernameRequired": "Felhasználónév megadása kötelező.",
@@ -920,7 +921,7 @@
"strengthFair": "Közepes",
"strengthGood": "Jó",
"strengthStrong": "Erős",
"passwordMinLength": "A jelszónak legalább 12 karakter hosszúnak kell lennie",
"passwordMinLength": "A jelszónak legalább {min} karakter hosszúnak kell lennie",
"passwordUpper": "A jelszónak legalább egy nagybetűt kell tartalmaznia",
"passwordLower": "A jelszónak legalább egy kisbetűt kell tartalmaznia",
"passwordDigit": "A jelszónak legalább egy számjegyet kell tartalmaznia",
@@ -955,10 +956,10 @@
"reset": {
"title": "Állítson be új jelszót",
"subtitle": "Válasszon erős jelszót, amelyet máshol nem használ.",
"passwordPlaceholder": "Új jelszó (minimum 12 karakter)",
"passwordPlaceholder": "Új jelszó (minimum {min} karakter)",
"resetPassword": "Jelszó visszaállítása",
"backToLogin": "Vissza a bejelentkezéshez",
"passwordMinLength": "A jelszónak legalább 12 karakter hosszúnak kell lennie",
"passwordMinLength": "A jelszónak legalább {min} karakter hosszúnak kell lennie",
"tooManyAttempts": "Túl sok próbálkozás — próbáld később",
"invalidLink": "Ez a visszaállítási link érvénytelen vagy lejárt. Kérjen újat.",
"failed": "A jelszó visszaállítása nem sikerült. Kérjük, próbálja újra."
+6 -5
View File
@@ -274,6 +274,7 @@
},
"home": {
"welcome": "Benvenuto su {hotelName}",
"heroEyebrow": "Benvenuto",
"welcomeSubtitle": "Il tuo hotel retrò, ora con tecnologia Next.js",
"welcomeBody": "Rilassati, colleziona furni e fai nuove amicizie.",
"enterHotel": "Entra in {hotelName}",
@@ -1027,7 +1028,7 @@
"confirmPassword": "Ripeti la password",
"usernamePlaceholder": "Nome utente",
"emailPlaceholder": "Email",
"passwordPlaceholder": "Password (min 12 caratteri)",
"passwordPlaceholder": "Password (min {min} caratteri)",
"confirmPasswordPlaceholder": "Ripeti la password",
"selectOutfit": "Scegli il tuo outfit",
"termsAccept": "Ho 18+ anni e accetto i termini e regole di {hotel}.",
@@ -1039,7 +1040,7 @@
"termsError": "Devi accettare i termini e regole per registrarti.",
"usernameError": "Il nome utente è obbligatorio.",
"emailError": "Email valida obbligatoria.",
"passwordError": "La password deve contenere almeno 12 caratteri",
"passwordError": "La password deve contenere almeno {min} caratteri",
"confirmPasswordError": "Le password non coincidono.",
"termsRequired": "Devi accettare i termini e le regole.",
"usernameRequired": "Il nome utente è obbligatorio.",
@@ -1062,7 +1063,7 @@
"strengthFair": "Discreta",
"strengthGood": "Buona",
"strengthStrong": "Forte",
"passwordMinLength": "La password deve contenere almeno 12 caratteri",
"passwordMinLength": "La password deve contenere almeno {min} caratteri",
"passwordUpper": "La password deve contenere almeno una lettera maiuscola",
"passwordLower": "La password deve contenere almeno una lettera minuscola",
"passwordDigit": "La password deve contenere almeno una cifra",
@@ -1097,10 +1098,10 @@
"reset": {
"title": "Imposta una nuova password",
"subtitle": "Scegli una password robusta che non usi altrove.",
"passwordPlaceholder": "Nuova password (min 12 caratteri)",
"passwordPlaceholder": "Nuova password (min {min} caratteri)",
"resetPassword": "Reimposta password",
"backToLogin": "Torna all'accesso",
"passwordMinLength": "La password deve contenere almeno 12 caratteri",
"passwordMinLength": "La password deve contenere almeno {min} caratteri",
"tooManyAttempts": "Troppi tentativi — riprova più tardi",
"invalidLink": "Questo link di reimpostazione non è valido o è scaduto. Richiedine uno nuovo.",
"failed": "Non è stato possibile reimpostare la password. Riprova."
+6 -5
View File
@@ -5353,6 +5353,7 @@
},
"home": {
"welcome": "Welcome to {hotelName}",
"heroEyebrow": "ようこそ",
"welcomeSubtitle": "Your retro hotel, now powered by Next.js",
"welcomeBody": "Hang out, collect furni, and meet friends.",
"enterHotel": "Enter {hotelName}",
@@ -6132,7 +6133,7 @@
"confirmPassword": "パスワードの確認",
"usernamePlaceholder": "ユーザー名",
"emailPlaceholder": "メールアドレス",
"passwordPlaceholder": "パスワード(12文字以上)",
"passwordPlaceholder": "パスワード({min}文字以上)",
"confirmPasswordPlaceholder": "パスワードの確認",
"selectOutfit": "アバターを選んでスタイルを設定",
"termsAccept": "18歳以上であり、{hotel} の規約に同意します。",
@@ -6153,7 +6154,7 @@
"termsError": "登録するには規約に同意する必要があります。",
"usernameError": "ユーザー名は必須です。",
"emailError": "有効なメールアドレスが必要です。",
"passwordError": "パスワードは12文字以上にしてください",
"passwordError": "パスワードは{min}文字以上にしてください",
"confirmPasswordError": "パスワードが一致しません。",
"termsRequired": "規約に同意する必要があります。",
"usernameRequired": "ユーザー名は必須です。",
@@ -6167,7 +6168,7 @@
"strengthFair": "普通",
"strengthGood": "良",
"strengthStrong": "強",
"passwordMinLength": "パスワードは12文字以上にしてください",
"passwordMinLength": "パスワードは{min}文字以上にしてください",
"passwordUpper": "パスワードには大文字を1文字以上含めてください",
"passwordLower": "パスワードには小文字を1文字以上含めてください",
"passwordDigit": "パスワードには数字を1文字以上含めてください",
@@ -6202,10 +6203,10 @@
"reset": {
"title": "Set a new password",
"subtitle": "Choose a strong password you don't use elsewhere.",
"passwordPlaceholder": "New password (min 12 chars)",
"passwordPlaceholder": "New password (min {min} chars)",
"resetPassword": "Reset password",
"backToLogin": "Back to login",
"passwordMinLength": "パスワードは12文字以上にしてください",
"passwordMinLength": "パスワードは{min}文字以上にしてください",
"tooManyAttempts": "試行回数が多すぎます — 後で再度お試しください",
"invalidLink": "このリセットリンクは無効か期限切れです。新しいリンクをリクエストしてください。",
"failed": "パスワードをリセットできませんでした。もう一度お試しください。"
+6 -5
View File
@@ -274,6 +274,7 @@
},
"home": {
"welcome": "Welkom bij {hotelName}",
"heroEyebrow": "Welkom",
"welcomeSubtitle": "Je retro hotel, nu mogelijk gemaakt door Next.js",
"welcomeBody": "Rondhangen, meubels verzamelen en vrienden ontmoeten.",
"enterHotel": "{hotelName} betreden",
@@ -1058,7 +1059,7 @@
"confirmPassword": "Wachtwoord herhalen",
"usernamePlaceholder": "Gebruikersnaam",
"emailPlaceholder": "E-mail",
"passwordPlaceholder": "Wachtwoord (min 12 tekens)",
"passwordPlaceholder": "Wachtwoord (min {min} tekens)",
"confirmPasswordPlaceholder": "Wachtwoord herhalen",
"selectOutfit": "Kies je outfit",
"termsAccept": "Ik ben 18+ en accepteer de {hotel} voorwaarden & regels.",
@@ -1074,7 +1075,7 @@
"termsError": "Je moet de voorwaarden & regels accepteren om te registreren.",
"usernameError": "Gebruikersnaam is verplicht.",
"emailError": "Een geldig e-mailadres is verplicht.",
"passwordError": "Wachtwoord moet minimaal 12 tekens lang zijn",
"passwordError": "Wachtwoord moet minimaal {min} tekens lang zijn",
"confirmPasswordError": "Wachtwoorden komen niet overeen.",
"termsRequired": "Je moet de voorwaarden accepteren.",
"ageVerified": "Ik ben 18+ en ga akkoord met de leeftijdsvereiste.",
@@ -1093,7 +1094,7 @@
"strengthFair": "Redelijk",
"strengthGood": "Goed",
"strengthStrong": "Sterk",
"passwordMinLength": "Wachtwoord moet minimaal 12 tekens lang zijn",
"passwordMinLength": "Wachtwoord moet minimaal {min} tekens lang zijn",
"passwordUpper": "Wachtwoord moet minstens één hoofdletter bevatten",
"passwordLower": "Wachtwoord moet minstens één kleine letter bevatten",
"passwordDigit": "Wachtwoord moet minstens één cijfer bevatten",
@@ -1128,10 +1129,10 @@
"reset": {
"title": "Nieuw wachtwoord instellen",
"subtitle": "Kies een sterk wachtwoord dat je nergens anders gebruikt.",
"passwordPlaceholder": "Nieuw wachtwoord (min 12 tekens)",
"passwordPlaceholder": "Nieuw wachtwoord (min {min} tekens)",
"resetPassword": "Wachtwoord resetten",
"backToLogin": "Terug naar inloggen",
"passwordMinLength": "Wachtwoord moet minimaal 12 tekens lang zijn",
"passwordMinLength": "Wachtwoord moet minimaal {min} tekens lang zijn",
"tooManyAttempts": "Te veel pogingen — probeer het later opnieuw",
"invalidLink": "Deze reset-link is ongeldig of verlopen. Vraag een nieuwe aan.",
"failed": "Het wachtwoord kon niet worden gereset. Probeer het opnieuw."
+6 -5
View File
@@ -232,6 +232,7 @@
},
"home": {
"welcome": "Velkommen til {hotelName}",
"heroEyebrow": "Velkommen",
"welcomeSubtitle": "Ditt retrohotell, nå drevet av Next.js",
"welcomeBody": "Heng med, samle møbler og møt venner.",
"enterHotel": "Skriv inn {hotelName}",
@@ -885,7 +886,7 @@
"confirmPassword": "Gjenta passord",
"usernamePlaceholder": "Brukernavn",
"emailPlaceholder": "E-post",
"passwordPlaceholder": "Passord (minst 12 tegn)",
"passwordPlaceholder": "Passord (minst {min} tegn)",
"confirmPasswordPlaceholder": "Gjenta passord",
"selectOutfit": "Velg antrekket ditt",
"termsAccept": "Jeg er 18+ og godtar vilkårene og reglene for {hotel}.",
@@ -897,7 +898,7 @@
"termsError": "Du må godta vilkårene og reglene for å registrere deg.",
"usernameError": "Brukernavn er påkrevd.",
"emailError": "Gyldig e-post er nødvendig.",
"passwordError": "Passordet må være på minst 12 tegn.",
"passwordError": "Passordet må være på minst {min} tegn.",
"confirmPasswordError": "Passord stemmer ikke.",
"termsRequired": "Du må godta vilkårene og reglene.",
"usernameRequired": "Brukernavn er påkrevd.",
@@ -920,7 +921,7 @@
"strengthFair": "Middels",
"strengthGood": "God",
"strengthStrong": "Sterk",
"passwordMinLength": "Passordet må være minst 12 tegn",
"passwordMinLength": "Passordet må være minst {min} tegn",
"passwordUpper": "Passordet må inneholde minst én stor bokstav",
"passwordLower": "Passordet må inneholde minst én liten bokstav",
"passwordDigit": "Passordet må inneholde minst ett siffer",
@@ -955,10 +956,10 @@
"reset": {
"title": "Angi et nytt passord",
"subtitle": "Velg et sterkt passord du ikke bruker andre steder.",
"passwordPlaceholder": "Nytt passord (minst 12 tegn)",
"passwordPlaceholder": "Nytt passord (minst {min} tegn)",
"resetPassword": "Tilbakestill passord",
"backToLogin": "Tilbake til innlogging",
"passwordMinLength": "Passordet må være minst 12 tegn",
"passwordMinLength": "Passordet må være minst {min} tegn",
"tooManyAttempts": "For mange forsøk — prøv igjen senere",
"invalidLink": "Denne tilbakestillingslenken er ugyldig eller utløpt. Be om en ny.",
"failed": "Passordet kunne ikke tilbakestilles. Prøv igjen."
+6 -5
View File
@@ -232,6 +232,7 @@
},
"home": {
"welcome": "Witamy w {hotelName}",
"heroEyebrow": "Witamy",
"welcomeSubtitle": "Twój hotel w stylu retro, teraz obsługiwany przez Next.js",
"welcomeBody": "Spędzaj czas, zbieraj meble i spotykaj się z przyjaciółmi.",
"enterHotel": "Wpisz {hotelName}",
@@ -885,7 +886,7 @@
"confirmPassword": "Powtórz hasło",
"usernamePlaceholder": "Nazwa użytkownika",
"emailPlaceholder": "E-mail",
"passwordPlaceholder": "Hasło (min. 12 znaków)",
"passwordPlaceholder": "Hasło (min. {min} znaków)",
"confirmPasswordPlaceholder": "Powtórz hasło",
"selectOutfit": "Wybierz swój strój",
"termsAccept": "Mam ukończone 18 lat i akceptuję warunki i zasady hotelu {hotel}.",
@@ -897,7 +898,7 @@
"termsError": "Aby się zarejestrować, musisz zaakceptować regulamin.",
"usernameError": "Nazwa użytkownika jest wymagana.",
"emailError": "Wymagany jest prawidłowy adres e-mail.",
"passwordError": "Hasło musi mieć co najmniej 12 znaków",
"passwordError": "Hasło musi mieć co najmniej {min} znaków",
"confirmPasswordError": "Hasła nie pasują.",
"termsRequired": "Musisz zaakceptować warunki i zasady.",
"usernameRequired": "Nazwa użytkownika jest wymagana.",
@@ -920,7 +921,7 @@
"strengthFair": "Średnie",
"strengthGood": "Dobre",
"strengthStrong": "Silne",
"passwordMinLength": "Hasło musi mieć co najmniej 12 znaków",
"passwordMinLength": "Hasło musi mieć co najmniej {min} znaków",
"passwordUpper": "Hasło musi zawierać co najmniej jedną wielką literę",
"passwordLower": "Hasło musi zawierać co najmniej jedną małą literę",
"passwordDigit": "Hasło musi zawierać co najmniej jedną cyfrę",
@@ -955,10 +956,10 @@
"reset": {
"title": "Ustaw nowe hasło",
"subtitle": "Wybierz silne hasło, którego nie używasz gdzie indziej.",
"passwordPlaceholder": "Nowe hasło (min. 12 znaków)",
"passwordPlaceholder": "Nowe hasło (min. {min} znaków)",
"resetPassword": "Zresetuj hasło",
"backToLogin": "Powrót do logowania",
"passwordMinLength": "Hasło musi mieć co najmniej 12 znaków",
"passwordMinLength": "Hasło musi mieć co najmniej {min} znaków",
"tooManyAttempts": "Zbyt wiele prób — spróbuj później",
"invalidLink": "Ten link resetujący jest nieprawidłowy lub wygasł. Poproś o nowy.",
"failed": "Nie udało się zresetować hasła. Spróbuj ponownie."
+6 -5
View File
@@ -232,6 +232,7 @@
},
"home": {
"welcome": "Bem-vindo ao {hotelName}",
"heroEyebrow": "Bem-vindo",
"welcomeSubtitle": "Seu hotel retrô, agora com tecnologia Next.js",
"welcomeBody": "Saia, colete mobis e encontre amigos.",
"enterHotel": "Digite {hotelName}",
@@ -885,7 +886,7 @@
"confirmPassword": "Repetir senha",
"usernamePlaceholder": "Nome de usuário",
"emailPlaceholder": "E-mail",
"passwordPlaceholder": "Senha (mín. 12 caracteres)",
"passwordPlaceholder": "Senha (mín. {min} caracteres)",
"confirmPasswordPlaceholder": "Repetir senha",
"selectOutfit": "Selecione sua roupa",
"termsAccept": "Tenho mais de 18 anos e aceito os termos e regras do {hotel}.",
@@ -897,7 +898,7 @@
"termsError": "Você deve aceitar os termos e regras para se registrar.",
"usernameError": "O nome de usuário é obrigatório.",
"emailError": "E-mail válido é obrigatório.",
"passwordError": "A senha deve ter pelo menos 12 caracteres",
"passwordError": "A senha deve ter pelo menos {min} caracteres",
"confirmPasswordError": "As senhas não coincidem.",
"termsRequired": "Você deve aceitar os termos e regras.",
"usernameRequired": "O nome de usuário é obrigatório.",
@@ -920,7 +921,7 @@
"strengthFair": "Razoável",
"strengthGood": "Boa",
"strengthStrong": "Forte",
"passwordMinLength": "A senha deve ter pelo menos 12 caracteres",
"passwordMinLength": "A senha deve ter pelo menos {min} caracteres",
"passwordUpper": "A senha deve conter pelo menos uma letra maiúscula",
"passwordLower": "A senha deve conter pelo menos uma letra minúscula",
"passwordDigit": "A senha deve conter pelo menos um dígito",
@@ -955,10 +956,10 @@
"reset": {
"title": "Defina uma nova senha",
"subtitle": "Escolha uma senha forte que você não usa em outro lugar.",
"passwordPlaceholder": "Nova senha (mínimo 12 caracteres)",
"passwordPlaceholder": "Nova senha (mínimo {min} caracteres)",
"resetPassword": "Redefinir senha",
"backToLogin": "Voltar ao login",
"passwordMinLength": "A senha deve ter pelo menos 12 caracteres",
"passwordMinLength": "A senha deve ter pelo menos {min} caracteres",
"tooManyAttempts": "Muitas tentativas — tente mais tarde",
"invalidLink": "Este link de redefinição é inválido ou expirou. Peça um novo.",
"failed": "Não foi possível redefinir a palavra-passe. Tente novamente."
+6 -5
View File
@@ -232,6 +232,7 @@
},
"home": {
"welcome": "Bun venit la {hotelName}",
"heroEyebrow": "Bine ai venit",
"welcomeSubtitle": "Hotelul tău retro, acum dezvoltat de Next.js",
"welcomeBody": "Ieșiți, strângeți mobilier și întâlniți-vă cu prietenii.",
"enterHotel": "Introdu {hotelName}",
@@ -885,7 +886,7 @@
"confirmPassword": "Repetați parola",
"usernamePlaceholder": "Nume de utilizator",
"emailPlaceholder": "E-mail",
"passwordPlaceholder": "Parola (min 12 caractere)",
"passwordPlaceholder": "Parola (min {min} caractere)",
"confirmPasswordPlaceholder": "Repetați parola",
"selectOutfit": "Alege-ți ținuta",
"termsAccept": "Am peste 18 ani și accept termenii și regulile {hotel}.",
@@ -897,7 +898,7 @@
"termsError": "Trebuie să acceptați termenii și regulile pentru a vă înregistra.",
"usernameError": "Numele de utilizator este obligatoriu.",
"emailError": "Este necesar un e-mail valid.",
"passwordError": "Parola trebuie să aibă cel puțin 12 caractere",
"passwordError": "Parola trebuie să aibă cel puțin {min} caractere",
"confirmPasswordError": "Parolele nu se potrivesc.",
"termsRequired": "Trebuie să acceptați termenii și regulile.",
"usernameRequired": "Numele de utilizator este obligatoriu.",
@@ -920,7 +921,7 @@
"strengthFair": "Medie",
"strengthGood": "Bună",
"strengthStrong": "Puternică",
"passwordMinLength": "Parola trebuie să aibă cel puțin 12 caractere",
"passwordMinLength": "Parola trebuie să aibă cel puțin {min} caractere",
"passwordUpper": "Parola trebuie să conțină cel puțin o literă mare",
"passwordLower": "Parola trebuie să conțină cel puțin o literă mică",
"passwordDigit": "Parola trebuie să conțină cel puțin o cifră",
@@ -955,10 +956,10 @@
"reset": {
"title": "Setați o nouă parolă",
"subtitle": "Alegeți o parolă puternică pe care nu o utilizați în altă parte.",
"passwordPlaceholder": "Parolă nouă (min 12 caractere)",
"passwordPlaceholder": "Parolă nouă (min {min} caractere)",
"resetPassword": "Resetează parola",
"backToLogin": "Înapoi la autentificare",
"passwordMinLength": "Parola trebuie să aibă cel puțin 12 caractere",
"passwordMinLength": "Parola trebuie să aibă cel puțin {min} caractere",
"tooManyAttempts": "Prea multe încercări — încearcă mai târziu",
"invalidLink": "Acest link de resetare nu este valabil sau a expirat. Solicită unul nou.",
"failed": "Parola nu a putut fi resetată. Încearcă din nou."
+6 -5
View File
@@ -232,6 +232,7 @@
},
"home": {
"welcome": "Добро пожаловать в {hotelName}",
"heroEyebrow": "Добро пожаловать",
"welcomeSubtitle": "Ваш ретро-отель на базе Next.js",
"welcomeBody": "Тусуйтесь, собирайте мебель и встречайтесь с друзьями.",
"enterHotel": "Введите {hotelName}",
@@ -883,7 +884,7 @@
"confirmPassword": "Повторите пароль",
"usernamePlaceholder": "Имя пользователя",
"emailPlaceholder": "Электронная почта",
"passwordPlaceholder": "Пароль (мин. 12 символов)",
"passwordPlaceholder": "Пароль (мин. {min} символов)",
"confirmPasswordPlaceholder": "Повторите пароль",
"selectOutfit": "Выберите свой наряд",
"termsAccept": "Мне 18+ и я принимаю условия и правила {hotel}.",
@@ -895,7 +896,7 @@
"termsError": "Чтобы зарегистрироваться, вы должны принять условия и правила.",
"usernameError": "Требуется имя пользователя.",
"emailError": "Требуется действительный адрес электронной почты.",
"passwordError": "Пароль должен содержать не менее 12 символов",
"passwordError": "Пароль должен содержать не менее {min} символов",
"confirmPasswordError": "Пароли не совпадают.",
"termsRequired": "Вы должны принять условия и правила.",
"usernameRequired": "Требуется имя пользователя.",
@@ -918,7 +919,7 @@
"strengthFair": "Средний",
"strengthGood": "Хороший",
"strengthStrong": "Надёжный",
"passwordMinLength": "Пароль должен содержать не менее 12 символов",
"passwordMinLength": "Пароль должен содержать не менее {min} символов",
"passwordUpper": "Пароль должен содержать хотя бы одну заглавную букву",
"passwordLower": "Пароль должен содержать хотя бы одну строчную букву",
"passwordDigit": "Пароль должен содержать хотя бы одну цифру",
@@ -953,10 +954,10 @@
"reset": {
"title": "Установить новый пароль",
"subtitle": "Выберите надежный пароль, который вы больше не используете.",
"passwordPlaceholder": "Новый пароль (мин. 12 символов)",
"passwordPlaceholder": "Новый пароль (мин. {min} символов)",
"resetPassword": "Сбросить пароль",
"backToLogin": "Назад к входу",
"passwordMinLength": "Пароль должен содержать не менее 12 символов",
"passwordMinLength": "Пароль должен содержать не менее {min} символов",
"tooManyAttempts": "Слишком много попыток — повторите позже",
"invalidLink": "Эта ссылка для сброса недействительна или истекла. Запросите новую.",
"failed": "Не удалось сбросить пароль. Попробуйте снова."
+6 -5
View File
@@ -232,6 +232,7 @@
},
"home": {
"welcome": "Vitajte v {hotelName}",
"heroEyebrow": "Vitajte",
"welcomeSubtitle": "Váš retro hotel, ktorý teraz používa Next.js",
"welcomeBody": "Stretnite sa, zbierajte nábytok a stretávajte sa s priateľmi.",
"enterHotel": "Zadajte {hotelName}",
@@ -885,7 +886,7 @@
"confirmPassword": "Zopakujte heslo",
"usernamePlaceholder": "Používateľské meno",
"emailPlaceholder": "Email",
"passwordPlaceholder": "Heslo (min. 12 znakov)",
"passwordPlaceholder": "Heslo (min. {min} znakov)",
"confirmPasswordPlaceholder": "Zopakujte heslo",
"selectOutfit": "Vyberte si oblečenie",
"termsAccept": "Mám 18+ a súhlasím so zmluvnými podmienkami a pravidlami {hotel}.",
@@ -897,7 +898,7 @@
"termsError": "Ak sa chcete zaregistrovať, musíte prijať podmienky a pravidlá.",
"usernameError": "Vyžaduje sa používateľské meno.",
"emailError": "Vyžaduje sa platný e-mail.",
"passwordError": "Heslo musí mať aspoň 12 znakov",
"passwordError": "Heslo musí mať aspoň {min} znakov",
"confirmPasswordError": "Heslá sa nezhodujú.",
"termsRequired": "Musíte prijať podmienky a pravidlá.",
"usernameRequired": "Vyžaduje sa používateľské meno.",
@@ -920,7 +921,7 @@
"strengthFair": "Priemerné",
"strengthGood": "Dobré",
"strengthStrong": "Silné",
"passwordMinLength": "Heslo musí mať aspoň 12 znakov",
"passwordMinLength": "Heslo musí mať aspoň {min} znakov",
"passwordUpper": "Heslo musí obsahovať aspoň jedno veľké písmeno",
"passwordLower": "Heslo musí obsahovať aspoň jedno malé písmeno",
"passwordDigit": "Heslo musí obsahovať aspoň jednu číslicu",
@@ -955,10 +956,10 @@
"reset": {
"title": "Nastavte si nové heslo",
"subtitle": "Zvoľte si silné heslo, ktoré inde nepoužívate.",
"passwordPlaceholder": "Nové heslo (min. 12 znakov)",
"passwordPlaceholder": "Nové heslo (min. {min} znakov)",
"resetPassword": "Obnoviť heslo",
"backToLogin": "Späť na prihlásenie",
"passwordMinLength": "Heslo musí mať aspoň 12 znakov",
"passwordMinLength": "Heslo musí mať aspoň {min} znakov",
"tooManyAttempts": "Príliš veľa pokusov — skúste to neskôr",
"invalidLink": "Tento odkaz na obnovenie je neplatný alebo vypršal. Vyžiadajte si nový.",
"failed": "Heslo nebolo možné obnoviť. Skúste to znova."
+6 -5
View File
@@ -232,6 +232,7 @@
},
"home": {
"welcome": "Добро дошли у {hotelName}",
"heroEyebrow": "Добродошли",
"welcomeSubtitle": "Your retro hotel, now powered by Next.js",
"welcomeBody": "Hang out, collect furni, and meet friends.",
"enterHotel": "Унесите {hotelName}",
@@ -885,7 +886,7 @@
"confirmPassword": "Поновите лозинку",
"usernamePlaceholder": "Корисничко име",
"emailPlaceholder": "Емаил",
"passwordPlaceholder": "Лозинка (мин 12 знакова)",
"passwordPlaceholder": "Лозинка (мин {min} знакова)",
"confirmPasswordPlaceholder": "Поновите лозинку",
"selectOutfit": "Изаберите своју одећу",
"termsAccept": "I am 18+ and accept the {hotel} terms & rules.",
@@ -897,7 +898,7 @@
"termsError": "Морате прихватити услове и правила да бисте се регистровали.",
"usernameError": "Корисничко име је обавезно.",
"emailError": "Потребан је важећи имејл.",
"passwordError": "Лозинка мора имати најмање 12 знакова",
"passwordError": "Лозинка мора имати најмање {min} знакова",
"confirmPasswordError": "Лозинке се не поклапају.",
"termsRequired": "Морате прихватити услове и правила.",
"usernameRequired": "Корисничко име је обавезно.",
@@ -920,7 +921,7 @@
"strengthFair": "Средња",
"strengthGood": "Добра",
"strengthStrong": "Јака",
"passwordMinLength": "Лозинка мора имати најмање 12 знакова",
"passwordMinLength": "Лозинка мора имати најмање {min} знакова",
"passwordUpper": "Лозинка мора садржати најмање једно велико слово",
"passwordLower": "Лозинка мора садржати најмање једно мало слово",
"passwordDigit": "Лозинка мора садржати најмање једну цифру",
@@ -955,10 +956,10 @@
"reset": {
"title": "Поставите нову лозинку",
"subtitle": "Изаберите јаку лозинку коју не користите другде.",
"passwordPlaceholder": "Нова лозинка (мин 12 знакова)",
"passwordPlaceholder": "Нова лозинка (мин {min} знакова)",
"resetPassword": "Ресетујте лозинку",
"backToLogin": "Назад на пријаву",
"passwordMinLength": "Лозинка мора имати најмање 12 знакова",
"passwordMinLength": "Лозинка мора имати најмање {min} знакова",
"tooManyAttempts": "Превише покушаја — покушајте касније",
"invalidLink": "Ова веза за ресетовање није исправна или је истекла. Затражите нову.",
"failed": "Лозинка није могла бити ресетована. Покушајте поново."
+6 -5
View File
@@ -232,6 +232,7 @@
},
"home": {
"welcome": "Välkommen till {hotelName}",
"heroEyebrow": "Välkommen",
"welcomeSubtitle": "Ditt retrohotell, nu drivs av Next.js",
"welcomeBody": "Umgås, samla möbler och träffa vänner.",
"enterHotel": "Ange {hotelName}",
@@ -885,7 +886,7 @@
"confirmPassword": "Upprepa lösenordet",
"usernamePlaceholder": "Användarnamn",
"emailPlaceholder": "E-post",
"passwordPlaceholder": "Lösenord (minst 12 tecken)",
"passwordPlaceholder": "Lösenord (minst {min} tecken)",
"confirmPasswordPlaceholder": "Upprepa lösenordet",
"selectOutfit": "Välj din outfit",
"termsAccept": "Jag är 18+ och accepterar villkoren och reglerna för {hotel}.",
@@ -897,7 +898,7 @@
"termsError": "Du måste acceptera villkoren och reglerna för att registrera dig.",
"usernameError": "Användarnamn krävs.",
"emailError": "Giltig e-postadress krävs.",
"passwordError": "Lösenordet måste bestå av minst 12 tecken.",
"passwordError": "Lösenordet måste bestå av minst {min} tecken.",
"confirmPasswordError": "Lösenord stämmer inte överens.",
"termsRequired": "Du måste acceptera villkoren och reglerna.",
"usernameRequired": "Användarnamn krävs.",
@@ -920,7 +921,7 @@
"strengthFair": "Medel",
"strengthGood": "God",
"strengthStrong": "Stark",
"passwordMinLength": "Lösenordet måste vara minst 12 tecken",
"passwordMinLength": "Lösenordet måste vara minst {min} tecken",
"passwordUpper": "Lösenordet måste innehålla minst en stor bokstav",
"passwordLower": "Lösenordet måste innehålla minst en liten bokstav",
"passwordDigit": "Lösenordet måste innehålla minst en siffra",
@@ -955,10 +956,10 @@
"reset": {
"title": "Ange ett nytt lösenord",
"subtitle": "Välj ett starkt lösenord som du inte använder någon annanstans.",
"passwordPlaceholder": "Nytt lösenord (minst 12 tecken)",
"passwordPlaceholder": "Nytt lösenord (minst {min} tecken)",
"resetPassword": "Återställ lösenord",
"backToLogin": "Tillbaka till inloggning",
"passwordMinLength": "Lösenordet måste vara minst 12 tecken",
"passwordMinLength": "Lösenordet måste vara minst {min} tecken",
"tooManyAttempts": "För många försök — försök igen senare",
"invalidLink": "Den här återställningslänken är ogiltig eller har gått ut. Begär en ny.",
"failed": "Lösenordet kunde inte återställas. Försök igen."
+6 -5
View File
@@ -232,6 +232,7 @@
},
"home": {
"welcome": "{hotelName}'e hoş geldiniz",
"heroEyebrow": "Hoş geldiniz",
"welcomeSubtitle": "Retro oteliniz artık Next.js tarafından destekleniyor",
"welcomeBody": "Takılın, mobilya toplayın ve arkadaşlarla tanışın.",
"enterHotel": "{hotelName} girin",
@@ -885,7 +886,7 @@
"confirmPassword": "Şifreyi tekrarla",
"usernamePlaceholder": "Kullanıcı adı",
"emailPlaceholder": "E-posta",
"passwordPlaceholder": "Şifre (en az 12 karakter)",
"passwordPlaceholder": "Şifre (en az {min} karakter)",
"confirmPasswordPlaceholder": "Şifreyi tekrarla",
"selectOutfit": "Kıyafetinizi seçin",
"termsAccept": "18 yaşındayım ve {hotel} şartlarını ve kurallarını kabul ediyorum.",
@@ -897,7 +898,7 @@
"termsError": "Kayıt olmak için şartları ve kuralları kabul etmelisiniz.",
"usernameError": "Kullanıcı adı gerekli.",
"emailError": "Geçerli e-posta gereklidir.",
"passwordError": "Şifre en az 12 karakter olmalıdır.",
"passwordError": "Şifre en az {min} karakter olmalıdır.",
"confirmPasswordError": "Şifreler eşleşmiyor.",
"termsRequired": "Şartları ve kuralları kabul etmelisiniz.",
"usernameRequired": "Kullanıcı adı gerekli.",
@@ -920,7 +921,7 @@
"strengthFair": "Orta",
"strengthGood": "İyi",
"strengthStrong": "Güçlü",
"passwordMinLength": "Şifre en az 12 karakter olmalıdır",
"passwordMinLength": "Şifre en az {min} karakter olmalıdır",
"passwordUpper": "Şifre en az bir büyük harf içermelidir",
"passwordLower": "Şifre en az bir küçük harf içermelidir",
"passwordDigit": "Şifre en az bir rakam içermelidir",
@@ -955,10 +956,10 @@
"reset": {
"title": "Yeni bir şifre belirleyin",
"subtitle": "Başka yerde kullanmadığınız güçlü bir şifre seçin.",
"passwordPlaceholder": "Yeni şifre (en az 12 karakter)",
"passwordPlaceholder": "Yeni şifre (en az {min} karakter)",
"resetPassword": "Şifreyi sıfırla",
"backToLogin": "Girişe geri dön",
"passwordMinLength": "Şifre en az 12 karakter olmalıdır",
"passwordMinLength": "Şifre en az {min} karakter olmalıdır",
"tooManyAttempts": "Çok fazla deneme — daha sonra tekrar deneyin",
"invalidLink": "Bu şifre sıfırlama bağlantısı geçersiz veya süresi dolmuş. Yeni bir tane isteyin.",
"failed": "Şifre sıfırlanamadı. Lütfen tekrar deneyin."
+6 -5
View File
@@ -232,6 +232,7 @@
},
"home": {
"welcome": "Ласкаво просимо до {hotelName}",
"heroEyebrow": "Ласкаво просимо",
"welcomeSubtitle": "Ваш ретро-готель, тепер на базі Next.js",
"welcomeBody": "Гуляйте, збирайте меблі та зустрічайтеся з друзями.",
"enterHotel": "Введіть {hotelName}",
@@ -885,7 +886,7 @@
"confirmPassword": "Повторіть пароль",
"usernamePlaceholder": "Ім'я користувача",
"emailPlaceholder": "Електронна пошта",
"passwordPlaceholder": "Пароль (мінімум 12 символів)",
"passwordPlaceholder": "Пароль (мінімум {min} символів)",
"confirmPasswordPlaceholder": "Повторіть пароль",
"selectOutfit": "Виберіть свій наряд",
"termsAccept": "Мені 18+ і я приймаю умови та правила {hotel}.",
@@ -897,7 +898,7 @@
"termsError": "Ви повинні прийняти умови та правила, щоб зареєструватися.",
"usernameError": "Потрібно ввести ім'я користувача.",
"emailError": "Потрібна дійсна електронна адреса.",
"passwordError": "Пароль має містити щонайменше 12 символів",
"passwordError": "Пароль має містити щонайменше {min} символів",
"confirmPasswordError": "Паролі не збігаються.",
"termsRequired": "Ви повинні прийняти умови та правила.",
"usernameRequired": "Потрібно ввести ім'я користувача.",
@@ -920,7 +921,7 @@
"strengthFair": "Середній",
"strengthGood": "Добрий",
"strengthStrong": "Надійний",
"passwordMinLength": "Пароль має містити щонайменше 12 символів",
"passwordMinLength": "Пароль має містити щонайменше {min} символів",
"passwordUpper": "Пароль має містити хоча б одну велику літеру",
"passwordLower": "Пароль має містити хоча б одну малу літеру",
"passwordDigit": "Пароль має містити хоча б одну цифру",
@@ -955,10 +956,10 @@
"reset": {
"title": "Встановіть новий пароль",
"subtitle": "Виберіть надійний пароль, який більше не використовується.",
"passwordPlaceholder": "Новий пароль (мінімум 12 символів)",
"passwordPlaceholder": "Новий пароль (мінімум {min} символів)",
"resetPassword": "Скинути пароль",
"backToLogin": "Назад до входу",
"passwordMinLength": "Пароль має містити щонайменше 12 символів",
"passwordMinLength": "Пароль має містити щонайменше {min} символів",
"tooManyAttempts": "Забагато спроб — спробуйте пізніше",
"invalidLink": "Це посилання для скидання недійсне або прострочене. Запросіть нове.",
"failed": "Не вдалося скинути пароль. Спробуйте ще раз."
+15 -4
View File
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { getToken } from "next-auth/jwt";
import { env } from "@/env";
import { buildContentSecurityPolicy, createCspNonce } from "@/lib/csp";
import { isSuspiciousPath } from "@/lib/ddos";
import { ddosReject, enforceDdosRateLimit } from "@/lib/ddos-guard";
import {
isCacheableAssetPath,
@@ -19,16 +20,26 @@ const SECURITY_HEADERS: Record<string, string> = {
};
export const proxy = async (req: import("next/server").NextRequest) => {
const decision = await enforceDdosRateLimit(req);
if (decision.outcome === "suspect") {
const pathname = req.nextUrl.pathname;
// Drop scanner probes immediately before performing any rate-limit or DB operations.
if (isSuspiciousPath(pathname)) {
return ddosReject(403);
}
// Server actions carry an opaque action id in this header. A malformed or
// trivially short id (e.g. "x" sent by bots) can never resolve to a real
// action; rejecting it here avoids the expensive lookup/render path.
const nextAction = req.headers.get("next-action");
if (nextAction && !/^[A-Za-z0-9_-]{8,}$/.test(nextAction)) {
return new NextResponse(null, { status: 400 });
}
const decision = await enforceDdosRateLimit(req);
if (decision.outcome === "block") {
return ddosReject(429, decision.retryAfterSeconds);
}
const pathname = req.nextUrl.pathname;
// Only /admin needs a real session token for the redirect guard; skipping
// JWT decoding on every other request keeps the proxy cheap under load.
const adminPath = pathname === "/admin" || pathname.startsWith("/admin/");
+2
View File
@@ -27,6 +27,8 @@ export default defineConfig({
DATABASE_URL: "mysql://root:root@localhost:3306/test",
},
testTimeout: 10000,
maxWorkers: 1,
fileParallelism: false,
exclude: ["integration/**", "e2e/**", "node_modules/**", ".next/**"],
coverage: {
// Off unless asked for (`pnpm test:coverage` passes