From 734868abc23731b0ac9ad73e799194df1e6aa6ab Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Tue, 3 Feb 2026 10:08:58 +0100 Subject: [PATCH] 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} + /> +
+ ); + })()} +