Compare commits

..

6 Commits

Author SHA1 Message Date
Paul Nothaft fe772b52d6 Merge pull request #106 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Failing after 4m6s
Build and Push Docker Images / build-frontend (push) Failing after 3m56s
Build and Push Docker Images / summary (push) Successful in 3s
chore(main): release 2.3.2
2026-01-15 08:03:07 -05:00
github-actions[bot] f29b77998b chore(main): release 2.3.2 2026-01-15 13:02:43 +00:00
Paul Nothaft f843e4c25c Merge pull request #105 from the-luap/fix/codeql-v4-upgrade
fix: watermark thumbnails, custom logo display, and German translations
2026-01-15 08:02:28 -05:00
Paul Nothaft 41f9b6d45d Merge pull request #104 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Failing after 3m44s
Build and Push Docker Images / build-frontend (push) Failing after 3m53s
Build and Push Docker Images / summary (push) Successful in 3s
chore(main): release 2.3.1
2026-01-15 06:46:06 -05:00
github-actions[bot] 7b5916d3b9 chore(main): release 2.3.1 2026-01-15 11:45:24 +00:00
Paul Nothaft 657c205a4d Merge pull request #103 from the-luap/fix/codeql-v4-upgrade
fix: CI workflow fixes for protected branches
2026-01-15 06:45:06 -05:00
40 changed files with 192 additions and 1410 deletions
-5
View File
@@ -39,11 +39,6 @@ EMAIL_FROM=noreply@yourdomain.com
FRONTEND_URL=https://yourdomain.com
ADMIN_URL=https://yourdomain.com
# API URL for email assets (logos, images in notification emails)
# This must be the publicly accessible URL where email recipients can load images.
# If not set, defaults to http://localhost:3001 which will show broken images in emails.
API_URL=https://yourdomain.com/api
# Frontend API base
# For pre-built images and production behind a reverse proxy, keep '/api'.
# If you rebuild the frontend yourself, you may set a full URL at build time.
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "3.2.2-beta.0"
".": "3.0.0-beta.0"
}
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "2.3.0"
".": "2.3.2"
}
+4 -48
View File
@@ -5,64 +5,20 @@ All notable changes to PicPeak will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [3.2.2-beta.0](https://github.com/the-luap/picpeak/compare/v3.2.1-beta.0...v3.2.2-beta.0) (2026-01-16)
## [2.3.2](https://github.com/the-luap/picpeak/compare/v2.3.1...v2.3.2) (2026-01-15)
### Bug Fixes
* mobile upload button visibility in gallery ([2a2c23d](https://github.com/the-luap/picpeak/commit/2a2c23d11610e6c81684163eb4ea934a6d6104fb)), closes [#113](https://github.com/the-luap/picpeak/issues/113)
* mobile upload button visibility in gallery ([#113](https://github.com/the-luap/picpeak/issues/113)) ([05a5307](https://github.com/the-luap/picpeak/commit/05a5307e22dc45be4b75b2996ff9fac65dec399d))
## [3.2.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.2.0-beta.0...v3.2.1-beta.0) (2026-01-16)
### Bug Fixes
* mobile upload button visibility in gallery ([df7dbff](https://github.com/the-luap/picpeak/commit/df7dbffbffb180e62af0d2b58326f9de0f515439)), closes [#113](https://github.com/the-luap/picpeak/issues/113)
* mobile upload button visibility in gallery ([#113](https://github.com/the-luap/picpeak/issues/113)) ([6cb4342](https://github.com/the-luap/picpeak/commit/6cb43428d1e703267edeacda9ede050a8c4f8e0c))
## [3.2.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.1.0-beta.0...v3.2.0-beta.0) (2026-01-16)
### Features
* add optional event date and expiration settings ([3079eaa](https://github.com/the-luap/picpeak/commit/3079eaa2e5d1728c2c0f315626cc253e4b08edc2))
* add optional event date and expiration settings ([2151147](https://github.com/the-luap/picpeak/commit/2151147f2d3134448ff32130da44678e2942d73c)), closes [#118](https://github.com/the-luap/picpeak/issues/118)
### Bug Fixes
* checkbox and toggle settings not persisting after page refresh ([808ed1d](https://github.com/the-luap/picpeak/commit/808ed1d2f1164d9fd1114586c68a1f925bf73ddf)), closes [#117](https://github.com/the-luap/picpeak/issues/117)
### Documentation
* add API_URL environment variable to .env.example files ([3e69579](https://github.com/the-luap/picpeak/commit/3e69579f5a171b31a253b2a42bb033bf1b97387d))
## [3.1.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.0.1-beta.0...v3.1.0-beta.0) (2026-01-15)
### Features
* dynamic website title from branding settings ([d29aab7](https://github.com/the-luap/picpeak/commit/d29aab7c70c5777451666fb7d5c7a9729dab684a))
* pre-generate watermarks for instant lightbox loading ([1be974a](https://github.com/the-luap/picpeak/commit/1be974afbb0b7a1bdbdd140327771907a5d3c2ae)), closes [#112](https://github.com/the-luap/picpeak/issues/112)
* pre-generated watermarks and mobile upload button improvements ([c6fdd38](https://github.com/the-luap/picpeak/commit/c6fdd38e842e1a8c0aa9cbab9fc791e6669e402d))
### Bug Fixes
* add lightbox loading spinner and watermark cache invalidation ([050ed37](https://github.com/the-luap/picpeak/commit/050ed378199eb3b15c7c7f243792f68f858803f5))
* lightbox watermark loading, white label translations, and dynamic footer year ([ce8587b](https://github.com/the-luap/picpeak/commit/ce8587b24df3f53a11a74348eff8b5c5b96c5488))
* prevent database migration restart failures ([83a4344](https://github.com/the-luap/picpeak/commit/83a4344a01de4f65c5024fdf2d177a04457ccd2f)), closes [#107](https://github.com/the-luap/picpeak/issues/107)
* show upload button in mobile topbar instead of sidebar ([ae181cf](https://github.com/the-luap/picpeak/commit/ae181cf92fc9c1e85cad7a7b843a4d83cec636ac)), closes [#113](https://github.com/the-luap/picpeak/issues/113)
* watermark thumbnails, custom logo display, and German translations ([f843e4c](https://github.com/the-luap/picpeak/commit/f843e4c25cef02eef354fd3ee25824e20e4f8fc8))
* watermark thumbnails, custom logo display, and German translations ([ea20446](https://github.com/the-luap/picpeak/commit/ea20446a797a00cf45dbe7bf6f06574a79c4d8a6))
## [3.0.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.0.0-beta.0...v3.0.1-beta.0) (2026-01-15)
## [2.3.1](https://github.com/the-luap/picpeak/compare/v2.3.0...v2.3.1) (2026-01-15)
### Bug Fixes
* CI workflow fixes for protected branches ([cb01218](https://github.com/the-luap/picpeak/commit/cb012186d93403a1ac4e2d2f5283319603b290d6))
* CI workflow fixes for protected branches ([657c205](https://github.com/the-luap/picpeak/commit/657c205a4d8ca49070b69973f4c7a3d1418633af))
* use Release Please extra-files instead of sync-versions job ([fe7d45d](https://github.com/the-luap/picpeak/commit/fe7d45dd122b2dca1b2a21ba5c86d32b9a193074))
## [3.0.0-beta.0](https://github.com/the-luap/picpeak/compare/v2.3.0-beta.0...v3.0.0-beta.0) (2026-01-15)
-5
View File
@@ -14,11 +14,6 @@ ADMIN_URL=https://photos.example.com
FRONTEND_URL=https://photos.example.com
BACKEND_URL=https://photos.example.com # Or https://api.photos.example.com if separate
# API URL for email assets (logos, images in emails)
# This must be the publicly accessible URL where recipients can load images
# If not set, defaults to http://localhost:3001 which will break images in production emails
API_URL=https://photos.example.com/api
# Database Configuration
DATABASE_CLIENT=pg
DB_HOST=localhost
+1 -2
View File
@@ -1,6 +1,6 @@
# Database Migrations
This directory contains database migrations for the PicPeak photo sharing platform.
This directory contains database migrations for the Wedding Photo Sharing platform.
## Directory Structure
@@ -9,7 +9,6 @@ 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.
@@ -1,53 +0,0 @@
/**
* Migration: Add optional event date and expiration settings
* These settings control whether event_date and expiration are required
* when creating new events, supporting non-event use cases like portraits.
*/
exports.up = async function(knex) {
// Add new settings for optional date and expiration
const settings = [
{ setting_key: 'event_require_event_date', setting_value: JSON.stringify(true), setting_type: 'boolean' },
{ setting_key: 'event_require_expiration', setting_value: JSON.stringify(true), setting_type: 'boolean' }
];
for (const setting of settings) {
const exists = await knex('app_settings').where('setting_key', setting.setting_key).first();
if (!exists) {
await knex('app_settings').insert({
...setting,
updated_at: knex.fn.now()
});
}
}
// Make event_date and expires_at columns nullable
// PostgreSQL supports ALTER COLUMN ... DROP NOT NULL
// SQLite requires table recreation (handled differently)
const client = knex.client.config.client;
if (client === 'pg' || client === 'postgresql') {
// PostgreSQL: directly alter columns
await knex.raw('ALTER TABLE events ALTER COLUMN event_date DROP NOT NULL');
await knex.raw('ALTER TABLE events ALTER COLUMN expires_at DROP NOT NULL');
} else if (client === 'sqlite3' || client === 'better-sqlite3') {
// SQLite: columns are already effectively nullable in most cases
// SQLite doesn't enforce NOT NULL as strictly, and altering requires table recreation
// For safety, we'll skip the schema change for SQLite as it's complex
// The application logic will handle null values appropriately
console.log('SQLite detected - skipping schema alteration (columns will accept NULL values)');
}
};
exports.down = async function(knex) {
// Remove the settings
await knex('app_settings')
.whereIn('setting_key', [
'event_require_event_date',
'event_require_expiration'
])
.del();
// Note: We don't restore NOT NULL constraints as that could fail
// if there are existing NULL values in the database
};
@@ -1,28 +0,0 @@
/**
* 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.
};
@@ -1,27 +1,22 @@
exports.up = async function(knex) {
// Check if photo_counter column already exists to make migration idempotent
const hasPhotoCounter = await knex.schema.hasColumn('photo_categories', 'photo_counter');
// Add photo_counter column to photo_categories table
await knex.schema.alterTable('photo_categories', function(table) {
table.integer('photo_counter').defaultTo(0).notNullable();
});
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 });
}
// 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 });
}
}
};
@@ -1,33 +1,23 @@
exports.up = async function(knex) {
// 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');
// 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');
});
// 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')
});
}
// 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) {
+6 -17
View File
@@ -67,37 +67,26 @@ 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
// 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) {
if (error.code === '42P07' || // PostgreSQL: relation already exists
error.code === 'SQLITE_ERROR' && error.message.includes('already exists')) {
console.log(`Migration ${filepath} - schema already exists, marking as applied`);
await markMigrationAsApplied(path.basename(filepath));
} else {
+3 -14
View File
@@ -26,22 +26,11 @@ 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}`);
// 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 });
}
await migration.up(db);
await db('migrations').insert({ filename });
console.log(`Migration ${filepath} completed`);
}
}
+1 -2
View File
@@ -1,6 +1,6 @@
{
"name": "picpeak-backend",
"version": "3.2.2-beta.0",
"version": "2.3.2",
"description": "Backend for PicPeak event photo sharing platform",
"main": "server.js",
"scripts": {
@@ -8,7 +8,6 @@
"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/"
},
-161
View File
@@ -1,161 +0,0 @@
#!/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);
});
+3 -4
View File
@@ -168,10 +168,9 @@ async function galleryAuth(req, res, next) {
return res.status(404).json({ error: 'Gallery not found or expired' });
}
// Check if gallery has expired (only if expires_at is set)
// Galleries with null expires_at never expire
if (event.expires_at && new Date(event.expires_at) < new Date()) {
return res.status(410).json({
// Check if gallery has expired
if (new Date(event.expires_at) < new Date()) {
return res.status(410).json({
error: 'Gallery has expired',
code: 'GALLERY_EXPIRED'
});
+18 -37
View File
@@ -25,18 +25,14 @@ const getEventFieldRequirements = async () => {
.whereIn('setting_key', [
'event_require_customer_name',
'event_require_customer_email',
'event_require_admin_email',
'event_require_event_date',
'event_require_expiration'
'event_require_admin_email'
])
.select('setting_key', 'setting_value');
const requirements = {
require_customer_name: true,
require_customer_email: true,
require_admin_email: true,
require_event_date: true,
require_expiration: true
require_admin_email: true
};
settings.forEach(s => {
@@ -51,8 +47,6 @@ const getEventFieldRequirements = async () => {
if (s.setting_key === 'event_require_customer_name') requirements.require_customer_name = value;
if (s.setting_key === 'event_require_customer_email') requirements.require_customer_email = value;
if (s.setting_key === 'event_require_admin_email') requirements.require_admin_email = value;
if (s.setting_key === 'event_require_event_date') requirements.require_event_date = value;
if (s.setting_key === 'event_require_expiration') requirements.require_expiration = value;
});
return requirements;
@@ -61,9 +55,7 @@ const getEventFieldRequirements = async () => {
return {
require_customer_name: true,
require_customer_email: true,
require_admin_email: true,
require_event_date: true,
require_expiration: true
require_admin_email: true
};
}
};
@@ -114,7 +106,7 @@ const hasCustomerContactColumns = async () => {
router.post('/', adminAuth, requirePermission('events.create'), [
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
body('event_name').notEmpty().trim(),
body('event_date').optional().isDate(),
body('event_date').isDate(),
body('customer_name').optional().trim(),
body('customer_email').optional().isEmail().normalizeEmail(),
body('admin_email').optional().isEmail().normalizeEmail(),
@@ -209,9 +201,6 @@ router.post('/', adminAuth, requirePermission('events.create'), [
if (fieldRequirements.require_admin_email && !admin_email) {
validationErrors.push({ path: 'admin_email', msg: 'Admin email is required' });
}
if (fieldRequirements.require_event_date && !event_date) {
validationErrors.push({ path: 'event_date', msg: 'Event date is required' });
}
if (validationErrors.length > 0) {
return res.status(400).json({ errors: validationErrors });
@@ -256,13 +245,10 @@ router.post('/', adminAuth, requirePermission('events.create'), [
.replace(/[^a-z0-9]/g, '-') // Replace non-alphanumeric with dash
.replace(/-+/g, '-') // Replace multiple dashes with single dash
.replace(/^-|-$/g, ''); // Remove leading/trailing dashes
// Use event_date in slug if provided, otherwise use random suffix
const slugSuffix = event_date || crypto.randomBytes(3).toString('hex');
const baseSlug = `${event_type}-${processedEventName}-${slugSuffix}`;
const baseSlug = `${event_type}-${processedEventName}-${event_date}`;
let slug = baseSlug;
let counter = 1;
while (await db('events').where({ slug }).first()) {
slug = `${baseSlug}-${counter}`;
counter++;
@@ -278,20 +264,15 @@ router.post('/', adminAuth, requirePermission('events.create'), [
: await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
// Calculate expiration date (days after event date)
// If expiration is not required, expires_at will be null (never expires)
// If event_date is not provided, use current date as base for expiration
let expires_at = null;
if (fieldRequirements.require_expiration) {
const baseDate = event_date || new Date().toISOString().split('T')[0];
// Parse YYYY-MM-DD format as local date to avoid timezone issues
if (baseDate.match(/^\d{4}-\d{2}-\d{2}$/)) {
const [year, month, day] = baseDate.split('-').map(num => parseInt(num, 10));
expires_at = new Date(year, month - 1, day);
} else {
expires_at = new Date(baseDate);
}
expires_at.setDate(expires_at.getDate() + parseInt(expiration_days, 10));
// Parse YYYY-MM-DD format as local date to avoid timezone issues
let expires_at;
if (event_date.match(/^\d{4}-\d{2}-\d{2}$/)) {
const [year, month, day] = event_date.split('-').map(num => parseInt(num, 10));
expires_at = new Date(year, month - 1, day);
} else {
expires_at = new Date(event_date);
}
expires_at.setDate(expires_at.getDate() + parseInt(expiration_days, 10));
// Create folder structure
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
@@ -304,7 +285,7 @@ router.post('/', adminAuth, requirePermission('events.create'), [
slug,
event_type,
event_name,
event_date: event_date || null,
event_date,
...(customerColumnsAvailable ? { customer_name: customerName, customer_email: customerEmail } : {}),
host_name: customerName,
host_email: customerEmail,
@@ -314,7 +295,7 @@ router.post('/', adminAuth, requirePermission('events.create'), [
color_theme,
share_link: shareLinkToStore,
share_token: shareToken,
expires_at: expires_at ? expires_at.toISOString() : null,
expires_at: expires_at.toISOString(),
created_at: new Date().toISOString(),
created_by: req.admin.id,
allow_user_uploads,
@@ -369,7 +350,7 @@ router.post('/', adminAuth, requirePermission('events.create'), [
event_date: event_date, // Pass raw date - will be formatted by email processor
gallery_link: shareUrl,
gallery_password: requirePassword ? password : 'No password required',
expiry_date: expires_at ? expires_at.toISOString() : null, // Pass ISO string - will be formatted by email processor
expiry_date: expires_at.toISOString(), // Pass ISO string - will be formatted by email processor
welcome_message: welcome_message || ''
}),
status: 'pending',
@@ -386,7 +367,7 @@ router.post('/', adminAuth, requirePermission('events.create'), [
customer_email: customerEmail,
require_password: requirePassword,
share_link: shareUrl,
expires_at: expires_at ? expires_at.toISOString() : null,
expires_at: expires_at.toISOString(),
created_at: new Date().toISOString()
});
} catch (error) {
+3 -22
View File
@@ -12,7 +12,6 @@ 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
@@ -309,7 +308,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];
@@ -320,14 +319,6 @@ 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({
@@ -470,12 +461,7 @@ 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();
@@ -596,13 +582,8 @@ 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)
+16 -86
View File
@@ -23,8 +23,6 @@ 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');
@@ -102,20 +100,13 @@ router.get('/', adminAuth, requirePermission('settings.view'), async (req, res)
// Convert to object format
const settingsObject = {};
settings.forEach(setting => {
// Check for null/undefined explicitly to handle boolean false and 0 values
// PostgreSQL json column returns parsed values (false as boolean, not string)
if (setting.setting_value !== null && setting.setting_value !== undefined) {
// If the value is already parsed (from json column), use it directly
if (typeof setting.setting_value !== 'string') {
if (setting.setting_value) {
try {
// Try to parse as JSON first
settingsObject[setting.setting_key] = JSON.parse(setting.setting_value);
} catch (e) {
// If it's not valid JSON, use the raw value
settingsObject[setting.setting_key] = setting.setting_value;
} else {
try {
// Try to parse as JSON first
settingsObject[setting.setting_key] = JSON.parse(setting.setting_value);
} catch (e) {
// If it's not valid JSON, use the raw value
settingsObject[setting.setting_key] = setting.setting_value;
}
}
} else {
settingsObject[setting.setting_key] = null;
@@ -136,24 +127,17 @@ router.get('/:type', adminAuth, requirePermission('settings.view'), async (req,
const settings = await db('app_settings')
.where('setting_type', type)
.select('*');
// Convert to object format
const settingsObject = {};
settings.forEach(setting => {
// Check for null/undefined explicitly to handle boolean false and 0 values
// PostgreSQL json column returns parsed values (false as boolean, not string)
if (setting.setting_value !== null && setting.setting_value !== undefined) {
// If the value is already parsed (from json column), use it directly
if (typeof setting.setting_value !== 'string') {
if (setting.setting_value) {
try {
// Try to parse as JSON first
settingsObject[setting.setting_key] = JSON.parse(setting.setting_value);
} catch (e) {
// If it's not valid JSON, use the raw value
settingsObject[setting.setting_key] = setting.setting_value;
} else {
try {
// Try to parse as JSON first
settingsObject[setting.setting_key] = JSON.parse(setting.setting_value);
} catch (e) {
// If it's not valid JSON, use the raw value
settingsObject[setting.setting_key] = setting.setting_value;
}
}
} else {
settingsObject[setting.setting_key] = null;
@@ -212,9 +196,6 @@ 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,
@@ -325,40 +306,7 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
clearPublicSiteCache();
// 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
});
res.json({ message: 'Branding settings updated successfully' });
} catch (error) {
console.error('Branding update error:', error);
res.status(500).json({ error: 'Failed to update branding settings' });
@@ -493,27 +441,9 @@ router.post('/branding/watermark-logo', adminAuth, requirePermission('settings.e
updated_at: new Date()
});
// 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({
res.json({
message: 'Watermark logo uploaded successfully',
watermarkLogoUrl: publicPath,
watermarkRegenerationStarted
watermarkLogoUrl: publicPath
});
} catch (error) {
console.error('Watermark logo upload error:', error);
+11 -76
View File
@@ -5,7 +5,6 @@ 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');
@@ -152,7 +151,7 @@ router.get('/:slug/info', async (req, res) => {
event_date: event.event_date,
expires_at: event.expires_at,
is_active: event.is_active,
is_expired: !event.is_active || (event.expires_at && new Date(event.expires_at) < new Date()),
is_expired: !event.is_active || new Date(event.expires_at) < new Date(),
requires_password: requiresPassword,
color_theme: event.color_theme,
allow_downloads: !(event.allow_downloads === false || event.allow_downloads === 0 || event.allow_downloads === '0'),
@@ -173,13 +172,7 @@ 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)
@@ -334,17 +327,15 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
categories: categories,
photos: photos.map(photo => {
const useJwtUrl = (protectionSettings.protection_level === 'basic' || protectionSettings.protection_level === 'standard');
// 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}` :
const photoUrl = useJwtUrl ?
`/api/gallery/${req.params.slug}/photo/${photo.id}` :
`/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}${wmQuery}` : null,
thumbnail_url: photo.thumbnail_path ? `/api/gallery/${req.params.slug}/thumbnail/${photo.id}` : 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,
@@ -763,54 +754,13 @@ 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) {
// 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
// Apply watermark and send
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
'ETag': etag,
'X-Protection-Level': 'basic'
});
@@ -819,7 +769,6 @@ 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
@@ -871,32 +820,18 @@ 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',
'ETag': etag
'X-Protected-Thumbnail': 'true'
});
// 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);
+1 -3
View File
@@ -74,9 +74,7 @@ router.get('/', async (req, res) => {
// Event field requirements
event_require_customer_name: settingsObject.event_require_customer_name !== false,
event_require_customer_email: settingsObject.event_require_customer_email !== false,
event_require_admin_email: settingsObject.event_require_admin_email !== false,
event_require_event_date: settingsObject.event_require_event_date !== false,
event_require_expiration: settingsObject.event_require_expiration !== false
event_require_admin_email: settingsObject.event_require_admin_email !== false
};
res.json(publicSettings);
@@ -21,11 +21,9 @@ async function checkExpirations() {
const warningDate = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); // 7 days from now
// Check for events needing warning emails
// Skip events with null expires_at (they never expire)
const eventsNeedingWarning = await db('events')
.where('is_active', formatBoolean(true))
.where('is_archived', formatBoolean(false))
.whereNotNull('expires_at')
.where('expires_at', '<=', warningDate)
.where('expires_at', '>', now);
@@ -42,11 +40,9 @@ async function checkExpirations() {
}
// Check for expired events
// Skip events with null expires_at (they never expire)
const expiredEvents = await db('events')
.where('is_active', formatBoolean(true))
.where('is_archived', formatBoolean(false))
.whereNotNull('expires_at')
.where('expires_at', '<=', now);
for (const event of expiredEvents) {
@@ -1,433 +0,0 @@
/**
* 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();
-120
View File
@@ -2,7 +2,6 @@ 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() {
@@ -233,125 +232,6 @@ 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();
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "picpeak-frontend",
"private": true,
"version": "3.2.2-beta.0",
"version": "2.3.2",
"type": "module",
"scripts": {
"dev": "vite",
@@ -7,7 +7,7 @@ import {
resolveSlugFromRequestUrl,
} from '../../utils/galleryAuthStorage';
interface AuthenticatedImageProps extends Omit<React.ImgHTMLAttributes<HTMLImageElement>, 'onLoad'> {
interface AuthenticatedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
src: string;
fallbackSrc?: string;
useWatermark?: boolean;
@@ -29,7 +29,6 @@ interface AuthenticatedImageProps extends Omit<React.ImgHTMLAttributes<HTMLImage
detectDevTools?: boolean;
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
useEnhancedProtection?: boolean;
onLoad?: () => void;
}
export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
@@ -55,7 +54,6 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
detectDevTools,
protectionLevel,
useEnhancedProtection,
onLoad,
...props
}) => {
const unusedProps = {
@@ -223,7 +221,6 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
img.onload = () => {
imageRef.current = img;
drawToCanvas();
onLoad?.();
};
img.onerror = (e) => {
@@ -238,7 +235,7 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
img.onload = null;
img.onerror = null;
};
}, [imageSrc, useCanvasRendering, drawToCanvas, onLoad]);
}, [imageSrc, useCanvasRendering, drawToCanvas]);
if (isLoading) {
return (
@@ -285,5 +282,5 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
);
}
return <img src={imageSrc} alt={alt} onLoad={onLoad} {...props} />;
return <img src={imageSrc} alt={alt} {...props} />;
};
@@ -2,8 +2,6 @@ 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'],
@@ -21,7 +19,6 @@ 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
@@ -32,27 +29,13 @@ 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;
};
@@ -240,7 +240,11 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
{/* Right side - Action buttons */}
<div className="flex items-center gap-2 sm:gap-3 flex-shrink-0">
{/* Extra header items (upload button, etc.) */}
{headerExtra}
{headerExtra && (
<div className="hidden sm:block">
{headerExtra}
</div>
)}
{/* Download all button - hidden on mobile when sidebar is shown */}
{showDownloadAll && onDownloadAll && (
@@ -440,7 +444,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
</p>
)}
<p className="text-xs sm:text-sm text-neutral-500">
{brandingSettings?.footer_text || `© ${new Date().getFullYear()}${brandingSettings?.company_name ? ` ${brandingSettings.company_name}` : ''}. All rights reserved.`}
{brandingSettings?.footer_text || '© 2024 Your Company. All rights reserved.'}
{!brandingSettings?.hide_powered_by && (
<> | Powered by <span className="font-semibold">PicPeak</span></>
)}
@@ -1,5 +1,5 @@
import React, { useEffect, useRef } from 'react';
import { X, Download, Filter, SortAsc, Search, Calendar, Type, HardDrive, Check, Star, Upload } from 'lucide-react';
import { X, Download, Filter, SortAsc, Search, Calendar, Type, HardDrive, Check, Upload, Star } from 'lucide-react';
import { Button } from '../common';
import { PhotoCategory } from '../../types';
import { useTranslation } from 'react-i18next';
@@ -139,18 +139,18 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
{/* Content */}
<div className="gallery-sidebar-content flex-1 overflow-y-auto">
{/* Upload Section - Show prominently at top for mobile users */}
{allowUploads && onUploadClick && (
<div className="gallery-sidebar-section gallery-sidebar-upload p-4 border-b border-neutral-200">
{/* Upload Section - Only show on mobile when uploads are allowed */}
{isMobile && allowUploads && onUploadClick && (
<div className="p-4 border-b border-neutral-200">
<Button
variant="outline"
size="sm"
leftIcon={<Upload className="w-4 h-4" />}
onClick={() => {
onUploadClick();
if (isMobile) onClose();
onClose();
}}
className="gallery-btn w-full"
className="w-full"
>
{t('upload.uploadPhotos')}
</Button>
@@ -203,7 +203,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ 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 || '',
footer_text: settingsData.branding_footer_text || '© 2024 Your Company. All rights reserved.',
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<GalleryViewProps> = ({ slug, event }) => {
if (watermarkEnabled) {
photos = photos.map(photo => ({
...photo,
url: `/api/gallery/${slug}/photo/${photo.id}`
url: `/gallery/${slug}/photo/${photo.id}`
}));
}
@@ -608,9 +608,9 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
);
}
// Upload button - always show when uploads are allowed (regardless of layout/theme loading state)
// Upload button only on desktop when sidebar is shown
const allowUploads = data?.event?.allow_user_uploads || event?.allow_user_uploads;
if (allowUploads) {
if (allowUploads && showSidebar && !isMobile) {
items.push(
<Button
key="upload-button"
@@ -618,7 +618,22 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
size="sm"
leftIcon={<Upload className="w-4 h-4" />}
onClick={() => setShowUploadModal(true)}
className={!showSidebar ? 'flex-1 sm:flex-initial' : ''}
>
{t('upload.uploadPhotos')}
</Button>
);
}
// Upload button for non-sidebar layouts
if (allowUploads && !showSidebar) {
items.push(
<Button
key="upload-button"
variant="outline"
size="sm"
leftIcon={<Upload className="w-4 h-4" />}
onClick={() => setShowUploadModal(true)}
className="flex-1 sm:flex-initial"
>
<span className="hidden sm:inline">{t('upload.uploadPhotos')}</span>
<span className="sm:hidden">{t('common.upload')}</span>
@@ -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, Loader2 } from 'lucide-react';
import { X, ChevronLeft, ChevronRight, Download, ZoomIn, ZoomOut, MessageSquare, Heart, Star } from 'lucide-react';
import type { Photo } from '../../types';
import { useDownloadPhoto } from '../../hooks/useGallery';
import { AuthenticatedImage } from '../common';
@@ -62,18 +62,12 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
const [showIdentityModal, setShowIdentityModal] = useState(false);
const [pendingAction, setPendingAction] = useState<null | { type: 'like' | 'rating'; rating?: number }>(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];
@@ -494,13 +488,6 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
right: isDesktopFeedback ? `${desktopFeedbackWidth}px` : 0,
}}
>
{/* Loading spinner */}
{!imageLoaded && currentPhoto.media_type !== 'video' && (
<div className="absolute inset-0 flex items-center justify-center z-10">
<Loader2 className="w-12 h-12 text-white animate-spin" />
</div>
)}
{currentPhoto.media_type === 'video' ? (
<VideoPlayer
src={currentPhoto.url}
@@ -518,10 +505,8 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
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}
@@ -539,7 +524,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
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', {
@@ -549,7 +534,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
zoom
});
}
// For maximum protection, close lightbox on violation
if (protectionLevel === 'maximum' &&
['devtools_detected', 'print_screen_detected', 'canvas_access_blocked'].includes(violationType)) {
@@ -49,8 +49,6 @@ export interface EventSettings {
event_require_customer_name: boolean;
event_require_customer_email: boolean;
event_require_admin_email: boolean;
event_require_event_date: boolean;
event_require_expiration: boolean;
}
export function useSettingsState() {
@@ -111,9 +109,7 @@ export function useSettingsState() {
const [eventSettings, setEventSettings] = useState<EventSettings>({
event_require_customer_name: true,
event_require_customer_email: true,
event_require_admin_email: true,
event_require_event_date: true,
event_require_expiration: true
event_require_admin_email: true
});
// Account form state
@@ -182,9 +178,7 @@ export function useSettingsState() {
setEventSettings({
event_require_customer_name: toBoolean(settings.event_require_customer_name, true),
event_require_customer_email: toBoolean(settings.event_require_customer_email, true),
event_require_admin_email: toBoolean(settings.event_require_admin_email, true),
event_require_event_date: toBoolean(settings.event_require_event_date, true),
event_require_expiration: toBoolean(settings.event_require_expiration, true)
event_require_admin_email: toBoolean(settings.event_require_admin_email, true)
});
}
}, [settings, i18n]);
@@ -99,56 +99,6 @@ export const EventsTab: React.FC<EventsTabProps> = ({
</div>
</label>
</div>
<div>
<label className="flex items-start gap-3">
<input
type="checkbox"
checked={eventSettings.event_require_event_date}
onChange={(e) => setEventSettings(prev => ({ ...prev, event_require_event_date: e.target.checked }))}
className="mt-1 w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<div>
<span className="text-sm font-medium text-neutral-700">
{t('settings.events.requireEventDate', 'Require event date')}
</span>
<p className="text-xs text-neutral-500 mt-1">
{t('settings.events.requireEventDateHelp', 'Event date must be provided when creating events')}
</p>
{!eventSettings.event_require_event_date && (
<p className="text-xs text-amber-600 mt-1 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
{t('settings.events.eventDateWarning', 'Gallery URLs will use random identifiers instead of dates')}
</p>
)}
</div>
</label>
</div>
<div>
<label className="flex items-start gap-3">
<input
type="checkbox"
checked={eventSettings.event_require_expiration}
onChange={(e) => setEventSettings(prev => ({ ...prev, event_require_expiration: e.target.checked }))}
className="mt-1 w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<div>
<span className="text-sm font-medium text-neutral-700">
{t('settings.events.requireExpiration', 'Require expiration date')}
</span>
<p className="text-xs text-neutral-500 mt-1">
{t('settings.events.requireExpirationHelp', 'Galleries must have an expiration date')}
</p>
{!eventSettings.event_require_expiration && (
<p className="text-xs text-amber-600 mt-1 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
{t('settings.events.expirationWarning', 'Galleries without expiration will remain active until manually archived')}
</p>
)}
</div>
</label>
</div>
</div>
<div className="mt-6">
+1 -33
View File
@@ -759,8 +759,6 @@
"themeAndStyle": "Design & Stil",
"galleryWillExpireOn": "Galerie läuft ab am {{date}}",
"expirationWarning": "Gäste erhalten 7 Tage vor Ablauf eine Warn-E-Mail.",
"noExpiration": "Kein Ablaufdatum",
"noExpirationHelp": "Diese Galerie bleibt aktiv, bis sie manuell archiviert wird.",
"userUploads": "Benutzer-Upload-Einstellungen",
"allowUserUploads": "Gästen erlauben, Fotos hochzuladen",
"allowUserUploadsHelp": "Ermöglichen Sie Gästen, ihre eigenen Fotos in diese Galerie hochzuladen",
@@ -1050,12 +1048,6 @@
"requireAdminEmail": "Admin-E-Mail erforderlich",
"requireAdminEmailHelp": "Admin-E-Mail muss für neue Veranstaltungen angegeben werden",
"adminEmailWarning": "Erforderlich für den Erhalt von Veranstaltungsbenachrichtigungen",
"requireEventDate": "Veranstaltungsdatum erforderlich",
"requireEventDateHelp": "Veranstaltungsdatum muss beim Erstellen angegeben werden",
"eventDateWarning": "Galerie-URLs verwenden zufällige Kennungen anstelle von Daten",
"requireExpiration": "Ablaufdatum erforderlich",
"requireExpirationHelp": "Galerien müssen ein Ablaufdatum haben",
"expirationWarning": "Galerien ohne Ablaufdatum bleiben aktiv, bis sie manuell archiviert werden",
"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."
@@ -1263,31 +1255,7 @@
"customizeTheme": "Design anpassen",
"saveTheme": "Design speichern",
"previewLayout": "Vorschau-Layout",
"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)"
"livePreview": "Live-Vorschau"
},
"admin": {
"title": "Admin-Panel",
+1 -33
View File
@@ -433,8 +433,6 @@
"expiresOn": "Expires on",
"galleryWillExpireOn": "Gallery will expire on {{date}}",
"expirationWarning": "Guests will receive a warning email 7 days before expiration.",
"noExpiration": "No Expiration",
"noExpirationHelp": "This gallery will remain active until manually archived.",
"userUploads": "User Upload Settings",
"allowUserUploads": "Allow guests to upload photos",
"allowUserUploadsHelp": "Enable guests to upload their own photos to this gallery",
@@ -755,12 +753,6 @@
"requireAdminEmail": "Require admin email",
"requireAdminEmailHelp": "Admin email must be provided for new events",
"adminEmailWarning": "Required for receiving event notifications",
"requireEventDate": "Require event date",
"requireEventDateHelp": "Event date must be provided when creating events",
"eventDateWarning": "Gallery URLs will use random identifiers instead of dates",
"requireExpiration": "Require expiration date",
"requireExpirationHelp": "Galleries must have an expiration date",
"expirationWarning": "Galleries without expiration will remain active until manually archived",
"saveSettings": "Save Event Settings",
"noteTitle": "Note",
"noteText": "These settings only affect new event creation. Existing events are not affected. Default behavior requires all fields."
@@ -1026,31 +1018,7 @@
"customizeTheme": "Customize Theme",
"saveTheme": "Save Theme",
"previewLayout": "Preview Layout",
"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)"
"livePreview": "Live Preview"
},
"admin": {
"title": "Admin Panel",
+2 -2
View File
@@ -15,7 +15,7 @@ export const BrandingPage: React.FC = () => {
const [brandingSettings, setBrandingSettings] = useState<BrandingSettings>({
company_name: '',
company_tagline: '',
footer_text: '',
footer_text: '© 2024 Your Company. All rights reserved.',
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={`© ${new Date().getFullYear()} Your Company. All rights reserved.`}
placeholder="© 2024 Your Company. All rights reserved."
/>
</div>
</div>
+26 -40
View File
@@ -148,8 +148,6 @@ export const CreateEventPage: React.FC = () => {
const requireCustomerName = publicSettings?.event_require_customer_name !== false;
const requireCustomerEmail = publicSettings?.event_require_customer_email !== false;
const requireAdminEmail = publicSettings?.event_require_admin_email !== false;
const requireEventDate = publicSettings?.event_require_event_date !== false;
const requireExpiration = publicSettings?.event_require_expiration !== false;
// Update default expiration days when settings are loaded
useEffect(() => {
@@ -203,7 +201,7 @@ export const CreateEventPage: React.FC = () => {
newErrors.event_name = t('validation.eventNameRequired');
}
if (requireEventDate && !formData.event_date) {
if (!formData.event_date) {
newErrors.event_date = t('validation.eventDateRequired');
}
@@ -249,7 +247,7 @@ export const CreateEventPage: React.FC = () => {
}
}
if (requireExpiration && (formData.expires_in_days < 1 || formData.expires_in_days > 365)) {
if (formData.expires_in_days < 1 || formData.expires_in_days > 365) {
newErrors.expires_in_days = t('validation.expirationRange');
}
@@ -269,7 +267,7 @@ export const CreateEventPage: React.FC = () => {
const payload = {
event_type: formData.event_type,
event_name: formData.event_name,
event_date: formData.event_date || undefined,
event_date: formData.event_date,
customer_name: formData.customer_name,
customer_email: formData.customer_email,
admin_email: formData.admin_email,
@@ -277,7 +275,7 @@ export const CreateEventPage: React.FC = () => {
password: formData.require_password ? formData.password : undefined,
welcome_message: formData.welcome_message || '',
color_theme: JSON.stringify(formData.theme_config),
expiration_days: requireExpiration ? formData.expires_in_days : undefined,
expiration_days: formData.expires_in_days,
allow_user_uploads: formData.allow_user_uploads,
upload_category_id: formData.upload_category_id,
css_template_id: formData.css_template_id,
@@ -396,7 +394,7 @@ export const CreateEventPage: React.FC = () => {
<Input
type="date"
label={requireEventDate ? t('events.eventDate') : `${t('events.eventDate')} (${t('common.optional')})`}
label={t('events.eventDate')}
value={formData.event_date}
onChange={handleInputChange('event_date')}
error={errors.event_date}
@@ -668,42 +666,30 @@ export const CreateEventPage: React.FC = () => {
</div>
)}
{requireExpiration ? (
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('events.galleryExpiration')}
</label>
<div className="flex items-center gap-2">
<div className="w-32">
<Input
type="number"
value={formData.expires_in_days}
onChange={handleInputChange('expires_in_days')}
error={errors.expires_in_days}
min={1}
max={365}
leftIcon={<Clock className="w-5 h-5" />}
/>
</div>
<span className="text-sm text-neutral-600">{t('events.daysAfterEvent')}</span>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('events.galleryExpiration')}
</label>
<div className="flex items-center gap-2">
<div className="w-32">
<Input
type="number"
value={formData.expires_in_days}
onChange={handleInputChange('expires_in_days')}
error={errors.expires_in_days}
min={1}
max={365}
leftIcon={<Clock className="w-5 h-5" />}
/>
</div>
{formData.event_date && (
<p className="mt-2 text-sm text-neutral-500">
{t('events.expiresOn')}: {format(addDays(new Date(formData.event_date), formData.expires_in_days))}
</p>
)}
<span className="text-sm text-neutral-600">{t('events.daysAfterEvent')}</span>
</div>
) : (
<div className="rounded-md border border-blue-200 bg-blue-50 p-3">
<div className="flex items-center gap-2 text-blue-800">
<Clock className="w-4 h-4" />
<span className="text-sm font-medium">{t('events.noExpiration', 'No Expiration')}</span>
</div>
<p className="mt-1 text-xs text-blue-700">
{t('events.noExpirationHelp', 'This gallery will remain active until manually archived.')}
{formData.event_date && (
<p className="mt-2 text-sm text-neutral-500">
{t('events.expiresOn')}: {format(addDays(new Date(formData.event_date), formData.expires_in_days))}
</p>
</div>
)}
)}
</div>
{/* User Upload Settings */}
<div className="pt-4 border-t border-neutral-200">
+1 -1
View File
@@ -144,7 +144,7 @@ export const LegalPage: React.FC = () => {
</Link>
</div>
<p className="text-sm text-neutral-500 mt-4">
© {new Date().getFullYear()} PicPeak. All rights reserved.
© 2024 PicPeak. All rights reserved.
</p>
</div>
</footer>
+4 -4
View File
@@ -20,15 +20,15 @@ const normalizeEvent = (event: Event): Event => {
interface CreateEventData {
event_type: string;
event_name: string;
event_date?: string;
event_date: string;
customer_name?: string;
customer_email?: string;
admin_email?: string;
customer_email: string;
admin_email: string;
require_password?: boolean;
password?: string;
welcome_message?: string;
color_theme?: string;
expiration_days?: number;
expiration_days: number;
allow_user_uploads?: boolean;
upload_category_id?: number | null;
feedback_enabled?: boolean;
+6 -6
View File
@@ -4,7 +4,7 @@ export interface Event {
slug: string;
event_type: string;
event_name: string;
event_date: string | null;
event_date: string;
customer_name?: string;
customer_email: string;
admin_email: string;
@@ -12,7 +12,7 @@ export interface Event {
color_theme?: string;
share_link: string;
created_at: string;
expires_at: string | null;
expires_at: string;
is_active: boolean;
is_archived: boolean;
archive_path?: string;
@@ -46,8 +46,8 @@ export interface Event {
export interface GalleryInfo {
event_name: string;
event_type: string;
event_date: string | null;
expires_at: string | null;
event_date: string;
expires_at: string;
is_active: boolean;
is_expired: boolean;
requires_password?: boolean;
@@ -97,10 +97,10 @@ export interface GalleryData {
id: number;
event_name: string;
event_type: string;
event_date: string | null;
event_date: string;
welcome_message?: string;
color_theme?: string;
expires_at: string | null;
expires_at: string;
allow_user_uploads?: boolean;
upload_category_id?: number | null;
hero_photo_id?: number | null;
+3 -3
View File
@@ -12,10 +12,10 @@ interface PhotoUrlOptions {
*/
export function getPhotoUrl({ slug, photo, watermarkEnabled = false, token }: PhotoUrlOptions): string {
if (watermarkEnabled && token) {
// Use the watermarked photo endpoint (needs /api prefix)
return `/api/gallery/${slug}/photo/${photo.id}`;
// Use the watermarked photo endpoint
return `/gallery/${slug}/photo/${photo.id}`;
}
// Use the static photo URL
return photo.url;
}