Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 70a0caa11f | |||
| e808e529cd | |||
| 05a5307e22 | |||
| 2a2c23d116 | |||
| a092d98523 | |||
| b5f06af126 | |||
| 6cb43428d1 | |||
| df7dbffbff | |||
| 94421a6b12 | |||
| 7805e89bfe | |||
| 3079eaa2e5 | |||
| 2151147f2d | |||
| 3e69579f5a | |||
| 808ed1d2f1 | |||
| b40e085d28 | |||
| d603567e21 | |||
| c6fdd38e84 | |||
| ae181cf92f | |||
| 1be974afbb | |||
| d29aab7c70 | |||
| 050ed37819 | |||
| 83a4344a01 | |||
| ce8587b24d | |||
| 1c8f686c19 | |||
| a0f38053d3 | |||
| cb012186d9 |
@@ -39,6 +39,11 @@ 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,3 +1,3 @@
|
||||
{
|
||||
".": "3.0.0-beta.0"
|
||||
".": "3.2.2-beta.0"
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "2.3.2"
|
||||
".": "2.3.0"
|
||||
}
|
||||
|
||||
+48
-4
@@ -5,20 +5,64 @@ 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).
|
||||
|
||||
## [2.3.2](https://github.com/the-luap/picpeak/compare/v2.3.1...v2.3.2) (2026-01-15)
|
||||
## [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)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* watermark thumbnails, custom logo display, and German translations ([f843e4c](https://github.com/the-luap/picpeak/commit/f843e4c25cef02eef354fd3ee25824e20e4f8fc8))
|
||||
* 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 ([ea20446](https://github.com/the-luap/picpeak/commit/ea20446a797a00cf45dbe7bf6f06574a79c4d8a6))
|
||||
|
||||
## [2.3.1](https://github.com/the-luap/picpeak/compare/v2.3.0...v2.3.1) (2026-01-15)
|
||||
## [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)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* CI workflow fixes for protected branches ([657c205](https://github.com/the-luap/picpeak/commit/657c205a4d8ca49070b69973f4c7a3d1418633af))
|
||||
* CI workflow fixes for protected branches ([cb01218](https://github.com/the-luap/picpeak/commit/cb012186d93403a1ac4e2d2f5283319603b290d6))
|
||||
* 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)
|
||||
|
||||
@@ -14,6 +14,11 @@ 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,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.
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* 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
|
||||
};
|
||||
@@ -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.
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "2.3.2",
|
||||
"version": "3.2.2-beta.0",
|
||||
"description": "Backend for PicPeak event photo sharing platform",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
@@ -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/"
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -168,9 +168,10 @@ async function galleryAuth(req, res, next) {
|
||||
return res.status(404).json({ error: 'Gallery not found or expired' });
|
||||
}
|
||||
|
||||
// Check if gallery has expired
|
||||
if (new Date(event.expires_at) < new Date()) {
|
||||
return res.status(410).json({
|
||||
// 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({
|
||||
error: 'Gallery has expired',
|
||||
code: 'GALLERY_EXPIRED'
|
||||
});
|
||||
|
||||
@@ -25,14 +25,18 @@ const getEventFieldRequirements = async () => {
|
||||
.whereIn('setting_key', [
|
||||
'event_require_customer_name',
|
||||
'event_require_customer_email',
|
||||
'event_require_admin_email'
|
||||
'event_require_admin_email',
|
||||
'event_require_event_date',
|
||||
'event_require_expiration'
|
||||
])
|
||||
.select('setting_key', 'setting_value');
|
||||
|
||||
const requirements = {
|
||||
require_customer_name: true,
|
||||
require_customer_email: true,
|
||||
require_admin_email: true
|
||||
require_admin_email: true,
|
||||
require_event_date: true,
|
||||
require_expiration: true
|
||||
};
|
||||
|
||||
settings.forEach(s => {
|
||||
@@ -47,6 +51,8 @@ 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;
|
||||
@@ -55,7 +61,9 @@ const getEventFieldRequirements = async () => {
|
||||
return {
|
||||
require_customer_name: true,
|
||||
require_customer_email: true,
|
||||
require_admin_email: true
|
||||
require_admin_email: true,
|
||||
require_event_date: true,
|
||||
require_expiration: true
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -106,7 +114,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').isDate(),
|
||||
body('event_date').optional().isDate(),
|
||||
body('customer_name').optional().trim(),
|
||||
body('customer_email').optional().isEmail().normalizeEmail(),
|
||||
body('admin_email').optional().isEmail().normalizeEmail(),
|
||||
@@ -201,6 +209,9 @@ 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 });
|
||||
@@ -245,10 +256,13 @@ 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
|
||||
const baseSlug = `${event_type}-${processedEventName}-${event_date}`;
|
||||
|
||||
// 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}`;
|
||||
let slug = baseSlug;
|
||||
let counter = 1;
|
||||
|
||||
|
||||
while (await db('events').where({ slug }).first()) {
|
||||
slug = `${baseSlug}-${counter}`;
|
||||
counter++;
|
||||
@@ -264,15 +278,20 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
: await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
|
||||
|
||||
// Calculate expiration date (days after event date)
|
||||
// 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);
|
||||
// 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));
|
||||
}
|
||||
expires_at.setDate(expires_at.getDate() + parseInt(expiration_days, 10));
|
||||
|
||||
// Create folder structure
|
||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
@@ -285,7 +304,7 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
slug,
|
||||
event_type,
|
||||
event_name,
|
||||
event_date,
|
||||
event_date: event_date || null,
|
||||
...(customerColumnsAvailable ? { customer_name: customerName, customer_email: customerEmail } : {}),
|
||||
host_name: customerName,
|
||||
host_email: customerEmail,
|
||||
@@ -295,7 +314,7 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
color_theme,
|
||||
share_link: shareLinkToStore,
|
||||
share_token: shareToken,
|
||||
expires_at: expires_at.toISOString(),
|
||||
expires_at: expires_at ? expires_at.toISOString() : null,
|
||||
created_at: new Date().toISOString(),
|
||||
created_by: req.admin.id,
|
||||
allow_user_uploads,
|
||||
@@ -350,7 +369,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.toISOString(), // Pass ISO string - will be formatted by email processor
|
||||
expiry_date: expires_at ? expires_at.toISOString() : null, // Pass ISO string - will be formatted by email processor
|
||||
welcome_message: welcome_message || ''
|
||||
}),
|
||||
status: 'pending',
|
||||
@@ -367,7 +386,7 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
customer_email: customerEmail,
|
||||
require_password: requirePassword,
|
||||
share_link: shareUrl,
|
||||
expires_at: expires_at.toISOString(),
|
||||
expires_at: expires_at ? expires_at.toISOString() : null,
|
||||
created_at: new Date().toISOString()
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -100,13 +102,20 @@ router.get('/', adminAuth, requirePermission('settings.view'), async (req, res)
|
||||
// Convert to object format
|
||||
const settingsObject = {};
|
||||
settings.forEach(setting => {
|
||||
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
|
||||
// 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') {
|
||||
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;
|
||||
@@ -127,17 +136,24 @@ 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 => {
|
||||
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
|
||||
// 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') {
|
||||
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;
|
||||
@@ -196,6 +212,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 +325,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 +493,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);
|
||||
|
||||
@@ -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');
|
||||
@@ -151,7 +152,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 || new Date(event.expires_at) < new Date(),
|
||||
is_expired: !event.is_active || (event.expires_at && 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'),
|
||||
@@ -172,7 +173,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 +334,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,13 +763,54 @@ 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
|
||||
// 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
|
||||
'ETag': etag,
|
||||
'X-Protection-Level': 'basic'
|
||||
});
|
||||
|
||||
@@ -769,6 +819,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 +871,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);
|
||||
|
||||
@@ -74,7 +74,9 @@ 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_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
|
||||
};
|
||||
|
||||
res.json(publicSettings);
|
||||
|
||||
@@ -21,9 +21,11 @@ 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);
|
||||
|
||||
@@ -40,9 +42,11 @@ 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) {
|
||||
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "picpeak-frontend",
|
||||
"private": true,
|
||||
"version": "2.3.2",
|
||||
"version": "3.2.2-beta.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
resolveSlugFromRequestUrl,
|
||||
} from '../../utils/galleryAuthStorage';
|
||||
|
||||
interface AuthenticatedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
||||
interface AuthenticatedImageProps extends Omit<React.ImgHTMLAttributes<HTMLImageElement>, 'onLoad'> {
|
||||
src: string;
|
||||
fallbackSrc?: string;
|
||||
useWatermark?: boolean;
|
||||
@@ -29,6 +29,7 @@ interface AuthenticatedImageProps extends React.ImgHTMLAttributes<HTMLImageEleme
|
||||
detectDevTools?: boolean;
|
||||
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
|
||||
useEnhancedProtection?: boolean;
|
||||
onLoad?: () => void;
|
||||
}
|
||||
|
||||
export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
||||
@@ -54,6 +55,7 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
||||
detectDevTools,
|
||||
protectionLevel,
|
||||
useEnhancedProtection,
|
||||
onLoad,
|
||||
...props
|
||||
}) => {
|
||||
const unusedProps = {
|
||||
@@ -221,6 +223,7 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
||||
img.onload = () => {
|
||||
imageRef.current = img;
|
||||
drawToCanvas();
|
||||
onLoad?.();
|
||||
};
|
||||
|
||||
img.onerror = (e) => {
|
||||
@@ -235,7 +238,7 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
||||
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<AuthenticatedImageProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
return <img src={imageSrc} alt={alt} {...props} />;
|
||||
return <img src={imageSrc} alt={alt} onLoad={onLoad} {...props} />;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -240,11 +240,7 @@ 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 && (
|
||||
<div className="hidden sm:block">
|
||||
{headerExtra}
|
||||
</div>
|
||||
)}
|
||||
{headerExtra}
|
||||
|
||||
{/* Download all button - hidden on mobile when sidebar is shown */}
|
||||
{showDownloadAll && onDownloadAll && (
|
||||
@@ -444,7 +440,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs sm:text-sm text-neutral-500">
|
||||
{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 <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, Upload, Star } from 'lucide-react';
|
||||
import { X, Download, Filter, SortAsc, Search, Calendar, Type, HardDrive, Check, Star, Upload } 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 - Only show on mobile when uploads are allowed */}
|
||||
{isMobile && allowUploads && onUploadClick && (
|
||||
<div className="p-4 border-b border-neutral-200">
|
||||
{/* 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">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<Upload className="w-4 h-4" />}
|
||||
onClick={() => {
|
||||
onUploadClick();
|
||||
onClose();
|
||||
if (isMobile) onClose();
|
||||
}}
|
||||
className="w-full"
|
||||
className="gallery-btn 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 || '© 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<GalleryViewProps> = ({ slug, event }) => {
|
||||
if (watermarkEnabled) {
|
||||
photos = photos.map(photo => ({
|
||||
...photo,
|
||||
url: `/gallery/${slug}/photo/${photo.id}`
|
||||
url: `/api/gallery/${slug}/photo/${photo.id}`
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -608,9 +608,9 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
);
|
||||
}
|
||||
|
||||
// Upload button only on desktop when sidebar is shown
|
||||
// Upload button - always show when uploads are allowed (regardless of layout/theme loading state)
|
||||
const allowUploads = data?.event?.allow_user_uploads || event?.allow_user_uploads;
|
||||
if (allowUploads && showSidebar && !isMobile) {
|
||||
if (allowUploads) {
|
||||
items.push(
|
||||
<Button
|
||||
key="upload-button"
|
||||
@@ -618,22 +618,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
size="sm"
|
||||
leftIcon={<Upload className="w-4 h-4" />}
|
||||
onClick={() => setShowUploadModal(true)}
|
||||
>
|
||||
{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"
|
||||
className={!showSidebar ? '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 } 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<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];
|
||||
@@ -488,6 +494,13 @@ 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}
|
||||
@@ -505,8 +518,10 @@ 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}
|
||||
@@ -524,7 +539,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', {
|
||||
@@ -534,7 +549,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,6 +49,8 @@ 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() {
|
||||
@@ -109,7 +111,9 @@ 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_admin_email: true,
|
||||
event_require_event_date: true,
|
||||
event_require_expiration: true
|
||||
});
|
||||
|
||||
// Account form state
|
||||
@@ -178,7 +182,9 @@ 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_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)
|
||||
});
|
||||
}
|
||||
}, [settings, i18n]);
|
||||
|
||||
@@ -99,6 +99,56 @@ 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">
|
||||
|
||||
@@ -759,6 +759,8 @@
|
||||
"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",
|
||||
@@ -1048,6 +1050,12 @@
|
||||
"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."
|
||||
@@ -1255,7 +1263,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",
|
||||
|
||||
@@ -433,6 +433,8 @@
|
||||
"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",
|
||||
@@ -753,6 +755,12 @@
|
||||
"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."
|
||||
@@ -1018,7 +1026,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",
|
||||
|
||||
@@ -15,7 +15,7 @@ export const BrandingPage: React.FC = () => {
|
||||
const [brandingSettings, setBrandingSettings] = useState<BrandingSettings>({
|
||||
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.`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -148,6 +148,8 @@ 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(() => {
|
||||
@@ -201,7 +203,7 @@ export const CreateEventPage: React.FC = () => {
|
||||
newErrors.event_name = t('validation.eventNameRequired');
|
||||
}
|
||||
|
||||
if (!formData.event_date) {
|
||||
if (requireEventDate && !formData.event_date) {
|
||||
newErrors.event_date = t('validation.eventDateRequired');
|
||||
}
|
||||
|
||||
@@ -247,7 +249,7 @@ export const CreateEventPage: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
if (formData.expires_in_days < 1 || formData.expires_in_days > 365) {
|
||||
if (requireExpiration && (formData.expires_in_days < 1 || formData.expires_in_days > 365)) {
|
||||
newErrors.expires_in_days = t('validation.expirationRange');
|
||||
}
|
||||
|
||||
@@ -267,7 +269,7 @@ export const CreateEventPage: React.FC = () => {
|
||||
const payload = {
|
||||
event_type: formData.event_type,
|
||||
event_name: formData.event_name,
|
||||
event_date: formData.event_date,
|
||||
event_date: formData.event_date || undefined,
|
||||
customer_name: formData.customer_name,
|
||||
customer_email: formData.customer_email,
|
||||
admin_email: formData.admin_email,
|
||||
@@ -275,7 +277,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: formData.expires_in_days,
|
||||
expiration_days: requireExpiration ? formData.expires_in_days : undefined,
|
||||
allow_user_uploads: formData.allow_user_uploads,
|
||||
upload_category_id: formData.upload_category_id,
|
||||
css_template_id: formData.css_template_id,
|
||||
@@ -394,7 +396,7 @@ export const CreateEventPage: React.FC = () => {
|
||||
|
||||
<Input
|
||||
type="date"
|
||||
label={t('events.eventDate')}
|
||||
label={requireEventDate ? t('events.eventDate') : `${t('events.eventDate')} (${t('common.optional')})`}
|
||||
value={formData.event_date}
|
||||
onChange={handleInputChange('event_date')}
|
||||
error={errors.event_date}
|
||||
@@ -666,30 +668,42 @@ export const CreateEventPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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" />}
|
||||
/>
|
||||
{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>
|
||||
<span className="text-sm text-neutral-600">{t('events.daysAfterEvent')}</span>
|
||||
{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>
|
||||
{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))}
|
||||
) : (
|
||||
<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.')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User Upload Settings */}
|
||||
<div className="pt-4 border-t border-neutral-200">
|
||||
|
||||
@@ -144,7 +144,7 @@ export const LegalPage: React.FC = () => {
|
||||
</Link>
|
||||
</div>
|
||||
<p className="text-sm text-neutral-500 mt-4">
|
||||
© 2024 PicPeak. All rights reserved.
|
||||
© {new Date().getFullYear()} PicPeak. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -4,7 +4,7 @@ export interface Event {
|
||||
slug: string;
|
||||
event_type: string;
|
||||
event_name: string;
|
||||
event_date: string;
|
||||
event_date: string | null;
|
||||
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;
|
||||
expires_at: string | null;
|
||||
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;
|
||||
expires_at: string;
|
||||
event_date: string | null;
|
||||
expires_at: string | null;
|
||||
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;
|
||||
event_date: string | null;
|
||||
welcome_message?: string;
|
||||
color_theme?: string;
|
||||
expires_at: string;
|
||||
expires_at: string | null;
|
||||
allow_user_uploads?: boolean;
|
||||
upload_category_id?: number | null;
|
||||
hero_photo_id?: number | null;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user