Merge pull request #164 from the-luap/feat/new-features

feat: gallery layouts, hero customization, bulk categories & event types
This commit is contained in:
Paul Nothaft
2026-02-03 15:50:54 +01:00
committed by GitHub
22 changed files with 959 additions and 230 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();
});
};
+77 -17
View File
@@ -106,42 +106,53 @@ router.post('/', adminAuth, requirePermission('settings.edit'), [
// Update a category
router.put('/:id', adminAuth, requirePermission('settings.edit'), [
body('name').notEmpty().withMessage('Category name is required')
body('name').notEmpty().withMessage('Category name is required'),
body('hero_photo_id').optional({ nullable: true }).custom((value) => {
if (value === null || value === undefined) return true;
return Number.isInteger(Number(value));
}).withMessage('hero_photo_id must be an integer or null')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { id } = req.params;
const { name } = req.body;
const { name, hero_photo_id } = req.body;
const category = await db('photo_categories').where('id', id).first();
if (!category) {
return res.status(404).json({ error: 'Category not found' });
}
const updateData = {
name,
slug: name.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.trim()
};
// Update hero_photo_id if provided (including null to clear it)
if (Object.prototype.hasOwnProperty.call(req.body, 'hero_photo_id')) {
updateData.hero_photo_id = hero_photo_id || null;
}
await db('photo_categories')
.where('id', id)
.update({
name,
slug: name.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.trim()
});
.update(updateData);
const updated = await db('photo_categories').where('id', id).first();
// Log activity
await logActivity('category_updated',
{ categoryName: name },
{ categoryName: name, heroPhotoId: hero_photo_id },
category.event_id,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json(updated);
} catch (error) {
console.error('Error updating category:', error);
@@ -149,6 +160,55 @@ router.put('/:id', adminAuth, requirePermission('settings.edit'), [
}
});
// Set category hero photo (#163)
router.put('/:id/hero', adminAuth, requirePermission('settings.edit'), [
body('hero_photo_id').optional({ nullable: true }).custom((value) => {
if (value === null || value === undefined) return true;
return Number.isInteger(Number(value));
}).withMessage('hero_photo_id must be an integer or null')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { id } = req.params;
const { hero_photo_id } = req.body;
const category = await db('photo_categories').where('id', id).first();
if (!category) {
return res.status(404).json({ error: 'Category not found' });
}
// If hero_photo_id is provided, verify it belongs to a photo in this category
if (hero_photo_id) {
const photo = await db('photos').where('id', hero_photo_id).first();
if (!photo) {
return res.status(404).json({ error: 'Photo not found' });
}
}
await db('photo_categories')
.where('id', id)
.update({ hero_photo_id: hero_photo_id || null });
const updated = await db('photo_categories').where('id', id).first();
// Log activity
await logActivity('category_hero_updated',
{ categoryName: category.name, heroPhotoId: hero_photo_id },
category.event_id,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json(updated);
} catch (error) {
console.error('Error updating category hero:', error);
res.status(500).json({ error: 'Failed to update category hero' });
}
});
// Delete a category
router.delete('/:id', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try {
+37 -13
View File
@@ -1,5 +1,5 @@
const express = require('express');
const { body, query, validationResult } = require('express-validator');
const { body, validationResult } = require('express-validator');
const { db, logActivity } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { adminAuth } = require('../middleware/auth');
@@ -17,10 +17,20 @@ const { escapeLikePattern } = require('../utils/sqlSecurity');
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
const logger = require('../utils/logger');
const { buildShareLinkVariants } = require('../services/shareLinkService');
const { parseBooleanInput, parseStringInput, parseJsonInput } = require('../utils/parsers');
const { parseBooleanInput, parseStringInput } = require('../utils/parsers');
const eventTypeService = require('../services/eventTypeService');
const { validateFileType } = require('../utils/fileSecurityUtils');
// Shared validator for hero_image_anchor accepts legacy keywords or "X% Y%" focal point
const validateHeroImageAnchor = (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)');
};
// Get storage path from environment or default
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
@@ -196,7 +206,9 @@ 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(validateHeroImageAnchor)
], async (req, res) => {
try {
logger.debug('Create event request body', { body: req.body });
@@ -242,7 +254,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);
@@ -302,10 +316,6 @@ router.post('/', adminAuth, requirePermission('events.create'), [
}
}
// Get event type info for slug generation
const eventTypeInfo = await eventTypeService.getEventTypeForSlug(event_type);
const slugPrefix = eventTypeInfo.slug_prefix || event_type;
// Generate unique slug
const processedEventName = event_name
.toLowerCase()
@@ -326,7 +336,7 @@ router.post('/', adminAuth, requirePermission('events.create'), [
// Generate share link respecting configured format
const shareToken = crypto.randomBytes(16).toString('hex');
const { sharePath, shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken });
const { shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken });
// Hash password with configurable rounds (random placeholder when not required)
const password_hash = requirePassword
@@ -385,7 +395,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)
@@ -621,7 +632,7 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), [
body('event_name').optional().trim().notEmpty(),
body('admin_email').optional().isEmail(),
body('is_active').optional().isBoolean(),
body('expires_at').optional().isISO8601(),
body('expires_at').optional({ nullable: true, checkFalsy: true }).isISO8601(),
body('welcome_message').optional({ nullable: true, checkFalsy: true }).trim(),
body('color_theme').optional({ nullable: true }),
body('allow_user_uploads').optional().isBoolean(),
@@ -653,7 +664,7 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), [
body('overlay_protection').optional().isBoolean(),
body('image_quality').optional().isInt({ min: 1, max: 100 }),
body('fragmentation_level').optional().isInt({ min: 1, max: 10 }),
body('password').optional().isString().custom((value, { req }) => {
body('password').optional().isString().custom((value) => {
if (value === undefined || value === null || value === '') {
return true;
}
@@ -669,7 +680,9 @@ 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(validateHeroImageAnchor)
], async (req, res) => {
try {
const errors = validationResult(req);
@@ -781,6 +794,17 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), [
updates.password_hash = await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
}
// Enforce expires_at requirement based on app settings
if (Object.prototype.hasOwnProperty.call(updates, 'expires_at')) {
if (!updates.expires_at) {
const fieldReqs = await getEventFieldRequirements();
if (fieldReqs.require_expiration) {
return res.status(400).json({ error: 'Expiration date is required.' });
}
updates.expires_at = null;
}
}
// Format hero logo settings if provided
if (Object.prototype.hasOwnProperty.call(updates, 'hero_logo_visible')) {
updates.hero_logo_visible = formatBoolean(updates.hero_logo_visible);
+65 -23
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);
@@ -294,20 +296,36 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
commentMap[c.photo_id] = parseInt(c.comment_count);
});
// Get distinct photo types for this event
const categoryResults = await db('photos')
// Get actual categories used by photos in this event
// This includes both global categories and event-specific ones
const usedCategoryIds = await db('photos')
.where('event_id', req.event.id)
.select('type')
.distinct('type')
.orderBy('type', 'asc');
// Convert types to category-like objects
const categories = categoryResults.map(result => ({
id: result.type,
name: result.type === 'individual' ? 'Individual Photos' : 'Collages',
slug: result.type,
is_global: false
}));
.whereNotNull('category_id')
.distinct('category_id')
.pluck('category_id');
// Fetch category details from photo_categories table
let categories = [];
if (usedCategoryIds.length > 0) {
const categoryDetails = await db('photo_categories')
.whereIn('id', usedCategoryIds)
.select('id', 'name', 'slug', 'is_global', 'hero_photo_id')
.orderBy('name', 'asc');
categories = categoryDetails.map(cat => ({
id: cat.id,
name: cat.name,
slug: cat.slug,
is_global: cat.is_global,
hero_photo_id: cat.hero_photo_id || null
}));
}
// Build a map for quick category lookup
const categoryMap = {};
categories.forEach(cat => {
categoryMap[cat.id] = cat;
});
// Log view
await db('access_logs').insert({
@@ -350,6 +368,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,
@@ -369,9 +388,9 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
secure_url_template: `/api/secure-images/${req.params.slug}/secure/${photo.id}/{{token}}`,
download_url_template: `/api/secure-images/${req.params.slug}/secure-download/${photo.id}/{{token}}`,
type: photo.type,
category_id: photo.type,
category_name: photo.type === 'individual' ? 'Individual Photos' : 'Collages',
category_slug: photo.type,
category_id: photo.category_id || null,
category_name: photo.category_id && categoryMap[photo.category_id] ? categoryMap[photo.category_id].name : null,
category_slug: photo.category_id && categoryMap[photo.category_id] ? categoryMap[photo.category_id].slug : null,
size: photo.size_bytes,
uploaded_at: photo.uploaded_at,
// Image dimensions for layout calculations
@@ -732,8 +751,34 @@ router.get('/:slug/photo/:photoId',
// Resolve the absolute file path for this photo, supporting both managed and external reference modes
const { resolvePhotoFilePath } = require('../services/photoResolver');
const filePath = resolvePhotoFilePath(req.event, photo);
const fs = require('fs');
let filePath;
try {
filePath = resolvePhotoFilePath(req.event, photo);
} catch (resolveError) {
logger.error('Failed to resolve photo path', {
slug: req.params.slug,
photoId,
eventId: req.event.id,
error: resolveError.message,
photoPath: photo.path,
photoFilename: photo.filename
});
return res.status(404).json({ error: 'Photo file not found' });
}
// Verify file exists before attempting to serve
if (!fs.existsSync(filePath)) {
logger.error('Photo file does not exist at resolved path', {
slug: req.params.slug,
photoId,
eventId: req.event.id,
resolvedPath: filePath,
photoPath: photo.path
});
return res.status(404).json({ error: 'Photo file not found' });
}
// Log access - temporarily disabled for debugging
// await secureImageService.logImageAccess(
@@ -745,14 +790,13 @@ router.get('/:slug/photo/:photoId',
// Handle video streaming with range requests
if (isVideo) {
const fs = require('fs');
const stat = fs.statSync(filePath);
const fileSize = stat.size;
const range = req.headers.range;
if (range) {
// Parse range header
const parts = range.replace(/bytes=/, "").split("-");
const parts = range.replace(/bytes=/, '').split('-');
const start = parseInt(parts[0], 10);
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
const chunksize = (end - start) + 1;
@@ -789,7 +833,6 @@ router.get('/:slug/photo/:photoId',
// Generate ETag based on photo id, modification time, and watermark settings
// This ensures cache invalidation when watermark settings change
const fs = require('fs');
const stat = fs.statSync(filePath);
const watermarkHash = watermarkSettings?.enabled
? `-wm${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}`
@@ -806,7 +849,6 @@ router.get('/:slug/photo/:photoId',
if (photo.watermark_path) {
const watermarkFilePath = path.join(getStoragePath(), photo.watermark_path);
try {
const fs = require('fs');
// Check if pre-generated watermark file exists
if (fs.existsSync(watermarkFilePath)) {
res.set({