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}
/>
)}