feat: map view with GPS extraction fix

Adds a new Map sidebar entry that plots photos by their EXIF GPS
coordinates on a clustered Leaflet map. While wiring this up, the
metadata extractor was reading unprefixed GPS keys that never exist
in `exiftool -G -j` output AND assumed coordinates were already
floats — every photo silently lost its GPS. The new extract_gps
helper handles Composite/EXIF group prefixes and parses DMS strings,
and lat/lon are stored as first-class indexed columns so the map
can query them without parsing exif_json on every request.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-09 23:44:29 +02:00
parent 9c9f5bd899
commit 7cf546af7a
17 changed files with 529 additions and 46 deletions

View File

@@ -91,11 +91,18 @@ async def init_db():
"duplicate_group_id",
"ALTER TABLE photos ADD COLUMN duplicate_group_id VARCHAR",
),
("latitude", "ALTER TABLE photos ADD COLUMN latitude REAL"),
("longitude", "ALTER TABLE photos ADD COLUMN longitude REAL"),
]
# Track whether the GPS columns were just added so we can kick
# off a one-shot backfill of existing photos at the end of init.
gps_columns_added = False
for col_name, alter_sql in pending_alters:
if col_name not in existing_cols:
logger.info(f"Adding photos.{col_name} column")
await conn.execute(text(alter_sql))
if col_name in ("latitude", "longitude"):
gps_columns_added = True
# Indexes for the new duplicate-detection columns. CREATE INDEX
# IF NOT EXISTS is supported on SQLite so this is safe to run
# every startup.
@@ -108,9 +115,27 @@ async def init_db():
"ON photos(duplicate_group_id)"
)
)
await conn.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_photos_lat_lon "
"ON photos(latitude, longitude)"
)
)
logger.info("Database initialized successfully")
# If we just introduced the GPS columns on an existing install, kick
# off a one-shot backfill so the Map view is populated without a
# manual full re-scan. Imported lazily to avoid pulling Celery into
# the import graph for non-worker processes that don't need it.
if "sqlite" in settings.database_url and gps_columns_added:
try:
from app.tasks.scan import backfill_gps
backfill_gps.delay()
logger.info("Queued one-shot backfill_gps task after column add")
except Exception as e:
logger.warning(f"Could not queue backfill_gps task: {e}")
async def create_fts_table():
"""Create Full-Text Search table for SQLite"""
if "sqlite" in settings.database_url:

View File

@@ -1,7 +1,7 @@
"""
Photo model definition
"""
from sqlalchemy import Column, String, Integer, Boolean, DateTime, ForeignKey, Text, Index
from sqlalchemy import Column, String, Integer, Float, Boolean, DateTime, ForeignKey, Text, Index
from sqlalchemy.sql import func
from datetime import datetime
import uuid
@@ -49,6 +49,13 @@ class Photo(Base):
# Metadata
exif_json = Column(Text) # full EXIF/XMP blob as JSON
# GPS coordinates extracted from EXIF, in signed decimal degrees
# (S latitude / W longitude are negative). Stored as first-class columns
# so the Map view and any future location filters can query/index them
# without parsing exif_json on every request.
latitude = Column(Float)
longitude = Column(Float)
# User-editable fields
user_title = Column(String)
@@ -91,4 +98,5 @@ class Photo(Base):
Index('ix_photos_color_label', 'color_label'),
Index('ix_photos_media_type', 'media_type'),
Index('ix_photos_processing_status', 'processing_status'),
Index('ix_photos_lat_lon', 'latitude', 'longitude'),
)

View File

