Separate project / feed / settings
All checks were successful
Deploy Spot / deploy (push) Successful in 49s
All checks were successful
Deploy Spot / deploy (push) Successful in 49s
This commit is contained in:
@@ -2,31 +2,29 @@
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import { Map, Marker, LngLatBounds, LngLat, Popup, ScaleControl } from 'maplibre-gl';
|
||||
import { createApp } from 'vue';
|
||||
import Simplebar from 'simplebar-vue';
|
||||
|
||||
import Lightbox from '@scripts/lightbox';
|
||||
|
||||
import SpotIcon from '@components/spotIcon';
|
||||
import SpotIconStack from '@components/spotIconStack';
|
||||
import ProjectPost from '@components/projectPost';
|
||||
import ProjectPopup from '@components/projectPopup';
|
||||
import ProjectNewsletter from '@components/projectNewsletter';
|
||||
import ProjectFeed from '@components/projectFeed';
|
||||
import ProjectSettings from '@components/projectSettings';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
SpotIcon,
|
||||
ProjectPost,
|
||||
ProjectNewsletter,
|
||||
Simplebar
|
||||
ProjectFeed,
|
||||
ProjectSettings
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
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,
|
||||
feedSwipe: {x: null, y: null},
|
||||
settingsPanelOpen: false,
|
||||
panels: {
|
||||
feedOpen: false,
|
||||
settingsOpen: false
|
||||
},
|
||||
feed: null,
|
||||
settings: null,
|
||||
track: null,
|
||||
markers: [],
|
||||
markerProps: {
|
||||
@@ -35,29 +33,24 @@ export default {
|
||||
video: {mainClasses: 'media', iconMain: 'marker', iconSub: 'video'},
|
||||
message: {mainClasses: 'message', iconMain: 'marker', iconSub: 'footprint', iconSubTransform: 'rotate-270'}
|
||||
},
|
||||
currProject: null,
|
||||
project: null,
|
||||
modeHisto: null,
|
||||
posts: [],
|
||||
baseMaps: [],
|
||||
baseMap: null,
|
||||
map: null,
|
||||
mapInitializing: false,
|
||||
markerHeight: 32, //FIXME
|
||||
mapPadding: 16 + 32, //1rem + marker height
|
||||
lightbox: null,
|
||||
hikes: {
|
||||
colors: {},
|
||||
width: 4
|
||||
},
|
||||
popup: {content: null, element: null},
|
||||
overview: {id: 0, codename:'overview', name: this.lang.get('project.overview')}
|
||||
overview: {id: 0, codename:'overview', name: this.lang.get('project.overview')},
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
projectClasses() {
|
||||
return [
|
||||
this.feedPanelOpen?'with-feed':'',
|
||||
this.settingsPanelOpen?'with-settings':''
|
||||
].filter(n => n).join(' ');
|
||||
},
|
||||
projectOptions() {
|
||||
return [
|
||||
this.overview,
|
||||
@@ -75,7 +68,7 @@ export default {
|
||||
'hash.items.0'(newProjectCodename, oldProjectCodename) {
|
||||
if(newProjectCodename != oldProjectCodename) {
|
||||
this.hash.items = [newProjectCodename];
|
||||
this.toggleSettingsPanel(false, 'none');
|
||||
this.settings.toggle(false, 0);
|
||||
this.init();
|
||||
}
|
||||
}
|
||||
@@ -113,11 +106,8 @@ export default {
|
||||
};
|
||||
|
||||
//Reset values
|
||||
this.setFeedUpdateTimer(-1);
|
||||
this.feed = {loading:false, updatable:true, outOfData:false, refIdFirst:0, refIdLast:0, firstChunk:true};
|
||||
this.posts = [];
|
||||
this.track = null;
|
||||
this.currProject = null;
|
||||
this.project = null;
|
||||
this.removeMapContent();
|
||||
|
||||
//Build Map
|
||||
@@ -128,28 +118,27 @@ export default {
|
||||
},
|
||||
quit() {
|
||||
this.lightbox.end();
|
||||
this.$refs.feedSimpleBar?.scrollElement.removeEventListener('scroll', this.onFeedScroll);
|
||||
this.setFeedUpdateTimer(-1);
|
||||
this.removeMap();
|
||||
},
|
||||
async initOverview() {
|
||||
this.modeHisto = true;
|
||||
this.hash.items = [this.overview.codename];
|
||||
this.toggleFeedPanel(false, 'none');
|
||||
this.feed.toggle(false, 0);
|
||||
|
||||
await this.initOverviewMap();
|
||||
},
|
||||
async initProject(iProjectId) {
|
||||
this.currProject = this.projects[iProjectId];
|
||||
this.modeHisto = (this.currProject.mode == this.consts.modes.histo);
|
||||
async initProject(sProjectCodeName) {
|
||||
this.project = this.projects[sProjectCodeName];
|
||||
this.modeHisto = (this.project.mode == this.consts.modes.histo);
|
||||
|
||||
await this.$nextTick();
|
||||
await Promise.all([
|
||||
this.initFeed(),
|
||||
this.feed.init(),
|
||||
this.initProjectMap()
|
||||
]);
|
||||
|
||||
//Direct link post action
|
||||
if(this.hash.items.length == 3) await this.findPost(this.hash.items[1], this.hash.items[2]);
|
||||
if(this.hash.items.length == 3) await this.feed.findPost(this.hash.items[1], this.hash.items[2]);
|
||||
},
|
||||
initLightbox() {
|
||||
if(!this.lightbox) {
|
||||
@@ -162,11 +151,11 @@ export default {
|
||||
resizeDuration: 400,
|
||||
hasVideo: true,
|
||||
onMediaChange: async (oMedia) => {
|
||||
this.hash.items = [this.currProject.codename, 'media', oMedia.id];
|
||||
this.hash.items = [this.project.codename, 'media', oMedia.id];
|
||||
if(oMedia.set == 'post-medias') {
|
||||
this.goToPost('media', oMedia.id)?.panMapToMarker();
|
||||
(await this.feed.goToPost('media', oMedia.id))?.panMapToMarker();
|
||||
if(!this.lightbox.hasMediaAfterCurrent()) {
|
||||
await this.getNextFeed();
|
||||
await this.feed.getNextFeed();
|
||||
await this.$nextTick();
|
||||
this.lightbox.refreshAlbum();
|
||||
}
|
||||
@@ -176,39 +165,19 @@ export default {
|
||||
});
|
||||
}
|
||||
},
|
||||
async initFeed() {
|
||||
await this.$nextTick();
|
||||
|
||||
//Simplebar event
|
||||
this.$refs.feedSimpleBar?.scrollElement.removeEventListener('scroll', this.onFeedScroll);
|
||||
this.$refs.feedSimpleBar?.scrollElement.addEventListener('scroll', this.onFeedScroll);
|
||||
|
||||
this.toggleFeedPanel(!this.isMobile(), 'none');
|
||||
|
||||
//Get first posts batch
|
||||
await this.getNextFeed();
|
||||
if(this.$refs.feedSimpleBar) this.$refs.feedSimpleBar.scrollElement.scrollTop = 0;
|
||||
|
||||
//Start auto-update
|
||||
if(!this.modeHisto) this.setFeedUpdateTimer(this.refreshRate);
|
||||
},
|
||||
async initProjectMap() {
|
||||
[
|
||||
{
|
||||
maps: this.baseMaps,
|
||||
markers: this.markers,
|
||||
last_update: this.lastUpdate
|
||||
},
|
||||
{maps: this.baseMaps, markers: this.markers},
|
||||
this.track
|
||||
] = await Promise.all([
|
||||
this.api.get('markers', {id_project: this.currProject.id}),
|
||||
this.api.get('geojson', {id_project: this.currProject.id})
|
||||
this.api.get('markers', {id_project: this.project.id}),
|
||||
this.api.get('geojson', {id_project: this.project.id})
|
||||
]);
|
||||
|
||||
await this.initMap({
|
||||
setCamera: () => {
|
||||
this.map.fitBounds(this.getInitialMapBounds(), {
|
||||
padding: 20,
|
||||
padding: this.mapPadding,
|
||||
animate: false,
|
||||
maxZoom: 15
|
||||
});
|
||||
@@ -229,7 +198,7 @@ export default {
|
||||
//Center on default project
|
||||
const oDefaultProject = this.projects.getDefaultProject();
|
||||
|
||||
//Adapt zoom to see whole planet
|
||||
//Get Map / Canvas size
|
||||
const $Canvas = this.map.getCanvas();
|
||||
const oMapBounds = this.map.getContainer().getBoundingClientRect();
|
||||
|
||||
@@ -248,7 +217,7 @@ export default {
|
||||
//Build map
|
||||
if(!this.map) this.addMap();
|
||||
this.updateMapPadding();
|
||||
setCamera();
|
||||
setCamera();
|
||||
|
||||
//Force wait for load event
|
||||
await new Promise((resolve) => {
|
||||
@@ -363,7 +332,7 @@ export default {
|
||||
'source': 'track',
|
||||
'paint': {
|
||||
'line-opacity': 0,
|
||||
'line-width': this.hikes.width + 20
|
||||
'line-width': this.hikes.width + this.mapPadding
|
||||
}
|
||||
});
|
||||
this.map.on('click', 'track-hitbox', this.openTrackPopup);
|
||||
@@ -435,7 +404,7 @@ export default {
|
||||
this.openPopup({
|
||||
lnglat: [oProject.longitude, oProject.latitude],
|
||||
options: oProject,
|
||||
offset: [0, -32] //FIXME
|
||||
offset: [0, -1 * this.markerHeight]
|
||||
});
|
||||
},
|
||||
openMarkerPopup(iMarkerId, sMarkerType) {
|
||||
@@ -443,7 +412,7 @@ export default {
|
||||
this.openPopup({
|
||||
lnglat: [oMarker.longitude, oMarker.latitude],
|
||||
options: oMarker,
|
||||
offset: [0, -32] //FIXME
|
||||
offset: [0, -1 * this.markerHeight]
|
||||
});
|
||||
},
|
||||
openTrackPopup(oEvent) {
|
||||
@@ -466,7 +435,7 @@ export default {
|
||||
|
||||
this.popup.content = createApp(ProjectPopup, {
|
||||
options: options,
|
||||
project: this.currProject
|
||||
project: this.project
|
||||
});
|
||||
this.popup.content
|
||||
.provide('lang', this.lang)
|
||||
@@ -501,7 +470,7 @@ export default {
|
||||
oBounds.extend(new LngLat(oHashMarker.longitude, oHashMarker.latitude));
|
||||
}
|
||||
else if( //Blog Mode: Fit to last message
|
||||
this.currProject.mode == this.consts.modes.blog &&
|
||||
this.project.mode == this.consts.modes.blog &&
|
||||
this.markers.length > 0
|
||||
) {
|
||||
let oLastMsg = this.markers.at(-1);
|
||||
@@ -510,9 +479,7 @@ export default {
|
||||
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);
|
||||
},
|
||||
(bounds, coord) => bounds.extend(coord),
|
||||
oBounds
|
||||
);
|
||||
}
|
||||
@@ -520,110 +487,9 @@ export default {
|
||||
|
||||
return oBounds;
|
||||
},
|
||||
|
||||
async findPost(sPostType, iPostId) {
|
||||
let vPost = this.goToPost(sPostType, iPostId);
|
||||
if(vPost) {
|
||||
await vPost.executeMainAction(0);
|
||||
return vPost;
|
||||
}
|
||||
else if(!this.feed.outOfData) {
|
||||
await this.getNextFeed();
|
||||
return this.findPost(sPostType, iPostId);
|
||||
}
|
||||
else console.log('Missing element ID "'+iPostId+'" of type "'+sPostType+'"');
|
||||
return null;
|
||||
},
|
||||
goToPost(sPostType, iPostId) {
|
||||
let bFound = false;
|
||||
let avPosts = this.$refs.posts.filter((post) => {return post.postId == sPostType+'-'+iPostId;});
|
||||
if(avPosts.length > 0) {
|
||||
let vPost = avPosts[0];
|
||||
this.$refs.feedSimpleBar.scrollElement.scrollTop += Math.round(
|
||||
vPost.$el.getBoundingClientRect().top
|
||||
+ window.pageYOffset
|
||||
- parseFloat(getComputedStyle(this.$refs.feedSimpleBar.$el).paddingTop)
|
||||
);
|
||||
|
||||
return vPost;
|
||||
}
|
||||
},
|
||||
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();
|
||||
},
|
||||
onFeedTouchStart(oEvent) {
|
||||
if(!this.isMobile() || !this.feedPanelOpen || oEvent.touches.length != 1) return;
|
||||
|
||||
const oTouch = oEvent.touches[0];
|
||||
this.feedSwipe = {x: oTouch.clientX, y: oTouch.clientY};
|
||||
},
|
||||
onFeedTouchEnd(oEvent) {
|
||||
const oTouch = oEvent.changedTouches[0];
|
||||
if(!oTouch || this.feedSwipe.x === null) return;
|
||||
|
||||
const iDeltaX = oTouch.clientX - this.feedSwipe.x;
|
||||
const iDeltaY = oTouch.clientY - this.feedSwipe.y;
|
||||
|
||||
if(iDeltaX > 80 && Math.abs(iDeltaX) > Math.abs(iDeltaY) * 1.5) {
|
||||
this.toggleFeedPanel();
|
||||
}
|
||||
|
||||
this.feedSwipe = {x: null, y: null};
|
||||
},
|
||||
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(this.addMarker);
|
||||
}
|
||||
|
||||
//Message Last Update
|
||||
this.lastUpdate = aoData.last_update;
|
||||
|
||||
//Reschedule
|
||||
this.setFeedUpdateTimer(this.refreshRate);
|
||||
addNewMarkers(aoMarkers) { //FIXME Use its own marker update API
|
||||
this.markers.push(...aoMarkers);
|
||||
aoMarkers.forEach(this.addMarker);
|
||||
},
|
||||
panToBetweenPanels(oLngLat, iZoom, iAnimDuration=500) {
|
||||
return new Promise((resolve) => {
|
||||
@@ -643,10 +509,10 @@ export default {
|
||||
getMapPadding() {
|
||||
let bIsMobile = this.isMobile();
|
||||
return {
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: (!bIsMobile && this.settingsPanelOpen)?this.$refs.settings.getBoundingClientRect().width:0,
|
||||
right: (!bIsMobile && this.feedPanelOpen)?this.$refs.feed.getBoundingClientRect().width:0
|
||||
top: this.mapPadding,
|
||||
bottom: this.mapPadding,
|
||||
left: this.mapPadding + ((!bIsMobile && this.panels.settingsOpen && this.settings)?this.settings.getWidth():0),
|
||||
right: this.mapPadding + ((!bIsMobile && this.panels.feedOpen && this.feed)?this.feed.getWidth():0)
|
||||
};
|
||||
},
|
||||
updateMapPadding(iDuration=0) {
|
||||
@@ -660,58 +526,31 @@ export default {
|
||||
isMarkerVisible(oLngLat){
|
||||
return !!this.map && 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);
|
||||
},
|
||||
toggleFeedPanel(bShow, sMapAction) {
|
||||
let bOldValue = this.feedPanelOpen;
|
||||
this.feedPanelOpen = (typeof bShow === 'object' || typeof bShow === 'undefined')?(!this.feedPanelOpen):bShow;
|
||||
onPanelToggle(sPanel, bNewValue, iAnimDuration=500) {
|
||||
const sPanelKey = sPanel + 'Open';
|
||||
let bOldValue = this.panels[sPanelKey];
|
||||
this.panels[sPanelKey] = bNewValue;
|
||||
|
||||
if(bOldValue != this.feedPanelOpen && !this.isMobile() && this.map) {
|
||||
sMapAction = sMapAction || 'panTo';
|
||||
switch(sMapAction) {
|
||||
case 'none':
|
||||
this.updateMapPadding();
|
||||
break;
|
||||
case 'panTo':
|
||||
this.updateMapPadding(500);
|
||||
break;
|
||||
case 'panToInstant':
|
||||
this.updateMapPadding();
|
||||
break;
|
||||
}
|
||||
if(bOldValue != bNewValue) {
|
||||
//Adjust map center
|
||||
if(!this.isMobile() && this.map) this.updateMapPadding(iAnimDuration);
|
||||
|
||||
//Open Close panels
|
||||
this.$el.classList.toggle('with-'+sPanel);
|
||||
}
|
||||
},
|
||||
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.map) {
|
||||
sMapAction = sMapAction || 'panTo';
|
||||
switch(sMapAction) {
|
||||
case 'none':
|
||||
this.updateMapPadding();
|
||||
break;
|
||||
case 'panTo':
|
||||
this.updateMapPadding(500);
|
||||
break;
|
||||
case 'panToInstant':
|
||||
this.updateMapPadding();
|
||||
break;
|
||||
}
|
||||
}
|
||||
setFeed(vPanel) {
|
||||
this.feed = vPanel;
|
||||
},
|
||||
setSettings(vPanel) {
|
||||
this.settings = vPanel;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="projects" :class="projectClasses">
|
||||
<div class="projects">
|
||||
<div id="background"></div>
|
||||
<div id="submap">
|
||||
<div class="loader">
|
||||
@@ -719,87 +558,23 @@ export default {
|
||||
</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 && 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 projectOptions" :key="'project-'+project.id">
|
||||
<input type="radio" :id="'project-'+project.id" :value="project.codename" v-model="hash.items[0]" :disabled="mapInitializing" />
|
||||
<label :for="'project-'+project.id">
|
||||
<span>{{ project.name }}</span>
|
||||
<a v-if="project.gpxfilepath" 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" :disabled="mapInitializing" />
|
||||
<label :for="'map-'+bm.id_map">{{ lang.get('map.'+bm.codename) }}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-section newsletter">
|
||||
<ProjectNewsletter />
|
||||
</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>
|
||||
<span> {{ lang.get('credits.license') }}</span>
|
||||
</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="currProject && !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 v-if="currProject" 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" @touchstart.passive="onFeedTouchStart" @touchend.passive="onFeedTouchEnd">
|
||||
<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 v-if="currProject" 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 v-if="currProject" :class="'map-control map-control-icon feed-control map-control-'+(isMobile()?'bottom':'top')" @click="toggleFeedPanel">
|
||||
<SpotIcon :icon="feedPanelOpen?'next':'post'" />
|
||||
</div>
|
||||
</div>
|
||||
<ProjectSettings
|
||||
:ref="setSettings"
|
||||
:projects="projectOptions"
|
||||
v-model:project-code-name="hash.items[0]"
|
||||
:base-maps="baseMaps"
|
||||
v-model:base-map="baseMap"
|
||||
:map-initializing="mapInitializing"
|
||||
:hikes="hikes"
|
||||
@toggle="(bIsOpen, iAnimDuration) => onPanelToggle('settings', bIsOpen, iAnimDuration)"
|
||||
/>
|
||||
<ProjectFeed
|
||||
:ref="setFeed"
|
||||
:project="project"
|
||||
:mode-histo="modeHisto"
|
||||
@request-last-update="settings?.setLastUpdate"
|
||||
@new-markers="addNewMarkers"
|
||||
@toggle="(bIsOpen, iAnimDuration) => onPanelToggle('feed', bIsOpen, iAnimDuration)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
216
src/components/projectFeed.vue
Normal file
216
src/components/projectFeed.vue
Normal file
@@ -0,0 +1,216 @@
|
||||
<script>
|
||||
import Simplebar from 'simplebar-vue';
|
||||
|
||||
import SpotIcon from '@components/spotIcon';
|
||||
import ProjectPost from '@components/projectPost';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
SpotIcon,
|
||||
ProjectPost,
|
||||
Simplebar
|
||||
},
|
||||
props: {
|
||||
project: Object,
|
||||
modeHisto: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
updatable: true,
|
||||
outOfData: false,
|
||||
refIdFirst: 0,
|
||||
refIdLast: 0,
|
||||
firstChunk: true,
|
||||
isOpen: false,
|
||||
posts: [],
|
||||
refreshRate: 60,
|
||||
swipe: {x: null, y: null}
|
||||
};
|
||||
},
|
||||
emits: ['request-last-update', 'new-markers', 'toggle'],
|
||||
inject: ['api', 'lang', 'consts', 'isMobile'],
|
||||
provide() {
|
||||
return {
|
||||
feed: {
|
||||
checkNewFeed: this.checkNewFeed
|
||||
}
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
project() {
|
||||
this.syncUpdateTimer();
|
||||
},
|
||||
modeHisto() {
|
||||
this.syncUpdateTimer();
|
||||
},
|
||||
firstChunk() {
|
||||
this.syncUpdateTimer();
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.getScrollElement()?.addEventListener('scroll', this.onFeedScroll);
|
||||
this.syncUpdateTimer();
|
||||
},
|
||||
beforeUnmount() {
|
||||
this.getScrollElement()?.removeEventListener('scroll', this.onFeedScroll);
|
||||
this.setUpdateTimer(-1);
|
||||
},
|
||||
methods: {
|
||||
async init() {
|
||||
this.setUpdateTimer(-1);
|
||||
this.loading = false;
|
||||
this.updatable = true;
|
||||
this.outOfData = false;
|
||||
this.refIdFirst = 0;
|
||||
this.refIdLast = 0;
|
||||
this.firstChunk = true;
|
||||
this.posts = [];
|
||||
this.swipe = {x: null, y: null};
|
||||
|
||||
await this.$nextTick();
|
||||
this.toggle(!this.isMobile(), 0);
|
||||
|
||||
await this.getNextFeed();
|
||||
this.getScrollElement().scrollTop = 0;
|
||||
this.syncUpdateTimer();
|
||||
},
|
||||
getScrollElement() {
|
||||
return this.$refs.feedSimpleBar?.scrollElement;
|
||||
},
|
||||
async findPost(sPostType, iPostId) {
|
||||
let vPost = await this.goToPost(sPostType, iPostId);
|
||||
if(vPost) {
|
||||
await vPost.executeMainAction(0);
|
||||
return vPost;
|
||||
}
|
||||
else if(!this.outOfData) {
|
||||
await this.getNextFeed();
|
||||
await this.$nextTick();
|
||||
return this.findPost(sPostType, iPostId);
|
||||
}
|
||||
else console.log('Missing element ID "'+iPostId+'" of type "'+sPostType+'"');
|
||||
return null;
|
||||
},
|
||||
async goToPost(sPostType, iPostId) {
|
||||
let avPosts = this.$refs.posts.filter((post) => {return post.postId == sPostType+'-'+iPostId;});
|
||||
if(avPosts.length == 0) return null;
|
||||
|
||||
//Force next update to have enough subsequent elements to position the post on top of the page
|
||||
await this.getNextFeed();
|
||||
|
||||
let vPost = avPosts[0];
|
||||
this.getScrollElement().scrollTop += Math.round(
|
||||
vPost.$el.getBoundingClientRect().top
|
||||
+ window.pageYOffset
|
||||
- parseFloat(getComputedStyle(this.$refs.feedSimpleBar.$el).paddingTop)
|
||||
);
|
||||
|
||||
return vPost;
|
||||
},
|
||||
async getNextFeed() {
|
||||
if(!this.project || this.outOfData || this.loading) return true;
|
||||
|
||||
//Get next chunk
|
||||
this.loading = true;
|
||||
let aoData = await this.api.get('next_feed', {id_project: this.project.id, id: this.refIdLast});
|
||||
let iPostCount = Object.keys(aoData.feed).length;
|
||||
|
||||
//Update pointers
|
||||
this.outOfData = (iPostCount < this.consts.chunk_size);
|
||||
if(iPostCount > 0) {
|
||||
this.refIdLast = aoData.ref_id_last;
|
||||
if(this.firstChunk) this.refIdFirst = aoData.ref_id_first;
|
||||
}
|
||||
|
||||
//Add posts
|
||||
this.posts.push(...aoData.feed);
|
||||
|
||||
this.loading = false;
|
||||
this.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();
|
||||
},
|
||||
onTouchStart(oEvent) {
|
||||
if(!this.isMobile() || !this.isOpen || oEvent.touches.length != 1) return;
|
||||
|
||||
const oTouch = oEvent.touches[0];
|
||||
this.swipe = {x: oTouch.clientX, y: oTouch.clientY};
|
||||
},
|
||||
onTouchEnd(oEvent) {
|
||||
const oTouch = oEvent.changedTouches[0];
|
||||
if(!oTouch || this.swipe.x === null) return;
|
||||
|
||||
const iDeltaX = oTouch.clientX - this.swipe.x;
|
||||
const iDeltaY = oTouch.clientY - this.swipe.y;
|
||||
|
||||
if(iDeltaX > 80 && Math.abs(iDeltaX) > Math.abs(iDeltaY) * 1.5) this.toggle();
|
||||
|
||||
this.swipe = {x: null, y: null};
|
||||
},
|
||||
setUpdateTimer(iSeconds) {
|
||||
if(typeof this.feedTimer != 'undefined') clearTimeout(this.feedTimer);
|
||||
if(iSeconds >= 0) this.feedTimer = setTimeout(this.onUpdateTimer, iSeconds * 1000);
|
||||
},
|
||||
syncUpdateTimer() {
|
||||
this.setUpdateTimer((!!this.project && !this.modeHisto && !this.firstChunk)?this.refreshRate:-1);
|
||||
},
|
||||
async onUpdateTimer() {
|
||||
await this.checkNewFeed();
|
||||
this.syncUpdateTimer();
|
||||
},
|
||||
async checkNewFeed() {
|
||||
if(!this.project) return;
|
||||
|
||||
let aoData = await this.api.get('new_feed', {id_project: this.project.id, id: this.refIdFirst});
|
||||
const aoFeed = aoData.feed || [];
|
||||
const aoMarkers = aoData.markers || [];
|
||||
|
||||
if(aoFeed.length > 0) {
|
||||
//Update pointer
|
||||
this.refIdFirst = aoData.ref_id_first;
|
||||
|
||||
//Add new posts
|
||||
this.posts.unshift(...aoFeed);
|
||||
}
|
||||
|
||||
if(aoMarkers.length > 0) this.$emit('new-markers', aoMarkers);
|
||||
this.$emit('request-last-update');
|
||||
},
|
||||
toggle(bShow, iAnimDuration=500) {
|
||||
this.isOpen = (typeof bShow == 'boolean')?bShow:(!this.isOpen);
|
||||
this.$emit('toggle', this.isOpen, iAnimDuration);
|
||||
return this.isOpen;
|
||||
},
|
||||
getWidth() {
|
||||
return this.$el.getBoundingClientRect().width;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="feed" class="map-container map-container-right" @touchstart.passive="onTouchStart" @touchend.passive="onTouchEnd">
|
||||
<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 v-if="project" id="feed-posts">
|
||||
<ProjectPost v-for="post in posts" :options="post" ref="posts" />
|
||||
</div>
|
||||
<div id="feed-footer" v-if="loading">
|
||||
<ProjectPost :options="{type: 'loading', headerless: true}" />
|
||||
</div>
|
||||
</Simplebar>
|
||||
<div v-if="project" :class="'map-control map-control-icon feed-control map-control-'+(isMobile()?'bottom':'top')" @click="toggle">
|
||||
<SpotIcon :icon="isOpen?'next':'post'" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -38,6 +38,7 @@
|
||||
focusZoomLevel: 15
|
||||
};
|
||||
},
|
||||
inject: ['api', 'lang', 'project', 'feed', 'user', 'map', 'hash', 'consts', 'isMobile'],
|
||||
computed: {
|
||||
postClass() {
|
||||
let sHeaderLess = this.options.headerless?' headerless':'';
|
||||
@@ -59,10 +60,10 @@
|
||||
return this.mouseOverDrill?null:'footprint';
|
||||
},
|
||||
anchorLink() {
|
||||
return '#'+[this.hash.page, this.project.currProject.codename, this.options.type, this.options.id].join(this.consts.hash_sep);
|
||||
return '#'+[this.hash.page, this.project.project.codename, this.options.type, this.options.id].join(this.consts.hash_sep);
|
||||
},
|
||||
modeHisto() {
|
||||
return (this.project?.currProject?.mode == this.consts.modes.histo);
|
||||
return (this.project?.project?.mode == this.consts.modes.histo);
|
||||
},
|
||||
relTime() {
|
||||
return this.modeHisto?(this.options.formatted_time || '').substr(0, 10):this.options.relative_time;
|
||||
@@ -90,7 +91,6 @@
|
||||
return new LngLat(oRelatedMarker.longitude, oRelatedMarker.latitude);
|
||||
}
|
||||
},
|
||||
inject: ['api', 'lang', 'project', 'user', 'map', 'hash', 'consts', 'isMobile'],
|
||||
methods: {
|
||||
copyAnchor() {
|
||||
copyTextToClipboard(this.consts.server+this.anchorLink);
|
||||
@@ -106,8 +106,8 @@
|
||||
|
||||
this.popupRequested = true;
|
||||
|
||||
if(this.isMobile()) this.project.toggleFeedPanel(false, 'panToInstant');
|
||||
this.hash.items = [this.project.currProject.codename, this.options.type, this.options.id];
|
||||
if(this.isMobile()) this.feed.toggle(false);
|
||||
this.hash.items = [this.project.project.codename, this.options.type, this.options.id];
|
||||
|
||||
return this.map.panToBetweenPanels(this.relatedMarkerLatLng, this.focusZoomLevel, iAnimDuration).then(() => {
|
||||
this.openMarkerPopup();
|
||||
@@ -137,14 +137,14 @@
|
||||
this.api.get(
|
||||
'add_post',
|
||||
{
|
||||
id_project: this.project.currProject.id,
|
||||
id_project: this.project.project.id,
|
||||
name: this.user.name,
|
||||
content: this.postMessage
|
||||
}
|
||||
)
|
||||
.then(() => {
|
||||
this.postMessage = '';
|
||||
this.project.checkNewFeed();
|
||||
this.feed.checkNewFeed();
|
||||
this.sending = false;
|
||||
})
|
||||
.catch((sDesc) => {
|
||||
|
||||
142
src/components/projectSettings.vue
Normal file
142
src/components/projectSettings.vue
Normal file
@@ -0,0 +1,142 @@
|
||||
<script>
|
||||
import Simplebar from 'simplebar-vue';
|
||||
|
||||
import SpotIcon from '@components/spotIcon';
|
||||
import ProjectNewsletter from '@components/projectNewsletter';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
SpotIcon,
|
||||
ProjectNewsletter,
|
||||
Simplebar
|
||||
},
|
||||
props: {
|
||||
projects: Array,
|
||||
projectCodeName: String,
|
||||
baseMaps: Array,
|
||||
baseMap: String,
|
||||
mapInitializing: Boolean,
|
||||
hikes: Object
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isOpen: false,
|
||||
lastUpdate: {unix_time: 0, relative_time: '', formatted_time: ''}
|
||||
};
|
||||
},
|
||||
emits: ['update:baseMap', 'update:projectCodeName', 'toggle'],
|
||||
inject: ['api', 'lang', 'user', 'consts', 'isMobile'],
|
||||
computed: {
|
||||
project() {
|
||||
return this.projects.find((project) => project.codename == this.projectCodeName);
|
||||
},
|
||||
projectCodeNameModel: {
|
||||
get() {
|
||||
return this.projectCodeName;
|
||||
},
|
||||
set(sProjectCodeName) {
|
||||
this.$emit('update:projectCodeName', sProjectCodeName);
|
||||
}
|
||||
},
|
||||
baseMapModel: {
|
||||
get() {
|
||||
return this.baseMap;
|
||||
},
|
||||
set(sBaseMap) {
|
||||
this.$emit('update:baseMap', sBaseMap);
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
projectCodeName() {
|
||||
this.setLastUpdate();
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.setLastUpdate();
|
||||
},
|
||||
methods: {
|
||||
async setLastUpdate() {
|
||||
if(this.project?.mode == this.consts.modes.blog) {
|
||||
this.lastUpdate = await this.api.get('last_update', {id_project: this.project.id});
|
||||
}
|
||||
},
|
||||
toggle(bShow, iAnimDuration=500) {
|
||||
this.isOpen = (typeof bShow == 'boolean')?bShow:(!this.isOpen);
|
||||
this.$emit('toggle', this.isOpen, iAnimDuration);
|
||||
return this.isOpen;
|
||||
},
|
||||
getWidth() {
|
||||
return this.$el.getBoundingClientRect().width;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="settings" class="map-container map-container-left">
|
||||
<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="project?.mode == 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="projectCodeNameModel" :disabled="mapInitializing" />
|
||||
<label :for="'project-'+project.id">
|
||||
<span>{{ project.name }}</span>
|
||||
<a v-if="project.gpxfilepath" 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="baseMapModel" :disabled="mapInitializing" />
|
||||
<label :for="'map-'+bm.id_map">{{ lang.get('map.'+bm.codename) }}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-section newsletter">
|
||||
<ProjectNewsletter />
|
||||
</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>
|
||||
<span> {{ lang.get('credits.license') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="'map-control map-control-icon settings-control map-control-'+(isMobile()?'bottom':'top')" @click="toggle">
|
||||
<SpotIcon :icon="isOpen?'prev':'menu'" />
|
||||
</div>
|
||||
<div v-if="project?.id && !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 v-if="project?.id" id="title" :class="'map-control settings-control map-control-'+(isMobile()?'bottom':'top')">
|
||||
<span>{{ project.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -10,7 +10,7 @@
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
#projects {
|
||||
.projects {
|
||||
.map-container {
|
||||
width: calc(#{$panel-width});
|
||||
max-width: calc(#{$panel-width});
|
||||
|
||||
@@ -6,7 +6,7 @@ $panel-width: 30vw;
|
||||
$panel-width-max: "400px + 3 * #{var.$block-spacing}";
|
||||
$panel-actual-width: min($panel-width, #{$panel-width-max});
|
||||
|
||||
#projects {
|
||||
.projects {
|
||||
&.with-feed, &.with-settings {
|
||||
#title {
|
||||
max-width: calc(100vw - var.$block-spacing - $panel-actual-width - (var.$button-width + var.$block-spacing * 2) * 2);
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
|
||||
@use '@styles/page.project.map' as map;
|
||||
@use '@styles/page.project.panel' as panel;
|
||||
@use '@styles/page.project.feed' as feed;
|
||||
@use '@styles/page.project.settings' as settings;
|
||||
@use '@styles/page.project.panel.feed' as feed;
|
||||
@use '@styles/page.project.panel.settings' as settings;
|
||||
|
||||
#projects {
|
||||
.projects {
|
||||
--space: #{color.$space};
|
||||
--horizon: #{color.$horizon};
|
||||
--track-main: #{color.$main-track};
|
||||
|
||||
Reference in New Issue
Block a user