Files
picpeak/docs/feature-custom-css-templates.md
T
Paul Nothaft 77a4bfd499 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
2026-01-02 09:56:19 +01:00

37 KiB

Feature: Custom CSS Gallery Templates

Overview

This feature allows administrators to create and manage up to 3 custom CSS templates for gallery styling. Each template can be edited via a tabbed interface in the admin panel and selected when creating or editing events. The first template slot includes a working example template.

GitHub Issue: Gallery Templates - Additional gallery layouts and themes


Problem Statement

Currently, gallery styling is limited to the built-in theme system with predefined color schemes. Photographers and administrators need:

  • Full CSS customization capabilities for unique branding
  • Ability to create multiple reusable style templates
  • Easy switching between templates for different event types
  • Professional-grade CSS editing experience

User Story

As an administrator, I want to create custom CSS templates for my galleries so that I can offer unique visual experiences for different clients and event types while maintaining my brand identity.


Feature Requirements

1. Custom CSS Template System

1.1 Template Slots

  • 3 custom CSS template slots available system-wide
  • Each slot has:
    • Name/label (editable, max 50 characters)
    • CSS content (unlimited, stored as TEXT)
    • Active/enabled toggle
    • Last modified timestamp

1.2 Default Template (Slot 1)

  • Pre-populated with a working example template
  • Demonstrates available CSS custom properties
  • Can be modified but shows "Reset to Default" option

2. Admin UI - Template Editor

2.1 Location

Path: Admin Panel → Settings → Styling → Custom CSS Templates (new tab)

2.2 Tabbed Interface

┌─────────────────────────────────────────────────────────────────┐
│ Settings > Styling                                               │
├─────────────────────────────────────────────────────────────────┤
│ [Theme] [Colors] [Custom CSS Templates]  ← NEW TAB              │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│  ┌──────────────┬──────────────┬──────────────┐                │
│  │ Template 1   │ Template 2   │ Template 3   │                │
│  │ (Elegant)    │ (Untitled)   │ (Untitled)   │                │
│  └──────────────┴──────────────┴──────────────┘                │
│                                                                  │
│  Template Name: [Elegant Dark_______________]                   │
│                                                                  │
│  [✓] Enable this template                                       │
│                                                                  │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │ /* Elegant Dark Theme */                                 │   │
│  │ :root {                                                  │   │
│  │   --gallery-bg: #1a1a2e;                                │   │
│  │   --gallery-text: #eaeaea;                              │   │
│  │   --gallery-accent: #e94560;                            │   │
│  │ }                                                        │   │
│  │                                                          │   │
│  │ .gallery-container {                                     │   │
│  │   background: var(--gallery-bg);                        │   │
│  │ }                                                        │   │
│  │ ...                                                      │   │
│  └─────────────────────────────────────────────────────────┘   │
│                                                                  │
│  CSS Editor (CodeMirror)                                        │
│  Line: 1  Col: 1  |  Valid CSS ✓                               │
│                                                                  │
│  [Preview] [Reset to Default*] [Save Template]                  │
│                                                                  │
│  * Only shown for Template 1                                    │
└─────────────────────────────────────────────────────────────────┘

2.3 CSS Editor Features

  • CodeMirror integration with CSS mode
  • Syntax highlighting
  • Auto-completion for CSS properties
  • Line numbers
  • Real-time CSS validation
  • Error highlighting
  • Find/replace functionality
  • Bracket matching

3. Event Integration

3.1 Event Creation/Edit Form

Add template selector dropdown:

┌─────────────────────────────────────────────────────────────────┐
│ Event Details                                                    │
├─────────────────────────────────────────────────────────────────┤
│ ...                                                              │
│                                                                  │
│ Gallery Styling                                                  │
│ ┌─────────────────────────────────────────────────────────┐    │
│ │ Custom CSS Template                                      │    │
│ │ ┌───────────────────────────────────────────────────┐   │    │
│ │ │ None (Use default theme)                      ▼   │   │    │
│ │ │ ─────────────────────────────────────────────────│   │    │
│ │ │ ○ None (Use default theme)                       │   │    │
│ │ │ ○ Elegant Dark                                   │   │    │
│ │ │ ○ Template 2 (disabled)                          │   │    │
│ │ │ ○ Minimalist White                               │   │    │
│ │ └───────────────────────────────────────────────────┘   │    │
│ │                                                          │    │
│ │ [Preview Template]                                       │    │
│ └─────────────────────────────────────────────────────────┘    │
│                                                                  │
│ ...                                                              │
└─────────────────────────────────────────────────────────────────┘

