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,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 { ThemeEditorModal } from './ThemeEditorModal';
export { HeroPhotoSelector } from './HeroPhotoSelector';
export { FocalPointPicker } from './FocalPointPicker';
export { PhotoUploadModal } from './PhotoUploadModal';
export { GalleryPreview } from './GalleryPreview';
export { BackupDashboard } from './BackupDashboard';
@@ -697,6 +697,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ 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'}
/>
</div>
@@ -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<HeroHeaderProps> = ({
@@ -45,7 +47,8 @@ export const HeroHeader: React.FC<HeroHeaderProps> = ({
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<HeroHeaderProps> = ({
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}
@@ -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<PhotoGridWithLayoutsProps> = ({
@@ -89,7 +91,8 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
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<PhotoGridWithLayoutsProps> = ({
protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection}
useCanvasRendering={useCanvasRendering}
heroImageAnchor={heroImageAnchor}
/>
)}
+32 -1
View File
@@ -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<FeedbackSettingsType>({
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 (
<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>
<label className="flex items-start gap-2">
<input
+4
View File
@@ -49,6 +49,8 @@ export interface Event {
// Header style settings (decoupled from layout)
header_style?: 'hero' | 'standard' | 'minimal' | '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_id?: number | null;
}
@@ -133,6 +135,8 @@ export interface GalleryData {
// Header style settings (decoupled from layout)
header_style?: 'hero' | 'standard' | 'minimal' | '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[];
photos: Photo[];