Separate project / feed / settings
All checks were successful
Deploy Spot / deploy (push) Successful in 49s

This commit is contained in:
2026-05-22 22:17:04 +02:00
parent 3fd68fa938
commit 8a590aa2fc
13 changed files with 519 additions and 378 deletions

View File

@@ -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>&nbsp;{{ 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>

View 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>

View File

@@ -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) => {

View 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>&nbsp;{{ 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>

View File

@@ -10,7 +10,7 @@
display: none !important;
}
#projects {
.projects {
.map-container {
width: calc(#{$panel-width});
max-width: calc(#{$panel-width});

View File

@@ -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);

View File

@@ -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};