feat(branding): self-hosted webfonts with filesystem scanner

This commit is contained in:
Luca
2026-05-04 19:15:47 +02:00
parent 0adec25fe7
commit bac51fe69a
32 changed files with 616 additions and 14 deletions
+60 -1
View File
@@ -1,6 +1,60 @@
import React, { createContext, useContext, useState, useEffect, useCallback, useMemo } from 'react';
import type { ReactNode } from 'react';
import { ThemeConfig, EventTheme, GALLERY_THEME_PRESETS } from '../types/theme.types';
import { fontsService, extractFamilyName, type FontDefinition } from '../services/fonts.service';
// Self-hosted font loader. Resolves the available-fonts list once (cached for
// 5 minutes) and lazily injects @font-face blocks into <head> only for the
// families a page actually uses. Avoids preloading every available font on
// every gallery view.
const FONTS_LIST_TTL_MS = 5 * 60 * 1000;
let fontsListPromise: Promise<FontDefinition[]> | null = null;
let fontsListExpiresAt = 0;
const injectedFamilies = new Set<string>();
const FONT_STYLE_ID = 'self-hosted-fonts';
function getFontsList(): Promise<FontDefinition[]> {
if (fontsListPromise && Date.now() < fontsListExpiresAt) {
return fontsListPromise;
}
fontsListPromise = fontsService.list().catch((err) => {
console.error('Failed to load fonts list:', err);
return [];
});
fontsListExpiresAt = Date.now() + FONTS_LIST_TTL_MS;
return fontsListPromise;
}
function ensureFontFaceLoaded(family: string, weights: number[]): void {
if (injectedFamilies.has(family)) return;
injectedFamilies.add(family);
let styleEl = document.getElementById(FONT_STYLE_ID) as HTMLStyleElement | null;
if (!styleEl) {
styleEl = document.createElement('style');
styleEl.id = FONT_STYLE_ID;
document.head.appendChild(styleEl);
}
// Folder name on disk = family name with hyphens. URL-encode in case of
// unusual characters (the scanner already restricts to subdirectory names,
// so this is belt-and-braces).
const folderName = family.replace(/ /g, '-');
const blocks = weights.map(
(w) => `@font-face{font-family:'${family}';font-style:normal;font-weight:${w};font-display:swap;src:url('/fonts/${encodeURIComponent(folderName)}/${w}.woff2') format('woff2');}`
);
styleEl.textContent += '\n' + blocks.join('\n');
}
async function loadFontForFamily(cssFontFamily: string | undefined | null): Promise<void> {
const family = extractFamilyName(cssFontFamily);
if (!family) return;
if (injectedFamilies.has(family)) return;
const fonts = await getFontsList();
const match = fonts.find((f) => f.family.toLowerCase() === family.toLowerCase());
if (!match) return; // unknown family — browser falls back to the CSS generic
ensureFontFaceLoaded(match.family, match.weights);
}
function resolveColorMode(mode: 'light' | 'dark' | 'auto' | undefined): 'light' | 'dark' {
if (mode === 'dark') return 'dark';
@@ -85,10 +139,15 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({
if (themeConfig.fontFamily) {
root.style.setProperty('--font-family', themeConfig.fontFamily);
// Lazily inject the @font-face for this family if we haven't already.
// Fire-and-forget: the CSS variable is set immediately, the font file
// streams in afterward and `font-display: swap` reflows on arrival.
void loadFontForFamily(themeConfig.fontFamily);
}
if (themeConfig.headingFontFamily) {
root.style.setProperty('--heading-font-family', themeConfig.headingFontFamily);
void loadFontForFamily(themeConfig.headingFontFamily);
}
if (themeConfig.borderRadius) {