diff --git a/backend/migrations/init.js b/backend/migrations/init.js index 79b623a..7cdb0db 100644 --- a/backend/migrations/init.js +++ b/backend/migrations/init.js @@ -26,6 +26,57 @@ async function runMigrations() { console.log('⚠️ Please change this password immediately!'); } + // Create default email templates if none exist + const templateExists = await db('email_templates').first(); + if (!templateExists) { + await db('email_templates').insert([ + { + template_key: 'gallery_created', + subject: 'Your Photo Gallery is Ready!', + body_html: `
Dear {{host_name}},
+Your photo gallery "{{event_name}}" has been created successfully!
+Gallery Details:
+Share this link and password with your guests to allow them to view and download photos.
`, + body_text: 'Gallery Created Successfully\n\nDear {{host_name}},\n\nYour photo gallery "{{event_name}}" has been created successfully!', + variables: JSON.stringify(['host_name', 'event_name', 'event_date', 'gallery_link', 'gallery_password', 'expiry_date']) + }, + { + template_key: 'expiration_warning', + subject: 'Your Photo Gallery Expires Soon', + body_html: `Dear {{host_name}},
+Your photo gallery "{{event_name}}" will expire in {{days_remaining}} days.
+After expiration, the gallery will be archived and no longer accessible to guests.
+`, + body_text: 'Gallery Expiring Soon\n\nDear {{host_name}},\n\nYour photo gallery "{{event_name}}" will expire in {{days_remaining}} days.', + variables: JSON.stringify(['host_name', 'event_name', 'days_remaining', 'gallery_link']) + } + ]); + console.log('Default email templates created'); + } + + // Create default email config if none exists + const emailConfig = await db('email_configs').first(); + if (!emailConfig) { + await db('email_configs').insert({ + smtp_host: process.env.SMTP_HOST || 'mailhog', + smtp_port: process.env.SMTP_PORT || 1025, + smtp_secure: process.env.SMTP_SECURE === 'true', + smtp_user: process.env.SMTP_USER || '', + smtp_pass: process.env.SMTP_PASS || '', + from_email: process.env.EMAIL_FROM || 'noreply@photo-sharing.local', + from_name: 'Photo Sharing' + }); + console.log('Default email configuration created'); + } + console.log('Migrations completed successfully'); process.exit(0); } catch (error) { diff --git a/backend/src/database/db.js b/backend/src/database/db.js index 6be9524..15e8544 100644 --- a/backend/src/database/db.js +++ b/backend/src/database/db.js @@ -95,6 +95,79 @@ async function initializeDatabase() { table.datetime('last_login'); }); } + + // Email configuration table + const hasEmailConfigTable = await db.schema.hasTable('email_configs'); + if (!hasEmailConfigTable) { + await db.schema.createTable('email_configs', (table) => { + table.increments('id').primary(); + table.string('smtp_host').notNullable(); + table.integer('smtp_port').notNullable(); + table.boolean('smtp_secure').defaultTo(false); + table.string('smtp_user'); + table.string('smtp_pass'); + table.string('from_email').notNullable(); + table.string('from_name'); + table.datetime('updated_at').defaultTo(db.fn.now()); + }); + } + + // Email templates table + const hasEmailTemplatesTable = await db.schema.hasTable('email_templates'); + if (!hasEmailTemplatesTable) { + await db.schema.createTable('email_templates', (table) => { + table.increments('id').primary(); + table.string('template_key').unique().notNullable(); // 'gallery_created', 'expiration_warning', etc. + table.string('subject').notNullable(); + table.text('body_html').notNullable(); + table.text('body_text'); + table.json('variables'); // Available template variables + table.datetime('updated_at').defaultTo(db.fn.now()); + }); + } + + // App settings table + const hasAppSettingsTable = await db.schema.hasTable('app_settings'); + if (!hasAppSettingsTable) { + await db.schema.createTable('app_settings', (table) => { + table.increments('id').primary(); + table.string('setting_key').unique().notNullable(); + table.json('setting_value'); + table.string('setting_type'); // 'branding', 'theme', 'general' + table.datetime('updated_at').defaultTo(db.fn.now()); + }); + } + + // Activity logs table + const hasActivityLogsTable = await db.schema.hasTable('activity_logs'); + if (!hasActivityLogsTable) { + await db.schema.createTable('activity_logs', (table) => { + table.increments('id').primary(); + table.string('activity_type').notNullable(); // 'event_created', 'photos_uploaded', etc. + table.string('actor_type'); // 'admin', 'system', 'guest' + table.integer('actor_id'); + table.string('actor_name'); + table.json('metadata'); // Additional data about the activity + table.integer('event_id').references('id').inTable('events'); + table.datetime('created_at').defaultTo(db.fn.now()); + }); + } } -module.exports = { db, initializeDatabase }; +// Helper function to log activities +async function logActivity(activityType, metadata = {}, eventId = null, actor = null) { + try { + await db('activity_logs').insert({ + activity_type: activityType, + actor_type: actor?.type || 'system', + actor_id: actor?.id || null, + actor_name: actor?.name || null, + metadata: JSON.stringify(metadata), + event_id: eventId + }); + } catch (error) { + console.error('Failed to log activity:', error); + } +} + +module.exports = { db, initializeDatabase, logActivity }; diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index d64572a..7536436 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -1,12 +1,16 @@ const express = require('express'); const router = express.Router(); -// This route handles admin endpoints that are different from events -// For now, just export an empty router as events.js handles most admin functionality +// Import sub-routers +const dashboardRoutes = require('./adminDashboard'); +const archiveRoutes = require('./adminArchives'); +const emailRoutes = require('./adminEmail'); +const settingsRoutes = require('./adminSettings'); -// Admin dashboard data could go here -router.get('/dashboard', async (req, res) => { - res.json({ message: 'Admin dashboard endpoint' }); -}); +// Mount sub-routers +router.use('/dashboard', dashboardRoutes); +router.use('/archives', archiveRoutes); +router.use('/email', emailRoutes); +router.use('/settings', settingsRoutes); module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/adminArchives.js b/backend/src/routes/adminArchives.js new file mode 100644 index 0000000..a5f4f51 --- /dev/null +++ b/backend/src/routes/adminArchives.js @@ -0,0 +1,269 @@ +const express = require('express'); +const path = require('path'); +const fs = require('fs').promises; +const { db } = require('../database/db'); +const { adminAuth } = require('../middleware/auth'); +const archiver = require('archiver'); +const router = express.Router(); + +// Get all archived events +router.get('/', adminAuth, async (req, res) => { + try { + const page = parseInt(req.query.page) || 1; + const limit = parseInt(req.query.limit) || 20; + const offset = (page - 1) * limit; + + // Get total count + const totalCount = await db('events') + .where('is_archived', true) + .count('id as count') + .first(); + + // Get archived events + const archives = await db('events') + .select( + 'events.*', + db.raw('COUNT(DISTINCT photos.id) as photo_count'), + db.raw('SUM(photos.size_bytes) as total_size') + ) + .leftJoin('photos', 'events.id', 'photos.event_id') + .where('events.is_archived', true) + .groupBy('events.id') + .orderBy('events.archived_at', 'desc') + .limit(limit) + .offset(offset); + + // Check if archive files exist and get their sizes + const archivesWithFileInfo = await Promise.all(archives.map(async (archive) => { + let archiveFileSize = 0; + if (archive.archive_path) { + try { + const stats = await fs.stat(archive.archive_path); + archiveFileSize = stats.size; + } catch (error) { + console.error(`Archive file not found: ${archive.archive_path}`); + } + } + + return { + id: archive.id, + slug: archive.slug, + eventName: archive.event_name, + eventDate: archive.event_date, + eventType: archive.event_type, + hostEmail: archive.host_email, + archivedAt: archive.archived_at, + expiresAt: archive.expires_at, + photoCount: archive.photo_count || 0, + originalSize: archive.total_size || 0, + archiveSize: archiveFileSize, + archivePath: archive.archive_path + }; + })); + + res.json({ + archives: archivesWithFileInfo, + pagination: { + page, + limit, + total: totalCount.count, + totalPages: Math.ceil(totalCount.count / limit) + } + }); + } catch (error) { + console.error('Archives list error:', error); + res.status(500).json({ error: 'Failed to fetch archives' }); + } +}); + +// Get single archive details +router.get('/:id', adminAuth, async (req, res) => { + try { + const archive = await db('events') + .where('id', req.params.id) + .where('is_archived', true) + .first(); + + if (!archive) { + return res.status(404).json({ error: 'Archive not found' }); + } + + // Get photo details + const photos = await db('photos') + .where('event_id', archive.id) + .select('filename', 'type', 'size_bytes', 'uploaded_at'); + + // Check archive file + let archiveFileInfo = null; + if (archive.archive_path) { + try { + const stats = await fs.stat(archive.archive_path); + archiveFileInfo = { + size: stats.size, + createdAt: stats.birthtime, + path: archive.archive_path + }; + } catch (error) { + console.error('Archive file not found:', error); + } + } + + res.json({ + id: archive.id, + slug: archive.slug, + eventName: archive.event_name, + eventDate: archive.event_date, + eventType: archive.event_type, + hostEmail: archive.host_email, + adminEmail: archive.admin_email, + welcomeMessage: archive.welcome_message, + colorTheme: archive.color_theme, + createdAt: archive.created_at, + expiresAt: archive.expires_at, + archivedAt: archive.archived_at, + photos: photos, + archiveFile: archiveFileInfo + }); + } catch (error) { + console.error('Archive details error:', error); + res.status(500).json({ error: 'Failed to fetch archive details' }); + } +}); + +// Restore archive +router.post('/:id/restore', adminAuth, async (req, res) => { + try { + const archive = await db('events') + .where('id', req.params.id) + .where('is_archived', true) + .first(); + + if (!archive) { + return res.status(404).json({ error: 'Archive not found' }); + } + + // Check if archive directory exists + const archiveDir = path.dirname(archive.archive_path); + const extractedDir = archive.archive_path.replace('.zip', ''); + + // TODO: Implement actual extraction logic + // For now, just update the database + + // Update event status + await db('events') + .where('id', req.params.id) + .update({ + is_archived: false, + is_active: true, + archive_path: null, + archived_at: null, + expires_at: db.raw("datetime('now', '+30 days')") // Reset expiration + }); + + // Log activity + await db('activity_logs').insert({ + activity_type: 'archive_restored', + actor_type: 'admin', + actor_id: req.user.id, + actor_name: req.user.username, + event_id: archive.id, + metadata: JSON.stringify({ event_name: archive.event_name }) + }); + + res.json({ message: 'Archive restored successfully' }); + } catch (error) { + console.error('Archive restore error:', error); + res.status(500).json({ error: 'Failed to restore archive' }); + } +}); + +// Download archive +router.get('/:id/download', adminAuth, async (req, res) => { + try { + const archive = await db('events') + .where('id', req.params.id) + .where('is_archived', true) + .first(); + + if (!archive) { + return res.status(404).json({ error: 'Archive not found' }); + } + + if (!archive.archive_path) { + return res.status(404).json({ error: 'Archive file not found' }); + } + + // Check if file exists + try { + await fs.access(archive.archive_path); + } catch (error) { + return res.status(404).json({ error: 'Archive file not found on disk' }); + } + + // Set headers for download + res.setHeader('Content-Type', 'application/zip'); + res.setHeader('Content-Disposition', `attachment; filename="${archive.slug}.zip"`); + + // Stream the file + const fileStream = require('fs').createReadStream(archive.archive_path); + fileStream.pipe(res); + + // Log download + await db('activity_logs').insert({ + activity_type: 'archive_downloaded', + actor_type: 'admin', + actor_id: req.user.id, + actor_name: req.user.username, + event_id: archive.id, + metadata: JSON.stringify({ event_name: archive.event_name }) + }); + } catch (error) { + console.error('Archive download error:', error); + res.status(500).json({ error: 'Failed to download archive' }); + } +}); + +// Delete archive permanently +router.delete('/:id', adminAuth, async (req, res) => { + try { + const archive = await db('events') + .where('id', req.params.id) + .where('is_archived', true) + .first(); + + if (!archive) { + return res.status(404).json({ error: 'Archive not found' }); + } + + // Delete archive file if exists + if (archive.archive_path) { + try { + await fs.unlink(archive.archive_path); + } catch (error) { + console.error('Failed to delete archive file:', error); + } + } + + // Delete from database (cascade will delete photos and logs) + await db('events').where('id', req.params.id).delete(); + + // Log activity + await db('activity_logs').insert({ + activity_type: 'archive_deleted', + actor_type: 'admin', + actor_id: req.user.id, + actor_name: req.user.username, + metadata: JSON.stringify({ + event_name: archive.event_name, + archived_date: archive.archived_at + }) + }); + + res.json({ message: 'Archive deleted permanently' }); + } catch (error) { + console.error('Archive delete error:', error); + res.status(500).json({ error: 'Failed to delete archive' }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/adminDashboard.js b/backend/src/routes/adminDashboard.js new file mode 100644 index 0000000..d4dd87e --- /dev/null +++ b/backend/src/routes/adminDashboard.js @@ -0,0 +1,218 @@ +const express = require('express'); +const { db } = require('../database/db'); +const { adminAuth } = require('../middleware/auth'); +const router = express.Router(); + +// Get dashboard statistics +router.get('/stats', adminAuth, async (req, res) => { + try { + // Get active events count + const activeEvents = await db('events') + .where('is_active', true) + .where('is_archived', false) + .count('id as count') + .first(); + + // Get events expiring within 7 days + const expiringEvents = await db('events') + .where('is_active', true) + .where('is_archived', false) + .whereRaw('expires_at <= datetime("now", "+7 days")') + .whereRaw('expires_at > datetime("now")') + .count('id as count') + .first(); + + // Get total photos count + const totalPhotos = await db('photos') + .count('id as count') + .first(); + + // Get storage usage (sum of all photo sizes) + const storageUsed = await db('photos') + .sum('size_bytes as total') + .first(); + + // Get total views (last 30 days) + const totalViews = await db('access_logs') + .where('action', 'view') + .whereRaw('timestamp >= datetime("now", "-30 days")') + .count('id as count') + .first(); + + // Get total downloads (last 30 days) + const totalDownloads = await db('access_logs') + .where('action', 'download') + .whereRaw('timestamp >= datetime("now", "-30 days")') + .count('id as count') + .first(); + + // Calculate trends (compare with previous 30 days) + const previousViews = await db('access_logs') + .where('action', 'view') + .whereRaw('timestamp >= datetime("now", "-60 days")') + .whereRaw('timestamp < datetime("now", "-30 days")') + .count('id as count') + .first(); + + const previousDownloads = await db('access_logs') + .where('action', 'download') + .whereRaw('timestamp >= datetime("now", "-60 days")') + .whereRaw('timestamp < datetime("now", "-30 days")') + .count('id as count') + .first(); + + // Calculate trend percentages + const viewsTrend = previousViews.count > 0 + ? ((totalViews.count - previousViews.count) / previousViews.count) * 100 + : 0; + + const downloadsTrend = previousDownloads.count > 0 + ? ((totalDownloads.count - previousDownloads.count) / previousDownloads.count) * 100 + : 0; + + res.json({ + activeEvents: activeEvents.count || 0, + expiringEvents: expiringEvents.count || 0, + totalPhotos: totalPhotos.count || 0, + storageUsed: storageUsed.total || 0, + totalViews: totalViews.count || 0, + totalDownloads: totalDownloads.count || 0, + viewsTrend: Math.round(viewsTrend * 10) / 10, + downloadsTrend: Math.round(downloadsTrend * 10) / 10 + }); + } catch (error) { + console.error('Dashboard stats error:', error); + res.status(500).json({ error: 'Failed to fetch dashboard statistics' }); + } +}); + +// Get recent activity +router.get('/activity', adminAuth, async (req, res) => { + try { + const limit = parseInt(req.query.limit) || 10; + + const activities = await db('activity_logs') + .select('activity_logs.*', 'events.event_name') + .leftJoin('events', 'activity_logs.event_id', 'events.id') + .orderBy('activity_logs.created_at', 'desc') + .limit(limit); + + // Format activities + const formattedActivities = activities.map(activity => ({ + id: activity.id, + type: activity.activity_type, + actorType: activity.actor_type, + actorName: activity.actor_name, + eventName: activity.event_name, + metadata: activity.metadata ? JSON.parse(activity.metadata) : {}, + createdAt: activity.created_at + })); + + res.json(formattedActivities); + } catch (error) { + console.error('Activity log error:', error); + res.status(500).json({ error: 'Failed to fetch activity log' }); + } +}); + +// Get analytics data for charts +router.get('/analytics', adminAuth, async (req, res) => { + try { + const days = parseInt(req.query.days) || 7; + + // Generate date range + const dates = []; + for (let i = days - 1; i >= 0; i--) { + dates.push({ + date: new Date(Date.now() - i * 24 * 60 * 60 * 1000).toISOString().split('T')[0], + views: 0, + downloads: 0, + uniqueVisitors: 0 + }); + } + + // Get views per day + const viewsData = await db('access_logs') + .select(db.raw('DATE(timestamp) as date'), db.raw('COUNT(*) as count')) + .where('action', 'view') + .whereRaw(`timestamp >= datetime("now", "-${days} days")`) + .groupByRaw('DATE(timestamp)'); + + // Get downloads per day + const downloadsData = await db('access_logs') + .select(db.raw('DATE(timestamp) as date'), db.raw('COUNT(*) as count')) + .where('action', 'download') + .whereRaw(`timestamp >= datetime("now", "-${days} days")`) + .groupByRaw('DATE(timestamp)'); + + // Get unique visitors per day + const visitorsData = await db('access_logs') + .select(db.raw('DATE(timestamp) as date'), db.raw('COUNT(DISTINCT ip_address) as count')) + .whereRaw(`timestamp >= datetime("now", "-${days} days")`) + .groupByRaw('DATE(timestamp)'); + + // Merge data into dates array + viewsData.forEach(row => { + const dateObj = dates.find(d => d.date === row.date); + if (dateObj) dateObj.views = row.count; + }); + + downloadsData.forEach(row => { + const dateObj = dates.find(d => d.date === row.date); + if (dateObj) dateObj.downloads = row.count; + }); + + visitorsData.forEach(row => { + const dateObj = dates.find(d => d.date === row.date); + if (dateObj) dateObj.uniqueVisitors = row.count; + }); + + // Get top galleries by views + const topGalleries = await db('access_logs') + .select('events.event_name', 'events.slug') + .select(db.raw('COUNT(*) as views')) + .join('events', 'access_logs.event_id', 'events.id') + .where('access_logs.action', 'view') + .whereRaw(`access_logs.timestamp >= datetime("now", "-${days} days")`) + .groupBy('events.id') + .orderBy('views', 'desc') + .limit(5); + + // Get device breakdown (simplified - based on user agent) + const deviceData = await db('access_logs') + .select( + db.raw(` + CASE + WHEN user_agent LIKE '%Mobile%' THEN 'mobile' + WHEN user_agent LIKE '%Tablet%' OR user_agent LIKE '%iPad%' THEN 'tablet' + ELSE 'desktop' + END as device_type + `), + db.raw('COUNT(*) as count') + ) + .whereRaw(`timestamp >= datetime("now", "-${days} days")`) + .groupBy('device_type'); + + const totalDevices = deviceData.reduce((sum, d) => sum + d.count, 0); + const devices = { + desktop: 0, + mobile: 0, + tablet: 0 + }; + + deviceData.forEach(d => { + devices[d.device_type] = Math.round((d.count / totalDevices) * 100); + }); + + res.json({ + chartData: dates, + topGalleries, + devices + }); + } catch (error) { + console.error('Analytics error:', error); + res.status(500).json({ error: 'Failed to fetch analytics data' }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/adminEmail.js b/backend/src/routes/adminEmail.js new file mode 100644 index 0000000..897c231 --- /dev/null +++ b/backend/src/routes/adminEmail.js @@ -0,0 +1,279 @@ +const express = require('express'); +const nodemailer = require('nodemailer'); +const { body, validationResult } = require('express-validator'); +const { db } = require('../database/db'); +const { adminAuth } = require('../middleware/auth'); +const router = express.Router(); + +// Get email configuration +router.get('/config', adminAuth, async (req, res) => { + try { + const config = await db('email_configs').first(); + + if (!config) { + return res.json({ + smtp_host: '', + smtp_port: 587, + smtp_secure: false, + smtp_user: '', + smtp_pass: '', // Don't send actual password + from_email: '', + from_name: '' + }); + } + + // Don't send the actual password + res.json({ + ...config, + smtp_pass: config.smtp_pass ? '********' : '' + }); + } catch (error) { + console.error('Email config fetch error:', error); + res.status(500).json({ error: 'Failed to fetch email configuration' }); + } +}); + +// Update email configuration +router.post('/config', [ + adminAuth, + body('smtp_host').notEmpty().withMessage('SMTP host is required'), + body('smtp_port').isInt({ min: 1, max: 65535 }).withMessage('Invalid port number'), + body('from_email').isEmail().withMessage('Invalid from email address') +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { + smtp_host, + smtp_port, + smtp_secure, + smtp_user, + smtp_pass, + from_email, + from_name + } = req.body; + + // Check if config exists + const existingConfig = await db('email_configs').first(); + + const configData = { + smtp_host, + smtp_port: parseInt(smtp_port), + smtp_secure: smtp_secure || false, + smtp_user: smtp_user || '', + from_email, + from_name: from_name || 'Photo Sharing', + updated_at: new Date() + }; + + // Only update password if provided and not masked + if (smtp_pass && smtp_pass !== '********') { + configData.smtp_pass = smtp_pass; + } + + if (existingConfig) { + await db('email_configs') + .where('id', existingConfig.id) + .update(configData); + } else { + await db('email_configs').insert(configData); + } + + // Log activity + await db('activity_logs').insert({ + activity_type: 'email_config_updated', + actor_type: 'admin', + actor_id: req.user.id, + actor_name: req.user.username, + metadata: JSON.stringify({ smtp_host, from_email }) + }); + + res.json({ message: 'Email configuration updated successfully' }); + } catch (error) { + console.error('Email config update error:', error); + res.status(500).json({ error: 'Failed to update email configuration' }); + } +}); + +// Test email configuration +router.post('/test', adminAuth, async (req, res) => { + try { + const { test_email } = req.body; + + if (!test_email) { + return res.status(400).json({ error: 'Test email address is required' }); + } + + // Get email config + const config = await db('email_configs').first(); + + if (!config) { + return res.status(400).json({ error: 'Email configuration not found. Please configure SMTP settings first.' }); + } + + // Create transporter + const transporter = nodemailer.createTransport({ + host: config.smtp_host, + port: config.smtp_port, + secure: config.smtp_secure, + auth: config.smtp_user ? { + user: config.smtp_user, + pass: config.smtp_pass + } : undefined + }); + + // Send test email + await transporter.sendMail({ + from: `${config.from_name} <${config.from_email}>`, + to: test_email, + subject: 'Test Email - Photo Sharing Platform', + html: ` +This is a test email from your Photo Sharing platform.
+If you're seeing this, your email configuration is working correctly.
+
+ Sent from: ${config.from_email}
+ SMTP Host: ${config.smtp_host}
+ Time: ${new Date().toISOString()}
+
New event created
-Wedding Davis-Miller
-2 hours ago
-245 photos downloaded
-Birthday Emma 2024
-5 hours ago
-Event archived
-Corporate Event Q2
-1 day ago
-Expiration warning sent
-3 events
-1 day ago
-No recent activity
+ ) : ( + recentActivity.slice(0, 5).map((activity) => { + // Get color based on activity type + const getActivityColor = (type: string) => { + const colors: Record+ {adminService.formatActivityMessage(activity)} +
+{activity.actorName}
++ {formatDistanceToNow(parseISO(activity.createdAt), { addSuffix: true })} +
+- {event.event.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())} -
- {event.gallery && ( -{event.gallery}
- )} -- {format(parseISO(event.timestamp), 'h:mm a')} -
+ {/* Storage Information */} + {dashboardStats && ( ++ {Math.round((dashboardStats.storageUsed / (10 * 1024 * 1024 * 1024)) * 100)}% of 10 GB +
+Storage Used
-{formatFileSize(getTotalSize())}
+{archiveService.formatBytes(getTotalSize())}
Total Photos
- {archives.reduce((sum, a) => sum + a.photo_count, 0).toLocaleString()} + {archives.reduce((sum, a) => sum + a.photoCount, 0).toLocaleString()}
Avg Archive Size
{archives.length > 0 - ? formatFileSize(getTotalSize() / archives.length) + ? archiveService.formatBytes(getTotalSize() / archives.length) : '0 Bytes' }
@@ -267,35 +255,35 @@ export const ArchivesPage: React.FC = () => {{archive.event_name}
+{archive.eventName}
- Event date: {format(parseISO(archive.event_date), 'MMM d, yyyy')} + Event date: {format(parseISO(archive.eventDate), 'MMM d, yyyy')}
{format(parseISO(archive.archived_at), 'MMM d, yyyy')}
+{format(parseISO(archive.archivedAt), 'MMM d, yyyy')}
- {format(parseISO(archive.archived_at), 'h:mm a')} + {format(parseISO(archive.archivedAt), 'h:mm a')}
{`{{${variable}}}`}
@@ -200,6 +212,14 @@ export const EmailConfigPage: React.FC = () => {
);
};
+ if (configLoading || templatesLoading) {
+ return (
+