Merge pull request #630 from the-luap/fix/lightroom-export-623

fix+feat: bundled bugfixes — Lightroom (#623), hero gap (#624), stale cache (#625), publish password (#627), low-memory OOM (#628), duplicate gallery (#626)
This commit is contained in:
Paul Nothaft
2026-06-17 23:27:55 +02:00
committed by GitHub
17 changed files with 844 additions and 40 deletions
+26 -1
View File
@@ -299,7 +299,12 @@ For local development with a receiver on the same machine or docker network, set
### Minimum Requirements ### Minimum Requirements
- **CPU**: 2 CPU cores - **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) - **Storage**: 20GB minimum (plus photo storage needs)
- **OS**: Linux (Ubuntu 20.04+), macOS, or Windows with WSL2 - **OS**: Linux (Ubuntu 20.04+), macOS, or Windows with WSL2
- **Node.js**: v18.0.0 or higher - **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**: v20.10.0+
- **Docker Compose**: v2.0.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 ### Video Support Requirements
When enabling video uploads, consider these additional resources: When enabling video uploads, consider these additional resources:
@@ -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');
});
});
+226 -4
View File
@@ -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) // 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 { try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { id } = req.params; const { id } = req.params;
const { password } = req.body;
const event = await db('events').where('id', id).first(); const event = await db('events').where('id', id).first();
if (!event) { 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' }); return res.status(400).json({ error: 'Event is already published' });
} }
// Set is_draft to false const requirePassword = parseBooleanInput(event.require_password, true);
await db('events').where('id', id).update({ is_draft: formatBoolean(false) }); 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 // Queue creation email
const customerEmail = event.customer_email || event.host_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 frontendBase = await getFrontendBaseUrl();
const { shareUrl } = await buildShareLinkVariants({ slug: event.slug, shareToken: event.share_token }); 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 = { const emailData = {
customer_name: customerName, customer_name: customerName,
customer_email: customerEmail, customer_email: customerEmail,
@@ -1092,7 +1127,7 @@ router.post('/:id/publish', adminAuth, requirePermission('events.edit'), require
event_name: event.event_name, event_name: event.event_name,
event_date: event.event_date, event_date: event.event_date,
gallery_link: shareUrl || `${frontendBase}/gallery/${event.slug}`, 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, expiry_date: event.expires_at ? new Date(event.expires_at).toISOString() : null,
welcome_message: event.welcome_message || '' welcome_message: event.welcome_message || ''
}; };
@@ -1141,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 // Update event
router.put('/:id', adminAuth, requirePermission('events.edit'), requireEventOwnership, [ router.put('/:id', adminAuth, requirePermission('events.edit'), requireEventOwnership, [
body('event_name').optional().trim().notEmpty(), body('event_name').optional().trim().notEmpty(),
+36 -2
View File
@@ -16,18 +16,52 @@
* is enough for the rare two-process case during dev). * is enough for the rare two-process case during dev).
* *
* Tunables (env, all optional): * 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_POLL_MS default 1000
* UPLOAD_PROCESSOR_STUCK_TIMEOUT_MS default 600000 (10 minutes) * UPLOAD_PROCESSOR_STUCK_TIMEOUT_MS default 600000 (10 minutes)
* UPLOAD_PROCESSOR_DISABLED default false (set 'true' to opt out, e.g. in CI) * UPLOAD_PROCESSOR_DISABLED default false (set 'true' to opt out, e.g. in CI)
*/ */
const os = require('os');
const { db } = require('../database/db'); const { db } = require('../database/db');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const { processPhoto } = require('./photoProcessor'); const { processPhoto } = require('./photoProcessor');
const POLL_INTERVAL_MS = parseInt(process.env.UPLOAD_PROCESSOR_POLL_MS || '1000', 10); 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 STUCK_TIMEOUT_MS = parseInt(process.env.UPLOAD_PROCESSOR_STUCK_TIMEOUT_MS || '600000', 10);
const JANITOR_INTERVAL_MS = 60 * 1000; const JANITOR_INTERVAL_MS = 60 * 1000;
+21 -6
View File
@@ -81,21 +81,36 @@ class PhotoExportService {
/** /**
* Export as plain text filename list * 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 = {}) { 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 => const filenames = photos.map(photo => {
filename_format === 'original' ? (photo.original_filename || photo.filename) : photo.filename const name = filename_format === 'original'
); ? (photo.original_filename || photo.filename)
: photo.filename;
return include_extension ? name : path.parse(name).name;
});
let content; let content;
switch (separator) { switch (separator) {
case 'comma': case 'comma':
content = filenames.join(', '); content = filenames.join(',');
break; break;
case 'semicolon': case 'semicolon':
content = filenames.join('; '); content = filenames.join(';');
break; break;
default: default:
content = filenames.join('\n'); content = filenames.join('\n');
+9
View File
@@ -69,6 +69,15 @@ services:
redis: redis:
condition: service_healthy condition: service_healthy
restart: unless-stopped 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: healthcheck:
# Backend exposes /health on internal port 3000. # Backend exposes /health on internal port 3000.
# The backend image only ships wget (Alpine base) — using curl # The backend image only ships wget (Alpine base) — using curl
@@ -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>
);
};
@@ -64,6 +64,11 @@ export const PhotoExportMenu: React.FC<PhotoExportMenuProps> = ({
format, format,
options: { options: {
filename_format: 'original', 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_rating: true,
include_label: true, include_label: true,
include_description: true, include_description: true,
@@ -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<PublishGalleryDialogProps> = ({
eventName,
requirePassword,
customerEmail,
isPublishing,
onConfirm,
onClose,
}) => {
const { t } = useTranslation();
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState<string | undefined>(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 (
<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.publishDialog.title', 'Publish 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">
{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.',
})}
</p>
{requirePassword && customerEmail && (
<div className="space-y-3 mb-4">
<Input
type={showPassword ? 'text' : 'password'}
label={t('events.publishDialog.passwordLabel', 'Gallery password')}
placeholder={t('events.publishDialog.passwordPlaceholder', 'Enter the gallery password')}
value={password}
onChange={(e) => {
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={<Lock className="w-5 h-5" />}
rightIcon={
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="p-1"
aria-label={showPassword ? t('events.passwordReset.hide', 'Hide') : t('events.passwordReset.show', 'Show')}
>
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
}
/>
</div>
)}
<div className="flex gap-3">
<Button
variant="outline"
onClick={onClose}
disabled={isPublishing}
className="flex-1"
>
{t('common.cancel', 'Cancel')}
</Button>
<Button
variant="primary"
onClick={handleSubmit}
disabled={isPublishing}
isLoading={isPublishing}
leftIcon={<Send className="w-4 h-4" />}
className="flex-1"
>
{t('events.publishAndNotify')}
</Button>
</div>
</Card>
</div>
);
};
+2
View File
@@ -17,6 +17,8 @@ export { AdminPhotoGrid } from './AdminPhotoGrid';
export { AdminPhotoViewer } from './AdminPhotoViewer'; export { AdminPhotoViewer } from './AdminPhotoViewer';
export { PhotoFilters } from './PhotoFilters'; export { PhotoFilters } from './PhotoFilters';
export { PasswordResetModal } from './PasswordResetModal'; export { PasswordResetModal } from './PasswordResetModal';
export { PublishGalleryDialog } from './PublishGalleryDialog';
export { DuplicateEventDialog } from './DuplicateEventDialog';
export { AdminAuthenticatedImage } from './AdminAuthenticatedImage'; export { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
export { AdminAuthenticatedVideo } from './AdminAuthenticatedVideo'; export { AdminAuthenticatedVideo } from './AdminAuthenticatedVideo';
export { ThemeCustomizerEnhanced } from './ThemeCustomizerEnhanced'; export { ThemeCustomizerEnhanced } from './ThemeCustomizerEnhanced';
@@ -697,6 +697,9 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
const headerStyle = data?.event?.header_style || theme.headerStyle || 'standard'; const headerStyle = data?.event?.header_style || theme.headerStyle || 'standard';
const isHeroHeader = headerStyle === 'hero'; const isHeroHeader = headerStyle === 'hero';
const showSidebar = theme.controlsStyle === 'sidebar'; 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 // Full-page layouts (gallery-premium, gallery-story) have their own integrated UI
// Skip all wrapper elements (header, footer, sidebar, filters) for these layouts // Skip all wrapper elements (header, footer, sidebar, filters) for these layouts
@@ -922,7 +925,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
filter bar globally, and when the gallery actually has photos filter bar globally, and when the gallery actually has photos
(avoids the empty "Search photos by filename" row in the screenshot (avoids the empty "Search photos by filename" row in the screenshot
from discussion #317). */} from discussion #317). */}
{!showSidebar && settingsData?.gallery_show_filter_bar !== false && (data?.photos?.length ?? 0) > 0 ? ( {filterBarShown ? (
<div className="mt-6"> <div className="mt-6">
<PhotoFilterBar <PhotoFilterBar
categories={data.categories} categories={data.categories}
@@ -945,8 +948,11 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
</div> </div>
) : null} ) : null}
{/* Photo Grid */} {/* Photo Grid — when the hero header sits directly under the filter
<div className={showSidebar ? "mt-6" : "mt-6"}> 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). */}
<div className={filterBarShown && isHeroHeader ? "mt-12" : "mt-6"}>
<PhotoGridWithLayouts <PhotoGridWithLayouts
photos={filteredPhotos} photos={filteredPhotos}
slug={slug} slug={slug}
+7 -2
View File
@@ -212,7 +212,13 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
if (sessionResponse.data?.valid && sessionResponse.data.type === 'gallery' && sessionResponse.data.eventSlug === currentSlug) { if (sessionResponse.data?.valid && sessionResponse.data.type === 'gallery' && sessionResponse.data.eventSlug === currentSlug) {
setIsAuthenticated(true); setIsAuthenticated(true);
if (!storedEvent) { // 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); const galleryData = await galleryService.getGalleryPhotos(currentSlug);
if (galleryData?.event) { if (galleryData?.event) {
const normalizedEvent = normalizeEvent(galleryData.event); const normalizedEvent = normalizeEvent(galleryData.event);
@@ -221,7 +227,6 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(normalizedEvent)); sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(normalizedEvent));
} }
} }
}
return; return;
} }
+25
View File
@@ -1018,6 +1018,31 @@
"publishAndNotify": "Veröffentlichen & Kunden benachrichtigen", "publishAndNotify": "Veröffentlichen & Kunden benachrichtigen",
"publishConfirm": "Dadurch wird die Galerie zugänglich und die Benachrichtigungs-E-Mail an den Kunden gesendet. Fortfahren?", "publishConfirm": "Dadurch wird die Galerie zugänglich und die Benachrichtigungs-E-Mail an den Kunden gesendet. Fortfahren?",
"publishSuccess": "Galerie veröffentlicht und Kunde benachrichtigt!", "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."
},
"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.", "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", "subtitle": "Verwalten Sie Ihre Fotogalerien und Veranstaltungen",
"failedToLoadEvents": "Veranstaltungen konnten nicht geladen werden", "failedToLoadEvents": "Veranstaltungen konnten nicht geladen werden",
+25
View File
@@ -565,6 +565,31 @@
"publishAndNotify": "Publish & Notify Client", "publishAndNotify": "Publish & Notify Client",
"publishConfirm": "This will make the gallery accessible and send the notification email to the client. Continue?", "publishConfirm": "This will make the gallery accessible and send the notification email to the client. Continue?",
"publishSuccess": "Gallery published and client notified!", "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."
},
"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.", "draftBanner": "This gallery is in draft mode. Upload your photos, then publish when ready.",
"subtitle": "Manage your photo galleries and events", "subtitle": "Manage your photo galleries and events",
"failedToLoadEvents": "Failed to load events", "failedToLoadEvents": "Failed to load events",
+72 -13
View File
@@ -59,7 +59,7 @@ import { toast } from 'react-toastify';
import { useLocalizedDate } from '../../hooks/useLocalizedDate'; import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { Button, Input, Card, Loading, MarkdownContent, LocalizedDateInput } from '../../components/common'; 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, DuplicateEventDialog, ThemeCustomizerEnhanced, ThemeDisplay, HeroPhotoSelector, FocalPointPicker, PhotoUploadModal, FeedbackSettings, FeedbackModerationPanel, EventRenameDialog, PhotoFilterPanel, PhotoExportMenu, AdminGuestsList } from '../../components/admin';
import { CustomerAccountPicker } from '../../components/admin/CustomerAccountPicker'; import { CustomerAccountPicker } from '../../components/admin/CustomerAccountPicker';
import { EventReminderOverrideCard } from '../../components/admin/EventReminderOverrideCard'; import { EventReminderOverrideCard } from '../../components/admin/EventReminderOverrideCard';
import { useFeatureFlags } from '../../contexts/FeatureFlagsContext'; import { useFeatureFlags } from '../../contexts/FeatureFlagsContext';
@@ -373,6 +373,8 @@ export const EventDetailsPage: React.FC = () => {
const [showPasswordReset, setShowPasswordReset] = useState(false); const [showPasswordReset, setShowPasswordReset] = useState(false);
const [showNewPassword, setShowNewPassword] = useState(false); const [showNewPassword, setShowNewPassword] = useState(false);
const [showRenameDialog, setShowRenameDialog] = useState(false); const [showRenameDialog, setShowRenameDialog] = useState(false);
const [showPublishDialog, setShowPublishDialog] = useState(false);
const [showDuplicateDialog, setShowDuplicateDialog] = useState(false);
const [logoUploading, setLogoUploading] = useState(false); const [logoUploading, setLogoUploading] = useState(false);
const [currentTheme, setCurrentTheme] = useState<ThemeConfig | null>(null); const [currentTheme, setCurrentTheme] = useState<ThemeConfig | null>(null);
const [currentPresetName, setCurrentPresetName] = useState<string>('default'); const [currentPresetName, setCurrentPresetName] = useState<string>('default');
@@ -533,19 +535,44 @@ 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({ const publishMutation = useMutation({
mutationFn: () => eventsService.publishEvent(parseInt(id!)), mutationFn: (password?: string) =>
eventsService.publishEvent(parseInt(id!), password ? { password } : undefined),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-event', id] }); queryClient.invalidateQueries({ queryKey: ['admin-event', id] });
queryClient.invalidateQueries({ queryKey: ['admin-events'] }); queryClient.invalidateQueries({ queryKey: ['admin-events'] });
toast.success(t('events.publishSuccess')); toast.success(t('events.publishSuccess'));
setShowPublishDialog(false);
}, },
onError: () => { onError: () => {
toast.error(t('errors.somethingWentWrong')); toast.error(t('errors.somethingWentWrong'));
}, },
}); });
// 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 // Extend expiration mutation
const extendMutation = useMutation({ const extendMutation = useMutation({
mutationFn: (days: number) => { mutationFn: (days: number) => {
@@ -1040,11 +1067,7 @@ export const EventDetailsPage: React.FC = () => {
variant="primary" variant="primary"
size="sm" size="sm"
leftIcon={<Send className="w-4 h-4" />} leftIcon={<Send className="w-4 h-4" />}
onClick={() => { onClick={() => setShowPublishDialog(true)}
if (confirm(t('events.publishConfirm'))) {
publishMutation.mutate();
}
}}
isLoading={publishMutation.isPending} isLoading={publishMutation.isPending}
> >
{t('events.publishAndNotify')} {t('events.publishAndNotify')}
@@ -2161,11 +2184,7 @@ export const EventDetailsPage: React.FC = () => {
<Button <Button
variant="primary" variant="primary"
leftIcon={<Send className="w-4 h-4" />} leftIcon={<Send className="w-4 h-4" />}
onClick={() => { onClick={() => setShowPublishDialog(true)}
if (confirm(t('events.publishConfirm'))) {
publishMutation.mutate();
}
}}
isLoading={publishMutation.isPending} isLoading={publishMutation.isPending}
className="w-full justify-center" className="w-full justify-center"
> >
@@ -2195,6 +2214,17 @@ export const EventDetailsPage: React.FC = () => {
</p> </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> </div>
</Card> </Card>
)} )}
@@ -2604,6 +2634,35 @@ export const EventDetailsPage: React.FC = () => {
onValidate={(newName) => eventsService.validateRename(event.id, newName)} onValidate={(newName) => eventsService.validateRename(event.id, newName)}
/> />
{/* Publish Gallery Dialog (#627) prompts for the password so the
gallery_created email carries the real plaintext, not the sentinel. */}
{showPublishDialog && (
<PublishGalleryDialog
eventName={event.event_name}
requirePassword={isGalleryPublic(event) ? false : true}
customerEmail={event.customer_email}
isPublishing={publishMutation.isPending}
onConfirm={(password) => publishMutation.mutate(password)}
onClose={() => {
if (!publishMutation.isPending) setShowPublishDialog(false);
}}
/>
)}
{/* 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> </div>
); );
}; };
+26 -3
View File
@@ -219,9 +219,32 @@ export const eventsService = {
return response.data; return response.data;
}, },
// Publish a draft event // Publish a draft event. `password` is optional; when the event is
async publishEvent(eventId: number): Promise<{ message: string; is_draft: boolean }> { // password-protected, supplying the password here makes the gallery_created
const response = await api.post(`/admin/events/${eventId}/publish`); // 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;
},
// 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; return response.data;
}, },
+1
View File
@@ -345,6 +345,7 @@ export interface ExportOptions {
options?: { options?: {
filename_format?: 'original' | 'picpeak'; filename_format?: 'original' | 'picpeak';
separator?: 'newline' | 'comma' | 'semicolon'; separator?: 'newline' | 'comma' | 'semicolon';
include_extension?: boolean;
include_rating?: boolean; include_rating?: boolean;
include_label?: boolean; include_label?: boolean;
include_description?: boolean; include_description?: boolean;