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
This commit is contained in:
ralksta
2026-03-08 18:55:45 +01:00
parent 89dfef6144
commit 23d8bf0425
14 changed files with 721 additions and 204 deletions

View File

@@ -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 (
<>
<div className="album-header">
<BackLink href={backLinkHref} label={backLinkLabel} />
<h1 className="album-header__title">{album.albumName}</h1>
<p className="album-header__meta">
{images.length} {images.length === 1 ? 'photo' : 'photos'}
{album.description && ` · ${album.description}`}
</p>
</div>
<PhotoGrid assets={images} layout={layout} gridStyle={gridStyle} />
</>
);
}

View File

@@ -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 (
<div className="subpage-grid">
{albums.map((album, i) => {
const ph = coverPlaceholders[i];
return (
<Link
key={album.id}
href={`/${slug}/${album.slug}`}
className="subpage-grid__item"
style={ph?.dominantColor ? { backgroundColor: ph.dominantColor } : undefined}
>
{album.albumThumbnailAssetId ? (
<Image
src={imageUrl(album.albumThumbnailAssetId, 'preview')}
alt={album.albumName}
fill
sizes="(max-width: 600px) 100vw, (max-width: 1000px) 50vw, 33vw"
loading="lazy"
{...(ph ? { placeholder: 'blur' as const, blurDataURL: ph.blurDataURL } : {})}
/>
) : (
<div className="skeleton" style={{ width: '100%', height: '100%' }} />
)}
<span className="subpage-grid__item-badge">
{album.assetCount} {album.assetCount === 1 ? 'photo' : 'photos'}
</span>
<div className="subpage-grid__item-overlay">
<span className="subpage-grid__item-title">{album.albumName}</span>
<span className="subpage-grid__item-count">
{album.assetCount} {album.assetCount === 1 ? 'photo' : 'photos'}
</span>
</div>
</Link>
);
})}
</div>
);
}

View File

@@ -28,6 +28,8 @@ import { getConfig, type GridConfig } from '@/lib/config';
import { isProtected, isAuthenticated } from '@/lib/auth'; import { isProtected, isAuthenticated } from '@/lib/auth';
import PasswordGate from '@/components/PasswordGate'; import PasswordGate from '@/components/PasswordGate';
import { BackLink } from '@/components/BackLink'; import { BackLink } from '@/components/BackLink';
import { AlbumDetailView } from './AlbumDetailView';
import { SubpageGridView } from './SubpageGridView';
// Render at request time — requires live Immich connection // Render at request time — requires live Immich connection
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -153,21 +155,14 @@ export default async function PathPage({ params }: PathPageProps) {
const images = toPhotoItems(album.assets, config.exifOnHover); const images = toPhotoItems(album.assets, config.exifOnHover);
return ( return (
<> <AlbumDetailView
<div className="album-header"> album={album}
<BackLink href={`/${subpageSlug}`} label={`Back to ${subpageName}`} /> images={images}
<h1 className="album-header__title">{album.albumName}</h1> layout={resolveLayout(spGrid)}
<p className="album-header__meta"> gridStyle={buildGridStyle(spGrid)}
{images.length} {images.length === 1 ? 'photo' : 'photos'} backLinkHref={`/${subpageSlug}`}
{album.description && ` · ${album.description}`} backLinkLabel={`Back to ${subpageName}`}
</p> />
</div>
<PhotoGrid
assets={images}
layout={resolveLayout(spGrid)}
gridStyle={buildGridStyle(spGrid)}
/>
</>
); );
} }
@@ -202,21 +197,14 @@ export default async function PathPage({ params }: PathPageProps) {
const images = toPhotoItems(album.assets, config.exifOnHover); const images = toPhotoItems(album.assets, config.exifOnHover);
return ( return (
<> <AlbumDetailView
<div className="album-header"> album={album}
<BackLink href="/" label="Back to Gallery" /> images={images}
<h1 className="album-header__title">{album.albumName}</h1> layout={resolveLayout(result.subpage.grid)}
<p className="album-header__meta"> gridStyle={buildGridStyle(result.subpage.grid)}
{images.length} {images.length === 1 ? 'photo' : 'photos'} backLinkHref="/"
{album.description && ` · ${album.description}`} backLinkLabel="Back to Gallery"
</p> />
</div>
<PhotoGrid
assets={images}
layout={resolveLayout(result.subpage.grid)}
gridStyle={buildGridStyle(result.subpage.grid)}
/>
</>
); );
} }
@@ -232,41 +220,11 @@ export default async function PathPage({ params }: PathPageProps) {
); );
return ( return (
<div className="subpage-grid"> <SubpageGridView
{albums.map((album, i) => { slug={slug}
const ph = coverPlaceholders[i]; albums={albums}
return ( coverPlaceholders={coverPlaceholders}
<Link />
key={album.id}
href={`/${slug}/${album.slug}`}
className="subpage-grid__item"
style={ph?.dominantColor ? { backgroundColor: ph.dominantColor } : undefined}
>
{album.albumThumbnailAssetId ? (
<Image
src={imageUrl(album.albumThumbnailAssetId, 'preview')}
alt={album.albumName}
fill
sizes="(max-width: 600px) 100vw, (max-width: 1000px) 50vw, 33vw"
loading="lazy"
{...(ph ? { placeholder: 'blur' as const, blurDataURL: ph.blurDataURL } : {})}
/>
) : (
<div className="skeleton" style={{ width: '100%', height: '100%' }} />
)}
<span className="subpage-grid__item-badge">
{album.assetCount} {album.assetCount === 1 ? 'photo' : 'photos'}
</span>
<div className="subpage-grid__item-overlay">
<span className="subpage-grid__item-title">{album.albumName}</span>
<span className="subpage-grid__item-count">
{album.assetCount} {album.assetCount === 1 ? 'photo' : 'photos'}
</span>
</div>
</Link>
);
})}
</div>
); );
} }
@@ -279,16 +237,13 @@ export default async function PathPage({ params }: PathPageProps) {
const images = toPhotoItems(album.assets, config.exifOnHover); const images = toPhotoItems(album.assets, config.exifOnHover);
return ( return (
<> <AlbumDetailView
<div className="album-header"> album={album}
<BackLink href="/" label="Back to Gallery" /> images={images}
<h1 className="album-header__title">{album.albumName}</h1> layout={resolveLayout()}
<p className="album-header__meta"> gridStyle={buildGridStyle()}
{images.length} {images.length === 1 ? 'photo' : 'photos'} backLinkHref="/"
{album.description && ` · ${album.description}`} backLinkLabel="Back to Gallery"
</p> />
</div>
<PhotoGrid assets={images} layout={resolveLayout()} gridStyle={buildGridStyle()} />
</>
); );
} }

