diff --git a/README.md b/README.md index 9047c055..31323977 100644 --- a/README.md +++ b/README.md @@ -7,12 +7,27 @@ [![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?style=flat&logo=docker&logoColor=white)](https://www.docker.com/) [![Node.js](https://img.shields.io/badge/node.js-6DA55F?style=flat&logo=node.js&logoColor=white)](https://nodejs.org/) [![React](https://img.shields.io/badge/react-%2320232a.svg?style=flat&logo=react&logoColor=%2361DAFB)](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. ![PicPeak Gallery Preview](docs/screenshot-gallery.png) +## 🎮 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 DemoGitHubDocumentationSupport 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} - -
- ))} +
+ {/* Hero photo thumbnail */} + + {category.name} +
+ +
+ ); + })}
)} {/* 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 ( +
+ + {cat.name} +
+ ); + })}
+ + {/* Hero Photo Picker Modal */} + {heroPickerCategoryId !== null && ( +
+ +
+
+

{t('categories.setCoverPhoto')}

+ +
+
+ +
+ {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 && ( +
+ +
+ )} +
+

{photo.filename}

+
+
+ ); + })} +
+ )} +
+ +
+ {categories.find(c => c.id === heroPickerCategoryId)?.hero_photo_id && ( + + )} +
+ +
+ +
+ )}
); }; -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) => ( + + ))} +
+
+ ); +}; + +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 ? ( - {brandName} - ) : ( -
- -
- ) - )} - {showText && ( -
-

{brandName}

- {brandTagline && ( -

{brandTagline}

+ {/* Hero Header - shown when headerStyle is 'hero' */} + {isHeroHeader && ( +
+
+
+ {/* Logo in Hero */} + {showLogo && ( +
+ {resolvedLogoUrl ? ( + {brandName} + ) : ( +
+ +
+ )} +
)} + {/* 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 ? ( + {brandName} + ) : ( +
+ +
+ ) + )} + {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.heroImageAnchorDescription', 'Click on the image to set the focal point for cropping.')} +

+ setEditForm(prev => ({ ...prev, hero_image_anchor: value }))} + slug={event.slug} + /> +
+ ); + })()} +