3.2 Template Selection Rules

  • Only enabled templates appear in dropdown
  • "None" option always available (uses default theme)
  • Disabled templates show "(disabled)" and cannot be selected
  • Selected template ID stored in events table

4.1 CSS Loading

When gallery loads:

  1. Check if event has css_template_id set
  2. If set, fetch template CSS from API
  3. Inject CSS into <style id="custom-gallery-css"> tag
  4. Apply after base styles (cascading override)

4.2 CSS Scoping

All custom CSS is scoped to gallery pages only:

  • Prepend .gallery-page selector to all rules (server-side)
  • Or use CSS nesting with .gallery-page { ... }

Technical Specification

5. Database Changes

5.1 New Table: css_templates

CREATE TABLE css_templates (
  id SERIAL PRIMARY KEY,
  slot_number INTEGER NOT NULL CHECK (slot_number BETWEEN 1 AND 3),
  name VARCHAR(50) NOT NULL DEFAULT 'Untitled',
  css_content TEXT NOT NULL DEFAULT '',
  is_enabled BOOLEAN NOT NULL DEFAULT false,
  is_default BOOLEAN NOT NULL DEFAULT false,
  created_at TIMESTAMP DEFAULT NOW(),
  updated_at TIMESTAMP DEFAULT NOW(),
  UNIQUE(slot_number)
);

-- Insert default templates
INSERT INTO css_templates (slot_number, name, css_content, is_enabled, is_default) VALUES
(1, 'Elegant Dark', '/* See default template below */', true, true),
(2, 'Untitled', '', false, false),
(3, 'Untitled', '', false, false);

5.2 Events Table Update

ALTER TABLE events ADD COLUMN css_template_id INTEGER REFERENCES css_templates(id) ON DELETE SET NULL;

5.3 Migration File

File: /backend/migrations/core/YYYYMMDDHHMMSS_add_css_templates.js

exports.up = function(knex) {
  return knex.schema
    .createTable('css_templates', (table) => {
      table.increments('id').primary();
      table.integer('slot_number').notNullable().checkBetween([1, 3]);
      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');
    })
    .then(() => {
      // Insert default templates
      return knex('css_templates').insert([
        {
          slot_number: 1,
          name: 'Elegant Dark',
          css_content: DEFAULT_CSS_TEMPLATE, // Constant defined below
          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 }
      ]);
    })
    .then(() => {
      return knex.schema.alterTable('events', (table) => {
        table.integer('css_template_id').references('id').inTable('css_templates').onDelete('SET NULL');
      });
    });
};

exports.down = function(knex) {
  return knex.schema
    .alterTable('events', (table) => {
      table.dropColumn('css_template_id');
    })
    .then(() => {
      return knex.schema.dropTable('css_templates');
    });
};

6. Security Implementation

6.1 CSS Sanitization (Critical)

Security Risks:

  • CSS injection attacks
  • JavaScript execution via url(), expression(), behavior
  • Data exfiltration via background-image: url()
  • UI redress attacks

Sanitization Rules:

// /backend/src/utils/cssSanitizer.js

