feat: visual polish & UX — fade-in, theme toggle, scroll-to-top, footer, cursor, badge

- Add FadeIn component (IntersectionObserver scroll-reveal + load animation)
- Add dark/light theme toggle (useSyncExternalStore + localStorage)
- Add scroll-to-top button (appears after 300px scroll)
- Add configurable footer with social links (gallery.yaml)
- Add crosshair cursor on photo grid items
- Add 'X photos' count badge on subpage cover images
- Add light theme color palette in tokens.css
This commit is contained in:
ralksta
2026-02-14 11:47:26 +01:00
parent e26f091595
commit 52fa16c1c8
57 changed files with 4790 additions and 1984 deletions

View File

@@ -6,16 +6,9 @@
import fs from 'fs';
import path from 'path';
import yaml from 'js-yaml';
import { env } from './env';
// ── Env helpers ────────────────────────────────────────────────
function requireEnv(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
}
// ── Env helpers (now handled by lib/env.ts via Zod) ───────────
// ── Types ──────────────────────────────────────────────────────
@@ -30,6 +23,14 @@ export interface SubpageConfig {
grid?: Partial<GridConfig>;
}
/** Footer configuration for social links. */
export interface FooterConfig {
name?: string;
instagram?: string;
email?: string;
website?: string;
}
/** Raw YAML structure (before validation). */
interface GalleryYaml {
hero?: string | string[];
@@ -41,6 +42,12 @@ interface GalleryYaml {
aspectRatio?: string;
layout?: string;
};
footer?: {
name?: string;
instagram?: string;
email?: string;
website?: string;
};
subpages?: Array<{
name: string;
albums: string[];
@@ -62,7 +69,7 @@ function loadGalleryYaml(): GalleryYaml {
if (!fs.existsSync(yamlPath)) {
throw new Error(
`Gallery config not found: ${yamlPath}\n` +
`Copy content/gallery.yaml.example to content/gallery.yaml and add your album IDs.`,
`Copy content/gallery.yaml.example to content/gallery.yaml and add your album IDs.`,
);
}
@@ -99,8 +106,8 @@ export function slugify(name: string): string {
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '') // strip diacritics (ü→u, é→e)
.replace(/[^a-z0-9]+/g, '-') // non-alphanumeric → hyphens
.replace(/^-+|-+$/g, ''); // trim leading/trailing hyphens
.replace(/[^a-z0-9]+/g, '-') // non-alphanumeric → hyphens
.replace(/^-+|-+$/g, ''); // trim leading/trailing hyphens
}
// ── Config type ────────────────────────────────────────────────
@@ -126,6 +133,8 @@ export interface AppConfig {
exifOnHover: boolean;
/** Photo grid layout configuration. */
grid: GridConfig;
/** Footer social links config. */
footer: FooterConfig | null;
cacheTtl: number;
rateLimitRpm: number;
}
@@ -137,13 +146,14 @@ let _config: AppConfig | null = null;
export function getConfig(): AppConfig {
if (_config) return _config;
const apiUrl = requireEnv('IMMICH_API_URL').replace(/\/+$/, '');
const apiKey = requireEnv('IMMICH_API_KEY');
const apiUrl = env.IMMICH_API_URL;
const apiKey = env.IMMICH_API_KEY;
const gallery = loadGalleryYaml();
// Parse standalone albums
const standaloneAlbumIds = (gallery.albums ?? [])
.map((id) => validateUuid(id, 'gallery.yaml albums'));
const standaloneAlbumIds = (gallery.albums ?? []).map((id) =>
validateUuid(id, 'gallery.yaml albums'),
);
if (standaloneAlbumIds.length === 0 && (!gallery.subpages || gallery.subpages.length === 0)) {
throw new Error('gallery.yaml must define at least one album or subpage');
@@ -152,21 +162,31 @@ export function getConfig(): AppConfig {
// Parse subpages
const subpages: SubpageConfig[] = (gallery.subpages ?? []).map((sp) => {
if (!sp.name || !sp.albums || sp.albums.length === 0) {
throw new Error(`Subpage "${sp.name || '(unnamed)'}" must have a name and at least one album`);
throw new Error(
`Subpage "${sp.name || '(unnamed)'}" must have a name and at least one album`,
);
}
return {
name: sp.name,
slug: slugify(sp.name),
albumIds: sp.albums.map((id) => validateUuid(id, `subpage "${sp.name}"`)),
password: sp.password,
...(sp.grid ? {
grid: {
...(sp.grid.columns != null ? { columns: sp.grid.columns } : {}),
...(sp.grid.gap != null ? { gap: sp.grid.gap } : {}),
...(sp.grid.aspectRatio != null ? { aspectRatio: sp.grid.aspectRatio } : {}),
...(sp.grid.layout != null ? { layout: (sp.grid.layout === 'uniform' ? 'uniform' : 'masonry') as GridConfig['layout'] } : {}),
},
} : {}),
...(sp.grid
? {
grid: {
...(sp.grid.columns != null ? { columns: sp.grid.columns } : {}),
...(sp.grid.gap != null ? { gap: sp.grid.gap } : {}),
...(sp.grid.aspectRatio != null ? { aspectRatio: sp.grid.aspectRatio } : {}),
...(sp.grid.layout != null
? {
layout: (sp.grid.layout === 'uniform'
? 'uniform'
: 'masonry') as GridConfig['layout'],
}
: {}),
},
}
: {}),
};
});
@@ -182,11 +202,12 @@ export function getConfig(): AppConfig {
albums: allAlbumIds,
standaloneAlbums: standaloneAlbumIds.filter((id) => !subpageAlbumIds.has(id)),
subpages,
siteTitle: process.env.SITE_TITLE || 'Gallery',
siteSubtitle: process.env.SITE_SUBTITLE || '',
siteTitle: env.SITE_TITLE,
siteSubtitle: env.SITE_SUBTITLE,
heroImages: gallery.hero
? (Array.isArray(gallery.hero) ? gallery.hero : [gallery.hero])
.map((id) => validateUuid(id, 'gallery.yaml hero'))
? (Array.isArray(gallery.hero) ? gallery.hero : [gallery.hero]).map((id) =>
validateUuid(id, 'gallery.yaml hero'),
)
: [],
exifOnHover: gallery.exifOnHover !== false,
grid: {
@@ -195,8 +216,16 @@ export function getConfig(): AppConfig {
aspectRatio: gallery.grid?.aspectRatio ?? '1',
layout: (gallery.grid?.layout === 'uniform' ? 'uniform' : 'masonry') as GridConfig['layout'],
},
cacheTtl: parseInt(process.env.CACHE_TTL || '300', 10) * 1000,
rateLimitRpm: parseInt(process.env.RATE_LIMIT_RPM || '120', 10),
footer: gallery.footer
? {
name: gallery.footer.name,
instagram: gallery.footer.instagram,
email: gallery.footer.email,
website: gallery.footer.website,
}
: null,
cacheTtl: env.CACHE_TTL * 1000,
rateLimitRpm: env.RATE_LIMIT_RPM,
};
return _config;