Audit follow-up to the Spanish-locale commit: `CustomerDetailPage` had
a hardcoded `<option>` list for the customer's preferred-language
selector — 5 entries (en/de/nl/pt/ru) that were missing both fr (an
existing gap) and es (the new one). Every other language selector in
the frontend (the navbar `LanguageSelector`, the `GeneralTab` default-
language dropdown, the `EmailConfigPage` per-language tabs) already
reads from the shared `SUPPORTED_LANGUAGES` constant, so adding es
there was enough for those. This one had drifted.
Now mapped from `SUPPORTED_LANGUAGES` so future locales only need to
touch one place.
Contributed by @AloePacci on issue #510. Drops their es.json into the
existing locale set, registers Spanish in the language selector with a
flag SVG matching the inline style of the other six locales, and
extends the email pipeline so es-language guests receive a localised
email subject/body where available.
Coverage:
- frontend/src/i18n/locales/es.json — 2132 translated keys. ~824
EN keys are not yet covered; i18next's `fallbackLng: 'en'` handles
those at runtime so the UI never renders a missing key. fr/nl/pt/ru
have a similar (smaller) gap and ship the same way.
- LanguageSelector.tsx — added the ESFlag inline SVG (red/yellow/red
horizontal bands, official #AA151B + #F1BF00; no coat of arms to
stay consistent with the other simple flag components) and a new
entry in SUPPORTED_LANGUAGES.
- emailProcessor.js — added .es to the domain-language heuristic, and
an `es:` row to the three inline-translated snippets
(passwordSecurityI18n / noPasswordI18n / passwordSetAtCreationI18n).
- 106_seed_es_email_template_translations.js (new) — idempotent
seeder for the four customer-facing templates AloePacci translated:
gallery_created, expiration_warning, gallery_expired, archive_complete.
Mirrors the pattern from 099. Template keys without an `es` row fall
back to `en` via the existing resolution chain in
emailProcessor.processTemplate — no functional gap, just untranslated
copy until someone fills them in.
What I deliberately did NOT take from the contribution: the proposed
in-place edit of migration 075 (history mutation — won't reseed for
existing installs anyway) and the whitespace/`gallery_list_html`-drop
churn in emailProcessor.js (would have regressed the #354 follow-up).
The semantic additions from those files are preserved via 106 and the
targeted edits above.
Regression of #208. PR #214 (commit 02a46e0, re-merged at 9b7495e)
shipped the configurable `general_max_upload_batch_size_mb` setting so
users behind Cloudflare Tunnel and other reverse proxies with
per-request size caps could lower the chunked-upload size below their
proxy's limit. Six days later the "Merge main into beta for
release/beta-to-main" commit (28793bb) resolved its conflict by
keeping main's older tree — which silently deleted the migration
(072), the setting input on Settings → General, the i18n strings, the
`useSettingsState` field, and the read in PhotoUpload.tsx, putting the
hardcoded 500MB chunk back. Galleries fronted by Cloudflare have
quietly been broken on batch uploads since then.
Re-applying exactly the same change set:
- `backend/migrations/core/072_add_max_upload_batch_size.js`
recreated, with a comment pointing at the regression in case the
same merge accident happens again.
- `frontend/src/components/admin/PhotoUpload.tsx` line 168 now reads
the setting from query cache and falls back to 95MB (Cloudflare-safe
headroom under 100MB).
- `useSettingsState.ts`, `GeneralTab.tsx`, `en.json`, `de.json` —
added the field to the state type + defaults + load path + the
Site-Configuration input.
Existing installs are safe either way:
- Ran original 072 then lost the file: migrations table still has the
filename, so the runner skips re-applying. The setting row in
`app_settings` is also untouched (the deletion was source-only, no
down migration ran). Now the new code starts reading it again.
- Installed after the regression: migrations runner picks up the new
072 normally and seeds the setting at 95.
Photographers running the gallery as a client-selection tool want to
map a guest's picks back to source files for retouching. The
`general_use_original_filenames_for_downloads` toggle (#493) already
does this on the download side; this extends the same toggle to the
in-lightbox view so the camera filename is visible alongside the
photo while it's being looked at.
Tied to the same toggle on purpose — one switch controls both
surfaces. Off by default; existing galleries keep showing only the
position counter.
Wiring:
- gallery.js serializes `photos[].original_filename` and surfaces the
resolved toggle as `event.use_original_filenames` so the client can
decide whether to render it.
- The bespoke `PhotoLightbox` renders the original filename (falling
back to the storage filename only for pre-migration-062 uploads) in
a muted line under the position counter, truncated to keep the
toolbar tidy.
- `GalleryStoryLayout` mounts the same `PhotoLightbox`, so its
rendering follows along.
- `GalleryPremiumLayout` uses `yet-another-react-lightbox` instead;
added the Captions plugin and a `title` field on the slides so the
same name appears as a caption when the toggle is on.
The remaining layouts feed back into the main `PhotoLightbox` via
`PhotoGridWithLayouts`, so the prop reaches them through the layout
props bag.
Follow-up to #498. The toggle reached zip downloads but single-photo
downloads still landed on disk with the renamed `event_individual_NNN.jpg`
even when the admin had flipped the setting on. Two reasons, fixed
in lockstep:
- Frontend overrode the server's Content-Disposition with a hardcoded
`<a download="X">` attribute (`gallery.service.ts`, `photos.service.ts`)
where X was the sanitized `photo.filename` known to the client. So
the backend's correctly-formed `Content-Disposition` never reached
the disk write. Added `parseContentDispositionFilename` (RFC 5987 +
plain `filename=` fallback) and let the server name win when present.
- `secureImages.js` (enhanced/maximum protection's secure-download
route) was missed in #498 and still emitted a hardcoded
`filename="${photo.filename}"` regardless of the toggle. Wired it
through `getUseOriginalFilenames` + `buildContentDisposition` so it
matches the regular gallery download path.
Also exposed `Content-Disposition` via CORS so split (cross-origin)
frontend deployments can still read it from JavaScript. Same-origin
Docker deploys already had access; this is a defensive addition for
the split case.
Four gallery layouts were rendering the per-photo Like button without
gating on the master "Guest Feedback" toggle, so a guest still saw a
heart icon and could submit likes on events where the host had turned
feedback off. The other layouts (Grid / Justified / Masonry / Story)
already gated correctly with `feedbackEnabled && allowLikes` —
Rekoo-PS's note that "it's hidden in some themes" matches that split.
- CarouselGalleryLayout, MosaicGalleryLayout, TimelineGalleryLayout:
the existing conditional checked only `feedbackOptions?.allowLikes`,
missing the `feedbackEnabled` master gate. Added it inline.
- GalleryPremiumLayout: the per-card Like button rendered
unconditionally because PhotoCard never received the allow-likes
signal. Added an `allowLikes` prop on PhotoCardProps, plumbed
`feedbackOptions?.allowLikes` down from the parent, and wrapped the
button in `feedbackEnabled && allowLikes`.
The follow-up "default guest-feedback ON" request from Rekoo-PS in
the comments is a separate feature (admin > General > Event Creation
default) and out of scope for this fix.
Two adjacent swipe-time defects, one diagnosis each:
1. Height differed between current and neighbouring slides during a
swipe but matched when the arrow buttons advanced the carousel.
Cause: neighbour slides wrap their image in a div with extra `px-2`
horizontal padding while the current slide does not. `object-contain`
then sees a narrower container on neighbours, so wide images cap on
width first and render shorter than the same image at the current
position. Removed the padding so both slots share the same container
geometry. Arrow-button navigation looked fine because it never
showed the neighbour layout side-by-side.
2. The image flashed black for ~100–400 ms each time a swipe committed
to the next slide. Cause: the 3-slide track has no React keys, so
React reconciled slides by position. After commit the photo at every
position changed (`prev → current → next` shifts left), every slot's
`<AuthenticatedImage>` saw a new `src` prop, and its fetch effect
restarted from the placeholder state — including the slot that was
the user's "next" slide a moment ago and held a fully-loaded image.
Added a stable `key` derived from `photo.id` so React MOVES existing
DOM nodes across slots instead of refetching. 2-photo galleries are
a key-collision edge case (`prev === next`), so they fall back to
slot-prefixed keys to keep siblings unique; behaviour there is no
worse than today.
The dashed-border upload area in `PhotoUpload` (admin) and
`UserPhotoUpload` (gallery user-upload) is styled and labelled as a
drop zone — every locale's `upload.clickToUpload` already reads
"Click to upload or drag and drop" or its translation — but neither
component had any `onDragOver` / `onDragEnter` / `onDragLeave` /
`onDrop` handlers. Files dropped on the zone fell through to the
browser's default behaviour (open the image in a new tab), which is
what Rekoo-PS reported.
Added native HTML5 drag-and-drop wiring on both components, plumbed
through the same filter/limit/toast pipeline used by the click path
(`addFiles` helper). Visual highlight on drag-over via an `isDragOver`
flag; the listener guards against the `dragleave` strobing that fires
on every child node. Also reset the `<input>` value after onChange so
re-picking the same file still triggers an upload — matches the
new drop-then-pick mental model.
When navigating to the Settings page, useSettingsState called
i18n.changeLanguage() with the server-stored general_default_language
value on every settings query resolution. This caused the admin UI
language to reset to the server default (e.g. "en") regardless of the
language the user had selected via the LanguageSelector.
The general_default_language setting is intended as the default for
public galleries, not for controlling the admin UI language. The admin
UI language is already persisted via localStorage through
i18next-browser-languagedetector and should not be overridden by server
settings.
Remove the i18n.changeLanguage() call from the useEffect that
initialises settings state from the API response.
New Settings → General toggle `Use original filenames on download` (off by
default). When on, single-photo downloads, bulk/selection zips, and per-event
archive zips surface `photos.original_filename` instead of the sanitized
storage filename. Storage paths are unchanged.
- Content-Disposition uses RFC 5987 (`filename=` ASCII + `filename*=UTF-8''…`)
so unicode camera filenames survive while header-injection bytes are stripped.
- Zip entries are deduplicated with a deterministic `_1` / `_2` suffix on
collision (folder structure preserved in archive zips).
- Pre-generated download-all zips and the in-memory setting cache are
invalidated when the toggle flips so the next download rebuilds with the
new names.
- Falls back to the storage filename whenever `original_filename` is null
(legacy uploads predating migration 062).
Adds an opt-in lightbox preview tier so guests open photos against an
aspect-preserved ~1920px JPEG (~200–500 KB) instead of the full original
(often 5–12 MB). Originals are still served on Download.
Backend:
- imageProcessor: generatePreviewImage / isPreviewValid / ensurePreviewImage
using fit:'inside' + withoutEnlargement (longEdge 1920, q85, mozjpeg)
- migration 104: photos.preview_path + lightbox_preview_enabled setting
(off by default, JSON-stringified for SQLite/Postgres parity)
- GET /api/gallery/:slug/preview/:photoId — gallery-auth, lazy generation,
ETag based on mtime+photoId+watermarkHash
- preview_url surfaced in the photo response only when the toggle is on
- admin /thumbnails/regenerate-previews mirrors regenerate-thumbnails,
skipping videos
- backup walk + archive cleanup + photo-delete now include previews/
Frontend:
- PhotoLightbox uses photo.preview_url ?? photo.url (null-safe fallback)
- ThumbnailsTab gets a Lightbox Preview Tier card: opt-in toggle +
Regenerate All Previews button (gated until the toggle is on)
- en/de locale strings; nl/pt/ru/fr fall back to en
Tested end-to-end: 11 MB / 4000×3000 source → 985 KB / 1920×1440 preview,
381 ms first call, 7 ms cached, ~91% byte reduction.
Admin > Users page crashed with "TypeError: e.split is not a function"
on native installs (SQLite default). Reported by @blazmaric in #485
with a clean diagnosis: SQLite returns lastLogin / createdAt /
updatedAt as integer milliseconds since epoch, while Postgres
returns ISO strings via the standard JSON serialiser. The page used
parseISO() on the raw value and parseISO trips on numbers.
Fix at both layers — defence in depth:
- backend/src/routes/adminUsers.js: new toIso() helper applied in
transformUser + transformInvitation. Coerces Date / number /
numeric-string / null to a single ISO 8601 string contract before
the response leaves the API. Protects every consumer (frontend
AND external API tokens / n8n) regardless of which DB driver is
underneath.
- frontend/src/services/userManagement.service.ts: same helper as
defence-in-depth for stale backends mid-deploy and any cached
pre-fix response shape. Also surfaced an existing
transformInvitation gap — invitations endpoints were returning
raw response.data.invitations without going through the
transformer.
10 unit tests pin the toIso contract: all known driver shapes
(Date, number, numeric-string, ISO-string, null/undefined/empty)
plus the full transformer paths for transformUser and
transformInvitation.
Out of scope: same epoch-ms surface may exist on other admin pages
that were never tested against SQLite (events list, customers,
webhooks, api tokens, activity log). Worth a follow-up audit pass
to apply toIso() in every snake_case→camelCase transformer the
admin routes use, but the immediate Users-page crash is the only
reported one and shipping that fix unblocks @blazmaric.
The gallery promotional banner (#440) read as visually offset from
the gallery footer because:
- Footer used `container text-center px-4` (full container width,
centered text).
- Promo block used `container py-4 sm:py-6` with an inner
`max-w-3xl mx-auto` wrapper holding left-aligned text — a
narrower column with left-aligned content sitting in the
middle of the page.
Two issues compounded: the column was narrower than the footer AND
its text alignment differed. Reported by Rekoo-PS in #482 with a
screenshot showing the misalignment, with a request for an admin
alignment option.
Fix:
- Drop the inner max-w-3xl wrapper. Promo content now spans the
same .container width as the footer, eliminating the
narrower-column visual.
- Default text alignment changed from left → center to match the
footer.
- New `branding_promo_alignment` setting ('left' | 'center' | 'right',
default 'center'). Surfaced as a dropdown next to the existing
Position dropdown on the BrandingPage. Live preview block on the
BrandingPage mirrors the gallery render so admins see what
guests will see.
- Also replaced the no-op `prose-sm` prose-modifier with a real
`prose prose-sm` outer class so the existing `prose-a:text-accent`
modifier actually takes effect (it didn't before — modifiers
without an outer .prose are silently ignored by Tailwind
Typography).
Migration 103 seeds the new setting at 'center' so existing
installs that have a promo banner today see the corrected
alignment immediately on next deploy.
i18n: en + de hand-translated; nl/pt/ru/fr machine-translated and
flagged for native review per project convention.
Background: galleryOgService already serves OG/Twitter Card meta tags
to social-crawler User-Agents (WhatsApp, Facebook, Slack, Telegram,
Discord, ~21 in total) for /gallery/:slug URLs. Today the og:image
is always the brand logo with the inline rationale "no protected
photo content".
#474 asked for a hero/cover photo preview. The trade-off is that any
URL embedded in og:image is fetched unauthenticated by every
link-preview crawler — so an opted-in image is effectively public
to anyone the gallery URL is shared to. Ship as a per-event boolean,
default FALSE, so existing galleries never start surfacing photos
without explicit admin intent.
Schema (migration 102):
- events.og_image_share_enabled BOOLEAN NOT NULL DEFAULT FALSE.
Backend:
- galleryOgService.buildOgMetadata: when opt-in is on AND a
hero_photo_id is set AND the photo has a generated thumbnail,
emit og:image as /og/gallery/:slug/cover. Falls back to the
brand logo on any miss (deleted hero, missing thumbnail, no
opt-in) so a half-configured gallery still gets a polished
preview rather than a broken-image src.
- galleryOgService.handleGalleryOgCover: new public endpoint that
streams the hero thumbnail. Validates slug shape, checks the
opt-in flag + hero presence + thumbnail existence; returns 404
on any failure. ETag = thumbnail mtime + photo id so a
regenerated thumb busts crawler caches. Cache-Control:
public, max-age=300 (short — admins shouldn't wait an hour for
a cover swap to land in chat previews).
- server.js: mount the new GET /og/gallery/:slug/cover route. The
existing nginx ^~ /og/gallery/ proxy block already covers it.
- adminEvents.js: validator + persistence on POST + PUT.
formatBoolean coercion so SQLite (0/1) and Postgres (boolean)
both behave correctly.
Frontend:
- Event type + UpdateEventData carry og_image_share_enabled.
- EventDetailsPage adds a checkbox under the HeroPhotoSelector,
disabled when no hero photo is picked. Help text deliberately
spells out the public-by-design consequence — admins shouldn't
flip this on for a sensitive gallery without realising what
they're sharing with link-preview crawlers.
Tests: 8 new in galleryOgService.shareImage.test.js — pin the
cover-vs-logo decision contract (3 cases) plus the defensive
fallbacks (deleted hero, missing thumbnail) and the 404 contract
on the cover endpoint (4 cases). The 404 tests assert that
ensureThumbnail() is NOT called when opt-in is off, so a future
refactor can't accidentally widen the unauthenticated cover
endpoint to expose a hero the admin hasn't shared.
i18n: en + de hand-translated; nl + pt + ru + fr machine-translated
and flagged for native review per project convention.
The Dashboard "Recent Activity" widget and the header notifications
dropdown both rendered raw activity-type strings (e.g. the literal
"feature_flags_updated") for any type missing from their lookup
maps — including everything emitted by the recently-added customer
portal (#354), webhooks (#327), API tokens (#322), event types,
event-publish flow, admin user management (#350), and the
feature-flags reorg itself.
Two coordinated changes:
1. Smart formatter for feature_flags_updated. The backend writes
`metadata.changed = { [flagKey]: { from, to } }` on every save.
New formatFeatureFlagsChanged() helper in admin.service.ts reads
that diff and renders:
- 1 change → "Customer Portal enabled"
- N changes → "3 features updated: Customer Portal enabled,
Calendar disabled, Quotes enabled"
Per-flag display labels source from `settings.features.<key>.title`
so they stay in sync with the Features tab. Unknown flag keys
fall through to a humanised version of the key.
2. 33 missing activity types added to BOTH renderers and to the
`admin.activities.*` + `admin.notificationMessages.*` i18n
namespaces across all six locales. Coverage groups: customer
portal (13 types), admin user management (6), webhooks (3),
API tokens (2), event types (4), event publish/logo (3), bulk
delete (1), and assorted post-merge surfaces (4).
The notifications.service.ts switch + admin.service.ts fallback
message map are still duplicated; consolidating them into a
single source of truth is a follow-up worth doing before the
next significant addition. For now both stay in sync via this PR.
en + de hand-translated. nl + pt + ru + fr machine-translated and
flagged for native review per project convention.
Settings → Features showed the customer-portal toggle as "Accounts"
("Konten" in DE, "Comptes" in FR, etc.) — the deeper sub-nav label
inside ClientsLayout — while the prominent menu-bar entry the admin
actually clicks first reads "Clients" / "Kunden". The mismatch was
confusing on first encounter ("which one do I look for?").
Align the Features tab card title and the "Sidebar:" callout with
the menu-bar wording (`navigation.clients`) across all six locales.
The sub-nav inside ClientsLayout keeps its own "Accounts" label —
that one matches the /admin/clients/accounts URL and is correct.
formatBrandingSettings was updated when the BrandingSettings
interface added the footer-overhaul fields (#441 / #440), so the
admin BrandingPage initialised them as empty strings on every load.
Saving any other field then sent the form's empty socials /
promo_markdown / promo_position back to the backend and wiped the
saved values from the DB. The public gallery footer kept rendering
the old values until the next save, which is why the bug appeared
asymmetric (visible to galleries, gone from the admin form).
Add the missing read mappings for the seven branding_* keys so the
form round-trips them correctly.
Reported by @Rekoo-PS in #460 (split out of #447).