fix: prevent custom logo blink with global loading state

This commit is contained in:
Steven RYDELL
2026-07-30 15:42:27 +02:00
parent fe24a9bec0
commit 25a38cd560
2 changed files with 70 additions and 41 deletions
+10 -40
View File
@@ -4,9 +4,9 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/ */
import { useState, useEffect } from 'react'; import { useEffect } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { getApiBaseUrl } from '@/services/api'; import { useUIStore } from '@/stores/uiStore';
export function DefaultLogo() { export function DefaultLogo() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -31,51 +31,21 @@ export function DefaultLogo() {
export default function Logo() { export default function Logo() {
const { t } = useTranslation(); const { t } = useTranslation();
const [logoUrl, setLogoUrl] = useState<string | null>(null); const logoUrl = useUIStore((s) => s.logoUrl);
const [failed, setFailed] = useState(false); const logoLoading = useUIStore((s) => s.logoLoading);
const fetchLogo = useUIStore((s) => s.fetchLogo);
useEffect(() => { useEffect(() => {
const controller = new AbortController();
async function fetchLogo() {
try {
const response = await fetch(`${getApiBaseUrl()}/logo`, {
signal: controller.signal,
});
const contentType = response.headers.get('content-type') ?? '';
if (response.ok && contentType.startsWith('image/')) {
const blob = await response.blob();
if (!controller.signal.aborted) {
const url = URL.createObjectURL(blob);
setLogoUrl(url);
}
} else {
if (!controller.signal.aborted) setFailed(true);
}
} catch {
if (!controller.signal.aborted) setFailed(true);
}
}
fetchLogo(); fetchLogo();
}, [fetchLogo]);
return () => {
controller.abort();
};
}, []);
useEffect(() => {
return () => {
if (logoUrl) { if (logoUrl) {
URL.revokeObjectURL(logoUrl);
}
};
}, [logoUrl]);
if (logoUrl && !failed) {
return <img src={logoUrl} alt={t('logo.alt', 'Logo')} className="h-7 w-auto max-w-[220px] object-contain" />; return <img src={logoUrl} alt={t('logo.alt', 'Logo')} className="h-7 w-auto max-w-[220px] object-contain" />;
} }
if (logoLoading) {
return <span className="h-7 w-[140px] block" aria-hidden="true" />;
}
return <DefaultLogo />; return <DefaultLogo />;
} }
+59
View File
@@ -6,17 +6,30 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { persist } from 'zustand/middleware'; import { persist } from 'zustand/middleware';
import { getApiBaseUrl } from '@/services/api';
export type Theme = 'light' | 'dark'; export type Theme = 'light' | 'dark';
export type ColorTheme = 'stalwart' | 'ocean' | 'forest' | 'violet'; export type ColorTheme = 'stalwart' | 'ocean' | 'forest' | 'violet';
export type Radius = 'rounded' | 'square'; export type Radius = 'rounded' | 'square';
let logoAbortController: AbortController | null = null;
let logoObjectUrl: string | null = null;
function revokeLogoObjectUrl() {
if (logoObjectUrl) {
URL.revokeObjectURL(logoObjectUrl);
logoObjectUrl = null;
}
}
interface UIState { interface UIState {
theme: Theme; theme: Theme;
colorTheme: ColorTheme; colorTheme: ColorTheme;
radius: Radius; radius: Radius;
sidebarOpen: boolean; sidebarOpen: boolean;
activeSection: string; activeSection: string;
logoUrl: string | null;
logoLoading: boolean;
setTheme: (theme: Theme) => void; setTheme: (theme: Theme) => void;
setColorTheme: (colorTheme: ColorTheme) => void; setColorTheme: (colorTheme: ColorTheme) => void;
@@ -24,6 +37,9 @@ interface UIState {
toggleSidebar: () => void; toggleSidebar: () => void;
setSidebarOpen: (open: boolean) => void; setSidebarOpen: (open: boolean) => void;
setActiveSection: (section: string) => void; setActiveSection: (section: string) => void;
setLogoUrl: (url: string | null) => void;
setLogoLoading: (loading: boolean) => void;
fetchLogo: () => void;
} }
function applyThemeClass(theme: Theme) { function applyThemeClass(theme: Theme) {
@@ -68,6 +84,49 @@ export const useUIStore = create<UIState>()(
radius: 'rounded', radius: 'rounded',
sidebarOpen: typeof window !== 'undefined' ? (window.matchMedia?.('(min-width: 768px)').matches ?? true) : true, sidebarOpen: typeof window !== 'undefined' ? (window.matchMedia?.('(min-width: 768px)').matches ?? true) : true,
activeSection: '', activeSection: '',
logoUrl: null,
logoLoading: false,
setLogoUrl: (url) => {
revokeLogoObjectUrl();
if (url) {
logoObjectUrl = url;
}
set({ logoUrl: url, logoLoading: false });
},
setLogoLoading: (loading) => {
set({ logoLoading: loading });
},
fetchLogo: () => {
const { logoUrl, logoLoading } = get();
if (logoUrl !== null || logoLoading) return;
set({ logoLoading: true });
if (logoAbortController) {
logoAbortController.abort();
}
logoAbortController = new AbortController();
fetch(`${getApiBaseUrl()}/logo`, {
signal: logoAbortController.signal,
})
.then((response) => {
const contentType = response.headers.get('content-type') ?? '';
if (response.ok && contentType.startsWith('image/')) {
return response.blob().then((blob) => {
revokeLogoObjectUrl();
logoObjectUrl = URL.createObjectURL(blob);
set({ logoUrl: logoObjectUrl, logoLoading: false });
});
}
set({ logoUrl: null, logoLoading: false });
})
.catch(() => {
set({ logoUrl: null, logoLoading: false });
});
},
setTheme: (theme) => { setTheme: (theme) => {
applyThemeClass(theme); applyThemeClass(theme);