feat: add Apple Liquid Glass templates, image security settings, and automated releases

## New Features
- Apple Liquid Glass CSS template with iOS 26-inspired design
- Liquid Glass Dark theme with neon accents
- Image Security settings tab with per-event protection levels
- Release Please automation for versioning and changelog

## Improvements
- Update CSS template migration with final working templates
- Add search placeholder visibility fix for glass themes
- Update README roadmap (Download Protection, Gallery Templates, Filtering & Export now implemented)

## Infrastructure
- Add release-please.yml workflow for automated releases
- Add release-please-config.json and manifest
- Update docker-build.yml with Release Please integration comments
- Add comprehensive CHANGELOG.md

## Cleanup
- Add working/planning docs to .gitignore (CLAUDE.md, test-*.md, feature-*.md, etc.)
- Remove internal planning documents from git tracking (kept locally)

## Files Added
- .github/workflows/release-please.yml
- .release-please-manifest.json
- release-please-config.json
- CHANGELOG.md
- frontend/src/features/settings/tabs/ImageSecurityTab.tsx
This commit is contained in:
Paul Nothaft
2026-01-03 23:35:23 +01:00
parent f3c2cee362
commit 6033461be1
44 changed files with 1978 additions and 9443 deletions
+8 -2
View File
@@ -1,13 +1,19 @@
name: Build and Push Docker Images name: Build and Push Docker Images
# This workflow is triggered by:
# - Push to main/develop branches (builds 'latest' or branch-tagged images)
# - Version tags from Release Please (e.g., v1.2.0 -> builds versioned images)
# - GitHub Releases (created by Release Please)
# - Manual workflow dispatch
on: on:
push: push:
branches: [ main, develop ] branches: [ main, develop ]
tags: [ 'v*.*.*' ] tags: [ 'v*.*.*' ] # Triggered by Release Please tags
pull_request: pull_request:
branches: [ main ] branches: [ main ]
release: release:
types: [ published ] types: [ published ] # Triggered when Release Please creates a release
workflow_dispatch: workflow_dispatch:
inputs: inputs:
push: push:
+71
View File
@@ -0,0 +1,71 @@
name: Release Please
on:
push:
branches: [main]
permissions:
contents: write
pull-requests: write
jobs:
release-please:
runs-on: ubuntu-latest
outputs:
release_created: ${{ steps.release.outputs.release_created }}
tag_name: ${{ steps.release.outputs.tag_name }}
version: ${{ steps.release.outputs.major }}.${{ steps.release.outputs.minor }}.${{ steps.release.outputs.patch }}
steps:
- name: Run Release Please
uses: googleapis/release-please-action@v4
id: release
with:
token: ${{ secrets.GITHUB_TOKEN }}
config-file: release-please-config.json
manifest-file: .release-please-manifest.json
- name: Output Release Info
if: ${{ steps.release.outputs.release_created }}
run: |
echo "## Release Created! " >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Tag:** ${{ steps.release.outputs.tag_name }}" >> $GITHUB_STEP_SUMMARY
echo "**Version:** ${{ steps.release.outputs.major }}.${{ steps.release.outputs.minor }}.${{ steps.release.outputs.patch }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Docker images will be built and tagged with this version." >> $GITHUB_STEP_SUMMARY
# Sync version to package.json files after release
sync-versions:
needs: release-please
if: ${{ needs.release-please.outputs.release_created }}
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: main
- name: Update package.json versions
run: |
VERSION="${{ needs.release-please.outputs.version }}"
echo "Updating package.json files to version $VERSION"
# Update backend package.json
cd backend
npm version $VERSION --no-git-tag-version --allow-same-version
cd ..
# Update frontend package.json
cd frontend
npm version $VERSION --no-git-tag-version --allow-same-version
cd ..
- name: Commit version updates
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add backend/package.json frontend/package.json
git diff --staged --quiet || git commit -m "chore: sync package.json versions to ${{ needs.release-please.outputs.version }}"
git push
+7
View File
@@ -77,6 +77,13 @@ certbot/
AGENTS.md AGENTS.md
CLAUDE.md CLAUDE.md
# Working/planning documents (not for release)
BUGS_AND_FEATURES.md
frontend/TEST_PLAN.md
docs/REFACTORING_PLAN.md
docs/test-*.md
docs/feature-*.md
# Local artifacts from browser tooling # Local artifacts from browser tooling
.playwright-mcp/ .playwright-mcp/
+3
View File
@@ -0,0 +1,3 @@
{
".": "1.1.15"
}
-1309
View File
File diff suppressed because it is too large Load Diff
+63
View File
@@ -0,0 +1,63 @@
# Changelog
All notable changes to PicPeak will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.2.0](https://github.com/the-luap/picpeak/compare/v1.1.15...v1.2.0) (2026-01-03)
### Features
* **Event Rename**: Safe event renaming with automatic slug updates, old URL redirects via `slug_redirects` table, and optional email notifications to clients
* **Optional Event Fields**: Make customer name, email, and admin email fields optional via admin settings with "(optional)" labels in forms
* **Photo Filtering**: Filter photos by rating, likes, favorites, and comments with a new PhotoFilterPanel component
* **Photo Export**: Export filtered photo selections as ZIP, generate Capture One/Lightroom-compatible XMP sidecar files, or export metadata lists
* **Custom CSS Templates**: 3 customizable CSS template slots with live preview, XSS-safe sanitization, and per-event template assignment
* **Apple Liquid Glass Theme**: Starter CSS template inspired by iOS 26 / macOS Tahoe Liquid Glass design with glass morphism effects, Apple SF Pro fonts, and responsive layout
* **Liquid Glass Dark Theme**: Neon-accented dark glass theme with animated gradient backgrounds
* **Image Security Settings**: Per-event download protection with configurable protection levels (basic, standard, enhanced, maximum), canvas rendering, DevTools detection, and right-click prevention
* **Automated Releases**: Release Please integration for automatic versioning, changelog generation, and GitHub releases that trigger Docker image builds
### Bug Fixes
* **Date Parsing**: Fix event date formatting in slugs (now uses YYYY-MM-DD format correctly)
* **Search Placeholder**: Fix search field placeholder visibility in glass-styled sidebar
* **Vite Proxy**: Fix Vite dev server proxy port configuration
* **Photo Export Button**: Fix export button staying disabled when photos are selected
* **Boolean Parsing**: Fix boolean parsing in publicSettings.js for optional fields
* **Translation Keys**: Add missing `common.optional` translation key in locales
### Security
* Fix critical vulnerabilities and harden application security
* Add CSS sanitizer utility blocking XSS vectors in custom templates
* Implement secure gallery CSS endpoint for template delivery
### Code Refactoring
* Add Photo and Settings service layers for better code organization
* Phase 1 code consolidation with service layer architecture
* Modular settings page with feature-based tab components
* Create photoFilterBuilder utility for query construction
* Add eventRenameService for safe event operations
### Documentation
* Add comprehensive REFACTORING_PLAN.md for codebase improvement roadmap
* Update README roadmap with implemented features (Download Protection, Gallery Templates, Filtering & Export)
* Add test specification documents for all new features
### Database Migrations
* `049_add_slug_redirects.js` - Store old slugs for URL redirects after rename
* `050_add_optional_event_fields_settings.js` - Settings for optional form fields
* `051_add_photo_filter_indexes.js` - Performance indexes for photo filtering
* `052_add_css_templates.js` - CSS template storage with 3 slots
* `053_add_liquid_glass_templates.js` - Apple Liquid Glass and Dark theme starter templates
---
## [1.1.15] - Previous Release
Initial stable release with core functionality.
+3 -3
View File
@@ -235,7 +235,6 @@ These features are currently in beta testing and may have limited functionality
| Feature | Description | Status | | Feature | Description | Status |
|---------|-------------|--------| |---------|-------------|--------|
| **Download Protection** | Advanced image protection system with canvas rendering, invisible watermarking, and right-click prevention to protect your photos from unauthorized downloads | 🧪 Beta |
| **Simple Deployment Script** | One-click deployment script for quick server setup with automated configuration and dependency installation | 🧪 Beta | | **Simple Deployment Script** | One-click deployment script for quick server setup with automated configuration and dependency installation | 🧪 Beta |
### 📋 Future Enhancements ### 📋 Future Enhancements
@@ -244,12 +243,13 @@ These features are currently in beta testing and may have limited functionality
|---------|-------------|----------|---------| |---------|-------------|----------|---------|
| **Backup & Restore** | Comprehensive backup system with S3/MinIO support, automated scheduling, and safe restore functionality | High | ✅ Implemented | | **Backup & Restore** | Comprehensive backup system with S3/MinIO support, automated scheduling, and safe restore functionality | High | ✅ Implemented |
| **External Media Library (Reference Mode)** | Use an external folder library as a readonly source with import and ondemand thumbnail generation | High | ✅ Implemented | | **External Media Library (Reference Mode)** | Use an external folder library as a readonly source with import and ondemand thumbnail generation | High | ✅ Implemented |
| **Gallery Templates** | Additional gallery layouts and themes (masonry, slideshow, story-style) for different event types | Medium | 🔄 Open | | **Download Protection** | Advanced image protection system with canvas rendering, invisible watermarking, right-click prevention, and DevTools detection to protect photos from unauthorized downloads | High | ✅ Implemented |
| **Gallery Templates** | Multiple gallery layouts (grid, masonry, carousel, timeline, hero, mosaic) with custom CSS styling support. Includes starter templates like Apple Liquid Glass for complete visual customization | Medium | ✅ Implemented |
| **Face Recognition** | AI-powered face detection to help guests find their photos and create automatic person-based albums | Low | 🔄 Open | | **Face Recognition** | AI-powered face detection to help guests find their photos and create automatic person-based albums | Low | 🔄 Open |
| **Gallery Feedback** | Allow guests to like, rate, and comment on photos with admin notifications and moderation | Medium | ✅ Implemented | | **Gallery Feedback** | Allow guests to like, rate, and comment on photos with admin notifications and moderation | Medium | ✅ Implemented |
| **Video Support** | Upload and display videos alongside photos in galleries with streaming support | Low | ✅ Implemented | | **Video Support** | Upload and display videos alongside photos in galleries with streaming support | Low | ✅ Implemented |
| **Multiple Administrators** | Support for multiple admin accounts with role-based permissions and activity tracking | Low | 📋 Planned | | **Multiple Administrators** | Support for multiple admin accounts with role-based permissions and activity tracking | Low | 📋 Planned |
| **Filtering & Export Options** | Add filters to show only rated, liked, or marked photos and export filtered selections for Capture One or Lightroom workflows | Low | 🔄 Open | | **Filtering & Export Options** | Filter photos by likes, ratings, comments, or favorites. Search by filename. Sort by date, name, size, or rating. Export filtered selections as ZIP or generate Capture One/Lightroom-compatible file lists for professional workflows | Medium | ✅ Implemented |
**Status Legend:** ✅ Implemented | 🚧 In Progress | 🔄 Open | 📋 Planned **Status Legend:** ✅ Implemented | 🚧 In Progress | 🔄 Open | 📋 Planned
@@ -1,296 +1,334 @@
/** /**
* Migration: Add Liquid Glass CSS Templates * Migration: Add Liquid Glass CSS Templates
* Updates template slots 2 and 3 with Apple-inspired Liquid Glass designs * Updates template slots 2 and 3 with Apple-inspired Liquid Glass designs
*/
const LIQUID_GLASS_LIGHT = `/*
* PicPeak Custom CSS Template: Liquid Glass Light
* Inspired by Apple's iOS 26 Liquid Glass Design Language
* *
* Features: * These are starter example templates for new installations.
* - Translucent frosted glass surfaces * Users can edit or replace them as needed.
* - Dynamic light refraction effects
* - Subtle specular highlights
* - Soft depth shadows
*/ */
/* ===== Base Theme Variables ===== */ const APPLE_LIQUID_GLASS = `/*
.gallery-page { * PicPeak Custom CSS Template: Apple Liquid Glass
--glass-bg: rgba(255, 255, 255, 0.7); * Authentic iOS 26 / macOS Tahoe Liquid Glass Design
--glass-bg-elevated: rgba(255, 255, 255, 0.85); */
--glass-border: rgba(255, 255, 255, 0.5);
--glass-shadow: 0 8px 32px rgba(31, 38, 135, 0.15);
--glass-blur: 20px;
--glass-saturation: 180%;
--gallery-bg: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%); /* ===== Apple System Fonts ===== */
.gallery-page,
.gallery-page *,
.gallery-sidebar,
.gallery-sidebar * {
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text",
"Helvetica Neue", Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* ===== CSS Variables ===== */
:root {
--glass-blur: 20px;
--glass-blur-heavy: 40px;
--glass-saturation: 180%;
--glass-bg: rgba(255, 255, 255, 0.08);
--glass-bg-medium: rgba(255, 255, 255, 0.18);
--glass-bg-solid: rgba(255, 255, 255, 0.25);
--glass-border: rgba(255, 255, 255, 0.2);
--glass-border-light: rgba(255, 255, 255, 0.4);
--glass-shadow: 0 8px 32px rgba(31, 38, 135, 0.15);
--glass-inset: inset 0 1px 1px rgba(255, 255, 255, 0.4),
inset 0 -1px 1px rgba(0, 0, 0, 0.05);
--gallery-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%);
--gallery-text: #1a1a2e; --gallery-text: #1a1a2e;
--gallery-text-muted: rgba(26, 26, 46, 0.7); --gallery-text-light: #ffffff;
--gallery-accent: #667eea; --gallery-accent: #667eea;
--gallery-accent-hover: #764ba2; --gallery-radius: 20px;
--gallery-radius: 24px; --gallery-radius-sm: 12px;
--gallery-spacing: 20px;
} }
/* ===== Page Background ===== */ /* ===== Page Background ===== */
.gallery-page { .gallery-page {
background: var(--gallery-bg); background: var(--gallery-gradient) !important;
background-attachment: fixed !important;
min-height: 100vh; min-height: 100vh;
position: relative;
} }
/* Animated gradient background */
.gallery-page::before { .gallery-page::before {
content: ''; content: '';
position: fixed; position: fixed;
top: 0; inset: 0;
left: 0;
right: 0;
bottom: 0;
background: background:
radial-gradient(circle at 20% 80%, rgba(255, 255, 255, 0.3) 0%, transparent 50%), radial-gradient(ellipse 600px 400px at 15% 85%, rgba(255, 255, 255, 0.2) 0%, transparent 50%),
radial-gradient(circle at 80% 20%, rgba(255, 255, 255, 0.2) 0%, transparent 40%); radial-gradient(ellipse 500px 350px at 85% 15%, rgba(255, 255, 255, 0.15) 0%, transparent 45%);
pointer-events: none; pointer-events: none;
z-index: 0; z-index: 0;
} }
/* ===== Glass Card Base ===== */ /* ===== TOP BAR / HEADER - Liquid Glass ===== */
.glass-surface { .gallery-page .gallery-header,
background: var(--glass-bg); .gallery-page header {
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); background: var(--glass-bg-medium) !important;
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); backdrop-filter: blur(var(--glass-blur-heavy)) saturate(var(--glass-saturation)) !important;
border: 1px solid var(--glass-border); -webkit-backdrop-filter: blur(var(--glass-blur-heavy)) saturate(var(--glass-saturation)) !important;
border-radius: var(--gallery-radius); border-bottom: 1px solid var(--glass-border) !important;
box-shadow: box-shadow: var(--glass-shadow), var(--glass-inset) !important;
var(--glass-shadow),
inset 0 1px 1px rgba(255, 255, 255, 0.8),
inset 0 -1px 1px rgba(0, 0, 0, 0.05);
} }
/* Liquid shine effect */ .gallery-page .gallery-header > div {
.glass-surface::before { background: transparent !important;
content: ''; border: none !important;
position: absolute;
top: 0;
left: 0;
right: 0;
height: 50%;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.4) 0%,
rgba(255, 255, 255, 0.1) 50%,
transparent 100%
);
border-radius: var(--gallery-radius) var(--gallery-radius) 0 0;
pointer-events: none;
} }
/* ===== Gallery Header ===== */ /* ===== SIDEBAR - Liquid Glass ===== */
.gallery-header { .gallery-sidebar {
background: var(--glass-bg-elevated); background: var(--glass-bg-medium) !important;
backdrop-filter: blur(30px) saturate(200%); backdrop-filter: blur(var(--glass-blur-heavy)) saturate(var(--glass-saturation)) !important;
-webkit-backdrop-filter: blur(30px) saturate(200%); -webkit-backdrop-filter: blur(var(--glass-blur-heavy)) saturate(var(--glass-saturation)) !important;
border-bottom: 1px solid var(--glass-border); border-right: 1px solid var(--glass-border) !important;
padding: calc(var(--gallery-spacing) * 1.5); box-shadow: 4px 0 32px rgba(31, 38, 135, 0.1), var(--glass-inset) !important;
position: sticky;
top: 0;
z-index: 100;
} }
.gallery-title { .gallery-sidebar h2,
color: var(--gallery-text); .gallery-sidebar h3 {
font-weight: 700; color: var(--gallery-text) !important;
font-size: 1.75rem; font-weight: 600 !important;
letter-spacing: -0.02em;
text-shadow: 0 1px 2px rgba(255, 255, 255, 0.5);
} }
/* ===== Photo Grid ===== */ /* ===== HERO LAYOUT - Transform to Glass Title Box ===== */
.photo-grid { /* Target the hero wrapper */
display: grid; .gallery-page .relative.-mt-6 {
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); margin-top: 0 !important;
gap: var(--gallery-spacing);
padding: calc(var(--gallery-spacing) * 2);
position: relative;
z-index: 1;
} }
/* ===== Photo Cards - Glass Style ===== */ /* Target the hero section (first child with h-[60vh]) */
.photo-card { .gallery-page .relative.-mt-6 > .relative:first-child {
position: relative; height: auto !important;
background: var(--glass-bg); min-height: auto !important;
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); margin: 0 !important;
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); padding: 2rem !important;
border: 1px solid var(--glass-border); display: flex !important;
border-radius: var(--gallery-radius); justify-content: center !important;
overflow: hidden; align-items: center !important;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); background: transparent !important;
box-shadow:
0 4px 16px rgba(0, 0, 0, 0.1),
inset 0 1px 1px rgba(255, 255, 255, 0.6);
} }
.photo-card::before { /* Hide the hero background image */
content: ''; .gallery-page .relative.-mt-6 > .relative:first-child > img,
position: absolute; .gallery-page .relative.-mt-6 > .relative:first-child > canvas {
top: 0; display: none !important;
left: 0;
right: 0;
height: 40%;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.3) 0%,
transparent 100%
);
pointer-events: none;
z-index: 1;
border-radius: var(--gallery-radius) var(--gallery-radius) 0 0;
} }
.photo-card:hover { /* Hide the dark overlay */
transform: translateY(-8px) scale(1.02); .gallery-page .relative.-mt-6 > .relative:first-child > .absolute.inset-0.bg-black {
box-shadow: display: none !important;
0 20px 40px rgba(102, 126, 234, 0.3), }
/* Style the content area as glass title box */
.gallery-page .relative.-mt-6 > .relative:first-child > .absolute.inset-0.flex {
position: relative !important;
inset: auto !important;
background: var(--glass-bg-medium) !important;
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)) !important;
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)) !important;
border: 1px solid var(--glass-border-light) !important;
border-radius: var(--gallery-radius) !important;
padding: 2rem 3rem !important;
box-shadow: var(--glass-shadow), var(--glass-inset) !important;
max-width: 600px !important;
width: auto !important;
}
/* Hide logo in glass title box */
.gallery-page .relative.-mt-6 > .relative:first-child .mb-6 {
display: none !important;
}
/* Style title text */
.gallery-page .relative.-mt-6 > .relative:first-child h1 {
color: var(--gallery-text) !important;
text-shadow: none !important;
font-weight: 700 !important;
font-size: 2.25rem !important;
margin-bottom: 0.75rem !important;
}
/* Style date text */
.gallery-page .relative.-mt-6 > .relative:first-child .text-white\\/90 {
color: var(--gallery-text) !important;
opacity: 0.8;
}
/* Hide scroll down button */
.gallery-page .relative.-mt-6 > .relative:first-child > .absolute.bottom-8,
.gallery-page .relative.-mt-6 > .relative:first-child > button.absolute {
display: none !important;
}
/* ===== PHOTO GRID ===== */
.gallery-page .photo-grid {
display: grid !important;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)) !important;
gap: 1.25rem !important;
padding: 1rem !important;
}
/* ===== PHOTO CARDS - Liquid Glass ===== */
.gallery-page .photo-card {
background: var(--glass-bg) !important;
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)) !important;
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)) !important;
border: 1px solid var(--glass-border) !important;
border-radius: var(--gallery-radius) !important;
overflow: hidden !important;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1) !important;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1), var(--glass-inset) !important;
}
.gallery-page .photo-card:hover {
transform: translateY(-6px) scale(1.02) !important;
box-shadow: 0 20px 40px rgba(102, 126, 234, 0.25),
0 8px 16px rgba(0, 0, 0, 0.1), 0 8px 16px rgba(0, 0, 0, 0.1),
inset 0 1px 1px rgba(255, 255, 255, 0.8); var(--glass-inset) !important;
border-color: var(--glass-border-light) !important;
} }
.photo-card img { .gallery-page .photo-card img {
width: 100%; transition: transform 0.4s ease !important;
height: 240px;
object-fit: cover;
transition: transform 0.4s ease;
} }
.photo-card:hover img { .gallery-page .photo-card:hover img {
transform: scale(1.05); transform: scale(1.05) !important;
} }
.photo-card-info { /* ===== BUTTONS - Glass Pill Style ===== */
padding: var(--gallery-spacing); .gallery-page button,
background: linear-gradient( .gallery-page [role="button"],
180deg, .gallery-sidebar button {
rgba(255, 255, 255, 0.1) 0%, background: var(--glass-bg) !important;
rgba(255, 255, 255, 0.3) 100% backdrop-filter: blur(12px) saturate(150%) !important;
); -webkit-backdrop-filter: blur(12px) saturate(150%) !important;
border: 1px solid var(--glass-border) !important;
border-radius: 9999px !important;
color: var(--gallery-text) !important;
font-weight: 500 !important;
transition: all 0.3s ease !important;
} }
/* ===== Buttons - Glass Style ===== */ .gallery-page button:hover,
.gallery-btn { .gallery-page [role="button"]:hover,
background: var(--glass-bg); .gallery-sidebar button:hover {
backdrop-filter: blur(10px); background: var(--glass-bg-medium) !important;
-webkit-backdrop-filter: blur(10px); transform: translateY(-1px);
border: 1px solid var(--glass-border); box-shadow: 0 4px 16px rgba(102, 126, 234, 0.2) !important;
border-radius: calc(var(--gallery-radius) / 2);
padding: 12px 24px;
color: var(--gallery-text);
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
} }
.gallery-btn::before { .gallery-page button[class*="bg-primary"],
content: ''; .gallery-page .gallery-btn-download {
position: absolute; background: linear-gradient(135deg, var(--gallery-accent) 0%, #764ba2 100%) !important;
top: 0; color: white !important;
left: 0; border: none !important;
right: 0;
height: 50%;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.4) 0%,
transparent 100%
);
} }
.gallery-btn:hover { /* ===== INPUT FIELDS ===== */
background: var(--glass-bg-elevated); .gallery-page input,
transform: translateY(-2px); .gallery-page select,
box-shadow: 0 8px 24px rgba(102, 126, 234, 0.3); .gallery-sidebar input,
.gallery-sidebar select {
background: rgba(255, 255, 255, 0.25) !important;
backdrop-filter: blur(8px) !important;
-webkit-backdrop-filter: blur(8px) !important;
border: 1px solid var(--glass-border) !important;
border-radius: var(--gallery-radius-sm) !important;
color: var(--gallery-text) !important;
} }
.gallery-btn-primary { /* Input placeholder text - make it visible */
background: linear-gradient(135deg, var(--gallery-accent) 0%, var(--gallery-accent-hover) 100%); .gallery-page input::placeholder,
color: white; .gallery-sidebar input::placeholder {
border: none; color: rgba(26, 26, 46, 0.6) !important;
opacity: 1 !important;
} }
/* ===== Lightbox - Glass Style ===== */ /* Input focus state */
.lightbox-overlay { .gallery-page input:focus,
background: rgba(26, 26, 46, 0.8); .gallery-sidebar input:focus {
backdrop-filter: blur(40px); background: rgba(255, 255, 255, 0.35) !important;
-webkit-backdrop-filter: blur(40px); border-color: var(--glass-border-light) !important;
outline: none !important;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.2) !important;
} }
.lightbox-content { /* ===== FOOTER ===== */
background: var(--glass-bg); .gallery-page .gallery-footer,
backdrop-filter: blur(20px); .gallery-page footer {
-webkit-backdrop-filter: blur(20px); background: var(--glass-bg) !important;
border: 1px solid var(--glass-border); backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)) !important;
border-radius: var(--gallery-radius); -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)) !important;
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.2); border-top: 1px solid var(--glass-border) !important;
} }
/* ===== Category Pills ===== */ /* ===== LIGHTBOX ===== */
.category-pill { .gallery-page [class*="fixed"][class*="inset-0"][class*="z-50"] {
background: var(--glass-bg); background: rgba(0, 0, 0, 0.7) !important;
backdrop-filter: blur(10px); backdrop-filter: blur(30px) !important;
-webkit-backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(30px) !important;
border: 1px solid var(--glass-border);
border-radius: 9999px;
padding: 8px 20px;
font-size: 0.875rem;
font-weight: 500;
color: var(--gallery-text);
transition: all 0.3s ease;
} }
.category-pill:hover, /* ===== SCROLLBAR ===== */
.category-pill.active { .gallery-page ::-webkit-scrollbar,
background: var(--gallery-accent); .gallery-sidebar ::-webkit-scrollbar {
color: white; width: 8px;
border-color: var(--gallery-accent);
} }
/* ===== Responsive ===== */ .gallery-page ::-webkit-scrollbar-track,
.gallery-sidebar ::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.1);
}
.gallery-page ::-webkit-scrollbar-thumb,
.gallery-sidebar ::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.3);
border-radius: 4px;
}
/* ===== RESPONSIVE ===== */
@media (max-width: 768px) { @media (max-width: 768px) {
.gallery-page { :root {
--gallery-radius: 16px; --gallery-radius: 16px;
--gallery-spacing: 12px;
--glass-blur: 16px; --glass-blur: 16px;
} }
.photo-grid { .gallery-page .relative.-mt-6 > .relative:first-child > .absolute.inset-0.flex {
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); padding: 1.5rem 2rem !important;
max-width: 90% !important;
} }
.photo-card img { .gallery-page .relative.-mt-6 > .relative:first-child h1 {
height: 180px; font-size: 1.5rem !important;
}
.gallery-page .photo-grid {
grid-template-columns: repeat(2, 1fr) !important;
gap: 0.75rem !important;
} }
} }
/* ===== Accessibility: Reduce Motion ===== */ /* ===== ACCESSIBILITY ===== */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.photo-card, .gallery-page .photo-card,
.gallery-btn { .gallery-page button {
transition: none; transition: none !important;
} }
.photo-card:hover { .gallery-page .photo-card:hover {
transform: none; transform: none !important;
} }
} }
/* ===== Accessibility: Reduce Transparency ===== */
@media (prefers-reduced-transparency: reduce) { @media (prefers-reduced-transparency: reduce) {
.glass-surface, .gallery-page .photo-card,
.photo-card, .gallery-page button,
.gallery-btn { .gallery-sidebar {
backdrop-filter: none; backdrop-filter: none !important;
-webkit-backdrop-filter: none; -webkit-backdrop-filter: none !important;
background: rgba(255, 255, 255, 0.95); background: rgba(255, 255, 255, 0.95) !important;
} }
}`; }`;
@@ -642,12 +680,12 @@ const LIQUID_GLASS_DARK = `/*
}`; }`;
exports.up = async function(knex) { exports.up = async function(knex) {
// Update template slot 2 with Liquid Glass Light // Update template slot 2 with Apple Liquid Glass (Light)
await knex('css_templates') await knex('css_templates')
.where({ slot_number: 2 }) .where({ slot_number: 2 })
.update({ .update({
name: 'Liquid Glass Light', name: 'Apple Liquid Glass',
css_content: LIQUID_GLASS_LIGHT, css_content: APPLE_LIQUID_GLASS,
is_enabled: true, is_enabled: true,
is_default: false, is_default: false,
updated_at: knex.fn.now() updated_at: knex.fn.now()
@@ -689,5 +727,5 @@ exports.down = async function(knex) {
}; };
// Export templates for use elsewhere // Export templates for use elsewhere
module.exports.LIQUID_GLASS_LIGHT = LIQUID_GLASS_LIGHT; module.exports.APPLE_LIQUID_GLASS = APPLE_LIQUID_GLASS;
module.exports.LIQUID_GLASS_DARK = LIQUID_GLASS_DARK; module.exports.LIQUID_GLASS_DARK = LIQUID_GLASS_DARK;
+728 -724
View File
File diff suppressed because it is too large Load Diff
Executable → Regular
View File
View File
Executable → Regular
View File
View File
View File
View File
+8 -1
View File
@@ -533,7 +533,7 @@ router.put('/:id', adminAuth, [
body('welcome_message').optional({ nullable: true, checkFalsy: true }).trim(), body('welcome_message').optional({ nullable: true, checkFalsy: true }).trim(),
body('color_theme').optional({ nullable: true }), body('color_theme').optional({ nullable: true }),
body('allow_user_uploads').optional().isBoolean(), body('allow_user_uploads').optional().isBoolean(),
body('customer_name').optional().trim().notEmpty(), body('customer_name').optional({ nullable: true, checkFalsy: true }).trim(),
body('customer_email').optional().isEmail().normalizeEmail(), body('customer_email').optional().isEmail().normalizeEmail(),
body('upload_category_id').optional().custom((value) => { body('upload_category_id').optional().custom((value) => {
// Accept null, undefined, or integer values // Accept null, undefined, or integer values
@@ -554,6 +554,13 @@ router.put('/:id', adminAuth, [
body('source_mode').optional().isIn(['managed', 'reference']), body('source_mode').optional().isIn(['managed', 'reference']),
body('external_path').optional({ nullable: true }).isString().trim(), body('external_path').optional({ nullable: true }).isString().trim(),
body('require_password').optional().isBoolean(), body('require_password').optional().isBoolean(),
// Download protection settings
body('protection_level').optional().isIn(['basic', 'standard', 'enhanced', 'maximum']),
body('enable_devtools_protection').optional().isBoolean(),
body('use_canvas_rendering').optional().isBoolean(),
body('overlay_protection').optional().isBoolean(),
body('image_quality').optional().isInt({ min: 1, max: 100 }),
body('fragmentation_level').optional().isInt({ min: 1, max: 10 }),
body('password').optional().isString().custom((value, { req }) => { body('password').optional().isString().custom((value, { req }) => {
if (value === undefined || value === null || value === '') { if (value === undefined || value === null || value === '') {
return true; return true;
+5 -3
View File
@@ -31,13 +31,15 @@ router.get('/settings', adminAuth, async (req, res) => {
const config = {}; const config = {};
settings.forEach(setting => { settings.forEach(setting => {
config[setting.setting_key] = JSON.parse(setting.setting_value); // PostgreSQL JSON columns are already parsed by the driver
// Just use the value directly - no need to JSON.parse
config[setting.setting_key] = setting.setting_value;
}); });
res.json(config); res.json(config);
} catch (error) { } catch (error) {
logger.error('Error getting image security settings', { error: error.message }); logger.error('Error getting image security settings', { error: error.message, stack: error.stack });
res.status(500).json({ error: 'Failed to get security settings' }); res.status(500).json({ error: 'Failed to get security settings', details: error.message });
} }
}); });
+40 -11
View File
@@ -110,7 +110,9 @@ router.get('/:slug/info', async (req, res) => {
'watermark_downloads', 'watermark_downloads',
'watermark_text', 'watermark_text',
'require_password', 'require_password',
'color_theme' 'color_theme',
'enable_devtools_protection',
'use_canvas_rendering'
) )
.first(); .first();
@@ -154,7 +156,9 @@ router.get('/:slug/info', async (req, res) => {
allow_downloads: !(event.allow_downloads === false || event.allow_downloads === 0 || event.allow_downloads === '0'), allow_downloads: !(event.allow_downloads === false || event.allow_downloads === 0 || event.allow_downloads === '0'),
disable_right_click: event.disable_right_click === true || event.disable_right_click === 1 || event.disable_right_click === '1', disable_right_click: event.disable_right_click === true || event.disable_right_click === 1 || event.disable_right_click === '1',
watermark_downloads: event.watermark_downloads === true || event.watermark_downloads === 1 || event.watermark_downloads === '1', watermark_downloads: event.watermark_downloads === true || event.watermark_downloads === 1 || event.watermark_downloads === '1',
watermark_text: event.watermark_text watermark_text: event.watermark_text,
enable_devtools_protection: event.enable_devtools_protection === true || event.enable_devtools_protection === 1 || event.enable_devtools_protection === '1',
use_canvas_rendering: event.use_canvas_rendering === true || event.use_canvas_rendering === 1 || event.use_canvas_rendering === '1'
}); });
} catch (error) { } catch (error) {
console.error('Error fetching gallery info:', error); console.error('Error fetching gallery info:', error);
@@ -315,6 +319,8 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
disable_right_click: req.event.disable_right_click === true, disable_right_click: req.event.disable_right_click === true,
watermark_downloads: req.event.watermark_downloads === true, watermark_downloads: req.event.watermark_downloads === true,
watermark_text: req.event.watermark_text, watermark_text: req.event.watermark_text,
enable_devtools_protection: req.event.enable_devtools_protection === true,
use_canvas_rendering: req.event.use_canvas_rendering === true,
...protectionSettings ...protectionSettings
}, },
categories: categories, categories: categories,
@@ -397,12 +403,20 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) =>
return res.status(404).json({ error: 'Photo file not found' }); return res.status(404).json({ error: 'Photo file not found' });
} }
// Get watermark settings // Get watermark settings - apply if global setting OR event-level setting is enabled
const watermarkSettings = await watermarkService.getWatermarkSettings(); const watermarkSettings = await watermarkService.getWatermarkSettings();
const eventWatermarkEnabled = req.event.watermark_downloads === true || req.event.watermark_downloads === 1;
const shouldApplyWatermark = (watermarkSettings && watermarkSettings.enabled) || eventWatermarkEnabled;
if (watermarkSettings && watermarkSettings.enabled) { if (shouldApplyWatermark) {
// Apply watermark and send // Apply watermark and send
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings); // Use event watermark text if available, otherwise fall back to global settings
const effectiveSettings = {
...watermarkSettings,
enabled: true,
text: req.event.watermark_text || watermarkSettings?.text || 'Protected'
};
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, effectiveSettings);
res.set({ res.set({
'Content-Type': photo.mime_type || 'image/jpeg', 'Content-Type': photo.mime_type || 'image/jpeg',
@@ -468,8 +482,15 @@ router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => {
archive.pipe(res); archive.pipe(res);
// Get watermark settings // Get watermark settings - apply if global setting OR event-level setting is enabled
const watermarkSettings = await watermarkService.getWatermarkSettings(); const watermarkSettings = await watermarkService.getWatermarkSettings();
const eventWatermarkEnabled = req.event.watermark_downloads === true || req.event.watermark_downloads === 1;
const shouldApplyWatermark = (watermarkSettings && watermarkSettings.enabled) || eventWatermarkEnabled;
const effectiveSettings = shouldApplyWatermark ? {
...watermarkSettings,
enabled: true,
text: req.event.watermark_text || watermarkSettings?.text || 'Protected'
} : null;
// Add photos to archive // Add photos to archive
for (const photo of photos) { for (const photo of photos) {
@@ -497,9 +518,9 @@ router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => {
archiveName = photo.filename; archiveName = photo.filename;
} }
if (watermarkSettings && watermarkSettings.enabled) { if (shouldApplyWatermark && effectiveSettings) {
try { try {
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings); const watermarkedBuffer = await watermarkService.applyWatermark(filePath, effectiveSettings);
archive.append(watermarkedBuffer, { name: archiveName }); archive.append(watermarkedBuffer, { name: archiveName });
} catch (watermarkError) { } catch (watermarkError) {
logger.warn('Failed to watermark photo for bulk download, skipping original to avoid leak', { logger.warn('Failed to watermark photo for bulk download, skipping original to avoid leak', {
@@ -586,15 +607,23 @@ router.post('/:slug/download-selected', verifyGalleryAccess, async (req, res) =>
}); });
archive.pipe(res); archive.pipe(res);
// Check watermark settings similar to download-all // Check watermark settings - apply if global setting OR event-level setting is enabled
const watermarkSettings = await watermarkService.getWatermarkSettings(); const watermarkSettings = await watermarkService.getWatermarkSettings();
const eventWatermarkEnabled = req.event.watermark_downloads === true || req.event.watermark_downloads === 1;
const shouldApplyWatermark = (watermarkSettings && watermarkSettings.enabled) || eventWatermarkEnabled;
const effectiveSettings = shouldApplyWatermark ? {
...watermarkSettings,
enabled: true,
text: req.event.watermark_text || watermarkSettings?.text || 'Protected'
} : null;
for (const photo of photos) { for (const photo of photos) {
try { try {
const filePath = resolvePhotoFilePath(req.event, photo); const filePath = resolvePhotoFilePath(req.event, photo);
const name = photo.filename || `photo-${photo.id}.jpg`; const name = photo.filename || `photo-${photo.id}.jpg`;
if (watermarkSettings && watermarkSettings.enabled) { if (shouldApplyWatermark && effectiveSettings) {
try { try {
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings); const watermarkedBuffer = await watermarkService.applyWatermark(filePath, effectiveSettings);
archive.append(watermarkedBuffer, { name }); archive.append(watermarkedBuffer, { name });
} catch (watermarkError) { } catch (watermarkError) {
logger.warn('Failed to watermark selected photo, skipping original to avoid leak', { logger.warn('Failed to watermark selected photo, skipping original to avoid leak', {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-492
View File
@@ -1,492 +0,0 @@
# Feature: Event Rename
## Overview
This feature allows administrators to rename gallery events from the admin panel. When an event is renamed, all associated resources (database records, file system folders, photo paths, and URLs) are updated to reflect the new name.
## User Story
As an administrator, I want to rename a gallery event so that I can correct typos, update event names after changes, or better organize my galleries while maintaining data integrity.
---
## Feature Requirements
### 1. UI Components
#### 1.1 Rename Button
- **Location**: Event detail page (next to "Edit" button)
- **Label**: "Rename Event" or icon with tooltip
- **Visibility**: Always visible for active (non-archived) events
- **Disabled state**: During rename operation or for archived events
#### 1.2 Rename Dialog/Modal
- **Trigger**: Click on "Rename Event" button
- **Components**:
- Header: "Rename Event"
- Current name display (read-only, for reference)
- Text input field for new event name
- Pre-filled with current name
- Validation: Required, min 3 characters, max 100 characters
- Real-time preview of new slug
- Checkbox: "Resend invitation email with new gallery link"
- Default: Unchecked
- Helper text: "Send updated gallery access email to {customer_email}"
- Cancel button
- Confirm button ("Rename Event")
#### 1.3 Progress/Status Indicator
- **Display**: Replace dialog content during operation
- **States**:
1. "Validating new name..."
2. "Renaming files..."
3. "Updating database..."
4. "Updating photo records..."
5. "Sending email..." (if checkbox selected)
6. "Complete!"
- **Error state**: Show error message with retry option
#### 1.4 Post-Rename Redirect
- After successful rename, redirect to: `/admin/events/{eventId}`
- Show success toast notification: "Event renamed successfully"
- If email sent: "Event renamed and invitation email sent"
---
## Technical Specification
### 2. Database Changes
#### 2.1 Events Table Updates
```sql
UPDATE events SET
event_name = :newEventName,
slug = :newSlug,
share_link = :newShareLink,
updated_at = NOW()
WHERE id = :eventId;
```
#### 2.2 Photos Table Updates
```sql
UPDATE photos SET
path = REPLACE(path, :oldSlug, :newSlug),
thumbnail_path = REPLACE(thumbnail_path, :oldSlug, :newSlug),
updated_at = NOW()
WHERE event_id = :eventId;
```
#### 2.3 New Table: Slug Redirects (Optional - for backward compatibility)
```sql
CREATE TABLE slug_redirects (
id SERIAL PRIMARY KEY,
old_slug VARCHAR(255) NOT NULL UNIQUE,
new_slug VARCHAR(255) NOT NULL,
event_id INTEGER REFERENCES events(id) ON DELETE CASCADE,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_slug_redirects_old_slug ON slug_redirects(old_slug);
```
#### 2.4 Activity Log Entry
```sql
INSERT INTO activity_logs (
event_id, action, details, created_at
) VALUES (
:eventId,
'event_renamed',
'{"old_name": "...", "new_name": "...", "old_slug": "...", "new_slug": "...", "email_sent": true/false}',
NOW()
);
```
---
### 3. File System Changes
#### 3.1 Folder Rename
```
Source: /storage/events/active/{oldSlug}/
Target: /storage/events/active/{newSlug}/
```
#### 3.2 Photo File Rename (within folder)
```
Source: {OldEventName}_individual_0001.jpg
Target: {NewEventName}_individual_0001.jpg
```
#### 3.3 Thumbnail Updates
Thumbnails are stored alongside photos - paths updated via database
#### 3.4 Rollback Strategy
1. Create backup of folder structure before rename
2. If any step fails, restore from backup
3. Use atomic operations where possible
---
### 4. API Endpoints
#### 4.1 Rename Event
```
POST /api/admin/events/:eventId/rename
```
**Request Body:**
```json
{
"newEventName": "New Wedding Gallery Name",
"resendEmail": true
}
```
**Response (Success - 200):**
```json
{
"success": true,
"message": "Event renamed successfully",
"data": {
"eventId": 1,
"oldName": "Test Wedding Gallery",
"newName": "New Wedding Gallery Name",
"oldSlug": "wedding-test-wedding-gallery-2026-01-01",
"newSlug": "wedding-new-wedding-gallery-name-2026-01-01",
"newShareLink": "/gallery/wedding-new-wedding-gallery-name-2026-01-01/abc123...",
"emailSent": true,
"filesRenamed": 3
}
}
```
**Response (Error - 400/500):**
```json
{
"success": false,
"error": "Event name already exists for this date",
"code": "DUPLICATE_SLUG"
}
```
#### 4.2 Validate New Name (Optional - for real-time validation)
```
POST /api/admin/events/:eventId/validate-rename
```
**Request Body:**
```json
{
"newEventName": "New Wedding Gallery Name"
}
```
**Response:**
```json
{
"valid": true,
"newSlug": "wedding-new-wedding-gallery-name-2026-01-01",
"conflicts": []
}
```
---
### 5. Backend Implementation
#### 5.1 New Route File
**Location**: `/backend/src/routes/adminEventRename.js`
#### 5.2 New Service File
**Location**: `/backend/src/services/eventRenameService.js`
**Service Methods:**
```javascript
class EventRenameService {
// Validate new name and check for conflicts
async validateRename(eventId, newEventName)
// Generate new slug from name
generateSlug(eventType, eventName, eventDate)
// Rename event folder on filesystem
async renameEventFolder(oldSlug, newSlug)
// Rename individual photo files
async renamePhotoFiles(eventId, oldName, newName)
// Update database records (events + photos)
async updateDatabaseRecords(eventId, oldSlug, newSlug, newName)
// Store old slug redirect
async createSlugRedirect(eventId, oldSlug, newSlug)
// Main orchestration method
async renameEvent(eventId, newEventName, resendEmail)
// Rollback on failure
async rollbackRename(eventId, backupData)
}
```
#### 5.3 Transaction Handling
```javascript
async renameEvent(eventId, newEventName, resendEmail) {
const trx = await db.transaction();
const backupData = {};
try {
// 1. Validate
const validation = await this.validateRename(eventId, newEventName);
if (!validation.valid) throw new Error(validation.error);
// 2. Get current event data
const event = await trx('events').where({ id: eventId }).first();
backupData.event = event;
// 3. Generate new slug
const newSlug = this.generateSlug(event.event_type, newEventName, event.event_date);
// 4. Rename folder (filesystem)
await this.renameEventFolder(event.slug, newSlug);
backupData.folderRenamed = true;
// 5. Rename photo files
const renamedFiles = await this.renamePhotoFiles(eventId, event.event_name, newEventName);
backupData.renamedFiles = renamedFiles;
// 6. Update database
await this.updateDatabaseRecords(trx, eventId, event.slug, newSlug, newEventName);
// 7. Create redirect entry
await this.createSlugRedirect(trx, eventId, event.slug, newSlug);
// 8. Commit transaction
await trx.commit();
// 9. Send email (after commit, non-critical)
if (resendEmail && event.customer_email) {
await this.sendRenamedEventEmail(eventId);
}
return { success: true, newSlug, ... };
} catch (error) {
await trx.rollback();
await this.rollbackRename(backupData);
throw error;
}
}
```
---
### 6. Frontend Implementation
#### 6.1 New Components
**Location**: `/frontend/src/components/admin/`
- `EventRenameButton.tsx` - Button component
- `EventRenameDialog.tsx` - Modal dialog with form
- `RenameProgressIndicator.tsx` - Status display during operation
#### 6.2 New Service Method
**Location**: `/frontend/src/services/eventService.ts`
```typescript
interface RenameEventRequest {
newEventName: string;
resendEmail: boolean;
}
interface RenameEventResponse {
success: boolean;
message: string;
data: {
eventId: number;
oldName: string;
newName: string;
oldSlug: string;
newSlug: string;
newShareLink: string;
emailSent: boolean;
filesRenamed: number;
};
}
export const renameEvent = async (
eventId: number,
request: RenameEventRequest
): Promise<RenameEventResponse> => {
const response = await api.post(`/admin/events/${eventId}/rename`, request);
return response.data;
};
```
#### 6.3 State Management
- Use React Query mutation for rename operation
- Invalidate event queries on success
- Handle optimistic updates if needed
---
### 7. Email Template Updates
#### 7.1 New/Updated Template
**Template**: Gallery link updated notification
**Subject**: "Your gallery link has been updated - {event_name}"
**Content**:
```
Hello {customer_name},
The gallery for {event_name} has been updated with a new link.
Your new gallery access link:
{new_gallery_url}
Password: (unchanged)
The previous link will automatically redirect to the new location.
Best regards,
{admin_name}
```
---
### 8. Slug Redirect Handling
#### 8.1 Gallery Route Update
**Location**: `/backend/src/routes/gallery.js`
```javascript
// Check for slug redirect before 404
router.get('/:slugOrToken/:token?', async (req, res) => {
let event = await findEventBySlug(req.params.slugOrToken);
if (!event) {
// Check redirect table
const redirect = await db('slug_redirects')
.where({ old_slug: req.params.slugOrToken })
.first();
if (redirect) {
// Redirect to new slug
const newUrl = `/gallery/${redirect.new_slug}/${req.params.token || ''}`;
return res.redirect(301, newUrl);
}
}
// Continue with normal flow...
});
```
---
## Implementation Checklist
### Phase 1: Backend Foundation
- [ ] Create database migration for `slug_redirects` table
- [ ] Create `eventRenameService.js` with core logic
- [ ] Create `adminEventRename.js` route file
- [ ] Add rename endpoint to admin routes
- [ ] Implement file system rename operations
- [ ] Add rollback mechanism
- [ ] Write unit tests for rename service
### Phase 2: Frontend UI
- [ ] Create `EventRenameButton.tsx` component
- [ ] Create `EventRenameDialog.tsx` modal component
- [ ] Create `RenameProgressIndicator.tsx` component
- [ ] Add rename service method to `eventService.ts`
- [ ] Integrate rename button into event detail page
- [ ] Add form validation
- [ ] Handle loading/error states
- [ ] Implement redirect after success
### Phase 3: Email Integration
- [ ] Create/update email template for renamed events
- [ ] Add email sending logic to rename service
- [ ] Test email delivery
### Phase 4: Redirect Support
- [ ] Update gallery route to check slug_redirects
- [ ] Add redirect logging for analytics
- [ ] Test old URLs redirect correctly
### Phase 5: Testing & Polish
- [ ] End-to-end testing with Chrome DevTools
- [ ] Test error scenarios and rollback
- [ ] Test with events containing many photos
- [ ] Performance testing
- [ ] Update API documentation
---
## Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|------|------------|--------|------------|
| File rename fails mid-operation | Medium | High | Transaction + rollback mechanism |
| Duplicate slug collision | Low | Medium | Pre-validation + counter suffix |
| Old bookmarked URLs break | High | Medium | Slug redirect table |
| Email send fails | Low | Low | Non-blocking, log error, notify admin |
| Large events slow to rename | Medium | Low | Progress indicator, async processing |
---
## Security Considerations
1. **Authorization**: Only admin users can rename events
2. **Input validation**: Sanitize new event name, prevent path traversal
3. **Rate limiting**: Prevent abuse of rename endpoint
4. **Audit logging**: Log all rename operations with before/after state
---
## Future Enhancements
1. **Bulk rename**: Rename multiple events at once
2. **Rename history**: View previous names of an event
3. **Undo rename**: Revert to previous name within time window
4. **Scheduled rename**: Set future date for rename to take effect
---
## Related Files
### Backend
- `/backend/src/routes/adminEvents.js` - Existing event routes
- `/backend/src/routes/gallery.js` - Gallery public routes
- `/backend/src/services/photoProcessor.js` - Photo handling
- `/backend/src/utils/filenameSanitizer.js` - Name sanitization
- `/backend/src/utils/shareLinkUtils.js` - Share link generation
### Frontend
- `/frontend/src/components/admin/EventDetails.tsx` - Event detail page
- `/frontend/src/services/eventService.ts` - Event API service
- `/frontend/src/components/admin/EventForm.tsx` - Event form components
### Database
- `/backend/migrations/` - Migration files
- `/backend/src/database/db.js` - Database schema
---
## Acceptance Criteria
1. Admin can click "Rename Event" button on event detail page
2. Rename dialog shows current name and input for new name
3. New slug preview is shown as user types
4. Checkbox option to resend invitation email
5. Progress indicator shows rename steps
6. All files are renamed on filesystem
7. All database records are updated
8. Old gallery URLs redirect to new URLs (301)
9. Admin is redirected to event page after success
10. Success/error notifications are displayed
11. If email checkbox selected, customer receives email with new link
12. Operation can be cancelled before confirmation
13. Archived events cannot be renamed
-564
View File
@@ -1,564 +0,0 @@
# Feature: Optional Event Contact Fields
## Overview
This feature adds a settings option to configure whether client/customer contact information (name, email) and admin email are required fields when creating new events. This addresses the needs of users who don't use the email/SMTP functionality and find these mandatory fields unnecessary.
**GitHub Issue:** [#60](https://github.com/the-luap/picpeak/issues/60)
---
## Problem Statement
Currently, when creating a new event, the following fields are **mandatory**:
- Customer Name
- Customer Email
- Admin Email
For users who:
- Don't use SMTP/email functionality
- Share galleries via manual link distribution
- Self-host for personal use without client management
These required fields create unnecessary friction and force users to enter placeholder data.
---
## User Story
As an administrator, I want to configure whether client contact information is required when creating events, so that I can streamline event creation when I don't need email functionality.
---
## Feature Requirements
### 1. New Settings Options
Add three new settings to the Admin Settings page under a new section "Event Creation":
| Setting | Label | Type | Default | Description |
|---------|-------|------|---------|-------------|
| `event_require_customer_name` | Require customer name | Toggle | `true` | When enabled, customer name is required for new events |
| `event_require_customer_email` | Require customer email | Toggle | `true` | When enabled, customer email is required for new events |
| `event_require_admin_email` | Require admin email | Toggle | `true` | When enabled, admin email is required for new events |
### 2. Settings UI Location
**Path:** Admin Panel → Settings → Event Creation (new section)
```
┌─────────────────────────────────────────────────────────────┐
│ Settings │
├─────────────────────────────────────────────────────────────┤
│ ▼ General │
│ ▼ Gallery Display │
│ ▼ Storage │
│ ▼ Event Creation ← NEW SECTION │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Required Fields │ │
│ │ │ │
│ │ Configure which contact fields are required when │ │
│ │ creating new events. │ │
│ │ │ │
│ │ [✓] Require customer name │ │
│ │ Customer name must be provided for new events │ │
│ │ │ │
│ │ [✓] Require customer email │ │
│ │ Customer email must be provided for new events │ │
│ │ ⚠️ Required for sending gallery invitations │ │
│ │ │ │
│ │ [✓] Require admin email │ │
│ │ Admin email must be provided for new events │ │
│ │ ⚠️ Required for receiving event notifications │ │
│ └─────────────────────────────────────────────────────┘ │
│ ▼ Security │
│ ▼ Email/SMTP │
└─────────────────────────────────────────────────────────────┘
```
### 3. Event Creation Form Updates
The event creation form (`/admin/events/new`) should:
1. **Fetch settings** on load to determine field requirements
2. **Conditionally apply validation** based on settings
3. **Update field labels** to show optional indicator when not required
4. **Remove asterisk (*)** from non-required fields
#### Field Display Logic
| Setting Value | Field Label | Validation | Placeholder |
|---------------|-------------|------------|-------------|
| `true` (required) | "Customer Name *" | Required | "Enter customer name" |
| `false` (optional) | "Customer Name (optional)" | Optional | "Enter customer name (optional)" |
### 4. Warning Messages
When disabling email-related required fields, show informational warnings:
**When disabling "Require customer email":**
> Note: Without a customer email, you won't be able to send gallery invitation emails automatically. You'll need to share gallery links manually.
**When disabling "Require admin email":**
> Note: Without an admin email, you won't receive event notifications such as expiration reminders or guest feedback alerts.
### 5. Backward Compatibility
- Existing events with empty contact fields remain valid
- Default values maintain current behavior (`true` = required)
- Settings changes only affect new event creation
---
## Technical Specification
### 6. Database Changes
#### 6.1 Settings Table Entries
Add new rows to `app_settings` table:
```sql
INSERT INTO app_settings (key, value, category, description) VALUES
('event_require_customer_name', 'true', 'events', 'Require customer name when creating events'),
('event_require_customer_email', 'true', 'events', 'Require customer email when creating events'),
('event_require_admin_email', 'true', 'events', 'Require admin email when creating events');
```
#### 6.2 Migration File
**File:** `/backend/migrations/core/YYYYMMDDHHMMSS_add_optional_event_fields_settings.js`
```javascript
exports.up = function(knex) {
return knex('app_settings').insert([
{
key: 'event_require_customer_name',
value: 'true',
category: 'events',
description: 'Require customer name when creating events'
},
{
key: 'event_require_customer_email',
value: 'true',
category: 'events',
description: 'Require customer email when creating events'
},
{
key: 'event_require_admin_email',
value: 'true',
category: 'events',
description: 'Require admin email when creating events'
}
]);
};
exports.down = function(knex) {
return knex('app_settings')
.whereIn('key', [
'event_require_customer_name',
'event_require_customer_email',
'event_require_admin_email'
])
.del();
};
```
---
### 7. API Changes
#### 7.1 Settings Endpoint (Existing)
The existing settings endpoints should already handle these new settings:
**GET `/api/admin/settings`** - Returns all settings including new ones
**PUT `/api/admin/settings`** - Updates settings including new ones
#### 7.2 Public Settings Endpoint
**GET `/api/public/settings`** - Should include event field requirements for frontend validation
Add to public settings response:
```json
{
"event_require_customer_name": true,
"event_require_customer_email": true,
"event_require_admin_email": true
}
```
#### 7.3 Event Creation Validation Update
**File:** `/backend/src/routes/adminEvents.js`
Update the event creation endpoint to conditionally validate fields:
```javascript
// Before (current)
if (!customer_name) {
return res.status(400).json({ error: 'Customer name is required' });
}
// After (with settings check)
const settings = await getSettings();
if (settings.event_require_customer_name && !customer_name) {
return res.status(400).json({ error: 'Customer name is required' });
}
```
---
### 8. Backend Implementation
#### 8.1 Update Event Validation
**File:** `/backend/src/routes/adminEvents.js`
```javascript
// POST /api/admin/events
router.post('/', adminAuth, async (req, res) => {
try {
const {
event_name,
event_type,
event_date,
customer_name,
customer_email,
admin_email,
// ... other fields
} = req.body;
// Get settings for validation
const settings = await db('app_settings')
.whereIn('key', [
'event_require_customer_name',
'event_require_customer_email',
'event_require_admin_email'
])
.select('key', 'value');
const settingsMap = settings.reduce((acc, s) => {
acc[s.key] = s.value === 'true';
return acc;
}, {});
// Conditional validation
const errors = [];
if (!event_name) {
errors.push('Event name is required');
}
if (settingsMap.event_require_customer_name && !customer_name) {
errors.push('Customer name is required');
}
if (settingsMap.event_require_customer_email && !customer_email) {
errors.push('Customer email is required');
}
if (settingsMap.event_require_admin_email && !admin_email) {
errors.push('Admin email is required');
}
if (errors.length > 0) {
return res.status(400).json({ errors });
}
// Continue with event creation...
} catch (error) {
// Error handling...
}
});
```
#### 8.2 Add Settings to Public Endpoint
**File:** `/backend/src/routes/publicSettings.js`
```javascript
// Add to public settings response
const eventFieldSettings = await db('app_settings')
.whereIn('key', [
'event_require_customer_name',
'event_require_customer_email',
'event_require_admin_email'
])
.select('key', 'value');
// Include in response
response.eventFieldRequirements = eventFieldSettings.reduce((acc, s) => {
acc[s.key] = s.value === 'true';
return acc;
}, {});
```
---
### 9. Frontend Implementation
#### 9.1 Settings Page Update
**File:** `/frontend/src/components/admin/Settings.tsx` (or similar)
Add new section for Event Creation settings:
```tsx
// New section component
const EventCreationSettings: React.FC = () => {
const { settings, updateSetting } = useSettings();
return (
<SettingsSection title="Event Creation" icon={<CalendarIcon />}>
<SettingsGroup title="Required Fields">
<p className="text-sm text-gray-500 mb-4">
Configure which contact fields are required when creating new events.
</p>
<ToggleSetting
label="Require customer name"
description="Customer name must be provided for new events"
checked={settings.event_require_customer_name}
onChange={(value) => updateSetting('event_require_customer_name', value)}
/>
<ToggleSetting
label="Require customer email"
description="Customer email must be provided for new events"
checked={settings.event_require_customer_email}
onChange={(value) => updateSetting('event_require_customer_email', value)}
warning="Required for sending gallery invitations"
/>
<ToggleSetting
label="Require admin email"
description="Admin email must be provided for new events"
checked={settings.event_require_admin_email}
onChange={(value) => updateSetting('event_require_admin_email', value)}
warning="Required for receiving event notifications"
/>
</SettingsGroup>
</SettingsSection>
);
};
```
#### 9.2 Event Creation Form Update
**File:** `/frontend/src/components/admin/EventForm.tsx` (or similar)
```tsx
interface EventFormProps {
// ... existing props
}
const EventForm: React.FC<EventFormProps> = () => {
const { settings } = usePublicSettings();
const isCustomerNameRequired = settings?.event_require_customer_name ?? true;
const isCustomerEmailRequired = settings?.event_require_customer_email ?? true;
const isAdminEmailRequired = settings?.event_require_admin_email ?? true;
// Form validation schema (using Zod or Yup)
const validationSchema = useMemo(() => {
return z.object({
event_name: z.string().min(1, 'Event name is required'),
customer_name: isCustomerNameRequired
? z.string().min(1, 'Customer name is required')
: z.string().optional(),
customer_email: isCustomerEmailRequired
? z.string().email('Valid email required')
: z.string().email().optional().or(z.literal('')),
admin_email: isAdminEmailRequired
? z.string().email('Valid email required')
: z.string().email().optional().or(z.literal('')),
// ... other fields
});
}, [isCustomerNameRequired, isCustomerEmailRequired, isAdminEmailRequired]);
return (
<form>
{/* Customer Name Field */}
<FormField
label={isCustomerNameRequired ? 'Customer Name' : 'Customer Name (optional)'}
required={isCustomerNameRequired}
>
<Input
name="customer_name"
placeholder={isCustomerNameRequired
? 'Enter customer name'
: 'Enter customer name (optional)'
}
/>
</FormField>
{/* Customer Email Field */}
<FormField
label={isCustomerEmailRequired ? 'Customer Email' : 'Customer Email (optional)'}
required={isCustomerEmailRequired}
>
<Input
name="customer_email"
type="email"
placeholder={isCustomerEmailRequired
? 'Enter customer email'
: 'Enter customer email (optional)'
}
/>
</FormField>
{/* Admin Email Field */}
<FormField
label={isAdminEmailRequired ? 'Admin Email' : 'Admin Email (optional)'}
required={isAdminEmailRequired}
>
<Input
name="admin_email"
type="email"
placeholder={isAdminEmailRequired
? 'Enter admin email'
: 'Enter admin email (optional)'
}
/>
</FormField>
{/* ... rest of form */}
</form>
);
};
```
#### 9.3 Update Types
**File:** `/frontend/src/types/settings.ts`
```typescript
interface PublicSettings {
// ... existing settings
event_require_customer_name: boolean;
event_require_customer_email: boolean;
event_require_admin_email: boolean;
}
```
---
### 10. Email Functionality Impact
When contact fields are optional and left empty:
| Field Empty | Impact |
|-------------|--------|
| Customer Email | Cannot send invitation email, "Send Email" button disabled |
| Admin Email | No notifications sent, warning shown in event details |
| Customer Name | Email salutation uses generic greeting |
#### 10.1 UI Adjustments for Empty Fields
**Event Details Page:**
- If no customer email: Hide/disable "Send Invitation Email" button
- If no admin email: Show info message "No admin email configured for notifications"
**Event Creation Success:**
- If customer email provided: "Event created. Send invitation email?"
- If no customer email: "Event created. Share the gallery link manually."
---
## Implementation Checklist
### Phase 1: Backend
- [ ] Create database migration for new settings
- [ ] Run migration to add default settings
- [ ] Update event creation endpoint with conditional validation
- [ ] Add new settings to public settings endpoint
- [ ] Update event update endpoint with same validation logic
- [ ] Write unit tests for conditional validation
### Phase 2: Frontend - Settings
- [ ] Add "Event Creation" section to Settings page
- [ ] Create toggle components for each setting
- [ ] Add warning messages for email-related settings
- [ ] Test settings save/load functionality
### Phase 3: Frontend - Event Form
- [ ] Update EventForm to fetch settings
- [ ] Implement conditional validation schema
- [ ] Update field labels based on requirements
- [ ] Update placeholder text
- [ ] Test form validation with various settings combinations
### Phase 4: UI Polish
- [ ] Update event details page for empty contact fields
- [ ] Disable email buttons when no email provided
- [ ] Add informational messages where appropriate
- [ ] Update any tooltips or help text
### Phase 5: Testing
- [ ] Test with all settings enabled (default behavior)
- [ ] Test with all settings disabled
- [ ] Test with mixed settings
- [ ] Test event editing with changed settings
- [ ] End-to-end testing
---
## Test Scenarios
| # | Settings | Action | Expected Result |
|---|----------|--------|-----------------|
| 1 | All required (default) | Create event without customer name | Validation error |
| 2 | All required | Create event with all fields | Success |
| 3 | Name optional | Create event without customer name | Success |
| 4 | Email optional | Create event without customer email | Success, email button disabled |
| 5 | All optional | Create event with only event name/date | Success |
| 6 | Mixed | Edit settings, create event | Respects new settings |
---
## Acceptance Criteria
1. ✅ Three new toggle settings available in Admin Settings
2. ✅ Settings persist after save and page refresh
3. ✅ Event creation form respects settings for field requirements
4. ✅ Field labels update to show "(optional)" when not required
5. ✅ Backend validates according to settings
6. ✅ Warning messages shown when disabling email-related fields
7. ✅ Email functionality gracefully handles empty contact fields
8. ✅ Default behavior unchanged (all fields required)
9. ✅ Existing events unaffected by settings changes
---
## Security Considerations
1. Settings changes require admin authentication
2. Public settings endpoint only exposes necessary field requirements
3. No sensitive data exposed through settings
---
## Related Files
### Backend
- `/backend/src/routes/adminEvents.js` - Event creation/update
- `/backend/src/routes/adminSettings.js` - Settings management
- `/backend/src/routes/publicSettings.js` - Public settings API
- `/backend/migrations/core/` - Database migrations
### Frontend
- `/frontend/src/components/admin/Settings.tsx` - Settings page
- `/frontend/src/components/admin/EventForm.tsx` - Event creation form
- `/frontend/src/components/admin/EventDetails.tsx` - Event detail page
- `/frontend/src/services/settingsService.ts` - Settings API service
- `/frontend/src/hooks/useSettings.ts` - Settings hook
---
## Future Enhancements
1. **Field-level defaults**: Set default values for optional fields
2. **Conditional fields**: Show/hide fields based on other selections
3. **Custom required fields**: Allow adding custom required fields
4. **Per-event-type settings**: Different requirements for wedding vs corporate
-798
View File
@@ -1,798 +0,0 @@
# Feature: Photo Filtering & Export for Professional Workflows
## Overview
This feature enables administrators to filter photos by guest feedback (rated, liked, favorited) and export filtered selections in formats compatible with professional photo editing tools like Adobe Lightroom, Capture One, Photo Mechanic, and Affinity Photo.
**Priority:** Low
**Status:** Open
---
## Problem Statement
Currently, PicPeak collects valuable guest feedback (ratings, likes, favorites) but administrators cannot easily:
1. Filter photos in the admin panel based on this feedback
2. Export filtered selections for use in professional photo editing workflows
3. Identify "client picks" to prioritize in post-processing
Professional photographers need to:
- Quickly identify which photos guests loved most
- Export these selections to Lightroom/Capture One for final editing
- Create client-approved photo sets without manual searching
---
## User Stories
1. **As an administrator**, I want to filter photos by rating, likes, or favorites, so that I can quickly see which photos guests preferred.
2. **As an administrator**, I want to export a filtered selection of photos, so that I can import them into Lightroom or Capture One for final processing.
3. **As an administrator**, I want multiple export format options, so that I can use whichever format works best with my photo editing software.
---
## Research: Professional Photo Tool Import Formats
### Industry Standards
Based on research into professional photo editing workflows:
| Format | Description | Compatibility |
|--------|-------------|---------------|
| **XMP Sidecar** | XML-based metadata files with ratings/labels | Lightroom, Bridge, Capture One, ACR |
| **CSV/Text List** | Simple filename list for manual selection | All tools (via search/filter) |
| **ZIP Archive** | Direct photo export with folder structure | All tools (file import) |
| **JSON Metadata** | Structured data for programmatic workflows | Scripts, automation |
### Sources Referenced
- [Adobe XMP Metadata Documentation](https://helpx.adobe.com/lightroom-classic/help/metadata-basics-actions.html)
- [Capture One EIP Format](https://support.captureone.com/hc/en-us/articles/360002478617-Enhanced-Image-Package-EIP-overview)
- [IPTC Photo Metadata Standard](https://iptc.org/standards/photo-metadata/iptc-standard/)
- [ExifTool Documentation](https://exiftool.org/)
- [Lightroom Filename Search Method](https://community.adobe.com/t5/lightroom-classic-discussions/how-to-make-a-selection-of-images-in-lightroom-classic-from-a-list-of-filenames/td-p/12002275)
---
## Current PicPeak Feedback System
### Existing Features (from codebase analysis)
| Feedback Type | Database Column | Scale | Guest Toggle |
|---------------|-----------------|-------|--------------|
| **Ratings** | `average_rating`, `feedback_count` | 1-5 stars | Update/Remove |
| **Likes** | `like_count` | Count | Toggle on/off |
| **Favorites** | `favorite_count` | Count | Toggle on/off |
| **Comments** | `photo_feedback` table | Text | No |
### Database Schema (photos table)
```sql
average_rating DECIMAL(3,2) -- 0.00 to 5.00
feedback_count INTEGER -- Total rating count
like_count INTEGER -- Total likes
favorite_count INTEGER -- Total favorites
```
### Existing Export (feedback only)
- Endpoint: `GET /admin/feedback/events/:eventId/feedback/export`
- Formats: JSON, CSV
- Exports feedback records, not filtered photo lists
---
## Feature Requirements
### 1. Admin Photo Filter UI
#### 1.1 Filter Panel Location
**Path:** Admin Panel → Event → Photos Tab
Add filter controls above the photo grid:
```
┌─────────────────────────────────────────────────────────────────────┐
│ Photos [Upload] │
├─────────────────────────────────────────────────────────────────────┤
│ ┌─ Filters ──────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ Feedback: [All ▼] [Rated ▼] [≥ 3 stars ▼] │ │
│ │ │ │
│ │ ☑ Has likes (15) ☑ Has favorites (8) ☐ Has comments (3) │ │
│ │ │ │
│ │ [Clear Filters] Showing: 23 of 150 photos │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ [Select All Filtered] [Export Selection ▼] │
│ │
│ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │ ☑ │ │ ☑ │ │ ☑ │ │ ☐ │ │ ☐ │ │ ☐ │ ... │
│ │ IMG │ │ IMG │ │ IMG │ │ IMG │ │ IMG │ │ IMG │ │
│ │ ⭐⭐⭐│ │ ⭐⭐⭐│ │ ⭐⭐⭐│ │ │ │ │ │ │ │
│ │ ❤ 5 │ │ ❤ 3 │ │ ❤ 8 │ │ │ │ │ │ │ │
│ └─────┘ └─────┘ └─────┘ └─────┘ └─────┘ └─────┘ │
└─────────────────────────────────────────────────────────────────────┘
```
#### 1.2 Filter Options
| Filter | Type | Options |
|--------|------|---------|
| **Rating** | Dropdown | All, Any Rating, ≥1 star, ≥2 stars, ≥3 stars, ≥4 stars, 5 stars only |
| **Likes** | Checkbox | Has likes (show count) |
| **Favorites** | Checkbox | Has favorites (show count) |
| **Comments** | Checkbox | Has comments (show count) |
| **Combine Logic** | Toggle | AND / OR (default: AND) |
#### 1.3 Quick Filter Presets
| Preset | Description | Filter Applied |
|--------|-------------|----------------|
| "Guest Picks" | Photos guests loved | Rating ≥4 OR Likes ≥3 OR Favorites ≥1 |
| "Top Rated" | Highest rated photos | Rating ≥4 stars |
| "Most Popular" | Most liked photos | Sorted by like_count DESC |
| "Client Favorites" | Favorited by guests | Has favorites |
---
### 2. Photo Selection & Batch Actions
#### 2.1 Selection Controls
- Individual photo checkbox selection
- "Select All Filtered" button (selects all photos matching current filter)
- "Select All" button (selects all photos regardless of filter)
- "Deselect All" button
- Selection counter: "23 photos selected"
#### 2.2 Selection Persistence
- Selection persists when changing filters
- Selection cleared on page navigation (with confirmation if >0 selected)
---
### 3. Export Functionality
#### 3.1 Export Button/Menu
```
┌──────────────────────────────┐
│ Export Selection ▼ │
├──────────────────────────────┤
│ 📋 Filename List (TXT) │ ← Simple text list
│ 📊 Filename List (CSV) │ ← Spreadsheet compatible
│ 📦 XMP Sidecar Files (ZIP) │ ← Lightroom/Bridge/C1
│ 🗂️ Original Photos (ZIP) │ ← Direct file export
│ 📄 Metadata (JSON) │ ← Programmatic use
│ ──────────────────────────── │
│ ⚙️ Export Settings... │ ← Configure defaults
└──────────────────────────────┘
```
#### 3.2 Export Formats
##### Format 1: Filename List (TXT)
**Use Case:** Paste into Lightroom's Library Filter to select matching photos
**Output:**
```
IMG_0001.jpg
IMG_0015.jpg
IMG_0023.jpg
IMG_0089.jpg
```
**Lightroom Workflow:**
1. Export TXT from PicPeak
2. In Lightroom: Library → Filter → Text → Filename → Contains
3. Paste comma-separated list: `IMG_0001, IMG_0015, IMG_0023, IMG_0089`
4. Select filtered photos → Add to Collection
##### Format 2: Filename List (CSV)
**Use Case:** Spreadsheet analysis, Photo Mechanic code replacement
**Output:**
```csv
filename,original_filename,rating,likes,favorites,category
Test_Wedding_individual_0001.jpg,IMG_0001.jpg,4.5,12,3,Ceremony
Test_Wedding_individual_0015.jpg,IMG_0015.jpg,5.0,8,5,Reception
Test_Wedding_individual_0023.jpg,IMG_0023.jpg,4.0,15,2,Portraits
```
##### Format 3: XMP Sidecar Files (ZIP)
**Use Case:** Import ratings/labels directly into Lightroom, Bridge, Capture One
**Output Structure:**
```
export_wedding_picks_2026-01-01.zip
├── IMG_0001.xmp
├── IMG_0015.xmp
├── IMG_0023.xmp
└── IMG_0089.xmp
```
**XMP Content Example:**
```xml
<?xml version="1.0" encoding="UTF-8"?>
<x:xmpmeta xmlns:x="adobe:ns:meta/">
<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="4"
xmp:Label="Yellow"
photoshop:Instructions="Guest Rating: 4.5 stars, 12 likes, 3 favorites">
<dc:description>
<rdf:Alt>
<rdf:li xml:lang="x-default">PicPeak Export - Guest Picks</rdf:li>
</rdf:Alt>
</dc:description>
<Iptc4xmpCore:SubjectCode>
<rdf:Bag>
<rdf:li>guest-pick</rdf:li>
<rdf:li>rating-4</rdf:li>
</rdf:Bag>
</Iptc4xmpCore:SubjectCode>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>
```
**XMP Rating Mapping:**
| PicPeak Rating | XMP Rating | XMP Label |
|----------------|------------|-----------|
| 4.5 - 5.0 | 5 | Red |
| 3.5 - 4.4 | 4 | Yellow |
| 2.5 - 3.4 | 3 | Green |
| 1.5 - 2.4 | 2 | Blue |
| 0.5 - 1.4 | 1 | Purple |
| No rating | 0 | None |
**Lightroom Import Workflow:**
1. Export XMP ZIP from PicPeak
2. Extract to folder containing original RAW/JPEG files
3. In Lightroom: Select photos → Metadata → Read Metadata from Files
4. Ratings and labels appear on photos
##### Format 4: Original Photos (ZIP)
**Use Case:** Direct photo export for clients or backup
**Output Structure:**
```
export_wedding_picks_2026-01-01.zip
├── Ceremony/
│ ├── IMG_0001.jpg
│ └── IMG_0002.jpg
├── Reception/
│ ├── IMG_0015.jpg
│ └── IMG_0016.jpg
└── export_manifest.json
```
**Options:**
- Include/exclude categories subfolder organization
- Include/exclude thumbnails
- Include/exclude metadata JSON
- Quality: Original / High (2048px) / Medium (1024px)
##### Format 5: Metadata JSON
**Use Case:** Programmatic workflows, custom integrations
**Output:**
```json
{
"export_info": {
"event_name": "Test Wedding Gallery",
"event_date": "2026-01-01",
"exported_at": "2026-01-02T10:30:00Z",
"filter_applied": {
"min_rating": 4,
"has_likes": true,
"has_favorites": null
},
"total_photos": 23
},
"photos": [
{
"filename": "Test_Wedding_individual_0001.jpg",
"original_filename": "IMG_0001.jpg",
"path": "wedding-test-2026-01-01/individual/Test_Wedding_individual_0001.jpg",
"category": "Ceremony",
"rating": {
"average": 4.5,
"count": 8
},
"likes": 12,
"favorites": 3,
"comments": 2,
"dimensions": {
"width": 5472,
"height": 3648
},
"size_bytes": 4521984,
"uploaded_at": "2026-01-01T14:30:00Z"
}
]
}
```
---
### 4. Export Settings Dialog
```
┌─────────────────────────────────────────────────────────────────┐
│ Export Settings [X] │
├─────────────────────────────────────────────────────────────────┤
│ │
│ XMP Sidecar Options │
│ ─────────────────────────────────────────────────────────────── │
│ Filename matching: ○ PicPeak filename (Test_Wedding_0001.jpg) │
│ ● Original filename (IMG_0001.jpg) │
│ │
│ Include in XMP: ☑ Rating (as XMP Rating 1-5) │
│ ☑ Color label (based on rating) │
│ ☑ Description (feedback summary) │
│ ☑ Keywords (guest-pick, category) │
│ │
│ ─────────────────────────────────────────────────────────────── │
│ Photo Export Options │
│ ─────────────────────────────────────────────────────────────── │
│ Quality: ○ Original ● High (2048px) ○ Medium │
│ Organization: ☑ Group by category │
│ Include: ☑ Metadata JSON ☐ Thumbnails │
│ │
│ ─────────────────────────────────────────────────────────────── │
│ CSV/TXT Options │
│ ─────────────────────────────────────────────────────────────── │
│ Filename format: ○ PicPeak filename ● Original filename │
│ Separator: ○ Newline ● Comma ○ Semicolon │
│ │
│ [Cancel] [Save as Default] [OK] │
└─────────────────────────────────────────────────────────────────┘
```
---
## Technical Specification
### 5. API Endpoints
#### 5.1 Get Filtered Photos
```
GET /api/admin/photos/:eventId/filtered
```
**Query Parameters:**
| Parameter | Type | Description |
|-----------|------|-------------|
| `min_rating` | number | Minimum average rating (0-5) |
| `max_rating` | number | Maximum average rating (0-5) |
| `has_likes` | boolean | Filter photos with likes |
| `min_likes` | number | Minimum like count |
| `has_favorites` | boolean | Filter photos with favorites |
| `min_favorites` | number | Minimum favorite count |
| `has_comments` | boolean | Filter photos with comments |
| `category_id` | number | Filter by category |
| `logic` | string | 'AND' or 'OR' (default: AND) |
| `sort` | string | 'rating', 'likes', 'favorites', 'date' |
| `order` | string | 'asc' or 'desc' |
| `page` | number | Page number |
| `limit` | number | Photos per page |
**Response:**
```json
{
"success": true,
"data": {
"photos": [...],
"pagination": {
"total": 150,
"filtered": 23,
"page": 1,
"limit": 50,
"pages": 1
},
"summary": {
"with_ratings": 45,
"with_likes": 78,
"with_favorites": 23,
"with_comments": 12
}
}
}
```
#### 5.2 Export Photos
```
POST /api/admin/photos/:eventId/export
```
**Request Body:**
```json
{
"photo_ids": [1, 5, 12, 23, 45],
"format": "xmp",
"options": {
"filename_format": "original",
"include_rating": true,
"include_label": true,
"include_description": true,
"include_keywords": true
}
}
```
**Or with filter (export all matching):**
```json
{
"filter": {
"min_rating": 4,
"has_likes": true
},
"format": "xmp",
"options": {...}
}
```
**Response:**
- For small exports: Direct file download
- For large exports: Background job with status polling
```json
{
"success": true,
"job_id": "export_abc123",
"status": "processing",
"progress": 45,
"estimated_time": 30
}
```
#### 5.3 Check Export Status
```
GET /api/admin/exports/:jobId/status
```
#### 5.4 Download Export
```
GET /api/admin/exports/:jobId/download
```
---
### 6. Backend Implementation
#### 6.1 New Files
| File | Purpose |
|------|---------|
| `/backend/src/routes/adminPhotoExport.js` | Export API routes |
| `/backend/src/services/photoExportService.js` | Export logic |
| `/backend/src/services/xmpGenerator.js` | XMP file generation |
| `/backend/src/utils/photoFilterBuilder.js` | Query builder for filters |
#### 6.2 XMP Generator Service
```javascript
// /backend/src/services/xmpGenerator.js
class XmpGenerator {
/**
* Generate XMP sidecar content for a photo
*/
generateXmp(photo, options = {}) {
const rating = this.mapRating(photo.average_rating);
const label = this.mapLabel(photo.average_rating);
return `<?xml version="1.0" encoding="UTF-8"?>
<x:xmpmeta xmlns:x="adobe:ns:meta/">
<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:dc="http://purl.org/dc/elements/1.1/"
xmlns:photoshop="http://ns.adobe.com/photoshop/1.0/"
xmp:Rating="${rating}"
${label ? `xmp:Label="${label}"` : ''}>
${this.generateDescription(photo, options)}
${this.generateKeywords(photo, options)}
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>`;
}
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;
}
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
}
generateDescription(photo, options) {
if (!options.include_description) return '';
const desc = `PicPeak Guest Feedback: ${photo.average_rating?.toFixed(1) || 0} stars, ${photo.like_count || 0} likes, ${photo.favorite_count || 0} favorites`;
return `<dc:description><rdf:Alt><rdf:li xml:lang="x-default">${desc}</rdf:li></rdf:Alt></dc:description>`;
}
generateKeywords(photo, options) {
if (!options.include_keywords) return '';
const keywords = ['picpeak-export'];
if (photo.average_rating >= 4) keywords.push('guest-pick');
if (photo.category_name) keywords.push(photo.category_name.toLowerCase());
return `<dc:subject><rdf:Bag>${keywords.map(k => `<rdf:li>${k}</rdf:li>`).join('')}</rdf:Bag></dc:subject>`;
}
}
```
#### 6.3 Export Service
```javascript
// /backend/src/services/photoExportService.js
class PhotoExportService {
async exportPhotos(eventId, photoIds, format, options) {
const photos = await this.getPhotosWithFeedback(eventId, photoIds);
switch (format) {
case 'txt':
return this.exportAsTxt(photos, options);
case 'csv':
return this.exportAsCsv(photos, options);
case 'xmp':
return this.exportAsXmpZip(photos, options);
case 'photos':
return this.exportPhotosZip(photos, options);
case 'json':
return this.exportAsJson(photos, options);
default:
throw new Error(`Unknown format: ${format}`);
}
}
async exportAsXmpZip(photos, options) {
const archive = archiver('zip', { zlib: { level: 9 } });
const xmpGenerator = new XmpGenerator();
for (const photo of photos) {
const filename = options.filename_format === 'original'
? photo.original_filename
: photo.filename;
const xmpFilename = filename.replace(/\.[^.]+$/, '.xmp');
const xmpContent = xmpGenerator.generateXmp(photo, options);
archive.append(xmpContent, { name: xmpFilename });
}
return archive;
}
// ... other export methods
}
```
---
### 7. Frontend Implementation
#### 7.1 New Components
| Component | Purpose |
|-----------|---------|
| `PhotoFilterPanel.tsx` | Filter controls UI |
| `PhotoExportMenu.tsx` | Export dropdown menu |
| `ExportSettingsDialog.tsx` | Export configuration modal |
| `ExportProgressModal.tsx` | Progress indicator for large exports |
#### 7.2 Filter Panel Component
```typescript
// /frontend/src/components/admin/PhotoFilterPanel.tsx
interface PhotoFilters {
minRating: number | null;
hasLikes: boolean;
hasFavorites: boolean;
hasComments: boolean;
logic: 'AND' | 'OR';
}
interface PhotoFilterPanelProps {
filters: PhotoFilters;
onChange: (filters: PhotoFilters) => void;
summary: {
total: number;
filtered: number;
withRatings: number;
withLikes: number;
withFavorites: number;
};
}
export const PhotoFilterPanel: React.FC<PhotoFilterPanelProps> = ({
filters,
onChange,
summary
}) => {
// Component implementation
};
```
#### 7.3 State Management
```typescript
// Use React Query for filtered photos
const useFilteredPhotos = (eventId: string, filters: PhotoFilters) => {
return useQuery({
queryKey: ['photos', eventId, 'filtered', filters],
queryFn: () => photoService.getFilteredPhotos(eventId, filters),
keepPreviousData: true
});
};
// Selection state
const [selectedPhotoIds, setSelectedPhotoIds] = useState<Set<number>>(new Set());
```
---
### 8. Database Considerations
#### 8.1 Indexing for Filter Performance
```sql
-- Add indexes for common filter queries
CREATE INDEX idx_photos_average_rating ON photos(event_id, average_rating) WHERE average_rating > 0;
CREATE INDEX idx_photos_like_count ON photos(event_id, like_count) WHERE like_count > 0;
CREATE INDEX idx_photos_favorite_count ON photos(event_id, favorite_count) WHERE favorite_count > 0;
```
#### 8.2 Export Jobs Table (for large exports)
```sql
CREATE TABLE export_jobs (
id SERIAL PRIMARY KEY,
job_id VARCHAR(50) UNIQUE NOT NULL,
event_id INTEGER REFERENCES events(id),
admin_user_id INTEGER REFERENCES admin_users(id),
format VARCHAR(20) NOT NULL,
status VARCHAR(20) DEFAULT 'pending',
progress INTEGER DEFAULT 0,
total_photos INTEGER,
options JSONB,
file_path VARCHAR(500),
file_size BIGINT,
error_message TEXT,
created_at TIMESTAMP DEFAULT NOW(),
completed_at TIMESTAMP
);
```
---
## Implementation Checklist
### Phase 1: Backend - Filtering
- [ ] Create `photoFilterBuilder.js` utility
- [ ] Add filtered photos endpoint to admin routes
- [ ] Add database indexes for performance
- [ ] Write unit tests for filter logic
### Phase 2: Backend - Export
- [ ] Create `xmpGenerator.js` service
- [ ] Create `photoExportService.js` service
- [ ] Add export endpoint to admin routes
- [ ] Implement TXT export
- [ ] Implement CSV export
- [ ] Implement XMP ZIP export
- [ ] Implement Photos ZIP export
- [ ] Implement JSON export
- [ ] Add background job support for large exports
- [ ] Write unit tests for export formats
### Phase 3: Frontend - Filter UI
- [ ] Create `PhotoFilterPanel.tsx` component
- [ ] Integrate filter panel into Photos tab
- [ ] Add filter state management
- [ ] Implement filter summary display
- [ ] Add preset filter buttons
### Phase 4: Frontend - Selection
- [ ] Add photo selection checkboxes
- [ ] Implement "Select All Filtered" functionality
- [ ] Add selection counter
- [ ] Persist selection across filter changes
### Phase 5: Frontend - Export
- [ ] Create `PhotoExportMenu.tsx` component
- [ ] Create `ExportSettingsDialog.tsx` component
- [ ] Create `ExportProgressModal.tsx` component
- [ ] Integrate export functionality
- [ ] Handle download responses
### Phase 6: Testing & Documentation
- [ ] End-to-end testing with real Lightroom import
- [ ] Test Capture One XMP compatibility
- [ ] Test with large photo sets (500+ photos)
- [ ] Document workflow guides for users
- [ ] Update API documentation
---
## Test Scenarios
| # | Scenario | Expected Result |
|---|----------|-----------------|
| 1 | Filter by rating ≥4 stars | Only high-rated photos shown |
| 2 | Filter by likes AND favorites | Photos with both shown |
| 3 | Filter by likes OR favorites | Photos with either shown |
| 4 | Export XMP for 10 photos | ZIP with 10 .xmp files |
| 5 | Import XMP into Lightroom | Ratings appear on matched photos |
| 6 | Export TXT list | Newline-separated filename list |
| 7 | Export large set (500 photos) | Background job with progress |
---
## Acceptance Criteria
1. ✅ Admin can filter photos by rating threshold
2. ✅ Admin can filter photos by likes/favorites/comments
3. ✅ Admin can combine filters with AND/OR logic
4. ✅ Filter counts shown in UI
5. ✅ Admin can select individual or all filtered photos
6. ✅ Export menu shows all format options
7. ✅ TXT export contains filename list
8. ✅ CSV export contains metadata columns
9. ✅ XMP export creates valid sidecar files
10. ✅ XMP files import correctly into Lightroom
11. ✅ ZIP export includes organized photos
12. ✅ Large exports show progress indicator
13. ✅ Export settings can be customized
---
## Security Considerations
1. Admin authentication required for all endpoints
2. Event ownership validation before export
3. Rate limiting on export endpoints
4. Temporary file cleanup after download
5. No sensitive data in exported files
---
## Related Files
### Backend
- `/backend/src/routes/adminPhotos.js` - Existing photo routes
- `/backend/src/routes/adminFeedback.js` - Existing feedback export
- `/backend/src/services/feedbackService.js` - Feedback queries
### Frontend
- `/frontend/src/components/admin/EventPhotos.tsx` - Photos tab
- `/frontend/src/components/admin/PhotoGrid.tsx` - Photo display
- `/frontend/src/services/photoService.ts` - Photo API service
---
## Future Enhancements
1. **Scheduled exports**: Auto-export daily/weekly
2. **Email delivery**: Send export to admin email
3. **Cloud storage**: Export directly to Dropbox/Google Drive
4. **Custom XMP templates**: User-defined XMP structures
5. **Batch operations**: Apply ratings/labels in bulk from PicPeak
6. **Two-way sync**: Import ratings back from Lightroom
-338
View File
@@ -1,338 +0,0 @@
# Test Specification: Custom CSS Gallery Templates
This document specifies the test cases for the Custom CSS Gallery Templates feature, which allows administrators to create and manage up to 3 custom CSS templates for gallery styling.
## Prerequisites
- Local Docker environment running (`docker-compose up`)
- Access to admin dashboard
- Backend migrations applied (052_add_css_templates.js)
## Test Cases
### 1. Template Editor Access
#### TC-CCT-001: Access CSS Templates Tab
**Steps:**
1. Navigate to Settings page
2. Look for "Custom CSS Templates" or "Styling" section
**Expected Result:**
- CSS Templates editor is accessible
- Three template slots are visible as tabs
#### TC-CCT-002: Default Template Content
**Steps:**
1. Navigate to CSS Templates editor
2. Select Template 1 tab
**Expected Result:**
- Template 1 named "Elegant Dark"
- Contains pre-populated CSS content
- Is marked as enabled
- Is marked as default
### 2. Template Editing
#### TC-CCT-003: Edit Template Name
**Steps:**
1. Select Template 2
2. Change name from "Untitled" to "My Custom Theme"
3. Save template
**Expected Result:**
- Name updates in tab
- Save confirmation shown
- Name persists after refresh
#### TC-CCT-004: Edit CSS Content
**Steps:**
1. Select Template 2
2. Add CSS: `.gallery-page { background: #ff0000; }`
3. Save template
**Expected Result:**
- CSS saved successfully
- No sanitization warnings for valid CSS
- Character count updates
#### TC-CCT-005: Enable/Disable Template
**Steps:**
1. Select Template 2
2. Toggle "Enable this template" checkbox
3. Save template
**Expected Result:**
- Template status changes
- Tab shows check mark when enabled
- Disabled templates not available in event form
### 3. CSS Sanitization
#### TC-CCT-006: Block JavaScript Expressions
**Steps:**
1. Enter CSS with `expression(alert('xss'))`
2. Save template
**Expected Result:**
- Pattern blocked (replaced with /* BLOCKED */)
- Sanitization warning shown
- Template saves with sanitized content
#### TC-CCT-007: Block @import Rules
**Steps:**
1. Enter CSS with `@import url('http://evil.com/styles.css');`
2. Save template
**Expected Result:**
- @import blocked
- Warning shown
- External resource not loaded
#### TC-CCT-008: Block External URLs
**Steps:**
1. Enter CSS with `background-image: url('http://external.com/image.jpg');`
2. Save template
**Expected Result:**
- External URL blocked
- Only data: URIs allowed for images
- Warning shown
#### TC-CCT-009: Allow Safe CSS Properties
**Steps:**
1. Enter CSS with standard properties:
```css
.gallery-page {
background-color: #333;
color: white;
font-family: Arial, sans-serif;
padding: 20px;
}
```
2. Save template
**Expected Result:**
- All properties saved as-is
- No sanitization warnings
- CSS valid
### 4. Template Size Limits
#### TC-CCT-010: CSS Size Limit
**Steps:**
1. Try to save CSS content > 100KB
2. Attempt to save
**Expected Result:**
- Error message about size limit
- Template not saved
- User informed of 100KB limit
### 5. Reset to Default
#### TC-CCT-011: Reset Template 1
**Steps:**
1. Modify Template 1 CSS
2. Save changes
3. Click "Reset to Default"
4. Confirm action
**Expected Result:**
- Template reverts to default "Elegant Dark" content
- Name reset to "Elegant Dark"
- Enable status reset to true
#### TC-CCT-012: Reset Button Only on Template 1
**Steps:**
1. Select Template 2
2. Look for Reset button
**Expected Result:**
- Reset to Default button NOT shown for Template 2 or 3
- Only Template 1 has reset option
### 6. Event Integration
#### TC-CCT-013: Template Dropdown in Event Form
**Steps:**
1. Enable at least one CSS template
2. Navigate to Create Event page
3. Look for CSS Template selector
**Expected Result:**
- Dropdown shows "None (Use default theme)" option
- Enabled templates appear in list
- Disabled templates NOT shown
#### TC-CCT-014: Assign Template to Event
**Steps:**
1. Create new event
2. Select an enabled CSS template
3. Save event
**Expected Result:**
- Event created with template assigned
- Template ID stored in database
- Event edit shows selected template
#### TC-CCT-015: Update Event Template
**Steps:**
1. Edit existing event
2. Change CSS template selection
3. Save event
**Expected Result:**
- Template updated successfully
- Gallery reflects new template
### 7. Gallery CSS Loading
#### TC-CCT-016: Gallery Loads Custom CSS
**Steps:**
1. Assign template to an event
2. View gallery as guest
3. Inspect page source/styles
**Expected Result:**
- Custom CSS injected via `<style id="gallery-custom-css">`
- Gallery styling matches template
- CSS scoped to .gallery-page
#### TC-CCT-017: Gallery Without Template
**Steps:**
1. Create event without template (select "None")
2. View gallery
**Expected Result:**
- No custom CSS loaded
- Default theme used
- No errors
#### TC-CCT-018: Disabled Template Not Applied
**Steps:**
1. Assign template to event
2. Disable the template in settings
3. View gallery
**Expected Result:**
- Custom CSS NOT loaded
- Gallery uses default styling
- No errors
### 8. API Tests
#### TC-CCT-019: Get All Templates
**Steps:**
1. Call API: `GET /api/admin/css-templates`
**Expected Result:**
- Returns array of 3 templates
- Each has: id, slot_number, name, css_content, is_enabled, is_default, updated_at
#### TC-CCT-020: Get Enabled Templates
**Steps:**
1. Call API: `GET /api/admin/css-templates/enabled`
**Expected Result:**
- Returns only enabled templates
- Each has: id, name, slot_number
#### TC-CCT-021: Update Template
**Steps:**
1. Call API: `PUT /api/admin/css-templates/2`
Body: `{ "name": "Test", "css_content": "...", "is_enabled": true }`
**Expected Result:**
- Returns 200 OK
- Template updated
- Sanitization warnings array included
#### TC-CCT-022: Gallery CSS Endpoint
**Steps:**
1. Assign template to event with slug "test-gallery"
2. Call API: `GET /api/gallery/test-gallery/css-template`
**Expected Result:**
- Returns 200 OK with Content-Type: text/css
- Body contains sanitized CSS content
#### TC-CCT-023: Gallery CSS Not Found
**Steps:**
1. Create event without template
2. Call API: `GET /api/gallery/no-template-event/css-template`
**Expected Result:**
- Returns 204 No Content
- No body
### 9. Edge Cases
#### TC-CCT-024: Empty CSS Content
**Steps:**
1. Save template with empty CSS content
2. Assign to event
3. View gallery
**Expected Result:**
- Template saves successfully
- Gallery loads without custom CSS
- No errors
#### TC-CCT-025: Invalid CSS Syntax
**Steps:**
1. Enter CSS with mismatched braces: `{ color: red;`
2. Try to save
**Expected Result:**
- Validation error shown
- Template not saved
- Error message indicates syntax issue
### 10. Persistence Tests
#### TC-CCT-026: Template Persists After Restart
**Steps:**
1. Create and save custom template
2. Restart backend container
3. Reload template editor
**Expected Result:**
- Template content preserved
- All settings intact
- No data loss
## Files Created/Modified
### Backend
- `/backend/migrations/core/052_add_css_templates.js`
- `/backend/src/utils/cssSanitizer.js`
- `/backend/src/routes/adminCssTemplates.js`
- `/backend/src/routes/gallery.js`
- `/backend/server.js`
### Frontend
- `/frontend/src/services/cssTemplates.service.ts`
- `/frontend/src/components/admin/CssTemplateEditor.tsx`
- `/frontend/src/components/admin/index.ts`
- `/frontend/src/hooks/useGalleryCustomCss.ts`
## Integration Notes
The following additional integrations are recommended:
1. Add CssTemplateEditor to Settings page styling tab
2. Add CSS template dropdown to CreateEventPageEnhanced.tsx
3. Add CSS template dropdown to CreateEventPage.tsx
4. Update event edit forms to show/edit template selection
5. Update GalleryPage.tsx to use useGalleryCustomCss hook
6. Add `.gallery-page` class to gallery container components
## Automated Testing Notes
For Playwright tests:
1. Login to admin dashboard
2. Navigate to CSS Templates editor
3. Manipulate template tabs, inputs, and checkboxes
4. Verify save operations via API calls
5. Navigate to Create Event, verify template dropdown
6. View gallery, verify custom CSS is applied
-342
View File
@@ -1,342 +0,0 @@
# Test Specification: Event Rename Feature
## Overview
This document specifies the test cases for the Event Rename feature, which allows administrators to rename events from the admin panel.
---
## Test Environment
- **URL**: http://localhost:7100
- **Admin Credentials**: admin / AdminTest@2026!
- **Prerequisites**: At least one active (non-archived) event with photos
---
## Test Cases
### TC-RN-001: Access Rename Dialog
**Description**: Verify the Rename button is visible and opens the rename dialog
**Steps**:
1. Login to admin panel
2. Navigate to an active event's detail page
3. Verify "Rename" button is visible in the header action buttons
4. Click the "Rename" button
5. Verify rename dialog opens
**Expected Results**:
- Rename button visible next to Edit button
- Dialog opens with current event name pre-filled
- Dialog shows input field for new name
---
### TC-RN-002: Validate New Name - Too Short
**Description**: Verify validation error for names shorter than 3 characters
**Steps**:
1. Open rename dialog for an event
2. Enter a name with 2 characters (e.g., "AB")
3. Observe validation state
**Expected Results**:
- Rename button remains disabled
- No API call made for validation
---
### TC-RN-003: Validate New Name - Same as Current
**Description**: Verify error when new name generates same slug as current
**Steps**:
1. Open rename dialog for an event
2. Enter the same name as current (or minor variation that results in same slug)
3. Wait for validation
**Expected Results**:
- Error message: "New name generates the same URL as the current name"
- Rename button disabled
---
### TC-RN-004: Validate New Name - Duplicate Slug
**Description**: Verify error when new name would conflict with existing event
**Steps**:
1. Create two events with different names on the same date
2. Open rename dialog for event A
3. Enter event B's name
4. Wait for validation
**Expected Results**:
- Error message: "An event with this name already exists for the same date"
- Rename button disabled
---
### TC-RN-005: Validate New Name - Valid
**Description**: Verify successful validation of a valid new name
**Steps**:
1. Open rename dialog for an event
2. Enter a valid, unique new name (at least 3 characters)
3. Wait for validation
**Expected Results**:
- New URL preview shown in green box
- Rename button becomes enabled
- No error messages
---
### TC-RN-006: Successful Rename
**Description**: Verify complete rename operation
**Steps**:
1. Open rename dialog for an event with photos
2. Enter a valid new name
3. Wait for validation to complete
4. Click "Rename Event" button
5. Wait for operation to complete
**Expected Results**:
- Progress indicator shown during operation
- Success message displayed
- New share link shown
- Files renamed count displayed
- Event name updated in UI
- Share link updated
---
### TC-RN-007: Rename with Email Notification
**Description**: Verify rename with email notification option
**Steps**:
1. Open rename dialog for event with customer email
2. Enter valid new name
3. Check "Resend invitation email with new gallery link" checkbox
4. Click "Rename Event"
5. Wait for completion
**Expected Results**:
- Operation completes successfully
- Email sent confirmation shown
- (If SMTP configured) Email received with new link
---
### TC-RN-008: Old URL Redirects to New URL
**Description**: Verify old gallery URLs redirect to new location
**Steps**:
1. Note the current gallery share link before rename
2. Rename the event
3. Try to access the old share link
4. Verify redirect to new URL
**Expected Results**:
- Old URL returns 301 redirect response
- Browser redirects to new gallery URL
- Gallery is accessible at new URL
---
### TC-RN-009: Archived Events Cannot Be Renamed
**Description**: Verify rename button is not available for archived events
**Steps**:
1. Navigate to an archived event's detail page
2. Check for Rename button presence
**Expected Results**:
- Rename button is NOT visible
- Only archive-related actions available
---
### TC-RN-010: Rename Dialog Cancel
**Description**: Verify cancel functionality in rename dialog
**Steps**:
1. Open rename dialog
2. Enter a new name
3. Click Cancel button
4. Verify dialog closes
5. Verify event name unchanged
**Expected Results**:
- Dialog closes
- No changes made to event
- Event name remains original
---
### TC-RN-011: Rename Dialog Close (X Button)
**Description**: Verify X button closes dialog without changes
**Steps**:
1. Open rename dialog
2. Enter a new name
3. Click X button in top-right
4. Verify dialog closes
5. Verify event name unchanged
**Expected Results**:
- Dialog closes
- No changes made to event
---
### TC-RN-012: Photo File Paths Updated
**Description**: Verify photo file paths are updated after rename
**Steps**:
1. Create event with photos
2. Note photo paths in database
3. Rename the event
4. Verify photos are still accessible
5. Check photo paths in database
**Expected Results**:
- All photos remain accessible
- Photo paths updated to reflect new slug
- Thumbnails still work
---
### TC-RN-013: Activity Log Entry Created
**Description**: Verify rename operation is logged
**Steps**:
1. Rename an event
2. Check activity logs in database or admin panel
**Expected Results**:
- Activity log entry created with type "event_renamed"
- Metadata includes old name, new name, old slug, new slug
- Actor information recorded
---
## API Test Cases
### TC-RN-API-001: POST /api/admin/events/:id/rename
**Description**: Test rename API endpoint
**Request**:
```json
{
"newEventName": "New Event Name",
"resendEmail": false
}
```
**Expected Response (200)**:
```json
{
"success": true,
"message": "Event renamed successfully",
"data": {
"eventId": 1,
"oldName": "Old Event Name",
"newName": "New Event Name",
"oldSlug": "wedding-old-event-name-2026-01-01",
"newSlug": "wedding-new-event-name-2026-01-01",
"newShareLink": "/gallery/wedding-new-event-name-2026-01-01/abc123...",
"emailSent": false,
"filesRenamed": 3
}
}
```
---
### TC-RN-API-002: POST /api/admin/events/:id/validate-rename
**Description**: Test rename validation endpoint
**Request**:
```json
{
"newEventName": "New Event Name"
}
```
**Expected Response (200)**:
```json
{
"valid": true,
"newSlug": "wedding-new-event-name-2026-01-01"
}
```
---
### TC-RN-API-003: Rename Requires Authentication
**Description**: Verify API requires admin authentication
**Steps**:
1. Call rename endpoint without auth token
2. Call rename endpoint with invalid token
**Expected Results**:
- 401 Unauthorized response
---
## Database Verification
### TC-RN-DB-001: Events Table Updated
After successful rename, verify:
- `event_name` updated to new name
- `slug` updated to new slug
- `share_link` updated with new slug
### TC-RN-DB-002: Photos Table Updated
After successful rename, verify:
- `path` column updated for all event photos
- `thumbnail_path` column updated for all event photos
### TC-RN-DB-003: Slug Redirects Table Populated
After successful rename, verify:
- Entry created in `slug_redirects` table
- `old_slug` contains previous slug
- `new_slug` contains new slug
- `event_id` references correct event
---
## Error Handling
### TC-RN-ERR-001: Database Error During Rename
**Description**: Verify rollback on database error
**Expected Behavior**:
- File system changes rolled back
- Original event state preserved
- Error message returned to user
### TC-RN-ERR-002: File System Error During Rename
**Description**: Verify handling of file system errors
**Expected Behavior**:
- Transaction rolled back
- Error message returned to user
- Event remains unchanged
---
## Performance
### TC-RN-PERF-001: Rename Large Event
**Description**: Verify performance with events containing many photos
**Steps**:
1. Create event with 100+ photos
2. Rename the event
3. Measure time taken
**Expected Results**:
- Operation completes within reasonable time
- Progress indicator keeps user informed
- All photos remain accessible
-245
View File
@@ -1,245 +0,0 @@
# Test Specification: Optional Event Fields
This document specifies the test cases for the Optional Event Fields feature, which allows administrators to configure whether customer name, customer email, and admin email are required when creating events.
## Prerequisites
- Local Docker environment running (`docker-compose up`)
- Access to admin dashboard
- Backend migrations applied
## Test Cases
### 1. Settings Page - Event Creation Tab
#### TC-OEF-001: Event Creation Tab Visibility
**Steps:**
1. Navigate to Settings page (`/admin/settings`)
2. Verify "Event Creation" tab is visible
**Expected Result:**
- Tab labeled "Event Creation" or similar should be present in the settings navigation
#### TC-OEF-002: Default Field Requirements
**Steps:**
1. Navigate to Settings > Event Creation tab
2. Check initial state of all three toggles
**Expected Result:**
- "Require Customer Name" toggle is ON (enabled)
- "Require Customer Email" toggle is ON (enabled)
- "Require Admin Email" toggle is ON (enabled)
#### TC-OEF-003: Toggle Customer Name Requirement
**Steps:**
1. Navigate to Settings > Event Creation
2. Toggle OFF "Require Customer Name"
3. Click Save
**Expected Result:**
- Setting saves successfully
- Toast notification confirms save
- Toggle remains OFF after page refresh
#### TC-OEF-004: Toggle Customer Email Requirement
**Steps:**
1. Navigate to Settings > Event Creation
2. Toggle OFF "Require Customer Email"
3. Click Save
**Expected Result:**
- Setting saves successfully
- Warning message about email functionality is shown
- Toggle remains OFF after page refresh
#### TC-OEF-005: Toggle Admin Email Requirement
**Steps:**
1. Navigate to Settings > Event Creation
2. Toggle OFF "Require Admin Email"
3. Click Save
**Expected Result:**
- Setting saves successfully
- Warning message about notifications is shown
- Toggle remains OFF after page refresh
### 2. Create Event Form - Conditional Validation
#### TC-OEF-006: All Fields Required (Default)
**Steps:**
1. Ensure all three settings are ON in Settings > Event Creation
2. Navigate to Create Event page
3. Try to submit form without filling customer name, customer email, or admin email
**Expected Result:**
- Validation errors shown for all three empty fields
- Form does not submit
#### TC-OEF-007: Customer Name Optional
**Steps:**
1. Set "Require Customer Name" to OFF in Settings
2. Navigate to Create Event page
3. Verify Host Name field label shows "(optional)"
4. Submit form without customer name (but with required fields filled)
**Expected Result:**
- Host Name label shows "(optional)" suffix
- Form submits successfully without customer name
- Event is created
#### TC-OEF-008: Customer Email Optional
**Steps:**
1. Set "Require Customer Email" to OFF in Settings
2. Navigate to Create Event page
3. Verify Host Email field label shows "(optional)"
4. Submit form without customer email (but with required fields filled)
**Expected Result:**
- Host Email label shows "(optional)" suffix
- Form submits successfully without customer email
- Event is created
#### TC-OEF-009: Admin Email Optional
**Steps:**
1. Set "Require Admin Email" to OFF in Settings
2. Navigate to Create Event page
3. Verify Admin Email field label shows "(optional)"
4. Submit form without admin email (but with required fields filled)
**Expected Result:**
- Admin Email label shows "(optional)" suffix
- Form submits successfully without admin email
- Event is created
#### TC-OEF-010: All Fields Optional
**Steps:**
1. Set all three settings to OFF in Settings
2. Navigate to Create Event page
3. Submit form with only event name, date, and password
**Expected Result:**
- All three optional fields show "(optional)" suffix
- Form submits successfully
- Event is created with null/empty contact fields
### 3. Backend Validation
#### TC-OEF-011: Backend Respects Settings
**Steps:**
1. Set "Require Customer Email" to OFF
2. Make direct API call to create event without customer_email:
```
POST /api/admin/events
{ event_name: "Test", event_date: "2025-01-15", ... }
```
**Expected Result:**
- API accepts the request
- Returns 201 Created
- Event is created without customer_email
#### TC-OEF-012: Backend Rejects When Required
**Steps:**
1. Set "Require Customer Email" to ON
2. Make direct API call to create event without customer_email
**Expected Result:**
- API rejects the request
- Returns 400 Bad Request with validation error
- Error message indicates customer_email is required
### 4. Format Validation for Optional Fields
#### TC-OEF-013: Invalid Email Format Still Rejected
**Steps:**
1. Set "Require Customer Email" to OFF
2. Navigate to Create Event page
3. Enter invalid email format (e.g., "notanemail")
4. Submit form
**Expected Result:**
- Validation error for invalid email format
- Form does not submit
- Error message: "Invalid email format"
### 5. Create Event Enhanced Page
#### TC-OEF-014: Enhanced Page Respects Settings
**Steps:**
1. Set all three settings to OFF
2. Navigate to enhanced Create Event page (`/admin/events/create`)
3. Verify all three fields show "(optional)"
4. Submit form without contact fields
**Expected Result:**
- All optional labels visible
- Form submits successfully
- Event is created
### 6. Settings Persistence
#### TC-OEF-015: Settings Persist Across Sessions
**Steps:**
1. Set specific combination (e.g., customer name OFF, emails ON)
2. Log out
3. Log back in
4. Navigate to Settings > Event Creation
**Expected Result:**
- Settings remain as configured
- Toggle states match what was saved
#### TC-OEF-016: Settings Persist Across Backend Restart
**Steps:**
1. Set specific combination of settings
2. Restart backend container
3. Create new event
**Expected Result:**
- Validation behavior matches saved settings
- Database persisted settings correctly
## Edge Cases
### TC-OEF-017: Empty String vs Null
**Steps:**
1. Set field to optional
2. Create event with empty string for that field
3. View event details
**Expected Result:**
- Field stores empty value appropriately
- No errors in display
### TC-OEF-018: Rapid Toggle Changes
**Steps:**
1. Quickly toggle settings on/off multiple times
2. Save after each change
**Expected Result:**
- Each save completes without error
- Final state matches last save action
## Automated Testing Notes
For Playwright tests:
1. Login to admin dashboard
2. Navigate to Settings
3. Click Event Creation tab
4. Manipulate toggles using checkbox selectors
5. Navigate to Create Event
6. Verify label text contains or doesn't contain "(optional)"
7. Attempt form submission with various field combinations
8. Assert on validation messages and success/failure states
## Files Modified
### Backend
- `/backend/migrations/core/050_add_optional_event_fields_settings.js`
- `/backend/src/routes/adminEvents.js`
- `/backend/src/routes/publicSettings.js`
### Frontend
- `/frontend/src/pages/admin/SettingsPage.tsx`
- `/frontend/src/pages/admin/CreateEventPage.tsx`
- `/frontend/src/pages/admin/CreateEventPageEnhanced.tsx`
-327
View File
@@ -1,327 +0,0 @@
# 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`
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useRef, useCallback } from 'react';
import { buildResourceUrl } from '../../utils/url'; import { buildResourceUrl } from '../../utils/url';
import { import {
getActiveGallerySlug, getActiveGallerySlug,
@@ -67,7 +67,6 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
overlayProtection, overlayProtection,
fragmentGrid, fragmentGrid,
scrambleFragments, scrambleFragments,
useCanvasRendering,
blockKeyboardShortcuts, blockKeyboardShortcuts,
detectPrintScreen, detectPrintScreen,
detectDevTools, detectDevTools,
@@ -79,6 +78,30 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
const [imageSrc, setImageSrc] = useState<string>(''); const [imageSrc, setImageSrc] = useState<string>('');
const [error, setError] = useState(false); const [error, setError] = useState(false);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [canvasReady, setCanvasReady] = useState(false);
const [canvasFailed, setCanvasFailed] = useState(false);
const canvasRef = useRef<HTMLCanvasElement>(null);
const imageRef = useRef<HTMLImageElement | null>(null);
// Draw image to canvas when canvas rendering is enabled
const drawToCanvas = useCallback(() => {
if (!useCanvasRendering || !canvasRef.current || !imageRef.current) return;
const canvas = canvasRef.current;
const img = imageRef.current;
const ctx = canvas.getContext('2d');
if (!ctx || !img.complete || img.naturalWidth === 0) return;
// Set canvas dimensions to match image
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
// Draw the image
ctx.drawImage(img, 0, 0);
setCanvasReady(true);
}, [useCanvasRendering]);
useEffect(() => { useEffect(() => {
let aborted = false; let aborted = false;
@@ -93,6 +116,8 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
setIsLoading(true); setIsLoading(true);
setError(false); setError(false);
setCanvasFailed(false);
setCanvasReady(false);
const resolveSlug = (candidateSrc?: string): string | null => { const resolveSlug = (candidateSrc?: string): string | null => {
if (slug) { if (slug) {
@@ -181,6 +206,37 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [src, fallbackSrc, slug]); }, [src, fallbackSrc, slug]);
// Effect to draw to canvas when image is loaded and canvas rendering is enabled
useEffect(() => {
if (!useCanvasRendering || !imageSrc) return;
// Create a hidden image to load and then draw to canvas
const img = new Image();
// Only set crossOrigin for non-blob URLs (blob URLs are same-origin)
// Setting crossOrigin on blob URLs can cause silent failures
if (!imageSrc.startsWith('blob:')) {
img.crossOrigin = 'anonymous';
}
img.onload = () => {
imageRef.current = img;
drawToCanvas();
};
img.onerror = (e) => {
// Fall back to regular img if canvas loading fails
console.warn('Canvas image load failed, falling back to img tag:', e);
setCanvasFailed(true);
};
img.src = imageSrc;
return () => {
img.onload = null;
img.onerror = null;
};
}, [imageSrc, useCanvasRendering, drawToCanvas]);
if (isLoading) { if (isLoading) {
return ( return (
<div className={props.className} style={{ backgroundColor: '#f3f4f6', ...props.style }}> <div className={props.className} style={{ backgroundColor: '#f3f4f6', ...props.style }}>
@@ -197,5 +253,34 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
return null; return null;
} }
// Canvas rendering mode - only if enabled and not failed
if (useCanvasRendering && !canvasFailed) {
return (
<canvas
ref={canvasRef}
className={props.className}
style={{
...props.style,
// Hide canvas until it's ready to prevent flash
opacity: canvasReady ? 1 : 0,
transition: 'opacity 0.2s ease-in-out',
}}
// Prevent context menu on canvas
onContextMenu={(e) => {
e.preventDefault();
onProtectionViolation?.('canvas_context_menu');
return false;
}}
// Prevent drag
onDragStart={(e) => {
e.preventDefault();
return false;
}}
aria-label={alt}
role="img"
/>
);
}
return <img src={imageSrc} alt={alt} {...props} />; return <img src={imageSrc} alt={alt} {...props} />;
}; };
@@ -101,9 +101,16 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
} }
}, [data?.event?.protection_level]); }, [data?.event?.protection_level]);
// DevTools protection for enhanced and maximum levels // Get individual protection settings from event
const disableRightClick = data?.event?.disable_right_click === true;
const enableDevtoolsProtection = data?.event?.enable_devtools_protection === true;
const useCanvasRendering = data?.event?.use_canvas_rendering === true;
// DevTools protection - enabled by individual setting OR legacy protection level
const devToolsEnabled = enableDevtoolsProtection || protectionLevel === 'enhanced' || protectionLevel === 'maximum';
useDevToolsProtection({ useDevToolsProtection({
enabled: protectionLevel === 'enhanced' || protectionLevel === 'maximum', enabled: devToolsEnabled,
detectionSensitivity: protectionLevel === 'maximum' ? 'high' : 'medium', detectionSensitivity: protectionLevel === 'maximum' ? 'high' : 'medium',
onDevToolsDetected: () => { onDevToolsDetected: () => {
console.warn('DevTools detected in gallery view'); console.warn('DevTools detected in gallery view');
@@ -128,6 +135,21 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
redirectUrl: '/' redirectUrl: '/'
}); });
// Right-click blocking - separate from DevTools protection
useEffect(() => {
if (!disableRightClick) return;
const handleContextMenu = (e: MouseEvent) => {
e.preventDefault();
return false;
};
document.addEventListener('contextmenu', handleContextMenu);
return () => {
document.removeEventListener('contextmenu', handleContextMenu);
};
}, [disableRightClick]);
// Data updates are handled by React Query // Data updates are handled by React Query
const downloadAllMutation = useDownloadAllPhotos(); const downloadAllMutation = useDownloadAllPhotos();
@@ -679,6 +701,9 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
allowDownloads={allowDownloads} allowDownloads={allowDownloads}
protectionLevel={protectionLevel} protectionLevel={protectionLevel}
useEnhancedProtection={protectionLevel !== 'basic'} useEnhancedProtection={protectionLevel !== 'basic'}
disableRightClick={disableRightClick}
enableDevtoolsProtection={enableDevtoolsProtection}
useCanvasRendering={useCanvasRendering}
/> />
</div> </div>
+13 -2
View File
@@ -19,6 +19,9 @@ interface PhotoGridProps {
allowDownloads?: boolean; allowDownloads?: boolean;
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum'; protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
useEnhancedProtection?: boolean; useEnhancedProtection?: boolean;
useCanvasRendering?: boolean;
disableRightClick?: boolean;
enableDevtoolsProtection?: boolean;
} }
export const PhotoGrid: React.FC<PhotoGridProps> = ({ export const PhotoGrid: React.FC<PhotoGridProps> = ({
@@ -28,7 +31,10 @@ export const PhotoGrid: React.FC<PhotoGridProps> = ({
feedbackEnabled = false, feedbackEnabled = false,
allowDownloads = true, allowDownloads = true,
protectionLevel = 'standard', protectionLevel = 'standard',
useEnhancedProtection = false useEnhancedProtection = false,
useCanvasRendering = false,
disableRightClick = false,
enableDevtoolsProtection = false
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [selectedPhotoIndex, setSelectedPhotoIndex] = useState<number | null>(null); const [selectedPhotoIndex, setSelectedPhotoIndex] = useState<number | null>(null);
@@ -190,6 +196,7 @@ export const PhotoGrid: React.FC<PhotoGridProps> = ({
allowDownloads={allowDownloads} allowDownloads={allowDownloads}
protectionLevel={protectionLevel} protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection} useEnhancedProtection={useEnhancedProtection}
useCanvasRendering={useCanvasRendering}
slug={slug} slug={slug}
feedbackEnabled={feedbackEnabled} feedbackEnabled={feedbackEnabled}
/> />
@@ -207,6 +214,8 @@ export const PhotoGrid: React.FC<PhotoGridProps> = ({
allowDownloads={allowDownloads} allowDownloads={allowDownloads}
protectionLevel={protectionLevel} protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection} useEnhancedProtection={useEnhancedProtection}
disableRightClick={disableRightClick}
enableDevtoolsProtection={enableDevtoolsProtection}
/> />
)} )}
</> </>
@@ -222,6 +231,7 @@ interface PhotoThumbnailProps {
allowDownloads?: boolean; allowDownloads?: boolean;
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum'; protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
useEnhancedProtection?: boolean; useEnhancedProtection?: boolean;
useCanvasRendering?: boolean;
slug: string; // Add slug as required prop slug: string; // Add slug as required prop
feedbackEnabled?: boolean; feedbackEnabled?: boolean;
} }
@@ -235,6 +245,7 @@ const PhotoThumbnail: React.FC<PhotoThumbnailProps> = ({
allowDownloads = true, allowDownloads = true,
protectionLevel = 'standard', protectionLevel = 'standard',
useEnhancedProtection = false, useEnhancedProtection = false,
useCanvasRendering = false,
slug, slug,
feedbackEnabled = false feedbackEnabled = false
}) => { }) => {
@@ -264,7 +275,7 @@ const PhotoThumbnail: React.FC<PhotoThumbnailProps> = ({
protectFromDownload={!allowDownloads || useEnhancedProtection} protectFromDownload={!allowDownloads || useEnhancedProtection}
protectionLevel={protectionLevel} protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection} useEnhancedProtection={useEnhancedProtection}
useCanvasRendering={protectionLevel === 'maximum'} useCanvasRendering={useCanvasRendering || protectionLevel === 'maximum'}
fragmentGrid={protectionLevel === 'enhanced' || protectionLevel === 'maximum'} fragmentGrid={protectionLevel === 'enhanced' || protectionLevel === 'maximum'}
blockKeyboardShortcuts={useEnhancedProtection} blockKeyboardShortcuts={useEnhancedProtection}
detectPrintScreen={useEnhancedProtection} detectPrintScreen={useEnhancedProtection}
@@ -41,6 +41,9 @@ interface PhotoGridWithLayoutsProps {
allowDownloads?: boolean; allowDownloads?: boolean;
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum'; protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
useEnhancedProtection?: boolean; useEnhancedProtection?: boolean;
useCanvasRendering?: boolean;
disableRightClick?: boolean;
enableDevtoolsProtection?: boolean;
feedbackOptions?: { feedbackOptions?: {
allowLikes?: boolean; allowLikes?: boolean;
allowFavorites?: boolean; allowFavorites?: boolean;
@@ -64,6 +67,9 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
allowDownloads = true, allowDownloads = true,
protectionLevel = 'standard', protectionLevel = 'standard',
useEnhancedProtection = false, useEnhancedProtection = false,
useCanvasRendering = false,
disableRightClick = false,
enableDevtoolsProtection = false,
onSelectionChange, onSelectionChange,
onToggleSelectionMode: parentToggleSelectionMode, onToggleSelectionMode: parentToggleSelectionMode,
showSelectionControls = true, showSelectionControls = true,
@@ -185,6 +191,7 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
allowDownloads, allowDownloads,
protectionLevel, protectionLevel,
useEnhancedProtection, useEnhancedProtection,
useCanvasRendering,
isSelectionMode, isSelectionMode,
onPhotoSelect: handlePhotoSelect, onPhotoSelect: handlePhotoSelect,
eventName, eventName,
@@ -290,6 +297,9 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
allowDownloads={allowDownloads} allowDownloads={allowDownloads}
protectionLevel={protectionLevel} protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection} useEnhancedProtection={useEnhancedProtection}
useCanvasRendering={useCanvasRendering}
disableRightClick={disableRightClick}
enableDevtoolsProtection={enableDevtoolsProtection}
initialShowFeedback={openFeedbackInitially} initialShowFeedback={openFeedbackInitially}
onFeedbackChange={onFeedbackChange} onFeedbackChange={onFeedbackChange}
/> />
@@ -18,8 +18,11 @@ interface PhotoLightboxProps {
allowDownloads?: boolean; allowDownloads?: boolean;
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum'; protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
useEnhancedProtection?: boolean; useEnhancedProtection?: boolean;
useCanvasRendering?: boolean;
initialShowFeedback?: boolean; initialShowFeedback?: boolean;
onFeedbackChange?: () => void; onFeedbackChange?: () => void;
disableRightClick?: boolean;
enableDevtoolsProtection?: boolean;
} }
export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
@@ -31,8 +34,11 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
allowDownloads = true, allowDownloads = true,
protectionLevel = 'standard', protectionLevel = 'standard',
useEnhancedProtection = false, useEnhancedProtection = false,
useCanvasRendering = false,
initialShowFeedback = false, initialShowFeedback = false,
onFeedbackChange, onFeedbackChange,
disableRightClick = false,
enableDevtoolsProtection = false,
}) => { }) => {
const [currentIndex, setCurrentIndex] = useState(initialIndex); const [currentIndex, setCurrentIndex] = useState(initialIndex);
const [zoom, setZoom] = useState(1); const [zoom, setZoom] = useState(1);
@@ -66,9 +72,11 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
const downloadPhotoMutation = useDownloadPhoto(); const downloadPhotoMutation = useDownloadPhoto();
const currentPhoto = photos[currentIndex]; const currentPhoto = photos[currentIndex];
// DevTools protection for the lightbox when enhanced protection is enabled // DevTools protection - enabled by individual setting OR legacy protection level
const devToolsEnabled = enableDevtoolsProtection || (useEnhancedProtection && (protectionLevel === 'enhanced' || protectionLevel === 'maximum'));
useDevToolsProtection({ useDevToolsProtection({
enabled: useEnhancedProtection && (protectionLevel === 'enhanced' || protectionLevel === 'maximum'), enabled: devToolsEnabled,
detectionSensitivity: protectionLevel === 'maximum' ? 'high' : 'medium', detectionSensitivity: protectionLevel === 'maximum' ? 'high' : 'medium',
onDevToolsDetected: () => { onDevToolsDetected: () => {
console.warn('DevTools detected in photo lightbox'); console.warn('DevTools detected in photo lightbox');
@@ -91,6 +99,21 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
redirectOnDetection: false, // Don't redirect, just close lightbox redirectOnDetection: false, // Don't redirect, just close lightbox
}); });
// Right-click blocking in lightbox
useEffect(() => {
if (!disableRightClick) return;
const handleContextMenu = (e: MouseEvent) => {
e.preventDefault();
return false;
};
document.addEventListener('contextmenu', handleContextMenu);
return () => {
document.removeEventListener('contextmenu', handleContextMenu);
};
}, [disableRightClick]);
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) { switch (e.key) {
@@ -494,7 +517,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
protectFromDownload={!allowDownloads || useEnhancedProtection} protectFromDownload={!allowDownloads || useEnhancedProtection}
protectionLevel={protectionLevel} protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection} useEnhancedProtection={useEnhancedProtection}
useCanvasRendering={protectionLevel === 'maximum'} useCanvasRendering={useCanvasRendering || protectionLevel === 'maximum'}
fragmentGrid={protectionLevel === 'enhanced' || protectionLevel === 'maximum'} fragmentGrid={protectionLevel === 'enhanced' || protectionLevel === 'maximum'}
blockKeyboardShortcuts={useEnhancedProtection} blockKeyboardShortcuts={useEnhancedProtection}
detectPrintScreen={useEnhancedProtection} detectPrintScreen={useEnhancedProtection}
@@ -20,6 +20,7 @@ export interface BaseGalleryLayoutProps {
allowDownloads?: boolean; allowDownloads?: boolean;
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum'; protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
useEnhancedProtection?: boolean; useEnhancedProtection?: boolean;
useCanvasRendering?: boolean;
feedbackEnabled?: boolean; feedbackEnabled?: boolean;
feedbackOptions?: { feedbackOptions?: {
allowLikes?: boolean; allowLikes?: boolean;
@@ -20,6 +20,7 @@ interface GridPhotoProps {
slug?: string; slug?: string;
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum'; protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
useEnhancedProtection?: boolean; useEnhancedProtection?: boolean;
useCanvasRendering?: boolean;
feedbackEnabled?: boolean; feedbackEnabled?: boolean;
feedbackOptions?: { feedbackOptions?: {
allowLikes?: boolean; allowLikes?: boolean;
@@ -48,6 +49,7 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
slug, slug,
protectionLevel = 'standard', protectionLevel = 'standard',
useEnhancedProtection = false, useEnhancedProtection = false,
useCanvasRendering = false,
feedbackEnabled = false, feedbackEnabled = false,
feedbackOptions, feedbackOptions,
savedIdentity, savedIdentity,
@@ -197,7 +199,7 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
protectFromDownload={!allowDownloads || useEnhancedProtection} protectFromDownload={!allowDownloads || useEnhancedProtection}
protectionLevel={protectionLevel} protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection} useEnhancedProtection={useEnhancedProtection}
useCanvasRendering={protectionLevel === 'maximum'} useCanvasRendering={useCanvasRendering || protectionLevel === 'maximum'}
fragmentGrid={protectionLevel === 'enhanced' || protectionLevel === 'maximum'} fragmentGrid={protectionLevel === 'enhanced' || protectionLevel === 'maximum'}
blockKeyboardShortcuts={useEnhancedProtection} blockKeyboardShortcuts={useEnhancedProtection}
detectPrintScreen={useEnhancedProtection} detectPrintScreen={useEnhancedProtection}
@@ -359,6 +361,7 @@ export const GridGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
allowDownloads = true, allowDownloads = true,
protectionLevel = 'standard', protectionLevel = 'standard',
useEnhancedProtection = false, useEnhancedProtection = false,
useCanvasRendering = false,
feedbackEnabled = false, feedbackEnabled = false,
feedbackOptions feedbackOptions
}) => { }) => {
@@ -397,6 +400,7 @@ export const GridGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
slug={slug} slug={slug}
protectionLevel={protectionLevel} protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection} useEnhancedProtection={useEnhancedProtection}
useCanvasRendering={useCanvasRendering}
feedbackEnabled={feedbackEnabled} feedbackEnabled={feedbackEnabled}
feedbackOptions={feedbackOptions} feedbackOptions={feedbackOptions}
savedIdentity={savedIdentity} savedIdentity={savedIdentity}
@@ -35,6 +35,9 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
expiresAt, expiresAt,
heroPhotoOverride, heroPhotoOverride,
allowDownloads = true, allowDownloads = true,
protectionLevel = 'standard',
useEnhancedProtection = false,
useCanvasRendering = false,
feedbackEnabled = false, feedbackEnabled = false,
feedbackOptions feedbackOptions
}) => { }) => {
@@ -113,7 +116,12 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
alt={heroPhoto.filename} alt={heroPhoto.filename}
className="w-full h-full object-cover" className="w-full h-full object-cover"
isGallery={true} isGallery={true}
protectFromDownload={!allowDownloads} slug={slug}
photoId={heroPhoto.id}
protectFromDownload={!allowDownloads || useEnhancedProtection}
protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection}
useCanvasRendering={useCanvasRendering || protectionLevel === 'maximum'}
/> />
{/* Overlay */} {/* Overlay */}
@@ -202,7 +210,12 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
className="w-full h-auto object-cover transition-transform duration-300 group-hover:scale-105" className="w-full h-auto object-cover transition-transform duration-300 group-hover:scale-105"
loading="lazy" loading="lazy"
isGallery={true} isGallery={true}
protectFromDownload={!allowDownloads} slug={slug}
photoId={photo.id}
protectFromDownload={!allowDownloads || useEnhancedProtection}
protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection}
useCanvasRendering={useCanvasRendering || protectionLevel === 'maximum'}
/> />
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center justify-center gap-2"> <div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center justify-center gap-2">
+1
View File
@@ -8,6 +8,7 @@ export { GeneralTab } from './tabs/GeneralTab';
export { EventsTab } from './tabs/EventsTab'; export { EventsTab } from './tabs/EventsTab';
export { StatusTab } from './tabs/StatusTab'; export { StatusTab } from './tabs/StatusTab';
export { SecurityTab } from './tabs/SecurityTab'; export { SecurityTab } from './tabs/SecurityTab';
export { ImageSecurityTab } from './tabs/ImageSecurityTab';
export { CategoriesTab } from './tabs/CategoriesTab'; export { CategoriesTab } from './tabs/CategoriesTab';
export { AnalyticsTab } from './tabs/AnalyticsTab'; export { AnalyticsTab } from './tabs/AnalyticsTab';
export { ModerationTab } from './tabs/ModerationTab'; export { ModerationTab } from './tabs/ModerationTab';
@@ -0,0 +1,381 @@
import React, { useState, useEffect } from 'react';
import { Save, Shield, Monitor, Image, RefreshCw, AlertCircle } from 'lucide-react';
import { Button, Card, Loading } from '../../../components/common';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import { api } from '../../../config/api';
interface ImageSecuritySettings {
default_protection_level: 'basic' | 'standard' | 'enhanced' | 'maximum';
default_image_quality: number;
enable_devtools_protection: boolean;
max_image_requests_per_minute: number;
max_image_requests_per_5_minutes: number;
max_image_requests_per_hour: number;
suspicious_activity_threshold: number;
enable_canvas_rendering: boolean;
default_fragmentation_level: number;
security_monitoring_enabled: boolean;
block_suspicious_ips: boolean;
log_security_events_to_db: boolean;
auto_block_threshold: number;
}
const defaultSettings: ImageSecuritySettings = {
default_protection_level: 'standard',
default_image_quality: 85,
enable_devtools_protection: true,
max_image_requests_per_minute: 30,
max_image_requests_per_5_minutes: 100,
max_image_requests_per_hour: 500,
suspicious_activity_threshold: 10,
enable_canvas_rendering: false,
default_fragmentation_level: 3,
security_monitoring_enabled: true,
block_suspicious_ips: true,
log_security_events_to_db: true,
auto_block_threshold: 50,
};
export const ImageSecurityTab: React.FC = () => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [settings, setSettings] = useState<ImageSecuritySettings>(defaultSettings);
const [isDirty, setIsDirty] = useState(false);
// Fetch current settings
const { data: fetchedSettings, isLoading, error } = useQuery({
queryKey: ['image-security-settings'],
queryFn: async () => {
const response = await api.get('/api/admin/image-security/settings');
return response.data;
},
});
// Update local state when settings are fetched
useEffect(() => {
if (fetchedSettings) {
setSettings({
...defaultSettings,
...fetchedSettings,
});
}
}, [fetchedSettings]);
// Save mutation
const saveMutation = useMutation({
mutationFn: async (newSettings: ImageSecuritySettings) => {
const response = await api.put('/api/admin/image-security/settings', newSettings);
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['image-security-settings'] });
toast.success(t('settings.imageSecurity.saveSuccess', 'Image security settings saved'));
setIsDirty(false);
},
onError: () => {
toast.error(t('settings.imageSecurity.saveError', 'Failed to save settings'));
},
});
const handleChange = <K extends keyof ImageSecuritySettings>(
key: K,
value: ImageSecuritySettings[K]
) => {
setSettings(prev => ({ ...prev, [key]: value }));
setIsDirty(true);
};
const handleSave = () => {
saveMutation.mutate(settings);
};
const handleReset = () => {
if (fetchedSettings) {
setSettings({ ...defaultSettings, ...fetchedSettings });
setIsDirty(false);
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[200px]">
<Loading size="lg" text={t('common.loading')} />
</div>
);
}
if (error) {
return (
<Card padding="md">
<div className="flex items-center gap-3 text-red-600">
<AlertCircle className="w-5 h-5" />
<p>{t('settings.imageSecurity.loadError', 'Failed to load image security settings')}</p>
</div>
</Card>
);
}
return (
<div className="space-y-6">
{/* Default Protection Level */}
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
<Shield className="w-5 h-5 text-primary-600" />
{t('settings.imageSecurity.defaultProtection', 'Default Protection Settings')}
</h2>
<p className="text-sm text-neutral-600 mb-4">
{t('settings.imageSecurity.defaultProtectionHelp', 'These settings apply to all new events. Individual events can override these defaults.')}
</p>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.imageSecurity.protectionLevel', 'Default Protection Level')}
</label>
<select
value={settings.default_protection_level}
onChange={(e) => handleChange('default_protection_level', e.target.value as ImageSecuritySettings['default_protection_level'])}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="basic">{t('events.protectionLevelBasic', 'Basic - Right-click blocking only')}</option>
<option value="standard">{t('events.protectionLevelStandard', 'Standard - Keyboard shortcuts blocked')}</option>
<option value="enhanced">{t('events.protectionLevelEnhanced', 'Enhanced - Print screen detection')}</option>
<option value="maximum">{t('events.protectionLevelMaximum', 'Maximum - DevTools detection & canvas rendering')}</option>
</select>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.imageSecurity.imageQuality', 'Default Image Quality')}
</label>
<input
type="number"
min="1"
max="100"
value={settings.default_image_quality}
onChange={(e) => handleChange('default_image_quality', parseInt(e.target.value) || 85)}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
<p className="text-xs text-neutral-500 mt-1">1-100, higher = better quality</p>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.imageSecurity.fragmentationLevel', 'Fragmentation Level')}
</label>
<input
type="number"
min="1"
max="10"
value={settings.default_fragmentation_level}
onChange={(e) => handleChange('default_fragmentation_level', parseInt(e.target.value) || 3)}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
<p className="text-xs text-neutral-500 mt-1">1-10, higher = more protection</p>
</div>
</div>
<div className="space-y-3 pt-2">
<label className="flex items-center">
<input
type="checkbox"
checked={settings.enable_devtools_protection}
onChange={(e) => handleChange('enable_devtools_protection', e.target.checked)}
className="w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
/>
<Monitor className="w-4 h-4 ml-2 mr-1 text-neutral-500" />
<span className="text-sm text-neutral-700">
{t('settings.imageSecurity.enableDevtools', 'Enable DevTools detection by default')}
</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={settings.enable_canvas_rendering}
onChange={(e) => handleChange('enable_canvas_rendering', e.target.checked)}
className="w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
/>
<Image className="w-4 h-4 ml-2 mr-1 text-neutral-500" />
<span className="text-sm text-neutral-700">
{t('settings.imageSecurity.enableCanvas', 'Enable canvas rendering by default (advanced protection)')}
</span>
</label>
</div>
</div>
</Card>
{/* Rate Limiting */}
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">
{t('settings.imageSecurity.rateLimiting', 'Rate Limiting')}
</h2>
<p className="text-sm text-neutral-600 mb-4">
{t('settings.imageSecurity.rateLimitingHelp', 'Limit how many images can be requested to prevent scraping.')}
</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.imageSecurity.requestsPerMinute', 'Requests per minute')}
</label>
<input
type="number"
min="1"
max="1000"
value={settings.max_image_requests_per_minute}
onChange={(e) => handleChange('max_image_requests_per_minute', parseInt(e.target.value) || 30)}
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>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.imageSecurity.requestsPer5Minutes', 'Requests per 5 min')}
</label>
<input
type="number"
min="1"
max="5000"
value={settings.max_image_requests_per_5_minutes}
onChange={(e) => handleChange('max_image_requests_per_5_minutes', parseInt(e.target.value) || 100)}
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>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.imageSecurity.requestsPerHour', 'Requests per hour')}
</label>
<input
type="number"
min="1"
max="10000"
value={settings.max_image_requests_per_hour}
onChange={(e) => handleChange('max_image_requests_per_hour', parseInt(e.target.value) || 500)}
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>
</div>
</Card>
{/* Security Monitoring */}
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">
{t('settings.imageSecurity.securityMonitoring', 'Security Monitoring')}
</h2>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.imageSecurity.suspiciousThreshold', 'Suspicious activity threshold')}
</label>
<input
type="number"
min="1"
max="100"
value={settings.suspicious_activity_threshold}
onChange={(e) => handleChange('suspicious_activity_threshold', parseInt(e.target.value) || 10)}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
<p className="text-xs text-neutral-500 mt-1">Violations before flagging as suspicious</p>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.imageSecurity.autoBlockThreshold', 'Auto-block threshold')}
</label>
<input
type="number"
min="1"
max="500"
value={settings.auto_block_threshold}
onChange={(e) => handleChange('auto_block_threshold', parseInt(e.target.value) || 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"
/>
<p className="text-xs text-neutral-500 mt-1">Violations before auto-blocking IP</p>
</div>
</div>
<div className="space-y-3 pt-2">
<label className="flex items-center">
<input
type="checkbox"
checked={settings.security_monitoring_enabled}
onChange={(e) => handleChange('security_monitoring_enabled', e.target.checked)}
className="w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
/>
<span className="ml-2 text-sm text-neutral-700">
{t('settings.imageSecurity.enableMonitoring', 'Enable security monitoring')}
</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={settings.block_suspicious_ips}
onChange={(e) => handleChange('block_suspicious_ips', e.target.checked)}
className="w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
/>
<span className="ml-2 text-sm text-neutral-700">
{t('settings.imageSecurity.blockSuspiciousIps', 'Automatically block suspicious IPs')}
</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={settings.log_security_events_to_db}
onChange={(e) => handleChange('log_security_events_to_db', e.target.checked)}
className="w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
/>
<span className="ml-2 text-sm text-neutral-700">
{t('settings.imageSecurity.logEvents', 'Log security events to database')}
</span>
</label>
</div>
</div>
</Card>
{/* Info Box */}
<Card padding="md" className="bg-blue-50 border-blue-200">
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
<div className="text-sm text-blue-800">
<p className="font-medium mb-1">{t('settings.imageSecurity.infoTitle', 'About Image Protection')}</p>
<p>
{t('settings.imageSecurity.infoText', 'These protection features help prevent casual downloading and copying but cannot block all methods. Determined users may still find ways to capture images. Consider using watermarks and legal agreements for comprehensive protection.')}
</p>
</div>
</div>
</Card>
{/* Action Buttons */}
<div className="flex gap-3">
<Button
variant="primary"
onClick={handleSave}
isLoading={saveMutation.isPending}
leftIcon={<Save className="w-5 h-5" />}
disabled={!isDirty}
>
{t('common.saveChanges', 'Save Changes')}
</Button>
{isDirty && (
<Button
variant="outline"
onClick={handleReset}
leftIcon={<RefreshCw className="w-5 h-5" />}
>
{t('common.resetChanges', 'Reset Changes')}
</Button>
)}
</div>
</div>
);
};
+6
View File
@@ -693,6 +693,12 @@
"downloadPermissions": "Download-Berechtigungen", "downloadPermissions": "Download-Berechtigungen",
"downloadsEnabled": "Downloads aktiviert", "downloadsEnabled": "Downloads aktiviert",
"downloadsDisabled": "Downloads deaktiviert", "downloadsDisabled": "Downloads deaktiviert",
"downloadProtection": "Download-Schutz",
"disableRightClick": "Rechtsklick-Menü blockieren",
"watermarkDownloads": "Wasserzeichen bei Downloads hinzufügen",
"enableDevtoolsProtection": "Entwicklertools erkennen",
"useCanvasRendering": "Canvas-Rendering (erweiterter Schutz)",
"protectionInfo": "Schutzfunktionen helfen, unerlaubte Downloads zu verhindern, können jedoch nicht alle Methoden blockieren.",
"heroPhoto": "Hero-Foto", "heroPhoto": "Hero-Foto",
"heroPhotoHelp": "Wählen Sie ein hervorgehobenes Foto für das Hero-Galerie-Layout", "heroPhotoHelp": "Wählen Sie ein hervorgehobenes Foto für das Hero-Galerie-Layout",
"selectHeroPhoto": "Hero-Foto auswählen", "selectHeroPhoto": "Hero-Foto auswählen",
+6
View File
@@ -371,6 +371,12 @@
"downloadPermissions": "Download Permissions", "downloadPermissions": "Download Permissions",
"downloadsEnabled": "Downloads Enabled", "downloadsEnabled": "Downloads Enabled",
"downloadsDisabled": "Downloads Disabled", "downloadsDisabled": "Downloads Disabled",
"downloadProtection": "Download Protection",
"disableRightClick": "Block right-click menu",
"watermarkDownloads": "Add watermark to downloads",
"enableDevtoolsProtection": "Detect developer tools",
"useCanvasRendering": "Canvas rendering (advanced protection)",
"protectionInfo": "Protection features help prevent unauthorized downloads but cannot block all methods.",
"heroPhoto": "Hero Photo", "heroPhoto": "Hero Photo",
"heroPhotoHelp": "Select a featured photo for the hero gallery layout", "heroPhotoHelp": "Select a featured photo for the hero gallery layout",
"selectHeroPhoto": "Select Hero Photo", "selectHeroPhoto": "Select Hero Photo",
+147 -2
View File
@@ -21,7 +21,11 @@ import {
Lock, Lock,
Eye, Eye,
EyeOff, EyeOff,
Type Type,
Shield,
Monitor,
Droplets,
MousePointer
} from 'lucide-react'; } from 'lucide-react';
import { parseISO, differenceInDays, isValid } from 'date-fns'; import { parseISO, differenceInDays, isValid } from 'date-fns';
@@ -147,6 +151,13 @@ export const EventDetailsPage: React.FC = () => {
require_password: boolean; require_password: boolean;
new_password: string; new_password: string;
confirm_new_password: string; confirm_new_password: string;
// Download protection settings
protection_level: 'basic' | 'standard' | 'enhanced' | 'maximum';
disable_right_click: boolean;
allow_downloads: boolean;
watermark_downloads: boolean;
enable_devtools_protection: boolean;
use_canvas_rendering: boolean;
}; };
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
@@ -163,6 +174,13 @@ export const EventDetailsPage: React.FC = () => {
require_password: true, require_password: true,
new_password: '', new_password: '',
confirm_new_password: '', confirm_new_password: '',
// Download protection settings
protection_level: 'standard',
disable_right_click: true,
allow_downloads: true,
watermark_downloads: false,
enable_devtools_protection: true,
use_canvas_rendering: false,
}); });
const [feedbackSettings, setFeedbackSettings] = useState<FeedbackSettingsType>({ const [feedbackSettings, setFeedbackSettings] = useState<FeedbackSettingsType>({
feedback_enabled: false, feedback_enabled: false,
@@ -367,6 +385,13 @@ export const EventDetailsPage: React.FC = () => {
require_password: normalizeRequirePassword(event.require_password), require_password: normalizeRequirePassword(event.require_password),
new_password: '', new_password: '',
confirm_new_password: '', confirm_new_password: '',
// Load protection settings from event
protection_level: event.protection_level || 'standard',
disable_right_click: event.disable_right_click ?? true,
allow_downloads: event.allow_downloads ?? true,
watermark_downloads: event.watermark_downloads ?? false,
enable_devtools_protection: event.enable_devtools_protection ?? true,
use_canvas_rendering: event.use_canvas_rendering ?? false,
}); });
setShowNewPassword(false); setShowNewPassword(false);
@@ -449,6 +474,13 @@ export const EventDetailsPage: React.FC = () => {
expires_at: editForm.expires_at, expires_at: editForm.expires_at,
allow_user_uploads: editForm.allow_user_uploads, allow_user_uploads: editForm.allow_user_uploads,
require_password: editForm.require_password, require_password: editForm.require_password,
// Download protection settings
protection_level: editForm.protection_level,
disable_right_click: editForm.disable_right_click,
allow_downloads: editForm.allow_downloads,
watermark_downloads: editForm.watermark_downloads,
enable_devtools_protection: editForm.enable_devtools_protection,
use_canvas_rendering: editForm.use_canvas_rendering,
}; };
// Only include fields that have defined values // Only include fields that have defined values
@@ -940,12 +972,81 @@ export const EventDetailsPage: React.FC = () => {
{/* Feedback Settings */} {/* Feedback Settings */}
<div className="mt-4 pt-4 border-t border-neutral-200"> <div className="mt-4 pt-4 border-t border-neutral-200">
<h3 className="text-sm font-semibold text-neutral-900 mb-3">{t('feedback.settings', 'Feedback Settings')}</h3> <h3 className="text-sm font-semibold text-neutral-900 mb-3">{t('feedback.settings.title', 'Guest Feedback Settings')}</h3>
<FeedbackSettings <FeedbackSettings
settings={feedbackSettings} settings={feedbackSettings}
onChange={setFeedbackSettings} onChange={setFeedbackSettings}
/> />
</div> </div>
{/* Download Protection Settings */}
<div className="mt-4 pt-4 border-t border-neutral-200">
<h3 className="text-sm font-semibold text-neutral-900 mb-3 flex items-center gap-2">
<Shield className="w-4 h-4 text-primary-600" />
{t('events.downloadProtection', 'Download Protection')}
</h3>
<div className="space-y-3">
<label className="flex items-center">
<input
type="checkbox"
checked={editForm.allow_downloads}
onChange={(e) => setEditForm(prev => ({ ...prev, allow_downloads: e.target.checked }))}
className="w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
/>
<Download className="w-4 h-4 ml-2 mr-1 text-neutral-500" />
<span className="text-sm text-neutral-700">{t('events.allowDownloads', 'Allow photo downloads')}</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={editForm.disable_right_click}
onChange={(e) => setEditForm(prev => ({ ...prev, disable_right_click: e.target.checked }))}
className="w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
/>
<MousePointer className="w-4 h-4 ml-2 mr-1 text-neutral-500" />
<span className="text-sm text-neutral-700">{t('events.disableRightClick', 'Block right-click menu')}</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={editForm.watermark_downloads}
onChange={(e) => setEditForm(prev => ({ ...prev, watermark_downloads: e.target.checked }))}
className="w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
/>
<Droplets className="w-4 h-4 ml-2 mr-1 text-neutral-500" />
<span className="text-sm text-neutral-700">{t('events.watermarkDownloads', 'Add watermark to downloads')}</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={editForm.enable_devtools_protection}
onChange={(e) => setEditForm(prev => ({ ...prev, enable_devtools_protection: e.target.checked }))}
className="w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
/>
<Monitor className="w-4 h-4 ml-2 mr-1 text-neutral-500" />
<span className="text-sm text-neutral-700">{t('events.enableDevtoolsProtection', 'Detect developer tools')}</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={editForm.use_canvas_rendering}
onChange={(e) => setEditForm(prev => ({ ...prev, use_canvas_rendering: e.target.checked }))}
className="w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
/>
<Image className="w-4 h-4 ml-2 mr-1 text-neutral-500" />
<span className="text-sm text-neutral-700">{t('events.useCanvasRendering', 'Canvas rendering (advanced protection)')}</span>
</label>
<p className="text-xs text-neutral-500 mt-2">
{t('events.protectionInfo', 'Protection features help prevent unauthorized downloads but cannot block all methods.')}
</p>
</div>
</div>
</div> </div>
) : ( ) : (
<dl className="space-y-4"> <dl className="space-y-4">
@@ -1037,6 +1138,50 @@ export const EventDetailsPage: React.FC = () => {
)} )}
</dd> </dd>
</div> </div>
{/* Download Protection Display */}
<div className="pt-3 mt-3 border-t border-neutral-200">
<dt className="text-sm font-medium text-neutral-500 flex items-center gap-2">
<Shield className="w-4 h-4" />
{t('events.downloadProtection', 'Download Protection')}
</dt>
<dd className="mt-2 text-sm text-neutral-900">
<div className="flex flex-wrap gap-2">
<span className={`inline-flex items-center px-2 py-1 text-xs font-medium rounded ${
event.protection_level === 'maximum' ? 'bg-red-100 text-red-700' :
event.protection_level === 'enhanced' ? 'bg-orange-100 text-orange-700' :
event.protection_level === 'standard' ? 'bg-blue-100 text-blue-700' :
'bg-neutral-100 text-neutral-700'
}`}>
{event.protection_level || 'standard'}
</span>
{event.disable_right_click && (
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-neutral-100 text-neutral-700 rounded">
<MousePointer className="w-3 h-3 mr-1" />
{t('events.rightClickBlocked', 'Right-click blocked')}
</span>
)}
{event.enable_devtools_protection && (
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-neutral-100 text-neutral-700 rounded">
<Monitor className="w-3 h-3 mr-1" />
{t('events.devtoolsDetection', 'DevTools detection')}
</span>
)}
{!event.allow_downloads && (
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-red-100 text-red-700 rounded">
<Download className="w-3 h-3 mr-1" />
{t('events.downloadsDisabled', 'Downloads disabled')}
</span>
)}
{event.watermark_downloads && (
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-neutral-100 text-neutral-700 rounded">
<Droplets className="w-3 h-3 mr-1" />
{t('events.watermarked', 'Watermarked')}
</span>
)}
</div>
</dd>
</div>
</dl> </dl>
)} )}
</Card> </Card>
+5 -1
View File
@@ -7,13 +7,14 @@ import {
EventsTab, EventsTab,
StatusTab, StatusTab,
SecurityTab, SecurityTab,
ImageSecurityTab,
CategoriesTab, CategoriesTab,
AnalyticsTab, AnalyticsTab,
ModerationTab, ModerationTab,
StylingTab, StylingTab,
} from '../../features/settings'; } from '../../features/settings';
type TabType = 'general' | 'events' | 'status' | 'security' | 'categories' | 'analytics' | 'moderation' | 'styling'; type TabType = 'general' | 'events' | 'status' | 'security' | 'imageSecurity' | 'categories' | 'analytics' | 'moderation' | 'styling';
export const SettingsPage: React.FC = () => { export const SettingsPage: React.FC = () => {
const [activeTab, setActiveTab] = useState<TabType>('general'); const [activeTab, setActiveTab] = useState<TabType>('general');
@@ -68,6 +69,7 @@ export const SettingsPage: React.FC = () => {
{ key: 'events', label: t('settings.events.title', 'Event Creation') }, { key: 'events', label: t('settings.events.title', 'Event Creation') },
{ key: 'status', label: t('settings.systemStatus.title') }, { key: 'status', label: t('settings.systemStatus.title') },
{ key: 'security', label: t('settings.security.title') }, { key: 'security', label: t('settings.security.title') },
{ key: 'imageSecurity', label: t('settings.imageSecurity.title', 'Image Protection') },
{ key: 'categories', label: t('settings.categories.title') }, { key: 'categories', label: t('settings.categories.title') },
{ key: 'analytics', label: t('settings.analytics.title') }, { key: 'analytics', label: t('settings.analytics.title') },
{ key: 'moderation', label: t('settings.moderation.title', 'Moderation') }, { key: 'moderation', label: t('settings.moderation.title', 'Moderation') },
@@ -151,6 +153,8 @@ export const SettingsPage: React.FC = () => {
/> />
)} )}
{activeTab === 'imageSecurity' && <ImageSecurityTab />}
{activeTab === 'categories' && <CategoriesTab />} {activeTab === 'categories' && <CategoriesTab />}
{activeTab === 'analytics' && ( {activeTab === 'analytics' && (
+9 -3
View File
@@ -34,6 +34,13 @@ export interface Event {
unique_visitors?: number; unique_visitors?: number;
source_mode?: 'managed' | 'reference' | string; source_mode?: 'managed' | 'reference' | string;
external_path?: string | null; external_path?: string | null;
// Download protection fields
allow_downloads?: boolean;
protection_level?: 'basic' | 'standard' | 'enhanced' | 'maximum';
disable_right_click?: boolean;
watermark_downloads?: boolean;
enable_devtools_protection?: boolean;
use_canvas_rendering?: boolean;
} }
export interface GalleryInfo { export interface GalleryInfo {
@@ -56,15 +63,13 @@ export interface Photo {
download_url_template?: string; download_url_template?: string;
requires_token?: boolean; requires_token?: boolean;
type: 'collage' | 'individual' | 'video'; type: 'collage' | 'individual' | 'video';
media_type?: 'photo' | 'video';
mime_type?: string;
category_id?: number | string | null; category_id?: number | string | null;
category_name?: string; category_name?: string;
category_slug?: string; category_slug?: string;
size: number; size: number;
uploaded_at: string; uploaded_at: string;
// Media type fields // Media type fields
media_type?: 'image' | 'video'; media_type?: 'photo' | 'video' | 'image';
mime_type?: string; mime_type?: string;
duration?: number; // Duration in seconds for videos duration?: number; // Duration in seconds for videos
video_codec?: string; video_codec?: string;
@@ -107,6 +112,7 @@ export interface GalleryData {
protection_level?: 'basic' | 'standard' | 'enhanced' | 'maximum'; protection_level?: 'basic' | 'standard' | 'enhanced' | 'maximum';
image_quality?: number; image_quality?: number;
use_canvas_rendering?: boolean; use_canvas_rendering?: boolean;
enable_devtools_protection?: boolean;
fragmentation_level?: number; fragmentation_level?: number;
overlay_protection?: boolean; overlay_protection?: boolean;
}; };
+28
View File
@@ -0,0 +1,28 @@
{
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
"release-type": "simple",
"bump-minor-pre-major": true,
"bump-patch-for-minor-pre-major": true,
"include-component-in-tag": false,
"include-v-in-tag": true,
"changelog-sections": [
{ "type": "feat", "section": "Features", "hidden": false },
{ "type": "fix", "section": "Bug Fixes", "hidden": false },
{ "type": "perf", "section": "Performance Improvements", "hidden": false },
{ "type": "revert", "section": "Reverts", "hidden": false },
{ "type": "docs", "section": "Documentation", "hidden": false },
{ "type": "style", "section": "Styles", "hidden": true },
{ "type": "chore", "section": "Miscellaneous", "hidden": true },
{ "type": "refactor", "section": "Code Refactoring", "hidden": true },
{ "type": "test", "section": "Tests", "hidden": true },
{ "type": "build", "section": "Build System", "hidden": true },
{ "type": "ci", "section": "CI/CD", "hidden": true }
],
"packages": {
".": {
"release-type": "simple",
"changelog-path": "CHANGELOG.md",
"extra-files": []
}
}
}