diff --git a/backend/migrations/core/086_migrate_non_grid_standard_to_banner.js b/backend/migrations/core/086_migrate_non_grid_standard_to_banner.js new file mode 100644 index 00000000..2d3340b6 --- /dev/null +++ b/backend/migrations/core/086_migrate_non_grid_standard_to_banner.js @@ -0,0 +1,125 @@ +/** + * 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. + * + * In the same release, GalleryView.tsx stopped deriving controlsStyle from + * the layout — non-grid events used to default to the sidebar drawer via + * `theme.galleryLayout !== 'grid' || isHeroHeader`, and now require an + * explicit `theme.controlsStyle === 'sidebar'`. To keep affected events + * pixel-identical post-upgrade, this migration also sets controlsStyle to + * 'sidebar' when it was previously unset on every event we flip to banner. + * + * To keep current galleries looking the same, every event whose effective + * config was non-grid + standard gets migrated to non-grid + banner, and + * its controlsStyle is pinned to whatever the runtime would have used + * before the decoupling. + */ +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; + let controlsPinnedCount = 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' }; + + // Preserve previous filter placement: before this release, non-grid + // layouts implicitly rendered the sidebar drawer when controlsStyle + // was unset. Pin it to 'sidebar' so the visual stays identical. Don't + // overwrite explicit values the user may have set deliberately. + if (!theme.controlsStyle) { + updatedTheme.controlsStyle = 'sidebar'; + controlsPinnedCount++; + } + + 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`); + console.log(`[Migration 086] Pinned controlsStyle='sidebar' on ${controlsPinnedCount} events`); +}; + +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' }; + + // Symmetric with up: if controlsStyle is currently 'sidebar', drop it + // so the runtime falls back to whatever the older code would compute. + // Cannot perfectly distinguish "we set this" from "user agreed", but + // the scope is narrow (non-grid + banner) and rolling back is + // intentionally restoring pre-migration state. + if (revertedTheme.controlsStyle === 'sidebar') { + delete revertedTheme.controlsStyle; + } + + 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`); +}; diff --git a/backend/src/routes/adminEvents.js b/backend/src/routes/adminEvents.js index f3b52113..d36db7fc 100644 --- a/backend/src/routes/adminEvents.js +++ b/backend/src/routes/adminEvents.js @@ -382,7 +382,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), @@ -1088,7 +1088,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), diff --git a/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx b/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx index 274337f8..d7564e36 100644 --- a/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx +++ b/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx @@ -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 = { const headerStyleIcons: Record = { hero: , standard: , + banner: , minimal: , none: }; @@ -626,7 +627,7 @@ export const ThemeCustomizerEnhanced: React.FC = (

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

