From 822be9a9b2716f1832a4cb6fccd53602e3cbab51 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Mon, 27 Apr 2026 14:59:27 +0200 Subject: [PATCH] fix: theme save without Live Preview, Branding default on new events, gallery loading flicker (#323, #321) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #323-A — Branding colour changes weren't persisting unless "Apply changes immediately (Live Preview)" was checked. ThemeCustomizerEnhanced was gating its `onChange` callback on `isPreviewMode`, but the parent BrandingPage already gates global `setTheme()` on its own copy of that flag — so the customizer's gate was double-gating and silently dropped the new values from the parent state that Save reads from. Always propagate `onChange`; let parents decide what's "live". Removed the now no-op `isPreviewMode` prop and dropped the unused passers. #323-B — Default theme set in Branding wasn't applied to new events. CreateEventPage only inherited the event-type's recommended preset, with 'default' falling back to Classic Grid. Now reads `settings.theme_config` on first load and uses it as the form's starting theme; the event-type effect skips the generic 'default' so the Branding default sticks for event types like "Other". #321 — Visitors saw four sequential render states when opening a gallery (full-page "Loading Gallery" → "publicly accessible — loading photos" card → skeleton grid → real gallery). Extracted the skeleton into a shared and used it for both GalleryView's photos- loading state and GalleryPage's gallery-info-loading + public-auto-login phases. The "publicly accessible" interstitial is gone. Net: one continuous skeleton from URL open until real photos render. --- .../admin/ThemeCustomizerEnhanced.tsx | 26 ++-- .../components/gallery/GallerySkeleton.tsx | 31 +++++ .../src/components/gallery/GalleryView.tsx | 29 +---- frontend/src/pages/GalleryPage.tsx | 115 +++++++----------- frontend/src/pages/admin/BrandingPage.tsx | 1 - frontend/src/pages/admin/CreateEventPage.tsx | 39 +++++- 6 files changed, 124 insertions(+), 117 deletions(-) create mode 100644 frontend/src/components/gallery/GallerySkeleton.tsx diff --git a/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx b/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx index 3ad8dfbd..8278f8b5 100644 --- a/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx +++ b/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx @@ -12,7 +12,6 @@ interface ThemeCustomizerEnhancedProps { onChange: (theme: ThemeConfig) => void; presetName?: string; onPresetChange?: (presetName: string) => void; - isPreviewMode?: boolean; showGalleryLayouts?: boolean; hideActions?: boolean; onApply?: (theme: ThemeConfig, metadata: { presetName: string }) => Promise | void; @@ -76,7 +75,6 @@ export const ThemeCustomizerEnhanced: React.FC = ( onChange, presetName = 'default', onPresetChange, - isPreviewMode = false, showGalleryLayouts = true, hideActions = false, onApply, @@ -125,10 +123,11 @@ export const ThemeCustomizerEnhanced: React.FC = ( onPresetChange('custom'); } - if (isPreviewMode) { - // Include customCss in the propagated theme - onChange({ ...updated, customCss }); - } + // Always propagate to parent so Save sees the latest values (#323). + // The "Apply changes immediately (Live Preview)" toggle controls whether + // the parent applies the theme globally — that gating belongs in the + // parent, not here. + onChange({ ...updated, customCss }); }; const handlePresetSelect = (presetKey: string) => { @@ -140,9 +139,8 @@ export const ThemeCustomizerEnhanced: React.FC = ( if (onPresetChange) { onPresetChange(presetKey); } - if (isPreviewMode) { - onChange(preset.config); - } + // Always propagate; live-apply gating is the parent's concern (#323). + onChange(preset.config); } }; @@ -795,7 +793,7 @@ export const ThemeCustomizerEnhanced: React.FC = ( mutedTextColor: '#a3a3a3', }; setLocalTheme(updated); - if (isPreviewMode) onChange({ ...updated, customCss }); + onChange({ ...updated, customCss }); } else if (mode === 'light' && localTheme.colorMode === 'dark') { const updated = { ...localTheme, @@ -807,7 +805,7 @@ export const ThemeCustomizerEnhanced: React.FC = ( mutedTextColor: '#737373', }; setLocalTheme(updated); - if (isPreviewMode) onChange({ ...updated, customCss }); + onChange({ ...updated, customCss }); } }} className={`px-4 py-2 text-sm font-medium rounded-lg border transition-colors ${ @@ -1181,10 +1179,8 @@ export const ThemeCustomizerEnhanced: React.FC = ( setSelectedPreset('custom'); onPresetChange('custom'); } - // Propagate customCss changes to parent in preview mode - if (isPreviewMode) { - onChange({ ...localTheme, customCss: newCss }); - } + // Propagate to parent so Save sees the latest CSS (#323). + onChange({ ...localTheme, customCss: newCss }); }} placeholder="/* Add custom CSS here */" className="w-full h-40 px-3 py-2 font-mono text-sm border border-neutral-300 dark:border-neutral-600 rounded-lg bg-neutral-50 dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100" diff --git a/frontend/src/components/gallery/GallerySkeleton.tsx b/frontend/src/components/gallery/GallerySkeleton.tsx new file mode 100644 index 00000000..4f2046d4 --- /dev/null +++ b/frontend/src/components/gallery/GallerySkeleton.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { Skeleton, SkeletonGalleryGrid } from '../common'; + +/** + * Loading placeholder shown while a gallery is resolving (slug → info → + * auto-login → photos). Used by GalleryPage during the pre-photos phases and + * by GalleryView while the photos query runs, so the visitor sees one + * continuous skeleton instead of multiple full-page interstitials (#321). + */ +export const GallerySkeleton: React.FC = () => ( +
+
+
+
+
+ + +
+
+ + +
+
+
+
+
+ + +
+
+); diff --git a/frontend/src/components/gallery/GalleryView.tsx b/frontend/src/components/gallery/GalleryView.tsx index 1f55dc30..f51afb58 100644 --- a/frontend/src/components/gallery/GalleryView.tsx +++ b/frontend/src/components/gallery/GalleryView.tsx @@ -3,7 +3,8 @@ import { differenceInDays, parseISO } from 'date-fns'; import { useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; -import { Button, SkeletonGalleryGrid, Skeleton } from '../common'; +import { Button } from '../common'; +import { GallerySkeleton } from './GallerySkeleton'; import { useGalleryAuth, useTheme } from '../../contexts'; import { useGalleryPhotos, useDownloadAllPhotos } from '../../hooks/useGallery'; import { PhotoGridWithLayouts } from './PhotoGridWithLayouts'; @@ -587,31 +588,7 @@ export const GalleryView: React.FC = ({ slug, event }) => { }, [showUrgentWarning, daysUntilExpiration, slug]); if (isLoading) { - return ( -
- {/* Header Skeleton */} -
-
-
-
- - -
-
- - -
-
-
-
- - {/* Content Skeleton */} -
- - -
-
- ); + return ; } if (error || !data) { diff --git a/frontend/src/pages/GalleryPage.tsx b/frontend/src/pages/GalleryPage.tsx index 35f75578..c37aae3e 100644 --- a/frontend/src/pages/GalleryPage.tsx +++ b/frontend/src/pages/GalleryPage.tsx @@ -6,10 +6,11 @@ import { useTranslation } from 'react-i18next'; import { useLocalizedDate } from '../hooks/useLocalizedDate'; import { useQuery } from '@tanstack/react-query'; -import { Card, CardContent, Input, Button, Loading, ReCaptcha } from '../components/common'; +import { Card, CardContent, Input, Button, ReCaptcha } from '../components/common'; import { useGalleryAuth, useTheme } from '../contexts'; import { useGalleryInfo } from '../hooks/useGallery'; import { GalleryView } from '../components/gallery'; +import { GallerySkeleton } from '../components/gallery/GallerySkeleton'; import { analyticsService } from '../services/analytics.service'; import { galleryService } from '../services'; import { api } from '../config/api'; @@ -258,15 +259,11 @@ export const GalleryPage: React.FC = () => { } }; - // Show loading state + // Show the same skeleton GalleryView uses while photos load, so the + // visitor sees one continuous loading state from URL open to real photos + // instead of three different full-page interstitials (#321). if (isLoadingInfo) { - return ( -
-
- -
-
- ); + return ; } if (identifierError && !resolvedSlug && !isResolvingIdentifier) { @@ -448,6 +445,13 @@ export const GalleryPage: React.FC = () => { return ; } + // Public gallery: auto-login is in flight (or about to fire). Show the + // skeleton instead of the "publicly accessible — loading photos" card so + // visitors see one continuous skeleton until real photos appear (#321). + if (!requiresPassword) { + return ; + } + // Show login form return (
@@ -487,67 +491,40 @@ export const GalleryPage: React.FC = () => { - {requiresPassword ? ( - <> -

{t('auth.enterPassword')}

- -
- setPassword(e.target.value)} - error={loginError || undefined} - autoFocus - className="text-sm sm:text-base" - /> - - setRecaptchaToken(null)} - /> - - - +

{t('auth.enterPassword')}

-

- {t('auth.passwordHint')} -

- - ) : ( -
- {isLoadingSettings ? ( -
- -
- ) : ( - <> -

- {t('gallery.publicGalleryTitle', 'This gallery is publicly accessible')} -

-

- {t('gallery.publicGallerySubtitle', 'Loading the photos now...')} -

-
- -
- - )} - {loginError && ( -

{loginError}

- )} -
- )} +
+ setPassword(e.target.value)} + error={loginError || undefined} + autoFocus + className="text-sm sm:text-base" + /> + + setRecaptchaToken(null)} + /> + + + + +

