fix: resolve external media dimensions, gallery theme race condition, and add email color customization

- Fix dimension repair for external media by using photoResolver instead of hardcoded paths
- Extract photo dimensions via Sharp during external media import
- Remove duplicate theme useEffect from GalleryView to prevent flash/revert race condition
- Pass event welcome_message to Story layout footer for per-event customization
- Add email_primary_color/email_secondary_color settings with admin UI color pickers
- Add i18n keys for email branding in all 4 locales (en, de, ru, pt)
This commit is contained in:
Paul Nothaft
2026-03-16 22:22:36 +01:00
parent 088de43f09
commit bbeedd1888
13 changed files with 220 additions and 92 deletions
+15
View File
@@ -5,6 +5,7 @@ const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { list, resolveExternalPath, getExternalMediaRoot } = require('../services/externalMediaService');
const { db, logActivity } = require('../database/db');
const sharp = require('sharp');
const logger = require('../utils/logger');
const router = express.Router();
@@ -108,6 +109,18 @@ router.post('/events/:id/import-external', adminAuth, requirePermission('photos.
.first();
if (exists) { skipped++; continue; }
const stats = await fs.stat(f.full);
// Extract dimensions via Sharp
let width = null;
let height = null;
try {
const metadata = await sharp(f.full).metadata();
width = metadata.width || null;
height = metadata.height || null;
} catch (dimErr) {
logger.warn(`Could not extract dimensions for ${f.rel}: ${dimErr.message}`);
}
const inserted = await db('photos')
.insert({
event_id: eventId,
@@ -117,6 +130,8 @@ router.post('/events/:id/import-external', adminAuth, requirePermission('photos.
thumbnail_path: null,
type,
size_bytes: stats.size,
width,
height,
source_origin: 'external',
external_relpath: f.rel
})
+16 -12
View File
@@ -3,11 +3,10 @@ const router = express.Router();
const { db } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const path = require('path');
const fs = require('fs').promises;
const logger = require('../utils/logger');
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const { resolvePhotoFilePath } = require('../services/photoResolver');
// Module-level progress state
let repairProgress = {
@@ -23,13 +22,18 @@ router.post('/repair-dimensions', adminAuth, requirePermission('photos.edit'), a
}
const photos = await db('photos')
.join('events', 'photos.event_id', 'events.id')
.where(function () {
this.whereNull('width').orWhereNull('height');
this.whereNull('photos.width').orWhereNull('photos.height');
})
.where(function () {
this.where('media_type', '!=', 'video').orWhereNull('media_type');
this.where('photos.media_type', '!=', 'video').orWhereNull('photos.media_type');
})
.select('id', 'path', 'filename');
.select(
'photos.id', 'photos.path', 'photos.filename',
'photos.source_origin', 'photos.external_relpath', 'photos.event_id',
'events.source_mode', 'events.external_path', 'events.slug'
);
if (photos.length === 0) {
return res.json({ message: 'No photos need dimension repair', count: 0 });
@@ -61,15 +65,16 @@ router.post('/repair-dimensions', adminAuth, requirePermission('photos.edit'), a
for (const photo of photos) {
try {
if (!photo.path) {
logger.warn(`Photo ${photo.id} has no path, skipping dimension repair`);
const event = { source_mode: photo.source_mode, external_path: photo.external_path, slug: photo.slug };
let fullPath;
try {
fullPath = resolvePhotoFilePath(event, photo);
} catch (err) {
logger.warn(`Photo ${photo.id} has no resolvable path, skipping dimension repair: ${err.message}`);
errorCount++;
continue;
}
const storagePath = getStoragePath();
const fullPath = path.join(storagePath, 'events/active', photo.path);
try {
await fs.access(fullPath);
} catch (err) {
@@ -85,8 +90,7 @@ router.post('/repair-dimensions', adminAuth, requirePermission('photos.edit'), a
.where({ id: photo.id })
.update({
width: metadata.width,
height: metadata.height,
updated_at: db.fn.now()
height: metadata.height
});
successCount++;
+34 -21
View File
@@ -111,35 +111,48 @@ async function getRecipientLanguage(email, eventId = null) {
return 'en'; // Default to English
}
// Darken a hex color by a percentage (0-1)
function darkenColor(hex, amount = 0.15) {
const num = parseInt(hex.replace('#', ''), 16);
const r = Math.max(0, Math.min(255, ((num >> 16) & 0xFF) * (1 - amount)));
const g = Math.max(0, Math.min(255, ((num >> 8) & 0xFF) * (1 - amount)));
const b = Math.max(0, Math.min(255, (num & 0xFF) * (1 - amount)));
return `#${(1 << 24 | Math.round(r) << 16 | Math.round(g) << 8 | Math.round(b)).toString(16).slice(1)}`;
}
// Wrap HTML body in the styled email template with header, footer, and logo
async function wrapEmailHtml(htmlBody, subject, language = 'en') {
// Get branding settings for logo
// Get branding settings for logo and email colors
let logoUrl = '';
let companyName = 'PicPeak';
let primaryColor = '#5C8762';
let secondaryColor = '#f9f9f9';
try {
const brandingSettings = await db('app_settings')
.whereIn('setting_key', ['branding_logo_url', 'branding_company_name'])
.whereIn('setting_key', [
'branding_logo_url', 'branding_company_name',
'email_primary_color', 'email_secondary_color'
])
.select('setting_key', 'setting_value');
brandingSettings.forEach(setting => {
if (setting.setting_key === 'branding_logo_url' && setting.setting_value) {
try {
logoUrl = JSON.parse(setting.setting_value);
} catch (e) {
logoUrl = setting.setting_value;
}
} else if (setting.setting_key === 'branding_company_name' && setting.setting_value) {
try {
companyName = JSON.parse(setting.setting_value);
} catch (e) {
companyName = setting.setting_value;
}
const val = setting.setting_value;
if (setting.setting_key === 'branding_logo_url' && val) {
try { logoUrl = JSON.parse(val); } catch (e) { logoUrl = val; }
} else if (setting.setting_key === 'branding_company_name' && val) {
try { companyName = JSON.parse(val); } catch (e) { companyName = val; }
} else if (setting.setting_key === 'email_primary_color' && val) {
try { primaryColor = JSON.parse(val); } catch (e) { primaryColor = val; }
} else if (setting.setting_key === 'email_secondary_color' && val) {
try { secondaryColor = JSON.parse(val); } catch (e) { secondaryColor = val; }
}
});
} catch (error) {
logger.error('Error fetching branding settings:', error);
}
const hoverColor = darkenColor(primaryColor, 0.15);
// If no custom logo, use default PicPeak logo
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000';
const logoFullUrl = `${frontendUrl}${logoUrl || '/picpeak-logo-transparent.png'}`;
@@ -172,7 +185,7 @@ async function wrapEmailHtml(htmlBody, subject, language = 'en') {
overflow: hidden;
}
.email-header {
background-color: #5C8762;
background-color: ${primaryColor};
padding: 30px;
text-align: center;
}
@@ -185,7 +198,7 @@ async function wrapEmailHtml(htmlBody, subject, language = 'en') {
padding: 40px 30px;
}
.email-content h2 {
color: #5C8762;
color: ${primaryColor};
margin-top: 0;
margin-bottom: 20px;
font-size: 24px;
@@ -206,7 +219,7 @@ async function wrapEmailHtml(htmlBody, subject, language = 'en') {
.button {
display: inline-block;
padding: 12px 30px;
background-color: #5C8762;
background-color: ${primaryColor};
color: white !important;
text-decoration: none;
border-radius: 5px;
@@ -214,10 +227,10 @@ async function wrapEmailHtml(htmlBody, subject, language = 'en') {
margin: 20px 0;
}
.button:hover {
background-color: #4a6f4f;
background-color: ${hoverColor};
}
.email-footer {
background-color: #f9f9f9;
background-color: ${secondaryColor};
padding: 30px;
text-align: center;
border-top: 1px solid #eee;
@@ -234,11 +247,11 @@ async function wrapEmailHtml(htmlBody, subject, language = 'en') {
margin: 5px 0;
}
a {
color: #5C8762;
color: ${primaryColor};
text-decoration: underline;
}
a:hover {
color: #4a6f4f;
color: ${hoverColor};
}
strong {
color: #333;