@@ -62,6 +62,14 @@ async def get_library_stats(db: AsyncSession = Depends(get_db)):
)
).scalar() or 0
with_gps_count = (
await db.execute(
select(func.count(Photo.id)).where(
not_discarded, Photo.latitude.is_not(None)
)
)
).scalar() or 0
duplicates_count = (
await db.execute(
select(func.count(Photo.id)).where(
@@ -96,6 +104,7 @@ async def get_library_stats(db: AsyncSession = Depends(get_db)):
"all_photos": all_photos_count,
"rated": rated_count,
"colored": colored_count,
"with_gps": with_gps_count,
"duplicates": duplicates_count,
"discarded": discarded_count,
"total_photos": photo_count,
@@ -108,11 +117,23 @@ async def get_library_stats(db: AsyncSession = Depends(get_db)):
async def trigger_scan():
"""Trigger full library re-scan"""
from app.tasks.scan import scan_all_source_roots
scan_all_source_roots.delay()
return {"status": "success", "message": "Library scan started"}
@router.post("/backfill-gps")
async def trigger_backfill_gps():
"""Re-run EXIF metadata extraction on every photo that's still missing
GPS coordinates. Useful after fixing the EXIF parser, or any time the
Map view looks emptier than expected. Returns immediately — work runs
on the Celery worker."""
from app.tasks.scan import backfill_gps
backfill_gps.delay()
return {"status": "success", "message": "GPS backfill queued"}
@router.get("/scan/status")
async def get_scan_status(db: AsyncSession = Depends(get_db)):
"""Get current scan status"""

View File

@@ -214,6 +214,36 @@ async def list_photos(
"pages": (total + per_page - 1) // per_page if total else 0,
}
@router.get("/map")
async def list_photos_with_gps(db: AsyncSession = Depends(get_db)):
"""Lightweight listing of every non-discarded photo that has GPS
coordinates, used by the Map view. Intentionally returns a flat list
(no pagination) with only the fields the map renderer needs, so even
large libraries serialize to a few MB at most. Declared *before*
``/{photo_id}`` so the literal path wins the FastAPI route match."""
result = await db.execute(
select(
Photo.id,
Photo.latitude,
Photo.longitude,
Photo.taken_at,
).where(
Photo.is_discarded.is_(False),
Photo.latitude.is_not(None),
Photo.longitude.is_not(None),
)
)
return [
{
"id": row.id,
"latitude": row.latitude,
"longitude": row.longitude,
"taken_at": row.taken_at.isoformat() if row.taken_at else None,
}
for row in result.all()
]
@router.get("/{photo_id}")
async def get_photo(
photo_id: str,

View File

@@ -36,6 +36,8 @@ class PhotoResponse(PhotoBase):
processing_status: str = 'pending'
processing_error: Optional[str] = None
exif_json: Optional[str] = None
latitude: Optional[float] = None
longitude: Optional[float] = None
is_duplicate: bool = False
live_photo_video_id: Optional[str] = None
# tags: List[Dict[str, Any]] = [] # TODO: Enable when using eager loading

View File

@@ -3,6 +3,7 @@ Metadata extraction service using ExifTool
"""
import json
import logging
import re
import asyncio
from datetime import datetime
from typing import Dict, Optional
@@ -40,44 +41,132 @@ def parse_exif_datetime(date_str: str) -> Optional[datetime]:
return None
_DMS_RE = re.compile(
r"""\s*
(?P<deg>-?\d+(?:\.\d+)?)\s*(?:deg|°|d)?\s*
(?:(?P<min>\d+(?:\.\d+)?)\s*[\'m]?\s*)?
(?:(?P<sec>\d+(?:\.\d+)?)\s*[\"”s]?\s*)?
(?P<ref>[NSEW])?\s*$""",
re.IGNORECASE | re.VERBOSE,
)
def _parse_coord(value, ref: str | None) -> float | None:
"""Coerce a single GPS coordinate from any form ExifTool may emit.
ExifTool's ``-j`` JSON output applies print conversion by default, so
coordinates can come back as:
* a number (``48.1278``) — happens for some sources / when ``-n`` is set
* a plain DMS string (``"48 deg 7' 39.96\\""``) — bare ``EXIF:GPSLatitude``
* a DMS-with-ref string (``"48 deg 7' 39.96\\" N"``) — ``Composite:GPSLatitude``
The optional ``ref`` argument lets the caller pass an explicit
``GPSLatitudeRef`` / ``GPSLongitudeRef`` ('N'/'S'/'E'/'W') when the
string itself doesn't carry one. Returns signed decimal degrees, or
``None`` if the value is unparseable.
"""
if value is None:
return None
# Numeric path — already decimal degrees, possibly already signed.
if isinstance(value, (int, float)):
out = float(value)
else:
m = _DMS_RE.match(str(value))
if not m:
return None
deg = float(m.group('deg'))
minutes = float(m.group('min') or 0)
seconds = float(m.group('sec') or 0)
out = abs(deg) + minutes / 60.0 + seconds / 3600.0
if deg < 0:
out = -out
embedded_ref = m.group('ref')
if embedded_ref:
ref = embedded_ref
if ref:
r = ref[0].upper()
if r in ('S', 'W'):
out = -abs(out)
elif r in ('N', 'E'):
out = abs(out)
return out
def extract_gps(exif_data: Dict) -> tuple:
"""Return (lat, lon) in signed decimal degrees, or (None, None).
With ``exiftool -G -j`` GPS values are keyed under their group.
``Composite:GPSLatitude`` / ``Composite:GPSLongitude`` carry the
hemisphere reference inline (``"48 deg 7' 39.96\\" N"``) while the bare
``EXIF:GPSLatitude`` / ``EXIF:GPSLongitude`` need the separate
``EXIF:GPSLatitudeRef`` / ``EXIF:GPSLongitudeRef`` to know the sign.
Pre-fix this function read the *unprefixed* keys ``GPSLatitude`` /
``GPSLongitude`` (which never exist in ``-G`` output) AND assumed
they were already floats — so it silently dropped every photo's GPS.
"""
lat = _parse_coord(exif_data.get('Composite:GPSLatitude'), None)
lon = _parse_coord(exif_data.get('Composite:GPSLongitude'), None)
if lat is None or lon is None:
lat = _parse_coord(
exif_data.get('EXIF:GPSLatitude'),
exif_data.get('EXIF:GPSLatitudeRef'),
)
lon = _parse_coord(
exif_data.get('EXIF:GPSLongitude'),
exif_data.get('EXIF:GPSLongitudeRef'),
)
if lat is None or lon is None:
return None, None
if not (-90 <= lat <= 90 and -180 <= lon <= 180):
return None, None
# Some cameras emit (0, 0) when they have no GPS lock — treat as missing
if lat == 0 and lon == 0:
return None, None
return lat, lon
def extract_key_metadata(exif_data: Dict) -> Dict:
"""Extract key metadata fields for FTS indexing"""
key_fields = []
# Camera information
if 'Make' in exif_data:
key_fields.append(exif_data['Make'])
if 'Model' in exif_data:
key_fields.append(exif_data['Model'])
if 'LensModel' in exif_data:
key_fields.append(exif_data['LensModel'])
if 'EXIF:Make' in exif_data:
key_fields.append(exif_data['EXIF:Make'])
if 'EXIF:Model' in exif_data:
key_fields.append(exif_data['EXIF:Model'])
if 'EXIF:LensModel' in exif_data:
key_fields.append(exif_data['EXIF:LensModel'])
# Location information
if 'GPSLatitude' in exif_data and 'GPSLongitude' in exif_data:
key_fields.append(f"GPS: {exif_data['GPSLatitude']}, {exif_data['GPSLongitude']}")
lat, lon = extract_gps(exif_data)
if lat is not None and lon is not None:
key_fields.append(f"GPS: {lat}, {lon}")
# IPTC/XMP keywords
if 'Keywords' in exif_data:
if isinstance(exif_data['Keywords'], list):
key_fields.extend(exif_data['Keywords'])
keywords = exif_data.get('IPTC:Keywords') or exif_data.get('XMP:Subject')
if keywords:
if isinstance(keywords, list):
key_fields.extend(keywords)
else:
key_fields.append(exif_data['Keywords'])
key_fields.append(keywords)
# Copyright and creator
if 'Copyright' in exif_data:
key_fields.append(exif_data['Copyright'])
if 'Creator' in exif_data:
key_fields.append(exif_data['Creator'])
if 'Artist' in exif_data:
key_fields.append(exif_data['Artist'])
if 'EXIF:Copyright' in exif_data:
key_fields.append(exif_data['EXIF:Copyright'])
if 'XMP:Creator' in exif_data:
key_fields.append(exif_data['XMP:Creator'])
if 'EXIF:Artist' in exif_data:
key_fields.append(exif_data['EXIF:Artist'])
return {
'exif_text': ' '.join(key_fields),
'camera_make': exif_data.get('Make'),
'camera_model': exif_data.get('Model'),
'lens_model': exif_data.get('LensModel'),
'gps_latitude': exif_data.get('GPSLatitude'),
'gps_longitude': exif_data.get('GPSLongitude'),
'exif_text': ' '.join(str(f) for f in key_fields),
'camera_make': exif_data.get('EXIF:Make'),
'camera_model': exif_data.get('EXIF:Model'),
'lens_model': exif_data.get('EXIF:LensModel'),
'gps_latitude': lat,
'gps_longitude': lon,
}
@shared_task(name='extract_metadata')
@@ -155,7 +244,13 @@ async def _extract_metadata_async(photo_id: str):
photo.width = exif_data.get('EXIF:ImageWidth') or exif_data.get('File:ImageWidth')
if not photo.height:
photo.height = exif_data.get('EXIF:ImageHeight') or exif_data.get('File:ImageHeight')
# Extract GPS coordinates into first-class columns so the
# Map view can query them without parsing exif_json.
lat, lon = extract_gps(exif_data)
photo.latitude = lat
photo.longitude = lon
# Extract and store key metadata for search
key_metadata = extract_key_metadata(exif_data)

View File

@@ -456,16 +456,44 @@ def watch_folders():
async def handle_file_deletion(filepath: str):
"""Handle deletion of a file from the filesystem"""
from sqlalchemy import select
async with AsyncSessionLocal() as session:
result = await session.execute(
select(Photo).where(Photo.filepath == filepath)
)
photo = result.scalar_one_or_none()
if photo:
# Mark as missing or delete from database
photo.is_discarded = True
photo.discarded_at = datetime.utcnow()
await session.commit()
logger.info(f"Marked photo as discarded: {filepath}")
logger.info(f"Marked photo as discarded: {filepath}")
@shared_task(name='backfill_gps')
def backfill_gps():
"""Re-run metadata extraction on every non-discarded photo that is
missing latitude/longitude. Used both as a one-shot kick-off after the
GPS columns are added on an existing install (see app/database.py) and
as a manual trigger from POST /api/v1/library/backfill-gps. Each
extract_metadata call is itself a Celery task, so this just enqueues —
it does not block on extraction completing."""
return asyncio.run(_backfill_gps_async())
async def _backfill_gps_async():
async with AsyncSessionLocal() as session:
result = await session.execute(
select(Photo.id).where(
Photo.latitude.is_(None),
Photo.is_discarded.is_(False),
)
)
photo_ids = [row[0] for row in result.all()]
for pid in photo_ids:
extract_metadata.delay(pid)
logger.info(f"backfill_gps: queued extract_metadata for {len(photo_ids)} photos")
return {'queued': len(photo_ids)}

View File

@@ -37,6 +37,7 @@
"react-hotkeys-hook": "^4.4.3",
"react-intersection-observer": "^9.5.3",
"react-leaflet": "^4.2.1",
"react-leaflet-cluster": "^2.1.0",
"tailwind-merge": "^2.2.0",
"zustand": "^4.4.7"
},
@@ -4572,6 +4573,15 @@
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
},
"node_modules/leaflet.markercluster": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/leaflet.markercluster/-/leaflet.markercluster-1.5.3.tgz",
"integrity": "sha512-vPTw/Bndq7eQHjLBVlWpnGeLa3t+3zGiuM7fJwCkiMFq+nmRuG3RI3f7f4N4TDX7T4NpbAXpR2+NTRSEGfCSeA==",
"license": "MIT",
"peerDependencies": {
"leaflet": "^1.3.1"
}
},
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -5249,6 +5259,21 @@
"react-dom": "^18.0.0"
}
},
"node_modules/react-leaflet-cluster": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/react-leaflet-cluster/-/react-leaflet-cluster-2.1.0.tgz",
"integrity": "sha512-16X7XQpRThQFC4PH4OpXHimGg19ouWmjxjtpxOeBKpvERSvIRqTx7fvhTwkEPNMFTQ8zTfddz6fRTUmUEQul7g==",
"license": "SEE LICENSE IN <LICENSE>",
"dependencies": {
"leaflet.markercluster": "^1.5.3"
},
"peerDependencies": {
"leaflet": "^1.8.0",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"react-leaflet": "^4.0.0"
}
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",

View File

@@ -39,6 +39,7 @@
"react-hotkeys-hook": "^4.4.3",
"react-intersection-observer": "^9.5.3",
"react-leaflet": "^4.2.1",
"react-leaflet-cluster": "^2.1.0",
"tailwind-merge": "^2.2.0",
"zustand": "^4.4.7"
},

View File

@@ -1,6 +1,7 @@
import { useState } from 'react'
import { Timeline } from './components/timeline/Timeline'
import { DuplicatesView } from './components/duplicates/DuplicatesView'
import { MapView } from './components/map/MapView'
import { LeftSidebar } from './components/layout/LeftSidebar'
import { RightSidebar } from './components/layout/RightSidebar'
import { TopBar } from './components/layout/TopBar'
@@ -73,11 +74,17 @@ function App() {
<FilterBar />
<DiscardActionBar />
<div className="flex-1 overflow-auto">
{/* The Duplicates section gets its own grouped grid view —
* the regular timeline can't represent groups, and a flat
* filtered list of "is_duplicate=true" photos was the old
* half-broken UX. */}
{currentSection === 'duplicates' ? <DuplicatesView /> : <Timeline />}
{/* Section-level routing. The Map view replaces the timeline
* with a Leaflet map of GPS-tagged photos; Duplicates gets its
* own grouped grid; everything else falls through to the
* filter-driven Timeline. */}
{currentSection === 'map' ? (
<MapView />
) : currentSection === 'duplicates' ? (
<DuplicatesView />
) : (
<Timeline />
)}
</div>
{/* Floating keyboard hints — bottom-center of the main column,
* glassy. Mounted here so it's centered against the timeline,

View File

@@ -11,6 +11,7 @@ import {
Copy,
Tag as TagIcon,
Palette,
MapPin,
Layers2,
MoreHorizontal,
Pencil,
@@ -248,6 +249,9 @@ export function LeftSidebar({ onCollapse, onOpenSettings }: LeftSidebarProps) {
case 'colors':
navigateToSection('colors', { groupBy: 'color' })
break
case 'map':
navigateToSection('map', {})
break
default:
if (id.startsWith('folder-')) {
const folderId = id.slice('folder-'.length)
@@ -349,6 +353,7 @@ export function LeftSidebar({ onCollapse, onOpenSettings }: LeftSidebarProps) {
{ id: 'rated', label: 'Rated', icon: <Star className="h-4 w-4" />, count: stats?.rated ?? 0 },
{ id: 'tags', label: 'Tags', icon: <TagIcon className="h-4 w-4" />, count: tagsTotalCount },
{ id: 'colors', label: 'Colors', icon: <Palette className="h-4 w-4" />, count: stats?.colored ?? 0 },
{ id: 'map', label: 'Map', icon: <MapPin className="h-4 w-4" />, count: stats?.with_gps ?? 0 },
{ id: 'duplicates', label: 'Duplicates', icon: <Copy className="h-4 w-4" />, count: stats?.duplicates ?? 0 },
{ id: 'discarded', label: 'Discarded', icon: <Trash2 className="h-4 w-4" />, count: stats?.discarded ?? 0 },
],

View File

@@ -0,0 +1,169 @@
import { useEffect, useMemo, useRef } from 'react'
import { useQuery } from '@tanstack/react-query'
import { MapContainer, TileLayer, Marker, useMap } from 'react-leaflet'
// react-leaflet-cluster has no own .d.ts that survives strict mode in
// every project, so we let TS infer from its runtime export.
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore — package ships JS with no bundled types
import MarkerClusterGroup from 'react-leaflet-cluster'
import L from 'leaflet'
import { photos as photosApi } from '../../services/api'
import { usePhotoStore } from '../../store/photoStore'
import { MapPin } from 'lucide-react'
interface MapPoint {
id: string
latitude: number
longitude: number
taken_at: string | null
}
/** Build the divIcon used for each photo marker. The HTML is a tiny
* square thumbnail with a white border + drop shadow so it reads on
* any tile background. Memoised per-photo so we don't re-create the
* L.DivIcon on every re-render. */
function buildPhotoIcon(photoId: string): L.DivIcon {
const url = photosApi.getThumbnailUrl(photoId, 'small')
return L.divIcon({
className: 'photo-map-marker',
html: `<div class="pmm-frame"><img src="${url}" loading="lazy" alt="" /></div>`,
iconSize: [56, 56],
iconAnchor: [28, 28],
popupAnchor: [0, -28],
})
}
/** Pans/zooms the map to fit the supplied points the first time they
* arrive. Subsequent loads (e.g. cache refresh) leave the user's pan
* alone — they're probably mid-investigation. */
function FitBoundsOnce({ points }: { points: MapPoint[] }) {
const map = useMap()
const fittedRef = useRef(false)
useEffect(() => {
if (fittedRef.current || points.length === 0) return
const bounds = L.latLngBounds(points.map((p) => [p.latitude, p.longitude]))
map.fitBounds(bounds, { padding: [40, 40], maxZoom: 14 })
fittedRef.current = true
}, [points, map])
return null
}
export function MapView() {
const { data: points = [], isLoading, error } = useQuery({
queryKey: ['photos', 'map'],
queryFn: () => photosApi.mapPoints(),
staleTime: 60 * 1000,
})
// Marker click hands off to the same PreviewView the timeline uses, so
// the user gets the full preview UI (large image, info panel with
// location, filmstrip nav between map photos) instead of a bespoke
// map-only lightbox. We pass the map's own point order as the visible
// sequence so left/right arrows step through neighboring markers.
const openPreview = usePhotoStore((s) => s.openPreview)
// Stable per-marker icons. Re-created only when the set of point ids
// changes — the underlying L.DivIcon objects are pure HTML so reusing
// them is safe across re-renders.
const iconsById = useMemo(() => {
const map = new Map<string, L.DivIcon>()
for (const p of points) map.set(p.id, buildPhotoIcon(p.id))
return map
}, [points])
const visibleSequence = useMemo(() => points.map((p) => p.id), [points])
if (isLoading) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
Loading map
</div>
)
}
if (error) {
return (
<div className="flex h-full items-center justify-center text-sm text-destructive">
Failed to load map photos
</div>
)
}
return (
<div className="relative h-full w-full">
{points.length === 0 ? (
<div className="absolute inset-0 z-[400] flex items-center justify-center bg-background/80 pointer-events-none">
<div className="flex flex-col items-center gap-2 text-center text-sm text-muted-foreground">
<MapPin className="h-6 w-6" />
<div>No photos with GPS data yet.</div>
<div className="text-xs">
Re-run metadata extraction from Settings Backfill GPS to
populate coordinates from existing photos.
</div>
</div>
</div>
) : null}
<MapContainer
center={[20, 0]}
zoom={2}
minZoom={2}
worldCopyJump
style={{ height: '100%', width: '100%' }}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<FitBoundsOnce points={points} />
<MarkerClusterGroup chunkedLoading maxClusterRadius={50}>
{points.map((p) => {
const icon = iconsById.get(p.id)
if (!icon) return null
return (
<PhotoMarker
key={p.id}
point={p}
icon={icon}
onClick={() => openPreview(p.id, visibleSequence)}
/>
)
})}
</MarkerClusterGroup>
</MapContainer>
<style>{`
.photo-map-marker { background: transparent; border: none; }
.photo-map-marker .pmm-frame {
width: 56px; height: 56px;
border-radius: 8px;
overflow: hidden;
border: 2px solid white;
box-shadow: 0 2px 6px rgba(0,0,0,0.4);
background: #1f2937;
}
.photo-map-marker .pmm-frame img {
width: 100%; height: 100%; object-fit: cover; display: block;
}
`}</style>
</div>
)
}
function PhotoMarker({
point,
icon,
onClick,
}: {
point: MapPoint
icon: L.DivIcon
onClick: () => void
}) {
return (
<Marker
position={[point.latitude, point.longitude]}
icon={icon}
eventHandlers={{ click: onClick }}
/>
)
}

View File

@@ -1,8 +1,10 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useHotkeys } from 'react-hotkeys-hook'
import { X, Info } from 'lucide-react'
import { usePhotoStore } from '../../store/photoStore'
import { usePhotosQuery } from '../../hooks/usePhotosQuery'
import { photos as photosApi } from '../../services/api'
import type { Photo } from '../../types/photo'
import { PreviewImage } from './PreviewImage'
import { PreviewFilmstrip } from './PreviewFilmstrip'
@@ -43,7 +45,24 @@ export function PreviewView() {
? photos.findIndex((p) => p.id === activePhotoId)
: 0
const safeIndex = currentIndex < 0 ? 0 : currentIndex
const currentPhoto: Photo | undefined = photos[safeIndex]
// Fallback fetch: when the preview is opened for a photo that isn't in
// the timeline query result (e.g. clicked from the Map view, where the
// active section's filter excludes it), look it up by id directly.
// PhotoInfoPanel runs the same query under the same key, so they share
// one cache entry — no extra request.
const photoInListById = activePhotoId
? photos.find((p) => p.id === activePhotoId)
: undefined
const { data: standalonePhoto } = useQuery<Photo>({
queryKey: ['photo', activePhotoId],
queryFn: () => photosApi.get(activePhotoId as string),
enabled: !!activePhotoId && !photoInListById,
staleTime: 60_000,
})
const currentPhoto: Photo | undefined =
photoInListById ?? photos[safeIndex] ?? standalonePhoto
// Keep the latest photos array + active id in a ref so the keyboard
// handlers ALWAYS read the freshest state. Without this, react-hotkeys-

View File

@@ -49,9 +49,20 @@ interface PhotoDetails {
user_notes: string | null
color_label: string | null
exif_json: string | null
latitude?: number | null
longitude?: number | null
tags?: PhotoTagSummary[]
}
/** Format a signed decimal degree value with the hemisphere letter, e.g.
* ``48.12777° N``. Keeps the panel readable without dragging in a heavy
* formatting lib. */
function formatLatLon(value: number, axis: 'lat' | 'lon'): string {
const abs = Math.abs(value).toFixed(5)
const ref = axis === 'lat' ? (value >= 0 ? 'N' : 'S') : (value >= 0 ? 'E' : 'W')
return `${abs}° ${ref}`
}
interface ExifData {
Make?: string
Model?: string
@@ -547,13 +558,20 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
expanded={expandedSections.has('location')}
onToggle={() => toggleSection('location')}
>
{exif.GPSLatitude && exif.GPSLongitude ? (
<div className="flex items-center gap-2 text-xs">
{photo.latitude != null && photo.longitude != null ? (
<a
href={`https://www.openstreetmap.org/?mlat=${photo.latitude}&mlon=${photo.longitude}#map=15/${photo.latitude}/${photo.longitude}`}
target="_blank"
rel="noreferrer"
className="flex items-center gap-2 text-xs hover:underline"
title="Open in OpenStreetMap"
>
<MapPin className="h-3 w-3 text-text-muted" />
<span className="font-mono text-text">
{String(exif.GPSLatitude)}, {String(exif.GPSLongitude)}
{formatLatLon(photo.latitude, 'lat')},{' '}
{formatLatLon(photo.longitude, 'lon')}
</span>
</div>
</a>
) : (
<div className="text-xs text-text-muted">No GPS data</div>
)}

View File

@@ -4,6 +4,10 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import App from './App'
import './index.css'
// Leaflet ships its base styles separately; without these the map tiles
// render at 0×0 and marker icons go missing. Imported once globally so
// any lazy MapView render picks them up.
import 'leaflet/dist/leaflet.css'
const queryClient = new QueryClient({
defaultOptions: {

View File

@@ -97,6 +97,20 @@ export const photos = {
return response.data
},
/** Lightweight list of every photo with GPS coordinates, used by the
* Map view. Returns one tiny object per photo (id, lat, lon, taken_at)
* rather than the full photo payload — keeps responses small even on
* big libraries. */
mapPoints: async () => {
const response = await api.get('/photos/map')
return response.data as Array<{
id: string
latitude: number
longitude: number
taken_at: string | null
}>
},
update: async (photoId: string, data: {
filename?: string
rating?: number
@@ -410,6 +424,7 @@ export interface LibraryStats {
all_photos: number
rated: number
colored: number
with_gps: number
duplicates: number
discarded: number
total_photos: number

View File

@@ -22,5 +22,16 @@ export interface Photo {
thumb_small?: string
thumb_medium?: string
thumb_large?: string
latitude?: number | null
longitude?: number | null
tags?: PhotoTagSummary[]
}
/** Minimal payload returned by GET /api/v1/photos/map — only what the
* Map view needs to render and open a marker's preview. */
export interface PhotoMapPoint {
id: string
latitude: number
longitude: number
taken_at: string | null
}