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:
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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 gallery’s 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -212,14 +212,19 @@ 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
|
||||||
const galleryData = await galleryService.getGalleryPhotos(currentSlug);
|
// is shown above as an instant placeholder for perceived perf, but it
|
||||||
if (galleryData?.event) {
|
// must NOT win permanently: admin edits to welcome_message / event_name /
|
||||||
const normalizedEvent = normalizeEvent(galleryData.event);
|
// hero_logo / colour theme need to land on the next page load for
|
||||||
setEvent(normalizedEvent);
|
// returning guests. sessionStorage survives Cmd+Shift+R, so without
|
||||||
if (normalizedEvent) {
|
// this refresh the cache could only be cleared by closing the tab or
|
||||||
sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(normalizedEvent));
|
// 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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user