feat(gallery): decouple header style from layout, add banner option
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Migration: Move existing non-grid + 'standard' events to the new 'banner'
|
||||
* header style so their visual appearance is preserved.
|
||||
*
|
||||
* Until now, GalleryLayout.tsx coupled the colored hero banner to non-grid
|
||||
* layouts whenever headerStyle was 'standard'. The 'standard' look has been
|
||||
* decoupled from layout (it now means: compact inline header, no banner) and
|
||||
* a new 'banner' option has been added that adds the colored banner above
|
||||
* the standard header.
|
||||
*
|
||||
* To keep current galleries looking the same, every event whose effective
|
||||
* config was non-grid + standard gets migrated to non-grid + banner.
|
||||
*/
|
||||
const NON_GRID_LAYOUTS = ['masonry', 'carousel', 'timeline', 'mosaic'];
|
||||
|
||||
exports.up = async function(knex) {
|
||||
console.log('[Migration 086] Migrating non-grid standard headers to banner');
|
||||
|
||||
const events = await knex('events')
|
||||
.where('header_style', 'standard')
|
||||
.whereNotNull('color_theme')
|
||||
.select('id', 'color_theme');
|
||||
|
||||
let migratedCount = 0;
|
||||
|
||||
for (const event of events) {
|
||||
try {
|
||||
if (!event.color_theme || !event.color_theme.startsWith('{')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const theme = JSON.parse(event.color_theme);
|
||||
|
||||
if (!NON_GRID_LAYOUTS.includes(theme.galleryLayout)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const updatedTheme = { ...theme, headerStyle: 'banner' };
|
||||
|
||||
await knex('events')
|
||||
.where('id', event.id)
|
||||
.update({
|
||||
color_theme: JSON.stringify(updatedTheme),
|
||||
header_style: 'banner'
|
||||
});
|
||||
|
||||
migratedCount++;
|
||||
} catch (err) {
|
||||
console.warn(`[Migration 086] Could not parse color_theme for event ${event.id}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[Migration 086] Migrated ${migratedCount} events from standard to banner`);
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
console.log('[Migration 086] Reverting non-grid banner headers to standard');
|
||||
|
||||
const events = await knex('events')
|
||||
.where('header_style', 'banner')
|
||||
.whereNotNull('color_theme')
|
||||
.select('id', 'color_theme');
|
||||
|
||||
let revertedCount = 0;
|
||||
|
||||
for (const event of events) {
|
||||
try {
|
||||
if (!event.color_theme || !event.color_theme.startsWith('{')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const theme = JSON.parse(event.color_theme);
|
||||
|
||||
// Only revert rows we would have migrated (non-grid + banner). Leaves
|
||||
// any banner events that were intentionally created on grid alone.
|
||||
if (!NON_GRID_LAYOUTS.includes(theme.galleryLayout)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const revertedTheme = { ...theme, headerStyle: 'standard' };
|
||||
|
||||
await knex('events')
|
||||
.where('id', event.id)
|
||||
.update({
|
||||
color_theme: JSON.stringify(revertedTheme),
|
||||
header_style: 'standard'
|
||||
});
|
||||
|
||||
revertedCount++;
|
||||
} catch (err) {
|
||||
console.warn(`[Migration 086] Could not revert color_theme for event ${event.id}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[Migration 086] Reverted ${revertedCount} events from banner to standard`);
|
||||
};
|
||||
@@ -310,7 +310,7 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
body('hero_logo_size').optional().isIn(['small', 'medium', 'large', 'xlarge']),
|
||||
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('header_style').optional().isIn(['hero', 'standard', 'banner', 'minimal', 'none']),
|
||||
body('hero_divider_style').optional().isIn(['wave', 'straight', 'angle', 'curve', 'none']),
|
||||
// Hero image anchor position (#162) – accepts legacy keywords or "X% Y%" focal point
|
||||
body('hero_image_anchor').optional().custom(validateHeroImageAnchor),
|
||||
@@ -1016,7 +1016,7 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), requireEventOwne
|
||||
body('hero_logo_size').optional().isIn(['small', 'medium', 'large', 'xlarge']),
|
||||
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('header_style').optional().isIn(['hero', 'standard', 'banner', 'minimal', 'none']),
|
||||
body('hero_divider_style').optional().isIn(['wave', 'straight', 'angle', 'curve', 'none']),
|
||||
// Hero image anchor position (#162) – accepts legacy keywords or "X% Y%" focal point
|
||||
body('hero_image_anchor').optional().custom(validateHeroImageAnchor),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Palette, RotateCcw, Check, Layout, Type, Sparkles, Grid3X3, Layers, Play, Clock, Image, LayoutGrid, ChevronDown, Code, Info, FileCode, ImageIcon, Minimize2, EyeOff, Menu, SlidersHorizontal, Columns, Film, AlertTriangle } from 'lucide-react';
|
||||
import { Palette, RotateCcw, Check, Layout, LayoutTemplate, Type, Sparkles, Grid3X3, Layers, Play, Clock, Image, LayoutGrid, ChevronDown, Code, Info, FileCode, ImageIcon, Minimize2, EyeOff, Menu, SlidersHorizontal, Columns, Film, AlertTriangle } from 'lucide-react';
|
||||
import { Button, Card, Input } from '../common';
|
||||
import { ThemeConfig, GALLERY_THEME_PRESETS, GalleryLayoutType, HeaderStyleType, HeroDividerStyle } from '../../types/theme.types';
|
||||
import type { EnabledTemplate } from '../../services/cssTemplates.service';
|
||||
@@ -35,6 +35,7 @@ const layoutIcons: Record<GalleryLayoutType, React.ReactNode> = {
|
||||
const headerStyleIcons: Record<HeaderStyleType, React.ReactNode> = {
|
||||
hero: <Image className="w-5 h-5" />,
|
||||
standard: <Layout className="w-5 h-5" />,
|
||||
banner: <LayoutTemplate className="w-5 h-5" />,
|
||||
minimal: <Minimize2 className="w-5 h-5" />,
|
||||
none: <EyeOff className="w-5 h-5" />
|
||||
};
|
||||
@@ -626,7 +627,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400 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">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
|
||||
{(Object.keys(headerStyleIcons) as HeaderStyleType[]).map((style) => (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -83,11 +83,9 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
// Determine header style - use prop first (from event data), then theme, then fall back to 'standard'
|
||||
const headerStyle: HeaderStyleType = headerStyleProp || theme.headerStyle || 'standard';
|
||||
const isHeroHeader = headerStyle === 'hero';
|
||||
const isBannerHeader = headerStyle === 'banner';
|
||||
const isMinimalHeader = headerStyle === 'minimal';
|
||||
const isNoHeader = headerStyle === 'none';
|
||||
|
||||
// Non-grid layouts that need the sidebar (excluding layouts using hero header)
|
||||
const isNonGridLayout = theme.galleryLayout && theme.galleryLayout !== 'grid';
|
||||
const fontFamily = theme.fontFamily || 'Inter, sans-serif';
|
||||
const headingFontFamily = theme.headingFontFamily || fontFamily;
|
||||
|
||||
@@ -153,55 +151,9 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
<DynamicFavicon />
|
||||
|
||||
{/* Header structure */}
|
||||
<header className={`gallery-header bg-surface border-b border-surface sticky top-0 z-40 ${isNonGridLayout || isHeroHeader ? 'shadow-sm' : ''}`}>
|
||||
{/* For non-grid layouts - keep the current structure (standard and minimal/none) */}
|
||||
{isNonGridLayout && !isHeroHeader && !isMinimalHeader && !isNoHeader && (
|
||||
<div className="bg-surface border-b border-surface">
|
||||
<div className="container py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Left side - Menu button and other header extras */}
|
||||
<div className="flex items-center gap-3">
|
||||
{menuButton}
|
||||
{headerExtra}
|
||||
</div>
|
||||
|
||||
{/* Right side - Download and Logout */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Download all button */}
|
||||
{showDownloadAll && onDownloadAll && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
leftIcon={<Download className="w-4 h-4" />}
|
||||
onClick={onDownloadAll}
|
||||
isLoading={isDownloading}
|
||||
className="gallery-btn gallery-btn-download"
|
||||
>
|
||||
<span className="hidden sm:inline">{t('gallery.downloadAll')}</span>
|
||||
<span className="sm:hidden">{t('common.download')}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Logout button */}
|
||||
{showLogout && onLogout && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<LogOut className="w-4 h-4" />}
|
||||
onClick={onLogout}
|
||||
className="gallery-btn gallery-btn-logout sm:min-w-0"
|
||||
>
|
||||
<span className="hidden sm:inline">{t('common.logout')}</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* For grid layout - everything in one bar (standard header) */}
|
||||
{!isNonGridLayout && !isHeroHeader && !isMinimalHeader && !isNoHeader && (
|
||||
<header className={`gallery-header bg-surface border-b border-surface sticky top-0 z-40 ${isHeroHeader || isBannerHeader ? 'shadow-sm' : ''}`}>
|
||||
{/* Standard / Banner header - full bar with logo, event info, and actions (all layouts) */}
|
||||
{!isHeroHeader && !isMinimalHeader && !isNoHeader && (
|
||||
<div className="container py-3">
|
||||
<div className="flex items-center justify-between gap-2 sm:gap-4">
|
||||
{/* Left side - Menu button, Logo */}
|
||||
@@ -322,56 +274,8 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* For minimal/none header + non-grid layouts - compact menu bar */}
|
||||
{isNonGridLayout && (isMinimalHeader || isNoHeader) && (
|
||||
<div className="bg-surface border-b border-surface">
|
||||
<div className="container py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{menuButton}
|
||||
{headerExtra}
|
||||
{isMinimalHeader && (
|
||||
<h1
|
||||
className="text-sm font-semibold text-theme truncate"
|
||||
style={{ fontFamily: headingFontFamily }}
|
||||
>
|
||||
{event.event_name}
|
||||
</h1>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{showDownloadAll && onDownloadAll && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
leftIcon={<Download className="w-4 h-4" />}
|
||||
onClick={onDownloadAll}
|
||||
isLoading={isDownloading}
|
||||
className="gallery-btn gallery-btn-download"
|
||||
>
|
||||
<span className="hidden sm:inline">{t('gallery.downloadAll')}</span>
|
||||
<span className="sm:hidden">{t('common.download')}</span>
|
||||
</Button>
|
||||
)}
|
||||
{showLogout && onLogout && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<LogOut className="w-4 h-4" />}
|
||||
onClick={onLogout}
|
||||
className="gallery-btn gallery-btn-logout sm:min-w-0"
|
||||
>
|
||||
<span className="hidden sm:inline">{t('common.logout')}</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* For minimal header + grid layout - compact bar with event name */}
|
||||
{!isNonGridLayout && isMinimalHeader && (
|
||||
{/* Minimal header - compact bar with event name (all layouts) */}
|
||||
{isMinimalHeader && (
|
||||
<div className="container py-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
@@ -413,8 +317,8 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* For none header + grid layout - just functional buttons, no event info */}
|
||||
{!isNonGridLayout && isNoHeader && (
|
||||
{/* No-header style - just functional buttons, no event info (all layouts) */}
|
||||
{isNoHeader && (
|
||||
<div className="container py-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -495,8 +399,8 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* Colored banner for non-grid layouts when using standard header style */}
|
||||
{isNonGridLayout && !isHeroHeader && !isMinimalHeader && !isNoHeader && (
|
||||
{/* Colored banner — only when headerStyle === 'banner', regardless of layout */}
|
||||
{isBannerHeader && (
|
||||
<div
|
||||
className="gallery-hero relative text-white overflow-hidden"
|
||||
style={{
|
||||
|
||||
@@ -1716,13 +1716,15 @@
|
||||
"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",
|
||||
"standard": "Standard",
|
||||
"banner": "Banner",
|
||||
"minimal": "Minimal",
|
||||
"none": "Keine Kopfzeile"
|
||||
},
|
||||
"headerStyleDescriptions": {
|
||||
"hero": "Bild in voller Höhe mit Event-Info-Overlay",
|
||||
"standard": "Klassisches Banner mit Veranstaltungsdetails",
|
||||
"standard": "Kompakte Kopfzeile mit Veranstaltungsdetails",
|
||||
"banner": "Standard-Kopfzeile mit farbigem Banner darüber",
|
||||
"minimal": "Kompakte Kopfzeile mit wesentlichen Infos",
|
||||
"none": "Kopfzeile komplett ausblenden"
|
||||
},
|
||||
|
||||
@@ -1287,13 +1287,15 @@
|
||||
"headerStyleDescription": "Choose how the gallery header appears. The header style is independent of the photo layout.",
|
||||
"headerStyleOptions": {
|
||||
"hero": "Hero Image",
|
||||
"standard": "Standard Banner",
|
||||
"standard": "Standard",
|
||||
"banner": "Banner",
|
||||
"minimal": "Minimal",
|
||||
"none": "No Header"
|
||||
},
|
||||
"headerStyleDescriptions": {
|
||||
"hero": "Full-height image with event info overlay",
|
||||
"standard": "Classic banner with event details",
|
||||
"standard": "Compact inline header with event details",
|
||||
"banner": "Standard header plus a colored banner above",
|
||||
"minimal": "Compact header with essential info",
|
||||
"none": "Hide header completely"
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
export type GalleryLayoutType = 'grid' | 'masonry' | 'carousel' | 'timeline' | 'mosaic' | 'gallery-premium' | 'gallery-story';
|
||||
|
||||
// Header Style Types (decoupled from layout)
|
||||
export type HeaderStyleType = 'hero' | 'standard' | 'minimal' | 'none';
|
||||
export type HeaderStyleType = 'hero' | 'standard' | 'banner' | 'minimal' | 'none';
|
||||
|
||||
// Hero Divider Styles
|
||||
export type HeroDividerStyle = 'wave' | 'straight' | 'angle' | 'curve' | 'none';
|
||||
@@ -201,7 +201,7 @@ export const GALLERY_THEME_PRESETS: Record<string, EventTheme> = {
|
||||
carouselInterval: 5000,
|
||||
carouselShowThumbnails: true
|
||||
},
|
||||
headerStyle: 'standard',
|
||||
headerStyle: 'banner',
|
||||
footerStyle: 'standard',
|
||||
backgroundPattern: 'dots'
|
||||
},
|
||||
@@ -225,7 +225,7 @@ export const GALLERY_THEME_PRESETS: Record<string, EventTheme> = {
|
||||
timelineGrouping: 'day',
|
||||
timelineShowDates: true
|
||||
},
|
||||
headerStyle: 'standard',
|
||||
headerStyle: 'banner',
|
||||
footerStyle: 'full',
|
||||
buttonStyle: 'outline'
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user