Files
spot/src/components/project.vue
2026-01-10 21:09:14 +01:00

590 lines
19 KiB
Vue

<script>
import 'maplibre-gl/dist/maplibre-gl.css';
import { Map, NavigationControl, Marker, LngLatBounds, LngLat, Popup } from 'maplibre-gl';
import { createApp, defineComponent, nextTick, ref, defineCustomElement, provide, inject } from 'vue';
import simplebar from 'simplebar-vue';
import autosize from 'autosize';
import mousewheel from 'jquery-mousewheel';
import waitforimages from 'jquery.waitforimages';
import lightbox from '../scripts/lightbox.js';
//import SimpleBar from 'simplebar';
import SpotIcon from './spotIcon.vue';
import SpotButton from './spotButton.vue';
import ProjectPost from './projectPost.vue';
import ProjectPopup from './projectPopup.vue';
export default {
components: {
SpotIcon,
SpotButton,
ProjectPost,
ProjectPopup,
simplebar
},
data() {
return {
server: this.spot.consts.server,
feed: {loading:false, updatable:true, outOfData:false, refIdFirst:0, refIdLast:0, firstChunk:true},
feedPanelOpen: false,
feedSimpleBar: null,
settingsPanelOpen: false,
markerSize: {width: 32, height: 32},
project: {},
projectCodename: null,
modeHisto: false,
posts: [],
nlFeedbacks: [],
nlLoading: false,
baseMaps: {},
baseMap: null,
messages: null,
map: null,
hikes: {
colors:{'main':'#00ff78', 'off-track':'#0000ff', 'hitchhiking':'#FF7814'},
width: 4
}
};
},
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';
},
mobile() {
return this.spot.isMobile();
}
},
watch: {
baseMap(sNewBaseMap, sOldBaseMap) {
if(sOldBaseMap) this.map.setLayoutProperty(sOldBaseMap, 'visibility', 'none');
if(sNewBaseMap) this.map.setLayoutProperty(sNewBaseMap, 'visibility', 'visible');
},
projectCodename(sNewCodeName, sOldCodeName) {
//console.log('change in projectCodename: '+sNewCodeName);
//this.toggleSettingsPanel(false);
this.$parent.setHash(this.$parent.hash.page, [sNewCodeName]);
this.init();
}
},
provide() {
return {
project: this.project
};
},
inject: ['spot', 'projects', 'user'],
mounted() {
this.spot.addPage('project', {
onResize: () => {
//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()});
}
*/
}
});
this.projectCodename = (this.$parent.hash.items.length==0)?this.spot.vars('default_project_codename'):this.$parent.hash.items[0];
},
methods: {
init() {
let bFirstLoad = (typeof this.project.codename == 'undefined');
this.initProject();
if(bFirstLoad) this.initLightbox();
this.initFeed();
this.initMap();
},
initProject() {
this.project = this.projects[this.projectCodename];
this.modeHisto = (this.project.mode == this.spot.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: '<i class="fa fa-fw fa-lg fa-media push"></i> %1 / %2',
fadeDuration: 300,
imageFadeDuration: 400,
positionFromTop: 0,
resizeDuration: 400,
hasVideo: true,
onMediaChange: (oMedia) => {
this.spot.updateHash('media', oMedia.id);
if(oMedia.set == 'post-medias') this.goToPost({type: 'media', id: oMedia.id});
},
onClosing: () => {this.spot.flushHash();}
});
},
async initFeed() {
//Simplebar event
this.$refs.feedSimpleBar.scrollElement.addEventListener('scroll', (oEvent) => {this.onFeedScroll(oEvent);});
//Mobile Touchscreen Events
//TODO
//Add post Event handling
//TODO
await this.getNextFeed();
//Scroll to post
if(this.$parent.hash.items.length == 3) this.findPost({type: this.$parent.hash.items[1], id: this.$parent.hash.items[2]});
},
async initMap() {
//Get Map Info
const aoMarkers = await this.spot.get2('markers', {id_project: this.project.id});
this.baseMap = null;
this.baseMaps = aoMarkers.maps;
this.messages = aoMarkers.messages;
//Base maps (raster tiles)
let asSources = {};
let asLayers = [];
for(const asBaseMap of this.baseMaps) {
asSources[asBaseMap.codename] = {
type: 'raster',
tiles: [asBaseMap.pattern],
tileSize: asBaseMap.tile_size
};
asLayers.push({
id: asBaseMap.codename,
type: 'raster',
source: asBaseMap.codename,
'layout': {'visibility': 'none'},
minZoom: asBaseMap.min_zoom,
maxZoom: asBaseMap.max_zoom
});
}
//Map
if(this.map) this.map.remove();
this.map = new Map({
container: 'map',
style: {
version: 8,
sources: asSources,
layers: asLayers
},
attributionControl: false
});
this.map.once('load', async () => {
//Default Basemap
this.baseMap = this.baseMaps.filter((asBM) => asBM.default_map)[0].codename;
//Get track
const oTrack = await this.spot.get2('geojson', {id_project: this.project.id});
this.map.addSource('track', {
'type': 'geojson',
'data': oTrack
});
//Color mapping
let asColorMapping = ['match', ['get', 'type']];
for(const sHikeType in this.hikes.colors) {
asColorMapping.push(sHikeType);
asColorMapping.push(this.hikes.colors[sHikeType]);
}
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
}
});
//Markers
let aoMarkerSource = {type:'geojson', data:{type: 'FeatureCollection', features:[]}};
for(const oMsg of this.messages) {
aoMarkerSource.data.features.push({
'type': 'Feature',
'properties': {
...oMsg,
...{'description': ''}
},
'geometry': {
'type': 'Point',
'coordinates': [oMsg.longitude, oMsg.latitude]
}
});
//Tooltip
/*
let $Tooltip = $($('<div>', {'class':'info-window'})
.append($('<h1>')
.addIcon('fa-message fa-lg', true)
.append($('<span>').text(this.spot.lang('post_message')+' '+this.spot.lang('counter', oMsg.displayed_id)))
.append($('<span>', {'class':'message-type'}).text('('+oMsg.type+')'))
)
.append($('<div>', {'class':'separator'}))
.append($('<p>', {'class':'coordinates'})
.addIcon('fa-coords fa-fw fa-lg', true)
.append(this.getGoogleMapsLink(oMsg))
)
.append($('<p>', {'class':'time'})
.addIcon('fa-time fa-fw fa-lg', true)
.append(oMsg.formatted_time+(this.project.mode==this.spot.consts.modes.blog?' ('+oMsg.relative_time+')':''))))[0];
const vTooltip = h(SpotIcon, {icon:'project', 'classes':'fa-fw', text:'hikes'});
//let vTooltip = h(SpotIcon, {icon:'project', 'classes':'fa-fw', text:'hikes'});
oPopup.setDOMContent(vTooltip);
new Marker({
element: $('<div style="width:'+this.markerSize.width+'px;height:'+this.markerSize.height+'px;"><span class="fa-stack"><i class="fa fa-message fa-stack-2x"></i><i class="fa fa-message-in fa-rotate-270 fa-stack-1x"></i></span></div>')[0],
anchor: 'bottom'
})
.setLngLat(new LngLat(oMsg.longitude, oMsg.latitude))
.setPopup(oPopup)
.addTo(this.map)
;
*/
}
this.map.addSource('markers', aoMarkerSource);
const image = await this.map.loadImage('images/footprint_mapbox.png');
this.map.addImage('markerIcon', image.data);
this.map.addLayer({
'id': 'markers',
'type': 'symbol',
'source': 'markers',
'layout': {
//'icon-anchor': 'bottom',
'icon-image': 'markerIcon'
//'icon-overlap': 'always'
}
});
this.map.on("click", "markers", (e) => {
var oPopup = new Popup({
anchor: 'bottom',
offset: [0, this.markerSize.height * -1],
closeButton: false
})
.setHTML('<div id="popup"></div>')
.setLngLat(e.lngLat)
.addTo(this.map);
let rProp = ref(e.features[0].properties);console.log(rProp);
const vPopup = defineComponent({
extends: ProjectPopup,
setup: () => {
//console.log(rProp.value.medias);
provide('options', rProp.value);
provide('spot', this.spot);
provide('project', this.project);
return {'options': rProp.value, 'medias': JSON.parse(rProp.value.medias || '""'), 'spot':this.spot, 'project':this.project};
}
});
nextTick(() => {
createApp(vPopup).mount("#popup");
});
});
//Centering map
let bOpenFeedPanel = !this.mobile;
let oBounds = new LngLatBounds();
if(
this.project.mode == this.spot.consts.modes.blog &&
this.messages.length > 0 &&
this.$parent.hash.items[2] != 'message'
) {
//Fit to last message
let oLastMsg = this.messages[this.messages.length - 1];
oBounds.extend(new LngLat(oLastMsg.longitude, oLastMsg.latitude));
}
else {
//Fit to track
for(const iFeatureId in oTrack.features) {
oBounds = oTrack.features[iFeatureId].geometry.coordinates.reduce(
(bounds, coord) => {
return bounds.extend(coord);
},
oBounds
);
}
}
const iFeedPanelPadding = bOpenFeedPanel?(getOuterWidth(this.$refs.feed)/2):0;
await this.map.fitBounds(
oBounds,
{
padding: {
top: 20,
bottom: 20,
left: (20 + iFeedPanelPadding),
right: (20 + iFeedPanelPadding)
},
animate: false,
maxZoom: 15
}
);
//Toggle only when map is ready, for the tilt effet
this.toggleFeedPanel(bOpenFeedPanel);
});
this.map.on('idle', () => {
});
//Legend
},
getGoogleMapsLink(asInfo) {
return $('<a>', {
href:'https://www.google.com/maps/place/'+asInfo.lat_dms+'+'+asInfo.lon_dms+'/@'+asInfo.latitude+','+asInfo.longitude+',10z',
title: this.spot.lang('see_on_google'),
target: '_blank',
rel: 'noreferrer noopener'
}).text(asInfo.lat_dms+' '+asInfo.lon_dms);
},
async getNextFeed() {
if(!this.feed.outOfData && !this.feed.loading) {
//Get next chunk
this.feed.loading = true;
let aoData = await this.spot.get2('next_feed', {id_project: this.project.id, id: this.feed.refIdLast});
let iPostCount = Object.keys(aoData.feed).length;
this.feed.loading = false;
this.feed.firstChunk = false;
//Update pointers
this.feed.outOfData = (iPostCount < this.spot.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);
}
return true;
},
onFeedScroll(oEvent) {
//FIXME remvove jquery dependency
var $Box = $(oEvent.currentTarget);
var $BoxContent = $Box.find('.simplebar-content');
if(($Box.scrollTop() + $(window).height()) / $BoxContent.height() >= 0.8) this.getNextFeed();
},
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.spot.lang('nl_invalid_email')});
else {
this.spot.get2(this.nlAction, {'email': this.user.email, 'name': this.user.name}, this.nlLoading)
.then((asUser, sDesc) => {
this.nlFeedbacks.push('success', sDesc);
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.mobile) {
this.spot.onResize();
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.mobile) {
this.spot.onResize();
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;
}
}
},
async findPost(oPost) {
if(this.goToPost(oPost)) {
//if(oPost.type=='media' || oPost.type=='message') $Post.find('a.drill').click();
}
else if(!this.feed.outOfData) {
await this.getNextFeed();
this.findPost(oPost);
}
else console.log('Missing element ID "'+oPost.id+'" of type "'+oPost.type+'"');
},
goToPost(oPost) {
//TODO remove jquery deps
let bFound = false;
let aoRefs = this.$refs.posts.filter((post)=>{return post.postId == oPost.type+'-'+oPost.id;});
if(aoRefs.length == 1) {
this.$refs.feedSimpleBar.scrollElement.scrollTop += Math.round(
$(aoRefs[0].$el).offset().top
- parseInt($(this.$refs.feedSimpleBar.$el).css('padding-top'))
);
bFound = true;
this.spot.flushHash(['post', 'message']);
}
return bFound;
}
}
}
</script>
<template>
<div id="projects" :class="projectClasses">
<div id="background"></div>
<div id="submap">
<div class="loader fa fa-fw fa-map flicker"></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"><p><span><img src="images/spot-logo-only.svg" alt="" /></span><abbr></abbr></p></div>
</div>
<div class="settings-sections">
<simplebar id="settings-sections-scrollbox">
<div class="settings-section">
<h1><SpotIcon :icon="'project'" :classes="'fa-fw'" :text="spot.lang('hikes')" /></h1>
<div class="settings-section-body">
<div class="radio" v-for="project in projects">
<input type="radio" :id="project.id" :value="project.codename" v-model="projectCodename" />
<label :for="project.id">
<span>{{ project.name }}</span>
<a class="download" :href="project.gpxfilepath" :title="spot.lang('track_download')" @click.stop="()=>{}">
<SpotIcon :icon="'download'" :classes="'push-left'" />
</a>
</label>
</div>
</div>
</div>
<div class="settings-section">
<h1><SpotIcon :icon="'map'" :classes="'fa-fw'" :text="spot.lang('maps')" /></h1>
<div class="settings-section-body">
<div class="radio" v-for="bm in baseMaps">
<input type="radio" :id="bm.id_map" :value="bm.codename" v-model="baseMap" />
<label :for="bm.id_map">{{ this.spot.lang('map_'+bm.codename) }}</label>
</div>
</div>
</div>
<div class="settings-section newsletter">
<h1><SpotIcon :icon="'newsletter'" :classes="'fa-fw'" :text="spot.lang('newsletter')" /></h1>
<input type="email" name="email" id="email" :placeholder="spot.lang('nl_email_placeholder')" v-model="user.email" :disabled="nlLoading || subscribed" />
<SpotButton id="nl_btn" :classes="nlClasses" :title="spot.lang('nl_'+nlAction)" @click="manageSubs" />
<div id="settings-feedback" class="feedback">
<p v-for="feedback in nlFeedbacks" :class="feedback.type">
<SpotIcon :icon="feedback.type" :text="feedback.msg" />
</p>
</div>
{{ spot.lang(subscribed?'nl_subscribed_desc':'nl_unsubscribed_desc') }}
</div>
<div class="settings-section admin" v-if="spot.checkClearance(spot.consts.clearances.admin)">
<h1><SpotIcon :icon="'admin fa-fw'" :text="spot.lang('admin')" /></h1>
<a class="button" href="#admin"><SpotIcon :icon="'config'" :text="spot.lang('admin_config')" /></a>
<a class="button" href="#upload"><SpotIcon :icon="'upload'" :text="spot.lang('admin_upload')" /></a>
</div>
</simplebar>
</div>
<div class="settings-footer">
<a href="https://git.lutran.fr/franzz/spot" :title="spot.lang('credits_git')" target="_blank" rel="noopener">
<SpotIcon :icon="'credits'" :text="spot.lang('credits_project')" />
</a> {{ spot.lang('credits_license') }}</div>
</div>
<div :class="'map-control map-control-icon settings-control map-control-'+(mobile?'bottom':'top')" @click="toggleSettingsPanel">
<SpotIcon :icon="settingsPanelOpen?'prev':'menu'" />
</div>
<div v-if="!mobile" 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">{{ spot.lang('track_'+hikeType) }}</span>
</div>
</div>
<div id="title" :class="'map-control settings-control map-control-'+(mobile?'bottom':'top')">
<span>{{ project.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: spot.lang('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-'+(mobile?'bottom':'top')" @click="toggleFeedPanel">
<SpotIcon :icon="feedPanelOpen?'next':'post'" />
</div>
</div>
</div>
</template>