const FORBIDDEN_PATTERNS = [
  // JavaScript execution
  /expression\s*\(/gi,
  /javascript:/gi,
  /behavior\s*:/gi,
  /-moz-binding/gi,

  // External resources (potential data exfiltration)
  /url\s*\(\s*["']?(?!data:image)/gi, // Allow only data: URIs for images
  /@import/gi,

  // Dangerous at-rules
  /@charset/gi,
  /@namespace/gi,

  // IE-specific exploits
  /\\0/g,  // Null byte
  /\\9/g,  // IE CSS hack
];

const ALLOWED_PROPERTIES = new Set([
  // Layout
  'display', 'position', 'top', 'right', 'bottom', 'left',
  'float', 'clear', 'z-index', 'overflow', 'overflow-x', 'overflow-y',

  // Box model
  'width', 'height', 'min-width', 'max-width', 'min-height', 'max-height',
  'margin', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
  'padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
  'border', 'border-width', 'border-style', 'border-color', 'border-radius',

  // Flexbox
  'flex', 'flex-direction', 'flex-wrap', 'justify-content', 'align-items',
  'align-content', 'gap', 'row-gap', 'column-gap',

  // Grid
  'grid', 'grid-template-columns', 'grid-template-rows', 'grid-gap',

  // Typography
  'font', 'font-family', 'font-size', 'font-weight', 'font-style',
  'line-height', 'letter-spacing', 'text-align', 'text-decoration',
  'text-transform', 'color',

  // Visual
  'background', 'background-color', 'background-image', 'background-size',
  'background-position', 'background-repeat',
  'opacity', 'visibility', 'box-shadow', 'filter',

  // Transforms & Animations
  'transform', 'transition', 'animation',

  // Custom properties
  '--gallery-bg', '--gallery-text', '--gallery-accent', '--gallery-border',
  '--gallery-shadow', '--gallery-radius', '--gallery-spacing',
]);

function sanitizeCSS(cssContent) {
  let sanitized = cssContent;

  // Remove forbidden patterns
  for (const pattern of FORBIDDEN_PATTERNS) {
    sanitized = sanitized.replace(pattern, '/* BLOCKED */');
  }

  // Validate and filter properties (using css-tree or postcss)
  // ... additional parsing and validation

  return sanitized;
}

module.exports = { sanitizeCSS, ALLOWED_PROPERTIES };

6.2 Content Security Policy

Add CSP header for gallery pages:

// Gallery CSP (allows inline styles for custom CSS)
app.use('/gallery', (req, res, next) => {
  res.setHeader('Content-Security-Policy',
    "default-src 'self'; " +
    "style-src 'self' 'unsafe-inline'; " +  // Allow custom CSS
    "img-src 'self' data: blob:; " +
    "script-src 'self'; " +
    "font-src 'self' data:;"
  );
  next();
});

6.3 CSS Size Limits

  • Maximum CSS content: 100KB per template
  • Maximum total CSS: 300KB (all templates)
  • Rate limiting on save: 10 saves per minute

7. API Endpoints

7.1 Get All Templates (Admin)

GET /api/admin/css-templates

Response:

{
  "templates": [
    {
      "id": 1,
      "slot_number": 1,
      "name": "Elegant Dark",
      "css_content": "/* CSS content */",
      "is_enabled": true,
      "is_default": true,
      "updated_at": "2026-01-02T10:00:00Z"
    },
    // ... templates 2 and 3
  ]
}

7.2 Update Template (Admin)

PUT /api/admin/css-templates/:slotNumber

Request:

{
  "name": "Elegant Dark",
  "css_content": "/* CSS content */",
  "is_enabled": true
}

Response:

{
  "success": true,
  "template": { /* updated template */ },
  "sanitization_warnings": [] // Any patterns that were blocked
}

7.3 Reset Template to Default (Admin)

POST /api/admin/css-templates/:slotNumber/reset

Response:

{
  "success": true,
  "template": { /* template with default CSS */ }
}

7.4 Get Enabled Templates (For Event Form)

GET /api/admin/css-templates/enabled

Response:

{
  "templates": [
    { "id": 1, "name": "Elegant Dark" },
    { "id": 3, "name": "Minimalist White" }
  ]
}
GET /api/gallery/:slug/css-template

Response:

/* Sanitized CSS content */
.gallery-page {
  --gallery-bg: #1a1a2e;
  ...
}

8. Backend Implementation

8.1 Route File

File: /backend/src/routes/adminCssTemplates.js

const express = require('express');
const router = express.Router();
const { adminAuth } = require('../middleware/auth');
const db = require('../database/db');
const { sanitizeCSS } = require('../utils/cssSanitizer');

const MAX_CSS_SIZE = 100 * 1024; // 100KB

// Get all templates
router.get('/', adminAuth, async (req, res) => {
  try {
    const templates = await db('css_templates')
      .orderBy('slot_number');
    res.json({ templates });
  } catch (error) {
    res.status(500).json({ error: 'Failed to fetch templates' });
  }
});

// Get enabled templates (for event form dropdown)
router.get('/enabled', adminAuth, async (req, res) => {
  try {
    const templates = await db('css_templates')
      .where({ is_enabled: true })
      .select('id', 'name')
      .orderBy('slot_number');
    res.json({ templates });
  } catch (error) {
    res.status(500).json({ error: 'Failed to fetch templates' });
  }
});

// Update template
router.put('/:slotNumber', adminAuth, async (req, res) => {
  try {
    const { slotNumber } = req.params;
    const { name, css_content, is_enabled } = req.body;

    // Validate slot number
    if (slotNumber < 1 || slotNumber > 3) {
      return res.status(400).json({ error: 'Invalid slot number' });
    }

    // 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`
      });
    }

    // Sanitize CSS
    const { sanitized, warnings } = sanitizeCSS(css_content || '');

    const updated = await db('css_templates')
      .where({ slot_number: slotNumber })
      .update({
        name: name?.substring(0, 50) || 'Untitled',
        css_content: sanitized,
        is_enabled: Boolean(is_enabled),
        updated_at: db.fn.now()
      })
      .returning('*');

    res.json({
      success: true,
      template: updated[0],
      sanitization_warnings: warnings
    });
  } catch (error) {
    res.status(500).json({ error: 'Failed to update template' });
  }
});

