diff --git a/README.md b/README.md
index 9047c055..31323977 100644
--- a/README.md
+++ b/README.md
@@ -7,12 +7,27 @@
[](https://www.docker.com/)
[](https://nodejs.org/)
[](https://reactjs.org/)
+
+ [Homepage](https://www.picpeak.app) · [Live Demo](https://demo.picpeak.app) · [Documentation](DEPLOYMENT_GUIDE.md)
**PicPeak** is a powerful, self-hosted open-source alternative to commercial photo-sharing platforms like PicDrop.com and Scrapbook.de. Designed specifically for photographers and event organizers, PicPeak makes it simple to share beautiful, time-limited photo galleries with clients while maintaining full control over your data and branding.

+## 🎮 Live Demo
+
+Try PicPeak without installing anything:
+
+| | |
+|---|---|
+| **Demo URL** | [demo.picpeak.app](https://demo.picpeak.app) |
+| **Admin Panel** | [demo.picpeak.app/admin](https://demo.picpeak.app/admin) |
+| **Email** | `demo@picpeak.app` |
+| **Password** | `Demo2026!` |
+
+> The demo resets periodically. Uploaded content may be removed without notice.
+
## 🌟 Why Choose PicPeak?
Unlike expensive SaaS solutions, PicPeak gives you:
@@ -327,6 +342,8 @@ PicPeak is released under the [MIT License](LICENSE). Use it freely for personal
Made with ❤️ by photographers, for photographers
+ Homepage •
+ Live Demo •
GitHub •
Documentation •
Support
diff --git a/backend/migrations/core/066_add_hero_anchor_and_category_hero.js b/backend/migrations/core/066_add_hero_anchor_and_category_hero.js
new file mode 100644
index 00000000..57ededa8
--- /dev/null
+++ b/backend/migrations/core/066_add_hero_anchor_and_category_hero.js
@@ -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');
+ });
+ }
+};
diff --git a/backend/migrations/core/067_expand_hero_image_anchor.js b/backend/migrations/core/067_expand_hero_image_anchor.js
new file mode 100644
index 00000000..ef496949
--- /dev/null
+++ b/backend/migrations/core/067_expand_hero_image_anchor.js
@@ -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();
+ });
+};
diff --git a/backend/src/routes/adminCategories.js b/backend/src/routes/adminCategories.js
index e03ff0e3..e58a5b68 100644
--- a/backend/src/routes/adminCategories.js
+++ b/backend/src/routes/adminCategories.js
@@ -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 {
diff --git a/backend/src/routes/adminEvents.js b/backend/src/routes/adminEvents.js
index 721f6e37..25845e6f 100644
--- a/backend/src/routes/adminEvents.js
+++ b/backend/src/routes/adminEvents.js
@@ -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);
diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js
index 1adb092d..7c028cf2 100644
--- a/backend/src/routes/gallery.js
+++ b/backend/src/routes/gallery.js
@@ -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({
diff --git a/frontend/src/components/admin/EventCategoryManager.tsx b/frontend/src/components/admin/EventCategoryManager.tsx
index 8a0f66b2..ee0401ad 100644
--- a/frontend/src/components/admin/EventCategoryManager.tsx
+++ b/frontend/src/components/admin/EventCategoryManager.tsx
@@ -1,9 +1,10 @@
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { Plus, X, Loader2 } from 'lucide-react';
+import { Plus, X, Loader2, Image as ImageIcon, Check } from 'lucide-react';
import { toast } from 'react-toastify';
import { categoriesService, type PhotoCategory } from '../../services/categories.service';
-import { Button } from '../common';
+import { photosService, type AdminPhoto } from '../../services/photos.service';
+import { Button, Card, AuthenticatedImage } from '../common';
import { useTranslation } from 'react-i18next';
interface EventCategoryManagerProps {
@@ -15,6 +16,7 @@ export const EventCategoryManager: React.FC = ({ even
const { t } = useTranslation();
const [isAdding, setIsAdding] = useState(false);
const [newCategoryName, setNewCategoryName] = useState('');
+ const [heroPickerCategoryId, setHeroPickerCategoryId] = useState(null);
// Fetch categories for this event
const { data: categories = [], isLoading } = useQuery({
@@ -22,16 +24,23 @@ export const EventCategoryManager: React.FC = ({ even
queryFn: () => categoriesService.getEventCategories(eventId),
});
+ // Fetch photos for hero selection
+ const { data: photos = [] } = useQuery({
+ queryKey: ['admin-event-photos', eventId, {}],
+ queryFn: () => photosService.getEventPhotos(eventId, {}),
+ enabled: heroPickerCategoryId !== null,
+ });
+
// Filter to show only event-specific categories
const eventCategories = categories.filter(cat => !cat.is_global);
// Create category mutation
const createMutation = useMutation({
- mutationFn: (name: string) =>
- categoriesService.createCategory({
- name,
+ mutationFn: (name: string) =>
+ categoriesService.createCategory({
+ name,
is_global: false,
- event_id: eventId
+ event_id: eventId
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['event-categories', eventId] });
@@ -56,6 +65,20 @@ export const EventCategoryManager: React.FC = ({ even
},
});
+ // Set hero photo mutation
+ const heroMutation = useMutation({
+ mutationFn: ({ categoryId, photoId }: { categoryId: number; photoId: number | null }) =>
+ categoriesService.setCategoryHeroPhoto(categoryId, photoId),
+ onSuccess: (_data, variables) => {
+ queryClient.invalidateQueries({ queryKey: ['event-categories', eventId] });
+ setHeroPickerCategoryId(null);
+ toast.success(variables.photoId ? t('categories.coverPhotoSet') : t('categories.coverPhotoRemoved'));
+ },
+ onError: (error: any) => {
+ toast.error(error.response?.data?.error || t('categories.failedToSetCoverPhoto'));
+ },
+ });
+
const handleCreate = () => {
if (newCategoryName.trim()) {
createMutation.mutate(newCategoryName.trim());
@@ -68,6 +91,14 @@ export const EventCategoryManager: React.FC = ({ even
}
};
+ const handleSelectHeroPhoto = (categoryId: number, photoId: number) => {
+ heroMutation.mutate({ categoryId, photoId });
+ };
+
+ const handleRemoveHeroPhoto = (categoryId: number) => {
+ heroMutation.mutate({ categoryId, photoId: null });
+ };
+
if (isLoading) {
return (
@@ -135,45 +166,172 @@ export const EventCategoryManager: React.FC
= ({ even
{t('categories.noEventSpecificCategories')}
) : (
-
- {eventCategories.map((category) => (
-
-
{category.name}
-
handleDelete(category)}
- className="p-1 text-neutral-400 hover:text-red-600 transition-colors"
- title={t('categories.deleteCategoryTitle')}
- disabled={deleteMutation.isPending}
+
+ {eventCategories.map((category) => {
+ const heroPhoto = category.hero_photo_id
+ ? photos.find(p => p.id === category.hero_photo_id)
+ : null;
+ return (
+
- {deleteMutation.isPending ? (
-
- ) : (
-
- )}
-
-
- ))}
+
+ {/* Hero photo thumbnail */}
+
setHeroPickerCategoryId(category.id)}
+ className="flex-shrink-0 w-10 h-10 rounded border border-neutral-200 overflow-hidden bg-neutral-100 hover:border-primary-400 transition-colors flex items-center justify-center"
+ title={t('categories.setCoverPhoto')}
+ >
+ {heroPhoto ? (
+
+ ) : category.hero_photo_id ? (
+
+ ) : (
+
+ )}
+
+
{category.name}
+
+
handleDelete(category)}
+ className="p-1 text-neutral-400 hover:text-red-600 transition-colors"
+ title={t('categories.deleteCategoryTitle')}
+ disabled={deleteMutation.isPending}
+ >
+ {deleteMutation.isPending ? (
+
+ ) : (
+
+ )}
+
+
+ );
+ })}
)}
{/* Show available global categories */}
{t('categories.globalCategoriesAlwaysAvailable')}
-
+
{categories
.filter(cat => cat.is_global)
- .map(cat => (
-
- {cat.name}
-
- ))}
+ .map(cat => {
+ const heroPhoto = cat.hero_photo_id
+ ? photos.find(p => p.id === cat.hero_photo_id)
+ : null;
+ return (
+
+
setHeroPickerCategoryId(cat.id)}
+ className="flex-shrink-0 w-10 h-10 rounded border border-neutral-200 overflow-hidden bg-neutral-100 hover:border-primary-400 transition-colors flex items-center justify-center"
+ title={t('categories.setCoverPhoto')}
+ >
+ {heroPhoto ? (
+
+ ) : cat.hero_photo_id ? (
+
+ ) : (
+
+ )}
+
+
{cat.name}
+
+ );
+ })}
+
+ {/* Hero Photo Picker Modal */}
+ {heroPickerCategoryId !== null && (
+
+
+
+
+
{t('categories.setCoverPhoto')}
+ setHeroPickerCategoryId(null)}
+ className="p-2 hover:bg-neutral-100 rounded-lg transition-colors"
+ >
+
+
+
+
+
+
+ {photos.length === 0 ? (
+
+ {t('events.noPhotosAvailable')}
+
+ ) : (
+
+ {photos.map((photo) => {
+ const currentCategory = categories.find(c => c.id === heroPickerCategoryId);
+ const isSelected = photo.id === currentCategory?.hero_photo_id;
+ return (
+
handleSelectHeroPhoto(heroPickerCategoryId, photo.id)}
+ className={`relative cursor-pointer rounded-lg overflow-hidden border-2 transition-all ${
+ isSelected
+ ? 'border-primary-500 ring-2 ring-primary-500 ring-offset-2'
+ : 'border-transparent hover:border-neutral-300'
+ }`}
+ >
+
+ {isSelected && (
+
+
+
+ )}
+
+
+ );
+ })}
+
+ )}
+
+
+
+ {categories.find(c => c.id === heroPickerCategoryId)?.hero_photo_id && (
+
handleRemoveHeroPhoto(heroPickerCategoryId)}
+ disabled={heroMutation.isPending}
+ >
+ {t('categories.removeCoverPhoto')}
+
+ )}
+
+
setHeroPickerCategoryId(null)}
+ >
+ {t('common.cancel')}
+
+
+
+
+ )}
);
};
-EventCategoryManager.displayName = 'EventCategoryManager';
\ No newline at end of file
+EventCategoryManager.displayName = 'EventCategoryManager';
diff --git a/frontend/src/components/admin/FocalPointPicker.tsx b/frontend/src/components/admin/FocalPointPicker.tsx
new file mode 100644
index 00000000..ad5394ec
--- /dev/null
+++ b/frontend/src/components/admin/FocalPointPicker.tsx
@@ -0,0 +1,117 @@
+import React, { useRef, useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+import { AuthenticatedImage, Button } 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
= ({
+ imageUrl,
+ currentValue,
+ onChange,
+ slug,
+}) => {
+ const { t } = useTranslation();
+ const containerRef = useRef(null);
+ const [x, y] = parseAnchor(currentValue);
+
+ const handleClick = useCallback(
+ (e: React.MouseEvent) => {
+ 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 (
+
+ {/* Clickable image preview */}
+
+
+
+ {/* Crosshair marker */}
+
+ {/* Outer ring (dark) for contrast on light areas */}
+
+ {/* Inner ring (white) for contrast on dark areas */}
+
+ {/* Center dot */}
+
+
+
+ {/* Coordinate label */}
+
+ {x}% {y}%
+
+
+
+ {/* Preset buttons */}
+
+ {presets.map((p) => (
+ onChange(p.value)}
+ className={
+ keywordToPercent(currentValue) === p.value
+ ? 'bg-primary-50 border-primary-300 text-primary-700'
+ : ''
+ }
+ >
+ {p.label}
+
+ ))}
+
+
+ );
+};
+
+FocalPointPicker.displayName = 'FocalPointPicker';
diff --git a/frontend/src/components/admin/GalleryPreview.tsx b/frontend/src/components/admin/GalleryPreview.tsx
index ac7d4c71..b2fa2e92 100644
--- a/frontend/src/components/admin/GalleryPreview.tsx
+++ b/frontend/src/components/admin/GalleryPreview.tsx
@@ -1,6 +1,6 @@
import React, { useMemo } from 'react';
-import { Camera } from 'lucide-react';
-import { ThemeConfig, GalleryLayoutType } from '../../types/theme.types';
+import { Camera, Calendar } from 'lucide-react';
+import { ThemeConfig, GalleryLayoutType, HeroDividerStyle } from '../../types/theme.types';
import { buildResourceUrl } from '../../utils/url';
interface GalleryPreviewBranding {
@@ -92,6 +92,39 @@ export const GalleryPreview: React.FC = ({
? 'justify-end text-right flex-row-reverse'
: 'justify-start text-left';
+ // Check if hero header style is selected
+ const isHeroHeader = theme.headerStyle === 'hero';
+ const heroDividerStyle: HeroDividerStyle = theme.heroDividerStyle || 'wave';
+
+ // Render hero divider based on style
+ const renderHeroDivider = () => {
+ const bgColor = theme.backgroundColor || '#fafafa';
+ switch (heroDividerStyle) {
+ case 'wave':
+ return (
+
+
+
+ );
+ case 'curve':
+ return (
+
+
+
+ );
+ case 'angle':
+ return (
+
+
+
+ );
+ case 'straight':
+ case 'none':
+ default:
+ return null;
+ }
+ };
+
const renderLayout = () => {
const spacing = theme.gallerySettings?.spacing || 'normal';
const gapClass = spacing === 'tight' ? 'gap-1' : spacing === 'relaxed' ? 'gap-4' : 'gap-2';
@@ -169,7 +202,7 @@ export const GalleryPreview: React.FC = ({
};
return (
- = ({
fontFamily: theme.fontFamily || 'Inter, sans-serif',
}}
>
- {/* Preview Header */}
-
-
- {showLogo && (
- resolvedLogoUrl ? (
-
- ) : (
-
-
-
- )
- )}
- {showText && (
-
-
{brandName}
- {brandTagline && (
-
{brandTagline}
+ {/* Hero Header - shown when headerStyle is 'hero' */}
+ {isHeroHeader && (
+
+
+
+ {/* Logo in Hero */}
+ {showLogo && (
+
+ {resolvedLogoUrl ? (
+
+ ) : (
+
+
+
+ )}
+
)}
+ {/* Event Name */}
+
+ Sample Event
+
+ {/* Event Date */}
+
+
+ January 15, 2026
+
- )}
- {!showLogo && !showText && (
-
{brandName}
- )}
+
+ {/* Divider */}
+
+ {renderHeroDivider()}
+
-
-
Gallery preview
-
{activeLayout} layout
+ )}
+
+ {/* Standard Header - shown when headerStyle is NOT 'hero' */}
+ {!isHeroHeader && (
+
+
+ {showLogo && (
+ resolvedLogoUrl ? (
+
+ ) : (
+
+
+
+ )
+ )}
+ {showText && (
+
+
{brandName}
+ {brandTagline && (
+
{brandTagline}
+ )}
+
+ )}
+ {!showLogo && !showText && (
+
{brandName}
+ )}
+
+ )}
+
+ {/* Layout info bar */}
+
+ Gallery preview
+ {isHeroHeader ? `Hero + ${activeLayout}` : `${activeLayout} layout`}
-
+
{/* Preview Content */}
{renderLayout()}
diff --git a/frontend/src/components/admin/PhotoUpload.tsx b/frontend/src/components/admin/PhotoUpload.tsx
index 4c4dc315..5ed4221c 100644
--- a/frontend/src/components/admin/PhotoUpload.tsx
+++ b/frontend/src/components/admin/PhotoUpload.tsx
@@ -96,12 +96,30 @@ export const PhotoUpload: React.FC
= ({ eventId, onUploadCompl
setIsUploading(true);
setUploadProgress(0);
- // For large uploads, chunk the files to prevent memory issues
- const CHUNK_SIZE = Math.max(1, Math.min(50, maxFilesPerUpload)); // Upload up to 50 (or limit) files at a time
- const chunks = [];
-
- for (let i = 0; i < selectedFiles.length; i += CHUNK_SIZE) {
- chunks.push(selectedFiles.slice(i, i + CHUNK_SIZE));
+ // For large uploads, chunk the files by both count AND size to prevent memory/network issues
+ const MAX_FILES_PER_CHUNK = Math.max(1, Math.min(50, maxFilesPerUpload)); // Max 50 files per chunk
+ const MAX_BYTES_PER_CHUNK = 500 * 1024 * 1024; // Max 500MB per chunk (nginx limit is 1GB)
+ const chunks: File[][] = [];
+
+ let currentChunk: File[] = [];
+ let currentChunkSize = 0;
+
+ for (const file of selectedFiles) {
+ // Start a new chunk if adding this file would exceed limits
+ if (currentChunk.length >= MAX_FILES_PER_CHUNK ||
+ (currentChunkSize + file.size > MAX_BYTES_PER_CHUNK && currentChunk.length > 0)) {
+ chunks.push(currentChunk);
+ currentChunk = [];
+ currentChunkSize = 0;
+ }
+
+ currentChunk.push(file);
+ currentChunkSize += file.size;
+ }
+
+ // Don't forget the last chunk
+ if (currentChunk.length > 0) {
+ chunks.push(currentChunk);
}
setTotalChunks(chunks.length);
diff --git a/frontend/src/components/admin/index.ts b/frontend/src/components/admin/index.ts
index ae843017..3f9f3157 100644
--- a/frontend/src/components/admin/index.ts
+++ b/frontend/src/components/admin/index.ts
@@ -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';
diff --git a/frontend/src/components/common/__tests__/ProtectedImage.test.tsx b/frontend/src/components/common/__tests__/ProtectedImage.test.tsx
index cd8126c3..c06ceed3 100644
--- a/frontend/src/components/common/__tests__/ProtectedImage.test.tsx
+++ b/frontend/src/components/common/__tests__/ProtectedImage.test.tsx
@@ -1,51 +1,62 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { vi } from 'vitest';
import { ProtectedImage } from '../ProtectedImage';
-// Mock canvas and image APIs
-const mockCanvas = {
- getContext: jest.fn(() => ({
- clearRect: jest.fn(),
- drawImage: jest.fn(),
- getImageData: jest.fn(() => ({
- data: new Uint8ClampedArray(4).fill(255)
- })),
- putImageData: jest.fn(),
- fillRect: jest.fn(),
- fillText: jest.fn(),
- strokeText: jest.fn(),
- measureText: jest.fn(() => ({ width: 100 }))
+// Create a stable mock context (same reference for all getContext calls)
+const mockContext = {
+ clearRect: vi.fn(),
+ drawImage: vi.fn(),
+ getImageData: vi.fn(() => ({
+ data: new Uint8ClampedArray(400).fill(255)
})),
- width: 100,
- height: 100,
- style: {},
- addEventListener: jest.fn(),
- removeEventListener: jest.fn()
+ putImageData: vi.fn(),
+ fillRect: vi.fn(),
+ fillText: vi.fn(),
+ strokeText: vi.fn(),
+ measureText: vi.fn(() => ({ width: 100 })),
+ globalAlpha: 1.0,
+ globalCompositeOperation: 'source-over',
+ font: '',
+ fillStyle: '',
+ strokeStyle: '',
+ lineWidth: 1,
+ textAlign: 'center',
+ textBaseline: 'middle',
+ shadowColor: 'transparent',
+ shadowBlur: 0,
+ shadowOffsetX: 0,
+ shadowOffsetY: 0,
};
-// Mock HTMLCanvasElement
+// Mock HTMLCanvasElement.getContext to always return our stable context
Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', {
- value: () => mockCanvas.getContext()
+ value: () => mockContext,
+ writable: true,
});
-// Mock Image constructor
-global.Image = class {
- onload: (() => void) | null = null;
- onerror: (() => void) | null = null;
- src = '';
- naturalWidth = 100;
- naturalHeight = 100;
- width = 100;
- height = 100;
- crossOrigin = '';
+// Default Image mock that simulates successful loading
+const createSuccessImage = () => {
+ return class {
+ onload: (() => void) | null = null;
+ onerror: (() => void) | null = null;
+ src = '';
+ naturalWidth = 100;
+ naturalHeight = 100;
+ width = 100;
+ height = 100;
+ crossOrigin = '';
+ complete = true;
- constructor() {
- // Simulate image loading
- setTimeout(() => {
- if (this.onload) this.onload();
- }, 10);
- }
-} as any;
+ constructor() {
+ setTimeout(() => {
+ if (this.onload) this.onload();
+ }, 10);
+ }
+ } as unknown as typeof Image;
+};
+
+global.Image = createSuccessImage();
describe('ProtectedImage', () => {
const defaultProps = {
@@ -54,28 +65,34 @@ describe('ProtectedImage', () => {
};
beforeEach(() => {
- jest.clearAllMocks();
+ vi.clearAllMocks();
+ // Reset Image mock to success variant
+ global.Image = createSuccessImage();
});
- it('renders loading state initially', () => {
+ it('renders canvas with loading styles initially', () => {
render( );
- expect(screen.getByRole('img', { name: /loading test image/i })).toBeInTheDocument();
+ const canvas = screen.getByRole('img', { name: 'Test image' });
+ expect(canvas).toBeInTheDocument();
+ // While loading, canvas has opacity 0
+ expect(canvas).toHaveStyle({ opacity: '0' });
});
it('renders canvas after image loads', async () => {
render( );
-
+
await waitFor(() => {
- expect(screen.getByRole('img', { name: 'Test image' })).toBeInTheDocument();
+ const canvas = screen.getByRole('img', { name: 'Test image' });
+ expect(canvas).toHaveStyle({ opacity: '1' });
});
});
it('applies protection level classes and events', async () => {
- const onViolation = jest.fn();
-
+ const onViolation = vi.fn();
+
render(
-
@@ -89,31 +106,32 @@ describe('ProtectedImage', () => {
// Test context menu blocking
const canvas = screen.getByRole('img', { name: 'Test image' });
fireEvent.contextMenu(canvas);
-
+
expect(onViolation).toHaveBeenCalledWith('canvas_context_menu');
});
it('applies watermark text when specified', async () => {
render(
-
);
await waitFor(() => {
- expect(screen.getByRole('img', { name: 'Test image' })).toBeInTheDocument();
+ const canvas = screen.getByRole('img', { name: 'Test image' });
+ expect(canvas).toHaveStyle({ opacity: '1' });
});
// Verify canvas context methods were called for watermark
- expect(mockCanvas.getContext().fillText).toHaveBeenCalled();
+ expect(mockContext.fillText).toHaveBeenCalled();
});
it('handles fragment grid rendering', async () => {
render(
- {
);
await waitFor(() => {
- expect(screen.getByRole('img', { name: 'Test image' })).toBeInTheDocument();
+ const canvas = screen.getByRole('img', { name: 'Test image' });
+ expect(canvas).toHaveStyle({ opacity: '1' });
});
// Verify multiple drawImage calls for fragments
- expect(mockCanvas.getContext().drawImage).toHaveBeenCalled();
+ expect(mockContext.drawImage).toHaveBeenCalled();
});
it('blocks interactions in maximum protection mode', async () => {
- const onViolation = jest.fn();
-
+ const onViolation = vi.fn();
+
render(
-
@@ -142,7 +161,7 @@ describe('ProtectedImage', () => {
await waitFor(() => {
const canvas = screen.getByRole('img', { name: 'Test image' });
expect(canvas).toBeInTheDocument();
-
+
// Test click blocking
fireEvent.click(canvas);
expect(onViolation).toHaveBeenCalledWith('canvas_interaction_blocked');
@@ -150,26 +169,37 @@ describe('ProtectedImage', () => {
});
it('handles image loading errors gracefully', async () => {
- // Mock image error
+ // Track how many times src is set to detect fallback attempts
+ let loadAttempt = 0;
+
global.Image = class {
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
- src = '';
+ private _src = '';
+ naturalWidth = 0;
+ naturalHeight = 0;
+ width = 0;
+ height = 0;
+ crossOrigin = '';
+ complete = false;
- constructor() {
+ get src() { return this._src; }
+ set src(value: string) {
+ this._src = value;
+ loadAttempt++;
setTimeout(() => {
if (this.onerror) this.onerror();
}, 10);
}
- } as any;
+ } as unknown as typeof Image;
- const onViolation = jest.fn();
-
+ const onViolation = vi.fn();
+
+ // Render WITHOUT fallbackSrc so error state is reached immediately
render(
-
);
@@ -182,8 +212,8 @@ describe('ProtectedImage', () => {
it('applies invisible watermark for enhanced protection', async () => {
render(
- {
);
await waitFor(() => {
- expect(screen.getByRole('img', { name: 'Test image' })).toBeInTheDocument();
+ const canvas = screen.getByRole('img', { name: 'Test image' });
+ expect(canvas).toHaveStyle({ opacity: '1' });
});
// Verify getImageData and putImageData called for steganography
- expect(mockCanvas.getContext().getImageData).toHaveBeenCalled();
- expect(mockCanvas.getContext().putImageData).toHaveBeenCalled();
+ expect(mockContext.getImageData).toHaveBeenCalled();
+ expect(mockContext.putImageData).toHaveBeenCalled();
});
it('scrambles fragments when enabled', async () => {
render(
- {
);
await waitFor(() => {
- expect(screen.getByRole('img', { name: 'Test image' })).toBeInTheDocument();
+ const canvas = screen.getByRole('img', { name: 'Test image' });
+ expect(canvas).toHaveStyle({ opacity: '1' });
});
// Fragment scrambling should result in multiple drawImage calls
- expect(mockCanvas.getContext().drawImage).toHaveBeenCalled();
+ expect(mockContext.drawImage).toHaveBeenCalled();
});
it('adds random noise in maximum protection', async () => {
render(
-
);
await waitFor(() => {
- expect(screen.getByRole('img', { name: 'Test image' })).toBeInTheDocument();
+ const canvas = screen.getByRole('img', { name: 'Test image' });
+ expect(canvas).toHaveStyle({ opacity: '1' });
});
// Noise injection requires getImageData and putImageData
- expect(mockCanvas.getContext().getImageData).toHaveBeenCalled();
- expect(mockCanvas.getContext().putImageData).toHaveBeenCalled();
+ expect(mockContext.getImageData).toHaveBeenCalled();
+ expect(mockContext.putImageData).toHaveBeenCalled();
});
-});
\ No newline at end of file
+});
diff --git a/frontend/src/components/gallery/GalleryLayout.tsx b/frontend/src/components/gallery/GalleryLayout.tsx
index efa4bd16..d9d5ec3d 100644
--- a/frontend/src/components/gallery/GalleryLayout.tsx
+++ b/frontend/src/components/gallery/GalleryLayout.tsx
@@ -39,6 +39,7 @@ interface GalleryLayoutProps {
isDownloading?: boolean;
headerExtra?: React.ReactNode;
menuButton?: React.ReactNode;
+ headerStyle?: HeaderStyleType;
children: React.ReactNode;
}
@@ -52,14 +53,15 @@ export const GalleryLayout: React.FC = ({
isDownloading = false,
headerExtra,
menuButton,
+ headerStyle: headerStyleProp,
children,
}) => {
const { t } = useTranslation();
const { format } = useLocalizedDate();
const { theme } = useTheme();
- // Determine header style - check theme.headerStyle first, then fall back to legacy behavior
- const headerStyle: HeaderStyleType = theme.headerStyle || 'standard';
+ // Determine header style - use prop first (from event data), then theme, then fall back to 'standard'
+ const headerStyle: HeaderStyleType = headerStyleProp || theme.headerStyle || 'standard';
const isHeroHeader = headerStyle === 'hero';
// Non-grid layouts that need the sidebar (excluding layouts using hero header)
diff --git a/frontend/src/components/gallery/GalleryView.tsx b/frontend/src/components/gallery/GalleryView.tsx
index 0083f2f4..3d9fb759 100644
--- a/frontend/src/components/gallery/GalleryView.tsx
+++ b/frontend/src/components/gallery/GalleryView.tsx
@@ -584,6 +584,7 @@ export const GalleryView: React.FC = ({ slug, event }) => {
= ({ 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'}
/>
diff --git a/frontend/src/components/gallery/HeroHeader.tsx b/frontend/src/components/gallery/HeroHeader.tsx
index 812d1681..6d7a20b1 100644
--- a/frontend/src/components/gallery/HeroHeader.tsx
+++ b/frontend/src/components/gallery/HeroHeader.tsx
@@ -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
= ({
@@ -45,7 +47,8 @@ export const HeroHeader: React.FC = ({
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 = ({
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}
diff --git a/frontend/src/components/gallery/PhotoGridWithLayouts.tsx b/frontend/src/components/gallery/PhotoGridWithLayouts.tsx
index eb016be2..4af2906d 100644
--- a/frontend/src/components/gallery/PhotoGridWithLayouts.tsx
+++ b/frontend/src/components/gallery/PhotoGridWithLayouts.tsx
@@ -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 = ({
@@ -89,7 +91,8 @@ export const PhotoGridWithLayouts: React.FC = ({
heroLogoSize = 'medium',
heroLogoPosition = 'top',
headerStyle,
- heroDividerStyle = 'wave'
+ heroDividerStyle = 'wave',
+ heroImageAnchor = 'center'
}) => {
const { t } = useTranslation();
const { theme } = useTheme();
@@ -108,7 +111,7 @@ export const PhotoGridWithLayouts: React.FC = ({
// Clear selection when category changes
useEffect(() => {
setSelectedPhotos(new Set());
- }, [categoryId]);
+ }, [categoryId, setSelectedPhotos]);
const handlePhotoClick = (index: number) => {
setOpenFeedbackInitially(false);
@@ -168,7 +171,7 @@ export const PhotoGridWithLayouts: React.FC = ({
try {
await galleryService.downloadSelectedPhotos(slug, ids);
analyticsService.trackGalleryEvent('bulk_download', { gallery: slug, photo_count: ids.length });
- } catch (error) {
+ } catch {
toastify.error(t('gallery.downloadError'));
} finally {
setSelectedPhotos(new Set());
@@ -260,6 +263,7 @@ export const PhotoGridWithLayouts: React.FC = ({
protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection}
useCanvasRendering={useCanvasRendering}
+ heroImageAnchor={heroImageAnchor}
/>
)}
diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json
index faa9ecdc..eed7520d 100644
--- a/frontend/src/i18n/locales/de.json
+++ b/frontend/src/i18n/locales/de.json
@@ -696,7 +696,12 @@
"noCategory": "Keine Kategorie",
"noCategoriesYet": "Noch keine Kategorien. Erstellen Sie Ihre erste Kategorie, um Fotos zu organisieren.",
"deleteConfirm": "Sind Sie sicher, dass Sie \"{{name}}\" löschen möchten?",
- "cannotDelete": "Kategorie mit Fotos kann nicht gelöscht werden. Bitte weisen Sie die Fotos zuerst neu zu."
+ "cannotDelete": "Kategorie mit Fotos kann nicht gelöscht werden. Bitte weisen Sie die Fotos zuerst neu zu.",
+ "setCoverPhoto": "Titelbild festlegen",
+ "removeCoverPhoto": "Titelbild entfernen",
+ "coverPhotoSet": "Titelbild erfolgreich festgelegt",
+ "coverPhotoRemoved": "Titelbild entfernt",
+ "failedToSetCoverPhoto": "Titelbild konnte nicht festgelegt werden"
},
"events": {
"noStatisticsAvailableYet": "Noch keine Statistiken verfügbar",
@@ -863,6 +868,12 @@
"selectHeroPhoto": "Hero-Foto auswählen",
"noHeroPhotoSelected": "Kein Hero-Foto ausgewählt",
"heroPhotoSelected": "Hero-Foto ausgewählt",
+ "heroImageAnchor": "Hero-Bild Zuschneideposition",
+ "heroImageAnchorDescription": "Klicken Sie auf das Bild, um den Fokuspunkt für den Zuschnitt festzulegen.",
+ "heroImageAnchorTop": "Oben",
+ "heroImageAnchorCenter": "Mitte",
+ "heroImageAnchorBottom": "Unten",
+ "heroPreview": "Hero-Vorschau",
"noPhotosAvailable": "Keine Fotos verfügbar",
"processingRequest": "Ihre Anfrage wird verarbeitet...",
"eventTypeWedding": "Hochzeit",
diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json
index 0605d225..2937a0b7 100644
--- a/frontend/src/i18n/locales/en.json
+++ b/frontend/src/i18n/locales/en.json
@@ -309,7 +309,12 @@
"noCategory": "No category",
"noCategoriesYet": "No categories yet. Create your first category to organize photos.",
"deleteConfirm": "Are you sure you want to delete \"{{name}}\"?",
- "cannotDelete": "Cannot delete category with photos. Please reassign photos first."
+ "cannotDelete": "Cannot delete category with photos. Please reassign photos first.",
+ "setCoverPhoto": "Set Cover Photo",
+ "removeCoverPhoto": "Remove Cover Photo",
+ "coverPhotoSet": "Cover photo set successfully",
+ "coverPhotoRemoved": "Cover photo removed",
+ "failedToSetCoverPhoto": "Failed to set cover photo"
},
"events": {
"title": "Events",
@@ -489,6 +494,12 @@
"selectHeroPhoto": "Select Hero Photo",
"noHeroPhotoSelected": "No hero photo selected",
"heroPhotoSelected": "Hero photo selected",
+ "heroImageAnchor": "Hero Image Crop Position",
+ "heroImageAnchorDescription": "Click on the image to set the focal point for cropping.",
+ "heroImageAnchorTop": "Top",
+ "heroImageAnchorCenter": "Center",
+ "heroImageAnchorBottom": "Bottom",
+ "heroPreview": "Hero preview",
"noPhotosAvailable": "No photos available",
"processingRequest": "Processing your request...",
"eventTypeWedding": "Wedding",
diff --git a/frontend/src/pages/admin/EventDetailsPage.tsx b/frontend/src/pages/admin/EventDetailsPage.tsx
index f426b71d..3eef09a0 100644
--- a/frontend/src/pages/admin/EventDetailsPage.tsx
+++ b/frontend/src/pages/admin/EventDetailsPage.tsx
@@ -52,9 +52,10 @@ 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 { publicSettingsService } from '../../services/publicSettings.service';
import { api } from '../../config/api';
import { buildResourceUrl } from '../../utils/url';
import { isGalleryPublic, normalizeRequirePassword } from '../../utils/accessControl';
@@ -89,6 +90,7 @@ const ExternalFolderPicker: React.FC<{ value: string; onChange: (p: string) => v
}
};
+ // eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => { load(currentPath || ''); }, []);
const navigateUp = () => {
@@ -168,6 +170,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 +200,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({
feedback_enabled: false,
@@ -291,7 +297,7 @@ export const EventDetailsPage: React.FC = () => {
const mediaTypes = useMemo(() => {
const types = new Set<'photo' | 'video'>();
- photos.forEach((p: any) => {
+ photos.forEach((p) => {
const mediaType = (p.media_type as 'photo' | 'video' | undefined)
|| ((p.mime_type && String(p.mime_type).startsWith('video/')) || p.type === 'video' ? 'video' : 'photo');
if (mediaType === 'video' || mediaType === 'photo') {
@@ -309,6 +315,13 @@ export const EventDetailsPage: React.FC = () => {
}
}, [showMediaFilter, photoFilters.media_type]);
+ // Fetch public settings (for field requirement checks like expiration)
+ const { data: publicSettings } = useQuery({
+ queryKey: ['public-settings'],
+ queryFn: () => publicSettingsService.getPublicSettings(),
+ });
+ const requireExpiration = publicSettings?.event_require_expiration !== false;
+
// Fetch categories for the event
const { data: categories = [] } = useQuery({
queryKey: ['admin-event-categories', id],
@@ -402,6 +415,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);
@@ -430,7 +445,7 @@ export const EventDetailsPage: React.FC = () => {
setCurrentPresetName(event.color_theme);
}
}
- } catch (e) {
+ } catch {
setCurrentTheme(GALLERY_THEME_PRESETS.default.config);
setCurrentPresetName('default');
}
@@ -510,10 +525,15 @@ export const EventDetailsPage: React.FC = () => {
toast.error(t('events.externalFolderRequired', 'Please select an external folder before saving.'));
return;
}
-
+
+ if (requireExpiration && !editForm.expires_at) {
+ toast.error(t('validation.expirationRequired', 'Expiration date is required.'));
+ return;
+ }
+
// Clean up the data - remove undefined values
const updateData: any = {
- expires_at: editForm.expires_at,
+ expires_at: editForm.expires_at || null,
allow_user_uploads: editForm.allow_user_uploads,
require_password: editForm.require_password,
css_template_id: editForm.css_template_id,
@@ -528,6 +548,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
@@ -570,7 +592,7 @@ export const EventDetailsPage: React.FC = () => {
// Update feedback settings separately
try {
await feedbackService.updateEventFeedbackSettings(id!, feedbackSettings);
- } catch (error) {
+ } catch {
// Error already handled by mutation
}
};
@@ -859,6 +881,29 @@ export const EventDetailsPage: React.FC = () => {
isEditing={isEditing}
/>
+ {/* Hero Image Focal Point Picker (#162) */}
+ {editForm.hero_photo_id && (() => {
+ const heroPhoto = (photos || []).find((p) => p.id === editForm.hero_photo_id);
+ const heroImageUrl = heroPhoto?.thumbnail_url || heroPhoto?.url;
+ if (!heroImageUrl) return null;
+ return (
+
+
+ {t('events.heroImageAnchor', 'Hero Image Crop Position')}
+
+
+ {t('events.heroImageAnchorDescription', 'Click on the image to set the focal point for cropping.')}
+
+
setEditForm(prev => ({ ...prev, hero_image_anchor: value }))}
+ slug={event.slug}
+ />
+
+ );
+ })()}
+
{
try {
await eventsService.resendCreationEmail(event.id);
toast.success(t('events.creationEmailResent'));
- } catch (error) {
+ } catch {
toast.error(t('events.failedToResendEmail'));
}
}}
@@ -1622,7 +1667,7 @@ export const EventDetailsPage: React.FC = () => {
toast.info(t('events.downloadingArchive', { name: event.event_name }));
await archiveService.downloadArchive(Number(id), `${event.slug}-archive.zip`);
toast.success(t('events.downloadStarted'));
- } catch (error) {
+ } catch {
toast.error(t('events.failedToDownloadArchive'));
}
}}
diff --git a/frontend/src/services/categories.service.ts b/frontend/src/services/categories.service.ts
index 56c5bed8..383d3f13 100644
--- a/frontend/src/services/categories.service.ts
+++ b/frontend/src/services/categories.service.ts
@@ -6,6 +6,7 @@ export interface PhotoCategory {
slug: string;
is_global: boolean;
event_id: number | null;
+ hero_photo_id?: number | null;
created_at: string;
}
@@ -41,6 +42,12 @@ export const categoriesService = {
return response.data;
},
+ // Set category hero photo (#163)
+ async setCategoryHeroPhoto(id: number, heroPhotoId: number | null): Promise {
+ const response = await api.put(`/admin/categories/${id}/hero`, { hero_photo_id: heroPhotoId });
+ return response.data;
+ },
+
// Delete a category
async deleteCategory(id: number): Promise {
await api.delete(`/admin/categories/${id}`);
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index d16624c0..eeaacd29 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -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;
}
@@ -100,6 +102,7 @@ export interface PhotoCategory {
name: string;
slug: string;
is_global: boolean;
+ hero_photo_id?: number | null;
}
export interface GalleryData {
@@ -133,6 +136,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[];
diff --git a/frontend/src/types/theme.types.ts b/frontend/src/types/theme.types.ts
index 4b6d20f5..659d3209 100644
--- a/frontend/src/types/theme.types.ts
+++ b/frontend/src/types/theme.types.ts
@@ -43,7 +43,6 @@ export interface GalleryLayoutSettings {
// Hero specific
heroImageId?: number;
- heroImagePosition?: 'top' | 'center' | 'bottom';
heroOverlayOpacity?: number;
// Mosaic specific