fix: render minimal/none header styles, cap hero height, switch category hero images (#158, #162, #163)

- Add distinct rendering branches for minimal and none header styles in
  GalleryLayout (grid and non-grid), skipping the colored banner/wave
  divider for both
- Cap hero section height at 700px via max-h to prevent it dominating
  ultra-wide viewports
- Watch selectedCategoryId in GalleryView and swap the hero photo to
  the category's hero_photo_id when filtering, reverting to the event
  default when cleared
- Add minimal/none preview branches in GalleryPreview so the admin
  theme editor shows visually distinct previews for all four styles
- Remove unused AdminPhoto import that was blocking the build
- Add Playwright e2e tests covering all four header styles, hero max
  height, and category hero switching
This commit is contained in:
Paul Nothaft
2026-02-04 08:30:55 +01:00
parent a19e7c40a2
commit bc6c48bb24
6 changed files with 510 additions and 19 deletions
@@ -3,7 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, X, Loader2, Image as ImageIcon, Check } from 'lucide-react';
import { toast } from 'react-toastify';
import { categoriesService, type PhotoCategory } from '../../services/categories.service';
import { photosService, type AdminPhoto } from '../../services/photos.service';
import { photosService } from '../../services/photos.service';
import { Button, Card, AuthenticatedImage } from '../common';
import { useTranslation } from 'react-i18next';
@@ -92,8 +92,10 @@ export const GalleryPreview: React.FC<GalleryPreviewProps> = ({
? 'justify-end text-right flex-row-reverse'
: 'justify-start text-left';
// Check if hero header style is selected
// Check header style
const isHeroHeader = theme.headerStyle === 'hero';
const isMinimalHeader = theme.headerStyle === 'minimal';
const isNoHeader = theme.headerStyle === 'none';
const heroDividerStyle: HeroDividerStyle = theme.heroDividerStyle || 'wave';
// Render hero divider based on style
@@ -210,7 +212,7 @@ export const GalleryPreview: React.FC<GalleryPreviewProps> = ({
fontFamily: theme.fontFamily || 'Inter, sans-serif',
}}
>
{/* Hero Header - shown when headerStyle is 'hero' */}
{/* Hero Header */}
{isHeroHeader && (
<div
className="relative text-white overflow-hidden"
@@ -221,7 +223,6 @@ export const GalleryPreview: React.FC<GalleryPreviewProps> = ({
>
<div className="py-8 px-4 relative z-10">
<div className="text-center max-w-md mx-auto">
{/* Logo in Hero */}
{showLogo && (
<div className="mb-3">
{resolvedLogoUrl ? (
@@ -238,7 +239,6 @@ export const GalleryPreview: React.FC<GalleryPreviewProps> = ({
)}
</div>
)}
{/* Event Name */}
<h1
className="text-xl font-bold mb-2"
style={{
@@ -248,22 +248,20 @@ export const GalleryPreview: React.FC<GalleryPreviewProps> = ({
>
Sample Event
</h1>
{/* Event Date */}
<div className="flex items-center justify-center text-white/80 text-sm" style={{ textShadow: '0 1px 3px rgba(0, 0, 0, 0.3)' }}>
<Calendar className="w-4 h-4 mr-1" />
<span>January 15, 2026</span>
</div>
</div>
</div>
{/* Divider */}
<div className="absolute bottom-0 left-0 right-0">
{renderHeroDivider()}
</div>
</div>
)}
{/* Standard Header - shown when headerStyle is NOT 'hero' */}
{!isHeroHeader && (
{/* Standard Header */}
{!isHeroHeader && !isMinimalHeader && !isNoHeader && (
<div
className="px-4 py-3 border-b space-y-2"
style={{
@@ -299,10 +297,32 @@ export const GalleryPreview: React.FC<GalleryPreviewProps> = ({
</div>
)}
{/* Minimal Header - thin bar with just event name */}
{isMinimalHeader && (
<div
className="px-4 py-2 border-b"
style={{
borderColor: theme.primaryColor ? `${theme.primaryColor}20` : '#e5e7eb',
}}
>
<p
className="text-sm font-semibold truncate"
style={{
fontFamily: theme.headingFontFamily || theme.fontFamily || 'Inter, sans-serif',
}}
>
Sample Event
</p>
</div>
)}
{/* None Header - no header content at all */}
{/* (isNoHeader renders nothing here — goes straight to layout bar) */}
{/* Layout info bar */}
<div className="px-4 py-1 border-b text-xs text-neutral-500 flex justify-between" style={{ borderColor: theme.primaryColor ? `${theme.primaryColor}20` : '#e5e7eb' }}>
<span>Gallery preview</span>
<span className="capitalize">{isHeroHeader ? `Hero + ${activeLayout}` : `${activeLayout} layout`}</span>
<span className="capitalize">{isHeroHeader ? `Hero + ${activeLayout}` : isMinimalHeader ? `Minimal + ${activeLayout}` : isNoHeader ? `No header + ${activeLayout}` : `${activeLayout} layout`}</span>
</div>
{/* Preview Content */}
@@ -63,6 +63,8 @@ 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 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';
@@ -132,8 +134,8 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
{/* Header structure */}
<header className={`gallery-header bg-white border-b border-neutral-200 sticky top-0 z-40 ${isNonGridLayout || isHeroHeader ? 'shadow-sm' : ''}`}>
{/* For non-grid layouts - keep the current structure */}
{isNonGridLayout && !isHeroHeader && (
{/* For non-grid layouts - keep the current structure (standard and minimal/none) */}
{isNonGridLayout && !isHeroHeader && !isMinimalHeader && !isNoHeader && (
<div className="bg-neutral-50 border-b border-neutral-200">
<div className="container py-2">
<div className="flex items-center justify-between">
@@ -179,7 +181,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
)}
{/* For grid layout - everything in one bar (standard header) */}
{!isNonGridLayout && !isHeroHeader && (
{!isNonGridLayout && !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 */}
@@ -300,6 +302,134 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
</div>
)}
{/* For minimal/none header + non-grid layouts - compact menu bar */}
{isNonGridLayout && (isMinimalHeader || isNoHeader) && (
<div className="bg-neutral-50 border-b border-neutral-200">
<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-neutral-900 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 && (
<div className="container py-2">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
{menuButton}
<h1
className="text-sm font-semibold text-neutral-900 truncate"
style={{ fontFamily: headingFontFamily }}
>
{event.event_name}
</h1>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{headerExtra}
{showDownloadAll && onDownloadAll && (
<Button
variant="primary"
size="sm"
leftIcon={<Download className="w-4 h-4" />}
onClick={onDownloadAll}
isLoading={isDownloading}
className="gallery-btn gallery-btn-download hidden sm:flex"
>
<span className="hidden sm:inline">{t('gallery.downloadAll')}</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>
)}
{/* For none header + grid layout - just functional buttons, no event info */}
{!isNonGridLayout && isNoHeader && (
<div className="container py-2">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
{menuButton}
{headerExtra}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{showDownloadAll && onDownloadAll && (
<Button
variant="primary"
size="sm"
leftIcon={<Download className="w-4 h-4" />}
onClick={onDownloadAll}
isLoading={isDownloading}
className="gallery-btn gallery-btn-download hidden sm:flex"
>
<span className="hidden sm:inline">{t('gallery.downloadAll')}</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>
)}
{/* For hero header style - minimal header with just menu and logout */}
{isHeroHeader && (
<div className="container py-3">
@@ -345,8 +475,8 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
)}
</header>
{/* Hero Header for non-grid layouts when using standard header style */}
{isNonGridLayout && !isHeroHeader && (
{/* Colored banner for non-grid layouts when using standard header style */}
{isNonGridLayout && !isHeroHeader && !isMinimalHeader && !isNoHeader && (
<div
className="gallery-hero relative text-white overflow-hidden"
style={{
@@ -236,9 +236,11 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
}
}, [showMediaFilter, mediaFilter]);
// Determine a stable hero photo from the initial (unfiltered) load
// Determine the default hero photo from the initial (unfiltered) load
const [defaultHeroPhoto, setDefaultHeroPhoto] = useState<Photo | null>(null);
useEffect(() => {
if (!staticHeroPhoto && data?.photos && filterType === 'all') {
if (!defaultHeroPhoto && data?.photos && filterType === 'all') {
let hero: Photo | null = null;
const heroId = data?.event?.hero_photo_id || null;
if (heroId) {
@@ -249,10 +251,29 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
hero = firstPhoto || data.photos[0];
}
if (hero) {
setDefaultHeroPhoto(hero);
setStaticHeroPhoto(hero);
}
}
}, [data?.photos, data?.event?.hero_photo_id, filterType, staticHeroPhoto]);
}, [data?.photos, data?.event?.hero_photo_id, filterType, defaultHeroPhoto]);
// Switch hero photo when a category with its own hero image is selected
useEffect(() => {
if (!data?.photos || !defaultHeroPhoto) return;
if (selectedCategoryId) {
const category = (data.categories || []).find(c => c.id === selectedCategoryId);
if (category?.hero_photo_id) {
const categoryHero = data.photos.find(p => p.id === category.hero_photo_id);
if (categoryHero) {
setStaticHeroPhoto(categoryHero);
return;
}
}
}
// No category selected or category has no hero — revert to default
setStaticHeroPhoto(defaultHeroPhoto);
}, [selectedCategoryId, data?.categories, data?.photos, defaultHeroPhoto]);
// Apply theme when settings are loaded
useEffect(() => {
@@ -135,7 +135,7 @@ export const HeroHeader: React.FC<HeroHeaderProps> = ({
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">
<div className="relative h-[60vh] sm:h-[70vh] lg:h-[80vh] max-h-[700px] -mx-4 sm:-mx-6 lg:-mx-8 mb-8">
<AuthenticatedImage
src={heroPhoto.url}
fallbackSrc={heroPhoto.thumbnail_url || undefined}