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
@@ -4,9 +4,47 @@ import { Button, Card, Input } from '../common';
import { ThemeConfig, GALLERY_THEME_PRESETS, GalleryLayoutType, HeaderStyleType, HeroDividerStyle } from '../../types/theme.types';
import type { EnabledTemplate } from '../../services/cssTemplates.service';
import { settingsService } from '../../services/settings.service';
import { fontsService, extractFamilyName, type FontDefinition } from '../../services/fonts.service';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
// Generic CSS fallback per family. Sans by default; serif for known serif
// families; cursive for handwriting/display families. Used when building
// the dropdown <option value> from a scanned family name.
const SERIF_FAMILIES = new Set(['Playfair Display', 'Georgia']);
const CURSIVE_FAMILIES = new Set(['Comic Neue']);
function buildFontFamilyValue(family: string): string {
const generic = SERIF_FAMILIES.has(family)
? 'serif'
: CURSIVE_FAMILIES.has(family)
? 'cursive'
: 'sans-serif';
// Always quote the family name (covers multi-word like 'Playfair Display').
return `'${family}', ${generic}`;
}
/**
* Match a saved CSS font-family string against the available scanned families
* and return the canonical option value the dropdown renders. Handles both
* legacy unquoted strings ("Inter, sans-serif") and the new quoted format
* ("'Inter', sans-serif"), so events saved before this change still show the
* right option as selected.
*/
function resolveFontDropdownValue(
saved: string | undefined,
available: FontDefinition[] | undefined,
fallback: string
): string {
if (!saved) return fallback;
const family = extractFamilyName(saved);
if (!family) return saved; // generic family like "system-ui, sans-serif"
const match = (available || []).find(
(f) => f.family.toLowerCase() === family.toLowerCase()
);
return match ? buildFontFamilyValue(match.family) : saved;
}
interface ThemeCustomizerEnhancedProps {
value: ThemeConfig;
onChange: (theme: ThemeConfig) => void;
@@ -99,6 +137,15 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
staleTime: 60000,
});
// Fetch the list of self-hosted font families discovered by the backend
// scanner. Used to populate the body / heading font dropdowns. Cached
// 5 minutes — fonts rarely change without a backend restart.
const { data: availableFonts } = useQuery<FontDefinition[]>({
queryKey: ['fonts'],
queryFn: () => fontsService.list(),
staleTime: 5 * 60 * 1000,
});
const thumbnailWidth = parseInt(allSettings?.thumbnail_width) || 300;
const thumbnailHeight = parseInt(allSettings?.thumbnail_height) || 300;
const isBetaLayout = BETA_LAYOUTS.includes(localTheme.galleryLayout as GalleryLayoutType);
@@ -928,17 +975,20 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
{t('branding.bodyFont')}
</label>
<select
value={localTheme.fontFamily || 'Inter, sans-serif'}
value={resolveFontDropdownValue(
localTheme.fontFamily,
availableFonts,
buildFontFamilyValue('Inter')
)}
onChange={(e) => handleChange('fontFamily', e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100"
>
<option value="Inter, sans-serif">Inter</option>
<option value="system-ui, sans-serif">System UI</option>
<option value="Georgia, serif">Georgia</option>
<option value="'Playfair Display', serif">Playfair Display</option>
<option value="'Montserrat', sans-serif">Montserrat</option>
<option value="'IBM Plex Sans', sans-serif">IBM Plex Sans</option>
<option value="'Comic Neue', cursive">Comic Neue</option>
{(availableFonts || []).map((f) => (
<option key={f.family} value={buildFontFamilyValue(f.family)}>
{f.family}
</option>
))}
</select>
</div>
@@ -947,15 +997,21 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
{t('branding.headingFont')}
</label>
<select
value={localTheme.headingFontFamily || localTheme.fontFamily || 'Inter, sans-serif'}
value={resolveFontDropdownValue(
localTheme.headingFontFamily,
availableFonts,
''
)}
onChange={(e) => handleChange('headingFontFamily', e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100"
>
<option value="">{t('branding.sameAsBody')}</option>
<option value="'Playfair Display', serif">Playfair Display</option>
<option value="'Montserrat', sans-serif">Montserrat</option>
<option value="Georgia, serif">Georgia</option>
<option value="'IBM Plex Sans', sans-serif">IBM Plex Sans</option>
<option value="system-ui, sans-serif">System UI</option>
{(availableFonts || []).map((f) => (
<option key={f.family} value={buildFontFamilyValue(f.family)}>
{f.family}
</option>
))}
</select>
</div>
</div>
+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) {
+11 -1
View File
@@ -1,4 +1,14 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Noto+Sans:wght@300;400;500;600;700&family=Poppins:wght@400;500;600;700&display=swap');
/*
* Self-hosted Inter is bootstrapped here so the very first paint —
* before React mounts and runs the dynamic @font-face injector — has
* the default body font available. Replaces the previous Google Fonts
* @import that leaked visitor IPs to fonts.googleapis.com (LG München
* 2022 GDPR ruling). All other families are injected on-demand by
* frontend/src/contexts/ThemeContext.tsx based on the active theme.
*/
@font-face { font-family: 'Inter'; font-style: normal; font-weight: 400; font-display: swap; src: url('/fonts/Inter/400.woff2') format('woff2'); }
@font-face { font-family: 'Inter'; font-style: normal; font-weight: 600; font-display: swap; src: url('/fonts/Inter/600.woff2') format('woff2'); }
@font-face { font-family: 'Inter'; font-style: normal; font-weight: 700; font-display: swap; src: url('/fonts/Inter/700.woff2') format('woff2'); }
/* Import image protection styles */
@import './styles/image-protection.css';
+55
View File
@@ -0,0 +1,55 @@
import { api } from '../config/api';
export interface FontDefinition {
family: string;
weights: number[];
}
export interface FontsListResponse {
fonts: FontDefinition[];
}
export const fontsService = {
/**
* List all self-hosted font families discovered by the backend scanner.
* Cached aggressively at the React Query layer; the underlying endpoint
* is also TTL-cached on the backend.
*/
async list(): Promise<FontDefinition[]> {
const res = await api.get<FontsListResponse>('/public/fonts');
return res.data.fonts;
}
};
const GENERIC_FAMILIES = new Set([
'sans-serif',
'serif',
'monospace',
'cursive',
'fantasy',
'system-ui',
'ui-sans-serif',
'ui-serif',
'ui-monospace',
'ui-rounded'
]);
/**
* Extract the primary font family name from a CSS font-family string.
*
* "'Jost', sans-serif" → "Jost"
* "Inter, sans-serif" → "Inter"
* "'Playfair Display', serif" → "Playfair Display"
* "system-ui, sans-serif" → null (generic, no @font-face needed)
* undefined / "" / "sans-serif" → null
*/
export function extractFamilyName(cssFontFamily: string | undefined | null): string | null {
if (!cssFontFamily) return null;
const first = cssFontFamily.split(',')[0]?.trim();
if (!first) return null;
// Strip surrounding single or double quotes
const unquoted = first.replace(/^['"]|['"]$/g, '').trim();
if (!unquoted) return null;
if (GENERIC_FAMILIES.has(unquoted.toLowerCase())) return null;
return unquoted;
}