Commit Graph

604 Commits

Author SHA1 Message Date
github-actions[bot] fd8ee5d52c chore(beta): release 3.55.0-beta.0 2026-05-27 13:53:11 +00:00
Paul Nothaft d5a37df2c4 fix(events): preserve branding inheritance when saving events with null color_theme
API-created events (and any event whose `color_theme` is NULL) had two
visible bugs in the admin edit page (#550 follow-up — PR #552 fixed the
v1 POST write path, this fixes the read/save path):

1. The theme picker initialised to the hardcoded `GALLERY_THEME_PRESETS
   .default.config` ("Classic Grid", green) — which had nothing to do
   with the admin's actual branding palette, while the gallery itself
   was rendering with the branding theme. Confusing visual mismatch.
2. Saving the event for ANY reason (changing the date, password, etc.)
   wrote `color_theme = 'default'` back to the row because the save
   handler always emitted the picker's initial preset name. That
   silently replaced "inherit from branding" with the literal Classic
   Grid preset, so the gallery's visuals jumped.

Two fixes, both in EventDetailsPage:

- Add a `themeChanged` flag, defaulted false. Flip in the picker's
  onChange / onPresetChange / onSyncFromBranding callbacks. The save
  handler now only writes `updateData.color_theme` when the flag is
  true, so saving without touching the picker preserves NULL.
