diff --git a/backend/data/photo_sharing.db b/backend/data/photo_sharing.db index b41c822..c551221 100644 Binary files a/backend/data/photo_sharing.db and b/backend/data/photo_sharing.db differ diff --git a/backend/data/wedding-photos.db b/backend/data/wedding-photos.db new file mode 100644 index 0000000..e69de29 diff --git a/backend/migrations/012_add_hero_photo_id.js b/backend/migrations/012_add_hero_photo_id.js new file mode 100644 index 0000000..608d75d --- /dev/null +++ b/backend/migrations/012_add_hero_photo_id.js @@ -0,0 +1,15 @@ +exports.up = async function(knex) { + // Add hero_photo_id to events table + const hasColumn = await knex.schema.hasColumn('events', 'hero_photo_id'); + if (!hasColumn) { + await knex.schema.alterTable('events', function(table) { + table.integer('hero_photo_id').references('id').inTable('photos').onDelete('SET NULL'); + }); + } +}; + +exports.down = async function(knex) { + await knex.schema.alterTable('events', function(table) { + table.dropColumn('hero_photo_id'); + }); +}; \ No newline at end of file diff --git a/backend/migrations/013_fix_email_links_and_date_format.js b/backend/migrations/013_fix_email_links_and_date_format.js new file mode 100644 index 0000000..fa12478 --- /dev/null +++ b/backend/migrations/013_fix_email_links_and_date_format.js @@ -0,0 +1,112 @@ +exports.up = async function(knex) { + // First, add date format configuration to app_settings table + const dateFormatSetting = await knex('app_settings').where('setting_key', 'general_date_format').first(); + if (!dateFormatSetting) { + await knex('app_settings').insert({ + setting_key: 'general_date_format', + setting_value: JSON.stringify({ + format: 'DD/MM/YYYY', // European format as default + locale: 'en-GB' + }), + setting_type: 'general', + updated_at: new Date() + }); + } + + // Update English email templates to use proper HTML links + await knex('email_templates') + .where('template_key', 'gallery_created') + .update({ + body_html_en: `

Gallery Successfully Created

+

Dear {{host_name}},

+

Your photo gallery "{{event_name}}" has been successfully created!

+

Gallery Details:

+ +

Share this link and password with your guests so they can view and download photos.

+

View Gallery

`, + body_html_de: `

Galerie erfolgreich erstellt

+

Liebe(r) {{host_name}},

+

Ihre Fotogalerie "{{event_name}}" wurde erfolgreich erstellt!

+

Galerie-Details:

+ +

Teilen Sie diesen Link und das Passwort mit Ihren Gästen, damit sie Fotos ansehen und herunterladen können.

+

Galerie anzeigen

` + }); + + // Update expiration warning template + await knex('email_templates') + .where('template_key', 'expiration_warning') + .update({ + body_html_en: `

Gallery Expiring Soon

+

Dear {{host_name}},

+

Your photo gallery "{{event_name}}" will expire in {{days_remaining}} days.

+

After expiration, the gallery will be archived and no longer accessible to guests.

+

Visit Gallery

+

Gallery Link: {{gallery_link}}

`, + body_html_de: `

Galerie läuft bald ab

+

Liebe(r) {{host_name}},

+

Ihre Fotogalerie "{{event_name}}" läuft in {{days_remaining}} Tagen ab.

+

Nach Ablauf wird die Galerie archiviert und ist für Gäste nicht mehr zugänglich.

+

Galerie besuchen

+

Galerie-Link: {{gallery_link}}

` + }); + + // Update gallery expired template + await knex('email_templates') + .where('template_key', 'gallery_expired') + .update({ + body_html_en: `

Gallery Expired

+

Your photo gallery for "{{event_name}}" has expired and is no longer accessible.

+

The photos have been safely archived. If you need access again, please contact the administrator at {{admin_email}}.

+

Thank you for using our photo sharing service!

+

Best regards,
The Photo Sharing Team

`, + body_html_de: `

Galerie abgelaufen

+

Ihre Fotogalerie für "{{event_name}}" ist abgelaufen und nicht mehr zugänglich.

+

Die Fotos wurden zur sicheren Aufbewahrung archiviert. Wenn Sie wieder Zugriff benötigen, wenden Sie sich bitte an den Administrator unter {{admin_email}}.

+

Vielen Dank für die Nutzung unseres Foto-Sharing-Services!

+

Mit freundlichen Grüßen,
Das Foto-Sharing-Team

` + }); +}; + +exports.down = async function(knex) { + // Remove date format setting + await knex('app_settings').where('setting_key', 'general_date_format').del(); + + // Revert email templates to plain text links + await knex('email_templates') + .where('template_key', 'gallery_created') + .update({ + body_html_en: `

Gallery Successfully Created

+

Dear {{host_name}},

+

Your photo gallery "{{event_name}}" has been successfully created!

+

Gallery Details:

+ +

Share this link and password with your guests so they can view and download photos.

`, + body_html_de: `

Galerie erfolgreich erstellt

+

Liebe(r) {{host_name}},

+

Ihre Fotogalerie "{{event_name}}" wurde erfolgreich erstellt!

+

Galerie-Details:

+ +

Teilen Sie diesen Link und das Passwort mit Ihren Gästen, damit sie Fotos ansehen und herunterladen können.

` + }); +}; \ No newline at end of file diff --git a/backend/migrations/014_add_host_name_to_events.js b/backend/migrations/014_add_host_name_to_events.js new file mode 100644 index 0000000..259eda2 --- /dev/null +++ b/backend/migrations/014_add_host_name_to_events.js @@ -0,0 +1,35 @@ +const { db } = require('../src/database/db'); + +async function up() { + // Check if host_name column already exists + const hasHostName = await db.schema.hasColumn('events', 'host_name'); + + if (!hasHostName) { + await db.schema.table('events', (table) => { + table.string('host_name').after('event_date'); + }); + + console.log('Added host_name column to events table'); + } +} + +async function down() { + await db.schema.table('events', (table) => { + table.dropColumn('host_name'); + }); +} + +module.exports = { up, down }; + +// Run migration if called directly +if (require.main === module) { + up() + .then(() => { + console.log('Migration completed successfully'); + process.exit(0); + }) + .catch((error) => { + console.error('Migration failed:', error); + process.exit(1); + }); +} \ No newline at end of file diff --git a/backend/src/database/db.js b/backend/src/database/db.js index 00f4b38..94d8050 100644 --- a/backend/src/database/db.js +++ b/backend/src/database/db.js @@ -23,7 +23,7 @@ async function initializeDatabase() { table.string('admin_email').notNullable(); table.string('password_hash').notNullable(); table.text('welcome_message'); - table.string('color_theme'); + table.text('color_theme'); table.string('share_link').unique().notNullable(); table.datetime('created_at').defaultTo(db.fn.now()); table.datetime('expires_at').notNullable(); @@ -32,6 +32,41 @@ async function initializeDatabase() { table.string('archive_path'); table.datetime('archived_at'); }); + } else { + // Check if color_theme needs to be updated to TEXT type + // This is needed for larger theme configurations + try { + await db.raw(` + CREATE TABLE IF NOT EXISTS events_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + slug TEXT UNIQUE NOT NULL, + event_type TEXT NOT NULL, + event_name TEXT NOT NULL, + event_date DATE NOT NULL, + host_email TEXT NOT NULL, + admin_email TEXT NOT NULL, + password_hash TEXT NOT NULL, + welcome_message TEXT, + color_theme TEXT, + share_link TEXT UNIQUE NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + expires_at DATETIME NOT NULL, + is_active BOOLEAN DEFAULT 1, + is_archived BOOLEAN DEFAULT 0, + archive_path TEXT, + archived_at DATETIME, + allow_user_uploads BOOLEAN DEFAULT 0, + upload_category_id INTEGER + ) + `); + + await db.raw(`INSERT INTO events_new SELECT * FROM events`); + await db.raw(`DROP TABLE events`); + await db.raw(`ALTER TABLE events_new RENAME TO events`); + } catch (error) { + // If the migration fails, it might already have been applied + console.log('Color theme migration may have already been applied'); + } } // Photo metadata table diff --git a/backend/src/routes/adminEvents.js b/backend/src/routes/adminEvents.js index 7098cd1..5a83d4b 100644 --- a/backend/src/routes/adminEvents.js +++ b/backend/src/routes/adminEvents.js @@ -8,6 +8,7 @@ const crypto = require('crypto'); const fs = require('fs').promises; const path = require('path'); const { archiveEvent } = require('../services/archiveService'); +const { formatDate } = require('../utils/dateFormatter'); // Create new event router.post('/', adminAuth, [ @@ -20,12 +21,15 @@ router.post('/', adminAuth, [ body('expiration_days').isInt({ min: 1, max: 365 }).optional(), body('welcome_message').optional().trim(), body('color_theme').optional().trim(), - body('allow_user_uploads').optional().isBoolean(), - body('upload_category_id').optional().isInt() + body('allow_user_uploads').optional().isBoolean().toBoolean(), + body('upload_category_id').optional({ nullable: true, checkFalsy: true }).isInt(), + body('host_name').notEmpty().trim() ], async (req, res) => { try { + console.log('Create event request body:', req.body); const errors = validationResult(req); if (!errors.isEmpty()) { + console.error('Validation errors:', errors.array()); return res.status(400).json({ errors: errors.array() }); } @@ -33,6 +37,7 @@ router.post('/', adminAuth, [ event_type, event_name, event_date, + host_name, host_email, admin_email, password, @@ -76,6 +81,7 @@ router.post('/', adminAuth, [ event_type, event_name, event_date, + host_name, host_email, admin_email, password_hash, @@ -96,17 +102,20 @@ router.post('/', adminAuth, [ ); // Queue creation email + // Determine language based on email domain + const emailLang = host_email.endsWith('.de') ? 'de' : 'en'; + await db('email_queue').insert({ event_id: eventId, recipient_email: host_email, email_type: 'gallery_created', email_data: JSON.stringify({ - host_name: host_email.split('@')[0], // Extract name from email + host_name: host_name, event_name, - event_date: new Date(event_date).toLocaleDateString(), + event_date: await formatDate(event_date, emailLang), gallery_link: shareLink, gallery_password: password, - expiry_date: expires_at.toLocaleDateString() + expiry_date: await formatDate(expires_at, emailLang) }) // scheduled_at will use default value }); @@ -245,10 +254,28 @@ router.get('/:id', adminAuth, async (req, res) => { .limit(10) .select('filename', 'type', 'size_bytes', 'uploaded_at'); + // Get view and download statistics + const [{ totalViews }] = await db('access_logs') + .where('event_id', id) + .where('action', 'view') + .count('* as totalViews'); + + const [{ totalDownloads }] = await db('access_logs') + .where('event_id', id) + .where('action', 'download') + .count('* as totalDownloads'); + + const [{ uniqueVisitors }] = await db('access_logs') + .where('event_id', id) + .countDistinct('ip_address as uniqueVisitors'); + res.json({ ...event, photo_count: parseInt(photoCount) || 0, total_size: parseInt(totalSize) || 0, + total_views: parseInt(totalViews) || 0, + total_downloads: parseInt(totalDownloads) || 0, + unique_visitors: parseInt(uniqueVisitors) || 0, recent_photos: recentPhotos }); } catch (error) { @@ -263,20 +290,44 @@ router.put('/:id', adminAuth, [ body('admin_email').optional().isEmail(), body('is_active').optional().isBoolean(), body('expires_at').optional().isISO8601(), - body('welcome_message').optional().trim(), - body('color_theme').optional().trim(), + body('welcome_message').optional({ nullable: true, checkFalsy: true }).trim(), + body('color_theme').optional({ nullable: true }), body('allow_user_uploads').optional().isBoolean(), - body('upload_category_id').optional().isInt() + body('host_name').optional().trim().notEmpty(), + body('upload_category_id').optional().custom((value) => { + // Accept null, undefined, or integer values + if (value === null || value === undefined) return true; + return Number.isInteger(Number(value)); + }).withMessage('upload_category_id must be an integer or null'), + body('hero_photo_id').optional().custom((value) => { + // Accept null, undefined, or numeric values + if (value === null || value === undefined) return true; + // Check if it's a number or can be converted to a valid integer + const num = Number(value); + return !isNaN(num) && Number.isInteger(num); + }).withMessage('hero_photo_id must be an integer or null') ], async (req, res) => { try { const errors = validationResult(req); if (!errors.isEmpty()) { + console.log('Update event validation errors:', JSON.stringify(errors.array(), null, 2)); + console.log('Request body:', req.body); return res.status(400).json({ errors: errors.array() }); } const { id } = req.params; const updates = req.body; + // Log the update request for debugging + console.log('Update event request:', { + id, + updates, + color_theme_length: updates.color_theme ? updates.color_theme.length : 0, + color_theme_type: typeof updates.color_theme, + hero_photo_id: updates.hero_photo_id, + hero_photo_id_type: typeof updates.hero_photo_id + }); + // Check if event exists const event = await db('events').where('id', id).first(); if (!event) { @@ -286,10 +337,7 @@ router.put('/:id', adminAuth, [ // Update event await db('events') .where('id', id) - .update({ - ...updates, - updated_at: new Date() - }); + .update(updates); // Log activity await logActivity('event_updated', diff --git a/backend/src/routes/adminSettings.js b/backend/src/routes/adminSettings.js index 4c056bc..ab8b7c6 100644 --- a/backend/src/routes/adminSettings.js +++ b/backend/src/routes/adminSettings.js @@ -133,7 +133,10 @@ router.put('/branding', adminAuth, async (req, res) => { watermark_enabled, watermark_position, watermark_opacity, - watermark_size + watermark_size, + favicon_url, + logo_url, + watermark_logo_url } = req.body; const brandingSettings = { @@ -144,9 +147,72 @@ router.put('/branding', adminAuth, async (req, res) => { watermark_enabled, watermark_position, watermark_opacity, - watermark_size + watermark_size, + favicon_url, + logo_url, + watermark_logo_url }; + // Handle favicon deletion if empty string or null is provided + if (favicon_url === '' || favicon_url === null || favicon_url === undefined) { + // Get current favicon path to delete file + const currentFaviconSetting = await db('app_settings') + .where('setting_key', 'branding_favicon_url') + .first(); + + if (currentFaviconSetting && currentFaviconSetting.setting_value) { + let currentFaviconUrl; + try { + // Try to parse as JSON first + currentFaviconUrl = JSON.parse(currentFaviconSetting.setting_value); + } catch (e) { + // If it's not valid JSON, use the raw value + currentFaviconUrl = currentFaviconSetting.setting_value; + } + + if (currentFaviconUrl && typeof currentFaviconUrl === 'string' && currentFaviconUrl.startsWith('/uploads/favicons/')) { + // Delete the file from filesystem + const faviconPath = path.join(__dirname, '..', '..', 'storage', currentFaviconUrl.replace('/uploads/', '')); + try { + await fs.unlink(faviconPath); + console.log('Deleted favicon file:', faviconPath); + } catch (err) { + console.error('Error deleting favicon file:', err); + } + } + } + } + + // Handle logo deletion if empty string or null is provided + if (logo_url === '' || logo_url === null || logo_url === undefined) { + // Get current logo path to delete file + const currentLogoSetting = await db('app_settings') + .where('setting_key', 'branding_logo_url') + .first(); + + if (currentLogoSetting && currentLogoSetting.setting_value) { + let currentLogoUrl; + try { + // Try to parse as JSON first + currentLogoUrl = JSON.parse(currentLogoSetting.setting_value); + } catch (e) { + // If it's not valid JSON, use the raw value + currentLogoUrl = currentLogoSetting.setting_value; + } + + if (currentLogoUrl && typeof currentLogoUrl === 'string' && currentLogoUrl.startsWith('/uploads/logos/')) { + // Delete the file from filesystem + const logoPath = path.join(__dirname, '..', '..', 'storage', currentLogoUrl.replace('/uploads/', '')); + try { + await fs.unlink(logoPath); + console.log('Deleted logo file:', logoPath); + } catch (err) { + console.error('Error deleting logo file:', err); + } + } + } + } + // Update or insert each setting for (const [key, value] of Object.entries(brandingSettings)) { await db('app_settings') diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js index 99f6cca..ff91ac5 100644 --- a/backend/src/routes/gallery.js +++ b/backend/src/routes/gallery.js @@ -96,7 +96,8 @@ router.get('/:slug/info', async (req, res) => { expires_at: event.expires_at, is_active: event.is_active, is_expired: !event.is_active || new Date(event.expires_at) < new Date(), - requires_password: true + requires_password: true, + color_theme: event.color_theme }); } catch (error) { console.error('Error fetching gallery info:', error); @@ -142,7 +143,8 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => { event_date: req.event.event_date, welcome_message: req.event.welcome_message, color_theme: req.event.color_theme, - expires_at: req.event.expires_at + expires_at: req.event.expires_at, + hero_photo_id: req.event.hero_photo_id }, categories: categories.map(cat => ({ id: cat.id, @@ -222,12 +224,26 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) => // Download all photos as ZIP router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => { try { - const photos = await db('photos').where('event_id', req.event.id); + // Fetch photos with category information + const photos = await db('photos') + .leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id') + .where('photos.event_id', req.event.id) + .select( + 'photos.*', + 'photo_categories.name as category_name', + 'photo_categories.slug as category_slug' + ) + .orderBy('photo_categories.name', 'asc') + .orderBy('photos.uploaded_at', 'desc'); if (photos.length === 0) { return res.status(404).json({ error: 'No photos found' }); } + // Count unique categories (excluding null) + const uniqueCategories = new Set(photos.filter(p => p.category_id).map(p => p.category_id)).size; + const hasMultipleCategories = uniqueCategories > 1; + res.setHeader('Content-Type', 'application/zip'); res.setHeader('Content-Disposition', `attachment; filename="${req.event.slug}.zip"`); @@ -245,13 +261,29 @@ router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => { for (const photo of photos) { const filePath = path.join(getStoragePath(), 'events/active', photo.path); + // Determine the file name in the archive + let archiveName; + if (hasMultipleCategories) { + if (photo.category_name) { + // Use category name as folder (sanitize for filesystem) + const folderName = photo.category_name.replace(/[^a-zA-Z0-9-_ ]/g, '').trim(); + archiveName = path.join(folderName, photo.filename); + } else { + // Put uncategorized photos in 'Uncategorized' folder + archiveName = path.join('Uncategorized', photo.filename); + } + } else { + // No folders, just the filename + archiveName = photo.filename; + } + if (watermarkSettings && watermarkSettings.enabled) { // Apply watermark const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings); - archive.append(watermarkedBuffer, { name: photo.path }); + archive.append(watermarkedBuffer, { name: archiveName }); } else { // Add original file - archive.file(filePath, { name: photo.path }); + archive.file(filePath, { name: archiveName }); } } diff --git a/backend/src/services/emailProcessor.js b/backend/src/services/emailProcessor.js index 5e40422..b8df520 100644 --- a/backend/src/services/emailProcessor.js +++ b/backend/src/services/emailProcessor.js @@ -50,7 +50,7 @@ async function getRecipientLanguage(email) { } // Process email template with variables -function processTemplate(template, variables, language = 'en') { +async function processTemplate(template, variables, language = 'en') { // Get the appropriate language fields const subjectField = language === 'de' ? 'subject_de' : 'subject_en'; const htmlField = language === 'de' ? 'body_html_de' : 'body_html_en'; @@ -61,6 +61,38 @@ function processTemplate(template, variables, language = 'en') { let htmlBody = template[htmlField] || template.body_html || ''; let textBody = template[textField] || template.body_text || ''; + // Get branding settings for logo + let logoUrl = ''; + let companyName = 'PicPeak'; + try { + const brandingSettings = await db('app_settings') + .whereIn('setting_key', ['branding_logo_url', 'branding_company_name']) + .select('setting_key', 'setting_value'); + + brandingSettings.forEach(setting => { + if (setting.setting_key === 'branding_logo_url' && setting.setting_value) { + try { + logoUrl = JSON.parse(setting.setting_value); + } catch (e) { + logoUrl = setting.setting_value; + } + } else if (setting.setting_key === 'branding_company_name' && setting.setting_value) { + try { + companyName = JSON.parse(setting.setting_value); + } catch (e) { + companyName = setting.setting_value; + } + } + }); + } catch (error) { + logger.error('Error fetching branding settings:', error); + } + + // If no custom logo, use default PicPeak logo + const apiUrl = process.env.API_URL || 'http://localhost:3001'; + const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3005'; + const logoFullUrl = logoUrl ? `${apiUrl}${logoUrl}` : `${frontendUrl}/picpeak-logo-transparent.png`; + // Replace variables Object.entries(variables).forEach(([key, value]) => { const regex = new RegExp(`{{${key}}}`, 'g'); @@ -69,7 +101,142 @@ function processTemplate(template, variables, language = 'en') { textBody = textBody.replace(regex, value || ''); }); - return { subject, htmlBody, textBody }; + // Wrap HTML body in styled template + const styledHtmlBody = ` + + + + + + ${subject} + + + +
+
+ + + +
+
+ +`; + + return { subject, htmlBody: styledHtmlBody, textBody }; } // Send email using template @@ -101,7 +268,7 @@ async function sendTemplateEmail(to, templateKey, variables) { const language = await getRecipientLanguage(to); // Process template with variables - const { subject, htmlBody, textBody } = processTemplate(template, variables, language); + const { subject, htmlBody, textBody } = await processTemplate(template, variables, language); // Send email const info = await transporter.sendMail({ diff --git a/backend/src/services/expirationChecker.js b/backend/src/services/expirationChecker.js index 7207ddc..ad7bed1 100644 --- a/backend/src/services/expirationChecker.js +++ b/backend/src/services/expirationChecker.js @@ -3,6 +3,7 @@ const { db } = require('../database/db'); const { archiveEvent } = require('./archiveService'); const { queueEmail } = require('./emailProcessor'); const logger = require('../utils/logger'); +const { formatDate } = require('../utils/dateFormatter'); function startExpirationChecker() { // Check every hour for expired events and warnings @@ -55,12 +56,15 @@ async function checkExpirations() { async function queueExpirationWarning(event) { const daysRemaining = Math.ceil((new Date(event.expires_at) - new Date()) / (1000 * 60 * 60 * 24)); + // Determine language based on email domain + const emailLang = event.host_email.endsWith('.de') ? 'de' : 'en'; + // Queue email to host await queueEmail(event.id, event.host_email, 'expiration_warning', { - host_name: event.host_email.split('@')[0], + host_name: event.host_name || event.host_email.split('@')[0], event_name: event.event_name, days_remaining: daysRemaining.toString(), - expiration_date: new Date(event.expires_at).toLocaleDateString(), + expiration_date: await formatDate(event.expires_at, emailLang), gallery_link: event.share_link }); diff --git a/backend/src/utils/dateFormatter.js b/backend/src/utils/dateFormatter.js new file mode 100644 index 0000000..ffcc7dc --- /dev/null +++ b/backend/src/utils/dateFormatter.js @@ -0,0 +1,65 @@ +const { db } = require('../database/db'); + +// Default date format settings +const DEFAULT_FORMAT = { + format: 'DD/MM/YYYY', + locale: 'en-GB' +}; + +// Format date based on system settings +async function formatDate(date, language = 'en') { + try { + // Get date format setting from database + const setting = await db('app_settings').where('setting_key', 'general_date_format').first(); + const dateConfig = setting ? JSON.parse(setting.setting_value) : DEFAULT_FORMAT; + + const dateObj = date instanceof Date ? date : new Date(date); + + // Use appropriate locale based on language + let locale = dateConfig.locale || 'en-GB'; + if (language === 'de') { + locale = 'de-DE'; + } else if (language === 'en' && dateConfig.format === 'MM/DD/YYYY') { + locale = 'en-US'; + } + + // Format based on the configured format + switch (dateConfig.format) { + case 'MM/DD/YYYY': + return dateObj.toLocaleDateString(locale, { + month: '2-digit', + day: '2-digit', + year: 'numeric' + }); + case 'DD/MM/YYYY': + return dateObj.toLocaleDateString(locale, { + day: '2-digit', + month: '2-digit', + year: 'numeric' + }); + case 'YYYY-MM-DD': + return dateObj.toISOString().split('T')[0]; + case 'DD.MM.YYYY': + return dateObj.toLocaleDateString('de-DE', { + day: '2-digit', + month: '2-digit', + year: 'numeric' + }); + default: + // Use long format as fallback + return dateObj.toLocaleDateString(locale, { + year: 'numeric', + month: 'long', + day: 'numeric' + }); + } + } catch (error) { + console.error('Error formatting date:', error); + // Fallback to basic formatting + return date instanceof Date ? date.toLocaleDateString() : new Date(date).toLocaleDateString(); + } +} + +module.exports = { + formatDate +}; \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index e40f611..edce7a0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,4 @@ # docker-compose.yml - Development configuration -version: '3.8' services: backend: diff --git a/frontend/index.html b/frontend/index.html index e4b78ea..e040cab 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,9 +2,9 @@ - + - Vite + React + TS + PicPeak - Photo Sharing Platform
diff --git a/frontend/public/favicon-32x32.png b/frontend/public/favicon-32x32.png new file mode 100644 index 0000000..ac40b82 Binary files /dev/null and b/frontend/public/favicon-32x32.png differ diff --git a/frontend/public/picpeak-kamera-transparent.png b/frontend/public/picpeak-kamera-transparent.png new file mode 100644 index 0000000..58311b8 Binary files /dev/null and b/frontend/public/picpeak-kamera-transparent.png differ diff --git a/frontend/public/picpeak-logo-transparent.png b/frontend/public/picpeak-logo-transparent.png new file mode 100644 index 0000000..a412674 Binary files /dev/null and b/frontend/public/picpeak-logo-transparent.png differ diff --git a/frontend/public/picpeak-logo.png b/frontend/public/picpeak-logo.png new file mode 100644 index 0000000..8ce30c9 Binary files /dev/null and b/frontend/public/picpeak-logo.png differ diff --git a/frontend/src/components/GlobalThemeProvider.tsx b/frontend/src/components/GlobalThemeProvider.tsx index 6aa8070..a6625fa 100644 --- a/frontend/src/components/GlobalThemeProvider.tsx +++ b/frontend/src/components/GlobalThemeProvider.tsx @@ -21,9 +21,12 @@ export const GlobalThemeProvider: React.FC = ({ childr staleTime: 5 * 60 * 1000, // Cache for 5 minutes }); - // Apply global theme when settings are loaded + // Apply global theme when settings are loaded (but not on gallery pages) useEffect(() => { - if (!themeAppliedRef.current && settingsData?.theme_config) { + // Skip if we're on a gallery page - gallery pages handle their own themes + const isGalleryPage = window.location.pathname.includes('/gallery/'); + + if (!themeAppliedRef.current && settingsData?.theme_config && !isGalleryPage) { themeAppliedRef.current = true; setTheme(settingsData.theme_config); } diff --git a/frontend/src/components/MaintenanceMode.tsx b/frontend/src/components/MaintenanceMode.tsx index b91f18c..7160163 100644 --- a/frontend/src/components/MaintenanceMode.tsx +++ b/frontend/src/components/MaintenanceMode.tsx @@ -42,32 +42,31 @@ export const MaintenanceMode: React.FC = () => { return (
- {/* Header with branding */} - {(settings?.branding_logo_url || settings?.branding_company_name) && ( -
-
-
- {settings.branding_logo_url ? ( - {settings.branding_company_name - ) : ( -
-

{settings.branding_company_name}

- {settings.branding_company_tagline && ( -

{settings.branding_company_tagline}

- )} -
- )} -
+ {/* Header with branding - Always show, with PicPeak logo as fallback */} +
+
+
+ {settings?.branding_company_name + {settings?.branding_company_name && settings.branding_company_name !== 'PicPeak' && ( +
+

{settings.branding_company_name}

+ {settings.branding_company_tagline && ( +

{settings.branding_company_tagline}

+ )} +
+ )}
- )} +
{/* Main content */}
diff --git a/frontend/src/components/admin/AdminHeader.tsx b/frontend/src/components/admin/AdminHeader.tsx index e0e0f44..c919592 100644 --- a/frontend/src/components/admin/AdminHeader.tsx +++ b/frontend/src/components/admin/AdminHeader.tsx @@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'; import { Menu, User, LogOut, Settings, Bell, Lock, CheckCircle, Trash2 } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { useLocalizedDate } from '../../hooks/useLocalizedDate'; +import { useLocalizedTimeAgo } from '../../hooks/useLocalizedTimeAgo'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useAdminAuth } from '../../contexts'; @@ -20,7 +21,8 @@ export const AdminHeader: React.FC = ({ onMenuClick }) => { const navigate = useNavigate(); const { user, logout } = useAdminAuth(); const { t } = useTranslation(); - const { format, formatDistanceToNow } = useLocalizedDate(); + const { format } = useLocalizedDate(); + const { formatTimeAgo } = useLocalizedTimeAgo(); const [showUserMenu, setShowUserMenu] = useState(false); const [showNotifications, setShowNotifications] = useState(false); const [showPasswordModal, setShowPasswordModal] = useState(false); @@ -49,7 +51,7 @@ export const AdminHeader: React.FC = ({ onMenuClick }) => { mutationFn: notificationsService.markAllAsRead, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['notifications'] }); - toast.success('All notifications marked as read'); + toast.success(t('admin.notificationToasts.markedAllRead')); }, }); @@ -58,7 +60,7 @@ export const AdminHeader: React.FC = ({ onMenuClick }) => { mutationFn: notificationsService.clearOldNotifications, onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: ['notifications'] }); - toast.success(`Cleared ${data.deletedCount} old notifications`); + toast.success(t('admin.notificationToasts.clearedOld', { count: data.deletedCount })); }, }); @@ -69,19 +71,26 @@ export const AdminHeader: React.FC = ({ onMenuClick }) => {
- {/* Mobile menu button */} - + {/* Left side - Menu button and Date */} +
+ + + {/* Date display */} +
+

+ {format(new Date(), 'PPPP')} +

+
+
- {/* Desktop breadcrumb or page title could go here */} -
-

- {format(new Date(), 'PPPP')} -

+ {/* Center - PicPeak branding */} +
+ PicPeak
{/* Right side actions */} @@ -111,26 +120,26 @@ export const AdminHeader: React.FC = ({ onMenuClick }) => { )}
{notifications.length === 0 ? (
- No notifications + {t('admin.noNotificationsMessage')}
) : ( notifications.map((notification) => { @@ -151,7 +160,7 @@ export const AdminHeader: React.FC = ({ onMenuClick }) => { {notificationsService.formatNotificationMessage(notification)}

- {formatDistanceToNow(new Date(notification.createdAt), { addSuffix: true })} + {formatTimeAgo(notification.createdAt)}

@@ -166,7 +175,7 @@ export const AdminHeader: React.FC = ({ onMenuClick }) => { onClick={() => setShowNotifications(false)} className="text-sm text-primary-600 hover:text-primary-700" > - Close + {t('admin.close')}
)} diff --git a/frontend/src/components/admin/AdminSidebar.tsx b/frontend/src/components/admin/AdminSidebar.tsx index f069210..560eff4 100644 --- a/frontend/src/components/admin/AdminSidebar.tsx +++ b/frontend/src/components/admin/AdminSidebar.tsx @@ -7,7 +7,6 @@ import { Archive, BarChart3, Settings, - Camera, X, Palette, FileText @@ -53,7 +52,7 @@ export const AdminSidebar: React.FC = ({ isOpen, onClose }) = {/* Logo/Brand */}
- + Camera {t('admin.title')}
)}
@@ -98,7 +100,7 @@ export const EventCategoryManager: React.FC = ({ even value={newCategoryName} onChange={(e) => setNewCategoryName(e.target.value)} onKeyPress={(e) => e.key === 'Enter' && handleCreate()} - placeholder="Category name" + placeholder={t('categories.categoryName')} className="flex-1 px-3 py-1.5 text-sm border border-neutral-300 rounded-md focus:ring-2 focus:ring-primary-500" autoFocus /> @@ -111,7 +113,7 @@ export const EventCategoryManager: React.FC = ({ even {createMutation.isPending ? ( ) : ( - 'Add' + t('common.add') )}
)} @@ -130,7 +132,7 @@ export const EventCategoryManager: React.FC = ({ even {/* Event categories list */} {eventCategories.length === 0 ? (

- No event-specific categories. Global categories are available by default. + {t('categories.noEventSpecificCategories')}

) : (
@@ -143,7 +145,7 @@ export const EventCategoryManager: React.FC = ({ even + +
+
+ ) : ( + + )} + + {/* Photo Selection Modal */} + {isOpen && ( +
+ +
+
+

{t('events.selectHeroPhoto')}

+ +
+
+ +
+ {photos.length === 0 ? ( +

+ {t('events.noPhotosAvailable')} +

+ ) : ( +
+ {photos.map((photo) => ( +
handleSelect(photo.id)} + className={`relative cursor-pointer rounded-lg overflow-hidden border-2 transition-all ${ + photo.id === selectedPhotoId + ? 'border-primary-500 ring-2 ring-primary-500 ring-offset-2' + : 'border-transparent hover:border-neutral-300' + }`} + > +
+ +
+ {photo.id === selectedPhotoId && ( +
+ +
+ )} +
+

{photo.filename}

+
+
+ ))} +
+ )} +
+ +
+ +
+
+
+ )} +
+ ); +}; \ No newline at end of file diff --git a/frontend/src/components/admin/ThemeCustomizer.tsx b/frontend/src/components/admin/ThemeCustomizer.tsx index 1579d60..f699def 100644 --- a/frontend/src/components/admin/ThemeCustomizer.tsx +++ b/frontend/src/components/admin/ThemeCustomizer.tsx @@ -10,15 +10,13 @@ interface ThemeCustomizerProps { onChange: (theme: ThemeConfig) => void; presetName?: string; onPresetChange?: (presetName: string) => void; - isPreviewMode?: boolean; } export const ThemeCustomizer: React.FC = ({ value, onChange, presetName = 'default', - onPresetChange, - isPreviewMode = false + onPresetChange }) => { const [localTheme, setLocalTheme] = useState(value); const [selectedPreset, setSelectedPreset] = useState(presetName); @@ -37,29 +35,27 @@ export const ThemeCustomizer: React.FC = ({ const handleChange = (key: keyof ThemeConfig, newValue: any) => { const updated = { ...localTheme, [key]: newValue }; setLocalTheme(updated); - if (isPreviewMode) { - onChange(updated); - } + // Always propagate changes to parent, not just in preview mode + onChange(updated); }; const handlePresetSelect = (presetKey: string) => { const preset = GALLERY_THEME_PRESETS[presetKey]; - console.log('Selecting preset:', presetKey, preset); // Debug log if (preset) { setSelectedPreset(presetKey); setLocalTheme(preset.config); if (onPresetChange) { onPresetChange(presetKey); } - if (isPreviewMode) { - onChange(preset.config); - } + // Always propagate preset changes + onChange(preset.config); } }; const handleApply = () => { - console.log('Applying theme:', { ...localTheme, customCss }); // Debug log - onChange({ ...localTheme, customCss }); + const themeWithCss = { ...localTheme, customCss }; + setLocalTheme(themeWithCss); + onChange(themeWithCss); }; const handleReset = () => { diff --git a/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx b/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx index 0062d6f..205a0d7 100644 --- a/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx +++ b/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx @@ -13,6 +13,7 @@ interface ThemeCustomizerEnhancedProps { onPresetChange?: (presetName: string) => void; isPreviewMode?: boolean; showGalleryLayouts?: boolean; + hideActions?: boolean; } const layoutIcons: Record = { @@ -24,14 +25,7 @@ const layoutIcons: Record = { mosaic: }; -const layoutDescriptions: Record = { - grid: 'Classic grid layout with consistent photo sizes', - masonry: 'Pinterest-style layout with varied heights', - carousel: 'Full-screen slideshow with navigation', - timeline: 'Photos organized by date', - hero: 'Featured image with grid below', - mosaic: 'Artistic layout with mixed sizes' -}; +// Layout descriptions will use translation keys export const ThemeCustomizerEnhanced: React.FC = ({ value, @@ -39,10 +33,10 @@ export const ThemeCustomizerEnhanced: React.FC = ( presetName = 'default', onPresetChange, isPreviewMode = false, - showGalleryLayouts = true + showGalleryLayouts = true, + hideActions = false }) => { const { t } = useTranslation(); - t; // Use to prevent unused warning const [localTheme, setLocalTheme] = useState(value); const [selectedPreset, setSelectedPreset] = useState(presetName); const [customCss, setCustomCss] = useState(value.customCss || ''); @@ -60,6 +54,13 @@ export const ThemeCustomizerEnhanced: React.FC = ( const handleChange = (key: keyof ThemeConfig, newValue: any) => { const updated = { ...localTheme, [key]: newValue }; setLocalTheme(updated); + + // When any change is made, mark it as custom + if (selectedPreset !== 'custom' && onPresetChange) { + setSelectedPreset('custom'); + onPresetChange('custom'); + } + if (isPreviewMode) { onChange(updated); } @@ -124,7 +125,7 @@ export const ThemeCustomizerEnhanced: React.FC = (

- Theme Presets + {t('branding.themePresets')}

{Object.entries(GALLERY_THEME_PRESETS).map(([key, theme]) => ( @@ -179,7 +180,7 @@ export const ThemeCustomizerEnhanced: React.FC = (

- Gallery Layout + {t('branding.galleryLayout')}

{(Object.keys(layoutIcons) as GalleryLayoutType[]).map((layout) => ( @@ -198,7 +199,7 @@ export const ThemeCustomizerEnhanced: React.FC = (
{layout} - {layoutDescriptions[layout]} + {t(`branding.layoutDescriptions.${layout}`)}
{localTheme.galleryLayout === layout && ( @@ -211,38 +212,38 @@ export const ThemeCustomizerEnhanced: React.FC = ( {/* Layout-specific settings */} {localTheme.galleryLayout && (
-

Layout Settings

+

{t('branding.layoutSettings')}

{/* Common settings */}
@@ -251,11 +252,11 @@ export const ThemeCustomizerEnhanced: React.FC = ( {localTheme.galleryLayout === 'grid' && (
- + = ( />
- + = ( />
- + = ( onChange={(e) => updateGallerySettings('carouselAutoplay', e.target.checked)} className="rounded" /> - Enable Autoplay + {t('branding.enableAutoplay')}
{localTheme.gallerySettings?.carouselAutoplay && (
= ( {localTheme.galleryLayout === 'timeline' && (
)} @@ -354,12 +355,12 @@ export const ThemeCustomizerEnhanced: React.FC = (

- Colors + {t('branding.colors')}

= (
= (
= (
= (

- Typography & Style + {t('branding.typographyAndStyle')}

handleChange('headingFontFamily', e.target.value)} className="w-full px-3 py-2 border border-neutral-300 rounded-lg" > - + @@ -487,64 +488,64 @@ export const ThemeCustomizerEnhanced: React.FC = (
@@ -553,35 +554,44 @@ export const ThemeCustomizerEnhanced: React.FC = ( {/* Custom CSS */} -

Custom CSS

+

{t('branding.customCSS')}