feat(branding): self-hosted webfonts with filesystem scanner
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user