From fa4c83812d87cfa63394e51186e320a072929d37 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Mon, 2 Feb 2026 22:55:48 +0100 Subject: [PATCH 1/5] fix: improve photo serving, category filters, and upload chunking (#155, #156, #161) - Add try-catch and file existence check for photo path resolution (#161) - Fix gallery categories to use photo_categories table instead of legacy type field (#156) - Add byte-size-based chunking (500MB max) for uploads to prevent oversized batches (#155) --- backend/src/routes/gallery.js | 78 ++++++++++++++----- frontend/src/components/admin/PhotoUpload.tsx | 30 +++++-- 2 files changed, 82 insertions(+), 26 deletions(-) diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js index 1adb092d..5241f9c9 100644 --- a/backend/src/routes/gallery.js +++ b/backend/src/routes/gallery.js @@ -294,20 +294,35 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => { commentMap[c.photo_id] = parseInt(c.comment_count); }); - // Get distinct photo types for this event - const categoryResults = await db('photos') + // Get actual categories used by photos in this event + // This includes both global categories and event-specific ones + const usedCategoryIds = await db('photos') .where('event_id', req.event.id) - .select('type') - .distinct('type') - .orderBy('type', 'asc'); - - // Convert types to category-like objects - const categories = categoryResults.map(result => ({ - id: result.type, - name: result.type === 'individual' ? 'Individual Photos' : 'Collages', - slug: result.type, - is_global: false - })); + .whereNotNull('category_id') + .distinct('category_id') + .pluck('category_id'); + + // Fetch category details from photo_categories table + let categories = []; + if (usedCategoryIds.length > 0) { + const categoryDetails = await db('photo_categories') + .whereIn('id', usedCategoryIds) + .select('id', 'name', 'slug', 'is_global') + .orderBy('name', 'asc'); + + categories = categoryDetails.map(cat => ({ + id: cat.id, + name: cat.name, + slug: cat.slug, + is_global: cat.is_global + })); + } + + // Build a map for quick category lookup + const categoryMap = {}; + categories.forEach(cat => { + categoryMap[cat.id] = cat; + }); // Log view await db('access_logs').insert({ @@ -369,9 +384,9 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => { secure_url_template: `/api/secure-images/${req.params.slug}/secure/${photo.id}/{{token}}`, download_url_template: `/api/secure-images/${req.params.slug}/secure-download/${photo.id}/{{token}}`, type: photo.type, - category_id: photo.type, - category_name: photo.type === 'individual' ? 'Individual Photos' : 'Collages', - category_slug: photo.type, + category_id: photo.category_id || null, + category_name: photo.category_id && categoryMap[photo.category_id] ? categoryMap[photo.category_id].name : null, + category_slug: photo.category_id && categoryMap[photo.category_id] ? categoryMap[photo.category_id].slug : null, size: photo.size_bytes, uploaded_at: photo.uploaded_at, // Image dimensions for layout calculations @@ -732,8 +747,34 @@ router.get('/:slug/photo/:photoId', // Resolve the absolute file path for this photo, supporting both managed and external reference modes const { resolvePhotoFilePath } = require('../services/photoResolver'); - const filePath = resolvePhotoFilePath(req.event, photo); + const fs = require('fs'); + let filePath; + try { + filePath = resolvePhotoFilePath(req.event, photo); + } catch (resolveError) { + logger.error('Failed to resolve photo path', { + slug: req.params.slug, + photoId, + eventId: req.event.id, + error: resolveError.message, + photoPath: photo.path, + photoFilename: photo.filename + }); + return res.status(404).json({ error: 'Photo file not found' }); + } + + // Verify file exists before attempting to serve + if (!fs.existsSync(filePath)) { + logger.error('Photo file does not exist at resolved path', { + slug: req.params.slug, + photoId, + eventId: req.event.id, + resolvedPath: filePath, + photoPath: photo.path + }); + return res.status(404).json({ error: 'Photo file not found' }); + } // Log access - temporarily disabled for debugging // await secureImageService.logImageAccess( @@ -745,7 +786,6 @@ router.get('/:slug/photo/:photoId', // Handle video streaming with range requests if (isVideo) { - const fs = require('fs'); const stat = fs.statSync(filePath); const fileSize = stat.size; const range = req.headers.range; @@ -789,7 +829,6 @@ router.get('/:slug/photo/:photoId', // Generate ETag based on photo id, modification time, and watermark settings // This ensures cache invalidation when watermark settings change - const fs = require('fs'); const stat = fs.statSync(filePath); const watermarkHash = watermarkSettings?.enabled ? `-wm${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}` @@ -806,7 +845,6 @@ router.get('/:slug/photo/:photoId', if (photo.watermark_path) { const watermarkFilePath = path.join(getStoragePath(), photo.watermark_path); try { - const fs = require('fs'); // Check if pre-generated watermark file exists if (fs.existsSync(watermarkFilePath)) { res.set({ diff --git a/frontend/src/components/admin/PhotoUpload.tsx b/frontend/src/components/admin/PhotoUpload.tsx index 4c4dc315..5ed4221c 100644 --- a/frontend/src/components/admin/PhotoUpload.tsx +++ b/frontend/src/components/admin/PhotoUpload.tsx @@ -96,12 +96,30 @@ export const PhotoUpload: React.FC = ({ eventId, onUploadCompl setIsUploading(true); setUploadProgress(0); - // For large uploads, chunk the files to prevent memory issues - const CHUNK_SIZE = Math.max(1, Math.min(50, maxFilesPerUpload)); // Upload up to 50 (or limit) files at a time - const chunks = []; - - for (let i = 0; i < selectedFiles.length; i += CHUNK_SIZE) { - chunks.push(selectedFiles.slice(i, i + CHUNK_SIZE)); + // For large uploads, chunk the files by both count AND size to prevent memory/network issues + const MAX_FILES_PER_CHUNK = Math.max(1, Math.min(50, maxFilesPerUpload)); // Max 50 files per chunk + const MAX_BYTES_PER_CHUNK = 500 * 1024 * 1024; // Max 500MB per chunk (nginx limit is 1GB) + const chunks: File[][] = []; + + let currentChunk: File[] = []; + let currentChunkSize = 0; + + for (const file of selectedFiles) { + // Start a new chunk if adding this file would exceed limits + if (currentChunk.length >= MAX_FILES_PER_CHUNK || + (currentChunkSize + file.size > MAX_BYTES_PER_CHUNK && currentChunk.length > 0)) { + chunks.push(currentChunk); + currentChunk = []; + currentChunkSize = 0; + } + + currentChunk.push(file); + currentChunkSize += file.size; + } + + // Don't forget the last chunk + if (currentChunk.length > 0) { + chunks.push(currentChunk); } setTotalChunks(chunks.length); From f554f463b3492346dba067c0980b52ef42dd5e70 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Mon, 2 Feb 2026 23:09:36 +0100 Subject: [PATCH 2/5] fix: hero header state and preview in admin theme editor (#158) - Add hero header rendering to GalleryPreview component with divider styles - Support event-specific header_style prop in GalleryLayout - Pass header_style from event data to GalleryLayout in GalleryView - Divider options now properly show/hide when switching header styles This ensures the live preview accurately reflects hero header changes and event-specific header styles are respected in the gallery view. --- .../src/components/admin/GalleryPreview.tsx | 163 ++++++++++++++---- .../src/components/gallery/GalleryLayout.tsx | 6 +- .../src/components/gallery/GalleryView.tsx | 1 + 3 files changed, 131 insertions(+), 39 deletions(-) diff --git a/frontend/src/components/admin/GalleryPreview.tsx b/frontend/src/components/admin/GalleryPreview.tsx index ac7d4c71..b2fa2e92 100644 --- a/frontend/src/components/admin/GalleryPreview.tsx +++ b/frontend/src/components/admin/GalleryPreview.tsx @@ -1,6 +1,6 @@ import React, { useMemo } from 'react'; -import { Camera } from 'lucide-react'; -import { ThemeConfig, GalleryLayoutType } from '../../types/theme.types'; +import { Camera, Calendar } from 'lucide-react'; +import { ThemeConfig, GalleryLayoutType, HeroDividerStyle } from '../../types/theme.types'; import { buildResourceUrl } from '../../utils/url'; interface GalleryPreviewBranding { @@ -92,6 +92,39 @@ export const GalleryPreview: React.FC = ({ ? 'justify-end text-right flex-row-reverse' : 'justify-start text-left'; + // Check if hero header style is selected + const isHeroHeader = theme.headerStyle === 'hero'; + const heroDividerStyle: HeroDividerStyle = theme.heroDividerStyle || 'wave'; + + // Render hero divider based on style + const renderHeroDivider = () => { + const bgColor = theme.backgroundColor || '#fafafa'; + switch (heroDividerStyle) { + case 'wave': + return ( + + + + ); + case 'curve': + return ( + + + + ); + case 'angle': + return ( + + + + ); + case 'straight': + case 'none': + default: + return null; + } + }; + const renderLayout = () => { const spacing = theme.gallerySettings?.spacing || 'normal'; const gapClass = spacing === 'tight' ? 'gap-1' : spacing === 'relaxed' ? 'gap-4' : 'gap-2'; @@ -169,7 +202,7 @@ export const GalleryPreview: React.FC = ({ }; return ( -
= ({ fontFamily: theme.fontFamily || 'Inter, sans-serif', }} > - {/* Preview Header */} -
-
- {showLogo && ( - resolvedLogoUrl ? ( - {brandName} - ) : ( -
- -
- ) - )} - {showText && ( -
-

{brandName}

- {brandTagline && ( -

{brandTagline}

+ {/* Hero Header - shown when headerStyle is 'hero' */} + {isHeroHeader && ( +
+
+
+ {/* Logo in Hero */} + {showLogo && ( +
+ {resolvedLogoUrl ? ( + {brandName} + ) : ( +
+ +
+ )} +
)} + {/* Event Name */} +

+ Sample Event +

+ {/* Event Date */} +
+ + January 15, 2026 +
- )} - {!showLogo && !showText && ( -

{brandName}

- )} +
+ {/* Divider */} +
+ {renderHeroDivider()} +
-
- Gallery preview - {activeLayout} layout + )} + + {/* Standard Header - shown when headerStyle is NOT 'hero' */} + {!isHeroHeader && ( +
+
+ {showLogo && ( + resolvedLogoUrl ? ( + {brandName} + ) : ( +
+ +
+ ) + )} + {showText && ( +
+

{brandName}

+ {brandTagline && ( +

{brandTagline}

+ )} +
+ )} + {!showLogo && !showText && ( +

{brandName}

+ )} +
+ )} + + {/* Layout info bar */} +
+ Gallery preview + {isHeroHeader ? `Hero + ${activeLayout}` : `${activeLayout} layout`}
- + {/* Preview Content */}
{renderLayout()} diff --git a/frontend/src/components/gallery/GalleryLayout.tsx b/frontend/src/components/gallery/GalleryLayout.tsx index efa4bd16..d9d5ec3d 100644 --- a/frontend/src/components/gallery/GalleryLayout.tsx +++ b/frontend/src/components/gallery/GalleryLayout.tsx @@ -39,6 +39,7 @@ interface GalleryLayoutProps { isDownloading?: boolean; headerExtra?: React.ReactNode; menuButton?: React.ReactNode; + headerStyle?: HeaderStyleType; children: React.ReactNode; } @@ -52,14 +53,15 @@ export const GalleryLayout: React.FC = ({ isDownloading = false, headerExtra, menuButton, + headerStyle: headerStyleProp, children, }) => { const { t } = useTranslation(); const { format } = useLocalizedDate(); const { theme } = useTheme(); - // Determine header style - check theme.headerStyle first, then fall back to legacy behavior - const headerStyle: HeaderStyleType = theme.headerStyle || 'standard'; + // Determine header style - use prop first (from event data), then theme, then fall back to 'standard' + const headerStyle: HeaderStyleType = headerStyleProp || theme.headerStyle || 'standard'; const isHeroHeader = headerStyle === 'hero'; // Non-grid layouts that need the sidebar (excluding layouts using hero header) diff --git a/frontend/src/components/gallery/GalleryView.tsx b/frontend/src/components/gallery/GalleryView.tsx index 0083f2f4..c71d19e0 100644 --- a/frontend/src/components/gallery/GalleryView.tsx +++ b/frontend/src/components/gallery/GalleryView.tsx @@ -584,6 +584,7 @@ export const GalleryView: React.FC = ({ slug, event }) => { Date: Tue, 3 Feb 2026 10:08:58 +0100 Subject: [PATCH 3/5] feat: add hero image focal point picker with anchor positioning (#162) Add interactive focal point picker for hero images, allowing precise crop positioning via click or preset buttons (top/center/bottom). Includes backend validation, migrations, and gallery rendering support. --- .../066_add_hero_anchor_and_category_hero.js | 48 ++++++++ .../core/067_expand_hero_image_anchor.js | 32 +++++ backend/src/routes/adminEvents.js | 29 ++++- backend/src/routes/gallery.js | 7 +- .../src/components/admin/FocalPointPicker.tsx | 115 ++++++++++++++++++ frontend/src/components/admin/index.ts | 1 + .../src/components/gallery/GalleryView.tsx | 1 + .../src/components/gallery/HeroHeader.tsx | 6 +- .../gallery/PhotoGridWithLayouts.tsx | 6 +- frontend/src/pages/admin/EventDetailsPage.tsx | 33 ++++- frontend/src/types/index.ts | 4 + 11 files changed, 273 insertions(+), 9 deletions(-) create mode 100644 backend/migrations/core/066_add_hero_anchor_and_category_hero.js create mode 100644 backend/migrations/core/067_expand_hero_image_anchor.js create mode 100644 frontend/src/components/admin/FocalPointPicker.tsx diff --git a/backend/migrations/core/066_add_hero_anchor_and_category_hero.js b/backend/migrations/core/066_add_hero_anchor_and_category_hero.js new file mode 100644 index 00000000..57ededa8 --- /dev/null +++ b/backend/migrations/core/066_add_hero_anchor_and_category_hero.js @@ -0,0 +1,48 @@ +/** + * Migration: Add hero image anchor position and category-specific hero images + * + * Issue #162: Add hero_image_anchor column to events table for controlling + * how hero images are cropped (top/center/bottom) + * + * Issue #163: Add hero_photo_id column to photo_categories table for + * category-specific hero images + */ + +exports.up = async function(knex) { + // Add hero_image_anchor to events table (Issue #162) + const hasHeroAnchor = await knex.schema.hasColumn('events', 'hero_image_anchor'); + if (!hasHeroAnchor) { + await knex.schema.alterTable('events', function(table) { + // Values: 'top', 'center', 'bottom' - defaults to 'center' for backward compatibility + table.string('hero_image_anchor', 10).defaultTo('center'); + }); + console.log('Added hero_image_anchor column to events table'); + } + + // Add hero_photo_id to photo_categories table (Issue #163) + const hasCategoryHero = await knex.schema.hasColumn('photo_categories', 'hero_photo_id'); + if (!hasCategoryHero) { + await knex.schema.alterTable('photo_categories', function(table) { + table.integer('hero_photo_id').references('id').inTable('photos').onDelete('SET NULL'); + }); + console.log('Added hero_photo_id column to photo_categories table'); + } +}; + +exports.down = async function(knex) { + // Remove hero_image_anchor from events table + const hasHeroAnchor = await knex.schema.hasColumn('events', 'hero_image_anchor'); + if (hasHeroAnchor) { + await knex.schema.alterTable('events', function(table) { + table.dropColumn('hero_image_anchor'); + }); + } + + // Remove hero_photo_id from photo_categories table + const hasCategoryHero = await knex.schema.hasColumn('photo_categories', 'hero_photo_id'); + if (hasCategoryHero) { + await knex.schema.alterTable('photo_categories', function(table) { + table.dropColumn('hero_photo_id'); + }); + } +}; diff --git a/backend/migrations/core/067_expand_hero_image_anchor.js b/backend/migrations/core/067_expand_hero_image_anchor.js new file mode 100644 index 00000000..ef496949 --- /dev/null +++ b/backend/migrations/core/067_expand_hero_image_anchor.js @@ -0,0 +1,32 @@ +/** + * Migration: Expand hero_image_anchor column to support focal point percentages + * + * Changes string(10) to string(20) so values like "100% 100%" (9 chars) fit + * with room to spare. Existing 'top', 'center', 'bottom' values are preserved. + */ + +exports.up = async function(knex) { + const hasColumn = await knex.schema.hasColumn('events', 'hero_image_anchor'); + if (!hasColumn) { + // Column doesn't exist yet – nothing to expand + return; + } + + // SQLite doesn't truly support ALTER COLUMN, but Knex handles the + // rebuild-table strategy internally when we call alterTable. + await knex.schema.alterTable('events', function(table) { + table.string('hero_image_anchor', 20).defaultTo('center').alter(); + }); + console.log('Expanded hero_image_anchor column to string(20)'); +}; + +exports.down = async function(knex) { + const hasColumn = await knex.schema.hasColumn('events', 'hero_image_anchor'); + if (!hasColumn) { + return; + } + + await knex.schema.alterTable('events', function(table) { + table.string('hero_image_anchor', 10).defaultTo('center').alter(); + }); +}; diff --git a/backend/src/routes/adminEvents.js b/backend/src/routes/adminEvents.js index 721f6e37..e0f5a475 100644 --- a/backend/src/routes/adminEvents.js +++ b/backend/src/routes/adminEvents.js @@ -196,7 +196,16 @@ router.post('/', adminAuth, requirePermission('events.create'), [ body('hero_logo_position').optional().isIn(['top', 'center', 'bottom']), // Header style settings (decoupled from layout) body('header_style').optional().isIn(['hero', 'standard', 'minimal', 'none']), - body('hero_divider_style').optional().isIn(['wave', 'straight', 'angle', 'curve', 'none']) + body('hero_divider_style').optional().isIn(['wave', 'straight', 'angle', 'curve', 'none']), + // Hero image anchor position (#162) – accepts legacy keywords or "X% Y%" focal point + body('hero_image_anchor').optional().custom((value) => { + if (['top', 'center', 'bottom'].includes(value)) return true; + if (typeof value === 'string' && /^\d{1,3}%\s+\d{1,3}%$/.test(value)) { + const [x, y] = value.split(/\s+/).map(v => parseInt(v)); + if (x >= 0 && x <= 100 && y >= 0 && y <= 100) return true; + } + throw new Error('Must be top, center, bottom, or "X% Y%" (0-100)'); + }) ], async (req, res) => { try { logger.debug('Create event request body', { body: req.body }); @@ -242,7 +251,9 @@ router.post('/', adminAuth, requirePermission('events.create'), [ hero_logo_position = 'top', // Header style settings header_style = 'standard', - hero_divider_style = 'wave' + hero_divider_style = 'wave', + // Hero image anchor position (#162) + hero_image_anchor = 'center' } = req.body; const customerName = getCustomerNameFromPayload(req.body); @@ -385,7 +396,8 @@ router.post('/', adminAuth, requirePermission('events.create'), [ hero_logo_size: hero_logo_size || 'medium', hero_logo_position: hero_logo_position || 'top', header_style: header_style || 'standard', - hero_divider_style: hero_divider_style || 'wave' + hero_divider_style: hero_divider_style || 'wave', + hero_image_anchor: hero_image_anchor || 'center' }).returning('id'); // Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs) @@ -669,7 +681,16 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), [ body('hero_logo_position').optional().isIn(['top', 'center', 'bottom']), // Header style settings (decoupled from layout) body('header_style').optional().isIn(['hero', 'standard', 'minimal', 'none']), - body('hero_divider_style').optional().isIn(['wave', 'straight', 'angle', 'curve', 'none']) + body('hero_divider_style').optional().isIn(['wave', 'straight', 'angle', 'curve', 'none']), + // Hero image anchor position (#162) – accepts legacy keywords or "X% Y%" focal point + body('hero_image_anchor').optional().custom((value) => { + if (['top', 'center', 'bottom'].includes(value)) return true; + if (typeof value === 'string' && /^\d{1,3}%\s+\d{1,3}%$/.test(value)) { + const [x, y] = value.split(/\s+/).map(v => parseInt(v)); + if (x >= 0 && x <= 100 && y >= 0 && y <= 100) return true; + } + throw new Error('Must be top, center, bottom, or "X% Y%" (0-100)'); + }) ], async (req, res) => { try { const errors = validationResult(req); diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js index 5241f9c9..128c49ff 100644 --- a/backend/src/routes/gallery.js +++ b/backend/src/routes/gallery.js @@ -121,7 +121,8 @@ router.get('/:slug/info', async (req, res) => { 'hero_logo_position', 'hero_logo_url', 'header_style', - 'hero_divider_style' + 'hero_divider_style', + 'hero_image_anchor' ) .first(); @@ -174,7 +175,8 @@ router.get('/:slug/info', async (req, res) => { hero_logo_position: event.hero_logo_position || 'top', hero_logo_url: event.hero_logo_url || null, header_style: event.header_style || 'standard', - hero_divider_style: event.hero_divider_style || 'wave' + hero_divider_style: event.hero_divider_style || 'wave', + hero_image_anchor: event.hero_image_anchor || 'center' }); } catch (error) { console.error('Error fetching gallery info:', error); @@ -365,6 +367,7 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => { hero_logo_url: req.event.hero_logo_url || null, header_style: req.event.header_style || 'standard', hero_divider_style: req.event.hero_divider_style || 'wave', + hero_image_anchor: req.event.hero_image_anchor || 'center', ...protectionSettings }, categories: categories, diff --git a/frontend/src/components/admin/FocalPointPicker.tsx b/frontend/src/components/admin/FocalPointPicker.tsx new file mode 100644 index 00000000..3793a381 --- /dev/null +++ b/frontend/src/components/admin/FocalPointPicker.tsx @@ -0,0 +1,115 @@ +import React, { useRef, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { AuthenticatedImage } from '../common'; + +interface FocalPointPickerProps { + imageUrl: string; + currentValue: string; + onChange: (value: string) => void; + slug?: string; +} + +/** Convert legacy keyword to percentage pair */ +const keywordToPercent = (value: string): string => { + switch (value) { + case 'top': return '50% 0%'; + case 'center': return '50% 50%'; + case 'bottom': return '50% 100%'; + default: return value || '50% 50%'; + } +}; + +/** Parse an anchor value (keyword or "X% Y%") into [x, y] numbers 0-100 */ +const parseAnchor = (value: string): [number, number] => { + const pct = keywordToPercent(value); + const match = pct.match(/^(\d{1,3})%\s+(\d{1,3})%$/); + if (match) return [parseInt(match[1]), parseInt(match[2])]; + return [50, 50]; +}; + +export const FocalPointPicker: React.FC = ({ + imageUrl, + currentValue, + onChange, + slug, +}) => { + const { t } = useTranslation(); + const containerRef = useRef(null); + const [x, y] = parseAnchor(currentValue); + + const handleClick = useCallback( + (e: React.MouseEvent) => { + const rect = containerRef.current?.getBoundingClientRect(); + if (!rect) return; + const px = Math.round(Math.min(100, Math.max(0, ((e.clientX - rect.left) / rect.width) * 100))); + const py = Math.round(Math.min(100, Math.max(0, ((e.clientY - rect.top) / rect.height) * 100))); + onChange(`${px}% ${py}%`); + }, + [onChange], + ); + + const presets: { label: string; value: string }[] = [ + { label: t('events.heroImageAnchorTop', 'Top'), value: '50% 0%' }, + { label: t('events.heroImageAnchorCenter', 'Center'), value: '50% 50%' }, + { label: t('events.heroImageAnchorBottom', 'Bottom'), value: '50% 100%' }, + ]; + + return ( +
+ {/* Clickable image preview */} +
+ + + {/* Crosshair marker */} +
+ {/* Outer ring (dark) for contrast on light areas */} +
+ {/* Inner ring (white) for contrast on dark areas */} +
+ {/* Center dot */} +
+
+
+
+ + {/* Coordinate label */} + + {x}% {y}% + +
+ + {/* Preset buttons */} +
+ {presets.map((p) => ( + + ))} +
+
+ ); +}; + +FocalPointPicker.displayName = 'FocalPointPicker'; diff --git a/frontend/src/components/admin/index.ts b/frontend/src/components/admin/index.ts index ae843017..3f9f3157 100644 --- a/frontend/src/components/admin/index.ts +++ b/frontend/src/components/admin/index.ts @@ -22,6 +22,7 @@ export { ThemeCustomizerEnhanced } from './ThemeCustomizerEnhanced'; export { ThemeDisplay } from './ThemeDisplay'; export { ThemeEditorModal } from './ThemeEditorModal'; export { HeroPhotoSelector } from './HeroPhotoSelector'; +export { FocalPointPicker } from './FocalPointPicker'; export { PhotoUploadModal } from './PhotoUploadModal'; export { GalleryPreview } from './GalleryPreview'; export { BackupDashboard } from './BackupDashboard'; diff --git a/frontend/src/components/gallery/GalleryView.tsx b/frontend/src/components/gallery/GalleryView.tsx index c71d19e0..3d9fb759 100644 --- a/frontend/src/components/gallery/GalleryView.tsx +++ b/frontend/src/components/gallery/GalleryView.tsx @@ -697,6 +697,7 @@ export const GalleryView: React.FC = ({ slug, event }) => { heroLogoPosition={data?.event?.hero_logo_position || 'top'} headerStyle={data?.event?.header_style || theme.headerStyle} heroDividerStyle={data?.event?.hero_divider_style || theme.heroDividerStyle || 'wave'} + heroImageAnchor={data?.event?.hero_image_anchor || 'center'} />
diff --git a/frontend/src/components/gallery/HeroHeader.tsx b/frontend/src/components/gallery/HeroHeader.tsx index 812d1681..6d7a20b1 100644 --- a/frontend/src/components/gallery/HeroHeader.tsx +++ b/frontend/src/components/gallery/HeroHeader.tsx @@ -27,6 +27,8 @@ interface HeroHeaderProps { useEnhancedProtection?: boolean; useCanvasRendering?: boolean; onScrollToContent?: () => void; + // Hero image anchor position (#162) – keyword or "X% Y%" focal point + heroImageAnchor?: string; } export const HeroHeader: React.FC = ({ @@ -45,7 +47,8 @@ export const HeroHeader: React.FC = ({ protectionLevel = 'standard', useEnhancedProtection = false, useCanvasRendering = false, - onScrollToContent + onScrollToContent, + heroImageAnchor = 'center' }) => { const { t } = useTranslation(); const { format } = useLocalizedDate(); @@ -138,6 +141,7 @@ export const HeroHeader: React.FC = ({ fallbackSrc={heroPhoto.thumbnail_url || undefined} alt={heroPhoto.filename} className="w-full h-full object-cover" + style={{ objectPosition: heroImageAnchor }} isGallery={true} slug={slug} photoId={heroPhoto.id} diff --git a/frontend/src/components/gallery/PhotoGridWithLayouts.tsx b/frontend/src/components/gallery/PhotoGridWithLayouts.tsx index eb016be2..a823d11e 100644 --- a/frontend/src/components/gallery/PhotoGridWithLayouts.tsx +++ b/frontend/src/components/gallery/PhotoGridWithLayouts.tsx @@ -60,6 +60,8 @@ interface PhotoGridWithLayoutsProps { // Header style (decoupled from layout) headerStyle?: HeaderStyleType; heroDividerStyle?: HeroDividerStyle; + // Hero image anchor position (#162) – keyword or "X% Y%" focal point + heroImageAnchor?: string; } export const PhotoGridWithLayouts: React.FC = ({ @@ -89,7 +91,8 @@ export const PhotoGridWithLayouts: React.FC = ({ heroLogoSize = 'medium', heroLogoPosition = 'top', headerStyle, - heroDividerStyle = 'wave' + heroDividerStyle = 'wave', + heroImageAnchor = 'center' }) => { const { t } = useTranslation(); const { theme } = useTheme(); @@ -260,6 +263,7 @@ export const PhotoGridWithLayouts: React.FC = ({ protectionLevel={protectionLevel} useEnhancedProtection={useEnhancedProtection} useCanvasRendering={useCanvasRendering} + heroImageAnchor={heroImageAnchor} /> )} diff --git a/frontend/src/pages/admin/EventDetailsPage.tsx b/frontend/src/pages/admin/EventDetailsPage.tsx index f426b71d..0812ac00 100644 --- a/frontend/src/pages/admin/EventDetailsPage.tsx +++ b/frontend/src/pages/admin/EventDetailsPage.tsx @@ -52,7 +52,7 @@ import { toast } from 'react-toastify'; import { useLocalizedDate } from '../../hooks/useLocalizedDate'; import { Button, Input, Card, Loading } from '../../components/common'; -import { EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PasswordResetModal, ThemeCustomizerEnhanced, ThemeDisplay, HeroPhotoSelector, PhotoUploadModal, FeedbackSettings, FeedbackModerationPanel, EventRenameDialog, PhotoFilterPanel, PhotoExportMenu } from '../../components/admin'; +import { EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PasswordResetModal, ThemeCustomizerEnhanced, ThemeDisplay, HeroPhotoSelector, FocalPointPicker, PhotoUploadModal, FeedbackSettings, FeedbackModerationPanel, EventRenameDialog, PhotoFilterPanel, PhotoExportMenu } from '../../components/admin'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { eventsService } from '../../services/events.service'; import { api } from '../../config/api'; @@ -168,6 +168,8 @@ export const EventDetailsPage: React.FC = () => { hero_logo_visible: boolean; hero_logo_size: 'small' | 'medium' | 'large' | 'xlarge'; hero_logo_position: 'top' | 'center' | 'bottom'; + // Hero image anchor position (#162) – keyword or "X% Y%" focal point + hero_image_anchor: string; }; const [isEditing, setIsEditing] = useState(false); @@ -196,6 +198,8 @@ export const EventDetailsPage: React.FC = () => { hero_logo_visible: true, hero_logo_size: 'medium', hero_logo_position: 'top', + // Hero image anchor position (#162) + hero_image_anchor: 'center', }); const [feedbackSettings, setFeedbackSettings] = useState({ feedback_enabled: false, @@ -402,6 +406,8 @@ export const EventDetailsPage: React.FC = () => { hero_logo_visible: event.hero_logo_visible ?? true, hero_logo_size: event.hero_logo_size || 'medium', hero_logo_position: event.hero_logo_position || 'top', + // Hero image anchor position (#162) + hero_image_anchor: event.hero_image_anchor || 'center', }); setShowNewPassword(false); @@ -528,6 +534,8 @@ export const EventDetailsPage: React.FC = () => { hero_logo_visible: editForm.hero_logo_visible, hero_logo_size: editForm.hero_logo_size, hero_logo_position: editForm.hero_logo_position, + // Hero image anchor position (#162) + hero_image_anchor: editForm.hero_image_anchor, }; // Only include fields that have defined values @@ -859,6 +867,29 @@ export const EventDetailsPage: React.FC = () => { isEditing={isEditing} /> + {/* Hero Image Focal Point Picker (#162) */} + {editForm.hero_photo_id && (() => { + const heroPhoto = (photos || []).find((p: any) => p.id === editForm.hero_photo_id); + const heroImageUrl = heroPhoto?.thumbnail_url || heroPhoto?.url; + if (!heroImageUrl) return null; + return ( +
+ +

+ {t('events.heroImageAnchorDescription', 'Click on the image to set the focal point for cropping.')} +

+ setEditForm(prev => ({ ...prev, hero_image_anchor: value }))} + slug={event.slug} + /> +
+ ); + })()} +