Files
spot/src/components/project.vue
2026-05-03 16:45:21 +02:00

687 lines
22 KiB
Vue

<script>
import 'maplibre-gl/dist/maplibre-gl.css';
import { Map, NavigationControl, Marker, LngLatBounds, LngLat, Popup } from 'maplibre-gl';
import { createApp, ref, provide, inject } from 'vue';
import Simplebar from 'simplebar-vue';
import lightbox from '../scripts/lightbox.js';
import { getOuterWidth } from '../scripts/common.js';
import SpotIcon from './spotIcon.vue';
import SpotIconStack from './spotIconStack.vue';
import SpotButton from './spotButton.vue';
import ProjectPost from './projectPost.vue';
import ProjectPopup from './projectPopup.vue';
export default {
components: {
SpotIcon,
SpotButton,
ProjectPost,
Simplebar
},
data() {
return {
server: this.consts.server,
feed: {loading:false, updatable:true, outOfData:false, refIdFirst:0, refIdLast:0, firstChunk:true},
refreshRate: 60,
lastUpdate: { unix_time: 0, relative_time: '', formatted_time: ''},
feedPanelOpen: false,
settingsPanelOpen: false,
track: null,
markers: [],
markerSize: {width: 32, height: 32},
markerProps: {
image: {mainClasses: 'media', iconMain: 'marker', iconSub: 'image'},
video: {mainClasses: 'media', iconMain: 'marker', iconSub: 'video'},
message: {mainClasses: 'message', iconMain: 'marker', iconSub: 'footprint', iconSubTransform: 'rotate-270'}
},
currProject: {},
modeHisto: false,
posts: [],
nlFeedbacks: [],
nlLoading: false,
baseMaps: {},
baseMap: null,
map: null,
hikes: {
colors:{'main':'#00ff78', 'off-track':'#0000ff', 'hitchhiking':'#FF7814'},
width: 4
},
popup: {content: null, element: null}
};
},
computed: {
projectClasses() {
return [
this.feedPanelOpen?'with-feed':'',
this.settingsPanelOpen?'with-settings':''
].filter(n => n).join(' ');
},
nlClasses() {
return [
this.nlAction,
this.nlLoading?'loading':''
].filter(n => n).join(' ');
},
subscribed() {
return this.user.id_user > 0;
},
nlAction() {
return this.subscribed?'unsubscribe':'subscribe';
}
},
watch: {
baseMap(sNewBaseMap, sOldBaseMap) {
if(this.map?.isStyleLoaded()) {
if(sOldBaseMap && this.map.getLayer(sOldBaseMap)) this.map.setLayoutProperty(sOldBaseMap, 'visibility', 'none');
if(sNewBaseMap && this.map.getLayer(sNewBaseMap)) this.map.setLayoutProperty(sNewBaseMap, 'visibility', 'visible');
}
},
'hash.items.0'(newProjectCodename, oldProjectCodename) {
if(newProjectCodename && newProjectCodename != oldProjectCodename) {
this.hash.items = [newProjectCodename];
this.toggleSettingsPanel(false, 'none');
this.init();
}
}
},
provide() {
return {
map: {
panToBetweenPanels: this.panToBetweenPanels,
openMarkerPopup: this.openMarkerPopup,
closePopup: this.closePopup,
isMarkerVisible: this.isMarkerVisible
},
project: this
};
},
inject: ['api', 'lang', 'hash', 'projects', 'user', 'consts', 'isMobile'],
beforeMount() {
if(this.hash.items.length == 0) this.hash.items[0] = this.projects.getDefaultCodeName();
},
mounted() {
window.addEventListener('resize', this.handleResize);
this.init();
},
beforeUnmount() {
window.removeEventListener('resize', this.handleResize);
this.quit();
},
methods: {
handleResize() {
//this.spot.tmp('map_offset', -1 * (this.feedPanelOpen?getOuterWidth(this.$refs.feed):0) / getOuterWidth(window));
/* TODO
if(typeof this.spot.tmp('elev') != 'undefined' && this.spot.tmp('elev')._showState) {
this.spot.tmp('elev').resize({width:this.getElevWidth()});
}
*/
},
async init() {
let bFirstLoad = (typeof this.currProject.codename == 'undefined');
this.initProject();
if(bFirstLoad) this.initLightbox();
await Promise.all([
this.initFeed(),
this.initMap()
]);
//Direct link post action
if(this.hash.items.length == 3) await this.findPost(this.hash.items[1], this.hash.items[2]);
},
quit() {
lightbox.end();
this.$refs.feedSimpleBar.scrollElement.removeEventListener('scroll', this.onFeedScroll);
this.setFeedUpdateTimer(-1);
this.map.remove();
},
initProject() {
this.currProject = this.projects[this.hash.items[0]];
this.modeHisto = (this.currProject.mode == this.consts.modes.histo);
this.feed = {loading:false, updatable:true, outOfData:false, refIdFirst:0, refIdLast:0, firstChunk:true};
this.posts = [];
//this.baseMap = null;
this.baseMaps = {};
},
initLightbox() {
lightbox.option({
alwaysShowNavOnTouchDevices: true,
albumLabel: 'Media %1 / %2',
fadeDuration: 300,
imageFadeDuration: 400,
positionFromTop: 0,
resizeDuration: 400,
hasVideo: true,
onMediaChange: (oMedia) => {
this.hash.items = [this.currProject.codename, 'media', oMedia.id];
if(oMedia.set == 'post-medias') this.goToPost('media', oMedia.id);
},
onClosing: () => {this.hash.items = [this.hash.items[0]];}
});
},
async initFeed() {
//Simplebar event
this.$refs.feedSimpleBar?.scrollElement.addEventListener('scroll', this.onFeedScroll);
//Mobile Touchscreen Events
//TODO
this.toggleFeedPanel(!this.isMobile(), 'none');
//Add post Event handling
//TODO
//Get first posts batch
await this.getNextFeed();
this.$refs.feedSimpleBar.scrollElement.scrollTop = 0;
//Start auto-update
if(!this.modeHisto) this.setFeedUpdateTimer(this.refreshRate);
},
async initMap() {
//Start async calls
[
{
maps: this.baseMaps,
markers: this.markers,
last_update: this.lastUpdate
},
this.track
] = await Promise.all([
this.api.get('markers', {id_project: this.currProject.id}),
this.api.get('geojson', {id_project: this.currProject.id})
]);
//Get default basemap
this.baseMap = this.baseMaps.find((asBM) => asBM.default_map)?.codename ?? null;
//Build Map
if(this.map) this.map.remove();
this.map = new Map({
container: 'map',
bounds: this.getInitialMapBounds(),
fitBoundsOptions: {
padding: {
top: 20,
bottom: 20,
left: 20,
right: 20 + (this.feedPanelOpen?(getOuterWidth(this.$refs.feed)):0)
},
animate: false,
maxZoom: 15
},
style: {
version: 8,
sources: {},
layers: []
},
attributionControl: false
});
//Force wait for load event
await new Promise((resolve) => {
if(this.map.loaded()) resolve();
else this.map.once('load', resolve);
});
//Base maps (raster tiles)
for(const asBaseMap of this.baseMaps) {
this.map.addSource(asBaseMap.codename, {
type: 'raster',
tiles: [asBaseMap.pattern],
tileSize: asBaseMap.tile_size
});
this.map.addLayer({
id: asBaseMap.codename,
type: 'raster',
source: asBaseMap.codename,
'layout': {'visibility': asBaseMap.codename == this.baseMap ? 'visible' : 'none'},
minZoom: asBaseMap.min_zoom,
maxZoom: asBaseMap.max_zoom
});
}
//Add track
this.addTrack(this.track);
//Add Markers
this.markers.forEach(oMarker => this.addMarker(oMarker));
//Force wait for idle event
await new Promise((resolve) => {
if(this.map.loaded() && this.map.areTilesLoaded()) resolve();
else this.map.once('idle', resolve);
});
},
addTrack(oTrack, bCenter=false) {
this.track = oTrack;
this.track.features.forEach((oFeature, iFeatureId) => {
oFeature.properties.track_id = iFeatureId;
});
this.map.addSource('track', {
'type': 'geojson',
'data': this.track
});
//Color mapping
let asColorMapping = ['match', ['get', 'type']];
for(const [sHikeType, sColor] of Object.entries(this.hikes.colors)) {
asColorMapping.push(sHikeType);
asColorMapping.push(sColor);
}
asColorMapping.push('black'); //fallback value
//Track layer
this.map.addLayer({
'id': 'track',
'type': 'line',
'source': 'track',
'layout': {
'line-join': 'round',
'line-cap': 'round'
},
'paint': {
'line-color': asColorMapping,
'line-width': this.hikes.width
}
});
//Enlarged track (click hit box)
this.map.addLayer({
'id': 'track-hitbox',
'type': 'line',
'source': 'track',
'paint': {
'line-opacity': 0,
'line-width': this.hikes.width + 20
}
});
this.map.on('click', 'track-hitbox', this.openTrackPopup);
this.map.on('mouseenter', 'track-hitbox', () => {this.map.getCanvas().style.cursor = 'pointer';});
this.map.on('mouseleave', 'track-hitbox', () => {this.map.getCanvas().style.cursor = '';});
},
openTrackPopup(oEvent) {
this.closePopup();
this.openPopup({
lnglat: oEvent.lngLat,
options: this.projects.getTrackInfo(oEvent.features[0], this.track, this.lang),
});
},
addMarker(oMarker) {
const $Marker = document.createElement('div');
createApp(SpotIconStack, this.markerProps[oMarker.subtype]).mount($Marker);
new Marker({element: $Marker, anchor: 'bottom'})
.setLngLat([oMarker.longitude, oMarker.latitude])
.addTo(this.map)
.getElement()
.addEventListener('click', (oEvent) => {
oEvent.preventDefault();
oEvent.stopPropagation();
this.openMarkerPopup(oMarker.id, oMarker.type);
});
},
openMarkerPopup(iMarkerId, sMarkerType) {
this.closePopup();
let oMarker = this.markers.find((oCandidate) => oCandidate.id == iMarkerId && oCandidate.type == sMarkerType);
this.openPopup({
offset: [0, this.markerSize.height * -1], //FIXME
lnglat: [oMarker.longitude, oMarker.latitude],
options: oMarker
});
},
openPopup({offset=[0, 0], lnglat, options={}} = {}) {
const $Popup = document.createElement('div');
this.popup.element = new Popup({
anchor: 'bottom',
offset: offset,
closeButton: false
})
.setDOMContent($Popup)
.setLngLat(lnglat)
.addTo(this.map);
this.popup.content = createApp(ProjectPopup, {
options: options,
project: this.currProject
});
this.popup.content
.provide('spot', this.spot)
.provide('lang', this.lang)
.provide('consts', this.consts)
.mount($Popup);
},
closePopup() {
if(this.popup.content) {
this.popup.content.unmount();
this.popup.content = null;
}
if(this.popup.element) {
this.popup.element.remove();
this.popup.element = null;
}
},
getInitialMapBounds() {
let oBounds = new LngLatBounds();
let oHashMarker;
if(this.hash.items.length == 3) {
oHashMarker = this.markers.find((oMarker) => (
oMarker.type == this.hash.items[1] &&
oMarker.id == this.hash.items[2] &&
oMarker.longitude != null &&
oMarker.latitude != null
)) || null;
}
if(oHashMarker) { //Direct link to marker
oBounds.extend(new LngLat(oHashMarker.longitude, oHashMarker.latitude));
}
else if( //Blog Mode: Fit to last message
this.currProject.mode == this.consts.modes.blog &&
this.markers.length > 0
) {
let oLastMsg = this.markers.at(-1);
oBounds.extend(new LngLat(oLastMsg.longitude, oLastMsg.latitude));
}
else { //Pre/Histo Mode: Fit to track
for(const iFeatureId in this.track.features) {
oBounds = this.track.features[iFeatureId].geometry.coordinates.reduce(
(bounds, coord) => {
return bounds.extend(coord);
},
oBounds
);
}
}
return oBounds;
},
async findPost(sPostType, iPostId) {
let oRef = this.goToPost(sPostType, iPostId);
if(oRef) {
await oRef.executeMainAction(0);
}
else if(!this.feed.outOfData) {
await this.getNextFeed();
await this.findPost(sPostType, iPostId);
}
else console.log('Missing element ID "'+iPostId+'" of type "'+sPostType+'"');
},
goToPost(sPostType, iPostId) {
let bFound = false;
let aoRefs = this.$refs.posts.filter((post) => {return post.postId == sPostType+'-'+iPostId;});
if(aoRefs.length > 0) {
let oRef = aoRefs[0];
this.$refs.feedSimpleBar.scrollElement.scrollTop += Math.round(
oRef.$el.getBoundingClientRect().top
+ window.pageYOffset
- parseFloat(getComputedStyle(this.$refs.feedSimpleBar.$el).paddingTop)
);
//this.hash.items = [this.hash.items[0]];
return oRef;
}
},
async getNextFeed() {
if(!this.feed.outOfData && !this.feed.loading) {
//Get next chunk
this.feed.loading = true;
let aoData = await this.api.get('next_feed', {id_project: this.currProject.id, id: this.feed.refIdLast});
let iPostCount = Object.keys(aoData.feed).length;
//Update pointers
this.feed.outOfData = (iPostCount < this.consts.chunk_size);
if(iPostCount > 0) {
this.feed.refIdLast = aoData.ref_id_last;
if(this.feed.firstChunk) this.feed.refIdFirst = aoData.ref_id_first;
}
//Add posts
this.posts.push(...aoData.feed);
this.feed.loading = false;
this.feed.firstChunk = false;
}
return true;
},
onFeedScroll(oEvent) {
const box = oEvent.currentTarget
const content = box.querySelector('.simplebar-content')
if ((box.scrollTop + box.clientHeight) / (content?.offsetHeight || 1) >= 0.8) this.getNextFeed();
},
setFeedUpdateTimer(iSeconds) {
if(typeof this.feedTimer != 'undefined') clearTimeout(this.feedTimer);
if(iSeconds >= 0) this.feedTimer = setTimeout(this.checkNewFeed, iSeconds * 1000);
},
async checkNewFeed() {
let aoData = await this.api.get('new_feed', {id_project: this.currProject.id, id: this.feed.refIdFirst});
const aoFeed = aoData.feed || [];
const aoMarkers = aoData.markers || [];
if(aoFeed.length > 0) {
//Update pointer
this.feed.refIdFirst = aoData.ref_id_first;
//Add new posts
this.posts.unshift(...aoFeed);
}
//Add new Markers
if(aoMarkers.length > 0) {
this.markers.push(...aoMarkers);
aoMarkers.forEach(oMarker => this.addMarker(oMarker));
}
//Message Last Update
this.lastUpdate = aoData.last_update;
//Reschedule
this.setFeedUpdateTimer(this.refreshRate);
},
panToBetweenPanels(oLngLat, iZoom, iAnimDuration=500) {
const iXOffset = (this.settingsPanelOpen?getOuterWidth(this.$refs.settings):0) - (this.feedPanelOpen?getOuterWidth(this.$refs.feed):0);
return new Promise((resolve) => {
this.map.once('moveend', resolve);
this.map.easeTo({
center: oLngLat,
zoom: iZoom,
offset: [iXOffset / 2, 0],
duration: iAnimDuration
});
});
},
isMarkerVisible(oLngLat){
return this.map.getBounds().contains(oLngLat);
},
getGoogleMapsLink(asInfo) {
return $('<a>', {
href:'https://www.google.com/maps/place/'+asInfo.lat_dms+'+'+asInfo.lon_dms+'/@'+asInfo.latitude+','+asInfo.longitude+',10z',
title: this.lang.get('map.see_on_google'),
target: '_blank',
rel: 'noreferrer noopener'
}).text(asInfo.lat_dms+' '+asInfo.lon_dms);
},
async manageSubs() {
var regexEmail = /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
if(!regexEmail.test(this.user.email)) this.nlFeedbacks.push({type:'error', 'msg':this.lang.get('newsletter.invalid_email')});
else {
this.api.get(this.nlAction, {'email': this.user.email, 'name': this.user.name})
.then((asUser, sDesc) => {
this.nlFeedbacks.push('success', sDesc);
Object.assign(this.user, asUser);
})
.catch((sDesc) => {this.nlFeedbacks.push('error', sDesc);});
}
},
toggleFeedPanel(bShow, sMapAction) {
let bOldValue = this.feedPanelOpen;
this.feedPanelOpen = (typeof bShow === 'object' || typeof bShow === 'undefined')?(!this.feedPanelOpen):bShow;
if(bOldValue != this.feedPanelOpen && !this.isMobile()) {
this.handleResize();
sMapAction = sMapAction || 'panTo';
switch(sMapAction) {
case 'none':
break;
case 'panTo':
this.map.panBy(
[(this.feedPanelOpen?1:-1) * getOuterWidth(this.$refs.feed) / 2, 0],
{duration: 500}
);
break;
case 'panToInstant':
this.map.panBy([(this.feedPanelOpen?1:-1) * getOuterWidth(this.$refs.feed) / 2, 0]);
break;
case 'fitBounds':
/*
this.map.fitBounds(
this.spot.tmp('track').getBounds(),
{
paddingTopLeft: L.point(5, this.spot.tmp('marker_size').height + 5),
paddingBottomRight: L.point(this.spot.tmp('$Feed').outerWidth(true) + 5, 5)
}
);
break;
*/
}
}
},
toggleSettingsPanel(bShow, sMapAction) {
let bOldValue = this.settingsPanelOpen;
this.settingsPanelOpen = (typeof bShow === 'object' || typeof bShow === 'undefined')?(!this.settingsPanelOpen):bShow;
if(bOldValue != this.settingsPanelOpen && !this.isMobile()) {
this.handleResize();
sMapAction = sMapAction || 'panTo';
switch(sMapAction) {
case 'none':
break;
case 'panTo':
this.map.panBy(
[(this.settingsPanelOpen?-1:1) * getOuterWidth(this.$refs.settings) / 2, 0],
{duration: 500}
);
break;
case 'panToInstant':
this.map.panBy([(this.settingsPanelOpen?-1:1) * getOuterWidth(this.$refs.settings) /2, 0]);
break;
}
}
}
}
}
</script>
<template>
<div id="projects" :class="projectClasses">
<div id="background"></div>
<div id="submap">
<div class="loader">
<SpotIcon :icon="'map'" :classes="'flicker'" width="fixed" />
</div>
</div>
<div id="map"></div>
<div id="settings" class="map-container map-container-left" ref="settings">
<div id="settings-panel" class="map-panel">
<div class="settings-header">
<div class="logo"><img width="289" height="72" src="images/logo_black.png" alt="Spotty" /></div>
<div id="last_update" v-if="this.currProject.mode == this.consts.modes.blog && lastUpdate.unix_time > 0">
<p><span><img src="images/spot-logo-only.svg" alt="" /></span><abbr :title="lastUpdate.formatted_time">{{ lang.get('feed.last_update')+' '+lastUpdate.relative_time }}</abbr></p>
</div>
</div>
<div class="settings-sections">
<Simplebar id="settings-sections-scrollbox">
<div class="settings-section">
<h1><SpotIcon :icon="'project'" width="fixed" :text="lang.get('project.hikes')" /></h1>
<div class="settings-section-body">
<div class="radio" v-for="project in projects" :key="'project-'+project.id">
<input type="radio" :id="'project-'+project.id" :value="project.codename" v-model="$parent.hash.items[0]" />
<label :for="'project-'+project.id">
<span>{{ project.name }}</span>
<a class="download" :href="project.gpxfilepath" :download="project.codename + '.gpx'" :title="lang.get('track.download')" @click.stop="()=>{}">
<SpotIcon :icon="'download'" margin="left" />
</a>
</label>
</div>
</div>
</div>
<div class="settings-section">
<h1><SpotIcon :icon="'map'" width="fixed" :text="lang.get('map.title')" /></h1>
<div class="settings-section-body">
<div class="radio" v-for="bm in baseMaps" :key="'map-'+bm.id_map">
<input type="radio" :id="'map-'+bm.id_map" :value="bm.codename" v-model="baseMap" />
<label :for="'map-'+bm.id_map">{{ lang.get('map.'+bm.codename) }}</label>
</div>
</div>
</div>
<div class="settings-section newsletter">
<h1><SpotIcon :icon="'newsletter'" width="fixed" :text="lang.get('newsletter.title')" /></h1>
<div class="newsletter-form">
<input type="email" name="email" id="email" :placeholder="lang.get('newsletter.email_placeholder')" v-model="user.email" :disabled="nlLoading || subscribed" />
<SpotButton id="nl_btn" :classes="nlClasses" :title="lang.get('newsletter.'+nlAction)" :icon="nlAction" @click="manageSubs" />
</div>
<div id="settings-feedback" class="feedback">
<p v-for="feedback in nlFeedbacks" :class="feedback.type">
<SpotIcon :icon="feedback.type" :text="feedback.msg" />
</p>
</div>
{{ lang.get('newsletter.'+(subscribed?'subscribed':'unsubscribed')+'_desc') }}
</div>
<div class="settings-section admin" v-if="user.hasClearance(consts.clearances.admin)">
<h1><SpotIcon :icon="'admin'" width="fixed" :text="lang.get('admin.title')" /></h1>
<div class="admin-actions">
<a class="button" href="#admin"><SpotIcon :icon="'config'" :text="lang.get('admin.config')" /></a>
<a class="button" href="#upload"><SpotIcon :icon="'upload'" :text="lang.get('admin.upload')" /></a>
</div>
</div>
</Simplebar>
</div>
<div class="settings-footer">
<a href="https://git.lutran.fr/franzz/spot" :title="lang.get('credits.git')" target="_blank" rel="noopener">
<SpotIcon :icon="'credits'" :text="lang.get('credits.project')" />
</a> {{ lang.get('credits.license') }}</div>
</div>
<div :class="'map-control map-control-icon settings-control map-control-'+(isMobile()?'bottom':'top')" @click="toggleSettingsPanel">
<SpotIcon :icon="settingsPanelOpen?'prev':'menu'" />
</div>
<div v-if="!isMobile()" id="legend" class="map-control settings-control map-control-bottom">
<div v-for="(color, hikeType) in hikes.colors" class="track">
<span class="line" :style="'background-color:'+color+'; height:'+hikes.width+'px;'"></span>
<span class="desc">{{ lang.get('track.'+hikeType) }}</span>
</div>
</div>
<div id="title" :class="'map-control settings-control map-control-'+(isMobile()?'bottom':'top')">
<span>{{ currProject.name }}</span>
</div>
</div>
<div id="feed" class="map-container map-container-right" ref="feed">
<Simplebar id="feed-panel" class="map-panel" ref="feedSimpleBar">
<div id="feed-header">
<ProjectPost v-if="modeHisto" :options="{type: 'archived', headerless: true}" />
<ProjectPost v-else :options="{type: 'poster', relative_time: lang.get('post.new_message')}" />
</div>
<div id="feed-posts">
<ProjectPost v-for="post in posts" :options="post" ref="posts" />
</div>
<div id="feed-footer" v-if="feed.loading">
<ProjectPost :options="{type: 'loading', headerless: true}" />
</div>
</Simplebar>
<div :class="'map-control map-control-icon feed-control map-control-'+(isMobile()?'bottom':'top')" @click="toggleFeedPanel">
<SpotIcon :icon="feedPanelOpen?'next':'post'" />
</div>
</div>
</div>
</template>