diff --git a/.gitignore b/.gitignore index aead7c1f..931739a7 100644 --- a/.gitignore +++ b/.gitignore @@ -102,8 +102,10 @@ backup/ # Local SQLite files in backend backend/*.sqlite* +backend/*.db # Test files and artifacts +test-images/ test-logo*.jpg test-logo*.png test-results/ diff --git a/backend/migrations/core/070_add_update_notification_settings.js b/backend/migrations/core/070_add_update_notification_settings.js new file mode 100644 index 00000000..db9d3a5a --- /dev/null +++ b/backend/migrations/core/070_add_update_notification_settings.js @@ -0,0 +1,161 @@ +/** + * Migration 070: Add update notification settings and email template + * - Settings for email notifications when new versions are available + * - Email template for version update notifications + */ + +exports.up = async function(knex) { + console.log('Running migration: 070_add_update_notification_settings'); + + // Add app_settings for update notifications + const settings = [ + { + setting_key: 'update_email_notifications_enabled', + setting_value: JSON.stringify(false), + setting_type: 'notifications' + }, + { + setting_key: 'update_email_recipients', + setting_value: JSON.stringify(''), // Comma-separated emails, or empty for all admin emails + setting_type: 'notifications' + }, + { + setting_key: 'last_notified_version', + setting_value: JSON.stringify(''), + setting_type: 'notifications' + } + ]; + + for (const setting of settings) { + const exists = await knex('app_settings').where('setting_key', setting.setting_key).first(); + if (!exists) { + await knex('app_settings').insert({ ...setting, updated_at: knex.fn.now() }); + } + } + + // Check if email template already exists + const existingTemplate = await knex('email_templates') + .where('template_key', 'version_update_available') + .first(); + + if (!existingTemplate) { + await knex('email_templates').insert({ + template_key: 'version_update_available', + subject_en: 'PicPeak Update Available: Version {{new_version}}', + subject_de: 'PicPeak Update verfugbar: Version {{new_version}}', + body_html_en: ` +

A New Version of PicPeak is Available

+ +

Great news! A new version of PicPeak is available for your installation.

+ +
+

Current Version: {{current_version}}

+

New Version: {{new_version}}

+

Channel: {{channel}}

+
+ +

What's New?

+

Check the release notes to see what's included in this update:

+ +
+ View Release Notes +
+ +

How to Update

+

To update your installation, log in to the admin panel and click on the "Update Available" notification. You'll find environment-specific instructions there.

+ +
+

Reminder: Always backup your database before updating to ensure you can recover if anything goes wrong.

+
+ +

Best regards,
+Your PicPeak Installation

`, + body_text_en: `A New Version of PicPeak is Available + +Great news! A new version of PicPeak is available for your installation. + +Current Version: {{current_version}} +New Version: {{new_version}} +Channel: {{channel}} + +What's New? +Check the release notes to see what's included in this update: +{{release_notes_url}} + +How to Update +To update your installation, log in to the admin panel and click on the "Update Available" notification. You'll find environment-specific instructions there. + +REMINDER: Always backup your database before updating to ensure you can recover if anything goes wrong. + +Best regards, +Your PicPeak Installation`, + body_html_de: ` +

Eine neue Version von PicPeak ist verfugbar

+ +

Gute Neuigkeiten! Eine neue Version von PicPeak ist fur Ihre Installation verfugbar.

+ +
+

Aktuelle Version: {{current_version}}

+

Neue Version: {{new_version}}

+

Kanal: {{channel}}

+
+ +

Was ist neu?

+

Schauen Sie sich die Versionshinweise an, um zu sehen, was in diesem Update enthalten ist:

+ +
+ Versionshinweise anzeigen +
+ +

So aktualisieren Sie

+

Um Ihre Installation zu aktualisieren, melden Sie sich im Admin-Panel an und klicken Sie auf die Benachrichtigung "Update verfugbar". Dort finden Sie umgebungsspezifische Anweisungen.

+ +
+

Erinnerung: Erstellen Sie immer ein Backup Ihrer Datenbank, bevor Sie aktualisieren, um sicherzustellen, dass Sie im Fehlerfall wiederherstellen konnen.

+
+ +

Mit freundlichen Grussen,
+Ihre PicPeak-Installation

