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.
This commit is contained in:
Paul Nothaft
2026-02-03 10:08:58 +01:00
parent f554f463b3
commit 734868abc2
11 changed files with 273 additions and 9 deletions
@@ -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');
});
}
};
@@ -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();
});
};
+25 -4
View File
@@ -196,7 +196,16 @@ router.post('/', adminAuth, requirePermission('events.create'), [
body('hero_logo_position').optional().isIn(['top', 'center', 'bottom']), body('hero_logo_position').optional().isIn(['top', 'center', 'bottom']),
// Header style settings (decoupled from layout) // Header style settings (decoupled from layout)
body('header_style').optional().isIn(['hero', 'standard', 'minimal', 'none']), 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) => { ], async (req, res) => {
try { try {
logger.debug('Create event request body', { body: req.body }); logger.debug('Create event request body', { body: req.body });
@@ -242,7 +251,9 @@ router.post('/', adminAuth, requirePermission('events.create'), [
hero_logo_position = 'top', hero_logo_position = 'top',
// Header style settings // Header style settings
header_style = 'standard', header_style = 'standard',
hero_divider_style = 'wave' hero_divider_style = 'wave',
// Hero image anchor position (#162)
hero_image_anchor = 'center'
} = req.body; } = req.body;
const customerName = getCustomerNameFromPayload(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_size: hero_logo_size || 'medium',
hero_logo_position: hero_logo_position || 'top', hero_logo_position: hero_logo_position || 'top',
header_style: header_style || 'standard', 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'); }).returning('id');
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs) // 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']), body('hero_logo_position').optional().isIn(['top', 'center', 'bottom']),
// Header style settings (decoupled from layout) // Header style settings (decoupled from layout)
body('header_style').optional().isIn(['hero', 'standard', 'minimal', 'none']), 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) => { ], async (req, res) => {
try { try {
const errors = validationResult(req); const errors = validationResult(req);
+5 -2
View File
@@ -121,7 +121,8 @@ router.get('/:slug/info', async (req, res) => {
'hero_logo_position', 'hero_logo_position',
'hero_logo_url', 'hero_logo_url',
'header_style', 'header_style',
'hero_divider_style' 'hero_divider_style',
'hero_image_anchor'
) )
.first(); .first();
@@ -174,7 +175,8 @@ router.get('/:slug/info', async (req, res) => {
hero_logo_position: event.hero_logo_position || 'top', hero_logo_position: event.hero_logo_position || 'top',
hero_logo_url: event.hero_logo_url || null, hero_logo_url: event.hero_logo_url || null,
header_style: event.header_style || 'standard', 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) { } catch (error) {
console.error('Error fetching gallery info:', 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, hero_logo_url: req.event.hero_logo_url || null,
header_style: req.event.header_style || 'standard', header_style: req.event.header_style || 'standard',
hero_divider_style: req.event.hero_divider_style || 'wave', hero_divider_style: req.event.hero_divider_style || 'wave',
hero_image_anchor: req.event.hero_image_anchor || 'center',
...protectionSettings ...protectionSettings
}, },
categories: categories, categories: categories,
@@ -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<FocalPointPickerProps> = ({
imageUrl,
currentValue,
onChange,
slug,
}) => {
const { t } = useTranslation();
const containerRef = useRef<HTMLDivElement>(null);
const [x, y] = parseAnchor(currentValue);
const handleClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
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 (
<div>
{/* Clickable image preview */}
<div
ref={containerRef}
onClick={handleClick}
className="relative w-full h-48 rounded-lg overflow-hidden cursor-crosshair border border-neutral-300"
>
<AuthenticatedImage
src={imageUrl}
alt="Hero preview"
className="w-full h-full object-cover pointer-events-none"
style={{ objectPosition: `${x}% ${y}%` }}
slug={slug}
/>
{/* Crosshair marker */}
<div
className="absolute pointer-events-none"
style={{ left: `${x}%`, top: `${y}%`, transform: 'translate(-50%, -50%)' }}
>
{/* Outer ring (dark) for contrast on light areas */}
<div className="w-6 h-6 rounded-full border-2 border-black/50" />
{/* Inner ring (white) for contrast on dark areas */}
<div className="absolute inset-0 w-6 h-6 rounded-full border-2 border-white" style={{ margin: '1px' }} />
{/* Center dot */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-1.5 h-1.5 rounded-full bg-white shadow-sm" />
</div>
</div>
{/* Coordinate label */}
<span className="absolute bottom-1.5 right-1.5 px-1.5 py-0.5 text-[10px] font-mono leading-none text-white bg-black/60 rounded">
{x}% {y}%
</span>
</div>
{/* Preset buttons */}
<div className="flex gap-2 mt-2">
{presets.map((p) => (
<button
key={p.value}
type="button"
onClick={() => onChange(p.value)}
className={`px-3 py-1 text-xs font-medium rounded-md border transition-colors ${
keywordToPercent(currentValue) === p.value
? 'bg-primary-50 border-primary-300 text-primary-700'
: 'bg-white border-neutral-300 text-neutral-600 hover:bg-neutral-50'
}`}
>
{p.label}
</button>
))}
</div>
</div>
);
};
FocalPointPicker.displayName = 'FocalPointPicker';
+1
View File
@@ -22,6 +22,7 @@ export { ThemeCustomizerEnhanced } from './ThemeCustomizerEnhanced';
export { ThemeDisplay } from './ThemeDisplay'; export { ThemeDisplay } from './ThemeDisplay';
export { ThemeEditorModal } from './ThemeEditorModal'; export { ThemeEditorModal } from './ThemeEditorModal';
export { HeroPhotoSelector } from './HeroPhotoSelector'; export { HeroPhotoSelector } from './HeroPhotoSelector';
export { FocalPointPicker } from './FocalPointPicker';
export { PhotoUploadModal } from './PhotoUploadModal'; export { PhotoUploadModal } from './PhotoUploadModal';
export { GalleryPreview } from './GalleryPreview'; export { GalleryPreview } from './GalleryPreview';
export { BackupDashboard } from './BackupDashboard'; export { BackupDashboard } from './BackupDashboard';
@@ -697,6 +697,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
heroLogoPosition={data?.event?.hero_logo_position || 'top'} heroLogoPosition={data?.event?.hero_logo_position || 'top'}
headerStyle={data?.event?.header_style || theme.headerStyle} headerStyle={data?.event?.header_style || theme.headerStyle}
heroDividerStyle={data?.event?.hero_divider_style || theme.heroDividerStyle || 'wave'} heroDividerStyle={data?.event?.hero_divider_style || theme.heroDividerStyle || 'wave'}
heroImageAnchor={data?.event?.hero_image_anchor || 'center'}
/> />
</div> </div>
@@ -27,6 +27,8 @@ interface HeroHeaderProps {
useEnhancedProtection?: boolean; useEnhancedProtection?: boolean;
useCanvasRendering?: boolean; useCanvasRendering?: boolean;
onScrollToContent?: () => void; onScrollToContent?: () => void;
// Hero image anchor position (#162) keyword or "X% Y%" focal point
heroImageAnchor?: string;
} }
export const HeroHeader: React.FC<HeroHeaderProps> = ({ export const HeroHeader: React.FC<HeroHeaderProps> = ({
@@ -45,7 +47,8 @@ export const HeroHeader: React.FC<HeroHeaderProps> = ({
protectionLevel = 'standard', protectionLevel = 'standard',
useEnhancedProtection = false, useEnhancedProtection = false,
useCanvasRendering = false, useCanvasRendering = false,
onScrollToContent onScrollToContent,
heroImageAnchor = 'center'
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { format } = useLocalizedDate(); const { format } = useLocalizedDate();
@@ -138,6 +141,7 @@ export const HeroHeader: React.FC<HeroHeaderProps> = ({
fallbackSrc={heroPhoto.thumbnail_url || undefined} fallbackSrc={heroPhoto.thumbnail_url || undefined}
alt={heroPhoto.filename} alt={heroPhoto.filename}
className="w-full h-full object-cover" className="w-full h-full object-cover"
style={{ objectPosition: heroImageAnchor }}
isGallery={true} isGallery={true}
slug={slug} slug={slug}
photoId={heroPhoto.id} photoId={heroPhoto.id}
@@ -60,6 +60,8 @@ interface PhotoGridWithLayoutsProps {
// Header style (decoupled from layout) // Header style (decoupled from layout)
headerStyle?: HeaderStyleType; headerStyle?: HeaderStyleType;
heroDividerStyle?: HeroDividerStyle; heroDividerStyle?: HeroDividerStyle;
// Hero image anchor position (#162) keyword or "X% Y%" focal point
heroImageAnchor?: string;
} }
export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
@@ -89,7 +91,8 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
heroLogoSize = 'medium', heroLogoSize = 'medium',
heroLogoPosition = 'top', heroLogoPosition = 'top',
headerStyle, headerStyle,
heroDividerStyle = 'wave' heroDividerStyle = 'wave',
heroImageAnchor = 'center'
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { theme } = useTheme(); const { theme } = useTheme();
@@ -260,6 +263,7 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
protectionLevel={protectionLevel} protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection} useEnhancedProtection={useEnhancedProtection}
useCanvasRendering={useCanvasRendering} useCanvasRendering={useCanvasRendering}
heroImageAnchor={heroImageAnchor}
/> />
)} )}
+32 -1
View File
@@ -52,7 +52,7 @@ import { toast } from 'react-toastify';
import { useLocalizedDate } from '../../hooks/useLocalizedDate'; import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { Button, Input, Card, Loading } from '../../components/common'; 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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { eventsService } from '../../services/events.service'; import { eventsService } from '../../services/events.service';
import { api } from '../../config/api'; import { api } from '../../config/api';
@@ -168,6 +168,8 @@ export const EventDetailsPage: React.FC = () => {
hero_logo_visible: boolean; hero_logo_visible: boolean;
hero_logo_size: 'small' | 'medium' | 'large' | 'xlarge'; hero_logo_size: 'small' | 'medium' | 'large' | 'xlarge';
hero_logo_position: 'top' | 'center' | 'bottom'; 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); const [isEditing, setIsEditing] = useState(false);
@@ -196,6 +198,8 @@ export const EventDetailsPage: React.FC = () => {
hero_logo_visible: true, hero_logo_visible: true,
hero_logo_size: 'medium', hero_logo_size: 'medium',
hero_logo_position: 'top', hero_logo_position: 'top',
// Hero image anchor position (#162)
hero_image_anchor: 'center',
}); });
const [feedbackSettings, setFeedbackSettings] = useState<FeedbackSettingsType>({ const [feedbackSettings, setFeedbackSettings] = useState<FeedbackSettingsType>({
feedback_enabled: false, feedback_enabled: false,
@@ -402,6 +406,8 @@ export const EventDetailsPage: React.FC = () => {
hero_logo_visible: event.hero_logo_visible ?? true, hero_logo_visible: event.hero_logo_visible ?? true,
hero_logo_size: event.hero_logo_size || 'medium', hero_logo_size: event.hero_logo_size || 'medium',
hero_logo_position: event.hero_logo_position || 'top', hero_logo_position: event.hero_logo_position || 'top',
// Hero image anchor position (#162)
hero_image_anchor: event.hero_image_anchor || 'center',
}); });
setShowNewPassword(false); setShowNewPassword(false);
@@ -528,6 +534,8 @@ export const EventDetailsPage: React.FC = () => {
hero_logo_visible: editForm.hero_logo_visible, hero_logo_visible: editForm.hero_logo_visible,
hero_logo_size: editForm.hero_logo_size, hero_logo_size: editForm.hero_logo_size,
hero_logo_position: editForm.hero_logo_position, 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 // Only include fields that have defined values
@@ -859,6 +867,29 @@ export const EventDetailsPage: React.FC = () => {
isEditing={isEditing} 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 (
<div className="ml-6 mt-2">
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('events.heroImageAnchor', 'Hero Image Crop Position')}
</label>
<p className="text-xs text-neutral-500 mb-2">
{t('events.heroImageAnchorDescription', 'Click on the image to set the focal point for cropping.')}
</p>
<FocalPointPicker
imageUrl={heroImageUrl}
currentValue={editForm.hero_image_anchor}
onChange={(value) => setEditForm(prev => ({ ...prev, hero_image_anchor: value }))}
slug={event.slug}
/>
</div>
);
})()}
<div> <div>
<label className="flex items-start gap-2"> <label className="flex items-start gap-2">
<input <input
+4
View File
@@ -49,6 +49,8 @@ export interface Event {
// Header style settings (decoupled from layout) // Header style settings (decoupled from layout)
header_style?: 'hero' | 'standard' | 'minimal' | 'none'; header_style?: 'hero' | 'standard' | 'minimal' | 'none';
hero_divider_style?: 'wave' | 'straight' | 'angle' | 'curve' | 'none'; hero_divider_style?: 'wave' | 'straight' | 'angle' | 'curve' | 'none';
// Hero image anchor position (#162) keyword or "X% Y%" focal point
hero_image_anchor?: string;
// CSS Template // CSS Template
css_template_id?: number | null; css_template_id?: number | null;
} }
@@ -133,6 +135,8 @@ export interface GalleryData {
// Header style settings (decoupled from layout) // Header style settings (decoupled from layout)
header_style?: 'hero' | 'standard' | 'minimal' | 'none'; header_style?: 'hero' | 'standard' | 'minimal' | 'none';
hero_divider_style?: 'wave' | 'straight' | 'angle' | 'curve' | 'none'; hero_divider_style?: 'wave' | 'straight' | 'angle' | 'curve' | 'none';
// Hero image anchor position (#162) keyword or "X% Y%" focal point
hero_image_anchor?: string;
}; };
categories?: PhotoCategory[]; categories?: PhotoCategory[];
photos: Photo[]; photos: Photo[];