// Reset to default
router.post('/:slotNumber/reset', adminAuth, async (req, res) => {
  try {
    const { slotNumber } = req.params;

    if (slotNumber !== '1') {
      return res.status(400).json({
        error: 'Only template 1 can be reset to default'
      });
    }

    const updated = await db('css_templates')
      .where({ slot_number: 1 })
      .update({
        name: 'Elegant Dark',
        css_content: DEFAULT_CSS_TEMPLATE,
        is_enabled: true,
        updated_at: db.fn.now()
      })
      .returning('*');

    res.json({ success: true, template: updated[0] });
  } catch (error) {
    res.status(500).json({ error: 'Failed to reset template' });
  }
});

module.exports = router;

File: /backend/src/routes/gallery.js (update)

// Add endpoint for fetching event's CSS template
router.get('/:slug/css-template', async (req, res) => {
  try {
    const { slug } = req.params;

    const event = await db('events')
      .where({ slug })
      .select('css_template_id')
      .first();

    if (!event || !event.css_template_id) {
      return res.status(204).send(); // No custom CSS
    }

    const template = await db('css_templates')
      .where({ id: event.css_template_id, is_enabled: true })
      .select('css_content')
      .first();

    if (!template) {
      return res.status(204).send();
    }

    res.setHeader('Content-Type', 'text/css');
    res.setHeader('Cache-Control', 'public, max-age=3600'); // 1 hour cache
    res.send(template.css_content);
  } catch (error) {
    res.status(500).send('/* Error loading template */');
  }
});

9. Frontend Implementation

9.1 New Components

File: /frontend/src/components/admin/CssTemplateEditor.tsx

import React, { useState, useEffect, useCallback } from 'react';
import CodeMirror from '@uiw/react-codemirror';
import { css } from '@codemirror/lang-css';
import { vscodeDark } from '@uiw/codemirror-theme-vscode';

interface CssTemplate {
  id: number;
  slot_number: number;
  name: string;
  css_content: string;
  is_enabled: boolean;
  is_default: boolean;
  updated_at: string;
}

