Enhance email templates with clickable links, branding, and improved styling
- Add clickable gallery links in all email templates - Include application logo in email header and footer (custom or PicPeak default) - Redesign emails with professional styling matching gallery login page - Gray background with white content box - PicPeak green header with centered logo - Clean typography and proper spacing - Responsive design for mobile devices - Styled call-to-action buttons - Footer with branding and copyright - Update email processor to fetch branding settings dynamically - Use proper API URLs for logo images in emails 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Binary file not shown.
@@ -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');
|
||||
});
|
||||
};
|
||||
@@ -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: `<h2>Gallery Successfully Created</h2>
|
||||
<p>Dear {{host_name}},</p>
|
||||
<p>Your photo gallery "{{event_name}}" has been successfully created!</p>
|
||||
<p><strong>Gallery Details:</strong></p>
|
||||
<ul>
|
||||
<li>Event Date: {{event_date}}</li>
|
||||
<li>Gallery Link: <a href="{{gallery_link}}" style="color: #5C8762; text-decoration: none;">{{gallery_link}}</a></li>
|
||||
<li>Password: {{gallery_password}}</li>
|
||||
<li>Valid Until: {{expiry_date}}</li>
|
||||
</ul>
|
||||
<p>Share this link and password with your guests so they can view and download photos.</p>
|
||||
<p><a href="{{gallery_link}}" style="display: inline-block; padding: 10px 20px; background-color: #5C8762; color: white; text-decoration: none; border-radius: 5px;">View Gallery</a></p>`,
|
||||
body_html_de: `<h2>Galerie erfolgreich erstellt</h2>
|
||||
<p>Liebe(r) {{host_name}},</p>
|
||||
<p>Ihre Fotogalerie "{{event_name}}" wurde erfolgreich erstellt!</p>
|
||||
<p><strong>Galerie-Details:</strong></p>
|
||||
<ul>
|
||||
<li>Veranstaltungsdatum: {{event_date}}</li>
|
||||
<li>Galerie-Link: <a href="{{gallery_link}}" style="color: #5C8762; text-decoration: none;">{{gallery_link}}</a></li>
|
||||
<li>Passwort: {{gallery_password}}</li>
|
||||
<li>Gültig bis: {{expiry_date}}</li>
|
||||
</ul>
|
||||
<p>Teilen Sie diesen Link und das Passwort mit Ihren Gästen, damit sie Fotos ansehen und herunterladen können.</p>
|
||||
<p><a href="{{gallery_link}}" style="display: inline-block; padding: 10px 20px; background-color: #5C8762; color: white; text-decoration: none; border-radius: 5px;">Galerie anzeigen</a></p>`
|
||||
});
|
||||
|
||||
// Update expiration warning template
|
||||
await knex('email_templates')
|
||||
.where('template_key', 'expiration_warning')
|
||||
.update({
|
||||
body_html_en: `<h2>Gallery Expiring Soon</h2>
|
||||
<p>Dear {{host_name}},</p>
|
||||
<p>Your photo gallery "{{event_name}}" will expire in {{days_remaining}} days.</p>
|
||||
<p>After expiration, the gallery will be archived and no longer accessible to guests.</p>
|
||||
<p><a href="{{gallery_link}}" style="display: inline-block; padding: 10px 20px; background-color: #5C8762; color: white; text-decoration: none; border-radius: 5px;">Visit Gallery</a></p>
|
||||
<p>Gallery Link: <a href="{{gallery_link}}" style="color: #5C8762;">{{gallery_link}}</a></p>`,
|
||||
body_html_de: `<h2>Galerie läuft bald ab</h2>
|
||||
<p>Liebe(r) {{host_name}},</p>
|
||||
<p>Ihre Fotogalerie "{{event_name}}" läuft in {{days_remaining}} Tagen ab.</p>
|
||||
<p>Nach Ablauf wird die Galerie archiviert und ist für Gäste nicht mehr zugänglich.</p>
|
||||
<p><a href="{{gallery_link}}" style="display: inline-block; padding: 10px 20px; background-color: #5C8762; color: white; text-decoration: none; border-radius: 5px;">Galerie besuchen</a></p>
|
||||
<p>Galerie-Link: <a href="{{gallery_link}}" style="color: #5C8762;">{{gallery_link}}</a></p>`
|
||||
});
|
||||
|
||||
// Update gallery expired template
|
||||
await knex('email_templates')
|
||||
.where('template_key', 'gallery_expired')
|
||||
.update({
|
||||
body_html_en: `<h2>Gallery Expired</h2>
|
||||
<p>Your photo gallery for "{{event_name}}" has expired and is no longer accessible.</p>
|
||||
<p>The photos have been safely archived. If you need access again, please contact the administrator at <a href="mailto:{{admin_email}}" style="color: #5C8762;">{{admin_email}}</a>.</p>
|
||||
<p>Thank you for using our photo sharing service!</p>
|
||||
<p>Best regards,<br>The Photo Sharing Team</p>`,
|
||||
body_html_de: `<h2>Galerie abgelaufen</h2>
|
||||
<p>Ihre Fotogalerie für "{{event_name}}" ist abgelaufen und nicht mehr zugänglich.</p>
|
||||
<p>Die Fotos wurden zur sicheren Aufbewahrung archiviert. Wenn Sie wieder Zugriff benötigen, wenden Sie sich bitte an den Administrator unter <a href="mailto:{{admin_email}}" style="color: #5C8762;">{{admin_email}}</a>.</p>
|
||||
<p>Vielen Dank für die Nutzung unseres Foto-Sharing-Services!</p>
|
||||
<p>Mit freundlichen Grüßen,<br>Das Foto-Sharing-Team</p>`
|
||||
});
|
||||
};
|
||||
|
||||
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: `<h2>Gallery Successfully Created</h2>
|
||||
<p>Dear {{host_name}},</p>
|
||||
<p>Your photo gallery "{{event_name}}" has been successfully created!</p>
|
||||
<p><strong>Gallery Details:</strong></p>
|
||||
<ul>
|
||||
<li>Event Date: {{event_date}}</li>
|
||||
<li>Gallery Link: {{gallery_link}}</li>
|
||||
<li>Password: {{gallery_password}}</li>
|
||||
<li>Valid Until: {{expiry_date}}</li>
|
||||
</ul>
|
||||
<p>Share this link and password with your guests so they can view and download photos.</p>`,
|
||||
body_html_de: `<h2>Galerie erfolgreich erstellt</h2>
|
||||
<p>Liebe(r) {{host_name}},</p>
|
||||
<p>Ihre Fotogalerie "{{event_name}}" wurde erfolgreich erstellt!</p>
|
||||
<p><strong>Galerie-Details:</strong></p>
|
||||
<ul>
|
||||
<li>Veranstaltungsdatum: {{event_date}}</li>
|
||||
<li>Galerie-Link: {{gallery_link}}</li>
|
||||
<li>Passwort: {{gallery_password}}</li>
|
||||
<li>Gültig bis: {{expiry_date}}</li>
|
||||
</ul>
|
||||
<p>Teilen Sie diesen Link und das Passwort mit Ihren Gästen, damit sie Fotos ansehen und herunterladen können.</p>`
|
||||
});
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="${language}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${subject}</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background-color: #f5f5f5;
|
||||
color: #333;
|
||||
}
|
||||
.email-wrapper {
|
||||
background-color: #f5f5f5;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
.email-container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background-color: #ffffff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
.email-header {
|
||||
background-color: #5C8762;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
}
|
||||
.logo {
|
||||
max-width: 180px;
|
||||
height: auto;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.email-content {
|
||||
padding: 40px 30px;
|
||||
}
|
||||
.email-content h2 {
|
||||
color: #5C8762;
|
||||
margin-top: 0;
|
||||
margin-bottom: 20px;
|
||||
font-size: 24px;
|
||||
}
|
||||
.email-content p {
|
||||
line-height: 1.6;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.email-content ul {
|
||||
background-color: #f9f9f9;
|
||||
padding: 20px 20px 20px 40px;
|
||||
border-radius: 5px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.email-content li {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.button {
|
||||
display: inline-block;
|
||||
padding: 12px 30px;
|
||||
background-color: #5C8762;
|
||||
color: white !important;
|
||||
text-decoration: none;
|
||||
border-radius: 5px;
|
||||
font-weight: 500;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.button:hover {
|
||||
background-color: #4a6f4f;
|
||||
}
|
||||
.email-footer {
|
||||
background-color: #f9f9f9;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
.email-footer img {
|
||||
max-width: 120px;
|
||||
height: auto;
|
||||
margin-bottom: 15px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.email-footer p {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
margin: 5px 0;
|
||||
}
|
||||
a {
|
||||
color: #5C8762;
|
||||
text-decoration: underline;
|
||||
}
|
||||
a:hover {
|
||||
color: #4a6f4f;
|
||||
}
|
||||
strong {
|
||||
color: #333;
|
||||
}
|
||||
@media only screen and (max-width: 600px) {
|
||||
.email-wrapper {
|
||||
padding: 20px 10px;
|
||||
}
|
||||
.email-content {
|
||||
padding: 30px 20px;
|
||||
}
|
||||
.email-header {
|
||||
padding: 20px;
|
||||
}
|
||||
.logo {
|
||||
max-width: 150px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="email-wrapper">
|
||||
<div class="email-container">
|
||||
<div class="email-header">
|
||||
<img src="${logoFullUrl}" alt="${companyName}" class="logo">
|
||||
</div>
|
||||
<div class="email-content">
|
||||
${htmlBody}
|
||||
</div>
|
||||
<div class="email-footer">
|
||||
<img src="${logoFullUrl}" alt="${companyName}">
|
||||
<p>${companyName}</p>
|
||||
<p style="font-size: 12px; color: #999;">© ${new Date().getFullYear()} ${companyName}. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
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({
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -1,5 +1,4 @@
|
||||
# docker-compose.yml - Development configuration
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
backend:
|
||||
|
||||
+2
-2
@@ -2,9 +2,9 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + React + TS</title>
|
||||
<title>PicPeak - Photo Sharing Platform</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1014 B |
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
@@ -21,9 +21,12 @@ export const GlobalThemeProvider: React.FC<GlobalThemeProviderProps> = ({ 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);
|
||||
}
|
||||
|
||||
@@ -42,32 +42,31 @@ export const MaintenanceMode: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-neutral-50 flex flex-col">
|
||||
{/* Header with branding */}
|
||||
{(settings?.branding_logo_url || settings?.branding_company_name) && (
|
||||
<div className="bg-white border-b border-neutral-200 py-4">
|
||||
<div className="container">
|
||||
<div className="flex items-center justify-center">
|
||||
{settings.branding_logo_url ? (
|
||||
<img
|
||||
src={settings.branding_logo_url.startsWith('http')
|
||||
? settings.branding_logo_url
|
||||
: `${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${settings.branding_logo_url}`
|
||||
}
|
||||
alt={settings.branding_company_name || 'Company Logo'}
|
||||
className="h-12 w-auto object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center">
|
||||
<h2 className="text-xl font-semibold text-neutral-800">{settings.branding_company_name}</h2>
|
||||
{settings.branding_company_tagline && (
|
||||
<p className="text-sm text-neutral-600">{settings.branding_company_tagline}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Header with branding - Always show, with PicPeak logo as fallback */}
|
||||
<div className="bg-white border-b border-neutral-200 py-4">
|
||||
<div className="container">
|
||||
<div className="flex items-center justify-center">
|
||||
<img
|
||||
src={settings?.branding_logo_url ?
|
||||
(settings.branding_logo_url.startsWith('http')
|
||||
? settings.branding_logo_url
|
||||
: `${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${settings.branding_logo_url}`)
|
||||
: '/picpeak-logo-transparent.png'
|
||||
}
|
||||
alt={settings?.branding_company_name || 'PicPeak'}
|
||||
className="h-12 w-auto object-contain"
|
||||
/>
|
||||
{settings?.branding_company_name && settings.branding_company_name !== 'PicPeak' && (
|
||||
<div className="ml-4 text-center">
|
||||
<h2 className="text-xl font-semibold text-neutral-800">{settings.branding_company_name}</h2>
|
||||
{settings.branding_company_tagline && (
|
||||
<p className="text-sm text-neutral-600">{settings.branding_company_tagline}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 flex items-center justify-center p-4">
|
||||
|
||||
@@ -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<AdminHeaderProps> = ({ 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<AdminHeaderProps> = ({ 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<AdminHeaderProps> = ({ 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<AdminHeaderProps> = ({ onMenuClick }) => {
|
||||
<header className="sticky top-0 z-30 bg-white border-b border-neutral-200">
|
||||
<div className="px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
{/* Mobile menu button */}
|
||||
<button
|
||||
onClick={onMenuClick}
|
||||
className="lg:hidden text-neutral-500 hover:text-neutral-700"
|
||||
>
|
||||
<Menu className="w-6 h-6" />
|
||||
</button>
|
||||
{/* Left side - Menu button and Date */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={onMenuClick}
|
||||
className="lg:hidden text-neutral-500 hover:text-neutral-700"
|
||||
>
|
||||
<Menu className="w-6 h-6" />
|
||||
</button>
|
||||
|
||||
{/* Date display */}
|
||||
<div className="hidden lg:block">
|
||||
<p className="text-base text-neutral-700">
|
||||
{format(new Date(), 'PPPP')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop breadcrumb or page title could go here */}
|
||||
<div className="hidden lg:block">
|
||||
<h2 className="text-lg font-semibold text-neutral-900">
|
||||
{format(new Date(), 'PPPP')}
|
||||
</h2>
|
||||
{/* Center - PicPeak branding */}
|
||||
<div className="absolute left-1/2 transform -translate-x-1/2">
|
||||
<span className="text-2xl" style={{ fontFamily: 'Poppins, sans-serif', fontWeight: 600, color: '#145346' }}>PicPeak</span>
|
||||
</div>
|
||||
|
||||
{/* Right side actions */}
|
||||
@@ -111,26 +120,26 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
||||
<button
|
||||
onClick={() => markAllAsReadMutation.mutate()}
|
||||
className="text-xs text-primary-600 hover:text-primary-700 flex items-center gap-1"
|
||||
title="Mark all as read"
|
||||
title={t('admin.markAllRead')}
|
||||
>
|
||||
<CheckCircle className="w-3 h-3" />
|
||||
Mark all read
|
||||
{t('admin.markAllRead')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => clearOldMutation.mutate()}
|
||||
className="text-xs text-neutral-600 hover:text-neutral-700 flex items-center gap-1"
|
||||
title="Clear old notifications"
|
||||
title={t('admin.clearOld')}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
Clear old
|
||||
{t('admin.clearOld')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
{notifications.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-neutral-500">
|
||||
No notifications
|
||||
{t('admin.noNotificationsMessage')}
|
||||
</div>
|
||||
) : (
|
||||
notifications.map((notification) => {
|
||||
@@ -151,7 +160,7 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
||||
{notificationsService.formatNotificationMessage(notification)}
|
||||
</p>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
{formatDistanceToNow(new Date(notification.createdAt), { addSuffix: true })}
|
||||
{formatTimeAgo(notification.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -166,7 +175,7 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
||||
onClick={() => setShowNotifications(false)}
|
||||
className="text-sm text-primary-600 hover:text-primary-700"
|
||||
>
|
||||
Close
|
||||
{t('admin.close')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
Archive,
|
||||
BarChart3,
|
||||
Settings,
|
||||
Camera,
|
||||
X,
|
||||
Palette,
|
||||
FileText
|
||||
@@ -53,7 +52,7 @@ export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose }) =
|
||||
{/* Logo/Brand */}
|
||||
<div className="flex items-center justify-between h-16 px-6 border-b border-neutral-200 flex-shrink-0">
|
||||
<div className="flex items-center">
|
||||
<Camera className="w-8 h-8 text-primary-600" />
|
||||
<img src="/picpeak-kamera-transparent.png" alt="Camera" className="w-8 h-8 object-contain" />
|
||||
<span className="ml-2 text-xl font-bold text-neutral-900">{t('admin.title')}</span>
|
||||
</div>
|
||||
<button
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Plus, X, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { categoriesService, type PhotoCategory } from '../../services/categories.service';
|
||||
import { Button } from '../common';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface EventCategoryManagerProps {
|
||||
eventId: number;
|
||||
@@ -11,6 +12,7 @@ interface EventCategoryManagerProps {
|
||||
|
||||
export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ eventId }) => {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [newCategoryName, setNewCategoryName] = useState('');
|
||||
|
||||
@@ -33,12 +35,12 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['event-categories', eventId] });
|
||||
toast.success('Category created successfully');
|
||||
toast.success(t('categories.categoryCreatedSuccess'));
|
||||
setNewCategoryName('');
|
||||
setIsAdding(false);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.error || 'Failed to create category');
|
||||
toast.error(error.response?.data?.error || t('categories.failedToCreateCategory'));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -47,10 +49,10 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
mutationFn: categoriesService.deleteCategory,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['event-categories', eventId] });
|
||||
toast.success('Category deleted successfully');
|
||||
toast.success(t('categories.categoryDeletedSuccess'));
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.error || 'Failed to delete category');
|
||||
toast.error(error.response?.data?.error || t('categories.failedToDeleteCategory'));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -61,7 +63,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
};
|
||||
|
||||
const handleDelete = (category: PhotoCategory) => {
|
||||
if (window.confirm(`Are you sure you want to delete "${category.name}"?`)) {
|
||||
if (window.confirm(t('categories.deleteConfirm', { name: category.name }))) {
|
||||
deleteMutation.mutate(category.id);
|
||||
}
|
||||
};
|
||||
@@ -77,7 +79,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-sm font-medium text-neutral-700">Event-Specific Categories</h3>
|
||||
<h3 className="text-sm font-medium text-neutral-700">{t('categories.eventSpecificCategories')}</h3>
|
||||
{!isAdding && (
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -85,7 +87,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
onClick={() => setIsAdding(true)}
|
||||
leftIcon={<Plus className="w-3 h-3" />}
|
||||
>
|
||||
Add
|
||||
{t('common.add')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -98,7 +100,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ 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<EventCategoryManagerProps> = ({ even
|
||||
{createMutation.isPending ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
'Add'
|
||||
t('common.add')
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
@@ -122,7 +124,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
setNewCategoryName('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -130,7 +132,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
{/* Event categories list */}
|
||||
{eventCategories.length === 0 ? (
|
||||
<p className="text-sm text-neutral-500 italic">
|
||||
No event-specific categories. Global categories are available by default.
|
||||
{t('categories.noEventSpecificCategories')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
@@ -143,7 +145,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
<button
|
||||
onClick={() => handleDelete(category)}
|
||||
className="p-1 text-neutral-400 hover:text-red-600 transition-colors"
|
||||
title="Delete category"
|
||||
title={t('categories.deleteCategoryTitle')}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{deleteMutation.isPending ? (
|
||||
@@ -159,7 +161,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
|
||||
{/* Show available global categories */}
|
||||
<div className="mt-4 pt-3 border-t border-neutral-200">
|
||||
<p className="text-xs font-medium text-neutral-500 mb-2">Global Categories (always available):</p>
|
||||
<p className="text-xs font-medium text-neutral-500 mb-2">{t('categories.globalCategoriesAlwaysAvailable')}</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{categories
|
||||
.filter(cat => cat.is_global)
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import React, { useState } from 'react';
|
||||
import { X, Image as ImageIcon, Check } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Card, AuthenticatedImage } from '../common';
|
||||
import { AdminPhoto } from '../../services/photos.service';
|
||||
|
||||
interface HeroPhotoSelectorProps {
|
||||
photos: AdminPhoto[];
|
||||
currentHeroPhotoId?: number | null;
|
||||
onSelect: (photoId: number | null) => void;
|
||||
isEditing: boolean;
|
||||
}
|
||||
|
||||
export const HeroPhotoSelector: React.FC<HeroPhotoSelectorProps> = ({
|
||||
photos,
|
||||
currentHeroPhotoId,
|
||||
onSelect,
|
||||
isEditing
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedPhotoId, setSelectedPhotoId] = useState<number | null>(currentHeroPhotoId || null);
|
||||
|
||||
const currentHeroPhoto = photos.find(p => p.id === currentHeroPhotoId);
|
||||
|
||||
const handleSelect = (photoId: number) => {
|
||||
setSelectedPhotoId(photoId);
|
||||
onSelect(photoId);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleRemove = () => {
|
||||
setSelectedPhotoId(null);
|
||||
onSelect(null);
|
||||
};
|
||||
|
||||
if (!isEditing) {
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('events.heroPhoto')}
|
||||
</label>
|
||||
{currentHeroPhoto ? (
|
||||
<div className="relative w-full h-48 rounded-lg overflow-hidden bg-neutral-100">
|
||||
<AuthenticatedImage
|
||||
src={currentHeroPhoto.thumbnail_url || currentHeroPhoto.url}
|
||||
alt={currentHeroPhoto.filename}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-neutral-500">{t('events.noHeroPhotoSelected')}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('events.heroPhoto')}
|
||||
</label>
|
||||
<p className="text-xs text-neutral-500 mb-2">
|
||||
{t('events.heroPhotoHelp')}
|
||||
</p>
|
||||
|
||||
{currentHeroPhoto ? (
|
||||
<div className="relative w-full h-48 rounded-lg overflow-hidden bg-neutral-100 mb-2">
|
||||
<AuthenticatedImage
|
||||
src={currentHeroPhoto.thumbnail_url || currentHeroPhoto.url}
|
||||
alt={currentHeroPhoto.filename}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute top-2 right-2 flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="bg-white/90 hover:bg-white"
|
||||
>
|
||||
{t('common.change')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleRemove}
|
||||
leftIcon={<X className="w-4 h-4" />}
|
||||
className="bg-white/90 hover:bg-white"
|
||||
>
|
||||
{t('common.remove')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<ImageIcon className="w-4 h-4" />}
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="w-full"
|
||||
>
|
||||
{t('events.selectHeroPhoto')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Photo Selection Modal */}
|
||||
{isOpen && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
|
||||
<Card className="max-w-4xl w-full max-h-[90vh] overflow-hidden">
|
||||
<div className="p-6 border-b border-neutral-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">{t('events.selectHeroPhoto')}</h2>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="p-2 hover:bg-neutral-100 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 overflow-y-auto max-h-[calc(90vh-180px)]">
|
||||
{photos.length === 0 ? (
|
||||
<p className="text-center text-neutral-500 py-8">
|
||||
{t('events.noPhotosAvailable')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
|
||||
{photos.map((photo) => (
|
||||
<div
|
||||
key={photo.id}
|
||||
onClick={() => 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'
|
||||
}`}
|
||||
>
|
||||
<div className="aspect-square bg-neutral-100">
|
||||
<AuthenticatedImage
|
||||
src={photo.thumbnail_url || photo.url}
|
||||
alt={photo.filename}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
{photo.id === selectedPhotoId && (
|
||||
<div className="absolute top-2 right-2 bg-primary-500 text-white rounded-full p-1">
|
||||
<Check className="w-4 h-4" />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/60 to-transparent p-2">
|
||||
<p className="text-white text-xs truncate">{photo.filename}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-6 border-t border-neutral-200 flex justify-end gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -10,15 +10,13 @@ interface ThemeCustomizerProps {
|
||||
onChange: (theme: ThemeConfig) => void;
|
||||
presetName?: string;
|
||||
onPresetChange?: (presetName: string) => void;
|
||||
isPreviewMode?: boolean;
|
||||
}
|
||||
|
||||
export const ThemeCustomizer: React.FC<ThemeCustomizerProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
presetName = 'default',
|
||||
onPresetChange,
|
||||
isPreviewMode = false
|
||||
onPresetChange
|
||||
}) => {
|
||||
const [localTheme, setLocalTheme] = useState<ThemeConfig>(value);
|
||||
const [selectedPreset, setSelectedPreset] = useState(presetName);
|
||||
@@ -37,29 +35,27 @@ export const ThemeCustomizer: React.FC<ThemeCustomizerProps> = ({
|
||||
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 = () => {
|
||||
|
||||
@@ -13,6 +13,7 @@ interface ThemeCustomizerEnhancedProps {
|
||||
onPresetChange?: (presetName: string) => void;
|
||||
isPreviewMode?: boolean;
|
||||
showGalleryLayouts?: boolean;
|
||||
hideActions?: boolean;
|
||||
}
|
||||
|
||||
const layoutIcons: Record<GalleryLayoutType, React.ReactNode> = {
|
||||
@@ -24,14 +25,7 @@ const layoutIcons: Record<GalleryLayoutType, React.ReactNode> = {
|
||||
mosaic: <LayoutGrid className="w-5 h-5" />
|
||||
};
|
||||
|
||||
const layoutDescriptions: Record<GalleryLayoutType, string> = {
|
||||
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<ThemeCustomizerEnhancedProps> = ({
|
||||
value,
|
||||
@@ -39,10 +33,10 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
presetName = 'default',
|
||||
onPresetChange,
|
||||
isPreviewMode = false,
|
||||
showGalleryLayouts = true
|
||||
showGalleryLayouts = true,
|
||||
hideActions = false
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
t; // Use to prevent unused warning
|
||||
const [localTheme, setLocalTheme] = useState<ThemeConfig>(value);
|
||||
const [selectedPreset, setSelectedPreset] = useState(presetName);
|
||||
const [customCss, setCustomCss] = useState(value.customCss || '');
|
||||
@@ -60,6 +54,13 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
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<ThemeCustomizerEnhancedProps> = (
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
|
||||
<Sparkles className="w-5 h-5" />
|
||||
Theme Presets
|
||||
{t('branding.themePresets')}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{Object.entries(GALLERY_THEME_PRESETS).map(([key, theme]) => (
|
||||
@@ -179,7 +180,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
|
||||
<Layout className="w-5 h-5" />
|
||||
Gallery Layout
|
||||
{t('branding.galleryLayout')}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{(Object.keys(layoutIcons) as GalleryLayoutType[]).map((layout) => (
|
||||
@@ -198,7 +199,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
</div>
|
||||
<span className="font-medium text-sm capitalize">{layout}</span>
|
||||
<span className="text-xs text-neutral-600 mt-1">
|
||||
{layoutDescriptions[layout]}
|
||||
{t(`branding.layoutDescriptions.${layout}`)}
|
||||
</span>
|
||||
</div>
|
||||
{localTheme.galleryLayout === layout && (
|
||||
@@ -211,38 +212,38 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
{/* Layout-specific settings */}
|
||||
{localTheme.galleryLayout && (
|
||||
<div className="mt-6 space-y-4 pt-6 border-t border-neutral-200">
|
||||
<h4 className="font-medium text-sm text-neutral-700">Layout Settings</h4>
|
||||
<h4 className="font-medium text-sm text-neutral-700">{t('branding.layoutSettings')}</h4>
|
||||
|
||||
{/* Common settings */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
Photo Spacing
|
||||
{t('branding.photoSpacing')}
|
||||
</label>
|
||||
<select
|
||||
value={localTheme.gallerySettings?.spacing || 'normal'}
|
||||
onChange={(e) => updateGallerySettings('spacing', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
|
||||
>
|
||||
<option value="tight">Tight</option>
|
||||
<option value="normal">Normal</option>
|
||||
<option value="relaxed">Relaxed</option>
|
||||
<option value="tight">{t('branding.spacing.tight')}</option>
|
||||
<option value="normal">{t('branding.spacing.normal')}</option>
|
||||
<option value="relaxed">{t('branding.spacing.relaxed')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
Photo Animation
|
||||
{t('branding.photoAnimation')}
|
||||
</label>
|
||||
<select
|
||||
value={localTheme.gallerySettings?.photoAnimation || 'fade'}
|
||||
onChange={(e) => updateGallerySettings('photoAnimation', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
|
||||
>
|
||||
<option value="none">None</option>
|
||||
<option value="fade">Fade</option>
|
||||
<option value="scale">Scale</option>
|
||||
<option value="slide">Slide</option>
|
||||
<option value="none">{t('branding.animation.none')}</option>
|
||||
<option value="fade">{t('branding.animation.fade')}</option>
|
||||
<option value="scale">{t('branding.animation.scale')}</option>
|
||||
<option value="slide">{t('branding.animation.slide')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -251,11 +252,11 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
{localTheme.galleryLayout === 'grid' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
Columns
|
||||
{t('branding.columns')}
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="text-xs text-neutral-600">Mobile</label>
|
||||
<label className="text-xs text-neutral-600">{t('branding.mobile')}</label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
@@ -268,7 +269,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-neutral-600">Tablet</label>
|
||||
<label className="text-xs text-neutral-600">{t('branding.tablet')}</label>
|
||||
<Input
|
||||
type="number"
|
||||
min="2"
|
||||
@@ -281,7 +282,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-neutral-600">Desktop</label>
|
||||
<label className="text-xs text-neutral-600">{t('branding.desktop')}</label>
|
||||
<Input
|
||||
type="number"
|
||||
min="3"
|
||||
@@ -308,13 +309,13 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
onChange={(e) => updateGallerySettings('carouselAutoplay', e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-sm font-medium text-neutral-700">Enable Autoplay</span>
|
||||
<span className="text-sm font-medium text-neutral-700">{t('branding.enableAutoplay')}</span>
|
||||
</label>
|
||||
</div>
|
||||
{localTheme.gallerySettings?.carouselAutoplay && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
Autoplay Interval (seconds)
|
||||
{t('branding.autoplayInterval')}
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
@@ -332,16 +333,16 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
{localTheme.galleryLayout === 'timeline' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
Group Photos By
|
||||
{t('branding.groupPhotosBy')}
|
||||
</label>
|
||||
<select
|
||||
value={localTheme.gallerySettings?.timelineGrouping || 'day'}
|
||||
onChange={(e) => updateGallerySettings('timelineGrouping', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
|
||||
>
|
||||
<option value="day">Day</option>
|
||||
<option value="week">Week</option>
|
||||
<option value="month">Month</option>
|
||||
<option value="day">{t('branding.grouping.day')}</option>
|
||||
<option value="week">{t('branding.grouping.week')}</option>
|
||||
<option value="month">{t('branding.grouping.month')}</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
@@ -354,12 +355,12 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
|
||||
<Palette className="w-5 h-5" />
|
||||
Colors
|
||||
{t('branding.colors')}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
Primary Color
|
||||
{t('branding.primaryColor')}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
@@ -379,7 +380,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
Accent Color
|
||||
{t('branding.accentColor')}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
@@ -399,7 +400,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
Background Color
|
||||
{t('branding.backgroundColor')}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
@@ -419,7 +420,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
Text Color
|
||||
{t('branding.textColor')}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
@@ -443,13 +444,13 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
|
||||
<Type className="w-5 h-5" />
|
||||
Typography & Style
|
||||
{t('branding.typographyAndStyle')}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
Body Font
|
||||
{t('branding.bodyFont')}
|
||||
</label>
|
||||
<select
|
||||
value={localTheme.fontFamily || 'Inter, sans-serif'}
|
||||
@@ -468,14 +469,14 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
Heading Font
|
||||
{t('branding.headingFont')}
|
||||
</label>
|
||||
<select
|
||||
value={localTheme.headingFontFamily || localTheme.fontFamily || 'Inter, sans-serif'}
|
||||
onChange={(e) => handleChange('headingFontFamily', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
|
||||
>
|
||||
<option value="">Same as body</option>
|
||||
<option value="">{t('branding.sameAsBody')}</option>
|
||||
<option value="'Playfair Display', serif">Playfair Display</option>
|
||||
<option value="'Montserrat', sans-serif">Montserrat</option>
|
||||
<option value="Georgia, serif">Georgia</option>
|
||||
@@ -487,64 +488,64 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
Font Size
|
||||
{t('branding.fontSize')}
|
||||
</label>
|
||||
<select
|
||||
value={localTheme.fontSize || 'normal'}
|
||||
onChange={(e) => handleChange('fontSize', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
|
||||
>
|
||||
<option value="small">Small</option>
|
||||
<option value="normal">Normal</option>
|
||||
<option value="large">Large</option>
|
||||
<option value="small">{t('branding.fontSizes.small')}</option>
|
||||
<option value="normal">{t('branding.fontSizes.normal')}</option>
|
||||
<option value="large">{t('branding.fontSizes.large')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
Border Radius
|
||||
{t('branding.borderRadius')}
|
||||
</label>
|
||||
<select
|
||||
value={localTheme.borderRadius || 'md'}
|
||||
onChange={(e) => handleChange('borderRadius', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
|
||||
>
|
||||
<option value="none">None</option>
|
||||
<option value="sm">Small</option>
|
||||
<option value="md">Medium</option>
|
||||
<option value="lg">Large</option>
|
||||
<option value="none">{t('branding.borderRadiusOptions.none')}</option>
|
||||
<option value="sm">{t('branding.borderRadiusOptions.small')}</option>
|
||||
<option value="md">{t('branding.borderRadiusOptions.medium')}</option>
|
||||
<option value="lg">{t('branding.borderRadiusOptions.large')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
Shadow Style
|
||||
{t('branding.shadowStyle')}
|
||||
</label>
|
||||
<select
|
||||
value={localTheme.shadowStyle || 'normal'}
|
||||
onChange={(e) => handleChange('shadowStyle', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
|
||||
>
|
||||
<option value="none">None</option>
|
||||
<option value="subtle">Subtle</option>
|
||||
<option value="normal">Normal</option>
|
||||
<option value="dramatic">Dramatic</option>
|
||||
<option value="none">{t('branding.shadowOptions.none')}</option>
|
||||
<option value="subtle">{t('branding.shadowOptions.subtle')}</option>
|
||||
<option value="normal">{t('branding.shadowOptions.normal')}</option>
|
||||
<option value="dramatic">{t('branding.shadowOptions.dramatic')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
Background
|
||||
{t('branding.backgroundPattern')}
|
||||
</label>
|
||||
<select
|
||||
value={localTheme.backgroundPattern || 'none'}
|
||||
onChange={(e) => handleChange('backgroundPattern', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
|
||||
>
|
||||
<option value="none">None</option>
|
||||
<option value="dots">Dots</option>
|
||||
<option value="grid">Grid</option>
|
||||
<option value="waves">Waves</option>
|
||||
<option value="none">{t('branding.backgroundOptions.none')}</option>
|
||||
<option value="dots">{t('branding.backgroundOptions.dots')}</option>
|
||||
<option value="grid">{t('branding.backgroundOptions.grid')}</option>
|
||||
<option value="waves">{t('branding.backgroundOptions.waves')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -553,35 +554,44 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
|
||||
{/* Custom CSS */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 mb-4">Custom CSS</h3>
|
||||
<h3 className="text-lg font-semibold text-neutral-900 mb-4">{t('branding.customCSS')}</h3>
|
||||
<textarea
|
||||
value={customCss}
|
||||
onChange={(e) => setCustomCss(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setCustomCss(e.target.value);
|
||||
// Mark as custom when CSS is added
|
||||
if (e.target.value && selectedPreset !== 'custom' && onPresetChange) {
|
||||
setSelectedPreset('custom');
|
||||
onPresetChange('custom');
|
||||
}
|
||||
}}
|
||||
placeholder="/* Add custom CSS here */"
|
||||
className="w-full h-32 px-3 py-2 font-mono text-sm border border-neutral-300 rounded-lg"
|
||||
/>
|
||||
<p className="mt-2 text-sm text-neutral-600">
|
||||
Advanced: Add custom CSS to further customize the appearance
|
||||
{t('branding.customCSSHelp')}
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
leftIcon={<RotateCcw className="w-4 h-4" />}
|
||||
onClick={handleReset}
|
||||
>
|
||||
Reset to Default
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
leftIcon={<Palette className="w-4 h-4" />}
|
||||
onClick={handleApply}
|
||||
>
|
||||
Apply Theme
|
||||
</Button>
|
||||
</div>
|
||||
{!hideActions && (
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
leftIcon={<RotateCcw className="w-4 h-4" />}
|
||||
onClick={handleReset}
|
||||
>
|
||||
{t('branding.resetToDefault')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
leftIcon={<Palette className="w-4 h-4" />}
|
||||
onClick={handleApply}
|
||||
>
|
||||
{t('branding.applyTheme')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,161 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Palette,
|
||||
Type,
|
||||
Grid3X3,
|
||||
Layers,
|
||||
Play,
|
||||
Clock,
|
||||
Image,
|
||||
LayoutGrid,
|
||||
Layout
|
||||
} from 'lucide-react';
|
||||
import { ThemeConfig, GalleryLayoutType, GALLERY_THEME_PRESETS } from '../../types/theme.types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface ThemeDisplayProps {
|
||||
theme: ThemeConfig | string;
|
||||
presetName?: string;
|
||||
className?: string;
|
||||
showDetails?: boolean;
|
||||
}
|
||||
|
||||
const layoutIcons: Record<GalleryLayoutType, React.ReactNode> = {
|
||||
grid: <Grid3X3 className="w-4 h-4" />,
|
||||
masonry: <Layers className="w-4 h-4" />,
|
||||
carousel: <Play className="w-4 h-4" />,
|
||||
timeline: <Clock className="w-4 h-4" />,
|
||||
hero: <Image className="w-4 h-4" />,
|
||||
mosaic: <LayoutGrid className="w-4 h-4" />
|
||||
};
|
||||
|
||||
export const ThemeDisplay: React.FC<ThemeDisplayProps> = ({
|
||||
theme,
|
||||
presetName,
|
||||
className = '',
|
||||
showDetails = true
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Parse theme if it's a string
|
||||
let themeConfig: ThemeConfig | null = null;
|
||||
let themeName = t('branding.theme');
|
||||
|
||||
if (typeof theme === 'string') {
|
||||
try {
|
||||
if (theme.startsWith('{')) {
|
||||
themeConfig = JSON.parse(theme);
|
||||
} else {
|
||||
// Legacy theme name - find matching preset
|
||||
const preset = Object.entries(GALLERY_THEME_PRESETS).find(([key]) => key === theme);
|
||||
if (preset) {
|
||||
themeConfig = preset[1].config;
|
||||
themeName = preset[1].name;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse theme:', e);
|
||||
}
|
||||
} else {
|
||||
themeConfig = theme;
|
||||
}
|
||||
|
||||
// If we have a preset name, use its display name
|
||||
if (presetName && GALLERY_THEME_PRESETS[presetName]) {
|
||||
themeName = GALLERY_THEME_PRESETS[presetName].name;
|
||||
}
|
||||
|
||||
if (!themeConfig) {
|
||||
return (
|
||||
<div className={`text-sm text-neutral-500 ${className}`}>
|
||||
{t('events.noThemeSet')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const galleryLayout = themeConfig.galleryLayout || 'grid';
|
||||
|
||||
return (
|
||||
<div className={`space-y-3 ${className}`}>
|
||||
{/* Theme Name & Layout */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layout className="w-4 h-4 text-neutral-500" />
|
||||
<span className="text-sm font-medium text-neutral-700">{themeName}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-neutral-600">
|
||||
{layoutIcons[galleryLayout]}
|
||||
<span className="capitalize">{t(`branding.layoutDescriptions.${galleryLayout}`)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showDetails && (
|
||||
<>
|
||||
{/* Color Palette */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Palette className="w-4 h-4 text-neutral-500" />
|
||||
<span className="text-sm text-neutral-600">{t('branding.colors')}:</span>
|
||||
<div className="flex gap-1">
|
||||
{themeConfig.primaryColor && (
|
||||
<div
|
||||
className="w-6 h-6 rounded border border-neutral-300"
|
||||
style={{ backgroundColor: themeConfig.primaryColor }}
|
||||
title={t('branding.primaryColor')}
|
||||
/>
|
||||
)}
|
||||
{themeConfig.accentColor && (
|
||||
<div
|
||||
className="w-6 h-6 rounded border border-neutral-300"
|
||||
style={{ backgroundColor: themeConfig.accentColor }}
|
||||
title={t('branding.accentColor')}
|
||||
/>
|
||||
)}
|
||||
{themeConfig.backgroundColor && (
|
||||
<div
|
||||
className="w-6 h-6 rounded border border-neutral-300"
|
||||
style={{ backgroundColor: themeConfig.backgroundColor }}
|
||||
title={t('branding.backgroundColor')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Typography */}
|
||||
{themeConfig.fontFamily && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Type className="w-4 h-4 text-neutral-500" />
|
||||
<span className="text-sm text-neutral-600">{t('branding.bodyFont')}:</span>
|
||||
<span className="text-sm font-medium" style={{ fontFamily: themeConfig.fontFamily }}>
|
||||
{themeConfig.fontFamily}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Layout Settings */}
|
||||
{themeConfig.gallerySettings && (
|
||||
<div className="text-sm text-neutral-600">
|
||||
{themeConfig.gallerySettings.spacing && (
|
||||
<span className="inline-flex items-center gap-1 mr-3">
|
||||
<span>{t('branding.photoSpacing')}:</span>
|
||||
<span className="font-medium capitalize">
|
||||
{t(`branding.spacing.${themeConfig.gallerySettings.spacing}`)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{themeConfig.gallerySettings.photoAnimation && themeConfig.gallerySettings.photoAnimation !== 'none' && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span>{t('branding.photoAnimation')}:</span>
|
||||
<span className="font-medium capitalize">
|
||||
{t(`branding.animation.${themeConfig.gallerySettings.photoAnimation}`)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ThemeDisplay.displayName = 'ThemeDisplay';
|
||||
@@ -0,0 +1,147 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, Save, RotateCcw } from 'lucide-react';
|
||||
import { Button } from '../common';
|
||||
import { ThemeCustomizerEnhanced } from './ThemeCustomizerEnhanced';
|
||||
import { ThemeConfig, GALLERY_THEME_PRESETS } from '../../types/theme.types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface ThemeEditorModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (theme: ThemeConfig, presetName: string) => void;
|
||||
currentTheme: ThemeConfig | string;
|
||||
eventName: string;
|
||||
}
|
||||
|
||||
export const ThemeEditorModal: React.FC<ThemeEditorModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSave,
|
||||
currentTheme,
|
||||
eventName
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [theme, setTheme] = useState<ThemeConfig>(GALLERY_THEME_PRESETS.default.config);
|
||||
const [presetName, setPresetName] = useState<string>('default');
|
||||
|
||||
useEffect(() => {
|
||||
if (currentTheme) {
|
||||
if (typeof currentTheme === 'string') {
|
||||
try {
|
||||
if (currentTheme.startsWith('{')) {
|
||||
const parsedTheme = JSON.parse(currentTheme);
|
||||
setTheme(parsedTheme);
|
||||
// Try to find matching preset
|
||||
const matchingPreset = Object.entries(GALLERY_THEME_PRESETS).find(
|
||||
([_, preset]) => JSON.stringify(preset.config) === JSON.stringify(parsedTheme)
|
||||
);
|
||||
setPresetName(matchingPreset ? matchingPreset[0] : 'custom');
|
||||
} else {
|
||||
// Legacy theme name
|
||||
const preset = GALLERY_THEME_PRESETS[currentTheme];
|
||||
if (preset) {
|
||||
setTheme(preset.config);
|
||||
setPresetName(currentTheme);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse theme:', e);
|
||||
setTheme(GALLERY_THEME_PRESETS.default.config);
|
||||
setPresetName('default');
|
||||
}
|
||||
} else {
|
||||
setTheme(currentTheme);
|
||||
setPresetName('custom');
|
||||
}
|
||||
}
|
||||
}, [currentTheme]);
|
||||
|
||||
const handleThemeChange = (newTheme: ThemeConfig) => {
|
||||
setTheme(newTheme);
|
||||
};
|
||||
|
||||
const handlePresetChange = (newPresetName: string) => {
|
||||
setPresetName(newPresetName);
|
||||
if (newPresetName !== 'custom') {
|
||||
const preset = GALLERY_THEME_PRESETS[newPresetName];
|
||||
if (preset) {
|
||||
setTheme(preset.config);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(theme, presetName);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
const defaultPreset = GALLERY_THEME_PRESETS.default;
|
||||
setTheme(defaultPreset.config);
|
||||
setPresetName('default');
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-neutral-200 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-neutral-900">
|
||||
{t('events.galleryTheme')}
|
||||
</h2>
|
||||
<p className="text-sm text-neutral-600 mt-1">
|
||||
{t('events.customizingThemeFor', { event: eventName })}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-neutral-400 hover:text-neutral-600 transition-colors"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<ThemeCustomizerEnhanced
|
||||
value={theme}
|
||||
onChange={handleThemeChange}
|
||||
presetName={presetName}
|
||||
onPresetChange={handlePresetChange}
|
||||
isPreviewMode={true}
|
||||
showGalleryLayouts={true}
|
||||
hideActions={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t border-neutral-200 flex items-center justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
leftIcon={<RotateCcw className="w-4 h-4" />}
|
||||
onClick={handleReset}
|
||||
>
|
||||
{t('branding.resetToDefault')}
|
||||
</Button>
|
||||
<div className="flex gap-3">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
leftIcon={<Save className="w-4 h-4" />}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{t('branding.saveTheme')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ThemeEditorModal.displayName = 'ThemeEditorModal';
|
||||
@@ -15,4 +15,8 @@ export { AdminPhotoGrid } from './AdminPhotoGrid';
|
||||
export { AdminPhotoViewer } from './AdminPhotoViewer';
|
||||
export { PhotoFilters } from './PhotoFilters';
|
||||
export { PasswordResetModal } from './PasswordResetModal';
|
||||
export { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
|
||||
export { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
|
||||
export { ThemeCustomizerEnhanced } from './ThemeCustomizerEnhanced';
|
||||
export { ThemeDisplay } from './ThemeDisplay';
|
||||
export { ThemeEditorModal } from './ThemeEditorModal';
|
||||
export { HeroPhotoSelector } from './HeroPhotoSelector';
|
||||
@@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
import { Button } from '../common';
|
||||
import { DynamicFavicon } from '../common/DynamicFavicon';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
|
||||
interface GalleryLayoutProps {
|
||||
event: {
|
||||
@@ -44,45 +45,104 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { format } = useLocalizedDate();
|
||||
const { theme } = useTheme();
|
||||
|
||||
const isNonGridLayout = theme.galleryLayout && theme.galleryLayout !== 'grid' && theme.galleryLayout !== 'hero';
|
||||
const fontFamily = theme.fontFamily || 'Inter, sans-serif';
|
||||
const headingFontFamily = theme.headingFontFamily || fontFamily;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-neutral-50">
|
||||
{/* Dynamic Favicon */}
|
||||
<DynamicFavicon />
|
||||
|
||||
{/* Header */}
|
||||
<header className="bg-white border-b border-neutral-200 sticky top-0 z-40">
|
||||
<div className="container py-3">
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Top row - Title and mobile logout */}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-start gap-3 flex-1 min-w-0">
|
||||
{/* Logo - Mobile optimized */}
|
||||
{brandingSettings?.logo_url && (
|
||||
<div className="flex-shrink-0">
|
||||
<img
|
||||
src={`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${brandingSettings.logo_url}`}
|
||||
alt={brandingSettings.company_name || 'Company Logo'}
|
||||
className="h-8 sm:h-10 lg:h-12 w-auto object-contain"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Header structure */}
|
||||
<header className={`bg-white border-b border-neutral-200 sticky top-0 z-40 ${isNonGridLayout || theme.galleryLayout === 'hero' ? 'shadow-sm' : ''}`}>
|
||||
{/* For non-grid layouts (excluding hero) - keep the current structure */}
|
||||
{isNonGridLayout && (
|
||||
<div className="bg-neutral-50 border-b border-neutral-200">
|
||||
<div className="container py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Left side - Menu button and other header extras */}
|
||||
<div className="flex items-center gap-3">
|
||||
{headerExtra}
|
||||
</div>
|
||||
|
||||
{/* Right side - Download and Logout */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Download all button */}
|
||||
{showDownloadAll && onDownloadAll && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
leftIcon={<Download className="w-4 h-4" />}
|
||||
onClick={onDownloadAll}
|
||||
isLoading={isDownloading}
|
||||
>
|
||||
<span className="hidden sm:inline">{t('gallery.downloadAll')}</span>
|
||||
<span className="sm:hidden">{t('common.download')}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Logout button */}
|
||||
{showLogout && onLogout && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<LogOut className="w-4 h-4" />}
|
||||
onClick={onLogout}
|
||||
>
|
||||
{t('common.logout')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* For grid and hero layouts - everything in one bar */}
|
||||
{(!isNonGridLayout || theme.galleryLayout === 'hero') && (
|
||||
<div className="container py-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
{/* Left side - Logo, Title, and Dates */}
|
||||
<div className="flex items-center gap-4 flex-1 min-w-0">
|
||||
{/* Menu button */}
|
||||
<div className="flex-shrink-0">
|
||||
{headerExtra}
|
||||
</div>
|
||||
|
||||
{/* Logo - Show custom logo or fallback to PicPeak logo */}
|
||||
<div className="flex-shrink-0">
|
||||
<img
|
||||
src={brandingSettings?.logo_url ?
|
||||
`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${brandingSettings.logo_url}` :
|
||||
'/picpeak-logo-transparent.png'
|
||||
}
|
||||
alt={brandingSettings?.company_name || 'PicPeak'}
|
||||
className="h-10 sm:h-12 w-auto object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Event info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-lg sm:text-xl lg:text-2xl font-bold text-neutral-900 leading-tight truncate">
|
||||
<h1
|
||||
className="text-lg sm:text-xl lg:text-2xl font-bold text-neutral-900 leading-tight truncate"
|
||||
style={{ fontFamily: headingFontFamily }}
|
||||
>
|
||||
{event.event_name}
|
||||
</h1>
|
||||
{(event.event_date || event.expires_at) && (
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-1 text-xs text-neutral-600">
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-1 text-xs sm:text-sm text-neutral-600">
|
||||
{event.event_date && (
|
||||
<span className="flex items-center">
|
||||
<Calendar className="w-3 h-3 mr-1 flex-shrink-0" />
|
||||
<Calendar className="w-3 h-3 sm:w-4 sm:h-4 mr-1 flex-shrink-0" />
|
||||
<span>{format(parseISO(event.event_date), 'PP')}</span>
|
||||
</span>
|
||||
)}
|
||||
{event.expires_at && (
|
||||
<span className="flex items-center">
|
||||
<Clock className="w-3 h-3 mr-1 flex-shrink-0" />
|
||||
<Clock className="w-3 h-3 sm:w-4 sm:h-4 mr-1 flex-shrink-0" />
|
||||
<span>{t('gallery.expires')} {format(parseISO(event.expires_at), 'PP')}</span>
|
||||
</span>
|
||||
)}
|
||||
@@ -90,57 +150,102 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile logout button - top right */}
|
||||
{showLogout && onLogout && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onLogout}
|
||||
className="sm:hidden p-2"
|
||||
title={t('common.logout')}
|
||||
>
|
||||
<LogOut className="w-5 h-5" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Right side - Action buttons */}
|
||||
<div className="flex items-center gap-3 flex-shrink-0">
|
||||
{/* Download all button */}
|
||||
{showDownloadAll && onDownloadAll && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
leftIcon={<Download className="w-4 h-4" />}
|
||||
onClick={onDownloadAll}
|
||||
isLoading={isDownloading}
|
||||
>
|
||||
<span className="hidden sm:inline">{t('gallery.downloadAll')}</span>
|
||||
<span className="sm:hidden">{t('common.download')}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Logout button */}
|
||||
{showLogout && onLogout && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<LogOut className="w-4 h-4" />}
|
||||
onClick={onLogout}
|
||||
>
|
||||
{t('common.logout')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action buttons row */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Header extra content (upload button, countdown) */}
|
||||
{headerExtra && headerExtra}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* Hero Header for non-grid layouts (excluding hero layout which has its own) */}
|
||||
{isNonGridLayout && (
|
||||
<div
|
||||
className="relative text-white overflow-hidden"
|
||||
style={{
|
||||
backgroundColor: theme.accentColor || '#22c55e',
|
||||
backgroundImage: theme.backgroundPattern !== 'none'
|
||||
? `url("data:image/svg+xml,%3Csvg width='40' height='40' viewBox='0 0 40 40' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='%23ffffff' fill-opacity='0.03'%3E%3Cpath d='M0 40L40 0H20L0 20M40 40V20L20 40'/%3E%3C/g%3E%3C/svg%3E")`
|
||||
: undefined
|
||||
}}
|
||||
>
|
||||
<div className="container py-12 sm:py-16 lg:py-20 relative z-10">
|
||||
<div className="text-center max-w-4xl mx-auto">
|
||||
{/* Logo - Show custom logo or fallback to PicPeak logo */}
|
||||
<div className="mb-6">
|
||||
<img
|
||||
src={brandingSettings?.logo_url ?
|
||||
`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${brandingSettings.logo_url}` :
|
||||
'/picpeak-logo-transparent.png'
|
||||
}
|
||||
alt={brandingSettings?.company_name || 'PicPeak'}
|
||||
className="h-16 sm:h-20 lg:h-24 w-auto object-contain mx-auto filter brightness-0 invert"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Download all button */}
|
||||
{showDownloadAll && onDownloadAll && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
leftIcon={<Download className="w-4 h-4" />}
|
||||
onClick={onDownloadAll}
|
||||
isLoading={isDownloading}
|
||||
className="flex-1 sm:flex-initial"
|
||||
>
|
||||
<span className="hidden sm:inline">{t('gallery.downloadAll')}</span>
|
||||
<span className="sm:hidden">{t('common.download')}</span>
|
||||
</Button>
|
||||
)}
|
||||
{/* Event Name */}
|
||||
<h1
|
||||
className="text-3xl sm:text-4xl lg:text-5xl font-bold mb-4"
|
||||
style={{ fontFamily: headingFontFamily }}
|
||||
>
|
||||
{event.event_name}
|
||||
</h1>
|
||||
|
||||
{/* Desktop logout button */}
|
||||
{showLogout && onLogout && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<LogOut className="w-4 h-4" />}
|
||||
onClick={onLogout}
|
||||
className="hidden sm:flex"
|
||||
>
|
||||
{t('common.logout')}
|
||||
</Button>
|
||||
{/* Event Details */}
|
||||
{(event.event_date || event.expires_at) && (
|
||||
<div className="flex flex-wrap items-center justify-center gap-4 sm:gap-6 text-white/80">
|
||||
{event.event_date && (
|
||||
<span className="flex items-center text-lg">
|
||||
<Calendar className="w-5 h-5 mr-2" />
|
||||
{format(parseISO(event.event_date), 'PP')}
|
||||
</span>
|
||||
)}
|
||||
{event.expires_at && (
|
||||
<span className="flex items-center text-lg">
|
||||
<Clock className="w-5 h-5 mr-2" />
|
||||
{t('gallery.expires')} {format(parseISO(event.expires_at), 'PP')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Decorative bottom wave */}
|
||||
<div className="absolute bottom-0 left-0 right-0">
|
||||
<svg className="w-full h-12 sm:h-16" viewBox="0 0 1200 120" preserveAspectRatio="none">
|
||||
<path d="M0,60 C150,90 350,30 600,60 C850,90 1050,30 1200,60 L1200,120 L0,120 Z"
|
||||
fill="var(--color-background, #fafafa)" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="container">{children}</main>
|
||||
@@ -160,7 +265,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 || '© 2024 Your Company. All rights reserved.'} | Powered by <span className="font-semibold">PicPeak</span>
|
||||
</p>
|
||||
{brandingSettings?.company_name && brandingSettings?.company_tagline && (
|
||||
<p className="text-xs text-neutral-400 mt-2">
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { X, Download, Filter, SortAsc, Search, Calendar, Type, HardDrive, Check } from 'lucide-react';
|
||||
import { Button } from '../common';
|
||||
import { PhotoCategory } from '../../types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface GallerySidebarProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
categories: PhotoCategory[];
|
||||
selectedCategoryId: number | null;
|
||||
onCategoryChange: (categoryId: number | null) => void;
|
||||
searchTerm: string;
|
||||
onSearchChange: (term: string) => void;
|
||||
sortBy: 'date' | 'name' | 'size';
|
||||
onSortChange: (sort: 'date' | 'name' | 'size') => void;
|
||||
isSelectionMode: boolean;
|
||||
onToggleSelectionMode: () => void;
|
||||
selectedCount: number;
|
||||
onDownloadAll: () => void;
|
||||
onDownloadSelected: () => void;
|
||||
isDownloading: boolean;
|
||||
photoCounts?: Record<number, number>;
|
||||
totalPhotos: number;
|
||||
isMobile: boolean;
|
||||
galleryLayout?: string;
|
||||
}
|
||||
|
||||
export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
categories,
|
||||
selectedCategoryId,
|
||||
onCategoryChange,
|
||||
searchTerm,
|
||||
onSearchChange,
|
||||
sortBy,
|
||||
onSortChange,
|
||||
isSelectionMode,
|
||||
onToggleSelectionMode,
|
||||
selectedCount,
|
||||
onDownloadAll,
|
||||
onDownloadSelected,
|
||||
isDownloading,
|
||||
photoCounts = {},
|
||||
totalPhotos,
|
||||
isMobile,
|
||||
galleryLayout
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const sidebarRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close sidebar when clicking outside on mobile
|
||||
useEffect(() => {
|
||||
if (isMobile && isOpen) {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (sidebarRef.current && !sidebarRef.current.contains(event.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
}, [isMobile, isOpen, onClose]);
|
||||
|
||||
// Prevent body scroll when sidebar is open on mobile
|
||||
useEffect(() => {
|
||||
if (isMobile && isOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.body.style.overflow = 'unset';
|
||||
};
|
||||
}
|
||||
}, [isMobile, isOpen]);
|
||||
|
||||
const sortOptions = [
|
||||
{ value: 'date', label: t('gallery.sortByDate'), icon: Calendar },
|
||||
{ value: 'name', label: t('gallery.sortByName'), icon: Type },
|
||||
{ value: 'size', label: t('gallery.sortBySize'), icon: HardDrive }
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop for mobile */}
|
||||
{isMobile && isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 z-40 transition-opacity"
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<div
|
||||
ref={sidebarRef}
|
||||
className={`
|
||||
fixed top-0 left-0 h-full bg-white shadow-xl z-50 transition-transform duration-300 ease-in-out
|
||||
${isMobile ? 'w-full max-w-sm' : 'w-80'}
|
||||
${isOpen ? 'translate-x-0' : '-translate-x-full'}
|
||||
`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-neutral-200">
|
||||
<h2 className="text-lg font-semibold text-neutral-900">{t('gallery.filters')}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-neutral-100 rounded-lg transition-colors"
|
||||
aria-label={t('common.close')}
|
||||
>
|
||||
<X className="w-5 h-5 text-neutral-600" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* Search Section - Hidden for carousel layout */}
|
||||
{galleryLayout !== 'carousel' && (
|
||||
<div className="p-4 border-b border-neutral-200">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-neutral-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder={t('gallery.searchPlaceholder')}
|
||||
className="w-full pl-10 pr-4 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Download Section */}
|
||||
<div className="p-4 border-b border-neutral-200">
|
||||
<h3 className="text-sm font-semibold text-neutral-700 mb-3 flex items-center gap-2">
|
||||
<Download className="w-4 h-4" />
|
||||
{t('gallery.download')}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
leftIcon={<Download className="w-4 h-4" />}
|
||||
onClick={onDownloadAll}
|
||||
disabled={isDownloading || totalPhotos === 0}
|
||||
className="w-full"
|
||||
>
|
||||
{t('gallery.downloadAll')} ({totalPhotos})
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={isSelectionMode ? 'secondary' : 'outline'}
|
||||
size="sm"
|
||||
onClick={onToggleSelectionMode}
|
||||
className="w-full"
|
||||
>
|
||||
{isSelectionMode ? t('gallery.cancelSelection') : t('gallery.selectPhotos')}
|
||||
</Button>
|
||||
|
||||
{isSelectionMode && selectedCount > 0 && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
leftIcon={<Download className="w-4 h-4" />}
|
||||
onClick={onDownloadSelected}
|
||||
disabled={isDownloading}
|
||||
className="w-full"
|
||||
>
|
||||
{t('gallery.downloadSelected')} ({selectedCount})
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Categories Section - Hidden for carousel layout */}
|
||||
{galleryLayout !== 'carousel' && categories.length > 0 && (
|
||||
<div className="p-4 border-b border-neutral-200">
|
||||
<h3 className="text-sm font-semibold text-neutral-700 mb-3 flex items-center gap-2">
|
||||
<Filter className="w-4 h-4" />
|
||||
{t('gallery.categories')}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
onCategoryChange(null);
|
||||
if (isMobile) onClose();
|
||||
}}
|
||||
className={`
|
||||
w-full text-left px-3 py-2 rounded-lg transition-colors flex items-center justify-between
|
||||
${selectedCategoryId === null
|
||||
? 'bg-primary-50 text-primary-700'
|
||||
: 'hover:bg-neutral-50 text-neutral-700'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<span>{t('gallery.allCategories')}</span>
|
||||
<span className="text-sm text-neutral-500">{totalPhotos}</span>
|
||||
</button>
|
||||
|
||||
{categories.map((category) => {
|
||||
const count = photoCounts[category.id] || 0;
|
||||
const isSelected = selectedCategoryId === category.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={category.id}
|
||||
onClick={() => {
|
||||
onCategoryChange(category.id);
|
||||
if (isMobile) onClose();
|
||||
}}
|
||||
className={`
|
||||
w-full text-left px-3 py-2 rounded-lg transition-colors flex items-center justify-between
|
||||
${isSelected
|
||||
? 'bg-primary-50 text-primary-700'
|
||||
: 'hover:bg-neutral-50 text-neutral-700'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{isSelected && <Check className="w-4 h-4" />}
|
||||
{category.name}
|
||||
</span>
|
||||
<span className="text-sm text-neutral-500">{count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sort Section - Hidden for carousel and timeline layouts */}
|
||||
{galleryLayout !== 'carousel' && galleryLayout !== 'timeline' && (
|
||||
<div className="p-4">
|
||||
<h3 className="text-sm font-semibold text-neutral-700 mb-3 flex items-center gap-2">
|
||||
<SortAsc className="w-4 h-4" />
|
||||
{t('gallery.sortBy')}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-1">
|
||||
{sortOptions.map((option) => {
|
||||
const Icon = option.icon;
|
||||
const isSelected = sortBy === option.value;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => {
|
||||
onSortChange(option.value as 'date' | 'name' | 'size');
|
||||
if (isMobile) onClose();
|
||||
}}
|
||||
className={`
|
||||
w-full text-left px-3 py-2 rounded-lg transition-colors flex items-center gap-3
|
||||
${isSelected
|
||||
? 'bg-primary-50 text-primary-700'
|
||||
: 'hover:bg-neutral-50 text-neutral-700'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
<span>{option.label}</span>
|
||||
{isSelected && <Check className="w-4 h-4 ml-auto" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useMemo, useEffect, useRef } from 'react';
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import { differenceInDays, parseISO } from 'date-fns';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -10,11 +10,14 @@ import { PhotoGridWithLayouts } from './PhotoGridWithLayouts';
|
||||
import { ExpirationBanner } from './ExpirationBanner';
|
||||
import { CountdownTimer } from './CountdownTimer';
|
||||
import { GalleryLayout } from './GalleryLayout';
|
||||
import { GallerySidebar } from './GallerySidebar';
|
||||
import { PhotoFilterBar } from './PhotoFilterBar';
|
||||
import { UserPhotoUpload } from './UserPhotoUpload';
|
||||
import { analyticsService } from '../../services/analytics.service';
|
||||
import { GALLERY_THEME_PRESETS } from '../../types/theme.types';
|
||||
import { api } from '../../config/api';
|
||||
import { Upload } from 'lucide-react';
|
||||
import { Upload, Menu } from 'lucide-react';
|
||||
import { galleryService } from '../../services/gallery.service';
|
||||
|
||||
interface GalleryViewProps {
|
||||
slug: string;
|
||||
@@ -28,24 +31,37 @@ interface GalleryViewProps {
|
||||
expires_at: string;
|
||||
allow_user_uploads?: boolean;
|
||||
upload_category_id?: number | null;
|
||||
hero_photo_id?: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
const { t } = useTranslation();
|
||||
const { logout } = useGalleryAuth();
|
||||
const { setTheme } = useTheme();
|
||||
const { setTheme, theme } = useTheme();
|
||||
const [selectedCategoryId, setSelectedCategoryId] = useState<number | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [sortBy, setSortBy] = useState<'date' | 'name' | 'size'>('date');
|
||||
const [brandingSettings, setBrandingSettings] = useState<any>(null);
|
||||
const [showUploadModal, setShowUploadModal] = useState(false);
|
||||
const themeAppliedRef = useRef(false);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [isSelectionMode, setIsSelectionMode] = useState(false);
|
||||
const [selectedPhotos, setSelectedPhotos] = useState<Set<number>>(new Set());
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
|
||||
|
||||
// Fetch photos
|
||||
const { data, isLoading, error } = useGalleryPhotos(slug);
|
||||
const downloadAllMutation = useDownloadAllPhotos();
|
||||
|
||||
// Handle window resize
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setIsMobile(window.innerWidth < 768);
|
||||
};
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
// Fetch branding settings
|
||||
const { data: settingsData } = useQuery({
|
||||
queryKey: ['gallery-settings'],
|
||||
@@ -70,9 +86,9 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
}
|
||||
}, [settingsData]);
|
||||
|
||||
// Apply theme only once when component mounts and settings are loaded
|
||||
// Apply theme when settings are loaded
|
||||
useEffect(() => {
|
||||
if (!themeAppliedRef.current && settingsData) {
|
||||
if (settingsData && event) {
|
||||
let themeToApply = null;
|
||||
|
||||
if (event.color_theme) {
|
||||
@@ -82,9 +98,15 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
const eventTheme = JSON.parse(event.color_theme);
|
||||
themeToApply = eventTheme;
|
||||
} else {
|
||||
// Handle legacy theme names - use global theme
|
||||
if (settingsData.theme_config) {
|
||||
themeToApply = settingsData.theme_config;
|
||||
// Handle legacy theme names - check if it's a preset
|
||||
const preset = GALLERY_THEME_PRESETS[event.color_theme];
|
||||
if (preset) {
|
||||
themeToApply = preset.config;
|
||||
} else {
|
||||
// Unknown theme name, fall back to global theme
|
||||
if (settingsData.theme_config) {
|
||||
themeToApply = settingsData.theme_config;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -99,13 +121,23 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
themeToApply = settingsData.theme_config;
|
||||
}
|
||||
|
||||
// Apply theme only once
|
||||
// Apply theme with a small delay to ensure it overrides any global theme
|
||||
if (themeToApply) {
|
||||
themeAppliedRef.current = true;
|
||||
setTheme(themeToApply);
|
||||
// Use setTimeout to ensure this runs after any global theme application
|
||||
const timer = setTimeout(() => {
|
||||
// If there's a hero photo, add it to gallery settings
|
||||
if (event.hero_photo_id && themeToApply.gallerySettings) {
|
||||
themeToApply.gallerySettings.heroImageId = event.hero_photo_id;
|
||||
} else if (event.hero_photo_id) {
|
||||
themeToApply.gallerySettings = { heroImageId: event.hero_photo_id };
|
||||
}
|
||||
setTheme(themeToApply);
|
||||
}, 0);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}, [settingsData]); // Only depend on settingsData, not setTheme or event
|
||||
}, [settingsData, event, setTheme]); // Include all dependencies
|
||||
|
||||
// Calculate days until expiration
|
||||
const daysUntilExpiration = differenceInDays(parseISO(event.expires_at), new Date());
|
||||
@@ -157,6 +189,39 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
});
|
||||
};
|
||||
|
||||
const handleDownloadSelected = async () => {
|
||||
if (selectedPhotos.size === 0) return;
|
||||
|
||||
const selectedPhotosList = filteredPhotos.filter(p => selectedPhotos.has(p.id));
|
||||
|
||||
// Track bulk download
|
||||
analyticsService.trackGalleryEvent('bulk_download', {
|
||||
gallery: slug,
|
||||
photo_count: selectedPhotos.size
|
||||
});
|
||||
|
||||
// Download each selected photo
|
||||
for (const photo of selectedPhotosList) {
|
||||
await galleryService.downloadPhoto(slug, photo.id, photo.filename);
|
||||
}
|
||||
|
||||
// Clear selection after download
|
||||
setSelectedPhotos(new Set());
|
||||
setIsSelectionMode(false);
|
||||
};
|
||||
|
||||
// Calculate photo counts per category
|
||||
const photoCounts = useMemo(() => {
|
||||
if (!data?.photos) return {};
|
||||
const counts: Record<number, number> = {};
|
||||
data.photos.forEach(photo => {
|
||||
if (photo.category_id) {
|
||||
counts[photo.category_id] = (counts[photo.category_id] || 0) + 1;
|
||||
}
|
||||
});
|
||||
return counts;
|
||||
}, [data?.photos]);
|
||||
|
||||
// Track search usage with debouncing
|
||||
useEffect(() => {
|
||||
if (searchTerm.length > 0) {
|
||||
@@ -216,23 +281,71 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
);
|
||||
}
|
||||
|
||||
const showSidebar = theme.galleryLayout !== 'grid';
|
||||
|
||||
return (
|
||||
<GalleryLayout
|
||||
event={event}
|
||||
brandingSettings={brandingSettings}
|
||||
showLogout={true}
|
||||
onLogout={logout}
|
||||
showDownloadAll={true}
|
||||
onDownloadAll={handleDownloadAll}
|
||||
isDownloading={downloadAllMutation.isPending}
|
||||
headerExtra={
|
||||
(daysUntilExpiration <= 1 && daysUntilExpiration > 0) || event.allow_user_uploads ? (
|
||||
<>
|
||||
{daysUntilExpiration <= 1 && daysUntilExpiration > 0 && (
|
||||
<CountdownTimer expiresAt={event.expires_at} className="mr-2" />
|
||||
)}
|
||||
{event.allow_user_uploads && (
|
||||
<>
|
||||
{/* Sidebar for non-grid layouts */}
|
||||
{showSidebar ? (
|
||||
<GallerySidebar
|
||||
isOpen={sidebarOpen}
|
||||
onClose={() => setSidebarOpen(!sidebarOpen)}
|
||||
categories={(data?.categories || []).filter(cat => photoCounts[cat.id] > 0)}
|
||||
selectedCategoryId={selectedCategoryId}
|
||||
onCategoryChange={setSelectedCategoryId}
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
sortBy={sortBy}
|
||||
onSortChange={setSortBy}
|
||||
isSelectionMode={isSelectionMode}
|
||||
onToggleSelectionMode={() => setIsSelectionMode(!isSelectionMode)}
|
||||
selectedCount={selectedPhotos.size}
|
||||
onDownloadAll={handleDownloadAll}
|
||||
onDownloadSelected={handleDownloadSelected}
|
||||
isDownloading={downloadAllMutation.isPending}
|
||||
photoCounts={photoCounts}
|
||||
totalPhotos={data?.photos.length || 0}
|
||||
isMobile={isMobile}
|
||||
galleryLayout={theme.galleryLayout}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<GalleryLayout
|
||||
event={event}
|
||||
brandingSettings={brandingSettings}
|
||||
showLogout={true}
|
||||
onLogout={logout}
|
||||
showDownloadAll={!showSidebar}
|
||||
onDownloadAll={handleDownloadAll}
|
||||
isDownloading={downloadAllMutation.isPending}
|
||||
headerExtra={(() => {
|
||||
const items = [];
|
||||
|
||||
if (showSidebar) {
|
||||
items.push(
|
||||
<Button
|
||||
key="menu-button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={<Menu className="w-4 h-4" />}
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
aria-label={t('gallery.toggleMenu')}
|
||||
>
|
||||
{t('common.menu')}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (daysUntilExpiration <= 1 && daysUntilExpiration > 0) {
|
||||
items.push(
|
||||
<CountdownTimer key="countdown" expiresAt={event.expires_at} className="mr-2" />
|
||||
);
|
||||
}
|
||||
|
||||
if (event.allow_user_uploads) {
|
||||
items.push(
|
||||
<Button
|
||||
key="upload-button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<Upload className="w-4 h-4" />}
|
||||
@@ -242,50 +355,64 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
<span className="hidden sm:inline">{t('upload.uploadPhotos')}</span>
|
||||
<span className="sm:hidden">{t('common.upload')}</span>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{/* Expiration Banner */}
|
||||
{showUrgentWarning && (
|
||||
<ExpirationBanner daysRemaining={daysUntilExpiration} expiresAt={event.expires_at} />
|
||||
)}
|
||||
);
|
||||
}
|
||||
|
||||
return <>{items}</>;
|
||||
})()}
|
||||
>
|
||||
{/* Expiration Banner */}
|
||||
{showUrgentWarning && (
|
||||
<ExpirationBanner daysRemaining={daysUntilExpiration} expiresAt={event.expires_at} />
|
||||
)}
|
||||
|
||||
|
||||
{/* Search and Filters */}
|
||||
<div className="mt-6">
|
||||
<PhotoFilterBar
|
||||
categories={data.categories}
|
||||
photos={data.photos}
|
||||
selectedCategoryId={selectedCategoryId}
|
||||
onCategoryChange={setSelectedCategoryId}
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
sortBy={sortBy}
|
||||
onSortChange={setSortBy}
|
||||
photoCount={filteredPhotos.length}
|
||||
/>
|
||||
{/* Search and Filters - Only for grid layout */}
|
||||
{!showSidebar ? (
|
||||
<div className="mt-6">
|
||||
<PhotoFilterBar
|
||||
categories={data.categories}
|
||||
photos={data.photos}
|
||||
selectedCategoryId={selectedCategoryId}
|
||||
onCategoryChange={setSelectedCategoryId}
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
sortBy={sortBy}
|
||||
onSortChange={setSortBy}
|
||||
photoCount={filteredPhotos.length}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Photo Grid */}
|
||||
<div className="mt-6">
|
||||
<PhotoGridWithLayouts photos={filteredPhotos} slug={slug} categoryId={selectedCategoryId} />
|
||||
<div className={showSidebar ? "mt-6" : "mt-6"}>
|
||||
<PhotoGridWithLayouts
|
||||
photos={filteredPhotos}
|
||||
slug={slug}
|
||||
categoryId={selectedCategoryId}
|
||||
isSelectionMode={isSelectionMode}
|
||||
selectedPhotos={selectedPhotos}
|
||||
onSelectionChange={setSelectedPhotos}
|
||||
onToggleSelectionMode={() => setIsSelectionMode(!isSelectionMode)}
|
||||
showSelectionControls={!showSidebar}
|
||||
eventName={event.event_name}
|
||||
eventLogo={brandingSettings?.logo_url}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upload Modal */}
|
||||
{showUploadModal && (
|
||||
<UserPhotoUpload
|
||||
eventId={event.id}
|
||||
categoryId={event.upload_category_id}
|
||||
onUploadComplete={() => {
|
||||
setShowUploadModal(false);
|
||||
// Refetch photos after upload
|
||||
window.location.reload(); // Simple reload for now
|
||||
}}
|
||||
onClose={() => setShowUploadModal(false)}
|
||||
/>
|
||||
)}
|
||||
</GalleryLayout>
|
||||
{/* Upload Modal */}
|
||||
{showUploadModal && (
|
||||
<UserPhotoUpload
|
||||
eventId={event.id}
|
||||
categoryId={event.upload_category_id}
|
||||
onUploadComplete={() => {
|
||||
setShowUploadModal(false);
|
||||
// Refetch photos after upload
|
||||
window.location.reload(); // Simple reload for now
|
||||
}}
|
||||
onClose={() => setShowUploadModal(false)}
|
||||
/>
|
||||
)}
|
||||
</GalleryLayout>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -25,20 +25,40 @@ interface PhotoGridWithLayoutsProps {
|
||||
photos: Photo[];
|
||||
slug: string;
|
||||
categoryId?: number | null;
|
||||
isSelectionMode?: boolean;
|
||||
selectedPhotos?: Set<number>;
|
||||
onSelectionChange?: (photos: Set<number>) => void;
|
||||
onToggleSelectionMode?: () => void;
|
||||
showSelectionControls?: boolean;
|
||||
eventName?: string;
|
||||
eventLogo?: string | null;
|
||||
}
|
||||
|
||||
export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
||||
photos,
|
||||
slug,
|
||||
categoryId
|
||||
categoryId,
|
||||
isSelectionMode: parentSelectionMode,
|
||||
selectedPhotos: parentSelectedPhotos,
|
||||
onSelectionChange,
|
||||
onToggleSelectionMode: parentToggleSelectionMode,
|
||||
showSelectionControls = true,
|
||||
eventName,
|
||||
eventLogo
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useTheme();
|
||||
const [selectedPhotoIndex, setSelectedPhotoIndex] = useState<number | null>(null);
|
||||
const [selectedPhotos, setSelectedPhotos] = useState<Set<number>>(new Set());
|
||||
const [isSelectionMode, setIsSelectionMode] = useState(false);
|
||||
const [localSelectedPhotos, setLocalSelectedPhotos] = useState<Set<number>>(new Set());
|
||||
const [localSelectionMode, setLocalSelectionMode] = useState(false);
|
||||
const downloadPhotoMutation = useDownloadPhoto();
|
||||
|
||||
// Use parent state if provided, otherwise use local state
|
||||
const selectedPhotos = parentSelectedPhotos ?? localSelectedPhotos;
|
||||
const isSelectionMode = parentSelectionMode ?? localSelectionMode;
|
||||
const setSelectedPhotos = onSelectionChange ?? setLocalSelectedPhotos;
|
||||
const toggleSelectionMode = parentToggleSelectionMode ?? (() => setLocalSelectionMode(!localSelectionMode));
|
||||
|
||||
// Clear selection when category changes
|
||||
useEffect(() => {
|
||||
setSelectedPhotos(new Set());
|
||||
@@ -71,10 +91,6 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
||||
});
|
||||
};
|
||||
|
||||
const toggleSelectionMode = () => {
|
||||
setIsSelectionMode(!isSelectionMode);
|
||||
setSelectedPhotos(new Set());
|
||||
};
|
||||
|
||||
const selectAll = () => {
|
||||
setSelectedPhotos(new Set(photos.map(p => p.id)));
|
||||
@@ -112,7 +128,11 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
||||
|
||||
// Clear selection after download
|
||||
setSelectedPhotos(new Set());
|
||||
setIsSelectionMode(false);
|
||||
if (parentToggleSelectionMode) {
|
||||
parentToggleSelectionMode();
|
||||
} else {
|
||||
setLocalSelectionMode(false);
|
||||
}
|
||||
} catch (error) {
|
||||
toastify.error(t('gallery.downloadError'));
|
||||
}
|
||||
@@ -138,6 +158,8 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
||||
selectedPhotos,
|
||||
isSelectionMode,
|
||||
onPhotoSelect: handlePhotoSelect,
|
||||
eventName,
|
||||
eventLogo,
|
||||
};
|
||||
|
||||
let LayoutComponent;
|
||||
@@ -163,8 +185,8 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Selection Mode Controls - Not shown for carousel layout */}
|
||||
{photos.length > 1 && galleryLayout !== 'carousel' && (
|
||||
{/* Selection Mode Controls - Not shown for carousel layout or when controls are hidden */}
|
||||
{showSelectionControls && photos.length > 1 && galleryLayout !== 'carousel' && (
|
||||
<div className="mb-4 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
@@ -181,7 +203,7 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setIsSelectionMode(true);
|
||||
toggleSelectionMode();
|
||||
selectAll();
|
||||
}}
|
||||
className="text-xs sm:text-sm"
|
||||
|
||||
@@ -9,6 +9,8 @@ export interface BaseGalleryLayoutProps {
|
||||
selectedPhotos?: Set<number>;
|
||||
isSelectionMode?: boolean;
|
||||
onPhotoSelect?: (photoId: number) => void;
|
||||
eventName?: string;
|
||||
eventLogo?: string | null;
|
||||
}
|
||||
|
||||
export abstract class BaseGalleryLayout<T extends BaseGalleryLayoutProps = BaseGalleryLayoutProps> extends React.Component<T> {
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Download, Maximize2, Check, ChevronDown } from 'lucide-react';
|
||||
import { useTheme } from '../../../contexts/ThemeContext';
|
||||
import { AuthenticatedImage, Button } from '../../common';
|
||||
import { AuthenticatedImage } from '../../common';
|
||||
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
|
||||
import type { Photo } from '../../../types';
|
||||
|
||||
export const HeroGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
interface HeroGalleryLayoutProps extends BaseGalleryLayoutProps {
|
||||
eventName?: string;
|
||||
eventLogo?: string | null;
|
||||
}
|
||||
|
||||
export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
|
||||
photos,
|
||||
onPhotoClick,
|
||||
onDownload,
|
||||
selectedPhotos = new Set(),
|
||||
isSelectionMode = false,
|
||||
onPhotoSelect
|
||||
onPhotoSelect,
|
||||
eventName,
|
||||
eventLogo
|
||||
}) => {
|
||||
const { theme } = useTheme();
|
||||
const [heroPhoto, setHeroPhoto] = useState<Photo | null>(null);
|
||||
@@ -50,30 +57,25 @@ export const HeroGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
|
||||
{/* Hero Content */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center text-white px-4">
|
||||
<h1 className="text-3xl sm:text-4xl lg:text-5xl font-bold mb-4 drop-shadow-lg">
|
||||
{heroPhoto.category_name || 'Featured Photo'}
|
||||
</h1>
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
leftIcon={<Maximize2 className="w-5 h-5" />}
|
||||
onClick={() => onPhotoClick(0)}
|
||||
className="bg-white/20 backdrop-blur-sm hover:bg-white/30"
|
||||
>
|
||||
View Gallery
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
leftIcon={<Download className="w-5 h-5" />}
|
||||
onClick={(e) => onDownload(heroPhoto, e)}
|
||||
className="border-white text-white hover:bg-white/20"
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-center px-4">
|
||||
{/* Logo */}
|
||||
{eventLogo && (
|
||||
<div className="mb-6">
|
||||
<img
|
||||
src={eventLogo}
|
||||
alt="Event logo"
|
||||
className="h-20 sm:h-24 lg:h-32 mx-auto drop-shadow-lg filter brightness-0 invert"
|
||||
style={{ filter: 'drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Event Title */}
|
||||
{eventName && (
|
||||
<h1 className="text-3xl sm:text-4xl lg:text-5xl xl:text-6xl font-bold text-white drop-shadow-lg">
|
||||
{eventName}
|
||||
</h1>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -24,16 +24,21 @@ const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={`relative group cursor-pointer overflow-hidden ${className}`}
|
||||
onClick={onClick}
|
||||
className={`relative group cursor-pointer overflow-hidden rounded-lg bg-neutral-100 ${className}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick(e);
|
||||
}}
|
||||
>
|
||||
<AuthenticatedImage
|
||||
src={photo.thumbnail_url || photo.url}
|
||||
alt={photo.filename}
|
||||
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
|
||||
loading="lazy"
|
||||
isGallery={true}
|
||||
/>
|
||||
<div className="absolute inset-0">
|
||||
<AuthenticatedImage
|
||||
src={photo.thumbnail_url || photo.url}
|
||||
alt={photo.filename}
|
||||
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
|
||||
loading="lazy"
|
||||
isGallery={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center justify-center gap-2">
|
||||
{!isSelectionMode && (
|
||||
@@ -90,96 +95,6 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
// const gallerySettings = theme.gallerySettings || {};
|
||||
// const pattern = gallerySettings.mosaicPattern || 'structured';
|
||||
|
||||
// Create mosaic patterns
|
||||
const renderStructuredPattern = () => {
|
||||
const patterns = [
|
||||
// Pattern 1: Large left, 2 small right
|
||||
<div key="pattern1" className="grid grid-cols-2 gap-2 h-96">
|
||||
{photos[0] && (
|
||||
<MosaicPhoto
|
||||
photo={photos[0]}
|
||||
isSelected={selectedPhotos.has(photos[0].id)}
|
||||
isSelectionMode={isSelectionMode}
|
||||
onClick={() => handlePhotoClick(0, photos[0].id)}
|
||||
onDownload={(e) => onDownload(photos[0], e)}
|
||||
className="col-span-1 row-span-2"
|
||||
/>
|
||||
)}
|
||||
<div className="grid grid-rows-2 gap-2">
|
||||
{photos[1] && (
|
||||
<MosaicPhoto
|
||||
photo={photos[1]}
|
||||
isSelected={selectedPhotos.has(photos[1].id)}
|
||||
isSelectionMode={isSelectionMode}
|
||||
onClick={() => handlePhotoClick(1, photos[1].id)}
|
||||
onDownload={(e) => onDownload(photos[1], e)}
|
||||
/>
|
||||
)}
|
||||
{photos[2] && (
|
||||
<MosaicPhoto
|
||||
photo={photos[2]}
|
||||
isSelected={selectedPhotos.has(photos[2].id)}
|
||||
isSelectionMode={isSelectionMode}
|
||||
onClick={() => handlePhotoClick(2, photos[2].id)}
|
||||
onDownload={(e) => onDownload(photos[2], e)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
|
||||
// Pattern 2: 3 equal columns
|
||||
<div key="pattern2" className="grid grid-cols-3 gap-2 h-64">
|
||||
{photos.slice(3, 6).map((photo, idx) => {
|
||||
const index = idx + 3;
|
||||
return photo ? (
|
||||
<MosaicPhoto
|
||||
key={photo.id}
|
||||
photo={photo}
|
||||
isSelected={selectedPhotos.has(photo.id)}
|
||||
isSelectionMode={isSelectionMode}
|
||||
onClick={() => handlePhotoClick(index, photo.id)}
|
||||
onDownload={(e) => onDownload(photo, e)}
|
||||
/>
|
||||
) : null;
|
||||
})}
|
||||
</div>,
|
||||
|
||||
// Pattern 3: Large center with sides
|
||||
<div key="pattern3" className="grid grid-cols-3 gap-2 h-96">
|
||||
{photos[6] && (
|
||||
<MosaicPhoto
|
||||
photo={photos[6]}
|
||||
isSelected={selectedPhotos.has(photos[6].id)}
|
||||
isSelectionMode={isSelectionMode}
|
||||
onClick={() => handlePhotoClick(6, photos[6].id)}
|
||||
onDownload={(e) => onDownload(photos[6], e)}
|
||||
/>
|
||||
)}
|
||||
{photos[7] && (
|
||||
<MosaicPhoto
|
||||
photo={photos[7]}
|
||||
isSelected={selectedPhotos.has(photos[7].id)}
|
||||
isSelectionMode={isSelectionMode}
|
||||
onClick={() => handlePhotoClick(7, photos[7].id)}
|
||||
onDownload={(e) => onDownload(photos[7], e)}
|
||||
className="row-span-2"
|
||||
/>
|
||||
)}
|
||||
{photos[8] && (
|
||||
<MosaicPhoto
|
||||
photo={photos[8]}
|
||||
isSelected={selectedPhotos.has(photos[8].id)}
|
||||
isSelectionMode={isSelectionMode}
|
||||
onClick={() => handlePhotoClick(8, photos[8].id)}
|
||||
onDownload={(e) => onDownload(photos[8], e)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
];
|
||||
|
||||
return patterns;
|
||||
};
|
||||
|
||||
const handlePhotoClick = (index: number, photoId: number) => {
|
||||
if (isSelectionMode && onPhotoSelect) {
|
||||
onPhotoSelect(photoId);
|
||||
@@ -188,21 +103,147 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// For now, we'll use the structured pattern
|
||||
// You can implement random and alternating patterns as needed
|
||||
const mosaicElements = renderStructuredPattern();
|
||||
|
||||
// Add remaining photos in a regular grid
|
||||
const remainingPhotos = photos.slice(9);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{mosaicElements}
|
||||
// Create a more structured mosaic layout
|
||||
const renderMosaicLayout = () => {
|
||||
const elements = [];
|
||||
let photoIndex = 0;
|
||||
let patternIndex = 0;
|
||||
|
||||
while (photoIndex < photos.length) {
|
||||
const remainingPhotos = photos.length - photoIndex;
|
||||
|
||||
{remainingPhotos.length > 0 && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-2">
|
||||
// Choose pattern based on rotation and remaining photos
|
||||
if (patternIndex % 3 === 0 && remainingPhotos >= 3) {
|
||||
// Pattern 1: Large left, 2 small right
|
||||
// Capture indices immediately to avoid closure issues
|
||||
const idx0 = photoIndex;
|
||||
const idx1 = photoIndex + 1;
|
||||
const idx2 = photoIndex + 2;
|
||||
const photo0 = photos[idx0];
|
||||
const photo1 = photos[idx1];
|
||||
const photo2 = photos[idx2];
|
||||
|
||||
elements.push(
|
||||
<div key={`pattern-${photoIndex}`} className="grid grid-cols-2 gap-2 mb-2 h-[400px]">
|
||||
{photo0 && (
|
||||
<MosaicPhoto
|
||||
photo={photo0}
|
||||
isSelected={selectedPhotos.has(photo0.id)}
|
||||
isSelectionMode={isSelectionMode}
|
||||
onClick={() => handlePhotoClick(idx0, photo0.id)}
|
||||
onDownload={(e) => onDownload(photo0, e)}
|
||||
className="col-span-1"
|
||||
/>
|
||||
)}
|
||||
<div className="grid grid-rows-2 gap-2">
|
||||
{photo1 && (
|
||||
<MosaicPhoto
|
||||
photo={photo1}
|
||||
isSelected={selectedPhotos.has(photo1.id)}
|
||||
isSelectionMode={isSelectionMode}
|
||||
onClick={() => handlePhotoClick(idx1, photo1.id)}
|
||||
onDownload={(e) => onDownload(photo1, e)}
|
||||
className=""
|
||||
/>
|
||||
)}
|
||||
{photo2 && (
|
||||
<MosaicPhoto
|
||||
photo={photo2}
|
||||
isSelected={selectedPhotos.has(photo2.id)}
|
||||
isSelectionMode={isSelectionMode}
|
||||
onClick={() => handlePhotoClick(idx2, photo2.id)}
|
||||
onDownload={(e) => onDownload(photo2, e)}
|
||||
className=""
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
photoIndex += 3;
|
||||
} else if (patternIndex % 3 === 1 && remainingPhotos >= 3) {
|
||||
// Pattern 2: 3 equal columns
|
||||
elements.push(
|
||||
<div key={`pattern-${photoIndex}`} className="grid grid-cols-3 gap-2 mb-2 h-[250px]">
|
||||
{[0, 1, 2].map(offset => {
|
||||
const currentIndex = photoIndex + offset;
|
||||
const photo = photos[currentIndex];
|
||||
return photo ? (
|
||||
<MosaicPhoto
|
||||
key={photo.id}
|
||||
photo={photo}
|
||||
isSelected={selectedPhotos.has(photo.id)}
|
||||
isSelectionMode={isSelectionMode}
|
||||
onClick={() => handlePhotoClick(currentIndex, photo.id)}
|
||||
onDownload={(e) => onDownload(photo, e)}
|
||||
className=""
|
||||
/>
|
||||
) : null;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
photoIndex += 3;
|
||||
} else if (patternIndex % 3 === 2 && remainingPhotos >= 3) {
|
||||
// Pattern 3: Large span-2 with 2 small on right
|
||||
// Capture indices immediately to avoid closure issues
|
||||
const idx0 = photoIndex;
|
||||
const idx1 = photoIndex + 1;
|
||||
const idx2 = photoIndex + 2;
|
||||
const photo0 = photos[idx0];
|
||||
const photo1 = photos[idx1];
|
||||
const photo2 = photos[idx2];
|
||||
|
||||
elements.push(
|
||||
<div key={`pattern-${photoIndex}`} className="grid grid-cols-3 gap-2 mb-2 h-[400px]">
|
||||
{photo0 && (
|
||||
<MosaicPhoto
|
||||
photo={photo0}
|
||||
isSelected={selectedPhotos.has(photo0.id)}
|
||||
isSelectionMode={isSelectionMode}
|
||||
onClick={() => handlePhotoClick(idx0, photo0.id)}
|
||||
onDownload={(e) => onDownload(photo0, e)}
|
||||
className="col-span-2"
|
||||
/>
|
||||
)}
|
||||
<div className="grid grid-rows-2 gap-2">
|
||||
{photo1 && (
|
||||
<MosaicPhoto
|
||||
photo={photo1}
|
||||
isSelected={selectedPhotos.has(photo1.id)}
|
||||
isSelectionMode={isSelectionMode}
|
||||
onClick={() => handlePhotoClick(idx1, photo1.id)}
|
||||
onDownload={(e) => onDownload(photo1, e)}
|
||||
className=""
|
||||
/>
|
||||
)}
|
||||
{photo2 && (
|
||||
<MosaicPhoto
|
||||
photo={photo2}
|
||||
isSelected={selectedPhotos.has(photo2.id)}
|
||||
isSelectionMode={isSelectionMode}
|
||||
onClick={() => handlePhotoClick(idx2, photo2.id)}
|
||||
onDownload={(e) => onDownload(photo2, e)}
|
||||
className=""
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
photoIndex += 3;
|
||||
} else {
|
||||
// Handle remaining photos that don't fit patterns
|
||||
break;
|
||||
}
|
||||
|
||||
patternIndex++;
|
||||
}
|
||||
|
||||
// Add remaining photos in a regular grid
|
||||
if (photoIndex < photos.length) {
|
||||
const remainingPhotos = photos.slice(photoIndex);
|
||||
elements.push(
|
||||
<div key={`remaining-${photoIndex}`} className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-2">
|
||||
{remainingPhotos.map((photo, idx) => {
|
||||
const index = idx + 9;
|
||||
const index = photoIndex + idx;
|
||||
return (
|
||||
<MosaicPhoto
|
||||
key={photo.id}
|
||||
@@ -216,7 +257,15 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
);
|
||||
}
|
||||
|
||||
return elements;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-7xl mx-auto">
|
||||
{renderMosaicLayout()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -170,13 +170,17 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Save theme to localStorage when it changes
|
||||
// Save theme to localStorage when it changes (but not in gallery views)
|
||||
useEffect(() => {
|
||||
// Only save if theme has actually changed
|
||||
const currentSaved = localStorage.getItem('gallery-theme');
|
||||
const newValue = JSON.stringify({ name: themeName, config: theme });
|
||||
if (currentSaved !== newValue) {
|
||||
localStorage.setItem('gallery-theme', newValue);
|
||||
// Don't save theme in gallery views to avoid conflicts
|
||||
const isGalleryView = window.location.pathname.includes('/gallery/');
|
||||
if (!isGalleryView) {
|
||||
// Only save if theme has actually changed
|
||||
const currentSaved = localStorage.getItem('gallery-theme');
|
||||
const newValue = JSON.stringify({ name: themeName, config: theme });
|
||||
if (currentSaved !== newValue) {
|
||||
localStorage.setItem('gallery-theme', newValue);
|
||||
}
|
||||
}
|
||||
}, [theme, themeName]);
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './useSessionTimeout';
|
||||
export * from './useOnClickOutside';
|
||||
export * from './useLocalizedDate';
|
||||
export * from './useLocalizedDate';
|
||||
export * from './useLocalizedTimeAgo';
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export const useLocalizedTimeAgo = () => {
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
const formatTimeAgo = (date: Date | string): string => {
|
||||
const dateObj = typeof date === 'string' ? new Date(date) : date;
|
||||
const now = new Date();
|
||||
const seconds = Math.floor((now.getTime() - dateObj.getTime()) / 1000);
|
||||
|
||||
const isGerman = i18n.language === 'de';
|
||||
|
||||
// Less than a minute
|
||||
if (seconds < 60) {
|
||||
return isGerman ? 'gerade eben' : 'just now';
|
||||
}
|
||||
|
||||
// Minutes
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) {
|
||||
if (minutes === 1) {
|
||||
return isGerman ? 'vor 1 Minute' : '1 minute ago';
|
||||
}
|
||||
return isGerman ? `vor ${minutes} Minuten` : `${minutes} minutes ago`;
|
||||
}
|
||||
|
||||
// Hours
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) {
|
||||
if (hours === 1) {
|
||||
return isGerman ? 'vor 1 Stunde' : '1 hour ago';
|
||||
}
|
||||
return isGerman ? `vor ${hours} Stunden` : `${hours} hours ago`;
|
||||
}
|
||||
|
||||
// Days
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 7) {
|
||||
if (days === 1) {
|
||||
return isGerman ? 'vor 1 Tag' : '1 day ago';
|
||||
}
|
||||
return isGerman ? `vor ${days} Tagen` : `${days} days ago`;
|
||||
}
|
||||
|
||||
// Weeks
|
||||
const weeks = Math.floor(days / 7);
|
||||
if (weeks < 4) {
|
||||
if (weeks === 1) {
|
||||
return isGerman ? 'vor 1 Woche' : '1 week ago';
|
||||
}
|
||||
return isGerman ? `vor ${weeks} Wochen` : `${weeks} weeks ago`;
|
||||
}
|
||||
|
||||
// Months
|
||||
const months = Math.floor(days / 30);
|
||||
if (months < 12) {
|
||||
if (months === 1) {
|
||||
return isGerman ? 'vor 1 Monat' : '1 month ago';
|
||||
}
|
||||
return isGerman ? `vor ${months} Monaten` : `${months} months ago`;
|
||||
}
|
||||
|
||||
// Years
|
||||
const years = Math.floor(days / 365);
|
||||
if (years === 1) {
|
||||
return isGerman ? 'vor 1 Jahr' : '1 year ago';
|
||||
}
|
||||
return isGerman ? `vor ${years} Jahren` : `${years} years ago`;
|
||||
};
|
||||
|
||||
return { formatTimeAgo };
|
||||
};
|
||||
@@ -17,6 +17,9 @@
|
||||
"previous": "Zurück",
|
||||
"close": "Schließen",
|
||||
"logout": "Abmelden",
|
||||
"menu": "Menü",
|
||||
"change": "Ändern",
|
||||
"remove": "Entfernen",
|
||||
"download": "Herunterladen",
|
||||
"downloadAll": "Alle herunterladen",
|
||||
"uploading": "Wird hochgeladen...",
|
||||
@@ -150,12 +153,30 @@
|
||||
"deselectAll": "Auswahl aufheben",
|
||||
"downloadSelected": "{{count}} ausgewählte herunterladen",
|
||||
"remaining": "verbleibend",
|
||||
"selectPhotosHint": "Tipp: Verwenden Sie Strg+Klick (Cmd+Klick auf Mac), um schnell mehrere Fotos auszuwählen"
|
||||
"selectPhotosHint": "Tipp: Verwenden Sie Strg+Klick (Cmd+Klick auf Mac), um schnell mehrere Fotos auszuwählen",
|
||||
"filters": "Filter",
|
||||
"openFilters": "Filter öffnen",
|
||||
"toggleSidebar": "Seitenleiste umschalten",
|
||||
"toggleMenu": "Menü umschalten",
|
||||
"allCategories": "Alle Kategorien",
|
||||
"categories": "Kategorien",
|
||||
"download": "Herunterladen",
|
||||
"searchPlaceholder": "Fotos suchen...",
|
||||
"sortBy": "Sortieren nach"
|
||||
},
|
||||
"categories": {
|
||||
"title": "Fotokategorien",
|
||||
"global": "Globale Kategorien",
|
||||
"eventSpecific": "Veranstaltungsspezifische Kategorien",
|
||||
"eventSpecificCategories": "Veranstaltungsspezifische Kategorien",
|
||||
"organizationInfo": "Organisieren Sie Ihre Fotos in Kategorien. Kategorien helfen Gästen, bestimmte Fototypen zu navigieren und zu finden.",
|
||||
"noEventSpecificCategories": "Keine veranstaltungsspezifischen Kategorien. Globale Kategorien sind standardmäßig verfügbar.",
|
||||
"globalCategoriesAlwaysAvailable": "Globale Kategorien (immer verfügbar):",
|
||||
"deleteCategoryTitle": "Kategorie löschen",
|
||||
"categoryCreatedSuccess": "Kategorie erfolgreich erstellt",
|
||||
"categoryDeletedSuccess": "Kategorie erfolgreich gelöscht",
|
||||
"failedToCreateCategory": "Fehler beim Erstellen der Kategorie",
|
||||
"failedToDeleteCategory": "Fehler beim Löschen der Kategorie",
|
||||
"addCategory": "Kategorie hinzufügen",
|
||||
"categoryName": "Kategoriename",
|
||||
"noCategory": "Keine Kategorie",
|
||||
@@ -164,6 +185,17 @@
|
||||
"cannotDelete": "Kategorie mit Fotos kann nicht gelöscht werden. Bitte weisen Sie die Fotos zuerst neu zu."
|
||||
},
|
||||
"events": {
|
||||
"noStatisticsAvailableYet": "Noch keine Statistiken verfügbar",
|
||||
"totalPhotos": "Gesamtfotos",
|
||||
"totalViews": "Gesamtaufrufe",
|
||||
"totalDownloads": "Gesamte Downloads",
|
||||
"uniqueVisitors": "Eindeutige Besucher",
|
||||
"addPlus": "Hinzufügen+",
|
||||
"galleryTheme": "Galerie-Design",
|
||||
"customizeTheme": "Design anpassen",
|
||||
"noThemeSet": "Kein Design konfiguriert",
|
||||
"customizingTheme": "Galerie-Design anpassen",
|
||||
"customizingThemeFor": "Design für {{event}} anpassen",
|
||||
"title": "Veranstaltungen",
|
||||
"create": "Veranstaltung erstellen",
|
||||
"createEvent": "Veranstaltung erstellen",
|
||||
@@ -172,6 +204,8 @@
|
||||
"eventType": "Veranstaltungstyp",
|
||||
"eventDate": "Veranstaltungsdatum",
|
||||
"hostEmail": "Gastgeber-E-Mail",
|
||||
"hostName": "Name des Gastgebers",
|
||||
"hostNamePlaceholder": "Max Mustermann",
|
||||
"adminEmail": "Admin-E-Mail",
|
||||
"expirationDate": "Ablaufdatum",
|
||||
"active": "Aktiv",
|
||||
@@ -199,6 +233,7 @@
|
||||
"eventInformation": "Veranstaltungsinformationen",
|
||||
"welcomeMessage": "Willkommensnachricht",
|
||||
"noWelcomeMessage": "Keine Willkommensnachricht festgelegt",
|
||||
"noWelcomeMessageSet": "Keine Willkommensnachricht festgelegt",
|
||||
"created": "Erstellt",
|
||||
"expires": "Läuft ab",
|
||||
"shareWithGuests": "Teilen Sie diesen Link mit Gästen. Sie benötigen das Passwort, um auf die Galerie zuzugreifen.",
|
||||
@@ -232,6 +267,7 @@
|
||||
"galleryExpiration": "Galerie-Ablauf",
|
||||
"galleryExpiresIn": "Galerie läuft ab in",
|
||||
"daysAfterEvent": "Tage nach der Veranstaltung",
|
||||
"expiresOn": "Läuft ab am",
|
||||
"themeAndStyle": "Design & Stil",
|
||||
"galleryWillExpireOn": "Galerie läuft ab am {{date}}",
|
||||
"expirationWarning": "Gäste erhalten 7 Tage vor Ablauf eine Warn-E-Mail.",
|
||||
@@ -340,7 +376,10 @@
|
||||
"defaultLanguage": "Standardsprache",
|
||||
"defaultLanguageHelp": "Sprache, die Gästen vor der Anmeldung angezeigt wird",
|
||||
"saveSettings": "Allgemeine Einstellungen speichern",
|
||||
"saveGeneralSettings": "Allgemeine Einstellungen speichern"
|
||||
"saveGeneralSettings": "Allgemeine Einstellungen speichern",
|
||||
"dateTimeFormat": "Datums- & Zeitformat",
|
||||
"dateFormat": "Datumsformat",
|
||||
"dateFormatHelp": "Wie Daten in E-Mails und in der gesamten Anwendung angezeigt werden"
|
||||
},
|
||||
"storage": {
|
||||
"title": "Speicher",
|
||||
@@ -467,7 +506,80 @@
|
||||
"saveChanges": "Änderungen speichern",
|
||||
"applyLivePreview": "Änderungen sofort anwenden (Live-Vorschau)",
|
||||
"eventSpecificThemes": "Veranstaltungsspezifische Themen",
|
||||
"eventThemesInfo": "Sie können diese globalen Theme-Einstellungen für einzelne Veranstaltungen beim Erstellen oder Bearbeiten überschreiben."
|
||||
"eventThemesInfo": "Sie können diese globalen Theme-Einstellungen für einzelne Veranstaltungen beim Erstellen oder Bearbeiten überschreiben.",
|
||||
"themePresets": "Theme-Vorlagen",
|
||||
"galleryLayout": "Galerie-Layout",
|
||||
"layoutDescriptions": {
|
||||
"grid": "Klassisches Rasterlayout mit einheitlichen Fotogrößen",
|
||||
"masonry": "Pinterest-ähnliches Layout mit variablen Höhen",
|
||||
"carousel": "Vollbild-Diashow mit Navigation",
|
||||
"timeline": "Nach Datum organisierte Fotos",
|
||||
"hero": "Hervorgehobenes Bild mit Raster darunter",
|
||||
"mosaic": "Künstlerisches Layout mit gemischten Größen"
|
||||
},
|
||||
"layoutSettings": "Layout-Einstellungen",
|
||||
"photoSpacing": "Foto-Abstand",
|
||||
"spacing": {
|
||||
"tight": "Eng",
|
||||
"normal": "Normal",
|
||||
"relaxed": "Locker"
|
||||
},
|
||||
"photoAnimation": "Foto-Animation",
|
||||
"animation": {
|
||||
"none": "Keine",
|
||||
"fade": "Einblenden",
|
||||
"scale": "Skalieren",
|
||||
"slide": "Gleiten"
|
||||
},
|
||||
"columns": "Spalten",
|
||||
"mobile": "Mobil",
|
||||
"tablet": "Tablet",
|
||||
"desktop": "Desktop",
|
||||
"enableAutoplay": "Autoplay aktivieren",
|
||||
"autoplayInterval": "Autoplay-Intervall (Sekunden)",
|
||||
"groupPhotosBy": "Fotos gruppieren nach",
|
||||
"grouping": {
|
||||
"day": "Tag",
|
||||
"week": "Woche",
|
||||
"month": "Monat"
|
||||
},
|
||||
"typographyAndStyle": "Typografie & Stil",
|
||||
"bodyFont": "Fließtext-Schriftart",
|
||||
"headingFont": "Überschriften-Schriftart",
|
||||
"sameAsBody": "Wie Fließtext",
|
||||
"fontSize": "Schriftgröße",
|
||||
"fontSizes": {
|
||||
"small": "Klein",
|
||||
"normal": "Normal",
|
||||
"large": "Groß"
|
||||
},
|
||||
"borderRadius": "Eckenradius",
|
||||
"borderRadiusOptions": {
|
||||
"none": "Keine",
|
||||
"small": "Klein",
|
||||
"medium": "Mittel",
|
||||
"large": "Groß"
|
||||
},
|
||||
"shadowStyle": "Schattenstil",
|
||||
"shadowOptions": {
|
||||
"none": "Kein",
|
||||
"subtle": "Dezent",
|
||||
"normal": "Normal",
|
||||
"dramatic": "Dramatisch"
|
||||
},
|
||||
"backgroundPattern": "Hintergrund",
|
||||
"backgroundOptions": {
|
||||
"none": "Keiner",
|
||||
"dots": "Punkte",
|
||||
"grid": "Raster",
|
||||
"waves": "Wellen"
|
||||
},
|
||||
"customCSSHelp": "Erweitert: Fügen Sie benutzerdefiniertes CSS hinzu, um das Erscheinungsbild weiter anzupassen",
|
||||
"resetToDefault": "Auf Standard zurücksetzen",
|
||||
"applyTheme": "Theme anwenden",
|
||||
"customTheme": "Benutzerdefiniertes Design",
|
||||
"customizeTheme": "Design anpassen",
|
||||
"saveTheme": "Design speichern"
|
||||
},
|
||||
"admin": {
|
||||
"title": "Admin-Panel",
|
||||
@@ -483,6 +595,48 @@
|
||||
"notifications": "Benachrichtigungen",
|
||||
"viewAllNotifications": "Alle Benachrichtigungen anzeigen",
|
||||
"noNotifications": "Keine neuen Benachrichtigungen",
|
||||
"markAllRead": "Alle als gelesen markieren",
|
||||
"clearOld": "Alte löschen",
|
||||
"close": "Schließen",
|
||||
"noNotificationsMessage": "Keine Benachrichtigungen",
|
||||
"notificationMessages": {
|
||||
"eventCreated": "Neues Event \"{{eventName}}\" wurde erstellt",
|
||||
"eventArchived": "Event \"{{eventName}}\" wurde archiviert",
|
||||
"eventUpdated": "Event \"{{eventName}}\" wurde aktualisiert",
|
||||
"eventDeleted": "Event \"{{eventName}}\" wurde gelöscht",
|
||||
"photosUploaded": "{{count}} Fotos zu \"{{eventName}}\" hochgeladen",
|
||||
"photoDeleted": "Foto aus \"{{eventName}}\" gelöscht",
|
||||
"photosBulkDeleted": "{{count}} Fotos aus \"{{eventName}}\" gelöscht",
|
||||
"eventExpiring": "Event \"{{eventName}}\" läuft in {{days}} Tagen ab",
|
||||
"eventExpired": "Event \"{{eventName}}\" ist abgelaufen",
|
||||
"passwordChanged": "Passwort geändert von {{actorName}}",
|
||||
"passwordReset": "Passwort zurückgesetzt für \"{{eventName}}\"",
|
||||
"settingsUpdated": "{{type}}-Einstellungen aktualisiert",
|
||||
"emailTemplateUpdated": "E-Mail-Vorlage \"{{template}}\" aktualisiert",
|
||||
"bulkDownload": "{{count}} Fotos von \"{{eventName}}\" heruntergeladen",
|
||||
"storageWarning": "Speichernutzung bei {{percentage}}%",
|
||||
"adminLogout": "Admin {{actorName}} hat sich abgemeldet",
|
||||
"categoryCreated": "Kategorie \"{{name}}\" für \"{{eventName}}\" erstellt",
|
||||
"categoryUpdated": "Kategorie \"{{name}}\" für \"{{eventName}}\" aktualisiert",
|
||||
"categoryDeleted": "Kategorie \"{{name}}\" aus \"{{eventName}}\" gelöscht",
|
||||
"cmsPageUpdated": "CMS-Seite \"{{slug}}\" aktualisiert",
|
||||
"emailConfigUpdated": "E-Mail-Konfiguration aktualisiert",
|
||||
"faviconUploaded": "Favicon hochgeladen",
|
||||
"brandingUpdated": "Branding-Einstellungen aktualisiert",
|
||||
"generalSettingsUpdated": "Allgemeine Einstellungen aktualisiert",
|
||||
"securitySettingsUpdated": "Sicherheitseinstellungen aktualisiert",
|
||||
"themeUpdated": "Theme-Einstellungen aktualisiert",
|
||||
"archiveDownloaded": "Archiv für \"{{eventName}}\" heruntergeladen",
|
||||
"archiveDeleted": "Archiv für \"{{eventName}}\" gelöscht",
|
||||
"archiveRestored": "Archiv für \"{{eventName}}\" wiederhergestellt",
|
||||
"systemActivity": "Systemaktivität: {{type}}"
|
||||
},
|
||||
"notificationToasts": {
|
||||
"markedAllRead": "Alle Benachrichtigungen als gelesen markiert",
|
||||
"clearedOld": "{{count}} alte Benachrichtigungen gelöscht"
|
||||
},
|
||||
"viewAllNotifications": "Alle Benachrichtigungen anzeigen",
|
||||
"noNotifications": "Keine neuen Benachrichtigungen",
|
||||
"markAsRead": "Als gelesen markieren",
|
||||
"markAllAsRead": "Alle als gelesen markieren",
|
||||
"notificationSettings": "Benachrichtigungseinstellungen",
|
||||
@@ -663,6 +817,7 @@
|
||||
"validation": {
|
||||
"eventNameRequired": "Veranstaltungsname ist erforderlich",
|
||||
"hostEmailRequired": "Gastgeber-E-Mail ist erforderlich",
|
||||
"hostNameRequired": "Der Name des Gastgebers ist erforderlich",
|
||||
"adminEmailRequired": "Admin-E-Mail ist erforderlich",
|
||||
"invalidEmailFormat": "Ungültiges E-Mail-Format",
|
||||
"passwordRequired": "Passwort ist erforderlich",
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
"previous": "Previous",
|
||||
"close": "Close",
|
||||
"logout": "Logout",
|
||||
"menu": "Menu",
|
||||
"change": "Change",
|
||||
"remove": "Remove",
|
||||
"download": "Download",
|
||||
"downloadAll": "Download All",
|
||||
"uploading": "Uploading...",
|
||||
@@ -150,13 +153,30 @@
|
||||
"deselectAll": "Deselect All",
|
||||
"downloadSelected": "Download {{count}} Selected",
|
||||
"remaining": "remaining",
|
||||
"selectPhotosHint": "Tip: Use Ctrl+Click (Cmd+Click on Mac) to quickly select multiple photos"
|
||||
"selectPhotosHint": "Tip: Use Ctrl+Click (Cmd+Click on Mac) to quickly select multiple photos",
|
||||
"filters": "Filters",
|
||||
"openFilters": "Open filters",
|
||||
"toggleSidebar": "Toggle sidebar",
|
||||
"toggleMenu": "Toggle menu",
|
||||
"allCategories": "All Categories",
|
||||
"categories": "Categories",
|
||||
"download": "Download",
|
||||
"searchPlaceholder": "Search photos..."
|
||||
},
|
||||
"categories": {
|
||||
"title": "Photo Categories",
|
||||
"global": "Global Categories",
|
||||
"eventSpecific": "Event-Specific Categories",
|
||||
"addCategory": "Add Category",
|
||||
"organizationInfo": "Organize your photos into categories. Categories help guests navigate and find specific types of photos.",
|
||||
"eventSpecificCategories": "Event-Specific Categories",
|
||||
"noEventSpecificCategories": "No event-specific categories. Global categories are available by default.",
|
||||
"globalCategoriesAlwaysAvailable": "Global Categories (always available):",
|
||||
"deleteCategoryTitle": "Delete category",
|
||||
"categoryCreatedSuccess": "Category created successfully",
|
||||
"categoryDeletedSuccess": "Category deleted successfully",
|
||||
"failedToCreateCategory": "Failed to create category",
|
||||
"failedToDeleteCategory": "Failed to delete category",
|
||||
"categoryName": "Category name",
|
||||
"noCategory": "No category",
|
||||
"noCategoriesYet": "No categories yet. Create your first category to organize photos.",
|
||||
@@ -166,6 +186,9 @@
|
||||
"events": {
|
||||
"title": "Events",
|
||||
"createEvent": "Create Event",
|
||||
"totalViews": "Total Views",
|
||||
"totalDownloads": "Total Downloads",
|
||||
"uniqueVisitors": "Unique Visitors",
|
||||
"createNewEvent": "Create New Event",
|
||||
"setupNewGallery": "Set up a new photo gallery for your event",
|
||||
"createNewEventSubtitle": "Set up a new photo gallery for your event",
|
||||
@@ -197,6 +220,8 @@
|
||||
"eventType": "Event Type",
|
||||
"eventDate": "Event Date",
|
||||
"hostEmail": "Host Email",
|
||||
"hostName": "Host Name",
|
||||
"hostNamePlaceholder": "John Smith",
|
||||
"adminEmail": "Admin Email",
|
||||
"adminNotificationEmail": "Admin Notification Email",
|
||||
"expirationDate": "Expiration Date",
|
||||
@@ -230,6 +255,7 @@
|
||||
"shareWithGuests": "Share this link with guests. They'll need the password to access the gallery.",
|
||||
"resetGalleryPassword": "Reset Gallery Password",
|
||||
"photoStatistics": "Photo Statistics",
|
||||
"totalPhotos": "Total Photos",
|
||||
"managePhotos": "Manage Photos",
|
||||
"actions": "Actions",
|
||||
"archivingInfo": "Archiving will create a ZIP file of all photos and remove the gallery from public access.",
|
||||
@@ -264,6 +290,12 @@
|
||||
"selectCategory": "Select a category for user uploads",
|
||||
"uploadCategoryHelp": "All user-uploaded photos will be added to this category",
|
||||
"userUploadWarning": "User uploads will be moderated and can be removed by admins at any time.",
|
||||
"heroPhoto": "Hero Photo",
|
||||
"heroPhotoHelp": "Select a featured photo for the hero gallery layout",
|
||||
"selectHeroPhoto": "Select Hero Photo",
|
||||
"noHeroPhotoSelected": "No hero photo selected",
|
||||
"heroPhotoSelected": "Hero photo selected",
|
||||
"noPhotosAvailable": "No photos available",
|
||||
"processingRequest": "Processing your request...",
|
||||
"eventTypeWedding": "Wedding",
|
||||
"eventTypeBirthday": "Birthday",
|
||||
@@ -317,8 +349,8 @@
|
||||
"adminEmail": "Admin Email",
|
||||
"createdOn": "Created",
|
||||
"expires": "Expires",
|
||||
"daysLeft": "({{days}} days left)",
|
||||
"daysLeft_plural": "({{days}} days left)",
|
||||
"daysLeft": "({{count}} day left)",
|
||||
"daysLeft_plural": "({{count}} days left)",
|
||||
"shareLink": "Share Link",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied!",
|
||||
@@ -332,7 +364,14 @@
|
||||
"downloadStarted": "Download started",
|
||||
"failedToDownloadArchive": "Failed to download archive",
|
||||
"statisticsNotAvailable": "No statistics available yet",
|
||||
"photoFilters": "Photo Filters"
|
||||
"photoFilters": "Photo Filters",
|
||||
"noStatisticsAvailableYet": "No statistics available yet",
|
||||
"addPlus": "Add+",
|
||||
"galleryTheme": "Gallery Theme",
|
||||
"customizeTheme": "Customize Theme",
|
||||
"noThemeSet": "No theme configured",
|
||||
"customizingTheme": "Customizing gallery theme",
|
||||
"customizingThemeFor": "Customizing theme for {{event}}"
|
||||
},
|
||||
"settings": {
|
||||
"title": "System Settings",
|
||||
@@ -358,7 +397,10 @@
|
||||
"defaultLanguage": "Default Language",
|
||||
"defaultLanguageHelp": "Language shown to guests before login",
|
||||
"saveSettings": "Save General Settings",
|
||||
"saveGeneralSettings": "Save General Settings"
|
||||
"saveGeneralSettings": "Save General Settings",
|
||||
"dateTimeFormat": "Date & Time Format",
|
||||
"dateFormat": "Date Format",
|
||||
"dateFormatHelp": "How dates are displayed in emails and throughout the application"
|
||||
},
|
||||
"storage": {
|
||||
"title": "Storage",
|
||||
@@ -519,7 +561,80 @@
|
||||
"saveChanges": "Save Changes",
|
||||
"applyLivePreview": "Apply changes immediately (Live Preview)",
|
||||
"eventSpecificThemes": "Event-Specific Themes",
|
||||
"eventThemesInfo": "You can override these global theme settings for individual events when creating or editing them."
|
||||
"eventThemesInfo": "You can override these global theme settings for individual events when creating or editing them.",
|
||||
"themePresets": "Theme Presets",
|
||||
"galleryLayout": "Gallery Layout",
|
||||
"layoutDescriptions": {
|
||||
"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"
|
||||
},
|
||||
"layoutSettings": "Layout Settings",
|
||||
"photoSpacing": "Photo Spacing",
|
||||
"spacing": {
|
||||
"tight": "Tight",
|
||||
"normal": "Normal",
|
||||
"relaxed": "Relaxed"
|
||||
},
|
||||
"photoAnimation": "Photo Animation",
|
||||
"animation": {
|
||||
"none": "None",
|
||||
"fade": "Fade",
|
||||
"scale": "Scale",
|
||||
"slide": "Slide"
|
||||
},
|
||||
"columns": "Columns",
|
||||
"mobile": "Mobile",
|
||||
"tablet": "Tablet",
|
||||
"desktop": "Desktop",
|
||||
"enableAutoplay": "Enable Autoplay",
|
||||
"autoplayInterval": "Autoplay Interval (seconds)",
|
||||
"groupPhotosBy": "Group Photos By",
|
||||
"grouping": {
|
||||
"day": "Day",
|
||||
"week": "Week",
|
||||
"month": "Month"
|
||||
},
|
||||
"typographyAndStyle": "Typography & Style",
|
||||
"bodyFont": "Body Font",
|
||||
"headingFont": "Heading Font",
|
||||
"sameAsBody": "Same as body",
|
||||
"fontSize": "Font Size",
|
||||
"fontSizes": {
|
||||
"small": "Small",
|
||||
"normal": "Normal",
|
||||
"large": "Large"
|
||||
},
|
||||
"borderRadius": "Border Radius",
|
||||
"borderRadiusOptions": {
|
||||
"none": "None",
|
||||
"small": "Small",
|
||||
"medium": "Medium",
|
||||
"large": "Large"
|
||||
},
|
||||
"shadowStyle": "Shadow Style",
|
||||
"shadowOptions": {
|
||||
"none": "None",
|
||||
"subtle": "Subtle",
|
||||
"normal": "Normal",
|
||||
"dramatic": "Dramatic"
|
||||
},
|
||||
"backgroundPattern": "Background",
|
||||
"backgroundOptions": {
|
||||
"none": "None",
|
||||
"dots": "Dots",
|
||||
"grid": "Grid",
|
||||
"waves": "Waves"
|
||||
},
|
||||
"customCSSHelp": "Advanced: Add custom CSS to further customize the appearance",
|
||||
"resetToDefault": "Reset to Default",
|
||||
"applyTheme": "Apply Theme",
|
||||
"customTheme": "Custom Theme",
|
||||
"customizeTheme": "Customize Theme",
|
||||
"saveTheme": "Save Theme"
|
||||
},
|
||||
"admin": {
|
||||
"title": "Admin Panel",
|
||||
@@ -535,6 +650,46 @@
|
||||
"notifications": "Notifications",
|
||||
"viewAllNotifications": "View all notifications",
|
||||
"noNotifications": "No new notifications",
|
||||
"markAllRead": "Mark all read",
|
||||
"clearOld": "Clear old",
|
||||
"close": "Close",
|
||||
"noNotificationsMessage": "No notifications",
|
||||
"notificationMessages": {
|
||||
"eventCreated": "New event \"{{eventName}}\" was created",
|
||||
"eventArchived": "Event \"{{eventName}}\" was archived",
|
||||
"eventUpdated": "Event \"{{eventName}}\" was updated",
|
||||
"eventDeleted": "Event \"{{eventName}}\" was deleted",
|
||||
"photosUploaded": "{{count}} photos uploaded to \"{{eventName}}\"",
|
||||
"photoDeleted": "Photo deleted from \"{{eventName}}\"",
|
||||
"photosBulkDeleted": "{{count}} photos deleted from \"{{eventName}}\"",
|
||||
"eventExpiring": "Event \"{{eventName}}\" expires in {{days}} days",
|
||||
"eventExpired": "Event \"{{eventName}}\" has expired",
|
||||
"passwordChanged": "Password changed by {{actorName}}",
|
||||
"passwordReset": "Password reset for \"{{eventName}}\"",
|
||||
"settingsUpdated": "{{type}} settings updated",
|
||||
"emailTemplateUpdated": "Email template \"{{template}}\" updated",
|
||||
"bulkDownload": "{{count}} photos downloaded from \"{{eventName}}\"",
|
||||
"storageWarning": "Storage usage at {{percentage}}%",
|
||||
"adminLogout": "Admin {{actorName}} logged out",
|
||||
"categoryCreated": "Category \"{{name}}\" created for \"{{eventName}}\"",
|
||||
"categoryUpdated": "Category \"{{name}}\" updated for \"{{eventName}}\"",
|
||||
"categoryDeleted": "Category \"{{name}}\" deleted from \"{{eventName}}\"",
|
||||
"cmsPageUpdated": "CMS page \"{{slug}}\" updated",
|
||||
"emailConfigUpdated": "Email configuration updated",
|
||||
"faviconUploaded": "Favicon uploaded",
|
||||
"brandingUpdated": "Branding settings updated",
|
||||
"generalSettingsUpdated": "General settings updated",
|
||||
"securitySettingsUpdated": "Security settings updated",
|
||||
"themeUpdated": "Theme settings updated",
|
||||
"archiveDownloaded": "Archive downloaded for \"{{eventName}}\"",
|
||||
"archiveDeleted": "Archive deleted for \"{{eventName}}\"",
|
||||
"archiveRestored": "Archive restored for \"{{eventName}}\"",
|
||||
"systemActivity": "System activity: {{type}}"
|
||||
},
|
||||
"notificationToasts": {
|
||||
"markedAllRead": "All notifications marked as read",
|
||||
"clearedOld": "Cleared {{count}} old notifications"
|
||||
},
|
||||
"markAsRead": "Mark as read",
|
||||
"markAllAsRead": "Mark all as read",
|
||||
"notificationSettings": "Notification Settings",
|
||||
@@ -584,6 +739,7 @@
|
||||
"validation": {
|
||||
"eventNameRequired": "Event name is required",
|
||||
"hostEmailRequired": "Host email is required",
|
||||
"hostNameRequired": "Host name is required",
|
||||
"adminEmailRequired": "Admin email is required",
|
||||
"invalidEmailFormat": "Invalid email format",
|
||||
"passwordRequired": "Password is required",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Noto+Sans:wght@300;400;500;600;700&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Noto+Sans:wght@300;400;500;600;700&family=Poppins:wght@400;500;600;700&display=swap');
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { Camera, Calendar, AlertCircle, Clock } from 'lucide-react';
|
||||
import { Calendar, AlertCircle, Clock } from 'lucide-react';
|
||||
import { differenceInDays, parseISO } from 'date-fns';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocalizedDate } from '../hooks/useLocalizedDate';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { Card, CardContent, Input, Button, Loading, ReCaptcha } from '../components/common';
|
||||
import { useGalleryAuth } from '../contexts';
|
||||
import { useGalleryAuth, useTheme } from '../contexts';
|
||||
import { useGalleryInfo } from '../hooks/useGallery';
|
||||
import { GalleryView } from '../components/gallery';
|
||||
import { analyticsService } from '../services/analytics.service';
|
||||
import { api } from '../config/api';
|
||||
import { GALLERY_THEME_PRESETS } from '../types/theme.types';
|
||||
|
||||
export const GalleryPage: React.FC = () => {
|
||||
const { slug, token } = useParams<{ slug: string; token?: string }>();
|
||||
const { isAuthenticated, login, event } = useGalleryAuth();
|
||||
const { t, i18n } = useTranslation();
|
||||
const { format } = useLocalizedDate();
|
||||
const { setTheme } = useTheme();
|
||||
const [password, setPassword] = useState('');
|
||||
const [isLoggingIn, setIsLoggingIn] = useState(false);
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
@@ -43,6 +45,47 @@ export const GalleryPage: React.FC = () => {
|
||||
}
|
||||
}, [settingsData, isAuthenticated, i18n]);
|
||||
|
||||
// Apply theme for login page
|
||||
React.useEffect(() => {
|
||||
if (!isAuthenticated && galleryInfo && settingsData) {
|
||||
let themeToApply = null;
|
||||
|
||||
if (galleryInfo.color_theme) {
|
||||
try {
|
||||
// Check if it's a valid JSON string
|
||||
if (galleryInfo.color_theme.startsWith('{')) {
|
||||
themeToApply = JSON.parse(galleryInfo.color_theme);
|
||||
} else {
|
||||
// Handle legacy theme names - check if it's a preset
|
||||
const preset = GALLERY_THEME_PRESETS[galleryInfo.color_theme];
|
||||
if (preset) {
|
||||
themeToApply = preset.config;
|
||||
} else {
|
||||
// Unknown theme name, fall back to global theme
|
||||
if (settingsData.theme_config) {
|
||||
themeToApply = settingsData.theme_config;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse event theme:', e);
|
||||
// Fall back to global theme
|
||||
if (settingsData.theme_config) {
|
||||
themeToApply = settingsData.theme_config;
|
||||
}
|
||||
}
|
||||
} else if (settingsData.theme_config) {
|
||||
// No event theme, use global theme
|
||||
themeToApply = settingsData.theme_config;
|
||||
}
|
||||
|
||||
// Apply theme
|
||||
if (themeToApply) {
|
||||
setTheme(themeToApply);
|
||||
}
|
||||
}
|
||||
}, [galleryInfo, settingsData, isAuthenticated, setTheme]);
|
||||
|
||||
// Calculate days until expiration
|
||||
const daysUntilExpiration = galleryInfo
|
||||
? differenceInDays(parseISO(galleryInfo.expires_at), new Date())
|
||||
@@ -159,6 +202,9 @@ export const GalleryPage: React.FC = () => {
|
||||
{t('legal.datenschutz')}
|
||||
</Link>
|
||||
</div>
|
||||
<p className="text-xs mt-2 text-neutral-500">
|
||||
Powered by <span className="font-semibold">PicPeak</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -213,6 +259,9 @@ export const GalleryPage: React.FC = () => {
|
||||
{t('legal.datenschutz')}
|
||||
</Link>
|
||||
</div>
|
||||
<p className="text-xs mt-2 text-neutral-500">
|
||||
Powered by <span className="font-semibold">PicPeak</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -231,17 +280,14 @@ export const GalleryPage: React.FC = () => {
|
||||
<div className="w-full max-w-lg">
|
||||
{/* Logo/Header */}
|
||||
<div className="text-center mb-4 sm:mb-6">
|
||||
{settingsData?.branding_logo_url ? (
|
||||
<img
|
||||
src={`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${settingsData.branding_logo_url}`}
|
||||
alt={settingsData.branding_company_name || 'Company Logo'}
|
||||
className="h-12 sm:h-16 lg:h-20 w-auto object-contain mx-auto mb-3 sm:mb-4"
|
||||
/>
|
||||
) : (
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 sm:w-16 sm:h-16 lg:w-20 lg:h-20 rounded-2xl mb-3 sm:mb-4" style={{ backgroundColor: 'var(--color-primary, #5C8762)' }}>
|
||||
<Camera className="w-6 h-6 sm:w-8 sm:h-8 lg:w-10 lg:h-10 text-white" />
|
||||
</div>
|
||||
)}
|
||||
<img
|
||||
src={settingsData?.branding_logo_url ?
|
||||
`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${settingsData.branding_logo_url}` :
|
||||
'/picpeak-logo-transparent.png'
|
||||
}
|
||||
alt={settingsData?.branding_company_name || 'PicPeak'}
|
||||
className="h-12 sm:h-16 lg:h-20 w-auto object-contain mx-auto mb-3 sm:mb-4"
|
||||
/>
|
||||
<h1 className="text-xl sm:text-2xl lg:text-3xl font-bold mb-2 px-2" style={{ color: 'var(--color-text, #171717)' }}>
|
||||
{galleryInfo?.event_name}
|
||||
</h1>
|
||||
@@ -325,6 +371,9 @@ export const GalleryPage: React.FC = () => {
|
||||
{t('legal.datenschutz')}
|
||||
</Link>
|
||||
</div>
|
||||
<p className="text-xs mt-2 text-neutral-500">
|
||||
Powered by <span className="font-semibold">PicPeak</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -120,17 +120,16 @@ export const AdminLoginPage: React.FC = () => {
|
||||
<div className="w-full max-w-md">
|
||||
{/* Logo/Header */}
|
||||
<div className="text-center mb-8">
|
||||
{settingsData?.branding_logo_url ? (
|
||||
<div
|
||||
className="w-[200px] h-[150px] mx-auto mb-6 rounded-2xl flex items-center justify-center"
|
||||
style={{ backgroundColor: '#eee6d2' }}
|
||||
>
|
||||
<img
|
||||
src={`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${settingsData.branding_logo_url}`}
|
||||
alt={settingsData.branding_company_name || 'Company Logo'}
|
||||
className="h-16 w-auto object-contain mx-auto mb-4"
|
||||
src="/picpeak-logo-transparent.png"
|
||||
alt="PicPeak"
|
||||
className="w-[180px] h-[130px] object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full mb-4" style={{ backgroundColor: 'var(--color-primary, #5C8762)' }}>
|
||||
<Lock className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold" style={{ color: 'var(--color-text, #171717)' }}>Admin Login</h1>
|
||||
<p className="mt-2" style={{ color: 'var(--color-text, #171717)', opacity: 0.7 }}>Sign in to manage your photo galleries</p>
|
||||
</div>
|
||||
@@ -229,16 +228,21 @@ export const AdminLoginPage: React.FC = () => {
|
||||
</Card>
|
||||
|
||||
{/* Footer */}
|
||||
<p className="text-center text-sm mt-8" style={{ color: 'var(--color-text, #171717)', opacity: 0.7 }}>
|
||||
Need help? Contact{' '}
|
||||
<a
|
||||
href={`mailto:${settingsData?.branding_support_email || 'support@example.com'}`}
|
||||
className="hover:underline"
|
||||
style={{ color: 'var(--color-primary, #5C8762)' }}
|
||||
>
|
||||
{settingsData?.branding_support_email || 'support@example.com'}
|
||||
</a>
|
||||
</p>
|
||||
<div className="text-center mt-8">
|
||||
<p className="text-sm" style={{ color: 'var(--color-text, #171717)', opacity: 0.7 }}>
|
||||
Need help? Contact{' '}
|
||||
<a
|
||||
href={`mailto:${settingsData?.branding_support_email || 'support@example.com'}`}
|
||||
className="hover:underline"
|
||||
style={{ color: 'var(--color-primary, #5C8762)' }}
|
||||
>
|
||||
{settingsData?.branding_support_email || 'support@example.com'}
|
||||
</a>
|
||||
</p>
|
||||
<p className="text-xs mt-2" style={{ color: 'var(--color-text, #171717)', opacity: 0.5 }}>
|
||||
Powered by <span className="font-semibold">PicPeak</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Development Hint */}
|
||||
{import.meta.env.DEV && (
|
||||
|
||||
@@ -4,7 +4,7 @@ import { toast } from 'react-toastify';
|
||||
import { Button, Card, Input, ErrorBoundary, Loading } from '../../components/common';
|
||||
import { ThemeCustomizer } from '../../components/admin/ThemeCustomizer';
|
||||
import { useTheme, type ThemeConfig, GALLERY_THEME_PRESETS } from '../../contexts/ThemeContext';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { settingsService, type BrandingSettings } from '../../services/settings.service';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -42,10 +42,15 @@ export const BrandingPage: React.FC = () => {
|
||||
});
|
||||
|
||||
// Update branding mutation
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const brandingMutation = useMutation({
|
||||
mutationFn: settingsService.updateBranding,
|
||||
onSuccess: () => {
|
||||
toast.success(t('toast.brandingUpdated'));
|
||||
// Invalidate all settings queries to refresh data
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['public-settings'] });
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('toast.saveError'));
|
||||
@@ -67,21 +72,24 @@ export const BrandingPage: React.FC = () => {
|
||||
useEffect(() => {
|
||||
if (settings) {
|
||||
const formatted = settingsService.formatBrandingSettings(settings);
|
||||
setBrandingSettings(formatted);
|
||||
// Don't set logo_url here - it will be synced from theme
|
||||
const { logo_url, ...brandingWithoutLogo } = formatted;
|
||||
setBrandingSettings(prev => ({ ...prev, ...brandingWithoutLogo }));
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
// Initialize theme from database
|
||||
useEffect(() => {
|
||||
if (themeSettings) {
|
||||
const formatted = settingsService.formatThemeSettings(themeSettings);
|
||||
const formatted = settingsService.formatThemeSettings(themeSettings) as ThemeConfig;
|
||||
|
||||
if (formatted && Object.keys(formatted).length > 0) {
|
||||
// Merge logo URL from branding settings if available
|
||||
const logoUrl = settings?.branding_logo_url || brandingSettings.logo_url;
|
||||
const themeWithLogo = logoUrl ? { ...formatted, logoUrl } : formatted;
|
||||
// Use the theme's logo URL as stored in the theme config
|
||||
setCurrentTheme(formatted);
|
||||
setTheme(formatted);
|
||||
|
||||
setCurrentTheme(themeWithLogo);
|
||||
setTheme(themeWithLogo);
|
||||
// Always sync the logo URL from theme to branding settings - theme is source of truth
|
||||
setBrandingSettings(prev => ({ ...prev, logo_url: formatted.logoUrl || '' }));
|
||||
|
||||
// Try to identify which preset this matches
|
||||
for (const [key, preset] of Object.entries(GALLERY_THEME_PRESETS)) {
|
||||
@@ -92,7 +100,7 @@ export const BrandingPage: React.FC = () => {
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [themeSettings, settings, brandingSettings.logo_url, setTheme]);
|
||||
}, [themeSettings, setTheme]);
|
||||
|
||||
const handleBrandingChange = (key: string, value: any) => {
|
||||
setBrandingSettings(prev => ({ ...prev, [key]: value }));
|
||||
@@ -151,17 +159,23 @@ export const BrandingPage: React.FC = () => {
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
// Save branding settings to database
|
||||
await brandingMutation.mutateAsync(brandingSettings);
|
||||
// Sync logo URL from theme to branding settings
|
||||
const updatedBrandingSettings = {
|
||||
...brandingSettings,
|
||||
logo_url: currentTheme.logoUrl || ''
|
||||
};
|
||||
|
||||
// Save theme settings to database (including logo URL if present)
|
||||
const themeToSave = brandingSettings.logo_url
|
||||
? { ...currentTheme, logoUrl: brandingSettings.logo_url }
|
||||
: currentTheme;
|
||||
await themeMutation.mutateAsync(themeToSave);
|
||||
// Save branding settings to database
|
||||
await brandingMutation.mutateAsync(updatedBrandingSettings);
|
||||
|
||||
// Save theme settings to database
|
||||
await themeMutation.mutateAsync(currentTheme);
|
||||
|
||||
// Apply theme globally
|
||||
setTheme(themeToSave);
|
||||
setTheme(currentTheme);
|
||||
|
||||
// Update local state to reflect saved values
|
||||
setBrandingSettings(updatedBrandingSettings);
|
||||
} catch (error) {
|
||||
console.error('Failed to save settings:', error);
|
||||
}
|
||||
@@ -463,7 +477,6 @@ export const BrandingPage: React.FC = () => {
|
||||
onChange={handleThemeChange}
|
||||
presetName={currentThemeName}
|
||||
onPresetChange={handlePresetChange}
|
||||
isPreviewMode={isPreviewMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
EyeOff
|
||||
} from 'lucide-react';
|
||||
import { format, addDays } from 'date-fns';
|
||||
import { enUS, de } from 'date-fns/locale';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import { Button, Input, Card } from '../../components/common';
|
||||
@@ -18,6 +19,7 @@ import { ThemeCustomizerEnhanced } from '../../components/admin/ThemeCustomizerE
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { eventsService } from '../../services/events.service';
|
||||
import { categoriesService } from '../../services/categories.service';
|
||||
import { settingsService } from '../../services/settings.service';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ThemeConfig, GALLERY_THEME_PRESETS } from '../../types/theme.types';
|
||||
|
||||
@@ -25,6 +27,7 @@ interface FormData {
|
||||
event_type: string;
|
||||
event_name: string;
|
||||
event_date: string;
|
||||
host_name: string;
|
||||
host_email: string;
|
||||
admin_email: string;
|
||||
password: string;
|
||||
@@ -53,7 +56,7 @@ const EVENT_TYPES = [
|
||||
|
||||
export const CreateEventPageEnhanced: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const isMountedRef = useRef(true);
|
||||
const [showThemeCustomizer, setShowThemeCustomizer] = useState(false);
|
||||
// const [showPreview, setShowPreview] = useState(false);
|
||||
@@ -68,6 +71,7 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
||||
event_type: 'wedding',
|
||||
event_name: '',
|
||||
event_date: format(new Date(), 'yyyy-MM-dd'),
|
||||
host_name: '',
|
||||
host_email: '',
|
||||
admin_email: '',
|
||||
password: '',
|
||||
@@ -89,6 +93,22 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
||||
queryFn: () => categoriesService.getGlobalCategories()
|
||||
});
|
||||
|
||||
// Fetch default settings
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ['admin-settings'],
|
||||
queryFn: () => settingsService.getAllSettings()
|
||||
});
|
||||
|
||||
// Update default expiration days when settings are loaded
|
||||
useEffect(() => {
|
||||
if (settings?.general_default_expiration_days) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
expires_in_days: settings.general_default_expiration_days
|
||||
}));
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
// Update theme when event type changes
|
||||
useEffect(() => {
|
||||
const recommendedPreset = EVENT_TYPE_PRESETS[formData.event_type];
|
||||
@@ -111,8 +131,21 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
||||
},
|
||||
onError: (error: any) => {
|
||||
console.error('Create event error:', error);
|
||||
console.error('Error response:', error.response?.data);
|
||||
console.error('Error status:', error.response?.status);
|
||||
console.error('Full error object:', JSON.stringify(error.response, null, 2));
|
||||
const errorMessage = error.response?.data?.error || error.message || t('errors.eventCreationFailed');
|
||||
toast.error(errorMessage);
|
||||
|
||||
// If validation errors exist, show them
|
||||
if (error.response?.data?.errors) {
|
||||
const validationErrors = error.response.data.errors;
|
||||
console.error('Validation errors:', validationErrors);
|
||||
validationErrors.forEach((err: any) => {
|
||||
toast.error(`${err.param}: ${err.msg}`);
|
||||
});
|
||||
} else {
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -127,6 +160,10 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
||||
newErrors.event_date = t('validation.eventDateRequired');
|
||||
}
|
||||
|
||||
if (!formData.host_name) {
|
||||
newErrors.host_name = t('validation.hostNameRequired');
|
||||
}
|
||||
|
||||
if (!formData.host_email) {
|
||||
newErrors.host_email = t('validation.hostEmailRequired');
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.host_email)) {
|
||||
@@ -164,10 +201,11 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
createMutation.mutate({
|
||||
const payload = {
|
||||
event_type: formData.event_type,
|
||||
event_name: formData.event_name,
|
||||
event_date: formData.event_date,
|
||||
host_name: formData.host_name,
|
||||
host_email: formData.host_email,
|
||||
admin_email: formData.admin_email,
|
||||
password: formData.password,
|
||||
@@ -176,7 +214,10 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
||||
expiration_days: formData.expires_in_days,
|
||||
allow_user_uploads: formData.allow_user_uploads,
|
||||
upload_category_id: formData.upload_category_id,
|
||||
});
|
||||
};
|
||||
|
||||
console.log('Submitting payload:', payload);
|
||||
createMutation.mutate(payload);
|
||||
};
|
||||
|
||||
const handleInputChange = (field: keyof FormData) => (
|
||||
@@ -354,16 +395,27 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
||||
{t('events.accessAndSecurity')}
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
type="email"
|
||||
label={t('events.hostEmail')}
|
||||
placeholder={t('events.hostEmailPlaceholder')}
|
||||
value={formData.host_email}
|
||||
onChange={handleInputChange('host_email')}
|
||||
error={errors.host_email}
|
||||
leftIcon={<Mail className="w-5 h-5" />}
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label={t('events.hostName')}
|
||||
placeholder={t('events.hostNamePlaceholder')}
|
||||
value={formData.host_name}
|
||||
onChange={handleInputChange('host_name')}
|
||||
error={errors.host_name}
|
||||
leftIcon={<Calendar className="w-5 h-5" />}
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="email"
|
||||
label={t('events.hostEmail')}
|
||||
placeholder={t('events.hostEmailPlaceholder')}
|
||||
value={formData.host_email}
|
||||
onChange={handleInputChange('host_email')}
|
||||
error={errors.host_email}
|
||||
leftIcon={<Mail className="w-5 h-5" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
type="email"
|
||||
@@ -411,22 +463,23 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
{t('events.galleryExpiration')}
|
||||
</label>
|
||||
<div className="flex items-center gap-4">
|
||||
<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" />}
|
||||
className="w-32"
|
||||
/>
|
||||
<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>
|
||||
{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), 'PPP')}
|
||||
{t('events.expiresOn')}: {format(addDays(new Date(formData.event_date), formData.expires_in_days), 'PPP', { locale: i18n.language === 'de' ? de : enUS })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -5,8 +5,6 @@ import {
|
||||
ArrowLeft,
|
||||
ExternalLink,
|
||||
Calendar,
|
||||
Users,
|
||||
Eye,
|
||||
Download,
|
||||
Archive,
|
||||
Edit2,
|
||||
@@ -17,24 +15,28 @@ import {
|
||||
CheckCircle,
|
||||
Upload,
|
||||
Image,
|
||||
Key
|
||||
Key,
|
||||
Palette,
|
||||
Settings
|
||||
} from 'lucide-react';
|
||||
import { format, parseISO, differenceInDays } from 'date-fns';
|
||||
import { parseISO, differenceInDays } from 'date-fns';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
|
||||
import { Button, Input, Card, Loading } from '../../components/common';
|
||||
import { PhotoUpload, EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PasswordResetModal } from '../../components/admin';
|
||||
import { PhotoUpload, EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PasswordResetModal, ThemeDisplay, ThemeCustomizerEnhanced, ThemeEditorModal, HeroPhotoSelector } from '../../components/admin';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { eventsService } from '../../services/events.service';
|
||||
import { galleryService } from '../../services/gallery.service';
|
||||
import { archiveService } from '../../services/archive.service';
|
||||
import { photosService, AdminPhoto } from '../../services/photos.service';
|
||||
import { ThemeConfig, GALLERY_THEME_PRESETS } from '../../types/theme.types';
|
||||
|
||||
export const EventDetailsPage: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
const { format } = useLocalizedDate();
|
||||
|
||||
// Validate ID parameter
|
||||
React.useEffect(() => {
|
||||
@@ -50,12 +52,18 @@ export const EventDetailsPage: React.FC = () => {
|
||||
expires_at: '',
|
||||
allow_user_uploads: false,
|
||||
upload_category_id: null as number | null,
|
||||
hero_photo_id: null as number | null,
|
||||
host_name: '',
|
||||
});
|
||||
const [copiedLink, setCopiedLink] = useState(false);
|
||||
const [showPhotoUpload, setShowPhotoUpload] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'photos' | 'categories'>('overview');
|
||||
const [selectedPhoto, setSelectedPhoto] = useState<{ photo: AdminPhoto; index: number } | null>(null);
|
||||
const [showPasswordReset, setShowPasswordReset] = useState(false);
|
||||
const [showThemeCustomizer, setShowThemeCustomizer] = useState(false);
|
||||
const [currentTheme, setCurrentTheme] = useState<ThemeConfig | null>(null);
|
||||
const [currentPresetName, setCurrentPresetName] = useState<string>('default');
|
||||
const [showThemeEditorModal, setShowThemeEditorModal] = useState(false);
|
||||
|
||||
// Photo filters state
|
||||
const [photoFilters, setPhotoFilters] = useState({
|
||||
@@ -72,19 +80,13 @@ export const EventDetailsPage: React.FC = () => {
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
// Fetch event statistics (skip if event doesn't exist or from admin context)
|
||||
const { data: stats } = useQuery({
|
||||
queryKey: ['admin-event-stats', event?.slug],
|
||||
queryFn: () => galleryService.getGalleryStats(event!.slug),
|
||||
enabled: false, // Disable stats from admin panel as it requires gallery auth
|
||||
retry: false,
|
||||
});
|
||||
// Statistics are now fetched with the event details from the admin API
|
||||
|
||||
// Fetch photos when on photos tab
|
||||
// Fetch photos (needed for both photos tab and hero photo selector)
|
||||
const { data: photos = [], isLoading: photosLoading, refetch: refetchPhotos } = useQuery({
|
||||
queryKey: ['admin-event-photos', id, photoFilters],
|
||||
queryFn: () => photosService.getEventPhotos(parseInt(id!), photoFilters),
|
||||
enabled: !!id && activeTab === 'photos',
|
||||
enabled: !!id && (activeTab === 'photos' || isEditing),
|
||||
});
|
||||
|
||||
// Fetch categories for the event
|
||||
@@ -105,8 +107,15 @@ export const EventDetailsPage: React.FC = () => {
|
||||
toast.success(t('toast.eventUpdated'));
|
||||
setIsEditing(false);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('toast.saveError'));
|
||||
onError: (error: any) => {
|
||||
console.error('Update event error:', error.response?.data || error);
|
||||
if (error.response?.data?.errors) {
|
||||
console.error('Validation errors:', error.response.data.errors);
|
||||
const errorMessage = error.response.data.errors[0].msg + ' (field: ' + error.response.data.errors[0].path + ')';
|
||||
toast.error(errorMessage);
|
||||
} else {
|
||||
toast.error(error.response?.data?.error || t('toast.saveError'));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -155,18 +164,85 @@ export const EventDetailsPage: React.FC = () => {
|
||||
expires_at: format(parseISO(event.expires_at), 'yyyy-MM-dd'),
|
||||
allow_user_uploads: event.allow_user_uploads || false,
|
||||
upload_category_id: event.upload_category_id || null,
|
||||
hero_photo_id: event.hero_photo_id || null,
|
||||
host_name: event.host_name || '',
|
||||
});
|
||||
|
||||
// Parse theme configuration
|
||||
if (event.color_theme) {
|
||||
try {
|
||||
if (event.color_theme.startsWith('{')) {
|
||||
const parsedTheme = JSON.parse(event.color_theme);
|
||||
setCurrentTheme(parsedTheme);
|
||||
// Try to find matching preset
|
||||
const matchingPreset = Object.entries(GALLERY_THEME_PRESETS).find(
|
||||
([_, preset]) => JSON.stringify(preset.config) === JSON.stringify(parsedTheme)
|
||||
);
|
||||
setCurrentPresetName(matchingPreset ? matchingPreset[0] : 'custom');
|
||||
} else {
|
||||
// Legacy theme name
|
||||
const preset = GALLERY_THEME_PRESETS[event.color_theme];
|
||||
if (preset) {
|
||||
setCurrentTheme(preset.config);
|
||||
setCurrentPresetName(event.color_theme);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse theme:', e);
|
||||
setCurrentTheme(GALLERY_THEME_PRESETS.default.config);
|
||||
setCurrentPresetName('default');
|
||||
}
|
||||
} else {
|
||||
setCurrentTheme(GALLERY_THEME_PRESETS.default.config);
|
||||
setCurrentPresetName('default');
|
||||
}
|
||||
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
const handleSaveEdit = () => {
|
||||
updateMutation.mutate({
|
||||
welcome_message: editForm.welcome_message || undefined,
|
||||
color_theme: editForm.color_theme || undefined,
|
||||
// Prepare color_theme - if we have a custom theme, serialize it
|
||||
let themeToSave = editForm.color_theme;
|
||||
if (currentTheme && (currentPresetName === 'custom' || showThemeCustomizer)) {
|
||||
themeToSave = JSON.stringify(currentTheme);
|
||||
} else if (currentPresetName && currentPresetName !== 'custom') {
|
||||
// Use preset name for non-custom themes
|
||||
themeToSave = currentPresetName;
|
||||
}
|
||||
|
||||
// Clean up the data - remove undefined values
|
||||
const updateData: any = {
|
||||
expires_at: editForm.expires_at,
|
||||
allow_user_uploads: editForm.allow_user_uploads,
|
||||
upload_category_id: editForm.upload_category_id,
|
||||
};
|
||||
|
||||
// Only include fields that have defined values
|
||||
if (editForm.welcome_message !== undefined && editForm.welcome_message !== null) {
|
||||
updateData.welcome_message = editForm.welcome_message;
|
||||
}
|
||||
if (themeToSave) {
|
||||
updateData.color_theme = themeToSave;
|
||||
}
|
||||
if (editForm.upload_category_id !== undefined) {
|
||||
updateData.upload_category_id = editForm.upload_category_id;
|
||||
}
|
||||
if (editForm.hero_photo_id !== undefined) {
|
||||
updateData.hero_photo_id = editForm.hero_photo_id;
|
||||
}
|
||||
if (editForm.host_name !== undefined && editForm.host_name !== null) {
|
||||
updateData.host_name = editForm.host_name;
|
||||
}
|
||||
|
||||
// Remove any keys with undefined values
|
||||
Object.keys(updateData).forEach(key => {
|
||||
if (updateData[key] === undefined) {
|
||||
delete updateData[key];
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Updating event with data:', updateData);
|
||||
console.log('Theme length:', updateData.color_theme ? updateData.color_theme.length : 0);
|
||||
updateMutation.mutate(updateData);
|
||||
};
|
||||
|
||||
const handleCopyLink = async () => {
|
||||
@@ -180,6 +256,35 @@ export const EventDetailsPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleThemeModalSave = async (theme: ThemeConfig, presetName: string) => {
|
||||
// Prepare theme for saving
|
||||
let themeToSave: string;
|
||||
if (presetName !== 'custom' && GALLERY_THEME_PRESETS[presetName]) {
|
||||
// Save preset name for standard presets
|
||||
themeToSave = presetName;
|
||||
} else {
|
||||
// Save full theme config for custom themes
|
||||
themeToSave = JSON.stringify(theme);
|
||||
}
|
||||
|
||||
try {
|
||||
// Update the event with new theme
|
||||
await eventsService.updateEvent(parseInt(id!), {
|
||||
color_theme: themeToSave
|
||||
});
|
||||
|
||||
// Invalidate queries to refresh the data
|
||||
await queryClient.invalidateQueries({ queryKey: ['admin-event', id] });
|
||||
|
||||
// Close modal and show success
|
||||
setShowThemeEditorModal(false);
|
||||
toast.success(t('toast.themeUpdated'));
|
||||
} catch (error) {
|
||||
console.error('Failed to save theme:', error);
|
||||
toast.error(t('toast.saveError'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Page Header */}
|
||||
@@ -200,15 +305,15 @@ export const EventDetailsPage: React.FC = () => {
|
||||
<div className="flex items-center gap-4 mt-2 text-sm text-neutral-600">
|
||||
<span className="flex items-center">
|
||||
<Calendar className="w-4 h-4 mr-1" />
|
||||
{format(parseISO(event.event_date), 'MMMM d, yyyy')}
|
||||
{format(parseISO(event.event_date), 'PPP')}
|
||||
</span>
|
||||
<span className="capitalize">{event.event_type}</span>
|
||||
{event.is_archived && (
|
||||
{event.is_archived ? (
|
||||
<span className="text-neutral-500 flex items-center">
|
||||
<Archive className="w-4 h-4 mr-1" />
|
||||
{t('events.archived')}
|
||||
</span>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -319,8 +424,8 @@ export const EventDetailsPage: React.FC = () => {
|
||||
}`}
|
||||
>
|
||||
<Image className="w-4 h-4" />
|
||||
{t('events.photos')}
|
||||
{event.photo_count && event.photo_count > 0 && (
|
||||
<span>{t('events.photos')}</span>
|
||||
{event.photo_count !== undefined && event.photo_count > 0 && (
|
||||
<span className="ml-1 px-2 py-0.5 text-xs font-medium bg-neutral-100 text-neutral-700 rounded-full">
|
||||
{event.photo_count}
|
||||
</span>
|
||||
@@ -341,9 +446,9 @@ export const EventDetailsPage: React.FC = () => {
|
||||
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'overview' && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Left Column - Details */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||||
{/* Left Column - Main Details */}
|
||||
<div className="space-y-6">
|
||||
{/* Event Information */}
|
||||
<Card padding="md">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('events.eventInformation')}</h2>
|
||||
@@ -363,6 +468,18 @@ export const EventDetailsPage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('events.hostName')}
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={editForm.host_name}
|
||||
onChange={(e) => setEditForm(prev => ({ ...prev, host_name: e.target.value }))}
|
||||
placeholder={t('events.hostNamePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('events.expirationDate')}
|
||||
@@ -375,6 +492,81 @@ export const EventDetailsPage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('events.galleryTheme')}
|
||||
</label>
|
||||
{!showThemeCustomizer ? (
|
||||
<div className="space-y-2">
|
||||
<select
|
||||
value={currentPresetName}
|
||||
onChange={(e) => {
|
||||
const presetName = e.target.value;
|
||||
setCurrentPresetName(presetName);
|
||||
if (presetName !== 'custom') {
|
||||
const preset = GALLERY_THEME_PRESETS[presetName];
|
||||
if (preset) {
|
||||
setCurrentTheme(preset.config);
|
||||
setEditForm(prev => ({ ...prev, color_theme: presetName }));
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
>
|
||||
{Object.entries(GALLERY_THEME_PRESETS).map(([key, preset]) => (
|
||||
<option key={key} value={key}>
|
||||
{preset.name}
|
||||
</option>
|
||||
))}
|
||||
<option value="custom">{t('branding.customTheme')}</option>
|
||||
</select>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<Settings className="w-4 h-4" />}
|
||||
onClick={() => setShowThemeCustomizer(true)}
|
||||
className="w-full"
|
||||
>
|
||||
{t('branding.customizeTheme')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-neutral-600">{t('branding.customizingTheme')}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowThemeCustomizer(false)}
|
||||
>
|
||||
{t('common.hide')}
|
||||
</Button>
|
||||
</div>
|
||||
<ThemeCustomizerEnhanced
|
||||
value={currentTheme || GALLERY_THEME_PRESETS.default.config}
|
||||
onChange={setCurrentTheme}
|
||||
presetName={currentPresetName}
|
||||
onPresetChange={(presetName) => {
|
||||
setCurrentPresetName(presetName);
|
||||
if (presetName !== 'custom') {
|
||||
setEditForm(prev => ({ ...prev, color_theme: presetName }));
|
||||
}
|
||||
}}
|
||||
isPreviewMode={false}
|
||||
showGalleryLayouts={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hero Photo Selection */}
|
||||
<HeroPhotoSelector
|
||||
photos={photos || []}
|
||||
currentHeroPhotoId={editForm.hero_photo_id}
|
||||
onSelect={(photoId) => setEditForm(prev => ({ ...prev, hero_photo_id: photoId }))}
|
||||
isEditing={isEditing}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
@@ -419,12 +611,19 @@ export const EventDetailsPage: React.FC = () => {
|
||||
) : (
|
||||
<dl className="space-y-4">
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-neutral-500">{t('events.welcomeMessageLabel')}</dt>
|
||||
<dt className="text-sm font-medium text-neutral-500">{t('events.welcomeMessage')}</dt>
|
||||
<dd className="mt-1 text-sm text-neutral-900">
|
||||
{event.welcome_message || <span className="text-neutral-400">{t('events.noWelcomeMessageSet')}</span>}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-neutral-500">{t('events.hostName')}</dt>
|
||||
<dd className="mt-1 text-sm text-neutral-900">
|
||||
{event.host_name || <span className="text-neutral-400">{t('common.notSet')}</span>}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-neutral-500">{t('events.hostEmail')}</dt>
|
||||
@@ -439,25 +638,36 @@ export const EventDetailsPage: React.FC = () => {
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-neutral-500">{t('events.createdOn')}</dt>
|
||||
<dt className="text-sm font-medium text-neutral-500">{t('events.created')}</dt>
|
||||
<dd className="mt-1 text-sm text-neutral-900">
|
||||
{format(parseISO(event.created_at), 'MMM d, yyyy')}
|
||||
{format(parseISO(event.created_at), 'PP')}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-neutral-500">{t('events.expires')}</dt>
|
||||
<dd className="mt-1 text-sm text-neutral-900">
|
||||
{format(parseISO(event.expires_at), 'MMM d, yyyy')}
|
||||
{format(parseISO(event.expires_at), 'PP')}
|
||||
{!event.is_archived && daysUntilExpiration > 0 && (
|
||||
<span className="text-neutral-500 ml-1">
|
||||
{t('events.daysLeft', { days: daysUntilExpiration })}
|
||||
{t('events.daysLeft', { count: daysUntilExpiration })}
|
||||
</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-neutral-500">{t('events.heroPhoto')}</dt>
|
||||
<dd className="mt-1 text-sm text-neutral-900">
|
||||
{event.hero_photo_id ? (
|
||||
<span className="text-primary-600">{t('events.heroPhotoSelected')}</span>
|
||||
) : (
|
||||
<span className="text-neutral-400">{t('events.noHeroPhotoSelected')}</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-neutral-500">{t('events.userUploads')}</dt>
|
||||
<dd className="mt-1 text-sm text-neutral-900">
|
||||
@@ -523,41 +733,7 @@ export const EventDetailsPage: React.FC = () => {
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Photo Statistics */}
|
||||
<Card padding="md">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('events.photoStatistics')}</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between py-2 px-3 bg-neutral-50 rounded-lg">
|
||||
<span className="text-sm text-neutral-600">{t('events.totalPhotos')}</span>
|
||||
<span className="text-sm font-medium">{event.photo_count || 0}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-2 px-3 bg-neutral-50 rounded-lg">
|
||||
<span className="text-sm text-neutral-600">{t('events.totalSize')}</span>
|
||||
<span className="text-sm font-medium">
|
||||
{event.total_size ? `${(event.total_size / (1024 * 1024)).toFixed(1)} MB` : '0 MB'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-2 px-3 bg-neutral-50 rounded-lg">
|
||||
<span className="text-sm text-neutral-600">{t('events.categories')}</span>
|
||||
<span className="text-sm font-medium">{categories.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<Image className="w-4 h-4" />}
|
||||
onClick={() => setActiveTab('photos')}
|
||||
className="w-full justify-center"
|
||||
>
|
||||
{t('events.managePhotos')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
|
||||
{/* Actions */}
|
||||
{!event.is_archived && (
|
||||
@@ -587,47 +763,90 @@ export const EventDetailsPage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Column - Statistics */}
|
||||
{/* Right Column - Statistics, Theme, and Actions */}
|
||||
<div className="space-y-6">
|
||||
{/* Photo Statistics */}
|
||||
<Card padding="md">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('events.statistics')}</h2>
|
||||
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('events.photoStatistics')}</h2>
|
||||
|
||||
{stats ? (
|
||||
<div className="space-y-4">
|
||||
<div className="text-center p-4 bg-neutral-50 rounded-lg">
|
||||
<p className="text-3xl font-bold text-neutral-900">{stats.total_photos}</p>
|
||||
<p className="text-sm text-neutral-500">{t('events.totalPhotos')}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="text-center p-3 bg-blue-50 rounded-lg">
|
||||
<Eye className="w-5 h-5 text-blue-600 mx-auto mb-1" />
|
||||
<p className="text-xl font-semibold text-neutral-900">{stats.total_views}</p>
|
||||
<p className="text-xs text-neutral-500">{t('events.views')}</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-3 bg-purple-50 rounded-lg">
|
||||
<Download className="w-5 h-5 text-purple-600 mx-auto mb-1" />
|
||||
<p className="text-xl font-semibold text-neutral-900">{stats.total_downloads}</p>
|
||||
<p className="text-xs text-neutral-500">{t('events.downloads')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-3 bg-green-50 rounded-lg">
|
||||
<Users className="w-5 h-5 text-green-600 mx-auto mb-1" />
|
||||
<p className="text-xl font-semibold text-neutral-900">{stats.unique_visitors}</p>
|
||||
<p className="text-xs text-neutral-500">{t('events.uniqueVisitors')}</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between py-2 px-3 bg-neutral-50 rounded-lg">
|
||||
<span className="text-sm text-neutral-600">{t('events.totalPhotos')}</span>
|
||||
<span className="text-sm font-medium">{event.photo_count || 0}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-neutral-500">
|
||||
<p>{t('events.statisticsNotAvailable')}</p>
|
||||
|
||||
<div className="flex items-center justify-between py-2 px-3 bg-neutral-50 rounded-lg">
|
||||
<span className="text-sm text-neutral-600">{t('events.totalSize')}</span>
|
||||
<span className="text-sm font-medium">
|
||||
{event.total_size ? `${(event.total_size / (1024 * 1024)).toFixed(1)} MB` : '0 MB'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between py-2 px-3 bg-neutral-50 rounded-lg">
|
||||
<span className="text-sm text-neutral-600">{t('events.categories')}</span>
|
||||
<span className="text-sm font-medium">{categories.length}</span>
|
||||
</div>
|
||||
|
||||
{event.total_views !== undefined && (
|
||||
<div className="flex items-center justify-between py-2 px-3 bg-neutral-50 rounded-lg">
|
||||
<span className="text-sm text-neutral-600">{t('events.totalViews')}</span>
|
||||
<span className="text-sm font-medium">{event.total_views || 0}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{event.total_downloads !== undefined && (
|
||||
<div className="flex items-center justify-between py-2 px-3 bg-neutral-50 rounded-lg">
|
||||
<span className="text-sm text-neutral-600">{t('events.totalDownloads')}</span>
|
||||
<span className="text-sm font-medium">{event.total_downloads || 0}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{event.unique_visitors !== undefined && (
|
||||
<div className="flex items-center justify-between py-2 px-3 bg-neutral-50 rounded-lg">
|
||||
<span className="text-sm text-neutral-600">{t('events.uniqueVisitors')}</span>
|
||||
<span className="text-sm font-medium">{event.unique_visitors || 0}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<Image className="w-4 h-4" />}
|
||||
onClick={() => setActiveTab('photos')}
|
||||
className="w-full justify-center"
|
||||
>
|
||||
{t('events.managePhotos')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Gallery Theme */}
|
||||
<Card padding="md">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-neutral-900">{t('events.galleryTheme')}</h2>
|
||||
{!event.is_archived && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<Palette className="w-4 h-4" />}
|
||||
onClick={() => setShowThemeEditorModal(true)}
|
||||
>
|
||||
{t('events.customizeTheme')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ThemeDisplay
|
||||
theme={event.color_theme || GALLERY_THEME_PRESETS.default.config}
|
||||
presetName={event.color_theme && !event.color_theme.startsWith('{') ? event.color_theme : undefined}
|
||||
showDetails={true}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Archive Status */}
|
||||
{event.is_archived && (
|
||||
{event.is_archived ? (
|
||||
<Card padding="md">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('events.archiveStatusTitle')}</h2>
|
||||
|
||||
@@ -635,7 +854,7 @@ export const EventDetailsPage: React.FC = () => {
|
||||
<div>
|
||||
<p className="text-sm font-medium text-neutral-500">{t('events.archivedOn')}</p>
|
||||
<p className="text-sm text-neutral-900">
|
||||
{event.archived_at && format(parseISO(event.archived_at), 'MMM d, yyyy h:mm a')}
|
||||
{event.archived_at && format(parseISO(event.archived_at), 'PPp')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -660,7 +879,7 @@ export const EventDetailsPage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -762,7 +981,7 @@ export const EventDetailsPage: React.FC = () => {
|
||||
<div className="mb-6">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 mb-2">{t('events.photoCategories')}</h2>
|
||||
<p className="text-sm text-neutral-600">
|
||||
{t('events.organizingPhotosInfo')}
|
||||
{t('events.organizeCategoriesInfo')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -790,6 +1009,17 @@ export const EventDetailsPage: React.FC = () => {
|
||||
onClose={() => setShowPasswordReset(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Theme Editor Modal */}
|
||||
{showThemeEditorModal && (
|
||||
<ThemeEditorModal
|
||||
isOpen={showThemeEditorModal}
|
||||
onClose={() => setShowThemeEditorModal(false)}
|
||||
onSave={handleThemeModalSave}
|
||||
currentTheme={event.color_theme || 'default'}
|
||||
eventName={event.event_name}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -11,8 +11,9 @@ import {
|
||||
Download,
|
||||
Trash2
|
||||
} from 'lucide-react';
|
||||
import { format, parseISO, differenceInDays } from 'date-fns';
|
||||
import { parseISO, differenceInDays } from 'date-fns';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
|
||||
import { Button, Input, Card, SkeletonTable, ErrorBoundary } from '../../components/common';
|
||||
import { BulkArchiveModal } from '../../components/admin';
|
||||
@@ -23,6 +24,7 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
export const EventsListPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { format } = useLocalizedDate();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
@@ -56,7 +56,8 @@ export const SettingsPage: React.FC = () => {
|
||||
enable_analytics: true,
|
||||
enable_registration: false,
|
||||
maintenance_mode: false,
|
||||
default_language: 'en'
|
||||
default_language: 'en',
|
||||
date_format: { format: 'DD/MM/YYYY', locale: 'en-GB' }
|
||||
});
|
||||
|
||||
// Security settings state
|
||||
@@ -88,7 +89,8 @@ export const SettingsPage: React.FC = () => {
|
||||
enable_analytics: settings.general_enable_analytics || true,
|
||||
enable_registration: settings.general_enable_registration || false,
|
||||
maintenance_mode: settings.general_maintenance_mode || false,
|
||||
default_language: settings.general_default_language || 'en'
|
||||
default_language: settings.general_default_language || 'en',
|
||||
date_format: settings.general_date_format || { format: 'DD/MM/YYYY', locale: 'en-GB' }
|
||||
});
|
||||
|
||||
// Extract security settings
|
||||
@@ -337,6 +339,38 @@ export const SettingsPage: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card padding="md">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.general.dateTimeFormat')}</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
{t('settings.general.dateFormat')}
|
||||
</label>
|
||||
<select
|
||||
value={generalSettings.date_format?.format || 'DD/MM/YYYY'}
|
||||
onChange={(e) => {
|
||||
const format = e.target.value;
|
||||
const locale = format === 'MM/DD/YYYY' ? 'en-US' : 'en-GB';
|
||||
setGeneralSettings(prev => ({
|
||||
...prev,
|
||||
date_format: { format, locale }
|
||||
}));
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
>
|
||||
<option value="DD/MM/YYYY">DD/MM/YYYY (European)</option>
|
||||
<option value="MM/DD/YYYY">MM/DD/YYYY (US)</option>
|
||||
<option value="YYYY-MM-DD">YYYY-MM-DD (ISO)</option>
|
||||
<option value="DD.MM.YYYY">DD.MM.YYYY (German)</option>
|
||||
</select>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
{t('settings.general.dateFormatHelp')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<Button
|
||||
|
||||
@@ -27,6 +27,7 @@ interface UpdateEventData {
|
||||
is_active?: boolean;
|
||||
allow_user_uploads?: boolean;
|
||||
upload_category_id?: number | null;
|
||||
hero_photo_id?: number | null;
|
||||
}
|
||||
|
||||
interface EventsListResponse {
|
||||
@@ -70,13 +71,13 @@ export const eventsService = {
|
||||
|
||||
// Update event (admin)
|
||||
async updateEvent(id: number, data: UpdateEventData): Promise<Event> {
|
||||
const response = await api.put<Event>(`/api/events/${id}`, data);
|
||||
const response = await api.put<Event>(`/api/admin/events/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Delete/deactivate event (admin)
|
||||
async deleteEvent(id: number): Promise<void> {
|
||||
await api.delete(`/api/events/${id}`);
|
||||
await api.delete(`/api/admin/events/${id}`);
|
||||
},
|
||||
|
||||
// Force archive event (admin)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { api } from '../config/api';
|
||||
import i18n from '../i18n/config';
|
||||
|
||||
export interface Notification {
|
||||
id: number;
|
||||
@@ -45,29 +46,113 @@ export const notificationsService = {
|
||||
|
||||
// Format notification message
|
||||
formatNotificationMessage(notification: Notification): string {
|
||||
const t = i18n.t;
|
||||
switch (notification.type) {
|
||||
case 'event_created':
|
||||
return `New event "${notification.eventName}" was created`;
|
||||
return t('admin.notificationMessages.eventCreated', { eventName: notification.eventName });
|
||||
case 'event_archived':
|
||||
return `Event "${notification.eventName}" was archived`;
|
||||
return t('admin.notificationMessages.eventArchived', { eventName: notification.eventName });
|
||||
case 'event_updated':
|
||||
return t('admin.notificationMessages.eventUpdated', {
|
||||
eventName: notification.eventName || notification.metadata.eventName
|
||||
});
|
||||
case 'event_deleted':
|
||||
return t('admin.notificationMessages.eventDeleted', {
|
||||
eventName: notification.metadata.event_name
|
||||
});
|
||||
case 'photos_uploaded':
|
||||
return `${notification.metadata.count || 0} photos uploaded to "${notification.eventName}"`;
|
||||
return t('admin.notificationMessages.photosUploaded', {
|
||||
count: notification.metadata.count || 0,
|
||||
eventName: notification.eventName
|
||||
});
|
||||
case 'photo_deleted':
|
||||
return t('admin.notificationMessages.photoDeleted', { eventName: notification.eventName });
|
||||
case 'photos_bulk_deleted':
|
||||
return t('admin.notificationMessages.photosBulkDeleted', {
|
||||
count: notification.metadata.count || 0,
|
||||
eventName: notification.eventName
|
||||
});
|
||||
case 'event_expiring':
|
||||
return `Event "${notification.eventName}" expires in ${notification.metadata.days || 0} days`;
|
||||
return t('admin.notificationMessages.eventExpiring', {
|
||||
eventName: notification.eventName,
|
||||
days: notification.metadata.days || 0
|
||||
});
|
||||
case 'event_expired':
|
||||
return `Event "${notification.eventName}" has expired`;
|
||||
return t('admin.notificationMessages.eventExpired', { eventName: notification.eventName });
|
||||
case 'password_changed':
|
||||
return `Password changed by ${notification.actorName}`;
|
||||
return t('admin.notificationMessages.passwordChanged', { actorName: notification.actorName });
|
||||
case 'password_reset':
|
||||
return t('admin.notificationMessages.passwordReset', {
|
||||
eventName: notification.metadata.eventName
|
||||
});
|
||||
case 'settings_updated':
|
||||
return `${notification.metadata.type || 'System'} settings updated`;
|
||||
return t('admin.notificationMessages.settingsUpdated', {
|
||||
type: notification.metadata.type || 'System'
|
||||
});
|
||||
case 'email_template_updated':
|
||||
return `Email template "${notification.metadata.template}" updated`;
|
||||
return t('admin.notificationMessages.emailTemplateUpdated', {
|
||||
template: notification.metadata.template
|
||||
});
|
||||
case 'bulk_download':
|
||||
return `${notification.metadata.count || 0} photos downloaded from "${notification.eventName}"`;
|
||||
return t('admin.notificationMessages.bulkDownload', {
|
||||
count: notification.metadata.count || 0,
|
||||
eventName: notification.eventName
|
||||
});
|
||||
case 'storage_warning':
|
||||
return `Storage usage at ${notification.metadata.percentage || 0}%`;
|
||||
return t('admin.notificationMessages.storageWarning', {
|
||||
percentage: notification.metadata.percentage || 0
|
||||
});
|
||||
case 'admin_logout':
|
||||
return t('admin.notificationMessages.adminLogout', { actorName: notification.actorName });
|
||||
case 'category_created':
|
||||
return t('admin.notificationMessages.categoryCreated', {
|
||||
name: notification.metadata.name,
|
||||
eventName: notification.eventName
|
||||
});
|
||||
case 'category_updated':
|
||||
return t('admin.notificationMessages.categoryUpdated', {
|
||||
name: notification.metadata.name,
|
||||
eventName: notification.eventName
|
||||
});
|
||||
case 'category_deleted':
|
||||
return t('admin.notificationMessages.categoryDeleted', {
|
||||
name: notification.metadata.name,
|
||||
eventName: notification.eventName
|
||||
});
|
||||
case 'cms_page_updated':
|
||||
return t('admin.notificationMessages.cmsPageUpdated', {
|
||||
slug: notification.metadata.slug
|
||||
});
|
||||
case 'email_config_updated':
|
||||
return t('admin.notificationMessages.emailConfigUpdated');
|
||||
case 'favicon_uploaded':
|
||||
return t('admin.notificationMessages.faviconUploaded');
|
||||
case 'branding_updated':
|
||||
return t('admin.notificationMessages.brandingUpdated');
|
||||
case 'general_settings_updated':
|
||||
return t('admin.notificationMessages.generalSettingsUpdated');
|
||||
case 'security_settings_updated':
|
||||
return t('admin.notificationMessages.securitySettingsUpdated');
|
||||
case 'theme_updated':
|
||||
return t('admin.notificationMessages.themeUpdated');
|
||||
case 'archive_downloaded':
|
||||
return t('admin.notificationMessages.archiveDownloaded', {
|
||||
eventName: notification.metadata.event_name
|
||||
});
|
||||
case 'archive_deleted':
|
||||
return t('admin.notificationMessages.archiveDeleted', {
|
||||
eventName: notification.metadata.event_name
|
||||
});
|
||||
case 'archive_restored':
|
||||
return t('admin.notificationMessages.archiveRestored', {
|
||||
eventName: notification.metadata.event_name
|
||||
});
|
||||
default:
|
||||
return notification.metadata.message || 'System notification';
|
||||
// Log unknown notification types for debugging
|
||||
console.warn('Unknown notification type:', notification.type, notification);
|
||||
return notification.metadata.message || t('admin.notificationMessages.systemActivity', {
|
||||
type: notification.type.replace(/_/g, ' ')
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -78,22 +163,48 @@ export const notificationsService = {
|
||||
return { icon: 'Calendar', color: 'text-blue-600' };
|
||||
case 'event_archived':
|
||||
return { icon: 'Archive', color: 'text-green-600' };
|
||||
case 'event_updated':
|
||||
case 'event_deleted':
|
||||
return { icon: 'Calendar', color: 'text-gray-600' };
|
||||
case 'photos_uploaded':
|
||||
return { icon: 'Image', color: 'text-purple-600' };
|
||||
case 'photo_deleted':
|
||||
case 'photos_bulk_deleted':
|
||||
return { icon: 'Image', color: 'text-red-600' };
|
||||
case 'event_expiring':
|
||||
return { icon: 'AlertCircle', color: 'text-amber-600' };
|
||||
case 'event_expired':
|
||||
return { icon: 'Clock', color: 'text-red-600' };
|
||||
case 'password_changed':
|
||||
case 'password_reset':
|
||||
return { icon: 'Lock', color: 'text-indigo-600' };
|
||||
case 'settings_updated':
|
||||
case 'branding_updated':
|
||||
case 'general_settings_updated':
|
||||
case 'security_settings_updated':
|
||||
case 'theme_updated':
|
||||
return { icon: 'Settings', color: 'text-gray-600' };
|
||||
case 'email_template_updated':
|
||||
case 'email_config_updated':
|
||||
return { icon: 'Mail', color: 'text-teal-600' };
|
||||
case 'bulk_download':
|
||||
return { icon: 'Download', color: 'text-cyan-600' };
|
||||
case 'storage_warning':
|
||||
return { icon: 'Database', color: 'text-orange-600' };
|
||||
case 'admin_logout':
|
||||
return { icon: 'LogOut', color: 'text-gray-600' };
|
||||
case 'category_created':
|
||||
case 'category_updated':
|
||||
case 'category_deleted':
|
||||
return { icon: 'Folder', color: 'text-indigo-600' };
|
||||
case 'cms_page_updated':
|
||||
return { icon: 'FileText', color: 'text-green-600' };
|
||||
case 'favicon_uploaded':
|
||||
return { icon: 'Globe', color: 'text-purple-600' };
|
||||
case 'archive_downloaded':
|
||||
case 'archive_deleted':
|
||||
case 'archive_restored':
|
||||
return { icon: 'Archive', color: 'text-blue-600' };
|
||||
default:
|
||||
return { icon: 'Bell', color: 'text-gray-600' };
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface Event {
|
||||
event_type: string;
|
||||
event_name: string;
|
||||
event_date: string;
|
||||
host_name?: string;
|
||||
host_email: string;
|
||||
admin_email: string;
|
||||
welcome_message?: string;
|
||||
@@ -26,6 +27,10 @@ export interface Event {
|
||||
}>;
|
||||
allow_user_uploads?: boolean;
|
||||
upload_category_id?: number | null;
|
||||
hero_photo_id?: number | null;
|
||||
total_views?: number;
|
||||
total_downloads?: number;
|
||||
unique_visitors?: number;
|
||||
}
|
||||
|
||||
export interface GalleryInfo {
|
||||
@@ -36,6 +41,7 @@ export interface GalleryInfo {
|
||||
is_active: boolean;
|
||||
is_expired: boolean;
|
||||
requires_password?: boolean;
|
||||
color_theme?: string;
|
||||
}
|
||||
|
||||
export interface Photo {
|
||||
@@ -69,6 +75,7 @@ export interface GalleryData {
|
||||
expires_at: string;
|
||||
allow_user_uploads?: boolean;
|
||||
upload_category_id?: number | null;
|
||||
hero_photo_id?: number | null;
|
||||
};
|
||||
categories?: PhotoCategory[];
|
||||
photos: Photo[];
|
||||
|
||||
Reference in New Issue
Block a user