feat: photography features & image optimizations
- Hero carousel: gallery.yaml hero accepts single UUID or array, crossfade component - Full-bleed single albums: subpages with 1 album skip grid, render photos directly - EXIF on hover: camera/lens/focal length overlay on grid items (toggle via exifOnHover) - Customizable grid: columns/gap/aspectRatio/layout (masonry|uniform) via gallery.yaml - Per-subpage grid overrides: each subpage can override global grid config - True masonry: per-image aspect ratios from EXIF dimensions - next/image migration: homepage hero, album covers, grid thumbnails, about portrait - ThumbHash blur placeholders and dominant color backgrounds - Custom next/image loader via image proxy with width/quality params - Lightbox fade-in transition with dominant color background - Password-protected subpages, health check, rate limiting, OG images
This commit is contained in:
244
lib/config.ts
244
lib/config.ts
@@ -1,8 +1,14 @@
|
||||
/**
|
||||
* Typed environment configuration with validation.
|
||||
* Fails fast at startup if required variables are missing.
|
||||
* Typed configuration loader.
|
||||
* Secrets come from env vars; gallery structure comes from content/gallery.yaml.
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import yaml from 'js-yaml';
|
||||
|
||||
// ── Env helpers ────────────────────────────────────────────────
|
||||
|
||||
function requireEnv(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (!value) {
|
||||
@@ -11,102 +17,79 @@ function requireEnv(name: string): string {
|
||||
return value;
|
||||
}
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────
|
||||
|
||||
/** A subpage groups multiple Immich albums under one URL path. */
|
||||
export interface SubpageConfig {
|
||||
name: string;
|
||||
slug: string;
|
||||
albumIds: string[];
|
||||
/** Optional password — if set, visitors must authenticate to view. */
|
||||
password?: string;
|
||||
/** Per-subpage grid overrides (merged with global grid config). */
|
||||
grid?: Partial<GridConfig>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a UUID from a string that may be "Label:uuid" or just "uuid".
|
||||
*/
|
||||
function extractUuid(entry: string): string | null {
|
||||
const trimmed = entry.trim();
|
||||
if (!trimmed) return null;
|
||||
const uuidMatch = trimmed.match(
|
||||
/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i,
|
||||
);
|
||||
return uuidMatch ? uuidMatch[1] : trimmed;
|
||||
/** Raw YAML structure (before validation). */
|
||||
interface GalleryYaml {
|
||||
hero?: string | string[];
|
||||
albums?: string[];
|
||||
exifOnHover?: boolean;
|
||||
grid?: {
|
||||
columns?: number;
|
||||
gap?: number;
|
||||
aspectRatio?: string;
|
||||
layout?: string;
|
||||
};
|
||||
subpages?: Array<{
|
||||
name: string;
|
||||
albums: string[];
|
||||
password?: string;
|
||||
grid?: {
|
||||
columns?: number;
|
||||
gap?: number;
|
||||
aspectRatio?: string;
|
||||
layout?: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse LIGHTBOX_SUBPAGES env var.
|
||||
* Format: "Japan > uuid1, uuid2; Italy > uuid3, uuid4"
|
||||
* Each subpage separated by ";", name separated from album IDs by ">".
|
||||
*/
|
||||
function parseSubpages(raw: string | undefined): SubpageConfig[] {
|
||||
if (!raw || !raw.trim()) return [];
|
||||
// ── YAML loading ───────────────────────────────────────────────
|
||||
|
||||
// Split on semicolons OR newlines — supports both single-line and multiline formats
|
||||
return raw
|
||||
.split(/[;\n]/)
|
||||
.map((segment) => segment.trim())
|
||||
.filter(Boolean)
|
||||
.map((segment) => {
|
||||
const parts = segment.split('>');
|
||||
if (parts.length < 2) {
|
||||
console.warn(`[Config] Skipping malformed subpage entry: "${segment}"`);
|
||||
return null;
|
||||
}
|
||||
const name = parts[0].trim();
|
||||
const albumIds = parts
|
||||
.slice(1)
|
||||
.join('>') // rejoin in case name contained ">"
|
||||
.split(',')
|
||||
.map((id) => extractUuid(id))
|
||||
.filter((id): id is string => id !== null);
|
||||
function loadGalleryYaml(): GalleryYaml {
|
||||
const yamlPath = path.join(process.cwd(), 'content', 'gallery.yaml');
|
||||
|
||||
if (!name || albumIds.length === 0) {
|
||||
console.warn(`[Config] Skipping subpage with missing name or albums: "${segment}"`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return { name, slug: slugify(name), albumIds };
|
||||
})
|
||||
.filter((sp): sp is SubpageConfig => sp !== null);
|
||||
}
|
||||
|
||||
export function getConfig() {
|
||||
const apiUrl = requireEnv('IMMICH_API_URL').replace(/\/+$/, '');
|
||||
const apiKey = requireEnv('IMMICH_API_KEY');
|
||||
|
||||
// Parse standalone albums from LIGHTBOX_ALBUMS
|
||||
const standaloneAlbumIds = requireEnv('LIGHTBOX_ALBUMS')
|
||||
.split(',')
|
||||
.map((entry) => extractUuid(entry))
|
||||
.filter((id): id is string => id !== null);
|
||||
|
||||
if (standaloneAlbumIds.length === 0) {
|
||||
throw new Error('LIGHTBOX_ALBUMS must contain at least one album ID');
|
||||
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.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Parse subpages (optional)
|
||||
const subpages = parseSubpages(process.env.LIGHTBOX_SUBPAGES);
|
||||
const raw = fs.readFileSync(yamlPath, 'utf8');
|
||||
const data = yaml.load(raw) as GalleryYaml;
|
||||
|
||||
// Collect ALL album IDs (standalone + subpage) — deduplicated
|
||||
const subpageAlbumIds = new Set(subpages.flatMap((sp) => sp.albumIds));
|
||||
const allAlbumIds = [...new Set([...standaloneAlbumIds, ...subpageAlbumIds])];
|
||||
if (!data || typeof data !== 'object') {
|
||||
throw new Error('gallery.yaml is empty or invalid');
|
||||
}
|
||||
|
||||
return {
|
||||
immich: {
|
||||
apiUrl: `${apiUrl}/api`,
|
||||
apiKey,
|
||||
},
|
||||
/** All album IDs (standalone + subpage) for allowlist validation */
|
||||
albums: allAlbumIds,
|
||||
/** Album IDs that appear only in LIGHTBOX_ALBUMS and NOT in any subpage */
|
||||
standaloneAlbums: standaloneAlbumIds.filter((id) => !subpageAlbumIds.has(id)),
|
||||
/** Subpage definitions */
|
||||
subpages,
|
||||
siteTitle: process.env.SITE_TITLE || 'Gallery',
|
||||
siteSubtitle: process.env.SITE_SUBTITLE || '',
|
||||
/** Immich asset ID for the homepage hero image */
|
||||
heroImage: process.env.HERO_IMAGE || '',
|
||||
cacheTtl: parseInt(process.env.CACHE_TTL || '300', 10) * 1000, // convert to ms
|
||||
};
|
||||
return data;
|
||||
}
|
||||
|
||||
// ── UUID validation ────────────────────────────────────────────
|
||||
|
||||
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
function validateUuid(id: string, context: string): string {
|
||||
const trimmed = id.trim();
|
||||
if (!UUID_REGEX.test(trimmed)) {
|
||||
throw new Error(`Invalid UUID in ${context}: "${trimmed}"`);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
// ── Slugify ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Convert an album name into a URL-friendly slug.
|
||||
* "Kloster Chorin" → "kloster-chorin"
|
||||
@@ -120,4 +103,101 @@ export function slugify(name: string): string {
|
||||
.replace(/^-+|-+$/g, ''); // trim leading/trailing hyphens
|
||||
}
|
||||
|
||||
export type AppConfig = ReturnType<typeof getConfig>;
|
||||
// ── Config type ────────────────────────────────────────────────
|
||||
|
||||
/** Grid layout configuration. */
|
||||
export interface GridConfig {
|
||||
columns: number;
|
||||
gap: number;
|
||||
aspectRatio: string;
|
||||
layout: 'masonry' | 'uniform';
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
immich: { apiUrl: string; apiKey: string };
|
||||
albums: string[];
|
||||
standaloneAlbums: string[];
|
||||
subpages: SubpageConfig[];
|
||||
siteTitle: string;
|
||||
siteSubtitle: string;
|
||||
/** One or more hero image asset IDs for the homepage carousel. */
|
||||
heroImages: string[];
|
||||
/** Show EXIF (camera/lens) on photo grid hover. Default: true. */
|
||||
exifOnHover: boolean;
|
||||
/** Photo grid layout configuration. */
|
||||
grid: GridConfig;
|
||||
cacheTtl: number;
|
||||
rateLimitRpm: number;
|
||||
}
|
||||
|
||||
// ── Config builder ─────────────────────────────────────────────
|
||||
|
||||
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 gallery = loadGalleryYaml();
|
||||
|
||||
// Parse standalone 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');
|
||||
}
|
||||
|
||||
// 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`);
|
||||
}
|
||||
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'] } : {}),
|
||||
},
|
||||
} : {}),
|
||||
};
|
||||
});
|
||||
|
||||
// Collect ALL album IDs (standalone + subpage) — deduplicated
|
||||
const subpageAlbumIds = new Set(subpages.flatMap((sp) => sp.albumIds));
|
||||
const allAlbumIds = [...new Set([...standaloneAlbumIds, ...subpageAlbumIds])];
|
||||
|
||||
_config = {
|
||||
immich: {
|
||||
apiUrl: `${apiUrl}/api`,
|
||||
apiKey,
|
||||
},
|
||||
albums: allAlbumIds,
|
||||
standaloneAlbums: standaloneAlbumIds.filter((id) => !subpageAlbumIds.has(id)),
|
||||
subpages,
|
||||
siteTitle: process.env.SITE_TITLE || 'Gallery',
|
||||
siteSubtitle: process.env.SITE_SUBTITLE || '',
|
||||
heroImages: gallery.hero
|
||||
? (Array.isArray(gallery.hero) ? gallery.hero : [gallery.hero])
|
||||
.map((id) => validateUuid(id, 'gallery.yaml hero'))
|
||||
: [],
|
||||
exifOnHover: gallery.exifOnHover !== false,
|
||||
grid: {
|
||||
columns: gallery.grid?.columns ?? 3,
|
||||
gap: gallery.grid?.gap ?? 12,
|
||||
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),
|
||||
};
|
||||
|
||||
return _config;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user