const CssTemplateEditor: React.FC = () => {
  const [templates, setTemplates] = useState<CssTemplate[]>([]);
  const [activeSlot, setActiveSlot] = useState(1);
  const [isSaving, setIsSaving] = useState(false);
  const [validationError, setValidationError] = useState<string | null>(null);

  const activeTemplate = templates.find(t => t.slot_number === activeSlot);

  useEffect(() => {
    fetchTemplates();
  }, []);

  const fetchTemplates = async () => {
    const response = await fetch('/api/admin/css-templates');
    const data = await response.json();
    setTemplates(data.templates);
  };

  const handleSave = async () => {
    if (!activeTemplate) return;

    setIsSaving(true);
    try {
      const response = await fetch(`/api/admin/css-templates/${activeSlot}`, {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          name: activeTemplate.name,
          css_content: activeTemplate.css_content,
          is_enabled: activeTemplate.is_enabled
        })
      });

      const data = await response.json();
      if (data.sanitization_warnings?.length > 0) {
        // Show warnings to user
      }

      await fetchTemplates();
    } finally {
      setIsSaving(false);
    }
  };

  const handleReset = async () => {
    if (activeSlot !== 1) return;

    const confirmed = window.confirm(
      'Reset this template to the default? Your changes will be lost.'
    );
    if (!confirmed) return;

    await fetch(`/api/admin/css-templates/1/reset`, { method: 'POST' });
    await fetchTemplates();
  };

  const updateTemplate = (updates: Partial<CssTemplate>) => {
    setTemplates(prev => prev.map(t =>
      t.slot_number === activeSlot ? { ...t, ...updates } : t
    ));
  };

  return (
    <div className="css-template-editor">
      {/* Tab Navigation */}
      <div className="template-tabs">
        {[1, 2, 3].map(slot => {
          const template = templates.find(t => t.slot_number === slot);
          return (
            <button
              key={slot}
              className={`template-tab ${activeSlot === slot ? 'active' : ''}`}
              onClick={() => setActiveSlot(slot)}
            >
              Template {slot}
              {template && (
                <span className="template-name">({template.name})</span>
              )}
            </button>
          );
        })}
      </div>

      {activeTemplate && (
        <div className="template-content">
          {/* Template Name */}
          <div className="form-group">
            <label>Template Name</label>
            <input
              type="text"
              value={activeTemplate.name}
              onChange={(e) => updateTemplate({ name: e.target.value })}
              maxLength={50}
            />
          </div>

          {/* Enable Toggle */}
          <div className="form-group">
            <label className="toggle-label">
              <input
                type="checkbox"
                checked={activeTemplate.is_enabled}
                onChange={(e) => updateTemplate({ is_enabled: e.target.checked })}
              />
              Enable this template
            </label>
          </div>

          {/* CSS Editor */}
          <div className="css-editor-container">
            <CodeMirror
              value={activeTemplate.css_content}
              height="400px"
              theme={vscodeDark}
              extensions={[css()]}
              onChange={(value) => updateTemplate({ css_content: value })}
            />
          </div>

          {/* Validation Status */}
          {validationError && (
            <div className="validation-error">{validationError}</div>
          )}

          {/* Action Buttons */}
          <div className="template-actions">
            <button
              className="btn btn-secondary"
              onClick={() => window.open(`/gallery/preview?template=${activeSlot}`, '_blank')}
            >
              Preview
            </button>

            {activeSlot === 1 && activeTemplate.is_default && (
              <button
                className="btn btn-warning"
                onClick={handleReset}
              >
                Reset to Default
              </button>
            )}

            <button
              className="btn btn-primary"
              onClick={handleSave}
              disabled={isSaving}
            >
              {isSaving ? 'Saving...' : 'Save Template'}
            </button>
          </div>
        </div>
      )}
    </div>
  );
};

export default CssTemplateEditor;

9.2 Event Form Integration

File: /frontend/src/components/admin/EventForm.tsx (update)

// Add to existing EventForm component

interface CssTemplateOption {
  id: number;
  name: string;
}

// In component:
const [cssTemplates, setCssTemplates] = useState<CssTemplateOption[]>([]);

useEffect(() => {
  fetchCssTemplates();
}, []);

const fetchCssTemplates = async () => {
  const response = await fetch('/api/admin/css-templates/enabled');
  const data = await response.json();
  setCssTemplates(data.templates);
};

// In form JSX:
<FormField label="Custom CSS Template">
  <select
    value={formData.css_template_id || ''}
    onChange={(e) => setFormData({
      ...formData,
      css_template_id: e.target.value ? Number(e.target.value) : null
    })}
  >
    <option value="">None (Use default theme)</option>
    {cssTemplates.map(template => (
      <option key={template.id} value={template.id}>
        {template.name}
      </option>
    ))}
  </select>
</FormField>

File: /frontend/src/hooks/useGalleryCustomCss.ts

import { useEffect, useState } from 'react';

export function useGalleryCustomCss(slug: string) {
  const [customCss, setCustomCss] = useState<string | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    if (!slug) return;

    const loadCustomCss = async () => {
      try {
        const response = await fetch(`/api/gallery/${slug}/css-template`);
        if (response.status === 204) {
          setCustomCss(null);
          return;
        }
        if (response.ok) {
          const css = await response.text();
          setCustomCss(css);
        }
      } catch (error) {
        console.error('Failed to load custom CSS:', error);
      } finally {
        setLoading(false);
      }
    };

    loadCustomCss();
  }, [slug]);

  // Inject CSS into document
  useEffect(() => {
    if (!customCss) return;

    const styleElement = document.createElement('style');
    styleElement.id = 'gallery-custom-css';
    styleElement.textContent = customCss;
    document.head.appendChild(styleElement);

    return () => {
      const existing = document.getElementById('gallery-custom-css');
      if (existing) {
        existing.remove();
      }
    };
  }, [customCss]);

  return { customCss, loading };
}

