<template> <div v-frag> <div v-frag v-if="permissions && permissions.can_view_project && project"> <div id="message" class="fullwidth"> <div v-if="tempMessage" class="ui positive message"> <!-- <i class="close icon"></i> --> <!-- <div class="header">You are eligible for a reward</div> --> <p><i class="check icon"></i> {{ tempMessage }}</p> </div> </div> <div id="message_info" class="fullwidth"> <div v-if="infoMessage" class="ui info message" style="text-align: left" > <div class="header"> <i class="info circle icon"></i> Informations </div> <ul class="list"> {{ infoMessage }} </ul> </div> </div> <div class="row"> <div class="four wide middle aligned column"> <img class="ui small spaced image" :src=" project.thumbnail.includes('default') ? require('@/assets/img/default.png') : DJANGO_BASE_URL + project.thumbnail + refreshId() " /> <div class="ui hidden divider"></div> <div class="ui basic teal label" data-tooltip="Membres"> <i class="user icon"></i>{{ project.nb_contributors }} </div> <div class="ui basic teal label" data-tooltip="Signalements publiés"> <i class="map marker icon"></i>{{ project.nb_published_features }} </div> <div class="ui basic teal label" data-tooltip="Commentaires"> <i class="comment icon"></i >{{ project.nb_published_features_comments }} </div> </div> <div class="ten wide column"> <h1 class="ui header"> <div class="content"> {{ project.title }} <div v-if="arraysOffline.length > 0"> {{ arraysOffline.length }} modification<span v-if="arraysOffline.length>1">s</span> en attente <button :disabled="isOffline()" @click="sendOfflineFeatures()" class="ui fluid teal icon button" > <i class="upload icon"></i> Envoyer au serveur </button> </div> <div class="ui icon right floated compact buttons"> <a v-if=" user && permissions && permissions.can_view_project && isOffline() !== true " id="subscribe-button" class="ui button button-hover-green" data-tooltip="S'abonner au projet" data-position="top center" data-variation="mini" @click="isModalOpen = true" > <i class="inverted grey envelope icon"></i> </a> <router-link v-if=" permissions && permissions.can_update_project && isOffline() !== true " :to="{ name: 'project_edit', params: { slug: project.slug } }" class="ui button button-hover-orange" data-tooltip="Modifier le projet" data-position="top center" data-variation="mini" > <i class="inverted grey pencil alternate icon"></i> </router-link> </div> <div class="ui hidden divider"></div> <div class="sub header"> {{ project.description }} </div> </div> </h1> </div> </div> <div class="row"> <div class="seven wide column"> <h3 class="ui header">Types de signalements</h3> <div class="ui middle aligned divided list"> <div :class="{ active : projectInfoLoading }" class="ui inverted dimmer" > <div class="ui text loader"> Récupération des types de signalements en cours... </div> </div> <div :class="{ active: featureTypeImporting }" class="ui inverted dimmer" > <div class="ui text loader"> Traitement du fichier en cours ... </div> </div> <div v-for="(type, index) in feature_types" :key="type.title + '-' + index" class="item" > <div class="feature-type-container"> <router-link :to="{ name: 'details-type-signalement', params: { feature_type_slug: type.slug }, }" class="feature-type-title" > <img v-if="type.geom_type === 'point'" class="list-image-type" src="@/assets/img/marker.png" /> <img v-if="type.geom_type === 'linestring'" class="list-image-type" src="@/assets/img/line.png" /> <img v-if="type.geom_type === 'polygon'" class="list-image-type" src="@/assets/img/polygon.png" /> {{ type.title }} </router-link> <!-- {% if project and feature_types and permissions|lookup:'can_create_feature' %} --> <!-- // ? should we get type.is_editable ? --> <!-- v-if=" project && permissions.can_create_feature && type.is_editable " --> <div class="middle aligned content"> <router-link v-if=" project && permissions && permissions.can_create_feature " :to="{ name: 'ajouter-signalement', params: { slug_type_signal: type.slug }, }" class=" ui compact small icon right floated button button-hover-green " data-tooltip="Ajouter un signalement" data-position="left center" data-variation="mini" > <i class="ui plus icon"></i> </router-link> <router-link :to="{ name: 'dupliquer-type-signalement', params: { slug_type_signal: type.slug }, }" v-if=" project && permissions && permissions.can_create_feature_type && isOffline() !== true " class=" ui compact small icon right floated button button-hover-green " data-tooltip="Dupliquer un type de signalement" data-position="left center" data-variation="mini" > <i class="inverted grey copy alternate icon"></i> </router-link> <div v-if="isImporting(type)" class="import-message" > <i class="info circle icon" /> Import en cours </div> <div v-else v-frag> <router-link :to="{ name: 'editer-symbologie-signalement', params: { slug_type_signal: type.slug }, }" v-if=" project && type.geom_type === 'point' && permissions && permissions.can_create_feature_type && isOffline() != true " class=" ui compact small icon right floated button button-hover-green " data-tooltip="Éditer la symbologie du type de signalement" data-position="left center" data-variation="mini" > <i class="inverted grey paint brush alternate icon"></i> </router-link> <router-link :to="{ name: 'editer-type-signalement', params: { slug_type_signal: type.slug }, }" v-if=" project && type.is_editable && permissions && permissions.can_create_feature_type && isOffline() !== true " class=" ui compact small icon right floated button button-hover-green " data-tooltip="Éditer le type de signalement" data-position="left center" data-variation="mini" > <i class="inverted grey pencil alternate icon"></i> </router-link> </div> </div> </div> </div> <div v-if="feature_types.length === 0"> <i> Le projet ne contient pas encore de type de signalements. </i> </div> </div> <div class="nouveau-type-signalement"> <router-link v-if=" permissions && permissions.can_update_project && isOffline() !== true " :to="{ name: 'ajouter-type-signalement', params: { slug: project.slug }, }" class="ui compact basic button button-hover-green" > <i class="ui plus icon"></i>Créer un nouveau type de signalement </router-link> </div> <div class="nouveau-type-signalement"> <a v-if=" permissions && permissions.can_update_project && isOffline() !== true " class=" ui compact basic button button-hover-green important-flex align-center text-left " > <i class="ui plus icon"></i> <label class="ui" for="json_file"> <span class="label" >Créer un nouveau type de signalement à partir d'un GeoJSON</span > </label> <input type="file" accept="application/json, .json, .geojson" style="display: none" name="json_file" id="json_file" @change="onFileChange" /> </a> <br /> <div id="button-import" v-if="fileToImport.size > 0"> <button :disabled="fileToImport.size === 0" @click="toNewFeatureType" class="ui fluid teal icon button" > <i class="upload icon"></i> Lancer l'import avec le fichier {{ fileToImport.name }} </button> </div> </div> </div> <div class="seven wide column"> <div id="map" ref="map"></div> </div> </div> <div class="row"> <div class="fourteen wide column"> <div class="ui two stackable cards"> <div class="red card"> <div class="content"> <div class="center aligned header">Derniers signalements</div> <div class="center aligned description"> <div :class="{ active: featuresLoading }" class="ui inverted dimmer" > <div class="ui text loader"> Récupération des signalements en cours... </div> </div> <div class="ui relaxed list"> <div v-for="(item, index) in features" :key="item.title + index" class="item" > <div class="content"> <div> <router-link :to="{ name: 'details-signalement', params: { slug: project.slug, slug_type_signal: item.feature_type.slug, slug_signal: item.feature_id, }, }" >{{ item.title || item.feature_id }}</router-link > </div> <div class="description"> <i >[{{ item.created_on | setDate }}<span v-if="user && item.display_creator" >, par {{ item.display_creator }} </span> ]</i > </div> </div> </div> <i v-if="features.length === 0" >Aucun signalement pour le moment.</i > </div> </div> </div> </div> <div class="orange card"> <div class="content"> <div class="center aligned header">Derniers commentaires</div> <div class="center aligned description"> <div :class="{ active: projectInfoLoading }" class="ui inverted dimmer" > <div class="ui text loader"> Récupération des commentaires en cours... </div> </div> <div class="ui relaxed list"> <div v-for="(item, index) in last_comments" :key="'comment ' + index" class="item" > <div class="content"> <div> <router-link :to="getRouteUrl(item.related_feature.feature_url)" >"{{ item.comment }}"</router-link > </div> <div class="description"> <i >[ {{ item.created_on }}<span v-if="user && item.display_author" >, par {{ item.display_author }} </span> ]</i > </div> </div> </div> <i v-if="!last_comments || last_comments.length === 0" >Aucun commentaire pour le moment.</i > </div> </div> </div> </div> </div> </div> </div> <div class="row"> <div class="fourteen wide column"> <div class="ui grey segment"> <h3 class="ui header">Paramètres du projet</h3> <div class="ui five stackable cards"> <div class="card"> <div class="center aligned content"> <h4 class="ui center aligned icon header"> <i class="disabled grey archive icon"></i> <div class="content">Délai avant archivage automatique</div> </h4> </div> <div class="center aligned extra content"> {{ project.archive_feature }} jours </div> </div> <div class="card"> <div class="content"> <h4 class="ui center aligned icon header"> <i class="disabled grey trash alternate icon"></i> <div class="content"> Délai avant suppression automatique </div> </h4> </div> <div class="center aligned extra content"> {{ project.delete_feature }} jours </div> </div> <div class="card"> <div class="content"> <h4 class="ui center aligned icon header"> <i class="disabled grey eye icon"></i> <div class="content"> Visibilité des signalements publiés </div> </h4> </div> <div class="center aligned extra content"> {{ project.access_level_pub_feature }} </div> </div> <div class="card"> <div class="content"> <h4 class="ui center aligned icon header"> <i class="disabled grey eye icon"></i> <div class="content"> Visibilité des signalements archivés </div> </h4> </div> <div class="center aligned extra content"> {{ project.access_level_arch_feature }} </div> </div> <div class="card"> <div class="content"> <h4 class="ui center aligned icon header"> <i class="disabled grey cogs icon"></i> <div class="content">Modération</div> </h4> </div> <div class="center aligned extra content"> {{ project.moderation ? "Oui" : "Non" }} </div> </div> </div> </div> </div> </div> </div> <span v-else> <i class="icon exclamation triangle"></i> <span >Vous ne disposez pas des droits nécessaires pour consulter ce projet.</span > </span> <div v-if="isModalOpen" class="ui dimmer modals page transition visible active" style="display: flex !important" > <div :class="[ 'ui mini modal subscription', { 'transition visible active': isModalOpen }, ]" > <i @click="isModalOpen = false" class="close icon"></i> <div class="ui icon header"> <i class="envelope icon"></i> Notifications du projet </div> <div class="content"> <button @click="subscribeProject" :class="['ui compact fluid button', is_suscriber ? 'red' : 'green']" > {{ is_suscriber ? "Se désabonner de ce projet" : "S'abonner à ce projet" }} </button> </div> </div> </div> <div :class="isFileSizeModalOpen ? 'active' : ''" class="ui dimmer" > <div :class="isFileSizeModalOpen ? 'active' : ''" class="ui modal tiny" style="top: 20%" > <div class="header"> Fichier trop grand! </div> <div class="content"> <p> Impossible de créer un type de signalement à partir d'un fichier GeoJSON de plus de 10Mo (celui importé fait {{ fileSize }} Mo). </p> </div> <div class="actions"> <div class="ui button teal" @click="closeFileSizeModal" > Fermer </div> </div> </div> </div> </div> </template> <script> import frag from "vue-frag"; import { mapUtil } from "@/assets/js/map-util.js"; import { mapGetters, mapState, mapActions, mapMutations } from "vuex"; import projectAPI from "@/services/project-api"; import featureAPI from "@/services/feature-api"; import axios from "@/axios-client.js"; import { fileConvertSizeToMo } from '@/assets/js/utils'; export default { name: "Project_details", props: ["message"], directives: { frag, }, filters: { setDate(value) { const date = new Date(value); const d = date.toLocaleDateString("fr", { year: "2-digit", month: "numeric", day: "numeric", }); return d; }, }, data() { return { infoMessage: "", importMessage: null, arraysOffline: [], arraysOfflineErrors: [], geojsonImport: [], fileToImport: { name: "", size: 0 }, slug: this.$route.params.slug, isModalOpen: false, is_suscriber: false, tempMessage: null, projectInfoLoading: true, featureTypeImporting: false, featuresLoading: true, isFileSizeModalOpen: false }; }, computed: { ...mapGetters([ 'project', 'permissions' ]), ...mapState('feature_type', [ 'feature_types', 'importFeatureTypeData' ]), ...mapState('feature', [ 'features' ]), ...mapState([ 'last_comments', 'user', 'reloadIntervalId' ]), DJANGO_BASE_URL() { return this.$store.state.configuration.VUE_APP_DJANGO_BASE; }, API_BASE_URL() { return this.$store.state.configuration.VUE_APP_DJANGO_API_BASE; }, fileSize() { return fileConvertSizeToMo(this.fileToImport.size); } }, watch: { feature_types: { deep: true, handler(newValue, oldValue) { if (newValue && newValue !== oldValue) { this.GET_IMPORTS({ project_slug: this.$route.params.slug }); } } }, importFeatureTypeData: { deep: true, handler(newValue) { if (newValue && newValue.some(el => el.status === 'pending') && !this.reloadIntervalId) { this.SET_RELOAD_INTERVAL_ID(setInterval(() => { this.GET_IMPORTS({ project_slug: this.$route.params.slug }); }, this.$store.state.configuration.VUE_APP_RELOAD_INTERVAL)); } else if (newValue && !newValue.some(el => el.status === 'pending') && this.reloadIntervalId) { this.GET_PROJECT_FEATURE_TYPES(this.project.slug); this.CLEAR_RELOAD_INTERVAL_ID(); } } } }, methods: { ...mapMutations([ 'SET_RELOAD_INTERVAL_ID', 'CLEAR_RELOAD_INTERVAL_ID' ]), ...mapActions([ 'GET_PROJECT_INFO' ]), ...mapActions('feature_type', [ 'GET_IMPORTS' ]), ...mapActions('feature', [ 'GET_PROJECT_FEATURES' ]), ...mapActions('feature_type', [ 'GET_PROJECT_FEATURE_TYPES' ]), refreshId() { return "?ver=" + Math.random(); }, getRouteUrl(url) { return "/" + url.replace(this.$store.state.configuration.BASE_URL, ""); // remove duplicate /geocontrib }, isOffline() { return navigator.onLine === false; }, isImporting(type) { if (this.importFeatureTypeData) { const singleImportData = this.importFeatureTypeData.find(el => el.feature_type_title === type.slug); return singleImportData && singleImportData.status === 'pending' } return false; }, checkForOfflineFeature() { let arraysOffline = []; let localStorageArray = localStorage.getItem("geocontrib_offline"); if (localStorageArray) { arraysOffline = JSON.parse(localStorageArray); this.arraysOffline = arraysOffline.filter( (x) => x.project === this.project.slug ); } }, sendOfflineFeatures() { var promises = []; let self = this; this.arraysOfflineErrors = []; this.arraysOffline.forEach((feature) => { console.log(feature); if (feature.type === "post") { promises.push( axios .post(`${this.API_BASE_URL}features/`, feature.geojson) .then((response) => { if (response.status === 201 && response.data) { return "OK" } else{ self.arraysOfflineErrors.push(feature); } }) .catch((error) => { console.log(error); self.arraysOfflineErrors.push(feature); }) ); } else if (feature.type === "put") { promises.push( axios .put( `${this.API_BASE_URL}features/${feature.featureId}`, feature.geojson ) .then((response) => { console.log(response); if (response.status === 200 && response.data) { return "OK" } else{ self.arraysOfflineErrors.push(feature); } }) .catch((error) => { console.log(error); self.arraysOfflineErrors.push(feature); }) ); } }); Promise.all(promises).then(() => { this.updateLocalStorage(); window.location.reload(); }); }, updateLocalStorage() { let arraysOffline = []; let localStorageArray = localStorage.getItem("geocontrib_offline"); if (localStorageArray) { arraysOffline = JSON.parse(localStorageArray); } let arraysOfflineOtherProject = arraysOffline.filter( (x) => x.project !== this.project.slug ); this.arraysOffline = []; arraysOffline = arraysOfflineOtherProject.concat(this.arraysOfflineErrors); localStorage.setItem("geocontrib_offline", JSON.stringify(arraysOffline)); }, toNewFeatureType() { this.featureTypeImporting = true; this.$router.push({ name: "ajouter-type-signalement", params: { geojson: this.geojsonImport, fileToImport: this.fileToImport, }, }); this.featureTypeImporting = false; }, onFileChange(e) { this.featureTypeImporting = true; var files = e.target.files || e.dataTransfer.files; if (!files.length) return; this.fileToImport = files[0]; // TODO : VALIDATION IF FILE IS JSON if (parseFloat(fileConvertSizeToMo(this.fileToImport.size)) > 10) { this.isFileSizeModalOpen = true; } else if (this.fileToImport.size > 0) { const fr = new FileReader(); try { fr.onload = (e) => { this.geojsonImport = JSON.parse(e.target.result); this.featureTypeImporting = false; }; fr.readAsText(this.fileToImport); //* stock filename to import features afterward this.$store.commit( "feature_type/SET_FILE_TO_IMPORT", this.fileToImport ); } catch (err) { console.error(err); this.featureTypeImporting = false } } else { this.featureTypeImporting = false; } }, closeFileSizeModal() { this.fileToImport = { name: "", size: 0 }; this.featureTypeImporting = false; this.isFileSizeModalOpen = false; }, subscribeProject() { projectAPI .subscribeProject({ suscribe: !this.is_suscriber, projectSlug: this.$route.params.slug, }) .then((data) => { this.is_suscriber = data.is_suscriber; this.isModalOpen = false; if (this.is_suscriber) this.infoMessage = "Vous êtes maintenant abonné aux notifications de ce projet."; else this.infoMessage = "Vous ne recevrez plus les notifications de ce projet."; setTimeout(() => (this.infoMessage = ""), 3000); }); }, initMap() { if (this.project && this.permissions.can_view_project) { this.$store.dispatch("map/INITIATE_MAP", this.$refs.map); this.checkForOfflineFeature(); let project_id = this.$route.params.slug.split("-")[0]; const mvtUrl = `${this.API_BASE_URL}features.mvt/?tile={z}/{x}/{y}&project_id=${project_id}`; mapUtil.addVectorTileLayer( mvtUrl, this.$route.params.slug, this.$store.state.feature_type.feature_types ); this.arraysOffline.forEach((x) => (x.geojson.properties.color = "red")); const features = this.arraysOffline.map((x) => x.geojson); mapUtil.addFeatures( features, {}, true, this.$store.state.feature_type.feature_types ); featureAPI .getFeaturesBbox(this.project.slug) .then((bbox) => { if (bbox) { mapUtil.getMap().fitBounds(bbox, { padding: [25, 25] }); } }); } }, }, created() { if (this.user) { projectAPI .getProjectSubscription({ projectSlug: this.$route.params.slug }) .then((data) => (this.is_suscriber = data.is_suscriber)); } this.$store.commit("feature_type/SET_FEATURE_TYPES", []); //* empty feature_types remaining from previous project }, mounted() { this.GET_PROJECT_INFO(this.slug) .then(() => { this.projectInfoLoading = false; setTimeout(this.initMap, 1000); }) .catch(() => { this.projectInfoLoading = false; }); this.GET_PROJECT_FEATURES({ project_slug: this.slug, ordering: '-created_on', limit: 5 }) .then(() => { this.featuresLoading = false; }) .catch(() => { this.featuresLoading = false; }); if (this.message) { this.tempMessage = this.message; document .getElementById("message") .scrollIntoView({ block: "end", inline: "nearest" }); setTimeout(() => (this.tempMessage = null), 5000); //* hide message after 5 seconds } }, }; </script> <style> @import "../../assets/resources/semantic-ui-2.4.2/semantic.min.css"; #map { width: 100%; height: 100%; min-height: 250px; } .list-image-type { margin-right: 5px; height: 25px; vertical-align: bottom; } /* // ! missing style in semantic.min.css, je ne comprends pas comment... */ .ui.right.floated.button { float: right; margin: 0 0 0 1em; } .feature-type-container { display: flex; justify-content: space-between; align-items: center; } .feature-type-container > .middle.aligned.content { width: 50%; } .feature-type-title { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; line-height: 1.5em; } .nouveau-type-signalement { cursor: pointer; padding-top: 1em; } .nouveau-type-signalement > a > .ui > .label{ cursor: pointer; } #button-import { padding-top: 0.5em; } .fullwidth { width: 100%; } .important-flex { display: flex !important; } .align-center { align-items: center !important; } .text-left { text-align: left !important; } .import-message { width: fit-content; line-height: 2em; color: teal; } </style>