Implements the recurring-customer login surface from
the-luap/picpeak#354 plugged into the maintainer's
new feature-flag infrastructure (PR #443) instead of
a parallel toggle.
* New `customerPortal` feature flag (foundation flag for the
not-yet-built calendar/quotes/bills/messaging customer
surfaces). Defaults FALSE on fresh installs, TRUE on existing
installs (events > 0) via migration 095 so live customer
accounts don't disappear mid-deployment.
* Foundation schema: customer_accounts, customer_invitations,
event_customer_assignments, customer_password_resets, plus
RBAC permissions customers.view / .create / .delete granted
to super_admin + admin system roles.
* Backend: /api/admin/customers (invite, list, search, assign,
deactivate, reset password) + /api/customer/auth/* +
/api/customer/* (login, dashboard, accept-invite, reset).
Customer JWT bypass minted via
/api/customer/events/:slug/access-token so existing gallery
middleware stays untouched.
* Frontend: /customer/* route tree gated by RequireFeature flag
customerPortal, with login / dashboard / accept-invite /
reset pages and a customer-side sidebar layout.
/admin/customers and /admin/customers/:id gated identically.
* Settings → Features grows a "Customers" section with a
Customer portal card. The maintainer's Features tab stays the
single source of truth — no parallel Advanced features tab.
* CustomerAccountPicker on event create/edit forms hides itself
when the flag is off; backend ignores customer_account_ids in
that case instead of erroring the whole event save.
Translations: en + de hand-translated. nl/pt/ru fall through to
en — flagged here as needing native review.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
Combined footer overhaul:
- Per-CMS-page show_in_footer toggle (#441) — admins can hide
Impressum / Datenschutz from the gallery footer when an external
privacy / imprint URL is enough.
- Five social-media URL fields in branding settings (#441) — Facebook,
Instagram, WhatsApp, X/Twitter, YouTube. Empty string hides each
icon individually; the row is omitted when none are set.
- Promotional banner slot above or below the gallery footer (#440) —
global default authored as markdown in branding settings, plus a
three-way per-event override on the Edit Event form
(inherit / custom / off). Backend nulls promo_markdown automatically
when mode != 'custom' so stale text never persists.
Sanitization: marked with gfm/breaks → DOMPurify with a tight
allowlist (no img, no tables, no inline html). Post-process forces
target=_blank rel="noopener noreferrer nofollow" on every link so
admin-set URLs can't tab-nap the gallery context.
i18n covers all six locales (en/de/nl/pt/ru/fr).
Targets the beta branch.
Reorganises the admin sidebar around what users actually do, and adds a
single Features page that gates which feature surfaces appear in the
nav. Shrinks the main sidebar from 11 items to 4-6 (depending on
feature flags) and groups configuration screens into a single Settings
home with six logical sections.
Why
---
The current sidebar mixes three concerns: workspaces (Dashboard, Events,
Archives), feature surfaces (Analytics, Users), and configuration
screens that get touched maybe once a month (Email Settings, Branding,
Event Types, Backup, CMS Pages). That's 11 items, half of them config.
Backend
-------
- New `feature_flags` table (key, value, updated_at, updated_by).
Migration 088 detects existing-vs-fresh installs from the events
table:
* Existing install (events>0) → all 9 flags TRUE so nothing
vanishes from an admin's UI on upgrade.
* Fresh install (events=0) → spec defaults: galleries,
reminderEmails, analytics, userManagement TRUE; calendar,
calendarBooking, quotes, bills, messaging FALSE.
- New `/api/admin/feature-flags` (GET/PUT) under `settings.view` and
`settings.edit`. Server enforces the same dependency rules the
frontend does (galleries always TRUE, quotes=false → bills=false,
calendar=false → calendarBooking=false). PUT writes one
`feature_flags_updated` activity log row with the diff.
Frontend
--------
- `FeatureFlagsContext` provides `useFeatureFlags()` (with staged/save/
reset/isDirty) and `useFeatureEnabled(key)`. Mounted inside
AdminLayout so flag fetches carry the auth cookie. Source of truth
is the server response; staged is a local copy that the Features tab
edits and the Save button PUTs.
- `RequireFeature` route guard for /admin/analytics and /admin/users —
redirects to /admin/dashboard when the corresponding flag is OFF.
- AdminSidebar dropped from 11 to 6 items. Removed: Email Settings,
Branding, Event Types, Backup, CMS Pages (now Settings tabs).
Feature-gated: Analytics, Users.
- Old top-level routes (/admin/email, /admin/branding, /admin/event-
types, /admin/backup, /admin/cms) kept as <Navigate> redirects to
/admin/settings?tab=<key> so existing bookmarks don't 404.
- SettingsPage rewritten with a 6-group inner-nav (General /
Content & Appearance / Communication / Privacy & Security /
Integrations / System) and 19 tabs. New Features tab is the
default landing tab. URL ?tab=<key> roundtrips with state — deep
links and the back button work.
- FeaturesTab renders 9 cards across 5 sections. Toggles enabled for
Analytics + User Management (the two flags that gate sidebar items
in this PR). All other toggles disabled with a "Not yet available"
lockedReason — the cards still render so admins see the roadmap, but
the flag has no UI effect until the surface ships in its own PR. The
galleries card is locked TRUE per spec (foundation, can't be off).
- Live SidebarPreview reflects unsaved staged changes — admins see
what their sidebar will look like before they save.
- New i18n keys across all 5 locales (en, de, nl, pt, ru) for the
Features tab copy, the new Settings group labels, and the lifted
tab titles.
Verified end-to-end
-------------------
- Migration on this dev DB (existing install, 977 events): all 9 flags
set to TRUE.
- Migration on simulated fresh install (events table emptied): spec
defaults applied (5 OFF, 4 ON).
- Backend round-trip: GET → PUT → audit-log entry written, dependency
rule enforced (bills forced false when quotes=false even when bills=
true requested).
- UI Playwright spec: sidebar dropped 5 items, old top-level routes
redirect, Features tab is default, Galleries+Calendar+Quotes+Bills+
Messaging+ReminderEmails toggles disabled, Analytics+Users toggles
enabled, toggling Analytics off + saving updates the sidebar +
redirects /admin/analytics to /admin/dashboard.
- Smoke 13/13 still green; no regressions on existing flows.
The bulk-delete modal previously used a password input as a confirmation
gate, with an Enter-to-submit handler. Windows Hello / passkey flows
that target password fields were able to autofill and synthesise an
Enter keystroke, which submitted the form and triggered the destructive
delete without an explicit click on the red Delete button (Rekoo's
report in #417).
Replace the password gate with a GitHub-style typed-literal pattern:
the user types the literal "DELETE" (English, case-sensitive) into a
plain text input. The Delete button stays disabled until the input
matches, and there is no Enter-to-submit handler — only an explicit
click on the red button proceeds. Plain text inputs aren't subject to
password autofill or passkey ceremony so the auto-submit class of bug
is gone.
Server side, drop the bcrypt password verify on /admin/events/bulk-delete
and the related INVALID_PASSWORD response. The server's auth boundary
remains adminAuth + requirePermission('events.delete'); this matches
DELETE /admin/events/:id which has never required a re-entered password.
The client-side typed gate is the safeguard against accidental clicks.
i18n: drop password-related keys, add confirmLabel + confirmHelp across
en, de, nl, pt, ru. The literal "DELETE" stays English in all locales
to keep the gesture immune to translation drift and unambiguous.
Verified locally: typed-DELETE sanity spec covers the gate (wrong case
disabled, correct enables, Enter-on-input no-ops, click submits, events
deleted). Existing 03-bulk-archive smoke remains green.
Two follow-ups from PR #401's review:
1. Download button text was hardcoded `color: '#ffffff'`. Once admins
start picking palettes via #400's expanded customizer, a pale accent
(yellow, pastel blue, etc.) leaves the button unreadable — white
text on near-white background.
Fix: derive the foreground colour from the accent's WCAG relative
luminance and expose it as the new `--color-accent-fg` CSS variable
in ThemeContext.applyTheme. Light backgrounds (L >= 0.5) get black
text; dark backgrounds get white. Same treatment applied to
`--color-accent-dark-fg` for the filled-CTA token.
The Download button now reads `var(--color-accent-fg, #ffffff)` so
any future component that paints on accent gets the same treatment
for free, and legacy deployments before the variable is set fall
back to the previous hardcoded white.
Threshold-based (rather than "highest contrast ratio") to preserve
how saturated mid-tone accents have always rendered. The Picpeak
default green (#5C8762, L≈0.20) keeps white text — same visual
identity as before. Only genuinely pale accents flip to black,
which is the actual scenario the review flagged.
2. The Download button JSX was duplicated three times in
GalleryLayout.tsx (standard/banner, minimal, hero — ~15 lines
each). Extracted into a small inline `HeaderDownloadButton`
component above the GalleryLayout export. Three call sites now
collapse to a 5-line component invocation each. Markup,
accessibility, and styling live in one place — future tweaks
only need to happen once.
## Files
- `frontend/src/utils/contrast.ts` — new helper module:
`relativeLuminance(hex)` (WCAG 2.x sRGB luminance) and
`getReadableForeground(hex)` (white-or-black picker).
- `frontend/src/utils/__tests__/contrast.test.ts` — 10 cases:
fallbacks, saturated mid-tones, pale accents, near-black,
shorthand `#RGB`, no-leading-`#`, case-insensitive, anchors
(black/white luminance).
- `frontend/src/contexts/ThemeContext.tsx` — wire the helper into
`applyTheme`: set `--color-accent-fg` from `accentColor` and
`--color-accent-dark-fg` from `accentDarkColor`/`primaryColor`.
- `frontend/src/components/gallery/GalleryLayout.tsx` — extract
`HeaderDownloadButton` component above `GalleryLayout`, replace
three inline button blocks with the component, update its inline
style to read `--color-accent-fg` (with the legacy `#ffffff` as
the CSS-variable fallback).
## Verified
- `npx vitest run src/utils/__tests__/contrast.test.ts` — 10/10 pass
- `npx tsc --noEmit` — clean
- `npx eslint` clean on every touched file
- Default PicPeak green still renders white text (no regression)
- Pale accent (#fef9c3 yellow-100) now correctly renders black text
Addresses the-luap/picpeak#386 — gallery header layout cleanup.
- Drop the redundant "Menu" text label; menu button is icon-only with
tight padding (p-2).
- Absolute-position the menu icon at the very left of the header so it
no longer pushes the logo right with every other action. Logo wrapper
picks up pl-12 sm:pl-14 only when a menu button is rendered, so the
icon and logo don't overlap. When no menu button (controlsStyle:
classic), logo is flush with .container.
- New accent-coloured "Download" CTA placed immediately left of Logout.
Always visible when downloads are allowed; replaces the previous
primary-coloured "Download All" header button. Same CTA appears in
standard, hero, and minimal headers. Intentionally NOT shown in the
no-header variant (chromeless by design).
- Coloured via var(--color-accent) inline so the button automatically
tracks whatever palette the admin has chosen — works on plain beta
today (#22c55e) and auto-upgrades to the CI accent when #400 lands.
The sidebar's own Download All is untouched. Old showDownloadAll prop
stays on GalleryLayout for back-compat; GalleryView now passes
showDownloadAll={false} so only the new accent button renders in the
header.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
Adds the bulk-delete half of #384 — admins can select multiple
events from the list and delete them in one batch, gated by
re-entering their password.
## Why password confirmation
Bulk delete is destructive and irreversible (cascades across 5 DB
tables and 3 filesystem paths per event). Re-entering the password
matches the pattern already used by /auth/admin/change-password and
makes accidental clicks much harder than a plain "type DELETE to
confirm" — the muscle-memory required to type your real password is
a stronger gate than typing a literal word.
## Changes
### Backend (adminEvents.js)
- Extracted the per-event cascade-delete logic into a module-private
`deleteEventCascade(eventId, adminContext)` helper. The DELETE /:id
route now calls it instead of inlining 60 lines of cascade — same
behaviour, no drift between the per-event and bulk paths.
- New `POST /admin/events/bulk-delete`. Body: `{ eventIds, password }`.
Permission: `events.delete`.
- Validates `eventIds` array length (1–100) and that each id is an
integer. The 100-cap keeps request time bounded; the per-event
cascade touches DB + filesystem so 1000 events at once would risk
timing out the request.
- Verifies `password` against the calling admin's bcrypt hash via
`bcrypt.compare()` (same as /auth/admin/change-password). Wrong
password → 401 `{ error, code: 'INVALID_PASSWORD' }` and no
events are touched.
- Loops via `deleteEventCascade`, returns
`{ results: { successful, failed } }` with the same shape as
/bulk-archive so the frontend can show partial-failure feedback.
- Logs `bulk_delete_completed` activity with totals.
### Frontend
- `events.service.ts`: `bulkDeleteEvents(eventIds, password)`.
- New `BulkDeleteModal.tsx`. Red/destructive variant of the
bulk-archive modal:
- Lists the events to be deleted (so the admin can verify).
- Password input with show/hide toggle, autofocus, Enter-to-submit.
- Inline `passwordError` prop surfaces the 401 INVALID_PASSWORD
response without losing the modal state — admin can retry
without re-typing the event list.
- "Processing" state replaces the form with a spinner + "Deleting
N events. This may take a few minutes — please don't close this
window." (i18n) so admins know not to abandon the page during
a slow operation.
- `EventsListPage.tsx`: "Delete Selected" button next to "Archive
Selected" in the bulk-actions bar (red-styled to signal danger),
bulkDeleteMutation that maps the 401 to the modal's inline error
and any other failure to a generic toast.
### i18n
12 new keys under `events.bulkDelete.*` in all 5 locales
(en/de/nl/pt/ru): title, warning, password label/placeholder/help,
submit, processing, incorrectPassword, successAll, successPartial,
errorGeneric, plus `events.deleteSelected` for the button. Hand-
written for de; nl/pt/ru should get a native-speaker pass at some
point but read naturally.
### Verified
- `npx tsc --noEmit` clean
- `npx eslint` clean on every touched file (4 pre-existing errors in
adminEvents.js for unused vars unrelated to this PR)
- All 5 locale JSON files parse cleanly
- `node -e "require('./src/routes/adminEvents')"` loads the module
Closes the bulk-delete half of #384. The Photos-column half lands
separately in PR #387.
The rebuilt modal in this PR shipped with hard-coded English strings.
That made the reset flow untranslated for German/Dutch/Portuguese/
Russian customers — toasts, confirm dialog, success screen all
fell back to English regardless of the active locale.
- New `events.passwordReset.*` namespace in en/de/nl/pt/ru with 22
keys covering both modal screens, the warning banner, validation
errors, and the toast messages.
- Modal uses `useTranslation()` for every previously hard-coded
string. Reuses `common.cancel`, `events.copy`, `events.copied`
where they already exist across all locales.
- The {{eventName}} interpolation uses i18next's standard variable
syntax so the description line reads naturally in each language.
No behaviour change. TypeScript clean (`npx tsc --noEmit`), ESLint
clean. JSON validity checked for all 5 locale files.
Two related defects on the same gallery-email surface that PR #367
opened, addressed together:
1. Reset-password endpoint was a one-way auto-generate.
`POST /admin/events/:id/reset-password` always called
`generateReadablePassword()` and ignored any client-supplied value;
the modal only offered a confirm + a forced auto-generated result.
Admins who wanted to set a memorable customer-supplied password
had no way to do it.
Backend: route now reads optional `password` from the body. If
present, validates with `validatePasswordInContext('gallery', …)`
(same rules as create-event) and uses it; if absent, falls back to
the existing generator, so old callers / cron stay functional.
Switched the bcrypt rounds from a hard-coded `10` to
`getBcryptRounds()` to match the create flow.
Frontend: rebuilt `PasswordResetModal.tsx`. Typed input with
show/hide, confirm-password field that appears on type, the same
`<PasswordGenerator>` used by `CreateEventPage` (event-context-
aware, fills both fields when used), send-email checkbox,
client-side validation, server-side validation feedback inline.
Submit empty → server auto-generates and the success screen shows
the value with a copy button (legacy one-click flow preserved);
submit with a typed password → success toast + close (no need to
re-show what the admin already typed).
Service layer: `events.service.resetPassword(id, sendEmail,
password?)` only sends `password` in the body when set.
Caller: `EventDetailsPage` now passes `eventDate` + `eventType`
into the modal so the generator has event context.
2. `gallery_link` was the path-only `event.share_link` in three
email-queue sites, so customer mail showed
`/gallery/<slug>/<token>` instead of the full
`https://example.com/gallery/<slug>/<token>` URL.
- `adminEvents.js` reset-password queue (#1437)
- `adminEvents.js` resend-creation-email queue (#1502)
- `expirationChecker.js` expiration_warning queue (#82)
All three now derive `shareUrl` from `buildShareLinkVariants`
(the same helper already used by create-event, publish-from-
draft, and event-rename). The other 4 callers
(`adminEvents.js:651/913`, `events.js:187`,
`eventRenameService.js:231`) already used the full URL — this
closes the gap.
Verified: TypeScript clean (`npx tsc --noEmit`), ESLint clean on
every touched file (the 4 lint errors that remain in
`adminEvents.js` are pre-existing and predate this branch).
Live processing-state UI that complements the backend async pipeline.
Modal stays open through the processing phase and surfaces real
progress (X of N photos processed); the admin grid renders placeholder
cards for in-flight photos and auto-refreshes via polling until the
queue drains.
services/uploads.service.ts (new)
- getStatus(uploadId) — JSON snapshot from /admin/uploads/:id/status
- retryPhoto(photoId) — POST /admin/photos/:id/retry
- streamUrl(uploadId) — SSE upgrade URL
hooks/useUploadProgress.ts (new)
- Tracks N concurrent upload IDs (one per chunk POST) and merges
counters into a single aggregate.
- Always polls every 1.5s; opportunistic SSE upgrade on top of that.
SSE failure (proxy buffering, etc.) silently downgrades to polling
only — no reconnect storms.
- Auto-stops both channels when every tracked group is in a terminal
(complete/failed) state.
components/admin/PhotoUpload.tsx
- Captures upload_id from each chunk's 202 response, feeds them into
useUploadProgress.
- Phase machine extended: stays in 'processing' until the worker
drains the queue (not just until bytes-on-wire). Progress UI shows
real "X of N done" with a determinate bar fed by the aggregate.
- "You can leave this page" hint kept — closing the modal is now
actually safe, work continues server-side.
- Side-effect refactor: invokes onUploadComplete twice — once early
so the user sees photos appearing immediately, once on terminal
so the parent grid sees final state.
components/admin/AdminPhotoGrid.tsx
- Photos with processing_status pending/processing render an amber
placeholder card with a spinning Cog instead of the missing
thumbnail.
- Photos with status='failed' render a red card with the error message
and a "Retry" button that POSTs /admin/photos/:id/retry.
pages/admin/EventDetailsPage.tsx
- Photo list query gains refetchInterval that polls every 2s while
any photo is non-terminal, then stops. Keeps the grid auto-fresh
during ongoing processing.
Phase 1 of the upload-progress redesign. Two changes that ship UX wins
without any architectural surgery — they're a stepping stone for the
full async-processing rework that follows in subsequent commits.
1. Two-state progress bar (PhotoUpload.tsx, UserPhotoUpload.tsx)
When axios.onUploadProgress reports loaded === total, the request is
on the server and the bytes have left the browser. Today the bar sits
at 100% for the chunk while the backend runs sharp/ffmpeg/EXIF (often
minutes on NFS-backed storage) and users assume the upload froze.
The component now distinguishes two phases:
- 'transferring' — bytes-on-wire, determinate progress bar.
- 'processing' — bytes done, waiting for response. Indeterminate
spinner + an explanatory hint that the backend is
generating thumbnails / reading metadata and the
user can leave the page.
Same pattern in UserPhotoUpload (gallery): the per-file checkmark
icon is replaced by a Loader2 spinner while the request is in flight
after bytes-on-wire finished.
2. Temp directory cleanup (adminPhotos.js)
Multer creates temp/upload_<ts>_<rand>/ per request. Files inside it
are individually unlinked after they're moved to storage on the
success path, but the empty directory was never removed. On error
paths three different inline blocks each tried to clean up; the
success path was missed entirely. Result: the orphan-empty-dirs
accumulation reported in the issue (70+ on the affected instance).
Replace the inline cleanup blocks with a single idempotent
cleanupTempDir() registered on res.finish + res.close, so it fires
exactly once on every exit path (validation 4xx, server 5xx, multer
error, success).
New translation keys (en/de): upload.transferring, upload.processing,
upload.processingHint, upload.processingProgress, upload.processingFailed,
upload.retryFailed.
Two further fixes for the gallery loading sequence shown in
@Rekoo-PS's frame breakdown on issue #358 — both about colours that
didn't track the active theme.
1. Initial white frame (frame f1)
The pre-React bootstrap script in #359 sets the cached background
on documentElement, but the browser may paint the very first frame
*before* that <script> tag runs (synchronous parse-time JS in the
<head> is still slightly later than CSS apply-time). On first-visit
dark-OS devices that meant a single white frame before the script
resolved.
Fix: move the OS-preference default into a <style> block that
precedes the script. CSS @media (prefers-color-scheme) is applied
before paint, so dark-OS devices land on dark from frame zero.
The script keeps the per-gallery cache hit on top, and now also
stamps the colour onto document.body in case the body element has
already mounted by the time the script runs.
2. "Most annoying" skeleton tile frame (frame f4)
Skeleton placeholders rendered as bright `bg-neutral-200` light grey
regardless of theme. On a dark gallery that's the highest-contrast
thing on screen during loading — the exact frame Rekoo-PS labelled
"the most annoying" in the issue.
Fix: the Skeleton component's background now reads
`var(--color-surface-border)`, which ThemeContext already wires up
per active theme (`#e5e5e5` light / `#2e2e2e` dark by default; per-
event themes can override). The bare `<div>` no longer carries any
colour utility class — the inline style supplies the active value.
Also dropped the leftover `bg-white` on SkeletonCard / SkeletonTable
in favour of `var(--color-surface)` for the same reason.
Tests:
New src/components/common/__tests__/Skeleton.test.tsx covers
- Skeleton uses var(--color-surface-border, ...)
- bg-neutral-200 is no longer present
- SkeletonGalleryGrid tiles all inherit the theme colour
- SkeletonCard surface uses var(--color-surface)
Two fixes for discussion #348.
Carousel-style swipe
The lightbox previously snapped to the next photo on swipe, then showed
a loading spinner while the new image fetched — choppy compared with
the reference video the reporter shared. The current photo is now
rendered inside a 3-slide track (prev/current/next). As the finger
drags, the track follows; on release the track animates to the
neighbouring slot or springs back if the gesture didn't pass the
threshold. Because the prev/next AuthenticatedImages render up front,
the browser starts fetching them while the user is still on the
current photo, so there's no loader flash on commit.
- Phase machine ('idle' | 'dragging' | 'committing' | 'springing')
drives the track's transform/transition. Commit + spring use a 280ms
cubic-bezier ease.
- Percentage-based transforms avoid measuring container width before
the first paint. Commit threshold (read from the ref on demand) is
max(60px, 20% of width) OR a fast flick (>0.5 px/ms with at least
40px of movement).
- transitionend advances currentIndex with wrap-around and resets the
track in one batch — slot contents rotate and the track snaps from
the commit position back to centered with transition: none, so the
visible image stays put. No flicker.
- Vertical-cancel (>24px dy) abandons the drag and springs back so the
user keeps the gesture they intended.
- touch-action: none on the carousel container stops the browser
fighting us with edge-swipe back navigation and native pinch-zoom.
- Pinch starting mid-drag springs the track back smoothly so the image
doesn't jerk under the second finger.
- onTouchCancel covers system-interrupted gestures (incoming call etc).
- dragX === 0 short-circuits to 'idle' instead of 'springing' so taps
don't get stuck waiting for a transitionend that never fires.
- Neighbour slides use a simplified AuthenticatedImage render (no
canvas/fragment-grid pipeline) since they're only on screen during
the swipe; the current slide keeps the full protection chain.
- Neighbour videos render their thumbnail rather than spinning up a
VideoPlayer. When the *current* photo is a video, the carousel is
bypassed entirely — single VideoPlayer + no swipe handlers — because
sliding a video element during a drag is awkward and adds nothing.
- Removed the now-redundant imageLoaded state + spinner;
AuthenticatedImage already shows a placeholder while loading.
Keyboard arrows and the on-screen Prev/Next buttons still snap (no
animation) — animating them would have required input queuing for
fast double-presses, and the request was specifically about swipe.
"Swipe to navigate" hint
Removed the mobile-only overlay text. Swipe is universal in image
viewers; the instruction read like training wheels and competed with
the photo for attention.
The gallery loading skeleton now renders the header bars immediately but
delays the 12-tile placeholder grid by 300ms. Galleries that load
quickly (the common case) never flash the empty grid before the real
photos render — addressing the follow-up reported on #321 — while
slower loads still get a placeholder so the page doesn't sit blank.
When feedback was enabled the lightbox bottom toolbar packed counter +
zoom + download + like + 5-star + comments into a single row that
overflowed the viewport on iPhone-class widths, putting the rating
stars under the screen edge and below the iOS home indicator.
Changes:
- Bottom toolbar now uses flex-wrap with reduced gap/padding on mobile,
so all controls fit (375px viewport: max-right 363 < 375; 390px:
max-right 378 < 390; 393px: max-right 393 < 393).
- pb computed as max(0.75rem, env(safe-area-inset-bottom)) so the row
sits above the iOS home indicator on devices with a gesture bar.
- Close button top/right now use max(1rem, env(safe-area-inset-*)) so
it doesn't disappear under the notch / dynamic island.
- "Swipe to navigate" hint moved from bottom-20 to bottom-40 so it
clears the now-taller wrapped toolbar.
- index.html viewport meta gains viewport-fit=cover to enable
env(safe-area-inset-*) on iOS Safari.
Verified in mobile emulation across iPhone SE (375x667), iPhone 13/14
(390x844), iPhone 14 Pro (393x852) portrait, and 14 Pro landscape
(852x393) — toolbar fits, photo centered, no clipping.
Found via real-browser verification: with useState the prior commit's
handleTouchEnd captures swipeStart from its render closure, so when
touchstart and touchend fire inside the same React batch (fast swipe,
synthetic events, or a tight render cycle) the end handler reads the
stale null and skips navigation. useRef sidesteps the closure entirely
and is the right primitive for cross-event scratchpad state anyway.
Verified in a 4-photo gallery on mobile-emulation (390x844 touch):
- left swipe (-200px) advances 1/4 → 2/4
- right swipe (+200px) returns 2/4 → 1/4
- 20px swipe (under threshold) does not navigate
- vertical swipe (dy 300, dx 20) does not navigate
The lightbox showed a "Swipe to navigate" hint on mobile, but the touch
handlers only implemented pinch-to-zoom (2-finger). Single-finger swipe
fell through and the user could only navigate with the on-screen arrows.
Add a 1-finger swipe detector: track the initial touch position, and on
touchEnd compute deltaX/deltaY/duration. Trigger goToPrevious /
goToNext when the horizontal swipe exceeds 50px, dominates over
vertical motion (1.2x), and completes within 600ms. Suppressed while
zoomed in so the user can pan the image instead.
Pre-dedup: 7 calls to /api/public/settings on a single /admin/login page
load — 4 from raw-fetch consumers + 3 from React Query consumers using
inconsistent queryKeys. Captured live in Chrome DevTools.
Post-dedup: 1 call. Verified by tests/e2e/public-settings-dedup.spec.ts.
Adds:
- frontend/src/hooks/usePublicSettings.ts — single React Query hook,
60s staleTime, queryKey ['public-settings']. Vitest with mocked api
proves multi-mount dedup.
- Extended PublicSettings interface with seo_meta_* fields used by
RobotsMetaTags and branding_logo_* fields used by GalleryView/AdminHeader.
Migrates 19 call sites across 4 risk-ordered rounds:
- Round A (high fan-in): GlobalThemeProvider, MaintenanceContext (with
refetchInterval to preserve maintenance polling), MaintenanceWrapper
(drops the now-redundant per-route ping; axios interceptor already
handles 503), AdminHeader.
- Round B (gallery/login): GalleryView, GalleryPage, ClientAccessPage,
AdminLoginPage, MaintenanceMode.
- Round C (decorative): RobotsMetaTags, DynamicFavicon, CMSContentBlock,
ReCaptcha, useWatermarkSettings (rips out raw fetch + local state),
LegalPage.
- Round D (queryKey alignment): useLocalizedDate, UserPhotoUpload,
CreateEventPage. EventDetailsPage Round D ships in the follow-up
commit that adds presigned-download UI on the same page.
App.tsx (AnalyticsBootstrap) and EventDetailsPage are deferred to
later commits — both files mix #325 changes with backend feature work.
Every <button> inside ThemeCustomizerEnhanced was bare — no `type`
attribute, defaulting to `type="submit"`. Inside CreateEventPage's
<form onSubmit={handleSubmit}>, that turned every theme/layout/header/
divider/control/colour-mode/CSS-template click into a form submission.
When the form was empty, validation killed the submit silently — that
showed up earlier as #317.2 ("theme picker unclickable").
When the form was filled (event_name set, etc.), validation passed,
`createMutation.mutate(payload)` ran, and the user was navigated to a
freshly-created event they never asked for — #326's reported symptom.
Fix: add `type="button"` to all 9 unmarked <button>s in the customizer.
Also covered by smoke spec 09-create-event-no-instant-submit which fills
the form, clicks Modern Masonry, and asserts the URL stays on
/admin/events/new and the events count is unchanged.
The 404 catch-all and the "gallery not found" branches in GalleryPage
were hard-coded English strings on a default-themed background — the
one place where a white-labelled deployment leaked the PicPeak default
look. Pluggable now via the existing CMS Pages mechanism.
Backend:
- Seed two new default CMS pages: `not-found` and `gallery-not-found`,
with sensible English/German copy admins can edit in /admin/cms.
- Add `cms_pages.logo_url` (nullable) for per-page logo override; online
migration on existing deployments. Null falls back to the global
branding logo.
- New per-page logo upload (POST /api/admin/cms/pages/:slug/logo) +
clear endpoint (DELETE …/logo). Reuses the existing /uploads/logos
storage location with a `cms-<slug>-` filename prefix.
- adminCMS PUT now accepts logo_url; publicCMS GET returns it.
Frontend:
- New <CMSContentBlock slug fallback> component renders the CMS page in
the standard branded shell (logo precedence: page → branding → bundled
default), with DOMPurified content and footer/legal links.
- App.tsx: `path="*"` catch-all routes through CMSContentBlock("not-found").
- GalleryPage: collapses the two "gallery not found" branches (invalid
identifier + infoError archived/missing) into a single
CMSContentBlock("gallery-not-found"), so admins can edit one source
of truth.
- Admin CMS Page editor gains an "Upload Logo / Use site default"
control per page; falls back to the page's own English title in the
page list when no `legal.<slug>` translation is registered.
#323-A — Branding colour changes weren't persisting unless "Apply changes
immediately (Live Preview)" was checked. ThemeCustomizerEnhanced was
gating its `onChange` callback on `isPreviewMode`, but the parent
BrandingPage already gates global `setTheme()` on its own copy of that
flag — so the customizer's gate was double-gating and silently dropped
the new values from the parent state that Save reads from. Always
propagate `onChange`; let parents decide what's "live". Removed the now
no-op `isPreviewMode` prop and dropped the unused passers.
#323-B — Default theme set in Branding wasn't applied to new events.
CreateEventPage only inherited the event-type's recommended preset, with
'default' falling back to Classic Grid. Now reads `settings.theme_config`
on first load and uses it as the form's starting theme; the event-type
effect skips the generic 'default' so the Branding default sticks for
event types like "Other".
#321 — Visitors saw four sequential render states when opening a gallery
(full-page "Loading Gallery" → "publicly accessible — loading photos"
card → skeleton grid → real gallery). Extracted the skeleton into a
shared <GallerySkeleton/> and used it for both GalleryView's photos-
loading state and GalleryPage's gallery-info-loading + public-auto-login
phases. The "publicly accessible" interstitial is gone. Net: one
continuous skeleton from URL open until real photos render.
- Share link: display and copy now use the absolute URL built from the
current origin instead of the relative path stored in events.share_link.
Added a Copy Link button to the events list (inline + dropdown).
- Detect dev tools default: event creation now reads the global
enable_devtools_protection app setting instead of always falling back to
the column default; admins who disable it globally get new events with
it disabled too.
- Require password default: added a global "Require password by default"
setting (event_default_require_password, default true), exposed via
Settings -> Events. Create-event form initialises from it.
- Filter bar: added gallery_show_filter_bar setting and hide the search/
sort row in the public gallery when off, or when the gallery has zero
photos (fixes the empty-state UX from the screenshot).
- Theme picker unclickable on Create Event: memoised availableEventTypes
so its identity is stable. The "auto-apply event-type recommended
preset" effect was firing on every render due to the unstable array
reference and silently overwriting the user's preset selection ~1ms
after each click.
- Branding logo disappearing on theme change: handlePresetChange and
handleThemeChange no longer wipe the existing logoUrl when a preset
config (which carries no logoUrl) is applied; handleSave falls back to
brandingSettings.logo_url. themeMutation now invalidates the
admin-settings and public-settings caches so saved theme changes appear
immediately.
Pre-zip downloads:
- Generate ZIP in background after photo mutations (upload/delete/watermark change)
- Serve cached zip with Content-Length for instant downloads and native progress bar
- Falls back to on-the-fly streaming when no cache exists yet
- Frontend uses browser-native download when zip is ready (no blob buffering)
- New downloadZipService with debounced regeneration and in-memory locking
Photo replacement:
- Admin upload form gets "Replace existing photos with same name" checkbox
- Matches by original_filename (case-insensitive) within the same event
- Preserves photo ID, position, feedback, category, and visibility
- Updates file, thumbnail, dimensions, EXIF capture date on replacement
- Ambiguous matches (multiple photos with same name) skip replacement with warning
- New photoReplacementService with findReplacementCandidate and replacePhoto