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:
@@ -12,7 +12,6 @@ interface ThemeCustomizerEnhancedProps {
|
|||||||
onChange: (theme: ThemeConfig) => void;
|
onChange: (theme: ThemeConfig) => void;
|
||||||
presetName?: string;
|
presetName?: string;
|
||||||
onPresetChange?: (presetName: string) => void;
|
onPresetChange?: (presetName: string) => void;
|
||||||
isPreviewMode?: boolean;
|
|
||||||
showGalleryLayouts?: boolean;
|
showGalleryLayouts?: boolean;
|
||||||
hideActions?: boolean;
|
hideActions?: boolean;
|
||||||
onApply?: (theme: ThemeConfig, metadata: { presetName: string }) => Promise<void> | void;
|
onApply?: (theme: ThemeConfig, metadata: { presetName: string }) => Promise<void> | void;
|
||||||
@@ -76,7 +75,6 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
|||||||
onChange,
|
onChange,
|
||||||
presetName = 'default',
|
presetName = 'default',
|
||||||
onPresetChange,
|
onPresetChange,
|
||||||
isPreviewMode = false,
|
|
||||||
showGalleryLayouts = true,
|
showGalleryLayouts = true,
|
||||||
hideActions = false,
|
hideActions = false,
|
||||||
onApply,
|
onApply,
|
||||||
@@ -125,10 +123,11 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
|||||||
onPresetChange('custom');
|
onPresetChange('custom');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isPreviewMode) {
|
// Always propagate to parent so Save sees the latest values (#323).
|
||||||
// Include customCss in the propagated theme
|
// The "Apply changes immediately (Live Preview)" toggle controls whether
|
||||||
onChange({ ...updated, customCss });
|
// the parent applies the theme globally — that gating belongs in the
|
||||||
}
|
// parent, not here.
|
||||||
|
onChange({ ...updated, customCss });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePresetSelect = (presetKey: string) => {
|
const handlePresetSelect = (presetKey: string) => {
|
||||||
@@ -140,9 +139,8 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
|||||||
if (onPresetChange) {
|
if (onPresetChange) {
|
||||||
onPresetChange(presetKey);
|
onPresetChange(presetKey);
|
||||||
}
|
}
|
||||||
if (isPreviewMode) {
|
// Always propagate; live-apply gating is the parent's concern (#323).
|
||||||
onChange(preset.config);
|
onChange(preset.config);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -795,7 +793,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
|||||||
mutedTextColor: '#a3a3a3',
|
mutedTextColor: '#a3a3a3',
|
||||||
};
|
};
|
||||||
setLocalTheme(updated);
|
setLocalTheme(updated);
|
||||||
if (isPreviewMode) onChange({ ...updated, customCss });
|
onChange({ ...updated, customCss });
|
||||||
} else if (mode === 'light' && localTheme.colorMode === 'dark') {
|
} else if (mode === 'light' && localTheme.colorMode === 'dark') {
|
||||||
const updated = {
|
const updated = {
|
||||||
...localTheme,
|
...localTheme,
|
||||||
@@ -807,7 +805,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
|||||||
mutedTextColor: '#737373',
|
mutedTextColor: '#737373',
|
||||||
};
|
};
|
||||||
setLocalTheme(updated);
|
setLocalTheme(updated);
|
||||||
if (isPreviewMode) onChange({ ...updated, customCss });
|
onChange({ ...updated, customCss });
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={`px-4 py-2 text-sm font-medium rounded-lg border transition-colors ${
|
className={`px-4 py-2 text-sm font-medium rounded-lg border transition-colors ${
|
||||||
@@ -1181,10 +1179,8 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
|||||||
setSelectedPreset('custom');
|
setSelectedPreset('custom');
|
||||||
onPresetChange('custom');
|
onPresetChange('custom');
|
||||||
}
|
}
|
||||||
// Propagate customCss changes to parent in preview mode
|
// Propagate to parent so Save sees the latest CSS (#323).
|
||||||
if (isPreviewMode) {
|
onChange({ ...localTheme, customCss: newCss });
|
||||||
onChange({ ...localTheme, customCss: newCss });
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
placeholder="/* Add custom CSS here */"
|
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"
|
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"
|
||||||
|
|||||||
@@ -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 = () => (
|
||||||
|
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
|
||||||
|
<header className="bg-surface border-b border-surface sticky top-0 z-40">
|
||||||
|
<div className="container py-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Skeleton height={32} width={200} className="mb-2" />
|
||||||
|
<Skeleton height={20} width={300} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Skeleton height={40} width={120} />
|
||||||
|
<Skeleton height={40} width={100} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div className="container mt-6">
|
||||||
|
<Skeleton height={80} className="mb-6" />
|
||||||
|
<SkeletonGalleryGrid count={12} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -3,7 +3,8 @@ import { differenceInDays, parseISO } from 'date-fns';
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 { useGalleryAuth, useTheme } from '../../contexts';
|
||||||
import { useGalleryPhotos, useDownloadAllPhotos } from '../../hooks/useGallery';
|
import { useGalleryPhotos, useDownloadAllPhotos } from '../../hooks/useGallery';
|
||||||
import { PhotoGridWithLayouts } from './PhotoGridWithLayouts';
|
import { PhotoGridWithLayouts } from './PhotoGridWithLayouts';
|
||||||
@@ -587,31 +588,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
}, [showUrgentWarning, daysUntilExpiration, slug]);
|
}, [showUrgentWarning, daysUntilExpiration, slug]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return <GallerySkeleton />;
|
||||||
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-background)' }}>
|
|
||||||
{/* Header Skeleton */}
|
|
||||||
<header className="bg-surface border-b border-surface sticky top-0 z-40">
|
|
||||||
<div className="container py-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<Skeleton height={32} width={200} className="mb-2" />
|
|
||||||
<Skeleton height={20} width={300} />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Skeleton height={40} width={120} />
|
|
||||||
<Skeleton height={40} width={100} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Content Skeleton */}
|
|
||||||
<div className="container mt-6">
|
|
||||||
<Skeleton height={80} className="mb-6" />
|
|
||||||
<SkeletonGalleryGrid count={12} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error || !data) {
|
if (error || !data) {
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { useLocalizedDate } from '../hooks/useLocalizedDate';
|
import { useLocalizedDate } from '../hooks/useLocalizedDate';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
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 { useGalleryAuth, useTheme } from '../contexts';
|
||||||
import { useGalleryInfo } from '../hooks/useGallery';
|
import { useGalleryInfo } from '../hooks/useGallery';
|
||||||
import { GalleryView } from '../components/gallery';
|
import { GalleryView } from '../components/gallery';
|
||||||
|
import { GallerySkeleton } from '../components/gallery/GallerySkeleton';
|
||||||
import { analyticsService } from '../services/analytics.service';
|
import { analyticsService } from '../services/analytics.service';
|
||||||
import { galleryService } from '../services';
|
import { galleryService } from '../services';
|
||||||
import { api } from '../config/api';
|
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) {
|
if (isLoadingInfo) {
|
||||||
return (
|
return <GallerySkeleton />;
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (identifierError && !resolvedSlug && !isResolvingIdentifier) {
|
if (identifierError && !resolvedSlug && !isResolvingIdentifier) {
|
||||||
@@ -448,6 +445,13 @@ export const GalleryPage: React.FC = () => {
|
|||||||
return <GalleryView slug={gallerySlugForView} event={event} />;
|
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
|
// Show login form
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
|
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
|
||||||
@@ -487,67 +491,40 @@ export const GalleryPage: React.FC = () => {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-4 sm:p-6">
|
<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>
|
||||||
<>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<p className="text-xs text-neutral-500 text-center mt-4 sm:mt-6">
|
<form onSubmit={handleLogin} className="space-y-4">
|
||||||
{t('auth.passwordHint')}
|
<Input
|
||||||
</p>
|
type="password"
|
||||||
</>
|
label={t('auth.password')}
|
||||||
) : (
|
placeholder={t('auth.passwordPlaceholder')}
|
||||||
<div className="text-center space-y-3">
|
value={password}
|
||||||
{isLoadingSettings ? (
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
<div className="flex justify-center py-4">
|
error={loginError || undefined}
|
||||||
<Loading size="sm" />
|
autoFocus
|
||||||
</div>
|
className="text-sm sm:text-base"
|
||||||
) : (
|
/>
|
||||||
<>
|
|
||||||
<h2 className="text-base sm:text-lg lg:text-xl font-semibold">
|
<ReCaptcha
|
||||||
{t('gallery.publicGalleryTitle', 'This gallery is publicly accessible')}
|
onChange={setRecaptchaToken}
|
||||||
</h2>
|
onExpired={() => setRecaptchaToken(null)}
|
||||||
<p className="text-sm text-neutral-600">
|
/>
|
||||||
{t('gallery.publicGallerySubtitle', 'Loading the photos now...')}
|
|
||||||
</p>
|
<Button
|
||||||
<div className="flex justify-center py-4">
|
type="submit"
|
||||||
<Loading size="sm" text={t('gallery.loading')} />
|
variant="primary"
|
||||||
</div>
|
size="lg"
|
||||||
</>
|
className="w-full text-sm sm:text-base"
|
||||||
)}
|
isLoading={isLoggingIn}
|
||||||
{loginError && (
|
disabled={isLoggingIn}
|
||||||
<p className="text-xs text-red-600">{loginError}</p>
|
>
|
||||||
)}
|
{t('gallery.viewGallery')}
|
||||||
</div>
|
</Button>
|
||||||
)}
|
</form>
|
||||||
|
|
||||||
|
<p className="text-xs text-neutral-500 text-center mt-4 sm:mt-6">
|
||||||
|
{t('auth.passwordHint')}
|
||||||
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -738,7 +738,6 @@ export const BrandingPage: React.FC = () => {
|
|||||||
onChange={handleThemeChange}
|
onChange={handleThemeChange}
|
||||||
presetName={currentThemeName}
|
presetName={currentThemeName}
|
||||||
onPresetChange={handlePresetChange}
|
onPresetChange={handlePresetChange}
|
||||||
isPreviewMode={isPreviewMode}
|
|
||||||
showGalleryLayouts={true}
|
showGalleryLayouts={true}
|
||||||
hideActions={true}
|
hideActions={true}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -204,13 +204,41 @@ export const CreateEventPage: React.FC = () => {
|
|||||||
}));
|
}));
|
||||||
}, [publicSettings]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
// Find the selected event type's theme preset
|
if (brandingThemeApplied.current) return;
|
||||||
const selectedType = availableEventTypes.find(t => t.value === formData.event_type);
|
const brandingTheme = settings?.theme_config as ThemeConfig | undefined;
|
||||||
const recommendedPreset = selectedType?.theme_preset || 'default';
|
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 => ({
|
setFormData(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
theme_preset: recommendedPreset,
|
theme_preset: recommendedPreset,
|
||||||
@@ -525,7 +553,6 @@ export const CreateEventPage: React.FC = () => {
|
|||||||
onChange={handleThemeChange}
|
onChange={handleThemeChange}
|
||||||
presetName={formData.theme_preset}
|
presetName={formData.theme_preset}
|
||||||
onPresetChange={handlePresetChange}
|
onPresetChange={handlePresetChange}
|
||||||
isPreviewMode={true}
|
|
||||||
showGalleryLayouts={true}
|
showGalleryLayouts={true}
|
||||||
hideActions={true}
|
hideActions={true}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user