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
This commit is contained in:
Paul Nothaft
2026-02-01 22:44:28 +01:00
parent 397d33a95a
commit 7b8d8bd92b
35 changed files with 907 additions and 533 deletions
@@ -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<AdminPhotoGridProps> = ({
@@ -21,13 +28,16 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
eventId,
onPhotoClick,
onPhotosDeleted,
onSelectionChange
onSelectionChange,
categories = []
}) => {
const { t } = useTranslation();
const [selectedPhotos, setSelectedPhotos] = useState<Set<number>>(new Set());
const [isSelectionMode, setIsSelectionMode] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [deletingPhotos, setDeletingPhotos] = useState<Set<number>>(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<AdminPhotoGridProps> = ({
}
};
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 (
<div>
{/* Action Bar */}
@@ -154,6 +193,14 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
<span className="text-sm text-neutral-600">
{t('gallery.photosSelected', { count: selectedPhotos.size })}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setIsCategoryModalOpen(true)}
leftIcon={<FolderOpen className="w-4 h-4" />}
>
{t('photos.moveToCategory', 'Move to Category')}
</Button>
<button
onClick={handleDeleteSelected}
disabled={isDeleting}
@@ -309,6 +356,16 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
<p className="text-neutral-500">{t('gallery.noMedia', 'No media uploaded yet')}</p>
</div>
)}
{/* Bulk Category Modal */}
<BulkCategoryModal
isOpen={isCategoryModalOpen}
onClose={() => setIsCategoryModalOpen(false)}
onConfirm={handleMoveToCategory}
photoCount={selectedPhotos.size}
categories={categories}
isLoading={isUpdatingCategory}
/>
</div>
);
};
@@ -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';
@@ -28,7 +28,7 @@ interface EventRenameDialogProps {
export const EventRenameDialog: React.FC<EventRenameDialogProps> = ({
isOpen,
eventName,
eventId,
eventId: _eventId,
customerEmail,
onClose,
onRename,
@@ -151,18 +151,6 @@ export const GalleryPreview: React.FC<GalleryPreviewProps> = ({
</div>
);
case 'hero':
return (
<div className="space-y-3">
<PreviewPhoto photo={mockPhotos[0]} aspectRatio="aspect-[16/9]" className="w-full" />
<div className={`grid grid-cols-4 ${gapClass}`}>
{mockPhotos.slice(1, 5).map((photo) => (
<PreviewPhoto key={photo.id} photo={photo} />
))}
</div>
</div>
);
case 'mosaic':
return (
<div className={`grid grid-cols-4 grid-rows-3 ${gapClass} h-64`}>
@@ -90,7 +90,7 @@ export const PhotoFilterPanel: React.FC<PhotoFilterPanelProps> = ({
>
{RATING_OPTIONS.map(option => (
<option key={option.label} value={option.value ?? ''}>
{t(option.label, option.label.split('.').pop())}
{t(option.label, { defaultValue: option.label.split('.').pop() })}
</option>
))}
</select>
@@ -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<GalleryLayoutType, React.ReactNode> = {
masonry: <Layers className="w-5 h-5" />,
carousel: <Play className="w-5 h-5" />,
timeline: <Clock className="w-5 h-5" />,
hero: <Image className="w-5 h-5" />,
mosaic: <LayoutGrid className="w-5 h-5" />
};
const headerStyleIcons: Record<HeaderStyleType, React.ReactNode> = {
hero: <Image className="w-5 h-5" />,
standard: <Layout className="w-5 h-5" />,
minimal: <Minimize2 className="w-5 h-5" />,
none: <EyeOff className="w-5 h-5" />
};
const dividerStylePreviews: Record<HeroDividerStyle, React.ReactNode> = {
wave: (
<svg className="w-full h-6" viewBox="0 0 100 24" preserveAspectRatio="none">
<path d="M0,12 C12,18 37,6 50,12 C63,18 88,6 100,12 L100,24 L0,24 Z" fill="currentColor" className="text-neutral-300" />
</svg>
),
straight: (
<svg className="w-full h-6" viewBox="0 0 100 24" preserveAspectRatio="none">
<rect x="0" y="12" width="100" height="12" fill="currentColor" className="text-neutral-300" />
</svg>
),
angle: (
<svg className="w-full h-6" viewBox="0 0 100 24" preserveAspectRatio="none">
<path d="M0,24 L100,8 L100,24 Z" fill="currentColor" className="text-neutral-300" />
</svg>
),
curve: (
<svg className="w-full h-6" viewBox="0 0 100 24" preserveAspectRatio="none">
<path d="M0,16 Q50,0 100,16 L100,24 L0,24 Z" fill="currentColor" className="text-neutral-300" />
</svg>
),
none: (
<svg className="w-full h-6" viewBox="0 0 100 24" preserveAspectRatio="none">
<rect x="0" y="0" width="100" height="24" fill="currentColor" className="text-neutral-100" />
<text x="50" y="16" textAnchor="middle" fontSize="10" fill="currentColor" className="text-neutral-400">No divider</text>
</svg>
)
};
// Layout descriptions will use translation keys
export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = ({
@@ -439,6 +474,85 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
</Card>
)}
{/* Header Style - Decoupled from Layout */}
{showGalleryLayouts && (
<Card className="p-6">
<h3 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
<ImageIcon className="w-5 h-5" />
{t('branding.headerStyle', 'Header Style')}
</h3>
<p className="text-sm text-neutral-600 mb-4">
{t('branding.headerStyleDescription', 'Choose how the gallery header appears. The header style is independent of the photo layout.')}
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{(Object.keys(headerStyleIcons) as HeaderStyleType[]).map((style) => (
<button
key={style}
onClick={() => handleChange('headerStyle', style)}
className={`relative p-4 rounded-lg border-2 transition-all ${
(localTheme.headerStyle || 'standard') === style
? 'border-primary-600 bg-primary-50'
: 'border-neutral-200 hover:border-neutral-300'
}`}
>
<div className="flex flex-col items-center text-center">
<div className="mb-2 text-neutral-700">
{headerStyleIcons[style]}
</div>
<span className="font-medium text-sm capitalize">
{t(`branding.headerStyleOptions.${style}`, style)}
</span>
<span className="text-xs text-neutral-600 mt-1">
{t(`branding.headerStyleDescriptions.${style}`, '')}
</span>
</div>
{(localTheme.headerStyle || 'standard') === style && (
<Check className="absolute top-2 right-2 w-4 h-4 text-primary-600" />
)}
</button>
))}
</div>
{/* Divider Style - Only show when hero header is selected */}
{localTheme.headerStyle === 'hero' && (
<div className="mt-6 pt-6 border-t border-neutral-200">
<h4 className="font-medium text-sm text-neutral-700 mb-3">
{t('branding.heroDividerStyle', 'Divider Style')}
</h4>
<p className="text-xs text-neutral-600 mb-4">
{t('branding.heroDividerDescription', 'Choose how the transition between the hero image and gallery content looks.')}
</p>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
{(Object.keys(dividerStylePreviews) as HeroDividerStyle[]).map((divider) => (
<button
key={divider}
onClick={() => handleChange('heroDividerStyle', divider)}
className={`relative p-3 rounded-lg border-2 transition-all ${
(localTheme.heroDividerStyle || 'wave') === divider
? 'border-primary-600 bg-primary-50'
: 'border-neutral-200 hover:border-neutral-300'
}`}
>
<div className="flex flex-col items-center">
<div className="w-full mb-2 bg-neutral-800 rounded-t overflow-hidden">
<div className="h-8"></div>
{dividerStylePreviews[divider]}
</div>
<span className="text-xs font-medium capitalize">
{t(`branding.dividerOptions.${divider}`, divider)}
</span>
</div>
{(localTheme.heroDividerStyle || 'wave') === divider && (
<Check className="absolute top-1 right-1 w-3 h-3 text-primary-600" />
)}
</button>
))}
</div>
</div>
)}
</Card>
)}
{/* Color Customization */}
<Card className="p-6">
<h3 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
+8 -11
View File
@@ -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<GalleryLayoutType, React.ReactNode> = {
masonry: <Layers className="w-4 h-4" />,
carousel: <Play className="w-4 h-4" />,
timeline: <Clock className="w-4 h-4" />,
hero: <Image className="w-4 h-4" />,
mosaic: <LayoutGrid className="w-4 h-4" />,
justified: <Layers className="w-4 h-4" />
mosaic: <LayoutGrid className="w-4 h-4" />
};
export const ThemeDisplay: React.FC<ThemeDisplayProps> = ({
@@ -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<GalleryLayoutType, React.ReactNode> = {
masonry: <Layers className="w-4 h-4" />,
carousel: <Play className="w-4 h-4" />,
timeline: <Clock className="w-4 h-4" />,
hero: <Image className="w-4 h-4" />,
mosaic: <LayoutGrid className="w-4 h-4" />,
justified: <Layers className="w-4 h-4" />
mosaic: <LayoutGrid className="w-4 h-4" />
};
export const ThemeEditorModal: React.FC<ThemeEditorModalProps> = ({