`, + body_text_de: `Eine neue Version von PicPeak ist verfugbar + +Gute Neuigkeiten! Eine neue Version von PicPeak ist fur Ihre Installation verfugbar. + +Aktuelle Version: {{current_version}} +Neue Version: {{new_version}} +Kanal: {{channel}} + +Was ist neu? +Schauen Sie sich die Versionshinweise an, um zu sehen, was in diesem Update enthalten ist: +{{release_notes_url}} + +So aktualisieren Sie +Um Ihre Installation zu aktualisieren, melden Sie sich im Admin-Panel an und klicken Sie auf die Benachrichtigung "Update verfugbar". Dort finden Sie umgebungsspezifische Anweisungen. + +ERINNERUNG: Erstellen Sie immer ein Backup Ihrer Datenbank, bevor Sie aktualisieren, um sicherzustellen, dass Sie im Fehlerfall wiederherstellen konnen. + +Mit freundlichen Grussen, +Ihre PicPeak-Installation`, + variables: JSON.stringify(['current_version', 'new_version', 'channel', 'release_notes_url']) + }); + } + + console.log('Migration 070_add_update_notification_settings completed'); +}; + +exports.down = async function(knex) { + console.log('Rollback: 070_add_update_notification_settings'); + + // Remove settings + await knex('app_settings') + .whereIn('setting_key', [ + 'update_email_notifications_enabled', + 'update_email_recipients', + 'last_notified_version' + ]) + .del(); + + // Remove email template + await knex('email_templates') + .where('template_key', 'version_update_available') + .del(); +}; diff --git a/backend/migrations/core/071_add_captured_at.js b/backend/migrations/core/071_add_captured_at.js new file mode 100644 index 00000000..3187af6b --- /dev/null +++ b/backend/migrations/core/071_add_captured_at.js @@ -0,0 +1,42 @@ +/** + * Migration 071: Add captured_at column to photos table + * - Stores the original capture date from EXIF metadata + * - Enables sorting photos by capture date instead of upload date + */ + +const { addColumnIfNotExists } = require('../helpers'); + +exports.up = async function(knex) { + console.log('Running migration: 071_add_captured_at'); + + // Add captured_at column to photos table + await addColumnIfNotExists(knex, 'photos', 'captured_at', (table) => { + table.datetime('captured_at').nullable(); + }); + + // Add index for sorting performance + const indexExists = await knex.schema.hasIndex + ? await knex.schema.hasIndex('photos', 'idx_photos_captured_at') + : false; + + if (!indexExists) { + // Use raw query for index creation with IF NOT EXISTS + const client = knex.client.config.client; + if (client === 'pg') { + await knex.raw('CREATE INDEX IF NOT EXISTS idx_photos_captured_at ON photos(captured_at)'); + } else if (client === 'sqlite3' || client === 'better-sqlite3') { + // SQLite doesn't support IF NOT EXISTS for indexes, so we need to check first + const existingIndexes = await knex.raw("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_photos_captured_at'"); + if (existingIndexes.length === 0) { + await knex.raw('CREATE INDEX idx_photos_captured_at ON photos(captured_at)'); + } + } + } + + console.log('Migration 071_add_captured_at completed'); +}; + +exports.down = async function(knex) { + console.log('Rollback: 071_add_captured_at'); + // Keep column for safe rollback (intentionally no-op) +}; diff --git a/backend/package-lock.json b/backend/package-lock.json index d3d9896a..77cb5a23 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -20,6 +20,7 @@ "cookie-parser": "^1.4.7", "cors": "^2.8.5", "dotenv": "^16.0.3", + "exifr": "^7.1.3", "express": "^4.18.2", "express-rate-limit": "^6.7.0", "express-validator": "^7.0.1", @@ -5730,6 +5731,12 @@ "dev": true, "license": "ISC" }, + "node_modules/exifr": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/exifr/-/exifr-7.1.3.tgz", + "integrity": "sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw==", + "license": "MIT" + }, "node_modules/exit": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", diff --git a/backend/package.json b/backend/package.json index 13e69bc7..205d0768 100644 --- a/backend/package.json +++ b/backend/package.json @@ -25,6 +25,7 @@ "cookie-parser": "^1.4.7", "cors": "^2.8.5", "dotenv": "^16.0.3", + "exifr": "^7.1.3", "express": "^4.18.2", "express-rate-limit": "^6.7.0", "express-validator": "^7.0.1", diff --git a/backend/src/routes/adminPhotos.js b/backend/src/routes/adminPhotos.js index e1a7bb81..e02fc42c 100644 --- a/backend/src/routes/adminPhotos.js +++ b/backend/src/routes/adminPhotos.js @@ -5,7 +5,7 @@ const fs = require('fs').promises; const { db, logActivity } = require('../database/db'); const { adminAuth } = require('../middleware/auth'); const { requirePermission } = require('../middleware/permissions'); -const { generateThumbnail, ensureThumbnail } = require('../services/imageProcessor'); +const { generateThumbnail, ensureThumbnail, extractCaptureDate } = require('../services/imageProcessor'); const { generatePhotoFilename } = require('../utils/filenameSanitizer'); const { escapeLikePattern } = require('../utils/sqlSecurity'); const { validateUploadedFiles } = require('../middleware/uploadValidation'); @@ -253,7 +253,16 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), u const finalPath = path.join(finalDestPath, newFilename); const storagePath = getStoragePath(); const relativePath = path.relative(path.join(storagePath, 'events/active'), finalPath); - + + // Extract capture date from EXIF metadata + let capturedAt = null; + try { + capturedAt = await extractCaptureDate(tempPath); + } catch (exifError) { + // Non-fatal - just log and continue without capture date + console.log(`Could not extract EXIF date for ${file.originalname}`); + } + // Prepare photo data for batch insert const photoData = { event_id: parseInt(eventId), @@ -263,7 +272,8 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), u thumbnail_path: null, // Will generate after successful commit type: photoType, category_id: parsedCategoryId, // Save the selected category - size_bytes: tempStats.size // Use actual file size from stat + size_bytes: tempStats.size, // Use actual file size from stat + captured_at: capturedAt // EXIF capture date (if available) }; batchPhotos.push(photoData); diff --git a/backend/src/routes/adminSystem.js b/backend/src/routes/adminSystem.js index da93b30c..e44d32a0 100644 --- a/backend/src/routes/adminSystem.js +++ b/backend/src/routes/adminSystem.js @@ -8,6 +8,12 @@ const os = require('os'); const { formatBoolean } = require('../utils/dbCompat'); const logger = require('../utils/logger'); const { checkForUpdates, getCurrentChannel } = require('../services/updateCheckService'); +const { detectEnvironment, generateUpdateInstructions } = require('../services/environmentService'); +const { + checkAndNotifyUpdates, + sendUpdateNotificationNow, + getUpdateNotificationSettings +} = require('../services/updateNotificationService'); const router = express.Router(); // Get system version @@ -65,6 +71,47 @@ router.get('/updates', adminAuth, requirePermission('settings.view'), async (req } }); +// Get update instructions for current environment +router.get('/updates/instructions', adminAuth, requirePermission('settings.view'), async (req, res) => { + try { + // Check if update checking is enabled + const updateCheckEnabled = process.env.UPDATE_CHECK_ENABLED !== 'false'; + + if (!updateCheckEnabled) { + return res.json({ + enabled: false, + message: 'Update checking is disabled' + }); + } + + const env = await detectEnvironment(); + const updateInfo = await checkForUpdates(); + + if (!updateInfo.updateAvailable) { + return res.json({ + updateAvailable: false, + currentVersion: updateInfo.current, + message: 'You are running the latest version' + }); + } + + const instructions = generateUpdateInstructions(env, updateInfo.latest.forChannel); + + res.json({ + updateAvailable: true, + currentVersion: updateInfo.current, + targetVersion: updateInfo.latest.forChannel, + channel: updateInfo.channel, + environment: env, + instructions, + releaseNotesUrl: `https://github.com/the-luap/picpeak/releases/tag/v${updateInfo.latest.forChannel}` + }); + } catch (error) { + logger.error('Error generating update instructions:', error); + res.status(500).json({ error: 'Failed to generate update instructions' }); + } +}); + // Get comprehensive system status router.get('/status', adminAuth, requirePermission('settings.view'), async (req, res) => { try { @@ -262,4 +309,68 @@ router.get('/database', adminAuth, requirePermission('settings.view'), async (re } }); +// Get update notification settings +router.get('/updates/notifications', adminAuth, requirePermission('settings.view'), async (req, res) => { + try { + const settings = await getUpdateNotificationSettings(); + res.json(settings); + } catch (error) { + logger.error('Error fetching update notification settings:', error); + res.status(500).json({ error: 'Failed to fetch update notification settings' }); + } +}); + +// Update notification settings +router.put('/updates/notifications', adminAuth, requirePermission('settings.edit'), async (req, res) => { + try { + const { enabled, recipients } = req.body; + + if (typeof enabled !== 'undefined') { + await db('app_settings') + .where('setting_key', 'update_email_notifications_enabled') + .update({ + setting_value: JSON.stringify(enabled === true), + updated_at: db.fn.now() + }); + } + + if (typeof recipients !== 'undefined') { + await db('app_settings') + .where('setting_key', 'update_email_recipients') + .update({ + setting_value: JSON.stringify(recipients || ''), + updated_at: db.fn.now() + }); + } + + const updatedSettings = await getUpdateNotificationSettings(); + res.json({ success: true, settings: updatedSettings }); + } catch (error) { + logger.error('Error updating notification settings:', error); + res.status(500).json({ error: 'Failed to update notification settings' }); + } +}); + +// Manually trigger update notification email +router.post('/updates/notifications/send', adminAuth, requirePermission('settings.edit'), async (req, res) => { + try { + const result = await sendUpdateNotificationNow(); + res.json(result); + } catch (error) { + logger.error('Error sending update notification:', error); + res.status(500).json({ error: 'Failed to send update notification' }); + } +}); + +// Check and send update notifications (called on admin login or periodically) +router.post('/updates/notifications/check', adminAuth, requirePermission('settings.view'), async (req, res) => { + try { + const result = await checkAndNotifyUpdates(); + res.json(result); + } catch (error) { + logger.error('Error checking for update notifications:', error); + res.status(500).json({ error: 'Failed to check for update notifications' }); + } +}); + module.exports = router; diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js index 5898ff5f..37fd1932 100644 --- a/backend/src/routes/gallery.js +++ b/backend/src/routes/gallery.js @@ -187,8 +187,8 @@ router.get('/:slug/info', async (req, res) => { // Get all photos router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => { try { - // Get filter parameters from query - const { filter, guest_id } = req.query; + // Get filter and sort parameters from query + const { filter, guest_id, sort = 'upload_date', order = 'desc' } = req.query; // Get watermark settings to generate cache-busting version for URLs const watermarkSettings = await watermarkService.getWatermarkSettings(); @@ -196,11 +196,25 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => { ? `wm=${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}` : ''; - // First get all photos - let photos = await db('photos') + // Build the query with sorting + const sortOrder = order === 'asc' ? 'asc' : 'desc'; + let photosQuery = db('photos') .where('photos.event_id', req.event.id) - .select('photos.*') - .orderBy('photos.uploaded_at', 'desc'); + .select('photos.*'); + + // Apply sort option + if (sort === 'capture_date') { + // Sort by capture date, falling back to uploaded_at if capture date is null + photosQuery = photosQuery.orderByRaw('COALESCE(photos.captured_at, photos.uploaded_at) ' + sortOrder); + } else if (sort === 'filename') { + photosQuery = photosQuery.orderBy('photos.filename', sortOrder); + } else { + // Default: sort by upload date + photosQuery = photosQuery.orderBy('photos.uploaded_at', sortOrder); + } + + // Execute the query + let photos = await photosQuery; // Apply filtering if requested (supports global stats + per-guest interactions) if (filter) { diff --git a/backend/src/services/environmentService.js b/backend/src/services/environmentService.js new file mode 100644 index 00000000..1d7fbd99 --- /dev/null +++ b/backend/src/services/environmentService.js @@ -0,0 +1,195 @@ +/** + * Environment Detection Service + * Detects the deployment environment and generates update instructions accordingly. + */ + +const fs = require('fs'); +const path = require('path'); +const logger = require('../utils/logger'); + +/** + * Detect the current deployment environment + * @returns {Object} Environment information + */ +async function detectEnvironment() { + // Check for Docker environment + const isDocker = fs.existsSync('/.dockerenv') || + process.env.DOCKER_CONTAINER === 'true'; + + // Determine project root (services -> src -> backend) + const projectRoot = path.join(__dirname, '../../..'); + + // Check for git repository + const isGit = fs.existsSync(path.join(projectRoot, '.git')); + + // Check for docker-compose files + const hasDockerCompose = fs.existsSync(path.join(projectRoot, 'docker-compose.yml')) || + fs.existsSync(path.join(projectRoot, 'docker-compose.yaml')); + + // Get app version + let appVersion = '0.0.0'; + try { + const packagePath = path.join(__dirname, '../../package.json'); + const packageContent = fs.readFileSync(packagePath, 'utf8'); + const packageJson = JSON.parse(packageContent); + appVersion = packageJson.version || '0.0.0'; + } catch (err) { + logger.warn('Could not read package.json for version:', err.message); + } + + // Determine environment type + let type; + if (isDocker) { + type = 'docker'; + } else if (isGit) { + type = 'git'; + } else { + type = 'standalone'; + } + + return { + type, + isDocker, + isGit, + hasDockerCompose, + platform: process.platform, + nodeVersion: process.version, + appVersion + }; +} + +/** + * Generate environment-specific update instructions + * @param {Object} env - Environment info from detectEnvironment() + * @param {string} targetVersion - Target version to update to + * @returns {Object} Update instructions with pre-checks, steps, and post-checks + */ +function generateUpdateInstructions(env, targetVersion) { + const instructions = { + preChecks: [ + { + id: 'backup', + text: 'I have backed up my database', + required: true + }, + { + id: 'no-uploads', + text: 'No uploads are currently in progress', + required: true + }, + { + id: 'downtime-aware', + text: 'I understand the application will restart during update', + required: false + } + ], + steps: [], + postChecks: [ + 'Verify the application starts correctly', + 'Check the version in Admin -> System', + 'Review release notes for any breaking changes or required actions' + ], + warnings: [] + }; + + if (env.isDocker) { + instructions.environmentName = 'Docker'; + instructions.steps = [ + { + description: 'Pull latest images', + command: 'docker compose pull', + note: 'Downloads the new version images' + }, + { + description: 'Recreate containers with new images', + command: 'docker compose up -d', + note: 'Restarts containers with new version' + }, + { + description: 'Watch logs for startup (optional)', + command: 'docker compose logs -f backend', + note: 'Press Ctrl+C to exit logs', + optional: true + } + ]; + instructions.warnings.push('Make sure you are in the directory containing your docker-compose.yml file'); + } else if (env.isGit) { + instructions.environmentName = 'Git (Development)'; + instructions.steps = [ + { + description: 'Fetch latest changes', + command: 'git fetch origin', + note: 'Downloads references from remote' + }, + { + description: 'Switch to new version tag', + command: `git checkout v${targetVersion}`, + note: 'Switches to the release version' + }, + { + description: 'Install backend dependencies', + command: 'cd backend && npm install', + note: 'Updates npm packages' + }, + { + description: 'Build frontend', + command: 'cd frontend && npm install && npm run build', + note: 'Compiles the frontend application' + }, + { + description: 'Run database migrations', + command: 'cd backend && npm run migrate', + note: 'Updates database schema' + }, + { + description: 'Restart application', + command: '# Restart your application (pm2, systemd, etc.)', + note: 'Method depends on your setup - e.g., pm2 restart picpeak' + } + ]; + instructions.warnings.push('Adjust the restart command based on your process manager (pm2, systemd, etc.)'); + } else { + instructions.environmentName = 'Standalone'; + instructions.steps = [ + { + description: 'Download release archive', + command: `# Download v${targetVersion} from GitHub Releases`, + note: `https://github.com/the-luap/picpeak/releases/tag/v${targetVersion}` + }, + { + description: 'Backup current installation', + command: '# Create backup of current files', + note: 'Keep a copy of your current installation' + }, + { + description: 'Extract and replace application files', + command: '# Extract release archive to installation directory', + note: 'Preserve your .env file and storage directory' + }, + { + description: 'Install dependencies', + command: 'cd backend && npm install --production', + note: 'Updates npm packages' + }, + { + description: 'Run database migrations', + command: 'cd backend && npm run migrate', + note: 'Updates database schema' + }, + { + description: 'Restart application', + command: '# Restart your application service', + note: 'Method depends on your setup' + } + ]; + instructions.warnings.push('Make sure to preserve your .env file and storage directory when updating'); + instructions.warnings.push('Consider creating a full backup before updating'); + } + + return instructions; +} + +module.exports = { + detectEnvironment, + generateUpdateInstructions +}; diff --git a/backend/src/services/imageProcessor.js b/backend/src/services/imageProcessor.js index 322f5ccf..dae55103 100644 --- a/backend/src/services/imageProcessor.js +++ b/backend/src/services/imageProcessor.js @@ -1,4 +1,5 @@ const sharp = require('sharp'); +const exifr = require('exifr'); const path = require('path'); const fs = require('fs').promises; const logger = require('../utils/logger'); @@ -442,6 +443,55 @@ async function ensureHeroImage(photo) { return null; } +/** + * Extract capture date from EXIF metadata + * @param {string} imagePath - Path to the image file + * @returns {Date|null} - The capture date or null if not available + */ +async function extractCaptureDate(imagePath) { + try { + // Parse EXIF data, looking for common date fields + const exif = await exifr.parse(imagePath, { + pick: ['DateTimeOriginal', 'CreateDate', 'DateTimeDigitized', 'ModifyDate'] + }); + + if (!exif) { + return null; + } + + // Priority order: DateTimeOriginal > CreateDate > DateTimeDigitized > ModifyDate + const captureDate = exif.DateTimeOriginal || + exif.CreateDate || + exif.DateTimeDigitized || + exif.ModifyDate; + + if (captureDate) { + // exifr returns Date objects directly when parsing dates + if (captureDate instanceof Date) { + // Validate the date is reasonable (not in the future, not before 1990) + const now = new Date(); + const minDate = new Date('1990-01-01'); + if (captureDate > minDate && captureDate <= now) { + return captureDate; + } + } + // Handle string dates if necessary + if (typeof captureDate === 'string') { + const parsed = new Date(captureDate); + if (!isNaN(parsed.getTime())) { + return parsed; + } + } + } + + return null; + } catch (error) { + // Log only as debug - many images don't have EXIF data + logger.debug(`Could not extract EXIF date from ${path.basename(imagePath)}:`, error.message); + return null; + } +} + module.exports = { generateThumbnail, isThumbnailValid, @@ -449,5 +499,6 @@ module.exports = { generateVideoPlaceholder, generateHeroImage, isHeroValid, - ensureHeroImage + ensureHeroImage, + extractCaptureDate }; diff --git a/backend/src/services/updateNotificationService.js b/backend/src/services/updateNotificationService.js new file mode 100644 index 00000000..d1563925 --- /dev/null +++ b/backend/src/services/updateNotificationService.js @@ -0,0 +1,246 @@ +/** + * Update Notification Service + * Checks for updates and sends email notifications to administrators. + */ + +const { db } = require('../database/db'); +const { checkForUpdates } = require('./updateCheckService'); +const { sendTemplateEmail, initializeTransporter } = require('./emailProcessor'); +const logger = require('../utils/logger'); + +/** + * Get update notification settings from database + */ +async function getUpdateNotificationSettings() { + try { + const settings = await db('app_settings') + .whereIn('setting_key', [ + 'update_email_notifications_enabled', + 'update_email_recipients', + 'last_notified_version' + ]) + .select('setting_key', 'setting_value'); + + const result = {}; + for (const setting of settings) { + try { + result[setting.setting_key] = JSON.parse(setting.setting_value); + } catch (e) { + result[setting.setting_key] = setting.setting_value; + } + } + + return { + enabled: result.update_email_notifications_enabled === true, + recipients: result.update_email_recipients || '', + lastNotifiedVersion: result.last_notified_version || '' + }; + } catch (error) { + logger.error('Error fetching update notification settings:', error); + return { + enabled: false, + recipients: '', + lastNotifiedVersion: '' + }; + } +} + +/** + * Update the last notified version in database + */ +async function updateLastNotifiedVersion(version) { + try { + await db('app_settings') + .where('setting_key', 'last_notified_version') + .update({ + setting_value: JSON.stringify(version), + updated_at: db.fn.now() + }); + } catch (error) { + logger.error('Error updating last notified version:', error); + } +} + +/** + * Get admin email addresses to notify + * If recipients setting is empty, get all active admin emails + */ +async function getNotificationRecipients(recipientsSetting) { + try { + if (recipientsSetting && recipientsSetting.trim()) { + // Use configured recipients (comma-separated) + return recipientsSetting.split(',').map(email => email.trim()).filter(Boolean); + } + + // Fallback: get all active admin user emails + const admins = await db('admin_users') + .where('is_active', true) + .whereNotNull('email') + .select('email'); + + return admins.map(admin => admin.email).filter(Boolean); + } catch (error) { + logger.error('Error fetching notification recipients:', error); + return []; + } +} + +/** + * Check for updates and send notification emails if new version is available + */ +async function checkAndNotifyUpdates() { + logger.info('Update notification service: Checking for updates...'); + + try { + // Check if update notifications are enabled + const settings = await getUpdateNotificationSettings(); + + if (!settings.enabled) { + logger.info('Update email notifications are disabled'); + return { notified: false, reason: 'notifications_disabled' }; + } + + // Check for available updates + const updateInfo = await checkForUpdates(); + + if (!updateInfo.updateAvailable) { + logger.info('No updates available'); + return { notified: false, reason: 'no_updates' }; + } + + const newVersion = updateInfo.latest.forChannel; + + // Check if we've already notified about this version + if (settings.lastNotifiedVersion === newVersion) { + logger.info(`Already notified about version ${newVersion}`); + return { notified: false, reason: 'already_notified' }; + } + + // Get recipients + const recipients = await getNotificationRecipients(settings.recipients); + + if (recipients.length === 0) { + logger.warn('No recipients configured for update notifications'); + return { notified: false, reason: 'no_recipients' }; + } + + // Ensure email transporter is initialized + await initializeTransporter(); + + // Send email to each recipient + const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000'; + const releaseNotesUrl = `https://github.com/the-luap/picpeak/releases/tag/v${newVersion}`; + const channelLabel = updateInfo.channel === 'beta' ? 'Beta' : 'Stable'; + + let successCount = 0; + let errorCount = 0; + + for (const email of recipients) { + try { + await sendTemplateEmail(email, 'version_update_available', { + current_version: updateInfo.current, + new_version: newVersion, + channel: channelLabel, + release_notes_url: releaseNotesUrl, + admin_url: `${frontendUrl}/admin` + }); + successCount++; + logger.info(`Update notification sent to ${email}`); + } catch (error) { + errorCount++; + logger.error(`Failed to send update notification to ${email}:`, error); + } + } + + // Update last notified version + if (successCount > 0) { + await updateLastNotifiedVersion(newVersion); + logger.info(`Update notifications sent: ${successCount} success, ${errorCount} failed`); + } + + return { + notified: successCount > 0, + newVersion, + successCount, + errorCount, + totalRecipients: recipients.length + }; + } catch (error) { + logger.error('Error in update notification service:', error); + return { notified: false, reason: 'error', error: error.message }; + } +} + +/** + * Force send update notification (for manual trigger from admin UI) + */ +async function sendUpdateNotificationNow() { + logger.info('Manually triggering update notification...'); + + try { + // Check for available updates + const updateInfo = await checkForUpdates(true); // Force refresh + + if (!updateInfo.updateAvailable) { + return { success: false, message: 'No updates available' }; + } + + const newVersion = updateInfo.latest.forChannel; + const settings = await getUpdateNotificationSettings(); + + // Get recipients + const recipients = await getNotificationRecipients(settings.recipients); + + if (recipients.length === 0) { + return { success: false, message: 'No recipients configured' }; + } + + // Ensure email transporter is initialized + await initializeTransporter(); + + // Send email to each recipient + const releaseNotesUrl = `https://github.com/the-luap/picpeak/releases/tag/v${newVersion}`; + const channelLabel = updateInfo.channel === 'beta' ? 'Beta' : 'Stable'; + + let successCount = 0; + let errorCount = 0; + + for (const email of recipients) { + try { + await sendTemplateEmail(email, 'version_update_available', { + current_version: updateInfo.current, + new_version: newVersion, + channel: channelLabel, + release_notes_url: releaseNotesUrl + }); + successCount++; + } catch (error) { + errorCount++; + logger.error(`Failed to send update notification to ${email}:`, error); + } + } + + // Update last notified version + if (successCount > 0) { + await updateLastNotifiedVersion(newVersion); + } + + return { + success: successCount > 0, + newVersion, + successCount, + errorCount, + totalRecipients: recipients.length + }; + } catch (error) { + logger.error('Error sending manual update notification:', error); + return { success: false, message: error.message }; + } +} + +module.exports = { + checkAndNotifyUpdates, + sendUpdateNotificationNow, + getUpdateNotificationSettings, + getNotificationRecipients +}; diff --git a/frontend/src/components/admin/UpdateInstructionsDialog.tsx b/frontend/src/components/admin/UpdateInstructionsDialog.tsx new file mode 100644 index 00000000..932c58bc --- /dev/null +++ b/frontend/src/components/admin/UpdateInstructionsDialog.tsx @@ -0,0 +1,357 @@ +import React, { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { + X, + ExternalLink, + Copy, + Check, + AlertTriangle, + Server, + Terminal, + CheckCircle2, + Circle +} from 'lucide-react'; +import { api } from '../../config/api'; + +interface UpdateStep { + description: string; + command: string; + note?: string; + optional?: boolean; +} + +interface PreCheck { + id: string; + text: string; + required: boolean; +} + +interface UpdateInstructions { + environmentName: string; + preChecks: PreCheck[]; + steps: UpdateStep[]; + postChecks: string[]; + warnings: string[]; +} + +interface Environment { + type: 'docker' | 'git' | 'standalone'; + isDocker: boolean; + isGit: boolean; + hasDockerCompose: boolean; + platform: string; + nodeVersion: string; + appVersion: string; +} + +interface UpdateInstructionsResponse { + enabled?: boolean; + updateAvailable: boolean; + currentVersion: string; + targetVersion?: string; + channel?: string; + environment?: Environment; + instructions?: UpdateInstructions; + releaseNotesUrl?: string; + message?: string; +} + +async function fetchUpdateInstructions(): Promise { + const response = await api.get('/admin/system/updates/instructions'); + return response.data; +} + +interface UpdateInstructionsDialogProps { + isOpen: boolean; + onClose: () => void; + targetVersion?: string; +} + +export const UpdateInstructionsDialog: React.FC = ({ + isOpen, + onClose, + targetVersion +}) => { + const { t } = useTranslation(); + const [checkedItems, setCheckedItems] = useState>(new Set()); + const [copiedCommand, setCopiedCommand] = useState(null); + + const { data, isLoading, error } = useQuery({ + queryKey: ['update-instructions'], + queryFn: fetchUpdateInstructions, + enabled: isOpen, + staleTime: 5 * 60 * 1000 // 5 minutes + }); + + if (!isOpen) return null; + + const handleCheckItem = (id: string) => { + const newChecked = new Set(checkedItems); + if (newChecked.has(id)) { + newChecked.delete(id); + } else { + newChecked.add(id); + } + setCheckedItems(newChecked); + }; + + const copyToClipboard = async (command: string, id: string) => { + try { + await navigator.clipboard.writeText(command); + setCopiedCommand(id); + setTimeout(() => setCopiedCommand(null), 2000); + } catch (err) { + console.error('Failed to copy:', err); + } + }; + + const copyAllCommands = async () => { + if (!data?.instructions?.steps) return; + const allCommands = data.instructions.steps + .filter(step => !step.command.startsWith('#')) + .map(step => step.command) + .join('\n'); + try { + await navigator.clipboard.writeText(allCommands); + setCopiedCommand('all'); + setTimeout(() => setCopiedCommand(null), 2000); + } catch (err) { + console.error('Failed to copy:', err); + } + }; + + const requiredChecks = data?.instructions?.preChecks.filter(c => c.required) || []; + const allRequiredChecked = requiredChecks.every(check => checkedItems.has(check.id)); + + return ( +
+
+ {/* Backdrop */} +
+ + {/* Dialog */} +
+ {/* Header */} +
+

