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:
@@ -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,
|
||||
|
||||
136
lib/config/index.ts
Normal file
136
lib/config/index.ts
Normal 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
30
lib/config/parser.ts
Normal 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
133
lib/config/schema.ts
Normal 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
122
lib/config/theme.ts
Normal 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`;
|
||||
}
|
||||
@@ -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<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.
|
||||
|
||||
85
lib/mapService.ts
Normal file
85
lib/mapService.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user