diff --git a/app/[...path]/AlbumDetailView.tsx b/app/[...path]/AlbumDetailView.tsx
new file mode 100644
index 0000000..b729619
--- /dev/null
+++ b/app/[...path]/AlbumDetailView.tsx
@@ -0,0 +1,38 @@
+import type { ImmichAlbum } from '@/lib/immich';
+import type { PhotoItem } from './PhotoGrid';
+import { PhotoGrid } from './PhotoGrid';
+import { BackLink } from '@/components/BackLink';
+import type { GridConfig } from '@/lib/config';
+import React from 'react';
+
+interface AlbumDetailViewProps {
+ album: ImmichAlbum;
+ images: PhotoItem[];
+ layout: GridConfig['layout'];
+ gridStyle: React.CSSProperties;
+ backLinkHref: string;
+ backLinkLabel: string;
+}
+
+export function AlbumDetailView({
+ album,
+ images,
+ layout,
+ gridStyle,
+ backLinkHref,
+ backLinkLabel,
+}: AlbumDetailViewProps) {
+ return (
+ <>
+
+
+
{album.albumName}
+
+ {images.length} {images.length === 1 ? 'photo' : 'photos'}
+ {album.description && ` · ${album.description}`}
+
+
+
+ >
+ );
+}
diff --git a/app/[...path]/SubpageGridView.tsx b/app/[...path]/SubpageGridView.tsx
new file mode 100644
index 0000000..7b8d4eb
--- /dev/null
+++ b/app/[...path]/SubpageGridView.tsx
@@ -0,0 +1,62 @@
+import Link from 'next/link';
+import Image from 'next/image';
+import { imageUrl } from '@/lib/urls';
+
+interface SubpageAlbum {
+ id: string;
+ slug: string;
+ albumName: string;
+ assetCount: number;
+ albumThumbnailAssetId: string | null;
+}
+
+interface Placeholder {
+ blurDataURL?: string;
+ dominantColor?: string;
+}
+
+interface SubpageGridViewProps {
+ slug: string;
+ albums: SubpageAlbum[];
+ coverPlaceholders: (Placeholder | null)[];
+}
+
+export function SubpageGridView({ slug, albums, coverPlaceholders }: SubpageGridViewProps) {
+ return (
+
+ {albums.map((album, i) => {
+ const ph = coverPlaceholders[i];
+ return (
+
+ {album.albumThumbnailAssetId ? (
+
+ ) : (
+
+ )}
+
+ {album.assetCount} {album.assetCount === 1 ? 'photo' : 'photos'}
+
+
+ {album.albumName}
+
+ {album.assetCount} {album.assetCount === 1 ? 'photo' : 'photos'}
+
+
+
+ );
+ })}
+
+ );
+}
diff --git a/app/[...path]/page.tsx b/app/[...path]/page.tsx
index 56cb465..0ab8190 100644
--- a/app/[...path]/page.tsx
+++ b/app/[...path]/page.tsx
@@ -28,6 +28,8 @@ import { getConfig, type GridConfig } from '@/lib/config';
import { isProtected, isAuthenticated } from '@/lib/auth';
import PasswordGate from '@/components/PasswordGate';
import { BackLink } from '@/components/BackLink';
+import { AlbumDetailView } from './AlbumDetailView';
+import { SubpageGridView } from './SubpageGridView';
// Render at request time — requires live Immich connection
export const dynamic = 'force-dynamic';
@@ -153,21 +155,14 @@ export default async function PathPage({ params }: PathPageProps) {
const images = toPhotoItems(album.assets, config.exifOnHover);
return (
- <>
-
-
-
{album.albumName}
-
- {images.length} {images.length === 1 ? 'photo' : 'photos'}
- {album.description && ` · ${album.description}`}
-
-
-
- >
+
);
}
@@ -202,21 +197,14 @@ export default async function PathPage({ params }: PathPageProps) {
const images = toPhotoItems(album.assets, config.exifOnHover);
return (
- <>
-
-
-
{album.albumName}
-
- {images.length} {images.length === 1 ? 'photo' : 'photos'}
- {album.description && ` · ${album.description}`}
-
-
-
- >
+
);
}
@@ -232,41 +220,11 @@ export default async function PathPage({ params }: PathPageProps) {
);
return (
-
- {albums.map((album, i) => {
- const ph = coverPlaceholders[i];
- return (
-
- {album.albumThumbnailAssetId ? (
-
- ) : (
-
- )}
-
- {album.assetCount} {album.assetCount === 1 ? 'photo' : 'photos'}
-
-
- {album.albumName}
-
- {album.assetCount} {album.assetCount === 1 ? 'photo' : 'photos'}
-
-
-
- );
- })}
-
+
);
}
@@ -279,16 +237,13 @@ export default async function PathPage({ params }: PathPageProps) {
const images = toPhotoItems(album.assets, config.exifOnHover);
return (
- <>
-
-
-
{album.albumName}
-
- {images.length} {images.length === 1 ? 'photo' : 'photos'}
- {album.description && ` · ${album.description}`}
-
-
-
- >
+
);
}
diff --git a/app/api/map/route.ts b/app/api/map/route.ts
index a0f2167..c6493ef 100644
--- a/app/api/map/route.ts
+++ b/app/api/map/route.ts
@@ -6,10 +6,10 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
-import { immich } from '@/lib/immich';
import { getConfig } from '@/lib/config';
import { imageUrl } from '@/lib/urls';
import { isAuthenticated } from '@/lib/auth';
+import { getMapData } from '@/lib/mapService';
export const dynamic = 'force-dynamic';
@@ -23,7 +23,7 @@ export async function GET(_request: NextRequest) {
const cookieStore = await cookies();
const getCookie = (name: string) => cookieStore.get(name)?.value;
- const locations = await immich.getMapData();
+ const locations = await getMapData();
// Filter locations and albums based on auth
const publicLocations = locations
diff --git a/components/Lightbox.tsx b/components/Lightbox.tsx
index 4a109c9..39ce916 100644
--- a/components/Lightbox.tsx
+++ b/components/Lightbox.tsx
@@ -14,20 +14,10 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { createPortal } from 'react-dom';
import type { PhotoItem } from '@/app/[...path]/PhotoGrid';
+import { useExif } from '@/hooks/useExif';
+import { useSwipe } from '@/hooks/useSwipe';
import styles from './Lightbox.module.css';
-interface ExifData {
- make?: string | null;
- model?: string | null;
- lensModel?: string | null;
- focalLength?: number | null;
- fNumber?: number | null;
- exposureTime?: string | null;
- iso?: number | null;
- city?: string | null;
- country?: string | null;
-}
-
interface LightboxProps {
assets: PhotoItem[];
currentIndex: number;
@@ -38,12 +28,9 @@ interface LightboxProps {
export function Lightbox({ assets, currentIndex, onClose, onNext, onPrev }: LightboxProps) {
const [showExif, setShowExif] = useState(false);
- const [exifData, setExifData] = useState(null);
- const [exifLoading, setExifLoading] = useState(false);
+ const { exifData, exifLoading, fetchExif, clearExif } = useExif();
const [imageLoaded, setImageLoaded] = useState(false);
const overlayRef = useRef(null);
- const touchStartX = useRef(0);
- const touchStartY = useRef(0);
const current = assets[currentIndex];
const [mounted, setMounted] = useState(false);
@@ -53,20 +40,9 @@ export function Lightbox({ assets, currentIndex, onClose, onNext, onPrev }: Ligh
setMounted(true);
}, []);
- // Fetch EXIF data on demand
- const fetchExif = useCallback((url: string) => {
- setExifLoading(true);
- setExifData(null);
- fetch(url)
- .then((res) => (res.ok ? res.json() : null))
- .then((data) => setExifData(data))
- .catch(() => setExifData(null))
- .finally(() => setExifLoading(false));
- }, []);
-
// Reset EXIF data when switching images; refetch if panel is open
useEffect(() => {
- setExifData(null);
+ clearExif();
setImageLoaded(false);
if (showExif && current) {
fetchExif(current.exifUrl);
@@ -95,25 +71,10 @@ export function Lightbox({ assets, currentIndex, onClose, onNext, onPrev }: Ligh
preload(currentIndex - 1);
}, [currentIndex, assets]);
- // Touch/swipe support
- const handleTouchStart = useCallback((e: React.TouchEvent) => {
- touchStartX.current = e.touches[0].clientX;
- touchStartY.current = e.touches[0].clientY;
- }, []);
-
- const handleTouchEnd = useCallback(
- (e: React.TouchEvent) => {
- const dx = e.changedTouches[0].clientX - touchStartX.current;
- const dy = e.changedTouches[0].clientY - touchStartY.current;
-
- // Only register horizontal swipes (not vertical scrolling)
- if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy)) {
- if (dx > 0) onPrev();
- else onNext();
- }
- },
- [onNext, onPrev],
- );
+ const { handleTouchStart, handleTouchEnd } = useSwipe({
+ onSwipeLeft: onNext,
+ onSwipeRight: onPrev,
+ });
// Click on overlay background → close
const handleOverlayClick = useCallback(
diff --git a/hooks/useExif.ts b/hooks/useExif.ts
new file mode 100644
index 0000000..6319a39
--- /dev/null
+++ b/hooks/useExif.ts
@@ -0,0 +1,34 @@
+import { useState, useCallback } from 'react';
+
+export interface ExifData {
+ make?: string | null;
+ model?: string | null;
+ lensModel?: string | null;
+ focalLength?: number | null;
+ fNumber?: number | null;
+ exposureTime?: string | null;
+ iso?: number | null;
+ city?: string | null;
+ country?: string | null;
+}
+
+export function useExif() {
+ const [exifData, setExifData] = useState(null);
+ const [exifLoading, setExifLoading] = useState(false);
+
+ const fetchExif = useCallback((url: string) => {
+ setExifLoading(true);
+ setExifData(null);
+ fetch(url)
+ .then((res) => (res.ok ? res.json() : null))
+ .then((data) => setExifData(data))
+ .catch(() => setExifData(null))
+ .finally(() => setExifLoading(false));
+ }, []);
+
+ const clearExif = useCallback(() => {
+ setExifData(null);
+ }, []);
+
+ return { exifData, exifLoading, fetchExif, clearExif };
+}
diff --git a/hooks/useSwipe.ts b/hooks/useSwipe.ts
new file mode 100644
index 0000000..2f06da6
--- /dev/null
+++ b/hooks/useSwipe.ts
@@ -0,0 +1,32 @@
+import { useRef, useCallback } from 'react';
+
+interface UseSwipeProps {
+ onSwipeLeft: () => void;
+ onSwipeRight: () => void;
+ threshold?: number;
+}
+
+export function useSwipe({ onSwipeLeft, onSwipeRight, threshold = 60 }: UseSwipeProps) {
+ const touchStartX = useRef(0);
+ const touchStartY = useRef(0);
+
+ const handleTouchStart = useCallback((e: React.TouchEvent | TouchEvent) => {
+ touchStartX.current = e.touches[0].clientX;
+ touchStartY.current = e.touches[0].clientY;
+ }, []);
+
+ const handleTouchEnd = useCallback(
+ (e: React.TouchEvent | TouchEvent) => {
+ const dx = e.changedTouches[0].clientX - touchStartX.current;
+ const dy = e.changedTouches[0].clientY - touchStartY.current;
+
+ if (Math.abs(dx) > threshold && Math.abs(dx) > Math.abs(dy)) {
+ if (dx > 0) onSwipeRight();
+ else onSwipeLeft();
+ }
+ },
+ [onSwipeLeft, onSwipeRight, threshold],
+ );
+
+ return { handleTouchStart, handleTouchEnd };
+}
diff --git a/lib/__tests__/urls.test.ts b/lib/__tests__/urls.test.ts
index 71bdb3a..ae05e9e 100644
--- a/lib/__tests__/urls.test.ts
+++ b/lib/__tests__/urls.test.ts
@@ -68,6 +68,8 @@ describe('assetExifSummary', () => {
iso: null,
exifImageWidth: null,
exifImageHeight: null,
+ latitude: null,
+ longitude: null,
city: null,
state: null,
country: null,
@@ -99,6 +101,8 @@ describe('assetExifSummary', () => {
iso: null,
exifImageWidth: null,
exifImageHeight: null,
+ latitude: null,
+ longitude: null,
city: null,
state: null,
country: null,
@@ -123,6 +127,8 @@ describe('assetAspectRatio', () => {
iso: null,
exifImageWidth: 3000,
exifImageHeight: 2000,
+ latitude: null,
+ longitude: null,
city: null,
state: null,
country: null,
@@ -150,6 +156,8 @@ describe('assetAspectRatio', () => {
iso: null,
exifImageWidth: 100,
exifImageHeight: 0,
+ latitude: null,
+ longitude: null,
city: null,
state: null,
country: null,
diff --git a/lib/config/index.ts b/lib/config/index.ts
new file mode 100644
index 0000000..a9f735a
--- /dev/null
+++ b/lib/config/index.ts
@@ -0,0 +1,136 @@
+import { env } from '../env';
+import { loadYaml, validateUuid } from './parser';
+import { resolveTheme, VALID_LAYOUTS } from './theme';
+import { slugify, AppConfig, SubpageConfig, GalleryYaml, SettingsYaml, GridConfig } from './schema';
+
+export * from './schema';
+export * from './theme';
+
+let _config: AppConfig | null = null;
+export function getConfig(): AppConfig {
+ const isDev = process.env.NODE_ENV !== 'production';
+ if (_config && !isDev) return _config;
+
+ const apiUrl = env.IMMICH_API_URL;
+ const apiKey = env.IMMICH_API_KEY;
+ const authSecret = env.AUTH_SECRET || apiKey;
+
+ if (!env.AUTH_SECRET && process.env.NODE_ENV === 'production') {
+ console.warn('\n⚠️ SECURITY WARNING: AUTH_SECRET is not set. Falling back to IMMICH_API_KEY.\n Please set a long random string as AUTH_SECRET in your .env for better security.\n');
+ }
+
+ const gallery = loadYaml('gallery.yaml');
+ const settings = loadYaml('settings.yaml');
+ const theme = resolveTheme(settings.theme);
+
+ const albumOverrides: Record = {};
+
+ function processAlbumEntry(entry: string | Record, context: string): string {
+ if (typeof entry === 'string') {
+ return validateUuid(entry, context);
+ }
+ const [uuid, name] = Object.entries(entry)[0];
+ const validatedUuid = validateUuid(uuid, context);
+ albumOverrides[validatedUuid] = name;
+ return validatedUuid;
+ }
+
+ const standaloneAlbumIds = (gallery.albums ?? []).map((entry) => processAlbumEntry(entry, 'gallery.yaml albums'));
+ if (standaloneAlbumIds.length === 0 && (!gallery.subpages || (Array.isArray(gallery.subpages) ? gallery.subpages.length === 0 : Object.keys(gallery.subpages).length === 0))) {
+ throw new Error('gallery.yaml must define at least one album or subpage');
+ }
+
+ let subpages: SubpageConfig[] = [];
+
+ if (Array.isArray(gallery.subpages)) {
+ subpages = 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((entry) => processAlbumEntry(entry, `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: (VALID_LAYOUTS.includes(sp.grid.layout) ? sp.grid.layout : 'masonry') as GridConfig['layout'] } : {}),
+ },
+ } : {}),
+ };
+ });
+ } else if (gallery.subpages) {
+ subpages = Object.entries(gallery.subpages).map(([name, value]) => {
+ if (Array.isArray(value)) {
+ return { name, slug: slugify(name), albumIds: value.map((entry) => processAlbumEntry(entry, `subpage "${name}"`)) };
+ }
+ interface SubpageObjectValue { albums?: Array>; password?: string; grid?: { columns?: number; gap?: number; aspectRatio?: string; layout?: string; }; }
+ const sp = value as SubpageObjectValue;
+ const albumEntries = sp.albums || [];
+ return {
+ name,
+ slug: slugify(name),
+ albumIds: albumEntries.map((entry) => processAlbumEntry(entry, `subpage "${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: (VALID_LAYOUTS.includes(sp.grid.layout) ? sp.grid.layout : 'masonry') as GridConfig['layout'] } : {}),
+ },
+ } : {}),
+ };
+ });
+ }
+
+ const subpageAlbumIds = new Set(subpages.flatMap((sp) => sp.albumIds));
+ const allAlbumIds = [...new Set([...standaloneAlbumIds, ...subpageAlbumIds])];
+
+ _config = {
+ immich: { apiUrl: `${apiUrl}/api`, apiKey },
+ authSecret,
+ albums: allAlbumIds,
+ standaloneAlbums: standaloneAlbumIds.filter((id) => !subpageAlbumIds.has(id)),
+ subpages,
+ siteTitle: settings.title ?? env.SITE_TITLE,
+ siteSubtitle: settings.subtitle ?? env.SITE_SUBTITLE,
+ seo: {
+ title: settings.seo?.title || settings.title || env.SITE_TITLE || 'Gallery',
+ description: settings.seo?.description || settings.subtitle || env.SITE_SUBTITLE || 'A curated photography portfolio',
+ noIndex: settings.seo?.noIndex === true,
+ noFollow: settings.seo?.noFollow === true,
+ },
+ heroImages: gallery.hero ? (Array.isArray(gallery.hero) ? gallery.hero : [gallery.hero]).map((id) => validateUuid(id, 'gallery.yaml hero')) : [],
+ exifOnHover: settings.exifOnHover !== false,
+ grid: {
+ 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: settings.footer ? { name: settings.footer.name, instagram: settings.footer.instagram, email: settings.footer.email, website: settings.footer.website } : null,
+ 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,
+ };
+ return _config;
+}
diff --git a/lib/config/parser.ts b/lib/config/parser.ts
new file mode 100644
index 0000000..8043103
--- /dev/null
+++ b/lib/config/parser.ts
@@ -0,0 +1,30 @@
+import fs from 'fs';
+import path from 'path';
+import yaml from 'js-yaml';
+
+export function loadYaml(filename: string): T {
+ const yamlPath = path.join(process.cwd(), 'content', filename);
+
+ if (!fs.existsSync(yamlPath)) {
+ 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');
+ return (yaml.load(raw) || {}) as T;
+}
+
+const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+
+export 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;
+}
diff --git a/lib/config/schema.ts b/lib/config/schema.ts
new file mode 100644
index 0000000..50f9e42
--- /dev/null
+++ b/lib/config/schema.ts
@@ -0,0 +1,133 @@
+export interface SubpageConfig {
+ name: string;
+ slug: string;
+ albumIds: string[];
+ password?: string;
+ grid?: Partial;
+}
+
+export interface FooterConfig {
+ name?: string;
+ instagram?: string;
+ email?: string;
+ website?: string;
+}
+
+export interface LegalConfig {
+ enabled: boolean;
+ name: string;
+ address: string;
+ zipCity: string;
+ country: string;
+ email?: string;
+ phone?: string;
+ taxId?: string;
+ vatId?: string;
+ extraInfo?: string;
+}
+
+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' | 'stacked' | 'typographic' | 'mosaic';
+}
+
+export interface GridConfig {
+ columns: number;
+ gap: number;
+ aspectRatio: string;
+ layout: 'masonry' | 'uniform' | 'showcase' | 'filmstrip' | 'editorial-flow';
+}
+
+export interface AppConfig {
+ immich: { apiUrl: string; apiKey: string };
+ authSecret: string;
+ albums: string[];
+ standaloneAlbums: string[];
+ subpages: SubpageConfig[];
+ siteTitle: string;
+ siteSubtitle: string;
+ seo: {
+ title: string;
+ description: string;
+ noIndex: boolean;
+ noFollow: boolean;
+ };
+ heroImages: string[];
+ exifOnHover: boolean;
+ grid: GridConfig;
+ theme: ThemeConfig;
+ footer: FooterConfig | null;
+ legal: LegalConfig;
+ map: boolean;
+ transitions: boolean;
+ albumOverrides: Record;
+ cacheTtl: number;
+ rateLimitRpm: number;
+}
+
+export 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;
+ };
+ }>;
+}
+
+export interface SettingsYaml {
+ title?: string;
+ subtitle?: string;
+ seo?: {
+ title?: string;
+ description?: string;
+ noIndex?: boolean;
+ noFollow?: boolean;
+ };
+ exifOnHover?: boolean;
+ map?: boolean;
+ transitions?: 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;
+ aspectRatio?: string;
+ layout?: string;
+ };
+ footer?: FooterConfig;
+ legal?: Partial;
+}
+
+export function slugify(name: string): string {
+ return name
+ .toLowerCase()
+ .normalize('NFD')
+ .replace(/[\u0300-\u036f]/g, '')
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '');
+}
diff --git a/lib/config/theme.ts b/lib/config/theme.ts
new file mode 100644
index 0000000..411beff
--- /dev/null
+++ b/lib/config/theme.ts
@@ -0,0 +1,122 @@
+import type { ThemeConfig, SettingsYaml } from './schema';
+
+export const THEME_PRESETS: Record = {
+ 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: '#000000',
+ fonts: { heading: 'Geist', body: 'Geist', caption: 'IBM Plex Mono' },
+ radius: 0,
+ photoFrame: 'none',
+ grain: false,
+ headerDot: false,
+ heroStyle: 'fullbleed',
+ },
+ editorial: {
+ preset: 'editorial',
+ accent: '#8B2500',
+ fonts: { heading: 'Bodoni Moda', body: 'Newsreader', caption: 'Spectral' },
+ radius: 0,
+ photoFrame: 'shadow',
+ grain: false,
+ headerDot: false,
+ heroStyle: 'split',
+ },
+ classic: {
+ preset: 'classic',
+ accent: '#c49a3c',
+ fonts: { heading: 'Cinzel', body: 'Crimson Pro', caption: 'Crimson Pro' },
+ radius: 12,
+ photoFrame: 'passepartout',
+ grain: false,
+ headerDot: true,
+ heroStyle: 'minimal',
+ },
+ noir: {
+ preset: 'noir',
+ accent: '#ff6b35',
+ fonts: { heading: 'Libre Baskerville', body: 'Source Sans 3', caption: 'Space Mono' },
+ radius: 0,
+ photoFrame: 'passepartout',
+ grain: true,
+ headerDot: false,
+ heroStyle: 'fullbleed',
+ },
+ monograph: {
+ preset: 'monograph',
+ accent: '#333333',
+ fonts: { heading: 'Instrument Serif', body: 'Inter', caption: 'IBM Plex Mono' },
+ radius: 0,
+ photoFrame: 'none',
+ grain: false,
+ headerDot: false,
+ heroStyle: 'typographic',
+ },
+ botanica: {
+ preset: 'botanica',
+ accent: '#4a7c59',
+ fonts: { heading: 'Cormorant Garamond', body: 'Nunito Sans', caption: 'Inconsolata' },
+ radius: 8,
+ photoFrame: 'none',
+ grain: false,
+ headerDot: true,
+ heroStyle: 'split',
+ },
+};
+
+export const VALID_PHOTO_FRAMES = ['none', 'passepartout', 'shadow'];
+export const VALID_HERO_STYLES = ['split', 'fullbleed', 'minimal', 'stacked', 'typographic', 'mosaic'];
+export const VALID_LAYOUTS = ['masonry', 'uniform', 'showcase', 'filmstrip', 'editorial-flow'];
+
+export function resolveTheme(raw?: SettingsYaml['theme']): ThemeConfig {
+ if (!raw) return { ...THEME_PRESETS.studio };
+
+ 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 };
+ }
+
+ 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,
+ };
+}
+
+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`;
+}
diff --git a/lib/immich.ts b/lib/immich.ts
index 5534f44..33aacce 100644
--- a/lib/immich.ts
+++ b/lib/immich.ts
@@ -55,16 +55,6 @@ export interface ImmichExifInfo {
description: string | null;
}
-/** A clustered map marker — one per unique city/country. */
-export interface MapLocation {
- city: string;
- country: string;
- lat: number;
- lng: number;
- photoCount: number;
- coverAssetId: string;
- albums: { name: string; slug: string; subpageSlug?: string }[];
-}
export type ImageSize = 'thumbnail' | 'preview' | 'original';
@@ -334,75 +324,6 @@ class ImmichClient {
return asset;
}
- /**
- * Aggregate all geotagged photos into location-level markers.
- * Returns one entry per unique city+country with averaged lat/lng.
- */
- async getMapData(): Promise {
- const cacheKey = 'map-data';
- const cached = cache.get(cacheKey);
- if (cached) return cached;
-
- const albums = await this.getAlbums();
-
- // Build a lookup: album ID → { name, slug, subpageSlug? }
- const albumMeta = new Map();
- for (const a of albums) {
- // Check if this album belongs to a subpage
- const sp = this.config.subpages.find((s) => s.albumIds.includes(a.id));
- albumMeta.set(a.id, { name: a.albumName, slug: a.slug, subpageSlug: sp?.slug });
- }
-
- // Fetch full album data (with assets) for each
- const fullAlbums = await Promise.all(albums.map((a) => this.getAlbum(a.id)));
-
- // Bucket assets by city+country
- const buckets = new Map<
- string,
- { lats: number[]; lngs: number[]; count: number; coverAssetId: string; albumIds: Set }
- >();
-
- for (const album of fullAlbums) {
- if (!album) continue;
- for (const asset of album.assets) {
- const exif = asset.exifInfo;
- if (!exif?.latitude || !exif?.longitude || !exif?.city || !exif?.country) continue;
-
- const key = `${exif.city}|${exif.country}`;
- let bucket = buckets.get(key);
- if (!bucket) {
- bucket = { lats: [], lngs: [], count: 0, coverAssetId: asset.id, albumIds: new Set() };
- buckets.set(key, bucket);
- }
- bucket.lats.push(exif.latitude);
- bucket.lngs.push(exif.longitude);
- bucket.count++;
- bucket.albumIds.add(album.id);
- }
- }
-
- // Convert buckets to MapLocation[]
- const locations: MapLocation[] = [];
- for (const [key, bucket] of buckets) {
- const [city, country] = key.split('|');
- const lat = bucket.lats.reduce((a, b) => a + b, 0) / bucket.lats.length;
- const lng = bucket.lngs.reduce((a, b) => a + b, 0) / bucket.lngs.length;
- locations.push({
- city,
- country,
- lat,
- lng,
- photoCount: bucket.count,
- coverAssetId: bucket.coverAssetId,
- albums: [...bucket.albumIds]
- .map((id) => albumMeta.get(id))
- .filter((m): m is NonNullable => !!m),
- });
- }
-
- cache.set(cacheKey, locations, this.config.cacheTtl);
- return locations;
- }
/**
* Check if the Immich server is reachable.
diff --git a/lib/mapService.ts b/lib/mapService.ts
new file mode 100644
index 0000000..aaa4458
--- /dev/null
+++ b/lib/mapService.ts
@@ -0,0 +1,85 @@
+import { immich } from './immich';
+import { getConfig } from './config';
+import { cache } from './cache';
+
+/** A clustered map marker — one per unique city/country. */
+export interface MapLocation {
+ city: string;
+ country: string;
+ lat: number;
+ lng: number;
+ photoCount: number;
+ coverAssetId: string;
+ albums: { name: string; slug: string; subpageSlug?: string }[];
+}
+
+/**
+ * Aggregate all geotagged photos into location-level markers.
+ * Returns one entry per unique city+country with averaged lat/lng.
+ */
+export async function getMapData(): Promise {
+ const config = getConfig();
+ const cacheKey = 'map-data';
+ const cached = cache.get(cacheKey);
+ if (cached) return cached;
+
+ const albums = await immich.getAlbums();
+
+ // Build a lookup: album ID → { name, slug, subpageSlug? }
+ const albumMeta = new Map();
+ for (const a of albums) {
+ // Check if this album belongs to a subpage
+ const sp = config.subpages.find((s) => s.albumIds.includes(a.id));
+ albumMeta.set(a.id, { name: a.albumName, slug: a.slug, subpageSlug: sp?.slug });
+ }
+
+ // Fetch full album data (with assets) for each
+ const fullAlbums = await Promise.all(albums.map((a) => immich.getAlbum(a.id)));
+
+ // Bucket assets by city+country
+ const buckets = new Map<
+ string,
+ { lats: number[]; lngs: number[]; count: number; coverAssetId: string; albumIds: Set }
+ >();
+
+ for (const album of fullAlbums) {
+ if (!album) continue;
+ for (const asset of album.assets) {
+ const exif = asset.exifInfo;
+ if (!exif?.latitude || !exif?.longitude || !exif?.city || !exif?.country) continue;
+
+ const key = `${exif.city}|${exif.country}`;
+ let bucket = buckets.get(key);
+ if (!bucket) {
+ bucket = { lats: [], lngs: [], count: 0, coverAssetId: asset.id, albumIds: new Set() };
+ buckets.set(key, bucket);
+ }
+ bucket.lats.push(exif.latitude);
+ bucket.lngs.push(exif.longitude);
+ bucket.count++;
+ bucket.albumIds.add(album.id);
+ }
+ }
+
+ // Convert buckets to MapLocation[]
+ const locations: MapLocation[] = [];
+ for (const [key, bucket] of buckets) {
+ const [city, country] = key.split('|');
+ const lat = bucket.lats.reduce((a, b) => a + b, 0) / bucket.lats.length;
+ const lng = bucket.lngs.reduce((a, b) => a + b, 0) / bucket.lngs.length;
+ locations.push({
+ city,
+ country,
+ lat,
+ lng,
+ photoCount: bucket.count,
+ coverAssetId: bucket.coverAssetId,
+ albums: [...bucket.albumIds]
+ .map((id) => albumMeta.get(id))
+ .filter((m): m is NonNullable => !!m),
+ });
+ }
+
+ cache.set(cacheKey, locations, config.cacheTtl);
+ return locations;
+}