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:
38
app/[...path]/AlbumDetailView.tsx
Normal file
38
app/[...path]/AlbumDetailView.tsx
Normal 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} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
app/[...path]/SubpageGridView.tsx
Normal file
62
app/[...path]/SubpageGridView.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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()} />
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
34
hooks/useExif.ts
Normal 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
32
hooks/useSwipe.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -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
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;
|
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
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