-
+
{(Object.keys(headerStyleIcons) as HeaderStyleType[]).map((style) => ( - )} - - {/* Logout button */} - {showLogout && onLogout && ( - - )} -
-
- - - )} - - {/* For grid layout - everything in one bar (standard header) */} - {!isNonGridLayout && !isHeroHeader && !isMinimalHeader && !isNoHeader && ( +
+ {/* Standard / Banner header - full bar with logo, event info, and actions (all layouts) */} + {!isHeroHeader && !isMinimalHeader && !isNoHeader && (
{/* Left side - Menu button, Logo */} @@ -322,56 +274,8 @@ export const GalleryLayout: React.FC = ({
)} - {/* For minimal/none header + non-grid layouts - compact menu bar */} - {isNonGridLayout && (isMinimalHeader || isNoHeader) && ( -
-
-
-
- {menuButton} - {headerExtra} - {isMinimalHeader && ( -

- {event.event_name} -

- )} -
-
- {showDownloadAll && onDownloadAll && ( - - )} - {showLogout && onLogout && ( - - )} -
-
-
-
- )} - - {/* For minimal header + grid layout - compact bar with event name */} - {!isNonGridLayout && isMinimalHeader && ( + {/* Minimal header - compact bar with event name (all layouts) */} + {isMinimalHeader && (
@@ -413,8 +317,8 @@ export const GalleryLayout: React.FC = ({
)} - {/* 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 && (
@@ -495,8 +399,8 @@ export const GalleryLayout: React.FC = ({ )}
- {/* 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 && (
= ({ slug, event }) => { ); } - // Determine controls style (sidebar vs classic inline filter bar) - // If controlsStyle is explicitly set in theme, use that - // Otherwise: use sidebar for non-grid layouts OR hero headers (prevents filter bar above hero) + // Determine controls style (sidebar vs classic inline filter bar). + // Decoupled from layout — only an explicit controlsStyle === 'sidebar' on + // the theme renders the sidebar. Default (unset or 'classic') is the inline + // filter bar for every layout, so the gallery header/filters look identical + // regardless of whether the photos render as grid, masonry, carousel, etc. const headerStyle = data?.event?.header_style || theme.headerStyle || 'standard'; const isHeroHeader = headerStyle === 'hero'; - const controlsStyle = theme.controlsStyle; - const showSidebar = controlsStyle - ? controlsStyle === 'sidebar' - : (theme.galleryLayout !== 'grid' || isHeroHeader); + const showSidebar = theme.controlsStyle === 'sidebar'; // Full-page layouts (gallery-premium, gallery-story) have their own integrated UI // Skip all wrapper elements (header, footer, sidebar, filters) for these layouts diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 3fa3b6aa..5ef1b9c5 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -1730,13 +1730,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" }, diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 4a815709..74a979c5 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1301,13 +1301,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" }, diff --git a/frontend/src/i18n/locales/nl.json b/frontend/src/i18n/locales/nl.json index e567d69f..ec9e1f64 100644 --- a/frontend/src/i18n/locales/nl.json +++ b/frontend/src/i18n/locales/nl.json @@ -1295,13 +1295,15 @@ "headerStyleDescription": "Kies hoe de galerijheader wordt weergegeven. De headerstijl is onafhankelijk van de fotolay-out.", "headerStyleOptions": { "hero": "Hero-afbeelding", - "standard": "Standaardbanner", + "standard": "Standaard", + "banner": "Banner", "minimal": "Minimaal", "none": "Geen header" }, "headerStyleDescriptions": { "hero": "Afbeelding op volledige hoogte met evenementinfo-overlay", - "standard": "Klassieke banner met evenementdetails", + "standard": "Compacte koptekst met evenementdetails", + "banner": "Standaard-header met een gekleurde banner erboven", "minimal": "Compacte header met essentiele info", "none": "Header volledig verbergen" }, diff --git a/frontend/src/i18n/locales/pt.json b/frontend/src/i18n/locales/pt.json index 5727bc7e..495baaf1 100644 --- a/frontend/src/i18n/locales/pt.json +++ b/frontend/src/i18n/locales/pt.json @@ -1295,13 +1295,15 @@ "headerStyleDescription": "Escolha como o cabeçalho da galeria é exibido. O estilo do cabeçalho é independente do layout das fotos.", "headerStyleOptions": { "hero": "Imagem Hero", - "standard": "Banner Padrão", + "standard": "Padrão", + "banner": "Banner", "minimal": "Minimalista", "none": "Sem Cabeçalho" }, "headerStyleDescriptions": { "hero": "Imagem em altura total com sobreposição de informações do evento", - "standard": "Banner clássico com detalhes do evento", + "standard": "Cabeçalho compacto com detalhes do evento", + "banner": "Cabeçalho padrão com banner colorido acima", "minimal": "Cabeçalho compacto com informações essenciais", "none": "Ocultar cabeçalho completamente" }, diff --git a/frontend/src/i18n/locales/ru.json b/frontend/src/i18n/locales/ru.json index 39aed650..0a9e531b 100644 --- a/frontend/src/i18n/locales/ru.json +++ b/frontend/src/i18n/locales/ru.json @@ -1295,13 +1295,15 @@ "headerStyleDescription": "Выберите, как выглядит заголовок галереи. Стиль заголовка не зависит от макета фото.", "headerStyleOptions": { "hero": "Изображение-баннер", - "standard": "Стандартный баннер", + "standard": "Стандартный заголовок", + "banner": "Баннер", "minimal": "Минималистичный", "none": "Без заголовка" }, "headerStyleDescriptions": { "hero": "Полноэкранное изображение с информацией о событии поверх", - "standard": "Классический баннер с деталями события", + "standard": "Компактный заголовок с деталями события", + "banner": "Стандартный заголовок с цветным баннером сверху", "minimal": "Компактный заголовок с основной информацией", "none": "Полностью скрыть заголовок" }, diff --git a/frontend/src/types/theme.types.ts b/frontend/src/types/theme.types.ts index e5aaf620..a809ddfb 100644 --- a/frontend/src/types/theme.types.ts +++ b/frontend/src/types/theme.types.ts @@ -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 = { carouselInterval: 5000, carouselShowThumbnails: true }, - headerStyle: 'standard', + headerStyle: 'banner', footerStyle: 'standard', backgroundPattern: 'dots' }, @@ -225,7 +225,7 @@ export const GALLERY_THEME_PRESETS: Record = { timelineGrouping: 'day', timelineShowDates: true }, - headerStyle: 'standard', + headerStyle: 'banner', footerStyle: 'full', buttonStyle: 'outline' },