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:
@@ -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;
|
||||||
@@ -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/image-security', require('./src/routes/adminImageSecurity'));
|
||||||
app.use('/api/admin/thumbnails', require('./src/routes/adminThumbnails'));
|
app.use('/api/admin/thumbnails', require('./src/routes/adminThumbnails'));
|
||||||
app.use('/api/admin/photos', require('./src/routes/adminPhotos'));
|
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/settings', require('./src/routes/publicSettings'));
|
||||||
app.use('/api/public', require('./src/routes/publicCMS'));
|
app.use('/api/public', require('./src/routes/publicCMS'));
|
||||||
app.use('/api/images', require('./src/routes/protectedImages'));
|
app.use('/api/images', require('./src/routes/protectedImages'));
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -16,6 +16,48 @@ const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwor
|
|||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
const { buildShareLinkVariants } = require('../services/shareLinkService');
|
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) => {
|
const parseBooleanInput = (value, defaultValue = true) => {
|
||||||
if (value === undefined || value === null) {
|
if (value === undefined || value === null) {
|
||||||
return defaultValue;
|
return defaultValue;
|
||||||
@@ -97,9 +139,9 @@ router.post('/', adminAuth, [
|
|||||||
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
|
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
|
||||||
body('event_name').notEmpty().trim(),
|
body('event_name').notEmpty().trim(),
|
||||||
body('event_date').isDate(),
|
body('event_date').isDate(),
|
||||||
body('customer_name').notEmpty().trim(),
|
body('customer_name').optional().trim(),
|
||||||
body('customer_email').isEmail().normalizeEmail(),
|
body('customer_email').optional().isEmail().normalizeEmail(),
|
||||||
body('admin_email').isEmail().normalizeEmail(),
|
body('admin_email').optional().isEmail().normalizeEmail(),
|
||||||
body('require_password').optional().isBoolean(),
|
body('require_password').optional().isBoolean(),
|
||||||
body('password').optional().isString().custom((value, { req }) => {
|
body('password').optional().isString().custom((value, { req }) => {
|
||||||
const input = req.body.require_password;
|
const input = req.body.require_password;
|
||||||
@@ -142,6 +184,9 @@ router.post('/', adminAuth, [
|
|||||||
return res.status(400).json({ errors: errors.array() });
|
return res.status(400).json({ errors: errors.array() });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get field requirements from settings
|
||||||
|
const fieldRequirements = await getEventFieldRequirements();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
event_type,
|
event_type,
|
||||||
event_name,
|
event_name,
|
||||||
@@ -174,8 +219,20 @@ router.post('/', adminAuth, [
|
|||||||
|
|
||||||
const customerColumnsAvailable = await hasCustomerContactColumns();
|
const customerColumnsAvailable = await hasCustomerContactColumns();
|
||||||
|
|
||||||
if (!customerName || !customerEmail) {
|
// Conditional validation based on settings
|
||||||
return res.status(400).json({ error: 'customer_name and customer_email are required' });
|
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);
|
const requirePassword = parseBooleanInput(requirePasswordInput, true);
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -14,13 +14,39 @@ const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = r
|
|||||||
// Get storage path from environment or default
|
// Get storage path from environment or default
|
||||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
|
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
|
// Resolve gallery identifier (slug or token) to canonical data
|
||||||
router.get('/resolve/:identifier', async (req, res) => {
|
router.get('/resolve/:identifier', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { identifier } = req.params;
|
const { identifier } = req.params;
|
||||||
const result = await resolveShareIdentifier(identifier);
|
let result = await resolveShareIdentifier(identifier);
|
||||||
|
|
||||||
|
// If not found, check for redirect
|
||||||
if (!result) {
|
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' });
|
return res.status(404).json({ error: 'Gallery not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +102,7 @@ router.get('/:slug/info', async (req, res) => {
|
|||||||
const { slug } = req.params;
|
const { slug } = req.params;
|
||||||
const { token } = req.query;
|
const { token } = req.query;
|
||||||
|
|
||||||
const event = await db('events')
|
let event = await db('events')
|
||||||
.where({ slug })
|
.where({ slug })
|
||||||
.select(
|
.select(
|
||||||
'event_name',
|
'event_name',
|
||||||
@@ -97,6 +123,15 @@ router.get('/:slug/info', async (req, res) => {
|
|||||||
.first();
|
.first();
|
||||||
|
|
||||||
if (!event) {
|
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' });
|
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;
|
module.exports = router;
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ const router = express.Router();
|
|||||||
// Get public settings (branding and theme)
|
// Get public settings (branding and theme)
|
||||||
router.get('/', async (req, res) => {
|
router.get('/', async (req, res) => {
|
||||||
try {
|
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
|
// Note: We include analytics in the query but it might not exist yet
|
||||||
const settings = await withRetry(async () => {
|
const settings = await withRetry(async () => {
|
||||||
return await db('app_settings')
|
return await db('app_settings')
|
||||||
.where(function() {
|
.where(function() {
|
||||||
this.whereIn('setting_type', ['branding', 'theme', 'general', 'security', 'analytics'])
|
this.whereIn('setting_type', ['branding', 'theme', 'general', 'security', 'analytics', 'boolean'])
|
||||||
.orWhere('setting_key', 'like', 'analytics_%');
|
.orWhere('setting_key', 'like', 'analytics_%')
|
||||||
|
.orWhere('setting_key', 'like', 'event_require_%');
|
||||||
})
|
})
|
||||||
.select('setting_key', 'setting_value');
|
.select('setting_key', 'setting_value');
|
||||||
});
|
});
|
||||||
@@ -19,10 +20,19 @@ router.get('/', async (req, res) => {
|
|||||||
// Convert to object format
|
// Convert to object format
|
||||||
const settingsObject = {};
|
const settingsObject = {};
|
||||||
settings.forEach(setting => {
|
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 {
|
try {
|
||||||
settingsObject[setting.setting_key] = setting.setting_value
|
settingsObject[setting.setting_key] = JSON.parse(setting.setting_value);
|
||||||
? JSON.parse(setting.setting_value)
|
|
||||||
: null;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// If parsing fails, use the raw value
|
// If parsing fails, use the raw value
|
||||||
settingsObject[setting.setting_key] = setting.setting_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_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_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_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);
|
res.json(publicSettings);
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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 };
|
||||||
@@ -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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 };
|
||||||
@@ -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) {
|
function sanitizeCss(css) {
|
||||||
if (!css || typeof css !== 'string') {
|
if (!css || typeof css !== 'string') {
|
||||||
return '';
|
return '';
|
||||||
@@ -27,6 +67,105 @@ function sanitizeCss(css) {
|
|||||||
return sanitized.trim();
|
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 = {
|
module.exports = {
|
||||||
sanitizeCss,
|
sanitizeCss,
|
||||||
|
sanitizeCSS,
|
||||||
|
validateCSS,
|
||||||
|
scopeToGalleryPage,
|
||||||
|
MAX_CSS_SIZE
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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`
|
||||||
@@ -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;
|
eventId: number;
|
||||||
onPhotoClick: (photo: AdminPhoto, index: number) => void;
|
onPhotoClick: (photo: AdminPhoto, index: number) => void;
|
||||||
onPhotosDeleted: () => void;
|
onPhotosDeleted: () => void;
|
||||||
|
onSelectionChange?: (selectedIds: number[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
||||||
photos,
|
photos,
|
||||||
eventId,
|
eventId,
|
||||||
onPhotoClick,
|
onPhotoClick,
|
||||||
onPhotosDeleted
|
onPhotosDeleted,
|
||||||
|
onSelectionChange
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [selectedPhotos, setSelectedPhotos] = useState<Set<number>>(new Set());
|
const [selectedPhotos, setSelectedPhotos] = useState<Set<number>>(new Set());
|
||||||
@@ -42,14 +44,18 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
|||||||
newSelected.add(photoId);
|
newSelected.add(photoId);
|
||||||
}
|
}
|
||||||
setSelectedPhotos(newSelected);
|
setSelectedPhotos(newSelected);
|
||||||
|
onSelectionChange?.(Array.from(newSelected));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectAll = () => {
|
const handleSelectAll = () => {
|
||||||
|
let newSelected: Set<number>;
|
||||||
if (selectedPhotos.size === photos.length) {
|
if (selectedPhotos.size === photos.length) {
|
||||||
setSelectedPhotos(new Set());
|
newSelected = new Set();
|
||||||
} else {
|
} 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) => {
|
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`);
|
toast.success(`${count} photo${count > 1 ? 's' : ''} deleted successfully`);
|
||||||
setSelectedPhotos(new Set());
|
setSelectedPhotos(new Set());
|
||||||
setIsSelectionMode(false);
|
setIsSelectionMode(false);
|
||||||
|
onSelectionChange?.([]);
|
||||||
onPhotosDeleted();
|
onPhotosDeleted();
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to delete photos');
|
toast.error('Failed to delete photos');
|
||||||
@@ -114,6 +121,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
|||||||
setIsSelectionMode(!isSelectionMode);
|
setIsSelectionMode(!isSelectionMode);
|
||||||
if (isSelectionMode) {
|
if (isSelectionMode) {
|
||||||
setSelectedPhotos(new Set());
|
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;
|
||||||
@@ -31,3 +31,7 @@ export { RestoreWizard } from './RestoreWizard';
|
|||||||
export { FeedbackSettings } from './FeedbackSettings';
|
export { FeedbackSettings } from './FeedbackSettings';
|
||||||
export { FeedbackModerationPanel } from './FeedbackModerationPanel';
|
export { FeedbackModerationPanel } from './FeedbackModerationPanel';
|
||||||
export { WordFilterManager } from './WordFilterManager';
|
export { WordFilterManager } from './WordFilterManager';
|
||||||
|
export { EventRenameDialog } from './EventRenameDialog';
|
||||||
|
export { PhotoFilterPanel } from './PhotoFilterPanel';
|
||||||
|
export { PhotoExportMenu } from './PhotoExportMenu';
|
||||||
|
export { CssTemplateEditor } from './CssTemplateEditor';
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -44,7 +44,8 @@
|
|||||||
"up": "Nach oben",
|
"up": "Nach oben",
|
||||||
"select": "Auswählen",
|
"select": "Auswählen",
|
||||||
"selected": "Ausgewählt",
|
"selected": "Ausgewählt",
|
||||||
"chunk": "Teil"
|
"chunk": "Teil",
|
||||||
|
"optional": "optional"
|
||||||
},
|
},
|
||||||
"upload": {
|
"upload": {
|
||||||
"photoCategory": "Fotokategorie",
|
"photoCategory": "Fotokategorie",
|
||||||
|
|||||||
@@ -44,7 +44,8 @@
|
|||||||
"up": "Up",
|
"up": "Up",
|
||||||
"select": "Select",
|
"select": "Select",
|
||||||
"selected": "Selected",
|
"selected": "Selected",
|
||||||
"chunk": "Chunk"
|
"chunk": "Chunk",
|
||||||
|
"optional": "optional"
|
||||||
},
|
},
|
||||||
"upload": {
|
"upload": {
|
||||||
"photoCategory": "Photo Category",
|
"photoCategory": "Photo Category",
|
||||||
|
|||||||
@@ -150,6 +150,16 @@ export const CreateEventPage: React.FC = () => {
|
|||||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
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({
|
const createMutation = useMutation({
|
||||||
mutationFn: eventsService.createEvent,
|
mutationFn: eventsService.createEvent,
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
@@ -198,17 +208,28 @@ export const CreateEventPage: React.FC = () => {
|
|||||||
newErrors.event_name = t('validation.eventNameRequired');
|
newErrors.event_name = t('validation.eventNameRequired');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Conditional validation based on settings
|
||||||
|
if (requireCustomerEmail) {
|
||||||
if (!formData.customer_email) {
|
if (!formData.customer_email) {
|
||||||
newErrors.customer_email = t('validation.hostEmailRequired');
|
newErrors.customer_email = t('validation.hostEmailRequired');
|
||||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.customer_email)) {
|
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.customer_email)) {
|
||||||
newErrors.customer_email = t('validation.invalidEmailFormat');
|
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 (requireAdminEmail) {
|
||||||
if (!formData.admin_email) {
|
if (!formData.admin_email) {
|
||||||
newErrors.admin_email = t('validation.adminEmailRequired');
|
newErrors.admin_email = t('validation.adminEmailRequired');
|
||||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.admin_email)) {
|
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.admin_email)) {
|
||||||
newErrors.admin_email = t('validation.invalidEmailFormat');
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
if (formData.require_password) {
|
if (formData.require_password) {
|
||||||
if (!formData.password) {
|
if (!formData.password) {
|
||||||
@@ -392,7 +413,7 @@ export const CreateEventPage: React.FC = () => {
|
|||||||
{/* Customer Email */}
|
{/* Customer Email */}
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="customer_email" className="block text-sm font-medium text-neutral-700 mb-1">
|
<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>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
id="customer_email"
|
id="customer_email"
|
||||||
@@ -411,7 +432,7 @@ export const CreateEventPage: React.FC = () => {
|
|||||||
{/* Admin Email */}
|
{/* Admin Email */}
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="admin_email" className="block text-sm font-medium text-neutral-700 mb-1">
|
<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>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
id="admin_email"
|
id="admin_email"
|
||||||
|
|||||||
@@ -128,6 +128,17 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
|||||||
queryFn: () => settingsService.getAllSettings()
|
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
|
// Update default expiration days when settings are loaded
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (settings?.general_default_expiration_days) {
|
if (settings?.general_default_expiration_days) {
|
||||||
@@ -184,21 +195,32 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
|||||||
newErrors.event_date = t('validation.eventDateRequired');
|
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');
|
newErrors.customer_name = t('validation.hostNameRequired');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (requireCustomerEmail) {
|
||||||
if (!formData.customer_email) {
|
if (!formData.customer_email) {
|
||||||
newErrors.customer_email = t('validation.hostEmailRequired');
|
newErrors.customer_email = t('validation.hostEmailRequired');
|
||||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.customer_email)) {
|
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.customer_email)) {
|
||||||
newErrors.customer_email = t('validation.invalidEmailFormat');
|
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 (requireAdminEmail) {
|
||||||
if (!formData.admin_email) {
|
if (!formData.admin_email) {
|
||||||
newErrors.admin_email = t('validation.adminEmailRequired');
|
newErrors.admin_email = t('validation.adminEmailRequired');
|
||||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.admin_email)) {
|
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.admin_email)) {
|
||||||
newErrors.admin_email = t('validation.invalidEmailFormat');
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
if (formData.require_password) {
|
if (formData.require_password) {
|
||||||
if (!formData.password) {
|
if (!formData.password) {
|
||||||
@@ -470,7 +492,7 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<Input
|
<Input
|
||||||
label={t('events.hostName')}
|
label={requireCustomerName ? t('events.hostName') : `${t('events.hostName')} (${t('common.optional')})`}
|
||||||
placeholder={t('events.hostNamePlaceholder')}
|
placeholder={t('events.hostNamePlaceholder')}
|
||||||
value={formData.customer_name}
|
value={formData.customer_name}
|
||||||
onChange={handleInputChange('customer_name')}
|
onChange={handleInputChange('customer_name')}
|
||||||
@@ -480,7 +502,7 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
|||||||
|
|
||||||
<Input
|
<Input
|
||||||
type="email"
|
type="email"
|
||||||
label={t('events.hostEmail')}
|
label={requireCustomerEmail ? t('events.hostEmail') : `${t('events.hostEmail')} (${t('common.optional')})`}
|
||||||
placeholder={t('events.hostEmailPlaceholder')}
|
placeholder={t('events.hostEmailPlaceholder')}
|
||||||
value={formData.customer_email}
|
value={formData.customer_email}
|
||||||
onChange={handleInputChange('customer_email')}
|
onChange={handleInputChange('customer_email')}
|
||||||
@@ -491,7 +513,7 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
|||||||
|
|
||||||
<Input
|
<Input
|
||||||
type="email"
|
type="email"
|
||||||
label={t('events.adminEmail')}
|
label={requireAdminEmail ? t('events.adminEmail') : `${t('events.adminEmail')} (${t('common.optional')})`}
|
||||||
placeholder={t('events.adminEmailPlaceholder')}
|
placeholder={t('events.adminEmailPlaceholder')}
|
||||||
value={formData.admin_email}
|
value={formData.admin_email}
|
||||||
onChange={handleInputChange('admin_email')}
|
onChange={handleInputChange('admin_email')}
|
||||||
|
|||||||
@@ -20,20 +20,21 @@ import {
|
|||||||
MessageSquare,
|
MessageSquare,
|
||||||
Lock,
|
Lock,
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff
|
EyeOff,
|
||||||
|
Type
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { parseISO, differenceInDays } from 'date-fns';
|
import { parseISO, differenceInDays } from 'date-fns';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||||
|
|
||||||
import { Button, Input, Card, Loading } from '../../components/common';
|
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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { eventsService } from '../../services/events.service';
|
import { eventsService } from '../../services/events.service';
|
||||||
import { isGalleryPublic, normalizeRequirePassword } from '../../utils/accessControl';
|
import { isGalleryPublic, normalizeRequirePassword } from '../../utils/accessControl';
|
||||||
import { archiveService } from '../../services/archive.service';
|
import { archiveService } from '../../services/archive.service';
|
||||||
import { externalMediaService } from '../../services/externalMedia.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 { feedbackService, FeedbackSettings as FeedbackSettingsType } from '../../services/feedback.service';
|
||||||
import { ThemeConfig, GALLERY_THEME_PRESETS } from '../../types/theme.types';
|
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 [selectedPhoto, setSelectedPhoto] = useState<{ photo: AdminPhoto; index: number } | null>(null);
|
||||||
const [showPasswordReset, setShowPasswordReset] = useState(false);
|
const [showPasswordReset, setShowPasswordReset] = useState(false);
|
||||||
const [showNewPassword, setShowNewPassword] = useState(false);
|
const [showNewPassword, setShowNewPassword] = useState(false);
|
||||||
|
const [showRenameDialog, setShowRenameDialog] = useState(false);
|
||||||
const [currentTheme, setCurrentTheme] = useState<ThemeConfig | null>(null);
|
const [currentTheme, setCurrentTheme] = useState<ThemeConfig | null>(null);
|
||||||
const [currentPresetName, setCurrentPresetName] = useState<string>('default');
|
const [currentPresetName, setCurrentPresetName] = useState<string>('default');
|
||||||
|
|
||||||
@@ -178,6 +180,16 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
order: 'desc' as 'asc' | 'desc'
|
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
|
// Fetch event details
|
||||||
const { data: event, isLoading: eventLoading } = useQuery({
|
const { data: event, isLoading: eventLoading } = useQuery({
|
||||||
queryKey: ['admin-event', id],
|
queryKey: ['admin-event', id],
|
||||||
@@ -208,6 +220,13 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
enabled: !!id && (activeTab === 'photos' || isEditing),
|
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 mediaTypes = useMemo(() => {
|
||||||
const types = new Set<'photo' | 'video'>();
|
const types = new Set<'photo' | 'video'>();
|
||||||
photos.forEach((p: any) => {
|
photos.forEach((p: any) => {
|
||||||
@@ -571,6 +590,14 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
>
|
>
|
||||||
{t('common.edit')}
|
{t('common.edit')}
|
||||||
</Button>
|
</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 && (
|
{feedbackSettings?.feedback_enabled && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -1265,8 +1292,17 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
showMediaFilter={showMediaFilter}
|
showMediaFilter={showMediaFilter}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Feedback Filter Panel for Export */}
|
||||||
|
<PhotoFilterPanel
|
||||||
|
filters={feedbackFilters}
|
||||||
|
onChange={setFeedbackFilters}
|
||||||
|
summary={filterSummary || null}
|
||||||
|
isLoading={photosLoading}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Actions Bar */}
|
{/* Actions Bar */}
|
||||||
<div className="mb-4 flex justify-between items-center">
|
<div className="mb-4 flex flex-wrap justify-between items-center gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -1276,7 +1312,6 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
{t('events.uploadPhotos')}
|
{t('events.uploadPhotos')}
|
||||||
</Button>
|
</Button>
|
||||||
{event.source_mode === 'reference' && (
|
{event.source_mode === 'reference' && (
|
||||||
<div className="ml-3">
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -1284,9 +1319,14 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
>
|
>
|
||||||
{t('events.importExternal', 'Import from External Folder')}
|
{t('events.importExternal', 'Import from External Folder')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<PhotoExportMenu
|
||||||
|
eventId={parseInt(id!)}
|
||||||
|
selectedPhotoIds={selectedPhotoIds}
|
||||||
|
filters={feedbackFilters}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Photo Grid */}
|
{/* Photo Grid */}
|
||||||
{photosLoading ? (
|
{photosLoading ? (
|
||||||
@@ -1302,6 +1342,7 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
refetchPhotos();
|
refetchPhotos();
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin-event', id] });
|
queryClient.invalidateQueries({ queryKey: ['admin-event', id] });
|
||||||
}}
|
}}
|
||||||
|
onSelectionChange={setSelectedPhotoIds}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1412,6 +1453,25 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,9 +19,11 @@ import { toast } from 'react-toastify';
|
|||||||
import { Button, Card, Input, Loading } from '../../components/common';
|
import { Button, Card, Input, Loading } from '../../components/common';
|
||||||
import { CategoryManager } from '../../components/admin/CategoryManager';
|
import { CategoryManager } from '../../components/admin/CategoryManager';
|
||||||
import { WordFilterManager } from '../../components/admin/WordFilterManager';
|
import { WordFilterManager } from '../../components/admin/WordFilterManager';
|
||||||
|
import { CssTemplateEditor } from '../../components/admin/CssTemplateEditor';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { settingsService } from '../../services/settings.service';
|
import { settingsService } from '../../services/settings.service';
|
||||||
import { adminService } from '../../services/admin.service';
|
import { adminService } from '../../services/admin.service';
|
||||||
|
import { authService } from '../../services/auth.service';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useAdminAuth } from '../../contexts';
|
import { useAdminAuth } from '../../contexts';
|
||||||
|
|
||||||
@@ -58,7 +60,7 @@ const toNumber = (value: unknown, defaultValue: number): number => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const SettingsPage: React.FC = () => {
|
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 queryClient = useQueryClient();
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { updateUserProfile } = useAdminAuth();
|
const { updateUserProfile } = useAdminAuth();
|
||||||
@@ -141,6 +143,13 @@ export const SettingsPage: React.FC = () => {
|
|||||||
umami_share_url: ''
|
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 [softLimitGb, setSoftLimitGb] = useState<number | ''>('');
|
||||||
const [softLimitDirty, setSoftLimitDirty] = useState(false);
|
const [softLimitDirty, setSoftLimitDirty] = useState(false);
|
||||||
const [capacityOverrideGb, setCapacityOverrideGb] = useState<number | ''>('');
|
const [capacityOverrideGb, setCapacityOverrideGb] = useState<number | ''>('');
|
||||||
@@ -203,6 +212,13 @@ export const SettingsPage: React.FC = () => {
|
|||||||
umami_website_id: settings.analytics_umami_website_id || '',
|
umami_website_id: settings.analytics_umami_website_id || '',
|
||||||
umami_share_url: settings.analytics_umami_share_url || ''
|
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]);
|
}, [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({
|
const updateAdminProfileMutation = useMutation({
|
||||||
mutationFn: (payload: { username: string; email: string }) => adminService.updateAdminProfile(payload),
|
mutationFn: (payload: { username: string; email: string }) => adminService.updateAdminProfile(payload),
|
||||||
onSuccess: (updatedUser) => {
|
onSuccess: (updatedUser) => {
|
||||||
@@ -550,6 +585,16 @@ export const SettingsPage: React.FC = () => {
|
|||||||
>
|
>
|
||||||
{t('settings.general.title')}
|
{t('settings.general.title')}
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={() => setActiveTab('status')}
|
onClick={() => setActiveTab('status')}
|
||||||
className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors ${
|
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')}
|
{t('settings.moderation.title', 'Moderation')}
|
||||||
</button>
|
</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>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -880,6 +935,114 @@ export const SettingsPage: React.FC = () => {
|
|||||||
</div>
|
</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 */}
|
{/* System Status Tab */}
|
||||||
{activeTab === 'status' && (
|
{activeTab === 'status' && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -1664,6 +1827,13 @@ export const SettingsPage: React.FC = () => {
|
|||||||
<WordFilterManager />
|
<WordFilterManager />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Custom CSS Templates Tab */}
|
||||||
|
{activeTab === 'styling' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<CssTemplateEditor />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</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();
|
||||||
@@ -160,4 +160,34 @@ export const eventsService = {
|
|||||||
const response = await api.post(`/admin/events/${eventId}/resend-email`);
|
const response = await api.post(`/admin/events/${eventId}/resend-email`);
|
||||||
return response.data;
|
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;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -199,6 +199,134 @@ class PhotosService {
|
|||||||
shouldUseChunkedUpload(fileSize: number): boolean {
|
shouldUseChunkedUpload(fileSize: number): boolean {
|
||||||
return fileSize > 100 * 1024 * 1024; // 100MB threshold
|
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();
|
export const photosService = new PhotosService();
|
||||||
|
|||||||
Reference in New Issue
Block a user