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 = '
' + '