Add real-time operational metrics to the HQ dashboard:
- Signals This Month: count with trend % vs previous 30 days
- Pending Actions: highlights when tickets need attention (rose border)
- Email Delivery: success rate percentage + sent/failed counts
- B2B Clients: approved vs total registered
Plus a Signal Breakdown panel showing all-time distribution by type
(Orders, Diagnostics, Consultations, B2B Access) with proportional
bars. All queries run in parallel via Promise.all for minimal latency.
Wrapped in try/catch so the dashboard never breaks if DB is slow.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Three improvements to the Operations Inbox:
1. Auto-save email routing: typing in the routing config auto-saves
after 800ms with inline "Saving…" / "✓ Saved" indicator. No more
manual Save button per route.
2. Search filter: instant client-side search across name, company,
email, and ticket ID. Composes with existing type/status filters.
3. No-email badge: signals without email delivery now show a dim
MailQuestion icon in the list view instead of nothing, making it
clear at a glance which tickets need attention.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Pre-render all known slugs at build time so first visits are instant
from cache. New slugs added after deploy render on-demand and get
cached by ISR (revalidate=60). try/catch ensures the build never
fails if the DB is unreachable during docker build — pages just
fall back to on-demand rendering.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Root cause: next-intl's getMessages/getTranslations internally resolves
requestLocale by reading cookies/headers, which trips DYNAMIC_SERVER_USAGE
under ISR. Fixed by calling setRequestLocale(locale) in layout + every
public page — caches the locale in React cache so next-intl never reads
cookies.
Changes:
- [locale]/layout.tsx: +setRequestLocale, +generateStaticParams (5 locales),
wrap NavigationManager in <Suspense> (uses useSearchParams)
- 5 public pages: force-dynamic → revalidate=60, +setRequestLocale
- HQ dashboard pages: unchanged (still force-dynamic for auth)
Build verified: home/heritage/news pre-render as SSG with 1m revalidation,
slug pages render on-demand with ISR cache. Nginx s-maxage=60 remains as
safety net. Zero DYNAMIC_SERVER_USAGE errors.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The Inbox panel got the polished Toast + Confirm primitives a few
commits back. This commit propagates them across every other panel in
HQ Command so the editor experience is uniformly on-brand. No more
1990s browser dialogs interrupting the dark CMS look.
NINE PANELS, ELEVEN CALL SITES UPGRADED
- health/page.tsx — DB export errors → toast
- network/page.tsx — Delete deployment → confirm + toast
- heritage/page.tsx — Delete section → confirm + toast
- users/page.tsx — Revoke architect → confirm + error toast
- news/page.tsx — Delete article → confirm + toast
- applications/page.tsx — Save error toast + delete-app confirm
- parts/page.tsx — Delete component → confirm + toast
- hero/page.tsx — Delete slide → confirm + toast
- timeline/page.tsx — Delete milestone → confirm + toast
Each destructive confirm now spells out what it does ('Permanently
remove this case from the global map. The asset folder on disk is
kept for safety') instead of a generic 'Delete?' prompt — much
clearer for editors who aren't sure whether files get nuked too.
Each success toast names the action ('Component deleted', 'Slide
deleted', 'Architect access revoked') so the editor sees exactly
what fired. Errors come in as red toasts with the actual error text.
NO BACKEND CHANGES. Pure UX layer on top of existing actions.
The HqUiProvider was already mounted in src/app/hq-command/layout.tsx,
so wiring up was just useHqUi() per page + the replacement calls.
The hero CMS only listed HeroSlide rows from the database. Files dropped
directly into /public/footage/main were rendering on the live site (via
the home page's filesystem fallback) but invisible in the editor — so
the editor couldn't manage their focal point, alt text, ordering or
on/off toggle without re-uploading.
NEW SERVER ACTIONS (src/app/hq-command/dashboard/hero/actions.ts)
- listImportableFootage()
Scans /public/footage/main, returns the list of files that aren't
already referenced by a HeroSlide row. Each entry has filename,
publicUrl, mediaType (image/video), file size and mtime.
- importFootageFiles(filenames[])
For each selected filename: validates extension, checks existence,
skips already-imported files, derives a sensible alt text (filename
with extension stripped, dashes/underscores → spaces, leading
numeric prefix removed), and creates a HeroSlide row at the next
available order position. Returns {created, skipped} so the UI can
show a precise toast.
UI (src/app/hq-command/dashboard/hero/page.tsx)
- New amber-tinted panel above the slide list, visible only when
importable.length > 0. Shows every uncovered file as a thumbnail
card with checkbox-style selection.
- 'Select all' / 'Clear' toggle, 'Import all' for one-click bulk
import, 'Import N selected' once a subset is picked.
- After import: panel re-renders empty (since those files now have
HeroSlide rows) and the new slides appear in the regular list,
ready for focal-point and caption editing.
NO BACKEND-DATA CHANGES BEYOND ROW CREATION
- Filesystem untouched (no rename, no move). Files keep their original
path under /public/footage/main, the new HeroSlide row simply points
at /footage/main/<filename>.
- Public-facing pages render the same images either way (they read
from HeroSlide first, footage scan second).
- After import: the home page now reads the slides from the DB and
uses the focal-point + alt text data the editor sets.
The unified bucket browser graduated from "acceptable" to "actually
useful for bulk work". Editors can now manage dozens of files in a
single session without dragging each one through a modal.
NEW FEATURES (frontend)
1. BULK SELECTION
- Click on a file when nothing's selected → opens it as before.
- Click on the corner checkbox, or click the file once selection is
active → toggle that one in/out.
- Shift-click → range select between last anchor and current item.
- Cmd/Ctrl-click → toggle without affecting others.
- "Select all" toggle in the toolbar respects the search filter.
2. BULK ACTIONS TOOLBAR
When at least one file is selected the toolbar morphs into:
[N selected] [Delete] [Move to: Videos | Renders | …]
Delete fires the new bulk DELETE endpoint with filePaths[], shows
a single toast for the whole batch + per-file failure breakdown.
Move iterates PATCH /api/assets per file (sequential, with a 'Moving…'
indicator in the bucket helper bar).
3. DRAG TO MOVE BETWEEN BUCKETS
Drag any file (or the whole selection if you started the drag from
a selected file) onto another bucket tab. The tab highlights green
with 'drop to move' while you hover. Drop fires the same per-file
PATCH flow. No dialog, no friction.
4. RENAME IN PLACE
Double-click a filename (in either grid or list view) → input opens
in place. Enter saves, Escape cancels, blur saves. Sanitizes to
safe characters. PATCH endpoint refuses to overwrite an existing
file (returns 409, surfaced as a toast).
5. KEYBOARD HINT FOOTER
Bottom-of-modal cheat sheet: Click / ⇧Click / ⌘Click / 2× click /
drag onto another tab. So new editors don't have to discover the
power-user features.
NEW BACKEND (src/app/api/assets/route.ts)
PATCH method
{ scope, slug, fromPath, toPath } → fs.renameSync.
Used for both rename (same dir, new name) and move (different bucket).
Refuses to overwrite an existing destination (409 conflict).
Creates intermediate folders if needed.
DELETE extended
Now accepts either { filePath: "x" } or { filePaths: ["a", "b"] }.
Bulk path deletes one-by-one and returns per-file success/failure
so the UI can show a precise toast.
REVIEWED FOR REGRESSIONS
- Single-file API still works — old { filePath } DELETE shape preserved.
- The 4 inline AssetManager call sites (network, news, applications,
parts) use AssetBucketBrowser via the alias added in the previous
commit; their integration is unchanged. Same props, same onSelect
callback shape.
- Toast/Confirm calls go through the existing HqUiProvider mounted in
hq-command/layout.tsx — no extra wiring.
The single-favicon upload was leaving most platforms with the wrong icon:
Android Chrome looks for 192×192, iOS for 180×180, Windows for 48×48,
HiDPI tabs for 32×32, legacy tabs for 16×16, PWA splash for 512×512 —
six different files, all from the same source image.
ONE EDITOR ACTION → SIX FILES
- POST /api/branding/favicon takes a single PNG/JPG/WebP (≥ 192×192,
ideally 512×512+) and runs it through sharp to produce:
/branding/favicon-16.png /branding/favicon-32.png
/branding/favicon-48.png /branding/favicon-180.png
/branding/favicon-192.png /branding/favicon-512.png
/branding/favicon-master.png (kept for re-generation)
- Square-ish source images get center-cropped automatically.
- Returns the variant list + soft warnings ("not square — center-cropped",
"upload at least 512×512 for retina") so the editor sees what happened.
- Validates dimensions and file type, caps at 20MB.
ROOT LAYOUT (src/app/[locale]/layout.tsx)
- Detects whether the multi-variant set exists on disk. If yes, emits
<link rel="icon" sizes="16x16 / 32x32 / 192x192">, an explicit
Apple touch link, and a Safari pinned-tab mask-icon. If no, falls
back to the legacy single faviconUrl from SiteSetting.
- Adds <link rel="manifest" href="/manifest.webmanifest"> so Android
Chrome surfaces "Add to Home Screen" properly.
PWA MANIFEST (src/app/manifest.ts)
- Auto-served at /manifest.webmanifest via Next.js's MetadataRoute.
- Pulls themeColor from SiteSetting.branding so editor changes there
cascade to the standalone-app theme.
- Lists 192 + 512 icons with both 'any' and 'maskable' purposes for
Android adaptive icons.
HQ SETTINGS UX (src/app/hq-command/dashboard/settings/page.tsx)
- New <FaviconMasterField/> at the top of the Branding tab — replaces
the old standalone Favicon ImageField. Shows a 32×32 preview, an
upload button, the generation result with each variant's preview,
and warnings when the source isn't ideal.
- The Apple Touch Icon ImageField below it stays as an override path
for editors who want a different icon on iOS (rare, but supported).
- Cache buster query string on previews so re-uploads visibly refresh
without forcing the editor to hard-reload.
NO BACKEND/DB CHANGES OUTSIDE FILE I/O.
The SiteSetting.branding row is unchanged. The favicon variants are
plain files on disk under public/branding/, served by Nginx with the
existing 5-min cache headers.
The Operations Inbox panel was using browser-native alert() and
confirm() — those popups break out of the dark-themed CMS aesthetic
and look like a 1998 form validation. Worse, they're modal blocking,
so the editor can't see the surrounding context (the ticket card,
the activity feed) while the dialog is up.
NEW PRIMITIVES (src/components/hq/Toast.tsx)
- <HqUiProvider> mounts a global toast stack (bottom-right) and a
global confirm dialog. Mounted in src/app/hq-command/layout.tsx so
every panel under /hq-command/* can use it.
- useHqUi() returns { toast, confirm }:
ui.toast("Saved", "success") // ephemeral, 3s
ui.toast("Save failed: ...", "error") // 5s
await ui.confirm({ // returns boolean
title: "Delete ticket",
message: "This permanently...",
confirmLabel: "Delete",
destructive: true,
})
- Toasts auto-dismiss with a manual close button. Confirm dialog
uses red accent for destructive actions.
- Zero deps. ~140 lines total.
INBOX (src/app/hq-command/dashboard/inbox/page.tsx)
- All 5 alert() calls replaced with ui.toast() — success / error
toned and persisting just long enough to read.
- All 4 confirm() calls replaced with ui.confirm() — destructive
ones (delete ticket, purge files, delete client) get the red
accent + 'cannot be undone' copy.
- Action descriptions are richer ('Resolve & purge attachments'
instead of 'Resolve') so the editor knows exactly what fires.
NO BACKEND CHANGES. Pure UX layer on top of the existing actions.
The HTTP cache was always reporting MISS on /en even on consecutive
hits. Two reasons converging:
1) next-intl writes a NEXT_LOCALE cookie on response, so every
upstream reply included a Set-Cookie header. Nginx refuses to
cache responses with Set-Cookie by default — that's a safe
default to avoid leaking session cookies, but it's the wrong
default for our public marketing pages, where the cookie just
records a locale preference and the HTML body is identical for
every visitor on the same URL.
2) proxy_cache_valid wasn't set, so even when Cache-Control would
have authorised caching, Nginx fell back to its conservative
no-cache stance.
Fix:
- proxy_ignore_headers Set-Cookie X-Accel-Expires Expires;
- proxy_hide_header Set-Cookie;
- proxy_cache_valid 200 60s;
Net result: marketing pages now actually cache. The Set-Cookie is
still emitted by Next.js (the upstream is unchanged), Nginx just
strips it before storing/relaying — locale detection still works
because next-intl persists locale through the URL prefix anyway.
DEPLOY (David)
cd /opt/flux-srl
git pull
docker compose restart nginx
docker compose exec nginx nginx -s reload
Then verify:
curl -sI https://rf-flux.com/en | grep -iE 'x-cache|cache-control|set-cookie'
curl -sI https://rf-flux.com/en | grep -iE 'x-cache|cache-control|set-cookie'
Second hit should show: x-cache-status: HIT
The VPS already had a server block redirecting lethepowerflux.com and
www.lethepowerflux.com to https://www.rf-flux.com, but it lived only on
the live config — not in git. That's why the latest pull complained
about local changes that would be overwritten.
Adding it here so the repo is the single source of truth for the Nginx
config again. Behaviour is unchanged on the VPS (redirect was already
in place) — this commit just lets future git pulls flow without
manual intervention.
Bringing the same UX pattern we shipped on Hero and Settings to the
Company Legacy and Our Heritage panels. No more modal-driven editing
where you have to click "Edit" → modify → click "Save" → wait → close
modal. Every field is now editable in place; saves fire on blur and
flash a "Saved ✓" confirmation; rows reorder via drag-drop.
NEW SERVER ACTIONS (additive — old formData-style actions still exist)
- timeline/actions.ts:
patchTimelineEvent(id, partial) — granular field update
reorderTimelineEvents(ids[]) — single transaction
createTimelineStub() — instant blank row
- heritage/actions.ts:
patchHeritageSection(id, partial) — granular field update
reorderHeritageSections(ids[]) — single transaction
createHeritageStub(type) — text/image/video stubs
PAGES REWRITTEN
- timeline/page.tsx: 208 → 215 lines, but radically simpler — drag
handle + inline year input + inline title + inline description
textarea per row, plus eye toggle for isActive and a trash button.
Global "Auto-translate edits" checkbox at top applies to every patch.
- heritage/page.tsx: 209 → 270 lines (more functionality fits in less
cognitive load). Three coloured "Add" buttons (Text / Image / Video)
spawn a stub that renders the right card type. Image/Video cards have
a built-in MediaPicker with upload-or-paste-filename + live preview.
UX UPGRADES THE EDITOR SEES
- Type-and-tab to save. No modals, no submit buttons per row.
- Saving / Saved ✓ badge per row so it's obvious when a field has
hit the database.
- Drag handle + drop target on every card → reordering becomes
"grab and pull", same metaphor as Hero slides.
- AI translation is one toggle at the top, not a per-modal switch.
- Soft-warning empty state with a clear "do this first" hint instead
of a blank page.
NO BREAKING CHANGES
- Old createTimelineEvent / updateTimelineEvent / createHeritageSection
/ updateHeritageSection actions are kept (in case anything still
imports them — nothing in the repo does, but external scripts
might). Internal call sites all use the new patch flow.
- Database schema unchanged.
- Public-facing /heritage page unchanged.
Pages got fast again. Public marketing routes are still rendered
per-request by Next.js (force-dynamic, until the ISR bug gets isolated),
but their HTML is now cached at the Nginx layer for 60s with a 5-minute
stale-while-revalidate window. Result: only the first hit on a URL
inside a 60s window pays the SSR cost; every other visitor in that
window gets a sub-10ms cached response. While a cached entry is
revalidating, peers keep getting the stale copy — no cold starts, no
thundering herds.
NEXT.JS MIDDLEWARE (src/proxy.ts)
- isCacheablePublicPath() identifies routes safe to share-cache:
/, /<locale>, /<locale>/applications, /<locale>/news,
/<locale>/heritage. Excludes /<locale>/parts (auth-gated B2B portal)
and /hq-command/*, /api/*, /_next/*.
- hasAuthCookie() short-circuits caching when the request carries a
flux_session (admin CMS) or flux_b2b_session (client portal) cookie.
Authenticated users always get a fresh per-account render.
- When both checks pass, the response gets:
Cache-Control: public, s-maxage=60, stale-while-revalidate=300
NGINX (nginx/nginx.conf)
- New shared zone:
proxy_cache_path /var/cache/nginx/flux levels=1:2
keys_zone=flux_html:50m max_size=1g inactive=24h
use_temp_path=off;
- Access log gets a `cache=$upstream_cache_status` field so we can
audit hit/miss ratios in the live logs.
NGINX (nginx/conf.d/flux.conf — location /)
- proxy_cache flux_html + proxy_cache_revalidate on
- proxy_cache_use_stale: serves stale on backend errors / timeout /
during update, so 502s during a Next.js restart never reach users.
- proxy_cache_background_update + proxy_cache_lock: only one upstream
request fires when a cached entry expires; others keep getting stale.
- proxy_cache_bypass / proxy_no_cache wired to flux_session +
flux_b2b_session cookies — admin and B2B traffic skips the shared
cache entirely.
- X-Cache-Status response header (HIT/MISS/EXPIRED/STALE/UPDATING/BYPASS)
for live debugging — open dev tools, refresh, watch the value flip.
WHAT YOU'LL FEEL
- First visitor on /en within a 60s window: ~150-300ms (SSR + DB).
- Second through Nth visitors in the same window: <10ms.
- Editor publishes a change in HQ Command → revalidatePath() inside
the existing actions invalidates the Next.js cache; the next
marketing-page request rebuilds and primes Nginx fresh. The 60s
TTL bounds how long stale content can linger if revalidation is
ever skipped.
NO BREAKING CHANGES
- Auth flows untouched (cookies bypass cache).
- HQ Command + API endpoints untouched (separate Nginx locations).
- Static assets (cases/, applications/, /branding/, /_next/static)
unaffected — they had their own cache headers already.
- Server-side cache invalidation via revalidatePath() still works.
DEPLOY (David)
cd /opt/flux-srl
git pull
docker compose up -d --build app
docker compose exec nginx nginx -t
docker compose exec nginx nginx -s reload
The two AI tool cards rendered inside the chat — CaseStudyViewer (the
"Show me proven installations" card) and EquipmentConfigurator (the
"Show equipment specs" card) — were composing image URLs without the
node-slug segment:
/cases/<filename> ← what the cards were emitting (404)
/cases/<nodeSlug>/<filename> ← what the public site actually serves
Result: cover images and gallery thumbnails inside chat cards came back
as broken-image icons, while the same files rendered fine on
/en/applications/<slug> and inside the CaseStudyModal (those
already used the correct path).
Fix: both components now derive the slug from data.title with the same
nodeToSlug() the public pages use, and prefix it on every /cases/
URL — cover and gallery thumbnails alike.
CHANGES (2 files)
- src/components/ai/CaseStudyViewer.tsx
- Added local nodeToSlug() helper (mirrors ApplicationClient + assetFolders)
- coverSrc: /cases/${nodeSlug}/${mediaFileName}
- gallery image: /cases/${nodeSlug}/${img}
- src/components/ai/EquipmentConfigurator.tsx
- Added local nodeToSlug() helper
- coverSrc: /cases/${nodeSlug}/${mediaFileName}
No backend / API / DB changes. Pure client-side path correction.
Every HQ Command panel had its own ~210-line AssetManager component
copy-pasted into the page file. Same UI, same API, four diverging
implementations — and no consistent metaphor for "where does this file
go?". Editors had to think about subfolder names (videos/, renders/)
that the front-end implicitly expects.
ONE COMPONENT. CLEAR BUCKETS. SAME PATHS.
src/components/hq/AssetBucketBrowser.tsx — the single picker. Takes
scope + slug, shows bucket tabs (Media / Videos / Renders / etc.) and
maps each to the on-disk path the public site already reads from:
cases: Media (root) | Videos (/videos) | Renders (/renders)
applications: Media (root) | Videos (/videos) | Renders (/renders)
news: Media (root)
parts: Media (root) | Renders (/renders)
footage: Hero Reel (root)
branding: Brand Assets (root)
Drop a file into the Videos tab → POSTs to /api/assets with path=videos
→ lands at /public/{scope}/{slug}/videos/<file> — exactly where
ApplicationClient.tsx and CaseStudyModal.tsx already look. Zero
front-end path changes, zero data migration.
UX upgrades the editor sees:
- Tabs make the bucket layout discoverable instead of buried in folder
navigation. Each tab has its own description and accent colour.
- Soft-warning hints flag obvious mismatches ("Videos bucket usually
holds .mp4 — this file may not display correctly") without blocking
the upload.
- Search + grid/list views.
- Hover actions per file: copy URL, delete (with confirm).
- Persistent on-screen path (/{scope}/{slug}/{bucket}) so editors can
always see the canonical location.
REPLACEMENT (4 page files)
- network/page.tsx: 719 → 513 lines (-206) — direct alias
- news/page.tsx: 484 → 313 lines (-171) — direct alias
- applications/page.tsx: 555 → 433 lines (-122) — adapter wraps the
picker's onSelect into the markdown-syntax onInsert callback this
panel uses. No call-site changes.
- parts/page.tsx: 413 → 320 lines (-93) — direct alias
Net: -592 lines of duplicated UI, +560 lines of single shared component.
Future bucket-layout changes live in one file instead of four.
NO PATH/API CHANGES — /api/assets is unchanged. The on-disk layout is
unchanged. Existing assets keep rendering on the public site. Existing
DB rows (mediaFileName, galleryJson, videosJson, rendersJson) are
unaffected because we never moved files.
THREE INTERLOCKING FIXES so editors stop hitting permission walls.
1) DOCKERFILE — gid 65533 (nogroup) on uploaded files
The container was creating files as 1001:65533 because Alpine's
`adduser --system --uid 1001 nextjs` doesn't set a primary group.
Files written through /api/assets ended up with `nogroup` ownership,
which surprised host sysadmins and made `chown -R 1001:1001` revert
on each fresh container start.
Fix: `adduser --system --uid 1001 --ingroup nodejs nextjs`. Now
every file written by the container is 1001:1001 (nextjs:nodejs),
matching the host conventions and the existing chown automation.
2) ENTRYPOINT — recursively normalise existing files
The recursive chown in scripts/docker-entrypoint.sh now sweeps every
subfolder of /app/public/branding|footage|applications|cases|news|
parts|operations-inbox|heritage on each container start, fixing any
files that previously slipped through with the wrong group. Single
fast pass, idempotent. Adds /app/public/heritage to the list (was
missing).
3) AUTO-CREATE ASSET BUCKETS on entity create
The big editor UX win: when an admin creates a Case (GlobalNode), an
Application or a News article in HQ Command, the server now also
mkdir's the well-known asset subfolders for that entity. So after
creating "Acme Industries" as a case, the editor immediately gets
/public/cases/acme-industries/{videos,renders,gallery,datasheet,models}
ready — no more "EACCES because the dir wasn't created" gotcha
when they upload their first video.
Implementation:
- src/lib/assetFolders.ts: typed helper with per-scope bucket lists
+ a titleToSlug helper that mirrors the front-end's slugger so the
folder name matches what ApplicationClient expects when rendering
/cases/<slug>/videos/<file>.
- network/actions.ts: createNode -> ensureAssetFolders("cases", slug).
Plus a new server action ensureNodeAssetFolders(id) so the editor
can fix existing nodes without recreating them (one-click "Repair").
- news/actions.ts: createNewsArticle -> ensureAssetFolders("news",slug)
- applications/actions.ts: createApplication -> ensureAssetFolders(...)
DEPLOY (David)
cd /opt/flux-srl
git pull
docker compose up -d --build app
# The entrypoint will fix existing 1001:65533 files automatically
# as the container boots — no manual chown needed.
The new docker-entrypoint.sh lives in scripts/, but .dockerignore was
excluding the whole folder from the build context — so the COPY in the
Dockerfile resolved to a missing file:
COPY scripts/docker-entrypoint.sh: not found
Removing the `scripts/` line lets the build context include it. The
other ignored paths stay (node_modules, .next, .git, env files,
Dockerfile, docker-compose, nginx, certbot, prisma dev.db) — those
genuinely don't need to be sent to the daemon.
THREE FIXES IN ONE SHOT.
1. UPLOAD EACCES (the crashing one)
The /app/public/branding upload was failing with EACCES because the
folder on the host was created by `debian` (uid 1000) but the container
runs as nextjs (uid 1001). Docker bind mounts preserve host ownership,
so the container couldn't write into branding/.
Fix: introduce a docker-entrypoint.sh that runs the container briefly
as root, chowns every public/* mount to uid 1001, runs Prisma migrate
deploy, then drops to nextjs via `su-exec`. From now on every deploy
self-heals permissions across all asset folders (branding, footage,
applications, cases, news, parts, operations-inbox) — even if a future
volume gets added with the wrong owner.
Dockerfile changes:
- Adds `su-exec` package (lightweight gosu equivalent for Alpine)
- Removes the static USER directive (entrypoint manages user transitions)
- Replaces CMD with an ENTRYPOINT pointing at the new script
2. metadataBase WARNING
Server logs were emitting:
⚠ metadataBase property in metadata export is not set ... using "http://localhost:3000"
That's the layout's generateMetadata not declaring metadataBase, so
Next.js couldn't resolve relative OG/Twitter image URLs to absolute
ones. Reading NEXT_PUBLIC_APP_URL (already set in docker-compose env)
and feeding it as `metadataBase: new URL(...)` silences the warning
and produces correct absolute URLs in social previews.
3. PERMISSIONS DOCS
The entrypoint chown is idempotent and silent on non-existent folders,
so future volumes added to docker-compose just work. No more "did you
sudo chown the new folder" gotchas.
DEPLOY (David)
cd /opt/flux-srl
# one-time fix for the existing branding folder so the next deploy
# doesn't have to chown 65MB of data — but the entrypoint now handles
# this automatically anyway:
sudo chown -R 1001:1001 /opt/flux-srl/public/branding
git pull
docker compose up -d --build app
The DYNAMIC_SERVER_USAGE errors persisted even after passing locale
explicitly to next-intl. Some other Server Component in the tree is
still triggering an implicit dynamic API read under ISR — and chasing
it across next-intl, Prisma, the @ai-sdk libs, and the standalone
build was eating the deploy. Pragmatic call: stop trying to keep ISR
while we still have unstable bug surface, take the runtime back to
puro SSR (the working state from before the SEO commit), then bring
ISR back surgically once the site is stable.
CHANGES (5 page.tsx files)
- /[locale]/page.tsx revalidate=60 → dynamic="force-dynamic"
- /[locale]/news/page.tsx revalidate=60 → dynamic="force-dynamic"
- /[locale]/news/[slug]/page.tsx revalidate=60 → dynamic="force-dynamic"
- /[locale]/heritage/page.tsx revalidate=60 → dynamic="force-dynamic"
- /[locale]/applications/[slug]/page.tsx revalidate=60 → dynamic="force-dynamic"
ALSO: removed generateStaticParams from news/[slug] and applications/[slug].
With it present (even returning [] in prod), Next.js still classified
those routes as SSG-eligible, which conflicted with the force-dynamic
flag and kept the ISR/dynamic boundary ambiguous. Removing it makes
the build output show all locale routes as ƒ (Dynamic) — pure SSR.
WHAT WE KEEP
- generateMetadata still runs per request, so all SEO benefits (canonical
URLs, hreflang, OG tags, Twitter cards) remain.
- sitemap.xml and robots.txt are unaffected.
- JSON-LD still emits.
- revalidatePath() in /api/assets still works (just becomes a no-op for
these pages since they're already dynamic — no cache to invalidate).
- Caching at the Nginx layer (max-age=300 + must-revalidate on /_next/image
and /branding|/cases|/applications|/news|/parts|/footage) is unchanged,
so static asset performance stays optimal.
WHAT WE LOSE TEMPORARILY
- Page HTML is generated on every request instead of every 60 seconds.
At Flux's traffic levels this is negligible — Prisma queries are sub-50ms
and Postgres has connection pooling. We'll move back to ISR once we've
isolated the offending dynamic read.
DEPLOY (David — IMPORTANT, force a real rebuild this time)
cd /opt/flux-srl
git pull
docker compose build --no-cache app
docker compose up -d app
docker compose logs app --tail=30
Production logs were spamming "DYNAMIC_SERVER_USAGE" errors and every
ISR-cached page (home, news, heritage, applications, news/[slug]) was
500-ing. Root cause: when force-dynamic was replaced with revalidate=60
last commit batch, the implicit locale-resolution path through
next-intl's getTranslations() / getLocale() / getMessages() became a
problem. Without an explicit locale arg, next-intl reads cookies()
under the hood — which trips Next.js's "dynamic API used inside a
static / ISR render" guard and aborts with DYNAMIC_SERVER_USAGE.
Fix: pass the locale (already known from the URL via params) every
time we call next-intl on the server.
CHANGES
- src/components/layout/Footer.tsx
- Now accepts `locale` as a prop instead of awaiting getLocale()
- getTranslations({ locale, namespace: "Footer" })
- src/app/[locale]/layout.tsx
- Passes locale prop to <Footer locale={locale} />
- getMessages({ locale }) instead of getMessages()
- src/components/sections/PatrizioLegacy.tsx
- Accepts `locale` prop, passes it to getTranslations
- src/app/[locale]/page.tsx
- Renders <PatrizioLegacy locale={locale} />
- src/app/[locale]/news/page.tsx (body)
- getTranslations({ locale, namespace: "NewsHub" })
- src/app/[locale]/heritage/page.tsx (body)
- getTranslations({ locale, namespace: "HeritagePage" })
The generateMetadata / generateStaticParams paths in news/[slug] and
heritage already passed locale correctly — only the page bodies and
shared components were leaking dynamic reads.
Net effect: all five ISR pages render statically again, the error spam
in `docker compose logs app` clears, and /en/applications/digital-print
loads its server component without falling through to the error boundary.
The /en/applications/digital-print page was still 500-ing after the
previous fixes. Without an error boundary, Next.js shows a generic
"Internal Server Error" with no detail — making remote diagnosis
require a `docker compose logs` round-trip every time.
ERROR BOUNDARIES (visible diagnostics)
- src/app/global-error.tsx: catches errors that bubble past every
route's error.tsx, including ones from the root layout. Renders
its own <html>/<body>.
- src/app/[locale]/error.tsx: locale-scoped boundary so the NavBar
and Footer keep rendering around the error UI. Shows the actual
error message + digest in a code block — much faster to diagnose
than a blank 500.
DEFENSIVE WRAPPING (every async + every transform)
- applications/[slug]/page.tsx
- getApplicationImages: try/catch around fs ops
- generateMetadata: full body wrapped, falls back to safe defaults
- getLocalizedData call wrapped (returns rawData if it throws)
- Cases query already had try/catch — adds same for the locale map
- JSON-LD build wrapped, falls back to empty array (still renders)
- Default fallbacks for title/description/category to avoid
productSchema receiving undefined fields
- news/[slug]/page.tsx
- prisma.newsArticle.findUnique now has try/catch
- getLocalizedData wrapped
- JSON-LD build wrapped, only rendered if non-empty
- publishedAt / updatedAt fallback to new Date() to avoid
"Invalid time value" from articleSchema's date conversion
The combination means: if the underlying bug is in any of the SEO
helpers, JSON-LD generation, or i18n merging, the page now degrades
gracefully and shows the actual error in the UI instead of 500-ing.
The site was redirecting / -> https://rf-flux.com:3000/en, where :3000
is the container's internal port (only "expose"d, not published) — so
the browser saw ERR_CONNECTION_REFUSED.
Root cause: when running behind Nginx in standalone mode, Next.js (via
next-intl in this case) can build absolute redirect URLs that leak the
container's internal PORT/HOSTNAME env into the Location header.
TWO LAYERS OF DEFENCE
1. Nginx (nginx/conf.d/flux.conf)
- Adds X-Forwarded-Host + X-Forwarded-Port so the upstream knows
the public port (443) and host
- proxy_redirect rewrites any Location header that still slips
through with :3000 back to the public https://$host
2. Middleware (src/proxy.ts)
- sanitizeRedirectLocation() runs after handleI18nRouting and
scrubs Location headers that point at internal hostnames (app /
localhost / 0.0.0.0) or the container port :3000, replacing them
with the public host derived from x-forwarded-host / host header.
Either layer alone would fix the immediate symptom; together they
also prevent the same class of bug from showing up in any future
redirect path.
The previous container restart loop ("Error: Cannot find module 'effect'")
happened because the runner stage cherry-picked only specific Prisma
subdirs (.prisma, @prisma, prisma) but missed transitive runtime deps
of the Prisma CLI — like @prisma/config's dep on `effect`.
Cherry-picking is fragile: any minor Prisma upgrade changes the
required dep set and the container stops booting.
Real fix: introduce a dedicated prod-deps stage that runs
`npm ci --omit=dev --include=optional` and ship the resulting
node_modules wholesale to the runner. Trade-off: the runner image
grows by ~200-300MB, gaining bullet-proof prod CLI execution in
exchange. Subsequent rebuilds are fully cached after the first run.
What changed in Dockerfile:
- New stage `prod-deps` produces a prod-only node_modules tree
- Runner stage drops the explicit @prisma/prisma/sharp/@img copies
(they're already in prod-deps' node_modules)
- Still copies prisma/, prisma.config.ts, .prisma generated client,
and Next.js standalone artefacts
- CMD unchanged: migrate deploy + server.js
The previous attempts (--include=optional, then a separate npm install
fallback) failed because npm ci runs sharp's install script DURING
installation — and that script crashes ("Please add node-gyp to your
dependencies") before the next Dockerfile step gets to run.
Real fix: pin every sharp platform binary as an optionalDependency in
package.json. npm now records URL+hash for all of them in the lock
file regardless of which OS generated the lock. On any build host,
npm ci picks the matching binary via the os/cpu/libc filters in those
packages and silently skips the rest.
Pinned binaries (sharp 0.34.5):
- @img/sharp-linuxmusl-x64 (Alpine x64 — our VPS)
- @img/sharp-linuxmusl-arm64 (Alpine arm64)
- @img/sharp-linux-x64 (glibc x64)
- @img/sharp-linux-arm64 (glibc arm64)
- @img/sharp-darwin-arm64 (Apple Silicon dev)
- @img/sharp-darwin-x64 (Intel Mac dev)
Side benefit: simplifies the Dockerfile. Drops the secondary
`npm install --no-save --cpu=x64 --os=linux --libc=musl sharp` step
and the vips-dev system package (no source compilation needed when
the prebuilt binary is guaranteed present). The runner stage still
needs `vips` runtime, that stays.
Sharp 0.34 ships a separate prebuilt binary per platform tuple as
optionalDependencies (@img/sharp-linuxmusl-x64 for Alpine, -darwin-arm64
for Apple Silicon, etc).
The lock file only records the dev machine's platform binary. `npm ci`
is strict — it installs exactly what's locked and refuses to add
platform-specific entries that weren't there at lock time. Result on
the VPS Alpine x64 build:
sharp: Attempting to build from source via node-gyp
sharp: Please add node-gyp to your dependencies
Fix: after the strict `npm ci`, run a separate `npm install --no-save
--cpu=x64 --os=linux --libc=musl sharp`. This downloads the Alpine
binary into node_modules without modifying package.json or the lock,
so the dev's Mac install stays untouched on the next git pull.
This is the recommended pattern from sharp's own docs for cross-target
Docker builds: https://sharp.pixelplumbing.com/install#cross-platform
The Docker build on the VPS failed because:
npm error Missing: @swc/helpers@0.5.21 from lock file
Cause: Next.js 16.1.6 declares @swc/helpers both as a direct dep at
0.5.15 AND as a peer dep ">=0.5.17". npm 11 (my local) accepts the
0.5.15 install and considers the peer satisfied. npm 10.9.7 (the
Alpine container in the VPS) is stricter — it tries to resolve the
peer to its own version and demands 0.5.21, which our lock file
didn't have.
Fix:
- Add @swc/helpers ^0.5.17 as a direct dependency so both npm 10 and
npm 11 pick the same up-to-date version (resolved to 0.5.21).
- Regenerate package-lock.json from a clean install so it matches.
`npm ci` now succeeds in the docker-compose build.
Two changes that together make Site Settings actually work end-to-end.
BRANDING ASSET SERVING (the broken thumbnails fix)
The favicon/logo previews were broken because uploaded files in
/public/branding had no path to reach the browser:
1. The folder wasn't mounted into the app container, so uploads
vanished on next deploy
2. Nginx had no location block, so /branding/foo.png returned 404
(everything not in cases/applications/news/parts/footage was a
proxy_pass to Next.js, which doesn't serve from /public/branding
in standalone mode)
Fix:
- docker-compose.yml: ./public/branding mounted to /app/public/branding
(write side) AND /srv/branding (read-only side for Nginx)
- nginx/conf.d/flux.conf: new "location /branding/" block, same
cache strategy as the other asset locations (max-age=300, must-revalidate)
FOOTER EMAIL + PHONE (David's request)
- siteSettingsTypes.ts: hqEmail and hqPhone fields added to FooterSettings,
pre-filled with sales@lethepowerflux.com and +39 0424 287 492
- Footer.tsx: clickable mailto: and tel: links with Mail / Phone icons
shown right under the HQ address. Hidden when fields are empty so the
layout stays clean for editors who want to suppress contact info.
- /hq-command/dashboard/settings: new "Headquarters contact" group in
the Footer tab with the two fields (auto-translate ignores them, since
emails and phone numbers don't need translation).
DEPLOY (David)
cd /opt/flux-srl
mkdir -p public/branding # one-time, creates the folder if missing
git pull
docker compose up -d --build app
docker compose exec nginx nginx -t
docker compose exec nginx nginx -s reload
Two related fixes for the deploy pipeline so DB schema changes never
again leave the site half-deployed.
PRISMA CONFIG (prisma.config.ts)
- "import 'dotenv/config'" was hard-required, but dotenv isn't installed
in the production runtime image (env vars come from docker-compose).
- Wrapped in try/catch so it loads .env locally and silently no-ops in
the container — `prisma migrate deploy` works in both environments.
DOCKERFILE
- Copies node_modules/prisma + prisma.config.ts to the runner stage so
the CLI is available at runtime, not just at build.
- New CMD runs `prisma migrate deploy` before booting the server.
Idempotent — already-applied migrations are skipped. If the DB is
unreachable, the container exits and docker-compose retries.
- This means: from now on, `git pull && docker compose up -d --build app`
is the entire deploy. No more "did you remember to run migrations?".
DEFENSIVE TRY/CATCH (applications/[slug]/page.tsx)
- prisma.application.findUnique and prisma.globalNode.findMany now have
try/catch with logged errors. A transient DB hiccup or missing
Application slug now degrades gracefully (renders "not found" or empty
cases wall) instead of triggering a 500 Internal Server Error.
DEPLOY (David, this is the recovery sequence on the VPS)
cd /opt/flux-srl
git pull
docker compose up -d --build app
# The container will run pending migrations on its own.
# No need to run `prisma migrate deploy` manually anymore.
Two production-grade hardening additions and one cost optimisation.
FLUXAI AUTONOMY RESTORED (api/chat)
- Brings back the multi-step agentic flow that the system prompt was
always designed for. The "temporarily removed maxSteps" comment is
gone — replaced with the AI SDK 6 equivalent stopWhen: stepCountIs(5).
- Cap at 5 chained tool calls per turn bounds latency + LLM cost.
- maxDuration raised 30s → 60s to absorb tool-chain runs.
- Result: one user prompt now triggers, e.g. search_installations →
energy_savings_calculator → show_case_study → schedule_consultation
in a single turn — exactly the SPIN methodology in the prompt.
RATE LIMITING (src/lib/rateLimit.ts + api/chat)
- Token-bucket per IP: 30 messages burst, sustained 30/minute. Trips
to 429 with Retry-After + X-RateLimit-Remaining headers when abused.
- IP extracted from x-forwarded-for (Nginx already passes this).
- In-memory Map with 10-min GC of stale buckets — no Redis dep.
If we scale to multiple replicas later, swap the Map for Upstash.
- Protects the OpenAI quota from someone hammering the chat endpoint.
IMAGE PIPELINE (src/lib/imageOptimizer.ts)
- sharp-based optimizer: auto-orient (EXIF), cap at 2560px long side,
re-encode WebP@85, content-hash filename. Re-uploads with same
content reuse the same hash; new content gets a new URL — perfect
cache invalidation without header tricks.
- Opt-in via optimize=1 form/query param on /api/assets POST.
- Hero CMS and Site Settings uploads turn it on automatically (those
are user-facing brand assets where compression matters most).
- App/news/parts uploads remain untouched (editors may be uploading
CAD drawings, datasheets, etc. that shouldn't be transcoded).
- Falls back gracefully to a no-op for unsupported formats (SVG, GIF,
videos, anything sharp can't decode) so it never breaks an upload.
DOCKERFILE
- Adds vips/vips-dev for sharp on Alpine + --include=optional so the
@img/sharp-linuxmusl-x64 prebuilt is downloaded
- Explicitly copies node_modules/sharp + node_modules/@img to the
runner stage (Next.js trace can miss conditional deps).
NO DB SCHEMA CHANGES.
Brings the site up to enterprise SEO standards. Google now gets a complete
machine-readable map of the content, with multilingual hreflang tags,
structured data for the knowledge panel, and rich Open Graph cards on
LinkedIn / WhatsApp / Twitter.
NEW
- src/app/sitemap.ts: dynamic sitemap.xml from Prisma. Emits 5 locales x
every active application + every active news article, with hreflang
alternates linking each translation. Hourly revalidation.
- src/app/robots.ts: robots.txt blocks /hq-command/, /api/, /parts (B2B
auth-gated), points crawlers at the sitemap.
- src/lib/seo.ts: helpers for canonical URLs, hreflang alternates, and
JSON-LD schemas (Organization, WebSite, Article, Product, BreadcrumbList).
- src/components/seo/JsonLd.tsx: server component that emits one
application/ld+json script tag per page.
PER-PAGE generateMetadata
- Home: localized titles + descriptions in EN/IT/VEC/ES/DE
- News hub: title built from translations, hreflang tags
- News article: title/description from DB, OG image = cover, type=article,
publishedTime + modifiedTime for date freshness signals
- Applications: title/description from DB, type=product, hero image
- Heritage: localized title/description
JSON-LD STRUCTURED DATA
- Site-wide (in root layout): Organization (with HQ address, founder,
contact, social profiles) + WebSite — drives Google knowledge panel
- Article pages: Article schema with publisher/datePublished/dateModified
— required for Google News / Discover eligibility
- Application pages: Product schema (FLUX brand, RF Industrial category)
+ BreadcrumbList — drives rich-snippet breadcrumb in search results
NOTES
- Open Graph metadataBase set from NEXT_PUBLIC_APP_URL so absolute URLs
for OG images are correct (LinkedIn previews require absolute paths)
- All pages have canonical URLs to prevent duplicate-content penalties
- /parts already has noindex meta (B2B portal) — also blocked in robots
- No DB schema changes. Pure additions to /src/lib and /src/app.
Some technical terms were being translated literally and reading awkwardly
to industrial buyers — fixed across IT, ES, VEC, DE so they match the
English source and industry convention.
PRESERVED IN ENGLISH (industry-standard, never translate)
- "Solid-State RF" — was "RF a Stato Solido" / "RF de Estado Sólido" /
"RF a Stato Sołido" / "Solid-State-RF"
- "Microwave Systems" — was "Sistemi a Microonde" / "Sistemas de
Microondas" / "Mikrowellensysteme" / "Sistemi Microwave"
- "Radio Frequency (RF)" — was "Radiofrequenza" / "Radiofrecuencia" /
"Radiofrequensa" / "Hochfrequenztechnologie" (kept as the technical
proper noun, with the RF acronym in parentheses for first reference)
- "Pulse Wave" — was "onde pulsate" / "ondas pulsadas" / "onde pulsà" /
"Pulswellen-Technologie"
Files: messages/it.json, messages/es.json, messages/vec.json,
messages/de.json. messages/en.json unchanged. JSON syntax validated.
Adds a full settings dashboard at /hq-command/dashboard/settings so the
client can update favicon, logos, footer text, social links and OG image
without code changes — wired into the SiteSetting model from the previous
commit.
NEW
- src/lib/siteSettingsTypes.ts: pure types + defaults (client-safe import)
- src/lib/siteSettings.ts: server-only loader using the SiteSetting model
- /api/assets gains a "branding" flat scope that writes to /public/branding
- /hq-command/dashboard/settings/{actions.ts, page.tsx} with three tabs:
1. Branding — favicon, Apple touch icon, main logo, email logo, OG
image, theme color. Each field has helper text with recommended
size/format and live preview.
2. Footer — CTA banner, HQ address, legal links. Optional one-click
AI translation to IT, VEC, ES, DE.
3. Social — LinkedIn, Instagram, YouTube, contact email.
WIRED INTO LAYOUT
- src/app/[locale]/layout.tsx now uses generateMetadata + generateViewport
to pull favicon, OG image and theme color dynamically. Adds Twitter
Card metadata. Falls back to the default flux-logo when SiteSetting
table is empty.
- src/components/layout/Footer.tsx reads CTA/HQ/legal copy from DB,
supports per-locale overrides via translationsJson, and renders social
icons (LinkedIn / Instagram / YouTube / Mail) only for filled fields.
UX FOR THE EDITOR (David's "12-year-old test")
- Drop-zone uploaders next to URL inputs — paste-or-upload either way
- Live image previews next to every branding field
- "Saved — live in 60 seconds" inline confirmation, no extra modals
- Recommended sizes spelled out next to each field (e.g. "PNG, square,
minimum 512×512" for favicon)
- Tooltip explaining why each image is needed
NO SCHEMA CHANGES — uses the SiteSetting table created in the previous
commit. Existing rows untouched.
Replaces the filesystem-scan hero (fs.readdirSync of /public/footage/main)
with a fully CMS-driven HeroSlide model. Editors can now drag-drop reorder,
toggle slides on/off, set focal points for proper mobile cropping, and
auto-translate per-slide captions.
NEW SCHEMA (additive — does not touch existing tables)
- HeroSlide: mediaUrl, mediaType, altText, order, isActive, focalPointX,
focalPointY, translationsJson, timestamps
- SiteSetting: key-value JSON store for site-wide config (favicon, logo,
footer, OG image) — wired up in next commit
- Migration 20260504120000_add_hero_slides_and_site_settings/migration.sql
uses CREATE TABLE IF NOT EXISTS, additive only
HERO REEL REFACTOR (Bug #4 — responsive mobile/iPad)
- Switches from `images: string[]` to `slides: HeroSlideData[]` while
keeping a backwards-compat path so legacy callers still work
- w-screen → w-full max-w-[100vw] (no horizontal scroll on iOS)
- h-[100vh] → h-[100svh] so iOS Safari URL bar doesn't push content
- Reduces title font sizes on small viewports (text-3xl → text-4xl
→ text-5xl → text-[5.5rem]) so the headline stays inside the canvas
- objectPosition driven by focal-point fields per slide
- Native <video> support for video slides
HQ COMMAND — /hq-command/dashboard/hero
- Drag-drop reorder, click-to-set-focal-point, inline alt-text editing
- Auto-save with "Saving…" / "Saved ✓" indicators
- Per-slide caption overrides (title, subtitle, descriptions)
- Optional one-click AI translation to IT, VEC, ES, DE
- Drop-zone uploader → /api/assets (scope=footage, flat folder)
API — /api/assets
- New flat scopes: "footage" (writes to /public/footage/main) and
"branding" (writes to /public/branding) — slug-less for site-wide assets
- New buildPublicUrl helper centralises the URL convention
- Revalidate helper expanded with branding + settings scopes
HOME PAGE
- Reads hero slides from DB first; falls back to filesystem scan when
HeroSlide table is empty (so production keeps working immediately
after migration runs but before the editor populates rows)
DEPLOY NOTES
- After git pull on VPS, run the migration ONCE:
docker compose exec app npx prisma migrate deploy
Then:
docker compose up -d --build app
Existing data (AdminUser w/ 2FA, ClientUser, GlobalNode, Application,
TimelineEvent, NewsArticle, HeritageSection, SparePart, OperationsSignal,
NotificationRoute, PageContent) is NOT touched. Migration only creates
two new tables.
Eliminates the need to run "docker compose build" after uploading
images via HQ Command. Heritage page now respects light/dark mode.
CACHE INVALIDATION
- New helper src/lib/revalidate.ts called from /api/assets and
/api/public-upload after every upload, delete, folder create
- Pages switch from force-dynamic to ISR with revalidate=60
(regenerated on demand whenever content changes, plus 60s safety)
- Nginx now sends "max-age=300, must-revalidate" instead of "expires 30d"
on /cases/, /applications/, /news/, /parts/, /footage/, /operations-inbox/
so browsers revalidate via If-Modified-Since (304s on unchanged files)
- Next.js Image Optimizer aligned with same TTL via minimumCacheTTL=300
and adds /_next/image location block in Nginx for correct headers
HERITAGE DARK/LIGHT FIX (Bug #8)
- Replaces hardcoded #0A0A0C / #00F0FF / text-white with proper
light + dark variants throughout markdown renderer (tables, lists,
headings, blockquotes, paragraphs, images)
- Hero section, navigation pill, and CMS-driven sections now switch
with the global theme toggle
SECURITY HARDENING
- Server actions bodySizeLimit reduced from 500MB to 50MB
(large uploads still go through /api/assets which uses Nginx 500MB cap)
DEPLOY NOTES
- Run on VPS:
git pull
docker compose up -d --build app
docker compose exec nginx nginx -s reload
- No DB schema changes in this commit. Existing 2FA users / data untouched.