Merge pull request #390 from Luca-Timo/feat/self-hosted-fonts

feat(branding): self-hosted webfonts with filesystem scanner
This commit is contained in:
Paul Nothaft
2026-05-04 23:23:04 +02:00
committed by GitHub
36 changed files with 1014 additions and 14 deletions
+18
View File
@@ -122,6 +122,24 @@ server {
proxy_cache_valid 404 1m;
}
# Self-hosted webfonts proxy (bundled families + admin user additions).
# ^~ modifier stops regex matching, ensuring fonts are proxied to the
# backend (which scans backend/assets/fonts and STORAGE_PATH/fonts) and
# NOT served locally — the .woff2 files do not exist in the frontend image.
location ^~ /fonts {
set $backend_upstream backend;
proxy_pass http://$backend_upstream:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Fonts rarely change; cache aggressively (matches backend Cache-Control).
proxy_cache_valid 200 302 7d;
proxy_cache_valid 404 1m;
}
# Dynamic robots.txt served by backend
location = /robots.txt {
set $backend_upstream backend;
@@ -4,9 +4,43 @@ 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';
/**
* Build the CSS font-family value for a scanned font, using the generic
* fallback the backend supplied (from each family's optional meta.json).
* Defaults to 'sans-serif' when the backend doesn't report one — keeps
* compatibility with backends that predate the generic field.
*/
function buildFontFamilyValue(font: FontDefinition): string {
const generic = font.generic ?? 'sans-serif';
// Always quote the family name (covers multi-word like 'Playfair Display').
return `'${font.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) : saved;
}
interface ThemeCustomizerEnhancedProps {
value: ThemeConfig;
onChange: (theme: ThemeConfig) => void;
@@ -100,6 +134,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);
@@ -929,17 +972,27 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
{t('branding.bodyFont')}
</label>
<select
value={localTheme.fontFamily || 'Inter, sans-serif'}
value={resolveFontDropdownValue(
localTheme.fontFamily,
availableFonts,
// Fallback when no fontFamily is saved yet: prefer the
// scanned Inter (with its real generic), else a bare CSS
// string when the backend hasn't loaded yet.
(availableFonts || []).find((f) => f.family === 'Inter')
? buildFontFamilyValue(
(availableFonts || []).find((f) => f.family === 'Inter')!
)
: "'Inter', sans-serif"
)}
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)}>
{f.family}
</option>
))}
</select>
</div>
@@ -948,15 +1001,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)}>
{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';
+59
View File
@@ -0,0 +1,59 @@
import { api } from '../config/api';
export interface FontDefinition {
family: string;
weights: number[];
// CSS generic to use as a fallback when building font-family strings.
// Optional in the type so older backends without this field still work;
// callers must default to 'sans-serif' when undefined.
generic?: 'sans-serif' | 'serif' | 'cursive' | 'monospace';
}
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;
}