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
+25 -4
View File
@@ -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);
+5 -2
View File
@@ -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,