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 admin events table didn't surface how many photos each event
contained — admins had to click into the event to find out. The
backend already computes `photo_count` for every row in the
GET /admin/events list response (adminEvents.js:794-796), so this
is a frontend-only display change.
- Insert a "Photos" column between Date and Status — groups with
the "what's in this event" info.
- Right-aligned, tabular-nums for clean numeric alignment in the
column.
- Reuses the existing `events.photos` i18n key already shipped in
all 5 locales for the EventDetailsPage tab list ("Fotos" / etc.) —
no new translations needed.
- Updates the empty-state colSpan from 7 to 8.
Closes the column-add half of #384. The bulk-delete request from
the same issue lands separately.
Follow-up to PR #378 — drops the (e: any) / (d: any) casts in the
external-folder-tree picker. ExternalEntry is already exported from
externalMedia.service.ts; the call site just wasn't using it.
- Import the type alongside the service.
- Annotate the dirs filter callback so `e.type` is the union 'dir' |
'file' instead of any.
- Drop the (d: any) annotation from the map — TypeScript infers
ExternalEntry from the typed `dirs` array.
No behaviour change, no test impact. `npx tsc --noEmit` clean,
`npx eslint` clean.
Two follow-ups to PR #372 (external-URL toggle for imprint /
privacy CMS pages):
1. **i18n.** PR #372 added 6 new `cms.*` keys to the en + de
locales but the project ships 5 locales total. Adds the missing
nl / pt / ru translations so the admin CMS page renders in the
active language for those users instead of falling back to
English literals next to the German/Dutch/Portuguese/Russian
surrounding strings.
2. **API shape.** `publicCMS.js` returned `external_url`
unconditionally — even when `use_external_url` is false the URL
value was still emitted in the public response. The frontend
correctly gated on both flags so it worked, but the API surface
was leaking a value the admin had explicitly disabled. The
value still lives in the DB (so the toggle can be flipped back
on without losing it), but the public endpoint now returns
`null` whenever the toggle is off.
Note: kept the existing `logo_url` shape unchanged. Its semantics
are different — null means "fall back to global branding" and
consumers rely on always having the field, so emitting it
unconditionally is intentional there.
No frontend change needed: both `GalleryLayout` and `LegalPage`
already gate on `use_external_url && external_url`, so the
short-circuit handles `external_url: null` correctly.
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)
Opening a gallery with a dark theme briefly painted a white background
between the initial HTML render and React applying the per-event theme.
The HTML shipped with no theme info, so the first paint used the
default (#fafafa) before /gallery/:slug/info resolved.
Two-part fix.
1. Inline bootstrap script in index.html runs synchronously before React
mounts. Reads the URL, looks up a per-slug background colour from
localStorage (gallery-theme-bg-<slug>), and applies it to
documentElement immediately. Falls back to #171717 when no cache
exists and the OS prefers dark, so first visits with dark OS still
land on a dark background.
2. ThemeContext.applyTheme writes the resolved background to
localStorage keyed by slug whenever a gallery theme loads. Revisits
then hit the bootstrap cache and never see a flash.
Added a 200ms transition on html.background-color so the rare
cache→API drift (e.g. theme palette changed admin-side since last
visit) is a smooth fade instead of a snap.
Limitation: first visit on a light-OS device to a dark gallery still
flashes once. Killing that case requires a server-rendered theme hint,
out of scope for an SPA bootstrap fix.
The empty-skeleton-grid part of the same report is already addressed
by the 300ms lazy render in #352 — Rekoo-PS just needs to update from
v3.32.1-beta.0 to v3.32.2-beta.0+.
The "Expires on" preview under the days-after-event input rendered
nonsense dates (e.g. 25.04.2026 + 120 days → 08.01.2095, ~68 years
out). Cause: handleInputChange stores e.target.value verbatim, which is
a string for <input type="number">, so formData.expires_in_days is "120"
not 120. date-fns addDays does:
_date.setDate(_date.getDate() + amount)
When amount is a string, the + is string concatenation:
25 + "120" = "25120". setDate("25120") then sets day-of-month to 25120,
which carries over by ~68 years.
Fix: cast to Number at the call site. The validation/API-payload
codepaths already work because the comparisons at line 330 and the
JSON payload coerce numerically through different paths — only addDays
was actually broken.
The TypeScript type FormData.expires_in_days: number is a lie because
handleInputChange's [field]: e.target.value sets a string regardless.
Tightening that handler is a separate cleanup; this commit only fixes
the visible date bug.
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.
Counters and search on Admin → Events were bounded to the first 100 rows
returned from /admin/events?page=1&limit=100, so on instances with more
events the totals were wrong and search couldn't find anything outside
that window. The dashboard's expiring list had the same first-100 issue.
Backend
- adminEvents.js: extend search to include customer_email so the column
shown in the table is actually queryable.
- adminDashboard.js: add totalEvents to /dashboard/stats so the events
page can render an accurate "All (N)" / Total Events counter without
walking the full table on the client.
Frontend
- events.service.ts: getEvents() now accepts search + the full status
enum (active|inactive|archived|draft|expiring); response type matches
the actual {events, pagination} shape.
- admin.service.ts: DashboardStats gains totalEvents.
- EventsListPage.tsx: rewired around server-side pagination, status
filter, and 300ms-debounced search; Prev/Next + range/page indicator
below the table; placeholderData keeps the previous page visible
during fetches; stat cards and "All (N)" pull from /dashboard/stats so
totals stay accurate regardless of the visible page; archive/delete
invalidates dashboard-stats so cards refresh.
- AdminDashboard.tsx: expiring list now fetches getEvents(1, 5,
'expiring') directly instead of slicing the first 100 client-side. As
a side effect the dashboard's "expiring" definition now matches the
backend (was excluding events expiring within the next 24h).