From 513b6224b4e4d76b7e4babdc8965bb1b9c2ae001 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 11:17:56 +0000 Subject: [PATCH] feat(places): photo map view (MapLibre + PMTiles, Approach A) Adds a Places tab to the Photos section: a self-hosted MapLibre GL map of the user's geotagged photos. - places.js: lazy-loads vendored MapLibre + pmtiles.js on first open; draws the server-aggregated clusters (GET /api/photos/geo) as HTML thumbnail markers (no glyphs/sprites, no client-side clustering); pan/zoom refetches for the new viewport; click a cluster to zoom in, or a single photo to open it in the lightbox. - Optional vector basemap read directly from /basemaps/basemap.pmtiles over HTTP Range (label-light Protomaps style, light/dark aware); falls back to a themed background when no basemap is present. ODbL attribution shown. - "Moments | Places" sub-nav wired into the Photos section. - enable_places now defaults on, since the route + UI are ready. No new backend serving code: tower-http ServeDir already serves static/ with Range, so the .pmtiles basemap is just a static file. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JW6ghFMDtnRYuYNzZhb47M --- src/common/config.rs | 2 +- static/css/views/places.css | 90 +++++++ static/index.html | 2 + static/js/app/navigation.js | 5 +- static/js/features/library/places.js | 377 +++++++++++++++++++++++++++ static/locales/en.json | 6 +- 6 files changed, 479 insertions(+), 3 deletions(-) create mode 100644 static/css/views/places.css create mode 100644 static/js/features/library/places.js diff --git a/src/common/config.rs b/src/common/config.rs index 0a1147c9..57414c63 100644 --- a/src/common/config.rs +++ b/src/common/config.rs @@ -895,7 +895,7 @@ impl Default for FeaturesConfig { enable_trash: true, // Enable trash feature enable_search: true, // Enable search feature enable_music: true, // Enable music feature - enable_places: false, // Photo map; off until the map UI ships + enable_places: true, // Photo map (GET /api/photos/geo + Places tab) expose_system_users: true, // Expose OxiCloud users as address book by default } } diff --git a/static/css/views/places.css b/static/css/views/places.css new file mode 100644 index 00000000..83d6502f --- /dev/null +++ b/static/css/views/places.css @@ -0,0 +1,90 @@ +/* Photos sub-navigation (Moments | Places) */ +.photos-subnav { + display: flex; + gap: var(--space-1); + padding: var(--space-2) var(--space-2) 0; +} + +.photos-subnav.hidden { + display: none; +} + +.photos-subnav-tab { + background: none; + border: none; + padding: var(--space-2) var(--space-3); + font-size: var(--text-base); + font-weight: var(--weight-medium); + color: var(--color-text-faint); + cursor: pointer; + border-radius: var(--radius-md); + border-bottom: 2px solid transparent; +} + +.photos-subnav-tab:hover { + color: var(--color-text); +} + +.photos-subnav-tab.active { + color: var(--color-accent); + border-bottom-color: var(--color-accent); +} + +/* Map view */ +.places-container { + display: none; +} + +.places-container.active { + display: flex; + flex-direction: column; + height: calc(100vh - 150px); + min-height: 360px; + padding: var(--space-2); +} + +.places-map { + flex: 1 1 auto; + width: 100%; + border-radius: var(--radius-2xl); + overflow: hidden; +} + +.places-loading, +.places-error { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + color: var(--color-text-faint); +} + +.places-loading i { + animation: spin 1s linear infinite; +} + +/* Cluster markers — a circular photo thumbnail with a count badge */ +.places-cluster { + background-size: cover; + background-position: center; + background-color: var(--color-bg-muted); + border-radius: 50%; + border: 2px solid var(--color-bg-surface); + box-shadow: 0 2px 8px var(--color-shadow-sm); + cursor: pointer; + display: flex; + align-items: flex-end; + justify-content: center; +} + +.places-cluster-count { + background: var(--color-accent); + color: var(--color-danger-text); + font-size: var(--text-2xs); + font-weight: var(--weight-bold); + line-height: 1; + padding: var(--space-0-5) var(--space-1-5); + border-radius: var(--radius-full); + transform: translateY(35%); +} diff --git a/static/index.html b/static/index.html index da1a4295..b2a37ff8 100644 --- a/static/index.html +++ b/static/index.html @@ -36,6 +36,7 @@ + @@ -57,6 +58,7 @@ + diff --git a/static/js/app/navigation.js b/static/js/app/navigation.js index ecf5304e..0f8ac042 100644 --- a/static/js/app/navigation.js +++ b/static/js/app/navigation.js @@ -10,6 +10,7 @@ import { batchToolbar } from '../features/files/batchToolbar.js'; import { favorites } from '../features/library/favorites.js'; import { musicView } from '../features/library/music.js'; import { photosView } from '../features/library/photos.js'; +import { placesView } from '../features/library/places.js'; import { grants } from '../model/grants.js'; import { favoritesView } from '../views/favorites/favoritesView.js'; import { mySharesView } from '../views/myShares/mySharesView.js'; @@ -225,9 +226,10 @@ function setCurrentSection(section) { // Reset owner column — sections that need it re-enable it explicitly below. ui.setOwnerColumnVisible(false); - // Hide photosView when switching to any other section + // Hide photosView (+ the Places sub-view) when switching to any other section if (section !== 'photos' && photosView) { photosView.hide(); + placesView.unmountTabs(); } // Hide musicView when switching to any other section @@ -451,6 +453,7 @@ function switchToPhotosSection() { if (photosView) { photosView.show(); } + placesView.mountTabs(); if (batchToolbar) batchToolbar.clear(); } diff --git a/static/js/features/library/places.js b/static/js/features/library/places.js new file mode 100644 index 00000000..6e11d5b1 --- /dev/null +++ b/static/js/features/library/places.js @@ -0,0 +1,377 @@ +/** + * OxiCloud - Places (photo map) + * + * Renders the user's geotagged photos on a self-hosted MapLibre GL map. + * Photos are clustered *server-side* (GET /api/photos/geo, grid aggregation), + * so we draw one lightweight HTML marker per cluster — no glyph/sprite assets + * and no client-side clustering needed. The vector basemap is optional: if a + * `static/basemaps/basemap.pmtiles` is present it is read directly by the + * browser over HTTP Range (pmtiles.js); otherwise the map falls back to a + * plain themed background and still shows the photo clusters. + * + * MapLibre + pmtiles.js are heavy, so they are vendored and lazy-loaded only + * when the Places tab is first opened. + */ + +import { getCsrfHeaders } from '../../core/csrf.js'; +import { i18n } from '../../core/i18n.js'; +import { photosView } from './photos.js'; +import { photosLightbox } from './photosLightbox.js'; + +/** @import {FileItem} from '../../core/types.js' */ +/** @typedef {{lng: number, lat: number, count: number, sample_file_id: string}} GeoClusterItem */ + +const BASEMAP_URL = '/basemaps/basemap.pmtiles'; + +export const placesView = { + /** @type {HTMLElement|null} */ + _container: null, + /** @type {HTMLElement|null} */ + _subnav: null, + /** @type {any} MapLibre Map instance */ + _map: null, + /** @type {any[]} current cluster markers */ + _markers: [], + /** @type {{maplibregl: any, pmtiles: any}|null} */ + _libs: null, + /** @type {number} debounce timer for moveend refresh */ + _moveTimer: 0, + /** @type {'moments'|'places'} */ + _activeTab: 'moments', + /** @type {boolean|null} cached basemap availability */ + _hasBasemap: null, + + /** Auth headers (HttpOnly cookies + CSRF) */ + _headers() { + return getCsrfHeaders(); + }, + + // ── Sub-navigation (Moments | Places) ─────────────────────────── + // Lives at the top of `.content-area`; mounted while the Photos section + // is active and torn down (hidden) when the user leaves it. + + /** Create/show the Moments|Places tab bar and the map container. */ + mountTabs() { + const contentArea = document.querySelector('.content-area'); + if (!contentArea) return; + + if (!this._subnav) { + const bar = document.createElement('div'); + bar.className = 'photos-subnav'; + bar.innerHTML = + `` + + ``; + bar.addEventListener('click', (e) => { + const btn = /** @type {HTMLElement} */ (e.target).closest('[data-ptab]'); + if (btn) this._switchTab(/** @type {'moments'|'places'} */ (btn.getAttribute('data-ptab'))); + }); + contentArea.insertBefore(bar, contentArea.firstChild); + this._subnav = bar; + } + this._subnav.classList.remove('hidden'); + + if (!this._container) { + const el = document.createElement('div'); + el.id = 'places-container'; + el.className = 'places-container'; + contentArea.appendChild(el); + this._container = el; + } + + // Always (re)enter the Photos section on the Moments tab. + this._activeTab = 'moments'; + this._setActiveTab('moments'); + this.hide(); + }, + + /** Hide the tab bar and the map (called when leaving the Photos section). */ + unmountTabs() { + this._subnav?.classList.add('hidden'); + this.hide(); + }, + + /** Hide the map container (without destroying the map). */ + hide() { + this._container?.classList.remove('active'); + }, + + /** + * @param {'moments'|'places'} tab + */ + _switchTab(tab) { + if (tab === this._activeTab) return; + this._activeTab = tab; + this._setActiveTab(tab); + if (tab === 'places') { + photosView.hide(); + this._showMap(); + } else { + this.hide(); + photosView.show(); + } + }, + + /** @param {string} tab */ + _setActiveTab(tab) { + this._subnav?.querySelectorAll('[data-ptab]').forEach((b) => { + b.classList.toggle('active', b.getAttribute('data-ptab') === tab); + }); + }, + + // ── Map ───────────────────────────────────────────────────────── + + /** Reveal the map container and (lazily) build the map. */ + async _showMap() { + if (!this._container) return; + this._container.classList.add('active'); + + if (this._map) { + this._map.resize(); + this._refreshClusters(false); + return; + } + + this._container.innerHTML = '
' + '
'; + try { + const libs = await this._loadLibs(); + await this._initMap(libs); + } catch (err) { + console.error('Places map failed to load:', err); + if (this._container) { + this._container.innerHTML = `
${this._esc(i18n.t('photos.map_error'))}
`; + } + } + }, + + /** Inject a vendored script once, resolving when it has loaded. + * @param {string} src + * @returns {Promise} + */ + _loadScript(src) { + 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. */ + async _loadLibs() { + if (this._libs) return this._libs; + if (!document.querySelector('link[data-vendor="maplibre-css"]')) { + const l = document.createElement('link'); + l.rel = 'stylesheet'; + l.href = '/js/vendors/maplibre-gl.css'; + l.dataset.vendor = 'maplibre-css'; + document.head.appendChild(l); + } + await this._loadScript('/js/vendors/maplibre-gl.js'); + await this._loadScript('/js/vendors/pmtiles.js'); + const w = /** @type {any} */ (window); + this._libs = { maplibregl: w.maplibregl, pmtiles: w.pmtiles }; + return this._libs; + }, + + /** Whether a basemap .pmtiles is available (cached after first probe). */ + async _checkBasemap() { + if (this._hasBasemap !== null) return this._hasBasemap; + try { + const res = await fetch(BASEMAP_URL, { headers: { Range: 'bytes=0-0' } }); + this._hasBasemap = res.ok; // 200/206 = present, 404 = absent + } catch { + this._hasBasemap = false; + } + return this._hasBasemap; + }, + + /** + * @param {{maplibregl: any, pmtiles: any}} libs + */ + async _initMap({ maplibregl, pmtiles }) { + const hasBasemap = await this._checkBasemap(); + if (hasBasemap) { + try { + const protocol = new pmtiles.Protocol(); + maplibregl.addProtocol('pmtiles', protocol.tile); + } catch (e) { + console.error('pmtiles protocol registration failed:', e); + } + } + + this._map = new maplibregl.Map({ + container: 'places-map', + style: hasBasemap ? this._basemapStyle() : this._blankStyle(), + center: [0, 25], + zoom: 1.3, + attributionControl: false + }); + this._map.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-right'); + if (hasBasemap) { + this._map.addControl( + new maplibregl.AttributionControl({ + customAttribution: 'Protomaps © OpenStreetMap' + }) + ); + } + + this._map.on('load', () => { + this._removeLoading(); + this._refreshClusters(true); + }); + this._map.on('moveend', () => { + clearTimeout(this._moveTimer); + this._moveTimer = window.setTimeout(() => this._refreshClusters(false), 250); + }); + }, + + _removeLoading() { + this._container?.querySelector('.places-loading')?.remove(); + }, + + /** Fetch clusters for the current viewport and render them. + * @param {boolean} fit Fit the map to the returned clusters (first load). + */ + async _refreshClusters(fit) { + if (!this._map) return; + const b = this._map.getBounds(); + const bbox = `${b.getWest()},${b.getSouth()},${b.getEast()},${b.getNorth()}`; + const zoom = Math.round(this._map.getZoom()); + try { + const res = await fetch(`/api/photos/geo?bbox=${bbox}&zoom=${zoom}`, { + credentials: 'include', + headers: this._headers() + }); + if (!res.ok) return; + /** @type {GeoClusterItem[]} */ + const clusters = await res.json(); + this._renderMarkers(clusters); + if (fit && clusters.length) this._fitTo(clusters); + } catch (err) { + console.error('Places geo fetch failed:', err); + } + }, + + /** @param {GeoClusterItem[]} clusters */ + _renderMarkers(clusters) { + for (const m of this._markers) m.remove(); + this._markers = []; + if (!this._libs) return; + const { maplibregl } = this._libs; + + for (const c of clusters) { + const size = Math.round(Math.min(64, 30 + Math.log2(c.count + 1) * 6)); + const el = document.createElement('div'); + el.className = 'places-cluster'; + el.style.width = `${size}px`; + el.style.height = `${size}px`; + el.style.backgroundImage = `url(/api/files/${c.sample_file_id}/thumbnail/icon)`; + if (c.count > 1) { + el.innerHTML = `${c.count}`; + } + el.addEventListener('click', () => this._onClusterClick(c)); + const marker = new maplibregl.Marker({ element: el }).setLngLat([c.lng, c.lat]).addTo(this._map); + this._markers.push(marker); + } + }, + + /** @param {GeoClusterItem} c */ + _onClusterClick(c) { + const zoom = this._map.getZoom(); + if (c.count === 1 || zoom >= 16) { + // Drill down to the representative photo. We only know its id, so + // build a minimal item and let the lightbox load the rest. + const item = /** @type {FileItem} */ ( + /** @type {any} */ ({ + id: c.sample_file_id, + name: '', + mime_type: 'image/jpeg', + created_at: 0, + sort_date: 0, + size_formatted: '' + }) + ); + photosLightbox.open([item], 0); + } else { + this._map.easeTo({ center: [c.lng, c.lat], zoom: Math.min(zoom + 2.5, 17) }); + } + }, + + /** @param {GeoClusterItem[]} clusters */ + _fitTo(clusters) { + if (!this._libs) return; + const { maplibregl } = this._libs; + const bounds = new maplibregl.LngLatBounds(); + for (const c of clusters) bounds.extend([c.lng, c.lat]); + if (!bounds.isEmpty()) { + this._map.fitBounds(bounds, { padding: 64, maxZoom: 14, duration: 0 }); + } + }, + + /** @returns {boolean} */ + _isDark() { + return document.documentElement.getAttribute('data-color-scheme') === 'dark'; + }, + + /** Minimal MapLibre style: themed background only (no basemap). */ + _blankStyle() { + return { + version: 8, + sources: {}, + layers: [ + { + id: 'bg', + type: 'background', + paint: { 'background-color': this._isDark() ? '#0f172a' : '#e8eef3' } + } + ] + }; + }, + + /** Label-light Protomaps vector style (no glyphs/sprites required). */ + _basemapStyle() { + const dark = this._isDark(); + const c = dark + ? { earth: '#1b2433', land: '#222d3d', water: '#0d1b2a', roads: '#3a4860', buildings: '#2a3547', boundary: '#475569' } + : { earth: '#f3efe9', land: '#e9e4da', water: '#a8c8e8', roads: '#ffffff', buildings: '#e0dccf', boundary: '#c9c2b6' }; + return { + version: 8, + sources: { + protomaps: { + type: 'vector', + url: `pmtiles://${BASEMAP_URL}`, + attribution: 'Protomaps © OpenStreetMap' + } + }, + layers: [ + { id: 'bg', type: 'background', paint: { 'background-color': c.earth } }, + { id: 'earth', type: 'fill', source: 'protomaps', 'source-layer': 'earth', paint: { 'fill-color': c.earth } }, + { id: 'landuse', type: 'fill', source: 'protomaps', 'source-layer': 'landuse', paint: { 'fill-color': c.land, 'fill-opacity': 0.6 } }, + { id: 'water', type: 'fill', source: 'protomaps', 'source-layer': 'water', paint: { 'fill-color': c.water } }, + { id: 'roads', type: 'line', source: 'protomaps', 'source-layer': 'roads', minzoom: 7, paint: { 'line-color': c.roads, 'line-width': 0.8 } }, + { id: 'buildings', type: 'fill', source: 'protomaps', 'source-layer': 'buildings', minzoom: 13, paint: { 'fill-color': c.buildings } }, + { + id: 'boundaries', + type: 'line', + source: 'protomaps', + 'source-layer': 'boundaries', + paint: { 'line-color': c.boundary, 'line-width': 0.6, 'line-dasharray': [2, 2] } + } + ] + }; + }, + + /** @param {any} s */ + _esc(s) { + const d = document.createElement('div'); + d.textContent = s; + return d.innerHTML; + } +}; diff --git a/static/locales/en.json b/static/locales/en.json index 32a0efca..ce783528 100644 --- a/static/locales/en.json +++ b/static/locales/en.json @@ -73,7 +73,11 @@ "delete_selected_confirm": "Move the selected items to Trash?", "delete_one_confirm": "Move \"{{name}}\" to Trash?", "layout_square": "Grid", - "layout_justified": "Justified" + "layout_justified": "Justified", + "tab_moments": "Moments", + "tab_places": "Places", + "map_loading": "Loading map…", + "map_error": "Could not load the map" }, "music": { "create_playlist": "Create Playlist",