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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JW6ghFMDtnRYuYNzZhb47M
This commit is contained in:
@@ -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%);
|
||||
}
|
||||
@@ -36,6 +36,7 @@
|
||||
<link rel="stylesheet" href="/css/views/trash.css">
|
||||
<link rel="stylesheet" href="/css/views/photos.css">
|
||||
<link rel="stylesheet" href="/css/views/photosLightbox.css">
|
||||
<link rel="stylesheet" href="/css/views/places.css">
|
||||
<link rel="stylesheet" href="/css/views/music.css">
|
||||
|
||||
<!-- Scripts (defer: download in parallel, execute in order, after HTML parsed) -->
|
||||
@@ -57,6 +58,7 @@
|
||||
<script defer type="module" src="/js/features/library/favorites.js"></script>
|
||||
<script defer type="module" src="/js/features/library/recent.js"></script>
|
||||
<script defer type="module" src="/js/features/library/photos.js"></script>
|
||||
<script defer type="module" src="/js/features/library/places.js"></script>
|
||||
<script defer type="module" src="/js/features/library/music.js"></script>
|
||||
<script defer type="module" src="/js/features/sharing/fileSharing.js"></script>
|
||||
<script defer type="module" src="/js/model/recentModel.js"></script>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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 =
|
||||
`<button class="photos-subnav-tab active" type="button" data-ptab="moments">${this._esc(i18n.t('photos.tab_moments'))}</button>` +
|
||||
`<button class="photos-subnav-tab" type="button" data-ptab="places">${this._esc(i18n.t('photos.tab_places'))}</button>`;
|
||||
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 = '<div class="places-map" id="places-map"></div>' + '<div class="places-loading"><i class="fas fa-spinner"></i></div>';
|
||||
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 = `<div class="places-error">${this._esc(i18n.t('photos.map_error'))}</div>`;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/** Inject a vendored script once, resolving when it has loaded.
|
||||
* @param {string} src
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
_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 © <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">OpenStreetMap</a>'
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
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 = `<span class="places-cluster-count">${c.count}</span>`;
|
||||
}
|
||||
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;
|
||||
}
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user