refactor(logo): encapsulate shared logo cache with AbortController cleanup
This commit is contained in:
@@ -4,9 +4,9 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useSyncExternalStore } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useUIStore } from '@/stores/uiStore';
|
||||
import { ensureLogoLoaded, getLogoSnapshot, subscribeLogo } from '@/lib/logoCache';
|
||||
|
||||
export function DefaultLogo() {
|
||||
const { t } = useTranslation();
|
||||
@@ -31,19 +31,17 @@ export function DefaultLogo() {
|
||||
|
||||
export default function Logo() {
|
||||
const { t } = useTranslation();
|
||||
const logoUrl = useUIStore((s) => s.logoUrl);
|
||||
const logoLoading = useUIStore((s) => s.logoLoading);
|
||||
const fetchLogo = useUIStore((s) => s.fetchLogo);
|
||||
const logo = useSyncExternalStore(subscribeLogo, getLogoSnapshot, getLogoSnapshot);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogo();
|
||||
}, [fetchLogo]);
|
||||
ensureLogoLoaded();
|
||||
}, []);
|
||||
|
||||
if (logoUrl) {
|
||||
return <img src={logoUrl} alt={t('logo.alt', 'Logo')} className="h-7 w-auto max-w-[220px] object-contain" />;
|
||||
if (logo.status === 'custom') {
|
||||
return <img src={logo.url} alt={t('logo.alt', 'Logo')} className="h-7 w-auto max-w-[220px] object-contain" />;
|
||||
}
|
||||
|
||||
if (logoLoading) {
|
||||
if (logo.status === 'loading') {
|
||||
return <span className="h-7 w-[140px] block" aria-hidden="true" />;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
import { getApiBaseUrl } from '@/services/api';
|
||||
|
||||
export type LogoSnapshot = { status: 'loading' } | { status: 'custom'; url: string } | { status: 'default' };
|
||||
|
||||
let snapshot: LogoSnapshot = { status: 'loading' };
|
||||
let objectUrl: string | null = null;
|
||||
let abortController: AbortController | null = null;
|
||||
let started = false;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function emit(): void {
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
function revokeObjectUrl(): void {
|
||||
if (objectUrl) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
objectUrl = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getLogoSnapshot(): LogoSnapshot {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export function subscribeLogo(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
/** Starts a single shared /logo fetch. Safe to call from every Logo mount. */
|
||||
export function ensureLogoLoaded(): void {
|
||||
if (started) return;
|
||||
started = true;
|
||||
snapshot = { status: 'loading' };
|
||||
emit();
|
||||
|
||||
abortController?.abort();
|
||||
abortController = new AbortController();
|
||||
const { signal } = abortController;
|
||||
|
||||
fetch(`${getApiBaseUrl()}/logo`, { signal })
|
||||
.then(async (response) => {
|
||||
const contentType = response.headers.get('content-type') ?? '';
|
||||
if (response.ok && contentType.startsWith('image/')) {
|
||||
const blob = await response.blob();
|
||||
if (signal.aborted) return;
|
||||
revokeObjectUrl();
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
snapshot = { status: 'custom', url: objectUrl };
|
||||
emit();
|
||||
return;
|
||||
}
|
||||
if (signal.aborted) return;
|
||||
snapshot = { status: 'default' };
|
||||
emit();
|
||||
})
|
||||
.catch(() => {
|
||||
if (signal.aborted) return;
|
||||
snapshot = { status: 'default' };
|
||||
emit();
|
||||
});
|
||||
}
|
||||
@@ -6,30 +6,17 @@
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { getApiBaseUrl } from '@/services/api';
|
||||
|
||||
export type Theme = 'light' | 'dark';
|
||||
export type ColorTheme = 'stalwart' | 'ocean' | 'forest' | 'violet' | 'rose' | 'amber' | 'teal';
|
||||
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 {
|
||||
theme: Theme;
|
||||
colorTheme: ColorTheme;
|
||||
radius: Radius;
|
||||
sidebarOpen: boolean;
|
||||
activeSection: string;
|
||||
logoUrl: string | null;
|
||||
logoLoading: boolean;
|
||||
|
||||
setTheme: (theme: Theme) => void;
|
||||
setColorTheme: (colorTheme: ColorTheme) => void;
|
||||
@@ -37,9 +24,6 @@ interface UIState {
|
||||
toggleSidebar: () => void;
|
||||
setSidebarOpen: (open: boolean) => void;
|
||||
setActiveSection: (section: string) => void;
|
||||
setLogoUrl: (url: string | null) => void;
|
||||
setLogoLoading: (loading: boolean) => void;
|
||||
fetchLogo: () => void;
|
||||
}
|
||||
|
||||
function applyThemeClass(theme: Theme) {
|
||||
@@ -87,49 +71,6 @@ export const useUIStore = create<UIState>()(
|
||||
radius: 'square',
|
||||
sidebarOpen: typeof window !== 'undefined' ? (window.matchMedia?.('(min-width: 768px)').matches ?? true) : true,
|
||||
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) => {
|
||||
applyThemeClass(theme);
|
||||
|
||||
Reference in New Issue
Block a user