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

feat: gallery layouts, hero customization, bulk categories & event types
This commit is contained in:
Paul Nothaft
2026-02-03 15:50:54 +01:00
committed by GitHub
22 changed files with 959 additions and 230 deletions
+17
View File
@@ -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)
</div>
**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
<p align="center">
Made with ❤️ by photographers, for photographers
<br>
<a href="https://www.picpeak.app">Homepage</a> •
<a href="https://demo.picpeak.app">Live Demo</a> •
<a href="https://github.com/the-luap/picpeak">GitHub</a> •
<a href="DEPLOYMENT_GUIDE.md">Documentation</a> •
<a href="https://github.com/the-luap/picpeak/issues">Support</a>
@@ -0,0 +1,48 @@
/**
* Migration: Add hero image anchor position and category-specific hero images
*
* Issue #162: Add hero_image_anchor column to events table for controlling
* how hero images are cropped (top/center/bottom)
*
* Issue #163: Add hero_photo_id column to photo_categories table for
* category-specific hero images
*/
exports.up = async function(knex) {
// Add hero_image_anchor to events table (Issue #162)
const hasHeroAnchor = await knex.schema.hasColumn('events', 'hero_image_anchor');
if (!hasHeroAnchor) {
await knex.schema.alterTable('events', function(table) {
// Values: 'top', 'center', 'bottom' - defaults to 'center' for backward compatibility
table.string('hero_image_anchor', 10).defaultTo('center');
});
console.log('Added hero_image_anchor column to events table');
}
// Add hero_photo_id to photo_categories table (Issue #163)
const hasCategoryHero = await knex.schema.hasColumn('photo_categories', 'hero_photo_id');
if (!hasCategoryHero) {
await knex.schema.alterTable('photo_categories', function(table) {
table.integer('hero_photo_id').references('id').inTable('photos').onDelete('SET NULL');
});
console.log('Added hero_photo_id column to photo_categories table');
}
};
exports.down = async function(knex) {
// Remove hero_image_anchor from events table
const hasHeroAnchor = await knex.schema.hasColumn('events', 'hero_image_anchor');
if (hasHeroAnchor) {
await knex.schema.alterTable('events', function(table) {
table.dropColumn('hero_image_anchor');
});
}
// Remove hero_photo_id from photo_categories table
const hasCategoryHero = await knex.schema.hasColumn('photo_categories', 'hero_photo_id');
if (hasCategoryHero) {
await knex.schema.alterTable('photo_categories', function(table) {
table.dropColumn('hero_photo_id');
});
}
};
@@ -0,0 +1,32 @@
/**
* Migration: Expand hero_image_anchor column to support focal point percentages
*
* Changes string(10) to string(20) so values like "100% 100%" (9 chars) fit
* with room to spare. Existing 'top', 'center', 'bottom' values are preserved.
*/
exports.up = async function(knex) {
const hasColumn = await knex.schema.hasColumn('events', 'hero_image_anchor');
if (!hasColumn) {
// Column doesn't exist yet nothing to expand
return;
}
// SQLite doesn't truly support ALTER COLUMN, but Knex handles the
// rebuild-table strategy internally when we call alterTable.
await knex.schema.alterTable('events', function(table) {
table.string('hero_image_anchor', 20).defaultTo('center').alter();
});
console.log('Expanded hero_image_anchor column to string(20)');
};
exports.down = async function(knex) {
const hasColumn = await knex.schema.hasColumn('events', 'hero_image_anchor');
if (!hasColumn) {
return;
}
await knex.schema.alterTable('events', function(table) {
table.string('hero_image_anchor', 10).defaultTo('center').alter();
});
};
+77 -17
View File
@@ -106,42 +106,53 @@ router.post('/', adminAuth, requirePermission('settings.edit'), [
// Update a category
router.put('/:id', adminAuth, requirePermission('settings.edit'), [
body('name').notEmpty().withMessage('Category name is required')
body('name').notEmpty().withMessage('Category name is required'),
body('hero_photo_id').optional({ nullable: true }).custom((value) => {
if (value === null || value === undefined) return true;
return Number.isInteger(Number(value));
}).withMessage('hero_photo_id must be an integer or null')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { id } = req.params;
const { name } = req.body;
const { name, hero_photo_id } = req.body;
const category = await db('photo_categories').where('id', id).first();
if (!category) {
return res.status(404).json({ error: 'Category not found' });
}
const updateData = {
name,
slug: name.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.trim()
};
// Update hero_photo_id if provided (including null to clear it)
if (Object.prototype.hasOwnProperty.call(req.body, 'hero_photo_id')) {
updateData.hero_photo_id = hero_photo_id || null;
}
await db('photo_categories')
.where('id', id)
.update({
name,
slug: name.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.trim()
});
.update(updateData);
const updated = await db('photo_categories').where('id', id).first();
// Log activity
await logActivity('category_updated',
{ categoryName: name },
{ categoryName: name, heroPhotoId: hero_photo_id },
category.event_id,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json(updated);
} catch (error) {
console.error('Error updating category:', error);
@@ -149,6 +160,55 @@ router.put('/:id', adminAuth, requirePermission('settings.edit'), [
}
});
// Set category hero photo (#163)
router.put('/:id/hero', adminAuth, requirePermission('settings.edit'), [
body('hero_photo_id').optional({ nullable: true }).custom((value) => {
if (value === null || value === undefined) return true;
return Number.isInteger(Number(value));
}).withMessage('hero_photo_id must be an integer or null')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { id } = req.params;
const { hero_photo_id } = req.body;
const category = await db('photo_categories').where('id', id).first();
if (!category) {
return res.status(404).json({ error: 'Category not found' });
}
// If hero_photo_id is provided, verify it belongs to a photo in this category
if (hero_photo_id) {
const photo = await db('photos').where('id', hero_photo_id).first();
if (!photo) {
return res.status(404).json({ error: 'Photo not found' });
}
}
await db('photo_categories')
.where('id', id)
.update({ hero_photo_id: hero_photo_id || null });
const updated = await db('photo_categories').where('id', id).first();
// Log activity
await logActivity('category_hero_updated',
{ categoryName: category.name, heroPhotoId: hero_photo_id },
category.event_id,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json(updated);
} catch (error) {
console.error('Error updating category hero:', error);
res.status(500).json({ error: 'Failed to update category hero' });
}
});
// Delete a category
router.delete('/:id', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try {
+37 -13
View File
@@ -1,5 +1,5 @@
const express = require('express');
const { body, query, validationResult } = require('express-validator');
const { body, validationResult } = require('express-validator');
const { db, logActivity } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { adminAuth } = require('../middleware/auth');
@@ -17,10 +17,20 @@ const { escapeLikePattern } = require('../utils/sqlSecurity');
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
const logger = require('../utils/logger');
const { buildShareLinkVariants } = require('../services/shareLinkService');
const { parseBooleanInput, parseStringInput, parseJsonInput } = require('../utils/parsers');
const { parseBooleanInput, parseStringInput } = require('../utils/parsers');
const eventTypeService = require('../services/eventTypeService');
const { validateFileType } = require('../utils/fileSecurityUtils');
// Shared validator for hero_image_anchor accepts legacy keywords or "X% Y%" focal point
const validateHeroImageAnchor = (value) => {
if (['top', 'center', 'bottom'].includes(value)) return true;
if (typeof value === 'string' && /^\d{1,3}%\s+\d{1,3}%$/.test(value)) {
const [x, y] = value.split(/\s+/).map(v => parseInt(v));
if (x >= 0 && x <= 100 && y >= 0 && y <= 100) return true;
}
throw new Error('Must be top, center, bottom, or "X% Y%" (0-100)');
};
// Get storage path from environment or default
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
@@ -196,7 +206,9 @@ router.post('/', adminAuth, requirePermission('events.create'), [
body('hero_logo_position').optional().isIn(['top', 'center', 'bottom']),
// Header style settings (decoupled from layout)
body('header_style').optional().isIn(['hero', 'standard', 'minimal', 'none']),
body('hero_divider_style').optional().isIn(['wave', 'straight', 'angle', 'curve', 'none'])
body('hero_divider_style').optional().isIn(['wave', 'straight', 'angle', 'curve', 'none']),
// Hero image anchor position (#162) accepts legacy keywords or "X% Y%" focal point
body('hero_image_anchor').optional().custom(validateHeroImageAnchor)
], async (req, res) => {
try {
logger.debug('Create event request body', { body: req.body });
@@ -242,7 +254,9 @@ router.post('/', adminAuth, requirePermission('events.create'), [
hero_logo_position = 'top',
// Header style settings
header_style = 'standard',
hero_divider_style = 'wave'
hero_divider_style = 'wave',
// Hero image anchor position (#162)
hero_image_anchor = 'center'
} = req.body;
const customerName = getCustomerNameFromPayload(req.body);
@@ -302,10 +316,6 @@ router.post('/', adminAuth, requirePermission('events.create'), [
}
}
// Get event type info for slug generation
const eventTypeInfo = await eventTypeService.getEventTypeForSlug(event_type);
const slugPrefix = eventTypeInfo.slug_prefix || event_type;
// Generate unique slug
const processedEventName = event_name
.toLowerCase()
@@ -326,7 +336,7 @@ router.post('/', adminAuth, requirePermission('events.create'), [
// Generate share link respecting configured format
const shareToken = crypto.randomBytes(16).toString('hex');
const { sharePath, shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken });
const { shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken });
// Hash password with configurable rounds (random placeholder when not required)
const password_hash = requirePassword
@@ -385,7 +395,8 @@ router.post('/', adminAuth, requirePermission('events.create'), [
hero_logo_size: hero_logo_size || 'medium',
hero_logo_position: hero_logo_position || 'top',
header_style: header_style || 'standard',
hero_divider_style: hero_divider_style || 'wave'
hero_divider_style: hero_divider_style || 'wave',
hero_image_anchor: hero_image_anchor || 'center'
}).returning('id');
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
@@ -621,7 +632,7 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), [
body('event_name').optional().trim().notEmpty(),
body('admin_email').optional().isEmail(),
body('is_active').optional().isBoolean(),
body('expires_at').optional().isISO8601(),
body('expires_at').optional({ nullable: true, checkFalsy: true }).isISO8601(),
body('welcome_message').optional({ nullable: true, checkFalsy: true }).trim(),
body('color_theme').optional({ nullable: true }),
body('allow_user_uploads').optional().isBoolean(),
@@ -653,7 +664,7 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), [
body('overlay_protection').optional().isBoolean(),
body('image_quality').optional().isInt({ min: 1, max: 100 }),
body('fragmentation_level').optional().isInt({ min: 1, max: 10 }),
body('password').optional().isString().custom((value, { req }) => {
body('password').optional().isString().custom((value) => {
if (value === undefined || value === null || value === '') {
return true;
}
@@ -669,7 +680,9 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), [
body('hero_logo_position').optional().isIn(['top', 'center', 'bottom']),
// Header style settings (decoupled from layout)
body('header_style').optional().isIn(['hero', 'standard', 'minimal', 'none']),
body('hero_divider_style').optional().isIn(['wave', 'straight', 'angle', 'curve', 'none'])
body('hero_divider_style').optional().isIn(['wave', 'straight', 'angle', 'curve', 'none']),
// Hero image anchor position (#162) accepts legacy keywords or "X% Y%" focal point
body('hero_image_anchor').optional().custom(validateHeroImageAnchor)
], async (req, res) => {
try {
const errors = validationResult(req);
@@ -781,6 +794,17 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), [
updates.password_hash = await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
}
// Enforce expires_at requirement based on app settings
if (Object.prototype.hasOwnProperty.call(updates, 'expires_at')) {
if (!updates.expires_at) {
const fieldReqs = await getEventFieldRequirements();
if (fieldReqs.require_expiration) {
return res.status(400).json({ error: 'Expiration date is required.' });
}
updates.expires_at = null;
}
}
// Format hero logo settings if provided
if (Object.prototype.hasOwnProperty.call(updates, 'hero_logo_visible')) {
updates.hero_logo_visible = formatBoolean(updates.hero_logo_visible);
+65 -23
View File
@@ -121,7 +121,8 @@ router.get('/:slug/info', async (req, res) => {
'hero_logo_position',
'hero_logo_url',
'header_style',
'hero_divider_style'
'hero_divider_style',
'hero_image_anchor'
)
.first();
@@ -174,7 +175,8 @@ router.get('/:slug/info', async (req, res) => {
hero_logo_position: event.hero_logo_position || 'top',
hero_logo_url: event.hero_logo_url || null,
header_style: event.header_style || 'standard',
hero_divider_style: event.hero_divider_style || 'wave'
hero_divider_style: event.hero_divider_style || 'wave',
hero_image_anchor: event.hero_image_anchor || 'center'
});
} catch (error) {
console.error('Error fetching gallery info:', error);
@@ -294,20 +296,36 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
commentMap[c.photo_id] = parseInt(c.comment_count);
});
// Get distinct photo types for this event
const categoryResults = await db('photos')
// Get actual categories used by photos in this event
// This includes both global categories and event-specific ones
const usedCategoryIds = await db('photos')
.where('event_id', req.event.id)
.select('type')
.distinct('type')
.orderBy('type', 'asc');
// Convert types to category-like objects
const categories = categoryResults.map(result => ({
id: result.type,
name: result.type === 'individual' ? 'Individual Photos' : 'Collages',
slug: result.type,
is_global: false
}));
.whereNotNull('category_id')
.distinct('category_id')
.pluck('category_id');
// Fetch category details from photo_categories table
let categories = [];
if (usedCategoryIds.length > 0) {
const categoryDetails = await db('photo_categories')
.whereIn('id', usedCategoryIds)
.select('id', 'name', 'slug', 'is_global', 'hero_photo_id')
.orderBy('name', 'asc');
categories = categoryDetails.map(cat => ({
id: cat.id,
name: cat.name,
slug: cat.slug,
is_global: cat.is_global,
hero_photo_id: cat.hero_photo_id || null
}));
}
// Build a map for quick category lookup
const categoryMap = {};
categories.forEach(cat => {
categoryMap[cat.id] = cat;
});
// Log view
await db('access_logs').insert({
@@ -350,6 +368,7 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
hero_logo_url: req.event.hero_logo_url || null,
header_style: req.event.header_style || 'standard',
hero_divider_style: req.event.hero_divider_style || 'wave',
hero_image_anchor: req.event.hero_image_anchor || 'center',
...protectionSettings
},
categories: categories,
@@ -369,9 +388,9 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
secure_url_template: `/api/secure-images/${req.params.slug}/secure/${photo.id}/{{token}}`,
download_url_template: `/api/secure-images/${req.params.slug}/secure-download/${photo.id}/{{token}}`,
type: photo.type,
category_id: photo.type,
category_name: photo.type === 'individual' ? 'Individual Photos' : 'Collages',
category_slug: photo.type,
category_id: photo.category_id || null,
category_name: photo.category_id && categoryMap[photo.category_id] ? categoryMap[photo.category_id].name : null,
category_slug: photo.category_id && categoryMap[photo.category_id] ? categoryMap[photo.category_id].slug : null,
size: photo.size_bytes,
uploaded_at: photo.uploaded_at,
// Image dimensions for layout calculations
@@ -732,8 +751,34 @@ router.get('/:slug/photo/:photoId',
// Resolve the absolute file path for this photo, supporting both managed and external reference modes
const { resolvePhotoFilePath } = require('../services/photoResolver');
const filePath = resolvePhotoFilePath(req.event, photo);
const fs = require('fs');
let filePath;
try {
filePath = resolvePhotoFilePath(req.event, photo);
} catch (resolveError) {
logger.error('Failed to resolve photo path', {
slug: req.params.slug,
photoId,
eventId: req.event.id,
error: resolveError.message,
photoPath: photo.path,
photoFilename: photo.filename
});
return res.status(404).json({ error: 'Photo file not found' });
}
// Verify file exists before attempting to serve
if (!fs.existsSync(filePath)) {
logger.error('Photo file does not exist at resolved path', {
slug: req.params.slug,
photoId,
eventId: req.event.id,
resolvedPath: filePath,
photoPath: photo.path
});
return res.status(404).json({ error: 'Photo file not found' });
}
// Log access - temporarily disabled for debugging
// await secureImageService.logImageAccess(
@@ -745,14 +790,13 @@ router.get('/:slug/photo/:photoId',
// Handle video streaming with range requests
if (isVideo) {
const fs = require('fs');
const stat = fs.statSync(filePath);
const fileSize = stat.size;
const range = req.headers.range;
if (range) {
// Parse range header
const parts = range.replace(/bytes=/, "").split("-");
const parts = range.replace(/bytes=/, '').split('-');
const start = parseInt(parts[0], 10);
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
const chunksize = (end - start) + 1;
@@ -789,7 +833,6 @@ router.get('/:slug/photo/:photoId',
// Generate ETag based on photo id, modification time, and watermark settings
// This ensures cache invalidation when watermark settings change
const fs = require('fs');
const stat = fs.statSync(filePath);
const watermarkHash = watermarkSettings?.enabled
? `-wm${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}`
@@ -806,7 +849,6 @@ router.get('/:slug/photo/:photoId',
if (photo.watermark_path) {
const watermarkFilePath = path.join(getStoragePath(), photo.watermark_path);
try {
const fs = require('fs');
// Check if pre-generated watermark file exists
if (fs.existsSync(watermarkFilePath)) {
res.set({
@@ -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<EventCategoryManagerProps> = ({ even
const { t } = useTranslation();
const [isAdding, setIsAdding] = useState(false);
const [newCategoryName, setNewCategoryName] = useState('');
const [heroPickerCategoryId, setHeroPickerCategoryId] = useState<number | null>(null);
// Fetch categories for this event
const { data: categories = [], isLoading } = useQuery({
@@ -22,16 +24,23 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ 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<EventCategoryManagerProps> = ({ 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<EventCategoryManagerProps> = ({ even
}
};
const handleSelectHeroPhoto = (categoryId: number, photoId: number) => {
heroMutation.mutate({ categoryId, photoId });
};
const handleRemoveHeroPhoto = (categoryId: number) => {
heroMutation.mutate({ categoryId, photoId: null });
};
if (isLoading) {
return (
<div className="flex justify-center items-center py-4">
@@ -135,45 +166,172 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
{t('categories.noEventSpecificCategories')}
</p>
) : (
<div className="space-y-1">
{eventCategories.map((category) => (
<div
key={category.id}
className="flex items-center justify-between px-3 py-2 bg-neutral-50 rounded-md"
>
<span className="text-sm text-neutral-700">{category.name}</span>
<button
onClick={() => handleDelete(category)}
className="p-1 text-neutral-400 hover:text-red-600 transition-colors"
title={t('categories.deleteCategoryTitle')}
disabled={deleteMutation.isPending}
<div className="space-y-2">
{eventCategories.map((category) => {
const heroPhoto = category.hero_photo_id
? photos.find(p => p.id === category.hero_photo_id)
: null;
return (
<div
key={category.id}
className="flex items-center justify-between px-3 py-2 bg-neutral-50 rounded-md"
>
{deleteMutation.isPending ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<X className="w-3 h-3" />
)}
</button>
</div>
))}
<div className="flex items-center gap-3 flex-1 min-w-0">
{/* Hero photo thumbnail */}
<button
onClick={() => 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 ? (
<AuthenticatedImage
src={heroPhoto.thumbnail_url || heroPhoto.url}
alt={category.name}
className="w-full h-full object-cover"
/>
) : category.hero_photo_id ? (
<ImageIcon className="w-4 h-4 text-primary-400" />
) : (
<ImageIcon className="w-4 h-4 text-neutral-300" />
)}
</button>
<span className="text-sm text-neutral-700 truncate">{category.name}</span>
</div>
<button
onClick={() => handleDelete(category)}
className="p-1 text-neutral-400 hover:text-red-600 transition-colors"
title={t('categories.deleteCategoryTitle')}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<X className="w-3 h-3" />
)}
</button>
</div>
);
})}
</div>
)}
{/* Show available global categories */}
<div className="mt-4 pt-3 border-t border-neutral-200">
<p className="text-xs font-medium text-neutral-500 mb-2">{t('categories.globalCategoriesAlwaysAvailable')}</p>
<div className="flex flex-wrap gap-1">
<div className="space-y-2">
{categories
.filter(cat => cat.is_global)
.map(cat => (
<span key={cat.id} className="px-2 py-1 text-xs bg-neutral-100 text-neutral-600 rounded">
{cat.name}
</span>
))}
.map(cat => {
const heroPhoto = cat.hero_photo_id
? photos.find(p => p.id === cat.hero_photo_id)
: null;
return (
<div key={cat.id} className="flex items-center gap-3 px-3 py-2 bg-neutral-50 rounded-md">
<button
onClick={() => 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 ? (
<AuthenticatedImage
src={heroPhoto.thumbnail_url || heroPhoto.url}
alt={cat.name}
className="w-full h-full object-cover"
/>
) : cat.hero_photo_id ? (
<ImageIcon className="w-4 h-4 text-primary-400" />
) : (
<ImageIcon className="w-4 h-4 text-neutral-300" />
)}
</button>
<span className="text-sm text-neutral-600">{cat.name}</span>
</div>
);
})}
</div>
</div>
{/* Hero Photo Picker Modal */}
{heroPickerCategoryId !== null && (
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
<Card className="max-w-4xl w-full max-h-[90vh] overflow-hidden">
<div className="p-6 border-b border-neutral-200">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold">{t('categories.setCoverPhoto')}</h2>
<button
onClick={() => setHeroPickerCategoryId(null)}
className="p-2 hover:bg-neutral-100 rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
<div className="p-6 overflow-y-auto max-h-[calc(90vh-180px)]">
{photos.length === 0 ? (
<p className="text-center text-neutral-500 py-8">
{t('events.noPhotosAvailable')}
</p>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
{photos.map((photo) => {
const currentCategory = categories.find(c => c.id === heroPickerCategoryId);
const isSelected = photo.id === currentCategory?.hero_photo_id;
return (
<div
key={photo.id}
onClick={() => 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'
}`}
>
<div className="aspect-square bg-neutral-100">
<AuthenticatedImage
src={photo.thumbnail_url || photo.url}
alt={photo.filename}
className="w-full h-full object-cover"
/>
</div>
{isSelected && (
<div className="absolute top-2 right-2 bg-primary-500 text-white rounded-full p-1">
<Check className="w-4 h-4" />
</div>
)}
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/60 to-transparent p-2">
<p className="text-white text-xs truncate">{photo.filename}</p>
</div>
</div>
);
})}
</div>
)}
</div>
<div className="p-6 border-t border-neutral-200 flex justify-between gap-3">
{categories.find(c => c.id === heroPickerCategoryId)?.hero_photo_id && (
<Button
variant="outline"
onClick={() => handleRemoveHeroPhoto(heroPickerCategoryId)}
disabled={heroMutation.isPending}
>
{t('categories.removeCoverPhoto')}
</Button>
)}
<div className="flex-1" />
<Button
variant="outline"
onClick={() => setHeroPickerCategoryId(null)}
>
{t('common.cancel')}
</Button>
</div>
</Card>
</div>
)}
</div>
);
};
EventCategoryManager.displayName = 'EventCategoryManager';
EventCategoryManager.displayName = 'EventCategoryManager';
@@ -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<FocalPointPickerProps> = ({
imageUrl,
currentValue,
onChange,
slug,
}) => {
const { t } = useTranslation();
const containerRef = useRef<HTMLDivElement>(null);
const [x, y] = parseAnchor(currentValue);
const handleClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
const px = Math.round(Math.min(100, Math.max(0, ((e.clientX - rect.left) / rect.width) * 100)));
const py = Math.round(Math.min(100, Math.max(0, ((e.clientY - rect.top) / rect.height) * 100)));
onChange(`${px}% ${py}%`);
},
[onChange],
);
const presets: { label: string; value: string }[] = [
{ label: t('events.heroImageAnchorTop', 'Top'), value: '50% 0%' },
{ label: t('events.heroImageAnchorCenter', 'Center'), value: '50% 50%' },
{ label: t('events.heroImageAnchorBottom', 'Bottom'), value: '50% 100%' },
];
return (
<div>
{/* Clickable image preview */}
<div
ref={containerRef}
onClick={handleClick}
className="relative w-full h-48 rounded-lg overflow-hidden cursor-crosshair border border-neutral-300"
>
<AuthenticatedImage
src={imageUrl}
alt={t('events.heroPreview', 'Hero preview')}
className="w-full h-full object-cover pointer-events-none"
style={{ objectPosition: `${x}% ${y}%` }}
slug={slug}
/>
{/* Crosshair marker */}
<div
className="absolute pointer-events-none"
style={{ left: `${x}%`, top: `${y}%`, transform: 'translate(-50%, -50%)' }}
>
{/* Outer ring (dark) for contrast on light areas */}
<div className="w-6 h-6 rounded-full border-2 border-black/50" />
{/* Inner ring (white) for contrast on dark areas */}
<div className="absolute inset-0 m-px w-6 h-6 rounded-full border-2 border-white" />
{/* Center dot */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-1.5 h-1.5 rounded-full bg-white shadow-sm" />
</div>
</div>
{/* Coordinate label */}
<span className="absolute bottom-1.5 right-1.5 px-1.5 py-0.5 text-[10px] font-mono leading-none text-white bg-black/60 rounded">
{x}% {y}%
</span>
</div>
{/* Preset buttons */}
<div className="flex gap-2 mt-2">
{presets.map((p) => (
<Button
key={p.value}
type="button"
variant="outline"
size="sm"
onClick={() => onChange(p.value)}
className={
keywordToPercent(currentValue) === p.value
? 'bg-primary-50 border-primary-300 text-primary-700'
: ''
}
>
{p.label}
</Button>
))}
</div>
</div>
);
};
FocalPointPicker.displayName = 'FocalPointPicker';
+126 -37
View File
@@ -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<GalleryPreviewProps> = ({
? '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 (
<svg className="w-full h-6" viewBox="0 0 1200 120" preserveAspectRatio="none">
<path d="M0,60 C150,90 350,30 600,60 C850,90 1050,30 1200,60 L1200,120 L0,120 Z" fill={bgColor} />
</svg>
);
case 'curve':
return (
<svg className="w-full h-6" viewBox="0 0 1200 120" preserveAspectRatio="none">
<path d="M0,120 Q600,0 1200,120 L1200,120 L0,120 Z" fill={bgColor} />
</svg>
);
case 'angle':
return (
<svg className="w-full h-6" viewBox="0 0 1200 120" preserveAspectRatio="none">
<path d="M0,120 L600,40 L1200,120 L1200,120 L0,120 Z" fill={bgColor} />
</svg>
);
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<GalleryPreviewProps> = ({
};
return (
<div
<div
className={`bg-white rounded-lg shadow-sm overflow-hidden ${className}`}
style={{
backgroundColor: theme.backgroundColor || '#ffffff',
@@ -177,45 +210,101 @@ export const GalleryPreview: React.FC<GalleryPreviewProps> = ({
fontFamily: theme.fontFamily || 'Inter, sans-serif',
}}
>
{/* Preview Header */}
<div
className="px-4 py-3 border-b space-y-2"
style={{
borderColor: theme.primaryColor ? `${theme.primaryColor}20` : '#e5e7eb',
}}
>
<div className={`flex items-center gap-3 ${brandFlexClass}`}>
{showLogo && (
resolvedLogoUrl ? (
<img
src={resolvedLogoUrl}
alt={brandName}
className="h-8 w-auto object-contain"
/>
) : (
<div className="h-8 w-8 rounded-full bg-neutral-200 flex items-center justify-center">
<Camera className="w-4 h-4 text-neutral-500" />
</div>
)
)}
{showText && (
<div>
<p className="text-sm font-semibold leading-tight">{brandName}</p>
{brandTagline && (
<p className="text-xs text-neutral-500 leading-tight">{brandTagline}</p>
{/* Hero Header - shown when headerStyle is 'hero' */}
{isHeroHeader && (
<div
className="relative text-white overflow-hidden"
style={{
backgroundColor: theme.accentColor || theme.primaryColor || '#22c55e',
backgroundImage: 'url("data:image/svg+xml,%3Csvg width=\'40\' height=\'40\' viewBox=\'0 0 40 40\' xmlns=\'http://www.w3.org/2000/svg\'%3E%3Cg fill=\'%23ffffff\' fill-opacity=\'0.03\'%3E%3Cpath d=\'M0 40L40 0H20L0 20M40 40V20L20 40\'/%3E%3C/g%3E%3C/svg%3E")',
}}
>
<div className="py-8 px-4 relative z-10">
<div className="text-center max-w-md mx-auto">
{/* Logo in Hero */}
{showLogo && (
<div className="mb-3">
{resolvedLogoUrl ? (
<img
src={resolvedLogoUrl}
alt={brandName}
className="h-10 w-auto object-contain mx-auto"
style={{ filter: 'brightness(0) invert(1) drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))' }}
/>
) : (
<div className="h-10 w-10 rounded-full bg-white/20 flex items-center justify-center mx-auto">
<Camera className="w-5 h-5 text-white" />
</div>
)}
</div>
)}
{/* Event Name */}
<h1
className="text-xl font-bold mb-2"
style={{
fontFamily: theme.headingFontFamily || theme.fontFamily || 'Inter, sans-serif',
textShadow: '0 2px 4px rgba(0, 0, 0, 0.3)'
}}
>
Sample Event
</h1>
{/* Event Date */}
<div className="flex items-center justify-center text-white/80 text-sm" style={{ textShadow: '0 1px 3px rgba(0, 0, 0, 0.3)' }}>
<Calendar className="w-4 h-4 mr-1" />
<span>January 15, 2026</span>
</div>
</div>
)}
{!showLogo && !showText && (
<p className="text-sm font-semibold">{brandName}</p>
)}
</div>
{/* Divider */}
<div className="absolute bottom-0 left-0 right-0">
{renderHeroDivider()}
</div>
</div>
<div className="text-xs text-neutral-500 flex justify-between">
<span>Gallery preview</span>
<span className="capitalize">{activeLayout} layout</span>
)}
{/* Standard Header - shown when headerStyle is NOT 'hero' */}
{!isHeroHeader && (
<div
className="px-4 py-3 border-b space-y-2"
style={{
borderColor: theme.primaryColor ? `${theme.primaryColor}20` : '#e5e7eb',
}}
>
<div className={`flex items-center gap-3 ${brandFlexClass}`}>
{showLogo && (
resolvedLogoUrl ? (
<img
src={resolvedLogoUrl}
alt={brandName}
className="h-8 w-auto object-contain"
/>
) : (
<div className="h-8 w-8 rounded-full bg-neutral-200 flex items-center justify-center">
<Camera className="w-4 h-4 text-neutral-500" />
</div>
)
)}
{showText && (
<div>
<p className="text-sm font-semibold leading-tight">{brandName}</p>
{brandTagline && (
<p className="text-xs text-neutral-500 leading-tight">{brandTagline}</p>
)}
</div>
)}
{!showLogo && !showText && (
<p className="text-sm font-semibold">{brandName}</p>
)}
</div>
</div>
)}
{/* Layout info bar */}
<div className="px-4 py-1 border-b text-xs text-neutral-500 flex justify-between" style={{ borderColor: theme.primaryColor ? `${theme.primaryColor}20` : '#e5e7eb' }}>
<span>Gallery preview</span>
<span className="capitalize">{isHeroHeader ? `Hero + ${activeLayout}` : `${activeLayout} layout`}</span>
</div>
{/* Preview Content */}
<div className="p-4" style={{ maxHeight: '400px', overflowY: 'auto' }}>
{renderLayout()}
+24 -6
View File
@@ -96,12 +96,30 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ 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);
+1
View File
@@ -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';
@@ -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(<ProtectedImage {...defaultProps} />);
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(<ProtectedImage {...defaultProps} />);
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(
<ProtectedImage
{...defaultProps}
<ProtectedImage
{...defaultProps}
protectionLevel="enhanced"
onProtectionViolation={onViolation}
/>
@@ -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(
<ProtectedImage
{...defaultProps}
<ProtectedImage
{...defaultProps}
watermarkText="Test Watermark"
protectionLevel="standard"
/>
);
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(
<ProtectedImage
{...defaultProps}
<ProtectedImage
{...defaultProps}
fragmentGrid={true}
gridSize={4}
protectionLevel="enhanced"
@@ -121,19 +139,20 @@ describe('ProtectedImage', () => {
);
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(
<ProtectedImage
{...defaultProps}
<ProtectedImage
{...defaultProps}
protectionLevel="maximum"
onProtectionViolation={onViolation}
/>
@@ -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(
<ProtectedImage
{...defaultProps}
<ProtectedImage
{...defaultProps}
onProtectionViolation={onViolation}
fallbackSrc="/fallback.jpg"
/>
);
@@ -182,8 +212,8 @@ describe('ProtectedImage', () => {
it('applies invisible watermark for enhanced protection', async () => {
render(
<ProtectedImage
{...defaultProps}
<ProtectedImage
{...defaultProps}
watermarkText="Hidden"
invisibleWatermark={true}
protectionLevel="enhanced"
@@ -191,18 +221,19 @@ describe('ProtectedImage', () => {
);
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(
<ProtectedImage
{...defaultProps}
<ProtectedImage
{...defaultProps}
fragmentGrid={true}
scrambleFragments={true}
protectionLevel="maximum"
@@ -210,27 +241,29 @@ describe('ProtectedImage', () => {
);
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(
<ProtectedImage
{...defaultProps}
<ProtectedImage
{...defaultProps}
protectionLevel="maximum"
/>
);
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();
});
});
});
@@ -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<GalleryLayoutProps> = ({
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)
@@ -584,6 +584,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
<GalleryLayout
event={event}
brandingSettings={brandingSettings}
headerStyle={data?.event?.header_style || theme.headerStyle}
showLogout={true}
onLogout={logout}
showDownloadAll={!showSidebar && allowDownloads}
@@ -696,6 +697,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
heroLogoPosition={data?.event?.hero_logo_position || 'top'}
headerStyle={data?.event?.header_style || theme.headerStyle}
heroDividerStyle={data?.event?.hero_divider_style || theme.heroDividerStyle || 'wave'}
heroImageAnchor={data?.event?.hero_image_anchor || 'center'}
/>
</div>
@@ -27,6 +27,8 @@ interface HeroHeaderProps {
useEnhancedProtection?: boolean;
useCanvasRendering?: boolean;
onScrollToContent?: () => void;
// Hero image anchor position (#162) keyword or "X% Y%" focal point
heroImageAnchor?: string;
}
export const HeroHeader: React.FC<HeroHeaderProps> = ({
@@ -45,7 +47,8 @@ export const HeroHeader: React.FC<HeroHeaderProps> = ({
protectionLevel = 'standard',
useEnhancedProtection = false,
useCanvasRendering = false,
onScrollToContent
onScrollToContent,
heroImageAnchor = 'center'
}) => {
const { t } = useTranslation();
const { format } = useLocalizedDate();
@@ -138,6 +141,7 @@ export const HeroHeader: React.FC<HeroHeaderProps> = ({
fallbackSrc={heroPhoto.thumbnail_url || undefined}
alt={heroPhoto.filename}
className="w-full h-full object-cover"
style={{ objectPosition: heroImageAnchor }}
isGallery={true}
slug={slug}
photoId={heroPhoto.id}
@@ -60,6 +60,8 @@ interface PhotoGridWithLayoutsProps {
// Header style (decoupled from layout)
headerStyle?: HeaderStyleType;
heroDividerStyle?: HeroDividerStyle;
// Hero image anchor position (#162) keyword or "X% Y%" focal point
heroImageAnchor?: string;
}
export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
@@ -89,7 +91,8 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
heroLogoSize = 'medium',
heroLogoPosition = 'top',
headerStyle,
heroDividerStyle = 'wave'
heroDividerStyle = 'wave',
heroImageAnchor = 'center'
}) => {
const { t } = useTranslation();
const { theme } = useTheme();
@@ -108,7 +111,7 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
// 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<PhotoGridWithLayoutsProps> = ({
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<PhotoGridWithLayoutsProps> = ({
protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection}
useCanvasRendering={useCanvasRendering}
heroImageAnchor={heroImageAnchor}
/>
)}
+12 -1
View File
@@ -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",
+12 -1
View File
@@ -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",
+53 -8
View File
@@ -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<FeedbackSettingsType>({
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 (
<div className="ml-6 mt-2">
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('events.heroImageAnchor', 'Hero Image Crop Position')}
</label>
<p className="text-xs text-neutral-500 mb-2">
{t('events.heroImageAnchorDescription', 'Click on the image to set the focal point for cropping.')}
</p>
<FocalPointPicker
imageUrl={heroImageUrl}
currentValue={editForm.hero_image_anchor}
onChange={(value) => setEditForm(prev => ({ ...prev, hero_image_anchor: value }))}
slug={event.slug}
/>
</div>
);
})()}
<div>
<label className="flex items-start gap-2">
<input
@@ -1446,7 +1491,7 @@ export const EventDetailsPage: React.FC = () => {
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'));
}
}}
@@ -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<PhotoCategory> {
const response = await api.put<PhotoCategory>(`/admin/categories/${id}/hero`, { hero_photo_id: heroPhotoId });
return response.data;
},
// Delete a category
async deleteCategory(id: number): Promise<void> {
await api.delete(`/admin/categories/${id}`);
+5
View File
@@ -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[];
-1
View File
@@ -43,7 +43,6 @@ export interface GalleryLayoutSettings {
// Hero specific
heroImageId?: number;
heroImagePosition?: 'top' | 'center' | 'bottom';
heroOverlayOpacity?: number;
// Mosaic specific