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;
@@ -16,7 +16,6 @@ import { UserPhotoUpload } from './UserPhotoUpload';
import type { FilterType } from './GalleryFilter';
import { analyticsService } from '../../services/analytics.service';
import { useDevToolsProtection } from '../../hooks/useDevToolsProtection';
import { GALLERY_THEME_PRESETS } from '../../types/theme.types';
import { api } from '../../config/api';
import { Upload, Menu } from 'lucide-react';
import { galleryService } from '../../services/gallery.service';
@@ -44,7 +43,7 @@ interface GalleryViewProps {
export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
const { t } = useTranslation();
const { logout } = useGalleryAuth();
const { setTheme, theme } = useTheme();
const { theme } = useTheme();
const [selectedCategoryId, setSelectedCategoryId] = useState<number | string | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [sortBy, setSortBy] = useState<'date' | 'name' | 'size' | 'rating' | 'capture_date'>('date');
@@ -275,62 +274,6 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
setStaticHeroPhoto(defaultHeroPhoto);
}, [selectedCategoryId, data?.categories, data?.photos, defaultHeroPhoto]);
// Apply theme when settings are loaded
useEffect(() => {
if (settingsData && data?.event) {
let themeToApply = null;
const fullEvent = data.event; // Use the full event data from API
if (fullEvent.color_theme) {
try {
// Check if it's a valid JSON string
if (fullEvent.color_theme.startsWith('{')) {
const eventTheme = JSON.parse(fullEvent.color_theme);
themeToApply = eventTheme;
} else {
// Handle legacy theme names - check if it's a preset
const preset = GALLERY_THEME_PRESETS[fullEvent.color_theme];
if (preset) {
themeToApply = preset.config;
} else {
// Unknown theme name, fall back to global theme
if (settingsData.theme_config) {
themeToApply = settingsData.theme_config;
}
}
}
} catch {
// Invalid theme format - use default
// Fall back to global theme
if (settingsData.theme_config) {
themeToApply = settingsData.theme_config;
}
}
} else if (settingsData.theme_config) {
// No event theme, use global theme
themeToApply = settingsData.theme_config;
}
// Apply theme with a small delay to ensure it overrides any global theme
if (themeToApply) {
// Use setTimeout to ensure this runs after any global theme application
const timer = setTimeout(() => {
// If there's a hero photo, add it to gallery settings
if (fullEvent.hero_photo_id && themeToApply.gallerySettings) {
themeToApply.gallerySettings.heroImageId = fullEvent.hero_photo_id;
// Apply hero photo ID to existing gallery settings
} else if (fullEvent.hero_photo_id) {
themeToApply.gallerySettings = { heroImageId: fullEvent.hero_photo_id };
// Create gallery settings with hero photo ID
}
setTheme(themeToApply);
}, 0);
return () => clearTimeout(timer);
}
}
}, [settingsData, data, setTheme]); // Use data instead of event prop
// Calculate days until expiration (null means never expires)
const daysUntilExpiration = event.expires_at
? differenceInDays(parseISO(event.expires_at), new Date())
@@ -619,6 +562,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
headerStyle={data?.event?.header_style || theme.headerStyle}
heroDividerStyle={data?.event?.hero_divider_style || theme.heroDividerStyle || 'wave'}
heroImageAnchor={data?.event?.hero_image_anchor || 'center'}
welcomeMessage={event.welcome_message}
onLogout={logout}
/>
@@ -794,6 +738,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
headerStyle={data?.event?.header_style || theme.headerStyle}
heroDividerStyle={data?.event?.hero_divider_style || theme.heroDividerStyle || 'wave'}
heroImageAnchor={data?.event?.hero_image_anchor || 'center'}
welcomeMessage={event.welcome_message}
/>
</div>
@@ -64,6 +64,8 @@ interface PhotoGridWithLayoutsProps {
heroDividerStyle?: HeroDividerStyle;
// Hero image anchor position (#162) keyword or "X% Y%" focal point
heroImageAnchor?: string;
// Welcome message (per-event) for layouts that display it
welcomeMessage?: string;
// Logout callback for full-page layouts
onLogout?: () => void;
}
@@ -97,6 +99,7 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
headerStyle,
heroDividerStyle = 'wave',
heroImageAnchor = 'center',
welcomeMessage,
onLogout
}) => {
const { t } = useTranslation();
@@ -226,6 +229,7 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
heroLogoVisible,
heroLogoSize,
heroLogoPosition,
welcomeMessage,
onLogout,
};
@@ -34,6 +34,7 @@ interface CategoryScene {
interface GalleryStoryLayoutProps extends BaseGalleryLayoutProps {
heroPhotoOverride?: Photo | null;
welcomeMessage?: string;
}
export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
@@ -55,6 +56,7 @@ export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
feedbackEnabled = false,
feedbackOptions,
heroPhotoOverride,
welcomeMessage,
onLogout
}) => {
// These props are passed by parent but we use our own feedback system, so mark as intentionally unused
@@ -348,7 +350,7 @@ export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
<footer className="story-footer">
<h2 className="story-footer-title">{t('gallery.thankYou', 'Thank You')}</h2>
<p className="story-footer-text">
{t('gallery.thankYouMessage', 'For being part of our story and making our special day unforgettable.')}
{welcomeMessage || t('gallery.thankYouMessage', 'For being part of our story and making our special day unforgettable.')}
</p>
{allowDownloads && (
<button className="story-footer-btn" onClick={handleDownloadAll}>
+7
View File
@@ -1963,6 +1963,13 @@
"required": "erforderlich",
"ignoreSslErrors": "SSL/TLS-Zertifikatfehler ignorieren",
"ignoreSslWarning": "Warnung: Das Deaktivieren der Zertifikatüberprüfung macht die Verbindung anfällig für Man-in-the-Middle-Angriffe. Aktivieren Sie dies nur, wenn Sie dem SMTP-Server vertrauen und die Sicherheitsrisiken verstehen.",
"brandingTitle": "E-Mail-Branding",
"brandingDescription": "Passen Sie die Farben in E-Mail-Vorlagen an. Änderungen gelten für Kopfzeile, Schaltflächen, Links und Fußzeilen-Hintergrund.",
"primaryColor": "Primärfarbe",
"primaryColorHint": "Wird für Kopfzeile, Schaltflächen und Links verwendet",
"secondaryColor": "Fußzeilen-Hintergrund",
"secondaryColorHint": "Wird für den Hintergrund der Fußzeile verwendet",
"saveEmailColors": "E-Mail-Farben speichern",
"editor": {
"bold": "Fett",
"italic": "Kursiv",
+7
View File
@@ -1660,6 +1660,13 @@
"required": "required",
"ignoreSslErrors": "Ignore SSL/TLS certificate errors",
"ignoreSslWarning": "Warning: Disabling certificate verification makes the connection vulnerable to man-in-the-middle attacks. Only enable this if you trust the SMTP server and understand the security implications.",
"brandingTitle": "Email Branding",
"brandingDescription": "Customize the colors used in email templates. Changes apply to the header bar, buttons, links, and footer background.",
"primaryColor": "Primary Color",
"primaryColorHint": "Used for header, buttons, and links",
"secondaryColor": "Footer Background",
"secondaryColorHint": "Used for footer section background",
"saveEmailColors": "Save Email Colors",
"editor": {
"bold": "Bold",
"italic": "Italic",
+7
View File
@@ -1636,6 +1636,13 @@
"required": "obrigatorio",
"ignoreSslErrors": "Ignorar erros de certificado SSL/TLS",
"ignoreSslWarning": "Aviso: Desabilitar verificacao de certificado torna a conexao vulneravel a ataques man-in-the-middle. Ative somente se voce confia no servidor SMTP e entende as implicacoes de seguranca.",
"brandingTitle": "Identidade Visual do E-mail",
"brandingDescription": "Personalize as cores dos modelos de e-mail. As alteracoes se aplicam ao cabecalho, botoes, links e fundo do rodape.",
"primaryColor": "Cor Primaria",
"primaryColorHint": "Usada no cabecalho, botoes e links",
"secondaryColor": "Fundo do Rodape",
"secondaryColorHint": "Usada no fundo da secao de rodape",
"saveEmailColors": "Salvar Cores do E-mail",
"editor": {
"bold": "Negrito",
"italic": "Italico",
+7
View File
@@ -1636,6 +1636,13 @@
"required": "обязательно",
"ignoreSslErrors": "Игнорировать ошибки SSL/TLS-сертификата",
"ignoreSslWarning": "Предупреждение: Отключение проверки сертификата делает соединение уязвимым для атак «человек посередине». Включайте только если доверяете SMTP-серверу и понимаете риски.",
"brandingTitle": "Оформление писем",
"brandingDescription": "Настройте цвета в шаблонах писем. Изменения применяются к шапке, кнопкам, ссылкам и фону подвала.",
"primaryColor": "Основной цвет",
"primaryColorHint": "Используется для шапки, кнопок и ссылок",
"secondaryColor": "Фон подвала",
"secondaryColorHint": "Используется для фона подвала письма",
"saveEmailColors": "Сохранить цвета",
"editor": {
"bold": "Жирный",
"italic": "Курсив",
+9
View File
@@ -156,6 +156,15 @@ export const GalleryPage: React.FC = () => {
themeToApply = settingsData.theme_config;
}
// Inject hero photo ID into theme gallery settings
if (themeToApply && galleryInfo.hero_photo_id) {
if (themeToApply.gallerySettings) {
themeToApply.gallerySettings.heroImageId = galleryInfo.hero_photo_id;
} else {
themeToApply.gallerySettings = { heroImageId: galleryInfo.hero_photo_id };
}
}
// Apply theme
if (themeToApply) {
setTheme(themeToApply);
@@ -17,8 +17,10 @@ import { toast } from 'react-toastify';
import { Button, Input, Card, Loading } from '../../components/common';
import { EmailPreviewModal } from '../../components/admin/EmailPreviewModal';
import { EmailTemplateEditor } from '../../components/admin/EmailTemplateEditor';
import { Palette } from 'lucide-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { emailService, type EmailConfig, type EmailTemplate } from '../../services/email.service';
import { settingsService } from '../../services/settings.service';
import { useTranslation } from 'react-i18next';
const defaultTemplateKeys = [
@@ -99,6 +101,8 @@ export const EmailConfigPage: React.FC = () => {
htmlContent: '',
textContent: ''
});
const [emailPrimaryColor, setEmailPrimaryColor] = useState('#5C8762');
const [emailSecondaryColor, setEmailSecondaryColor] = useState('#f9f9f9');
const queryClient = useQueryClient();
// SMTP Configuration state
@@ -119,6 +123,19 @@ export const EmailConfigPage: React.FC = () => {
queryFn: () => emailService.getConfig(),
});
// Fetch email branding colors from app settings
const { data: allSettings } = useQuery({
queryKey: ['admin-settings'],
queryFn: () => settingsService.getAllSettings(),
});
React.useEffect(() => {
if (allSettings) {
if (allSettings.email_primary_color) setEmailPrimaryColor(allSettings.email_primary_color);
if (allSettings.email_secondary_color) setEmailSecondaryColor(allSettings.email_secondary_color);
}
}, [allSettings]);
// Fetch email templates
const { data: templates = [], isLoading: templatesLoading } = useQuery({
queryKey: ['email-templates'],
@@ -186,6 +203,25 @@ export const EmailConfigPage: React.FC = () => {
}
});
const saveEmailColorsMutation = useMutation({
mutationFn: (colors: { email_primary_color: string; email_secondary_color: string }) =>
settingsService.updateSettings(colors),
onSuccess: () => {
toast.success(t('toast.saveSuccess'));
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
},
onError: () => {
toast.error(t('toast.saveError'));
}
});
const handleSaveEmailColors = () => {
saveEmailColorsMutation.mutate({
email_primary_color: emailPrimaryColor,
email_secondary_color: emailSecondaryColor,
});
};
const handleSaveSmtp = () => {
// Validate SMTP config
if (!smtpConfig.smtp_host || !smtpConfig.smtp_port || !smtpConfig.from_email) {
@@ -498,6 +534,76 @@ export const EmailConfigPage: React.FC = () => {
</div>
)}
{/* Email Branding - below SMTP settings */}
{activeTab === 'smtp' && (
<div className="mt-6">
<Card padding="md">
<div className="flex items-center gap-2 mb-4">
<Palette className="w-5 h-5 text-neutral-500" />
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">{t('email.brandingTitle')}</h2>
</div>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-6">{t('email.brandingDescription')}</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('email.primaryColor')}
</label>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-2">{t('email.primaryColorHint')}</p>
<div className="flex items-center gap-3">
<input
type="color"
value={emailPrimaryColor}
onChange={(e) => setEmailPrimaryColor(e.target.value)}
className="w-10 h-10 rounded border border-neutral-300 dark:border-neutral-600 cursor-pointer"
/>
<Input
type="text"
value={emailPrimaryColor}
onChange={(e) => setEmailPrimaryColor(e.target.value)}
className="w-32"
placeholder="#5C8762"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('email.secondaryColor')}
</label>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-2">{t('email.secondaryColorHint')}</p>
<div className="flex items-center gap-3">
<input
type="color"
value={emailSecondaryColor}
onChange={(e) => setEmailSecondaryColor(e.target.value)}
className="w-10 h-10 rounded border border-neutral-300 dark:border-neutral-600 cursor-pointer"
/>
<Input
type="text"
value={emailSecondaryColor}
onChange={(e) => setEmailSecondaryColor(e.target.value)}
className="w-32"
placeholder="#f9f9f9"
/>
</div>
</div>
</div>
<div className="mt-6">
<Button
variant="primary"
onClick={handleSaveEmailColors}
isLoading={saveEmailColorsMutation.isPending}
leftIcon={<Save className="w-5 h-5" />}
>
{t('email.saveEmailColors')}
</Button>
</div>
</Card>
</div>
)}
{/* Email Templates Tab */}
{activeTab === 'templates' && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
@@ -242,6 +242,8 @@ export const settingsService = {
endpoint = '/admin/settings/branding';
} else if (firstKey?.startsWith('seo_')) {
endpoint = '/admin/settings/seo';
} else if (firstKey?.startsWith('email_')) {
endpoint = '/admin/settings/general';
}
await api.put(endpoint, settings);