'use client'; import { useEffect, useMemo, useRef, useState } from 'react'; import { MAP_TILES } from '../../constants'; import { groupByTime } from '../../lib/grouping'; import { Icon } from '../../icons'; import { useGallery, useGalleryStoreApi } from '../../store/context'; import { clusterByLocation, type LocationCluster, liveMedia, locatedMedia, searchMedia, } from '../../store/selectors'; import type { MediaItem } from '../../types'; import { MediaGrid } from '../MediaGrid'; import { MosaicGrid } from './MosaicGrid'; /** Thumbnails shown in a multi-photo pin's hover strip before the "+N" chip. */ const TIP_STRIP_MAX = 5; /** How long each frame of the hover mini-slider stays up. */ const TIP_FRAME_MS = 900; /** Parse a `` value into a local-midnight epoch, or null. */ function parseDateInput(value: string, endOfDay: boolean): number | null { if (!value) return null; const [y, m, d] = value.split('-').map(Number); if (!y || !m || !d) return null; return endOfDay ? new Date(y, m - 1, d, 23, 59, 59, 999).getTime() : new Date(y, m - 1, d, 0, 0, 0, 0).getTime(); } /** Format a Date to a `` value (local `YYYY-MM-DD`). */ function toInputDate(d: Date): string { const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, '0'); const day = String(d.getDate()).padStart(2, '0'); return `${y}-${m}-${day}`; } /** Short human label for one `YYYY-MM-DD` value. */ function labelDate(value: string): string { const [y, m, d] = value.split('-').map(Number); if (!y || !m || !d) return ''; return new Date(y, m - 1, d).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric', }); } /** Button label for the current range ("All dates" when unset). */ function rangeLabel(from: string, to: string): string { if (!from && !to) return 'All dates'; if (from && to) return `${labelDate(from)} – ${labelDate(to)}`; if (from) return `From ${labelDate(from)}`; return `Until ${labelDate(to)}`; } /** * A single, polished "date range" control: a button showing the current range * that opens a popover of quick presets + a custom From/To pair. It drives the * SAME from/to strings the MapView already filters by — so search/object chips, * the live count, and Clear all keep working unchanged. */ function DateRangeControl({ from, to, onChange, }: { from: string; to: string; onChange: (from: string, to: string) => void; }) { const [open, setOpen] = useState(false); const ref = useRef(null); useEffect(() => { if (!open) return; const onDown = (e: PointerEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); }; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); }; window.addEventListener('pointerdown', onDown, true); window.addEventListener('keydown', onKey); return () => { window.removeEventListener('pointerdown', onDown, true); window.removeEventListener('keydown', onKey); }; }, [open]); const today = new Date(); const daysAgo = (n: number) => { const d = new Date(); d.setDate(today.getDate() - n); return d; }; const presets: Array<{ label: string; from: string; to: string }> = [ { label: 'All dates', from: '', to: '' }, { label: 'Last 7 days', from: toInputDate(daysAgo(6)), to: toInputDate(today) }, { label: 'Last 30 days', from: toInputDate(daysAgo(29)), to: toInputDate(today) }, { label: 'This year', from: toInputDate(new Date(today.getFullYear(), 0, 1)), to: toInputDate(today), }, ]; const active = Boolean(from || to); return (
{open ? (
{presets.map((p) => { const on = p.from === from && p.to === to; return ( ); })}
Custom range
) : null}
); } export function MapView() { const api = useGalleryStoreApi(); const mapMode = useGallery((s) => s.mapMode); const media = useGallery((s) => s.media); const mapFocus = useGallery((s) => s.mapFocus); const located = locatedMedia(media); // The location pin the user tapped → its photos shown date-grouped in a sheet. const [cluster, setCluster] = useState(null); // Sheet height as a fraction of the viewport (drag the handle up → toward full). const [sheetH, setSheetH] = useState(0.5); const dragRef = useRef<{ startY: number; startH: number } | null>(null); // ---- sheet filters (search + date range + object chips), AND-combined ---- const [query, setQuery] = useState(''); const [fromDate, setFromDate] = useState(''); const [toDate, setToDate] = useState(''); const [objectFilter, setObjectFilter] = useState(null); const resetFilters = () => { setQuery(''); setFromDate(''); setToDate(''); setObjectFilter(null); }; const onHandleDown = (e: React.PointerEvent) => { dragRef.current = { startY: e.clientY, startH: sheetH }; (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); }; const onHandleMove = (e: React.PointerEvent) => { if (!dragRef.current) return; const dy = dragRef.current.startY - e.clientY; // dragging up is positive const next = dragRef.current.startH + dy / window.innerHeight; setSheetH(Math.max(0.18, Math.min(0.98, next))); }; const onHandleUp = () => { if (dragRef.current && sheetH <= 0.2) setCluster(null); // dragged down → dismiss dragRef.current = null; }; const containerRef = useRef(null); // Leaflet instances kept in refs (typed loosely to avoid an SSR import). const mapRef = useRef(null); const tileRef = useRef(null); const markersRef = useRef(null); const leafletRef = useRef(null); // Every hover-slider interval currently running, so teardown can kill them all // (a leaked interval keeps mutating detached DOM forever). const tipTimersRef = useRef(new Set>()); const clearTipTimers = () => { for (const t of tipTimersRef.current) clearInterval(t); tipTimersRef.current.clear(); }; // addMarkers reads the latest `located` + setCluster via refs (Leaflet callbacks // are created once, so closing over state directly would go stale). const locatedRef = useRef(located); locatedRef.current = located; // Current sheet height in px, read by the fly-to offset (the sheet covers the // bottom of the map, so the pin must end up above it). const sheetHRef = useRef(sheetH); sheetHRef.current = sheetH; /** Zoom to the pin, keeping it clear of the sheet, then open the sheet. */ const openClusterRef = useRef<(c: LocationCluster) => void>(() => {}); openClusterRef.current = (c) => { const map = mapRef.current; if (map) { const current = map.getZoom?.() ?? 2; // Never zoom OUT: a single photo warrants street level, a cluster stays wide // enough that its members remain distinguishable. const target = Math.max(current, c.items.length > 1 ? 12 : 14); const size = map.getSize?.(); const mapH: number = size?.y ?? 0; // The sheet is about to occupy the bottom `sheetH` of the map, so shift the // centre up by half of that — the pin then sits in the visible upper band. const sheetPx = mapH * 0.5; // the sheet always (re)opens at half height map.flyTo([c.lat, c.lng], target, { animate: true, duration: 0.6 }); if (sheetPx > 0) { // panBy after the fly settles, else Leaflet cancels the in-flight animation. // Leaflet's panBy moves the map pane BY the offset, so content shifts the // opposite way: a POSITIVE y lifts the pin toward the top of the map, // which is the direction that gets it clear of the sheet. map.once('moveend', () => map.panBy([0, sheetPx / 2], { animate: true })); } } setCluster(c); setSheetH(0.5); // reset to half height each time a pin is opened resetFilters(); // a new pin starts with a clean filter }; // Opening one photo from a pin's hover strip. const openLightboxRef = useRef<(id: string) => void>(() => {}); openLightboxRef.current = (id) => api.getState().openLightbox(id); // Create the map once when entering a map tile mode. useEffect(() => { if (mapMode === 'grid' || !containerRef.current || mapRef.current) return; let cancelled = false; void import('leaflet').then((mod) => { const L = (mod as any).default ?? mod; if (cancelled || !containerRef.current) return; leafletRef.current = L; const map = L.map(containerRef.current, { zoomControl: false, attributionControl: true, worldCopyJump: true, }).setView([20, 0], 2); mapRef.current = map; addTiles(); addMarkers(); // Re-cluster pins each time the zoom changes (precision is zoom-dependent). map.on('zoomend', addMarkers); // Center on a focused photo (from the Info mini-map), else fit to all markers. if (mapFocus) { map.setView([mapFocus.lat, mapFocus.lng], 12, { animate: false }); } else if (located.length) { const bounds = L.latLngBounds(located.map((m) => [m.location!.lat, m.location!.lng])); map.fitBounds(bounds.pad(0.3), { animate: false }); } }); return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [mapMode]); // Tear the map down when leaving map tile modes. useEffect(() => { if (mapMode === 'grid' && mapRef.current) { clearTipTimers(); mapRef.current.remove(); mapRef.current = null; tileRef.current = null; markersRef.current = null; } // eslint-disable-next-line react-hooks/exhaustive-deps }, [mapMode]); // Destroy on unmount. useEffect(() => { return () => { clearTipTimers(); if (mapRef.current) { mapRef.current.remove(); mapRef.current = null; tileRef.current = null; markersRef.current = null; } }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const addTiles = () => { const L = leafletRef.current; const map = mapRef.current; if (!L || !map) return; if (tileRef.current) { map.removeLayer(tileRef.current); tileRef.current = null; } const cfg = mapMode === 'satellite' ? MAP_TILES.satellite : MAP_TILES.map; tileRef.current = L.tileLayer(cfg.url, { attribution: cfg.attribution, maxZoom: cfg.maxZoom, }).addTo(map); }; const addMarkers = () => { const L = leafletRef.current; const map = mapRef.current; if (!L || !map) return; // Keep markers in a dedicated layer group so they can be re-synced wholesale. if (!markersRef.current) markersRef.current = L.layerGroup().addTo(map); // Every marker is about to be destroyed — kill their hover timers first. clearTipTimers(); markersRef.current.clearLayers(); // Tighter clustering as you zoom in, so pins split apart (macOS behaviour). const zoom = map.getZoom?.() ?? 2; const precision = zoom < 4 ? 0 : zoom < 7 ? 1 : zoom < 11 ? 2 : 3; const clusters = clusterByLocation(locatedRef.current, precision); for (const c of clusters) { const cover = c.items[0]!; const multi = c.items.length > 1; // Static markup only (no interpolated data) so Leaflet's innerHTML can't be // injected; the thumbnail src + count are set via DOM after the marker mounts. const icon = L.divIcon({ className: 'apg-pin-wrap', // Static markup only (data set via DOM after mount → XSS-safe). Includes a // hover tooltip: one preview for a single photo, an auto-advancing strip // of thumbnails for a cluster. html: '' + '' + '' + '' + '', iconSize: [56, 64], iconAnchor: [28, 64], }); const marker = L.marker([c.lat, c.lng], { icon, keyboard: false }); marker.on('add', () => { const el: HTMLElement | null = marker.getElement(); if (!el) return; const thumb = cover.thumbnail ?? cover.src; const img = el.querySelector('.apg-pin__img') as HTMLImageElement | null; if (img) img.src = thumb; const tipImg = el.querySelector('.apg-pin__tip-img') as HTMLImageElement | null; const strip = el.querySelector('.apg-pin__strip') as HTMLElement | null; const cap = el.querySelector('.apg-pin__tip-cap') as HTMLElement | null; // Keep the hover tooltip inside the map: on enter, measure it against the map // container and slide it horizontally (--tip-dx) / flip it below (--below) so it // never overflows and gets clipped by the gallery's rounded, overflow-hidden shell. const pinEl = el.querySelector('.apg-pin') as HTMLElement | null; const tipEl = el.querySelector('.apg-pin__tip') as HTMLElement | null; const clampTip = () => { if (!tipEl) return; const mapEl = el.closest('.leaflet-container') as HTMLElement | null; if (!mapEl) return; tipEl.style.setProperty('--tip-dx', '0px'); tipEl.classList.remove('apg-pin__tip--below'); const m = mapEl.getBoundingClientRect(); const t = tipEl.getBoundingClientRect(); const pad = 8; let dx = 0; if (t.left < m.left + pad) dx = m.left + pad - t.left; else if (t.right > m.right - pad) dx = m.right - pad - t.right; if (dx) tipEl.style.setProperty('--tip-dx', `${Math.round(dx)}px`); if (t.top < m.top + pad) tipEl.classList.add('apg-pin__tip--below'); }; pinEl?.addEventListener('mouseenter', clampTip); if (cap) { // textContent (never innerHTML) — place names are attacker-influencable. const place = cover.location?.place ?? ''; const n = c.items.length; cap.textContent = place ? `${place}${n > 1 ? ` · ${n} photos` : ''}` : `${n} photo${n === 1 ? '' : 's'}`; } const badge = el.querySelector('.apg-pin__count') as HTMLElement | null; if (badge) { // The badge always carries the cluster's TOTAL, and only appears when // there is genuinely more than one photo here. badge.textContent = String(c.items.length); badge.style.display = multi ? '' : 'none'; } if (!multi || !strip) { if (tipImg) tipImg.src = thumb; return; } // ---- multi-photo pin: horizontal strip + auto-advancing preview ---- if (tipImg) tipImg.src = thumb; const shown = c.items.slice(0, TIP_STRIP_MAX); const cells: HTMLElement[] = []; for (const item of shown) { const cell = document.createElement('button'); cell.type = 'button'; cell.className = 'apg-pin__strip-cell'; cell.title = item.name; const thumbEl = document.createElement('img'); thumbEl.alt = ''; thumbEl.src = item.thumbnail ?? item.src; cell.appendChild(thumbEl); // Opening a specific photo must not also open the cluster sheet. cell.addEventListener('click', (ev) => { ev.stopPropagation(); ev.preventDefault(); openLightboxRef.current(item.id); }); strip.appendChild(cell); cells.push(cell); } if (c.items.length > TIP_STRIP_MAX) { const more = document.createElement('span'); more.className = 'apg-pin__strip-more'; more.textContent = `+${c.items.length - TIP_STRIP_MAX}`; strip.appendChild(more); } // Auto-advance the big preview (and the highlighted cell) while hovered. const pin = el.querySelector('.apg-pin') as HTMLElement | null; if (!pin) return; let timer: ReturnType | null = null; let frame = 0; const paint = () => { const item = c.items[frame % c.items.length]!; if (tipImg) tipImg.src = item.thumbnail ?? item.src; cells.forEach((cell, i) => cell.classList.toggle('apg-pin__strip-cell--on', i === frame % c.items.length), ); }; const stop = () => { if (timer === null) return; clearInterval(timer); tipTimersRef.current.delete(timer); timer = null; frame = 0; if (tipImg) tipImg.src = thumb; cells.forEach((cell) => cell.classList.remove('apg-pin__strip-cell--on')); }; const start = () => { if (timer !== null) return; frame = 0; paint(); timer = setInterval(() => { frame += 1; paint(); }, TIP_FRAME_MS); tipTimersRef.current.add(timer); }; pin.addEventListener('mouseenter', start); pin.addEventListener('mouseleave', stop); // Leaflet destroys the element on clearLayers(); `remove` fires first. marker.on('remove', stop); }); marker.on('click', () => openClusterRef.current(c)); markersRef.current.addLayer(marker); } }; // Swap tile layer when Map/Satellite toggles. useEffect(() => { if (mapRef.current) addTiles(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [mapMode]); // Re-sync markers when the located set changes while the map stays mounted. useEffect(() => { if (mapRef.current) addMarkers(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [media]); // Recenter when a photo's location is focused (from the Info mini-map). useEffect(() => { if (mapFocus && mapRef.current) { mapRef.current.setView([mapFocus.lat, mapFocus.lng], 12, { animate: true }); } }, [mapFocus]); // Distinct object labels present in the open cluster, most common first. const clusterObjects = useMemo(() => { if (!cluster) return [] as Array<{ label: string; count: number }>; const counts = new Map(); for (const m of cluster.items) { for (const label of new Set(m.objectLabels)) { counts.set(label, (counts.get(label) ?? 0) + 1); } } return [...counts.entries()] .map(([label, count]) => ({ label, count })) .sort((a, b) => b.count - a.count || a.label.localeCompare(b.label)); }, [cluster]); // Search + date range + object chip, combined with AND. const filtered = useMemo(() => { if (!cluster) return []; let items = cluster.items; if (objectFilter) items = items.filter((m) => m.objectLabels.includes(objectFilter)); const from = parseDateInput(fromDate, false); const to = parseDateInput(toDate, true); if (from !== null) items = items.filter((m) => m.takenAt >= from); if (to !== null) items = items.filter((m) => m.takenAt <= to); // Same matcher as the global search bar, so "car" means the same thing here. if (query.trim()) items = searchMedia(items, query); return items; }, [cluster, query, fromDate, toDate, objectFilter]); const filterActive = Boolean(query.trim() || fromDate || toDate || objectFilter); if (mapMode === 'grid') { // The Grid tab shows ALL photos in the macOS Memories-style mosaic (not just // located ones) so it's a rich date-grouped collage, per the design. const all = liveMedia(media); if (all.length === 0) { return (
No Photos
Imported photos appear here as a mosaic.
); } return ; } return (
Legal {cluster ? (
{cluster.items[0]?.location?.place ?? 'This location'}
{filterActive ? `${filtered.length} of ${cluster.items.length} photo${ cluster.items.length === 1 ? '' : 's' }` : `${cluster.items.length} photo${cluster.items.length === 1 ? '' : 's'}`}
{/* Search + date range + object chips — all AND-combined. */}
setQuery(e.target.value)} /> {filterActive ? ( ) : null}
{ setFromDate(f); setToDate(t); }} />
{clusterObjects.length ? (
{clusterObjects.map(({ label, count }) => { const on = objectFilter === label; return ( ); })}
) : null}
{filtered.length === 0 ? (
No matching photos
Try a different search, date range, or object.
) : ( groupByTime(filtered, 'day').map((s) => ( )) )}
) : null}
); }