fix: prevent custom logo blink with global loading state
This commit is contained in:
@@ -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 () => {
|
if (logoUrl) {
|
||||||
controller.abort();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
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 />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user