From 23d8bf042574b960fa015823fec4b5dbf14a3451 Mon Sep 17 00:00:00 2001 From: ralksta Date: Sun, 8 Mar 2026 18:55:45 +0100 Subject: [PATCH] refactor: apply Clean Architecture principles - Extract EXIF and Swipe logic into custom hooks for Lightbox - Extract Map layout data formatting into mapService - Extract AlbumDetailView and SubpageGridView components from page router - Restructure monolithic config.ts into clean config/ directory --- app/[...path]/AlbumDetailView.tsx | 38 +++++++++ app/[...path]/SubpageGridView.tsx | 62 ++++++++++++++ app/[...path]/page.tsx | 107 +++++++---------------- app/api/map/route.ts | 4 +- components/Lightbox.tsx | 55 ++---------- hooks/useExif.ts | 34 ++++++++ hooks/useSwipe.ts | 32 +++++++ lib/__tests__/urls.test.ts | 8 ++ lib/config/index.ts | 136 ++++++++++++++++++++++++++++++ lib/config/parser.ts | 30 +++++++ lib/config/schema.ts | 133 +++++++++++++++++++++++++++++ lib/config/theme.ts | 122 +++++++++++++++++++++++++++ lib/immich.ts | 79 ----------------- lib/mapService.ts | 85 +++++++++++++++++++ 14 files changed, 721 insertions(+), 204 deletions(-) create mode 100644 app/[...path]/AlbumDetailView.tsx create mode 100644 app/[...path]/SubpageGridView.tsx create mode 100644 hooks/useExif.ts create mode 100644 hooks/useSwipe.ts create mode 100644 lib/config/index.ts create mode 100644 lib/config/parser.ts create mode 100644 lib/config/schema.ts create mode 100644 lib/config/theme.ts create mode 100644 lib/mapService.ts 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.albumName} + ) : ( +
+ )} + + {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.albumName} - ) : ( -
- )} - - {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; +}