@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
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)
@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
@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
@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
@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
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.