fix: theme save without Live Preview, Branding default on new events, gallery loading flicker (#323, #321)

#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 <GallerySkeleton/> 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.
This commit is contained in:
Paul Nothaft
2026-04-27 22:38:00 +02:00
parent 63a6bfebce
commit 822be9a9b2
6 changed files with 124 additions and 117 deletions
+46 -69
View File
@@ -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 (
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
<div className="min-h-screen flex items-center justify-center">
<Loading size="lg" text={t('gallery.loading')} />
</div>
</div>
);
return <GallerySkeleton />;
}
if (identifierError && !resolvedSlug && !isResolvingIdentifier) {
@@ -448,6 +445,13 @@ export const GalleryPage: React.FC = () => {
return <GalleryView slug={gallerySlugForView} event={event} />;
}
// 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 <GallerySkeleton />;
}
// Show login form
return (
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
@@ -487,67 +491,40 @@ export const GalleryPage: React.FC = () => {
<Card>
<CardContent className="p-4 sm:p-6">
{requiresPassword ? (
<>
<h2 className="text-base sm:text-lg lg:text-xl font-semibold mb-4">{t('auth.enterPassword')}</h2>
<form onSubmit={handleLogin} className="space-y-4">
<Input
type="password"
label={t('auth.password')}
placeholder={t('auth.passwordPlaceholder')}
value={password}
onChange={(e) => setPassword(e.target.value)}
error={loginError || undefined}
autoFocus
className="text-sm sm:text-base"
/>
<ReCaptcha
onChange={setRecaptchaToken}
onExpired={() => setRecaptchaToken(null)}
/>
<Button
type="submit"
variant="primary"
size="lg"
className="w-full text-sm sm:text-base"
isLoading={isLoggingIn}
disabled={isLoggingIn}
>
{t('gallery.viewGallery')}
</Button>
</form>
<h2 className="text-base sm:text-lg lg:text-xl font-semibold mb-4">{t('auth.enterPassword')}</h2>
<p className="text-xs text-neutral-500 text-center mt-4 sm:mt-6">
{t('auth.passwordHint')}
</p>
</>
) : (
<div className="text-center space-y-3">
{isLoadingSettings ? (
<div className="flex justify-center py-4">
<Loading size="sm" />
</div>
) : (
<>
<h2 className="text-base sm:text-lg lg:text-xl font-semibold">
{t('gallery.publicGalleryTitle', 'This gallery is publicly accessible')}
</h2>
<p className="text-sm text-neutral-600">
{t('gallery.publicGallerySubtitle', 'Loading the photos now...')}
</p>
<div className="flex justify-center py-4">
<Loading size="sm" text={t('gallery.loading')} />
</div>
</>
)}
{loginError && (
<p className="text-xs text-red-600">{loginError}</p>
)}
</div>
)}
<form onSubmit={handleLogin} className="space-y-4">
<Input
type="password"
label={t('auth.password')}
placeholder={t('auth.passwordPlaceholder')}
value={password}
onChange={(e) => setPassword(e.target.value)}
error={loginError || undefined}
autoFocus
className="text-sm sm:text-base"
/>
<ReCaptcha
onChange={setRecaptchaToken}
onExpired={() => setRecaptchaToken(null)}
/>
<Button
type="submit"
variant="primary"
size="lg"
className="w-full text-sm sm:text-base"
isLoading={isLoggingIn}
disabled={isLoggingIn}
>
{t('gallery.viewGallery')}
</Button>
</form>
<p className="text-xs text-neutral-500 text-center mt-4 sm:mt-6">
{t('auth.passwordHint')}
</p>
</CardContent>
</Card>
@@ -738,7 +738,6 @@ export const BrandingPage: React.FC = () => {
onChange={handleThemeChange}
presetName={currentThemeName}
onPresetChange={handlePresetChange}
isPreviewMode={isPreviewMode}
showGalleryLayouts={true}
hideActions={true}
/>
+33 -6
View File
@@ -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}
/>