From 397d33a95a09e0b0986c3f6cf5965c544992a764 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Sun, 1 Feb 2026 21:07:44 +0100 Subject: [PATCH 1/3] fix: increase upload limit to 1GB and fix category filters (#155, #156) - Increase nginx client_max_body_size from 100MB to 1GB for video support - Fix admin photo category filtering to properly handle numeric category IDs from the photo_categories table, not just legacy 'individual'/'collage' types - Add support for 'uncategorized' filter to show photos with no category --- backend/src/routes/adminPhotos.js | 19 +++++++++++++------ frontend/nginx.conf | 8 ++++---- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/backend/src/routes/adminPhotos.js b/backend/src/routes/adminPhotos.js index fc312d68..4bbf8bab 100644 --- a/backend/src/routes/adminPhotos.js +++ b/backend/src/routes/adminPhotos.js @@ -726,13 +726,20 @@ router.get('/:eventId/photos', adminAuth, requirePermission('photos.view'), asyn .leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id') .select('photos.*', 'photo_categories.name as pc_name', 'photo_categories.slug as pc_slug'); - // Filter by type (individual/collage) - category_id maps to type - if (category_id !== undefined) { - if (category_id === '' || category_id === '0') { - // For backwards compatibility, empty category means no filter - // Don't filter anything - } else if (category_id === 'individual' || category_id === 'collage') { + // Filter by category_id + if (category_id !== undefined && category_id !== '' && category_id !== '0') { + if (category_id === 'individual' || category_id === 'collage') { + // Legacy type-based filtering query = query.where({ 'photos.type': category_id }); + } else if (category_id === 'uncategorized') { + // Filter for photos with no category assigned + query = query.whereNull('photos.category_id'); + } else { + // Numeric category ID from photo_categories table + const numericCategoryId = parseInt(category_id, 10); + if (!isNaN(numericCategoryId)) { + query = query.where({ 'photos.category_id': numericCategoryId }); + } } } diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 348c669d..e0bf6905 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -8,8 +8,8 @@ server { resolver 127.0.0.11 valid=10s ipv6=off; resolver_timeout 5s; - # Allow larger file uploads (up to 100MB) - client_max_body_size 100M; + # Allow larger file uploads (up to 1GB for video support) + client_max_body_size 1G; client_body_timeout 300s; # Gzip compression @@ -60,8 +60,8 @@ server { proxy_cache_bypass $http_upgrade; proxy_read_timeout 86400; - # Allow larger uploads for API endpoints - client_max_body_size 100M; + # Allow larger uploads for API endpoints (up to 1GB for video support) + client_max_body_size 1G; client_body_timeout 300s; } From 7b8d8bd92ba7a96717bb4d821b38dddc395f701a Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Sun, 1 Feb 2026 22:44:28 +0100 Subject: [PATCH 2/3] feat: decouple hero header from gallery layouts (#158) - Add separate header_style setting (hero/standard/minimal/none) that can be combined with any layout type (grid/masonry/carousel/timeline/mosaic) - Create HeroHeader and HeroDivider components for reusable hero section - Add hero_divider_style setting (wave/straight/angle/curve/none) - Add database migration for header_style and hero_divider_style columns - Remove deprecated HeroGalleryLayout component - Fix various TypeScript errors across the codebase: - Add missing type properties (css_template_id, updatedAt, justified settings) - Fix null handling for event_date and expires_at fields - Fix translation function calls and i18n config - Remove unused imports and variables --- .../migrations/core/065_add_header_style.js | 131 ++++++ backend/src/routes/adminEvents.js | 19 +- backend/src/routes/adminPhotos.js | 4 +- backend/src/routes/gallery.js | 10 +- .../src/components/admin/AdminPhotoGrid.tsx | 61 ++- .../components/admin/CssTemplateEditor.tsx | 2 +- .../components/admin/EventRenameDialog.tsx | 2 +- .../src/components/admin/GalleryPreview.tsx | 12 - .../src/components/admin/PhotoFilterPanel.tsx | 2 +- .../admin/ThemeCustomizerEnhanced.tsx | 120 ++++- .../src/components/admin/ThemeDisplay.tsx | 19 +- .../src/components/admin/ThemeEditorModal.tsx | 6 +- .../src/components/gallery/GalleryLayout.tsx | 32 +- .../src/components/gallery/GalleryView.tsx | 10 +- .../src/components/gallery/HeroDivider.tsx | 62 +++ .../src/components/gallery/HeroHeader.tsx | 259 +++++++++++ .../src/components/gallery/PhotoFilterBar.tsx | 8 +- .../gallery/PhotoGridWithLayouts.tsx | 44 +- .../gallery/layouts/BaseGalleryLayout.tsx | 4 +- .../gallery/layouts/GridGalleryLayout.tsx | 2 + .../gallery/layouts/HeroGalleryLayout.tsx | 412 ------------------ .../src/components/gallery/layouts/index.ts | 5 +- frontend/src/contexts/GalleryAuthContext.tsx | 4 +- frontend/src/i18n/config.ts | 4 +- frontend/src/i18n/locales/de.json | 25 +- frontend/src/i18n/locales/en.json | 25 +- frontend/src/pages/admin/AdminDashboard.tsx | 4 +- frontend/src/pages/admin/CreateEventPage.tsx | 3 +- frontend/src/pages/admin/EventDetailsPage.tsx | 27 +- frontend/src/pages/admin/EventTypesPage.tsx | 2 +- frontend/src/pages/gallery/PreviewPage.tsx | 2 +- .../src/services/publicSettings.service.ts | 6 + frontend/src/types/index.ts | 15 + frontend/src/types/theme.types.ts | 36 +- frontend/src/utils/themeMigration.ts | 61 +++ 35 files changed, 907 insertions(+), 533 deletions(-) create mode 100644 backend/migrations/core/065_add_header_style.js create mode 100644 frontend/src/components/gallery/HeroDivider.tsx create mode 100644 frontend/src/components/gallery/HeroHeader.tsx delete mode 100644 frontend/src/components/gallery/layouts/HeroGalleryLayout.tsx create mode 100644 frontend/src/utils/themeMigration.ts diff --git a/backend/migrations/core/065_add_header_style.js b/backend/migrations/core/065_add_header_style.js new file mode 100644 index 00000000..334ef70b --- /dev/null +++ b/backend/migrations/core/065_add_header_style.js @@ -0,0 +1,131 @@ +/** + * Migration: Add header_style and hero_divider_style columns + * + * This migration decouples the hero header style from gallery layout, + * allowing any combination of header style with any layout type. + */ + +exports.up = async function(knex) { + console.log('[Migration 065] Adding header_style and hero_divider_style columns'); + + // Check if columns already exist + const hasHeaderStyle = await knex.schema.hasColumn('events', 'header_style'); + const hasDividerStyle = await knex.schema.hasColumn('events', 'hero_divider_style'); + + if (!hasHeaderStyle) { + await knex.schema.alterTable('events', (table) => { + table.string('header_style', 20).defaultTo('standard'); + }); + console.log('[Migration 065] Added header_style column'); + } + + if (!hasDividerStyle) { + await knex.schema.alterTable('events', (table) => { + table.string('hero_divider_style', 20).defaultTo('wave'); + }); + console.log('[Migration 065] Added hero_divider_style column'); + } + + // Migrate existing events with hero layout in color_theme + console.log('[Migration 065] Migrating existing hero layouts...'); + + const events = await knex('events') + .whereNotNull('color_theme') + .select('id', 'color_theme'); + + let migratedCount = 0; + + for (const event of events) { + try { + // Skip if color_theme is not JSON + if (!event.color_theme || !event.color_theme.startsWith('{')) { + continue; + } + + const theme = JSON.parse(event.color_theme); + + // Check if this event uses hero layout + if (theme.galleryLayout === 'hero') { + // Migrate: set headerStyle to 'hero' and galleryLayout to 'grid' + const updatedTheme = { + ...theme, + headerStyle: 'hero', + galleryLayout: 'grid', + heroDividerStyle: theme.heroDividerStyle || 'wave' + }; + + await knex('events') + .where('id', event.id) + .update({ + color_theme: JSON.stringify(updatedTheme), + header_style: 'hero', + hero_divider_style: theme.heroDividerStyle || 'wave' + }); + + migratedCount++; + } + } catch (err) { + // Invalid JSON in color_theme, skip + console.warn(`[Migration 065] Could not parse color_theme for event ${event.id}: ${err.message}`); + } + } + + console.log(`[Migration 065] Migrated ${migratedCount} events from hero layout`); + console.log('[Migration 065] Completed'); +}; + +exports.down = async function(knex) { + console.log('[Migration 065] Removing header_style and hero_divider_style columns'); + + // First, migrate any hero header styles back to hero layout + const events = await knex('events') + .where('header_style', 'hero') + .whereNotNull('color_theme') + .select('id', 'color_theme'); + + for (const event of events) { + try { + if (!event.color_theme || !event.color_theme.startsWith('{')) { + continue; + } + + const theme = JSON.parse(event.color_theme); + + // Revert: set galleryLayout back to 'hero' + const revertedTheme = { + ...theme, + galleryLayout: 'hero' + }; + + // Remove the new properties + delete revertedTheme.headerStyle; + delete revertedTheme.heroDividerStyle; + + await knex('events') + .where('id', event.id) + .update({ + color_theme: JSON.stringify(revertedTheme) + }); + } catch (err) { + console.warn(`[Migration 065] Could not revert color_theme for event ${event.id}: ${err.message}`); + } + } + + // Remove the columns + const hasHeaderStyle = await knex.schema.hasColumn('events', 'header_style'); + const hasDividerStyle = await knex.schema.hasColumn('events', 'hero_divider_style'); + + if (hasHeaderStyle) { + await knex.schema.alterTable('events', (table) => { + table.dropColumn('header_style'); + }); + } + + if (hasDividerStyle) { + await knex.schema.alterTable('events', (table) => { + table.dropColumn('hero_divider_style'); + }); + } + + console.log('[Migration 065] Rollback completed'); +}; diff --git a/backend/src/routes/adminEvents.js b/backend/src/routes/adminEvents.js index 6426b8bc..721f6e37 100644 --- a/backend/src/routes/adminEvents.js +++ b/backend/src/routes/adminEvents.js @@ -193,7 +193,10 @@ router.post('/', adminAuth, requirePermission('events.create'), [ // Hero logo settings body('hero_logo_visible').optional().isBoolean(), body('hero_logo_size').optional().isIn(['small', 'medium', 'large', 'xlarge']), - body('hero_logo_position').optional().isIn(['top', 'center', 'bottom']) + body('hero_logo_position').optional().isIn(['top', 'center', 'bottom']), + // Header style settings (decoupled from layout) + body('header_style').optional().isIn(['hero', 'standard', 'minimal', 'none']), + body('hero_divider_style').optional().isIn(['wave', 'straight', 'angle', 'curve', 'none']) ], async (req, res) => { try { logger.debug('Create event request body', { body: req.body }); @@ -236,7 +239,10 @@ router.post('/', adminAuth, requirePermission('events.create'), [ // Hero logo settings hero_logo_visible = true, hero_logo_size = 'medium', - hero_logo_position = 'top' + hero_logo_position = 'top', + // Header style settings + header_style = 'standard', + hero_divider_style = 'wave' } = req.body; const customerName = getCustomerNameFromPayload(req.body); @@ -377,7 +383,9 @@ router.post('/', adminAuth, requirePermission('events.create'), [ css_template_id: css_template_id || null, hero_logo_visible: formatBoolean(hero_logo_visible !== undefined ? hero_logo_visible : true), hero_logo_size: hero_logo_size || 'medium', - hero_logo_position: hero_logo_position || 'top' + hero_logo_position: hero_logo_position || 'top', + header_style: header_style || 'standard', + hero_divider_style: hero_divider_style || 'wave' }).returning('id'); // Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs) @@ -658,7 +666,10 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), [ // Hero logo settings body('hero_logo_visible').optional().isBoolean(), body('hero_logo_size').optional().isIn(['small', 'medium', 'large', 'xlarge']), - body('hero_logo_position').optional().isIn(['top', 'center', 'bottom']) + body('hero_logo_position').optional().isIn(['top', 'center', 'bottom']), + // Header style settings (decoupled from layout) + body('header_style').optional().isIn(['hero', 'standard', 'minimal', 'none']), + body('hero_divider_style').optional().isIn(['wave', 'straight', 'angle', 'curve', 'none']) ], async (req, res) => { try { const errors = validationResult(req); diff --git a/backend/src/routes/adminPhotos.js b/backend/src/routes/adminPhotos.js index 4bbf8bab..e1a7bb81 100644 --- a/backend/src/routes/adminPhotos.js +++ b/backend/src/routes/adminPhotos.js @@ -647,9 +647,7 @@ router.post('/:eventId/photos/bulk-update', adminAuth, requirePermission('photos } // Prepare update data - const updateData = { - updated_at: new Date() - }; + const updateData = {}; if (updates.category_id !== undefined) { // Handle type-based categories ('individual' or 'collage') diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js index 0b682cc6..1adb092d 100644 --- a/backend/src/routes/gallery.js +++ b/backend/src/routes/gallery.js @@ -119,7 +119,9 @@ router.get('/:slug/info', async (req, res) => { 'hero_logo_visible', 'hero_logo_size', 'hero_logo_position', - 'hero_logo_url' + 'hero_logo_url', + 'header_style', + 'hero_divider_style' ) .first(); @@ -170,7 +172,9 @@ router.get('/:slug/info', async (req, res) => { hero_logo_visible: event.hero_logo_visible !== false && event.hero_logo_visible !== 0 && event.hero_logo_visible !== '0', hero_logo_size: event.hero_logo_size || 'medium', hero_logo_position: event.hero_logo_position || 'top', - hero_logo_url: event.hero_logo_url || null + hero_logo_url: event.hero_logo_url || null, + header_style: event.header_style || 'standard', + hero_divider_style: event.hero_divider_style || 'wave' }); } catch (error) { console.error('Error fetching gallery info:', error); @@ -344,6 +348,8 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => { hero_logo_size: req.event.hero_logo_size || 'medium', hero_logo_position: req.event.hero_logo_position || 'top', hero_logo_url: req.event.hero_logo_url || null, + header_style: req.event.header_style || 'standard', + hero_divider_style: req.event.hero_divider_style || 'wave', ...protectionSettings }, categories: categories, diff --git a/frontend/src/components/admin/AdminPhotoGrid.tsx b/frontend/src/components/admin/AdminPhotoGrid.tsx index 325d3fc2..82cff063 100644 --- a/frontend/src/components/admin/AdminPhotoGrid.tsx +++ b/frontend/src/components/admin/AdminPhotoGrid.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { Check, Download, Trash2, Eye, Package, MessageSquare, Star, Video } from 'lucide-react'; +import { Check, Download, Trash2, Eye, Package, MessageSquare, Star, Video, FolderOpen } from 'lucide-react'; import { toast } from 'react-toastify'; import { useTranslation } from 'react-i18next'; @@ -7,6 +7,12 @@ import { AdminPhoto } from '../../services/photos.service'; import { photosService } from '../../services/photos.service'; import { Button } from '../common'; import { AdminAuthenticatedImage } from './AdminAuthenticatedImage'; +import { BulkCategoryModal } from './BulkCategoryModal'; + +interface CategoryOption { + id: number; + name: string; +} interface AdminPhotoGridProps { photos: AdminPhoto[]; @@ -14,6 +20,7 @@ interface AdminPhotoGridProps { onPhotoClick: (photo: AdminPhoto, index: number) => void; onPhotosDeleted: () => void; onSelectionChange?: (selectedIds: number[]) => void; + categories?: CategoryOption[]; } export const AdminPhotoGrid: React.FC = ({ @@ -21,13 +28,16 @@ export const AdminPhotoGrid: React.FC = ({ eventId, onPhotoClick, onPhotosDeleted, - onSelectionChange + onSelectionChange, + categories = [] }) => { const { t } = useTranslation(); const [selectedPhotos, setSelectedPhotos] = useState>(new Set()); const [isSelectionMode, setIsSelectionMode] = useState(false); const [isDeleting, setIsDeleting] = useState(false); const [deletingPhotos, setDeletingPhotos] = useState>(new Set()); + const [isCategoryModalOpen, setIsCategoryModalOpen] = useState(false); + const [isUpdatingCategory, setIsUpdatingCategory] = useState(false); const handlePhotoSelect = (photoId: number, e?: React.MouseEvent) => { if (e) { @@ -125,6 +135,35 @@ export const AdminPhotoGrid: React.FC = ({ } }; + const handleMoveToCategory = async (categoryId: number | null) => { + if (selectedPhotos.size === 0) return; + + setIsUpdatingCategory(true); + const selectedIds = Array.from(selectedPhotos); + + try { + await photosService.updatePhotosCategory(eventId, selectedIds, categoryId); + const categoryName = categoryId + ? categories.find(c => Number(c.id) === categoryId)?.name || t('photos.selectedCategory', 'selected category') + : t('photos.uncategorized', 'Uncategorized'); + toast.success( + t('photos.movedToCategory', '{{count}} photos moved to {{category}}', { + count: selectedIds.length, + category: categoryName + }) + ); + setSelectedPhotos(new Set()); + setIsSelectionMode(false); + onSelectionChange?.([]); + setIsCategoryModalOpen(false); + onPhotosDeleted(); // Refresh the photo list + } catch { + toast.error(t('photos.moveToCategoryFailed', 'Failed to move photos to category')); + } finally { + setIsUpdatingCategory(false); + } + }; + return (
{/* Action Bar */} @@ -154,6 +193,14 @@ export const AdminPhotoGrid: React.FC = ({ {t('gallery.photosSelected', { count: selectedPhotos.size })} +
)} + + {/* Bulk Category Modal */} + setIsCategoryModalOpen(false)} + onConfirm={handleMoveToCategory} + photoCount={selectedPhotos.size} + categories={categories} + isLoading={isUpdatingCategory} + /> ); }; diff --git a/frontend/src/components/admin/CssTemplateEditor.tsx b/frontend/src/components/admin/CssTemplateEditor.tsx index b5d8c3ed..6226a52b 100644 --- a/frontend/src/components/admin/CssTemplateEditor.tsx +++ b/frontend/src/components/admin/CssTemplateEditor.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { toast } from 'react-toastify'; -import { Save, RotateCcw, Eye, Code, AlertTriangle, Check } from 'lucide-react'; +import { Save, RotateCcw, Code, AlertTriangle, Check } from 'lucide-react'; import { Button, Card, Loading } from '../common'; import { cssTemplatesService, CssTemplate } from '../../services/cssTemplates.service'; diff --git a/frontend/src/components/admin/EventRenameDialog.tsx b/frontend/src/components/admin/EventRenameDialog.tsx index a6f118d0..272540d2 100644 --- a/frontend/src/components/admin/EventRenameDialog.tsx +++ b/frontend/src/components/admin/EventRenameDialog.tsx @@ -28,7 +28,7 @@ interface EventRenameDialogProps { export const EventRenameDialog: React.FC = ({ isOpen, eventName, - eventId, + eventId: _eventId, customerEmail, onClose, onRename, diff --git a/frontend/src/components/admin/GalleryPreview.tsx b/frontend/src/components/admin/GalleryPreview.tsx index e89b7f81..ac7d4c71 100644 --- a/frontend/src/components/admin/GalleryPreview.tsx +++ b/frontend/src/components/admin/GalleryPreview.tsx @@ -151,18 +151,6 @@ export const GalleryPreview: React.FC = ({ ); - case 'hero': - return ( -
- -
- {mockPhotos.slice(1, 5).map((photo) => ( - - ))} -
-
- ); - case 'mosaic': return (
diff --git a/frontend/src/components/admin/PhotoFilterPanel.tsx b/frontend/src/components/admin/PhotoFilterPanel.tsx index 38a19774..6ba946f8 100644 --- a/frontend/src/components/admin/PhotoFilterPanel.tsx +++ b/frontend/src/components/admin/PhotoFilterPanel.tsx @@ -90,7 +90,7 @@ export const PhotoFilterPanel: React.FC = ({ > {RATING_OPTIONS.map(option => ( ))} diff --git a/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx b/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx index ba7255c3..29d49837 100644 --- a/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx +++ b/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect } from 'react'; -import { Palette, RotateCcw, Check, Layout, Type, Sparkles, Grid3X3, Layers, Play, Clock, Image, LayoutGrid, ChevronDown, Code, Info, FileCode } from 'lucide-react'; +import { Palette, RotateCcw, Check, Layout, Type, Sparkles, Grid3X3, Layers, Play, Clock, Image, LayoutGrid, ChevronDown, Code, Info, FileCode, ImageIcon, Minimize2, EyeOff } from 'lucide-react'; import { Button, Card, Input } from '../common'; -import { ThemeConfig, GALLERY_THEME_PRESETS, GalleryLayoutType } from '../../types/theme.types'; +import { ThemeConfig, GALLERY_THEME_PRESETS, GalleryLayoutType, HeaderStyleType, HeroDividerStyle } from '../../types/theme.types'; import type { EnabledTemplate } from '../../services/cssTemplates.service'; // import { settingsService } from '../../services/settings.service'; // import { toast } from 'react-toastify'; @@ -28,10 +28,45 @@ const layoutIcons: Record = { masonry: , carousel: , timeline: , - hero: , mosaic: }; +const headerStyleIcons: Record = { + hero: , + standard: , + minimal: , + none: +}; + +const dividerStylePreviews: Record = { + wave: ( + + + + ), + straight: ( + + + + ), + angle: ( + + + + ), + curve: ( + + + + ), + none: ( + + + No divider + + ) +}; + // Layout descriptions will use translation keys export const ThemeCustomizerEnhanced: React.FC = ({ @@ -439,6 +474,85 @@ export const ThemeCustomizerEnhanced: React.FC = ( )} + {/* Header Style - Decoupled from Layout */} + {showGalleryLayouts && ( + +

+ + {t('branding.headerStyle', 'Header Style')} +

+

+ {t('branding.headerStyleDescription', 'Choose how the gallery header appears. The header style is independent of the photo layout.')} +

+
+ {(Object.keys(headerStyleIcons) as HeaderStyleType[]).map((style) => ( + + ))} +
+ + {/* Divider Style - Only show when hero header is selected */} + {localTheme.headerStyle === 'hero' && ( +
+

+ {t('branding.heroDividerStyle', 'Divider Style')} +

+

+ {t('branding.heroDividerDescription', 'Choose how the transition between the hero image and gallery content looks.')} +

+
+ {(Object.keys(dividerStylePreviews) as HeroDividerStyle[]).map((divider) => ( + + ))} +
+
+ )} +
+ )} + {/* Color Customization */}

diff --git a/frontend/src/components/admin/ThemeDisplay.tsx b/frontend/src/components/admin/ThemeDisplay.tsx index 5587b055..285f70a9 100644 --- a/frontend/src/components/admin/ThemeDisplay.tsx +++ b/frontend/src/components/admin/ThemeDisplay.tsx @@ -1,12 +1,11 @@ import React from 'react'; -import { - Palette, - Type, - Grid3X3, - Layers, - Play, - Clock, - Image, +import { + Palette, + Type, + Grid3X3, + Layers, + Play, + Clock, LayoutGrid, Layout } from 'lucide-react'; @@ -25,9 +24,7 @@ const layoutIcons: Record = { masonry: , carousel: , timeline: , - hero: , - mosaic: , - justified: + mosaic: }; export const ThemeDisplay: React.FC = ({ diff --git a/frontend/src/components/admin/ThemeEditorModal.tsx b/frontend/src/components/admin/ThemeEditorModal.tsx index 168c2b2d..4abe9034 100644 --- a/frontend/src/components/admin/ThemeEditorModal.tsx +++ b/frontend/src/components/admin/ThemeEditorModal.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react'; -import { X, Save, RotateCcw, Grid3X3, Layers, Play, Clock, Image, LayoutGrid, Check } from 'lucide-react'; +import { X, Save, RotateCcw, Grid3X3, Layers, Play, Clock, LayoutGrid, Check } from 'lucide-react'; import { Button } from '../common'; import { ThemeCustomizerEnhanced } from './ThemeCustomizerEnhanced'; import { GalleryPreview } from './GalleryPreview'; @@ -21,9 +21,7 @@ const layoutIcons: Record = { masonry: , carousel: , timeline: , - hero: , - mosaic: , - justified: + mosaic: }; export const ThemeEditorModal: React.FC = ({ diff --git a/frontend/src/components/gallery/GalleryLayout.tsx b/frontend/src/components/gallery/GalleryLayout.tsx index e6baf342..efa4bd16 100644 --- a/frontend/src/components/gallery/GalleryLayout.tsx +++ b/frontend/src/components/gallery/GalleryLayout.tsx @@ -8,13 +8,14 @@ import { Button } from '../common'; import { DynamicFavicon } from '../common/DynamicFavicon'; import { useTheme } from '../../contexts/ThemeContext'; import { buildResourceUrl } from '../../utils/url'; +import type { HeaderStyleType } from '../../types/theme.types'; interface GalleryLayoutProps { event: { event_name: string; event_type?: string; - event_date?: string; - expires_at?: string; + event_date?: string | null; + expires_at?: string | null; }; brandingSettings?: { company_name?: string; @@ -56,8 +57,13 @@ export const GalleryLayout: React.FC = ({ const { t } = useTranslation(); const { format } = useLocalizedDate(); const { theme } = useTheme(); - - const isNonGridLayout = theme.galleryLayout && theme.galleryLayout !== 'grid' && theme.galleryLayout !== 'hero'; + + // Determine header style - check theme.headerStyle first, then fall back to legacy behavior + const headerStyle: HeaderStyleType = theme.headerStyle || 'standard'; + const isHeroHeader = headerStyle === 'hero'; + + // Non-grid layouts that need the sidebar (excluding layouts using hero header) + const isNonGridLayout = theme.galleryLayout && theme.galleryLayout !== 'grid'; const fontFamily = theme.fontFamily || 'Inter, sans-serif'; const headingFontFamily = theme.headingFontFamily || fontFamily; @@ -123,9 +129,9 @@ export const GalleryLayout: React.FC = ({ {/* Header structure */} -
- {/* For non-grid layouts (excluding hero) - keep the current structure */} - {isNonGridLayout && ( +
+ {/* For non-grid layouts - keep the current structure */} + {isNonGridLayout && !isHeroHeader && (
@@ -170,8 +176,8 @@ export const GalleryLayout: React.FC = ({
)} - {/* For grid layout - everything in one bar */} - {!isNonGridLayout && theme.galleryLayout !== 'hero' && ( + {/* For grid layout - everything in one bar (standard header) */} + {!isNonGridLayout && !isHeroHeader && (
{/* Left side - Menu button, Logo */} @@ -292,8 +298,8 @@ export const GalleryLayout: React.FC = ({
)} - {/* For hero layout - minimal header with just menu and logout */} - {theme.galleryLayout === 'hero' && ( + {/* For hero header style - minimal header with just menu and logout */} + {isHeroHeader && (
{/* Left side - Menu button */} @@ -337,8 +343,8 @@ export const GalleryLayout: React.FC = ({ )}
- {/* Hero Header for non-grid layouts (excluding hero layout which has its own) */} - {isNonGridLayout && ( + {/* Hero Header for non-grid layouts when using standard header style */} + {isNonGridLayout && !isHeroHeader && (
= ({ slug, event }) => { headerExtra={(() => { const items = []; - if (daysUntilExpiration !== null && daysUntilExpiration <= 1 && daysUntilExpiration > 0) { + if (daysUntilExpiration !== null && daysUntilExpiration <= 1 && daysUntilExpiration > 0 && event.expires_at) { items.push( ); @@ -632,7 +632,7 @@ export const GalleryView: React.FC = ({ slug, event }) => { })()} > {/* Expiration Banner */} - {showUrgentWarning && ( + {showUrgentWarning && event.expires_at && ( )} @@ -694,6 +694,8 @@ export const GalleryView: React.FC = ({ slug, event }) => { heroLogoVisible={data?.event?.hero_logo_visible !== false} heroLogoSize={data?.event?.hero_logo_size || 'medium'} heroLogoPosition={data?.event?.hero_logo_position || 'top'} + headerStyle={data?.event?.header_style || theme.headerStyle} + heroDividerStyle={data?.event?.hero_divider_style || theme.heroDividerStyle || 'wave'} />
diff --git a/frontend/src/components/gallery/HeroDivider.tsx b/frontend/src/components/gallery/HeroDivider.tsx new file mode 100644 index 00000000..2b425716 --- /dev/null +++ b/frontend/src/components/gallery/HeroDivider.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import type { HeroDividerStyle } from '../../types/theme.types'; + +interface HeroDividerProps { + style: HeroDividerStyle; + fillColor?: string; + className?: string; +} + +export const HeroDivider: React.FC = ({ + style, + fillColor = 'var(--color-background, #fafafa)', + className = '' +}) => { + if (style === 'none' || style === 'straight') { + // No visible divider - straight clean edge + return null; + } + + switch (style) { + case 'wave': + return ( +
+ + + +
+ ); + + case 'angle': + return ( +
+ + + +
+ ); + + case 'curve': + return ( +
+ + + +
+ ); + + default: + return null; + } +}; + +HeroDivider.displayName = 'HeroDivider'; diff --git a/frontend/src/components/gallery/HeroHeader.tsx b/frontend/src/components/gallery/HeroHeader.tsx new file mode 100644 index 00000000..812d1681 --- /dev/null +++ b/frontend/src/components/gallery/HeroHeader.tsx @@ -0,0 +1,259 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { ChevronDown, Calendar, Clock } from 'lucide-react'; +import { parseISO } from 'date-fns'; +import { useTranslation } from 'react-i18next'; +import { useLocalizedDate } from '../../hooks/useLocalizedDate'; +import { useTheme } from '../../contexts/ThemeContext'; +import { AuthenticatedImage } from '../common'; +import { HeroDivider } from './HeroDivider'; +import { buildResourceUrl } from '../../utils/url'; +import type { Photo } from '../../types'; +import type { HeroDividerStyle } from '../../types/theme.types'; + +interface HeroHeaderProps { + photos: Photo[]; + slug: string; + eventName?: string; + eventLogo?: string | null; + eventDate?: string | null; + expiresAt?: string | null; + heroPhotoOverride?: Photo | null; + heroLogoVisible?: boolean; + heroLogoSize?: 'small' | 'medium' | 'large' | 'xlarge'; + heroLogoPosition?: 'top' | 'center' | 'bottom'; + dividerStyle?: HeroDividerStyle; + allowDownloads?: boolean; + protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum'; + useEnhancedProtection?: boolean; + useCanvasRendering?: boolean; + onScrollToContent?: () => void; +} + +export const HeroHeader: React.FC = ({ + photos, + slug, + eventName, + eventLogo, + eventDate, + expiresAt, + heroPhotoOverride, + heroLogoVisible = true, + heroLogoSize = 'medium', + heroLogoPosition = 'top', + dividerStyle = 'wave', + allowDownloads = true, + protectionLevel = 'standard', + useEnhancedProtection = false, + useCanvasRendering = false, + onScrollToContent +}) => { + const { t } = useTranslation(); + const { format } = useLocalizedDate(); + const { theme } = useTheme(); + const [heroPhoto, setHeroPhoto] = useState(null); + const [hasInitialized, setHasInitialized] = useState(false); + + const gallerySettings = theme.gallerySettings || {}; + const overlayOpacity = gallerySettings.heroOverlayOpacity || 0.3; + + // Helper function to get logo size classes + const getLogoSizeClasses = (size: string): string => { + switch (size) { + case 'small': + return 'h-12 sm:h-14 lg:h-16'; + case 'medium': + return 'h-20 sm:h-24 lg:h-32'; + case 'large': + return 'h-28 sm:h-32 lg:h-40'; + case 'xlarge': + return 'h-36 sm:h-40 lg:h-48'; + default: + return 'h-20 sm:h-24 lg:h-32'; + } + }; + + const handleScrollToContent = useCallback(() => { + if (onScrollToContent) { + onScrollToContent(); + } else { + // Default: scroll to gallery grid section + const gridSection = document.getElementById('gallery-grid-section'); + if (gridSection) { + gridSection.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } else { + // Fallback: scroll down by hero section height + window.scrollBy({ top: window.innerHeight * 0.9, behavior: 'smooth' }); + } + } + }, [onScrollToContent]); + + // If an override is provided, always use it and skip initialization logic + useEffect(() => { + if (heroPhotoOverride) { + setHeroPhoto(heroPhotoOverride); + setHasInitialized(true); + } + }, [heroPhotoOverride]); + + // Reset initialization when heroImageId changes + useEffect(() => { + if (gallerySettings.heroImageId) { + setHasInitialized(false); + } + }, [gallerySettings.heroImageId]); + + // Select hero photo (admin-selected or first photo only if gallery was empty) + useEffect(() => { + // When an override is provided, the effect above has already set the hero. + if (heroPhotoOverride) return; + + if (photos.length > 0) { + const heroId = gallerySettings.heroImageId; + // If admin has selected a specific hero image, always use it when available + if (heroId) { + const adminSelectedHero = photos.find(p => p.id === heroId); + if (adminSelectedHero) { + setHeroPhoto(adminSelectedHero); + setHasInitialized(true); + return; + } + } + + // Only auto-select first photo on initial load + if (!hasInitialized) { + setHeroPhoto(photos[0]); + setHasInitialized(true); + } + } + }, [photos, gallerySettings.heroImageId, hasInitialized, heroPhotoOverride]); + + if (!heroPhoto) return null; + + return ( +
+ {/* Hero Section */} +
+ + + {/* Overlay */} +
+ + {/* Hero Content */} +
+
+ {/* Logo at top position */} + {heroLogoVisible && heroLogoPosition === 'top' && ( +
+ Event logo +
+ )} + + {/* Event Title */} + {eventName && ( +

+ {eventName} +

+ )} + + {/* Logo at center position (between title and dates) */} + {heroLogoVisible && heroLogoPosition === 'center' && ( +
+ Event logo +
+ )} + + {/* Event Dates */} + {(eventDate || expiresAt) && ( +
+ {eventDate && ( + + + {format(parseISO(eventDate), 'PP')} + + )} + {expiresAt && ( + + + {t('gallery.expires')} {format(parseISO(expiresAt), 'PP')} + + )} +
+ )} + + {/* Logo at bottom position */} + {heroLogoVisible && heroLogoPosition === 'bottom' && ( +
+ Event logo +
+ )} +
+
+ + {/* Scroll Indicator */} + + + {/* Decorative Divider */} + +
+
+ ); +}; + +HeroHeader.displayName = 'HeroHeader'; diff --git a/frontend/src/components/gallery/PhotoFilterBar.tsx b/frontend/src/components/gallery/PhotoFilterBar.tsx index 6caec6f1..7381c93c 100644 --- a/frontend/src/components/gallery/PhotoFilterBar.tsx +++ b/frontend/src/components/gallery/PhotoFilterBar.tsx @@ -5,7 +5,7 @@ import { Button, Input } from '../common'; import type { FilterType } from './GalleryFilter'; interface PhotoCategory { - id: number; + id: number | string; name: string; slug: string; is_global: boolean; @@ -13,7 +13,7 @@ interface PhotoCategory { interface Photo { id: number; - category_id?: number; + category_id?: number | string | null; like_count?: number; favorite_count?: number; } @@ -21,8 +21,8 @@ interface Photo { interface PhotoFilterBarProps { categories?: PhotoCategory[]; photos: Photo[]; - selectedCategoryId: number | null; - onCategoryChange: (categoryId: number | null) => void; + selectedCategoryId: number | string | null; + onCategoryChange: (categoryId: number | string | null) => void; searchTerm: string; onSearchChange: (term: string) => void; sortBy: 'date' | 'name' | 'size' | 'rating'; diff --git a/frontend/src/components/gallery/PhotoGridWithLayouts.tsx b/frontend/src/components/gallery/PhotoGridWithLayouts.tsx index 2c123b79..eb016be2 100644 --- a/frontend/src/components/gallery/PhotoGridWithLayouts.tsx +++ b/frontend/src/components/gallery/PhotoGridWithLayouts.tsx @@ -17,14 +17,15 @@ import { MasonryGalleryLayout, CarouselGalleryLayout, TimelineGalleryLayout, - HeroGalleryLayout, MosaicGalleryLayout, } from './layouts'; +import { HeroHeader } from './HeroHeader'; +import type { HeaderStyleType, HeroDividerStyle } from '../../types/theme.types'; interface PhotoGridWithLayoutsProps { photos: Photo[]; slug: string; - categoryId?: number | null; + categoryId?: number | string | null; // When provided, the hero layout will use this photo // instead of deriving from the filtered photo list. heroPhotoOverride?: Photo | null; @@ -35,8 +36,8 @@ interface PhotoGridWithLayoutsProps { showSelectionControls?: boolean; eventName?: string; eventLogo?: string | null; - eventDate?: string; - expiresAt?: string; + eventDate?: string | null; + expiresAt?: string | null; feedbackEnabled?: boolean; allowDownloads?: boolean; protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum'; @@ -56,6 +57,9 @@ interface PhotoGridWithLayoutsProps { heroLogoVisible?: boolean; heroLogoSize?: 'small' | 'medium' | 'large' | 'xlarge'; heroLogoPosition?: 'top' | 'center' | 'bottom'; + // Header style (decoupled from layout) + headerStyle?: HeaderStyleType; + heroDividerStyle?: HeroDividerStyle; } export const PhotoGridWithLayouts: React.FC = ({ @@ -83,7 +87,9 @@ export const PhotoGridWithLayouts: React.FC = ({ expiresAt, heroLogoVisible = true, heroLogoSize = 'medium', - heroLogoPosition = 'top' + heroLogoPosition = 'top', + headerStyle, + heroDividerStyle = 'wave' }) => { const { t } = useTranslation(); const { theme } = useTheme(); @@ -212,6 +218,10 @@ export const PhotoGridWithLayouts: React.FC = ({ heroLogoPosition, }; + // Determine if we should show hero header (decoupled from layout) + const effectiveHeaderStyle = headerStyle || theme.headerStyle; + const showHeroHeader = effectiveHeaderStyle === 'hero'; + let LayoutComponent; switch (galleryLayout) { case 'masonry': @@ -223,9 +233,6 @@ export const PhotoGridWithLayouts: React.FC = ({ case 'timeline': LayoutComponent = TimelineGalleryLayout; break; - case 'hero': - LayoutComponent = HeroGalleryLayout; - break; case 'mosaic': LayoutComponent = MosaicGalleryLayout; break; @@ -235,6 +242,27 @@ export const PhotoGridWithLayouts: React.FC = ({ return ( <> + {/* Hero Header - shown when headerStyle is 'hero' */} + {showHeroHeader && ( + + )} + {/* Selection Mode Controls - Not shown for carousel layout or when controls are hidden */} {showSelectionControls && photos.length > 1 && galleryLayout !== 'carousel' && (
diff --git a/frontend/src/components/gallery/layouts/BaseGalleryLayout.tsx b/frontend/src/components/gallery/layouts/BaseGalleryLayout.tsx index 7b526910..4ee9dc83 100644 --- a/frontend/src/components/gallery/layouts/BaseGalleryLayout.tsx +++ b/frontend/src/components/gallery/layouts/BaseGalleryLayout.tsx @@ -15,8 +15,8 @@ export interface BaseGalleryLayoutProps { onPhotoSelect?: (photoId: number) => void; eventName?: string; eventLogo?: string | null; - eventDate?: string; - expiresAt?: string; + eventDate?: string | null; + expiresAt?: string | null; allowDownloads?: boolean; protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum'; useEnhancedProtection?: boolean; diff --git a/frontend/src/components/gallery/layouts/GridGalleryLayout.tsx b/frontend/src/components/gallery/layouts/GridGalleryLayout.tsx index 00e83ca7..a22c6d61 100644 --- a/frontend/src/components/gallery/layouts/GridGalleryLayout.tsx +++ b/frontend/src/components/gallery/layouts/GridGalleryLayout.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { Download, Maximize2, Check, MessageSquare, Star, Heart, Video } from 'lucide-react'; import { useInView } from 'react-intersection-observer'; +import { useTranslation } from 'react-i18next'; import { useTheme } from '../../../contexts/ThemeContext'; import { AuthenticatedImage } from '../../common'; import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal'; @@ -59,6 +60,7 @@ const GridPhoto: React.FC = ({ liked = false, onLikeSuccess }) => { + const { t } = useTranslation(); const [overlayVisible, setOverlayVisible] = React.useState(false); const [isTouchDevice, setIsTouchDevice] = React.useState(false); const overlayTimeoutRef = React.useRef(null); diff --git a/frontend/src/components/gallery/layouts/HeroGalleryLayout.tsx b/frontend/src/components/gallery/layouts/HeroGalleryLayout.tsx deleted file mode 100644 index 4004d021..00000000 --- a/frontend/src/components/gallery/layouts/HeroGalleryLayout.tsx +++ /dev/null @@ -1,412 +0,0 @@ -import React, { useState, useEffect, useRef, useCallback } from 'react'; -import { Download, Maximize2, Check, ChevronDown, Calendar, Clock, Heart, MessageSquare } from 'lucide-react'; -import { parseISO } from 'date-fns'; -import { useTranslation } from 'react-i18next'; -import { useLocalizedDate } from '../../../hooks/useLocalizedDate'; -import { useTheme } from '../../../contexts/ThemeContext'; -import { AuthenticatedImage } from '../../common'; -import type { BaseGalleryLayoutProps } from './BaseGalleryLayout'; -import type { Photo } from '../../../types'; -import { buildResourceUrl } from '../../../utils/url'; -import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal'; -import { feedbackService } from '../../../services/feedback.service'; - -interface HeroGalleryLayoutProps extends BaseGalleryLayoutProps { - eventName?: string; - eventLogo?: string | null; - eventDate?: string; - expiresAt?: string; - // Use a static hero photo independent of current filter - heroPhotoOverride?: Photo | null; - // Hero logo customization options - heroLogoVisible?: boolean; - heroLogoSize?: 'small' | 'medium' | 'large' | 'xlarge'; - heroLogoPosition?: 'top' | 'center' | 'bottom'; -} - -export const HeroGalleryLayout: React.FC = ({ - photos, - slug, - onPhotoClick, - onOpenPhotoWithFeedback, - onDownload, - selectedPhotos = new Set(), - isSelectionMode = false, - onPhotoSelect, - eventName, - eventLogo, - eventDate, - expiresAt, - heroPhotoOverride, - heroLogoVisible = true, - heroLogoSize = 'medium', - heroLogoPosition = 'top', - allowDownloads = true, - protectionLevel = 'standard', - useEnhancedProtection = false, - useCanvasRendering = false, - feedbackEnabled = false, - feedbackOptions -}) => { - const { t } = useTranslation(); - const { format } = useLocalizedDate(); - const { theme } = useTheme(); - const [heroPhoto, setHeroPhoto] = useState(null); - const [hasInitialized, setHasInitialized] = useState(false); - const [showIdentityModal, setShowIdentityModal] = useState(false); - const [pendingAction, setPendingAction] = useState(null); - const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null); - const gallerySettings = theme.gallerySettings || {}; - const overlayOpacity = gallerySettings.heroOverlayOpacity || 0.3; - - // Helper function to get logo size classes - const getLogoSizeClasses = (size: string): string => { - switch (size) { - case 'small': - return 'h-12 sm:h-14 lg:h-16'; - case 'medium': - return 'h-20 sm:h-24 lg:h-32'; - case 'large': - return 'h-28 sm:h-32 lg:h-40'; - case 'xlarge': - return 'h-36 sm:h-40 lg:h-48'; - default: - return 'h-20 sm:h-24 lg:h-32'; - } - }; - const [likedIds, setLikedIds] = useState>(new Set()); - const canQuickComment = Boolean(feedbackEnabled && feedbackOptions?.allowComments && onOpenPhotoWithFeedback); - const gridRef = useRef(null); - const handleScrollToGrid = useCallback(() => { - if (gridRef.current) { - gridRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' }); - } - }, []); - - // If an override is provided, always use it and skip initialization logic - useEffect(() => { - if (heroPhotoOverride) { - setHeroPhoto(heroPhotoOverride); - setHasInitialized(true); - } - }, [heroPhotoOverride]); - - // Reset initialization when heroImageId changes - useEffect(() => { - if (gallerySettings.heroImageId) { - setHasInitialized(false); - } - }, [gallerySettings.heroImageId]); - - // Select hero photo (admin-selected or first photo only if gallery was empty) - useEffect(() => { - // When an override is provided, the effect above has already set the hero. - if (heroPhotoOverride) return; - - if (photos.length > 0) { - const heroId = gallerySettings.heroImageId; - // If admin has selected a specific hero image, always use it when available - if (heroId) { - const adminSelectedHero = photos.find(p => p.id === heroId); - if (adminSelectedHero) { - setHeroPhoto(adminSelectedHero); - setHasInitialized(true); - return; - } - } - - // Only auto-select first photo on initial load - if (!hasInitialized) { - setHeroPhoto(photos[0]); - setHasInitialized(true); - } - } - }, [photos, gallerySettings.heroImageId, hasInitialized, heroPhotoOverride]); - - if (!heroPhoto) return null; - - // Show all photos including the hero photo in the grid - const remainingPhotos = photos; - - return ( - <> -
- {/* Hero Section */} -
- - - {/* Overlay */} -
- - {/* Hero Content */} -
-
- {/* Logo at top position */} - {heroLogoVisible && heroLogoPosition === 'top' && ( -
- Event logo -
- )} - - {/* Event Title */} - {eventName && ( -

- {eventName} -

- )} - - {/* Logo at center position (between title and dates) */} - {heroLogoVisible && heroLogoPosition === 'center' && ( -
- Event logo -
- )} - - {/* Event Dates */} - {(eventDate || expiresAt) && ( -
- {eventDate && ( - - - {format(parseISO(eventDate), 'PP')} - - )} - {expiresAt && ( - - - {t('gallery.expires')} {format(parseISO(expiresAt), 'PP')} - - )} -
- )} - - {/* Logo at bottom position */} - {heroLogoVisible && heroLogoPosition === 'bottom' && ( -
- Event logo -
- )} -
-
- - {/* Scroll Indicator */} - -
- - {/* Grid Section */} - -
- { setShowIdentityModal(false); setPendingAction(null); }} - onSubmit={async (name, email) => { - setSavedIdentity({ name, email }); - setShowIdentityModal(false); - if (pendingAction) { - await feedbackService.submitFeedback(slug!, String(pendingAction.photoId), { - feedback_type: pendingAction.type, - guest_name: name, - guest_email: email, - }); - setPendingAction(null); - } - }} - feedbackType="like" - /> - - ); -}; diff --git a/frontend/src/components/gallery/layouts/index.ts b/frontend/src/components/gallery/layouts/index.ts index 72e5a4f9..f3a91ff5 100644 --- a/frontend/src/components/gallery/layouts/index.ts +++ b/frontend/src/components/gallery/layouts/index.ts @@ -2,6 +2,7 @@ export { GridGalleryLayout } from './GridGalleryLayout'; export { MasonryGalleryLayout } from './MasonryGalleryLayout'; export { CarouselGalleryLayout } from './CarouselGalleryLayout'; export { TimelineGalleryLayout } from './TimelineGalleryLayout'; -export { HeroGalleryLayout } from './HeroGalleryLayout'; export { MosaicGalleryLayout } from './MosaicGalleryLayout'; -export type { BaseGalleryLayoutProps } from './BaseGalleryLayout'; \ No newline at end of file +export type { BaseGalleryLayoutProps } from './BaseGalleryLayout'; +// Note: HeroGalleryLayout has been deprecated in favor of HeroHeader component +// which can be used with any layout via the headerStyle setting \ No newline at end of file diff --git a/frontend/src/contexts/GalleryAuthContext.tsx b/frontend/src/contexts/GalleryAuthContext.tsx index 93ab37ac..85acfae5 100644 --- a/frontend/src/contexts/GalleryAuthContext.tsx +++ b/frontend/src/contexts/GalleryAuthContext.tsx @@ -16,10 +16,10 @@ interface GalleryEvent { id: number; event_name: string; event_type: string; - event_date: string; + event_date: string | null; welcome_message?: string; color_theme?: string; - expires_at: string; + expires_at: string | null; require_password?: boolean; } diff --git a/frontend/src/i18n/config.ts b/frontend/src/i18n/config.ts index f84cbb5a..f8b29a5a 100644 --- a/frontend/src/i18n/config.ts +++ b/frontend/src/i18n/config.ts @@ -27,8 +27,8 @@ i18n escapeValue: false, }, - // Use v3 format for pluralization (_plural suffix instead of _one/_other) - compatibilityJSON: 'v3', + // Use v4 format for pluralization (_one/_other instead of _plural suffix) + compatibilityJSON: 'v4', detection: { order: ['localStorage', 'cookie', 'navigator', 'htmlTag'], diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index aeed14c3..faa9ecdc 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -1391,7 +1391,30 @@ "showLogoInHeader": "Logo im Galerie-Header anzeigen", "showLogoInHeaderHelp": "Logo in der Hauptkopfzeile anzeigen", "showLogoInHero": "Logo im Hero-Bereich anzeigen", - "showLogoInHeroHelp": "Logo in Hero-Bereichen anzeigen (für Nicht-Raster-Layouts)" + "showLogoInHeroHelp": "Logo in Hero-Bereichen anzeigen (für Nicht-Raster-Layouts)", + "headerStyle": "Kopfzeilen-Stil", + "headerStyleDescription": "Wählen Sie, wie die Galerie-Kopfzeile aussieht. Der Kopfzeilen-Stil ist unabhängig vom Foto-Layout.", + "headerStyleOptions": { + "hero": "Hero-Bild", + "standard": "Standard-Banner", + "minimal": "Minimal", + "none": "Keine Kopfzeile" + }, + "headerStyleDescriptions": { + "hero": "Bild in voller Höhe mit Event-Info-Overlay", + "standard": "Klassisches Banner mit Veranstaltungsdetails", + "minimal": "Kompakte Kopfzeile mit wesentlichen Infos", + "none": "Kopfzeile komplett ausblenden" + }, + "heroDividerStyle": "Trennlinie-Stil", + "heroDividerDescription": "Wählen Sie, wie der Übergang zwischen dem Hero-Bild und dem Galerie-Inhalt aussieht.", + "dividerOptions": { + "wave": "Welle", + "straight": "Gerade", + "angle": "Winkel", + "curve": "Kurve", + "none": "Keine" + } }, "admin": { "title": "Admin-Panel", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 90b75f52..0605d225 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1106,7 +1106,30 @@ "showLogoInHeader": "Show logo in gallery header", "showLogoInHeaderHelp": "Display the logo in the main header bar", "showLogoInHero": "Show logo in hero section", - "showLogoInHeroHelp": "Display the logo in hero sections (for non-grid layouts)" + "showLogoInHeroHelp": "Display the logo in hero sections (for non-grid layouts)", + "headerStyle": "Header Style", + "headerStyleDescription": "Choose how the gallery header appears. The header style is independent of the photo layout.", + "headerStyleOptions": { + "hero": "Hero Image", + "standard": "Standard Banner", + "minimal": "Minimal", + "none": "No Header" + }, + "headerStyleDescriptions": { + "hero": "Full-height image with event info overlay", + "standard": "Classic banner with event details", + "minimal": "Compact header with essential info", + "none": "Hide header completely" + }, + "heroDividerStyle": "Divider Style", + "heroDividerDescription": "Choose how the transition between the hero image and gallery content looks.", + "dividerOptions": { + "wave": "Wave", + "straight": "Straight", + "angle": "Angle", + "curve": "Curve", + "none": "None" + } }, "admin": { "title": "Admin Panel", diff --git a/frontend/src/pages/admin/AdminDashboard.tsx b/frontend/src/pages/admin/AdminDashboard.tsx index 4a4495af..474d849e 100644 --- a/frontend/src/pages/admin/AdminDashboard.tsx +++ b/frontend/src/pages/admin/AdminDashboard.tsx @@ -195,7 +195,7 @@ export const AdminDashboard: React.FC = () => { ) : (
{expiringEvents.slice(0, 5).map((event) => { - const daysLeft = differenceInDays(parseISO(event.expires_at), new Date()); + const daysLeft = differenceInDays(parseISO(event.expires_at!), new Date()); return (
{ {t('admin.daysLeft', { count: daysLeft })}

- {t('gallery.expires')} {format(parseISO(event.expires_at), 'PP')} + {t('gallery.expires')} {format(parseISO(event.expires_at!), 'PP')}

diff --git a/frontend/src/pages/admin/CreateEventPage.tsx b/frontend/src/pages/admin/CreateEventPage.tsx index f6262799..456ef153 100644 --- a/frontend/src/pages/admin/CreateEventPage.tsx +++ b/frontend/src/pages/admin/CreateEventPage.tsx @@ -20,6 +20,7 @@ import { eventsService } from '../../services/events.service'; import { useLocalizedDate } from '../../hooks/useLocalizedDate'; import { categoriesService } from '../../services/categories.service'; import { settingsService } from '../../services/settings.service'; +import { publicSettingsService } from '../../services/publicSettings.service'; import { cssTemplatesService } from '../../services/cssTemplates.service'; import { eventTypesService } from '../../services/eventTypes.service'; import { useTranslation } from 'react-i18next'; @@ -152,7 +153,7 @@ export const CreateEventPage: React.FC = () => { // Fetch public settings for field requirements const { data: publicSettings } = useQuery({ queryKey: ['public-settings'], - queryFn: () => settingsService.getPublicSettings() + queryFn: () => publicSettingsService.getPublicSettings() }); // Get field requirements (default to true if not set) diff --git a/frontend/src/pages/admin/EventDetailsPage.tsx b/frontend/src/pages/admin/EventDetailsPage.tsx index be477b42..f426b71d 100644 --- a/frontend/src/pages/admin/EventDetailsPage.tsx +++ b/frontend/src/pages/admin/EventDetailsPage.tsx @@ -60,7 +60,7 @@ import { buildResourceUrl } from '../../utils/url'; import { isGalleryPublic, normalizeRequirePassword } from '../../utils/accessControl'; import { archiveService } from '../../services/archive.service'; import { externalMediaService } from '../../services/externalMedia.service'; -import { photosService, AdminPhoto, type PhotoFilters as PhotoFilterParams, type FeedbackFilters, type FilterSummary } from '../../services/photos.service'; +import { photosService, AdminPhoto, type PhotoFilters as PhotoFilterParams, type FeedbackFilters } from '../../services/photos.service'; import { feedbackService, FeedbackSettings as FeedbackSettingsType } from '../../services/feedback.service'; import { cssTemplatesService, type EnabledTemplate } from '../../services/cssTemplates.service'; import { ThemeConfig, GALLERY_THEME_PRESETS } from '../../types/theme.types'; @@ -337,28 +337,6 @@ export const EventDetailsPage: React.FC = () => { }, }); - const applyThemeMutation = useMutation({ - mutationFn: async ({ theme, presetName }: { theme: ThemeConfig; presetName: string }) => { - if (!id) { - throw new Error('Missing event identifier'); - } - - const colorThemeValue = presetName && presetName !== 'custom' - ? presetName - : JSON.stringify(theme); - - return eventsService.updateEvent(parseInt(id), { color_theme: colorThemeValue }); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['admin-event', id] }); - toast.success(t('branding.themeApplied', 'Theme updated')); - }, - onError: (error: any) => { - const message = error?.response?.data?.error || t('branding.themeApplyError', 'Failed to apply theme'); - toast.error(message); - } - }); - // Archive mutation const archiveMutation = useMutation({ mutationFn: () => eventsService.archiveEvent(parseInt(id!)), @@ -470,7 +448,7 @@ export const EventDetailsPage: React.FC = () => { try { const formData = new FormData(); formData.append('logo', file); - const response = await api.post(`/admin/events/${id}/logo`, formData, { + await api.post(`/admin/events/${id}/logo`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }); toast.success(t('events.eventLogoUploaded', 'Event logo uploaded successfully')); @@ -1745,6 +1723,7 @@ export const EventDetailsPage: React.FC = () => { queryClient.invalidateQueries({ queryKey: ['admin-event', id] }); }} onSelectionChange={setSelectedPhotoIds} + categories={categories} /> )} diff --git a/frontend/src/pages/admin/EventTypesPage.tsx b/frontend/src/pages/admin/EventTypesPage.tsx index 81453c45..baca0d05 100644 --- a/frontend/src/pages/admin/EventTypesPage.tsx +++ b/frontend/src/pages/admin/EventTypesPage.tsx @@ -277,7 +277,7 @@ export const EventTypesPage: React.FC = () => { {showCreateModal && ( setShowCreateModal(false)} - onSubmit={(data) => createMutation.mutate(data)} + onSubmit={(data) => createMutation.mutate(data as CreateEventTypeData)} isLoading={createMutation.isPending} /> )} diff --git a/frontend/src/pages/gallery/PreviewPage.tsx b/frontend/src/pages/gallery/PreviewPage.tsx index f3f1c2dc..3963987b 100644 --- a/frontend/src/pages/gallery/PreviewPage.tsx +++ b/frontend/src/pages/gallery/PreviewPage.tsx @@ -30,7 +30,7 @@ const mockCategories = [ export const PreviewPage: React.FC = () => { const { setTheme } = useTheme(); const [brandingSettings, setBrandingSettings] = useState(null); - const [selectedCategoryId, setSelectedCategoryId] = useState(null); + const [selectedCategoryId, setSelectedCategoryId] = useState(null); const [searchTerm, setSearchTerm] = useState(''); const [sortBy, setSortBy] = useState<'date' | 'name' | 'size' | 'rating'>('date'); diff --git a/frontend/src/services/publicSettings.service.ts b/frontend/src/services/publicSettings.service.ts index 0c7eb871..5e6835bb 100644 --- a/frontend/src/services/publicSettings.service.ts +++ b/frontend/src/services/publicSettings.service.ts @@ -23,6 +23,12 @@ export interface PublicSettings { umami_url: string | null; umami_website_id: string | null; umami_share_url: string | null; + // Event field requirements + event_require_customer_name?: boolean; + event_require_customer_email?: boolean; + event_require_admin_email?: boolean; + event_require_event_date?: boolean; + event_require_expiration?: boolean; } export const publicSettingsService = { diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 75c6815d..d16624c0 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -45,6 +45,12 @@ export interface Event { hero_logo_visible?: boolean; hero_logo_size?: 'small' | 'medium' | 'large' | 'xlarge'; hero_logo_position?: 'top' | 'center' | 'bottom'; + hero_logo_url?: string | null; + // Header style settings (decoupled from layout) + header_style?: 'hero' | 'standard' | 'minimal' | 'none'; + hero_divider_style?: 'wave' | 'straight' | 'angle' | 'curve' | 'none'; + // CSS Template + css_template_id?: number | null; } export interface GalleryInfo { @@ -119,6 +125,14 @@ export interface GalleryData { enable_devtools_protection?: boolean; fragmentation_level?: number; overlay_protection?: boolean; + // Hero logo customization fields + hero_logo_visible?: boolean; + hero_logo_size?: 'small' | 'medium' | 'large' | 'xlarge'; + hero_logo_position?: 'top' | 'center' | 'bottom'; + hero_logo_url?: string | null; + // Header style settings (decoupled from layout) + header_style?: 'hero' | 'standard' | 'minimal' | 'none'; + hero_divider_style?: 'wave' | 'straight' | 'angle' | 'curve' | 'none'; }; categories?: PhotoCategory[]; photos: Photo[]; @@ -159,6 +173,7 @@ export interface AdminUser { lastLogin?: string | null; lastLoginIp?: string | null; createdAt?: string; + updatedAt?: string; createdByUsername?: string; } diff --git a/frontend/src/types/theme.types.ts b/frontend/src/types/theme.types.ts index c608c8d9..4b6d20f5 100644 --- a/frontend/src/types/theme.types.ts +++ b/frontend/src/types/theme.types.ts @@ -1,5 +1,11 @@ // Gallery Layout Types -export type GalleryLayoutType = 'grid' | 'masonry' | 'carousel' | 'timeline' | 'hero' | 'mosaic'; +export type GalleryLayoutType = 'grid' | 'masonry' | 'carousel' | 'timeline' | 'mosaic'; + +// Header Style Types (decoupled from layout) +export type HeaderStyleType = 'hero' | 'standard' | 'minimal' | 'none'; + +// Hero Divider Styles +export type HeroDividerStyle = 'wave' | 'straight' | 'angle' | 'curve' | 'none'; export interface GalleryLayoutSettings { // Common settings @@ -15,10 +21,16 @@ export interface GalleryLayoutSettings { }; // Masonry specific - masonryMode?: 'columns' | 'rows' | 'flickr' | 'quilted'; // columns = Pinterest-style, rows = justified rows, flickr = Flickr justified-layout, quilted = mixed sizes based on aspect ratio + masonryMode?: 'columns' | 'rows' | 'flickr' | 'quilted' | 'justified'; // columns = Pinterest-style, rows = justified rows, flickr = Flickr justified-layout, quilted = mixed sizes, justified = Knuth-Plass masonryGutter?: number; masonryRowHeight?: number; // Target row height for rows mode (150-400) masonryLastRowBehavior?: 'justify' | 'left' | 'center'; // How to align incomplete last row + + // Justified layout specific + justifiedRowHeight?: number; + justifiedLastRowBehavior?: 'justify' | 'left' | 'center'; + justifiedShowHero?: boolean; + justifiedHeroHeight?: 'small' | 'medium' | 'large'; // Carousel specific carouselAutoplay?: boolean; @@ -58,9 +70,13 @@ export interface ThemeConfig { // Gallery Layout galleryLayout?: GalleryLayoutType; gallerySettings?: GalleryLayoutSettings; - - // Header/Footer - headerStyle?: 'minimal' | 'standard' | 'full'; + + // Header Style (decoupled from layout) + headerStyle?: HeaderStyleType; + heroDividerStyle?: HeroDividerStyle; + + // Legacy Header/Footer (kept for backward compatibility) + legacyHeaderStyle?: 'minimal' | 'standard' | 'full'; footerStyle?: 'minimal' | 'standard' | 'full'; showEventInfo?: boolean; showBranding?: boolean; @@ -115,14 +131,16 @@ export const GALLERY_THEME_PRESETS: Record = { headingFontFamily: 'Playfair Display, serif', borderRadius: 'lg', shadowStyle: 'subtle', - galleryLayout: 'hero', + galleryLayout: 'grid', + headerStyle: 'hero', + heroDividerStyle: 'wave', gallerySettings: { spacing: 'relaxed', photoAnimation: 'scale', photoShape: 'rounded', heroOverlayOpacity: 0.3 }, - headerStyle: 'full', + legacyHeaderStyle: 'full', footerStyle: 'minimal' }, isPreset: true @@ -172,13 +190,13 @@ export const GALLERY_THEME_PRESETS: Record = { carouselInterval: 5000, carouselShowThumbnails: true }, - headerStyle: 'full', + headerStyle: 'standard', footerStyle: 'standard', backgroundPattern: 'dots' }, isPreset: true }, - + corporateTimeline: { name: 'Corporate Timeline', description: 'Professional chronological layout', diff --git a/frontend/src/utils/themeMigration.ts b/frontend/src/utils/themeMigration.ts new file mode 100644 index 00000000..8fcda1ac --- /dev/null +++ b/frontend/src/utils/themeMigration.ts @@ -0,0 +1,61 @@ +import type { ThemeConfig, HeaderStyleType, HeroDividerStyle, GalleryLayoutType } from '../types/theme.types'; + +/** + * Migrates legacy theme configurations that used 'hero' as a galleryLayout + * to the new decoupled headerStyle + galleryLayout system. + * + * This ensures backward compatibility with existing events that have + * 'hero' set as their galleryLayout. + */ +export function migrateThemeConfig(theme: ThemeConfig): ThemeConfig { + if (!theme) return theme; + + // Check if this theme uses the legacy 'hero' layout + if ((theme.galleryLayout as string) === 'hero') { + return { + ...theme, + headerStyle: 'hero' as HeaderStyleType, + galleryLayout: 'grid' as GalleryLayoutType, + heroDividerStyle: (theme.heroDividerStyle || 'wave') as HeroDividerStyle, + }; + } + + // If headerStyle is not set but galleryLayout is valid, default to 'standard' + if (!theme.headerStyle && theme.galleryLayout) { + return { + ...theme, + headerStyle: 'standard' as HeaderStyleType, + }; + } + + return theme; +} + +/** + * Parses and migrates a color_theme JSON string from the database. + * Handles both JSON strings and legacy preset names. + */ +export function parseAndMigrateTheme(colorTheme: string | null | undefined): ThemeConfig | null { + if (!colorTheme) return null; + + try { + // Check if it's a JSON string + if (colorTheme.startsWith('{')) { + const parsed = JSON.parse(colorTheme); + return migrateThemeConfig(parsed); + } + + // Legacy preset name - return null to let the caller handle preset lookup + return null; + } catch { + // Invalid JSON + return null; + } +} + +/** + * Checks if a theme configuration needs migration from legacy hero layout. + */ +export function needsMigration(theme: ThemeConfig): boolean { + return (theme.galleryLayout as string) === 'hero'; +} From eca36c70a23f18f937a9f5bddeff855e18f364c3 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Sun, 1 Feb 2026 22:48:08 +0100 Subject: [PATCH 3/3] feat: add bulk category editing for photos (#157) Add BulkCategoryModal component that allows selecting multiple photos and moving them to a different category in one operation. --- .../components/admin/BulkCategoryModal.tsx | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 frontend/src/components/admin/BulkCategoryModal.tsx diff --git a/frontend/src/components/admin/BulkCategoryModal.tsx b/frontend/src/components/admin/BulkCategoryModal.tsx new file mode 100644 index 00000000..9017c1ba --- /dev/null +++ b/frontend/src/components/admin/BulkCategoryModal.tsx @@ -0,0 +1,102 @@ +import React, { useState } from 'react'; +import { FolderOpen, X } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { Button, Card } from '../common'; + +interface CategoryOption { + id: number; + name: string; +} + +interface BulkCategoryModalProps { + isOpen: boolean; + onClose: () => void; + onConfirm: (categoryId: number | null) => Promise; + photoCount: number; + categories: CategoryOption[]; + isLoading: boolean; +} + +export const BulkCategoryModal: React.FC = ({ + isOpen, + onClose, + onConfirm, + photoCount, + categories, + isLoading, +}) => { + const { t } = useTranslation(); + const [selectedCategoryId, setSelectedCategoryId] = useState(null); + + if (!isOpen) return null; + + const handleConfirm = async () => { + await onConfirm(selectedCategoryId); + }; + + const handleClose = () => { + setSelectedCategoryId(null); + onClose(); + }; + + return ( +
+ +
+
+

+ {t('photos.moveToCategory', 'Move {{count}} photos to category', { count: photoCount })} +

+ +
+ +
+ + +
+ +
+ + +
+
+
+
+ ); +}; + +BulkCategoryModal.displayName = 'BulkCategoryModal';