+ {t('auth.passwordHint')} +

diff --git a/frontend/src/pages/admin/BrandingPage.tsx b/frontend/src/pages/admin/BrandingPage.tsx index 1fb2e86a..fc26fd6d 100644 --- a/frontend/src/pages/admin/BrandingPage.tsx +++ b/frontend/src/pages/admin/BrandingPage.tsx @@ -738,7 +738,6 @@ export const BrandingPage: React.FC = () => { onChange={handleThemeChange} presetName={currentThemeName} onPresetChange={handlePresetChange} - isPreviewMode={isPreviewMode} showGalleryLayouts={true} hideActions={true} /> diff --git a/frontend/src/pages/admin/CreateEventPage.tsx b/frontend/src/pages/admin/CreateEventPage.tsx index ce30d945..b96c29b3 100644 --- a/frontend/src/pages/admin/CreateEventPage.tsx +++ b/frontend/src/pages/admin/CreateEventPage.tsx @@ -204,13 +204,41 @@ export const CreateEventPage: React.FC = () => { })); }, [publicSettings]); - // Update theme when event type changes + // Apply the global Branding default theme on first load so admins who set a + // site-wide default in Branding actually see it on new events (#323). + const brandingThemeApplied = useRef(false); useEffect(() => { - // Find the selected event type's theme preset - const selectedType = availableEventTypes.find(t => t.value === formData.event_type); - const recommendedPreset = selectedType?.theme_preset || 'default'; + if (brandingThemeApplied.current) return; + const brandingTheme = settings?.theme_config as ThemeConfig | undefined; + if (!brandingTheme || Object.keys(brandingTheme).length === 0) return; + brandingThemeApplied.current = true; - if (recommendedPreset && GALLERY_THEME_PRESETS[recommendedPreset]) { + // Identify which preset (if any) the Branding theme matches, so the + // "Theme & Style" panel shows the right name. + let matchedPreset = 'custom'; + for (const [key, preset] of Object.entries(GALLERY_THEME_PRESETS)) { + if (JSON.stringify(preset.config) === JSON.stringify(brandingTheme)) { + matchedPreset = key; + break; + } + } + + setFormData(prev => ({ + ...prev, + theme_preset: matchedPreset, + theme_config: brandingTheme + })); + }, [settings]); + + // Update theme when event type changes — but only when the event type has + // an explicit recommended preset. Skip the generic 'default' so the global + // Branding theme isn't clobbered by Classic Grid for event types like + // "Other" (#323). + useEffect(() => { + const selectedType = availableEventTypes.find(t => t.value === formData.event_type); + const recommendedPreset = selectedType?.theme_preset; + + if (recommendedPreset && recommendedPreset !== 'default' && GALLERY_THEME_PRESETS[recommendedPreset]) { setFormData(prev => ({ ...prev, theme_preset: recommendedPreset, @@ -525,7 +553,6 @@ export const CreateEventPage: React.FC = () => { onChange={handleThemeChange} presetName={formData.theme_preset} onPresetChange={handlePresetChange} - isPreviewMode={true} showGalleryLayouts={true} hideActions={true} />