From a239fec9d7b6c01c9649c075a40427b8844f83b8 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Wed, 17 Jun 2026 22:25:12 +0200 Subject: [PATCH 1/6] fix(admin/exports): Lightroom TXT export joins with comma + drops extension (#623) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PhotoExportMenu's TXT format advertises "Simple text list for Lightroom search" but emitted newline-separated filenames WITH `.jpg`. Lightroom's filename search wants a comma-separated one-liner, and the gallery JPEGs may correspond to RAW files in the catalog — so the search has to match on the stem only. The frontend now passes `separator: 'comma'` + `include_extension: false` for the TXT format specifically. The backend gains an `include_extension` option (defaulting to true so direct API consumers don't break), and the comma case joins without a trailing space (the form Lightroom expects). Unit test pins the Lightroom-mode output AND the backward-compatible default for any direct API caller. CSV / XMP / JSON exports are unchanged. --- .../services/photoExportService.txt.test.js | 71 +++++++++++++++++++ backend/src/services/photoExportService.js | 27 +++++-- .../src/components/admin/PhotoExportMenu.tsx | 5 ++ frontend/src/services/photos.service.ts | 1 + 4 files changed, 98 insertions(+), 6 deletions(-) create mode 100644 backend/__tests__/services/photoExportService.txt.test.js diff --git a/backend/__tests__/services/photoExportService.txt.test.js b/backend/__tests__/services/photoExportService.txt.test.js new file mode 100644 index 00000000..f2985113 --- /dev/null +++ b/backend/__tests__/services/photoExportService.txt.test.js @@ -0,0 +1,71 @@ +/** + * exportAsTxt — issue #623 regression test. + * + * The admin UI labels the TXT export "for Lightroom search". Lightroom's + * filename search wants ONE comma-separated line WITHOUT file extensions + * (the gallery JPEGs may map to RAW files in the catalog). The frontend + * now passes separator='comma' + include_extension=false for the TXT + * format; this test pins the resulting shape so a future refactor can't + * silently regress it back to the newline-separated form the bug reported. + * + * Also pins backward compatibility: a direct API caller passing no options + * still gets the original newline-with-extension behaviour, so existing + * integrations don't break. + */ +jest.mock('../../src/database/db', () => ({ db: jest.fn() })); +jest.mock('../../src/services/xmpGenerator', () => ({ XmpGenerator: class {} })); + +const { PhotoExportService } = require('../../src/services/photoExportService'); +const service = new PhotoExportService(); + +const PHOTOS = [ + { original_filename: 'IMG_0001.jpg', filename: 'abc123.jpg' }, + { original_filename: 'IMG_0002.JPEG', filename: 'def456.jpeg' }, + { original_filename: 'shoot.final.tif', filename: 'ghi789.tif' }, + { original_filename: null, filename: 'fallback.png' }, // null original → falls back to filename +]; + +describe('exportAsTxt (issue #623)', () => { + it('Lightroom mode: comma-joined, no extension, no space', () => { + const result = service.exportAsTxt(PHOTOS, { + separator: 'comma', + include_extension: false, + }); + expect(result.content).toBe('IMG_0001,IMG_0002,shoot.final,fallback'); + expect(result.contentType).toBe('text/plain'); + }); + + it('backward compatible: no options → newline-joined with extensions', () => { + const result = service.exportAsTxt(PHOTOS); + expect(result.content).toBe( + 'IMG_0001.jpg\nIMG_0002.JPEG\nshoot.final.tif\nfallback.png', + ); + }); + + it('semicolon separator joins without a trailing space', () => { + const result = service.exportAsTxt(PHOTOS, { + separator: 'semicolon', + include_extension: false, + }); + expect(result.content).toBe('IMG_0001;IMG_0002;shoot.final;fallback'); + }); + + it('filename_format=picpeak uses photo.filename (hashed) instead of original', () => { + const result = service.exportAsTxt(PHOTOS, { + filename_format: 'picpeak', + separator: 'comma', + include_extension: false, + }); + expect(result.content).toBe('abc123,def456,ghi789,fallback'); + }); + + it('extension stripping uses only the last segment ("a.b.c" → "a.b")', () => { + // path.parse('shoot.final.tif').name === 'shoot.final' — Lightroom + // catalogs that store basenames like "shoot.final" still match. + const result = service.exportAsTxt( + [{ original_filename: 'shoot.final.tif', filename: 'x.tif' }], + { separator: 'comma', include_extension: false }, + ); + expect(result.content).toBe('shoot.final'); + }); +}); diff --git a/backend/src/services/photoExportService.js b/backend/src/services/photoExportService.js index 0f48d245..5b424112 100644 --- a/backend/src/services/photoExportService.js +++ b/backend/src/services/photoExportService.js @@ -81,21 +81,36 @@ class PhotoExportService { /** * Export as plain text filename list + * + * include_extension defaults to true for backward compatibility with any + * direct API consumer. The admin UI sets it to false for the Lightroom + * search use case — the gallery JPEGs may correspond to RAW files in the + * photographer's catalog, so the search has to match on the stem only. + * + * The comma separator joins without a space, the form Lightroom's filename + * search expects (per issue #623). */ exportAsTxt(photos, options = {}) { - const { filename_format = 'original', separator = 'newline' } = options; + const { + filename_format = 'original', + separator = 'newline', + include_extension = true, + } = options; - const filenames = photos.map(photo => - filename_format === 'original' ? (photo.original_filename || photo.filename) : photo.filename - ); + const filenames = photos.map(photo => { + const name = filename_format === 'original' + ? (photo.original_filename || photo.filename) + : photo.filename; + return include_extension ? name : path.parse(name).name; + }); let content; switch (separator) { case 'comma': - content = filenames.join(', '); + content = filenames.join(','); break; case 'semicolon': - content = filenames.join('; '); + content = filenames.join(';'); break; default: content = filenames.join('\n'); diff --git a/frontend/src/components/admin/PhotoExportMenu.tsx b/frontend/src/components/admin/PhotoExportMenu.tsx index 69c7a7b4..ef8a3e60 100644 --- a/frontend/src/components/admin/PhotoExportMenu.tsx +++ b/frontend/src/components/admin/PhotoExportMenu.tsx @@ -64,6 +64,11 @@ export const PhotoExportMenu: React.FC = ({ format, options: { filename_format: 'original', + // TXT is labelled "for Lightroom search" — Lightroom's filename + // search field takes one comma-separated line, and the gallery + // JPEGs may correspond to RAW files in the catalog so the search + // has to match on the stem only (issue #623). + ...(format === 'txt' ? { separator: 'comma' as const, include_extension: false } : {}), include_rating: true, include_label: true, include_description: true, diff --git a/frontend/src/services/photos.service.ts b/frontend/src/services/photos.service.ts index 004f92d7..629ba4d7 100644 --- a/frontend/src/services/photos.service.ts +++ b/frontend/src/services/photos.service.ts @@ -345,6 +345,7 @@ export interface ExportOptions { options?: { filename_format?: 'original' | 'picpeak'; separator?: 'newline' | 'comma' | 'semicolon'; + include_extension?: boolean; include_rating?: boolean; include_label?: boolean; include_description?: boolean; From 178d6dafb18cd4d30229d745a82af9ce27c41f08 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Wed, 17 Jun 2026 22:31:04 +0200 Subject: [PATCH 2/6] fix(gallery): leave a visible gap between filter bar and hero header (#624) When a gallery uses the 'hero' header_style AND the admin enables the filter bar (search + sort), the search/sort row glued itself to the top of the hero image. Root cause: HeroHeader carries a decorative `-mt-6` on its outer div (so it can bleed flush against the page header when nothing else is above), and that exactly cancelled the wrapper's `mt-6` between PhotoFilterBar and PhotoGridWithLayouts. Fix: when the filter bar is shown above a hero header, the grid wrapper uses `mt-12` instead of `mt-6` so the hero's bleed leaves a 24px net gap rather than zero. The no-filter-bar case keeps the original flush bleed. Also tidied up: extract the filter-bar-shown predicate to a named const so the two reads (conditional render + wrapper class) can't drift apart. --- frontend/src/components/gallery/GalleryView.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/gallery/GalleryView.tsx b/frontend/src/components/gallery/GalleryView.tsx index 1b95d63c..7ff8d5be 100644 --- a/frontend/src/components/gallery/GalleryView.tsx +++ b/frontend/src/components/gallery/GalleryView.tsx @@ -697,6 +697,9 @@ export const GalleryView: React.FC = ({ slug, event }) => { const headerStyle = data?.event?.header_style || theme.headerStyle || 'standard'; const isHeroHeader = headerStyle === 'hero'; const showSidebar = theme.controlsStyle === 'sidebar'; + const filterBarShown = !showSidebar + && settingsData?.gallery_show_filter_bar !== false + && (data?.photos?.length ?? 0) > 0; // Full-page layouts (gallery-premium, gallery-story) have their own integrated UI // Skip all wrapper elements (header, footer, sidebar, filters) for these layouts @@ -922,7 +925,7 @@ export const GalleryView: React.FC = ({ slug, event }) => { filter bar globally, and when the gallery actually has photos (avoids the empty "Search photos by filename" row in the screenshot from discussion #317). */} - {!showSidebar && settingsData?.gallery_show_filter_bar !== false && (data?.photos?.length ?? 0) > 0 ? ( + {filterBarShown ? (
= ({ slug, event }) => {
) : null} - {/* Photo Grid */} -
+ {/* Photo Grid — when the hero header sits directly under the filter + bar, double the wrapper margin (mt-12) so the hero's decorative + `-mt-6` bleed leaves a visible gap instead of gluing the filter + bar to the hero image (issue #624). */} +
Date: Wed, 17 Jun 2026 22:47:48 +0200 Subject: [PATCH 3/6] fix(gallery): admin edits to welcome_message land for returning guests (#625) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GalleryAuthContext cached the event in sessionStorage on first visit and then SKIPPED the server fetch on returning visits (`if (!storedEvent)`), so a guest who'd already opened the gallery would never see admin edits to welcome_message / event_name / hero_logo / colour theme — sessionStorage survives Cmd+Shift+R, so the only escape was closing the tab or wiping site data manually. The cached event is still shown above as an instant placeholder for perceived perf, but the server fetch is no longer gated: on every mount the fresh row overwrites both React state and the sessionStorage entry. Cost is one extra /gallery/:slug/photos request per gallery navigation when the session is already authenticated; benefit is admin edits propagating on next page load for everyone. --- frontend/src/contexts/GalleryAuthContext.tsx | 21 ++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/frontend/src/contexts/GalleryAuthContext.tsx b/frontend/src/contexts/GalleryAuthContext.tsx index 8b70da34..2fce4061 100644 --- a/frontend/src/contexts/GalleryAuthContext.tsx +++ b/frontend/src/contexts/GalleryAuthContext.tsx @@ -212,14 +212,19 @@ export const GalleryAuthProvider: React.FC = ({ childr if (sessionResponse.data?.valid && sessionResponse.data.type === 'gallery' && sessionResponse.data.eventSlug === currentSlug) { setIsAuthenticated(true); - if (!storedEvent) { - const galleryData = await galleryService.getGalleryPhotos(currentSlug); - if (galleryData?.event) { - const normalizedEvent = normalizeEvent(galleryData.event); - setEvent(normalizedEvent); - if (normalizedEvent) { - sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(normalizedEvent)); - } + // Always refresh from the server — the stored event from sessionStorage + // is shown above as an instant placeholder for perceived perf, but it + // must NOT win permanently: admin edits to welcome_message / event_name / + // hero_logo / colour theme need to land on the next page load for + // returning guests. sessionStorage survives Cmd+Shift+R, so without + // this refresh the cache could only be cleared by closing the tab or + // wiping site data manually (#625). + const galleryData = await galleryService.getGalleryPhotos(currentSlug); + if (galleryData?.event) { + const normalizedEvent = normalizeEvent(galleryData.event); + setEvent(normalizedEvent); + if (normalizedEvent) { + sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(normalizedEvent)); } } From 83b568ee2ddc007b7d981fd4b46b69810f0165c3 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Wed, 17 Jun 2026 22:58:17 +0200 Subject: [PATCH 4/6] fix(events): publish-from-draft email carries the real password (#627) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, publishing a password-protected DRAFT gallery sent the gallery_created email with the literal sentinel "(set at creation)", which the email processor localised to "The password you set when creating the gallery" / "Das bei der Erstellung der Galerie gesetzte Passwort". Root cause: at draft creation only the bcrypt hash is stored (no plaintext column, by design); the publish endpoint had nowhere to pull the actual password from. Create-and-publish-in-one-step worked because the plaintext is still in memory at email-queue time. Fix: the Publish action now opens a small PublishGalleryDialog that prompts the admin to (re-)type the gallery password. The publish endpoint accepts an optional `password` body, re-hashes + writes `password_hash` so the stored hash matches what was just emailed (admins who mistype at creation get a self-healing publish flow), and puts the plaintext into the gallery_password email field. When the publish call is made without a password (API-only consumers), behaviour falls back to the legacy sentinel — no breaking change. The window.confirm() publish flow is gone; the dialog handles the no- password case too (plain confirm + Publish button). I18n: EN + DE entries for the dialog. Other locales fall through to the EN defaults via the t() default-value pattern. No schema changes. No plaintext at rest. --- backend/src/routes/adminEvents.js | 43 +++++- .../components/admin/PublishGalleryDialog.tsx | 136 ++++++++++++++++++ frontend/src/components/admin/index.ts | 1 + frontend/src/i18n/locales/de.json | 9 ++ frontend/src/i18n/locales/en.json | 9 ++ frontend/src/pages/admin/EventDetailsPage.tsx | 37 +++-- frontend/src/services/events.service.ts | 13 +- 7 files changed, 228 insertions(+), 20 deletions(-) create mode 100644 frontend/src/components/admin/PublishGalleryDialog.tsx diff --git a/backend/src/routes/adminEvents.js b/backend/src/routes/adminEvents.js index bfe5a76c..448cf552 100644 --- a/backend/src/routes/adminEvents.js +++ b/backend/src/routes/adminEvents.js @@ -1062,9 +1062,25 @@ router.get('/:id', adminAuth, requirePermission('events.view'), async (req, res) }); // Publish a draft event (set is_draft=false and queue creation email) -router.post('/:id/publish', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => { +router.post('/:id/publish', adminAuth, requirePermission('events.edit'), requireEventOwnership, [ + // Optional password the admin re-types in the publish dialog so the + // gallery_created email can carry the actual plaintext (#627). When the + // event is password-protected and the body carries a password, picpeak + // re-hashes + writes `password_hash` (the admin may have mistyped at + // creation; this guarantees the email content matches the live login + // password). When omitted, behaviour is the legacy sentinel for backward + // compat with API-only consumers. + body('password').optional().isString().isLength({ min: 6 }) + .withMessage('Password must be at least 6 characters long'), +], async (req, res) => { try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + const { id } = req.params; + const { password } = req.body; const event = await db('events').where('id', id).first(); if (!event) { @@ -1075,8 +1091,15 @@ router.post('/:id/publish', adminAuth, requirePermission('events.edit'), require return res.status(400).json({ error: 'Event is already published' }); } - // Set is_draft to false - await db('events').where('id', id).update({ is_draft: formatBoolean(false) }); + const requirePassword = parseBooleanInput(event.require_password, true); + const publishUpdates = { is_draft: formatBoolean(false) }; + if (requirePassword && password) { + // Re-hash so the stored hash matches what the email carries — even if + // the admin mistypes vs. what was set at draft creation, the gallery + // password the customer receives is the one that actually works. + publishUpdates.password_hash = await bcrypt.hash(password, getBcryptRounds()); + } + await db('events').where('id', id).update(publishUpdates); // Queue creation email const customerEmail = event.customer_email || event.host_email; @@ -1085,6 +1108,18 @@ router.post('/:id/publish', adminAuth, requirePermission('events.edit'), require const frontendBase = await getFrontendBaseUrl(); const { shareUrl } = await buildShareLinkVariants({ slug: event.slug, shareToken: event.share_token }); + let galleryPasswordForEmail; + if (!requirePassword) { + galleryPasswordForEmail = 'No password required'; + } else if (password) { + // Admin re-typed the password in the publish dialog — put it straight + // into the email so the customer can actually log in (#627). + galleryPasswordForEmail = password; + } else { + // Legacy fallback for API-only publishes that don't carry the password. + galleryPasswordForEmail = '(set at creation)'; + } + const emailData = { customer_name: customerName, customer_email: customerEmail, @@ -1092,7 +1127,7 @@ router.post('/:id/publish', adminAuth, requirePermission('events.edit'), require event_name: event.event_name, event_date: event.event_date, gallery_link: shareUrl || `${frontendBase}/gallery/${event.slug}`, - gallery_password: parseBooleanInput(event.require_password, true) ? '(set at creation)' : 'No password required', + gallery_password: galleryPasswordForEmail, expiry_date: event.expires_at ? new Date(event.expires_at).toISOString() : null, welcome_message: event.welcome_message || '' }; diff --git a/frontend/src/components/admin/PublishGalleryDialog.tsx b/frontend/src/components/admin/PublishGalleryDialog.tsx new file mode 100644 index 00000000..d0421602 --- /dev/null +++ b/frontend/src/components/admin/PublishGalleryDialog.tsx @@ -0,0 +1,136 @@ +import React, { useState } from 'react'; +import { X, Send, Lock, Eye, EyeOff } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { Button, Card, Input } from '../common'; + +interface PublishGalleryDialogProps { + eventName: string; + requirePassword: boolean; + customerEmail?: string | null; + isPublishing: boolean; + onConfirm: (password?: string) => void; + onClose: () => void; +} + +/** + * Confirmation dialog for the "Publish & Notify" action on a draft gallery. + * + * When the gallery is password-protected, the admin re-types the password + * here so the gallery_created email can carry the real plaintext instead of + * the "(set at creation)" sentinel (#627). The backend also re-hashes what + * the admin types so the stored hash matches what was just emailed — admins + * who mistype at creation get a self-healing publish flow. + * + * For galleries without a password, the dialog is a plain confirm + Publish + * button (mirrors the previous window.confirm() flow). + */ +export const PublishGalleryDialog: React.FC = ({ + eventName, + requirePassword, + customerEmail, + isPublishing, + onConfirm, + onClose, +}) => { + const { t } = useTranslation(); + const [password, setPassword] = useState(''); + const [showPassword, setShowPassword] = useState(false); + const [error, setError] = useState(undefined); + + const handleSubmit = () => { + if (requirePassword) { + if (!password || password.trim().length < 6) { + setError(t('events.publishDialog.errorMinLength', 'Password must be at least 6 characters long.')); + return; + } + } + setError(undefined); + onConfirm(requirePassword ? password : undefined); + }; + + return ( +
+ +
+

+ {t('events.publishDialog.title', 'Publish gallery')} +

+ +
+ +

+ {customerEmail + ? t('events.publishDialog.descriptionWithEmail', { + eventName, + customerEmail, + defaultValue: + 'Publishing "{{eventName}}" makes the gallery accessible and sends the notification email to {{customerEmail}}.', + }) + : t('events.publishDialog.descriptionNoEmail', { + eventName, + defaultValue: + 'Publishing "{{eventName}}" makes the gallery accessible. No customer email is set, so no notification will be sent.', + })} +

+ + {requirePassword && customerEmail && ( +
+ { + setPassword(e.target.value); + if (error) setError(undefined); + }} + error={error} + helperText={t( + 'events.publishDialog.passwordHelp', + 'Re-type the password set at creation (or pick a new one). The email includes this exact text; the backend re-hashes it so the gallery login still works.', + )} + leftIcon={} + rightIcon={ + + } + /> +
+ )} + +
+ + +
+
+
+ ); +}; diff --git a/frontend/src/components/admin/index.ts b/frontend/src/components/admin/index.ts index c1bb95a2..d274aab2 100644 --- a/frontend/src/components/admin/index.ts +++ b/frontend/src/components/admin/index.ts @@ -17,6 +17,7 @@ export { AdminPhotoGrid } from './AdminPhotoGrid'; export { AdminPhotoViewer } from './AdminPhotoViewer'; export { PhotoFilters } from './PhotoFilters'; export { PasswordResetModal } from './PasswordResetModal'; +export { PublishGalleryDialog } from './PublishGalleryDialog'; export { AdminAuthenticatedImage } from './AdminAuthenticatedImage'; export { AdminAuthenticatedVideo } from './AdminAuthenticatedVideo'; export { ThemeCustomizerEnhanced } from './ThemeCustomizerEnhanced'; diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index e63ffee1..eb1fda5d 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -1018,6 +1018,15 @@ "publishAndNotify": "Veröffentlichen & Kunden benachrichtigen", "publishConfirm": "Dadurch wird die Galerie zugänglich und die Benachrichtigungs-E-Mail an den Kunden gesendet. Fortfahren?", "publishSuccess": "Galerie veröffentlicht und Kunde benachrichtigt!", + "publishDialog": { + "title": "Galerie veröffentlichen", + "descriptionWithEmail": "Die Galerie \"{{eventName}}\" wird zugänglich gemacht und die Benachrichtigungs-E-Mail an {{customerEmail}} gesendet.", + "descriptionNoEmail": "Die Galerie \"{{eventName}}\" wird zugänglich gemacht. Es ist keine Kunden-E-Mail hinterlegt – es wird keine Benachrichtigung gesendet.", + "passwordLabel": "Galerie-Passwort", + "passwordPlaceholder": "Galerie-Passwort eingeben", + "passwordHelp": "Gib das bei der Erstellung gesetzte Passwort erneut ein (oder wähle ein neues). Die E-Mail enthält genau diesen Text; das Backend hasht es erneut, sodass die Galerie-Anmeldung weiterhin funktioniert.", + "errorMinLength": "Das Passwort muss mindestens 6 Zeichen lang sein." + }, "draftBanner": "Diese Galerie befindet sich im Entwurfsmodus. Laden Sie Ihre Fotos hoch und veröffentlichen Sie, wenn Sie bereit sind.", "subtitle": "Verwalten Sie Ihre Fotogalerien und Veranstaltungen", "failedToLoadEvents": "Veranstaltungen konnten nicht geladen werden", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 51de701e..6151d286 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -565,6 +565,15 @@ "publishAndNotify": "Publish & Notify Client", "publishConfirm": "This will make the gallery accessible and send the notification email to the client. Continue?", "publishSuccess": "Gallery published and client notified!", + "publishDialog": { + "title": "Publish gallery", + "descriptionWithEmail": "Publishing \"{{eventName}}\" makes the gallery accessible and sends the notification email to {{customerEmail}}.", + "descriptionNoEmail": "Publishing \"{{eventName}}\" makes the gallery accessible. No customer email is set, so no notification will be sent.", + "passwordLabel": "Gallery password", + "passwordPlaceholder": "Enter the gallery password", + "passwordHelp": "Re-type the password set at creation (or pick a new one). The email includes this exact text; the backend re-hashes it so the gallery login still works.", + "errorMinLength": "Password must be at least 6 characters long." + }, "draftBanner": "This gallery is in draft mode. Upload your photos, then publish when ready.", "subtitle": "Manage your photo galleries and events", "failedToLoadEvents": "Failed to load events", diff --git a/frontend/src/pages/admin/EventDetailsPage.tsx b/frontend/src/pages/admin/EventDetailsPage.tsx index 640e4349..1ddf4332 100644 --- a/frontend/src/pages/admin/EventDetailsPage.tsx +++ b/frontend/src/pages/admin/EventDetailsPage.tsx @@ -59,7 +59,7 @@ import { toast } from 'react-toastify'; import { useLocalizedDate } from '../../hooks/useLocalizedDate'; import { Button, Input, Card, Loading, MarkdownContent, LocalizedDateInput } from '../../components/common'; -import { EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PasswordResetModal, ThemeCustomizerEnhanced, ThemeDisplay, HeroPhotoSelector, FocalPointPicker, PhotoUploadModal, FeedbackSettings, FeedbackModerationPanel, EventRenameDialog, PhotoFilterPanel, PhotoExportMenu, AdminGuestsList } from '../../components/admin'; +import { EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PasswordResetModal, PublishGalleryDialog, ThemeCustomizerEnhanced, ThemeDisplay, HeroPhotoSelector, FocalPointPicker, PhotoUploadModal, FeedbackSettings, FeedbackModerationPanel, EventRenameDialog, PhotoFilterPanel, PhotoExportMenu, AdminGuestsList } from '../../components/admin'; import { CustomerAccountPicker } from '../../components/admin/CustomerAccountPicker'; import { EventReminderOverrideCard } from '../../components/admin/EventReminderOverrideCard'; import { useFeatureFlags } from '../../contexts/FeatureFlagsContext'; @@ -373,6 +373,7 @@ export const EventDetailsPage: React.FC = () => { const [showPasswordReset, setShowPasswordReset] = useState(false); const [showNewPassword, setShowNewPassword] = useState(false); const [showRenameDialog, setShowRenameDialog] = useState(false); + const [showPublishDialog, setShowPublishDialog] = useState(false); const [logoUploading, setLogoUploading] = useState(false); const [currentTheme, setCurrentTheme] = useState(null); const [currentPresetName, setCurrentPresetName] = useState('default'); @@ -533,13 +534,16 @@ export const EventDetailsPage: React.FC = () => { }, }); - // Publish mutation (Draft mode) + // Publish mutation (Draft mode). Accepts the admin-typed password so the + // gallery_created email can carry the real plaintext (#627). const publishMutation = useMutation({ - mutationFn: () => eventsService.publishEvent(parseInt(id!)), + mutationFn: (password?: string) => + eventsService.publishEvent(parseInt(id!), password ? { password } : undefined), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin-event', id] }); queryClient.invalidateQueries({ queryKey: ['admin-events'] }); toast.success(t('events.publishSuccess')); + setShowPublishDialog(false); }, onError: () => { toast.error(t('errors.somethingWentWrong')); @@ -1040,11 +1044,7 @@ export const EventDetailsPage: React.FC = () => { variant="primary" size="sm" leftIcon={} - onClick={() => { - if (confirm(t('events.publishConfirm'))) { - publishMutation.mutate(); - } - }} + onClick={() => setShowPublishDialog(true)} isLoading={publishMutation.isPending} > {t('events.publishAndNotify')} @@ -2161,11 +2161,7 @@ export const EventDetailsPage: React.FC = () => {
); }; diff --git a/frontend/src/services/events.service.ts b/frontend/src/services/events.service.ts index 3f21fcbe..27567c6b 100644 --- a/frontend/src/services/events.service.ts +++ b/frontend/src/services/events.service.ts @@ -219,9 +219,16 @@ export const eventsService = { return response.data; }, - // Publish a draft event - async publishEvent(eventId: number): Promise<{ message: string; is_draft: boolean }> { - const response = await api.post(`/admin/events/${eventId}/publish`); + // Publish a draft event. `password` is optional; when the event is + // password-protected, supplying the password here makes the gallery_created + // email carry the actual plaintext instead of the "set at creation" sentinel + // (#627) — the backend also re-hashes it so the stored hash matches. + async publishEvent( + eventId: number, + options?: { password?: string }, + ): Promise<{ message: string; is_draft: boolean }> { + const body = options?.password ? { password: options.password } : undefined; + const response = await api.post(`/admin/events/${eventId}/publish`, body); return response.data; }, From 714a9f6fb1f48ba1316cc240054d5128749581d8 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Wed, 17 Jun 2026 23:04:30 +0200 Subject: [PATCH 5/6] fix(upload): auto-throttle on low-memory hosts + correct documented RAM minimum (#628) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README claimed 2GB RAM as the minimum, but two background-processor worker loops × sharp.concurrency(2) means up to four libvips threads can decode full-resolution images in parallel — peak RSS lands at 1.5GB+ on a batch of 20MP+ photos. Add Postgres + Redis + Node baseline and one heavy batch on a 2GB VPS OOM-kills the backend, surfacing as 503s on thumbnails until restart:unless-stopped brings it back. Reported in #602, filed as #628. Three changes, smallest-surface-area each: 1. backgroundProcessor.js — on startup, when UPLOAD_PROCESSOR_CONCURRENCY is NOT set and os.totalmem() reports < 3GB, default to 1 instead of 2 and log a one-shot warning naming the override env var. Explicit env-var setters keep their value. os.totalmem() reports container memory under cgroup v2 so this works in Docker / k8s as well as bare metal. 2. README.md — bumped the documented minimum from 2GB to 4GB, kept 2GB only as a "Low-memory hosts" recipe pointing at UPLOAD_PROCESSOR_CONCURRENCY=1 with the throughput trade-off spelled out. Added the 503-on-OOM symptom so the next reporter finds it via search. 3. docker-compose.production.yml — commented mem_limit / memswap_limit example on the backend service. Off by default (don't surprise existing deployments) but visible to operators thinking about shared/multi-tenant hosts. restart:unless-stopped already on every service. No code path for memory-aware runtime throttling (Luca's option 4) — out of scope for a bug fix; tracked separately if #1-#3 don't close the case. --- README.md | 27 ++++++++++++++- backend/src/services/backgroundProcessor.js | 38 +++++++++++++++++++-- docker-compose.production.yml | 9 +++++ 3 files changed, 71 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index e571ba24..38321678 100644 --- a/README.md +++ b/README.md @@ -299,7 +299,12 @@ For local development with a receiver on the same machine or docker network, set ### Minimum Requirements - **CPU**: 2 CPU cores -- **RAM**: 2GB minimum +- **RAM**: **4 GB minimum** for a normal photo-upload workload — sharp/libvips + decodes the full uncompressed frame before resize, and the default two + worker loops at sharp-concurrency 2 can push peak RSS past 1.5 GB on a + batch of 20-MP+ photos. On a 2 GB VPS that's enough to OOM-kill the + backend mid-batch (surfaces as 503s on thumbnails — see [Low-memory + hosts](#low-memory-hosts) below for the recipe to run on 2 GB). - **Storage**: 20GB minimum (plus photo storage needs) - **OS**: Linux (Ubuntu 20.04+), macOS, or Windows with WSL2 - **Node.js**: v18.0.0 or higher @@ -309,6 +314,26 @@ For local development with a receiver on the same machine or docker network, set - **Docker**: v20.10.0+ - **Docker Compose**: v2.0.0+ +### Low-memory hosts + +Running on 2 GB RAM (e.g. an entry-level VPS) is workable but requires +tuning the upload-processor concurrency down. The backend auto-detects +total RAM at startup via `os.totalmem()` — on a host that reports < 3 GB, +it defaults `UPLOAD_PROCESSOR_CONCURRENCY` to **1** instead of 2 and logs +a one-shot warning. You can pin the value explicitly in `.env`: + +```env +# Single worker loop — slower batch processing, lower peak RSS +UPLOAD_PROCESSOR_CONCURRENCY=1 +``` + +The trade-off is throughput: a single worker processes one photo at a +time, so a 100-photo batch takes ~2× as long but won't OOM. **Health-check +note**: if the backend dies under memory pressure, the gallery serves +`503 Service Unavailable` on thumbnails until Docker's +`restart: unless-stopped` brings the container back. Persistent 503s +during/after an upload batch on a low-memory host are almost always this. + ### Video Support Requirements When enabling video uploads, consider these additional resources: diff --git a/backend/src/services/backgroundProcessor.js b/backend/src/services/backgroundProcessor.js index 32148405..004c9c51 100644 --- a/backend/src/services/backgroundProcessor.js +++ b/backend/src/services/backgroundProcessor.js @@ -16,18 +16,52 @@ * is enough for the rare two-process case during dev). * * Tunables (env, all optional): - * UPLOAD_PROCESSOR_CONCURRENCY default 2 + * UPLOAD_PROCESSOR_CONCURRENCY default 2 on hosts with ≥3GB RAM, + * 1 on smaller hosts (auto-detected + * via os.totalmem() with one-shot + * warning, #628). Always honoured + * when set explicitly. * UPLOAD_PROCESSOR_POLL_MS default 1000 * UPLOAD_PROCESSOR_STUCK_TIMEOUT_MS default 600000 (10 minutes) * UPLOAD_PROCESSOR_DISABLED default false (set 'true' to opt out, e.g. in CI) */ +const os = require('os'); const { db } = require('../database/db'); const logger = require('../utils/logger'); const { processPhoto } = require('./photoProcessor'); const POLL_INTERVAL_MS = parseInt(process.env.UPLOAD_PROCESSOR_POLL_MS || '1000', 10); -const CONCURRENCY = Math.max(1, parseInt(process.env.UPLOAD_PROCESSOR_CONCURRENCY || '2', 10)); + +// Soft default: two worker loops × sharp.concurrency(2) means up to four +// libvips threads can decode full-resolution photos in parallel. Each decode +// holds the full uncompressed frame in RAM — a 24MP photo is ~96MB before +// resize. On a 2GB VPS (the documented but barely-viable minimum) one busy +// batch is enough to OOM-kill the backend and surface as 503s on thumbnails +// (#628). When the host reports < 3GB total memory AND the admin hasn't set +// an explicit override, drop the default to 1 and log a one-shot warning +// naming the override env var. Explicit env-var setters keep their value. +// +// os.totalmem() reports container memory under cgroup v2 (Docker / k8s) and +// host memory on bare metal — accurate enough for this decision in either +// deployment shape. +function pickDefaultConcurrency() { + if (process.env.UPLOAD_PROCESSOR_CONCURRENCY !== undefined) { + return parseInt(process.env.UPLOAD_PROCESSOR_CONCURRENCY, 10); + } + const totalRamGB = os.totalmem() / (1024 ** 3); + if (totalRamGB < 3) { + logger.warn?.( + `[backgroundProcessor] Detected ${totalRamGB.toFixed(1)}GB total RAM (< 3GB threshold). ` + + 'Defaulting UPLOAD_PROCESSOR_CONCURRENCY to 1 to avoid OOM on heavy upload batches. ' + + 'Set UPLOAD_PROCESSOR_CONCURRENCY=2 (or higher) explicitly to override.', + ); + return 1; + } + return 2; +} + +const CONCURRENCY = Math.max(1, pickDefaultConcurrency()); const STUCK_TIMEOUT_MS = parseInt(process.env.UPLOAD_PROCESSOR_STUCK_TIMEOUT_MS || '600000', 10); const JANITOR_INTERVAL_MS = 60 * 1000; diff --git a/docker-compose.production.yml b/docker-compose.production.yml index f6560438..77dbff71 100644 --- a/docker-compose.production.yml +++ b/docker-compose.production.yml @@ -69,6 +69,15 @@ services: redis: condition: service_healthy restart: unless-stopped + # Memory cap (optional, recommended on shared / multi-tenant hosts): + # uncomment to bound the backend's RSS. Sharp/libvips decodes the full + # uncompressed image before resize, so a multi-photo upload batch can + # spike memory. With a cap set, the kernel OOM-killer takes the + # container instead of the whole host; restart:unless-stopped brings + # it back. Match this to the RAM budget you've allocated for picpeak + # (`docker stats` shows the live usage). + # mem_limit: 3g + # memswap_limit: 3g healthcheck: # Backend exposes /health on internal port 3000. # The backend image only ships wget (Alpine base) — using curl From e985d25207671cbfefcdda9775a96eadf2fe0698 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Wed, 17 Jun 2026 23:16:12 +0200 Subject: [PATCH 6/6] feat(events): duplicate-gallery action (#626) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Daniel asked for a way to re-use a good gallery configuration without re-entering every setting. Two of his three suggested workflows are covered by this PR; the third (per-event-type behaviour defaults) is partially shipped already via event_types.theme_preset + theme_config and is left as a follow-up if the duplicate workflow doesn't cover it. Backend — POST /admin/events/:id/duplicate. Validates a new event_name (required) + event_date (optional) + customer_name/email (optional); copies branding (color_theme, css_template_id, header/hero/divider/anchor), behaviour toggles (allow_downloads, watermark_*, allow_user_uploads, require_password, etc.), photo_cap, welcome_message, default_photo_sort, admin_email, and feedback settings + per-event photo categories. Mints a fresh slug + share_token + random-placeholder password_hash (admin sets the real one via the publish dialog shipped in #627). Recomputes expires_at = new_event_date + (source.expires_at - source.event_date) so the duplicate keeps the same active window; defaults to 30 days if either source field was null. is_draft is always true. Deliberately NOT carried over: photos, hero_photo_id, client_access secrets, og_image_share opt-in, customer_phone, sent_at flags, archive state, customer-account assignments. Frontend — new DuplicateEventDialog (matches the PublishGalleryDialog pattern), wired into the Actions card on EventDetailsPage. Visible in both draft and live mode since admins typically duplicate from a published gallery. On success the page navigates to the new draft so the admin can finish customising + publish. I18n: EN + DE entries for the dialog + button label. Backend logs an event_duplicated activity with the source event id/name so the trail is auditable. Frontend service: eventsService.duplicateEvent(eventId, data). --- backend/src/routes/adminEvents.js | 187 ++++++++++++++++++ .../components/admin/DuplicateEventDialog.tsx | 141 +++++++++++++ frontend/src/components/admin/index.ts | 1 + frontend/src/i18n/locales/de.json | 16 ++ frontend/src/i18n/locales/en.json | 16 ++ frontend/src/pages/admin/EventDetailsPage.tsx | 50 ++++- frontend/src/services/events.service.ts | 16 ++ 7 files changed, 426 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/admin/DuplicateEventDialog.tsx diff --git a/backend/src/routes/adminEvents.js b/backend/src/routes/adminEvents.js index 448cf552..3054ed80 100644 --- a/backend/src/routes/adminEvents.js +++ b/backend/src/routes/adminEvents.js @@ -1176,6 +1176,193 @@ router.post('/:id/publish', adminAuth, requirePermission('events.edit'), require } }); +// Duplicate an event (#626). Creates a new DRAFT gallery that inherits the +// source event's branding, behaviour, hero/header, feedback, and category +// configuration — admin then fills in customer + publishes via the publish +// dialog (#627), where the password is set. Photos, hero photo selection, +// client-access secrets, customer assignments, archive/sent state are NOT +// carried over. +router.post('/:id/duplicate', adminAuth, requirePermission('events.create'), requireEventOwnership, [ + body('event_name').trim().notEmpty().withMessage('Event name is required'), + body('event_date').optional({ values: 'falsy' }).isDate(), + body('customer_name').optional().trim(), + body('customer_email').optional({ values: 'falsy' }).isEmail().normalizeEmail(IDENTITY_PRESERVING_NORMALIZE_EMAIL), +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { id } = req.params; + const source = await db('events').where('id', id).first(); + if (!source) { + return res.status(404).json({ error: 'Source event not found' }); + } + + const { event_name, event_date, customer_name, customer_email } = req.body; + + // Generate a fresh unique slug using the same shape as the create path. + const slugify = require('../utils/slug').slugify; + const processedEventName = slugify(event_name); + const slugSuffix = event_date || crypto.randomBytes(3).toString('hex'); + const baseSlug = `${source.event_type}-${processedEventName}-${slugSuffix}`; + let slug = baseSlug; + let counter = 1; + // eslint-disable-next-line no-await-in-loop + while (await db('events').where({ slug }).first()) { + slug = `${baseSlug}-${counter}`; + counter += 1; + } + + // Recompute expires_at: preserve the source's expiration window (delta + // between source.expires_at and source.event_date) so the duplicate keeps + // the same "active for N days" feel. Falls back to 30 days if source had + // no expiration set. + let newExpiresAt = null; + if (event_date) { + let expirationDays = 30; + if (source.expires_at && source.event_date) { + const days = Math.round( + (new Date(source.expires_at).getTime() - new Date(source.event_date).getTime()) + / (24 * 60 * 60 * 1000), + ); + if (days > 0) expirationDays = days; + } + const [year, month, day] = event_date.split('-').map((s) => parseInt(s, 10)); + const baseDate = new Date(year, month - 1, day); + baseDate.setDate(baseDate.getDate() + expirationDays); + newExpiresAt = baseDate; + } + + const shareToken = crypto.randomBytes(16).toString('hex'); + const { shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken }); + + // Random-placeholder password hash. When the admin publishes via the + // PublishGalleryDialog (#627), the dialog re-hashes whatever they type and + // overwrites this. Pattern matches the create path at line ~606. + const password_hash = await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds()); + + // Create the storage folder structure (same as create path). + const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); + const eventPath = path.join(storagePath, 'events/active', slug); + await fs.mkdir(path.join(eventPath, 'collages'), { recursive: true }); + await fs.mkdir(path.join(eventPath, 'individual'), { recursive: true }); + + const customerColumnsAvailable = await hasCustomerContactColumns(); + const calendarColumnsExist = await hasColumnCached('events', 'is_full_day'); + + // Build the insert row. Copy behaviour + branding fields from source; + // leave per-gallery secrets / state / photos blank. + const insertResult = await db('events').insert({ + slug, + event_type: source.event_type, + event_name, + event_date: event_date || null, + ...(calendarColumnsExist ? { + event_time_start: source.event_time_start, + event_time_end: source.event_time_end, + is_full_day: source.is_full_day, + } : {}), + ...(customerColumnsAvailable ? { + customer_name: customer_name || null, + customer_email: customer_email || null, + } : {}), + host_name: customer_name || null, + host_email: customer_email || null, + admin_email: source.admin_email || null, + password_hash, + welcome_message: source.welcome_message || '', + color_theme: source.color_theme, + share_link: shareLinkToStore, + share_token: shareToken, + expires_at: newExpiresAt ? newExpiresAt.toISOString() : null, + created_at: new Date().toISOString(), + created_by: req.admin.id, + allow_user_uploads: source.allow_user_uploads, + upload_category_id: source.upload_category_id, + allow_downloads: source.allow_downloads, + disable_right_click: source.disable_right_click, + enable_devtools_protection: source.enable_devtools_protection, + watermark_downloads: source.watermark_downloads, + watermark_text: source.watermark_text, + allow_presigned_download: source.allow_presigned_download, + require_password: source.require_password, + css_template_id: source.css_template_id || null, + hero_logo_visible: source.hero_logo_visible, + hero_logo_size: source.hero_logo_size, + hero_logo_position: source.hero_logo_position, + header_style: source.header_style || 'standard', + hero_divider_style: source.hero_divider_style || 'wave', + hero_image_anchor: source.hero_image_anchor || 'center', + photo_cap: source.photo_cap || null, + is_draft: formatBoolean(true), + default_photo_sort: source.default_photo_sort || 'upload_date_desc', + // Client-access secrets and the OG-share opt-in deliberately do NOT + // carry over — admin re-decides per gallery. + client_access_enabled: formatBoolean(false), + og_image_share_enabled: formatBoolean(false), + }).returning('id'); + + const newEventId = insertResult[0]?.id || insertResult[0]; + + // Copy event_feedback_settings if the source had a row (only present when + // feedback_enabled was true on the source event). + const sourceFeedback = await db('event_feedback_settings').where({ event_id: id }).first(); + if (sourceFeedback) { + await db('event_feedback_settings').insert({ + event_id: newEventId, + feedback_enabled: sourceFeedback.feedback_enabled, + allow_ratings: sourceFeedback.allow_ratings, + allow_likes: sourceFeedback.allow_likes, + allow_comments: sourceFeedback.allow_comments, + allow_favorites: sourceFeedback.allow_favorites, + require_name_email: sourceFeedback.require_name_email, + moderate_comments: sourceFeedback.moderate_comments, + show_feedback_to_guests: sourceFeedback.show_feedback_to_guests, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }); + } + + // Copy per-event photo categories (global categories are not duplicated — + // they apply to every event already). Mapping by name; photo_categories + // has no foreign key into photos here so we just clone the rows. + if (await db.schema.hasTable('photo_categories')) { + const sourceCategories = await db('photo_categories') + .where({ event_id: id }) + .where(function () { this.whereNull('is_global').orWhere('is_global', formatBoolean(false)); }) + .select('name', 'slug', 'is_global'); + if (sourceCategories.length > 0) { + await db('photo_categories').insert( + sourceCategories.map((c) => ({ + event_id: newEventId, + name: c.name, + slug: c.slug, + is_global: formatBoolean(false), + })), + ); + } + } + + await logActivity('event_duplicated', + { source_event_id: parseInt(id, 10), source_event_name: source.event_name }, + newEventId, + { type: 'admin', id: req.admin.id, name: req.admin.username }, + ); + + res.json({ + message: 'Event duplicated successfully', + id: newEventId, + slug, + is_draft: true, + }); + } catch (error) { + logger.error('Error duplicating event:', { error: error.message }); + res.status(500).json({ error: 'Failed to duplicate event' }); + } +}); + // Update event router.put('/:id', adminAuth, requirePermission('events.edit'), requireEventOwnership, [ body('event_name').optional().trim().notEmpty(), diff --git a/frontend/src/components/admin/DuplicateEventDialog.tsx b/frontend/src/components/admin/DuplicateEventDialog.tsx new file mode 100644 index 00000000..42756617 --- /dev/null +++ b/frontend/src/components/admin/DuplicateEventDialog.tsx @@ -0,0 +1,141 @@ +import React, { useState } from 'react'; +import { X, Copy } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { Button, Card, Input, LocalizedDateInput } from '../common'; + +interface DuplicateEventDialogProps { + sourceEventName: string; + isDuplicating: boolean; + onConfirm: (data: { + event_name: string; + event_date?: string; + customer_name?: string; + customer_email?: string; + }) => void; + onClose: () => void; +} + +/** + * "Duplicate gallery" dialog (#626) — admin types a fresh event name + date + * (and optionally a new customer) and the backend clones the source event's + * branding / behaviour / feedback / categories into a new draft. Photos, + * the password, the share token and client-access secrets do NOT carry over — + * those are set fresh on the duplicate. The new event opens in draft mode so + * the admin can finish customising before publishing via the publish dialog + * (#627). + */ +export const DuplicateEventDialog: React.FC = ({ + sourceEventName, + isDuplicating, + onConfirm, + onClose, +}) => { + const { t } = useTranslation(); + const [eventName, setEventName] = useState(''); + const [eventDate, setEventDate] = useState(''); + const [customerName, setCustomerName] = useState(''); + const [customerEmail, setCustomerEmail] = useState(''); + const [error, setError] = useState(undefined); + + const handleSubmit = () => { + if (!eventName.trim()) { + setError(t('events.duplicateDialog.errorNameRequired', 'Event name is required.')); + return; + } + setError(undefined); + onConfirm({ + event_name: eventName.trim(), + event_date: eventDate || undefined, + customer_name: customerName.trim() || undefined, + customer_email: customerEmail.trim() || undefined, + }); + }; + + return ( +
+ +
+

