Merge pull request #164 from the-luap/feat/new-features
feat: gallery layouts, hero customization, bulk categories & event types
This commit is contained in:
@@ -7,12 +7,27 @@
|
||||
[](https://www.docker.com/)
|
||||
[](https://nodejs.org/)
|
||||
[](https://reactjs.org/)
|
||||
|
||||
[Homepage](https://www.picpeak.app) · [Live Demo](https://demo.picpeak.app) · [Documentation](DEPLOYMENT_GUIDE.md)
|
||||
</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.
|
||||
|
||||

|
||||
|
||||
## 🎮 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();
|
||||
});
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
@@ -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()}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -43,7 +43,6 @@ export interface GalleryLayoutSettings {
|
||||
|
||||
// Hero specific
|
||||
heroImageId?: number;
|
||||
heroImagePosition?: 'top' | 'center' | 'bottom';
|
||||
heroOverlayOpacity?: number;
|
||||
|
||||
// Mosaic specific
|
||||
|
||||
Reference in New Issue
Block a user