feat(frontend): port Places, People & photo tabs to the Svelte app

Bring the Photos/People/Places UI that main added (in the legacy vanilla
frontend) into the SvelteKit rewrite, wired to the now-merged backend
(/api/photos/geo, /api/people/*).

Photos page (routes/photos/+page.svelte):
- Moments | Places | People sub-tabs (the People tab appears only when the
  faces feature is enabled, via a /api/people capability probe), mirroring
  the vanilla photos sub-nav.
- Square ↔ justified layout toggle. Justified uses a Flickr-style
  row-packer over the width/height the photos list endpoint returns
  (PhotoItem), falling back to 1:1 when dimensions are missing.

New components:
- PhotoLightbox.svelte — the lightbox extracted from the photos page into a
  reusable component (items + bindable index, onDelete callback) so the
  grid, People and Places all share one implementation (no duplication).
- PlacesMap.svelte — MapLibre GL map with server-clustered markers; the
  vector basemap is optional (probed at /basemaps/basemap.pmtiles, themed
  fallback otherwise). Cluster click zooms in or opens the lightbox.
- PeopleView.svelte — identity-cluster grid → per-person photo grid, with
  rename via the in-app prompt dialog.

Supporting:
- api/endpoints/people.ts (+ peopleEnabled probe); photos.ts gains
  fetchPhotosGeo + GeoCluster + PhotoItem; fileThumbnailUrl takes a size.
- lib/vendor/maplibre.ts — minimal typings + lazy loader for the vendored
  MapLibre GL + pmtiles globals (kept any-free for ESLint).
- utils/media.ts — shared isVideo / photoTimestamp / minimalPhotoItem.
- Vendored maplibre-gl 5.24.0 + pmtiles 4.4.1 under static/vendors and an
  optional static/basemaps dir, matching the PR's vendored-asset pattern.
- New photos.tab_*/layout_*/map_* + people.* keys in en.json.

Verified: npm run check (svelte-check + eslint + stylelint + prettier),
npm run test:unit (36 pass), and npm run build all green.
This commit is contained in:
Claude
2026-06-19 12:59:16 +00:00
parent 047e0f06ff
commit 5494efea35
16 changed files with 1663 additions and 456 deletions
+95
View File
@@ -0,0 +1,95 @@
/**
* Minimal typings + lazy loader for the vendored MapLibre GL + pmtiles globals.
*
* The libraries are heavy (~1 MB) and only the Places map needs them, so they
* are vendored under `/vendors` (not bundled) and injected on first use — the
* same pattern the legacy frontend used. We declare only the small slice of the
* MapLibre API the Places view touches, so the rest of the app stays `any`-free.
*/
export interface MapBounds {
getWest(): number;
getSouth(): number;
getEast(): number;
getNorth(): number;
}
export interface LngLatBounds {
extend(lngLat: [number, number]): LngLatBounds;
isEmpty(): boolean;
}
export interface MapMarker {
setLngLat(lngLat: [number, number]): MapMarker;
addTo(map: MapLibreMap): MapMarker;
remove(): void;
}
export interface MapLibreMap {
addControl(control: unknown, position?: string): MapLibreMap;
on(type: string, listener: () => void): void;
getBounds(): MapBounds;
getZoom(): number;
resize(): void;
easeTo(opts: { center: [number, number]; zoom: number }): void;
fitBounds(
bounds: LngLatBounds,
opts?: { padding?: number; maxZoom?: number; duration?: number }
): void;
remove(): void;
}
export interface MapLibreModule {
Map: new (opts: Record<string, unknown>) => MapLibreMap;
Marker: new (opts: { element: HTMLElement }) => MapMarker;
NavigationControl: new (opts?: { showCompass?: boolean }) => unknown;
AttributionControl: new (opts?: { customAttribution?: string }) => unknown;
LngLatBounds: new () => LngLatBounds;
addProtocol(name: string, fn: unknown): void;
}
export interface PMTilesModule {
Protocol: new () => { tile: unknown };
}
export interface MapLibs {
maplibregl: MapLibreModule;
pmtiles: PMTilesModule;
}
let cached: MapLibs | null = null;
/** Inject a vendored script once, resolving when it has loaded. */
function loadScript(src: string): Promise<void> {
return new Promise((resolve, reject) => {
if (document.querySelector(`script[data-vendor="${src}"]`)) {
resolve();
return;
}
const s = document.createElement('script');
s.src = src;
s.async = true;
s.dataset.vendor = src;
s.addEventListener('load', () => resolve());
s.addEventListener('error', () => reject(new Error(`Failed to load ${src}`)));
document.head.appendChild(s);
});
}
/** Lazy-load MapLibre GL + pmtiles.js (+ MapLibre CSS) and read their globals. */
export async function loadMapLibs(): Promise<MapLibs> {
if (cached) return cached;
if (!document.querySelector('link[data-vendor="maplibre-css"]')) {
const l = document.createElement('link');
l.rel = 'stylesheet';
l.href = '/vendors/maplibre-gl.css';
l.dataset.vendor = 'maplibre-css';
document.head.appendChild(l);
}
await loadScript('/vendors/maplibre-gl.js');
await loadScript('/vendors/pmtiles.js');
const w = window as unknown as { maplibregl?: MapLibreModule; pmtiles?: PMTilesModule };
if (!w.maplibregl || !w.pmtiles) throw new Error('map libraries failed to initialise');
cached = { maplibregl: w.maplibregl, pmtiles: w.pmtiles };
return cached;
}