- When `event.color_theme` is null and `publicSettings.theme_config`
  (the site branding) is available, initialise `currentTheme` from
  branding instead of the Classic Grid preset, with currentPresetName
  set to 'custom' (since inherited branding isn't a named preset).
  Falls back to the Classic Grid preset only when no branding theme
  exists either.

Combined effect: opening an API-created event shows the same palette
the gallery uses, and saving without changing the theme preserves the
inheritance. Existing events with a stored color_theme are unaffected
(themeChanged stays false → no write, just like before for the
common no-change-to-theme save).
2026-05-27 15:44:41 +02:00
Paul Nothaft d5823c79d9 feat(lightbox): multi-photo Web Share save-to-Photos on iOS (#557)
Extends #531 to the selection-based bulk-download flow. On iOS with a
selection at or under MAX_WEB_SHARE_FILES (25), galleryService
.downloadSelectedPhotos now routes through navigator.share({ files })
so the photos land directly in Photos via the share sheet's "Save N
Images" action. Above the cap, anywhere off-iOS, or on any failure,
the existing server-side zip path runs unchanged.

The 25-file cap is the empirically-safe ceiling: iOS Safari's share
sheet starts choking beyond ~25–30 files, and every File materialises
as an in-memory Blob before share() is invoked, so a 500-photo
selection would buffer multiple GB on the device.

trySaveMultipleToDevice exposes three outcomes:
- 'shared'    — share() resolved; flow ends
- 'dismissed' — user cancelled (AbortError); flow ends without zip
                fallback so dismissal isn't silently overridden
- 'fallback'  — capability missing or unexpected failure; caller
                takes the zip path

Partial shares are deliberately avoided: a single failed photo fetch
collapses the whole selection back to the zip endpoint rather than
sharing only the photos that resolved.

All 4 grid callers (PhotoGrid, PhotoGridWithLayouts, GalleryStoryLayout,
GalleryPremiumLayout) funnel through downloadSelectedPhotos, so no
caller-side changes are needed. Android, desktop, Firefox, and
"Download All" are untouched.

Layers on top of #556 (iOS-only gating via isIOS()). Builds against the
fix/android-download-web-share-554 branch.
2026-05-27 15:37:52 +02:00
Paul Nothaft 04795219a0 fix(lightbox): eliminate download lag on Android by skipping the blob round-trip
`savePhotoToDevice` previously buffered the full image through JS as a
Blob on every platform before clicking <a download>. On cellular this
added ~5s of dead air between the button press and the browser's
download dialog, prompting users to re-click and produce duplicate
downloads (#554 follow-up, post-#556).

The blob round-trip is only required for the iOS Web Share path
(`navigator.share({files})` needs File objects in hand). On Android and
desktop the browser can fetch the download URL itself and show its own
progress in the notification shade — instantly. So iOS keeps the
existing flow; everywhere else gets a direct anchor navigation.

The new `triggerDirectDownload` helper uses `api.getUri()` so the path
also works in split-origin deployments (where the existing hardcoded
`/api/...` pattern used by `downloadAllPhotos` would 404).

Tests updated: Android / desktop / regular-Mac branches now assert that
`fetchPhotoBlob` is NOT called and `triggerDirectDownload` is invoked
with a `/gallery/{slug}/download/{id}` URL. iOS tests unchanged.
2026-05-27 13:16:36 +02:00
github-actions[bot] 54b185db47 chore(beta): release 3.54.7-beta.0 2026-05-26 20:54:29 +00:00
Paul Nothaft 2a309c75a7 fix(lightbox): restrict Web Share save-to-Photos path to iOS (#554)
PR #531 routed the single-photo download through navigator.share()
whenever canShare({files}) returned true, on the assumption that any
mobile share sheet would expose a "Save Image" action. That holds on
iOS — Safari's share sheet has a first-party "Save to Photos" entry —
but on Android the system share sheet only lists installed apps that
registered an image/* intent (WhatsApp, Telegram, Drive, etc.). There
is no built-in save-to-Gallery action, so Android users tapping the
download button got an app-picker instead of the file saved to their
device.

Fix: gate the Web Share branch behind a UA-based isIOS() check. Android,
desktop, and everything else fall through to the existing <a download>
path (file lands in Downloads, visible in the Photos / Gallery app
afterwards — same behaviour as before #531). iOS — including iPadOS
13+, which reports as MacIntel + touch — keeps the share-sheet flow
that drops directly into Photos.

UA-sniff is the only available signal here: canShare({files}) is true
on both iOS Safari and Chrome Android, so feature detection cannot
distinguish them.

Tests pin all six scenarios — iOS share path, Android download fallback
(even with canShare=true), desktop, iPadOS-as-Mac detected as iOS, regular
Mac NOT detected as iOS, AbortError dismissal preserved (no surprise
fallback), and non-Abort share() rejection falls back to download.
2026-05-26 22:37:01 +02:00
github-actions[bot] 08fa2e9b63 chore(beta): release 3.54.6-beta.0 2026-05-25 20:19:09 +00:00
github-actions[bot] 793aa1247e chore(beta): release 3.54.5-beta.0 2026-05-22 12:41:21 +00:00
Paul Nothaft 5488de3383 fix(nginx): honour outer X-Forwarded-Proto when behind a reverse proxy (#547)
When PicPeak runs behind NPM / Traefik / Caddy, the inner nginx receives
plain HTTP from the outer proxy. The previous `X-Forwarded-Proto $scheme`
therefore always forwarded "http" to the backend, even when the public URL
was HTTPS. Express has `trust proxy` enabled for loopback/linklocal, so
req.secure became false, the Secure cookie flag wasn't set, and generated
URLs (cookies, tokens) used http://.

Add a top-of-file `map` block that picks the incoming X-Forwarded-Proto
when present and falls back to `$scheme` for direct access. Applied to both
nginx.conf (bundled production image) and nginx.dev.conf.

Validated with `nginx -t` against nginx:1.28-alpine (the same image used
by Dockerfile.prod / Dockerfile).
2026-05-22 13:32:01 +02:00
Paul Nothaft 6b6ac64346 Merge pull request #537 from rpintodasilva/imp/french-translation
French Transalation - v2
2026-05-22 10:30:44 +02:00
Paul Nothaft 27e9b0535e Merge pull request #545 from the-luap/chore/clawpatch-review-fixes
chore: address clawpatch review findings
2026-05-21 19:07:17 +02:00
Paul Nothaft dba98f1325 chore: address clawpatch review findings (test scope, deps, legal-page hardening)
- frontend: `npm test` now runs all 7 vitest suites instead of one hardcoded
  file; the previously skipped ProtectedImage / Skeleton / usePublicSettings /
  contrast / themeMigration / url suites are now active in CI
- frontend: wrap ThemeCustomizerEnhanced test in QueryClientProvider so the
  newly-enabled run passes (component uses useQuery internally)
- root: drop unused better-sqlite3 / canvas / node-fetch + their
  prebuild-install/tar-fs override (backend keeps its own copies); add dotenv
  so playwright.config.ts can load on a clean install; add name/version/private
- LegalPage: scheme-validate external_url before window.location.replace so a
  CMS edit can't redirect visitors to javascript:/data:
- LegalPage: force rel="noopener noreferrer" on target="_blank" anchors in
  sanitized CMS HTML to block reverse-tabnabbing
2026-05-21 17:10:34 +02:00
github-actions[bot] 955ada4945 chore(beta): release 3.54.4-beta.0 2026-05-21 08:24:22 +00:00
Paul Nothaft 9607b4666c Merge pull request #542 from the-luap/fix/recover-orphaned-527
fix: recover three orphaned commits from #527 (BRAND_TITLE runtime, Web Share, pan zoom)
2026-05-21 10:23:59 +02:00
github-actions[bot] c1e8e0d73a chore(beta): release 3.54.3-beta.0 2026-05-21 08:22:26 +00:00
Paul Nothaft efa6b4a205 fix(brand-title): runtime substitution so GHCR-image users can override (#521 follow-up)
@Rekoo-PS confirmed the prior #521 fix landed on beta but reported the
preview still shows the default "PicPeak" title — their brand is
"arkan-studio". Root cause: that fix used Vite's build-time
%VITE_DEFAULT_TITLE% substitution. Self-hosters running the pre-built
ghcr.io/the-luap/picpeak/frontend image can't override at build time
without rebuilding, so they were stuck with whatever the upstream
build baked in.

Pivot to runtime substitution: the frontend container now reads
BRAND_TITLE / BRAND_DESCRIPTION env vars on startup and envsubsts
them into index.html. Change the values in .env, restart the frontend
service, done — no rebuild required.

Mechanics:
  - frontend/index.html: tokens are now ${BRAND_TITLE} /
    ${BRAND_DESCRIPTION} (shell expansion syntax, passes through Vite
    unchanged into the built dist).
  - frontend/Dockerfile: install gettext (provides envsubst), snapshot
    /usr/share/nginx/html/index.html → index.html.tpl at build, install
    docker-entrypoint.sh, wire ENTRYPOINT to it. The .tpl is the
    immutable source — every container start re-renders index.html
    from .tpl, so restarts pick up new env values cleanly (no
    accidental "first-boot env stuck forever" trap).
  - frontend/docker-entrypoint.sh: applies defaults if env unset,
    runs envsubst (locked to BRAND_TITLE + BRAND_DESCRIPTION
    explicitly so /assets/*.js template literals aren't touched if
    anyone ever extends substitution to the bundle), execs nginx.
  - frontend/vite.config.ts: drop the htmlTitleDefaults plugin — no
    longer needed since substitution is fully runtime.
  - frontend/.env.example + .env.production.example: drop the
    VITE_DEFAULT_* docs (the vars no longer have effect).
  - docker-compose.yml + docker-compose.production.yml: pass
    BRAND_TITLE / BRAND_DESCRIPTION env into the frontend service
    with sensible defaults so unconfigured installs work unchanged.
  - .env.example: add BRAND_TITLE / BRAND_DESCRIPTION with comment
    pointing at the social-preview use case.

Verified end-to-end against the built image:
  - BRAND_TITLE="Arkan Studio" BRAND_DESCRIPTION="Wedding photographs
    by Arkan Studio" → index.html serves <title>Arkan Studio</title>
    + og:title="Arkan Studio" + og:description correctly substituted.
  - .tpl preserves ${...} tokens so the next restart can re-substitute.
  - Bundle assets unaffected.
  - Defaults applied when env unset → <title>PicPeak</title>.

Docs PR in picpeak-docs describes the two new env vars under
"Social link preview fallback" in the environment-variables reference.

Refs: #521
2026-05-21 10:00:21 +02:00
Paul Nothaft 53139b8cb8 fix(lightbox): pan zoomed image with single-finger touch on mobile (#532)
@Rekoo-PS reported zoom on mobile only shows the centre crop — the
image zooms but you can't pan around to see other parts. Single-finger
touch was being routed through the carousel-swipe branch which is
gated on zoom <= 1 (so swipe doesn't fight with pan), so when zoomed
the touch hit no handler at all.

Desktop has the equivalent path via handleMouseDown / handleMouseMove
(line 364), which is why this only manifests on mobile.

Add a single-finger pan branch to the touch handlers that mirrors the
mouse path:
  - handleTouchStart: when zoom > 1 and one finger, record dragStart
    relative to the existing dragOffset (so subsequent moves continue
    from where the last pan left off, not from origin).
  - handleTouchMove: when isDragging + zoom > 1 + one finger, update
    dragOffset from touch position.
  - handleTouchEnd: clear the isDragging flag (offset persists so the
    image stays where the user left it).

Also fix a latent bug surfaced while reading the pinch-zoom path:
when pinch-out drops zoom back to 1.0, dragOffset wasn't reset, so the
photo sat off-centre at natural zoom. Re-centre in handleTouchMove
when newZoom drops to <=1 with a non-zero offset.

Carousel swipe stays disabled when zoomed (existing behaviour). Mouse
path untouched. Pinch-to-zoom path untouched.

Refs: #532
2026-05-21 10:00:21 +02:00
Paul Nothaft b2bbf7efb5 feat(lightbox): save photo to Photos app on mobile via Web Share (#531)
@Jasper2213 reported non-technical clients struggle to get downloaded
photos into their Photos / Gallery app — current flow goes through the
Files folder, requires unzipping for the bulk download, and is hard to
explain over email. Browsers can't write directly to the OS Photos app
(it's a protected location), but navigator.share({ files: [...] }) opens
the native share sheet which on iOS includes "Save Image" and on Android
includes "Save to Photos" / "Save image" — exactly the affordance non-
technical users are looking for.

Plumbed through three layers:

1. galleryService — new savePhotoToDevice(slug, photoId, filename).
   Fetches the photo blob, probes navigator.canShare({ files: [file] })
   with a representative File (some browsers return true for empty
   files arrays even when they won't accept a non-empty one), and:
     - shares if supported,
     - falls back to the existing <a download> path otherwise.
   AbortError on share() means the user dismissed the sheet — that's
   a choice, not a failure, so no fallback. Any other error falls
   through to a regular download so the user still gets the file.
   Refactored the existing downloadPhoto to share the fetch + trigger
   helpers (no behaviour change for the other 3 callers; they keep
   the regular download path).

2. useGallery — new useSavePhotoToDevice() hook next to the existing
   useDownloadPhoto(). Onsuccess toast omitted because the share-sheet
   path doesn't finish from this code's perspective — the OS UI takes
   over and the user picks the destination, so "Photo downloaded" is
   misleading. Fallback path stays silent to keep the two flows
   symmetrical (the file appearing in Downloads is its own signal).

3. PhotoLightbox — swap the existing useDownloadPhoto call site to
   useSavePhotoToDevice. No UI change. Desktop unchanged. Other
   download buttons (PhotoGrid, PhotoGridWithLayouts, GalleryView
   bulk) still use useDownloadPhoto — scoping this PR to the
   lightbox download button per the discussion thread.

Browser support:
  - iOS Safari 15+:    Web Share Files → "Save Image" → Photos      ✓
  - Chrome Android:    Web Share Files → "Save to Photos" / "Save"  ✓
  - Desktop Chrome:    canShare returns false → regular download     ✓
  - Desktop Safari:    canShare returns false → regular download     ✓
  - Firefox (any):     no Web Share File support → regular download  ✓

No new tests — the flow is browser-API-driven; jsdom doesn't model
navigator.share or canShare, so a meaningful unit test would mostly
exercise the mock rather than the contract. Verified the build is
clean (tsc --noEmit + vite build both pass).

Refs: #531
2026-05-21 10:00:21 +02:00
Paul Nothaft 600c29db8a fix(lightbox): fill the heart icon when liked (#538 follow-up)
@Tietge86 spotted that both branches of the heart-icon className were
`text-white` — the conditional was a no-op, the `fill-current` class
that would actually fill the icon was missing entirely. The button
background was turning red on like, but the heart icon stayed as a
white outline against the red, making it nearly invisible.

Move text-white outside the conditional (always white against the
red/dark backgrounds the button uses), and add fill-current to the
liked branch so the heart fills in.

Same shape as bug 2 of the original report — the like state needed to
be visually unambiguous. PhotoLikes.tsx was already fixed in this PR;
this catches the equivalent latent bug in the inline lightbox toolbar
button.

Also: bug 4 of the original report (recovery flow) turned out to be
SMTP misconfig on the reporter's end (mailhog silently dropping
emails), not a PicPeak bug. Confirmed in this thread; no further
backend changes needed.

Refs: #538
2026-05-20 17:28:50 +02:00
github-actions[bot] 03cf09a7bf chore(beta): release 3.54.2-beta.0 2026-05-20 14:49:19 +00:00
Paul Nothaft 5311588baf fix(feedback): three guest-mode bugs reported in #538
Picks up three of the four bugs from @Tietge86's report. Bug 4
(recovery flow) needs network-tab data from the reporter before a fix
makes sense; commented on the issue asking for the HTTP status from
POST /gallery/:slug/guest/recover.

Bug 1 — "Liked" filter empty in guest identity mode (GalleryView.tsx)

  The feedback filter was scoping by `photo.like_count > 0`, which is
  the global aggregate across all guests. In guest identity mode the
  filter intent is "show MY picks", so a guest who'd liked photos that
  nobody else had touched got an empty grid.

  Fix: pull the current guest's interactions from /my-feedback (already
  keyed by x-guest-token in the api interceptor) into per-type
  photo-id Sets and filter against those when identity_mode === 'guest'.
  Falls back to the aggregate-count check in simple mode where there's
  no per-person identity to scope by. Same per-guest scoping applied to
  the chip-count labels ("Liked (N)" etc.) so the chip number matches
  what the filter actually surfaces — otherwise the chip says one
  count globally and the filter shows a different (smaller) one, which
  is the same UX cliff #538 originally surfaced.

  The /my-feedback query is gated on isGuestIdentityMode (not on
  filterType being feedback-related) so the chip counts are populated
  on first render. One extra request per gallery load in guest mode;
  payload is tiny.

Bug 2 — Liked state on PhotoLikes button invisible

  bg-red-50 text-red-600 is barely visible against most themes,
  especially dark + brand-coloured backgrounds. Switch to the same
  filled state the lightbox toolbar already uses
  (bg-red-500/80 text-white) so the like registers visually.
  Heart icon's fill-current was already there for the liked state —
  unchanged.

Bug 3 — Aggregate like count leaks in lightbox toolbar

  PhotoLightbox.tsx rendered {likeCount} unconditionally next to the
  inline heart button. When the admin has show_feedback_to_guests off,
  guests still saw how many other guests had liked a photo (the count
  is an admin-only metric in that mode). Gate the span on
  feedbackSettings?.show_feedback_to_guests, matching how the rest of
  the lightbox toolbar treats that toggle. Also added show_feedback_to_guests
  to the local feedbackSettings TS type (backend already returns it).

Tests: tsc --noEmit clean. No new unit tests — bug 1 is data-flow
plumbing best validated via manual / e2e (the existing my-feedback
endpoint and feedbackService are already covered upstream); bugs 2/3
are CSS and a conditional render.

Refs: #538 (bugs 1, 2, 3 of 4)
2026-05-20 16:42:22 +02:00
rpintodasilva dae47518fb fixes 2026-05-20 13:13:35 +02:00
rpintodasilva fd15be6247 French Transalation - v2 2026-05-20 10:40:24 +02:00
github-actions[bot] baf5f27fb4 chore(beta): release 3.54.1-beta.0 2026-05-20 06:53:31 +00:00
paul 8b72721812 fix(public-site): honor dark theme surface colors 2026-05-20 08:48:40 +02:00
github-actions[bot] 085a2e5fed chore(beta): release 3.54.0-beta.0 2026-05-20 06:24:22 +00:00
github-actions[bot] c042a33431 chore(beta): release 3.53.0-beta.0 2026-05-19 05:27:14 +00:00
Paul Nothaft 4b4ecfdf71 fix(header): hide language name on mobile to free the title (#523)
@Rekoo-PS reported the LanguageSelector pushing into the company-name
title on narrow viewports — the button always rendered
Globe + flag + full language name (~120px), and on mobile that pinched
the left-side title cluster in AdminHeader.

Wrap the name in `hidden sm:inline` so <sm the button collapses to
just Globe + flag, matching the existing "hidden xl:block" pattern
on the date display in the same header. Self-explanatory at icon-only
width (users see their current flag and a globe), and the dropdown
still shows full names when opened. Title/aria-label keep the name
discoverable for screen readers + tooltip hover on the icon-only state.

Refs: #523
2026-05-18 23:19:42 +02:00
Paul Nothaft b960639035 fix(og): brandable static title + wider crawler UA coverage (#521)
@Rekoo-PS reported that gallery URLs sent via the WhatsApp Business
API render an unbranded "PicPeak - Photo Sharing Platform" preview
even though manual link sends from the WhatsApp app pick up the
per-event rich preview correctly. Two root causes, two fixes:

1. WhatsApp Business and 3rd-party preview services (Twilio,
   LinkPreview.net, etc.) don't always crawl with the recognisable
   "WhatsApp/X.Y.Z" UA we matched in nginx + galleryOgService.
   Extend the regex (both copies) to also catch WhatsAppBot, wa-bot,
   LinkPreview, and Slack-ImgProxy.

2. Even with broader UA coverage, some senders cache metadata with
   no UA at all and fetch the static SPA shell. That shell's
   <title> was hard-coded to "PicPeak - Photo Sharing Platform" —
   embarrassingly generic for any self-hosted brand. Switch to
   Vite's %VITE_DEFAULT_TITLE% / %VITE_DEFAULT_DESCRIPTION% HTML
   substitution so self-hosters can bake their brand into the
   fallback at build time. Defaults stay "PicPeak" so the upstream
   image doesn't change behaviour for anyone.

The per-event rich preview path (handleGalleryOgRequest, fired on
matched crawler UAs) is unchanged — this only improves the fallback
for unrecognised UAs and for the SPA-shell title that humans see in
their browser tab.

Adds a vite.config plugin to provide the defaults when env vars
aren't set, so unsubstituted "%VITE_..." literals never reach the
built HTML. Adds .env.example entries explaining the override.

Tests: extend galleryOgService.shareImage.test.js with an
isSocialCrawler suite that pins every documented UA (incl. the new
ones) plus three browser UAs (negative) and null/empty edge cases.
Verified locally: `vite build` with VITE_DEFAULT_TITLE="MyBrand"
produces <title>MyBrand</title> + og:title="MyBrand"; without the
env var falls back to "PicPeak".

Refs: #521
2026-05-18 22:45:00 +02:00
Paul Nothaft 3465b55abc feat(events): default Guest Feedback ON via admin setting (#520)
@Rekoo-PS asked for an admin-level switch so new events can have Guest
Feedback enabled out of the box instead of toggling it on every time.
Mirrors the existing event_default_require_password pattern (#317) —
same shape end-to-end, same set of five files.

- publicSettings.js: whitelist + expose event_default_feedback_enabled
  (defaults to false to match the prior hard-coded form default; no
  behaviour change for existing installs until an admin flips it).
- adminEvents.js: rename `feedback_enabled = false` destructure to
  `feedback_enabled: feedbackEnabledInput` so we can distinguish
  "omitted" from "explicit false", then resolve the default from the
  setting only when the caller omitted it — identical to the
  require_password handling a few lines above.
- Frontend EventSettings type + state + loader: new boolean,
  default false.
- EventsTab: toggle UI right under "Require password by default".
- CreateEventPage: one-shot useEffect that seeds
  feedback_settings.feedback_enabled from the public setting on first
  load (mirrors the require_password seed effect right above it).
  Sub-toggles (likes / ratings / comments) keep their hard-coded
  true defaults so flipping the master setting immediately gives
  sensible behaviour without a second admin setting to manage.

Refs: #520
2026-05-18 21:26:45 +02:00
Paul Nothaft d44e1adba7 fix(lightbox): hide comments toggle when allow_comments=false (#518)
@Rekoo-PS reported the MessageSquare comment button stayed visible in
the lightbox toolbar even when guest comments were disabled. Same
class of bug as #513 (per-photo Like button missing the master
gate) but on a different control.

The Like and Rating buttons in the lightbox toolbar gate correctly:
  feedbackEnabled && feedbackSettings?.allow_likes
  feedbackEnabled && feedbackSettings?.allow_ratings

The MessageSquare button only checked feedbackEnabled. Since likes
and ratings already have their own inline buttons in the same
toolbar, this third button is effectively the "open comments panel"
affordance — its badge counts comments, its tooltip mentions
comments. When comments are off it has nothing meaningful to do.

Add allow_comments to the local feedbackSettings type (the backend
already returns it via galleryFeedback.js:33) and gate the button on
feedbackEnabled && feedbackSettings?.allow_comments.

Refs: #518
2026-05-18 21:13:03 +02:00
github-actions[bot] b2b46d311d chore(beta): release 3.52.1-beta.0 2026-05-18 18:59:57 +00:00
github-actions[bot] db3f1b83ce chore(beta): release 3.52.0-beta.0 2026-05-18 18:56:50 +00:00
github-actions[bot] acb387f69c chore(beta): release 3.51.5-beta.0 2026-05-17 07:28:47 +00:00
github-actions[bot] 4da6726e57 chore(beta): release 3.51.4-beta.0 2026-05-17 07:26:57 +00:00
github-actions[bot] 941453fd7f chore(beta): release 3.51.3-beta.0 2026-05-16 23:19:50 +00:00
Paul Nothaft 482e91bbf8 Merge pull request #501 from filpgame/fix/settings-page-language-reset
fix(i18n): settings page resets UI language to server default
2026-05-17 01:19:27 +02:00
github-actions[bot] 5a9ca4ad57 chore(beta): release 3.51.2-beta.0 2026-05-16 23:02:10 +00:00
Paul Nothaft aac60fa895 Merge pull request #513 from the-luap/fix/bug-batch
fix/feat: bug batch — drag-drop, lightbox, likes, downloads, upload, i18n (#504-510)
2026-05-17 01:01:49 +02:00
Paul Nothaft 51890e1aa5 fix(i18n): drive customer "Preferred language" select from SUPPORTED_LANGUAGES (#510)
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.
2026-05-17 00:57:03 +02:00
github-actions[bot] 2e828fcf9f chore(beta): release 3.51.1-beta.0 2026-05-16 22:53:14 +00:00
Paul Nothaft 061712ebf1 feat(i18n): add Spanish (es) locale (#510)
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.
2026-05-17 00:50:51 +02:00
Paul Nothaft 98f3c3df41 fix(upload): restore configurable batch-size for reverse proxies (#509)
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.
2026-05-17 00:42:22 +02:00
Paul Nothaft 33de294d57 feat(lightbox): surface original camera filenames (#508)
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.
2026-05-17 00:35:16 +02:00
Paul Nothaft 38343e62de fix(downloads): apply original-filename toggle to individual downloads too (#507)
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.
2026-05-17 00:25:12 +02:00
Paul Nothaft 9d2db9a73b fix(gallery): hide Like button when guest feedback is off (#506)
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.
2026-05-17 00:13:21 +02:00
Paul Nothaft d2d55098d6 fix(lightbox): align swipe-neighbour height + stop black flash on commit (#505)
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.
2026-05-17 00:09:15 +02:00
Paul Nothaft 577c4bdf29 fix(upload): wire drag-and-drop on admin + user upload zones (#504)
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.
2026-05-17 00:02:39 +02:00
filpgame 165ebce8d1 fix: settings page resets UI language to server default
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.
2026-05-16 01:14:57 -03:00
github-actions[bot] 72c2b5c796 chore(beta): release 3.51.0-beta.0 2026-05-14 21:25:07 +00:00