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:
@@ -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';
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user