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) => `${a.name} →`) + .join(''); + + const popupHtml = ` +
+ ${loc.city} +
+

${loc.city}

+

${loc.country}

+

${loc.photoCount} photo${loc.photoCount === 1 ? '' : 's'}

+
${albumLinks}
+
+
+ `; + + 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 ( +
+

{error}

+
+ ); + } + + return ( + <> + {loading && ( +
+

Loading map…

+
+ )} +
+ + ); +} 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