990ebdb15826b69b67d6a9ef0ec1f744fbbb914a
413
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
990ebdb158 |
fix(pwa): retire the service worker instead of just fixing it
Gitea Actions Runner Test / test-job (push) Successful in 2s
CI / check (push) Successful in 35s
CI / tests-integration (push) Successful in 1m58s
CI / tests-unit (push) Successful in 2m22s
CI / tests-ui (push) Successful in 2m50s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m50s
The previous commit stopped the worker from caching build output, which removed the cause of the blank Catalog Studio but left the worker itself serving a purpose worth one offline fallback: three public endpoints (/api/home, /api/online, /api/radio/config). Behind Cloudflare, for a site whose visitors are online, that fallback rarely fires and goes stale exactly where it is most likely to matter. So the worker goes away rather than staying as a mostly-inert layer that every future release still has to keep correct. public/sw.js is not deleted, because deleting it would leave every existing registration alive and still in control of the page, caching as it did before. It becomes the opposite of what it was: on activate it deletes every cache, unregisters itself and reloads open clients so they stop being controlled. Browsers that already installed a worker therefore uninstall it on their next visit; browsers that never had one are unaffected. src/components/pwa-register.tsx is removed and unmounted from the root layout, so nothing registers a worker any more and the kill switch only ever runs for the visitors who need it. The manifest is deliberately untouched. src/app/manifest.ts is an ordinary Next.js route, independent of any worker, and Chrome installs from a manifest alone, so the site stays installable on a phone. Verified nothing depended on it: no other navigator.serviceWorker or caches.* reference exists in the app, the theme runs from the plain /scripts/ theme-init.js script, and no Studio, catalog or import module touches a worker. Typecheck and lint clean, 2326 unit tests and 72 UI tests green, including every Studio and catalog spec. |
||
|
|
1a4888df50 |
fix(pwa): stop the service worker replaying chunks from a previous release
Gitea Actions Runner Test / test-job (push) Successful in 3s
CI / check (push) Successful in 32s
CI / tests-integration (push) Successful in 2m11s
CI / tests-unit (push) Successful in 2m16s
CI / tests-ui (push) Successful in 2m54s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m47s
The Catalog Studio rendered as a blank white page after a deploy, while the server was serving it correctly: /admin/studio/furni answered 200 with 110KB of HTML and every API it calls answered 200 with data. No script chunk 404ed during the session and no JavaScript threw, so the failure was entirely in what the browser chose to execute. The service worker wrapped /assets/ and /_next/static/ in a Cache Storage entry served cache-first, under a hardcoded name (atom-v3) with no build id and no revalidation. A release therefore could not invalidate it: the browser kept replaying the previous release's chunks against the new HTML, and React never hydrated. The chunk branch also had no .catch(), unlike the API branch below it, so a rejected fetch silently dropped the <script> instead of surfacing a network error the browser could retry. That cache also bought nothing. nginx already sends /_next/static/ and /assets/ as `max-age=31536000, immutable`, and those filenames are content-hashed, so the HTTP cache is both sufficient and safe — a changed file gets a new name. The worker was the only layer able to go stale across a deploy, and it was the one doing it. Both prefixes now fall through to the network and are left to the HTTP cache. The API offline fallback and network-first navigations are unchanged, and the cache names move to v4 so an existing worker is replaced. SW_VERSION moves with them: it is part of the registration URL, so without the bump the browser never refetches sw.js and the old worker keeps running. Unrelated but confirmed while tracing this: /assets/images/themes/arctic-ice.png is requested by the browser and 404s, but that string exists nowhere in the code, the database or the build. It was a stale reference served out of this same cache, not a missing asset. |
||
|
|
43742e8d99 |
fix(catalog): decode WebP bundle textures so furniture icons resolve again
Every write path normalises a bundle's texture to WebP Lossless, so the catalog icon was being read back with a PNG-only decoder. decodePng throws on anything that is not a PNG, the callers caught that and returned null, and the user-visible result was "no icon (not in source or bundle)" for every furniture whose source does not serve a standalone icon. Measured against the production asset tree, all 18,505 bundles were WebP; icon extraction succeeded on 0 of them. src/lib/services/imager/ decode-texture.ts keeps PNG on the dependency-free decoder and routes WebP through sharp, which is already a dependency and already encodes these textures. extractFurniIconPng and getPetIconPng become async; the five call sites (upload, clone import, furni import, icon repair and both icon routes) already awaited their surrounding work. Same root cause, second bug: the spritesheet frame key. Converters disagree on packing — some keep a trailing ".png", and some lowercase the whole key while leaving the bundle name mixed-case, so "LTD_fashionistaf" looks up frame "LTD_fashionistaf_LTD_fashionistaf_icon_a" and never finds "ltd_fashionistaf_ltd_fashionistaf_icon_a". Any mixed-case classname could therefore never match, which is most of the catalogue. findFrame tries the two exact spellings, then falls back to one case-insensitive pass. Extraction now succeeds on 18,483 of 18,505 bundles (99.88%); the 22 remainder are data, not code — 9 bundles ship no icon asset, 11 do not parse. Third: three catalogue icons exist only as .gif while catalogueIconUrl hardcoded .png, so the picker offered icons that could only ever 404, and 291 icons that ship as both formats were listed twice. The API now dedupes per id and the two renderers retry with .gif before falling back to the placeholder, matching what catalog-image-picker already did. Verified live: /gamedata/.../icon_1542.png returns 404 while icon_1542.gif returns 200. Separately, close the last hole in the memory cap. Every script in package.json routes through scripts/with-memory-cap.sh, but invoking the builder directly — from a terminal, an IDE or an agent — skipped the wrapper and ran unbounded, on a host with no swap where the OOM killer picks its victim across the whole machine. next.config.ts now refuses a production build that the wrapper has not marked, before anything allocates. next dev and next start are deliberately unaffected. README gains a Memory-capped commands section covering the per-script ceilings, the backends and the ulimit -v trap, and its stale version and script tables are corrected. |
||
|
|
adffac7360 |
feat(catalog): store furniture bundles as .hab instead of .nitro
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 29s
CI / tests-unit (push) Failing after 1m37s
CI / tests-integration (push) Successful in 1m37s
CI / tests-ui (push) Successful in 2m17s
CI / preflight (push) Skipped
CI / deploy (push) Skipped
Every bundle the CMS writes — upload, clone, sync, repair and the pet / effect / figure importers — now lands as `<classname>.hab`, the extension this deployment's renderer asks for. `.hab` and `.nitro` are the same container, so an upload of either extension is accepted. Resolution goes through one module, src/lib/furni/bundle-file.ts, so nothing has to know the extension twice. Every existence check probes `.hab` first and falls back to `.nitro`: the on-disk asset set is still predominantly `.nitro`, and without the fallback Studio would report every imported item as missing and the cleanup scan would classify 18k live bundles as fake leftovers. Downloads are unchanged — Habbo's CDN and every configured clone source still serve `.nitro`, so the conversion happens on write, not on request. Deliberately unchanged: the staged-attachment store in furni-attachment.ts keys on a UUID and never reaches the client, so renaming it would break in-flight recovery jobs. Adds scripts/migrate-nitro-to-hab.ts to rename the existing asset set. It refuses to run without --dry-run or --yes, never overwrites an existing .hab, never deletes, and is idempotent. Note: renderer-config.json lives outside this repo and was patched to .hab separately; that file is served with a 30-day max-age, so returning clients need a cms-client cache purge to pick the change up. |
||
|
|
759ae91745 |
perf: cache search and news archive, drop motion/react from public pages
Gitea Actions Runner Test / test-job (push) Successful in 2s
CI / check (push) Successful in 26s
CI / tests-unit (push) Failing after 1m37s
CI / tests-integration (push) Successful in 1m38s
CI / tests-ui (push) Failing after 2m24s
CI / preflight (push) Skipped
CI / deploy (push) Skipped
Closes the four remaining LOW items. Search and news archive caching - A leading-wildcard LIKE cannot use an index, so every /search section cost a COUNT(*) scan plus an ordered page fetch, and /news did the same for its archive. Both now cache: search per section for 30s, the archive for 60s under the existing news revision so publishing an article drops it at once. - Sections are cached independently, so one slow query cannot hold up the rest and a failure is not cached as a result. - The cached value is passed through cacheSafe() so the Redis path and the in-process path return the same types; without it a cache hit would hand the events grid a string where a miss hands it a Date, and it calls toISOString() on that field. Dates are revived on the way out so the public signatures of loadNewsArchive and loadPublicSearch are unchanged. - Archive entries are keyed on the REQUESTED page rather than the clamped one, so two requests that clamp onto the same page cannot alias each other. motion/react out of the public bundle - Converted the six public-facing users: the radio player, the typewriter text (a motion.span with no animation props at all), the photo lightbox, the animated counter, the footer CMS-info popup and the scroll reveal. That was the actual entry points — the counter and the popup reach the public home page and footer through static imports, so removing only the three originally named would have left the library in the bundle anyway. - Each animation moved to a CSS class, and the two that animate on exit now hold the element for the length of the fade, which is what AnimatePresence used to do. - motion/react now only ships with /admin and the two already-lazy nav panels. - Two safety fixes came out of this: the scroll reveal starts at opacity 0, so it is forced visible under prefers-reduced-motion and via a <noscript> rule in the root layout; and it now emits the .motion-reveal class, which the theme panel's "Scroll Reveal" toggle selects and which previously matched nothing. - The CMS-info backdrop became a real button in a pointer-transparent layer instead of a handler on a static element, so click-outside-to-dismiss is reachable by keyboard. Fewer duplicate router refreshes - Next.js re-renders the current route as part of a server action's own response when that action revalidates, and applies it with a seeded navigation; the router only skips its own update when the action did NOT revalidate. So the refresh after such an action fetched the same tree twice. - useServerAction takes an opt-in `revalidated` flag that skips it. It is opt-in per call rather than derived from an action name, since a rename would silently change behaviour. Applied to the two user-facing call sites whose actions were verified to revalidate their own route. Touch targets - .btn was the one shared control at 40px; it and the lightbox and CMS-info close buttons are now 44px, as is the password toggle (the auth input already reserved 44px for it). The remaining 32px icon buttons pass WCAG 2.2 AA, which only asks for 24px; enlarging those inside inputs and overlays was left alone because it risks visual breakage that cannot be checked from here. |
||
|
|
179484642f |
feat: per-account login lockout, mail index, resend captcha, i18n scoping
Gitea Actions Runner Test / test-job (push) Successful in 2s
CI / check (push) Successful in 28s
CI / tests-integration (push) Successful in 1m42s
CI / tests-unit (push) Failing after 1m45s
CI / tests-ui (push) Successful in 2m29s
CI / preflight (push) Skipped
CI / deploy (push) Skipped
Closes the four HIGH/MEDIUM items left open after the previous pass. Login lockout - The only login limits were keyed on the client IP, so a distributed attempt could grind on one account indefinitely. Added a per-account lockout with a budget of 8 failures per 15 minutes. - The bucket is keyed on the RESOLVED account id, not on the submitted string: users may sign in with either username or e-mail and neither the lookup nor the input normaliser folds case, so an input-keyed bucket would hand out a fresh budget per spelling of the same account. - precheckLogin and NextAuth's authorize share the bucket, so the pre-check cannot be used to buy extra attempts and a client that skips it entirely is still bounded. Both check the lockout BEFORE verifying the password: the success path clears the counter, which would otherwise walk a locked account straight back in on the right password. - A successful login clears the failures, which needs two new primitives in rate-limit.ts: peekRateLimit (read-only, does not consume a unit) and clearRateLimit. - Fixed a latent inconsistency while doing so: the in-process bucket capped its counter at the limit while Redis' INCR kept climbing, so the two backends disagreed about how far over the limit a key was. Both now track the true count. Mail lookup index - Added an index on users.mail (0035). Password reset, e-mail verification and the resend cooldown all resolve a single account from a submitted address and were full table scans of `users`. Deliberately non-unique: legacy rows can hold the same address more than once, so a unique index would fail to apply. Resend captcha - /verify's resend form triggers real outbound mail and was reachable with only a cooldown. It now runs the configured captcha before the account lookup and before any send. Client message payload - The root layout serialised the whole catalogue into every page. pages.admin and admin are ~177 KB of the ~235 KB and are unreachable from the public route group, so that layout now installs its own provider with the staff namespaces removed. Nested providers replace rather than merge, which is why this has to live in the segment layout. /admin, /mod, /client and /admin-next keep the full set; a guard test fails if a public page ever references a staff namespace. |
||
|
|
6cc45d7413 |
feat: harden atoms-nexst against review findings (37 items)
Gitea Actions Runner Test / test-job (push) Successful in 2s
CI / check (push) Successful in 30s
CI / tests-integration (push) Successful in 1m42s
CI / tests-unit (push) Failing after 1m49s
CI / tests-ui (push) Successful in 2m31s
CI / preflight (push) Skipped
CI / deploy (push) Skipped
Second review pass covering security, performance, admin tooling and the public/room flows. All HIGH and MEDIUM findings from the audit are resolved; nothing in this commit changes the visible feature set. Authentication & session security - CSP is now set on the request headers in the proxy, which is what Next.js uses to derive the render nonce, so the nonce is effective. - 2FA: an already-enabled user cannot re-enroll, the setup endpoint is rate-limited per account, and confirmed codes are persisted so the second secret no longer silently never applies. - Password reset revokes the ticket, authTicket and all personal access tokens, and bumps the token version so existing sessions die. The same revocation is now wired into the staff-side password reset. - /reset and /verify return a stable error code instead of raw text; the mail lookups are ordered by id so duplicates cannot vary between runs. - Resending the verification mail gets a per-address cooldown on top of the per-user limit. - Issue API tokens with the narrower radio/ticket ability set instead of "*". Authorization & input handling - Mid-rank staff can no longer keep dynamically granted non-view admin.* permissions: existing grants are revoked by migration and the grant lookup is restricted to "%.view". Rank guards use the dynamic super-admin check. - Alerting a user is permission-checked and audited like the other tools. - Material mutations (giveCredits/giveDuckets/giveDiamonds, the admin user actions route, bulk user actions) are capped and rank-guarded, and bulk ids are bounded. - updateRoom / updateRoomItem write through a field allowlist, and items may only be edited through their own room. - Classnames reaching the filesystem are validated before use so a crafted value cannot escape the asset directories. - The word filter now also covers offline mails, guild forum threads and replies, and user mottos. - Media uploads are validated by magic bytes, /api/media requires the page edit permission, APP_URL must be configured once mail is enabled, and the diagnostics error route checks the fetch site header. Admin tooling - Secret settings render masked and cannot be overwritten with a blank or an arbitrary raw key; radio credentials are new password inputs. - Commandocentrum balance changes are audited. - Admin list pagination reads the caller's per-page instead of the max, and the log exporter caps offset and search length. Performance - Catalog translations are cached per module, with a cheap revision hash; the public online count uses a stale window instead of hammering the DB. - The cache warmup now primes the payload the home route actually reads. - TopHeader batches its queries into one round trip, and LCP avatars load eagerly. - motion/react and sonner are no longer part of the root layout; the nav dropdown and mobile nav panels are lazy client chunks. Anonymous visitors again get the navigation chrome, and public pages get an edge cacheable response. Accessibility - Nested <main> elements in phase pages became <section>; the page entrance and route progress animations are pure CSS that respect reduced motion. |
||
|
|
3933214953 |
feat(auth): implement all 16 homepage/login/register review items
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 33s
CI / tests-integration (push) Successful in 1m42s
CI / tests-unit (push) Successful in 1m47s
CI / tests-ui (push) Successful in 2m30s
CI / preflight (push) Skipped
CI / deploy (push) Failing after 2m56s
- add countArticles() (published-only, mirrors news-list) and warm total_articles - localize homepage metadata; bind articleCount to both stats; unique photo alts - drop duplicate news date and the mascot preload priorities - extract shared AuthPageFrame/AuthUsersCards used by /login and /register - login: localized noindex metadata, session redirect via safeRedirectPath, ?from passthrough from proxy, unified auth roster cache keys, registered notice - register: localized metadata, session redirect to /me, unified cache keys - add resend-verification flow on /verify with rate-limited non-enumerable action - add safeRedirectPath() with unit tests - register form: live requirements checklist + password mismatch guard - login form: unverified state with resend-link CTA - honour prefers-reduced-motion in TypewriterText - add 6 translations across all 25 locales |
||
|
|
3265c149da |
style(landing): add micro-interactions and polish public pages
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 29s
CI / tests-unit (push) Successful in 1m48s
CI / tests-integration (push) Successful in 1m52s
CI / tests-ui (push) Successful in 2m35s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 3m7s
|
||
|
|
57710a7fe3 |
style(landing): polish the public index, login and register screens
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Failing after 26s
CI / tests-unit (push) Skipped
CI / tests-integration (push) Skipped
CI / tests-ui (push) Skipped
CI / preflight (push) Skipped
CI / deploy (push) Skipped
- add theme-aware helpers: btn-brand, btn-glass-dark, auth-input, auth-label, auth-alert, explore-pill, avatar-tile, aurora blobs and a hero scroll cue - reorder backdrop-filter declarations so the glass blur survives the production CSS optimizer in modern Chromium - hero: aurora glow, frosted recent-users chip, premium CTA buttons and cue - explore nav: icon pills with hover arrows; features: gradient icon tiles - stats: single glass panel with column dividers; join CTA: aurora + ring - auth forms: visible labels, icon inputs, eye/password toggle, gradient submit and pill footer links - auth pages: gradient card frame, aurora accents on the intro panel and hover-lift avatar tiles; unified pill-shaped top bar - drop the hard-coded register banner image in favor of the framed card |
||
|
|
108c6ce03d |
fix(ci): make the lint gate fail for real and stop byparr leaking disk
Gitea Actions Runner Test / test-job (push) Successful in 2s
CI / check (push) Successful in 36s
CI / tests-integration (push) Successful in 2m3s
CI / tests-unit (push) Successful in 2m18s
CI / tests-ui (push) Successful in 3m6s
CI / preflight (push) Skipped
CI / deploy (push) Failing after 3m14s
The CI lint step was `biome check . || true`, so it could never fail: 14 real
violations were passing unnoticed. Drop the `|| true` and fix what it found.
Lint fixes, none of which change behaviour:
- give list items their natural identity instead of the array index
(key={c} / key={char}, key={`skeleton-${i}`})
- document the two useEffect dependency lists that must keep their
function-declaration handlers, with the reasoning that dropping them broke
the tree and save-on-Ctrl+S once already (
|
||
|
|
6bffc53779 |
refactor(auth): merge the duplicate login form and localize the auth screens
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 1m8s
CI / tests-integration (push) Successful in 1m53s
CI / tests-unit (push) Successful in 1m59s
CI / tests-ui (push) Successful in 2m42s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 4m33s
`home-login-form.tsx` and `login-form.tsx` were two ~240-line near-identical components. Delete the former and give `LoginForm` a `variant` prop: - `variant="page"` sr-only labels plus the register/forgot footer (/login) - `variant="compact"` visible labels, no footer (homepage sidebar) Field ids now come from `useId()`, so the two usages can never collide, and the hardcoded "Show"/"Hide"/"Loading" strings are translated. Localization of the login and register screens: - `home-login-form.tsx` was entirely hardcoded English. - `passwordStrength()` returned hardcoded "Weak"/"Fair"/"Good"/"Strong". - `register.ts` returned only English strings. It now returns a locale-independent `code` next to the message, and the form renders `t(code)` with the English string as a fallback. - Backfilled the new keys across all 25 locales, plus the login/register strings that were still English in most of them. `ar`, `fi` and `ja` had their entire login/register namespace in English and are now filled in. Locale parity stays at 0 missing keys, as `i18n:check` requires. Copy that did not match the enforced rules: the UI advertised "min 8 chars" (EN) / "min 6 tekens" (NL) while registration requires 12 characters plus an uppercase, a lowercase, a digit and a special character. Corrected in every locale. `password-reset.ts` enforced only 6 characters and is raised to 12 to match registration. Accessibility: `login-form.tsx` had no `<label>`, no `id` and no `required` on any field. All three are now present, and error banners are announced with `role="alert"`. Adds `src/i18n/auth-messages.test.ts`, which asserts every `RegisterErrorCode` resolves to a non-empty message in all 25 locales; verified it fails when a key is removed. The existing register tests now also assert the error `code`. |
||
|
|
7f07c111ac |
perf(studio): load motion's minimal entry instead of the full component library
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 30s
CI / tests-integration (push) Successful in 1m48s
CI / tests-unit (push) Successful in 1m52s
CI / tests-ui (push) Successful in 2m44s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m34s
/admin/studio/furni sat at 94.9% of its initial-JS budget (427436 of 450560 gzip bytes), so the next feature would have broken the build. Of the 98228 gzip bytes unique to that route, a large part is framer-motion. This file uses motion twice, for one thing: a 150ms opacity fade on the result pane when viewMode changes. Importing `motion/react` to get it pulls in framer-motion's complete component library — 73 internal modules — plus its render components, drag/gesture and projection code, none of which is rendered here. `motion/react-m` ships only the element factories: 2 internal modules, and the same initial/animate/transition props, so the fade is unchanged. It exports the elements flat rather than under a `motion.` namespace, so the import becomes `div as Mdiv` and the two JSX tags are renamed to match. I could not measure the resulting bundle here: the local build is OOM-killed (exit 137) with the running containers on the host, so the actual saving is unverified. The CI build reports it in build-reports, and the number in this commit message should be read as a hypothesis, not a measurement. Verified: typecheck clean, lint clean, and the 10 studio UI tests pass — including the pane and navigation specs that exercise the view switch. |
||
|
|
704e33638f |
fix: restore six useEffect dependencies removed while silencing lint
Gitea Actions Runner Test / test-job (push) Successful in 2s
CI / check (push) Successful in 29s
CI / tests-unit (push) Successful in 1m38s
CI / tests-integration (push) Successful in 1m40s
CI / tests-ui (push) Successful in 2m24s
CI / preflight (push) Skipped
CI / deploy (push) Failing after 2m58s
The previous commit dropped biome-ignore comments to clear useExhaustiveDependencies diagnostics and, in doing so, also deleted the dependencies themselves. Six components were left with effects that no longer react to the state they read. Every one of these is a real behaviour regression, not a lint preference: - health-check-client: checkEmulator is a function declaration, so it gets a fresh identity each render. As an effect dependency that re-fires the effect after every setState, polling /api/admin/devops/health in a loop. Wrapped in useCallback so the identity is stable. - article-recovery: reload restarts the autosave timer for the "Retry recovery" button. Without it in the deps that button is a no-op. The counter had been renamed to _reload to satisfy the unused-variable rule. - catalog-integrity-panel: same pattern; refresh starts a new read-only scan, so the rescan control did nothing. - catalog-search: refreshKey re-runs the query after a bulk edit, so results were not refreshed after catalog edits. The selection-reset effect also lost catalogType, so switching catalog no longer cleared the selection. - catalog-image-picker: dropped debounced (the search term) and name (the error reset), so image search and error state no longer reacted to input. - icon-picker: dropped iconImage, so a failed load left the placeholder on the next icon too. Each restored dependency carries a biome-ignore with the reason it is load-bearing, so the diagnostic can be re-derived instead of silently disappearing again. Verified: typecheck, lint clean on all six, unit 3315 passed, integration 20 passed, UI 72 passed / 2 skipped. |
||
|
|
eddb7edea4 |
fix: make all CI jobs pass (integration, ui) and restore prefix dialog reset
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 28s
CI / tests-integration (push) Successful in 1m34s
CI / tests-unit (push) Successful in 1m35s
CI / tests-ui (push) Successful in 2m20s
CI / preflight (push) Skipped
CI / deploy (push) Failing after 2m37s
Three failing test suites blocked CI. All three were test defects, not application bugs. Integration tests (integration/database.test.ts) ------------------------------------------------ The suite set NODE_ENV=test, which makes cache.cached() short-circuit both its Redis read (src/lib/cache.ts:226) and its write (:249). A suite whose stated purpose is exercising the real Redis path therefore never touched Redis. Switched to NODE_ENV=development, the only non-production value src/env.ts accepts, so the shared-cache code paths are genuinely covered. Three assertions then needed correcting for real Redis semantics: - `await cache.cached(...)` followed by `.resolves` can never hold: await yields a value, not a Promise. Assert the value directly. - A cached negative result is stored as the JSON encoding of null, so `redis.get(key)` returns "null", not null. - The news negative-cache key does not exist at all, so `ttl()` returned -2. Now that the write path is live the key is created and the TTL assertion holds as originally written. UI tests (src/app/admin/prefixes/prefix-dialog.tsx) --------------------------------------------------- The form-reset effect had `isOpen` removed from its dependency array. The component returns null when closed, so the effect only ever ran on mount: reopening the dialog no longer cleared the fields and a dismissed-but- unsaved edit reappeared. Two tests in e2e/ui/unsaved-changes.spec.ts caught this. Restored the dependency and documented why it is load-bearing. The remaining edits in this branch drop stale biome-ignore comments that suppressed useExhaustiveDependencies and noArrayIndexKey diagnostics. Where the suppression had been load-bearing for behaviour, the underlying dependency is now listed explicitly rather than silenced. Verified: check (toolchain, audit, lint, i18n, typecheck), unit 3315 passed, integration 20 passed, UI 72 passed / 2 skipped. |
||
|
|
30ff970c38 |
chore: upgrade to pnpm v12, update dependencies, and fix msw v3 typescript types
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Failing after 22s
CI / tests-unit (push) Skipped
CI / tests-integration (push) Skipped
CI / tests-ui (push) Skipped
CI / preflight (push) Skipped
CI / deploy (push) Skipped
|
||
|
|
cede541813 |
fix(catalog): route item-table writes to the catalog they belong to
Gitea Actions Runner Test / test-job (push) Successful in 0s
CI / check (push) Successful in 32s
CI / tests-integration (push) Successful in 1m43s
CI / tests-unit (push) Successful in 1m48s
CI / tests-ui (push) Successful in 2m37s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m16s
The items table is shared between both catalogs, but its four mutating actions were normal-only: moving, reordering, creating and updating a Builder Club offer wrote to catalog_items, so a BC edit either landed in the wrong catalog or hit an unknown column. Pass the catalog from the table through the actions and let the server resolve it. BC rows have no price, points or currency column, so the BC commands strip those fields instead of rejecting them. Moving and reordering now share one command that locks the category and writes the table for the same catalog, and BC writes revalidate the BC route. |
||
|
|
e0efbef30d |
fix(catalog): make the Builder Club catalog read and write its own offers
Gitea Actions Runner Test / test-job (push) Successful in 0s
CI / check (push) Successful in 28s
CI / tests-unit (push) Successful in 1m39s
CI / tests-integration (push) Successful in 1m42s
CI / tests-ui (push) Successful in 2m23s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m6s
The previous commit taught bulk editing and delete-with-restore about the BC catalog. Neither actually worked, and one of them was destructive. `catalog_items_bc` has six columns: id, item_ids, page_id, catalog_name, order_number, extradata. There is no price, points, currency, offer_id, limit or membership column on it. The bulk path read and wrote columns that do not exist, and the UPDATE was aimed at catalog_items while the SELECT came from catalog_items_bc — so a BC category move wrote into the normal catalog. Two tests now pin that pairing: reads and writes have to stay in the same table. Underneath it the BC table was never being read at all. The inline editor fetched `/api/admin/catalog/items?pageId=N` without the catalog, so opening a BC category showed the normal catalog's offers, and the route selected BC rows directly instead of going through the loader, skipping the furni enrichment the table needs to render anything but a bare caption. Both catalogs now take the same path, and the catalog is in the fetch callback's dependencies — without that, a switch keeps reading the previous catalog's rows through a stale closure. Because a BC offer has no price, the editor no longer offers one. The server refuses price, points and currency changes with a readable message instead of letting them reach the database as an unknown-column error, and a BC bulk edit is what it can actually be: a category move. BC deletions also went through a bare DELETE, which made them the one catalog mutation with no way back. They now keep their rows and hand back a restoreId like the normal ones. The catalog is recorded in the audit target rather than in the payload, so a restore can never put a BC row into the normal offers table. |
||
|
|
cebcf440c5 |
feat(catalog): record bulk edits, make deletions reversible, unify the tree read
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 28s
CI / tests-integration (push) Successful in 1m43s
CI / tests-unit (push) Successful in 1m48s
CI / tests-ui (push) Successful in 2m37s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m23s
A bulk offer edit is the catalog mutation that rewrites hundreds of rows at once, and it was the only one writing nothing to the staff activity log: 22 of the 43 catalog actions logged, this one did not. The entry it now writes says what changed, not just that something did, because the log has no undo of its own and "bulk updated 200 offers" cannot answer the question it exists for. Deleting offers had no inverse at all. Every removed row is now kept at delete time and the caller gets a restoreId back, so an accidental multi-select is a click rather than a hand-edit of the table. The undo toast covers the common case; a RecentDeletionsPanel holds the same records so a delete noticed later is still reachable. Three refusals guard it: an id that another offer has since taken, a category that no longer exists (which would leave an offer that sells nowhere and shows under no page), and a delete whose restore record cannot be written — that one rolls back rather than deleting without a way back. Reading the audit row FOR UPDATE is also what stops two restores of one deletion from both inserting. sendCatalogUpdate() overwrote hotel-status.json on every write, so "which imports reached the hotel" was answerable for the last attempt only, and a failure two imports ago was gone by the time anyone looked. That file is now also appended to as a bounded 50-entry tail. The tree route carried four copies of the same page-select-plus-counts shaping, of which the BC branches had already drifted: one counted offers through the VARCHAR-tolerant helper, the other inline and swallowing errors. All of it is one readPages() now, and readFullTree sends both catalogs through one depth computation instead of delegating normal to getTreeFlat while computing BC here — a split that left two implementations behind one function name. getTreeFlat is gone. The BC ancestor walk also went from 20 levels to 50, matching getAncestors, so a deeply nested catalog no longer loses its breadcrumb. Bulk editing reaches the BC catalog, which previously had no way to edit or duplicate offers in bulk. The catalog is part of the operation identity now, so replaying one request key against the other catalog is not mistaken for the same work. Integration tests failed to import: the next/cache mock supplied only revalidatePath, and catalog-totals calls unstable_cache at module scope. |
||
|
|
9550b3d66f |
feat(catalog): make the live catalog self-correcting and honest about failure
Gitea Actions Runner Test / test-job (push) Successful in 0s
CI / check (push) Successful in 29s
CI / tests-unit (push) Successful in 1m34s
CI / tests-integration (push) Failing after 1m34s
CI / tests-ui (push) Successful in 2m19s
CI / preflight (push) Skipped
CI / deploy (push) Skipped
The previous commit made imports update the Studio without a reload, but the guarantee only held inside the tab that started the import and only as long as every read succeeded. Four holes were left, and this closes them. A session that mounted the tree before an import kept the pre-import tree for the rest of its life, because ensureCatalogTreeLoaded() was a once-per-session no-op. It now asks the server whether what it holds is still current. The answer is a revision: sendCatalogUpdate() already runs after every catalog write, so it bumps one, and clients read it on mount, on focus, on a 20s poll and from other tabs over a BroadcastChannel. An import that finishes in another tab, another browser or the job worker now lands here too. A failed read used to be swallowed, which is the worst outcome available: the rail kept showing pre-import counts as if they were current and nothing said so. The snapshot now carries the error, the rail shows it with a retry, and the previous tree stays on screen because stale beats empty. Every settled import pulled the entire flat tree, which is the one payload that grows with the size of the catalog. The revision doubles as the ETag on mode=full, so an unchanged catalog answers 304 and the poll costs a file read. An import could also report success for an offer the hotel will never sell: a hidden or disabled page, an item_ids that misses the furni id, a zero amount. importSingleFurni reads its own row back and reports each of those as a warning, where the import report already is, instead of leaving it to surface as "the import did not work" in the client. Finally, the catalog items table no longer falls back to router.refresh() — onRefresh is now required, so every mutation ends in a refresh of the caller's own data instead of a route re-render that threw away editor state and scroll position. useServerAction keeps its default, because 47 callers across the app depend on it. The 750-line CatalogTree in catalog-tree.tsx was dead code that kept its own stale tree and three more router.refresh() calls; only CatalogIcon and LAYOUT_COLORS are still imported, so the rest is gone. Tests: the store now covers revisions, 304s, probe failures and error recovery; a jsdom test mounts a consumer and asserts the tree updates in place with no navigation; the old organize-imports e2e asserted nothing about the endpoints the code actually calls, and is replaced by one that asserts a cross-tab write lands in the mounted categories without a reload. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> |
||
|
|
28ce0f911c |
fix(catalog): keep the live catalog truthful after every import path
Gitea Actions Runner Test / test-job (push) Successful in 0s
CI / check (push) Successful in 29s
CI / tests-integration (push) Failing after 1m43s
CI / tests-unit (push) Successful in 1m47s
CI / tests-ui (push) Successful in 2m33s
CI / preflight (push) Skipped
CI / deploy (push) Skipped
The live catalog store only covered part of the import surface. A durable job settled, a sync queue drained, a .nitro upload or a clone run left the Studio rail and the stats bar showing pre-import numbers until the page was reloaded, and the Catalog Manager kept a second tree that never saw writes made elsewhere in the session. Every one of those paths now pulls the tree again, and the refresh carries the totals with it: importing writes catalog rows server-side, so the counts the store holds were stale for the rest of the session. - refreshCatalogTree shares one request between concurrent callers and queues a single follow-up read when a write lands mid-flight, so a burst of edits costs at most one extra read. - useFurnitureJobs treats its first payload as a baseline, so a page load no longer replays every past import as "just settled", and hands the settled jobs to the callback. - The Catalog Manager pushes its own mutations into the store and re-reads its active tab when the store changes. - The 30s unstable_cache on the admin totals is now tagged and invalidated from every catalog write, including the import worker, so it no longer survives an import even across a hard reload. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> |
||
|
|
2f7e557d5e |
feat(catalog): add a Studio button to fix missing furnidata entries
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 28s
CI / tests-integration (push) Successful in 1m33s
CI / tests-unit (push) Successful in 1m38s
CI / tests-ui (push) Successful in 2m26s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m1s
"Missing furnidata" was only a filter in the Studio status dropdown, so
imported items whose classname was absent from FurnitureData.json could
be found but not fixed from that screen. Only the Catalog Audit page could
repair them, and only globally.
Adds the same shape of quick action that "no nitro" already had:
- studio-client.tsx: a "N no furnidata" shortcut next to the "N no nitro"
button that sets the missingFurnidata status filter, and a bulk "Add
missing furnidata (N)" button for the selected rows. Both only appear
when there is something to act on. Rows that come back repaired flip
to hasFurnidata: true so the badges and counts update in place; rows
the server reported in errors keep their state.
- resync/route.ts: accepts an optional { classnames: string[] } body to
target exactly the selected rows. classnames are resolved through the
same normalized local index the listing uses to decide hasFurnidata, so
the rows written are the rows flagged as missing. The upsert is already
idempotent, and RCON updateCatalog + updateItems run afterwards so the
emulator picks the new entries up.
Also clears the Studio furnidata cache after a write, which this route
never did: without it the listing kept serving a stale hasFurnidata for
up to the 30s cache TTL, so a repair looked like it had done nothing.
PERMS is now imported from permission-slugs (identical re-export) so the
route no longer pulls next-auth into tests.
- studio-filters.test.ts: pins the missingFurnidata branch, in particular
that an unchecked item (hasFurnidata undefined) is not treated as missing.
The existing ?days / ?missing / ?broken / ?all modes are unchanged; the
body is only consulted when it carries a classnames array.
|
||
|
|
9cc57cddfc |
feat(catalog): update the catalog live after an import, no page refresh
Organising imports, the Studio furni batch, the catalog totals and the "import from a source" stats all used to need a full page reload, or at best a router.refresh() that re-rendered the whole admin route, before anything on screen reflected what the import had just written. - live-catalog-merge.ts (new): pure tree and total arithmetic. Applies a delta of created pages, added offers and moved offers, recomputes depth for the touched subtree, bumps parent child counts and the item totals. Returns the input untouched when a delta is empty, so subscribers can bail out instead of re-rendering. Depth resolution tolerates a parent cycle in a dirty DB and still terminates, matching getTreeFlat. - use-live-catalog.ts (new): one module-level store exposed through useSyncExternalStore, so every consumer shares a single instance without threading a provider through the admin layout. Deltas only apply to the "normal" catalog, so public and public_handlers trees stay separate. seedCatalogTotals() takes the first server value per mode and never overwrites it afterwards, so a later hard render cannot make the header totals jump backwards. - actions/catalog.ts: organizeImportFurni now reports each group through the new OrganizedPageChange, carrying parentId, pageLayout, the icon, isNew and the per-source movedFrom counts, so the client can fold the result into the tree without reading the page back. - organize-imports-dialog.tsx: drops useRouter and router.refresh(); the response is applied as a delta the moment the run finishes. - studio-client.tsx: reads the tree from the store instead of freezing it with useState(initialTree), loads it on mount when empty, and refreshes it once a batch import settles. The batch is server-side and derives its import pages from furnidata, so that one path re-reads the tree via GET /api/admin/catalog/tree?mode=full rather than trusting the delta. - studio/furni/page.tsx: stops calling getTreeFlat() and no longer passes initialTree; the store is the single source of truth for the rail. - import-clone-client.tsx: tracks which items are already present, so present and clonable update per cloned row instead of only at the end. - catalog-manager-dialog.tsx: seeds the totals once and renders the live values, so the header reflects an import that just ran. - e2e/ui/fixtures/entry.tsx: drops the removed initialTree prop. |
||
|
|
944527e078 |
feat(nitro-cleanup): dedupe FurnitureData and clean dangling figure entries
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 36s
CI / tests-integration (push) Successful in 1m58s
CI / tests-unit (push) Successful in 2m16s
CI / tests-ui (push) Successful in 3m8s
CI / preflight (push) Skipped
CI / deploy (push) Failing after 2m30s
Add a gamedata cleanup to the Nitro Cleanup panel: a read-only preview plus an apply run that dedupes FurnitureData classnames and removes rows and figure entries that reference nothing. Three passes run in a fixed order, because cleanFigureMap has to precede cleanFigureData: dropping the part that points at a set is what makes that set unreferenced. A pass refuses to write when it would delete more than maxRemovals rows (default 500) and reports the reason, a wrong asset directory otherwise turns every row into an orphan and one call would empty the file. Passes that would act on empty input (no libraries, no sets) treat that as a missing file rather than as a reason to delete everything. Every write copies the file to a timestamped backup first, so a pass that turns out to be wrong can be undone by hand. The plan reads FurnitureData once and hands the parsed copy to both furniture passes; the file is tens of megabytes in a real deployment. |
||
|
|
17de94d984 |
feat(nitro): convert imported SWF bundles to WebP Lossless
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 31s
CI / tests-unit (push) Successful in 1m59s
CI / tests-integration (push) Successful in 2m19s
CI / tests-ui (push) Successful in 2m56s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m18s
Newly converted .nitro bundles now store their spritesheet as WebP VP8L instead of PNG, so imports land much smaller without changing a single pixel. The texture member and spritesheet.meta.image are both labelled from the actual bytes, never from a caller's assumption. - encode through sharp with lossless and exact, so colour hidden under alpha 0 survives; this mirrors ImageSharp's TransparentColorMode.Preserve - detect PNG/WebP by magic bytes and reject anything the client cannot render, on create, download and upload paths - keep the source format when deriving size-32 sheets, scaling composites and editing metadata, so existing bundles are never silently rewritten - report fidelity in the studio: the compression panel re-encodes with the same options the importer uses, so it cannot drift and invent false warnings, and shows PNG/WebP size estimates convertSwfToNitro and buildSpritesheet are now async, so the worker, the main-thread fallback and every import call site await them. PNG stays supported for existing bundles and icon sidecars are untouched. |
||
|
|
b1eeda8de0 | feat(nitro-cleanup): make delete button visible | ||
|
|
c3ff497050 |
feat(referrals): add referral attribution and daily login rewards
- Track referral attribution at registration via ?ref code with same-IP and duplicate-pair guards - Add daily login rewards with streak tracking, claim flow and sendCurrency payout backed by RCON with DB fallback - Add admin pages for referral settings and the daily reward schedule - Add migration 0033 with tables, seed schedule, settings and ACL grants - Add admin.referrals.* and admin.dailyrewards.* permission slugs - Localize new copy in en, nl and it |
||
|
|
9813dbc2a4 | fix(studio): surface selected nitro cleanup actions in sticky bar | ||
|
|
6c53f4680c | feat(studio): run nitro scans in the background with cancel-re-attach and nightly auto-clean | ||
|
|
9d571e0c29 | perf(studio): stream nitro repair progress over SSE and allow cancelling | ||
|
|
ba81d16f00 | perf(studio): cache nitro scan, stream progress, virtualize cleanup list | ||
|
|
c916e42572 | perf(studio): speed up nitro scan and stop the cleanup panel freezing | ||
|
|
91e649398c | feat(i18n): make admin and catalog components fully translatable | ||
|
|
03774bb411 | feat(i18n): make admin and catalog components translatable | ||
|
|
469f69ddd7 | feat(home): two-column hero with live status panel, explore nav, featured news | ||
|
|
a6e808a099 |
perf(landing): share one SSE socket for online counters, respect reduced motion
Header and hero/stats counters each opened their own EventSource to the online-count stream; a shared subscriber now opens a single socket and multicasts to every mounted counter. The entrance count-up animation skips its requestAnimationFrame loop when the user prefers reduced motion. |
||
|
|
d131124515 |
feat(theme): animate public background with aurora and particles, polish landing pages
Add background_effect (aurora/particles), background_overlay tint and opacity to the theme manager, rendered site-wide by ThemeVars on every public page. Polish the home and register pages (hero mascot, live stat pulse, date pills, photo strip, CTA band, theme-aware register intro, i18n for home/register section). |
||
|
|
5923b736fa |
refactor(studio): extract helper components from OrganizeImportsDialog
Extract FurniThumb, LayoutPreview, and GroupItemList into a dedicated mall-helpers module alongside OrganizeImportsDialog. Preserves all virtualization, drag-and-drop, and preview behavior while reducing the main dialog component size. |
||
|
|
3862649369 |
refactor(catalog): extract AddItemFormFields from CatalogItemsTable
Split the Add Item dialog form fields into its own module, reducing the main table component size while preserving all form fields, validation and handler logic. |
||
|
|
5b4b275b2a |
feat(housekeeping): add per-hotel theme manager with import/export and background
Theme Manager under /admin-next/hotel/theme-manager lets the owner save, apply, rename, delete, import, and export custom themes, plus set a custom site background by URL or upload. Themes are stored in WebsiteSetting/custom_themes JSON so they survive CMS updates. |
||
|
|
3d7278e96d |
perf(studio): header-only nitro validation for fake/broken scan
The cleanup scan used to read every .nitro bundle in full and decompress the large PNG texture just to confirm the file is structurally valid. On directories with hundreds of thousands of bundles this took minutes, the reverse proxy cut the request at its 30s timeoutable with an HTML 504, and the panel then crashed with "Unexpected token '<'". Validate bundles with a cheap header-only read (a few KB, no decompression) that mirrors parseNitroBundle's byte layout; only files whose header looks suspicious get the expensive full parse. Robust against downloads that landed as an HTML error page, truncated or zero-filled files. The scan drops from minutes to seconds on large nitro directories. Also guard the panel against non-JSON (proxy error page / HTML) responses so it reports a clear error message instead of a JSON parse failure. |
||
|
|
438277a17a |
feat(studio): expand nitro cleanup with repair, auto-clean, and orphaned assets
Scan distinguishes fake, broken, and orphaned SWF/icon assets with age metadata, deletes per asset kind, re-downloads broken nitro bundles from configured sources, auto-cleans old fake leftovers, and exports a JSON manifest. Adds rebuild and auto-clean API endpoints with audit coverage and a housekeeping preview route under the hotel domain. Verified: full vitest suite (2213 tests), typecheck, and biome all pass. |
||
|
|
694f87d98c | feat(studio): add nitro cleanup tool for fake and broken bundles | ||
|
|
ce93b92a81 |
fix(avatar): render onError avatars only in client components
AvatarImage is used on server pages via UserAvatarThumbnail but lacked 'use client', so the onError handler on its <img> could not cross the RSC boundary. /login rendered the error page, hanging the news e2e journey until the 240s test timeout. - Mark AvatarImage as a client component like ProfileImage - Replace inline <img onError> on mod/users server pages with the client AvatarImage component |
||
|
|
0f01ebf48b | Organize imports: persist undo across sessions, translate UI to 23 languages | ||
|
|
3a4089a5fa |
Organize imports: drag & drop, virtualization, dupes, undo, days select, localStorage
- Native HTML5 drag & drop between categories with drop-target highlight - Virtualized item lists via @tanstack/react-virtual (fixed 34px rows) - Auto-batching of large groups (500 items / 50 groups per run) - Duplicate detection against the destination page with badge + summary - Per-category layout preview grid - Undo history for item moves (single + batch, tracks source groups) - Days-range selector to load older imports (30/60/90/180) - LocalStorage persistence of user settings (mode, destination, price, days) - Added translation keys across all 25 locales |
||
|
|
644d53ca16 | Rebuild organize imports: move furniture between categories, 500-item cap | ||
|
|
67430e7e9d | Polish catalog studio: undo delete, save status pill, faster totals | ||
|
|
6ea0ec7d99 | Resolve remaining biome lint and formatting errors | ||
|
|
1df1ffc3e9 | Make avatar imager resilient with upstream fallback everywhere |