diff --git a/backend/src/routes/adminExternalMedia.js b/backend/src/routes/adminExternalMedia.js index f5b502d9..d88a0765 100644 --- a/backend/src/routes/adminExternalMedia.js +++ b/backend/src/routes/adminExternalMedia.js @@ -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 }) diff --git a/backend/src/routes/adminPhotoDimensions.js b/backend/src/routes/adminPhotoDimensions.js index 74c7be9f..0c630a0d 100644 --- a/backend/src/routes/adminPhotoDimensions.js +++ b/backend/src/routes/adminPhotoDimensions.js @@ -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++; diff --git a/backend/src/services/emailProcessor.js b/backend/src/services/emailProcessor.js index f978ab14..09e3d878 100644 --- a/backend/src/services/emailProcessor.js +++ b/backend/src/services/emailProcessor.js @@ -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; diff --git a/frontend/src/components/gallery/GalleryView.tsx b/frontend/src/components/gallery/GalleryView.tsx index 586aa405..854528d1 100644 --- a/frontend/src/components/gallery/GalleryView.tsx +++ b/frontend/src/components/gallery/GalleryView.tsx @@ -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 = ({ slug, event }) => { const { t } = useTranslation(); const { logout } = useGalleryAuth(); - const { setTheme, theme } = useTheme(); + const { theme } = useTheme(); const [selectedCategoryId, setSelectedCategoryId] = useState(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 = ({ 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 = ({ 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 = ({ 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} /> diff --git a/frontend/src/components/gallery/PhotoGridWithLayouts.tsx b/frontend/src/components/gallery/PhotoGridWithLayouts.tsx index db1f1be4..3e7667ef 100644 --- a/frontend/src/components/gallery/PhotoGridWithLayouts.tsx +++ b/frontend/src/components/gallery/PhotoGridWithLayouts.tsx @@ -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 = ({ headerStyle, heroDividerStyle = 'wave', heroImageAnchor = 'center', + welcomeMessage, onLogout }) => { const { t } = useTranslation(); @@ -226,6 +229,7 @@ export const PhotoGridWithLayouts: React.FC = ({ heroLogoVisible, heroLogoSize, heroLogoPosition, + welcomeMessage, onLogout, }; diff --git a/frontend/src/components/gallery/layouts/GalleryStoryLayout.tsx b/frontend/src/components/gallery/layouts/GalleryStoryLayout.tsx index cec74677..f369fe73 100644 --- a/frontend/src/components/gallery/layouts/GalleryStoryLayout.tsx +++ b/frontend/src/components/gallery/layouts/GalleryStoryLayout.tsx @@ -34,6 +34,7 @@ interface CategoryScene { interface GalleryStoryLayoutProps extends BaseGalleryLayoutProps { heroPhotoOverride?: Photo | null; + welcomeMessage?: string; } export const GalleryStoryLayout: React.FC = ({ @@ -55,6 +56,7 @@ export const GalleryStoryLayout: React.FC = ({ 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 = ({