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:
Vendored
+95
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user