File: /frontend/src/pages/GalleryPage.tsx (update)

import { useGalleryCustomCss } from '../hooks/useGalleryCustomCss';

const GalleryPage: React.FC = () => {
  const { slug } = useParams();

  // Load custom CSS for this gallery
  useGalleryCustomCss(slug || '');

  // ... rest of component
};

10. Default CSS Template (Slot 1)

Working Example - "Elegant Dark"

/*
 * 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;
}

.gallery-subtitle {
  color: var(--gallery-text-muted);
  margin-top: calc(var(--gallery-spacing) / 2);
}

/* ===== 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);
}

.photo-card-overlay {
  background: linear-gradient(to top, rgba(26, 26, 46, 0.9), transparent);
  padding: var(--gallery-spacing);
}

/* ===== 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);
}

.gallery-btn-secondary {
  background: transparent;
  border: 1px solid var(--gallery-border);
  color: var(--gallery-text);
}

.gallery-btn-secondary:hover {
  background: var(--gallery-bg-secondary);
  border-color: var(--gallery-accent);
}

/* ===== Lightbox ===== */
.lightbox-overlay {
  background: rgba(10, 10, 20, 0.95);
  backdrop-filter: blur(20px);
}

.lightbox-content {
  max-width: 90vw;
  max-height: 90vh;
}

.lightbox-navigation {
  color: var(--gallery-text);
}

.lightbox-navigation:hover {
  color: var(--gallery-accent);
}

/* ===== Photo Details ===== */
.photo-details {
  background: var(--gallery-bg-secondary);
  border-radius: var(--gallery-radius);
  padding: calc(var(--gallery-spacing) * 1.5);
  border: 1px solid var(--gallery-border);
}

.photo-meta {
  color: var(--gallery-text-muted);
  font-size: 0.875rem;
}

/* ===== Rating Stars ===== */
.rating-star {
  color: var(--gallery-text-muted);
  transition: color 0.2s ease, transform 0.2s ease;
}

.rating-star.active,
.rating-star:hover {
  color: var(--gallery-accent);
  transform: scale(1.1);
}

/* ===== Selection Checkbox ===== */
.photo-select-checkbox {
  accent-color: var(--gallery-accent);
}

/* ===== Loading States ===== */
.skeleton-loader {
  background: linear-gradient(
    90deg,
    var(--gallery-bg-secondary) 25%,
    var(--gallery-border) 50%,
    var(--gallery-bg-secondary) 75%
  );
  background-size: 200% 100%;
  animation: shimmer 1.5s infinite;
  border-radius: var(--gallery-radius);
}

@keyframes shimmer {
  0% { background-position: -200% 0; }
  100% { background-position: 200% 0; }
}

/* ===== Scrollbar Styling ===== */
.gallery-page ::-webkit-scrollbar {
  width: 8px;
  height: 8px;
}

.gallery-page ::-webkit-scrollbar-track {
  background: var(--gallery-bg);
}

.gallery-page ::-webkit-scrollbar-thumb {
  background: var(--gallery-border);
  border-radius: 4px;
}

.gallery-page ::-webkit-scrollbar-thumb:hover {
  background: var(--gallery-accent);
}

