feat: implement 4 new features with bug fixes and refactoring plan

## Features Implemented

### 1. Event Rename Functionality
- Add EventRenameDialog component with live slug preview
- Create eventRenameService for safe event renaming
- Add slug_redirects table for old URL redirects
- Support optional email notification on rename
- Fix date formatting in slug (YYYY-MM-DD format)

### 2. Optional Event Contact Fields
- Add settings to make customer name/email/admin email optional
- Create migration for field requirement settings
- Update CreateEventPage forms to show "(optional)" labels
- Fix boolean parsing in publicSettings.js

### 3. Photo Filtering & Export
- Add PhotoFilterPanel with rating/likes/favorites/comments filters
- Create PhotoExportMenu with ZIP/metadata/XMP export options
- Add photoExportService with Lightroom XMP sidecar generation
- Create photoFilterBuilder utility for query construction
- Wire up photo selection to export button via onSelectionChange

### 4. Custom CSS Gallery Templates
- Add CssTemplateEditor component with 3 template slots
- Create cssSanitizer utility blocking XSS vectors
- Add gallery CSS endpoint for template delivery
- Integrate Custom CSS tab into Settings page
- Include default "Elegant Dark" template

## Bug Fixes
- Fix event rename date formatting (was showing full Date string)
- Fix common.optional translation key missing in locales
- Fix photo export button staying disabled when photos selected
- Fix authService import missing in SettingsPage

## Documentation
- Add comprehensive REFACTORING_PLAN.md for codebase improvement
- Add test specification documents for all features
- Add feature documentation for CSS templates

