feat: theming system, documentation, and photo grid regression tests
- Add configurable theme system with 4 presets (studio, minimal, editorial, classic) - Theme controls fonts, colors, accent, photo frames, grain, and header dot - Resolve themes from gallery.yaml: string shorthand or full object override - Inject theme as CSS custom properties on <html> element - Dynamic Google Fonts loading based on theme config - OG image route reads accent/background from theme - Refactor Studio overrides into conditional CSS via data-attributes Documentation: - Add docs/gallery-config.md with comprehensive configuration reference - Add docs/theming.md with preset reference and custom theme guide - Slim README to link to detailed docs Fix CSS regression: - Fix passepartout frame overflow:visible breaking next/image fill rendering - Remove duplicate .photo-grid__item block that stripped overflow:hidden Testing: - Add e2e/photo-grid.spec.ts with 3 regression tests: - Grid items render with real dimensions - overflow:hidden CSS guard (catches the exact bug fixed) - Images load with real pixel data (not blurhash) Tooling: - Add scripts/screenshots.ts for automated theme screenshot generation - Handles dev server lifecycle, theme cycling, FadeIn + lazy image handling
This commit is contained in:
135
lib/config.ts
135
lib/config.ts
@@ -31,11 +31,80 @@ export interface FooterConfig {
|
||||
website?: string;
|
||||
}
|
||||
|
||||
/** Theme configuration — controls the visual identity of the gallery. */
|
||||
export interface ThemeConfig {
|
||||
preset: string;
|
||||
accent: string;
|
||||
fonts: { heading: string; body: string; caption: string };
|
||||
radius: number;
|
||||
photoFrame: 'none' | 'passepartout' | 'shadow';
|
||||
grain: boolean;
|
||||
headerDot: boolean;
|
||||
heroStyle: 'split' | 'fullbleed' | 'minimal';
|
||||
}
|
||||
|
||||
// ── Theme Presets ──────────────────────────────────────────────
|
||||
|
||||
const THEME_PRESETS: Record<string, ThemeConfig> = {
|
||||
studio: {
|
||||
preset: 'studio',
|
||||
accent: '#e60012',
|
||||
fonts: { heading: 'Playfair Display', body: 'DM Sans', caption: 'EB Garamond' },
|
||||
radius: 0,
|
||||
photoFrame: 'passepartout',
|
||||
grain: true,
|
||||
headerDot: true,
|
||||
heroStyle: 'split',
|
||||
},
|
||||
minimal: {
|
||||
preset: 'minimal',
|
||||
accent: '#666666',
|
||||
fonts: { heading: 'Inter', body: 'Inter', caption: 'JetBrains Mono' },
|
||||
radius: 0,
|
||||
photoFrame: 'none',
|
||||
grain: false,
|
||||
headerDot: false,
|
||||
heroStyle: 'split',
|
||||
},
|
||||
editorial: {
|
||||
preset: 'editorial',
|
||||
accent: '#1a1a1a',
|
||||
fonts: { heading: 'Libre Baskerville', body: 'Source Sans 3', caption: 'Source Sans 3' },
|
||||
radius: 4,
|
||||
photoFrame: 'shadow',
|
||||
grain: false,
|
||||
headerDot: false,
|
||||
heroStyle: 'split',
|
||||
},
|
||||
classic: {
|
||||
preset: 'classic',
|
||||
accent: '#8b6914',
|
||||
fonts: { heading: 'Cormorant Garamond', body: 'Lato', caption: 'Lato' },
|
||||
radius: 8,
|
||||
photoFrame: 'none',
|
||||
grain: false,
|
||||
headerDot: false,
|
||||
heroStyle: 'split',
|
||||
},
|
||||
};
|
||||
|
||||
/** Raw YAML structure (before validation). */
|
||||
interface GalleryYaml {
|
||||
hero?: string | string[];
|
||||
albums?: string[];
|
||||
exifOnHover?: boolean;
|
||||
theme?:
|
||||
| string
|
||||
| {
|
||||
preset?: string;
|
||||
accent?: string;
|
||||
fonts?: { heading?: string; body?: string; caption?: string };
|
||||
radius?: number;
|
||||
photoFrame?: string;
|
||||
grain?: boolean;
|
||||
headerDot?: boolean;
|
||||
heroStyle?: string;
|
||||
};
|
||||
grid?: {
|
||||
columns?: number;
|
||||
gap?: number;
|
||||
@@ -133,12 +202,74 @@ export interface AppConfig {
|
||||
exifOnHover: boolean;
|
||||
/** Photo grid layout configuration. */
|
||||
grid: GridConfig;
|
||||
/** Theme configuration. */
|
||||
theme: ThemeConfig;
|
||||
/** Footer social links config. */
|
||||
footer: FooterConfig | null;
|
||||
cacheTtl: number;
|
||||
rateLimitRpm: number;
|
||||
}
|
||||
|
||||
// ── Theme resolution ───────────────────────────────────────────
|
||||
|
||||
const VALID_PHOTO_FRAMES = ['none', 'passepartout', 'shadow'];
|
||||
const VALID_HERO_STYLES = ['split', 'fullbleed', 'minimal'];
|
||||
|
||||
function resolveTheme(raw?: GalleryYaml['theme']): ThemeConfig {
|
||||
// No theme key → default preset
|
||||
if (!raw) return { ...THEME_PRESETS.studio };
|
||||
|
||||
// String shorthand: `theme: minimal`
|
||||
if (typeof raw === 'string') {
|
||||
const preset = THEME_PRESETS[raw];
|
||||
if (!preset) {
|
||||
throw new Error(
|
||||
`Unknown theme preset "${raw}". Valid presets: ${Object.keys(THEME_PRESETS).join(', ')}`,
|
||||
);
|
||||
}
|
||||
return { ...preset };
|
||||
}
|
||||
|
||||
// Object: merge with base preset
|
||||
const baseName = raw.preset ?? 'studio';
|
||||
const base = THEME_PRESETS[baseName];
|
||||
if (!base) {
|
||||
throw new Error(
|
||||
`Unknown theme preset "${baseName}". Valid presets: ${Object.keys(THEME_PRESETS).join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
preset: baseName,
|
||||
accent: raw.accent ?? base.accent,
|
||||
fonts: {
|
||||
heading: raw.fonts?.heading ?? base.fonts.heading,
|
||||
body: raw.fonts?.body ?? base.fonts.body,
|
||||
caption: raw.fonts?.caption ?? base.fonts.caption,
|
||||
},
|
||||
radius: raw.radius ?? base.radius,
|
||||
photoFrame: VALID_PHOTO_FRAMES.includes(raw.photoFrame ?? '')
|
||||
? (raw.photoFrame as ThemeConfig['photoFrame'])
|
||||
: base.photoFrame,
|
||||
grain: raw.grain ?? base.grain,
|
||||
headerDot: raw.headerDot ?? base.headerDot,
|
||||
heroStyle: VALID_HERO_STYLES.includes(raw.heroStyle ?? '')
|
||||
? (raw.heroStyle as ThemeConfig['heroStyle'])
|
||||
: base.heroStyle,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Google Fonts URL for the theme's fonts.
|
||||
* Deduplicates fonts and includes key weights.
|
||||
*/
|
||||
export function getGoogleFontsUrl(theme: ThemeConfig): string {
|
||||
const weights = '300;400;500;600';
|
||||
const families = [...new Set([theme.fonts.heading, theme.fonts.body, theme.fonts.caption])];
|
||||
const params = families.map((f) => `family=${f.replace(/ /g, '+')}:wght@${weights}`).join('&');
|
||||
return `https://fonts.googleapis.com/css2?${params}&display=swap`;
|
||||
}
|
||||
|
||||
// ── Config builder ─────────────────────────────────────────────
|
||||
|
||||
let _config: AppConfig | null = null;
|
||||
@@ -150,6 +281,9 @@ export function getConfig(): AppConfig {
|
||||
const apiKey = env.IMMICH_API_KEY;
|
||||
const gallery = loadGalleryYaml();
|
||||
|
||||
// Resolve theme: string shorthand → preset, object → merged with preset base
|
||||
const theme = resolveTheme(gallery.theme);
|
||||
|
||||
// Parse standalone albums
|
||||
const standaloneAlbumIds = (gallery.albums ?? []).map((id) =>
|
||||
validateUuid(id, 'gallery.yaml albums'),
|
||||
@@ -216,6 +350,7 @@ export function getConfig(): AppConfig {
|
||||
aspectRatio: gallery.grid?.aspectRatio ?? '1',
|
||||
layout: (gallery.grid?.layout === 'uniform' ? 'uniform' : 'masonry') as GridConfig['layout'],
|
||||
},
|
||||
theme,
|
||||
footer: gallery.footer
|
||||
? {
|
||||
name: gallery.footer.name,
|
||||
|
||||
Reference in New Issue
Block a user