diff --git a/app/api/map/route.ts b/app/api/map/route.ts
new file mode 100644
index 0000000..477edbb
--- /dev/null
+++ b/app/api/map/route.ts
@@ -0,0 +1,42 @@
+/**
+ * Map data API — returns location-level markers for the map view.
+ * Each marker represents a unique city/country with averaged GPS coords.
+ * Cover asset IDs are encrypted for security.
+ */
+
+import { NextResponse } from 'next/server';
+import { immich } from '@/lib/immich';
+import { getConfig } from '@/lib/config';
+import { imageUrl } from '@/lib/urls';
+
+export const dynamic = 'force-dynamic';
+
+export async function GET() {
+ const config = getConfig();
+
+ if (!config.map) {
+ return NextResponse.json({ error: 'Map is not enabled' }, { status: 404 });
+ }
+
+ const locations = await immich.getMapData();
+
+ // Encrypt cover asset IDs and convert to public-safe image URLs
+ const publicLocations = locations.map((loc) => ({
+ city: loc.city,
+ country: loc.country,
+ lat: loc.lat,
+ lng: loc.lng,
+ photoCount: loc.photoCount,
+ coverUrl: imageUrl(loc.coverAssetId, 'thumbnail'),
+ albums: loc.albums.map((a) => ({
+ name: a.name,
+ url: a.subpageSlug ? `/${a.subpageSlug}/${a.slug}` : `/${a.slug}`,
+ })),
+ }));
+
+ return NextResponse.json(publicLocations, {
+ headers: {
+ 'Cache-Control': `public, max-age=${Math.floor(config.cacheTtl / 1000)}, stale-while-revalidate=60`,
+ },
+ });
+}
diff --git a/app/layout.tsx b/app/layout.tsx
index de124e3..5fd7bc3 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -38,7 +38,8 @@ export const metadata: Metadata = {
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
- const { theme } = getConfig();
+ const config = getConfig();
+ const { theme } = config;
const fontsUrl = getGoogleFontsUrl(theme);
// Inject theme values as CSS custom properties
@@ -78,6 +79,11 @@ export default function RootLayout({ children }: { children: React.ReactNode })
About
+ {config.map && (
+
+ Map
+
+ )}
diff --git a/app/map/map.css b/app/map/map.css
new file mode 100644
index 0000000..5875b27
--- /dev/null
+++ b/app/map/map.css
@@ -0,0 +1,174 @@
+/* ── Map Page ──────────────────────────────────────────────── */
+
+.map-page {
+ display: flex;
+ flex-direction: column;
+ min-height: calc(100vh - var(--header-height, 64px));
+}
+
+.map-page__header {
+ padding: 2rem var(--page-padding, 2rem) 1rem;
+}
+
+.map-page__title {
+ font-family: var(--font-heading);
+ font-size: clamp(1.5rem, 4vw, 2.5rem);
+ font-weight: 600;
+ margin: 0.5rem 0 0.25rem;
+ color: var(--text-primary);
+}
+
+.map-page__subtitle {
+ font-family: var(--font-body);
+ font-size: 0.875rem;
+ color: var(--text-secondary);
+ margin: 0;
+}
+
+/* ── Map Container ─────────────────────────────────────────── */
+
+.map-container {
+ flex: 1;
+ min-height: 500px;
+ margin: 0 var(--page-padding, 2rem) 2rem;
+ border-radius: var(--radius, 0);
+ overflow: hidden;
+ border: 1px solid var(--border-color, rgba(128, 128, 128, 0.15));
+}
+
+.map-container__loading {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 500px;
+ color: var(--text-secondary);
+ font-family: var(--font-body);
+ font-size: 0.875rem;
+}
+
+/* ── Map Popups ────────────────────────────────────────────── */
+
+.leaflet-popup-content-wrapper {
+ background: var(--bg-primary, #1a1a1a) !important;
+ color: var(--text-primary, #fff) !important;
+ border-radius: var(--radius, 4px) !important;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4) !important;
+ border: 1px solid var(--border-color, rgba(128, 128, 128, 0.15)) !important;
+ padding: 0 !important;
+ overflow: hidden;
+}
+
+.leaflet-popup-tip {
+ background: var(--bg-primary, #1a1a1a) !important;
+ border: 1px solid var(--border-color, rgba(128, 128, 128, 0.15)) !important;
+ border-top: none !important;
+ border-left: none !important;
+}
+
+.leaflet-popup-content {
+ margin: 0 !important;
+ min-width: 200px;
+}
+
+.map-popup {
+ font-family: var(--font-body);
+}
+
+.map-popup__cover {
+ width: 100%;
+ height: 120px;
+ object-fit: cover;
+ display: block;
+}
+
+.map-popup__body {
+ padding: 0.75rem 1rem;
+}
+
+.map-popup__city {
+ font-family: var(--font-heading);
+ font-size: 0.9375rem;
+ font-weight: 600;
+ margin: 0 0 0.125rem;
+ color: var(--text-primary);
+}
+
+.map-popup__country {
+ font-family: var(--font-caption);
+ font-size: 0.6875rem;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--text-secondary);
+ margin: 0 0 0.5rem;
+}
+
+.map-popup__count {
+ font-family: var(--font-caption);
+ font-size: 0.75rem;
+ color: var(--text-secondary);
+ margin-bottom: 0.5rem;
+}
+
+.map-popup__albums {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+
+.map-popup__album-link {
+ font-size: 0.8125rem;
+ color: var(--accent, #e60012);
+ text-decoration: none;
+ transition: opacity 0.15s;
+}
+
+.map-popup__album-link:hover {
+ opacity: 0.7;
+}
+
+/* ── Custom Marker ─────────────────────────────────────────── */
+
+.map-marker {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+ background: var(--accent, #e60012);
+ color: #fff;
+ font-family: var(--font-caption);
+ font-size: 0.6875rem;
+ font-weight: 700;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
+ border: 2px solid rgba(255, 255, 255, 0.9);
+ cursor: pointer;
+ transition: transform 0.15s ease;
+}
+
+.map-marker:hover {
+ transform: scale(1.15);
+}
+
+/* ── Leaflet overrides ─────────────────────────────────────── */
+
+.leaflet-control-attribution {
+ background: var(--bg-primary, #1a1a1a) !important;
+ color: var(--text-secondary, #888) !important;
+ font-size: 0.625rem !important;
+ opacity: 0.7;
+}
+
+.leaflet-control-attribution a {
+ color: var(--text-secondary, #888) !important;
+}
+
+.leaflet-control-zoom a {
+ background: var(--bg-primary, #1a1a1a) !important;
+ color: var(--text-primary, #fff) !important;
+ border-color: var(--border-color, rgba(128, 128, 128, 0.15)) !important;
+}
+
+.leaflet-control-zoom a:hover {
+ background: var(--bg-secondary, #222) !important;
+}
\ No newline at end of file
diff --git a/app/map/page.tsx b/app/map/page.tsx
new file mode 100644
index 0000000..986ad07
--- /dev/null
+++ b/app/map/page.tsx
@@ -0,0 +1,46 @@
+/**
+ * Map page — server component shell for the interactive map.
+ * Loads Leaflet CSS, renders the MapView client component.
+ */
+
+import { getConfig } from '@/lib/config';
+import { notFound } from 'next/navigation';
+import { MapView } from '@/components/MapView';
+import { BackLink } from '@/components/BackLink';
+import type { Metadata } from 'next';
+import './map.css';
+
+export const dynamic = 'force-dynamic';
+
+export const metadata: Metadata = {
+ title: 'Map',
+};
+
+export default function MapPage() {
+ const config = getConfig();
+
+ if (!config.map) {
+ notFound();
+ }
+
+ return (
+ <>
+ {/* Leaflet CSS from CDN */}
+ {/* eslint-disable-next-line @next/next/no-page-custom-font */}
+
+
+
+
+
Map
+
Where the photos were taken
+
+
+
+ >
+ );
+}
diff --git a/components/MapView.tsx b/components/MapView.tsx
new file mode 100644
index 0000000..b17705c
--- /dev/null
+++ b/components/MapView.tsx
@@ -0,0 +1,150 @@
+'use client';
+
+/**
+ * MapView — client component that initializes Leaflet, fetches /api/map,
+ * and renders location-level markers with themed popups.
+ */
+
+import { useEffect, useRef, useState } from 'react';
+
+interface MapLocationPublic {
+ city: string;
+ country: string;
+ lat: number;
+ lng: number;
+ photoCount: number;
+ coverUrl: string;
+ albums: { name: string; url: string }[];
+}
+
+export function MapView() {
+ const containerRef = useRef(null);
+ const mapRef = useRef(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ let cancelled = false;
+
+ async function init() {
+ // Dynamically import Leaflet (client-only)
+ const L = (await import('leaflet')).default;
+
+ if (cancelled || !containerRef.current || mapRef.current) return;
+
+ // Fetch map data
+ const res = await fetch('/api/map');
+ if (!res.ok) {
+ setError('Failed to load map data');
+ setLoading(false);
+ return;
+ }
+ const locations: MapLocationPublic[] = await res.json();
+
+ if (cancelled) return;
+
+ // Initialize map
+ const map = L.map(containerRef.current, {
+ zoomControl: true,
+ attributionControl: true,
+ });
+ mapRef.current = map;
+
+ // Dark-themed tiles (CartoDB Dark Matter)
+ L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
+ attribution: '© OSM © CARTO',
+ subdomains: 'abcd',
+ maxZoom: 19,
+ }).addTo(map);
+
+ if (locations.length === 0) {
+ // No geotagged photos — show world view
+ map.setView([20, 0], 2);
+ setLoading(false);
+ return;
+ }
+
+ // Create markers with custom HTML icons
+ const markers: L.Marker[] = [];
+
+ for (const loc of locations) {
+ const icon = L.divIcon({
+ className: '',
+ html: `${loc.photoCount}
`,
+ iconSize: [32, 32],
+ iconAnchor: [16, 16],
+ popupAnchor: [0, -20],
+ });
+
+ const albumLinks = loc.albums
+ .map((a) => ``)
+ .join('');
+
+ const popupHtml = `
+
+ `;
+
+ const marker = L.marker([loc.lat, loc.lng], { icon })
+ .addTo(map)
+ .bindPopup(popupHtml, { maxWidth: 260, minWidth: 200 });
+
+ markers.push(marker);
+ }
+
+ // Fit bounds to show all markers
+ if (markers.length > 0) {
+ const group = L.featureGroup(markers);
+ map.fitBounds(group.getBounds().pad(0.15));
+ }
+
+ setLoading(false);
+ }
+
+ init().catch((err) => {
+ console.error('[Map] Init error:', err);
+ if (!cancelled) {
+ setError('Failed to initialize map');
+ setLoading(false);
+ }
+ });
+
+ return () => {
+ cancelled = true;
+ if (mapRef.current) {
+ (mapRef.current as L.Map).remove();
+ mapRef.current = null;
+ }
+ };
+ }, []);
+
+ if (error) {
+ return (
+
+ );
+ }
+
+ return (
+ <>
+ {loading && (
+
+ )}
+
+ >
+ );
+}
diff --git a/content/gallery.yaml b/content/gallery.yaml
index 6287a84..367132a 100644
--- a/content/gallery.yaml
+++ b/content/gallery.yaml
@@ -33,3 +33,6 @@ footer:
instagram: quasiralf
# email: hello@example.com
# website: https://example.com
+
+# Enable the /map page
+map: true
diff --git a/content/gallery.yaml.example b/content/gallery.yaml.example
index 82bbc84..fc90f0e 100644
--- a/content/gallery.yaml.example
+++ b/content/gallery.yaml.example
@@ -20,6 +20,9 @@ albums:
# Show camera/lens EXIF on photo hover (default: true)
# exifOnHover: false
+# Enable the /map page — plots photos on a world map by city/country (default: false)
+# map: true
+
# Photo grid layout — customize columns, gap, aspect ratio, and mode.
# grid:
# columns: 3 # default: 3
diff --git a/lib/config.ts b/lib/config.ts
index d97b38a..f4b5546 100644
--- a/lib/config.ts
+++ b/lib/config.ts
@@ -123,6 +123,7 @@ interface GalleryYaml {
hero?: string | string[];
albums?: string[];
exifOnHover?: boolean;
+ map?: boolean;
theme?:
| string
| {
@@ -236,6 +237,8 @@ export interface AppConfig {
theme: ThemeConfig;
/** Footer social links config. */
footer: FooterConfig | null;
+ /** Show the /map page. Default: false. */
+ map: boolean;
cacheTtl: number;
rateLimitRpm: number;
}
@@ -392,6 +395,7 @@ export function getConfig(): AppConfig {
website: gallery.footer.website,
}
: null,
+ map: gallery.map === true,
cacheTtl: env.CACHE_TTL * 1000,
rateLimitRpm: env.RATE_LIMIT_RPM,
};
diff --git a/lib/immich.ts b/lib/immich.ts
index 7de14f8..a3968e5 100644
--- a/lib/immich.ts
+++ b/lib/immich.ts
@@ -46,6 +46,8 @@ export interface ImmichExifInfo {
iso: number | null;
exifImageWidth: number | null;
exifImageHeight: number | null;
+ latitude: number | null;
+ longitude: number | null;
city: string | null;
state: string | null;
country: string | null;
@@ -53,6 +55,17 @@ 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';
/** Enriched subpage with album metadata (for rendering cards). */
@@ -312,6 +325,76 @@ class ImmichClient {
return asset;
}
+ /**
+ * Aggregate all geotagged photos into location-level markers.
+ * Returns one entry per unique city+country with averaged lat/lng.
+ */
+ async getMapData(): Promise {
+ const cacheKey = 'map-data';
+ const cached = cache.get(cacheKey);
+ if (cached) return cached;
+
+ const albums = await this.getAlbums();
+
+ // Build a lookup: album ID → { name, slug, subpageSlug? }
+ const albumMeta = new Map();
+ for (const a of albums) {
+ // Check if this album belongs to a subpage
+ const sp = this.config.subpages.find((s) => s.albumIds.includes(a.id));
+ albumMeta.set(a.id, { name: a.albumName, slug: a.slug, subpageSlug: sp?.slug });
+ }
+
+ // Fetch full album data (with assets) for each
+ const fullAlbums = await Promise.all(albums.map((a) => this.getAlbum(a.id)));
+
+ // Bucket assets by city+country
+ const buckets = new Map<
+ string,
+ { lats: number[]; lngs: number[]; count: number; coverAssetId: string; albumIds: Set }
+ >();
+
+ for (const album of fullAlbums) {
+ if (!album) continue;
+ for (const asset of album.assets) {
+ const exif = asset.exifInfo;
+ if (!exif?.latitude || !exif?.longitude || !exif?.city || !exif?.country) continue;
+
+ const key = `${exif.city}|${exif.country}`;
+ let bucket = buckets.get(key);
+ if (!bucket) {
+ bucket = { lats: [], lngs: [], count: 0, coverAssetId: asset.id, albumIds: new Set() };
+ buckets.set(key, bucket);
+ }
+ bucket.lats.push(exif.latitude);
+ bucket.lngs.push(exif.longitude);
+ bucket.count++;
+ bucket.albumIds.add(album.id);
+ }
+ }
+
+ // Convert buckets to MapLocation[]
+ const locations: MapLocation[] = [];
+ for (const [key, bucket] of buckets) {
+ const [city, country] = key.split('|');
+ const lat = bucket.lats.reduce((a, b) => a + b, 0) / bucket.lats.length;
+ const lng = bucket.lngs.reduce((a, b) => a + b, 0) / bucket.lngs.length;
+ locations.push({
+ city,
+ country,
+ lat,
+ lng,
+ photoCount: bucket.count,
+ coverAssetId: bucket.coverAssetId,
+ albums: [...bucket.albumIds]
+ .map((id) => albumMeta.get(id))
+ .filter((m): m is NonNullable => !!m),
+ });
+ }
+
+ cache.set(cacheKey, locations, this.config.cacheTtl);
+ return locations;
+ }
+
/**
* Check if the Immich server is reachable.
*/
diff --git a/next.config.ts b/next.config.ts
index e726c8f..f5a087b 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -5,9 +5,9 @@ import type { NextConfig } from 'next';
const cspDirectives = [
"default-src 'self'",
"script-src 'self' 'unsafe-inline'",
- "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
+ "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://unpkg.com",
"font-src 'self' https://fonts.gstatic.com",
- "img-src 'self' data:",
+ "img-src 'self' data: https://*.basemaps.cartocdn.com",
"connect-src 'self'",
"frame-ancestors 'none'",
"base-uri 'self'",
diff --git a/package-lock.json b/package-lock.json
index 922318f..b1f590a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,8 +8,10 @@
"name": "immich-lightbox",
"version": "0.1.0",
"dependencies": {
+ "@types/leaflet": "^1.9.21",
"gray-matter": "^4.0.3",
"js-yaml": "^4.1.1",
+ "leaflet": "^1.9.4",
"next": "16.1.6",
"react": "19.2.3",
"react-dom": "19.2.3",
@@ -2112,6 +2114,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/geojson": {
+ "version": "7946.0.16",
+ "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
+ "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
+ "license": "MIT"
+ },
"node_modules/@types/js-yaml": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz",
@@ -2133,6 +2141,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/leaflet": {
+ "version": "1.9.21",
+ "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
+ "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/geojson": "*"
+ }
+ },
"node_modules/@types/node": {
"version": "20.19.33",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz",
@@ -5512,6 +5529,12 @@
"node": ">=0.10"
}
},
+ "node_modules/leaflet": {
+ "version": "1.9.4",
+ "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
+ "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
+ "license": "BSD-2-Clause"
+ },
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
diff --git a/package.json b/package.json
index 02bb7c8..c98c8da 100644
--- a/package.json
+++ b/package.json
@@ -15,8 +15,10 @@
"test:e2e": "playwright test"
},
"dependencies": {
+ "@types/leaflet": "^1.9.21",
"gray-matter": "^4.0.3",
"js-yaml": "^4.1.1",
+ "leaflet": "^1.9.4",
"next": "16.1.6",
"react": "19.2.3",
"react-dom": "19.2.3",
diff --git a/plans/IDEAS.md b/plans/IDEAS.md
index 7dda791..541998f 100644
--- a/plans/IDEAS.md
+++ b/plans/IDEAS.md
@@ -10,6 +10,17 @@
- **Photo stories / Essays** — Vertical scroll format with images + text interspersed, like a photo essay. Portfolio differentiator
- **Map view** — Minimal world map from EXIF city/country data, each dot links to an album. Mapbox GL or Leaflet
- **Collections by camera/lens** — Auto-generate virtual albums from EXIF data ("Shot on Leica Q3", "Summilux 35mm")
+- **Client proofing** — Let clients favorite/select photos in password-protected galleries. Extends existing password gate
+- **Download originals** — Button in lightbox to download full-res, proxied through server (keeps Immich hidden). Unlocks client delivery
+- **Before/After slider** — Drag-to-reveal comparison component for retouching/editing showcases
+- **Print shop integration** — "Buy Print" button linking to external print-on-demand service or contact form with photo reference
+- **Watermarking** — Server-side watermark overlay on proxied images (corner logo, center text, or diagonal). Configurable in YAML
+- **Curated homepage feed** — Hand-picked "best of" sequence across all albums, like an Instagram highlights page
+- **Photo of the Day** — Auto-rotate hero image daily from a designated favorites album. Gives visitors a reason to return
+- **EXIF analytics page** — Visual breakdown of shooting patterns: most-used focal lengths, aperture distribution, camera body stats
+- **Color palette extraction** — Show dominant colors per photo/album as subtle accents. Art direction + bespoke feel
+- **Timeline / Year view** — Chronological scroll of all work by year/month, showing progression over time
+- **Search by EXIF** — Filter entire portfolio by camera settings ("everything at f/1.4", "all black & white")
## 👤 User / Visitor
@@ -27,9 +38,14 @@
| Slideshow mode | Low | High — exhibition ready |
| Fullscreen API in lightbox | Low | Medium — immersive |
| Monochrome toggle | Low | Medium — Leica vibes |
+| Download originals | Low | High — client delivery |
| ISR caching | Medium | High — performance |
+| Photo of the Day | Low | Medium — return visits |
+| Before/After slider | Medium | Medium — editing showcase |
| Photo stories/essays | High | Very high — differentiator |
| Map view | Medium | High — wow factor |
+| Client proofing | High | Very high — business tool |
+| EXIF analytics | Medium | Medium — gear-nerd appeal |
## ✅ Already Implemented