+ {t('events.duplicateDialog.title', 'Duplicate gallery')} +

+ +
+ +

+ {t('events.duplicateDialog.description', { + sourceEventName, + defaultValue: + 'Creates a new draft gallery that inherits the branding, behaviour, feedback, and category configuration from "{{sourceEventName}}". Photos, password, and share tokens are NOT carried over.', + })} +

+ +
+ { + setEventName(e.target.value); + if (error) setError(undefined); + }} + error={error} + /> + + + + setCustomerName(e.target.value)} + /> + + setCustomerEmail(e.target.value)} + /> +
+ +
+ + +
+
+
+ ); +}; diff --git a/frontend/src/components/admin/index.ts b/frontend/src/components/admin/index.ts index d274aab2..05911bac 100644 --- a/frontend/src/components/admin/index.ts +++ b/frontend/src/components/admin/index.ts @@ -18,6 +18,7 @@ export { AdminPhotoViewer } from './AdminPhotoViewer'; export { PhotoFilters } from './PhotoFilters'; export { PasswordResetModal } from './PasswordResetModal'; export { PublishGalleryDialog } from './PublishGalleryDialog'; +export { DuplicateEventDialog } from './DuplicateEventDialog'; export { AdminAuthenticatedImage } from './AdminAuthenticatedImage'; export { AdminAuthenticatedVideo } from './AdminAuthenticatedVideo'; export { ThemeCustomizerEnhanced } from './ThemeCustomizerEnhanced'; diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index eb1fda5d..23cc1c67 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -1027,6 +1027,22 @@ "passwordHelp": "Gib das bei der Erstellung gesetzte Passwort erneut ein (oder wähle ein neues). Die E-Mail enthält genau diesen Text; das Backend hasht es erneut, sodass die Galerie-Anmeldung weiterhin funktioniert.", "errorMinLength": "Das Passwort muss mindestens 6 Zeichen lang sein." }, + "duplicateEvent": "Galerie duplizieren", + "duplicateDialog": { + "title": "Galerie duplizieren", + "description": "Erstellt eine neue Entwurfsgalerie, die Branding, Verhalten, Feedback und Kategorien aus \"{{sourceEventName}}\" übernimmt. Fotos, Passwort und Share-Tokens werden NICHT übernommen.", + "eventNameLabel": "Neuer Veranstaltungsname *", + "eventNamePlaceholder": "z. B. Hochzeit Müller 2026", + "eventDateLabel": "Veranstaltungsdatum", + "eventDateHelp": "Leer lassen, um eine zufällige Endung in der Galerie-URL zu verwenden. Das Ablaufdatum wird aus diesem Datum zuzüglich des Ablauffensters der Quellgalerie berechnet.", + "customerNameLabel": "Kundenname", + "customerNamePlaceholder": "Optional – kann später ausgefüllt werden", + "customerEmailLabel": "Kunden-E-Mail", + "customerEmailPlaceholder": "Optional", + "confirm": "Duplikat erstellen", + "errorNameRequired": "Veranstaltungsname ist erforderlich.", + "successToast": "Galerie dupliziert." + }, "draftBanner": "Diese Galerie befindet sich im Entwurfsmodus. Laden Sie Ihre Fotos hoch und veröffentlichen Sie, wenn Sie bereit sind.", "subtitle": "Verwalten Sie Ihre Fotogalerien und Veranstaltungen", "failedToLoadEvents": "Veranstaltungen konnten nicht geladen werden", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 6151d286..9fc812af 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -574,6 +574,22 @@ "passwordHelp": "Re-type the password set at creation (or pick a new one). The email includes this exact text; the backend re-hashes it so the gallery login still works.", "errorMinLength": "Password must be at least 6 characters long." }, + "duplicateEvent": "Duplicate gallery", + "duplicateDialog": { + "title": "Duplicate gallery", + "description": "Creates a new draft gallery that inherits the branding, behaviour, feedback, and category configuration from \"{{sourceEventName}}\". Photos, password, and share tokens are NOT carried over.", + "eventNameLabel": "New event name *", + "eventNamePlaceholder": "e.g. Müller Wedding 2026", + "eventDateLabel": "Event date", + "eventDateHelp": "Leave blank to use a random suffix in the gallery URL. Expiration is recomputed from this date plus the source gallery's expiration window.", + "customerNameLabel": "Customer name", + "customerNamePlaceholder": "Optional — fill in later if unknown", + "customerEmailLabel": "Customer email", + "customerEmailPlaceholder": "Optional", + "confirm": "Create duplicate", + "errorNameRequired": "Event name is required.", + "successToast": "Gallery duplicated." + }, "draftBanner": "This gallery is in draft mode. Upload your photos, then publish when ready.", "subtitle": "Manage your photo galleries and events", "failedToLoadEvents": "Failed to load events", diff --git a/frontend/src/pages/admin/EventDetailsPage.tsx b/frontend/src/pages/admin/EventDetailsPage.tsx index 1ddf4332..a413ecdb 100644 --- a/frontend/src/pages/admin/EventDetailsPage.tsx +++ b/frontend/src/pages/admin/EventDetailsPage.tsx @@ -59,7 +59,7 @@ import { toast } from 'react-toastify'; import { useLocalizedDate } from '../../hooks/useLocalizedDate'; import { Button, Input, Card, Loading, MarkdownContent, LocalizedDateInput } from '../../components/common'; -import { EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PasswordResetModal, PublishGalleryDialog, ThemeCustomizerEnhanced, ThemeDisplay, HeroPhotoSelector, FocalPointPicker, PhotoUploadModal, FeedbackSettings, FeedbackModerationPanel, EventRenameDialog, PhotoFilterPanel, PhotoExportMenu, AdminGuestsList } from '../../components/admin'; +import { EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PasswordResetModal, PublishGalleryDialog, DuplicateEventDialog, ThemeCustomizerEnhanced, ThemeDisplay, HeroPhotoSelector, FocalPointPicker, PhotoUploadModal, FeedbackSettings, FeedbackModerationPanel, EventRenameDialog, PhotoFilterPanel, PhotoExportMenu, AdminGuestsList } from '../../components/admin'; import { CustomerAccountPicker } from '../../components/admin/CustomerAccountPicker'; import { EventReminderOverrideCard } from '../../components/admin/EventReminderOverrideCard'; import { useFeatureFlags } from '../../contexts/FeatureFlagsContext'; @@ -374,6 +374,7 @@ export const EventDetailsPage: React.FC = () => { const [showNewPassword, setShowNewPassword] = useState(false); const [showRenameDialog, setShowRenameDialog] = useState(false); const [showPublishDialog, setShowPublishDialog] = useState(false); + const [showDuplicateDialog, setShowDuplicateDialog] = useState(false); const [logoUploading, setLogoUploading] = useState(false); const [currentTheme, setCurrentTheme] = useState(null); const [currentPresetName, setCurrentPresetName] = useState('default'); @@ -550,6 +551,28 @@ export const EventDetailsPage: React.FC = () => { }, }); + // Duplicate mutation (#626). Backend creates a draft inheriting branding + + // behaviour + categories from the source; we navigate to the new event so + // the admin can finish configuring + publish. + const duplicateMutation = useMutation({ + mutationFn: (data: { + event_name: string; + event_date?: string; + customer_name?: string; + customer_email?: string; + }) => eventsService.duplicateEvent(parseInt(id!), data), + onSuccess: (result) => { + queryClient.invalidateQueries({ queryKey: ['admin-events'] }); + toast.success(t('events.duplicateDialog.successToast', 'Gallery duplicated.')); + setShowDuplicateDialog(false); + navigate(`/admin/events/${result.id}`); + }, + onError: (err: any) => { + const msg = err?.response?.data?.errors?.[0]?.msg || err?.response?.data?.error; + toast.error(msg || t('errors.somethingWentWrong')); + }, + }); + // Extend expiration mutation const extendMutation = useMutation({ mutationFn: (days: number) => { @@ -2191,6 +2214,17 @@ export const EventDetailsPage: React.FC = () => {

)} + {/* Duplicate (#626) — visible in both draft and live mode. + Creates a new draft inheriting this gallery's config. */} +
)} @@ -2615,6 +2649,20 @@ export const EventDetailsPage: React.FC = () => { /> )} + {/* Duplicate Event Dialog (#626) — admin types a new event name/date + (+ optional customer); backend clones the source gallery's config + and we navigate to the new draft. */} + {showDuplicateDialog && ( + duplicateMutation.mutate(data)} + onClose={() => { + if (!duplicateMutation.isPending) setShowDuplicateDialog(false); + }} + /> + )} + ); }; diff --git a/frontend/src/services/events.service.ts b/frontend/src/services/events.service.ts index 27567c6b..b678e6bb 100644 --- a/frontend/src/services/events.service.ts +++ b/frontend/src/services/events.service.ts @@ -232,6 +232,22 @@ export const eventsService = { return response.data; }, + // Duplicate an event (#626). Creates a new draft gallery that inherits the + // source event's branding + behaviour + feedback + categories. Photos are + // NOT carried over. The returned id/slug are the new draft event. + async duplicateEvent( + eventId: number, + data: { + event_name: string; + event_date?: string; + customer_name?: string; + customer_email?: string; + }, + ): Promise<{ message: string; id: number; slug: string; is_draft: boolean }> { + const response = await api.post(`/admin/events/${eventId}/duplicate`, data); + return response.data; + }, + // Get admin preview token (uses existing admin session token) getPreviewToken(): string | null { const token = sessionStorage.getItem('admin_token') || localStorage.getItem('admin_token');