From ea20446a797a00cf45dbe7bf6f06574a79c4d8a6 Mon Sep 17 00:00:00 2001
From: Paul Nothaft
Date: Thu, 15 Jan 2026 13:57:22 +0100
Subject: [PATCH 1/7] fix: watermark thumbnails, custom logo display, and
German translations
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Fix thumbnail display when watermarks enabled globally on existing galleries
- Backend: Apply watermarks to thumbnails at the thumbnail endpoint
- Frontend: Remove hack that redirected thumbnails to photo endpoint
- Fix custom logo display in gallery hero sections
- Only apply brightness/invert filter to default PicPeak logo
- Custom logos now display as-is with drop-shadow only
- Add German translations for Event Creation and Image Protection settings
- settings.events: Pflichtfelder, Kundenname/E-Mail erforderlich, etc.
- settings.imageSecurity: Bildschutz, Ratenbegrenzung, Sicherheitsüberwachung
- Protection level options in both EN and DE locales
---
backend/src/routes/gallery.js | 13 +++++-
.../src/components/gallery/GalleryLayout.tsx | 5 +-
.../src/components/gallery/GalleryView.tsx | 6 +--
.../gallery/layouts/HeroGalleryLayout.tsx | 7 ++-
frontend/src/i18n/locales/de.json | 46 +++++++++++++++++++
frontend/src/i18n/locales/en.json | 4 ++
6 files changed, 73 insertions(+), 8 deletions(-)
diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js
index 4b5b7a40..1464fca3 100644
--- a/backend/src/routes/gallery.js
+++ b/backend/src/routes/gallery.js
@@ -829,8 +829,17 @@ router.get('/:slug/thumbnail/:photoId',
'X-Protected-Thumbnail': 'true'
});
- // Send file
- res.sendFile(path.resolve(thumbPath));
+ // Check if watermarks are enabled and apply to thumbnail
+ const watermarkSettings = await watermarkService.getWatermarkSettings();
+
+ if (watermarkSettings && watermarkSettings.enabled) {
+ // Apply watermark to thumbnail
+ const watermarkedBuffer = await watermarkService.applyWatermark(thumbPath, watermarkSettings);
+ res.send(watermarkedBuffer);
+ } else {
+ // Send file without watermark
+ res.sendFile(path.resolve(thumbPath));
+ }
} catch (error) {
logger.error('Error serving thumbnail:', {
error: error.message,
diff --git a/frontend/src/components/gallery/GalleryLayout.tsx b/frontend/src/components/gallery/GalleryLayout.tsx
index 87738879..38dd25a8 100644
--- a/frontend/src/components/gallery/GalleryLayout.tsx
+++ b/frontend/src/components/gallery/GalleryLayout.tsx
@@ -366,7 +366,10 @@ export const GalleryLayout: React.FC = ({
className={`${heroLogoSize.className} w-auto object-contain mx-auto`}
style={{
...(heroLogoSize.style || {}),
- filter: 'brightness(0) invert(1) drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))'
+ // Only apply brightness/invert filter to default logo; custom logos display as-is
+ filter: brandingSettings?.logo_url
+ ? 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))'
+ : 'brightness(0) invert(1) drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))'
}}
/>
{shouldShowCompanyName() && brandingSettings?.company_name && (
diff --git a/frontend/src/components/gallery/GalleryView.tsx b/frontend/src/components/gallery/GalleryView.tsx
index d22a2e24..333f2d08 100644
--- a/frontend/src/components/gallery/GalleryView.tsx
+++ b/frontend/src/components/gallery/GalleryView.tsx
@@ -380,12 +380,12 @@ export const GalleryView: React.FC = ({ slug, event }) => {
}
});
- // Transform URLs for watermarks if enabled
+ // Transform full-size URLs for watermarks if enabled
+ // Note: Thumbnails are watermarked server-side at the thumbnail endpoint
if (watermarkEnabled) {
photos = photos.map(photo => ({
...photo,
- url: `/gallery/${slug}/photo/${photo.id}`,
- thumbnail_url: `/gallery/${slug}/photo/${photo.id}` // Use watermarked version for thumbnails too
+ url: `/gallery/${slug}/photo/${photo.id}`
}));
}
diff --git a/frontend/src/components/gallery/layouts/HeroGalleryLayout.tsx b/frontend/src/components/gallery/layouts/HeroGalleryLayout.tsx
index d3ec0487..e06c725a 100644
--- a/frontend/src/components/gallery/layouts/HeroGalleryLayout.tsx
+++ b/frontend/src/components/gallery/layouts/HeroGalleryLayout.tsx
@@ -142,8 +142,11 @@ export const HeroGalleryLayout: React.FC = ({
}
alt="Event logo"
className="h-20 sm:h-24 lg:h-32 mx-auto"
- style={{
- filter: 'brightness(0) invert(1) drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
+ style={{
+ // Only apply brightness/invert filter to default logo; custom logos display as-is
+ filter: eventLogo
+ ? 'drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
+ : 'brightness(0) invert(1) drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
}}
/>
diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json
index 5f24300f..63f4c16a 100644
--- a/frontend/src/i18n/locales/de.json
+++ b/frontend/src/i18n/locales/de.json
@@ -778,6 +778,10 @@
"enableDevtoolsProtection": "Entwicklertools erkennen",
"useCanvasRendering": "Canvas-Rendering (erweiterter Schutz)",
"protectionInfo": "Schutzfunktionen helfen, unerlaubte Downloads zu verhindern, können jedoch nicht alle Methoden blockieren.",
+ "protectionLevelBasic": "Einfach - Nur Rechtsklick-Sperre",
+ "protectionLevelStandard": "Standard - Tastenkombinationen blockiert",
+ "protectionLevelEnhanced": "Erweitert - Bildschirmfoto-Erkennung",
+ "protectionLevelMaximum": "Maximum - DevTools-Erkennung & Canvas-Rendering",
"heroPhoto": "Hero-Foto",
"heroPhotoHelp": "Wählen Sie ein hervorgehobenes Foto für das Hero-Galerie-Layout",
"selectHeroPhoto": "Hero-Foto auswählen",
@@ -1032,6 +1036,48 @@
"saveSettings": "Sicherheitseinstellungen speichern",
"saveSecuritySettings": "Sicherheitseinstellungen speichern"
},
+ "events": {
+ "title": "Veranstaltungserstellung",
+ "requiredFields": "Pflichtfelder",
+ "requiredFieldsDescription": "Konfigurieren Sie, welche Kontaktfelder beim Erstellen neuer Veranstaltungen erforderlich sind.",
+ "requireCustomerName": "Kundenname erforderlich",
+ "requireCustomerNameHelp": "Kundenname muss für neue Veranstaltungen angegeben werden",
+ "requireCustomerEmail": "Kunden-E-Mail erforderlich",
+ "requireCustomerEmailHelp": "Kunden-E-Mail muss für neue Veranstaltungen angegeben werden",
+ "customerEmailWarning": "Erforderlich für das Versenden von Galerie-Einladungen",
+ "requireAdminEmail": "Admin-E-Mail erforderlich",
+ "requireAdminEmailHelp": "Admin-E-Mail muss für neue Veranstaltungen angegeben werden",
+ "adminEmailWarning": "Erforderlich für den Erhalt von Veranstaltungsbenachrichtigungen",
+ "saveSettings": "Veranstaltungseinstellungen speichern",
+ "noteTitle": "Hinweis",
+ "noteText": "Diese Einstellungen betreffen nur die Erstellung neuer Veranstaltungen. Bestehende Veranstaltungen sind nicht betroffen. Standardmäßig sind alle Felder erforderlich."
+ },
+ "imageSecurity": {
+ "title": "Bildschutz",
+ "saveSuccess": "Bildschutz-Einstellungen gespeichert",
+ "saveError": "Fehler beim Speichern der Einstellungen",
+ "loadError": "Fehler beim Laden der Bildschutz-Einstellungen",
+ "defaultProtection": "Standard-Schutzeinstellungen",
+ "defaultProtectionHelp": "Diese Einstellungen gelten für alle neuen Veranstaltungen. Einzelne Veranstaltungen können diese Standardwerte überschreiben.",
+ "protectionLevel": "Standard-Schutzstufe",
+ "imageQuality": "Standard-Bildqualität",
+ "fragmentationLevel": "Fragmentierungsstufe",
+ "enableDevtools": "DevTools-Erkennung standardmäßig aktivieren",
+ "enableCanvas": "Canvas-Rendering standardmäßig aktivieren (erweiterter Schutz)",
+ "rateLimiting": "Ratenbegrenzung",
+ "rateLimitingHelp": "Begrenzen Sie, wie viele Bilder angefordert werden können, um Scraping zu verhindern.",
+ "requestsPerMinute": "Anfragen pro Minute",
+ "requestsPer5Minutes": "Anfragen pro 5 Min.",
+ "requestsPerHour": "Anfragen pro Stunde",
+ "securityMonitoring": "Sicherheitsüberwachung",
+ "suspiciousThreshold": "Schwellenwert für verdächtige Aktivitäten",
+ "autoBlockThreshold": "Automatische Sperrschwelle",
+ "enableMonitoring": "Sicherheitsüberwachung aktivieren",
+ "blockSuspiciousIps": "Verdächtige IPs automatisch blockieren",
+ "logEvents": "Sicherheitsereignisse in Datenbank protokollieren",
+ "infoTitle": "Über Bildschutz",
+ "infoText": "Diese Schutzfunktionen helfen, gelegentliches Herunterladen und Kopieren zu verhindern, können aber nicht alle Methoden blockieren. Entschlossene Benutzer finden möglicherweise trotzdem Wege, Bilder zu erfassen. Erwägen Sie die Verwendung von Wasserzeichen und rechtlichen Vereinbarungen für umfassenden Schutz."
+ },
"categories": {
"title": "Kategorien",
"about": "Über Fotokategorien",
diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json
index 3cfdc0ba..080206a6 100644
--- a/frontend/src/i18n/locales/en.json
+++ b/frontend/src/i18n/locales/en.json
@@ -452,6 +452,10 @@
"enableDevtoolsProtection": "Detect developer tools",
"useCanvasRendering": "Canvas rendering (advanced protection)",
"protectionInfo": "Protection features help prevent unauthorized downloads but cannot block all methods.",
+ "protectionLevelBasic": "Basic - Right-click blocking only",
+ "protectionLevelStandard": "Standard - Keyboard shortcuts blocked",
+ "protectionLevelEnhanced": "Enhanced - Print screen detection",
+ "protectionLevelMaximum": "Maximum - DevTools detection & canvas rendering",
"heroPhoto": "Hero Photo",
"heroPhotoHelp": "Select a featured photo for the hero gallery layout",
"selectHeroPhoto": "Select Hero Photo",
From ce8587b24df3f53a11a74348eff8b5c5b96c5488 Mon Sep 17 00:00:00 2001
From: Paul Nothaft
Date: Thu, 15 Jan 2026 14:16:00 +0100
Subject: [PATCH 2/7] fix: lightbox watermark loading, white label
translations, and dynamic footer year
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Fix watermarked images not opening in lightbox (add /api prefix to photo URLs)
- Add i18n translations for 'White Label' and 'Hide Powered by' branding settings
- Add complete logo customization translations (EN and DE)
- Replace hardcoded © 2024 with dynamic current year in footer
- Use company name from settings in default footer text
---
.../src/components/gallery/GalleryLayout.tsx | 2 +-
.../src/components/gallery/GalleryView.tsx | 4 +--
frontend/src/i18n/locales/de.json | 26 ++++++++++++++++++-
frontend/src/i18n/locales/en.json | 26 ++++++++++++++++++-
frontend/src/pages/admin/BrandingPage.tsx | 4 +--
frontend/src/pages/public/LegalPage.tsx | 2 +-
frontend/src/utils/photoUrl.ts | 6 ++---
7 files changed, 59 insertions(+), 11 deletions(-)
diff --git a/frontend/src/components/gallery/GalleryLayout.tsx b/frontend/src/components/gallery/GalleryLayout.tsx
index 38dd25a8..be708d27 100644
--- a/frontend/src/components/gallery/GalleryLayout.tsx
+++ b/frontend/src/components/gallery/GalleryLayout.tsx
@@ -444,7 +444,7 @@ export const GalleryLayout: React.FC = ({
)}
- {brandingSettings?.footer_text || '© 2024 Your Company. All rights reserved.'}
+ {brandingSettings?.footer_text || `© ${new Date().getFullYear()}${brandingSettings?.company_name ? ` ${brandingSettings.company_name}` : ''}. All rights reserved.`}
{!brandingSettings?.hide_powered_by && (
<> | Powered by PicPeak>
)}
diff --git a/frontend/src/components/gallery/GalleryView.tsx b/frontend/src/components/gallery/GalleryView.tsx
index 333f2d08..76e1f53b 100644
--- a/frontend/src/components/gallery/GalleryView.tsx
+++ b/frontend/src/components/gallery/GalleryView.tsx
@@ -203,7 +203,7 @@ export const GalleryView: React.FC = ({ slug, event }) => {
company_name: settingsData.branding_company_name || '',
company_tagline: settingsData.branding_company_tagline || '',
support_email: settingsData.branding_support_email || '',
- footer_text: settingsData.branding_footer_text || '© 2024 Your Company. All rights reserved.',
+ footer_text: settingsData.branding_footer_text || '',
watermark_enabled: settingsData.branding_watermark_enabled || false,
logo_url: settingsData.branding_logo_url || null,
logo_size: settingsData.branding_logo_size || 'medium',
@@ -385,7 +385,7 @@ export const GalleryView: React.FC = ({ slug, event }) => {
if (watermarkEnabled) {
photos = photos.map(photo => ({
...photo,
- url: `/gallery/${slug}/photo/${photo.id}`
+ url: `/api/gallery/${slug}/photo/${photo.id}`
}));
}
diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json
index 63f4c16a..dbf23b3f 100644
--- a/frontend/src/i18n/locales/de.json
+++ b/frontend/src/i18n/locales/de.json
@@ -1255,7 +1255,31 @@
"customizeTheme": "Design anpassen",
"saveTheme": "Design speichern",
"previewLayout": "Vorschau-Layout",
- "livePreview": "Live-Vorschau"
+ "livePreview": "Live-Vorschau",
+ "whiteLabel": "White Label",
+ "hidePoweredBy": "\"Powered by PicPeak\" Branding ausblenden",
+ "hidePoweredByHelp": "Entfernen Sie die PicPeak-Kennzeichnung aus Galerie-Fußzeilen für ein vollständig personalisiertes Erscheinungsbild",
+ "logoCustomization": "Logo-Anpassung",
+ "changeLogo": "Logo ändern",
+ "logoSizeSmall": "Klein (32px)",
+ "logoSizeMedium": "Mittel (48px)",
+ "logoSizeLarge": "Groß (64px)",
+ "logoSizeXLarge": "Extra Groß (96px)",
+ "logoSizeCustom": "Benutzerdefiniert",
+ "logoMaxHeight": "Maximale Höhe (Pixel)",
+ "logoMaxHeightHelp": "Legen Sie eine benutzerdefinierte maximale Höhe für das Logo fest (20-200 Pixel)",
+ "logoPosition": "Logo-Position im Header",
+ "positionLeft": "Links",
+ "positionCenter": "Mitte",
+ "positionRight": "Rechts",
+ "logoDisplayMode": "Anzeigemodus",
+ "logoOnly": "Nur Logo",
+ "textOnly": "Nur Firmenname",
+ "logoAndText": "Logo und Firmenname",
+ "showLogoInHeader": "Logo im Galerie-Header anzeigen",
+ "showLogoInHeaderHelp": "Logo in der Hauptkopfzeile anzeigen",
+ "showLogoInHero": "Logo im Hero-Bereich anzeigen",
+ "showLogoInHeroHelp": "Logo in Hero-Bereichen anzeigen (für Nicht-Raster-Layouts)"
},
"admin": {
"title": "Admin-Panel",
diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json
index 080206a6..6ecb989b 100644
--- a/frontend/src/i18n/locales/en.json
+++ b/frontend/src/i18n/locales/en.json
@@ -1018,7 +1018,31 @@
"customizeTheme": "Customize Theme",
"saveTheme": "Save Theme",
"previewLayout": "Preview Layout",
- "livePreview": "Live Preview"
+ "livePreview": "Live Preview",
+ "whiteLabel": "White Label",
+ "hidePoweredBy": "Hide \"Powered by PicPeak\" branding",
+ "hidePoweredByHelp": "Remove the PicPeak attribution from gallery footers for a fully white-labeled experience",
+ "logoCustomization": "Logo Customization",
+ "changeLogo": "Change Logo",
+ "logoSizeSmall": "Small (32px)",
+ "logoSizeMedium": "Medium (48px)",
+ "logoSizeLarge": "Large (64px)",
+ "logoSizeXLarge": "Extra Large (96px)",
+ "logoSizeCustom": "Custom",
+ "logoMaxHeight": "Maximum Height (pixels)",
+ "logoMaxHeightHelp": "Set a custom maximum height for the logo (20-200 pixels)",
+ "logoPosition": "Logo Position in Header",
+ "positionLeft": "Left",
+ "positionCenter": "Center",
+ "positionRight": "Right",
+ "logoDisplayMode": "Display Mode",
+ "logoOnly": "Logo Only",
+ "textOnly": "Company Name Only",
+ "logoAndText": "Logo and Company Name",
+ "showLogoInHeader": "Show logo in gallery header",
+ "showLogoInHeaderHelp": "Display the logo in the main header bar",
+ "showLogoInHero": "Show logo in hero section",
+ "showLogoInHeroHelp": "Display the logo in hero sections (for non-grid layouts)"
},
"admin": {
"title": "Admin Panel",
diff --git a/frontend/src/pages/admin/BrandingPage.tsx b/frontend/src/pages/admin/BrandingPage.tsx
index 4108ac90..8693e059 100644
--- a/frontend/src/pages/admin/BrandingPage.tsx
+++ b/frontend/src/pages/admin/BrandingPage.tsx
@@ -15,7 +15,7 @@ export const BrandingPage: React.FC = () => {
const [brandingSettings, setBrandingSettings] = useState({
company_name: '',
company_tagline: '',
- footer_text: '© 2024 Your Company. All rights reserved.',
+ footer_text: '',
support_email: '',
watermark_enabled: false,
watermark_position: 'bottom-right',
@@ -307,7 +307,7 @@ export const BrandingPage: React.FC = () => {
onChange={(e) => handleBrandingChange('footer_text', e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500"
rows={2}
- placeholder="© 2024 Your Company. All rights reserved."
+ placeholder={`© ${new Date().getFullYear()} Your Company. All rights reserved.`}
/>
diff --git a/frontend/src/pages/public/LegalPage.tsx b/frontend/src/pages/public/LegalPage.tsx
index 0b4ab625..327db3ef 100644
--- a/frontend/src/pages/public/LegalPage.tsx
+++ b/frontend/src/pages/public/LegalPage.tsx
@@ -144,7 +144,7 @@ export const LegalPage: React.FC = () => {
- © 2024 PicPeak. All rights reserved.
+ © {new Date().getFullYear()} PicPeak. All rights reserved.
diff --git a/frontend/src/utils/photoUrl.ts b/frontend/src/utils/photoUrl.ts
index 2bce36b2..f0916f21 100644
--- a/frontend/src/utils/photoUrl.ts
+++ b/frontend/src/utils/photoUrl.ts
@@ -12,10 +12,10 @@ interface PhotoUrlOptions {
*/
export function getPhotoUrl({ slug, photo, watermarkEnabled = false, token }: PhotoUrlOptions): string {
if (watermarkEnabled && token) {
- // Use the watermarked photo endpoint
- return `/gallery/${slug}/photo/${photo.id}`;
+ // Use the watermarked photo endpoint (needs /api prefix)
+ return `/api/gallery/${slug}/photo/${photo.id}`;
}
-
+
// Use the static photo URL
return photo.url;
}
From 83a4344a01de4f65c5024fdf2d177a04457ccd2f Mon Sep 17 00:00:00 2001
From: Paul Nothaft
Date: Thu, 15 Jan 2026 15:43:13 +0100
Subject: [PATCH 3/7] fix: prevent database migration restart failures
- Move migrations table insert inside PostgreSQL transaction for atomicity
- Add PostgreSQL error codes 42701 (duplicate column), 42710 (duplicate
object), and 23505 (unique violation) to error handling
- Make migrations 006 and 008 idempotent with column existence checks
Fixes #107
---
.../006_add_photo_counter_to_categories.js | 39 +++++++++-------
...add_language_support_to_email_templates.js | 46 +++++++++++--------
backend/migrations/run-migrations-safe.js | 23 +++++++---
backend/migrations/run-migrations.js | 17 +++++--
4 files changed, 81 insertions(+), 44 deletions(-)
diff --git a/backend/migrations/legacy/006_add_photo_counter_to_categories.js b/backend/migrations/legacy/006_add_photo_counter_to_categories.js
index 6f378aad..4f4a46a6 100644
--- a/backend/migrations/legacy/006_add_photo_counter_to_categories.js
+++ b/backend/migrations/legacy/006_add_photo_counter_to_categories.js
@@ -1,22 +1,27 @@
exports.up = async function(knex) {
- // Add photo_counter column to photo_categories table
- await knex.schema.alterTable('photo_categories', function(table) {
- table.integer('photo_counter').defaultTo(0).notNullable();
- });
+ // Check if photo_counter column already exists to make migration idempotent
+ const hasPhotoCounter = await knex.schema.hasColumn('photo_categories', 'photo_counter');
- // Initialize counters based on existing photos
- const categories = await knex('photo_categories').select('id');
-
- for (const category of categories) {
- const photoCount = await knex('photos')
- .where('category_id', category.id)
- .count('id as count')
- .first();
-
- if (photoCount && photoCount.count > 0) {
- await knex('photo_categories')
- .where('id', category.id)
- .update({ photo_counter: photoCount.count });
+ if (!hasPhotoCounter) {
+ // Add photo_counter column to photo_categories table
+ await knex.schema.alterTable('photo_categories', function(table) {
+ table.integer('photo_counter').defaultTo(0).notNullable();
+ });
+
+ // Initialize counters based on existing photos
+ const categories = await knex('photo_categories').select('id');
+
+ for (const category of categories) {
+ const photoCount = await knex('photos')
+ .where('category_id', category.id)
+ .count('id as count')
+ .first();
+
+ if (photoCount && photoCount.count > 0) {
+ await knex('photo_categories')
+ .where('id', category.id)
+ .update({ photo_counter: photoCount.count });
+ }
}
}
};
diff --git a/backend/migrations/legacy/008_add_language_support_to_email_templates.js b/backend/migrations/legacy/008_add_language_support_to_email_templates.js
index 40718509..d2604504 100644
--- a/backend/migrations/legacy/008_add_language_support_to_email_templates.js
+++ b/backend/migrations/legacy/008_add_language_support_to_email_templates.js
@@ -1,23 +1,33 @@
exports.up = async function(knex) {
- // Add language-specific columns to email_templates
- await knex.schema.alterTable('email_templates', function(table) {
- // Add English versions (rename existing columns for consistency)
- table.renameColumn('subject', 'subject_en');
- table.renameColumn('body_html', 'body_html_en');
- table.renameColumn('body_text', 'body_text_en');
-
- // Add German versions
- table.string('subject_de');
- table.text('body_html_de');
- table.text('body_text_de');
- });
+ // Check which columns already exist to make migration idempotent
+ const hasSubjectEn = await knex.schema.hasColumn('email_templates', 'subject_en');
+ const hasSubjectDe = await knex.schema.hasColumn('email_templates', 'subject_de');
+ const hasSubjectOriginal = await knex.schema.hasColumn('email_templates', 'subject');
- // Copy existing values to German columns as defaults
- await knex('email_templates').update({
- subject_de: knex.raw('subject_en'),
- body_html_de: knex.raw('body_html_en'),
- body_text_de: knex.raw('body_text_en')
- });
+ // Only rename columns if they haven't been renamed yet
+ if (hasSubjectOriginal && !hasSubjectEn) {
+ await knex.schema.alterTable('email_templates', function(table) {
+ table.renameColumn('subject', 'subject_en');
+ table.renameColumn('body_html', 'body_html_en');
+ table.renameColumn('body_text', 'body_text_en');
+ });
+ }
+
+ // Only add German columns if they don't exist
+ if (!hasSubjectDe) {
+ await knex.schema.alterTable('email_templates', function(table) {
+ table.string('subject_de');
+ table.text('body_html_de');
+ table.text('body_text_de');
+ });
+
+ // Copy existing values to German columns as defaults
+ await knex('email_templates').update({
+ subject_de: knex.raw('subject_en'),
+ body_html_de: knex.raw('body_html_en'),
+ body_text_de: knex.raw('body_text_en')
+ });
+ }
};
exports.down = async function(knex) {
diff --git a/backend/migrations/run-migrations-safe.js b/backend/migrations/run-migrations-safe.js
index 99c5abee..f66271d0 100644
--- a/backend/migrations/run-migrations-safe.js
+++ b/backend/migrations/run-migrations-safe.js
@@ -67,26 +67,37 @@ async function runMigrationSafely(filepath) {
const migrationPath = path.join(__dirname, filepath);
const migration = require(migrationPath);
const filename = path.basename(filepath);
-
+
if (migration.up) {
console.log(`Running migration: ${filepath}`);
-
+
// Run migration in a transaction if possible
+ // IMPORTANT: Include the migrations table insert INSIDE the transaction
+ // to ensure atomicity between schema changes and tracking
if (db.client.config.client === 'pg') {
await db.transaction(async (trx) => {
await migration.up(trx);
+ // Insert migration record inside transaction for atomicity
+ await trx('migrations').insert({ filename });
});
} else {
await migration.up(db);
+ await db('migrations').insert({ filename });
}
-
- await db('migrations').insert({ filename });
+
console.log(`Migration ${filepath} completed successfully`);
}
} catch (error) {
// Check if error is because schema already exists
- if (error.code === '42P07' || // PostgreSQL: relation already exists
- error.code === 'SQLITE_ERROR' && error.message.includes('already exists')) {
+ // PostgreSQL error codes:
+ // - 42P07: duplicate_table (relation already exists)
+ // - 42701: duplicate_column (column already exists)
+ // - 42710: duplicate_object (constraint, index, etc. already exists)
+ // - 23505: unique_violation (migration record already exists)
+ const schemaExistsErrors = ['42P07', '42701', '42710', '23505'];
+ const isSQLiteAlreadyExists = error.code === 'SQLITE_ERROR' && error.message.includes('already exists');
+
+ if (schemaExistsErrors.includes(error.code) || isSQLiteAlreadyExists) {
console.log(`Migration ${filepath} - schema already exists, marking as applied`);
await markMigrationAsApplied(path.basename(filepath));
} else {
diff --git a/backend/migrations/run-migrations.js b/backend/migrations/run-migrations.js
index 3ab7f7b1..86c9114f 100644
--- a/backend/migrations/run-migrations.js
+++ b/backend/migrations/run-migrations.js
@@ -26,11 +26,22 @@ async function runMigration(filepath) {
const migrationPath = path.join(__dirname, filepath);
const migration = require(migrationPath);
const filename = path.basename(filepath);
-
+
if (migration.up) {
console.log(`Running migration: ${filepath}`);
- await migration.up(db);
- await db('migrations').insert({ filename });
+
+ // Run migration in a transaction if PostgreSQL to ensure atomicity
+ // between schema changes and migration tracking
+ if (db.client.config.client === 'pg') {
+ await db.transaction(async (trx) => {
+ await migration.up(trx);
+ await trx('migrations').insert({ filename });
+ });
+ } else {
+ await migration.up(db);
+ await db('migrations').insert({ filename });
+ }
+
console.log(`Migration ${filepath} completed`);
}
}
From 050ed378199eb3b15c7c7f243792f68f858803f5 Mon Sep 17 00:00:00 2001
From: Paul Nothaft
Date: Thu, 15 Jan 2026 16:19:22 +0100
Subject: [PATCH 4/7] fix: add lightbox loading spinner and watermark cache
invalidation
- Add spinning loader in lightbox while large images are loading
- Add onLoad callback to AuthenticatedImage for canvas and img modes
- Add ETag headers based on watermark settings for HTTP cache validation
- Add watermark version query param to photo/thumbnail URLs for cache busting
- Ensures images refresh when watermark settings are enabled/changed
---
backend/src/routes/gallery.js | 56 ++++++++++++++++---
.../components/common/AuthenticatedImage.tsx | 9 ++-
.../src/components/gallery/PhotoLightbox.tsx | 21 ++++++-
3 files changed, 71 insertions(+), 15 deletions(-)
diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js
index 1464fca3..2b5a6db6 100644
--- a/backend/src/routes/gallery.js
+++ b/backend/src/routes/gallery.js
@@ -172,7 +172,13 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
try {
// Get filter parameters from query
const { filter, guest_id } = req.query;
-
+
+ // Get watermark settings to generate cache-busting version for URLs
+ const watermarkSettings = await watermarkService.getWatermarkSettings();
+ const wmVersion = watermarkSettings?.enabled
+ ? `wm=${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}`
+ : '';
+
// First get all photos
let photos = await db('photos')
.where('photos.event_id', req.event.id)
@@ -327,15 +333,17 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
categories: categories,
photos: photos.map(photo => {
const useJwtUrl = (protectionSettings.protection_level === 'basic' || protectionSettings.protection_level === 'standard');
- const photoUrl = useJwtUrl ?
- `/api/gallery/${req.params.slug}/photo/${photo.id}` :
+ // Add watermark version to URLs for cache busting when settings change
+ const wmQuery = wmVersion ? `?${wmVersion}` : '';
+ const photoUrl = useJwtUrl ?
+ `/api/gallery/${req.params.slug}/photo/${photo.id}${wmQuery}` :
`/api/secure-images/${req.params.slug}/secure/${photo.id}/{{token}}`;
-
+
return {
id: photo.id,
filename: photo.filename,
url: photoUrl,
- thumbnail_url: photo.thumbnail_path ? `/api/gallery/${req.params.slug}/thumbnail/${photo.id}` : null,
+ thumbnail_url: photo.thumbnail_path ? `/api/gallery/${req.params.slug}/thumbnail/${photo.id}${wmQuery}` : null,
secure_url_template: `/api/secure-images/${req.params.slug}/secure/${photo.id}/{{token}}`,
download_url_template: `/api/secure-images/${req.params.slug}/secure-download/${photo.id}/{{token}}`,
type: photo.type,
@@ -754,6 +762,20 @@ router.get('/:slug/photo/:photoId',
// Get watermark settings
const watermarkSettings = await watermarkService.getWatermarkSettings();
+ // Generate ETag based on photo id, modification time, and watermark settings
+ // This ensures cache invalidation when watermark settings change
+ const fs = require('fs');
+ const stat = fs.statSync(filePath);
+ const watermarkHash = watermarkSettings?.enabled
+ ? `-wm${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}`
+ : '-nowm';
+ const etag = `"${photoId}-${stat.mtime.getTime()}${watermarkHash}"`;
+
+ // Check if client has valid cached version
+ if (req.headers['if-none-match'] === etag) {
+ return res.status(304).end();
+ }
+
if (watermarkSettings && watermarkSettings.enabled) {
// Apply watermark and send
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
@@ -761,6 +783,7 @@ router.get('/:slug/photo/:photoId',
res.set({
'Content-Type': photo.mime_type || 'image/jpeg',
'Cache-Control': 'private, max-age=1800', // Cache for 30 minutes
+ 'ETag': etag,
'X-Protection-Level': 'basic'
});
@@ -769,6 +792,7 @@ router.get('/:slug/photo/:photoId',
// Send original file with basic protection headers
res.set({
'Cache-Control': 'private, max-age=1800',
+ 'ETag': etag,
'X-Protection-Level': 'basic'
});
// Ensure absolute path for res.sendFile
@@ -820,18 +844,32 @@ router.get('/:slug/thumbnail/:photoId',
'thumbnail'
);
+ // Check if watermarks are enabled and apply to thumbnail
+ const watermarkSettings = await watermarkService.getWatermarkSettings();
+
+ // Generate ETag based on photo id, thumbnail modification time, and watermark settings
+ const fs = require('fs');
+ const stat = fs.statSync(thumbPath);
+ const watermarkHash = watermarkSettings?.enabled
+ ? `-wm${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}`
+ : '-nowm';
+ const etag = `"thumb-${photoId}-${stat.mtime.getTime()}${watermarkHash}"`;
+
+ // Check if client has valid cached version
+ if (req.headers['if-none-match'] === etag) {
+ return res.status(304).end();
+ }
+
// Set appropriate headers with enhanced security
res.set({
'Content-Type': 'image/jpeg',
'Cache-Control': 'private, max-age=1800', // Reduced cache time
'Cross-Origin-Resource-Policy': 'cross-origin',
'X-Content-Type-Options': 'nosniff',
- 'X-Protected-Thumbnail': 'true'
+ 'X-Protected-Thumbnail': 'true',
+ 'ETag': etag
});
- // Check if watermarks are enabled and apply to thumbnail
- const watermarkSettings = await watermarkService.getWatermarkSettings();
-
if (watermarkSettings && watermarkSettings.enabled) {
// Apply watermark to thumbnail
const watermarkedBuffer = await watermarkService.applyWatermark(thumbPath, watermarkSettings);
diff --git a/frontend/src/components/common/AuthenticatedImage.tsx b/frontend/src/components/common/AuthenticatedImage.tsx
index 66d52748..71ad2c2f 100644
--- a/frontend/src/components/common/AuthenticatedImage.tsx
+++ b/frontend/src/components/common/AuthenticatedImage.tsx
@@ -7,7 +7,7 @@ import {
resolveSlugFromRequestUrl,
} from '../../utils/galleryAuthStorage';
-interface AuthenticatedImageProps extends React.ImgHTMLAttributes {
+interface AuthenticatedImageProps extends Omit, 'onLoad'> {
src: string;
fallbackSrc?: string;
useWatermark?: boolean;
@@ -29,6 +29,7 @@ interface AuthenticatedImageProps extends React.ImgHTMLAttributes void;
}
export const AuthenticatedImage: React.FC = ({
@@ -54,6 +55,7 @@ export const AuthenticatedImage: React.FC = ({
detectDevTools,
protectionLevel,
useEnhancedProtection,
+ onLoad,
...props
}) => {
const unusedProps = {
@@ -221,6 +223,7 @@ export const AuthenticatedImage: React.FC = ({
img.onload = () => {
imageRef.current = img;
drawToCanvas();
+ onLoad?.();
};
img.onerror = (e) => {
@@ -235,7 +238,7 @@ export const AuthenticatedImage: React.FC = ({
img.onload = null;
img.onerror = null;
};
- }, [imageSrc, useCanvasRendering, drawToCanvas]);
+ }, [imageSrc, useCanvasRendering, drawToCanvas, onLoad]);
if (isLoading) {
return (
@@ -282,5 +285,5 @@ export const AuthenticatedImage: React.FC = ({
);
}
- return
;
+ return
;
};
diff --git a/frontend/src/components/gallery/PhotoLightbox.tsx b/frontend/src/components/gallery/PhotoLightbox.tsx
index c6ceb8f6..b9c70d8f 100644
--- a/frontend/src/components/gallery/PhotoLightbox.tsx
+++ b/frontend/src/components/gallery/PhotoLightbox.tsx
@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react';
import { useDevToolsProtection } from '../../hooks/useDevToolsProtection';
-import { X, ChevronLeft, ChevronRight, Download, ZoomIn, ZoomOut, MessageSquare, Heart, Star } from 'lucide-react';
+import { X, ChevronLeft, ChevronRight, Download, ZoomIn, ZoomOut, MessageSquare, Heart, Star, Loader2 } from 'lucide-react';
import type { Photo } from '../../types';
import { useDownloadPhoto } from '../../hooks/useGallery';
import { AuthenticatedImage } from '../common';
@@ -62,12 +62,18 @@ export const PhotoLightbox: React.FC = ({
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
const [showIdentityModal, setShowIdentityModal] = useState(false);
const [pendingAction, setPendingAction] = useState(null);
+ const [imageLoaded, setImageLoaded] = useState(false);
useEffect(() => {
const onResize = () => setIsSmallScreen(window.innerWidth < 640);
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
+
+ // Reset image loaded state when changing photos
+ useEffect(() => {
+ setImageLoaded(false);
+ }, [currentIndex]);
const downloadPhotoMutation = useDownloadPhoto();
const currentPhoto = photos[currentIndex];
@@ -488,6 +494,13 @@ export const PhotoLightbox: React.FC = ({
right: isDesktopFeedback ? `${desktopFeedbackWidth}px` : 0,
}}
>
+ {/* Loading spinner */}
+ {!imageLoaded && currentPhoto.media_type !== 'video' && (
+
+
+
+ )}
+
{currentPhoto.media_type === 'video' ? (
= ({
style={{
transform: `scale(${zoom}) translate(${dragOffset.x / zoom}px, ${dragOffset.y / zoom}px)`,
transition: isDragging ? 'none' : 'transform 0.2s',
+ opacity: imageLoaded ? 1 : 0,
}}
draggable={false}
+ onLoad={() => setImageLoaded(true)}
useWatermark={useEnhancedProtection}
watermarkText={useEnhancedProtection ? `${currentPhoto.filename} - Protected` : undefined}
isGallery={true}
@@ -524,7 +539,7 @@ export const PhotoLightbox: React.FC = ({
detectDevTools={protectionLevel === 'enhanced' || protectionLevel === 'maximum'}
onProtectionViolation={(violationType) => {
console.warn(`Protection violation in lightbox for photo ${currentPhoto.id}: ${violationType}`);
-
+
// Track analytics
if (typeof window !== 'undefined' && (window as any).umami) {
(window as any).umami.track('lightbox_protection_violation', {
@@ -534,7 +549,7 @@ export const PhotoLightbox: React.FC = ({
zoom
});
}
-
+
// For maximum protection, close lightbox on violation
if (protectionLevel === 'maximum' &&
['devtools_detected', 'print_screen_detected', 'canvas_access_blocked'].includes(violationType)) {
From d29aab7c70c5777451666fb7d5c7a9729dab684a Mon Sep 17 00:00:00 2001
From: Paul Nothaft
Date: Thu, 15 Jan 2026 16:43:21 +0100
Subject: [PATCH 5/7] feat: dynamic website title from branding settings
Update document title based on company name and tagline settings:
- Both filled: "{Company Name} - {Tagline}"
- Name only: "{Company Name}"
- Neither: "PicPeak - Photo Sharing Platform" (default)
---
.../src/components/common/DynamicFavicon.tsx | 23 ++++++++++++++++---
1 file changed, 20 insertions(+), 3 deletions(-)
diff --git a/frontend/src/components/common/DynamicFavicon.tsx b/frontend/src/components/common/DynamicFavicon.tsx
index 40d90682..eff7e9ed 100644
--- a/frontend/src/components/common/DynamicFavicon.tsx
+++ b/frontend/src/components/common/DynamicFavicon.tsx
@@ -2,6 +2,8 @@ import { useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { getApiBaseUrl, buildResourceUrl } from '../../utils/url';
+const DEFAULT_TITLE = 'PicPeak - Photo Sharing Platform';
+
export const DynamicFavicon: React.FC = () => {
const { data: settings } = useQuery({
queryKey: ['public-settings'],
@@ -19,6 +21,7 @@ export const DynamicFavicon: React.FC = () => {
staleTime: 5 * 60 * 1000, // 5 minutes
});
+ // Update favicon when branding settings change
useEffect(() => {
if (settings?.branding_favicon_url) {
// Remove existing favicon links
@@ -29,13 +32,27 @@ export const DynamicFavicon: React.FC = () => {
const link = document.createElement('link');
link.rel = 'icon';
link.type = 'image/png';
- link.href = settings.branding_favicon_url.startsWith('http')
- ? settings.branding_favicon_url
+ link.href = settings.branding_favicon_url.startsWith('http')
+ ? settings.branding_favicon_url
: buildResourceUrl(settings.branding_favicon_url);
-
+
document.head.appendChild(link);
}
}, [settings?.branding_favicon_url]);
+ // Update document title when company name or tagline changes
+ useEffect(() => {
+ const companyName = settings?.branding_company_name?.trim();
+ const tagline = settings?.branding_company_tagline?.trim();
+
+ if (companyName && tagline) {
+ document.title = `${companyName} - ${tagline}`;
+ } else if (companyName) {
+ document.title = companyName;
+ } else {
+ document.title = DEFAULT_TITLE;
+ }
+ }, [settings?.branding_company_name, settings?.branding_company_tagline]);
+
return null;
};
\ No newline at end of file
From 1be974afbb0b7a1bdbdd140327771907a5d3c2ae Mon Sep 17 00:00:00 2001
From: Paul Nothaft
Date: Thu, 15 Jan 2026 21:00:10 +0100
Subject: [PATCH 6/7] feat: pre-generate watermarks for instant lightbox
loading
Previously watermarks were applied on-the-fly when viewing photos in the
lightbox, causing 1+ minute load times for high-resolution images.
This change pre-generates watermarked versions during upload and when
watermark settings change, enabling instant image loading (~50-100ms).
- Add database migration for watermark_path tracking (061)
- Add watermarkGeneratorService for batch operations
- Extend watermarkService with save-to-disk capability
- Modify gallery endpoint to serve pre-generated files
- Add background regeneration when branding settings change
- Add npm script for migrating existing photos
Closes #112
---
backend/migrations/README.md | 3 +-
.../migrations/core/061_add_watermark_path.js | 28 ++
backend/package.json | 1 +
backend/scripts/generate-watermarks.js | 161 +++++++
backend/src/routes/adminPhotos.js | 25 +-
backend/src/routes/adminSettings.js | 62 ++-
backend/src/routes/gallery.js | 29 +-
.../src/services/watermarkGeneratorService.js | 433 ++++++++++++++++++
backend/src/services/watermarkService.js | 120 +++++
9 files changed, 854 insertions(+), 8 deletions(-)
create mode 100644 backend/migrations/core/061_add_watermark_path.js
create mode 100644 backend/scripts/generate-watermarks.js
create mode 100644 backend/src/services/watermarkGeneratorService.js
diff --git a/backend/migrations/README.md b/backend/migrations/README.md
index d6bae21d..3adfceb6 100644
--- a/backend/migrations/README.md
+++ b/backend/migrations/README.md
@@ -1,6 +1,6 @@
# Database Migrations
-This directory contains database migrations for the Wedding Photo Sharing platform.
+This directory contains database migrations for the PicPeak photo sharing platform.
## Directory Structure
@@ -9,6 +9,7 @@ Essential migrations that are always run for new deployments. These include:
- `init.js` - Initial database schema creation
- Backup service tables (029-035)
- Gallery feedback tables (033)
+- Pre-generated watermarks (061)
### `/legacy`
Migrations needed only when upgrading from older versions. New deployments can skip these as the core schema already includes all necessary tables and columns.
diff --git a/backend/migrations/core/061_add_watermark_path.js b/backend/migrations/core/061_add_watermark_path.js
new file mode 100644
index 00000000..454a2088
--- /dev/null
+++ b/backend/migrations/core/061_add_watermark_path.js
@@ -0,0 +1,28 @@
+/**
+ * Migration 061: Add pre-generated watermark path to photos table
+ * - photos.watermark_path: path to pre-generated watermarked image
+ * - photos.watermark_generated_at: timestamp of watermark generation
+ */
+
+const { addColumnIfNotExists } = require('../helpers');
+
+exports.up = async function(knex) {
+ console.log('Running migration: 061_add_watermark_path');
+
+ // photos.watermark_path (nullable - path to pre-generated watermarked image)
+ await addColumnIfNotExists(knex, 'photos', 'watermark_path', (table) => {
+ table.string('watermark_path', 512);
+ });
+
+ // photos.watermark_generated_at (nullable - when watermark was last generated)
+ await addColumnIfNotExists(knex, 'photos', 'watermark_generated_at', (table) => {
+ table.timestamp('watermark_generated_at');
+ });
+
+ console.log('Migration 061_add_watermark_path completed');
+};
+
+exports.down = async function(knex) {
+ console.log('Rollback: 061_add_watermark_path');
+ // Keep columns (safe rollback not removing data). Intentionally no-op.
+};
diff --git a/backend/package.json b/backend/package.json
index 6525d924..ef6d0118 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -8,6 +8,7 @@
"dev": "nodemon server.js",
"migrate": "node migrations/run-migrations.js",
"migrate:safe": "node migrations/run-migrations-safe.js",
+ "generate:watermarks": "node scripts/generate-watermarks.js",
"test": "jest",
"lint": "eslint src/"
},
diff --git a/backend/scripts/generate-watermarks.js b/backend/scripts/generate-watermarks.js
new file mode 100644
index 00000000..af2ee138
--- /dev/null
+++ b/backend/scripts/generate-watermarks.js
@@ -0,0 +1,161 @@
+#!/usr/bin/env node
+
+/**
+ * Script to generate pre-watermarked versions for existing photos
+ * This is a one-time migration script to populate watermarks for photos
+ * that existed before the pre-generation feature was implemented.
+ *
+ * Usage: node scripts/generate-watermarks.js [eventId]
+ *
+ * Options:
+ * eventId - Optional: Only generate watermarks for a specific event
+ *
+ * Examples:
+ * node scripts/generate-watermarks.js # Generate for all photos
+ * node scripts/generate-watermarks.js 5 # Generate for event ID 5
+ */
+
+const path = require('path');
+const { db } = require('../src/database/db');
+const watermarkService = require('../src/services/watermarkService');
+const watermarkGeneratorService = require('../src/services/watermarkGeneratorService');
+
+async function generateWatermarks(eventId = null) {
+ try {
+ console.log('='.repeat(60));
+ console.log('PicPeak Watermark Generation Script');
+ console.log('='.repeat(60));
+
+ // Check if watermarking is enabled
+ const settings = await watermarkService.getWatermarkSettings();
+
+ if (!settings || !settings.enabled) {
+ console.log('\nWatermarking is currently DISABLED in settings.');
+ console.log('Enable watermarking in Admin > Branding settings first.');
+ console.log('Exiting without generating watermarks.');
+ process.exit(0);
+ }
+
+ console.log('\nWatermark Settings:');
+ console.log(` Enabled: ${settings.enabled}`);
+ console.log(` Position: ${settings.position}`);
+ console.log(` Opacity: ${settings.opacity}%`);
+ console.log(` Size: ${settings.size}%`);
+ console.log(` Logo: ${settings.logoPath || '(using text fallback)'}`);
+
+ // Build query
+ let query = db('photos')
+ .join('events', 'photos.event_id', 'events.id')
+ .whereNull('photos.watermark_path')
+ .whereNot(function() {
+ this.where('photos.media_type', 'video')
+ .orWhere('photos.mime_type', 'like', 'video/%');
+ })
+ .select(
+ 'photos.id',
+ 'photos.filename',
+ 'photos.event_id',
+ 'events.event_name'
+ );
+
+ if (eventId) {
+ query = query.where('photos.event_id', eventId);
+ console.log(`\nFiltering to event ID: ${eventId}`);
+ }
+
+ const photos = await query;
+
+ if (photos.length === 0) {
+ console.log('\nNo photos found without watermarks.');
+ if (eventId) {
+ console.log(`(Checked event ID: ${eventId})`);
+ }
+ console.log('All photos already have pre-generated watermarks or watermarking is disabled.');
+ process.exit(0);
+ }
+
+ console.log(`\nFound ${photos.length} photos without watermarks.`);
+
+ // Group by event for display
+ const eventCounts = {};
+ photos.forEach(p => {
+ eventCounts[p.event_name] = (eventCounts[p.event_name] || 0) + 1;
+ });
+
+ console.log('\nPhotos by event:');
+ Object.entries(eventCounts).forEach(([name, count]) => {
+ console.log(` ${name}: ${count} photos`);
+ });
+
+ console.log('\nStarting watermark generation...\n');
+
+ let successCount = 0;
+ let failCount = 0;
+ const startTime = Date.now();
+
+ // Process photos with progress display
+ for (let i = 0; i < photos.length; i++) {
+ const photo = photos[i];
+ const progress = Math.round(((i + 1) / photos.length) * 100);
+
+ process.stdout.write(`\r[${progress}%] Processing photo ${i + 1}/${photos.length}: ${photo.filename.substring(0, 30)}...`);
+
+ try {
+ const result = await watermarkGeneratorService.generateForPhoto(photo.id);
+
+ if (result.success) {
+ successCount++;
+ } else {
+ failCount++;
+ console.log(`\n Failed: ${photo.filename} - ${result.error}`);
+ }
+ } catch (error) {
+ failCount++;
+ console.log(`\n Error: ${photo.filename} - ${error.message}`);
+ }
+ }
+
+ const duration = ((Date.now() - startTime) / 1000).toFixed(1);
+
+ console.log('\n');
+ console.log('='.repeat(60));
+ console.log('Watermark Generation Complete');
+ console.log('='.repeat(60));
+ console.log(` Total processed: ${photos.length}`);
+ console.log(` Successful: ${successCount}`);
+ console.log(` Failed: ${failCount}`);
+ console.log(` Duration: ${duration} seconds`);
+ console.log(` Average: ${(photos.length / parseFloat(duration)).toFixed(1)} photos/second`);
+
+ if (failCount > 0) {
+ console.log('\nSome watermarks failed to generate. Check the errors above.');
+ console.log('You can re-run this script to retry failed photos.');
+ }
+
+ process.exit(failCount > 0 ? 1 : 0);
+ } catch (error) {
+ console.error('\nFatal error:', error.message);
+ console.error(error.stack);
+ process.exit(1);
+ }
+}
+
+// Parse command line arguments
+const args = process.argv.slice(2);
+const eventId = args[0] ? parseInt(args[0], 10) : null;
+
+if (args[0] && isNaN(eventId)) {
+ console.error('Error: eventId must be a number');
+ console.log('Usage: node scripts/generate-watermarks.js [eventId]');
+ process.exit(1);
+}
+
+// Run the script
+generateWatermarks(eventId)
+ .then(() => {
+ process.exit(0);
+ })
+ .catch(error => {
+ console.error('Unhandled error:', error);
+ process.exit(1);
+ });
diff --git a/backend/src/routes/adminPhotos.js b/backend/src/routes/adminPhotos.js
index 8f13e84b..ff65a6fc 100644
--- a/backend/src/routes/adminPhotos.js
+++ b/backend/src/routes/adminPhotos.js
@@ -12,6 +12,7 @@ const { validateUploadedFiles } = require('../middleware/uploadValidation');
const { getMaxFilesPerUpload } = require('../services/uploadSettings');
const { processUploadedPhotos } = require('../services/photoProcessor');
const chunkedUpload = require('../services/chunkedUploadService');
+const watermarkGeneratorService = require('../services/watermarkGeneratorService');
const router = express.Router();
// Get storage path from environment or default
@@ -308,7 +309,7 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), u
let thumbnailPath = null;
try {
thumbnailPath = await generateThumbnail(operation.finalPath);
-
+
// Update the database with thumbnail path
if (thumbnailPath && insertedIds[idx]) {
const photoId = insertedIds[idx]?.id || insertedIds[idx];
@@ -319,6 +320,14 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), u
} catch (thumbError) {
console.error(`Thumbnail generation failed for ${operation.filename}:`, thumbError.message);
}
+
+ // Queue watermark generation in background (non-blocking)
+ // This pre-generates watermarked versions for fast serving in lightbox
+ if (insertedIds[idx]) {
+ const photoId = insertedIds[idx]?.id || insertedIds[idx];
+ watermarkGeneratorService.generateForPhoto(photoId)
+ .catch(err => console.warn(`Watermark generation queued failed for photo ${photoId}:`, err.message));
+ }
// Add to successful uploads
uploadedPhotos.push({
@@ -461,7 +470,12 @@ router.delete('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.
}
}
}
-
+
+ // Delete pre-generated watermark if exists
+ if (photo.watermark_path) {
+ await watermarkGeneratorService.deleteForPhoto(photo.id);
+ }
+
// Remove from database
await db('photos').where({ id: photoId }).delete();
@@ -582,8 +596,13 @@ router.post('/:eventId/photos/bulk-delete', adminAuth, requirePermission('photos
}
}
}
+
+ // Delete pre-generated watermark
+ if (photo.watermark_path) {
+ await watermarkGeneratorService.deleteForPhoto(photo.id);
+ }
}
-
+
// Delete from database
await db('photos')
.whereIn('id', photoIds)
diff --git a/backend/src/routes/adminSettings.js b/backend/src/routes/adminSettings.js
index b92c5ec7..64896bbf 100644
--- a/backend/src/routes/adminSettings.js
+++ b/backend/src/routes/adminSettings.js
@@ -23,6 +23,8 @@ const { clearShareLinkSettingsCache } = require('../services/shareLinkService');
const { resetSecurityConfigCache } = require('../utils/authSecurity');
const router = express.Router();
const { clearMaxFilesPerUploadCache, MAX_ALLOWED_FILES_PER_UPLOAD } = require('../services/uploadSettings');
+const watermarkService = require('../services/watermarkService');
+const watermarkGeneratorService = require('../services/watermarkGeneratorService');
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
@@ -196,6 +198,9 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
hide_powered_by
} = req.body;
+ // Get current watermark settings hash for change detection
+ const oldSettingsHash = await watermarkService.getSettingsHash();
+
const brandingSettings = {
company_name,
company_tagline,
@@ -306,7 +311,40 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
clearPublicSiteCache();
- res.json({ message: 'Branding settings updated successfully' });
+ // Check if watermark settings changed and trigger regeneration
+ const newSettingsHash = await watermarkService.getSettingsHash();
+ let watermarkRegenerationStarted = false;
+
+ if (oldSettingsHash !== newSettingsHash) {
+ // Clear watermark cache
+ watermarkService.clearCache();
+
+ // Check if watermarking is now enabled or settings changed
+ const currentSettings = await watermarkService.getWatermarkSettings();
+
+ if (currentSettings && currentSettings.enabled) {
+ // Start background regeneration of all watermarks
+ console.log('Watermark settings changed, starting background regeneration');
+ watermarkGeneratorService.regenerateAll()
+ .then(result => {
+ console.log(`Watermark regeneration completed: ${result.success}/${result.total} successful`);
+ })
+ .catch(err => {
+ console.error('Watermark regeneration failed:', err);
+ });
+ watermarkRegenerationStarted = true;
+ } else {
+ // Watermarking was disabled, clear all pre-generated watermarks
+ console.log('Watermarking disabled, clearing pre-generated watermarks');
+ watermarkGeneratorService.clearAllWatermarks()
+ .catch(err => console.error('Failed to clear watermarks:', err));
+ }
+ }
+
+ res.json({
+ message: 'Branding settings updated successfully',
+ watermarkRegenerationStarted
+ });
} catch (error) {
console.error('Branding update error:', error);
res.status(500).json({ error: 'Failed to update branding settings' });
@@ -441,9 +479,27 @@ router.post('/branding/watermark-logo', adminAuth, requirePermission('settings.e
updated_at: new Date()
});
- res.json({
+ // Trigger watermark regeneration since the logo changed
+ watermarkService.clearCache();
+ const currentSettings = await watermarkService.getWatermarkSettings();
+ let watermarkRegenerationStarted = false;
+
+ if (currentSettings && currentSettings.enabled) {
+ console.log('Watermark logo changed, starting background regeneration');
+ watermarkGeneratorService.regenerateAll()
+ .then(result => {
+ console.log(`Watermark regeneration completed: ${result.success}/${result.total} successful`);
+ })
+ .catch(err => {
+ console.error('Watermark regeneration failed:', err);
+ });
+ watermarkRegenerationStarted = true;
+ }
+
+ res.json({
message: 'Watermark logo uploaded successfully',
- watermarkLogoUrl: publicPath
+ watermarkLogoUrl: publicPath,
+ watermarkRegenerationStarted
});
} catch (error) {
console.error('Watermark logo upload error:', error);
diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js
index 2b5a6db6..e75572ad 100644
--- a/backend/src/routes/gallery.js
+++ b/backend/src/routes/gallery.js
@@ -5,6 +5,7 @@ const archiver = require('archiver');
const path = require('path');
const router = express.Router();
const watermarkService = require('../services/watermarkService');
+const watermarkGeneratorService = require('../services/watermarkGeneratorService');
const { verifyGalleryAccess } = require('../middleware/gallery');
const secureImageService = require('../services/secureImageService');
const logger = require('../utils/logger');
@@ -777,9 +778,35 @@ router.get('/:slug/photo/:photoId',
}
if (watermarkSettings && watermarkSettings.enabled) {
- // Apply watermark and send
+ // Try to serve pre-generated watermarked file for instant loading
+ if (photo.watermark_path) {
+ const watermarkFilePath = path.join(getStoragePath(), photo.watermark_path);
+ try {
+ const fs = require('fs');
+ // Check if pre-generated watermark file exists
+ if (fs.existsSync(watermarkFilePath)) {
+ res.set({
+ 'Content-Type': photo.mime_type || 'image/jpeg',
+ 'Cache-Control': 'private, max-age=1800',
+ 'ETag': etag,
+ 'X-Protection-Level': 'basic'
+ });
+ return res.sendFile(watermarkFilePath);
+ }
+ } catch (err) {
+ // File doesn't exist or error, fall through to on-the-fly generation
+ logger.warn(`Pre-generated watermark not found for photo ${photoId}, falling back to on-the-fly`);
+ }
+ }
+
+ // Fallback: Apply watermark on-the-fly (slower, but ensures image is served)
+ // Also queue regeneration for next time
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
+ // Queue watermark generation in background for next request
+ watermarkGeneratorService.generateForPhoto(photo.id)
+ .catch(err => logger.warn(`Background watermark generation failed for photo ${photo.id}:`, err.message));
+
res.set({
'Content-Type': photo.mime_type || 'image/jpeg',
'Cache-Control': 'private, max-age=1800', // Cache for 30 minutes
diff --git a/backend/src/services/watermarkGeneratorService.js b/backend/src/services/watermarkGeneratorService.js
new file mode 100644
index 00000000..1dde9ec1
--- /dev/null
+++ b/backend/src/services/watermarkGeneratorService.js
@@ -0,0 +1,433 @@
+/**
+ * WatermarkGeneratorService
+ *
+ * Handles batch generation of pre-watermarked images for fast serving.
+ * This service is responsible for:
+ * - Generating watermarks for newly uploaded photos
+ * - Regenerating all watermarks when settings change
+ * - Tracking regeneration progress
+ */
+
+const path = require('path');
+const { db } = require('../database/db');
+const watermarkService = require('./watermarkService');
+const { getStoragePath } = require('../config/storage');
+
+class WatermarkGeneratorService {
+ constructor() {
+ // Track active regeneration jobs
+ this.activeJobs = new Map();
+ // Batch size for processing (to manage memory)
+ this.batchSize = 10;
+ // Concurrent processing limit
+ this.concurrentLimit = 2;
+ }
+
+ /**
+ * Generate watermark for a single photo
+ * @param {number} photoId - The photo ID
+ * @returns {Object} Result with success status and watermark path
+ */
+ async generateForPhoto(photoId) {
+ try {
+ // Get photo with event info
+ const photo = await db('photos')
+ .join('events', 'photos.event_id', 'events.id')
+ .where('photos.id', photoId)
+ .select(
+ 'photos.*',
+ 'events.slug',
+ 'events.source_mode',
+ 'events.external_path'
+ )
+ .first();
+
+ if (!photo) {
+ return { success: false, error: 'Photo not found' };
+ }
+
+ // Skip video files
+ if (photo.media_type === 'video' || (photo.mime_type && photo.mime_type.startsWith('video/'))) {
+ return { success: false, error: 'Videos do not support watermarks' };
+ }
+
+ // Get watermark settings
+ const settings = await watermarkService.getWatermarkSettings();
+ if (!settings || !settings.enabled) {
+ return { success: false, error: 'Watermarking is disabled' };
+ }
+
+ // Resolve the original file path
+ const originalPath = this.resolvePhotoPath(photo);
+ if (!originalPath) {
+ return { success: false, error: 'Could not resolve photo path' };
+ }
+
+ // Generate and save watermark
+ const result = await watermarkService.generateAndSaveWatermark(photo, originalPath, settings);
+
+ if (result.success) {
+ // Update database with watermark path
+ await db('photos')
+ .where({ id: photoId })
+ .update({
+ watermark_path: result.watermarkPath,
+ watermark_generated_at: db.fn.now()
+ });
+ }
+
+ return result;
+ } catch (error) {
+ console.error(`Error generating watermark for photo ${photoId}:`, error);
+ return { success: false, error: error.message };
+ }
+ }
+
+ /**
+ * Resolve the full file path for a photo
+ */
+ resolvePhotoPath(photo) {
+ const storagePath = getStoragePath();
+
+ // Handle external/reference mode
+ if (photo.source_mode === 'reference' && photo.external_relpath) {
+ const externalRoot = process.env.EXTERNAL_MEDIA_PATH || path.join(storagePath, 'external');
+ return path.join(externalRoot, photo.external_path || '', photo.external_relpath);
+ }
+
+ // Standard managed mode
+ if (photo.file_path) {
+ // file_path might be absolute or relative
+ if (path.isAbsolute(photo.file_path)) {
+ return photo.file_path;
+ }
+ return path.join(storagePath, photo.file_path);
+ }
+
+ // Fallback to constructing path from slug and filename
+ return path.join(storagePath, 'events', 'active', photo.slug, photo.filename);
+ }
+
+ /**
+ * Generate watermarks for all photos in an event
+ * @param {number} eventId - The event ID
+ * @param {Function} onProgress - Optional callback for progress updates
+ * @returns {Object} Result with success count and errors
+ */
+ async generateForEvent(eventId, onProgress = null) {
+ const results = { total: 0, success: 0, failed: 0, errors: [] };
+
+ try {
+ // Get all photos for the event (excluding videos)
+ const photos = await db('photos')
+ .join('events', 'photos.event_id', 'events.id')
+ .where('photos.event_id', eventId)
+ .whereNot(function() {
+ this.where('photos.media_type', 'video')
+ .orWhere('photos.mime_type', 'like', 'video/%');
+ })
+ .select(
+ 'photos.*',
+ 'events.slug',
+ 'events.source_mode',
+ 'events.external_path'
+ );
+
+ results.total = photos.length;
+
+ if (photos.length === 0) {
+ return results;
+ }
+
+ // Get watermark settings once
+ const settings = await watermarkService.getWatermarkSettings();
+ if (!settings || !settings.enabled) {
+ return { ...results, errors: ['Watermarking is disabled'] };
+ }
+
+ // Process in batches
+ for (let i = 0; i < photos.length; i += this.batchSize) {
+ const batch = photos.slice(i, i + this.batchSize);
+
+ // Process batch with limited concurrency
+ const batchResults = await Promise.all(
+ batch.map(photo => this.processPhotoWatermark(photo, settings))
+ );
+
+ // Collect results
+ for (const result of batchResults) {
+ if (result.success) {
+ results.success++;
+ } else {
+ results.failed++;
+ if (result.error) {
+ results.errors.push(`Photo ${result.photoId}: ${result.error}`);
+ }
+ }
+ }
+
+ // Progress callback
+ if (onProgress) {
+ onProgress({
+ total: results.total,
+ processed: results.success + results.failed,
+ success: results.success,
+ failed: results.failed
+ });
+ }
+ }
+
+ return results;
+ } catch (error) {
+ console.error(`Error generating watermarks for event ${eventId}:`, error);
+ return { ...results, errors: [...results.errors, error.message] };
+ }
+ }
+
+ /**
+ * Process watermark for a single photo (internal helper)
+ */
+ async processPhotoWatermark(photo, settings) {
+ try {
+ const originalPath = this.resolvePhotoPath(photo);
+ if (!originalPath) {
+ return { success: false, photoId: photo.id, error: 'Could not resolve path' };
+ }
+
+ const result = await watermarkService.generateAndSaveWatermark(photo, originalPath, settings);
+
+ if (result.success) {
+ await db('photos')
+ .where({ id: photo.id })
+ .update({
+ watermark_path: result.watermarkPath,
+ watermark_generated_at: db.fn.now()
+ });
+ }
+
+ return { ...result, photoId: photo.id };
+ } catch (error) {
+ return { success: false, photoId: photo.id, error: error.message };
+ }
+ }
+
+ /**
+ * Regenerate watermarks for all photos in the system
+ * @param {Function} onProgress - Optional callback for progress updates
+ * @returns {Object} Result with success count and errors
+ */
+ async regenerateAll(onProgress = null) {
+ const jobId = Date.now().toString();
+ const results = { jobId, total: 0, success: 0, failed: 0, errors: [], status: 'running' };
+
+ try {
+ this.activeJobs.set(jobId, results);
+
+ // Get watermark settings
+ const settings = await watermarkService.getWatermarkSettings();
+ if (!settings || !settings.enabled) {
+ results.status = 'completed';
+ results.errors.push('Watermarking is disabled');
+ return results;
+ }
+
+ // First, clear existing watermarks from DB (the files will be overwritten)
+ // This ensures stale paths don't persist if regeneration fails
+
+ // Get all image photos (exclude videos)
+ const photos = await db('photos')
+ .join('events', 'photos.event_id', 'events.id')
+ .whereNot(function() {
+ this.where('photos.media_type', 'video')
+ .orWhere('photos.mime_type', 'like', 'video/%');
+ })
+ .select(
+ 'photos.*',
+ 'events.slug',
+ 'events.source_mode',
+ 'events.external_path'
+ );
+
+ results.total = photos.length;
+
+ if (photos.length === 0) {
+ results.status = 'completed';
+ return results;
+ }
+
+ console.log(`Starting watermark regeneration for ${photos.length} photos`);
+
+ // Process in batches
+ for (let i = 0; i < photos.length; i += this.batchSize) {
+ // Check if job was cancelled
+ if (!this.activeJobs.has(jobId)) {
+ results.status = 'cancelled';
+ return results;
+ }
+
+ const batch = photos.slice(i, i + this.batchSize);
+
+ // Process batch with limited concurrency
+ const batchResults = await Promise.all(
+ batch.map(photo => this.processPhotoWatermark(photo, settings))
+ );
+
+ // Collect results
+ for (const result of batchResults) {
+ if (result.success) {
+ results.success++;
+ } else {
+ results.failed++;
+ if (result.error && results.errors.length < 50) {
+ results.errors.push(`Photo ${result.photoId}: ${result.error}`);
+ }
+ }
+ }
+
+ // Update job status
+ this.activeJobs.set(jobId, { ...results });
+
+ // Progress callback
+ if (onProgress) {
+ onProgress({
+ jobId,
+ total: results.total,
+ processed: results.success + results.failed,
+ success: results.success,
+ failed: results.failed,
+ percentComplete: Math.round(((results.success + results.failed) / results.total) * 100)
+ });
+ }
+
+ // Small delay between batches to prevent CPU saturation
+ await new Promise(resolve => setTimeout(resolve, 100));
+ }
+
+ results.status = 'completed';
+ console.log(`Watermark regeneration completed: ${results.success}/${results.total} successful`);
+
+ return results;
+ } catch (error) {
+ console.error('Error during watermark regeneration:', error);
+ results.status = 'failed';
+ results.errors.push(error.message);
+ return results;
+ } finally {
+ // Clean up job tracking after a delay
+ setTimeout(() => {
+ this.activeJobs.delete(jobId);
+ }, 60000); // Keep for 1 minute for status queries
+ }
+ }
+
+ /**
+ * Clear all watermarks (when watermarking is disabled)
+ */
+ async clearAllWatermarks() {
+ try {
+ // Get all photos with watermarks
+ const photos = await db('photos')
+ .whereNotNull('watermark_path')
+ .select('id', 'watermark_path');
+
+ // Delete watermark files
+ for (const photo of photos) {
+ await watermarkService.deleteWatermarkFile(photo.watermark_path);
+ }
+
+ // Clear database paths
+ await db('photos')
+ .whereNotNull('watermark_path')
+ .update({
+ watermark_path: null,
+ watermark_generated_at: null
+ });
+
+ console.log(`Cleared ${photos.length} watermarks`);
+ return { success: true, cleared: photos.length };
+ } catch (error) {
+ console.error('Error clearing watermarks:', error);
+ return { success: false, error: error.message };
+ }
+ }
+
+ /**
+ * Delete watermark for a specific photo
+ */
+ async deleteForPhoto(photoId) {
+ try {
+ const photo = await db('photos')
+ .where({ id: photoId })
+ .select('watermark_path')
+ .first();
+
+ if (photo && photo.watermark_path) {
+ await watermarkService.deleteWatermarkFile(photo.watermark_path);
+ await db('photos')
+ .where({ id: photoId })
+ .update({
+ watermark_path: null,
+ watermark_generated_at: null
+ });
+ }
+
+ return { success: true };
+ } catch (error) {
+ console.error(`Error deleting watermark for photo ${photoId}:`, error);
+ return { success: false, error: error.message };
+ }
+ }
+
+ /**
+ * Get status of an active regeneration job
+ */
+ getJobStatus(jobId) {
+ return this.activeJobs.get(jobId) || null;
+ }
+
+ /**
+ * Cancel an active regeneration job
+ */
+ cancelJob(jobId) {
+ if (this.activeJobs.has(jobId)) {
+ this.activeJobs.delete(jobId);
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Check if there's an active regeneration job
+ */
+ hasActiveJob() {
+ for (const [, job] of this.activeJobs) {
+ if (job.status === 'running') {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Get count of photos needing watermark generation
+ */
+ async getPendingCount() {
+ const settings = await watermarkService.getWatermarkSettings();
+ if (!settings || !settings.enabled) {
+ return 0;
+ }
+
+ const result = await db('photos')
+ .whereNull('watermark_path')
+ .whereNot(function() {
+ this.where('media_type', 'video')
+ .orWhere('mime_type', 'like', 'video/%');
+ })
+ .count('id as count')
+ .first();
+
+ return parseInt(result.count) || 0;
+ }
+}
+
+module.exports = new WatermarkGeneratorService();
diff --git a/backend/src/services/watermarkService.js b/backend/src/services/watermarkService.js
index 2ef44a6c..37d08172 100644
--- a/backend/src/services/watermarkService.js
+++ b/backend/src/services/watermarkService.js
@@ -2,6 +2,7 @@ const sharp = require('sharp');
const path = require('path');
const fs = require('fs').promises;
const { db } = require('../database/db');
+const { getStoragePath } = require('../config/storage');
class WatermarkService {
constructor() {
@@ -232,6 +233,125 @@ class WatermarkService {
clearCache() {
this.cache.clear();
}
+
+ /**
+ * Get the watermarks directory path, creating it if needed
+ */
+ async getWatermarksDir() {
+ const watermarksDir = path.join(getStoragePath(), 'watermarks');
+ try {
+ await fs.access(watermarksDir);
+ } catch {
+ await fs.mkdir(watermarksDir, { recursive: true });
+ }
+ return watermarksDir;
+ }
+
+ /**
+ * Get the file extension from a filename
+ */
+ getFileExtension(filename) {
+ const ext = path.extname(filename).toLowerCase();
+ // Map common extensions
+ if (ext === '.jpeg') return '.jpg';
+ return ext || '.jpg';
+ }
+
+ /**
+ * Generate watermarked version of a photo and save to disk
+ * @param {Object} photo - Photo object with id, filename, and path info
+ * @param {string} originalPath - Full path to the original image file
+ * @param {Object} settings - Watermark settings (optional, will fetch if not provided)
+ * @returns {Object} { success, watermarkPath, error }
+ */
+ async generateAndSaveWatermark(photo, originalPath, settings = null) {
+ try {
+ // Get settings if not provided
+ if (!settings) {
+ settings = await this.getWatermarkSettings();
+ }
+
+ // If watermarking is disabled, return early
+ if (!settings || !settings.enabled) {
+ return { success: false, watermarkPath: null, error: 'Watermarking is disabled' };
+ }
+
+ // Verify original file exists
+ try {
+ await fs.access(originalPath);
+ } catch {
+ return { success: false, watermarkPath: null, error: 'Original file not found' };
+ }
+
+ // Generate watermarked buffer using existing method
+ const watermarkedBuffer = await this.applyWatermark(originalPath, settings);
+
+ // Determine output path
+ const watermarksDir = await this.getWatermarksDir();
+ const ext = this.getFileExtension(photo.filename);
+ const outputFilename = `${photo.id}_watermarked${ext}`;
+ const outputPath = path.join(watermarksDir, outputFilename);
+
+ // Write the watermarked image to disk
+ await fs.writeFile(outputPath, watermarkedBuffer);
+
+ // Return relative path for database storage
+ const relativePath = `watermarks/${outputFilename}`;
+
+ return {
+ success: true,
+ watermarkPath: relativePath,
+ error: null
+ };
+ } catch (error) {
+ console.error(`Error generating watermark for photo ${photo.id}:`, error);
+ return {
+ success: false,
+ watermarkPath: null,
+ error: error.message
+ };
+ }
+ }
+
+ /**
+ * Delete a pre-generated watermark file
+ * @param {string} watermarkPath - Relative path to the watermark file
+ * @returns {boolean} - True if deleted successfully
+ */
+ async deleteWatermarkFile(watermarkPath) {
+ if (!watermarkPath) return false;
+
+ try {
+ const fullPath = path.join(getStoragePath(), watermarkPath);
+ await fs.unlink(fullPath);
+ return true;
+ } catch (error) {
+ // File might not exist, which is fine
+ if (error.code !== 'ENOENT') {
+ console.error('Error deleting watermark file:', error);
+ }
+ return false;
+ }
+ }
+
+ /**
+ * Create a hash of current watermark settings for change detection
+ * @returns {string} - Hash string of settings
+ */
+ async getSettingsHash() {
+ const settings = await this.getWatermarkSettings();
+ if (!settings) return '';
+
+ const hashData = `${settings.enabled}-${settings.logoPath || ''}-${settings.position}-${settings.opacity}-${settings.size}`;
+ // Simple hash for change detection (not cryptographic)
+ let hash = 0;
+ for (let i = 0; i < hashData.length; i++) {
+ const char = hashData.charCodeAt(i);
+ hash = ((hash << 5) - hash) + char;
+ hash = hash & hash; // Convert to 32bit integer
+ }
+ return hash.toString(16);
+ }
}
module.exports = new WatermarkService();
\ No newline at end of file
From ae181cf92fc9c1e85cad7a7b843a4d83cec636ac Mon Sep 17 00:00:00 2001
From: Paul Nothaft
Date: Thu, 15 Jan 2026 21:00:17 +0100
Subject: [PATCH 7/7] fix: show upload button in mobile topbar instead of
sidebar
The upload button was hidden in the sidebar on mobile devices, requiring
users to open the menu to find it. Now it appears directly in the topbar
for easy access on all screen sizes.
- Remove !isMobile condition from header upload button
- Add responsive text (short on mobile, full on desktop)
- Remove duplicate upload button from sidebar
Fixes #113
---
.../src/components/gallery/GallerySidebar.tsx | 20 +------------------
.../src/components/gallery/GalleryView.tsx | 7 ++++---
2 files changed, 5 insertions(+), 22 deletions(-)
diff --git a/frontend/src/components/gallery/GallerySidebar.tsx b/frontend/src/components/gallery/GallerySidebar.tsx
index a0575d27..8f9febdc 100644
--- a/frontend/src/components/gallery/GallerySidebar.tsx
+++ b/frontend/src/components/gallery/GallerySidebar.tsx
@@ -1,5 +1,5 @@
import React, { useEffect, useRef } from 'react';
-import { X, Download, Filter, SortAsc, Search, Calendar, Type, HardDrive, Check, Upload, Star } from 'lucide-react';
+import { X, Download, Filter, SortAsc, Search, Calendar, Type, HardDrive, Check, Star } from 'lucide-react';
import { Button } from '../common';
import { PhotoCategory } from '../../types';
import { useTranslation } from 'react-i18next';
@@ -139,24 +139,6 @@ export const GallerySidebar: React.FC = ({
{/* Content */}
- {/* Upload Section - Only show on mobile when uploads are allowed */}
- {isMobile && allowUploads && onUploadClick && (
-
- }
- onClick={() => {
- onUploadClick();
- onClose();
- }}
- className="w-full"
- >
- {t('upload.uploadPhotos')}
-
-
- )}
-
{/* Search Section - Hidden for carousel layout */}
{galleryLayout !== 'carousel' && (
diff --git a/frontend/src/components/gallery/GalleryView.tsx b/frontend/src/components/gallery/GalleryView.tsx
index 76e1f53b..aadbbf60 100644
--- a/frontend/src/components/gallery/GalleryView.tsx
+++ b/frontend/src/components/gallery/GalleryView.tsx
@@ -608,9 +608,9 @@ export const GalleryView: React.FC = ({ slug, event }) => {
);
}
- // Upload button only on desktop when sidebar is shown
+ // Upload button for sidebar layouts (shown on both mobile and desktop)
const allowUploads = data?.event?.allow_user_uploads || event?.allow_user_uploads;
- if (allowUploads && showSidebar && !isMobile) {
+ if (allowUploads && showSidebar) {
items.push(
);
}