## Database Migrations
- 049_add_slug_redirects.js
- 050_add_optional_event_fields_settings.js
- 051_add_photo_filter_indexes.js
- 052_add_css_templates.js
This commit is contained in:
Paul Nothaft
2026-01-02 09:56:19 +01:00
parent 64ceb20431
commit 77a4bfd499
38 changed files with 7989 additions and 58 deletions
@@ -0,0 +1,21 @@
/**
* Migration: Add slug_redirects table for event rename feature
* This table stores old slugs that should redirect to new slugs
*/
exports.up = function(knex) {
return knex.schema.createTable('slug_redirects', (table) => {
table.increments('id').primary();
table.string('old_slug', 255).notNullable().unique();
table.string('new_slug', 255).notNullable();
table.integer('event_id').references('id').inTable('events').onDelete('CASCADE');
table.timestamp('created_at').defaultTo(knex.fn.now());
// Index for fast lookup
table.index('old_slug');
});
};
exports.down = function(knex) {
return knex.schema.dropTableIfExists('slug_redirects');
};
@@ -0,0 +1,33 @@
/**
* Migration: Add optional event fields settings
* These settings control whether customer name, customer email, and admin email
* are required when creating new events.
*/
exports.up = async function(knex) {
const settings = [
{ setting_key: 'event_require_customer_name', setting_value: JSON.stringify(true), setting_type: 'boolean' },
{ setting_key: 'event_require_customer_email', setting_value: JSON.stringify(true), setting_type: 'boolean' },
{ setting_key: 'event_require_admin_email', setting_value: JSON.stringify(true), setting_type: 'boolean' }
];
for (const setting of settings) {
const exists = await knex('app_settings').where('setting_key', setting.setting_key).first();
if (!exists) {
await knex('app_settings').insert({
...setting,
updated_at: knex.fn.now()
});
}
}
};
exports.down = function(knex) {
return knex('app_settings')
.whereIn('setting_key', [
'event_require_customer_name',
'event_require_customer_email',
'event_require_admin_email'
])
.del();
};
@@ -0,0 +1,100 @@
/**
* Migration: Add indexes for photo filtering performance
* These indexes optimize queries that filter by feedback metrics
*/
exports.up = async function(knex) {
// Add comment_count column if it doesn't exist
const hasCommentCount = await knex.schema.hasColumn('photos', 'comment_count');
if (!hasCommentCount) {
await knex.schema.alterTable('photos', (table) => {
table.integer('comment_count').defaultTo(0);
});
}
// Add indexes for common filter queries
// Note: PostgreSQL supports partial indexes, SQLite does not
const client = knex.client.config.client;
if (client === 'pg' || client === 'postgresql') {
// Partial indexes for PostgreSQL
await knex.raw(`
CREATE INDEX IF NOT EXISTS idx_photos_rating_filter
ON photos(event_id, average_rating)
WHERE average_rating > 0
`);
await knex.raw(`
CREATE INDEX IF NOT EXISTS idx_photos_likes_filter
ON photos(event_id, like_count)
WHERE like_count > 0
`);
await knex.raw(`
CREATE INDEX IF NOT EXISTS idx_photos_favorites_filter
ON photos(event_id, favorite_count)
WHERE favorite_count > 0
`);
await knex.raw(`
CREATE INDEX IF NOT EXISTS idx_photos_comments_filter
ON photos(event_id, comment_count)
WHERE comment_count > 0
`);
} else {
// Regular indexes for SQLite
await knex.raw(`
CREATE INDEX IF NOT EXISTS idx_photos_rating_filter
ON photos(event_id, average_rating)
`);
await knex.raw(`
CREATE INDEX IF NOT EXISTS idx_photos_likes_filter
ON photos(event_id, like_count)
`);
await knex.raw(`
CREATE INDEX IF NOT EXISTS idx_photos_favorites_filter
ON photos(event_id, favorite_count)
`);
await knex.raw(`
CREATE INDEX IF NOT EXISTS idx_photos_comments_filter
ON photos(event_id, comment_count)
`);
}
// Create export_jobs table for tracking large exports
const hasExportJobs = await knex.schema.hasTable('export_jobs');
if (!hasExportJobs) {
await knex.schema.createTable('export_jobs', (table) => {
table.increments('id').primary();
table.string('job_id', 50).unique().notNullable();
table.integer('event_id').references('id').inTable('events').onDelete('CASCADE');
table.integer('admin_user_id').references('id').inTable('admin_users').onDelete('SET NULL');
table.string('format', 20).notNullable();
table.string('status', 20).defaultTo('pending');
table.integer('progress').defaultTo(0);
table.integer('total_photos');
table.json('options');
table.string('file_path', 500);
table.bigInteger('file_size');
table.text('error_message');
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('completed_at');
});
}
};
exports.down = async function(knex) {
// Drop indexes
await knex.raw('DROP INDEX IF EXISTS idx_photos_rating_filter');
await knex.raw('DROP INDEX IF EXISTS idx_photos_likes_filter');
await knex.raw('DROP INDEX IF EXISTS idx_photos_favorites_filter');
await knex.raw('DROP INDEX IF EXISTS idx_photos_comments_filter');
// Drop export_jobs table
await knex.schema.dropTableIfExists('export_jobs');
// Note: We don't remove comment_count column as it might have data
};
@@ -0,0 +1,192 @@
/**
* Migration: Add CSS Templates feature
* Creates css_templates table and adds css_template_id to events table
*/
// Default CSS template content
const DEFAULT_CSS_TEMPLATE = `/*
* PicPeak Custom CSS Template: Elegant Dark
*
* Available CSS Custom Properties:
* --gallery-bg: Background color
* --gallery-text: Primary text color
* --gallery-accent: Accent/highlight color
* --gallery-border: Border color
* --gallery-shadow: Box shadow value
* --gallery-radius: Border radius value
* --gallery-spacing: Base spacing unit
*/
/* ===== Base Theme Variables ===== */
.gallery-page {
--gallery-bg: #1a1a2e;
--gallery-bg-secondary: #16213e;
--gallery-text: #eaeaea;
--gallery-text-muted: #8b8b9a;
--gallery-accent: #e94560;
--gallery-accent-hover: #ff6b6b;
--gallery-border: #2d2d44;
--gallery-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
--gallery-radius: 12px;
--gallery-spacing: 16px;
}
/* ===== Page Background ===== */
.gallery-page {
background: linear-gradient(135deg, var(--gallery-bg) 0%, var(--gallery-bg-secondary) 100%);
min-height: 100vh;
color: var(--gallery-text);
}
/* ===== Gallery Header ===== */
.gallery-header {
background: rgba(22, 33, 62, 0.8);
backdrop-filter: blur(10px);
border-bottom: 1px solid var(--gallery-border);
padding: calc(var(--gallery-spacing) * 2);
}
.gallery-title {
color: var(--gallery-text);
font-size: 2rem;
font-weight: 700;
letter-spacing: -0.02em;
}
/* ===== Photo Grid ===== */
.photo-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--gallery-spacing);
padding: calc(var(--gallery-spacing) * 2);
}
/* ===== Photo Cards ===== */
.photo-card {
background: var(--gallery-bg-secondary);
border-radius: var(--gallery-radius);
overflow: hidden;
transition: transform 0.3s ease, box-shadow 0.3s ease;
border: 1px solid var(--gallery-border);
}
.photo-card:hover {
transform: translateY(-4px);
box-shadow: var(--gallery-shadow);
}
.photo-card img {
width: 100%;
height: 200px;
object-fit: cover;
transition: transform 0.3s ease;
}
.photo-card:hover img {
transform: scale(1.05);
}
/* ===== Buttons ===== */
.gallery-btn {
background: var(--gallery-accent);
color: white;
border: none;
border-radius: calc(var(--gallery-radius) / 2);
padding: calc(var(--gallery-spacing) / 2) var(--gallery-spacing);
font-weight: 600;
cursor: pointer;
transition: background 0.2s ease, transform 0.2s ease;
}
.gallery-btn:hover {
background: var(--gallery-accent-hover);
transform: translateY(-2px);
}
/* ===== Lightbox ===== */
.lightbox-overlay {
background: rgba(10, 10, 20, 0.95);
backdrop-filter: blur(20px);
}
/* ===== Responsive Adjustments ===== */
@media (max-width: 768px) {
.gallery-page {
--gallery-spacing: 12px;
}
.photo-grid {
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
}
.gallery-title {
font-size: 1.5rem;
}
}`;
exports.up = async function(knex) {
// Create css_templates table
const hasTable = await knex.schema.hasTable('css_templates');
if (!hasTable) {
await knex.schema.createTable('css_templates', (table) => {
table.increments('id').primary();
table.integer('slot_number').notNullable();
table.string('name', 50).notNullable().defaultTo('Untitled');
table.text('css_content').notNullable().defaultTo('');
table.boolean('is_enabled').notNullable().defaultTo(false);
table.boolean('is_default').notNullable().defaultTo(false);
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());
table.unique('slot_number');
});
// Insert default templates
await knex('css_templates').insert([
{
slot_number: 1,
name: 'Elegant Dark',
css_content: DEFAULT_CSS_TEMPLATE,
is_enabled: true,
is_default: true
},
{
slot_number: 2,
name: 'Untitled',
css_content: '',
is_enabled: false,
is_default: false
},
{
slot_number: 3,
name: 'Untitled',
css_content: '',
is_enabled: false,
is_default: false
}
]);
}
// Add css_template_id to events table
const hasColumn = await knex.schema.hasColumn('events', 'css_template_id');
if (!hasColumn) {
await knex.schema.alterTable('events', (table) => {
table.integer('css_template_id').references('id').inTable('css_templates').onDelete('SET NULL');
});
}
};
exports.down = async function(knex) {
// Remove css_template_id from events table
const hasColumn = await knex.schema.hasColumn('events', 'css_template_id');
if (hasColumn) {
await knex.schema.alterTable('events', (table) => {
table.dropColumn('css_template_id');
});
}
// Drop css_templates table
await knex.schema.dropTableIfExists('css_templates');
};
// Export default template for use in reset functionality
module.exports.DEFAULT_CSS_TEMPLATE = DEFAULT_CSS_TEMPLATE;
+3
View File
@@ -432,6 +432,9 @@ app.use('/api/admin/feedback', require('./src/routes/adminFeedback'));
app.use('/api/admin/image-security', require('./src/routes/adminImageSecurity'));
app.use('/api/admin/thumbnails', require('./src/routes/adminThumbnails'));
app.use('/api/admin/photos', require('./src/routes/adminPhotos'));
app.use('/api/admin/photo-export', require('./src/routes/adminPhotoExport'));
app.use('/api/admin/css-templates', require('./src/routes/adminCssTemplates'));
app.use('/api/admin/events', require('./src/routes/adminEventRename'));
app.use('/api/public/settings', require('./src/routes/publicSettings'));
app.use('/api/public', require('./src/routes/publicCMS'));
app.use('/api/images', require('./src/routes/protectedImages'));
+194
View File
@@ -0,0 +1,194 @@
/**
* Admin CSS Templates Routes
* Handles CRUD operations for custom CSS gallery templates
*/
const express = require('express');
const router = express.Router();
const { body, param, validationResult } = require('express-validator');
const { db, withRetry } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { sanitizeCSS, validateCSS, MAX_CSS_SIZE } = require('../utils/cssSanitizer');
const { DEFAULT_CSS_TEMPLATE } = require('../../migrations/core/052_add_css_templates');
/**
* GET /admin/css-templates
* Get all CSS templates
*/
router.get('/', adminAuth, async (req, res) => {
try {
const templates = await withRetry(() =>
db('css_templates').orderBy('slot_number')
);
res.json({ success: true, templates });
} catch (error) {
console.error('Get CSS templates error:', error);
res.status(500).json({ error: 'Failed to fetch templates' });
}
});
/**
* GET /admin/css-templates/enabled
* Get only enabled templates (for event form dropdown)
*/
router.get('/enabled', adminAuth, async (req, res) => {
try {
const templates = await withRetry(() =>
db('css_templates')
.where({ is_enabled: true })
.select('id', 'name', 'slot_number')
.orderBy('slot_number')
);
res.json({ success: true, templates });
} catch (error) {
console.error('Get enabled templates error:', error);
res.status(500).json({ error: 'Failed to fetch templates' });
}
});
/**
* GET /admin/css-templates/:slotNumber
* Get a specific template by slot number
*/
router.get('/:slotNumber', adminAuth, [
param('slotNumber').isInt({ min: 1, max: 3 })
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { slotNumber } = req.params;
const template = await withRetry(() =>
db('css_templates')
.where({ slot_number: parseInt(slotNumber) })
.first()
);
if (!template) {
return res.status(404).json({ error: 'Template not found' });
}
res.json({ success: true, template });
} catch (error) {
console.error('Get template error:', error);
res.status(500).json({ error: 'Failed to fetch template' });
}
});
/**
* PUT /admin/css-templates/:slotNumber
* Update a template
*/
router.put('/:slotNumber', adminAuth, [
param('slotNumber').isInt({ min: 1, max: 3 }),
body('name').optional().isString().isLength({ max: 50 }),
body('css_content').optional().isString(),
body('is_enabled').optional().isBoolean()
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { slotNumber } = req.params;
const { name, css_content, is_enabled } = req.body;
// Validate CSS size
if (css_content && css_content.length > MAX_CSS_SIZE) {
return res.status(400).json({
error: `CSS content exceeds maximum size of ${MAX_CSS_SIZE / 1024}KB`
});
}
// Validate CSS syntax
if (css_content) {
const validation = validateCSS(css_content);
if (!validation.valid) {
return res.status(400).json({
error: 'Invalid CSS syntax',
details: validation.error
});
}
}
// Sanitize CSS
const { sanitized, warnings } = sanitizeCSS(css_content || '');
const updates = {
updated_at: db.fn.now()
};
if (name !== undefined) {
updates.name = name.substring(0, 50) || 'Untitled';
}
if (css_content !== undefined) {
updates.css_content = sanitized;
}
if (is_enabled !== undefined) {
updates.is_enabled = Boolean(is_enabled);
}
await withRetry(() =>
db('css_templates')
.where({ slot_number: parseInt(slotNumber) })
.update(updates)
);
const template = await withRetry(() =>
db('css_templates')
.where({ slot_number: parseInt(slotNumber) })
.first()
);
res.json({
success: true,
template,
sanitization_warnings: warnings
});
} catch (error) {
console.error('Update template error:', error);
res.status(500).json({ error: 'Failed to update template' });
}
});
/**
* POST /admin/css-templates/:slotNumber/reset
* Reset template to default (only for slot 1)
*/
router.post('/:slotNumber/reset', adminAuth, [
param('slotNumber').isInt({ min: 1, max: 1 }).withMessage('Only template 1 can be reset to default')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
await withRetry(() =>
db('css_templates')
.where({ slot_number: 1 })
.update({
name: 'Elegant Dark',
css_content: DEFAULT_CSS_TEMPLATE,
is_enabled: true,
updated_at: db.fn.now()
})
);
const template = await withRetry(() =>
db('css_templates')
.where({ slot_number: 1 })
.first()
);
res.json({ success: true, template });
} catch (error) {
console.error('Reset template error:', error);
res.status(500).json({ error: 'Failed to reset template' });
}
});
module.exports = router;
+88
View File
@@ -0,0 +1,88 @@
/**
* Admin Event Rename Routes
* Handles event renaming operations
*/
const express = require('express');
const { body, validationResult } = require('express-validator');
const { adminAuth } = require('../middleware/auth-enhanced-v2');
const eventRenameService = require('../services/eventRenameService');
const router = express.Router();
/**
* POST /api/admin/events/:eventId/rename
* Rename an event
*/
router.post('/:eventId/rename', adminAuth, [
body('newEventName')
.trim()
.isLength({ min: 3, max: 100 })
.withMessage('Event name must be between 3 and 100 characters'),
body('resendEmail')
.optional()
.isBoolean()
.withMessage('resendEmail must be a boolean')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ success: false, errors: errors.array() });
}
const { eventId } = req.params;
const { newEventName, resendEmail = false } = req.body;
const result = await eventRenameService.renameEvent(
parseInt(eventId, 10),
newEventName,
resendEmail,
req.admin
);
if (!result.success) {
return res.status(400).json(result);
}
res.json({
success: true,
message: 'Event renamed successfully',
data: result.data
});
} catch (error) {
console.error('Error renaming event:', error);
res.status(500).json({ success: false, error: 'Failed to rename event' });
}
});
/**
* POST /api/admin/events/:eventId/validate-rename
* Validate a potential rename without executing it
*/
router.post('/:eventId/validate-rename', adminAuth, [
body('newEventName')
.trim()
.isLength({ min: 3, max: 100 })
.withMessage('Event name must be between 3 and 100 characters')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ valid: false, errors: errors.array() });
}
const { eventId } = req.params;
const { newEventName } = req.body;
const validation = await eventRenameService.validateRename(
parseInt(eventId, 10),
newEventName
);
res.json(validation);
} catch (error) {
console.error('Error validating rename:', error);
res.status(500).json({ valid: false, error: 'Validation failed' });
}
});
module.exports = router;
+63 -6
View File
@@ -16,6 +16,48 @@ const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwor
const logger = require('../utils/logger');
const { buildShareLinkVariants } = require('../services/shareLinkService');
// Helper to get event field requirements from settings
const getEventFieldRequirements = async () => {
try {
const settings = await db('app_settings')
.whereIn('setting_key', [
'event_require_customer_name',
'event_require_customer_email',
'event_require_admin_email'
])
.select('setting_key', 'setting_value');
const requirements = {
require_customer_name: true,
require_customer_email: true,
require_admin_email: true
};
settings.forEach(s => {
let value = s.setting_value;
if (typeof value === 'string') {
try {
value = JSON.parse(value);
} catch (e) {
value = value === 'true';
}
}
if (s.setting_key === 'event_require_customer_name') requirements.require_customer_name = value;
if (s.setting_key === 'event_require_customer_email') requirements.require_customer_email = value;
if (s.setting_key === 'event_require_admin_email') requirements.require_admin_email = value;
});
return requirements;
} catch (error) {
logger.error('Failed to get event field requirements', { error: error.message });
return {
require_customer_name: true,
require_customer_email: true,
require_admin_email: true
};
}
};
const parseBooleanInput = (value, defaultValue = true) => {
if (value === undefined || value === null) {
return defaultValue;
@@ -97,9 +139,9 @@ router.post('/', adminAuth, [
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
body('event_name').notEmpty().trim(),
body('event_date').isDate(),
body('customer_name').notEmpty().trim(),
body('customer_email').isEmail().normalizeEmail(),
body('admin_email').isEmail().normalizeEmail(),
body('customer_name').optional().trim(),
body('customer_email').optional().isEmail().normalizeEmail(),
body('admin_email').optional().isEmail().normalizeEmail(),
body('require_password').optional().isBoolean(),
body('password').optional().isString().custom((value, { req }) => {
const input = req.body.require_password;
@@ -141,7 +183,10 @@ router.post('/', adminAuth, [
console.error('Validation errors:', errors.array());
return res.status(400).json({ errors: errors.array() });
}
// Get field requirements from settings
const fieldRequirements = await getEventFieldRequirements();
const {
event_type,
event_name,
@@ -174,8 +219,20 @@ router.post('/', adminAuth, [
const customerColumnsAvailable = await hasCustomerContactColumns();
if (!customerName || !customerEmail) {
return res.status(400).json({ error: 'customer_name and customer_email are required' });
// Conditional validation based on settings
const validationErrors = [];
if (fieldRequirements.require_customer_name && !customerName) {
validationErrors.push({ path: 'customer_name', msg: 'Customer name is required' });
}
if (fieldRequirements.require_customer_email && !customerEmail) {
validationErrors.push({ path: 'customer_email', msg: 'Customer email is required' });
}
if (fieldRequirements.require_admin_email && !admin_email) {
validationErrors.push({ path: 'admin_email', msg: 'Admin email is required' });
}
if (validationErrors.length > 0) {
return res.status(400).json({ errors: validationErrors });
}
const requirePassword = parseBooleanInput(requirePasswordInput, true);
+223
View File
@@ -0,0 +1,223 @@
/**
* Admin Photo Export Routes
* Handles filtering and exporting photos based on guest feedback
*/
const express = require('express');
const router = express.Router();
const { body, query, validationResult } = require('express-validator');
const { db, withRetry } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { PhotoFilterBuilder } = require('../utils/photoFilterBuilder');
const { PhotoExportService } = require('../services/photoExportService');
const exportService = new PhotoExportService();
/**
* GET /admin/photos/:eventId/filtered
* Get filtered photos with pagination
*/
router.get('/:eventId/filtered', adminAuth, [
query('min_rating').optional().isFloat({ min: 0, max: 5 }),
query('max_rating').optional().isFloat({ min: 0, max: 5 }),
query('has_likes').optional().isBoolean(),
query('min_likes').optional().isInt({ min: 0 }),
query('has_favorites').optional().isBoolean(),
query('min_favorites').optional().isInt({ min: 0 }),
query('has_comments').optional().isBoolean(),
query('category_id').optional().isInt(),
query('logic').optional().isIn(['AND', 'OR']),
query('sort').optional().isIn(['rating', 'likes', 'favorites', 'date', 'filename']),
query('order').optional().isIn(['asc', 'desc']),
query('page').optional().isInt({ min: 1 }),
query('limit').optional().isInt({ min: 1, max: 100 })
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const eventId = parseInt(req.params.eventId);
// Verify event exists
const event = await withRetry(() =>
db('events').where('id', eventId).first()
);
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
// Parse filter params
const filters = {
min_rating: req.query.min_rating ? parseFloat(req.query.min_rating) : undefined,
max_rating: req.query.max_rating ? parseFloat(req.query.max_rating) : undefined,
has_likes: req.query.has_likes,
min_likes: req.query.min_likes ? parseInt(req.query.min_likes) : undefined,
has_favorites: req.query.has_favorites,
min_favorites: req.query.min_favorites ? parseInt(req.query.min_favorites) : undefined,
has_comments: req.query.has_comments,
category_id: req.query.category_id ? parseInt(req.query.category_id) : undefined,
logic: req.query.logic || 'AND'
};
const sort = req.query.sort || 'date';
const order = req.query.order || 'desc';
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 50;
// Build filtered query
const filterBuilder = new PhotoFilterBuilder(
db('photos')
.leftJoin('categories', 'photos.category_id', 'categories.id')
.select(
'photos.id',
'photos.filename',
'photos.original_filename',
'photos.file_path',
'photos.average_rating',
'photos.feedback_count',
'photos.like_count',
'photos.favorite_count',
'photos.comment_count',
'photos.width',
'photos.height',
'photos.created_at',
'categories.name as category_name'
),
eventId
);
filterBuilder
.applyFilters(filters)
.applySorting(sort, order)
.applyPagination(page, limit);
const photos = await withRetry(() => filterBuilder.getQuery());
// Get count of filtered photos
const countResult = await withRetry(() =>
PhotoFilterBuilder.buildCountQuery(db, eventId, filters)
);
const filteredCount = parseInt(countResult[0]?.count) || 0;
// Get summary counts
const summary = await withRetry(() =>
PhotoFilterBuilder.getSummary(db, eventId)
);
res.json({
success: true,
data: {
photos,
pagination: {
total: summary.total,
filtered: filteredCount,
page,
limit,
pages: Math.ceil(filteredCount / limit)
},
summary
}
});
} catch (error) {
console.error('Filter photos error:', error);
res.status(500).json({ error: 'Failed to filter photos' });
}
});
/**
* GET /admin/photos/:eventId/filter-summary
* Get just the summary counts for filter UI
*/
router.get('/:eventId/filter-summary', adminAuth, async (req, res) => {
try {
const eventId = parseInt(req.params.eventId);
const summary = await withRetry(() =>
PhotoFilterBuilder.getSummary(db, eventId)
);
res.json({
success: true,
data: summary
});
} catch (error) {
console.error('Filter summary error:', error);
res.status(500).json({ error: 'Failed to get filter summary' });
}
});
/**
* POST /admin/photos/:eventId/export
* Export selected or filtered photos
*/
router.post('/:eventId/export', adminAuth, [
body('photo_ids').optional().isArray(),
body('photo_ids.*').optional().isInt(),
body('filter').optional().isObject(),
body('format').isIn(['txt', 'csv', 'xmp', 'json']),
body('options').optional().isObject()
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const eventId = parseInt(req.params.eventId);
const { photo_ids, filter, format, options = {} } = req.body;
// Verify event exists
const event = await withRetry(() =>
db('events').where('id', eventId).first()
);
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
// If filter provided instead of photo_ids, get matching photo IDs
let photoIds = photo_ids;
if (!photoIds && filter) {
const filterBuilder = new PhotoFilterBuilder(
db('photos').select('id'),
eventId
);
filterBuilder.applyFilters(filter);
const filteredPhotos = await withRetry(() => filterBuilder.getQuery());
photoIds = filteredPhotos.map(p => p.id);
}
// Export photos
const result = await exportService.exportPhotos(eventId, photoIds, format, options);
if (result.type === 'stream') {
res.setHeader('Content-Type', result.contentType);
res.setHeader('Content-Disposition', `attachment; filename="${result.filename}"`);
result.stream.pipe(res);
} else {
res.setHeader('Content-Type', result.contentType);
res.setHeader('Content-Disposition', `attachment; filename="${result.filename}"`);
res.send(result.content);
}
} catch (error) {
console.error('Export photos error:', error);
res.status(500).json({ error: error.message || 'Failed to export photos' });
}
});
/**
* GET /admin/photos/export-formats
* Get available export format options
*/
router.get('/export-formats', adminAuth, (req, res) => {
res.json({
success: true,
data: PhotoExportService.getFormatOptions()
});
});
module.exports = router;
+78 -4
View File
@@ -14,13 +14,39 @@ const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = r
// Get storage path from environment or default
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
// Check for slug redirect (for renamed events)
async function checkSlugRedirect(slug) {
try {
const hasTable = await db.schema.hasTable('slug_redirects');
if (!hasTable) return null;
const redirect = await db('slug_redirects')
.where({ old_slug: slug })
.first();
return redirect ? redirect.new_slug : null;
} catch (error) {
logger.warn('Error checking slug redirect:', { slug, error: error.message });
return null;
}
}
// Resolve gallery identifier (slug or token) to canonical data
router.get('/resolve/:identifier', async (req, res) => {
try {
const { identifier } = req.params;
const result = await resolveShareIdentifier(identifier);
let result = await resolveShareIdentifier(identifier);
// If not found, check for redirect
if (!result) {
const newSlug = await checkSlugRedirect(identifier);
if (newSlug) {
return res.status(301).json({
redirect: true,
newSlug,
message: 'Gallery has been renamed'
});
}
return res.status(404).json({ error: 'Gallery not found' });
}
@@ -75,8 +101,8 @@ router.get('/:slug/info', async (req, res) => {
try {
const { slug } = req.params;
const { token } = req.query;
const event = await db('events')
let event = await db('events')
.where({ slug })
.select(
'event_name',
@@ -95,8 +121,17 @@ router.get('/:slug/info', async (req, res) => {
'color_theme'
)
.first();
if (!event) {
// Check for redirect
const newSlug = await checkSlugRedirect(slug);
if (newSlug) {
return res.status(301).json({
redirect: true,
newSlug,
message: 'Gallery has been renamed'
});
}
return res.status(404).json({ error: 'Gallery not found' });
}
@@ -920,4 +955,43 @@ router.post('/:eventId/upload', verifyGalleryAccess, async (req, res) => {
}
});
/**
* GET /:slug/css-template
* Get custom CSS template for gallery (public endpoint)
*/
router.get('/:slug/css-template', async (req, res) => {
try {
const { slug } = req.params;
// Find the event by slug
const event = await db('events')
.where({ slug })
.select('css_template_id')
.first();
if (!event || !event.css_template_id) {
// No custom CSS - return 204 No Content
return res.status(204).send();
}
// Get the template if it's enabled
const template = await db('css_templates')
.where({ id: event.css_template_id, is_enabled: true })
.select('css_content')
.first();
if (!template || !template.css_content) {
return res.status(204).send();
}
// Return CSS with caching headers
res.setHeader('Content-Type', 'text/css');
res.setHeader('Cache-Control', 'public, max-age=3600'); // 1 hour cache
res.send(template.css_content);
} catch (error) {
console.error('Get CSS template error:', error);
res.status(500).send('/* Error loading template */');
}
});
module.exports = router;
+21 -7
View File
@@ -5,13 +5,14 @@ const router = express.Router();
// Get public settings (branding and theme)
router.get('/', async (req, res) => {
try {
// Fetch branding, theme, general, and security settings
// Fetch branding, theme, general, security, analytics, and event settings
// Note: We include analytics in the query but it might not exist yet
const settings = await withRetry(async () => {
return await db('app_settings')
.where(function() {
this.whereIn('setting_type', ['branding', 'theme', 'general', 'security', 'analytics'])
.orWhere('setting_key', 'like', 'analytics_%');
this.whereIn('setting_type', ['branding', 'theme', 'general', 'security', 'analytics', 'boolean'])
.orWhere('setting_key', 'like', 'analytics_%')
.orWhere('setting_key', 'like', 'event_require_%');
})
.select('setting_key', 'setting_value');
});
@@ -19,10 +20,19 @@ router.get('/', async (req, res) => {
// Convert to object format
const settingsObject = {};
settings.forEach(setting => {
// Handle null/undefined values
if (setting.setting_value === null || setting.setting_value === undefined) {
settingsObject[setting.setting_key] = null;
return;
}
// If value is already a primitive (boolean, number), use it directly
if (typeof setting.setting_value === 'boolean' || typeof setting.setting_value === 'number') {
settingsObject[setting.setting_key] = setting.setting_value;
return;
}
// Try to parse string values as JSON
try {
settingsObject[setting.setting_key] = setting.setting_value
? JSON.parse(setting.setting_value)
: null;
settingsObject[setting.setting_key] = JSON.parse(setting.setting_value);
} catch (e) {
// If parsing fails, use the raw value
settingsObject[setting.setting_key] = setting.setting_value;
@@ -59,7 +69,11 @@ router.get('/', async (req, res) => {
umami_enabled: settingsObject.analytics_umami_enabled === true || settingsObject.analytics_umami_enabled === 'true',
umami_url: (settingsObject.analytics_umami_enabled === true || settingsObject.analytics_umami_enabled === 'true') ? (settingsObject.analytics_umami_url || null) : null,
umami_website_id: (settingsObject.analytics_umami_enabled === true || settingsObject.analytics_umami_enabled === 'true') ? (settingsObject.analytics_umami_website_id || null) : null,
umami_share_url: (settingsObject.analytics_umami_enabled === true || settingsObject.analytics_umami_enabled === 'true') ? (settingsObject.analytics_umami_share_url || null) : null
umami_share_url: (settingsObject.analytics_umami_enabled === true || settingsObject.analytics_umami_enabled === 'true') ? (settingsObject.analytics_umami_share_url || null) : null,
// Event field requirements
event_require_customer_name: settingsObject.event_require_customer_name !== false,
event_require_customer_email: settingsObject.event_require_customer_email !== false,
event_require_admin_email: settingsObject.event_require_admin_email !== false
};
res.json(publicSettings);
+421
View File
@@ -0,0 +1,421 @@
/**
* Event Rename Service
* Handles renaming events including slug updates, file system changes, and database updates
*/
const { db, logActivity } = require('../database/db');
const fs = require('fs').promises;
const path = require('path');
const logger = require('../utils/logger');
const { buildShareLinkVariants } = require('./shareLinkService');
const { queueEmail } = require('./emailProcessor');
class EventRenameService {
constructor() {
this.storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
}
/**
* Format a date to YYYY-MM-DD string
* @param {Date|string} date - Date object or string
* @returns {string} Formatted date string
*/
formatDate(date) {
if (!date) return '';
const d = new Date(date);
if (isNaN(d.getTime())) return String(date);
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
/**
* Generate a slug from event details
* @param {string} eventType - Type of event (wedding, birthday, etc.)
* @param {string} eventName - Name of the event
* @param {string|Date} eventDate - Date of the event
* @returns {string} Generated slug
*/
generateSlug(eventType, eventName, eventDate) {
const processedEventName = eventName
.toLowerCase()
.replace(/[^a-z0-9]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
const formattedDate = this.formatDate(eventDate);
return `${eventType}-${processedEventName}-${formattedDate}`;
}
/**
* Validate if a rename operation is possible
* @param {number} eventId - Event ID
* @param {string} newEventName - New event name
* @returns {Promise<{valid: boolean, error?: string, newSlug?: string}>}
*/
async validateRename(eventId, newEventName) {
try {
// Get current event
const event = await db('events').where({ id: eventId }).first();
if (!event) {
return { valid: false, error: 'Event not found' };
}
if (event.is_archived) {
return { valid: false, error: 'Cannot rename archived events' };
}
// Validate new name
if (!newEventName || newEventName.trim().length < 3) {
return { valid: false, error: 'Event name must be at least 3 characters' };
}
if (newEventName.trim().length > 100) {
return { valid: false, error: 'Event name must be less than 100 characters' };
}
// Generate new slug
const newSlug = this.generateSlug(event.event_type, newEventName.trim(), event.event_date);
// Check if slug already exists (for different event)
const existingEvent = await db('events')
.where({ slug: newSlug })
.whereNot({ id: eventId })
.first();
if (existingEvent) {
return { valid: false, error: 'An event with this name already exists for the same date', conflicts: [existingEvent.event_name] };
}
// Check if the slug is the same as current
if (newSlug === event.slug) {
return { valid: false, error: 'New name generates the same URL as the current name' };
}
return { valid: true, newSlug, currentSlug: event.slug };
} catch (error) {
logger.error('Validation error:', { error: error.message });
return { valid: false, error: 'Validation failed' };
}
}
/**
* Rename the event folder on the filesystem
* @param {string} oldSlug - Current slug
* @param {string} newSlug - New slug
* @returns {Promise<boolean>}
*/
async renameEventFolder(oldSlug, newSlug) {
const oldPath = path.join(this.storagePath, 'events/active', oldSlug);
const newPath = path.join(this.storagePath, 'events/active', newSlug);
try {
// Check if old folder exists
await fs.access(oldPath);
// Check if new folder already exists
try {
await fs.access(newPath);
throw new Error('Target folder already exists');
} catch (error) {
if (error.code !== 'ENOENT') {
throw error;
}
}
// Rename folder
await fs.rename(oldPath, newPath);
logger.info('Event folder renamed', { oldSlug, newSlug });
return true;
} catch (error) {
if (error.code === 'ENOENT') {
logger.warn('Event folder not found, skipping rename', { oldSlug });
return true; // Not a fatal error if folder doesn't exist
}
throw error;
}
}
/**
* Rename individual photo files to match new event name
* @param {number} eventId - Event ID
* @param {string} oldEventName - Old event name
* @param {string} newEventName - New event name
* @param {string} newSlug - New slug for path updates
* @returns {Promise<number>} Number of files renamed
*/
async renamePhotoFiles(eventId, oldEventName, newEventName, oldSlug, newSlug) {
const photos = await db('photos').where({ event_id: eventId });
let renamedCount = 0;
// Process event name for filenames
const oldNamePrefix = oldEventName.replace(/[^a-zA-Z0-9]/g, '_');
const newNamePrefix = newEventName.replace(/[^a-zA-Z0-9]/g, '_');
for (const photo of photos) {
try {
const oldFilename = photo.filename;
let newFilename = oldFilename;
// Replace event name prefix if present
if (oldFilename.startsWith(oldNamePrefix)) {
newFilename = oldFilename.replace(oldNamePrefix, newNamePrefix);
}
// Update path with new slug
const newPath = photo.path.replace(oldSlug, newSlug);
const newThumbnailPath = photo.thumbnail_path ?
photo.thumbnail_path.replace(oldSlug, newSlug) : null;
// Rename physical file if filename changed
if (newFilename !== oldFilename) {
const oldFilePath = path.join(this.storagePath, 'events/active', newSlug,
photo.type === 'collage' ? 'collages' : 'individual', oldFilename);
const newFilePath = path.join(this.storagePath, 'events/active', newSlug,
photo.type === 'collage' ? 'collages' : 'individual', newFilename);
try {
await fs.rename(oldFilePath, newFilePath);
} catch (error) {
if (error.code !== 'ENOENT') {
logger.warn('Could not rename photo file', { oldFilename, error: error.message });
}
}
}
// Update database record
await db('photos')
.where({ id: photo.id })
.update({
filename: newFilename,
path: newPath,
thumbnail_path: newThumbnailPath
});
renamedCount++;
} catch (error) {
logger.error('Error renaming photo', { photoId: photo.id, error: error.message });
}
}
return renamedCount;
}
/**
* Update database records for the rename
* @param {object} trx - Knex transaction
* @param {number} eventId - Event ID
* @param {string} oldSlug - Current slug
* @param {string} newSlug - New slug
* @param {string} newEventName - New event name
* @returns {Promise<{newShareLink: string}>}
*/
async updateDatabaseRecords(trx, eventId, oldSlug, newSlug, newEventName) {
const event = await trx('events').where({ id: eventId }).first();
// Generate new share link
const { sharePath, shareUrl, shareLinkToStore } = await buildShareLinkVariants({
slug: newSlug,
shareToken: event.share_token
});
// Update event
await trx('events')
.where({ id: eventId })
.update({
event_name: newEventName,
slug: newSlug,
share_link: shareLinkToStore
});
return { newShareLink: shareUrl };
}
/**
* Create a redirect entry for the old slug
* @param {object} trx - Knex transaction
* @param {number} eventId - Event ID
* @param {string} oldSlug - Old slug
* @param {string} newSlug - New slug
*/
async createSlugRedirect(trx, eventId, oldSlug, newSlug) {
// Check if table exists
const hasTable = await trx.schema.hasTable('slug_redirects');
if (!hasTable) {
logger.warn('slug_redirects table does not exist, skipping redirect creation');
return;
}
// Check if redirect already exists
const existingRedirect = await trx('slug_redirects')
.where({ old_slug: oldSlug })
.first();
if (existingRedirect) {
// Update existing redirect
await trx('slug_redirects')
.where({ old_slug: oldSlug })
.update({ new_slug: newSlug });
} else {
// Create new redirect
await trx('slug_redirects').insert({
old_slug: oldSlug,
new_slug: newSlug,
event_id: eventId
});
}
// Also update any existing redirects pointing to the old slug
await trx('slug_redirects')
.where({ new_slug: oldSlug })
.update({ new_slug: newSlug });
}
/**
* Send notification email about the rename
* @param {number} eventId - Event ID
* @param {string} newShareLink - New share link
*/
async sendRenamedEventEmail(eventId, newShareLink) {
const event = await db('events').where({ id: eventId }).first();
if (!event) return;
const recipientEmail = event.customer_email || event.host_email;
if (!recipientEmail) return;
const recipientName = event.customer_name || event.host_name ||
(recipientEmail ? recipientEmail.split('@')[0] : 'Guest');
await queueEmail(eventId, recipientEmail, 'gallery_link_updated', {
customer_name: recipientName,
event_name: event.event_name,
new_gallery_link: newShareLink,
event_date: event.event_date
});
}
/**
* Rollback a failed rename operation
* @param {object} backupData - Backup data from the rename attempt
*/
async rollbackRename(backupData) {
try {
if (backupData.folderRenamed && backupData.event) {
const oldPath = path.join(this.storagePath, 'events/active', backupData.newSlug);
const newPath = path.join(this.storagePath, 'events/active', backupData.event.slug);
try {
await fs.rename(oldPath, newPath);
logger.info('Rolled back folder rename');
} catch (error) {
logger.error('Failed to rollback folder rename', { error: error.message });
}
}
} catch (error) {
logger.error('Rollback failed', { error: error.message });
}
}
/**
* Main method to rename an event
* @param {number} eventId - Event ID
* @param {string} newEventName - New event name
* @param {boolean} resendEmail - Whether to resend invitation email
* @param {object} adminUser - Admin user performing the action
* @returns {Promise<{success: boolean, data?: object, error?: string}>}
*/
async renameEvent(eventId, newEventName, resendEmail = false, adminUser = null) {
const backupData = {};
try {
// 1. Validate
const validation = await this.validateRename(eventId, newEventName);
if (!validation.valid) {
return { success: false, error: validation.error };
}
// 2. Get current event data
const event = await db('events').where({ id: eventId }).first();
backupData.event = event;
backupData.newSlug = validation.newSlug;
const oldSlug = event.slug;
const oldName = event.event_name;
const newSlug = validation.newSlug;
// 3. Start transaction
const trx = await db.transaction();
try {
// 4. Rename folder (filesystem)
await this.renameEventFolder(oldSlug, newSlug);
backupData.folderRenamed = true;
// 5. Rename photo files and update paths
const filesRenamed = await this.renamePhotoFiles(eventId, oldName, newEventName.trim(), oldSlug, newSlug);
// 6. Update database records
const { newShareLink } = await this.updateDatabaseRecords(trx, eventId, oldSlug, newSlug, newEventName.trim());
// 7. Create redirect entry
await this.createSlugRedirect(trx, eventId, oldSlug, newSlug);
// 8. Log activity
await trx('activity_logs').insert({
activity_type: 'event_renamed',
actor_type: adminUser ? 'admin' : 'system',
actor_id: adminUser?.id || null,
actor_name: adminUser?.username || 'system',
metadata: JSON.stringify({
old_name: oldName,
new_name: newEventName.trim(),
old_slug: oldSlug,
new_slug: newSlug,
files_renamed: filesRenamed,
email_sent: resendEmail
}),
event_id: eventId
});
// 9. Commit transaction
await trx.commit();
// 10. Send email (after commit, non-critical)
let emailSent = false;
if (resendEmail) {
try {
await this.sendRenamedEventEmail(eventId, newShareLink);
emailSent = true;
} catch (error) {
logger.error('Failed to send rename notification email', { error: error.message });
}
}
return {
success: true,
data: {
eventId,
oldName,
newName: newEventName.trim(),
oldSlug,
newSlug,
newShareLink,
emailSent,
filesRenamed
}
};
} catch (error) {
await trx.rollback();
throw error;
}
} catch (error) {
logger.error('Event rename failed', { eventId, error: error.message });
await this.rollbackRename(backupData);
return { success: false, error: error.message || 'Failed to rename event' };
}
}
}
module.exports = new EventRenameService();
+251
View File
@@ -0,0 +1,251 @@
/**
* Photo Export Service
* Handles exporting filtered photos in various formats
*/
const archiver = require('archiver');
const { PassThrough } = require('stream');
const { XmpGenerator } = require('./xmpGenerator');
const { db } = require('../database/db');
const path = require('path');
const fs = require('fs').promises;
class PhotoExportService {
constructor() {
this.xmpGenerator = new XmpGenerator();
}
/**
* Get photos with full feedback data
* @param {number} eventId - Event ID
* @param {number[]} photoIds - Photo IDs to export (optional, exports all if not provided)
* @returns {Promise<Object[]>} Photos with feedback
*/
async getPhotosWithFeedback(eventId, photoIds = null) {
let query = db('photos')
.leftJoin('categories', 'photos.category_id', 'categories.id')
.where('photos.event_id', eventId)
.select(
'photos.id',
'photos.filename',
'photos.original_filename',
'photos.file_path',
'photos.average_rating',
'photos.feedback_count',
'photos.like_count',
'photos.favorite_count',
'photos.comment_count',
'photos.width',
'photos.height',
'photos.file_size',
'photos.created_at',
'categories.name as category_name'
)
.orderBy('photos.filename', 'asc');
if (photoIds && photoIds.length > 0) {
query = query.whereIn('photos.id', photoIds);
}
return await query;
}
/**
* Export photos in the specified format
* @param {number} eventId - Event ID
* @param {number[]} photoIds - Photo IDs to export
* @param {string} format - Export format (txt, csv, xmp, photos, json)
* @param {Object} options - Export options
* @returns {Promise<Object>} Export result with stream/content
*/
async exportPhotos(eventId, photoIds, format, options = {}) {
const photos = await this.getPhotosWithFeedback(eventId, photoIds);
if (photos.length === 0) {
throw new Error('No photos to export');
}
switch (format) {
case 'txt':
return this.exportAsTxt(photos, options);
case 'csv':
return this.exportAsCsv(photos, options);
case 'xmp':
return this.exportAsXmpZip(photos, options);
case 'json':
return this.exportAsJson(photos, eventId, options);
default:
throw new Error(`Unknown export format: ${format}`);
}
}
/**
* Export as plain text filename list
*/
exportAsTxt(photos, options = {}) {
const { filename_format = 'original', separator = 'newline' } = options;
const filenames = photos.map(photo =>
filename_format === 'original' ? photo.original_filename : photo.filename
);
let content;
switch (separator) {
case 'comma':
content = filenames.join(', ');
break;
case 'semicolon':
content = filenames.join('; ');
break;
default:
content = filenames.join('\n');
}
return {
type: 'text',
content,
filename: `photo_list_${Date.now()}.txt`,
contentType: 'text/plain'
};
}
/**
* Export as CSV with metadata
*/
exportAsCsv(photos, options = {}) {
const { filename_format = 'original' } = options;
const headers = [
'filename',
'original_filename',
'rating',
'rating_count',
'likes',
'favorites',
'comments',
'category',
'width',
'height',
'file_size',
'created_at'
];
const rows = photos.map(photo => [
filename_format === 'original' ? photo.original_filename : photo.filename,
photo.original_filename || '',
photo.average_rating ? photo.average_rating.toFixed(2) : '0.00',
photo.feedback_count || 0,
photo.like_count || 0,
photo.favorite_count || 0,
photo.comment_count || 0,
photo.category_name || '',
photo.width || '',
photo.height || '',
photo.file_size || '',
photo.created_at ? new Date(photo.created_at).toISOString() : ''
]);
const csvContent = [
headers.join(','),
...rows.map(row => row.map(cell => `"${String(cell).replace(/"/g, '""')}"`).join(','))
].join('\n');
return {
type: 'text',
content: csvContent,
filename: `photo_export_${Date.now()}.csv`,
contentType: 'text/csv'
};
}
/**
* Export as XMP sidecar files in a ZIP archive
*/
async exportAsXmpZip(photos, options = {}) {
const { filename_format = 'original' } = options;
const archive = archiver('zip', { zlib: { level: 9 } });
const passthrough = new PassThrough();
archive.pipe(passthrough);
for (const photo of photos) {
const baseFilename = filename_format === 'original'
? photo.original_filename
: photo.filename;
const xmpFilename = this.xmpGenerator.getXmpFilename(baseFilename);
const xmpContent = this.xmpGenerator.generateXmp(photo, options);
archive.append(xmpContent, { name: xmpFilename });
}
archive.finalize();
return {
type: 'stream',
stream: passthrough,
filename: `xmp_export_${Date.now()}.zip`,
contentType: 'application/zip'
};
}
/**
* Export as JSON metadata
*/
async exportAsJson(photos, eventId, options = {}) {
// Get event info
const event = await db('events')
.where('id', eventId)
.select('event_name', 'event_date', 'slug')
.first();
const exportData = {
export_info: {
event_name: event?.event_name || 'Unknown Event',
event_date: event?.event_date,
event_slug: event?.slug,
exported_at: new Date().toISOString(),
total_photos: photos.length
},
photos: photos.map(photo => ({
id: photo.id,
filename: photo.filename,
original_filename: photo.original_filename,
category: photo.category_name || null,
rating: {
average: photo.average_rating ? parseFloat(photo.average_rating.toFixed(2)) : 0,
count: photo.feedback_count || 0
},
likes: photo.like_count || 0,
favorites: photo.favorite_count || 0,
comments: photo.comment_count || 0,
dimensions: {
width: photo.width,
height: photo.height
},
file_size: photo.file_size,
created_at: photo.created_at
}))
};
return {
type: 'text',
content: JSON.stringify(exportData, null, 2),
filename: `photo_metadata_${Date.now()}.json`,
contentType: 'application/json'
};
}
/**
* Get export format display names
*/
static getFormatOptions() {
return [
{ value: 'txt', label: 'Filename List (TXT)', description: 'Simple text list of filenames' },
{ value: 'csv', label: 'Filename List (CSV)', description: 'Spreadsheet with metadata' },
{ value: 'xmp', label: 'XMP Sidecar Files (ZIP)', description: 'For Lightroom/Bridge/Capture One' },
{ value: 'json', label: 'Metadata (JSON)', description: 'Structured data for automation' }
];
}
}
module.exports = { PhotoExportService };
+165
View File
@@ -0,0 +1,165 @@
/**
* XMP Sidecar File Generator
* Generates Adobe XMP metadata files for photos with guest feedback
*/
class XmpGenerator {
/**
* Generate XMP sidecar content for a photo
* @param {Object} photo - Photo object with feedback data
* @param {Object} options - Generation options
* @returns {string} XMP file content
*/
generateXmp(photo, options = {}) {
const {
include_rating = true,
include_label = true,
include_description = true,
include_keywords = true
} = options;
const rating = include_rating ? this.mapRating(photo.average_rating) : 0;
const label = include_label ? this.mapLabel(photo.average_rating) : null;
const descriptionXml = include_description ? this.generateDescription(photo) : '';
const keywordsXml = include_keywords ? this.generateKeywords(photo) : '';
return `<?xml version="1.0" encoding="UTF-8"?>
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="PicPeak Export">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:xmp="http://ns.adobe.com/xap/1.0/"
xmlns:xmpMM="http://ns.adobe.com/xap/1.0/mm/"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:photoshop="http://ns.adobe.com/photoshop/1.0/"
xmlns:Iptc4xmpCore="http://iptc.org/std/Iptc4xmpCore/1.0/xmlns/"
xmp:Rating="${rating}"${label ? `
xmp:Label="${label}"` : ''}>
${descriptionXml}
${keywordsXml}
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>`;
}
/**
* Map PicPeak average rating to XMP 1-5 rating
* @param {number} avgRating - Average rating (0-5, decimal)
* @returns {number} XMP rating (0-5, integer)
*/
mapRating(avgRating) {
if (!avgRating || avgRating === 0) return 0;
if (avgRating >= 4.5) return 5;
if (avgRating >= 3.5) return 4;
if (avgRating >= 2.5) return 3;
if (avgRating >= 1.5) return 2;
return 1;
}
/**
* Map PicPeak rating to XMP color label
* @param {number} avgRating - Average rating
* @returns {string|null} XMP label color
*/
mapLabel(avgRating) {
if (!avgRating || avgRating === 0) return null;
if (avgRating >= 4.5) return 'Red'; // Top picks
if (avgRating >= 3.5) return 'Yellow'; // Good
if (avgRating >= 2.5) return 'Green'; // Average
if (avgRating >= 1.5) return 'Blue'; // Below average
return 'Purple'; // Low
}
/**
* Generate XMP description element
* @param {Object} photo - Photo object
* @returns {string} Description XML
*/
generateDescription(photo) {
const rating = photo.average_rating ? photo.average_rating.toFixed(1) : '0';
const likes = photo.like_count || 0;
const favorites = photo.favorite_count || 0;
const desc = `PicPeak Guest Feedback: ${rating} stars, ${likes} likes, ${favorites} favorites`;
return `<dc:description>
<rdf:Alt>
<rdf:li xml:lang="x-default">${this.escapeXml(desc)}</rdf:li>
</rdf:Alt>
</dc:description>`;
}
/**
* Generate XMP keywords element
* @param {Object} photo - Photo object
* @returns {string} Keywords XML
*/
generateKeywords(photo) {
const keywords = ['picpeak-export'];
if (photo.average_rating >= 4) {
keywords.push('guest-pick');
}
if (photo.average_rating >= 4.5) {
keywords.push('top-rated');
}
if (photo.like_count >= 5) {
keywords.push('popular');
}
if (photo.favorite_count > 0) {
keywords.push('favorited');
}
if (photo.category_name) {
keywords.push(this.sanitizeKeyword(photo.category_name));
}
return `<dc:subject>
<rdf:Bag>
${keywords.map(k => `<rdf:li>${this.escapeXml(k)}</rdf:li>`).join('\n ')}
</rdf:Bag>
</dc:subject>`;
}
/**
* Escape special XML characters
* @param {string} str - Input string
* @returns {string} Escaped string
*/
escapeXml(str) {
if (!str) return '';
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
/**
* Sanitize keyword for XMP
* @param {string} keyword - Raw keyword
* @returns {string} Sanitized keyword
*/
sanitizeKeyword(keyword) {
return keyword
.toLowerCase()
.replace(/[^a-z0-9-]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
}
/**
* Get XMP filename from photo filename
* @param {string} photoFilename - Photo filename
* @returns {string} XMP filename
*/
getXmpFilename(photoFilename) {
return photoFilename.replace(/\.[^.]+$/, '.xmp');
}
}
module.exports = { XmpGenerator };
+139
View File
@@ -1,3 +1,43 @@
/**
* CSS Sanitizer
* Sanitizes user-provided CSS to prevent security vulnerabilities
*/
// Patterns that should be blocked for security
const FORBIDDEN_PATTERNS = [
// JavaScript execution
/expression\s*\(/gi,
/javascript:/gi,
/behavior\s*:/gi,
/-moz-binding/gi,
/vbscript:/gi,
// External resources (potential data exfiltration)
/@import/gi,
// Dangerous at-rules
/@charset/gi,
/@namespace/gi,
// IE-specific exploits
/\\0/g, // Null byte
/\\9/g, // IE CSS hack
// Script injection attempts
/<script/gi,
/<\/script/gi,
/on\w+\s*=/gi, // onclick=, onload=, etc.
];
// Pattern for external URLs (block external, allow data: for images)
const EXTERNAL_URL_PATTERN = /url\s*\(\s*["']?(?!data:image)/gi;
// Maximum CSS size in bytes (100KB)
const MAX_CSS_SIZE = 100 * 1024;
/**
* Basic CSS sanitization (original function, kept for compatibility)
*/
function sanitizeCss(css) {
if (!css || typeof css !== 'string') {
return '';
@@ -27,6 +67,105 @@ function sanitizeCss(css) {
return sanitized.trim();
}
/**
* Enhanced CSS sanitization with warnings
* @param {string} cssContent - Raw CSS content
* @returns {Object} - { sanitized: string, warnings: string[] }
*/
function sanitizeCSS(cssContent) {
if (!cssContent || typeof cssContent !== 'string') {
return { sanitized: '', warnings: [] };
}
const warnings = [];
let sanitized = cssContent;
// Check size
if (sanitized.length > MAX_CSS_SIZE) {
warnings.push(`CSS exceeds maximum size of ${MAX_CSS_SIZE / 1024}KB`);
sanitized = sanitized.substring(0, MAX_CSS_SIZE);
}
// Remove forbidden patterns
for (const pattern of FORBIDDEN_PATTERNS) {
const patternStr = pattern.toString();
// Reset lastIndex for global patterns
pattern.lastIndex = 0;
if (pattern.test(sanitized)) {
const patternName = patternStr.replace(/\/[gi]*/g, '').substring(0, 30);
warnings.push(`Blocked potentially unsafe pattern: ${patternName}`);
pattern.lastIndex = 0;
sanitized = sanitized.replace(pattern, '/* BLOCKED */');
}
}
// Block external URLs (only allow data: URIs for images)
EXTERNAL_URL_PATTERN.lastIndex = 0;
if (EXTERNAL_URL_PATTERN.test(sanitized)) {
warnings.push('Blocked external URL references. Only data: URIs are allowed for images.');
EXTERNAL_URL_PATTERN.lastIndex = 0;
sanitized = sanitized.replace(EXTERNAL_URL_PATTERN, '/* BLOCKED URL */ url(');
}
// Remove HTML comments that might be used for injection
sanitized = sanitized.replace(/<!--[\s\S]*?-->/g, '');
// Remove control characters
sanitized = sanitized.replace(/[\u0000-\u001F\u007F]/g, '');
// Remove any remaining script-like content
sanitized = sanitized.replace(/<[^>]*>/g, '/* BLOCKED TAG */');
return { sanitized: sanitized.trim(), warnings };
}
/**
* Validate CSS syntax (basic check)
* @param {string} cssContent - CSS content to validate
* @returns {Object} - { valid: boolean, error?: string }
*/
function validateCSS(cssContent) {
if (!cssContent || cssContent.trim() === '') {
return { valid: true };
}
// Basic bracket matching
const openBraces = (cssContent.match(/{/g) || []).length;
const closeBraces = (cssContent.match(/}/g) || []).length;
if (openBraces !== closeBraces) {
return {
valid: false,
error: `Mismatched braces: ${openBraces} opening, ${closeBraces} closing`
};
}
return { valid: true };
}
/**
* Scope CSS to gallery page
* @param {string} cssContent - CSS content
* @returns {string} - Scoped CSS
*/
function scopeToGalleryPage(cssContent) {
if (!cssContent || cssContent.trim() === '') {
return '';
}
// If the CSS already uses .gallery-page, return as-is
if (cssContent.includes('.gallery-page')) {
return cssContent;
}
// Simple scoping: wrap entire content in .gallery-page
return `.gallery-page {\n${cssContent}\n}`;
}
module.exports = {
sanitizeCss,
sanitizeCSS,
validateCSS,
scopeToGalleryPage,
MAX_CSS_SIZE
};
+160
View File
@@ -0,0 +1,160 @@
/**
* Photo Filter Query Builder
* Builds Knex queries for filtering photos by feedback metrics
*/
class PhotoFilterBuilder {
constructor(queryBuilder, eventId) {
this.query = queryBuilder;
this.eventId = eventId;
}
/**
* Apply all filters from a filter object
*/
applyFilters(filters = {}) {
const {
min_rating,
max_rating,
has_likes,
min_likes,
has_favorites,
min_favorites,
has_comments,
category_id,
logic = 'AND'
} = filters;
// Always filter by event
this.query.where('photos.event_id', this.eventId);
// Build conditions array
const conditions = [];
if (min_rating !== undefined && min_rating !== null) {
conditions.push(builder => builder.where('photos.average_rating', '>=', min_rating));
}
if (max_rating !== undefined && max_rating !== null) {
conditions.push(builder => builder.where('photos.average_rating', '<=', max_rating));
}
if (has_likes === true || has_likes === 'true') {
conditions.push(builder => builder.where('photos.like_count', '>', 0));
}
if (min_likes !== undefined && min_likes !== null) {
conditions.push(builder => builder.where('photos.like_count', '>=', min_likes));
}
if (has_favorites === true || has_favorites === 'true') {
conditions.push(builder => builder.where('photos.favorite_count', '>', 0));
}
if (min_favorites !== undefined && min_favorites !== null) {
conditions.push(builder => builder.where('photos.favorite_count', '>=', min_favorites));
}
if (has_comments === true || has_comments === 'true') {
conditions.push(builder => builder.where('photos.comment_count', '>', 0));
}
if (category_id) {
conditions.push(builder => builder.where('photos.category_id', category_id));
}
// Apply conditions with AND/OR logic
if (conditions.length > 0) {
if (logic === 'OR') {
this.query.where(builder => {
conditions.forEach((condition, index) => {
if (index === 0) {
condition(builder);
} else {
builder.orWhere(subBuilder => condition(subBuilder));
}
});
});
} else {
// AND logic (default)
conditions.forEach(condition => {
this.query.where(builder => condition(builder));
});
}
}
return this;
}
/**
* Apply sorting
*/
applySorting(sort = 'date', order = 'desc') {
const sortMap = {
rating: 'photos.average_rating',
likes: 'photos.like_count',
favorites: 'photos.favorite_count',
date: 'photos.created_at',
filename: 'photos.filename'
};
const sortColumn = sortMap[sort] || sortMap.date;
this.query.orderBy(sortColumn, order === 'asc' ? 'asc' : 'desc');
return this;
}
/**
* Apply pagination
*/
applyPagination(page = 1, limit = 50) {
const offset = (page - 1) * limit;
this.query.limit(limit).offset(offset);
return this;
}
/**
* Get the built query
*/
getQuery() {
return this.query;
}
/**
* Build a count query for the same filters
*/
static buildCountQuery(db, eventId, filters = {}) {
const builder = new PhotoFilterBuilder(
db('photos').count('* as count'),
eventId
);
builder.applyFilters(filters);
return builder.getQuery();
}
/**
* Build a summary query for feedback counts
*/
static async getSummary(db, eventId) {
const result = await db('photos')
.where('event_id', eventId)
.select(
db.raw('COUNT(*) as total'),
db.raw('COUNT(CASE WHEN average_rating > 0 THEN 1 END) as with_ratings'),
db.raw('COUNT(CASE WHEN like_count > 0 THEN 1 END) as with_likes'),
db.raw('COUNT(CASE WHEN favorite_count > 0 THEN 1 END) as with_favorites'),
db.raw('COUNT(CASE WHEN comment_count > 0 THEN 1 END) as with_comments')
)
.first();
return {
total: parseInt(result.total) || 0,
withRatings: parseInt(result.with_ratings) || 0,
withLikes: parseInt(result.with_likes) || 0,
withFavorites: parseInt(result.with_favorites) || 0,
withComments: parseInt(result.with_comments) || 0
};
}
}
module.exports = { PhotoFilterBuilder };
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+338
View File
@@ -0,0 +1,338 @@
# Test Specification: Custom CSS Gallery Templates
This document specifies the test cases for the Custom CSS Gallery Templates feature, which allows administrators to create and manage up to 3 custom CSS templates for gallery styling.
## Prerequisites
- Local Docker environment running (`docker-compose up`)
- Access to admin dashboard
- Backend migrations applied (052_add_css_templates.js)
## Test Cases
### 1. Template Editor Access
#### TC-CCT-001: Access CSS Templates Tab
**Steps:**
1. Navigate to Settings page
2. Look for "Custom CSS Templates" or "Styling" section
**Expected Result:**
- CSS Templates editor is accessible
- Three template slots are visible as tabs
#### TC-CCT-002: Default Template Content
**Steps:**
1. Navigate to CSS Templates editor
2. Select Template 1 tab
**Expected Result:**
- Template 1 named "Elegant Dark"
- Contains pre-populated CSS content
- Is marked as enabled
- Is marked as default
### 2. Template Editing
#### TC-CCT-003: Edit Template Name
**Steps:**
1. Select Template 2
2. Change name from "Untitled" to "My Custom Theme"
3. Save template
**Expected Result:**
- Name updates in tab
- Save confirmation shown
- Name persists after refresh
#### TC-CCT-004: Edit CSS Content
**Steps:**
1. Select Template 2
2. Add CSS: `.gallery-page { background: #ff0000; }`
3. Save template
**Expected Result:**
- CSS saved successfully
- No sanitization warnings for valid CSS
- Character count updates
#### TC-CCT-005: Enable/Disable Template
**Steps:**
1. Select Template 2
2. Toggle "Enable this template" checkbox
3. Save template
**Expected Result:**
- Template status changes
- Tab shows check mark when enabled
- Disabled templates not available in event form
### 3. CSS Sanitization
#### TC-CCT-006: Block JavaScript Expressions
**Steps:**
1. Enter CSS with `expression(alert('xss'))`
2. Save template
**Expected Result:**
- Pattern blocked (replaced with /* BLOCKED */)
- Sanitization warning shown
- Template saves with sanitized content
#### TC-CCT-007: Block @import Rules
**Steps:**
1. Enter CSS with `@import url('http://evil.com/styles.css');`
2. Save template
**Expected Result:**
- @import blocked
- Warning shown
- External resource not loaded
#### TC-CCT-008: Block External URLs
**Steps:**
1. Enter CSS with `background-image: url('http://external.com/image.jpg');`
2. Save template
**Expected Result:**
- External URL blocked
- Only data: URIs allowed for images
- Warning shown
#### TC-CCT-009: Allow Safe CSS Properties
**Steps:**
1. Enter CSS with standard properties:
```css
.gallery-page {
background-color: #333;
color: white;
font-family: Arial, sans-serif;
padding: 20px;
}
```
2. Save template
**Expected Result:**
- All properties saved as-is
- No sanitization warnings
- CSS valid
### 4. Template Size Limits
#### TC-CCT-010: CSS Size Limit
**Steps:**
1. Try to save CSS content > 100KB
2. Attempt to save
**Expected Result:**
- Error message about size limit
- Template not saved
- User informed of 100KB limit
### 5. Reset to Default
#### TC-CCT-011: Reset Template 1
**Steps:**
1. Modify Template 1 CSS
2. Save changes
3. Click "Reset to Default"
4. Confirm action
**Expected Result:**
- Template reverts to default "Elegant Dark" content
- Name reset to "Elegant Dark"
- Enable status reset to true
#### TC-CCT-012: Reset Button Only on Template 1
**Steps:**
1. Select Template 2
2. Look for Reset button
**Expected Result:**
- Reset to Default button NOT shown for Template 2 or 3
- Only Template 1 has reset option
### 6. Event Integration
#### TC-CCT-013: Template Dropdown in Event Form
**Steps:**
1. Enable at least one CSS template
2. Navigate to Create Event page
3. Look for CSS Template selector
**Expected Result:**
- Dropdown shows "None (Use default theme)" option
- Enabled templates appear in list
- Disabled templates NOT shown
#### TC-CCT-014: Assign Template to Event
**Steps:**
1. Create new event
2. Select an enabled CSS template
3. Save event
**Expected Result:**
- Event created with template assigned
- Template ID stored in database
- Event edit shows selected template
#### TC-CCT-015: Update Event Template
**Steps:**
1. Edit existing event
2. Change CSS template selection
3. Save event
**Expected Result:**
- Template updated successfully
- Gallery reflects new template
### 7. Gallery CSS Loading
#### TC-CCT-016: Gallery Loads Custom CSS
**Steps:**
1. Assign template to an event
2. View gallery as guest
3. Inspect page source/styles
**Expected Result:**
- Custom CSS injected via `<style id="gallery-custom-css">`
- Gallery styling matches template
- CSS scoped to .gallery-page
#### TC-CCT-017: Gallery Without Template
**Steps:**
1. Create event without template (select "None")
2. View gallery
**Expected Result:**
- No custom CSS loaded
- Default theme used
- No errors
#### TC-CCT-018: Disabled Template Not Applied
**Steps:**
1. Assign template to event
2. Disable the template in settings
3. View gallery
**Expected Result:**
- Custom CSS NOT loaded
- Gallery uses default styling
- No errors
### 8. API Tests
#### TC-CCT-019: Get All Templates
**Steps:**
1. Call API: `GET /api/admin/css-templates`
**Expected Result:**
- Returns array of 3 templates
- Each has: id, slot_number, name, css_content, is_enabled, is_default, updated_at
#### TC-CCT-020: Get Enabled Templates
**Steps:**
1. Call API: `GET /api/admin/css-templates/enabled`
**Expected Result:**
- Returns only enabled templates
- Each has: id, name, slot_number
#### TC-CCT-021: Update Template
**Steps:**
1. Call API: `PUT /api/admin/css-templates/2`
Body: `{ "name": "Test", "css_content": "...", "is_enabled": true }`
**Expected Result:**
- Returns 200 OK
- Template updated
- Sanitization warnings array included
#### TC-CCT-022: Gallery CSS Endpoint
**Steps:**
1. Assign template to event with slug "test-gallery"
2. Call API: `GET /api/gallery/test-gallery/css-template`
**Expected Result:**
- Returns 200 OK with Content-Type: text/css
- Body contains sanitized CSS content
#### TC-CCT-023: Gallery CSS Not Found
**Steps:**
1. Create event without template
2. Call API: `GET /api/gallery/no-template-event/css-template`
**Expected Result:**
- Returns 204 No Content
- No body
### 9. Edge Cases
#### TC-CCT-024: Empty CSS Content
**Steps:**
1. Save template with empty CSS content
2. Assign to event
3. View gallery
**Expected Result:**
- Template saves successfully
- Gallery loads without custom CSS
- No errors
#### TC-CCT-025: Invalid CSS Syntax
**Steps:**
1. Enter CSS with mismatched braces: `{ color: red;`
2. Try to save
**Expected Result:**
- Validation error shown
- Template not saved
- Error message indicates syntax issue
### 10. Persistence Tests
#### TC-CCT-026: Template Persists After Restart
**Steps:**
1. Create and save custom template
2. Restart backend container
3. Reload template editor
**Expected Result:**
- Template content preserved
- All settings intact
- No data loss
## Files Created/Modified
### Backend
- `/backend/migrations/core/052_add_css_templates.js`
- `/backend/src/utils/cssSanitizer.js`
- `/backend/src/routes/adminCssTemplates.js`
- `/backend/src/routes/gallery.js`
- `/backend/server.js`
### Frontend
- `/frontend/src/services/cssTemplates.service.ts`
- `/frontend/src/components/admin/CssTemplateEditor.tsx`
- `/frontend/src/components/admin/index.ts`
- `/frontend/src/hooks/useGalleryCustomCss.ts`
## Integration Notes
The following additional integrations are recommended:
1. Add CssTemplateEditor to Settings page styling tab
2. Add CSS template dropdown to CreateEventPageEnhanced.tsx
3. Add CSS template dropdown to CreateEventPage.tsx
4. Update event edit forms to show/edit template selection
5. Update GalleryPage.tsx to use useGalleryCustomCss hook
6. Add `.gallery-page` class to gallery container components
## Automated Testing Notes
For Playwright tests:
1. Login to admin dashboard
2. Navigate to CSS Templates editor
3. Manipulate template tabs, inputs, and checkboxes
4. Verify save operations via API calls
5. Navigate to Create Event, verify template dropdown
6. View gallery, verify custom CSS is applied
+342
View File
@@ -0,0 +1,342 @@
# Test Specification: Event Rename Feature
## Overview
This document specifies the test cases for the Event Rename feature, which allows administrators to rename events from the admin panel.
---
## Test Environment
- **URL**: http://localhost:7100
- **Admin Credentials**: admin / AdminTest@2026!
- **Prerequisites**: At least one active (non-archived) event with photos
---
## Test Cases
### TC-RN-001: Access Rename Dialog
**Description**: Verify the Rename button is visible and opens the rename dialog
**Steps**:
1. Login to admin panel
2. Navigate to an active event's detail page
3. Verify "Rename" button is visible in the header action buttons
4. Click the "Rename" button
5. Verify rename dialog opens
**Expected Results**:
- Rename button visible next to Edit button
- Dialog opens with current event name pre-filled
- Dialog shows input field for new name
---
### TC-RN-002: Validate New Name - Too Short
**Description**: Verify validation error for names shorter than 3 characters
**Steps**:
1. Open rename dialog for an event
2. Enter a name with 2 characters (e.g., "AB")
3. Observe validation state
**Expected Results**:
- Rename button remains disabled
- No API call made for validation
---
### TC-RN-003: Validate New Name - Same as Current
**Description**: Verify error when new name generates same slug as current
**Steps**:
1. Open rename dialog for an event
2. Enter the same name as current (or minor variation that results in same slug)
3. Wait for validation
**Expected Results**:
- Error message: "New name generates the same URL as the current name"
- Rename button disabled
---
### TC-RN-004: Validate New Name - Duplicate Slug
**Description**: Verify error when new name would conflict with existing event
**Steps**:
1. Create two events with different names on the same date
2. Open rename dialog for event A
3. Enter event B's name
4. Wait for validation
**Expected Results**:
- Error message: "An event with this name already exists for the same date"
- Rename button disabled
---
### TC-RN-005: Validate New Name - Valid
**Description**: Verify successful validation of a valid new name
**Steps**:
1. Open rename dialog for an event
2. Enter a valid, unique new name (at least 3 characters)
3. Wait for validation
**Expected Results**:
- New URL preview shown in green box
- Rename button becomes enabled
- No error messages
---
### TC-RN-006: Successful Rename
**Description**: Verify complete rename operation
**Steps**:
1. Open rename dialog for an event with photos
2. Enter a valid new name
3. Wait for validation to complete
4. Click "Rename Event" button
5. Wait for operation to complete
**Expected Results**:
- Progress indicator shown during operation
- Success message displayed
- New share link shown
- Files renamed count displayed
- Event name updated in UI
- Share link updated
---
### TC-RN-007: Rename with Email Notification
**Description**: Verify rename with email notification option
**Steps**:
1. Open rename dialog for event with customer email
2. Enter valid new name
3. Check "Resend invitation email with new gallery link" checkbox
4. Click "Rename Event"
5. Wait for completion
**Expected Results**:
- Operation completes successfully
- Email sent confirmation shown
- (If SMTP configured) Email received with new link
---
### TC-RN-008: Old URL Redirects to New URL
**Description**: Verify old gallery URLs redirect to new location
**Steps**:
1. Note the current gallery share link before rename
2. Rename the event
3. Try to access the old share link
4. Verify redirect to new URL
**Expected Results**:
- Old URL returns 301 redirect response
- Browser redirects to new gallery URL
- Gallery is accessible at new URL
---
### TC-RN-009: Archived Events Cannot Be Renamed
**Description**: Verify rename button is not available for archived events
**Steps**:
1. Navigate to an archived event's detail page
2. Check for Rename button presence
**Expected Results**:
- Rename button is NOT visible
- Only archive-related actions available
---
### TC-RN-010: Rename Dialog Cancel
**Description**: Verify cancel functionality in rename dialog
**Steps**:
1. Open rename dialog
2. Enter a new name
3. Click Cancel button
4. Verify dialog closes
5. Verify event name unchanged
**Expected Results**:
- Dialog closes
- No changes made to event
- Event name remains original
---
### TC-RN-011: Rename Dialog Close (X Button)
**Description**: Verify X button closes dialog without changes
**Steps**:
1. Open rename dialog
2. Enter a new name
3. Click X button in top-right
4. Verify dialog closes
5. Verify event name unchanged
**Expected Results**:
- Dialog closes
- No changes made to event
---
### TC-RN-012: Photo File Paths Updated
**Description**: Verify photo file paths are updated after rename
**Steps**:
1. Create event with photos
2. Note photo paths in database
3. Rename the event
4. Verify photos are still accessible
5. Check photo paths in database
**Expected Results**:
- All photos remain accessible
- Photo paths updated to reflect new slug
- Thumbnails still work
---
### TC-RN-013: Activity Log Entry Created
**Description**: Verify rename operation is logged
**Steps**:
1. Rename an event
2. Check activity logs in database or admin panel
**Expected Results**:
- Activity log entry created with type "event_renamed"
- Metadata includes old name, new name, old slug, new slug
- Actor information recorded
---
## API Test Cases
### TC-RN-API-001: POST /api/admin/events/:id/rename
**Description**: Test rename API endpoint
**Request**:
```json
{
"newEventName": "New Event Name",
"resendEmail": false
}
```
**Expected Response (200)**:
```json
{
"success": true,
"message": "Event renamed successfully",
"data": {
"eventId": 1,
"oldName": "Old Event Name",
"newName": "New Event Name",
"oldSlug": "wedding-old-event-name-2026-01-01",
"newSlug": "wedding-new-event-name-2026-01-01",
"newShareLink": "/gallery/wedding-new-event-name-2026-01-01/abc123...",
"emailSent": false,
"filesRenamed": 3
}
}
```
---
### TC-RN-API-002: POST /api/admin/events/:id/validate-rename
**Description**: Test rename validation endpoint
**Request**:
```json
{
"newEventName": "New Event Name"
}
```
**Expected Response (200)**:
```json
{
"valid": true,
"newSlug": "wedding-new-event-name-2026-01-01"
}
```
---
### TC-RN-API-003: Rename Requires Authentication
**Description**: Verify API requires admin authentication
**Steps**:
1. Call rename endpoint without auth token
2. Call rename endpoint with invalid token
**Expected Results**:
- 401 Unauthorized response
---
## Database Verification
### TC-RN-DB-001: Events Table Updated
After successful rename, verify:
- `event_name` updated to new name
- `slug` updated to new slug
- `share_link` updated with new slug
### TC-RN-DB-002: Photos Table Updated
After successful rename, verify:
- `path` column updated for all event photos
- `thumbnail_path` column updated for all event photos
### TC-RN-DB-003: Slug Redirects Table Populated
After successful rename, verify:
- Entry created in `slug_redirects` table
- `old_slug` contains previous slug
- `new_slug` contains new slug
- `event_id` references correct event
---
## Error Handling
### TC-RN-ERR-001: Database Error During Rename
**Description**: Verify rollback on database error
**Expected Behavior**:
- File system changes rolled back
- Original event state preserved
- Error message returned to user
### TC-RN-ERR-002: File System Error During Rename
**Description**: Verify handling of file system errors
**Expected Behavior**:
- Transaction rolled back
- Error message returned to user
- Event remains unchanged
---
## Performance
### TC-RN-PERF-001: Rename Large Event
**Description**: Verify performance with events containing many photos
**Steps**:
1. Create event with 100+ photos
2. Rename the event
3. Measure time taken
**Expected Results**:
- Operation completes within reasonable time
- Progress indicator keeps user informed
- All photos remain accessible
+245
View File
@@ -0,0 +1,245 @@
# Test Specification: Optional Event Fields
This document specifies the test cases for the Optional Event Fields feature, which allows administrators to configure whether customer name, customer email, and admin email are required when creating events.
## Prerequisites
- Local Docker environment running (`docker-compose up`)
- Access to admin dashboard
- Backend migrations applied
## Test Cases
### 1. Settings Page - Event Creation Tab
#### TC-OEF-001: Event Creation Tab Visibility
**Steps:**
1. Navigate to Settings page (`/admin/settings`)
2. Verify "Event Creation" tab is visible
**Expected Result:**
- Tab labeled "Event Creation" or similar should be present in the settings navigation
#### TC-OEF-002: Default Field Requirements
**Steps:**
1. Navigate to Settings > Event Creation tab
2. Check initial state of all three toggles
**Expected Result:**
- "Require Customer Name" toggle is ON (enabled)
- "Require Customer Email" toggle is ON (enabled)
- "Require Admin Email" toggle is ON (enabled)
#### TC-OEF-003: Toggle Customer Name Requirement
**Steps:**
1. Navigate to Settings > Event Creation
2. Toggle OFF "Require Customer Name"
3. Click Save
**Expected Result:**
- Setting saves successfully
- Toast notification confirms save
- Toggle remains OFF after page refresh
#### TC-OEF-004: Toggle Customer Email Requirement
**Steps:**
1. Navigate to Settings > Event Creation
2. Toggle OFF "Require Customer Email"
3. Click Save
**Expected Result:**
- Setting saves successfully
- Warning message about email functionality is shown
- Toggle remains OFF after page refresh
#### TC-OEF-005: Toggle Admin Email Requirement
**Steps:**
1. Navigate to Settings > Event Creation
2. Toggle OFF "Require Admin Email"
3. Click Save
**Expected Result:**
- Setting saves successfully
- Warning message about notifications is shown
- Toggle remains OFF after page refresh
### 2. Create Event Form - Conditional Validation
#### TC-OEF-006: All Fields Required (Default)
**Steps:**
1. Ensure all three settings are ON in Settings > Event Creation
2. Navigate to Create Event page
3. Try to submit form without filling customer name, customer email, or admin email
**Expected Result:**
- Validation errors shown for all three empty fields
- Form does not submit
#### TC-OEF-007: Customer Name Optional
**Steps:**
1. Set "Require Customer Name" to OFF in Settings
2. Navigate to Create Event page
3. Verify Host Name field label shows "(optional)"
4. Submit form without customer name (but with required fields filled)
**Expected Result:**
- Host Name label shows "(optional)" suffix
- Form submits successfully without customer name
- Event is created
#### TC-OEF-008: Customer Email Optional
**Steps:**
1. Set "Require Customer Email" to OFF in Settings
2. Navigate to Create Event page
3. Verify Host Email field label shows "(optional)"
4. Submit form without customer email (but with required fields filled)
**Expected Result:**
- Host Email label shows "(optional)" suffix
- Form submits successfully without customer email
- Event is created
#### TC-OEF-009: Admin Email Optional
**Steps:**
1. Set "Require Admin Email" to OFF in Settings
2. Navigate to Create Event page
3. Verify Admin Email field label shows "(optional)"
4. Submit form without admin email (but with required fields filled)
**Expected Result:**
- Admin Email label shows "(optional)" suffix
- Form submits successfully without admin email
- Event is created
#### TC-OEF-010: All Fields Optional
**Steps:**
1. Set all three settings to OFF in Settings
2. Navigate to Create Event page
3. Submit form with only event name, date, and password
**Expected Result:**
- All three optional fields show "(optional)" suffix
- Form submits successfully
- Event is created with null/empty contact fields
### 3. Backend Validation
#### TC-OEF-011: Backend Respects Settings
**Steps:**
1. Set "Require Customer Email" to OFF
2. Make direct API call to create event without customer_email:
```
POST /api/admin/events
{ event_name: "Test", event_date: "2025-01-15", ... }
```
**Expected Result:**
- API accepts the request
- Returns 201 Created
- Event is created without customer_email
#### TC-OEF-012: Backend Rejects When Required
**Steps:**
1. Set "Require Customer Email" to ON
2. Make direct API call to create event without customer_email
**Expected Result:**
- API rejects the request
- Returns 400 Bad Request with validation error
- Error message indicates customer_email is required
### 4. Format Validation for Optional Fields
#### TC-OEF-013: Invalid Email Format Still Rejected
**Steps:**
1. Set "Require Customer Email" to OFF
2. Navigate to Create Event page
3. Enter invalid email format (e.g., "notanemail")
4. Submit form
**Expected Result:**
- Validation error for invalid email format
- Form does not submit
- Error message: "Invalid email format"
### 5. Create Event Enhanced Page
#### TC-OEF-014: Enhanced Page Respects Settings
**Steps:**
1. Set all three settings to OFF
2. Navigate to enhanced Create Event page (`/admin/events/create`)
3. Verify all three fields show "(optional)"
4. Submit form without contact fields
**Expected Result:**
- All optional labels visible
- Form submits successfully
- Event is created
### 6. Settings Persistence
#### TC-OEF-015: Settings Persist Across Sessions
**Steps:**
1. Set specific combination (e.g., customer name OFF, emails ON)
2. Log out
3. Log back in
4. Navigate to Settings > Event Creation
**Expected Result:**
- Settings remain as configured
- Toggle states match what was saved
#### TC-OEF-016: Settings Persist Across Backend Restart
**Steps:**
1. Set specific combination of settings
2. Restart backend container
3. Create new event
**Expected Result:**
- Validation behavior matches saved settings
- Database persisted settings correctly
## Edge Cases
### TC-OEF-017: Empty String vs Null
**Steps:**
1. Set field to optional
2. Create event with empty string for that field
3. View event details
**Expected Result:**
- Field stores empty value appropriately
- No errors in display
### TC-OEF-018: Rapid Toggle Changes
**Steps:**
1. Quickly toggle settings on/off multiple times
2. Save after each change
**Expected Result:**
- Each save completes without error
- Final state matches last save action
## Automated Testing Notes
For Playwright tests:
1. Login to admin dashboard
2. Navigate to Settings
3. Click Event Creation tab
4. Manipulate toggles using checkbox selectors
5. Navigate to Create Event
6. Verify label text contains or doesn't contain "(optional)"
7. Attempt form submission with various field combinations
8. Assert on validation messages and success/failure states
## Files Modified
### Backend
- `/backend/migrations/core/050_add_optional_event_fields_settings.js`
- `/backend/src/routes/adminEvents.js`
- `/backend/src/routes/publicSettings.js`
### Frontend
- `/frontend/src/pages/admin/SettingsPage.tsx`
- `/frontend/src/pages/admin/CreateEventPage.tsx`
- `/frontend/src/pages/admin/CreateEventPageEnhanced.tsx`
+327
View File
@@ -0,0 +1,327 @@
# Test Specification: Photo Filtering & Export
This document specifies the test cases for the Photo Filtering & Export feature, which allows administrators to filter photos by guest feedback and export filtered selections in various formats.
## Prerequisites
- Local Docker environment running (`docker-compose up`)
- Access to admin dashboard
- Event with photos that have feedback (ratings, likes, favorites, comments)
- Backend migrations applied (051_add_photo_filter_indexes.js)
## Test Cases
### 1. Filter Panel UI
#### TC-PFE-001: Filter Panel Visibility
**Steps:**
1. Navigate to Event Details > Photos tab
2. Verify "Feedback Filters" panel is visible
**Expected Result:**
- Filter panel shows rating dropdown
- Checkboxes for: Has likes, Has favorites, Has comments
- Summary counts displayed
#### TC-PFE-002: Rating Filter Options
**Steps:**
1. Open the Rating dropdown in filter panel
2. Verify all rating options are present
**Expected Result:**
- Options: All photos, Any rating, 1+ stars, 2+ stars, 3+ stars, 4+ stars, 5 stars only
- Dropdown is functional
#### TC-PFE-003: Filter by Rating
**Steps:**
1. Select "4+ stars" from rating dropdown
2. Observe changes
**Expected Result:**
- Filter is applied
- Export menu becomes enabled (if photos match)
#### TC-PFE-004: Filter by Likes
**Steps:**
1. Check "Has likes" checkbox
2. Observe changes
**Expected Result:**
- Count shows number of photos with likes
- Filter is applied
#### TC-PFE-005: Filter by Favorites
**Steps:**
1. Check "Has favorites" checkbox
2. Observe changes
**Expected Result:**
- Count shows number of favorited photos
- Filter is applied
#### TC-PFE-006: Filter by Comments
**Steps:**
1. Check "Has comments" checkbox
2. Observe changes
**Expected Result:**
- Count shows number of commented photos
- Filter is applied
#### TC-PFE-007: AND/OR Logic Toggle
**Steps:**
1. Check multiple feedback filters (e.g., Has likes AND Has favorites)
2. Toggle between AND and OR
3. Observe export button state
**Expected Result:**
- AND: Photos must have both likes AND favorites
- OR: Photos can have likes OR favorites
- Toggle is visible when multiple filters selected
#### TC-PFE-008: Clear Filters
**Steps:**
1. Apply multiple filters
2. Click "Clear" button
**Expected Result:**
- All filters reset to default
- Rating dropdown shows "All photos"
- All checkboxes unchecked
### 2. Export Menu
#### TC-PFE-009: Export Menu Disabled State
**Steps:**
1. Clear all feedback filters
2. Ensure no photos are selected
3. Observe Export button
**Expected Result:**
- Export button is disabled
- Hint text: "Select photos or apply filters to export"
#### TC-PFE-010: Export Menu Enabled with Filters
**Steps:**
1. Apply a feedback filter (e.g., rating >= 3)
2. Observe Export button
**Expected Result:**
- Export button becomes enabled
- Dropdown shows export format options
#### TC-PFE-011: Export Format Options
**Steps:**
1. Enable Export button with filters
2. Click to open dropdown
**Expected Result:**
- Four format options visible:
- Filename List (TXT)
- Filename List (CSV)
- XMP Sidecar Files (ZIP)
- Metadata (JSON)
- Each has description text
### 3. Export Functionality
#### TC-PFE-012: Export TXT Format
**Steps:**
1. Apply filter (e.g., has favorites)
2. Click Export > Filename List (TXT)
3. Open downloaded file
**Expected Result:**
- File downloads with .txt extension
- Contains one filename per line
- Uses original filenames (e.g., IMG_0001.jpg)
#### TC-PFE-013: Export CSV Format
**Steps:**
1. Apply filter
2. Click Export > Filename List (CSV)
3. Open in spreadsheet
**Expected Result:**
- File downloads with .csv extension
- Headers: filename, original_filename, rating, rating_count, likes, favorites, comments, category, etc.
- Data rows for each filtered photo
#### TC-PFE-014: Export XMP Format
**Steps:**
1. Apply filter
2. Click Export > XMP Sidecar Files (ZIP)
3. Extract ZIP and inspect files
**Expected Result:**
- ZIP file downloads
- Contains .xmp files for each photo
- XMP files contain:
- xmp:Rating (1-5)
- xmp:Label (color)
- Description with feedback summary
- Keywords including "picpeak-export"
#### TC-PFE-015: Export JSON Format
**Steps:**
1. Apply filter
2. Click Export > Metadata (JSON)
3. Open/parse JSON file
**Expected Result:**
- JSON file downloads
- Contains export_info (event name, date, exported_at, total_photos)
- Contains photos array with full metadata
#### TC-PFE-016: XMP Rating Mapping
**Steps:**
1. Have photos with various ratings
2. Export XMP
3. Check xmp:Rating values
**Expected Result:**
- 4.5-5.0 stars → xmp:Rating="5", Label="Red"
- 3.5-4.4 stars → xmp:Rating="4", Label="Yellow"
- 2.5-3.4 stars → xmp:Rating="3", Label="Green"
- 1.5-2.4 stars → xmp:Rating="2", Label="Blue"
- 0.5-1.4 stars → xmp:Rating="1", Label="Purple"
### 4. Backend API Tests
#### TC-PFE-017: Filtered Photos Endpoint
**Steps:**
1. Call API: `GET /api/admin/photo-export/:eventId/filtered?min_rating=4&has_likes=true`
**Expected Result:**
- Returns 200 OK
- Response includes photos, pagination, and summary
- Only photos matching filter returned
#### TC-PFE-018: Filter Summary Endpoint
**Steps:**
1. Call API: `GET /api/admin/photo-export/:eventId/filter-summary`
**Expected Result:**
- Returns 200 OK
- Response includes: total, withRatings, withLikes, withFavorites, withComments
#### TC-PFE-019: Export Endpoint with Photo IDs
**Steps:**
1. Call API: `POST /api/admin/photo-export/:eventId/export`
Body: `{ "photo_ids": [1, 2, 3], "format": "csv" }`
**Expected Result:**
- Returns CSV file
- Contains only specified photos
#### TC-PFE-020: Export Endpoint with Filters
**Steps:**
1. Call API: `POST /api/admin/photo-export/:eventId/export`
Body: `{ "filter": { "minRating": 4 }, "format": "txt" }`
**Expected Result:**
- Returns TXT file
- Contains all photos matching filter
### 5. Edge Cases
#### TC-PFE-021: Empty Filter Results
**Steps:**
1. Apply filter that matches no photos (e.g., rating = 5 when no 5-star photos exist)
2. Try to export
**Expected Result:**
- Export button disabled or shows "0 photos"
- Error message if attempting export
#### TC-PFE-022: Large Export
**Steps:**
1. Filter to include 100+ photos
2. Export as XMP ZIP
**Expected Result:**
- Export completes (may take time)
- ZIP contains all matching .xmp files
- Loading indicator shown during export
#### TC-PFE-023: Special Characters in Filenames
**Steps:**
1. Have photo with special characters in original filename
2. Export CSV
**Expected Result:**
- Filename properly escaped in CSV
- No parsing errors
### 6. Integration Tests
#### TC-PFE-024: XMP Import to Lightroom
**Steps:**
1. Export XMP files
2. Place XMP files next to original photos
3. In Lightroom: Select photos > Metadata > Read Metadata from Files
**Expected Result:**
- Lightroom reads XMP files
- Ratings appear on photos
- Color labels applied
- Keywords visible in metadata panel
#### TC-PFE-025: TXT List in Lightroom Search
**Steps:**
1. Export TXT filename list
2. In Lightroom: Library > Filter > Text > Filename > Contains
3. Paste comma-separated list
**Expected Result:**
- Lightroom filters to matching files
- Can select and add to collection
### 7. Performance Tests
#### TC-PFE-026: Filter Performance
**Steps:**
1. Event with 500+ photos
2. Apply feedback filter
3. Measure response time
**Expected Result:**
- Filter applied within 2 seconds
- UI remains responsive
#### TC-PFE-027: Export Performance
**Steps:**
1. Export 200 photos as XMP ZIP
2. Measure download time
**Expected Result:**
- Export completes within reasonable time
- Progress indicator shown for large exports
## Automated Testing Notes
For Playwright tests:
1. Login to admin dashboard
2. Navigate to an event with photos
3. Go to Photos tab
4. Manipulate filter panel controls
5. Click export menu
6. Verify file downloads
7. For API tests, use direct fetch/axios calls
## Files Modified
### Backend
- `/backend/migrations/core/051_add_photo_filter_indexes.js`
- `/backend/src/utils/photoFilterBuilder.js`
- `/backend/src/services/xmpGenerator.js`
- `/backend/src/services/photoExportService.js`
- `/backend/src/routes/adminPhotoExport.js`
- `/backend/server.js`
### Frontend
- `/frontend/src/services/photos.service.ts`
- `/frontend/src/components/admin/PhotoFilterPanel.tsx`
- `/frontend/src/components/admin/PhotoExportMenu.tsx`
- `/frontend/src/components/admin/index.ts`
- `/frontend/src/pages/admin/EventDetailsPage.tsx`
@@ -13,13 +13,15 @@ interface AdminPhotoGridProps {
eventId: number;
onPhotoClick: (photo: AdminPhoto, index: number) => void;
onPhotosDeleted: () => void;
onSelectionChange?: (selectedIds: number[]) => void;
}
export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
photos,
eventId,
onPhotoClick,
onPhotosDeleted
onPhotosDeleted,
onSelectionChange
}) => {
const { t } = useTranslation();
const [selectedPhotos, setSelectedPhotos] = useState<Set<number>>(new Set());
@@ -42,14 +44,18 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
newSelected.add(photoId);
}
setSelectedPhotos(newSelected);
onSelectionChange?.(Array.from(newSelected));
};
const handleSelectAll = () => {
let newSelected: Set<number>;
if (selectedPhotos.size === photos.length) {
setSelectedPhotos(new Set());
newSelected = new Set();
} else {
setSelectedPhotos(new Set(photos.map(p => p.id)));
newSelected = new Set(photos.map(p => p.id));
}
setSelectedPhotos(newSelected);
onSelectionChange?.(Array.from(newSelected));
};
const handleDeleteSingle = async (photo: AdminPhoto, e: React.MouseEvent) => {
@@ -91,6 +97,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
toast.success(`${count} photo${count > 1 ? 's' : ''} deleted successfully`);
setSelectedPhotos(new Set());
setIsSelectionMode(false);
onSelectionChange?.([]);
onPhotosDeleted();
} catch {
toast.error('Failed to delete photos');
@@ -114,6 +121,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
setIsSelectionMode(!isSelectionMode);
if (isSelectionMode) {
setSelectedPhotos(new Set());
onSelectionChange?.([]);
}
};
@@ -0,0 +1,241 @@
import React, { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import { Save, RotateCcw, Eye, Code, AlertTriangle, Check } from 'lucide-react';
import { Button, Card, Loading } from '../common';
import { cssTemplatesService, CssTemplate } from '../../services/cssTemplates.service';
export const CssTemplateEditor: React.FC = () => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [activeSlot, setActiveSlot] = useState(1);
const [localTemplates, setLocalTemplates] = useState<CssTemplate[]>([]);
const [hasChanges, setHasChanges] = useState(false);
// Fetch templates
const { data: templates, isLoading } = useQuery({
queryKey: ['css-templates'],
queryFn: () => cssTemplatesService.getTemplates()
});
// Update local state when templates load
useEffect(() => {
if (templates) {
setLocalTemplates(templates);
setHasChanges(false);
}
}, [templates]);
// Save mutation
const saveMutation = useMutation({
mutationFn: async () => {
const template = localTemplates.find(t => t.slot_number === activeSlot);
if (!template) throw new Error('Template not found');
return cssTemplatesService.updateTemplate(activeSlot, {
name: template.name,
css_content: template.css_content,
is_enabled: template.is_enabled
});
},
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['css-templates'] });
setHasChanges(false);
if (result.warnings.length > 0) {
toast.warning(t('cssTemplates.sanitizationWarning', 'Some CSS patterns were blocked for security'));
} else {
toast.success(t('cssTemplates.saved', 'Template saved successfully'));
}
},
onError: (error: Error) => {
toast.error(error.message || t('cssTemplates.saveFailed', 'Failed to save template'));
}
});
// Reset mutation
const resetMutation = useMutation({
mutationFn: () => cssTemplatesService.resetToDefault(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['css-templates'] });
toast.success(t('cssTemplates.reset', 'Template reset to default'));
},
onError: (error: Error) => {
toast.error(error.message || t('cssTemplates.resetFailed', 'Failed to reset template'));
}
});
const activeTemplate = localTemplates.find(t => t.slot_number === activeSlot);
const updateLocalTemplate = (updates: Partial<CssTemplate>) => {
setLocalTemplates(prev =>
prev.map(t =>
t.slot_number === activeSlot ? { ...t, ...updates } : t
)
);
setHasChanges(true);
};
const handleReset = () => {
if (!confirm(t('cssTemplates.resetConfirm', 'Reset this template to the default? Your changes will be lost.'))) {
return;
}
resetMutation.mutate();
};
if (isLoading) {
return <Loading size="lg" text={t('common.loading', 'Loading...')} />;
}
return (
<Card>
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-semibold text-neutral-900 flex items-center gap-2">
<Code className="w-5 h-5" />
{t('cssTemplates.title', 'Custom CSS Templates')}
</h2>
</div>
{/* Tab Navigation */}
<div className="flex border-b border-neutral-200 mb-6">
{[1, 2, 3].map(slot => {
const template = localTemplates.find(t => t.slot_number === slot);
return (
<button
key={slot}
onClick={() => setActiveSlot(slot)}
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
activeSlot === slot
? 'border-primary-600 text-primary-600'
: 'border-transparent text-neutral-600 hover:text-neutral-900 hover:border-neutral-300'
}`}
>
{t('cssTemplates.template', 'Template')} {slot}
{template && (
<span className="ml-2 text-neutral-400">
({template.name})
</span>
)}
{template?.is_enabled && (
<Check className="w-3 h-3 inline ml-1 text-green-500" />
)}
</button>
);
})}
</div>
{activeTemplate && (
<div className="space-y-6">
{/* Template Name */}
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('cssTemplates.templateName', 'Template Name')}
</label>
<input
type="text"
value={activeTemplate.name}
onChange={(e) => updateLocalTemplate({ name: e.target.value })}
maxLength={50}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
{/* Enable Toggle */}
<div>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={activeTemplate.is_enabled}
onChange={(e) => updateLocalTemplate({ is_enabled: e.target.checked })}
className="rounded border-neutral-300 text-primary-600 focus:ring-primary-500"
/>
<span className="text-sm font-medium text-neutral-700">
{t('cssTemplates.enableTemplate', 'Enable this template')}
</span>
</label>
<p className="text-xs text-neutral-500 mt-1 ml-6">
{t('cssTemplates.enableHint', 'Enabled templates can be selected when creating events')}
</p>
</div>
{/* CSS Editor */}
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('cssTemplates.cssContent', 'CSS Content')}
</label>
<div className="relative">
<textarea
value={activeTemplate.css_content}
onChange={(e) => updateLocalTemplate({ css_content: e.target.value })}
className="w-full h-96 px-4 py-3 font-mono text-sm border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-neutral-900 text-green-400"
spellCheck={false}
placeholder="/* Enter your custom CSS here */"
/>
<div className="absolute bottom-3 right-3 text-xs text-neutral-400">
{(activeTemplate.css_content?.length || 0).toLocaleString()} / 102,400 {t('common.characters', 'characters')}
</div>
</div>
<p className="text-xs text-neutral-500 mt-2">
{t('cssTemplates.cssHint', 'Use .gallery-page to scope styles to the gallery. Available variables: --gallery-bg, --gallery-text, --gallery-accent')}
</p>
</div>
{/* Security Notice */}
<div className="flex items-start gap-2 p-3 bg-amber-50 border border-amber-200 rounded-lg">
<AlertTriangle className="w-4 h-4 text-amber-600 mt-0.5 flex-shrink-0" />
<div className="text-xs text-amber-800">
<strong>{t('cssTemplates.securityNotice', 'Security Notice')}:</strong>{' '}
{t('cssTemplates.securityText', 'CSS is sanitized to prevent malicious code. External URLs, @import, and JavaScript expressions are blocked.')}
</div>
</div>
{/* Action Buttons */}
<div className="flex items-center justify-between pt-4 border-t border-neutral-100">
<div className="flex items-center gap-3">
{activeSlot === 1 && activeTemplate.is_default && (
<Button
variant="outline"
size="sm"
onClick={handleReset}
disabled={resetMutation.isPending}
leftIcon={<RotateCcw className="w-4 h-4" />}
>
{t('cssTemplates.resetToDefault', 'Reset to Default')}
</Button>
)}
</div>
<div className="flex items-center gap-3">
{hasChanges && (
<span className="text-sm text-amber-600">
{t('cssTemplates.unsavedChanges', 'Unsaved changes')}
</span>
)}
<Button
variant="primary"
onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending || !hasChanges}
isLoading={saveMutation.isPending}
leftIcon={<Save className="w-4 h-4" />}
>
{t('cssTemplates.saveTemplate', 'Save Template')}
</Button>
</div>
</div>
{/* Last Updated */}
{activeTemplate.updated_at && (
<p className="text-xs text-neutral-400 text-right">
{t('cssTemplates.lastUpdated', 'Last updated')}: {new Date(activeTemplate.updated_at).toLocaleString()}
</p>
)}
</div>
)}
</div>
</Card>
);
};
export default CssTemplateEditor;
@@ -0,0 +1,315 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { X, AlertCircle, CheckCircle, Loader2, Type, Mail } from 'lucide-react';
import { Button, Input, Card } from '../common';
interface EventRenameDialogProps {
isOpen: boolean;
eventName: string;
eventId: number;
customerEmail?: string;
onClose: () => void;
onRename: (newName: string, resendEmail: boolean) => Promise<{
success: boolean;
data?: {
newSlug: string;
newShareLink: string;
filesRenamed: number;
};
error?: string;
}>;
onValidate: (newName: string) => Promise<{
valid: boolean;
newSlug?: string;
error?: string;
}>;
}
export const EventRenameDialog: React.FC<EventRenameDialogProps> = ({
isOpen,
eventName,
eventId,
customerEmail,
onClose,
onRename,
onValidate
}) => {
const { t } = useTranslation();
const [newName, setNewName] = useState(eventName);
const [resendEmail, setResendEmail] = useState(false);
const [isValidating, setIsValidating] = useState(false);
const [isRenaming, setIsRenaming] = useState(false);
const [validationResult, setValidationResult] = useState<{
valid: boolean;
newSlug?: string;
error?: string;
} | null>(null);
const [renameStatus, setRenameStatus] = useState<string | null>(null);
const [renameResult, setRenameResult] = useState<{
success: boolean;
newSlug?: string;
newShareLink?: string;
filesRenamed?: number;
error?: string;
} | null>(null);
// Reset state when dialog opens
useEffect(() => {
if (isOpen) {
setNewName(eventName);
setResendEmail(false);
setValidationResult(null);
setRenameStatus(null);
setRenameResult(null);
}
}, [isOpen, eventName]);
// Debounced validation
useEffect(() => {
if (!isOpen || newName.trim() === eventName.trim() || newName.trim().length < 3) {
setValidationResult(null);
return;
}
const timeoutId = setTimeout(async () => {
setIsValidating(true);
try {
const result = await onValidate(newName.trim());
setValidationResult(result);
} catch (error) {
setValidationResult({ valid: false, error: 'Validation failed' });
} finally {
setIsValidating(false);
}
}, 500);
return () => clearTimeout(timeoutId);
}, [newName, eventName, isOpen, onValidate]);
const handleRename = async () => {
if (!validationResult?.valid) return;
setIsRenaming(true);
setRenameStatus(t('events.rename.validating', 'Validating new name...'));
try {
setRenameStatus(t('events.rename.renamingFiles', 'Renaming files...'));
const result = await onRename(newName.trim(), resendEmail);
if (result.success) {
setRenameStatus(t('events.rename.complete', 'Complete!'));
setRenameResult({
success: true,
newSlug: result.data?.newSlug,
newShareLink: result.data?.newShareLink,
filesRenamed: result.data?.filesRenamed
});
} else {
setRenameResult({
success: false,
error: result.error || 'Rename failed'
});
}
} catch (error: any) {
setRenameResult({
success: false,
error: error.message || 'Rename failed'
});
} finally {
setIsRenaming(false);
setRenameStatus(null);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<Card className="max-w-lg w-full">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-neutral-900">
{t('events.rename.title', 'Rename Event')}
</h2>
<button
onClick={onClose}
disabled={isRenaming}
className="text-neutral-400 hover:text-neutral-600 disabled:opacity-50"
>
<X className="w-5 h-5" />
</button>
</div>
{renameResult?.success ? (
// Success state
<div className="space-y-4">
<div className="flex items-center gap-3 p-4 bg-green-50 rounded-lg">
<CheckCircle className="w-6 h-6 text-green-600 flex-shrink-0" />
<div>
<p className="font-medium text-green-900">
{t('events.rename.success', 'Event renamed successfully!')}
</p>
{renameResult.filesRenamed !== undefined && renameResult.filesRenamed > 0 && (
<p className="text-sm text-green-700 mt-1">
{t('events.rename.filesRenamed', '{{count}} files updated', { count: renameResult.filesRenamed })}
</p>
)}
</div>
</div>
{renameResult.newShareLink && (
<div className="p-3 bg-neutral-50 rounded-lg">
<p className="text-sm font-medium text-neutral-700 mb-1">
{t('events.rename.newLink', 'New Gallery Link')}
</p>
<p className="text-sm text-neutral-900 break-all">{renameResult.newShareLink}</p>
</div>
)}
<div className="flex justify-end">
<Button variant="primary" onClick={onClose}>
{t('common.done', 'Done')}
</Button>
</div>
</div>
) : renameResult?.error ? (
// Error state
<div className="space-y-4">
<div className="flex items-center gap-3 p-4 bg-red-50 rounded-lg">
<AlertCircle className="w-6 h-6 text-red-600 flex-shrink-0" />
<div>
<p className="font-medium text-red-900">
{t('events.rename.failed', 'Rename failed')}
</p>
<p className="text-sm text-red-700 mt-1">{renameResult.error}</p>
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setRenameResult(null)}>
{t('common.retry', 'Retry')}
</Button>
<Button variant="primary" onClick={onClose}>
{t('common.close', 'Close')}
</Button>
</div>
</div>
) : isRenaming ? (
// Renaming in progress
<div className="space-y-4 py-8">
<div className="flex flex-col items-center gap-4">
<Loader2 className="w-10 h-10 text-primary-600 animate-spin" />
<p className="text-neutral-700 font-medium">{renameStatus}</p>
</div>
</div>
) : (
// Input form
<div className="space-y-4">
<div>
<p className="text-sm text-neutral-600 mb-3">
{t('events.rename.currentName', 'Current name:')} <span className="font-medium">{eventName}</span>
</p>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('events.rename.newName', 'New Event Name')}
</label>
<Input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder={t('events.rename.enterNewName', 'Enter new event name')}
leftIcon={<Type className="w-5 h-5 text-neutral-400" />}
autoFocus
/>
</div>
{/* New slug preview */}
{validationResult?.valid && validationResult.newSlug && (
<div className="p-3 bg-green-50 rounded-lg">
<p className="text-sm text-green-800">
<span className="font-medium">{t('events.rename.newUrl', 'New URL:')}</span>{' '}
<span className="break-all">/gallery/{validationResult.newSlug}/...</span>
</p>
</div>
)}
{/* Validation status */}
{isValidating && (
<div className="flex items-center gap-2 text-sm text-neutral-500">
<Loader2 className="w-4 h-4 animate-spin" />
{t('events.rename.checkingAvailability', 'Checking availability...')}
</div>
)}
{validationResult && !validationResult.valid && (
<div className="flex items-center gap-2 p-3 bg-red-50 rounded-lg">
<AlertCircle className="w-4 h-4 text-red-600 flex-shrink-0" />
<p className="text-sm text-red-700">{validationResult.error}</p>
</div>
)}
{/* Resend email option */}
{customerEmail && (
<div className="pt-2 border-t border-neutral-200">
<label className="flex items-start gap-2">
<input
type="checkbox"
checked={resendEmail}
onChange={(e) => setResendEmail(e.target.checked)}
className="mt-1 w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
/>
<div>
<span className="text-sm font-medium text-neutral-700 flex items-center gap-1">
<Mail className="w-4 h-4" />
{t('events.rename.resendEmail', 'Resend invitation email with new gallery link')}
</span>
<p className="text-xs text-neutral-500 mt-1">
{t('events.rename.emailTo', 'Send updated gallery access email to')} {customerEmail}
</p>
</div>
</label>
</div>
)}
{/* Warning */}
<div className="p-3 bg-amber-50 rounded-lg border border-amber-200">
<div className="flex gap-2">
<AlertCircle className="w-4 h-4 text-amber-600 flex-shrink-0 mt-0.5" />
<div className="text-sm text-amber-800">
<p className="font-medium">{t('events.rename.warningTitle', 'Please note:')}</p>
<ul className="mt-1 list-disc list-inside space-y-1">
<li>{t('events.rename.warning1', 'The gallery URL will change')}</li>
<li>{t('events.rename.warning2', 'Old URLs will automatically redirect to the new URL')}</li>
<li>{t('events.rename.warning3', 'Photo files may be renamed')}</li>
</ul>
</div>
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={onClose}>
{t('common.cancel')}
</Button>
<Button
variant="primary"
onClick={handleRename}
disabled={
!validationResult?.valid ||
isValidating ||
newName.trim() === eventName.trim() ||
newName.trim().length < 3
}
>
{t('events.rename.confirm', 'Rename Event')}
</Button>
</div>
</div>
)}
</Card>
</div>
);
};
EventRenameDialog.displayName = 'EventRenameDialog';
@@ -0,0 +1,176 @@
import React, { useState } from 'react';
import { Download, FileText, FileSpreadsheet, Archive, FileJson, ChevronDown, Loader2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useMutation } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import { photosService, ExportOptions, FeedbackFilters } from '../../services/photos.service';
interface PhotoExportMenuProps {
eventId: number;
selectedPhotoIds: number[];
filters?: FeedbackFilters;
disabled?: boolean;
}
const EXPORT_FORMATS = [
{
value: 'txt',
label: 'Filename List (TXT)',
description: 'Simple text list for Lightroom search',
icon: FileText
},
{
value: 'csv',
label: 'Filename List (CSV)',
description: 'Spreadsheet with metadata',
icon: FileSpreadsheet
},
{
value: 'xmp',
label: 'XMP Sidecar Files (ZIP)',
description: 'Import ratings into Lightroom/Bridge',
icon: Archive
},
{
value: 'json',
label: 'Metadata (JSON)',
description: 'Structured data for automation',
icon: FileJson
},
];
export const PhotoExportMenu: React.FC<PhotoExportMenuProps> = ({
eventId,
selectedPhotoIds,
filters,
disabled = false
}) => {
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const exportMutation = useMutation({
mutationFn: (options: ExportOptions) => photosService.exportPhotos(eventId, options),
onSuccess: () => {
toast.success(t('export.success', 'Export downloaded successfully'));
setIsOpen(false);
},
onError: (error: Error) => {
toast.error(t('export.error', 'Export failed: ') + error.message);
}
});
const handleExport = (format: 'txt' | 'csv' | 'xmp' | 'json') => {
const options: ExportOptions = {
format,
options: {
filename_format: 'original',
include_rating: true,
include_label: true,
include_description: true,
include_keywords: true
}
};
// Use selected photos if any, otherwise use filters
if (selectedPhotoIds.length > 0) {
options.photo_ids = selectedPhotoIds;
} else if (filters) {
options.filter = filters;
}
exportMutation.mutate(options);
};
const hasSelection = selectedPhotoIds.length > 0;
const hasFilters = filters && (
filters.minRating !== null ||
filters.hasLikes ||
filters.hasFavorites ||
filters.hasComments
);
const isDisabled = disabled || (!hasSelection && !hasFilters);
return (
<div className="relative">
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
disabled={isDisabled || exportMutation.isPending}
className={`
inline-flex items-center gap-2 px-4 py-2 rounded-lg border font-medium text-sm
transition-colors
${isDisabled
? 'bg-neutral-100 text-neutral-400 border-neutral-200 cursor-not-allowed'
: 'bg-white text-neutral-700 border-neutral-300 hover:bg-neutral-50'
}
`}
>
{exportMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Download className="w-4 h-4" />
)}
{t('export.button', 'Export')}
{hasSelection && (
<span className="bg-primary-100 text-primary-700 text-xs px-2 py-0.5 rounded-full">
{selectedPhotoIds.length}
</span>
)}
<ChevronDown className={`w-4 h-4 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
</button>
{isOpen && !isDisabled && (
<>
{/* Backdrop */}
<div
className="fixed inset-0 z-10"
onClick={() => setIsOpen(false)}
/>
{/* Dropdown Menu */}
<div className="absolute right-0 mt-2 w-72 bg-white rounded-lg shadow-lg border border-neutral-200 z-20">
<div className="p-2">
<p className="px-3 py-2 text-xs font-medium text-neutral-500 uppercase tracking-wider">
{hasSelection
? t('export.exportSelected', 'Export {{count}} selected', { count: selectedPhotoIds.length })
: t('export.exportFiltered', 'Export filtered photos')
}
</p>
{EXPORT_FORMATS.map((format) => {
const Icon = format.icon;
return (
<button
key={format.value}
onClick={() => handleExport(format.value as 'txt' | 'csv' | 'xmp' | 'json')}
disabled={exportMutation.isPending}
className="w-full flex items-start gap-3 px-3 py-2 rounded-md hover:bg-neutral-50 text-left transition-colors"
>
<Icon className="w-5 h-5 text-neutral-500 mt-0.5" />
<div>
<div className="text-sm font-medium text-neutral-900">
{format.label}
</div>
<div className="text-xs text-neutral-500">
{format.description}
</div>
</div>
</button>
);
})}
</div>
</div>
</>
)}
{!hasSelection && !hasFilters && (
<p className="mt-1 text-xs text-neutral-500">
{t('export.hint', 'Select photos or apply filters to export')}
</p>
)}
</div>
);
};
export default PhotoExportMenu;
@@ -0,0 +1,202 @@
import React from 'react';
import { Star, Heart, Bookmark, MessageCircle, Filter, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button } from '../common';
import { FeedbackFilters, FilterSummary } from '../../services/photos.service';
interface PhotoFilterPanelProps {
filters: FeedbackFilters;
onChange: (filters: FeedbackFilters) => void;
summary: FilterSummary | null;
isLoading?: boolean;
}
const RATING_OPTIONS = [
{ value: null, label: 'filter.allPhotos' },
{ value: 0.1, label: 'filter.anyRating' },
{ value: 1, label: 'filter.oneStarPlus' },
{ value: 2, label: 'filter.twoStarsPlus' },
{ value: 3, label: 'filter.threeStarsPlus' },
{ value: 4, label: 'filter.fourStarsPlus' },
{ value: 5, label: 'filter.fiveStarsOnly' },
];
export const PhotoFilterPanel: React.FC<PhotoFilterPanelProps> = ({
filters,
onChange,
summary,
isLoading = false
}) => {
const { t } = useTranslation();
const handleRatingChange = (value: number | null) => {
onChange({ ...filters, minRating: value });
};
const handleCheckboxChange = (field: 'hasLikes' | 'hasFavorites' | 'hasComments') => {
onChange({ ...filters, [field]: !filters[field] });
};
const handleLogicChange = (logic: 'AND' | 'OR') => {
onChange({ ...filters, logic });
};
const clearFilters = () => {
onChange({
minRating: null,
hasLikes: false,
hasFavorites: false,
hasComments: false,
logic: 'AND'
});
};
const hasActiveFilters = filters.minRating !== null ||
filters.hasLikes ||
filters.hasFavorites ||
filters.hasComments;
return (
<div className="bg-white rounded-lg border border-neutral-200 p-4 mb-4">
<div className="flex items-center justify-between mb-4">
<h3 className="font-medium text-neutral-900 flex items-center gap-2">
<Filter className="w-4 h-4" />
{t('filter.feedbackFilters', 'Feedback Filters')}
</h3>
{hasActiveFilters && (
<Button
variant="ghost"
size="sm"
onClick={clearFilters}
leftIcon={<X className="w-3 h-3" />}
>
{t('filter.clear', 'Clear')}
</Button>
)}
</div>
<div className="space-y-4">
{/* Rating Filter */}
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
<Star className="w-4 h-4 inline mr-1" />
{t('filter.rating', 'Rating')}
</label>
<select
value={filters.minRating ?? ''}
onChange={(e) => handleRatingChange(e.target.value === '' ? null : parseFloat(e.target.value))}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
disabled={isLoading}
>
{RATING_OPTIONS.map(option => (
<option key={option.label} value={option.value ?? ''}>
{t(option.label, option.label.split('.').pop())}
</option>
))}
</select>
</div>
{/* Checkbox Filters */}
<div className="flex flex-wrap gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={filters.hasLikes || false}
onChange={() => handleCheckboxChange('hasLikes')}
className="rounded border-neutral-300 text-primary-600 focus:ring-primary-500"
disabled={isLoading}
/>
<Heart className="w-4 h-4 text-red-500" />
<span className="text-sm text-neutral-700">
{t('filter.hasLikes', 'Has likes')}
{summary && (
<span className="text-neutral-500 ml-1">({summary.withLikes})</span>
)}
</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={filters.hasFavorites || false}
onChange={() => handleCheckboxChange('hasFavorites')}
className="rounded border-neutral-300 text-primary-600 focus:ring-primary-500"
disabled={isLoading}
/>
<Bookmark className="w-4 h-4 text-yellow-500" />
<span className="text-sm text-neutral-700">
{t('filter.hasFavorites', 'Has favorites')}
{summary && (
<span className="text-neutral-500 ml-1">({summary.withFavorites})</span>
)}
</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={filters.hasComments || false}
onChange={() => handleCheckboxChange('hasComments')}
className="rounded border-neutral-300 text-primary-600 focus:ring-primary-500"
disabled={isLoading}
/>
<MessageCircle className="w-4 h-4 text-blue-500" />
<span className="text-sm text-neutral-700">
{t('filter.hasComments', 'Has comments')}
{summary && (
<span className="text-neutral-500 ml-1">({summary.withComments})</span>
)}
</span>
</label>
</div>
{/* Logic Toggle */}
{(filters.hasLikes || filters.hasFavorites || filters.hasComments) && (
<div className="flex items-center gap-2">
<span className="text-sm text-neutral-600">{t('filter.combineWith', 'Combine with')}:</span>
<div className="flex rounded-lg border border-neutral-200 overflow-hidden">
<button
type="button"
onClick={() => handleLogicChange('AND')}
className={`px-3 py-1 text-sm font-medium transition-colors ${
filters.logic === 'AND' || !filters.logic
? 'bg-primary-600 text-white'
: 'bg-white text-neutral-600 hover:bg-neutral-50'
}`}
disabled={isLoading}
>
AND
</button>
<button
type="button"
onClick={() => handleLogicChange('OR')}
className={`px-3 py-1 text-sm font-medium transition-colors ${
filters.logic === 'OR'
? 'bg-primary-600 text-white'
: 'bg-white text-neutral-600 hover:bg-neutral-50'
}`}
disabled={isLoading}
>
OR
</button>
</div>
</div>
)}
{/* Summary */}
{summary && (
<div className="pt-2 border-t border-neutral-100 text-sm text-neutral-600">
{t('filter.showingPhotos', 'Total photos')}: {summary.total}
{summary.withRatings > 0 && (
<span className="ml-2">
| {t('filter.withRatings', 'With ratings')}: {summary.withRatings}
</span>
)}
</div>
)}
</div>
</div>
);
};
export default PhotoFilterPanel;
+4
View File
@@ -31,3 +31,7 @@ export { RestoreWizard } from './RestoreWizard';
export { FeedbackSettings } from './FeedbackSettings';
export { FeedbackModerationPanel } from './FeedbackModerationPanel';
export { WordFilterManager } from './WordFilterManager';
export { EventRenameDialog } from './EventRenameDialog';
export { PhotoFilterPanel } from './PhotoFilterPanel';
export { PhotoExportMenu } from './PhotoExportMenu';
export { CssTemplateEditor } from './CssTemplateEditor';
+66
View File
@@ -0,0 +1,66 @@
import { useEffect, useState } from 'react';
import { cssTemplatesService } from '../services/cssTemplates.service';
/**
* Hook to load and inject custom CSS for a gallery
* @param slug - Gallery slug
* @returns Object with customCss content and loading state
*/
export function useGalleryCustomCss(slug: string) {
const [customCss, setCustomCss] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!slug) {
setLoading(false);
return;
}
const loadCustomCss = async () => {
try {
setLoading(true);
setError(null);
const css = await cssTemplatesService.getGalleryCss(slug);
setCustomCss(css);
} catch (err) {
console.error('Failed to load custom CSS:', err);
setError('Failed to load custom styles');
} finally {
setLoading(false);
}
};
loadCustomCss();
}, [slug]);
// Inject CSS into document
useEffect(() => {
if (!customCss) return;
// Remove any existing custom CSS
const existingStyle = document.getElementById('gallery-custom-css');
if (existingStyle) {
existingStyle.remove();
}
// Create and inject new style element
const styleElement = document.createElement('style');
styleElement.id = 'gallery-custom-css';
styleElement.textContent = customCss;
document.head.appendChild(styleElement);
// Cleanup on unmount or when CSS changes
return () => {
const existing = document.getElementById('gallery-custom-css');
if (existing) {
existing.remove();
}
};
}, [customCss]);
return { customCss, loading, error };
}
export default useGalleryCustomCss;
+2 -1
View File
@@ -44,7 +44,8 @@
"up": "Nach oben",
"select": "Auswählen",
"selected": "Ausgewählt",
"chunk": "Teil"
"chunk": "Teil",
"optional": "optional"
},
"upload": {
"photoCategory": "Fotokategorie",
+2 -1
View File
@@ -44,7 +44,8 @@
"up": "Up",
"select": "Select",
"selected": "Selected",
"chunk": "Chunk"
"chunk": "Chunk",
"optional": "optional"
},
"upload": {
"photoCategory": "Photo Category",
+29 -8
View File
@@ -150,6 +150,16 @@ export const CreateEventPage: React.FC = () => {
staleTime: 5 * 60 * 1000, // 5 minutes
});
// Fetch public settings for field requirements
const { data: publicSettings } = useQuery({
queryKey: ['public-settings'],
queryFn: () => settingsService.getPublicSettings()
});
// Get field requirements (default to true if not set)
const requireCustomerEmail = publicSettings?.event_require_customer_email !== false;
const requireAdminEmail = publicSettings?.event_require_admin_email !== false;
const createMutation = useMutation({
mutationFn: eventsService.createEvent,
onSuccess: (data) => {
@@ -198,15 +208,26 @@ export const CreateEventPage: React.FC = () => {
newErrors.event_name = t('validation.eventNameRequired');
}
if (!formData.customer_email) {
newErrors.customer_email = t('validation.hostEmailRequired');
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.customer_email)) {
// Conditional validation based on settings
if (requireCustomerEmail) {
if (!formData.customer_email) {
newErrors.customer_email = t('validation.hostEmailRequired');
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.customer_email)) {
newErrors.customer_email = t('validation.invalidEmailFormat');
}
} else if (formData.customer_email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.customer_email)) {
// Still validate format if value is provided, even if optional
newErrors.customer_email = t('validation.invalidEmailFormat');
}
if (!formData.admin_email) {
newErrors.admin_email = t('validation.adminEmailRequired');
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.admin_email)) {
if (requireAdminEmail) {
if (!formData.admin_email) {
newErrors.admin_email = t('validation.adminEmailRequired');
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.admin_email)) {
newErrors.admin_email = t('validation.invalidEmailFormat');
}
} else if (formData.admin_email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.admin_email)) {
// Still validate format if value is provided, even if optional
newErrors.admin_email = t('validation.invalidEmailFormat');
}
@@ -392,7 +413,7 @@ export const CreateEventPage: React.FC = () => {
{/* Customer Email */}
<div>
<label htmlFor="customer_email" className="block text-sm font-medium text-neutral-700 mb-1">
{t('events.hostEmail')}
{requireCustomerEmail ? t('events.hostEmail') : `${t('events.hostEmail')} (${t('common.optional')})`}
</label>
<Input
id="customer_email"
@@ -411,7 +432,7 @@ export const CreateEventPage: React.FC = () => {
{/* Admin Email */}
<div>
<label htmlFor="admin_email" className="block text-sm font-medium text-neutral-700 mb-1">
{t('events.adminNotificationEmail')}
{requireAdminEmail ? t('events.adminNotificationEmail') : `${t('events.adminNotificationEmail')} (${t('common.optional')})`}
</label>
<Input
id="admin_email"
@@ -128,6 +128,17 @@ export const CreateEventPageEnhanced: React.FC = () => {
queryFn: () => settingsService.getAllSettings()
});
// Fetch public settings for field requirements
const { data: publicSettings } = useQuery({
queryKey: ['public-settings'],
queryFn: () => settingsService.getPublicSettings()
});
// Get field requirements (default to true if not set)
const requireCustomerName = publicSettings?.event_require_customer_name !== false;
const requireCustomerEmail = publicSettings?.event_require_customer_email !== false;
const requireAdminEmail = publicSettings?.event_require_admin_email !== false;
// Update default expiration days when settings are loaded
useEffect(() => {
if (settings?.general_default_expiration_days) {
@@ -184,19 +195,30 @@ export const CreateEventPageEnhanced: React.FC = () => {
newErrors.event_date = t('validation.eventDateRequired');
}
if (!formData.customer_name) {
// Conditional validation based on settings
if (requireCustomerName && !formData.customer_name) {
newErrors.customer_name = t('validation.hostNameRequired');
}
if (!formData.customer_email) {
newErrors.customer_email = t('validation.hostEmailRequired');
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.customer_email)) {
if (requireCustomerEmail) {
if (!formData.customer_email) {
newErrors.customer_email = t('validation.hostEmailRequired');
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.customer_email)) {
newErrors.customer_email = t('validation.invalidEmailFormat');
}
} else if (formData.customer_email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.customer_email)) {
// Still validate format if value is provided, even if optional
newErrors.customer_email = t('validation.invalidEmailFormat');
}
if (!formData.admin_email) {
newErrors.admin_email = t('validation.adminEmailRequired');
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.admin_email)) {
if (requireAdminEmail) {
if (!formData.admin_email) {
newErrors.admin_email = t('validation.adminEmailRequired');
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.admin_email)) {
newErrors.admin_email = t('validation.invalidEmailFormat');
}
} else if (formData.admin_email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.admin_email)) {
// Still validate format if value is provided, even if optional
newErrors.admin_email = t('validation.invalidEmailFormat');
}
@@ -470,7 +492,7 @@ export const CreateEventPageEnhanced: React.FC = () => {
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label={t('events.hostName')}
label={requireCustomerName ? t('events.hostName') : `${t('events.hostName')} (${t('common.optional')})`}
placeholder={t('events.hostNamePlaceholder')}
value={formData.customer_name}
onChange={handleInputChange('customer_name')}
@@ -480,7 +502,7 @@ export const CreateEventPageEnhanced: React.FC = () => {
<Input
type="email"
label={t('events.hostEmail')}
label={requireCustomerEmail ? t('events.hostEmail') : `${t('events.hostEmail')} (${t('common.optional')})`}
placeholder={t('events.hostEmailPlaceholder')}
value={formData.customer_email}
onChange={handleInputChange('customer_email')}
@@ -491,7 +513,7 @@ export const CreateEventPageEnhanced: React.FC = () => {
<Input
type="email"
label={t('events.adminEmail')}
label={requireAdminEmail ? t('events.adminEmail') : `${t('events.adminEmail')} (${t('common.optional')})`}
placeholder={t('events.adminEmailPlaceholder')}
value={formData.admin_email}
onChange={handleInputChange('admin_email')}
+77 -17
View File
@@ -1,7 +1,7 @@
import React, { useState, useEffect, useMemo } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
import {
ArrowLeft,
ExternalLink,
Calendar,
@@ -20,20 +20,21 @@ import {
MessageSquare,
Lock,
Eye,
EyeOff
EyeOff,
Type
} from 'lucide-react';
import { parseISO, differenceInDays } from 'date-fns';
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 } from '../../components/admin';
import { EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PasswordResetModal, ThemeCustomizerEnhanced, ThemeDisplay, HeroPhotoSelector, PhotoUploadModal, FeedbackSettings, FeedbackModerationPanel, EventRenameDialog, PhotoFilterPanel, PhotoExportMenu } from '../../components/admin';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { eventsService } from '../../services/events.service';
import { isGalleryPublic, normalizeRequirePassword } from '../../utils/accessControl';
import { archiveService } from '../../services/archive.service';
import { externalMediaService } from '../../services/externalMedia.service';
import { photosService, AdminPhoto, type PhotoFilters as PhotoFilterParams } from '../../services/photos.service';
import { photosService, AdminPhoto, type PhotoFilters as PhotoFilterParams, type FeedbackFilters, type FilterSummary } from '../../services/photos.service';
import { feedbackService, FeedbackSettings as FeedbackSettingsType } from '../../services/feedback.service';
import { ThemeConfig, GALLERY_THEME_PRESETS } from '../../types/theme.types';
@@ -167,6 +168,7 @@ export const EventDetailsPage: React.FC = () => {
const [selectedPhoto, setSelectedPhoto] = useState<{ photo: AdminPhoto; index: number } | null>(null);
const [showPasswordReset, setShowPasswordReset] = useState(false);
const [showNewPassword, setShowNewPassword] = useState(false);
const [showRenameDialog, setShowRenameDialog] = useState(false);
const [currentTheme, setCurrentTheme] = useState<ThemeConfig | null>(null);
const [currentPresetName, setCurrentPresetName] = useState<string>('default');
@@ -178,6 +180,16 @@ export const EventDetailsPage: React.FC = () => {
order: 'desc' as 'asc' | 'desc'
});
// Feedback filters state for export
const [feedbackFilters, setFeedbackFilters] = useState<FeedbackFilters>({
minRating: null,
hasLikes: false,
hasFavorites: false,
hasComments: false,
logic: 'AND'
});
const [selectedPhotoIds, setSelectedPhotoIds] = useState<number[]>([]);
// Fetch event details
const { data: event, isLoading: eventLoading } = useQuery({
queryKey: ['admin-event', id],
@@ -208,6 +220,13 @@ export const EventDetailsPage: React.FC = () => {
enabled: !!id && (activeTab === 'photos' || isEditing),
});
// Fetch filter summary for feedback filters
const { data: filterSummary } = useQuery({
queryKey: ['admin-event-filter-summary', id],
queryFn: () => photosService.getFilterSummary(parseInt(id!)),
enabled: !!id && activeTab === 'photos',
});
const mediaTypes = useMemo(() => {
const types = new Set<'photo' | 'video'>();
photos.forEach((p: any) => {
@@ -571,6 +590,14 @@ export const EventDetailsPage: React.FC = () => {
>
{t('common.edit')}
</Button>
<Button
variant="outline"
size="sm"
leftIcon={<Type className="w-4 h-4" />}
onClick={() => setShowRenameDialog(true)}
>
{t('events.rename.button', 'Rename')}
</Button>
{feedbackSettings?.feedback_enabled && (
<Button
variant="outline"
@@ -1265,18 +1292,26 @@ export const EventDetailsPage: React.FC = () => {
showMediaFilter={showMediaFilter}
/>
{/* Feedback Filter Panel for Export */}
<PhotoFilterPanel
filters={feedbackFilters}
onChange={setFeedbackFilters}
summary={filterSummary || null}
isLoading={photosLoading}
/>
{/* Actions Bar */}
<div className="mb-4 flex justify-between items-center">
<Button
variant="primary"
size="sm"
leftIcon={<Upload className="w-4 h-4" />}
onClick={() => setShowPhotoUpload(true)}
>
{t('events.uploadPhotos')}
</Button>
{event.source_mode === 'reference' && (
<div className="ml-3">
<div className="mb-4 flex flex-wrap justify-between items-center gap-4">
<div className="flex items-center gap-3">
<Button
variant="primary"
size="sm"
leftIcon={<Upload className="w-4 h-4" />}
onClick={() => setShowPhotoUpload(true)}
>
{t('events.uploadPhotos')}
</Button>
{event.source_mode === 'reference' && (
<Button
variant="outline"
size="sm"
@@ -1284,8 +1319,13 @@ export const EventDetailsPage: React.FC = () => {
>
{t('events.importExternal', 'Import from External Folder')}
</Button>
</div>
)}
)}
</div>
<PhotoExportMenu
eventId={parseInt(id!)}
selectedPhotoIds={selectedPhotoIds}
filters={feedbackFilters}
/>
</div>
{/* Photo Grid */}
@@ -1302,6 +1342,7 @@ export const EventDetailsPage: React.FC = () => {
refetchPhotos();
queryClient.invalidateQueries({ queryKey: ['admin-event', id] });
}}
onSelectionChange={setSelectedPhotoIds}
/>
)}
@@ -1412,6 +1453,25 @@ export const EventDetailsPage: React.FC = () => {
</div>
)}
{/* Event Rename Dialog */}
<EventRenameDialog
isOpen={showRenameDialog}
eventName={event.event_name}
eventId={event.id}
customerEmail={event.customer_email}
onClose={() => setShowRenameDialog(false)}
onRename={async (newName, resendEmail) => {
const result = await eventsService.renameEvent(event.id, newName, resendEmail);
if (result.success) {
queryClient.invalidateQueries({ queryKey: ['admin-event', id] });
queryClient.invalidateQueries({ queryKey: ['admin-events'] });
toast.success(t('events.rename.success', 'Event renamed successfully!'));
}
return result;
}}
onValidate={(newName) => eventsService.validateRename(event.id, newName)}
/>
</div>
);
};
+171 -1
View File
@@ -19,9 +19,11 @@ import { toast } from 'react-toastify';
import { Button, Card, Input, Loading } from '../../components/common';
import { CategoryManager } from '../../components/admin/CategoryManager';
import { WordFilterManager } from '../../components/admin/WordFilterManager';
import { CssTemplateEditor } from '../../components/admin/CssTemplateEditor';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { settingsService } from '../../services/settings.service';
import { adminService } from '../../services/admin.service';
import { authService } from '../../services/auth.service';
import { useTranslation } from 'react-i18next';
import { useAdminAuth } from '../../contexts';
@@ -58,7 +60,7 @@ const toNumber = (value: unknown, defaultValue: number): number => {
};
export const SettingsPage: React.FC = () => {
const [activeTab, setActiveTab] = useState<'general' | 'status' | 'security' | 'categories' | 'analytics' | 'moderation'>('general');
const [activeTab, setActiveTab] = useState<'general' | 'events' | 'status' | 'security' | 'categories' | 'analytics' | 'moderation' | 'styling'>('general');
const queryClient = useQueryClient();
const { t, i18n } = useTranslation();
const { updateUserProfile } = useAdminAuth();
@@ -141,6 +143,13 @@ export const SettingsPage: React.FC = () => {
umami_share_url: ''
});
// Event creation settings state
const [eventSettings, setEventSettings] = useState({
event_require_customer_name: true,
event_require_customer_email: true,
event_require_admin_email: true
});
const [softLimitGb, setSoftLimitGb] = useState<number | ''>('');
const [softLimitDirty, setSoftLimitDirty] = useState(false);
const [capacityOverrideGb, setCapacityOverrideGb] = useState<number | ''>('');
@@ -203,6 +212,13 @@ export const SettingsPage: React.FC = () => {
umami_website_id: settings.analytics_umami_website_id || '',
umami_share_url: settings.analytics_umami_share_url || ''
});
// Extract event creation settings
setEventSettings({
event_require_customer_name: toBoolean(settings.event_require_customer_name, true),
event_require_customer_email: toBoolean(settings.event_require_customer_email, true),
event_require_admin_email: toBoolean(settings.event_require_admin_email, true)
});
}
}, [settings, i18n]);
@@ -335,6 +351,25 @@ export const SettingsPage: React.FC = () => {
}
});
const saveEventSettingsMutation = useMutation({
mutationFn: async () => {
// Convert to the format expected by the API
const settingsData: Record<string, any> = {};
Object.entries(eventSettings).forEach(([key, value]) => {
settingsData[key] = value;
});
return settingsService.updateSettings(settingsData);
},
onSuccess: () => {
toast.success(t('toast.settingsSaved'));
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
queryClient.invalidateQueries({ queryKey: ['public-settings'] });
},
onError: () => {
toast.error(t('toast.saveError'));
}
});
const updateAdminProfileMutation = useMutation({
mutationFn: (payload: { username: string; email: string }) => adminService.updateAdminProfile(payload),
onSuccess: (updatedUser) => {
@@ -550,6 +585,16 @@ export const SettingsPage: React.FC = () => {
>
{t('settings.general.title')}
</button>
<button
onClick={() => setActiveTab('events')}
className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors ${
activeTab === 'events'
? 'border-primary-600 text-primary-600'
: 'border-transparent text-neutral-500 hover:text-neutral-700'
}`}
>
{t('settings.events.title', 'Event Creation')}
</button>
<button
onClick={() => setActiveTab('status')}
className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors ${
@@ -600,6 +645,16 @@ export const SettingsPage: React.FC = () => {
>
{t('settings.moderation.title', 'Moderation')}
</button>
<button
onClick={() => setActiveTab('styling')}
className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors ${
activeTab === 'styling'
? 'border-primary-600 text-primary-600'
: 'border-transparent text-neutral-500 hover:text-neutral-700'
}`}
>
{t('settings.styling.title', 'Custom CSS')}
</button>
</nav>
</div>
@@ -880,6 +935,114 @@ export const SettingsPage: React.FC = () => {
</div>
)}
{/* Event Creation Settings Tab */}
{activeTab === 'events' && (
<div className="space-y-6">
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">
{t('settings.events.requiredFields', 'Required Fields')}
</h2>
<p className="text-sm text-neutral-600 mb-4">
{t('settings.events.requiredFieldsDescription', 'Configure which contact fields are required when creating new events.')}
</p>
<div className="space-y-4">
<div>
<label className="flex items-start gap-3">
<input
type="checkbox"
checked={eventSettings.event_require_customer_name}
onChange={(e) => setEventSettings(prev => ({ ...prev, event_require_customer_name: e.target.checked }))}
className="mt-1 w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<div>
<span className="text-sm font-medium text-neutral-700">
{t('settings.events.requireCustomerName', 'Require customer name')}
</span>
<p className="text-xs text-neutral-500 mt-1">
{t('settings.events.requireCustomerNameHelp', 'Customer name must be provided for new events')}
</p>
</div>
</label>
</div>
<div>
<label className="flex items-start gap-3">
<input
type="checkbox"
checked={eventSettings.event_require_customer_email}
onChange={(e) => setEventSettings(prev => ({ ...prev, event_require_customer_email: e.target.checked }))}
className="mt-1 w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<div>
<span className="text-sm font-medium text-neutral-700">
{t('settings.events.requireCustomerEmail', 'Require customer email')}
</span>
<p className="text-xs text-neutral-500 mt-1">
{t('settings.events.requireCustomerEmailHelp', 'Customer email must be provided for new events')}
</p>
{!eventSettings.event_require_customer_email && (
<p className="text-xs text-amber-600 mt-1 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
{t('settings.events.customerEmailWarning', 'Required for sending gallery invitations')}
</p>
)}
</div>
</label>
</div>
<div>
<label className="flex items-start gap-3">
<input
type="checkbox"
checked={eventSettings.event_require_admin_email}
onChange={(e) => setEventSettings(prev => ({ ...prev, event_require_admin_email: e.target.checked }))}
className="mt-1 w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<div>
<span className="text-sm font-medium text-neutral-700">
{t('settings.events.requireAdminEmail', 'Require admin email')}
</span>
<p className="text-xs text-neutral-500 mt-1">
{t('settings.events.requireAdminEmailHelp', 'Admin email must be provided for new events')}
</p>
{!eventSettings.event_require_admin_email && (
<p className="text-xs text-amber-600 mt-1 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
{t('settings.events.adminEmailWarning', 'Required for receiving event notifications')}
</p>
)}
</div>
</label>
</div>
</div>
<div className="mt-6">
<Button
variant="primary"
onClick={() => saveEventSettingsMutation.mutate()}
isLoading={saveEventSettingsMutation.isPending}
leftIcon={<Save className="w-5 h-5" />}
>
{t('settings.events.saveSettings', 'Save Event Settings')}
</Button>
</div>
</Card>
<Card padding="md">
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-blue-600 flex-shrink-0" />
<div className="text-sm text-blue-800">
<p className="font-medium mb-1">{t('settings.events.noteTitle', 'Note')}</p>
<p>
{t('settings.events.noteText', 'These settings only affect new event creation. Existing events are not affected. Default behavior requires all fields.')}
</p>
</div>
</div>
</Card>
</div>
)}
{/* System Status Tab */}
{activeTab === 'status' && (
<div className="space-y-6">
@@ -1664,6 +1827,13 @@ export const SettingsPage: React.FC = () => {
<WordFilterManager />
</div>
)}
{/* Custom CSS Templates Tab */}
{activeTab === 'styling' && (
<div className="space-y-6">
<CssTemplateEditor />
</div>
)}
</div>
);
};
@@ -0,0 +1,92 @@
import { api } from '../config/api';
export interface CssTemplate {
id: number;
slot_number: number;
name: string;
css_content: string;
is_enabled: boolean;
is_default: boolean;
created_at: string;
updated_at: string;
}
export interface CssTemplateUpdate {
name?: string;
css_content?: string;
is_enabled?: boolean;
}
export interface EnabledTemplate {
id: number;
name: string;
slot_number: number;
}
class CssTemplatesService {
/**
* Get all CSS templates
*/
async getTemplates(): Promise<CssTemplate[]> {
const response = await api.get('/admin/css-templates');
return response.data.templates;
}
/**
* Get a specific template by slot number
*/
async getTemplate(slotNumber: number): Promise<CssTemplate> {
const response = await api.get(`/admin/css-templates/${slotNumber}`);
return response.data.template;
}
/**
* Get only enabled templates (for event form dropdown)
*/
async getEnabledTemplates(): Promise<EnabledTemplate[]> {
const response = await api.get('/admin/css-templates/enabled');
return response.data.templates;
}
/**
* Update a template
*/
async updateTemplate(
slotNumber: number,
updates: CssTemplateUpdate
): Promise<{ template: CssTemplate; warnings: string[] }> {
const response = await api.put(`/admin/css-templates/${slotNumber}`, updates);
return {
template: response.data.template,
warnings: response.data.sanitization_warnings || []
};
}
/**
* Reset template 1 to default
*/
async resetToDefault(): Promise<CssTemplate> {
const response = await api.post('/admin/css-templates/1/reset');
return response.data.template;
}
/**
* Get CSS template for a gallery (public endpoint)
*/
async getGalleryCss(slug: string): Promise<string | null> {
try {
const response = await api.get(`/gallery/${slug}/css-template`, {
responseType: 'text'
});
if (response.status === 204) {
return null;
}
return response.data;
} catch (error) {
console.error('Failed to load gallery CSS:', error);
return null;
}
}
}
export const cssTemplatesService = new CssTemplatesService();
+30
View File
@@ -160,4 +160,34 @@ export const eventsService = {
const response = await api.post(`/admin/events/${eventId}/resend-email`);
return response.data;
},
// Validate rename
async validateRename(eventId: number, newEventName: string): Promise<{
valid: boolean;
newSlug?: string;
error?: string;
}> {
const response = await api.post(`/admin/events/${eventId}/validate-rename`, { newEventName });
return response.data;
},
// Rename event
async renameEvent(eventId: number, newEventName: string, resendEmail: boolean = false): Promise<{
success: boolean;
message?: string;
data?: {
eventId: number;
oldName: string;
newName: string;
oldSlug: string;
newSlug: string;
newShareLink: string;
emailSent: boolean;
filesRenamed: number;
};
error?: string;
}> {
const response = await api.post(`/admin/events/${eventId}/rename`, { newEventName, resendEmail });
return response.data;
},
};
+128
View File
@@ -199,6 +199,134 @@ class PhotosService {
shouldUseChunkedUpload(fileSize: number): boolean {
return fileSize > 100 * 1024 * 1024; // 100MB threshold
}
// ============================================
// Photo Filtering & Export Methods
// ============================================
async getFilteredPhotos(
eventId: number,
filters: FeedbackFilters
): Promise<FilteredPhotosResponse> {
const params = new URLSearchParams();
if (filters.minRating !== undefined && filters.minRating !== null) {
params.append('min_rating', filters.minRating.toString());
}
if (filters.hasLikes) params.append('has_likes', 'true');
if (filters.hasFavorites) params.append('has_favorites', 'true');
if (filters.hasComments) params.append('has_comments', 'true');
if (filters.categoryId) params.append('category_id', filters.categoryId.toString());
if (filters.logic) params.append('logic', filters.logic);
if (filters.sort) params.append('sort', filters.sort);
if (filters.order) params.append('order', filters.order);
if (filters.page) params.append('page', filters.page.toString());
if (filters.limit) params.append('limit', filters.limit.toString());
const queryString = params.toString();
const url = `/admin/photo-export/${eventId}/filtered${queryString ? `?${queryString}` : ''}`;
const response = await api.get(url);
return response.data.data;
}
async getFilterSummary(eventId: number): Promise<FilterSummary> {
const response = await api.get(`/admin/photo-export/${eventId}/filter-summary`);
return response.data.data;
}
async exportPhotos(
eventId: number,
options: ExportOptions
): Promise<void> {
const response = await api.post(
`/admin/photo-export/${eventId}/export`,
options,
{ responseType: 'blob' }
);
// Get filename from Content-Disposition header
const contentDisposition = response.headers['content-disposition'];
let filename = `export_${Date.now()}`;
if (contentDisposition) {
const filenameMatch = contentDisposition.match(/filename="?([^";\n]+)"?/);
if (filenameMatch) {
filename = filenameMatch[1];
}
}
// Download the file
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
}
async getExportFormats(): Promise<ExportFormat[]> {
const response = await api.get('/admin/photo-export/export-formats');
return response.data.data;
}
}
// Types for filtering and export
export interface FeedbackFilters {
minRating?: number | null;
maxRating?: number | null;
hasLikes?: boolean;
minLikes?: number;
hasFavorites?: boolean;
minFavorites?: number;
hasComments?: boolean;
categoryId?: number;
logic?: 'AND' | 'OR';
sort?: 'rating' | 'likes' | 'favorites' | 'date' | 'filename';
order?: 'asc' | 'desc';
page?: number;
limit?: number;
}
export interface FilterSummary {
total: number;
withRatings: number;
withLikes: number;
withFavorites: number;
withComments: number;
}
export interface FilteredPhotosResponse {
photos: AdminPhoto[];
pagination: {
total: number;
filtered: number;
page: number;
limit: number;
pages: number;
};
summary: FilterSummary;
}
export interface ExportOptions {
photo_ids?: number[];
filter?: FeedbackFilters;
format: 'txt' | 'csv' | 'xmp' | 'json';
options?: {
filename_format?: 'original' | 'picpeak';
separator?: 'newline' | 'comma' | 'semicolon';
include_rating?: boolean;
include_label?: boolean;
include_description?: boolean;
include_keywords?: boolean;
};
}
export interface ExportFormat {
value: string;
label: string;
description: string;
}
export const photosService = new PhotosService();