/* ===== 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;
  }
}

Implementation Checklist

Phase 1: Backend Foundation

  • Create database migration for css_templates table
  • Add css_template_id column to events table
  • Run migrations
  • Create /backend/src/utils/cssSanitizer.js
  • Create /backend/src/routes/adminCssTemplates.js
  • Register new routes in server.js
  • Add gallery CSS endpoint to gallery.js
  • Write unit tests for CSS sanitization

Phase 2: Admin UI - Template Editor

  • Install CodeMirror: npm install @uiw/react-codemirror @codemirror/lang-css
  • Create CssTemplateEditor.tsx component
  • Add "Custom CSS Templates" tab to Styling page
  • Implement tabbed interface for 3 slots
  • Add template name editing
  • Add enable/disable toggle
  • Add CSS editor with syntax highlighting
  • Add save functionality
  • Add reset to default functionality (slot 1 only)
  • Add preview button functionality

Phase 3: Event Integration

  • Add CSS template dropdown to EventForm
  • Fetch enabled templates for dropdown
  • Save css_template_id with event
  • Update event edit to show current template
  • Add template preview in event form
  • Create useGalleryCustomCss hook
  • Update GalleryPage to load custom CSS
  • Add .gallery-page class to gallery container
  • Test CSS injection and cleanup
  • Verify CSS scoping works correctly

Phase 5: Testing & Security

  • Test CSS sanitization with malicious inputs
  • Test all forbidden patterns are blocked
  • Test size limits (100KB)
  • Test CSP headers on gallery pages
  • E2E test: create template → assign to event → view gallery
  • Test template enable/disable
  • Test template reset
  • Cross-browser testing

Test Scenarios

# Scenario Expected Result
1 Load template editor Shows 3 tabs, first tab active, default CSS loaded
2 Edit template name Name updates, reflected in tab
3 Edit CSS content CodeMirror highlights syntax, changes tracked
4 Save template API called, success message, timestamp updated
5 CSS with javascript: Pattern removed, warning shown
6 CSS with @import Pattern removed, warning shown
7 CSS > 100KB Save rejected with size error
8 Enable template Template appears in event form dropdown
9 Disable template Template removed from event form dropdown
10 Assign template to event Event saves with css_template_id
11 View gallery with template Custom CSS applied, styling visible
12 Reset template 1 Confirmation prompt, default CSS restored
13 Try reset on template 2 Button not shown / action rejected
14 Gallery without template No custom CSS loaded, default theme used

Security Checklist

  • CSS sanitization removes all forbidden patterns
  • No JavaScript execution possible via CSS
  • No external resource loading (@import, external url())
  • Size limits enforced (100KB per template)
  • Admin authentication required for template management
  • CSP headers configured for gallery pages
  • Rate limiting on template saves
  • Input validation on template names

Acceptance Criteria

  1. Admin can access "Custom CSS Templates" tab in Styling settings
  2. 3 template slots displayed with tabbed interface
  3. Template 1 pre-populated with working "Elegant Dark" example
  4. CodeMirror editor with CSS syntax highlighting
  5. Templates can be named, enabled/disabled, and saved
  6. Template 1 has "Reset to Default" option
  7. Enabled templates appear in event creation/edit dropdown
  8. Event stores selected template reference
  9. Gallery loads and applies custom CSS from assigned template
  10. CSS sanitization blocks malicious patterns
  11. Size limits enforced (100KB)
  12. All CSS scoped to gallery pages only

Risk Assessment

Risk Likelihood Impact Mitigation
CSS injection attack Medium High Comprehensive sanitization, CSP headers
Performance (large CSS) Low Medium Size limits, caching
Template breaks gallery Medium Medium Preview functionality, reset option
Browser compatibility Low Low Standard CSS properties only
Lost changes Medium Low Auto-save, confirmation on leave

Backend

  • /backend/src/routes/adminCssTemplates.js - New route file
  • /backend/src/routes/gallery.js - CSS template endpoint
  • /backend/src/utils/cssSanitizer.js - New sanitization utility
  • /backend/migrations/core/ - Migration files

Frontend

  • /frontend/src/components/admin/CssTemplateEditor.tsx - New component
  • /frontend/src/components/admin/EventForm.tsx - Update for dropdown
  • /frontend/src/hooks/useGalleryCustomCss.ts - New hook
  • /frontend/src/pages/GalleryPage.tsx - CSS loading integration
  • /frontend/src/pages/admin/BrandingPage.tsx - Add templates tab

Future Enhancements

  1. Template marketplace: Share/download community templates
  2. Template versioning: History of changes per template
  3. CSS variables UI: Visual editor for CSS custom properties
  4. Template categories: Organize by event type (wedding, corporate, etc.)
  5. Live preview editor: Side-by-side CSS editing with live preview
  6. Template cloning: Duplicate existing template to new slot
  7. More slots: Configurable number of template slots
  8. Template import/export: JSON file format for backup/sharing