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:
@@ -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');
|
||||||
|
};
|
||||||
@@ -193,7 +193,10 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
|||||||
// Hero logo settings
|
// Hero logo settings
|
||||||
body('hero_logo_visible').optional().isBoolean(),
|
body('hero_logo_visible').optional().isBoolean(),
|
||||||
body('hero_logo_size').optional().isIn(['small', 'medium', 'large', 'xlarge']),
|
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) => {
|
], async (req, res) => {
|
||||||
try {
|
try {
|
||||||
logger.debug('Create event request body', { body: req.body });
|
logger.debug('Create event request body', { body: req.body });
|
||||||
@@ -236,7 +239,10 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
|||||||
// Hero logo settings
|
// Hero logo settings
|
||||||
hero_logo_visible = true,
|
hero_logo_visible = true,
|
||||||
hero_logo_size = 'medium',
|
hero_logo_size = 'medium',
|
||||||
hero_logo_position = 'top'
|
hero_logo_position = 'top',
|
||||||
|
// Header style settings
|
||||||
|
header_style = 'standard',
|
||||||
|
hero_divider_style = 'wave'
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
const customerName = getCustomerNameFromPayload(req.body);
|
const customerName = getCustomerNameFromPayload(req.body);
|
||||||
@@ -377,7 +383,9 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
|||||||
css_template_id: css_template_id || null,
|
css_template_id: css_template_id || null,
|
||||||
hero_logo_visible: formatBoolean(hero_logo_visible !== undefined ? hero_logo_visible : true),
|
hero_logo_visible: formatBoolean(hero_logo_visible !== undefined ? hero_logo_visible : true),
|
||||||
hero_logo_size: hero_logo_size || 'medium',
|
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');
|
}).returning('id');
|
||||||
|
|
||||||
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
|
// 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
|
// Hero logo settings
|
||||||
body('hero_logo_visible').optional().isBoolean(),
|
body('hero_logo_visible').optional().isBoolean(),
|
||||||
body('hero_logo_size').optional().isIn(['small', 'medium', 'large', 'xlarge']),
|
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) => {
|
], async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const errors = validationResult(req);
|
const errors = validationResult(req);
|
||||||
|
|||||||
@@ -647,9 +647,7 @@ router.post('/:eventId/photos/bulk-update', adminAuth, requirePermission('photos
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Prepare update data
|
// Prepare update data
|
||||||
const updateData = {
|
const updateData = {};
|
||||||
updated_at: new Date()
|
|
||||||
};
|
|
||||||
|
|
||||||
if (updates.category_id !== undefined) {
|
if (updates.category_id !== undefined) {
|
||||||
// Handle type-based categories ('individual' or 'collage')
|
// Handle type-based categories ('individual' or 'collage')
|
||||||
|
|||||||
@@ -119,7 +119,9 @@ router.get('/:slug/info', async (req, res) => {
|
|||||||
'hero_logo_visible',
|
'hero_logo_visible',
|
||||||
'hero_logo_size',
|
'hero_logo_size',
|
||||||
'hero_logo_position',
|
'hero_logo_position',
|
||||||
'hero_logo_url'
|
'hero_logo_url',
|
||||||
|
'header_style',
|
||||||
|
'hero_divider_style'
|
||||||
)
|
)
|
||||||
.first();
|
.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_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_size: event.hero_logo_size || 'medium',
|
||||||
hero_logo_position: event.hero_logo_position || 'top',
|
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) {
|
} catch (error) {
|
||||||
console.error('Error fetching gallery info:', 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_size: req.event.hero_logo_size || 'medium',
|
||||||
hero_logo_position: req.event.hero_logo_position || 'top',
|
hero_logo_position: req.event.hero_logo_position || 'top',
|
||||||
hero_logo_url: req.event.hero_logo_url || null,
|
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
|
...protectionSettings
|
||||||
},
|
},
|
||||||
categories: categories,
|
categories: categories,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
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 { toast } from 'react-toastify';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
@@ -7,6 +7,12 @@ import { AdminPhoto } from '../../services/photos.service';
|
|||||||
import { photosService } from '../../services/photos.service';
|
import { photosService } from '../../services/photos.service';
|
||||||
import { Button } from '../common';
|
import { Button } from '../common';
|
||||||
import { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
|
import { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
|
||||||
|
import { BulkCategoryModal } from './BulkCategoryModal';
|
||||||
|
|
||||||
|
interface CategoryOption {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface AdminPhotoGridProps {
|
interface AdminPhotoGridProps {
|
||||||
photos: AdminPhoto[];
|
photos: AdminPhoto[];
|
||||||
@@ -14,6 +20,7 @@ interface AdminPhotoGridProps {
|
|||||||
onPhotoClick: (photo: AdminPhoto, index: number) => void;
|
onPhotoClick: (photo: AdminPhoto, index: number) => void;
|
||||||
onPhotosDeleted: () => void;
|
onPhotosDeleted: () => void;
|
||||||
onSelectionChange?: (selectedIds: number[]) => void;
|
onSelectionChange?: (selectedIds: number[]) => void;
|
||||||
|
categories?: CategoryOption[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
||||||
@@ -21,13 +28,16 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
|||||||
eventId,
|
eventId,
|
||||||
onPhotoClick,
|
onPhotoClick,
|
||||||
onPhotosDeleted,
|
onPhotosDeleted,
|
||||||
onSelectionChange
|
onSelectionChange,
|
||||||
|
categories = []
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [selectedPhotos, setSelectedPhotos] = useState<Set<number>>(new Set());
|
const [selectedPhotos, setSelectedPhotos] = useState<Set<number>>(new Set());
|
||||||
const [isSelectionMode, setIsSelectionMode] = useState(false);
|
const [isSelectionMode, setIsSelectionMode] = useState(false);
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
const [deletingPhotos, setDeletingPhotos] = useState<Set<number>>(new Set());
|
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) => {
|
const handlePhotoSelect = (photoId: number, e?: React.MouseEvent) => {
|
||||||
if (e) {
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Action Bar */}
|
{/* Action Bar */}
|
||||||
@@ -154,6 +193,14 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
|||||||
<span className="text-sm text-neutral-600">
|
<span className="text-sm text-neutral-600">
|
||||||
{t('gallery.photosSelected', { count: selectedPhotos.size })}
|
{t('gallery.photosSelected', { count: selectedPhotos.size })}
|
||||||
</span>
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsCategoryModalOpen(true)}
|
||||||
|
leftIcon={<FolderOpen className="w-4 h-4" />}
|
||||||
|
>
|
||||||
|
{t('photos.moveToCategory', 'Move to Category')}
|
||||||
|
</Button>
|
||||||
<button
|
<button
|
||||||
onClick={handleDeleteSelected}
|
onClick={handleDeleteSelected}
|
||||||
disabled={isDeleting}
|
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>
|
<p className="text-neutral-500">{t('gallery.noMedia', 'No media uploaded yet')}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Bulk Category Modal */}
|
||||||
|
<BulkCategoryModal
|
||||||
|
isOpen={isCategoryModalOpen}
|
||||||
|
onClose={() => setIsCategoryModalOpen(false)}
|
||||||
|
onConfirm={handleMoveToCategory}
|
||||||
|
photoCount={selectedPhotos.size}
|
||||||
|
categories={categories}
|
||||||
|
isLoading={isUpdatingCategory}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { toast } from 'react-toastify';
|
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 { Button, Card, Loading } from '../common';
|
||||||
import { cssTemplatesService, CssTemplate } from '../../services/cssTemplates.service';
|
import { cssTemplatesService, CssTemplate } from '../../services/cssTemplates.service';
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ interface EventRenameDialogProps {
|
|||||||
export const EventRenameDialog: React.FC<EventRenameDialogProps> = ({
|
export const EventRenameDialog: React.FC<EventRenameDialogProps> = ({
|
||||||
isOpen,
|
isOpen,
|
||||||
eventName,
|
eventName,
|
||||||
eventId,
|
eventId: _eventId,
|
||||||
customerEmail,
|
customerEmail,
|
||||||
onClose,
|
onClose,
|
||||||
onRename,
|
onRename,
|
||||||
|
|||||||
@@ -151,18 +151,6 @@ export const GalleryPreview: React.FC<GalleryPreviewProps> = ({
|
|||||||
</div>
|
</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':
|
case 'mosaic':
|
||||||
return (
|
return (
|
||||||
<div className={`grid grid-cols-4 grid-rows-3 ${gapClass} h-64`}>
|
<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 => (
|
{RATING_OPTIONS.map(option => (
|
||||||
<option key={option.label} value={option.value ?? ''}>
|
<option key={option.label} value={option.value ?? ''}>
|
||||||
{t(option.label, option.label.split('.').pop())}
|
{t(option.label, { defaultValue: option.label.split('.').pop() })}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
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 { 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 type { EnabledTemplate } from '../../services/cssTemplates.service';
|
||||||
// import { settingsService } from '../../services/settings.service';
|
// import { settingsService } from '../../services/settings.service';
|
||||||
// import { toast } from 'react-toastify';
|
// import { toast } from 'react-toastify';
|
||||||
@@ -28,10 +28,45 @@ const layoutIcons: Record<GalleryLayoutType, React.ReactNode> = {
|
|||||||
masonry: <Layers className="w-5 h-5" />,
|
masonry: <Layers className="w-5 h-5" />,
|
||||||
carousel: <Play className="w-5 h-5" />,
|
carousel: <Play className="w-5 h-5" />,
|
||||||
timeline: <Clock 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" />
|
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
|
// Layout descriptions will use translation keys
|
||||||
|
|
||||||
export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = ({
|
export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = ({
|
||||||
@@ -439,6 +474,85 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
|||||||
</Card>
|
</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 */}
|
{/* Color Customization */}
|
||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<h3 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
|
<h3 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
Layers,
|
Layers,
|
||||||
Play,
|
Play,
|
||||||
Clock,
|
Clock,
|
||||||
Image,
|
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
Layout
|
Layout
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
@@ -25,9 +24,7 @@ const layoutIcons: Record<GalleryLayoutType, React.ReactNode> = {
|
|||||||
masonry: <Layers className="w-4 h-4" />,
|
masonry: <Layers className="w-4 h-4" />,
|
||||||
carousel: <Play className="w-4 h-4" />,
|
carousel: <Play className="w-4 h-4" />,
|
||||||
timeline: <Clock 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" />
|
||||||
mosaic: <LayoutGrid className="w-4 h-4" />,
|
|
||||||
justified: <Layers className="w-4 h-4" />
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ThemeDisplay: React.FC<ThemeDisplayProps> = ({
|
export const ThemeDisplay: React.FC<ThemeDisplayProps> = ({
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
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 { Button } from '../common';
|
||||||
import { ThemeCustomizerEnhanced } from './ThemeCustomizerEnhanced';
|
import { ThemeCustomizerEnhanced } from './ThemeCustomizerEnhanced';
|
||||||
import { GalleryPreview } from './GalleryPreview';
|
import { GalleryPreview } from './GalleryPreview';
|
||||||
@@ -21,9 +21,7 @@ const layoutIcons: Record<GalleryLayoutType, React.ReactNode> = {
|
|||||||
masonry: <Layers className="w-4 h-4" />,
|
masonry: <Layers className="w-4 h-4" />,
|
||||||
carousel: <Play className="w-4 h-4" />,
|
carousel: <Play className="w-4 h-4" />,
|
||||||
timeline: <Clock 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" />
|
||||||
mosaic: <LayoutGrid className="w-4 h-4" />,
|
|
||||||
justified: <Layers className="w-4 h-4" />
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ThemeEditorModal: React.FC<ThemeEditorModalProps> = ({
|
export const ThemeEditorModal: React.FC<ThemeEditorModalProps> = ({
|
||||||
|
|||||||
@@ -8,13 +8,14 @@ import { Button } from '../common';
|
|||||||
import { DynamicFavicon } from '../common/DynamicFavicon';
|
import { DynamicFavicon } from '../common/DynamicFavicon';
|
||||||
import { useTheme } from '../../contexts/ThemeContext';
|
import { useTheme } from '../../contexts/ThemeContext';
|
||||||
import { buildResourceUrl } from '../../utils/url';
|
import { buildResourceUrl } from '../../utils/url';
|
||||||
|
import type { HeaderStyleType } from '../../types/theme.types';
|
||||||
|
|
||||||
interface GalleryLayoutProps {
|
interface GalleryLayoutProps {
|
||||||
event: {
|
event: {
|
||||||
event_name: string;
|
event_name: string;
|
||||||
event_type?: string;
|
event_type?: string;
|
||||||
event_date?: string;
|
event_date?: string | null;
|
||||||
expires_at?: string;
|
expires_at?: string | null;
|
||||||
};
|
};
|
||||||
brandingSettings?: {
|
brandingSettings?: {
|
||||||
company_name?: string;
|
company_name?: string;
|
||||||
@@ -57,7 +58,12 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
|||||||
const { format } = useLocalizedDate();
|
const { format } = useLocalizedDate();
|
||||||
const { theme } = useTheme();
|
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 fontFamily = theme.fontFamily || 'Inter, sans-serif';
|
||||||
const headingFontFamily = theme.headingFontFamily || fontFamily;
|
const headingFontFamily = theme.headingFontFamily || fontFamily;
|
||||||
|
|
||||||
@@ -123,9 +129,9 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
|||||||
<DynamicFavicon />
|
<DynamicFavicon />
|
||||||
|
|
||||||
{/* Header structure */}
|
{/* Header structure */}
|
||||||
<header className={`gallery-header bg-white border-b border-neutral-200 sticky top-0 z-40 ${isNonGridLayout || theme.galleryLayout === 'hero' ? 'shadow-sm' : ''}`}>
|
<header className={`gallery-header bg-white border-b border-neutral-200 sticky top-0 z-40 ${isNonGridLayout || isHeroHeader ? 'shadow-sm' : ''}`}>
|
||||||
{/* For non-grid layouts (excluding hero) - keep the current structure */}
|
{/* For non-grid layouts - keep the current structure */}
|
||||||
{isNonGridLayout && (
|
{isNonGridLayout && !isHeroHeader && (
|
||||||
<div className="bg-neutral-50 border-b border-neutral-200">
|
<div className="bg-neutral-50 border-b border-neutral-200">
|
||||||
<div className="container py-2">
|
<div className="container py-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -170,8 +176,8 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* For grid layout - everything in one bar */}
|
{/* For grid layout - everything in one bar (standard header) */}
|
||||||
{!isNonGridLayout && theme.galleryLayout !== 'hero' && (
|
{!isNonGridLayout && !isHeroHeader && (
|
||||||
<div className="container py-3">
|
<div className="container py-3">
|
||||||
<div className="flex items-center justify-between gap-2 sm:gap-4">
|
<div className="flex items-center justify-between gap-2 sm:gap-4">
|
||||||
{/* Left side - Menu button, Logo */}
|
{/* Left side - Menu button, Logo */}
|
||||||
@@ -292,8 +298,8 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* For hero layout - minimal header with just menu and logout */}
|
{/* For hero header style - minimal header with just menu and logout */}
|
||||||
{theme.galleryLayout === 'hero' && (
|
{isHeroHeader && (
|
||||||
<div className="container py-3">
|
<div className="container py-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
{/* Left side - Menu button */}
|
{/* Left side - Menu button */}
|
||||||
@@ -337,8 +343,8 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
|||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Hero Header for non-grid layouts (excluding hero layout which has its own) */}
|
{/* Hero Header for non-grid layouts when using standard header style */}
|
||||||
{isNonGridLayout && (
|
{isNonGridLayout && !isHeroHeader && (
|
||||||
<div
|
<div
|
||||||
className="gallery-hero relative text-white overflow-hidden"
|
className="gallery-hero relative text-white overflow-hidden"
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -30,10 +30,10 @@ interface GalleryViewProps {
|
|||||||
id: number;
|
id: number;
|
||||||
event_name: string;
|
event_name: string;
|
||||||
event_type: string;
|
event_type: string;
|
||||||
event_date: string;
|
event_date: string | null;
|
||||||
welcome_message?: string;
|
welcome_message?: string;
|
||||||
color_theme?: string;
|
color_theme?: string;
|
||||||
expires_at: string;
|
expires_at: string | null;
|
||||||
allow_user_uploads?: boolean;
|
allow_user_uploads?: boolean;
|
||||||
upload_category_id?: number | null;
|
upload_category_id?: number | null;
|
||||||
hero_photo_id?: number | null;
|
hero_photo_id?: number | null;
|
||||||
@@ -604,7 +604,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
headerExtra={(() => {
|
headerExtra={(() => {
|
||||||
const items = [];
|
const items = [];
|
||||||
|
|
||||||
if (daysUntilExpiration !== null && daysUntilExpiration <= 1 && daysUntilExpiration > 0) {
|
if (daysUntilExpiration !== null && daysUntilExpiration <= 1 && daysUntilExpiration > 0 && event.expires_at) {
|
||||||
items.push(
|
items.push(
|
||||||
<CountdownTimer key="countdown" expiresAt={event.expires_at} className="mr-2" />
|
<CountdownTimer key="countdown" expiresAt={event.expires_at} className="mr-2" />
|
||||||
);
|
);
|
||||||
@@ -632,7 +632,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
})()}
|
})()}
|
||||||
>
|
>
|
||||||
{/* Expiration Banner */}
|
{/* Expiration Banner */}
|
||||||
{showUrgentWarning && (
|
{showUrgentWarning && event.expires_at && (
|
||||||
<ExpirationBanner daysRemaining={daysUntilExpiration} expiresAt={event.expires_at} />
|
<ExpirationBanner daysRemaining={daysUntilExpiration} expiresAt={event.expires_at} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -694,6 +694,8 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
heroLogoVisible={data?.event?.hero_logo_visible !== false}
|
heroLogoVisible={data?.event?.hero_logo_visible !== false}
|
||||||
heroLogoSize={data?.event?.hero_logo_size || 'medium'}
|
heroLogoSize={data?.event?.hero_logo_size || 'medium'}
|
||||||
heroLogoPosition={data?.event?.hero_logo_position || 'top'}
|
heroLogoPosition={data?.event?.hero_logo_position || 'top'}
|
||||||
|
headerStyle={data?.event?.header_style || theme.headerStyle}
|
||||||
|
heroDividerStyle={data?.event?.hero_divider_style || theme.heroDividerStyle || 'wave'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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<HeroDividerProps> = ({
|
||||||
|
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 (
|
||||||
|
<div className={`absolute bottom-0 left-0 right-0 ${className}`}>
|
||||||
|
<svg className="w-full h-12 sm:h-16" viewBox="0 0 1200 120" preserveAspectRatio="none">
|
||||||
|
<path
|
||||||
|
d="M0,60 C150,90 350,30 600,60 C850,90 1050,30 1200,60 L1200,120 L0,120 Z"
|
||||||
|
fill={fillColor}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'angle':
|
||||||
|
return (
|
||||||
|
<div className={`absolute bottom-0 left-0 right-0 ${className}`}>
|
||||||
|
<svg className="w-full h-12 sm:h-16" viewBox="0 0 1200 120" preserveAspectRatio="none">
|
||||||
|
<path
|
||||||
|
d="M0,120 L1200,40 L1200,120 Z"
|
||||||
|
fill={fillColor}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'curve':
|
||||||
|
return (
|
||||||
|
<div className={`absolute bottom-0 left-0 right-0 ${className}`}>
|
||||||
|
<svg className="w-full h-12 sm:h-16" viewBox="0 0 1200 120" preserveAspectRatio="none">
|
||||||
|
<path
|
||||||
|
d="M0,80 Q600,0 1200,80 L1200,120 L0,120 Z"
|
||||||
|
fill={fillColor}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
HeroDivider.displayName = 'HeroDivider';
|
||||||
@@ -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<HeroHeaderProps> = ({
|
||||||
|
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<Photo | null>(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 (
|
||||||
|
<div className="relative -mt-6">
|
||||||
|
{/* Hero Section */}
|
||||||
|
<div className="relative h-[60vh] sm:h-[70vh] lg:h-[80vh] -mx-4 sm:-mx-6 lg:-mx-8 mb-8">
|
||||||
|
<AuthenticatedImage
|
||||||
|
src={heroPhoto.url}
|
||||||
|
fallbackSrc={heroPhoto.thumbnail_url || undefined}
|
||||||
|
alt={heroPhoto.filename}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
isGallery={true}
|
||||||
|
slug={slug}
|
||||||
|
photoId={heroPhoto.id}
|
||||||
|
protectFromDownload={!allowDownloads || useEnhancedProtection}
|
||||||
|
protectionLevel={protectionLevel}
|
||||||
|
useEnhancedProtection={useEnhancedProtection}
|
||||||
|
useCanvasRendering={useCanvasRendering || protectionLevel === 'maximum'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Overlay */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black"
|
||||||
|
style={{ opacity: overlayOpacity }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Hero Content */}
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="text-center px-4">
|
||||||
|
{/* Logo at top position */}
|
||||||
|
{heroLogoVisible && heroLogoPosition === 'top' && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<img
|
||||||
|
src={eventLogo ?
|
||||||
|
buildResourceUrl(eventLogo) :
|
||||||
|
'/picpeak-logo-transparent.png'
|
||||||
|
}
|
||||||
|
alt="Event logo"
|
||||||
|
className={`${getLogoSizeClasses(heroLogoSize)} mx-auto`}
|
||||||
|
style={{
|
||||||
|
filter: eventLogo
|
||||||
|
? 'drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
|
||||||
|
: 'brightness(0) invert(1) drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Event Title */}
|
||||||
|
{eventName && (
|
||||||
|
<h1 className="text-3xl sm:text-4xl lg:text-5xl xl:text-6xl font-bold text-white drop-shadow-lg mb-4">
|
||||||
|
{eventName}
|
||||||
|
</h1>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Logo at center position (between title and dates) */}
|
||||||
|
{heroLogoVisible && heroLogoPosition === 'center' && (
|
||||||
|
<div className="my-6">
|
||||||
|
<img
|
||||||
|
src={eventLogo ?
|
||||||
|
buildResourceUrl(eventLogo) :
|
||||||
|
'/picpeak-logo-transparent.png'
|
||||||
|
}
|
||||||
|
alt="Event logo"
|
||||||
|
className={`${getLogoSizeClasses(heroLogoSize)} mx-auto`}
|
||||||
|
style={{
|
||||||
|
filter: eventLogo
|
||||||
|
? 'drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
|
||||||
|
: 'brightness(0) invert(1) drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Event Dates */}
|
||||||
|
{(eventDate || expiresAt) && (
|
||||||
|
<div className="flex flex-wrap items-center justify-center gap-4 sm:gap-6 text-white/90">
|
||||||
|
{eventDate && (
|
||||||
|
<span className="flex items-center text-lg sm:text-xl">
|
||||||
|
<Calendar className="w-5 h-5 sm:w-6 sm:h-6 mr-2" />
|
||||||
|
{format(parseISO(eventDate), 'PP')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{expiresAt && (
|
||||||
|
<span className="flex items-center text-lg sm:text-xl">
|
||||||
|
<Clock className="w-5 h-5 sm:w-6 sm:h-6 mr-2" />
|
||||||
|
{t('gallery.expires')} {format(parseISO(expiresAt), 'PP')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Logo at bottom position */}
|
||||||
|
{heroLogoVisible && heroLogoPosition === 'bottom' && (
|
||||||
|
<div className="mt-6">
|
||||||
|
<img
|
||||||
|
src={eventLogo ?
|
||||||
|
buildResourceUrl(eventLogo) :
|
||||||
|
'/picpeak-logo-transparent.png'
|
||||||
|
}
|
||||||
|
alt="Event logo"
|
||||||
|
className={`${getLogoSizeClasses(heroLogoSize)} mx-auto`}
|
||||||
|
style={{
|
||||||
|
filter: eventLogo
|
||||||
|
? 'drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
|
||||||
|
: 'brightness(0) invert(1) drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scroll Indicator */}
|
||||||
|
<button
|
||||||
|
onClick={handleScrollToContent}
|
||||||
|
className="absolute bottom-8 left-1/2 transform -translate-x-1/2 animate-bounce cursor-pointer hover:scale-110 transition-transform focus:outline-none focus:ring-2 focus:ring-white focus:ring-opacity-50 rounded-full p-2"
|
||||||
|
aria-label="Scroll to gallery"
|
||||||
|
>
|
||||||
|
<ChevronDown className="w-8 h-8 text-white drop-shadow-lg" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Decorative Divider */}
|
||||||
|
<HeroDivider style={dividerStyle} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
HeroHeader.displayName = 'HeroHeader';
|
||||||
@@ -5,7 +5,7 @@ import { Button, Input } from '../common';
|
|||||||
import type { FilterType } from './GalleryFilter';
|
import type { FilterType } from './GalleryFilter';
|
||||||
|
|
||||||
interface PhotoCategory {
|
interface PhotoCategory {
|
||||||
id: number;
|
id: number | string;
|
||||||
name: string;
|
name: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
is_global: boolean;
|
is_global: boolean;
|
||||||
@@ -13,7 +13,7 @@ interface PhotoCategory {
|
|||||||
|
|
||||||
interface Photo {
|
interface Photo {
|
||||||
id: number;
|
id: number;
|
||||||
category_id?: number;
|
category_id?: number | string | null;
|
||||||
like_count?: number;
|
like_count?: number;
|
||||||
favorite_count?: number;
|
favorite_count?: number;
|
||||||
}
|
}
|
||||||
@@ -21,8 +21,8 @@ interface Photo {
|
|||||||
interface PhotoFilterBarProps {
|
interface PhotoFilterBarProps {
|
||||||
categories?: PhotoCategory[];
|
categories?: PhotoCategory[];
|
||||||
photos: Photo[];
|
photos: Photo[];
|
||||||
selectedCategoryId: number | null;
|
selectedCategoryId: number | string | null;
|
||||||
onCategoryChange: (categoryId: number | null) => void;
|
onCategoryChange: (categoryId: number | string | null) => void;
|
||||||
searchTerm: string;
|
searchTerm: string;
|
||||||
onSearchChange: (term: string) => void;
|
onSearchChange: (term: string) => void;
|
||||||
sortBy: 'date' | 'name' | 'size' | 'rating';
|
sortBy: 'date' | 'name' | 'size' | 'rating';
|
||||||
|
|||||||
@@ -17,14 +17,15 @@ import {
|
|||||||
MasonryGalleryLayout,
|
MasonryGalleryLayout,
|
||||||
CarouselGalleryLayout,
|
CarouselGalleryLayout,
|
||||||
TimelineGalleryLayout,
|
TimelineGalleryLayout,
|
||||||
HeroGalleryLayout,
|
|
||||||
MosaicGalleryLayout,
|
MosaicGalleryLayout,
|
||||||
} from './layouts';
|
} from './layouts';
|
||||||
|
import { HeroHeader } from './HeroHeader';
|
||||||
|
import type { HeaderStyleType, HeroDividerStyle } from '../../types/theme.types';
|
||||||
|
|
||||||
interface PhotoGridWithLayoutsProps {
|
interface PhotoGridWithLayoutsProps {
|
||||||
photos: Photo[];
|
photos: Photo[];
|
||||||
slug: string;
|
slug: string;
|
||||||
categoryId?: number | null;
|
categoryId?: number | string | null;
|
||||||
// When provided, the hero layout will use this photo
|
// When provided, the hero layout will use this photo
|
||||||
// instead of deriving from the filtered photo list.
|
// instead of deriving from the filtered photo list.
|
||||||
heroPhotoOverride?: Photo | null;
|
heroPhotoOverride?: Photo | null;
|
||||||
@@ -35,8 +36,8 @@ interface PhotoGridWithLayoutsProps {
|
|||||||
showSelectionControls?: boolean;
|
showSelectionControls?: boolean;
|
||||||
eventName?: string;
|
eventName?: string;
|
||||||
eventLogo?: string | null;
|
eventLogo?: string | null;
|
||||||
eventDate?: string;
|
eventDate?: string | null;
|
||||||
expiresAt?: string;
|
expiresAt?: string | null;
|
||||||
feedbackEnabled?: boolean;
|
feedbackEnabled?: boolean;
|
||||||
allowDownloads?: boolean;
|
allowDownloads?: boolean;
|
||||||
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
|
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
|
||||||
@@ -56,6 +57,9 @@ interface PhotoGridWithLayoutsProps {
|
|||||||
heroLogoVisible?: boolean;
|
heroLogoVisible?: boolean;
|
||||||
heroLogoSize?: 'small' | 'medium' | 'large' | 'xlarge';
|
heroLogoSize?: 'small' | 'medium' | 'large' | 'xlarge';
|
||||||
heroLogoPosition?: 'top' | 'center' | 'bottom';
|
heroLogoPosition?: 'top' | 'center' | 'bottom';
|
||||||
|
// Header style (decoupled from layout)
|
||||||
|
headerStyle?: HeaderStyleType;
|
||||||
|
heroDividerStyle?: HeroDividerStyle;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
||||||
@@ -83,7 +87,9 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
|||||||
expiresAt,
|
expiresAt,
|
||||||
heroLogoVisible = true,
|
heroLogoVisible = true,
|
||||||
heroLogoSize = 'medium',
|
heroLogoSize = 'medium',
|
||||||
heroLogoPosition = 'top'
|
heroLogoPosition = 'top',
|
||||||
|
headerStyle,
|
||||||
|
heroDividerStyle = 'wave'
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
@@ -212,6 +218,10 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
|||||||
heroLogoPosition,
|
heroLogoPosition,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Determine if we should show hero header (decoupled from layout)
|
||||||
|
const effectiveHeaderStyle = headerStyle || theme.headerStyle;
|
||||||
|
const showHeroHeader = effectiveHeaderStyle === 'hero';
|
||||||
|
|
||||||
let LayoutComponent;
|
let LayoutComponent;
|
||||||
switch (galleryLayout) {
|
switch (galleryLayout) {
|
||||||
case 'masonry':
|
case 'masonry':
|
||||||
@@ -223,9 +233,6 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
|||||||
case 'timeline':
|
case 'timeline':
|
||||||
LayoutComponent = TimelineGalleryLayout;
|
LayoutComponent = TimelineGalleryLayout;
|
||||||
break;
|
break;
|
||||||
case 'hero':
|
|
||||||
LayoutComponent = HeroGalleryLayout;
|
|
||||||
break;
|
|
||||||
case 'mosaic':
|
case 'mosaic':
|
||||||
LayoutComponent = MosaicGalleryLayout;
|
LayoutComponent = MosaicGalleryLayout;
|
||||||
break;
|
break;
|
||||||
@@ -235,6 +242,27 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{/* Hero Header - shown when headerStyle is 'hero' */}
|
||||||
|
{showHeroHeader && (
|
||||||
|
<HeroHeader
|
||||||
|
photos={photos}
|
||||||
|
slug={slug}
|
||||||
|
eventName={eventName}
|
||||||
|
eventLogo={eventLogo}
|
||||||
|
eventDate={eventDate}
|
||||||
|
expiresAt={expiresAt}
|
||||||
|
heroPhotoOverride={heroPhotoOverride}
|
||||||
|
heroLogoVisible={heroLogoVisible}
|
||||||
|
heroLogoSize={heroLogoSize}
|
||||||
|
heroLogoPosition={heroLogoPosition}
|
||||||
|
dividerStyle={heroDividerStyle}
|
||||||
|
allowDownloads={allowDownloads}
|
||||||
|
protectionLevel={protectionLevel}
|
||||||
|
useEnhancedProtection={useEnhancedProtection}
|
||||||
|
useCanvasRendering={useCanvasRendering}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Selection Mode Controls - Not shown for carousel layout or when controls are hidden */}
|
{/* Selection Mode Controls - Not shown for carousel layout or when controls are hidden */}
|
||||||
{showSelectionControls && photos.length > 1 && galleryLayout !== 'carousel' && (
|
{showSelectionControls && photos.length > 1 && galleryLayout !== 'carousel' && (
|
||||||
<div className="mb-4 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
<div className="mb-4 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ export interface BaseGalleryLayoutProps {
|
|||||||
onPhotoSelect?: (photoId: number) => void;
|
onPhotoSelect?: (photoId: number) => void;
|
||||||
eventName?: string;
|
eventName?: string;
|
||||||
eventLogo?: string | null;
|
eventLogo?: string | null;
|
||||||
eventDate?: string;
|
eventDate?: string | null;
|
||||||
expiresAt?: string;
|
expiresAt?: string | null;
|
||||||
allowDownloads?: boolean;
|
allowDownloads?: boolean;
|
||||||
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
|
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
|
||||||
useEnhancedProtection?: boolean;
|
useEnhancedProtection?: boolean;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Download, Maximize2, Check, MessageSquare, Star, Heart, Video } from 'lucide-react';
|
import { Download, Maximize2, Check, MessageSquare, Star, Heart, Video } from 'lucide-react';
|
||||||
import { useInView } from 'react-intersection-observer';
|
import { useInView } from 'react-intersection-observer';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useTheme } from '../../../contexts/ThemeContext';
|
import { useTheme } from '../../../contexts/ThemeContext';
|
||||||
import { AuthenticatedImage } from '../../common';
|
import { AuthenticatedImage } from '../../common';
|
||||||
import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal';
|
import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal';
|
||||||
@@ -59,6 +60,7 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
|
|||||||
liked = false,
|
liked = false,
|
||||||
onLikeSuccess
|
onLikeSuccess
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [overlayVisible, setOverlayVisible] = React.useState(false);
|
const [overlayVisible, setOverlayVisible] = React.useState(false);
|
||||||
const [isTouchDevice, setIsTouchDevice] = React.useState(false);
|
const [isTouchDevice, setIsTouchDevice] = React.useState(false);
|
||||||
const overlayTimeoutRef = React.useRef<number | null>(null);
|
const overlayTimeoutRef = React.useRef<number | null>(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<HeroGalleryLayoutProps> = ({
|
|
||||||
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<Photo | null>(null);
|
|
||||||
const [hasInitialized, setHasInitialized] = useState(false);
|
|
||||||
const [showIdentityModal, setShowIdentityModal] = useState(false);
|
|
||||||
const [pendingAction, setPendingAction] = useState<null | { type: 'like'; photoId: number }>(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<Set<number>>(new Set());
|
|
||||||
const canQuickComment = Boolean(feedbackEnabled && feedbackOptions?.allowComments && onOpenPhotoWithFeedback);
|
|
||||||
const gridRef = useRef<HTMLDivElement | null>(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 (
|
|
||||||
<>
|
|
||||||
<div className="relative -mt-6">
|
|
||||||
{/* Hero Section */}
|
|
||||||
<div className="relative h-[60vh] sm:h-[70vh] lg:h-[80vh] -mx-4 sm:-mx-6 lg:-mx-8 mb-8">
|
|
||||||
<AuthenticatedImage
|
|
||||||
src={heroPhoto.url}
|
|
||||||
fallbackSrc={heroPhoto.thumbnail_url || undefined}
|
|
||||||
alt={heroPhoto.filename}
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
isGallery={true}
|
|
||||||
slug={slug}
|
|
||||||
photoId={heroPhoto.id}
|
|
||||||
protectFromDownload={!allowDownloads || useEnhancedProtection}
|
|
||||||
protectionLevel={protectionLevel}
|
|
||||||
useEnhancedProtection={useEnhancedProtection}
|
|
||||||
useCanvasRendering={useCanvasRendering || protectionLevel === 'maximum'}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Overlay */}
|
|
||||||
<div
|
|
||||||
className="absolute inset-0 bg-black"
|
|
||||||
style={{ opacity: overlayOpacity }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Hero Content */}
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
|
||||||
<div className="text-center px-4">
|
|
||||||
{/* Logo at top position */}
|
|
||||||
{heroLogoVisible && heroLogoPosition === 'top' && (
|
|
||||||
<div className="mb-6">
|
|
||||||
<img
|
|
||||||
src={eventLogo ?
|
|
||||||
buildResourceUrl(eventLogo) :
|
|
||||||
'/picpeak-logo-transparent.png'
|
|
||||||
}
|
|
||||||
alt="Event logo"
|
|
||||||
className={`${getLogoSizeClasses(heroLogoSize)} mx-auto`}
|
|
||||||
style={{
|
|
||||||
filter: eventLogo
|
|
||||||
? 'drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
|
|
||||||
: 'brightness(0) invert(1) drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Event Title */}
|
|
||||||
{eventName && (
|
|
||||||
<h1 className="text-3xl sm:text-4xl lg:text-5xl xl:text-6xl font-bold text-white drop-shadow-lg mb-4">
|
|
||||||
{eventName}
|
|
||||||
</h1>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Logo at center position (between title and dates) */}
|
|
||||||
{heroLogoVisible && heroLogoPosition === 'center' && (
|
|
||||||
<div className="my-6">
|
|
||||||
<img
|
|
||||||
src={eventLogo ?
|
|
||||||
buildResourceUrl(eventLogo) :
|
|
||||||
'/picpeak-logo-transparent.png'
|
|
||||||
}
|
|
||||||
alt="Event logo"
|
|
||||||
className={`${getLogoSizeClasses(heroLogoSize)} mx-auto`}
|
|
||||||
style={{
|
|
||||||
filter: eventLogo
|
|
||||||
? 'drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
|
|
||||||
: 'brightness(0) invert(1) drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Event Dates */}
|
|
||||||
{(eventDate || expiresAt) && (
|
|
||||||
<div className="flex flex-wrap items-center justify-center gap-4 sm:gap-6 text-white/90">
|
|
||||||
{eventDate && (
|
|
||||||
<span className="flex items-center text-lg sm:text-xl">
|
|
||||||
<Calendar className="w-5 h-5 sm:w-6 sm:h-6 mr-2" />
|
|
||||||
{format(parseISO(eventDate), 'PP')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{expiresAt && (
|
|
||||||
<span className="flex items-center text-lg sm:text-xl">
|
|
||||||
<Clock className="w-5 h-5 sm:w-6 sm:h-6 mr-2" />
|
|
||||||
{t('gallery.expires')} {format(parseISO(expiresAt), 'PP')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Logo at bottom position */}
|
|
||||||
{heroLogoVisible && heroLogoPosition === 'bottom' && (
|
|
||||||
<div className="mt-6">
|
|
||||||
<img
|
|
||||||
src={eventLogo ?
|
|
||||||
buildResourceUrl(eventLogo) :
|
|
||||||
'/picpeak-logo-transparent.png'
|
|
||||||
}
|
|
||||||
alt="Event logo"
|
|
||||||
className={`${getLogoSizeClasses(heroLogoSize)} mx-auto`}
|
|
||||||
style={{
|
|
||||||
filter: eventLogo
|
|
||||||
? 'drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
|
|
||||||
: 'brightness(0) invert(1) drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Scroll Indicator */}
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
// Scroll to the 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' });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="absolute bottom-8 left-1/2 transform -translate-x-1/2 animate-bounce cursor-pointer hover:scale-110 transition-transform focus:outline-none focus:ring-2 focus:ring-white focus:ring-opacity-50 rounded-full p-2"
|
|
||||||
aria-label="Scroll to gallery"
|
|
||||||
>
|
|
||||||
<ChevronDown className="w-8 h-8 text-white drop-shadow-lg" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Grid Section */}
|
|
||||||
<div id="gallery-grid-section" className="photo-grid grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
|
||||||
{remainingPhotos.map((photo) => {
|
|
||||||
const actualIndex = photos.findIndex(p => p.id === photo.id);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={photo.id}
|
|
||||||
className="photo-card relative group cursor-pointer overflow-hidden rounded-lg"
|
|
||||||
onClick={() => onPhotoClick(actualIndex)}
|
|
||||||
>
|
|
||||||
<AuthenticatedImage
|
|
||||||
src={photo.thumbnail_url || photo.url}
|
|
||||||
alt={photo.filename}
|
|
||||||
className="w-full h-auto object-cover transition-transform duration-300 group-hover:scale-105"
|
|
||||||
loading="lazy"
|
|
||||||
isGallery={true}
|
|
||||||
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">
|
|
||||||
{!isSelectionMode && (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onPhotoClick(actualIndex);
|
|
||||||
}}
|
|
||||||
aria-label="View full size"
|
|
||||||
>
|
|
||||||
<Maximize2 className="w-5 h-5 text-neutral-800" />
|
|
||||||
</button>
|
|
||||||
{allowDownloads && (
|
|
||||||
<button
|
|
||||||
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onDownload(photo, e);
|
|
||||||
}}
|
|
||||||
aria-label="Download photo"
|
|
||||||
>
|
|
||||||
<Download className="w-5 h-5 text-neutral-800" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{feedbackOptions?.allowLikes && (
|
|
||||||
<button
|
|
||||||
className={`p-2 rounded-full transition-colors ${likedIds.has(photo.id) ? 'bg-red-500/90 hover:bg-red-500' : 'bg-white/90 hover:bg-white'}`}
|
|
||||||
onClick={async (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (feedbackOptions?.requireNameEmail && !savedIdentity) {
|
|
||||||
setPendingAction({ type: 'like', photoId: photo.id });
|
|
||||||
setShowIdentityModal(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setLikedIds(prev => new Set(prev).add(photo.id));
|
|
||||||
try {
|
|
||||||
await feedbackService.submitFeedback(slug!, String(photo.id), {
|
|
||||||
feedback_type: 'like',
|
|
||||||
guest_name: savedIdentity?.name,
|
|
||||||
guest_email: savedIdentity?.email,
|
|
||||||
});
|
|
||||||
} catch (_) {}
|
|
||||||
}}
|
|
||||||
aria-label="Like photo"
|
|
||||||
aria-pressed={likedIds.has(photo.id)}
|
|
||||||
title="Like"
|
|
||||||
>
|
|
||||||
<Heart className={`w-5 h-5 ${likedIds.has(photo.id) ? 'text-white fill-white' : 'text-neutral-800'}`} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{canQuickComment && (
|
|
||||||
<button
|
|
||||||
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
|
|
||||||
onClick={(e) => { e.stopPropagation(); onOpenPhotoWithFeedback?.(actualIndex); }}
|
|
||||||
aria-label="Comment on photo"
|
|
||||||
title="Comment"
|
|
||||||
>
|
|
||||||
<MessageSquare className="w-5 h-5 text-neutral-800" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Selection Checkbox (visible on hover or when selected) */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={`Select ${photo.filename}`}
|
|
||||||
role="checkbox"
|
|
||||||
aria-checked={selectedPhotos.has(photo.id)}
|
|
||||||
data-testid={`gallery-photo-checkbox-${photo.id}`}
|
|
||||||
className={`absolute top-2 right-2 z-20 transition-opacity ${
|
|
||||||
selectedPhotos.has(photo.id) ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
|
|
||||||
}`}
|
|
||||||
onClick={(e) => { e.stopPropagation(); onPhotoSelect && onPhotoSelect(photo.id); }}
|
|
||||||
>
|
|
||||||
<div className={`w-6 h-6 rounded-full border-2 ${selectedPhotos.has(photo.id) ? 'bg-primary-600 border-primary-600' : 'bg-white/90 border-white'} flex items-center justify-center transition-colors`}>
|
|
||||||
{selectedPhotos.has(photo.id) && <Check className="w-4 h-4 text-white" />}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Feedback indicators (always visible, bottom-left). Show like immediately when liked */}
|
|
||||||
{((photo.like_count ?? 0) > 0 || likedIds.has(photo.id) || (photo.average_rating ?? 0) > 0 || (photo.comment_count ?? 0) > 0) && (
|
|
||||||
<div className={`absolute ${photo.type === 'collage' ? 'bottom-8' : 'bottom-2'} left-2 flex items-center gap-1 z-20`}>
|
|
||||||
{((photo.like_count ?? 0) > 0 || likedIds.has(photo.id)) && (
|
|
||||||
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-white/90 backdrop-blur-sm" title="Liked">
|
|
||||||
<Heart className="w-3.5 h-3.5 text-red-500" fill="currentColor" />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{(photo.average_rating ?? 0) > 0 && (
|
|
||||||
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-white/90 backdrop-blur-sm" title="Rated">
|
|
||||||
<svg viewBox="0 0 24 24" className="w-3.5 h-3.5 text-yellow-500 fill-current"><path d="M12 .587l3.668 7.431 8.2 1.193-5.934 5.787 1.402 8.168L12 18.897l-7.336 3.869 1.402-8.168L.132 9.211l8.2-1.193z"/></svg>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{(photo.comment_count ?? 0) > 0 && (
|
|
||||||
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-white/90 backdrop-blur-sm" title="Commented">
|
|
||||||
<svg viewBox="0 0 24 24" className="w-3.5 h-3.5 text-blue-600 fill-current"><path d="M20 2H4a2 2 0 00-2 2v18l4-4h14a2 2 0 002-2V4a2 2 0 00-2-2z"/></svg>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<FeedbackIdentityModal
|
|
||||||
isOpen={showIdentityModal}
|
|
||||||
onClose={() => { 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"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -2,6 +2,7 @@ export { GridGalleryLayout } from './GridGalleryLayout';
|
|||||||
export { MasonryGalleryLayout } from './MasonryGalleryLayout';
|
export { MasonryGalleryLayout } from './MasonryGalleryLayout';
|
||||||
export { CarouselGalleryLayout } from './CarouselGalleryLayout';
|
export { CarouselGalleryLayout } from './CarouselGalleryLayout';
|
||||||
export { TimelineGalleryLayout } from './TimelineGalleryLayout';
|
export { TimelineGalleryLayout } from './TimelineGalleryLayout';
|
||||||
export { HeroGalleryLayout } from './HeroGalleryLayout';
|
|
||||||
export { MosaicGalleryLayout } from './MosaicGalleryLayout';
|
export { MosaicGalleryLayout } from './MosaicGalleryLayout';
|
||||||
export type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
|
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
|
||||||
@@ -16,10 +16,10 @@ interface GalleryEvent {
|
|||||||
id: number;
|
id: number;
|
||||||
event_name: string;
|
event_name: string;
|
||||||
event_type: string;
|
event_type: string;
|
||||||
event_date: string;
|
event_date: string | null;
|
||||||
welcome_message?: string;
|
welcome_message?: string;
|
||||||
color_theme?: string;
|
color_theme?: string;
|
||||||
expires_at: string;
|
expires_at: string | null;
|
||||||
require_password?: boolean;
|
require_password?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ i18n
|
|||||||
escapeValue: false,
|
escapeValue: false,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Use v3 format for pluralization (_plural suffix instead of _one/_other)
|
// Use v4 format for pluralization (_one/_other instead of _plural suffix)
|
||||||
compatibilityJSON: 'v3',
|
compatibilityJSON: 'v4',
|
||||||
|
|
||||||
detection: {
|
detection: {
|
||||||
order: ['localStorage', 'cookie', 'navigator', 'htmlTag'],
|
order: ['localStorage', 'cookie', 'navigator', 'htmlTag'],
|
||||||
|
|||||||
@@ -1391,7 +1391,30 @@
|
|||||||
"showLogoInHeader": "Logo im Galerie-Header anzeigen",
|
"showLogoInHeader": "Logo im Galerie-Header anzeigen",
|
||||||
"showLogoInHeaderHelp": "Logo in der Hauptkopfzeile anzeigen",
|
"showLogoInHeaderHelp": "Logo in der Hauptkopfzeile anzeigen",
|
||||||
"showLogoInHero": "Logo im Hero-Bereich 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": {
|
"admin": {
|
||||||
"title": "Admin-Panel",
|
"title": "Admin-Panel",
|
||||||
|
|||||||
@@ -1106,7 +1106,30 @@
|
|||||||
"showLogoInHeader": "Show logo in gallery header",
|
"showLogoInHeader": "Show logo in gallery header",
|
||||||
"showLogoInHeaderHelp": "Display the logo in the main header bar",
|
"showLogoInHeaderHelp": "Display the logo in the main header bar",
|
||||||
"showLogoInHero": "Show logo in hero section",
|
"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": {
|
"admin": {
|
||||||
"title": "Admin Panel",
|
"title": "Admin Panel",
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ export const AdminDashboard: React.FC = () => {
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{expiringEvents.slice(0, 5).map((event) => {
|
{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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -216,7 +216,7 @@ export const AdminDashboard: React.FC = () => {
|
|||||||
{t('admin.daysLeft', { count: daysLeft })}
|
{t('admin.daysLeft', { count: daysLeft })}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-neutral-500">
|
<p className="text-xs text-neutral-500">
|
||||||
{t('gallery.expires')} {format(parseISO(event.expires_at), 'PP')}
|
{t('gallery.expires')} {format(parseISO(event.expires_at!), 'PP')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { eventsService } from '../../services/events.service';
|
|||||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||||
import { categoriesService } from '../../services/categories.service';
|
import { categoriesService } from '../../services/categories.service';
|
||||||
import { settingsService } from '../../services/settings.service';
|
import { settingsService } from '../../services/settings.service';
|
||||||
|
import { publicSettingsService } from '../../services/publicSettings.service';
|
||||||
import { cssTemplatesService } from '../../services/cssTemplates.service';
|
import { cssTemplatesService } from '../../services/cssTemplates.service';
|
||||||
import { eventTypesService } from '../../services/eventTypes.service';
|
import { eventTypesService } from '../../services/eventTypes.service';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -152,7 +153,7 @@ export const CreateEventPage: React.FC = () => {
|
|||||||
// Fetch public settings for field requirements
|
// Fetch public settings for field requirements
|
||||||
const { data: publicSettings } = useQuery({
|
const { data: publicSettings } = useQuery({
|
||||||
queryKey: ['public-settings'],
|
queryKey: ['public-settings'],
|
||||||
queryFn: () => settingsService.getPublicSettings()
|
queryFn: () => publicSettingsService.getPublicSettings()
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get field requirements (default to true if not set)
|
// Get field requirements (default to true if not set)
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ import { buildResourceUrl } from '../../utils/url';
|
|||||||
import { isGalleryPublic, normalizeRequirePassword } from '../../utils/accessControl';
|
import { isGalleryPublic, normalizeRequirePassword } from '../../utils/accessControl';
|
||||||
import { archiveService } from '../../services/archive.service';
|
import { archiveService } from '../../services/archive.service';
|
||||||
import { externalMediaService } from '../../services/externalMedia.service';
|
import { externalMediaService } from '../../services/externalMedia.service';
|
||||||
import { photosService, AdminPhoto, type PhotoFilters as PhotoFilterParams, 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 { feedbackService, FeedbackSettings as FeedbackSettingsType } from '../../services/feedback.service';
|
||||||
import { cssTemplatesService, type EnabledTemplate } from '../../services/cssTemplates.service';
|
import { cssTemplatesService, type EnabledTemplate } from '../../services/cssTemplates.service';
|
||||||
import { ThemeConfig, GALLERY_THEME_PRESETS } from '../../types/theme.types';
|
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
|
// Archive mutation
|
||||||
const archiveMutation = useMutation({
|
const archiveMutation = useMutation({
|
||||||
mutationFn: () => eventsService.archiveEvent(parseInt(id!)),
|
mutationFn: () => eventsService.archiveEvent(parseInt(id!)),
|
||||||
@@ -470,7 +448,7 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('logo', file);
|
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' }
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
});
|
});
|
||||||
toast.success(t('events.eventLogoUploaded', 'Event logo uploaded successfully'));
|
toast.success(t('events.eventLogoUploaded', 'Event logo uploaded successfully'));
|
||||||
@@ -1745,6 +1723,7 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
queryClient.invalidateQueries({ queryKey: ['admin-event', id] });
|
queryClient.invalidateQueries({ queryKey: ['admin-event', id] });
|
||||||
}}
|
}}
|
||||||
onSelectionChange={setSelectedPhotoIds}
|
onSelectionChange={setSelectedPhotoIds}
|
||||||
|
categories={categories}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -277,7 +277,7 @@ export const EventTypesPage: React.FC = () => {
|
|||||||
{showCreateModal && (
|
{showCreateModal && (
|
||||||
<EventTypeModal
|
<EventTypeModal
|
||||||
onClose={() => setShowCreateModal(false)}
|
onClose={() => setShowCreateModal(false)}
|
||||||
onSubmit={(data) => createMutation.mutate(data)}
|
onSubmit={(data) => createMutation.mutate(data as CreateEventTypeData)}
|
||||||
isLoading={createMutation.isPending}
|
isLoading={createMutation.isPending}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ const mockCategories = [
|
|||||||
export const PreviewPage: React.FC = () => {
|
export const PreviewPage: React.FC = () => {
|
||||||
const { setTheme } = useTheme();
|
const { setTheme } = useTheme();
|
||||||
const [brandingSettings, setBrandingSettings] = useState<any>(null);
|
const [brandingSettings, setBrandingSettings] = useState<any>(null);
|
||||||
const [selectedCategoryId, setSelectedCategoryId] = useState<number | null>(null);
|
const [selectedCategoryId, setSelectedCategoryId] = useState<number | string | null>(null);
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [sortBy, setSortBy] = useState<'date' | 'name' | 'size' | 'rating'>('date');
|
const [sortBy, setSortBy] = useState<'date' | 'name' | 'size' | 'rating'>('date');
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ export interface PublicSettings {
|
|||||||
umami_url: string | null;
|
umami_url: string | null;
|
||||||
umami_website_id: string | null;
|
umami_website_id: string | null;
|
||||||
umami_share_url: 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 = {
|
export const publicSettingsService = {
|
||||||
|
|||||||
@@ -45,6 +45,12 @@ export interface Event {
|
|||||||
hero_logo_visible?: boolean;
|
hero_logo_visible?: boolean;
|
||||||
hero_logo_size?: 'small' | 'medium' | 'large' | 'xlarge';
|
hero_logo_size?: 'small' | 'medium' | 'large' | 'xlarge';
|
||||||
hero_logo_position?: 'top' | 'center' | 'bottom';
|
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 {
|
export interface GalleryInfo {
|
||||||
@@ -119,6 +125,14 @@ export interface GalleryData {
|
|||||||
enable_devtools_protection?: boolean;
|
enable_devtools_protection?: boolean;
|
||||||
fragmentation_level?: number;
|
fragmentation_level?: number;
|
||||||
overlay_protection?: boolean;
|
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[];
|
categories?: PhotoCategory[];
|
||||||
photos: Photo[];
|
photos: Photo[];
|
||||||
@@ -159,6 +173,7 @@ export interface AdminUser {
|
|||||||
lastLogin?: string | null;
|
lastLogin?: string | null;
|
||||||
lastLoginIp?: string | null;
|
lastLoginIp?: string | null;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
createdByUsername?: string;
|
createdByUsername?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
// Gallery Layout Types
|
// 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 {
|
export interface GalleryLayoutSettings {
|
||||||
// Common settings
|
// Common settings
|
||||||
@@ -15,11 +21,17 @@ export interface GalleryLayoutSettings {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Masonry specific
|
// 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;
|
masonryGutter?: number;
|
||||||
masonryRowHeight?: number; // Target row height for rows mode (150-400)
|
masonryRowHeight?: number; // Target row height for rows mode (150-400)
|
||||||
masonryLastRowBehavior?: 'justify' | 'left' | 'center'; // How to align incomplete last row
|
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
|
// Carousel specific
|
||||||
carouselAutoplay?: boolean;
|
carouselAutoplay?: boolean;
|
||||||
carouselInterval?: number;
|
carouselInterval?: number;
|
||||||
@@ -59,8 +71,12 @@ export interface ThemeConfig {
|
|||||||
galleryLayout?: GalleryLayoutType;
|
galleryLayout?: GalleryLayoutType;
|
||||||
gallerySettings?: GalleryLayoutSettings;
|
gallerySettings?: GalleryLayoutSettings;
|
||||||
|
|
||||||
// Header/Footer
|
// Header Style (decoupled from layout)
|
||||||
headerStyle?: 'minimal' | 'standard' | 'full';
|
headerStyle?: HeaderStyleType;
|
||||||
|
heroDividerStyle?: HeroDividerStyle;
|
||||||
|
|
||||||
|
// Legacy Header/Footer (kept for backward compatibility)
|
||||||
|
legacyHeaderStyle?: 'minimal' | 'standard' | 'full';
|
||||||
footerStyle?: 'minimal' | 'standard' | 'full';
|
footerStyle?: 'minimal' | 'standard' | 'full';
|
||||||
showEventInfo?: boolean;
|
showEventInfo?: boolean;
|
||||||
showBranding?: boolean;
|
showBranding?: boolean;
|
||||||
@@ -115,14 +131,16 @@ export const GALLERY_THEME_PRESETS: Record<string, EventTheme> = {
|
|||||||
headingFontFamily: 'Playfair Display, serif',
|
headingFontFamily: 'Playfair Display, serif',
|
||||||
borderRadius: 'lg',
|
borderRadius: 'lg',
|
||||||
shadowStyle: 'subtle',
|
shadowStyle: 'subtle',
|
||||||
galleryLayout: 'hero',
|
galleryLayout: 'grid',
|
||||||
|
headerStyle: 'hero',
|
||||||
|
heroDividerStyle: 'wave',
|
||||||
gallerySettings: {
|
gallerySettings: {
|
||||||
spacing: 'relaxed',
|
spacing: 'relaxed',
|
||||||
photoAnimation: 'scale',
|
photoAnimation: 'scale',
|
||||||
photoShape: 'rounded',
|
photoShape: 'rounded',
|
||||||
heroOverlayOpacity: 0.3
|
heroOverlayOpacity: 0.3
|
||||||
},
|
},
|
||||||
headerStyle: 'full',
|
legacyHeaderStyle: 'full',
|
||||||
footerStyle: 'minimal'
|
footerStyle: 'minimal'
|
||||||
},
|
},
|
||||||
isPreset: true
|
isPreset: true
|
||||||
@@ -172,7 +190,7 @@ export const GALLERY_THEME_PRESETS: Record<string, EventTheme> = {
|
|||||||
carouselInterval: 5000,
|
carouselInterval: 5000,
|
||||||
carouselShowThumbnails: true
|
carouselShowThumbnails: true
|
||||||
},
|
},
|
||||||
headerStyle: 'full',
|
headerStyle: 'standard',
|
||||||
footerStyle: 'standard',
|
footerStyle: 'standard',
|
||||||
backgroundPattern: 'dots'
|
backgroundPattern: 'dots'
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user