feat: separate settings and gallery config, add impressum page

This commit is contained in:
ralksta
2026-03-07 08:34:05 +01:00
parent 1e079760d8
commit 0fed8e6c78
6 changed files with 307 additions and 124 deletions

View File

@@ -877,11 +877,17 @@
.footer__inner { .footer__inner {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: space-between;
gap: 20px; gap: 20px;
flex-wrap: wrap; flex-wrap: wrap;
} }
.footer__left {
display: flex;
align-items: center;
gap: 20px;
}
.footer__name { .footer__name {
font-family: var(--font-sans); font-family: var(--font-sans);
font-size: 0.75rem; font-size: 0.75rem;
@@ -891,6 +897,19 @@
color: var(--text-muted); color: var(--text-muted);
} }
.footer__legal-link {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-muted);
opacity: 0.5;
transition: opacity var(--transition-fast);
}
.footer__legal-link:hover {
opacity: 1;
}
.footer__links { .footer__links {
display: flex; display: flex;
gap: 12px; gap: 12px;

121
app/impressum/page.tsx Normal file
View File

@@ -0,0 +1,121 @@
/**
* Impressum (Legal Notice) page.
* Required for German law compliance. Displays information from settings.yaml.
*/
import { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { getConfig } from '@/lib/config';
import { BackLink } from '@/components/BackLink';
import { FadeIn } from '@/components/FadeIn';
export const metadata: Metadata = {
title: 'Impressum',
robots: { index: false, follow: true }, // Usually no need to index legal pages
};
export default function ImpressumPage() {
const { legal } = getConfig();
if (!legal.enabled) {
notFound();
}
return (
<div className="subpage-container">
<header className="subpage-header">
<BackLink href="/" label="Home" />
<FadeIn>
<h1 className="subpage-title">Impressum</h1>
<p className="subpage-subtitle">Angaben gemäß § 5 TMG</p>
</FadeIn>
</header>
<main className="subpage-content" style={{ maxWidth: '600px', margin: '0 auto' }}>
<FadeIn delay={0.1}>
<section style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1.2rem', marginBottom: '0.5rem', opacity: 0.8 }}>Anschrift</h2>
<p style={{ lineHeight: 1.6 }}>
{legal.name}
<br />
{legal.address}
<br />
{legal.zipCity}
<br />
{legal.country}
</p>
</section>
{(legal.email || legal.phone) && (
<section style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1.2rem', marginBottom: '0.5rem', opacity: 0.8 }}>Kontakt</h2>
<p style={{ lineHeight: 1.6 }}>
{legal.email && (
<>
E-Mail: {legal.email}
<br />
</>
)}
{legal.phone && <>Telefon: {legal.phone}</>}
</p>
</section>
)}
{(legal.taxId || legal.vatId) && (
<section style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1.2rem', marginBottom: '0.5rem', opacity: 0.8 }}>
Steuernummer
</h2>
<p style={{ lineHeight: 1.6 }}>
{legal.taxId && (
<>
Steuernummer: {legal.taxId}
<br />
</>
)}
{legal.vatId && <>Umsatzsteuer-ID: {legal.vatId}</>}
</p>
</section>
)}
{legal.extraInfo && (
<section style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1.2rem', marginBottom: '0.5rem', opacity: 0.8 }}>
Weitere Informationen
</h2>
<p style={{ lineHeight: 1.6, whiteSpace: 'pre-wrap' }}>{legal.extraInfo}</p>
</section>
)}
<section style={{ marginTop: '4rem', fontSize: '0.85rem', opacity: 0.6 }}>
<p>Quelle: Erstellt mit dem Impressum-Generator von eRecht24.</p>
</section>
</FadeIn>
</main>
<style jsx>{`
.subpage-container {
padding: 4rem 2rem;
min-height: 80vh;
}
.subpage-header {
margin-bottom: 4rem;
text-align: center;
}
.subpage-title {
font-family: var(--font-serif);
font-size: clamp(2.5rem, 8vw, 4rem);
font-weight: 400;
margin-bottom: 1rem;
}
.subpage-subtitle {
font-family: var(--font-caption);
font-size: 1rem;
text-transform: uppercase;
letter-spacing: 0.1em;
opacity: 0.6;
}
`}</style>
</div>
);
}

View File

@@ -3,21 +3,32 @@
* Reads configuration from gallery.yaml via getConfig(). * Reads configuration from gallery.yaml via getConfig().
*/ */
import Link from 'next/link';
import { getConfig } from '@/lib/config'; import { getConfig } from '@/lib/config';
export function Footer() { export function Footer() {
const config = getConfig(); const config = getConfig();
const { footer } = config; const { footer, legal } = config;
// Don't render if no footer config // Don't render if no footer config AND no legal config
if (!footer || (!footer.name && !footer.instagram && !footer.email && !footer.website)) { if (
!legal.enabled &&
(!footer || (!footer.name && !footer.instagram && !footer.email && !footer.website))
) {
return null; return null;
} }
return ( return (
<footer className="footer"> <footer className="footer">
<div className="footer__inner"> <div className="footer__inner">
{footer.name && <span className="footer__name">{footer.name}</span>} <div className="footer__left">
{footer?.name && <span className="footer__name">{footer.name}</span>}
{legal.enabled && (
<Link href="/impressum" className="footer__legal-link">
Impressum
</Link>
)}
</div>
<div className="footer__links"> <div className="footer__links">
{footer.instagram && ( {footer.instagram && (

View File

@@ -1,72 +1,24 @@
# ── Gallery Configuration ────────────────────────────────────── # ── Gallery Structure ──────────────────────────────────────────────
# Define which Immich albums to publish and how to organize them. # This file defines WHICH albums and subpages are shown.
# # Global settings (theme, map, legal, title) now live in content/settings.yaml.
# Find album UUIDs in Immich: Albums → click album → UUID in the URL bar.
# Find asset UUIDs: click a photo → UUID in the URL bar.
# Hero image(s) shown on the homepage (Immich asset UUIDs). # Hero images for the homepage carousel (Immich asset UUIDs)
# Use a single UUID for a static image, or a list for a crossfade carousel. hero:
hero: 00000000-0000-0000-0000-000000000000 - "your-asset-uuid-1"
# hero: - "your-asset-uuid-2"
# - 00000000-0000-0000-0000-000000000000
# - 11111111-1111-1111-1111-111111111111
# - 22222222-2222-2222-2222-222222222222
# Standalone albums — shown directly on the homepage as cards # Standalone albums (shown on the home page)
# Use a string for the UUID, or a map for an inline name override.
albums: albums:
- 11111111-1111-1111-1111-111111111111: "Meine Favoriten" # inline override - "your-album-uuid-1"
- 22222222-2222-2222-2222-222222222222 - "your-album-uuid-2"
# Show camera/lens EXIF on photo hover (default: true) # Subpages: group albums under custom URL paths
# 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: subpages:
Japan: # Simple map format "Travel":
- 33333333-3333-3333-3333-333333333333: "Tokyo Nights" - "album-uuid-3"
- 44444444-4444-4444-4444-444444444444 - "album-uuid-4"
"Projects":
Wedding: # Advanced object format (needed for passwords/grid overrides) password: "your-password" # Optional protection
password: clientpass123
albums: albums:
- 55555555-5555-5555-5555-555555555555: "The Big Day" - "album-uuid-5"
- "album-uuid-6"
# 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"

View File

@@ -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"

View File

@@ -1,6 +1,7 @@
/** /**
* Typed configuration loader. * Typed configuration loader.
* Secrets come from env vars; gallery structure comes from content/gallery.yaml. * Secrets come from env vars; gallery structure comes from content/gallery.yaml.
* Global settings come from content/settings.yaml.
*/ */
import fs from 'fs'; import fs from 'fs';
@@ -8,8 +9,6 @@ import path from 'path';
import yaml from 'js-yaml'; import yaml from 'js-yaml';
import { env } from './env'; import { env } from './env';
// ── Env helpers (now handled by lib/env.ts via Zod) ───────────
// ── Types ────────────────────────────────────────────────────── // ── Types ──────────────────────────────────────────────────────
/** A subpage groups multiple Immich albums under one URL path. */ /** A subpage groups multiple Immich albums under one URL path. */
@@ -31,6 +30,20 @@ export interface FooterConfig {
website?: string; 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. */ /** Theme configuration — controls the visual identity of the gallery. */
export interface ThemeConfig { export interface ThemeConfig {
preset: string; preset: string;
@@ -118,10 +131,29 @@ const THEME_PRESETS: Record<string, ThemeConfig> = {
}, },
}; };
/** Raw YAML structure (before validation). */ /** Raw YAML structure for gallery structure. */
interface GalleryYaml { interface GalleryYaml {
hero?: string | string[]; hero?: string | string[];
albums?: string[]; albums?: string[];
subpages?:
| Record<string, string[] | Array<string | Record<string, string>>>
| Array<{
name: string;
albums: Array<string | Record<string, string>>;
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; exifOnHover?: boolean;
map?: boolean; map?: boolean;
transitions?: boolean; transitions?: boolean;
@@ -143,47 +175,28 @@ interface GalleryYaml {
aspectRatio?: string; aspectRatio?: string;
layout?: string; layout?: string;
}; };
footer?: { footer?: FooterConfig;
name?: string; legal?: Partial<LegalConfig>;
instagram?: string;
email?: string;
website?: string;
};
subpages?:
| Record<string, string[] | Array<string | Record<string, string>>>
| Array<{
name: string;
albums: Array<string | Record<string, string>>;
password?: string;
grid?: {
columns?: number;
gap?: number;
aspectRatio?: string;
layout?: string;
};
}>;
} }
// ── YAML loading ─────────────────────────────────────────────── // ── YAML loading ───────────────────────────────────────────────
function loadGalleryYaml(): GalleryYaml { function loadYaml<T>(filename: string): T {
const yamlPath = path.join(process.cwd(), 'content', 'gallery.yaml'); const yamlPath = path.join(process.cwd(), 'content', filename);
if (!fs.existsSync(yamlPath)) { if (!fs.existsSync(yamlPath)) {
throw new Error( // gallery.yaml is required, settings.yaml can fallback to defaults
`Gallery config not found: ${yamlPath}\n` + if (filename === 'gallery.yaml') {
`Copy content/gallery.yaml.example to content/gallery.yaml and add your album IDs.`, 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 raw = fs.readFileSync(yamlPath, 'utf8');
const data = yaml.load(raw) as GalleryYaml; return (yaml.load(raw) || {}) as T;
if (!data || typeof data !== 'object') {
throw new Error('gallery.yaml is empty or invalid');
}
return data;
} }
// ── UUID validation ──────────────────────────────────────────── // ── UUID validation ────────────────────────────────────────────
@@ -242,6 +255,8 @@ export interface AppConfig {
theme: ThemeConfig; theme: ThemeConfig;
/** Footer social links config. */ /** Footer social links config. */
footer: FooterConfig | null; footer: FooterConfig | null;
/** Legal / Impressum configuration. */
legal: LegalConfig;
/** Show the /map page. Default: false. */ /** Show the /map page. Default: false. */
map: boolean; map: boolean;
/** Enable page transitions between routes. Default: true. */ /** 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_HERO_STYLES = ['split', 'fullbleed', 'minimal', 'stacked', 'typographic', 'mosaic'];
const VALID_LAYOUTS = ['masonry', 'uniform', 'showcase', 'filmstrip', 'editorial-flow']; 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 // No theme key → default preset
if (!raw) return { ...THEME_PRESETS.studio }; if (!raw) return { ...THEME_PRESETS.studio };
@@ -331,10 +346,11 @@ export function getConfig(): AppConfig {
); );
} }
const gallery = loadGalleryYaml(); const gallery = loadYaml<GalleryYaml>('gallery.yaml');
const settings = loadYaml<SettingsYaml>('settings.yaml');
// Resolve theme: string shorthand → preset, object → merged with preset base // Resolve theme: string shorthand → preset, object → merged with preset base
const theme = resolveTheme(gallery.theme); const theme = resolveTheme(settings.theme);
const albumOverrides: Record<string, string> = {}; const albumOverrides: Record<string, string> = {};
@@ -451,33 +467,45 @@ export function getConfig(): AppConfig {
albums: allAlbumIds, albums: allAlbumIds,
standaloneAlbums: standaloneAlbumIds.filter((id) => !subpageAlbumIds.has(id)), standaloneAlbums: standaloneAlbumIds.filter((id) => !subpageAlbumIds.has(id)),
subpages, subpages,
siteTitle: env.SITE_TITLE, siteTitle: settings.title ?? env.SITE_TITLE,
siteSubtitle: env.SITE_SUBTITLE, siteSubtitle: settings.subtitle ?? env.SITE_SUBTITLE,
heroImages: gallery.hero heroImages: gallery.hero
? (Array.isArray(gallery.hero) ? gallery.hero : [gallery.hero]).map((id) => ? (Array.isArray(gallery.hero) ? gallery.hero : [gallery.hero]).map((id) =>
validateUuid(id, 'gallery.yaml hero'), validateUuid(id, 'gallery.yaml hero'),
) )
: [], : [],
exifOnHover: gallery.exifOnHover !== false, exifOnHover: settings.exifOnHover !== false,
grid: { grid: {
columns: gallery.grid?.columns ?? 3, columns: settings.grid?.columns ?? 3,
gap: gallery.grid?.gap ?? 12, gap: settings.grid?.gap ?? 12,
aspectRatio: gallery.grid?.aspectRatio ?? '1', aspectRatio: settings.grid?.aspectRatio ?? '1',
layout: (VALID_LAYOUTS.includes(gallery.grid?.layout ?? '') layout: (VALID_LAYOUTS.includes(settings.grid?.layout ?? '')
? gallery.grid!.layout ? settings.grid!.layout
: 'masonry') as GridConfig['layout'], : 'masonry') as GridConfig['layout'],
}, },
theme, theme,
footer: gallery.footer footer: settings.footer
? { ? {
name: gallery.footer.name, name: settings.footer.name,
instagram: gallery.footer.instagram, instagram: settings.footer.instagram,
email: gallery.footer.email, email: settings.footer.email,
website: gallery.footer.website, website: settings.footer.website,
} }
: null, : null,
map: gallery.map === true, legal: {
transitions: gallery.transitions !== false, 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, albumOverrides,
cacheTtl: env.CACHE_TTL * 1000, cacheTtl: env.CACHE_TTL * 1000,
rateLimitRpm: env.RATE_LIMIT_RPM, rateLimitRpm: env.RATE_LIMIT_RPM,