feat(events): duplicate-gallery action (#626)

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).
This commit is contained in:
Paul Nothaft
2026-06-17 23:16:12 +02:00
parent 714a9f6fb1
commit e985d25207
7 changed files with 426 additions and 1 deletions
@@ -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<DuplicateEventDialogProps> = ({
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<string | undefined>(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 (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<Card className="max-w-md w-full">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">
{t('events.duplicateDialog.title', 'Duplicate gallery')}
</h2>
<button
onClick={onClose}
className="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300"
aria-label={t('common.close', 'Close')}
>
<X className="w-5 h-5" />
</button>
</div>
<p className="text-neutral-600 dark:text-neutral-400 mb-4">
{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.',
})}
</p>
<div className="space-y-3 mb-4">
<Input
type="text"
label={t('events.duplicateDialog.eventNameLabel', 'New event name *')}
placeholder={t('events.duplicateDialog.eventNamePlaceholder', 'e.g. Müller Wedding 2026')}
value={eventName}
onChange={(e) => {
setEventName(e.target.value);
if (error) setError(undefined);
}}
error={error}
/>
<LocalizedDateInput
label={t('events.duplicateDialog.eventDateLabel', 'Event date')}
value={eventDate}
onChange={setEventDate}
helperText={t(
'events.duplicateDialog.eventDateHelp',
'Leave blank to use a random suffix in the gallery URL. Expiration is recomputed from this date plus the source gallerys expiration window.',
)}
/>
<Input
type="text"
label={t('events.duplicateDialog.customerNameLabel', 'Customer name')}
placeholder={t('events.duplicateDialog.customerNamePlaceholder', 'Optional — fill in later if unknown')}
value={customerName}
onChange={(e) => setCustomerName(e.target.value)}
/>
<Input
type="email"
label={t('events.duplicateDialog.customerEmailLabel', 'Customer email')}
placeholder={t('events.duplicateDialog.customerEmailPlaceholder', 'Optional')}
value={customerEmail}
onChange={(e) => setCustomerEmail(e.target.value)}
/>
</div>
<div className="flex gap-3">
<Button
variant="outline"
onClick={onClose}
disabled={isDuplicating}
className="flex-1"
>
{t('common.cancel', 'Cancel')}
</Button>
<Button
variant="primary"
onClick={handleSubmit}
disabled={isDuplicating}
isLoading={isDuplicating}
leftIcon={<Copy className="w-4 h-4" />}
className="flex-1"
>
{t('events.duplicateDialog.confirm', 'Create duplicate')}
</Button>
</div>
</Card>
</div>
);
};
+1
View File
@@ -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';
+16
View File
@@ -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",
+16
View File
@@ -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",
+49 -1
View File
@@ -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<ThemeConfig | null>(null);
const [currentPresetName, setCurrentPresetName] = useState<string>('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 = () => {
</p>
</>
)}
{/* Duplicate (#626) visible in both draft and live mode.
Creates a new draft inheriting this gallery's config. */}
<Button
variant="outline"
leftIcon={<Copy className="w-4 h-4" />}
onClick={() => setShowDuplicateDialog(true)}
isLoading={duplicateMutation.isPending}
className="w-full justify-center"
>
{t('events.duplicateEvent', 'Duplicate gallery')}
</Button>
</div>
</Card>
)}
@@ -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 && (
<DuplicateEventDialog
sourceEventName={event.event_name}
isDuplicating={duplicateMutation.isPending}
onConfirm={(data) => duplicateMutation.mutate(data)}
onClose={() => {
if (!duplicateMutation.isPending) setShowDuplicateDialog(false);
}}
/>
)}
</div>
);
};
+16
View File
@@ -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');