+ {t('admin.updates.updateDialog.title', 'Update PicPeak')} + {data?.targetVersion && ( + + v{data.targetVersion} + + )} +

+ +
+ + {/* Content */} +
+ {isLoading && ( +
+
+
+ )} + + {error && ( +
+ +

+ {t('admin.updates.updateDialog.error', 'Failed to load update instructions')} +

+
+ )} + + {data && !data.updateAvailable && ( +
+ +

+ {t('admin.updates.upToDate', "You're up to date")} (v{data.currentVersion}) +

+
+ )} + + {data?.instructions && ( +
+ {/* Environment Info */} +
+ + + {t('admin.updates.updateDialog.detectedEnv', 'Detected Environment')}:{' '} + {data.instructions.environmentName} + +
+ + {/* Warnings */} + {data.instructions.warnings.length > 0 && ( +
+ {data.instructions.warnings.map((warning, idx) => ( +
+ +

{warning}

+
+ ))} +
+ )} + + {/* Pre-flight Checklist */} +
+

+ + {t('admin.updates.updateDialog.beforeUpdating', 'Before updating:')} +

+
+ {data.instructions.preChecks.map((check) => ( + + ))} +
+
+ + {/* Divider */} +
+ + {/* Update Commands */} +
+

+ + {t('admin.updates.updateDialog.updateCommands', 'Update Commands:')} +

+
+ {data.instructions.steps.map((step, idx) => ( +
+
+ + {idx + 1}. {step.description} + {step.optional && ( + + ({t('common.optional', 'optional')}) + + )} + +
+
+ + {step.command} + + +
+ {step.note && ( +

+ {step.note} +

+ )} +
+ ))} +
+
+ + {/* Divider */} +
+ + {/* Post-update Checks */} +
+

+ + {t('admin.updates.updateDialog.afterUpdating', 'After updating:')} +

+
    + {data.instructions.postChecks.map((check, idx) => ( +
  • + + {check} +
  • + ))} +
+
+ + {/* Release Notes Link */} + {data.releaseNotesUrl && ( + + + {t('admin.updates.viewReleaseNotes', 'View Release Notes')} + + )} +
+ )} +
+ + {/* Footer */} +
+
+ {!allRequiredChecked && data?.instructions && ( + + {t('admin.updates.updateDialog.completeChecklist', 'Complete the checklist before updating')} + + )} +
+
+ {data?.instructions && ( + + )} + +
+
+
+
+
+ ); +}; diff --git a/frontend/src/components/admin/UpdateNotification.tsx b/frontend/src/components/admin/UpdateNotification.tsx index a2483b68..0a8d2382 100644 --- a/frontend/src/components/admin/UpdateNotification.tsx +++ b/frontend/src/components/admin/UpdateNotification.tsx @@ -1,8 +1,9 @@ import React, { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; -import { ArrowUpCircle, X, ExternalLink } from 'lucide-react'; +import { ArrowUpCircle, X, ExternalLink, Wrench } from 'lucide-react'; import { api } from '../../config/api'; +import { UpdateInstructionsDialog } from './UpdateInstructionsDialog'; interface UpdateInfo { enabled: boolean; @@ -32,6 +33,7 @@ interface UpdateNotificationProps { export const UpdateNotification: React.FC = ({ onDismiss }) => { const { t } = useTranslation(); const [dismissed, setDismissed] = useState(false); + const [showInstructions, setShowInstructions] = useState(false); const { data: updateInfo } = useQuery({ queryKey: ['update-check'], @@ -79,15 +81,24 @@ export const UpdateNotification: React.FC = ({ onDismis channel: channelLabel })}

- - {t('admin.updates.viewReleaseNotes', 'View Release Notes')} - - +
+ + + {t('admin.updates.viewReleaseNotes', 'View Release Notes')} + + +
+ + {/* Update Instructions Dialog */} + setShowInstructions(false)} + targetVersion={updateInfo?.latest?.forChannel} + /> ); }; diff --git a/frontend/src/components/gallery/GallerySidebar.tsx b/frontend/src/components/gallery/GallerySidebar.tsx index a3c2cb53..9936ca7f 100644 --- a/frontend/src/components/gallery/GallerySidebar.tsx +++ b/frontend/src/components/gallery/GallerySidebar.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useRef } from 'react'; -import { X, Download, Filter, SortAsc, Search, Calendar, Type, HardDrive, Check, Star, Upload } from 'lucide-react'; +import { X, Download, Filter, SortAsc, Search, Calendar, Type, HardDrive, Check, Star, Upload, Camera } from 'lucide-react'; import { Button } from '../common'; import { PhotoCategory } from '../../types'; import { useTranslation } from 'react-i18next'; @@ -13,8 +13,8 @@ interface GallerySidebarProps { onCategoryChange: (categoryId: number | string | null) => void; searchTerm: string; onSearchChange: (term: string) => void; - sortBy: 'date' | 'name' | 'size' | 'rating'; - onSortChange: (sort: 'date' | 'name' | 'size' | 'rating') => void; + sortBy: 'date' | 'name' | 'size' | 'rating' | 'capture_date'; + onSortChange: (sort: 'date' | 'name' | 'size' | 'rating' | 'capture_date') => void; isSelectionMode: boolean; onToggleSelectionMode: () => void; selectedCount: number; @@ -101,6 +101,7 @@ export const GallerySidebar: React.FC = ({ const sortOptions = [ { value: 'date', label: t('gallery.sortByDate'), icon: Calendar }, + { value: 'capture_date', label: t('gallery.sortByCaptureDate', 'Capture Date'), icon: Camera }, { value: 'name', label: t('gallery.sortByName'), icon: Type }, { value: 'size', label: t('gallery.sortBySize'), icon: HardDrive }, { value: 'rating', label: t('gallery.sortByRating', 'Rating'), icon: Star } diff --git a/frontend/src/components/gallery/GalleryView.tsx b/frontend/src/components/gallery/GalleryView.tsx index 2bac56a1..586aa405 100644 --- a/frontend/src/components/gallery/GalleryView.tsx +++ b/frontend/src/components/gallery/GalleryView.tsx @@ -47,7 +47,7 @@ export const GalleryView: React.FC = ({ slug, event }) => { const { setTheme, theme } = useTheme(); const [selectedCategoryId, setSelectedCategoryId] = useState(null); const [searchTerm, setSearchTerm] = useState(''); - const [sortBy, setSortBy] = useState<'date' | 'name' | 'size' | 'rating'>('date'); + const [sortBy, setSortBy] = useState<'date' | 'name' | 'size' | 'rating' | 'capture_date'>('date'); const [brandingSettings, setBrandingSettings] = useState(null); const [showUploadModal, setShowUploadModal] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false); @@ -397,6 +397,11 @@ export const GalleryView: React.FC = ({ slug, event }) => { } // If ratings are equal, sort by comment count return (b.comment_count || 0) - (a.comment_count || 0); + case 'capture_date': + // Sort by capture date (from EXIF), fall back to upload date + const captureDateA = a.captured_at || a.uploaded_at; + const captureDateB = b.captured_at || b.uploaded_at; + return new Date(captureDateB).getTime() - new Date(captureDateA).getTime(); case 'date': default: return new Date(b.uploaded_at).getTime() - new Date(a.uploaded_at).getTime(); diff --git a/frontend/src/features/settings/components/UpdateNotificationSettings.tsx b/frontend/src/features/settings/components/UpdateNotificationSettings.tsx new file mode 100644 index 00000000..1f2a0f29 --- /dev/null +++ b/frontend/src/features/settings/components/UpdateNotificationSettings.tsx @@ -0,0 +1,229 @@ +import React from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { Bell, Save, Mail, Send, RefreshCw } from 'lucide-react'; +import { Card, Button, Input } from '../../../components/common'; +import { api } from '../../../config/api'; +import { toast } from 'react-toastify'; + +interface UpdateNotificationSettingsData { + enabled: boolean; + recipients: string; + lastNotifiedVersion: string; +} + +async function fetchNotificationSettings(): Promise { + const response = await api.get('/admin/system/updates/notifications'); + return response.data; +} + +async function updateNotificationSettings(data: Partial): Promise { + const response = await api.put<{ success: boolean; settings: UpdateNotificationSettingsData }>( + '/admin/system/updates/notifications', + data + ); + return response.data.settings; +} + +async function sendTestNotification(): Promise<{ success: boolean; message?: string; successCount?: number }> { + const response = await api.post('/admin/system/updates/notifications/send'); + return response.data; +} + +async function checkForNotifications(): Promise<{ notified: boolean; reason?: string }> { + const response = await api.post('/admin/system/updates/notifications/check'); + return response.data; +} + +export const UpdateNotificationSettings: React.FC = () => { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + + const { data: settings, isLoading } = useQuery({ + queryKey: ['update-notification-settings'], + queryFn: fetchNotificationSettings + }); + + const [localEnabled, setLocalEnabled] = React.useState(false); + const [localRecipients, setLocalRecipients] = React.useState(''); + const [isDirty, setIsDirty] = React.useState(false); + + // Sync local state when data is loaded + React.useEffect(() => { + if (settings && !isDirty) { + setLocalEnabled(settings.enabled); + setLocalRecipients(settings.recipients || ''); + } + }, [settings, isDirty]); + + const updateMutation = useMutation({ + mutationFn: updateNotificationSettings, + onSuccess: (data) => { + queryClient.setQueryData(['update-notification-settings'], data); + setIsDirty(false); + toast.success(t('settings.updateNotifications.saved', 'Settings saved')); + }, + onError: () => { + toast.error(t('settings.updateNotifications.saveError', 'Failed to save settings')); + } + }); + + const sendMutation = useMutation({ + mutationFn: sendTestNotification, + onSuccess: (data) => { + if (data.success) { + toast.success( + t('settings.updateNotifications.emailSent', 'Notification email sent to {{count}} recipients', { + count: data.successCount || 0 + }) + ); + } else { + toast.error(data.message || t('settings.updateNotifications.emailFailed', 'Failed to send notification')); + } + }, + onError: () => { + toast.error(t('settings.updateNotifications.emailFailed', 'Failed to send notification')); + } + }); + + const checkMutation = useMutation({ + mutationFn: checkForNotifications, + onSuccess: (data) => { + if (data.notified) { + toast.success(t('settings.updateNotifications.checkSuccess', 'Notification sent for new version')); + } else { + toast.success( + t('settings.updateNotifications.checkNoAction', 'No notification needed: {{reason}}', { + reason: data.reason || 'unknown' + }) + ); + } + }, + onError: () => { + toast.error(t('settings.updateNotifications.checkError', 'Failed to check for updates')); + } + }); + + const handleSave = () => { + updateMutation.mutate({ + enabled: localEnabled, + recipients: localRecipients + }); + }; + + const handleToggleEnabled = (value: boolean) => { + setLocalEnabled(value); + setIsDirty(true); + }; + + const handleRecipientsChange = (e: React.ChangeEvent) => { + setLocalRecipients(e.target.value); + setIsDirty(true); + }; + + if (isLoading) { + return ( + +
+
+
+
+
+
+ ); + } + + return ( + +

+ + {t('settings.updateNotifications.title', 'Update Notifications')} +

+ +

+ {t('settings.updateNotifications.description', 'Receive email notifications when new versions of PicPeak are available.')} +

+ +
+ {/* Enable/Disable Toggle */} + + + {/* Recipients */} +
+ } + disabled={!localEnabled} + /> +
+ + {/* Last notified version */} + {settings?.lastNotifiedVersion && ( +
+

+ {t('settings.updateNotifications.lastNotified', 'Last notification sent for version: {{version}}', { + version: settings.lastNotifiedVersion + })} +

+
+ )} + + {/* Action Buttons */} +
+
+ + +
+ +
+
+
+ ); +}; diff --git a/frontend/src/features/settings/tabs/StatusTab.tsx b/frontend/src/features/settings/tabs/StatusTab.tsx index 52614988..8c633f96 100644 --- a/frontend/src/features/settings/tabs/StatusTab.tsx +++ b/frontend/src/features/settings/tabs/StatusTab.tsx @@ -12,6 +12,7 @@ import { Button, Card, Input } from '../../../components/common'; import { useTranslation } from 'react-i18next'; import { settingsService } from '../../../services/settings.service'; import { useStatusTab } from '../hooks/useStatusTab'; +import { UpdateNotificationSettings } from '../components/UpdateNotificationSettings'; const BYTES_PER_GB = 1024 * 1024 * 1024; @@ -527,6 +528,9 @@ export const StatusTab: React.FC = ({ )} + {/* Update Notification Settings */} + + {/* Last update time */} {systemStatus && (
diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index faefbabd..33f8ac02 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -674,6 +674,7 @@ "searchPlaceholder": "Fotos suchen...", "sortBy": "Sortieren nach", "sortByDate": "Nach Datum sortieren", + "sortByCaptureDate": "Nach Aufnahmedatum sortieren", "sortByName": "Nach Name sortieren", "sortBySize": "Nach Größe sortieren", "sortByRating": "Nach Bewertung sortieren", @@ -1248,6 +1249,25 @@ "sent": "Gesendet", "failed": "Fehlgeschlagen", "lastUpdate": "Letzte Aktualisierung" + }, + "updateNotifications": { + "title": "Update-Benachrichtigungen", + "description": "Erhalten Sie E-Mail-Benachrichtigungen, wenn neue Versionen von PicPeak verfügbar sind.", + "enableEmails": "E-Mail-Benachrichtigungen aktivieren", + "enableEmailsDesc": "E-Mail an Admins senden, wenn eine neue Version verfügbar ist", + "recipients": "E-Mail-Empfänger", + "recipientsPlaceholder": "admin@beispiel.de, andere@beispiel.de", + "recipientsHelper": "Kommagetrennte E-Mail-Adressen. Leer lassen, um an alle Admin-Benutzer zu senden.", + "lastNotified": "Letzte Benachrichtigung gesendet für Version: {{version}}", + "checkNow": "Prüfen & Benachrichtigen", + "sendTest": "Test-E-Mail senden", + "saved": "Einstellungen gespeichert", + "saveError": "Fehler beim Speichern der Einstellungen", + "emailSent": "Benachrichtigungs-E-Mail an {{count}} Empfänger gesendet", + "emailFailed": "Fehler beim Senden der Benachrichtigung", + "checkSuccess": "Benachrichtigung für neue Version gesendet", + "checkNoAction": "Keine Benachrichtigung erforderlich: {{reason}}", + "checkError": "Fehler beim Prüfen auf Updates" } }, "branding": { @@ -1528,7 +1548,18 @@ "updateAvailableShort": "v{{version}} verfügbar", "checkForUpdates": "Nach Updates suchen", "upToDate": "Alles aktuell", - "lastChecked": "Zuletzt geprüft: {{time}}" + "lastChecked": "Zuletzt geprüft: {{time}}", + "updateNow": "Jetzt aktualisieren", + "updateDialog": { + "title": "PicPeak aktualisieren", + "detectedEnv": "Erkannte Umgebung", + "beforeUpdating": "Vor dem Update:", + "updateCommands": "Update-Befehle:", + "afterUpdating": "Nach dem Update:", + "copyAllCommands": "Alle Befehle kopieren", + "completeChecklist": "Checkliste vor dem Update ausfüllen", + "error": "Fehler beim Laden der Update-Anweisungen" + } }, "notifications": "Benachrichtigungen", "viewAllNotifications": "Alle Benachrichtigungen anzeigen", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 2b8f7c34..89e0022f 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -287,6 +287,7 @@ "searchPlaceholder": "Search photos...", "sortBy": "Sort By", "sortByDate": "Sort by Date", + "sortByCaptureDate": "Sort by Capture Date", "sortByName": "Sort by Name", "sortBySize": "Sort by Size", "sortByRating": "Sort by Rating", @@ -798,6 +799,25 @@ "failed": "Failed", "lastUpdate": "Last update" }, + "updateNotifications": { + "title": "Update Notifications", + "description": "Receive email notifications when new versions of PicPeak are available.", + "enableEmails": "Enable email notifications", + "enableEmailsDesc": "Send email to admins when a new version is available", + "recipients": "Email Recipients", + "recipientsPlaceholder": "admin@example.com, other@example.com", + "recipientsHelper": "Comma-separated email addresses. Leave empty to send to all admin users.", + "lastNotified": "Last notification sent for version: {{version}}", + "checkNow": "Check & Notify", + "sendTest": "Send Test Email", + "saved": "Settings saved", + "saveError": "Failed to save settings", + "emailSent": "Notification email sent to {{count}} recipients", + "emailFailed": "Failed to send notification", + "checkSuccess": "Notification sent for new version", + "checkNoAction": "No notification needed: {{reason}}", + "checkError": "Failed to check for updates" + }, "events": { "title": "Event Creation", "requiredFields": "Required Fields", @@ -1241,7 +1261,18 @@ "updateAvailableShort": "v{{version}} available", "checkForUpdates": "Check for Updates", "upToDate": "You're up to date", - "lastChecked": "Last checked: {{time}}" + "lastChecked": "Last checked: {{time}}", + "updateNow": "Update Now", + "updateDialog": { + "title": "Update PicPeak", + "detectedEnv": "Detected Environment", + "beforeUpdating": "Before updating:", + "updateCommands": "Update Commands:", + "afterUpdating": "After updating:", + "copyAllCommands": "Copy All Commands", + "completeChecklist": "Complete the checklist before updating", + "error": "Failed to load update instructions" + } }, "notifications": "Notifications", "viewAllNotifications": "View all notifications", diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index d3affcc4..7b407662 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -81,6 +81,7 @@ export interface Photo { category_slug?: string; size: number; uploaded_at: string; + captured_at?: string; // EXIF capture date (if available) // Media type fields media_type?: 'photo' | 'video' | 'image'; mime_type?: string; diff --git a/package-lock.json b/package-lock.json index 9212cfbc..398a3cf7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ }, "devDependencies": { "@playwright/test": "^1.48.2", + "dotenv": "^17.3.1", "puppeteer": "^24.17.0" } }, @@ -561,6 +562,19 @@ "license": "BSD-3-Clause", "peer": true }, + "node_modules/dotenv": { + "version": "17.3.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", + "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", diff --git a/package.json b/package.json index 7c5d6c5e..cda2229b 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,9 @@ "node-fetch": "^2.7.0" }, "devDependencies": { - "puppeteer": "^24.17.0", - "@playwright/test": "^1.48.2" + "@playwright/test": "^1.48.2", + "dotenv": "^17.3.1", + "puppeteer": "^24.17.0" }, "overrides": { "prebuild-install": { diff --git a/playwright.config.ts b/playwright.config.ts index 3b23f837..1944ad0f 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,4 +1,8 @@ import { defineConfig, devices } from '@playwright/test'; +import dotenv from 'dotenv'; + +// Load environment variables from .env file +dotenv.config(); export default defineConfig({ testDir: 'tests/e2e', diff --git a/tests/e2e/dark-mode.spec.ts b/tests/e2e/dark-mode.spec.ts new file mode 100644 index 00000000..76f158a0 --- /dev/null +++ b/tests/e2e/dark-mode.spec.ts @@ -0,0 +1,217 @@ +import { test, expect } from '@playwright/test'; + +const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@example.com'; +const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234'; + +// Helper to login to admin +async function loginToAdmin(page) { + await page.goto('/admin/login'); + await page.getByLabel(/Email|E-Mail/i).fill(ADMIN_EMAIL); + await page.getByLabel(/Password|Passwort/i).fill(ADMIN_PASSWORD); + await page.getByRole('button', { name: /Sign In|Log in|Anmelden/i }).click(); + await expect(page.getByRole('heading', { name: /Dashboard|Übersicht/i })).toBeVisible({ timeout: 20000 }); +} + +test.describe('Admin Dark Mode Toggle', () => { + test('dark mode toggle button exists in admin header', async ({ page }, testInfo) => { + if (testInfo.project.name === 'mobile-chrome') { + test.skip('Dark mode toggle validated on desktop viewport'); + } + + await loginToAdmin(page); + + // Look for the dark mode toggle button + const toggleButton = page.getByRole('button', { name: /dark mode|light mode|Dunkelmodus|Hellmodus/i }); + await expect(toggleButton).toBeVisible(); + }); + + test('clicking toggle switches to dark mode and adds dark class', async ({ page }, testInfo) => { + if (testInfo.project.name === 'mobile-chrome') { + test.skip('Dark mode toggle validated on desktop viewport'); + } + + await loginToAdmin(page); + + // Check initial state - should be light + const html = page.locator('html'); + const initialHasDark = await html.evaluate(el => el.classList.contains('dark')); + + // Click the toggle + const toggleButton = page.getByRole('button', { name: /dark mode|light mode|Dunkelmodus|Hellmodus/i }); + await toggleButton.click(); + + // Wait a moment for the class to toggle + await page.waitForTimeout(500); + + // Verify dark class toggled + const afterHasDark = await html.evaluate(el => el.classList.contains('dark')); + expect(afterHasDark).toBe(!initialHasDark); + + // Click again to revert + await toggleButton.click(); + await page.waitForTimeout(500); + + const finalHasDark = await html.evaluate(el => el.classList.contains('dark')); + expect(finalHasDark).toBe(initialHasDark); + }); + + test('dark mode preference persists across page reloads', async ({ page }, testInfo) => { + if (testInfo.project.name === 'mobile-chrome') { + test.skip('Dark mode toggle validated on desktop viewport'); + } + + await loginToAdmin(page); + + // Set to dark mode + const html = page.locator('html'); + const isAlreadyDark = await html.evaluate(el => el.classList.contains('dark')); + + if (!isAlreadyDark) { + const toggleButton = page.getByRole('button', { name: /dark mode|Dunkelmodus/i }); + await toggleButton.click(); + await page.waitForTimeout(500); + } + + // Verify dark mode is active + await expect(html).toHaveAttribute('class', /dark/); + + // Reload the page + await page.reload(); + await expect(page.getByRole('heading', { name: /Dashboard|Übersicht/i })).toBeVisible({ timeout: 20000 }); + + // Verify dark mode persists + await expect(html).toHaveAttribute('class', /dark/); + + // Clean up - switch back to light mode + const toggleButton = page.getByRole('button', { name: /light mode|Hellmodus/i }); + await toggleButton.click(); + await page.waitForTimeout(500); + }); + + test('dark mode applies correct dark background to admin layout', async ({ page }, testInfo) => { + if (testInfo.project.name === 'mobile-chrome') { + test.skip('Dark mode toggle validated on desktop viewport'); + } + + await loginToAdmin(page); + + // Enable dark mode + const html = page.locator('html'); + const isAlreadyDark = await html.evaluate(el => el.classList.contains('dark')); + + if (!isAlreadyDark) { + const toggleButton = page.getByRole('button', { name: /dark mode|Dunkelmodus/i }); + await toggleButton.click(); + await page.waitForTimeout(500); + } + + // Verify the main layout container has dark background + const mainContainer = page.locator('.h-screen.bg-neutral-50, .h-screen.dark\\:bg-neutral-950').first(); + const bgColor = await mainContainer.evaluate(el => getComputedStyle(el).backgroundColor); + + // In dark mode, background should be very dark (close to black) + // neutral-950 is approximately rgb(10, 10, 10) + expect(bgColor).not.toBe('rgb(255, 255, 255)'); // Not white + expect(bgColor).not.toBe('rgba(0, 0, 0, 0)'); // Not transparent + + // Clean up + const toggleButton = page.getByRole('button', { name: /light mode|Hellmodus/i }); + await toggleButton.click(); + await page.waitForTimeout(500); + }); + + test('dark mode preference is stored in localStorage', async ({ page }, testInfo) => { + if (testInfo.project.name === 'mobile-chrome') { + test.skip('Dark mode toggle validated on desktop viewport'); + } + + await loginToAdmin(page); + + // Enable dark mode + const html = page.locator('html'); + const isAlreadyDark = await html.evaluate(el => el.classList.contains('dark')); + + if (!isAlreadyDark) { + const toggleButton = page.getByRole('button', { name: /dark mode|Dunkelmodus/i }); + await toggleButton.click(); + await page.waitForTimeout(500); + } + + // Verify dark mode preference is stored in localStorage + const storedPreference = await page.evaluate(() => localStorage.getItem('admin-dark-mode')); + expect(storedPreference).toBe('dark'); + + // Clean up - toggle back to light + const toggleButton = page.getByRole('button', { name: /light mode|Hellmodus/i }); + await toggleButton.click(); + await page.waitForTimeout(500); + + // Verify light mode preference is stored + const lightPreference = await page.evaluate(() => localStorage.getItem('admin-dark-mode')); + expect(lightPreference).toBe('light'); + }); +}); + +test.describe('Settings Page Dark Mode', () => { + test('settings page tabs render correctly in dark mode', async ({ page }, testInfo) => { + if (testInfo.project.name === 'mobile-chrome') { + test.skip('Settings dark mode validated on desktop viewport'); + } + + await loginToAdmin(page); + + // Enable dark mode + const html = page.locator('html'); + const isAlreadyDark = await html.evaluate(el => el.classList.contains('dark')); + + if (!isAlreadyDark) { + const toggleButton = page.getByRole('button', { name: /dark mode|Dunkelmodus/i }); + await toggleButton.click(); + await page.waitForTimeout(500); + } + + // Go to settings + await page.goto('/admin/settings'); + await expect(page.getByRole('heading', { name: /Settings|Einstellungen/i })).toBeVisible({ timeout: 10000 }); + + // Verify settings heading has dark text style + const heading = page.getByRole('heading', { name: /Settings|Einstellungen/i }).first(); + const headingColor = await heading.evaluate(el => getComputedStyle(el).color); + + // In dark mode, text should be light (not dark) + // neutral-100 is approximately rgb(245, 245, 245) + const [r, g, b] = headingColor.match(/\d+/g).map(Number); + expect(r + g + b).toBeGreaterThan(500); // Light colored text + + // Verify tab buttons are visible (use exact: true to avoid matching save buttons) + await expect(page.getByRole('button', { name: 'General', exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: /^SEO & Robots$|^SEO$/ })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Security', exact: true })).toBeVisible(); + + // Clean up + const toggleButton = page.getByRole('button', { name: /light mode|Hellmodus/i }); + await toggleButton.click(); + await page.waitForTimeout(500); + }); +}); + +test.describe('Gallery Theme Color Mode', () => { + test('branding page has color mode selector', async ({ page }, testInfo) => { + if (testInfo.project.name === 'mobile-chrome') { + test.skip('Theme customizer validated on desktop viewport'); + } + + await loginToAdmin(page); + + await page.goto('/admin/branding'); + await expect(page.getByText(/Theme|Themen/i).first()).toBeVisible({ timeout: 10000 }); + + // Look for the color mode selector + await expect(page.getByText(/Color Mode|Farbmodus/i)).toBeVisible(); + + // Verify the mode buttons exist + await expect(page.getByRole('button', { name: /^Light$|^Hell$/i })).toBeVisible(); + await expect(page.getByRole('button', { name: /^Dark$|^Dunkel$/i })).toBeVisible(); + await expect(page.getByRole('button', { name: /^Auto$/i })).toBeVisible(); + }); +}); diff --git a/tests/e2e/seo-settings.spec.ts b/tests/e2e/seo-settings.spec.ts new file mode 100644 index 00000000..f5222921 --- /dev/null +++ b/tests/e2e/seo-settings.spec.ts @@ -0,0 +1,158 @@ +import { test, expect } from '@playwright/test'; + +const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@example.com'; +const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234'; + +// Helper to login and navigate to settings +async function loginAndGoToSeoSettings(page) { + await page.goto('/admin/login'); + await page.getByLabel(/Email|E-Mail/i).fill(ADMIN_EMAIL); + await page.getByLabel(/Password|Passwort/i).fill(ADMIN_PASSWORD); + await page.getByRole('button', { name: /Sign In|Log in|Anmelden/i }).click(); + await expect(page.getByRole('heading', { name: /Dashboard|Übersicht/i })).toBeVisible({ timeout: 20000 }); + + await page.goto('/admin/settings'); + // Click on SEO tab + const seoTab = page.getByRole('button', { name: /SEO|Robots/i }); + await seoTab.click(); + await expect(page.getByRole('heading', { name: /Search Engine Indexing|Suchmaschinen-Indexierung/i })).toBeVisible({ timeout: 10000 }); +} + +test.describe('SEO Settings Tab', () => { + test('SEO tab renders all sections correctly', async ({ page }, testInfo) => { + if (testInfo.project.name === 'mobile-chrome') { + test.skip('SEO settings UI validated on desktop viewport'); + } + + await loginAndGoToSeoSettings(page); + + // Verify all three cards are visible + await expect(page.getByRole('heading', { name: /Search Engine Indexing|Suchmaschinen-Indexierung/i })).toBeVisible(); + await expect(page.getByRole('heading', { name: /AI & Bot Blocking|KI- & Bot-Blockierung/i })).toBeVisible(); + await expect(page.getByRole('heading', { name: /Meta Tags|Meta-Tags/i })).toBeVisible(); + + // Verify key form elements + await expect(page.getByLabel(/Allow search engine indexing|Suchmaschinen-Indexierung erlauben/i)).toBeVisible(); + await expect(page.getByLabel(/Block AI\/LLM crawlers|KI-\/LLM-Crawler blockieren/i)).toBeVisible(); + await expect(page.getByLabel(/Add noindex meta tag|noindex-Meta-Tag hinzufügen/i)).toBeVisible(); + }); + + test('can toggle indexing and save settings', async ({ page }, testInfo) => { + if (testInfo.project.name === 'mobile-chrome') { + test.skip('SEO settings UI validated on desktop viewport'); + } + + await loginAndGoToSeoSettings(page); + + const indexingToggle = page.getByLabel(/Allow search engine indexing|Suchmaschinen-Indexierung erlauben/i); + const initialState = await indexingToggle.isChecked(); + + // Toggle the setting + await indexingToggle.click(); + + // Save + const saveButton = page.getByRole('button', { name: /Save SEO Settings|SEO-Einstellungen speichern/i }); + await saveButton.click(); + + // Wait for success toast + await expect(page.locator('.Toastify__toast--success')).toBeVisible({ timeout: 10000 }); + + // Verify toggle state changed + const newState = await indexingToggle.isChecked(); + expect(newState).toBe(!initialState); + + // Revert to original state + await indexingToggle.click(); + await saveButton.click(); + await expect(page.locator('.Toastify__toast--success')).toBeVisible({ timeout: 10000 }); + }); + + test('robots.txt preview updates based on settings', async ({ page }, testInfo) => { + if (testInfo.project.name === 'mobile-chrome') { + test.skip('SEO settings UI validated on desktop viewport'); + } + + await loginAndGoToSeoSettings(page); + + // Click show preview button + const previewButton = page.getByRole('button', { name: /Show robots\.txt preview|robots\.txt-Vorschau anzeigen/i }); + await previewButton.click(); + + // Verify preview content is visible + const previewContent = page.locator('pre'); + await expect(previewContent).toBeVisible(); + + // Verify it contains expected content + const previewText = await previewContent.textContent(); + expect(previewText).toContain('User-agent'); + expect(previewText).toContain('Disallow'); + }); + + test('can add blocked AI agents', async ({ page }, testInfo) => { + if (testInfo.project.name === 'mobile-chrome') { + test.skip('SEO settings UI validated on desktop viewport'); + } + + await loginAndGoToSeoSettings(page); + + // Find the blocked agents section + const agentInput = page.getByPlaceholder(/Enter agent name|Agentenname eingeben/i); + await expect(agentInput).toBeVisible(); + + // Add a new agent + const testAgent = 'TestBot-' + Date.now(); + await agentInput.fill(testAgent); + await agentInput.press('Enter'); + + // Verify the agent tag appears in the list + await expect(page.getByText(testAgent)).toBeVisible(); + + // Save and verify it persists + const saveButton = page.getByRole('button', { name: /Save SEO Settings|SEO-Einstellungen speichern/i }); + await saveButton.click(); + + // Wait for success toast + await expect(page.locator('.Toastify__toast--success')).toBeVisible({ timeout: 10000 }); + + // Refresh and verify it's still there + await page.reload(); + await page.getByRole('button', { name: /SEO|Robots/i }).click(); + await expect(page.getByText(testAgent)).toBeVisible({ timeout: 10000 }); + }); +}); + +test.describe('robots.txt Endpoint', () => { + test('returns valid robots.txt from backend', async ({ request }) => { + const response = await request.get('/robots.txt'); + + expect(response.status()).toBe(200); + expect(response.headers()['content-type']).toContain('text/plain'); + + const body = await response.text(); + expect(body).toContain('User-agent'); + expect(body).toContain('Disallow: /admin'); + expect(body).toContain('Disallow: /api'); + }); + + test('robots.txt blocks AI crawlers by default', async ({ request }) => { + const response = await request.get('/robots.txt'); + const body = await response.text(); + + // Check for some of the default blocked AI agents + expect(body).toContain('GPTBot'); + expect(body).toContain('Claude-Web'); + expect(body).toContain('Google-Extended'); + }); +}); + +test.describe('SEO Meta Tags', () => { + test('public settings include SEO meta flags', async ({ request }) => { + const response = await request.get('/api/public/settings'); + expect(response.status()).toBe(200); + + const settings = await response.json(); + expect(settings).toHaveProperty('seo_meta_noindex'); + expect(settings).toHaveProperty('seo_meta_nofollow'); + expect(settings).toHaveProperty('seo_meta_noai'); + }); +});