- {footer.name &&
{footer.name}}
+
+ {footer?.name && {footer.name}}
+ {legal.enabled && (
+
+ Impressum
+
+ )}
+
{footer.instagram && (
diff --git a/content/gallery.yaml.example b/content/gallery.yaml.example
index eb9889d..de0d89c 100644
--- a/content/gallery.yaml.example
+++ b/content/gallery.yaml.example
@@ -1,72 +1,24 @@
-# ── Gallery Configuration ──────────────────────────────────────
-# Define which Immich albums to publish and how to organize them.
-#
-# Find album UUIDs in Immich: Albums → click album → UUID in the URL bar.
-# Find asset UUIDs: click a photo → UUID in the URL bar.
+# ── Gallery Structure ──────────────────────────────────────────────
+# This file defines WHICH albums and subpages are shown.
+# Global settings (theme, map, legal, title) now live in content/settings.yaml.
-# Hero image(s) shown on the homepage (Immich asset UUIDs).
-# Use a single UUID for a static image, or a list for a crossfade carousel.
-hero: 00000000-0000-0000-0000-000000000000
-# hero:
-# - 00000000-0000-0000-0000-000000000000
-# - 11111111-1111-1111-1111-111111111111
-# - 22222222-2222-2222-2222-222222222222
+# Hero images for the homepage carousel (Immich asset UUIDs)
+hero:
+ - "your-asset-uuid-1"
+ - "your-asset-uuid-2"
-# Standalone albums — shown directly on the homepage as cards
-# Use a string for the UUID, or a map for an inline name override.
+# Standalone albums (shown on the home page)
albums:
- - 11111111-1111-1111-1111-111111111111: "Meine Favoriten" # inline override
- - 22222222-2222-2222-2222-222222222222
+ - "your-album-uuid-1"
+ - "your-album-uuid-2"
-# Show camera/lens EXIF on photo hover (default: true)
-# exifOnHover: false
-
-# Enable the /map page — plots photos on a world map by city/country (default: false)
-# map: true
-
-# Photo grid layout — customize columns, gap, aspect ratio, and mode.
-# grid:
-# columns: 3 # default: 3
-# gap: 12 # pixels, default: 12
-# aspectRatio: "1" # "1", "3/2", "auto", etc. default: "1"
-# layout: masonry # "masonry" or "uniform", default: "masonry"
-
-# Subpages — group albums into collections at /subpage-slug/album-slug.
-# You can use a simple map for concise definitions, or a full object for passwords/grids.
+# Subpages: group albums under custom URL paths
subpages:
- Japan: # Simple map format
- - 33333333-3333-3333-3333-333333333333: "Tokyo Nights"
- - 44444444-4444-4444-4444-444444444444
-
- Wedding: # Advanced object format (needed for passwords/grid overrides)
- password: clientpass123
+ "Travel":
+ - "album-uuid-3"
+ - "album-uuid-4"
+ "Projects":
+ password: "your-password" # Optional protection
albums:
- - 55555555-5555-5555-5555-555555555555: "The Big Day"
-
-# Footer — optional minimal footer with social links
-# footer:
-# name: John Doe
-# instagram: johndoe
-# email: hello@example.com
-# website: https://example.com
-
-# ── Theme ─────────────────────────────────────────────────────
-# Control the visual identity of your gallery. Use a preset name:
-# theme: studio # Leica-inspired, red dot, passepartout frames, film grain
-# theme: minimal # Swiss brutalist, fullbleed hero, true B&W, zero-gap grid
-# theme: editorial # Cinematic magazine, warm charcoal, Bodoni serif, shadow frames
-# theme: classic # Gilded gallery, gold accents, Cinzel headings, passepartout
-#
-# Or customize individual properties (defaults from the preset):
-# theme:
-# preset: studio # base preset to extend
-# accent: "#2563eb" # brand/accent color
-# fonts:
-# heading: "Inter" # Google Fonts name for headings
-# body: "Inter" # body text
-# caption: "JetBrains Mono" # EXIF captions
-# radius: 8 # border-radius in pixels (0 = sharp)
-# photoFrame: none # "none" | "passepartout" | "shadow"
-# grain: false # film grain overlay on photos
-# headerDot: false # accent dot in the nav bar
-# heroStyle: split # "split" | "fullbleed" | "minimal"
+ - "album-uuid-5"
+ - "album-uuid-6"
diff --git a/content/settings.yaml.example b/content/settings.yaml.example
new file mode 100644
index 0000000..6c99b32
--- /dev/null
+++ b/content/settings.yaml.example
@@ -0,0 +1,52 @@
+# ── Global Site Settings ──────────────────────────────────────────
+# This file controls the visual identity, global behavior, and legal info.
+# Structure is separated from gallery.yaml (album IDs) for clarity.
+
+# Public site title and subtitle (overrides .env if set)
+title: "My Portfolio"
+subtitle: "A visual journal"
+
+# Show EXIF metadata (camera, lens, ISO) on photo grid hover
+exifOnHover: true
+
+# Enable the interactive world map view (/map)
+map: true
+
+# Enable smooth page transitions between routes
+transitions: true
+
+# ── Theme configuration ──────────────────────────────────────────
+# Presets: studio, minimal, editorial, classic, noir, monograph, botanica
+theme:
+ preset: "studio"
+ # accent: "#e60012"
+ # photoFrame: "passepartout" # none, passepartout, shadow
+ # grain: true
+
+# ── Grid configuration ───────────────────────────────────────────
+grid:
+ columns: 3
+ gap: 12
+ aspectRatio: "1" # "1" (square), "3/2", "2/3", "16/9", "auto"
+ layout: "masonry" # masonry, uniform, showcase, filmstrip, editorial-flow
+
+# ── Footer & Social ──────────────────────────────────────────────
+footer:
+ name: "My Photography"
+ instagram: "https://instagram.com/your-handle"
+ email: "hello@example.com"
+ website: "https://example.com"
+
+# ── Legal / Impressum (German Law compliance) ────────────────────
+# If enabled: true, a link to /impressum will appear in the footer.
+legal:
+ enabled: false
+ name: "Your Full Name"
+ address: "Street and House Number"
+ zipCity: "ZIP Code and City"
+ country: "Germany"
+ email: "legal@example.com"
+ phone: "+49 123 456789"
+ # taxId: "DE123456789"
+ # vatId: "12/345/67890"
+ extraInfo: "Verantwortlich für den Inhalt nach § 18 Abs. 2 MStV: Max Mustermann"
diff --git a/lib/config.ts b/lib/config.ts
index c096498..cd2e075 100644
--- a/lib/config.ts
+++ b/lib/config.ts
@@ -1,6 +1,7 @@
/**
* Typed configuration loader.
* Secrets come from env vars; gallery structure comes from content/gallery.yaml.
+ * Global settings come from content/settings.yaml.
*/
import fs from 'fs';
@@ -8,8 +9,6 @@ import path from 'path';
import yaml from 'js-yaml';
import { env } from './env';
-// ── Env helpers (now handled by lib/env.ts via Zod) ───────────
-
// ── Types ──────────────────────────────────────────────────────
/** A subpage groups multiple Immich albums under one URL path. */
@@ -31,6 +30,20 @@ export interface FooterConfig {
website?: string;
}
+/** Legal / Impressum configuration. */
+export interface LegalConfig {
+ enabled: boolean;
+ name: string;
+ address: string;
+ zipCity: string;
+ country: string;
+ email?: string;
+ phone?: string;
+ taxId?: string;
+ vatId?: string;
+ extraInfo?: string;
+}
+
/** Theme configuration — controls the visual identity of the gallery. */
export interface ThemeConfig {
preset: string;
@@ -118,10 +131,29 @@ const THEME_PRESETS: Record = {
},
};
-/** Raw YAML structure (before validation). */
+/** Raw YAML structure for gallery structure. */
interface GalleryYaml {
hero?: string | string[];
albums?: string[];
+ subpages?:
+ | Record>>
+ | Array<{
+ name: string;
+ albums: Array>;
+ password?: string;
+ grid?: {
+ columns?: number;
+ gap?: number;
+ aspectRatio?: string;
+ layout?: string;
+ };
+ }>;
+}
+
+/** Raw YAML structure for global settings. */
+interface SettingsYaml {
+ title?: string;
+ subtitle?: string;
exifOnHover?: boolean;
map?: boolean;
transitions?: boolean;
@@ -143,47 +175,28 @@ interface GalleryYaml {
aspectRatio?: string;
layout?: string;
};
- footer?: {
- name?: string;
- instagram?: string;
- email?: string;
- website?: string;
- };
- subpages?:
- | Record>>
- | Array<{
- name: string;
- albums: Array>;
- password?: string;
- grid?: {
- columns?: number;
- gap?: number;
- aspectRatio?: string;
- layout?: string;
- };
- }>;
+ footer?: FooterConfig;
+ legal?: Partial;
}
// ── YAML loading ───────────────────────────────────────────────
-function loadGalleryYaml(): GalleryYaml {
- const yamlPath = path.join(process.cwd(), 'content', 'gallery.yaml');
+function loadYaml(filename: string): T {
+ const yamlPath = path.join(process.cwd(), 'content', filename);
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.`,
- );
+ // gallery.yaml is required, settings.yaml can fallback to defaults
+ if (filename === 'gallery.yaml') {
+ throw new Error(
+ `Required config not found: ${yamlPath}\n` +
+ `Copy content/gallery.yaml.example to content/gallery.yaml and add your album IDs.`,
+ );
+ }
+ return {} as T;
}
const raw = fs.readFileSync(yamlPath, 'utf8');
- const data = yaml.load(raw) as GalleryYaml;
-
- if (!data || typeof data !== 'object') {
- throw new Error('gallery.yaml is empty or invalid');
- }
-
- return data;
+ return (yaml.load(raw) || {}) as T;
}
// ── UUID validation ────────────────────────────────────────────
@@ -242,6 +255,8 @@ export interface AppConfig {
theme: ThemeConfig;
/** Footer social links config. */
footer: FooterConfig | null;
+ /** Legal / Impressum configuration. */
+ legal: LegalConfig;
/** Show the /map page. Default: false. */
map: boolean;
/** Enable page transitions between routes. Default: true. */
@@ -258,7 +273,7 @@ const VALID_PHOTO_FRAMES = ['none', 'passepartout', 'shadow'];
const VALID_HERO_STYLES = ['split', 'fullbleed', 'minimal', 'stacked', 'typographic', 'mosaic'];
const VALID_LAYOUTS = ['masonry', 'uniform', 'showcase', 'filmstrip', 'editorial-flow'];
-function resolveTheme(raw?: GalleryYaml['theme']): ThemeConfig {
+function resolveTheme(raw?: SettingsYaml['theme']): ThemeConfig {
// No theme key → default preset
if (!raw) return { ...THEME_PRESETS.studio };
@@ -331,10 +346,11 @@ export function getConfig(): AppConfig {
);
}
- const gallery = loadGalleryYaml();
+ const gallery = loadYaml('gallery.yaml');
+ const settings = loadYaml('settings.yaml');
// Resolve theme: string shorthand → preset, object → merged with preset base
- const theme = resolveTheme(gallery.theme);
+ const theme = resolveTheme(settings.theme);
const albumOverrides: Record = {};
@@ -451,33 +467,45 @@ export function getConfig(): AppConfig {
albums: allAlbumIds,
standaloneAlbums: standaloneAlbumIds.filter((id) => !subpageAlbumIds.has(id)),
subpages,
- siteTitle: env.SITE_TITLE,
- siteSubtitle: env.SITE_SUBTITLE,
+ siteTitle: settings.title ?? env.SITE_TITLE,
+ siteSubtitle: settings.subtitle ?? 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,
+ exifOnHover: settings.exifOnHover !== false,
grid: {
- columns: gallery.grid?.columns ?? 3,
- gap: gallery.grid?.gap ?? 12,
- aspectRatio: gallery.grid?.aspectRatio ?? '1',
- layout: (VALID_LAYOUTS.includes(gallery.grid?.layout ?? '')
- ? gallery.grid!.layout
+ columns: settings.grid?.columns ?? 3,
+ gap: settings.grid?.gap ?? 12,
+ aspectRatio: settings.grid?.aspectRatio ?? '1',
+ layout: (VALID_LAYOUTS.includes(settings.grid?.layout ?? '')
+ ? settings.grid!.layout
: 'masonry') as GridConfig['layout'],
},
theme,
- footer: gallery.footer
+ footer: settings.footer
? {
- name: gallery.footer.name,
- instagram: gallery.footer.instagram,
- email: gallery.footer.email,
- website: gallery.footer.website,
+ name: settings.footer.name,
+ instagram: settings.footer.instagram,
+ email: settings.footer.email,
+ website: settings.footer.website,
}
: null,
- map: gallery.map === true,
- transitions: gallery.transitions !== false,
+ legal: {
+ enabled: settings.legal?.enabled === true,
+ name: settings.legal?.name || '',
+ address: settings.legal?.address || '',
+ zipCity: settings.legal?.zipCity || '',
+ country: settings.legal?.country || '',
+ email: settings.legal?.email,
+ phone: settings.legal?.phone,
+ taxId: settings.legal?.taxId,
+ vatId: settings.legal?.vatId,
+ extraInfo: settings.legal?.extraInfo,
+ },
+ map: settings.map === true,
+ transitions: settings.transitions !== false,
albumOverrides,
cacheTtl: env.CACHE_TTL * 1000,
rateLimitRpm: env.RATE_LIMIT_RPM,