View File

@@ -6,10 +6,10 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers'; import { cookies } from 'next/headers';
import { immich } from '@/lib/immich';
import { getConfig } from '@/lib/config'; import { getConfig } from '@/lib/config';
import { imageUrl } from '@/lib/urls'; import { imageUrl } from '@/lib/urls';
import { isAuthenticated } from '@/lib/auth'; import { isAuthenticated } from '@/lib/auth';
import { getMapData } from '@/lib/mapService';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -23,7 +23,7 @@ export async function GET(_request: NextRequest) {
const cookieStore = await cookies(); const cookieStore = await cookies();
const getCookie = (name: string) => cookieStore.get(name)?.value; const getCookie = (name: string) => cookieStore.get(name)?.value;
const locations = await immich.getMapData(); const locations = await getMapData();
// Filter locations and albums based on auth // Filter locations and albums based on auth
const publicLocations = locations const publicLocations = locations

View File

@@ -14,20 +14,10 @@
import { useState, useEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import type { PhotoItem } from '@/app/[...path]/PhotoGrid'; import type { PhotoItem } from '@/app/[...path]/PhotoGrid';
import { useExif } from '@/hooks/useExif';
import { useSwipe } from '@/hooks/useSwipe';
import styles from './Lightbox.module.css'; 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 { interface LightboxProps {
assets: PhotoItem[]; assets: PhotoItem[];
currentIndex: number; currentIndex: number;
@@ -38,12 +28,9 @@ interface LightboxProps {
export function Lightbox({ assets, currentIndex, onClose, onNext, onPrev }: LightboxProps) { export function Lightbox({ assets, currentIndex, onClose, onNext, onPrev }: LightboxProps) {
const [showExif, setShowExif] = useState(false); const [showExif, setShowExif] = useState(false);
const [exifData, setExifData] = useState<ExifData | null>(null); const { exifData, exifLoading, fetchExif, clearExif } = useExif();
const [exifLoading, setExifLoading] = useState(false);
const [imageLoaded, setImageLoaded] = useState(false); const [imageLoaded, setImageLoaded] = useState(false);
const overlayRef = useRef<HTMLDivElement>(null); const overlayRef = useRef<HTMLDivElement>(null);
const touchStartX = useRef<number>(0);
const touchStartY = useRef<number>(0);
const current = assets[currentIndex]; const current = assets[currentIndex];
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
@@ -53,20 +40,9 @@ export function Lightbox({ assets, currentIndex, onClose, onNext, onPrev }: Ligh
setMounted(true); 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 // Reset EXIF data when switching images; refetch if panel is open
useEffect(() => { useEffect(() => {
setExifData(null); clearExif();
setImageLoaded(false); setImageLoaded(false);
if (showExif && current) { if (showExif && current) {
fetchExif(current.exifUrl); fetchExif(current.exifUrl);
@@ -95,25 +71,10 @@ export function Lightbox({ assets, currentIndex, onClose, onNext, onPrev }: Ligh
preload(currentIndex - 1); preload(currentIndex - 1);
}, [currentIndex, assets]); }, [currentIndex, assets]);
// Touch/swipe support const { handleTouchStart, handleTouchEnd } = useSwipe({
const handleTouchStart = useCallback((e: React.TouchEvent) => { onSwipeLeft: onNext,
touchStartX.current = e.touches[0].clientX; onSwipeRight: onPrev,
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],
);
// Click on overlay background → close // Click on overlay background → close
const handleOverlayClick = useCallback( const handleOverlayClick = useCallback(

34
hooks/useExif.ts Normal file
View File

@@ -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<ExifData | null>(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 };
}

32
hooks/useSwipe.ts Normal file
View File

@@ -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<number>(0);
const touchStartY = useRef<number>(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 };
}

View File

@@ -68,6 +68,8 @@ describe('assetExifSummary', () => {
iso: null, iso: null,
exifImageWidth: null, exifImageWidth: null,
exifImageHeight: null, exifImageHeight: null,
latitude: null,
longitude: null,
city: null, city: null,
state: null, state: null,
country: null, country: null,
@@ -99,6 +101,8 @@ describe('assetExifSummary', () => {
iso: null, iso: null,
exifImageWidth: null, exifImageWidth: null,
exifImageHeight: null, exifImageHeight: null,
latitude: null,
longitude: null,
city: null, city: null,
state: null, state: null,
country: null, country: null,
@@ -123,6 +127,8 @@ describe('assetAspectRatio', () => {
iso: null, iso: null,
exifImageWidth: 3000, exifImageWidth: 3000,
exifImageHeight: 2000, exifImageHeight: 2000,
latitude: null,
longitude: null,
city: null, city: null,
state: null, state: null,
country: null, country: null,
@@ -150,6 +156,8 @@ describe('assetAspectRatio', () => {
iso: null, iso: null,
exifImageWidth: 100, exifImageWidth: 100,
exifImageHeight: 0, exifImageHeight: 0,
latitude: null,
longitude: null,
city: null, city: null,
state: null, state: null,
country: null, country: null,

136
lib/config/index.ts Normal file
View File

@@ -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<GalleryYaml>('gallery.yaml');
const settings = loadYaml<SettingsYaml>('settings.yaml');
const theme = resolveTheme(settings.theme);
const albumOverrides: Record<string, string> = {};
function processAlbumEntry(entry: string | Record<string, string>, 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<string | Record<string, string>>; 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;
}

30
lib/config/parser.ts Normal file
View File

@@ -0,0 +1,30 @@
import fs from 'fs';
import path from 'path';
import yaml from 'js-yaml';
export function loadYaml<T>(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;
}

133
lib/config/schema.ts Normal file
View File

@@ -0,0 +1,133 @@
export interface SubpageConfig {
name: string;
slug: string;
albumIds: string[];
password?: string;
grid?: Partial<GridConfig>;
}
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<string, string>;
cacheTtl: number;
rateLimitRpm: number;
}
export interface GalleryYaml {
hero?: string | 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;
};
}>;
}
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<LegalConfig>;
}
export function slugify(name: string): string {
return name
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}

122
lib/config/theme.ts Normal file
View File

@@ -0,0 +1,122 @@
import type { ThemeConfig, SettingsYaml } from './schema';
export const THEME_PRESETS: Record<string, ThemeConfig> = {
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`;
}

View File

@@ -55,16 +55,6 @@ export interface ImmichExifInfo {
description: string | null; 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'; export type ImageSize = 'thumbnail' | 'preview' | 'original';
@@ -334,75 +324,6 @@ class ImmichClient {
return asset; return asset;
} }
/**
* Aggregate all geotagged photos into location-level markers.
* Returns one entry per unique city+country with averaged lat/lng.
*/
async getMapData(): Promise<MapLocation[]> {
const cacheKey = 'map-data';
const cached = cache.get<MapLocation[]>(cacheKey);
if (cached) return cached;
const albums = await this.getAlbums();
// Build a lookup: album ID → { name, slug, subpageSlug? }
const albumMeta = new Map<string, { name: string; slug: string; subpageSlug?: string }>();
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<string> }
>();
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<typeof m> => !!m),
});
}
cache.set(cacheKey, locations, this.config.cacheTtl);
return locations;
}
/** /**
* Check if the Immich server is reachable. * Check if the Immich server is reachable.

85
lib/mapService.ts Normal file
View File

@@ -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<MapLocation[]> {
const config = getConfig();
const cacheKey = 'map-data';
const cached = cache.get<MapLocation[]>(cacheKey);
if (cached) return cached;
const albums = await immich.getAlbums();
// Build a lookup: album ID → { name, slug, subpageSlug? }
const albumMeta = new Map<string, { name: string; slug: string; subpageSlug?: string }>();
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<string> }
>();
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<typeof m> => !!m),
});
}
cache.set(cacheKey, locations, config.cacheTtl);
return locations;
}