Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • geocontrib/geocontrib-frontend
  • ext_matthieu/geocontrib-frontend
  • fnecas/geocontrib-frontend
  • MatthieuE/geocontrib-frontend
4 results
Show changes
Showing
with 3815 additions and 0 deletions
<template>
<div>
<div class="three fields">
<h5 class="field">
{{ title }}
</h5>
<div class="required inline field">
<label :for="form.color.id_for_label">{{ form.color.label }}</label>
<input
:id="form.color.id_for_label"
v-model.lazy="form.color.value"
type="color"
required
:name="form.color.html_name"
>
</div>
<div
v-if="geomType === 'polygon' || geomType === 'multipolygon'"
class="field"
>
<label>Opacité &nbsp;<span>(%)</span></label>
<div class="range-container">
<input
id="opacity"
v-model="form.opacity"
type="range"
min="0"
max="1"
step="0.01"
>
<output class="range-output-bubble">
{{ getOpacity(form.opacity) }}
</output>
</div>
</div>
</div>
<div
v-if="isIconPickerModalOpen"
ref="iconsPickerModal"
class="ui dimmer modal transition active"
>
<div class="header">
Sélectionnez le symbole pour ce type de signalement :
</div>
<div class="scrolling content">
<div
v-for="icon of iconsNamesList"
:key="icon"
:class="['icon-container', { active: form.icon === icon }]"
@click="selectIcon(icon)"
>
<i
:class="`icon alt fas fa-${icon}`"
aria-hidden="true"
/>
</div>
</div>
<div class="actions">
<div
class="ui cancel button"
@click="isIconPickerModalOpen = false;"
>
Fermer
</div>
</div>
</div>
</div>
</template>
<script>
import faIconsNames from '@/assets/icons/fa-icons.js';
export default {
name: 'SymbologySelector',
props: {
title: {
type: String,
default: 'Couleur par défault :'
},
initColor: {
type: String,
default: '#000000'
},
initIcon: {
type: String,
default: 'circle'
},
initOpacity: {
type: [String, Number],
default: '0.5'
},
geomType: {
type: String,
default: 'Point'
}
},
data() {
return {
isIconPickerModalOpen: false,
iconsNamesList: faIconsNames,
form: {
icon: 'circle',
color: {
id_for_label: 'couleur',
label: 'Couleur',
field: {
max_length: 128, // ! Vérifier la valeur dans django
},
html_name: 'couleur',
value: '#000000',
},
opacity: '0.5',
}
};
},
watch: {
form: {
deep: true,
handler(newValue) {
this.$emit('set', {
name: this.isDefault ? null : this.title,
value: newValue
});
}
}
},
created() {
this.form.color.value = this.initColor;
if (this.initIcon) {
this.form.icon = this.initIcon;
}
if (this.initOpacity) {
this.form.opacity = this.initOpacity;
}
this.$emit('set', {
name: this.title,
value: this.form
});
},
methods: {
openIconSelectionModal() {
this.isIconPickerModalOpen = true;
},
selectIcon(icon) {
this.form.icon = icon;
},
getOpacity(opacity) {
return Math.round(parseFloat(opacity) * 100);
},
}
};
</script>
<style lang="less" scoped>
.fields {
align-items: center;
}
#customFieldSymbology .fields {
margin-left: 1em !important;
margin-bottom: 1em !important;
}
h5 {
font-weight: initial;
font-style: italic;
}
#couleur {
width: 66%;
cursor: pointer;
box-shadow: 0 0 1px 1px rgb(189, 189, 189);
}
.picker-button {
height: 50px;
width: 50px;
border-radius: 3px;
box-shadow: 0 0 2px 1px rgb(131, 131, 131);
.icon.alt {
width: 30px;
height: 30px;
}
}
.picker-button:hover {
box-shadow: 0 0 2px 1px rgb(165, 165, 165);
}
.modal {
height: fit-content;
.content {
display: flex;
flex-flow: row wrap;
.icon-container {
padding: 7px;
.icon.alt {
color: rgb(75, 75, 75);
width: 30px;
height: 30px;
}
}
.icon-container:hover {
cursor: pointer;
background-color: rgba(130, 216, 219, 0.589);
}
.icon-container.active {
background-color: rgba(130, 216, 219, 0.589);
}
}
}
</style>
<template>
<div>
<geor-header
:legacy-header="legacyHeader"
:legacy-url="legacyUrl"
:style="customStyle"
:logo-url="logo"
:stylesheet="customStylesheet"
:active-app="activeApp"
/>
</div>
</template>
<script>
import { mapState } from 'vuex';
export default {
name: 'GeorchestraHeader',
computed: {
...mapState([
'configuration'
]),
headerConfig() {
return this.configuration.GEORCHESTRA_INTEGRATION?.HEADER;
},
activeApp() {
return this.$route.path.includes('my_account') ? 'geocontrib-account' : 'geocontrib';
},
logo() {
return this.configuration.VUE_APP_LOGO_PATH;
},
legacyHeader() {
return this.headerConfig.LEGACY_HEADER;
},
legacyUrl() {
return this.headerConfig.LEGACY_URL;
},
customStyle() {
return this.headerConfig.STYLE;
},
customStylesheet() {
return this.headerConfig.STYLESHEET;
},
headerScript() {
return this.headerConfig.HEADER_SCRIPT;
},
},
mounted() {
const headerScript = document.createElement('script');
headerScript.setAttribute('src', this.headerScript ? this.headerScript : 'https://cdn.jsdelivr.net/gh/georchestra/header@dist/header.js');
document.head.appendChild(headerScript);
},
};
</script>
<template>
<div id="table-imports">
<div class="imports-header">
<div class="file-column">
Fichiers importés
</div>
<div class="status-column">
Statuts
</div>
</div>
<div
v-for="importFile in imports"
:key="importFile.created_on"
class="filerow"
>
<div class="file-column">
<h4 class="ui header align-right">
<div
:data-tooltip="importFile.geojson_file_name"
class="ellipsis"
>
{{ importFile.geojson_file_name }}
</div>
</h4>
<div class="sub header">
ajouté le {{ importFile.created_on | formatDate }}
</div>
</div>
<div class="status-column">
<span
v-if="importFile.infos"
:data-tooltip="importFile.infos"
class="ui icon margin-left"
>
<i
v-if="importFile.status === 'processing'"
class="orange hourglass half icon"
aria-hidden="true"
/>
<i
v-else-if="importFile.status === 'finished'"
class="green check circle outline icon"
aria-hidden="true"
/>
<i
v-else-if="importFile.status === 'failed'"
class="red x icon"
aria-hidden="true"
/>
<i
v-else
class="red ban icon"
aria-hidden="true"
/>
</span>
<span
v-if="importFile.status === 'pending'"
data-tooltip="Statut en attente. Cliquez pour rafraichir."
>
<i
:class="['orange icon', !reloading ? 'sync' : 'hourglass half rotate']"
aria-hidden="true"
@click="fetchImports()"
/>
</span>
</div>
</div>
</div>
</template>
<script>
export default {
filters: {
formatDate: function (value) {
const date = new Date(value);
return date.toLocaleDateString('fr', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
},
subString: function (value) {
return value.substring(0, 27) + '..';
},
},
props: {
imports: {
type: Array,
default: null,
},
},
data() {
return {
reloading: false,
fetchCallCounter: 0,
};
},
mounted() {
this.fetchImports();
},
methods: {
fetchImports() {
this.fetchCallCounter += 1; // register each time function is programmed to be called in order to avoid redundant calls
this.reloading = true;
// fetch imports
this.$store.dispatch('feature-type/GET_IMPORTS', {
project_slug: this.$route.params.slug,
feature_type: this.$route.params.feature_type_slug
})
.then((response) => {
if (response.data && response.data.some(el => el.status === 'pending')) {
// if there is still some pending imports re-fetch imports by calling this function again
setTimeout(() => {
if (this.fetchCallCounter <= 1 ) {
// if the function wasn't called more than once in the reload interval, then call it again
this.fetchImports();
}
this.fetchCallCounter -= 1; // decrease function counter
}, this.$store.state.configuration.VUE_APP_RELOAD_INTERVAL);
// give a bit time for loader to be seen by user if response was fast
setTimeout(() => {
this.reloading = false;
}, 1500);
} else {
// if no pending import, get last features
this.$emit('reloadFeatureType');
}
});
},
},
};
</script>
<style scoped lang="less">
#table-imports {
border: 1px solid lightgrey;
margin-top: 1rem;
.imports-header {
border-bottom: 1px solid lightgrey;
font-weight: bold;
}
> div {
padding: .5em 1em;
}
.filerow {
background-color: #fff;
}
.imports-header, .filerow {
display: flex;
.file-column {
width: 80%;
h4 {
margin-bottom: .2em;
}
}
.status-column {
width: 20%;
text-align: right;
}
}
}
.sync {
cursor: pointer;
}
i.icon {
width: 20px !important;
height: 20px !important;
}
.rotate {
-webkit-animation:spin 1.5s cubic-bezier(.3,.25,.15,1) infinite;
-moz-animation:spin 1.5s cubic-bezier(.3,.25,.15,1) infinite;
animation:spin 1.5s cubic-bezier(.3,.25,.15,1) infinite;
}
@-moz-keyframes spin { 100% { -moz-transform: rotate(180deg); } }
@-webkit-keyframes spin { 100% { -webkit-transform: rotate(180deg); } }
@keyframes spin { 100% { -webkit-transform: rotate(180deg); transform:rotate(180deg); } }
</style>
<template>
<div class="editionToolbar">
<div class="leaflet-bar">
<a
v-if="noExistingFeature"
class="leaflet-draw-draw-polygon active"
:title="`Dessiner ${editionService.geom_type === 'linestring' ? 'une' : 'un'} ${toolbarGeomTitle}`
"
>
<img
v-if="editionService.geom_type === 'linestring'"
class="list-image-type"
src="@/assets/img/line.png"
alt="line"
>
<img
v-if="editionService.geom_type === 'point'"
class="list-image-type"
src="@/assets/img/marker.png"
alt="marker"
>
<img
v-if="editionService.geom_type === 'polygon'"
class="list-image-type"
src="@/assets/img/polygon.png"
alt="polygon"
>
</a>
<a
v-else
:class="{ active: isEditing }"
:title="`Modifier ${toolbarEditGeomTitle}`"
@click="toggleEdition"
>
<i class="edit outline icon" />
<span class="sr-only">Modifier {{ toolbarEditGeomTitle }}</span>
</a>
<a
v-if="noExistingFeature || isEditing"
:class="{ active: isSnapEnabled }"
:title="`${ isSnapEnabled ? 'Désactiver' : 'Activer' } l'accrochage aux points`"
@click="toggleSnap"
>
<i
class="magnet icon"
aria-hidden="true"
/>
<span class="sr-only">{{ isSnapEnabled ? 'Désactiver' : 'Activer' }} l'accrochage aux points</span>
</a>
<a
v-if="!noExistingFeature"
:title="`Supprimer ${toolbarEditGeomTitle}`"
@click="deleteObj"
>
<i class="trash alternate outline icon" />
<span class="sr-only">Supprimer {{ toolbarEditGeomTitle }}</span>
</a>
</div>
</div>
</template>
<script>
import editionService from '@/services/edition-service';
export default {
name: 'EditingToolbar',
props: {
map: {
type: Object,
default: null,
},
},
data() {
return {
editionService: editionService,
isEditing: false,
isSnapEnabled: false,
};
},
computed: {
noExistingFeature() {
return this.editionService?.editing_feature === undefined;
},
toolbarGeomTitle() {
switch (this.editionService.geom_type) {
case 'polygon':
return 'polygone';
case 'linestring':
return 'ligne';
}
return 'point';
},
toolbarEditGeomTitle() {
return `${this.editionService.geom_type === 'linestring' ? 'la' : 'le'} ${this.toolbarGeomTitle}`;
}
},
methods: {
toggleEdition() {
this.isEditing = !this.isEditing;
editionService.activeUpdateFeature(this.isEditing);
},
deleteObj() {
const hasBeenRemoved = editionService.removeFeatureFromMap();
// Vérifie que l'utilisateur a bien confirmé la suppression avant de réinitialiser le bouton d'édition
if (this.isEditing && hasBeenRemoved) {
this.isEditing = false;
}
},
toggleSnap() {
if (this.isSnapEnabled) {
editionService.removeSnapInteraction(this.map);
} else {
editionService.addSnapInteraction(this.map);
}
this.isSnapEnabled = !this.isSnapEnabled;
}
}
};
</script>
<style lang="less" scoped>
.editionToolbar{
position: absolute;
// each button have (more or less depends on borders) .5em space between
// zoom buttons are 60px high, geolocation and full screen button is 34px high with borders
top: calc(2em + 60px + 34px + 64px);
right: 6px;
border: 2px solid rgba(0,0,0,.2);
border-radius: 4px;
background-clip: padding-box;
padding: 0;
z-index: 9;
}
.leaflet-bar {
a:first-child {
border-top-left-radius: 2px;
border-top-right-radius: 2px;
}
a:last-child {
border-bottom-left-radius: 2px;
border-bottom-right-radius: 2px;
border-bottom: none;
}
a, .leaflet-control-layers-toggle {
background-position: 50% 50%;
background-repeat: no-repeat;
display: block;
}
a {
background-color: #fff;
width: 30px;
height: 30px;
display: block;
text-align: center;
text-decoration: none;
color: black;
i {
margin: 0;
vertical-align: middle;
&.magnet {
transform: rotate(90deg);
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
opacity: .85;
}
}
}
.active {
background-color: rgba(255, 145, 0, 0.904);
color: #fff;
i {
font-weight: bold;
}
img {
filter: invert(100%) sepia(0%) saturate(7500%) hue-rotate(282deg) brightness(105%) contrast(100%);
}
}
.list-image-type {
height: 20px;
vertical-align: middle;
margin: 5px 0 5px 0;
}
a:hover {
cursor: pointer !important;
background-color: #ebebeb !important;
}
a:focus {
background-color: #ebebeb !important;
}
}
</style>
<template>
<div>
<div
id="geocoder-container"
:class="{ isExpanded }"
>
<button
class="button-geocoder"
title="Rechercher une adresse"
type="button"
@click="toggleGeocoder"
>
<i class="search icon" />
</button>
</div>
<div
id="geocoder-select-container"
:class="{ isExpanded }"
>
<!-- internal-search should be disabled to avoid filtering options, which is done by the api calls anyway https://stackoverflow.com/questions/57813170/vue-multi-select-not-showing-all-the-options -->
<!-- otherwise approximate results are not shown (cannot explain why though) -->
<Multiselect
v-if="isExpanded"
ref="multiselect"
v-model="selection"
class="expanded-geocoder"
:options="addresses"
:options-limit="limit"
:allow-empty="true"
:internal-search="false"
track-by="id"
label="label"
:show-labels="false"
:reset-after="true"
select-label=""
selected-label=""
deselect-label=""
:searchable="true"
:placeholder="placeholder"
:show-no-results="true"
:loading="loading"
:clear-on-select="false"
:preserve-search="true"
@search-change="search"
@select="select"
@open="retrievePreviousPlaces"
@close="close"
>
<template
slot="option"
slot-scope="props"
>
<div class="option__desc">
<span class="option__title">{{ props.option.label }}</span>
</div>
</template>
<template slot="clear">
<div
v-if="selection"
class="multiselect__clear"
@click.prevent.stop="selection = null"
>
<i class="close icon" />
</div>
</template>
<span slot="noResult">
Aucun résultat.
</span>
<span slot="noOptions">
Saisissez les premiers caractères ...
</span>
</Multiselect>
<div style="display: none;">
<div
id="marker"
title="Marker"
/>
</div>
</div>
</div>
</template>
<script>
import Multiselect from 'vue-multiselect';
import { Subject } from 'rxjs';
import { debounceTime } from 'rxjs/operators';
import axios from 'axios';
import mapService from '@/services/map-service';
const apiAdressAxios = axios.create({
baseURL: 'https://api-adresse.data.gouv.fr',
withCredentials: false,
});
export default {
name: 'Geocoder',
components: {
Multiselect
},
data() {
return {
loading: false,
limit: 10,
selection: null,
text: null,
selectedAddress: null,
addresses: [],
resultats: [],
placeholder: 'Rechercher une adresse ...',
isExpanded: false
};
},
mounted() {
this.addressTextChange = new Subject();
this.addressTextChange.pipe(debounceTime(200)).subscribe((res) => this.getAddresses(res));
},
methods: {
toggleGeocoder() {
this.isExpanded = !this.isExpanded;
if (this.isExpanded) {
this.retrievePreviousPlaces();
this.$nextTick(()=> this.$refs.multiselect.activate());
}
},
getAddresses(query){
if (query.length < 3) {
this.addresses = [];
return;
}
const coords = mapService.getMapCenter();
let url = `https://api-adresse.data.gouv.fr/search/?q=${query}&limit=${this.limit}`;
if (coords) url += `&lon=${coords[0]}&lat=${coords[1]}`;
apiAdressAxios.get(url)
.then((retour) => {
this.resultats = retour.data.features;
this.addresses = retour.data.features.map(x=>x.properties);
});
},
selectAddresse(event) {
this.selectedAddress = event;
if (this.selectedAddress !== null && this.selectedAddress.geometry) {
let zoomlevel = 14;
const { type } = this.selectedAddress.properties;
if (type === 'housenumber') {
zoomlevel = 19;
} else if (type === 'street') {
zoomlevel = 16;
} else if (type === 'locality') {
zoomlevel = 16;
}
// On ajoute un point pour localiser la ville
mapService.addOverlay(this.selectedAddress.geometry.coordinates, zoomlevel);
// On enregistre l'adresse sélectionné pour le proposer à la prochaine recherche
this.setLocalstorageSelectedAdress(this.selectedAddress);
this.toggleGeocoder();
}
},
search(text) {
this.text = text;
this.addressTextChange.next(this.text);
},
select(e) {
this.selectAddresse(this.resultats.find(x=>x.properties.label === e.label));
this.$emit('select', e);
},
close() {
this.$emit('close', this.selection);
},
setLocalstorageSelectedAdress(newAdress) {
let selectedAdresses = JSON.parse(localStorage.getItem('geocontrib-selected-adresses'));
selectedAdresses = Array.isArray(selectedAdresses) ? selectedAdresses : [];
selectedAdresses = [ newAdress, ...selectedAdresses ];
const uniqueLabels = [...new Set(selectedAdresses.map(el => el.properties.label))];
const uniqueAdresses = uniqueLabels.map((label) => {
return selectedAdresses.find(adress => adress.properties.label === label);
});
localStorage.setItem(
'geocontrib-selected-adresses',
JSON.stringify(uniqueAdresses.slice(0, 5))
);
},
getLocalstorageSelectedAdress() {
return JSON.parse(localStorage.getItem('geocontrib-selected-adresses')) || [];
},
retrievePreviousPlaces() {
const previousAdresses = this.getLocalstorageSelectedAdress();
if (previousAdresses.length > 0) {
this.addresses = previousAdresses.map(x=>x.properties);
this.resultats = previousAdresses;
}
},
}
};
</script>
<style lang="less">
#marker {
width: 14px;
height: 14px;
border: 2px solid #fff;
border-radius: 7px;
background-color: #3399CC;
}
#geocoder-container {
position: absolute;
right: 6px;
// each button have (more or less depends on borders) .5em space between
// zoom buttons are 60px high, geolocation and full screen button is 34px high with borders
top: calc(1.6em + 60px + 34px + 34px);
pointer-events: auto;
z-index: 999;
border: 2px solid rgba(0,0,0,.2);
background-clip: padding-box;
padding: 0;
border-radius: 4px;
display: flex;
.button-geocoder {
border: none;
padding: 0;
margin: 0;
text-align: center;
background-color: #fff;
color: rgb(39, 39, 39);
width: 30px;
height: 30px;
font: 700 18px Lucida Console,Monaco,monospace;
border-radius: 2px;
line-height: 1.15;
i {
margin: 0;
font-size: 0.9em;
}
}
.button-geocoder:hover {
cursor: pointer;
background-color: #ebebeb;
}
.expanded-geocoder {
max-width: 400px;
}
&&.isExpanded {
.button-geocoder {
/*height: 41px;*/
color: rgb(99, 99, 99);
border-radius: 2px 0 0 2px;
}
}
}
#geocoder-select-container{
position: absolute;
right: 46px;
// each button have (more or less depends on borders) .5em space between
// zoom buttons are 60px high, geolocation and full screen button is 34px high with borders
top: calc(1.6em + 60px + 34px + 34px - 4px);
pointer-events: auto;
z-index: 999;
border: 2px solid rgba(0,0,0,.2);
background-clip: padding-box;
padding: 0;
border-radius: 4px;
display: flex;
// /* keep placeholder width when opening dropdown */
.multiselect {
min-width: 208px;
}
/* keep font-weight from overide of semantic classes */
.multiselect__placeholder, .multiselect__content, .multiselect__tags {
font-weight: initial !important;
}
/* keep placeholder eigth */
.multiselect .multiselect__placeholder {
margin-bottom: 9px !important;
padding-top: 1px;
}
/* keep placeholder height when opening dropdown without selection */
input.multiselect__input {
padding: 3px 0 0 0 !important;
}
/* keep placeholder height when opening dropdown with already a value selected */
.multiselect__tags .multiselect__single {
padding: 1px 0 0 0 !important;
margin-bottom: 9px;
}
.multiselect__tags {
border: 0 !important;
min-height: 41px !important;
padding: 10px 40px 0 8px;
}
.multiselect input {
line-height: 1em !important;
padding: 0 !important;
}
.multiselect__content-wrapper {
border: 2px solid rgba(0,0,0,.2);
}
}
</style>
\ No newline at end of file
<template>
<div class="geolocation-container">
<button
:class="['button-geolocation', { tracking }]"
title="Me localiser"
@click.prevent="toggleTracking"
>
<i class="icon" />
</button>
</div>
</template>
<script>
import mapService from '@/services/map-service';
export default {
name: 'Geolocation',
data() {
return {
tracking: false,
};
},
methods: {
toggleTracking() {
this.tracking = !this.tracking;
mapService.toggleGeolocation(this.tracking);
},
}
};
</script>
\ No newline at end of file
<template>
<div class="basemaps-items ui accordion styled">
<div
:class="['basemap-item title', { active }]"
@click="$emit('activateGroup', basemap.id)"
>
<i
class="map outline fitted icon"
aria-hidden="true"
/>
<span>{{ basemap.title }}</span>
</div>
<div
v-if="queryableLayersOptions.length > 0 && active"
:id="`queryable-layers-selector-${basemap.id}`"
>
<strong>Couche requêtable</strong>
<Dropdown
:options="queryableLayersOptions"
:selected="selectedQueryLayer"
:search="true"
@update:selection="setQueryLayer($event)"
/>
</div>
<div
:id="`list-${basemap.id}`"
:class="['content', { active }]"
:data-basemap-index="basemap.id"
>
<div
v-for="layer in basemap.layers"
:key="basemap.id + '-' + layer.id + Math.random()"
class="layer-item transition visible item list-group-item"
:data-id="layer.id"
>
<!-- layer id is used for retrieving layer when changing order -->
<p class="layer-handle-sort">
<i
class="th icon"
aria-hidden="true"
/>
{{ layer.title }}
</p>
<label>Opacité &nbsp;<span>(%)</span></label>
<div class="range-container">
<input
type="range"
min="0"
max="1"
:value="layer.opacity"
step="0.01"
@change="updateOpacity($event, layer)"
><output class="range-output-bubble">{{
getOpacity(layer.opacity)
}}</output>
</div>
<div class="ui divider" />
</div>
</div>
</div>
</template>
<script>
import Sortable from 'sortablejs';
import Dropdown from '@/components/Dropdown.vue';
import mapService from '@/services/map-service';
export default {
name: 'LayerSelector',
components: {
Dropdown,
},
props: {
basemap: {
type: Object,
default: null,
},
active: {
type: Boolean,
default: false,
},
selectedQueryLayer: {
type: String,
default: ''
}
},
data() {
return {
sortable: null,
};
},
computed: {
queryableLayersOptions() {
const queryableLayers = this.basemap.layers.filter((l) => l.queryable === true);
return queryableLayers.map((x) => {
return {
name: x.title,
value: x,
};
});
},
},
mounted() {
setTimeout(this.initSortable.bind(this), 1000);
},
methods: {
isQueryable(baseMap) {
const queryableLayer = baseMap.layers.filter((l) => l.queryable === true);
return queryableLayer.length > 0;
},
initSortable() {
const element = document.getElementById(`list-${this.basemap.id}`);
if (element) {
this.sortable = new Sortable(element, {
animation: 150,
handle: '.layer-handle-sort', // The element that is active to drag
ghostClass: 'blue-background-class',
dragClass: 'white-opacity-background-class',
onEnd: () => this.$emit('onlayerMove'),
});
} else {
console.error(`list-${this.basemap.id} not found in dom`);
}
},
setQueryLayer(layer) {
this.$emit('onQueryLayerChange', layer.name);
},
getOpacity(opacity) {
return Math.round(parseFloat(opacity) * 100);
},
updateOpacity(event, layer) {
const layerId = layer.id;
const opacity = event.target.value;
mapService.updateOpacity(layerId, opacity);
this.$emit('onOpacityUpdate', { layerId, opacity });
},
}
};
</script>
<style>
.basemap-item.title > i {
margin-left: -1em !important;
}
.basemap-item.title > span {
margin-left: .5em;
}
.queryable-layers-dropdown {
margin-bottom: 1em;
}
.queryable-layers-dropdown > label {
font-weight: bold;
}
</style>
<template>
<div
v-if="isOnline"
:class="['sidebar-container', { expanded }]"
>
<div
class="layers-icon"
@click="toggleSidebar()"
>
<!-- // ! svg point d'interrogation pas accepté par linter -->
<!-- <?xml version="1.0" encoding="iso-8859-1"?> -->
<svg
id="Capa_1"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
x="0px"
y="0px"
viewBox="0 0 491.203 491.203"
style="enable-background: new 0 0 491.203 491.203"
xml:space="preserve"
>
<g>
<g>
<!-- eslint-disable max-len -->
<path
d="M487.298,326.733l-62.304-37.128l62.304-37.128c2.421-1.443,3.904-4.054,3.904-6.872s-1.483-5.429-3.904-6.872
l-62.304-37.128l62.304-37.128c3.795-2.262,5.038-7.172,2.776-10.968c-0.68-1.142-1.635-2.096-2.776-2.776l-237.6-141.6
c-2.524-1.504-5.669-1.504-8.192,0l-237.6,141.6c-3.795,2.262-5.038,7.172-2.776,10.968c0.68,1.142,1.635,2.096,2.776,2.776
l62.304,37.128L3.905,238.733c-3.795,2.262-5.038,7.172-2.776,10.968c0.68,1.142,1.635,2.096,2.776,2.776l62.304,37.128
L3.905,326.733c-3.795,2.262-5.038,7.172-2.776,10.968c0.68,1.142,1.635,2.096,2.776,2.776l237.6,141.6
c2.526,1.494,5.666,1.494,8.192,0l237.6-141.6c3.795-2.262,5.038-7.172,2.776-10.968
C489.393,328.368,488.439,327.414,487.298,326.733z M23.625,157.605L245.601,25.317l221.976,132.288L245.601,289.893
L23.625,157.605z M23.625,245.605l58.208-34.68l159.672,95.2c2.524,1.504,5.668,1.504,8.192,0l159.672-95.2l58.208,34.68
L245.601,377.893L23.625,245.605z M245.601,465.893L23.625,333.605l58.208-34.68l159.672,95.2c2.524,1.504,5.668,1.504,8.192,0
l159.672-95.2l58.208,34.68L245.601,465.893z"
/>
<!--eslint-enable-->
</g>
</g>
</svg>
</div>
<div class="basemaps-title">
<h4>
Fonds cartographiques
</h4>
</div>
<LayerSelector
v-for="basemap in baseMaps"
:key="`list-${basemap.id}`"
:basemap="basemap"
:selected-query-layer="selectedQueryLayer"
:active="basemap.active"
@addLayers="addLayers"
@activateGroup="activateGroup"
@onlayerMove="onlayerMove"
@onOpacityUpdate="onOpacityUpdate"
@onQueryLayerChange="onQueryLayerChange"
/>
</div>
</template>
<script>
import { mapState } from 'vuex';
import LayerSelector from '@/components/Map/LayerSelector.vue';
import mapService from '@/services/map-service';
export default {
name: 'SidebarLayers',
components: {
LayerSelector
},
data() {
return {
baseMaps: [],
expanded: false,
projectSlug: this.$route.params.slug,
selectedQueryLayer: '',
};
},
computed: {
...mapState([
'isOnline',
]),
...mapState('map', [
'availableLayers',
'basemaps'
]),
activeBasemap() {
return this.baseMaps.find((baseMap) => baseMap.active);
},
activeBasemapIndex() {
return this.baseMaps.findIndex((el) => el.id === this.activeBasemap.id);
},
activeQueryableLayers() {
return this.baseMaps[this.activeBasemapIndex].layers.filter((layer) => layer.queryable);
},
},
mounted() {
this.initBasemaps();
},
methods: {
/**
* Initializes the basemaps and handles their state.
* This function checks if the basemaps stored in the local storage match those fetched from the server.
* If changes are detected, it resets the local storage with updated basemaps.
* It also sets the first basemap as active by default and adds the corresponding layers to the map.
*/
initBasemaps() {
// Clone object to not modify data in store, using JSON parse instead of spread operator
// that modifies data, for instance when navigating to basemap administration page
this.baseMaps = JSON.parse(JSON.stringify(this.basemaps));
// Retrieve map options from local storage
const mapOptions = JSON.parse(localStorage.getItem('geocontrib-map-options')) || {};
// Check if map options exist for the current project
if (mapOptions && mapOptions[this.projectSlug]) {
// If already in the storage, we need to check if the admin did some
// modification in the basemaps on the server side.
// The rule is: if one layer has been added or deleted on the server,
// then we reset the local storage.
const baseMapsFromLocalstorage = mapOptions[this.projectSlug]['basemaps'];
const areChanges = this.areChangesInBasemaps(
this.baseMaps,
baseMapsFromLocalstorage
);
// If changes are detected, update local storage with the latest basemaps
if (areChanges) {
mapOptions[this.projectSlug] = {
basemaps: this.baseMaps,
'current-basemap-index': 0,
};
localStorage.setItem(
'geocontrib-map-options',
JSON.stringify(mapOptions)
);
} else if (baseMapsFromLocalstorage) {
// If no changes, use the basemaps from local storage
this.baseMaps = baseMapsFromLocalstorage;
}
}
if (this.baseMaps.length > 0) {
// Set the first basemap as active by default
this.baseMaps[0].active = true;
// Check if an active layer has been set previously by the user
const activeBasemapId = mapOptions[this.projectSlug]
? mapOptions[this.projectSlug]['current-basemap-index']
: null;
// Ensure the active layer ID exists in the current basemaps in case id does not exist anymore or has changed
if (activeBasemapId >= 0 && this.baseMaps.some(bm => bm.id === activeBasemapId)) {
this.baseMaps.forEach((baseMap) => {
// Set the active layer by matching the ID and setting the active property to true
if (baseMap.id === mapOptions[this.projectSlug]['current-basemap-index']) {
baseMap.active = true;
} else {
// Reset others to false to prevent errors from incorrect mapOptions
baseMap.active = false;
}
});
}
// Add layers for the active basemap
this.addLayers(this.activeBasemap);
this.setSelectedQueryLayer();
} else {
// If no basemaps are available, add the default base map layers from the configuration
mapService.addLayers(
null,
this.$store.state.configuration.DEFAULT_BASE_MAP_SERVICE,
this.$store.state.configuration.DEFAULT_BASE_MAP_OPTIONS,
this.$store.state.configuration.DEFAULT_BASE_MAP_SCHEMA_TYPE
);
}
},
toggleSidebar(value) {
this.expanded = value !== undefined ? value : !this.expanded;
},
// Check if there are changes in the basemaps settings. Changes are detected if:
// - one basemap has been added or deleted
// - one layer has been added or deleted to a basemap
areChangesInBasemaps(basemapFromServer, basemapFromLocalstorage) {
// prevent undefined later even if in this case the function is not called anyway
if (!basemapFromLocalstorage) return false;
let isSameBasemaps = false;
let isSameLayers = true;
let isSameTitles = true;
// Compare the length and the id values of the basemaps
const idBasemapsServer = basemapFromServer.map((b) => b.id).sort();
const idBasemapsLocalstorage = basemapFromLocalstorage.map((b) => b.id).sort() || {};
isSameBasemaps =
idBasemapsServer.length === idBasemapsLocalstorage.length &&
idBasemapsServer.every(
(value, index) => value === idBasemapsLocalstorage[index]
);
// if basemaps changed, return that changed occured to avoid more processing
if (!isSameBasemaps) return true;
outer_block: {
// For each basemap from the server, compare the length and id values of the layers
for (const serverBasemap of basemapFromServer) {
// loop over basemaps from localStorage and check if layers id & queryable setting match with the layers from the server
// we don't check opacity since it would detect a change and reinit each time the user set it. It would need to be stored separatly like current basemap index
for (const localBasemap of basemapFromLocalstorage) {
if (serverBasemap.id === localBasemap.id) {
isSameLayers =
serverBasemap.layers.length === localBasemap.layers.length &&
serverBasemap.layers.every(
(layer, index) => layer.id === localBasemap.layers[index].id &&
layer.queryable === localBasemap.layers[index].queryable
);
if (!isSameLayers) {
break outer_block;
}
}
}
}
// Compare basemaps titles
const titlesBasemapsServer = basemapFromServer
.map((b) => b.title)
.sort();
const titlesBasemapsLocalstorage = basemapFromLocalstorage.map((b) => b.title).sort() || {};
isSameTitles = titlesBasemapsServer.every(
(title, index) => title === titlesBasemapsLocalstorage[index]
);
if (!isSameTitles) {
break outer_block;
}
}
return !(isSameBasemaps && isSameLayers && isSameTitles);
},
addLayers(baseMap) {
baseMap.layers.forEach((layer) => {
const layerOptions = this.availableLayers.find((l) => l.id === layer.id);
layer = Object.assign(layer, layerOptions);
layer.options.basemapId = baseMap.id;
});
mapService.removeLayers();
// Reverse is done because the first layer in order has to be added in the map in last.
// Slice is done because reverse() changes the original array, so we make a copy first
mapService.addLayers(baseMap.layers.slice().reverse(), null, null, null,);
},
activateGroup(basemapId) {
//* to activate a basemap, we need to set the active property to true and set the others to false
this.baseMaps = this.baseMaps.map((bm) => {
return { ...bm, active: bm.id === basemapId ? true : false };
});
this.setSelectedQueryLayer();
//* add the basemap that was clicked to the map
this.addLayers(this.baseMaps.find((bm) => bm.id === basemapId));
//* to persist the settings, we set the localStorage mapOptions per project
this.setLocalstorageMapOptions({ 'current-basemap-index': basemapId });
},
onlayerMove() {
// Get ids of the draggable layers in its current order.
const currentLayersIdInOrder = Array.from(
document.querySelectorAll('.content.active .layer-item.transition.visible')
).map((el) => parseInt(el.attributes['data-id'].value));
// Create an array to put the original layers in the same order.
let movedLayers = [];
for (const layerId of currentLayersIdInOrder) {
movedLayers.push(
this.activeBasemap.layers.find((el) => el.id === layerId)
);
}
// Remove existing layers undefined
movedLayers = movedLayers.filter(function (x) {
return x !== undefined;
});
const eventOrder = new CustomEvent('change-layers-order', {
detail: {
layers: movedLayers,
},
});
document.dispatchEvent(eventOrder);
// report layers order change in the basemaps object before saving them
this.baseMaps[this.activeBasemapIndex].layers = movedLayers;
// Save the basemaps options into the localstorage
this.setLocalstorageMapOptions({ basemaps: this.baseMaps });
},
onOpacityUpdate(data) {
const { layerId, opacity } = data;
// retrieve layer to update opacity
this.baseMaps[this.activeBasemapIndex].layers.find((layer) => layer.id === layerId).opacity = opacity;
// Save the basemaps options into the localstorage
this.setLocalstorageMapOptions({ basemaps: this.baseMaps });
},
activateQueryLayer(layerTitle) {
const baseMapLayer = this.baseMaps[this.activeBasemapIndex].layers.find((l) => l.title === layerTitle);
if (baseMapLayer) {
// remove any query property in all layers and set query property at true for selected layer
this.baseMaps[this.activeBasemapIndex].layers.forEach((l) => delete l.query);
baseMapLayer['query'] = true;
// update selected query layer
this.setSelectedQueryLayer();
} else {
console.error('No such param \'query\' found among basemap[0].layers');
}
},
onQueryLayerChange(layerTitle) {
this.activateQueryLayer(layerTitle);
this.setLocalstorageMapOptions({ basemaps: this.baseMaps });
},
// retrieve selected query layer in active basemap (to be called when mounting and when changing active basemap)
setSelectedQueryLayer() {
const currentQueryLayer = this.baseMaps[this.activeBasemapIndex].layers.find((l) => l.query === true);
if (currentQueryLayer) {
this.selectedQueryLayer = currentQueryLayer.title;
} else if (this.activeQueryableLayers[0]) { // if no current query layer previously selected by user
this.activateQueryLayer(this.activeQueryableLayers[0].title); // then activate the first available query layer of the active basemap
}
},
setLocalstorageMapOptions(newOptionObj) {
let mapOptions = localStorage.getItem('geocontrib-map-options') || {};
mapOptions = mapOptions.length ? JSON.parse(mapOptions) : {};
mapOptions[this.projectSlug] = {
...mapOptions[this.projectSlug],
...newOptionObj,
};
localStorage.setItem(
'geocontrib-map-options',
JSON.stringify(mapOptions)
);
},
},
};
</script>
<template>
<li
:ref="'message-' + message.counter"
:class="['list-container', { show }]"
>
<div :class="['list-item', { show}]">
<div :class="['ui', message.level ? message.level : 'info', 'message']">
<i
class="close icon"
aria-hidden="true"
@click="removeListItem"
/>
<div class="header">
<i
:class="[headerIcon, 'circle icon']"
aria-hidden="true"
/>
{{ message.header || 'Informations' }}
</div>
<ul class="list">
{{
message.comment
}}
</ul>
</div>
</div>
</li>
</template>
<script>
import { mapState, mapMutations } from 'vuex';
export default {
name: 'MessageInfo',
props: {
message: {
type: Object,
default: () => {},
},
},
data() {
return {
listMessages: [],
show: false,
};
},
computed: {
...mapState(['messages']),
headerIcon() {
switch (this.message.level) {
case 'positive':
return 'check';
case 'negative':
return 'times';
default:
return 'info';
}
}
},
mounted() {
setTimeout(() => {
this.show = true;
}, 15);
},
methods: {
...mapMutations(['DISCARD_MESSAGE']),
removeListItem(){
const container = this.$refs['message-' + this.message.counter];
container.ontransitionend = () => {
this.DISCARD_MESSAGE(this.message.counter);
};
this.show = false;
},
},
};
</script>
<style scoped>
.list-container{
list-style: none;
width: 100%;
height: 0;
position: relative;
cursor: pointer;
overflow: hidden;
transition: all 0.6s ease-out;
}
.list-container.show{
height: 7.5em;
}
@media screen and (min-width: 726px) {
.list-container.show{
height: 6em;
}
}
.list-container.show:not(:first-child){
margin-top: 10px;
}
.list-container .list-item{
padding: .5rem 0;
width: 100%;
position: absolute;
opacity: 0;
top: 0;
left: 0;
transition: all 0.6s ease-out;
}
.list-container .list-item.show{
opacity: 1;
}
ul.list{
overflow: auto;
height: 3.5em;
margin-bottom: .5em !important;
}
@media screen and (min-width: 726px) {
ul.list{
height: 2.2em;
}
}
.ui.message {
overflow: hidden;
padding-bottom: 0 !important;
}
.ui.message::after {
content: "";
position: absolute;
bottom: 0;
left: 1em;
right: 0;
width: calc(100% - 2em);
}
.ui.info.message::after {
box-shadow: 0px -8px 5px 3px rgb(248, 255, 255);
}
.ui.positive.message::after {
box-shadow: 0px -8px 5px 3px rgb(248, 255, 255);
}
.ui.negative.message::after {
box-shadow: 0px -8px 5px 3px rgb(248, 255, 255);
}
.ui.message > .close.icon {
cursor: pointer;
position: absolute;
margin: 0em;
top: 0.78575em;
right: 0.5em;
opacity: 0.7;
-webkit-transition: opacity 0.1s ease;
transition: opacity 0.1s ease;
}
</style>
\ No newline at end of file
<template>
<div
v-if="messages && messages.length > 0"
class="row over-content"
>
<div class="fourteen wide column">
<ul
class="message-list"
aria-live="assertive"
>
<MessageInfo
v-for="message in messages"
:key="'message-' + message.counter"
:message="message"
/>
</ul>
</div>
</div>
</template>
<script>
import { mapState } from 'vuex';
import MessageInfo from '@/components/MessageInfo';
export default {
name: 'MessageInfoList',
components: {
MessageInfo,
},
computed: {
...mapState(['messages']),
},
};
</script>
<style scoped>
.row.over-content {
position: absolute; /* to display message info over page content */
z-index: 99;
opacity: 0.95;
width: calc(100% - 4em); /* 4em is #content left + right paddings */
top: calc(40px + 1em); /* 40px is #app-header height */
right: 2em; /* 2em is #content left paddings */
}
.message-list{
list-style: none;
padding-left: 0;
margin-top: 0;
}
@media screen and (max-width: 725px) {
.row.over-content {
top: calc(80px + 1em); /* 90px is #app-header height in mobile display */
width: calc(100% - 2em);
right: 1em;
}
}
</style>
\ No newline at end of file
<template>
<div style="display: flex;">
<nav style="margin: 0 auto;">
<ul class="custom-pagination">
<li
class="page-item"
:class="{ disabled: currentPage === 1 }"
>
<a
class="page-link pointer"
@click="changePage(currentPage - 1)"
>
<i
class="ui icon big angle left"
aria-hidden="true"
/>
</a>
</li>
<div
v-if="nbPages > 5"
style="display: contents;"
>
<li
v-for="index in pagination(currentPage, nbPages)"
:key="index"
class="page-item"
:class="{ active: currentPage === index }"
>
<a
class="page-link pointer"
@click="changePage(index)"
>
{{ index }}
</a>
</li>
</div>
<div
v-else
style="display: contents;"
>
<li
v-for="index in nbPages"
:key="index"
class="page-item"
:class="{ active: currentPage === index }"
>
<a
class="page-link pointer"
@click="changePage(index)"
>
{{ index }}
</a>
</li>
</div>
<li
class="page-item"
:class="{ disabled: currentPage === nbPages }"
>
<a
class="page-link pointer"
@click="changePage(currentPage + 1)"
>
<i
class="ui icon big angle right"
aria-hidden="true"
/>
</a>
</li>
</ul>
</nav>
</div>
</template>
<script>
import { mapState, mapMutations } from 'vuex';
export default {
name: 'Pagination',
props: {
nbPages: {
type: Number,
default: 1
},
},
computed: {
...mapState('projects', ['currentPage']),
},
methods: {
...mapMutations('projects', [
'SET_CURRENT_PAGE',
'SET_PROJECTS_FILTER'
]),
pagination(c, m) {
const current = c,
last = m,
delta = 2,
left = current - delta,
right = current + delta + 1,
range = [],
rangeWithDots = [];
let l;
for (let i = 1; i <= last; i++) {
if (i === 1 || i === last || i >= left && i < right) {
range.push(i);
}
}
for (const i of range) {
if (l) {
if (i - l === 2) {
rangeWithDots.push(l + 1);
} else if (i - l !== 1) {
rangeWithDots.push('...');
}
}
rangeWithDots.push(i);
l = i;
}
return rangeWithDots;
},
changePage(pageNumber) {
if (typeof pageNumber === 'number') {
this.SET_CURRENT_PAGE(pageNumber);
// Scroll back to the first results on top of page
window.scrollTo({ top: 0, behavior: 'smooth' });
// emit event for parent component to fetch new page data
this.$emit('page-update', pageNumber);
}
}
}
};
</script>
<template>
<div class="ui segment secondary">
<div class="field required">
<label for="basemap-title">Titre</label>
<input
:value="basemap.title"
type="text"
name="basemap-title"
required
@input="updateTitle"
>
<ul
v-if="basemap.errors && basemap.errors.length > 0"
id="errorlist-title"
class="errorlist"
>
<li>
{{ basemap.errors }}
</li>
</ul>
</div>
<div class="nested">
<div
:id="`list-${basemap.id}`"
:class="[basemap.layers.length > 0 ? 'ui segments': '', 'layers-container', 'raised']"
>
<ProjectMappingContextLayer
v-for="layer in basemap.layers"
:key="'layer-' + layer.dataKey"
:layer="layer"
:basemapid="basemap.id"
/>
</div>
<div class="ui bottom two attached buttons">
<button
class="ui icon button basic positive"
type="button"
@click="addLayer"
>
<i
class="ui plus icon"
aria-hidden="true"
/>
<span>&nbsp;Ajouter une couche</span>
</button>
<button
class=" ui icon button basic negative"
type="button"
@click="deleteBasemap"
>
<i
class="ui trash alternate icon"
aria-hidden="true"
/>
<span>&nbsp;Supprimer ce fond cartographique</span>
</button>
</div>
</div>
</div>
</template>
<script>
import { mapMutations } from 'vuex';
import Sortable from 'sortablejs';
import ProjectMappingContextLayer from '@/components/Project/Basemaps/ProjectMappingContextLayer.vue';
export default {
name: 'BasemapListItem',
components: {
ProjectMappingContextLayer,
},
props: {
basemap: {
type: Object,
default: null,
}
},
data() {
return {
sortable: null
};
},
computed: {
maxLayersCount: function () {
return this.basemap.layers.reduce((acc, curr) => {
if (curr.dataKey > acc) {
return curr.dataKey;
} else {
return acc;
}
}, 0);
},
},
created() {
if (this.basemap.layers) {
//* add datakeys to layers coming from api
this.fillLayersWithDatakey(this.basemap.layers);
}
},
mounted() {
this.initSortable();
},
methods: {
...mapMutations('map', [
'UPDATE_BASEMAP',
'DELETE_BASEMAP',
'REPLACE_BASEMAP_LAYERS'
]),
deleteBasemap() {
this.DELETE_BASEMAP(this.basemap.id);
},
addLayer(layer) {
const newLayer = {
dataKey: this.maxLayersCount + 1,
opacity: '1.00',
order: 0,
queryable: false,
title: 'Open street map',
...layer,
};
this.UPDATE_BASEMAP({
layers: [...this.basemap.layers, newLayer],
id: this.basemap.id,
title: this.basemap.title,
errors: this.basemap.errors,
});
},
updateTitle(evt) {
let errors = '';
if(evt.target.value === '') { //* delete or add error message while typing
errors = 'Veuillez compléter ce champ.';
}
this.UPDATE_BASEMAP({
id: this.basemap.id,
title: evt.target.value,
errors,
});
},
removeLayer(dataKey) {
this.layers = this.layers.filter((layer) => layer.dataKey !== dataKey);
},
fillLayersWithDatakey(layers) {
let dataKey = 0;
this.REPLACE_BASEMAP_LAYERS({
basemapId: this.basemap.id,
layers: layers.map((el) => {
dataKey += 1;
return { dataKey, ...el };
}),
});
},
//* drag & drop *//
onlayerMove() {
//* Get the names of the current layers in order.
const currentLayersNamesInOrder = Array.from(
document.getElementsByClassName(`basemap-${this.basemap.id}`)
).map((el) => el.id);
//* increment value 'order' in this.basemap.layers looping over layers from template ^
let order = 0;
const movedLayers = [];
for (const id of currentLayersNamesInOrder) {
const matchingLayer = this.basemap.layers.find(
(el) => el.dataKey === Number(id)
);
if (matchingLayer) {
matchingLayer['order'] = order;
movedLayers.push(matchingLayer);
order += 1;
}
}
//* update the store
this.UPDATE_BASEMAP({
layers: movedLayers,
id: this.basemap.id,
title: this.basemap.title,
errors: this.basemap.errors,
});
},
initSortable() {
this.sortable = new Sortable(document.getElementById(`list-${this.basemap.id}`), {
animation: 150,
handle: '.layer-handle-sort', // The element that is active to drag
ghostClass: 'blue-background-class',
dragClass: 'white-opacity-background-class',
onEnd: this.onlayerMove.bind(this),
});
},
},
};
</script>
<template>
<div
:id="layer.dataKey"
:class="`ui segment layer-item basemap-${basemapid}`"
>
<div class="ui divided form">
<div
class="field"
data-type="layer-field"
>
<label
:for="layer.title"
class="layer-handle-sort"
>
<i
class="th icon"
aria-hidden="true"
/>
couche
</label>
<Dropdown
:options="availableLayerOptions"
:selected="selectedLayer.name"
:selection.sync="selectedLayer"
:search="true"
:placeholder="placeholder"
/>
</div>
<div class="six wide field">
<label for="opacity">Opacité</label>
<input
v-model.number="layerOpacity"
type="number"
oninput="validity.valid||(value='');"
step="0.01"
min="0"
max="1"
>
</div>
<div class="field">
<div
class="ui checkbox"
@click="updateLayer({ ...layer, queryable: !layer.queryable })"
>
<input
:checked="layer.queryable"
class="hidden"
type="checkbox"
name="queryable"
>
<label for="queryable">&nbsp;Requêtable</label>
</div>
</div>
<button
type="button"
class="ui compact small icon floated button button-hover-red"
@click="removeLayer"
>
<i
class="ui grey trash alternate icon"
aria-hidden="true"
/>
<span>&nbsp;Supprimer cette couche</span>
</button>
</div>
</div>
</template>
<script>
import Dropdown from '@/components/Dropdown.vue';
import { mapState } from 'vuex';
export default {
name: 'ProjectMappingContextLayer',
components: {
Dropdown,
},
props: {
layer:
{
type: Object,
default: null,
},
basemapid:
{
type: Number,
default: null,
},
},
computed: {
...mapState('map', ['availableLayers']),
selectedLayer: {
get() {
return this.retrieveLayer(this.layer.title) || [];
},
set(newValue) {
const matchingLayer = this.retrieveLayer(newValue.title);
if (matchingLayer !== undefined) {
this.updateLayer({
...this.layer,
service: newValue.name,
title: newValue.value,
id: matchingLayer.id,
});
}
},
},
layerOpacity: {
get() {
return this.layer.opacity;
},
set(newValue) {
if (newValue) {
//* check if value was filled
this.updateLayer({ ...this.layer, opacity: newValue });
}
},
},
availableLayerOptions: function () {
return this.availableLayers.map((el) => {
return {
id: el.id,
name: `${el.title} - ${el.service}`,
value: el.title,
title: el.title,
};
});
},
placeholder: function () {
return this.selectedLayer && this.selectedLayer.name
? ''
: 'Choisissez une couche';
},
},
mounted() {
const matchingLayer = this.retrieveLayer(this.layer.title);
if (matchingLayer !== undefined) {
this.updateLayer({
...this.layer,
service: matchingLayer.service,
title: matchingLayer.title,
id: matchingLayer.id,
});
}
},
methods: {
retrieveLayer(title) {
return this.availableLayerOptions.find((el) => el.title === title);
},
removeLayer() {
this.$store.commit('map/DELETE_BASEMAP_LAYER', {
basemapId: this.basemapid,
layerId: this.layer.dataKey,
});
},
updateLayer(layer) {
this.$store.commit('map/UPDATE_BASEMAP_LAYER', {
basemapId: this.basemapid,
layerId: this.layer.dataKey,
layer,
});
},
},
};
</script>
<template>
<div>
<h3 class="ui header">
Types de signalements
</h3>
<div
id="feature_type-list"
class="ui middle aligned divided list"
>
<div
:class="{ active: loading }"
class="ui inverted dimmer"
>
<div class="ui text loader">
Récupération des types de signalements en cours...
</div>
</div>
<div
:class="{ active: importing }"
class="ui inverted dimmer"
>
<div class="ui text loader">
Traitement du fichier en cours ...
</div>
</div>
<div
v-for="(type, index) in feature_types"
:id="type.title"
:key="type.title + '-' + index"
class="item"
>
<div class="feature-type-container">
<FeatureTypeLink :feature-type="type" />
<div class="middle aligned content">
<div
v-if="isImporting(type)"
class="import-message"
>
<i
class="info circle icon"
aria-hidden="true"
/>
Import en cours
</div>
<template v-else>
<router-link
v-if="project && type.is_editable && permissions && permissions.can_create_feature_type && isOnline"
:to="{
name: 'editer-type-signalement',
params: { slug_type_signal: type.slug },
}"
class="ui compact small icon button button-hover-orange tiny-margin"
data-tooltip="Éditer le type de signalement"
data-position="top center"
data-variation="mini"
:data-test="`edit-feature-type-for-${type.title}`"
>
<i
class="inverted grey pencil alternate icon"
aria-hidden="true"
/>
</router-link>
<router-link
v-if="project && permissions && permissions.can_create_feature_type && isOnline"
:to="{
name: 'editer-affichage-signalement',
params: { slug_type_signal: type.slug },
}"
class="ui compact small icon button button-hover-orange tiny-margin"
data-tooltip="Éditer l'affichage du type de signalement"
data-position="top center"
data-variation="mini"
:data-test="`edit-feature-type-display-for-${type.title}`"
>
<i
class="inverted grey paint brush alternate icon"
aria-hidden="true"
/>
</router-link>
<a
v-if="isProjectAdmin && isOnline"
class="ui compact small icon button button-hover-red tiny-margin"
data-tooltip="Supprimer le type de signalement"
data-position="top center"
data-variation="mini"
:data-test="`delete-feature-type-for-${type.title}`"
@click="toggleDeleteFeatureType(type)"
>
<i
class="inverted grey trash alternate icon"
aria-hidden="true"
/>
</a>
</template>
<router-link
v-if="project && permissions && permissions.can_create_feature_type && isOnline"
:to="{
name: 'dupliquer-type-signalement',
params: { slug_type_signal: type.slug },
}"
class="ui compact small icon button button-hover-green tiny-margin"
data-tooltip="Dupliquer un type de signalement"
data-position="top right"
data-variation="mini"
:data-test="`duplicate-feature-type-for-${type.title}`"
>
<i
class="inverted grey copy alternate icon"
aria-hidden="true"
/>
</router-link>
<router-link
v-if="project && permissions && permissions.can_create_feature && !type.geom_type.includes('multi')"
:to="{
name: 'ajouter-signalement',
params: { slug_type_signal: type.slug },
}"
class="ui compact small icon button button-hover-green tiny-margin"
data-tooltip="Ajouter un signalement"
data-position="top right"
data-variation="mini"
:data-test="`add-feature-for-${type.title}`"
>
<i
class="ui plus icon"
aria-hidden="true"
/>
</router-link>
</div>
</div>
</div>
<div v-if="feature_types.length === 0">
<em> Le projet ne contient pas encore de type de signalements. </em>
</div>
</div>
<div id="new-feature-type-container">
<div
class="ui small button circular compact floated right icon teal help"
data-tooltip="Consulter la documentation"
data-position="bottom right"
data-variation="mini"
data-test="read-doc"
>
<i
class="question icon"
@click="goToDocumentation"
/>
</div>
<router-link
v-if="permissions && permissions.can_update_project && isOnline"
:to="{
name: 'ajouter-type-signalement',
params: { slug },
}"
class="ui compact basic button"
data-test="add-feature-type"
>
<i
class="ui plus icon"
aria-hidden="true"
/>
<label class="ui pointer">
Créer un nouveau type de signalement
</label>
</router-link>
<div
v-if="permissions && permissions.can_update_project && isOnline"
class="ui compact basic button button-align-left"
data-test="add-feature-type-from-geojson"
>
<i
class="ui plus icon"
aria-hidden="true"
/>
<label
class="ui pointer"
for="geojson_file"
>
Créer un nouveau type de signalement à partir d'un GeoJSON
</label>
<input
id="geojson_file"
type="file"
accept=".geojson"
style="display: none"
name="geojson_file"
@change="onGeoJSONFileChange"
>
</div>
<div
v-if="permissions && permissions.can_update_project && isOnline"
class="ui compact basic button button-align-left"
data-test="add-feature-type-from-json"
>
<i
class="ui plus icon"
aria-hidden="true"
/>
<label
class="ui pointer"
for="json_file"
>
Créer un nouveau type de signalement à partir d'un JSON (non-géographique)
</label>
<input
id="json_file"
type="file"
accept="application/json, .json"
style="display: none"
name="json_file"
@change="onGeoJSONFileChange"
>
</div>
<div
v-if="permissions && permissions.can_update_project && isOnline"
class="ui compact basic button button-align-left"
data-test="add-feature-type-from-csv"
>
<i
class="ui plus icon"
aria-hidden="true"
/>
<label
class="ui pointer"
for="csv_file"
>
Créer un nouveau type de signalement à partir d'un CSV
</label>
<input
id="csv_file"
type="file"
accept="application/csv, .csv"
style="display: none"
name="csv_file"
@change="onCSVFileChange"
>
</div>
<router-link
v-if="IDGO && permissions && permissions.can_update_project && isOnline"
:to="{
name: 'catalog-import',
params: {
slug,
feature_type_slug: 'create'
},
}"
class="ui compact basic button button-align-left"
data-test="add-feature-type-from-catalog"
>
<i
class="ui plus icon"
aria-hidden="true"
/>
Créer un nouveau type de signalement à partir du catalogue {{ CATALOG_NAME || 'IDGO' }}
</router-link>
</div>
<div
v-if="geojsonFileToImport.size > 0"
id="button-import"
>
<button
:disabled="geojsonFileToImport.size === 0"
class="ui fluid teal icon button"
data-test="start-geojson-file-import"
@click="toNewGeojsonFeatureType"
>
<i
class="upload icon"
aria-hidden="true"
/> Lancer l'import avec le fichier
{{ geojsonFileToImport.name }}
</button>
</div>
<div
v-if="csvFileToImport.size > 0 && !csvError"
id="button-import"
>
<button
:disabled="csvFileToImport.size === 0"
class="ui fluid teal icon button"
data-test="start-csv-file-import"
@click="toNewCsvFeatureType"
>
<i
class="upload icon"
aria-hidden="true"
/> Lancer l'import avec le fichier
{{ csvFileToImport.name }}
</button>
</div>
<div
v-if="csvError"
class="ui negative message"
>
<i
class="close icon"
aria-hidden="true"
@click="csvError = null; csvFileToImport = { name: '', size: 0 }"
/>
{{ csvError }}
</div>
<!-- MODALE FILESIZE -->
<div
:class="isFileSizeModalOpen ? 'active' : ''"
class="ui dimmer inverted"
>
<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
de plus de 100Mo (celui importé fait {{ geojsonFileSize > 0 ? geojsonFileSize : csvFileSize }} Mo).
</p>
</div>
<div class="actions">
<div
class="ui button teal"
@click="closeFileSizeModal"
>
Fermer
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { csv } from 'csvtojson';
import { mapState, mapGetters, mapMutations, mapActions } from 'vuex';
import { fileConvertSizeToMo, determineDelimiter } from '@/assets/js/utils';
import FeatureTypeLink from '@/components/FeatureType/FeatureTypeLink';
export default {
name: 'ProjectFeatureTypes',
components: {
FeatureTypeLink
},
props: {
loading: {
type: Boolean,
default: false
},
project: {
type: Object,
default: () => {
return {};
},
}
},
data() {
return {
importing: false,
slug: this.$route.params.slug,
isFileSizeModalOpen: false,
geojsonImport: [],
csvImport: null,
csvError: null,
geojsonFileToImport: { name: '', size: 0 },
csvFileToImport: { name: '', size: 0 },
fetchCallCounter: 0,
hadPending: false
};
},
computed: {
...mapState([
'configuration',
'isOnline',
'user_permissions',
]),
...mapState('feature-type', [
'feature_types',
'importFeatureTypeData'
]),
...mapGetters([
'permissions'
]),
CATALOG_NAME() {
return this.configuration.VUE_APP_CATALOG_NAME;
},
IDGO() {
return this.$store.state.configuration.VUE_APP_IDGO;
},
isProjectAdmin() {
return this.user_permissions && this.user_permissions[this.slug] &&
this.user_permissions[this.slug].is_project_administrator;
},
geojsonFileSize() {
return fileConvertSizeToMo(this.geojsonFileToImport.size);
},
csvFileSize() {
return fileConvertSizeToMo(this.csvFileToImport.size);
},
},
mounted() {
this.fetchImports();
},
methods: {
...mapMutations('feature-type', [
'SET_FILE_TO_IMPORT'
]),
...mapActions('feature-type', [
'GET_IMPORTS',
]),
fetchImports() {
this.fetchCallCounter += 1; // register each time function is programmed to be called in order to avoid redundant calls
this.GET_IMPORTS({
project_slug: this.$route.params.slug,
})
.then((response) => {
if (response.data && response.data.some(el => el.status === 'pending')) {
this.hadPending = true; // store pending import to know if project need to be updated, after mounted
// if there is still some pending imports re-fetch imports by calling this function again
setTimeout(() => {
if (this.fetchCallCounter <= 1 ) {
// if the function wasn't called more than once in the reload interval, then call it again
this.fetchImports();
}
this.fetchCallCounter -= 1; // decrease function counter
}, this.$store.state.configuration.VUE_APP_RELOAD_INTERVAL);
} else if (this.hadPending) {
// if no more pending import, get last features
this.$emit('update');
}
});
},
isImporting(type) {
if (this.importFeatureTypeData) {
const singleImportData = this.importFeatureTypeData.find(
(el) => el.feature_type_title === type.slug
);
return singleImportData && singleImportData.status === 'pending';
}
return false;
},
goToDocumentation() {
window.open(this.configuration.VUE_APP_URL_DOCUMENTATION);
},
toNewGeojsonFeatureType() {
this.importing = true;
if(typeof this.geojsonImport == 'object'){
if(!Array.isArray(this.geojsonImport)){
this.$router.push({
name: 'ajouter-type-signalement',
params: {
geojson: this.geojsonImport,
fileToImport: this.geojsonFileToImport,
},
});
}else{
this.$router.push({
name: 'ajouter-type-signalement',
params: {
json: this.geojsonImport,
fileToImport: this.geojsonFileToImport,
},
});
}
}
this.importing = false;
},
toNewCsvFeatureType() {
this.importing = true;
this.$router.push({
name: 'ajouter-type-signalement',
params: {
csv: this.csvImport,
fileToImport: this.csvFileToImport,
},
});
this.importing = false;
},
onGeoJSONFileChange(e) {
this.importing = true;
const files = e.target.files || e.dataTransfer.files;
if (!files.length) {
return;
}
this.geojsonFileToImport = files[0];
// TODO : VALIDATION IF FILE IS JSON
if (parseFloat(fileConvertSizeToMo(this.geojsonFileToImport.size)) > 100) {
this.isFileSizeModalOpen = true;
} else if (this.geojsonFileToImport.size > 0) {
const fr = new FileReader();
try {
fr.onload = (ev) => {
this.geojsonImport = JSON.parse(ev.target.result);
this.importing = false;
};
fr.readAsText(this.geojsonFileToImport);
//* stock filename to import features afterward
this.SET_FILE_TO_IMPORT(this.geojsonFileToImport);
} catch (err) {
console.error(err);
this.importing = false;
}
} else {
this.importing = false;
}
},
onCSVFileChange(e) {
this.featureTypeImporting = true;
const files = e.target.files || e.dataTransfer.files;
if (!files.length) {
return;
}
this.csvFileToImport = files[0];
if (parseFloat(fileConvertSizeToMo(this.csvFileToImport.size)) > 100) {
this.isFileSizeModalOpen = true;
} else if (this.csvFileToImport.size > 0) {
const fr = new FileReader();
try {
fr.readAsText(this.csvFileToImport);
fr.onloadend = () => {
// Find csv delimiter
const delimiter = determineDelimiter(fr.result);
if (!delimiter) {
this.csvError = `Le fichier ${this.csvFileToImport.name} n'est pas formaté correctement`;
this.featureTypeImporting = false;
return;
}
this.csvError = null;
csv({ delimiter })
.fromString(fr.result)
.then((jsonObj)=>{
this.csvImport = jsonObj;
});
this.featureTypeImporting = false;
//* stock filename to import features afterward
this.SET_FILE_TO_IMPORT(this.csvFileToImport);
};
} catch (err) {
console.error(err);
this.featureTypeImporting = false;
}
} else {
this.featureTypeImporting = false;
}
},
closeFileSizeModal() {
this.geojsonFileToImport = { name: '', size: 0 };
this.csvFileToImport = { name: '', size: 0 };
this.importing = false;
this.isFileSizeModalOpen = false;
},
toggleDeleteFeatureType(featureType) {
this.$emit('delete', featureType);
},
}
};
</script>
<style>
/* // ! missing style in semantic.min.css */
.ui.right.floated.button {
float: right;
}
</style>
<style lang="less" scoped>
.feature-type-container {
display: flex;
justify-content: space-between;
align-items: center;
.feature-type-title {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
line-height: 1.5em;
}
& > .middle.aligned.content {
display: flex;
}
}
#new-feature-type-container {
& > div {
margin: 1em 0;
}
& > div.help {
margin-top: 0;
}
.button:not(.help) {
line-height: 1.25em;
.icon {
height: auto;
}
}
}
#button-import {
margin-top: 0.5em;
}
.button-align-left {
display: flex;
align-items: center;
text-align: left;
width: fit-content;
}
.import-message {
color: var(--primary-highlight-color, #008c86);
white-space: nowrap;
padding: .25em;
display: flex;
align-items: center;
& > i {
height: auto;
}
}
</style>
<template>
<div class="project-header ui grid stackable">
<div class="row">
<div class="three wide middle aligned column">
<div class="margin-bottom">
<img
class="ui small centered image"
alt="Thumbnail du projet"
:src="
project.thumbnail.includes('default')
? require('@/assets/img/default.png')
: DJANGO_BASE_URL + project.thumbnail + refreshId()
"
>
</div>
<div class="centered">
<div
class="ui basic teal label tiny-margin"
data-tooltip="Membres"
>
<i
class="user icon"
aria-hidden="true"
/>{{ project.nb_contributors }}
</div>
<div
class="ui basic teal label tiny-margin"
data-tooltip="Signalements publiés"
>
<i
class="map marker icon"
aria-hidden="true"
/>{{ project.nb_published_features }}
</div>
<div
class="ui basic teal label tiny-margin"
data-tooltip="Commentaires"
>
<i
class="comment icon"
aria-hidden="true"
/>{{
project.nb_published_features_comments
}}
</div>
</div>
</div>
<div class="nine wide column">
<h1 class="ui header margin-bottom">
{{ project.title }}
</h1>
<div class="sub header">
<!-- {{ project.description }} -->
<div id="preview" />
<textarea
id="editor"
v-model="project.description"
data-preview="#preview"
hidden
/>
</div>
</div>
<div class="four wide column right-column">
<div class="ui icon right compact buttons">
<a
v-if="
user &&
permissions &&
permissions.can_view_project &&
isOnline
"
id="subscribe-button"
class="ui button button-hover-green tiny-margin"
data-tooltip="Gérer mon abonnement au projet"
data-position="bottom center"
data-variation="mini"
@click="OPEN_PROJECT_MODAL('subscribe')"
>
<i
class="inverted grey envelope icon"
aria-hidden="true"
/>
</a>
<router-link
v-if="
permissions &&
permissions.can_update_project &&
isOnline
"
id="edit-project"
:to="{ name: 'project_edit', params: { slug } }"
class="ui button button-hover-orange tiny-margin"
data-tooltip="Modifier le projet"
data-position="bottom center"
data-variation="mini"
>
<i
class="inverted grey pencil alternate icon"
aria-hidden="true"
/>
</router-link>
<a
v-if="isProjectAdmin && isOnline"
id="delete-button"
class="ui button button-hover-red tiny-margin"
data-tooltip="Supprimer le projet"
data-position="bottom right"
data-variation="mini"
@click="OPEN_PROJECT_MODAL('deleteProject')"
>
<i
class="inverted grey trash icon"
aria-hidden="true"
/>
</a>
<div
v-if="isProjectAdmin && !isSharedProject"
id="share-button"
class="ui dropdown button compact tiny-margin"
data-tooltip="Partager le projet"
data-position="bottom right"
data-variation="mini"
@click="toggleShareOptions"
>
<i
class="inverted grey share icon"
aria-hidden="true"
/>
<div
:class="['menu left transition', {'visible': showShareOptions}]"
style="z-index: 9999"
>
<div
v-if="project.generate_share_link"
class="item"
@click="copyLink"
>
Copier le lien de partage
</div>
<div
class="item"
@click="copyCode"
>
Copier le code du Web Component
</div>
</div>
</div>
</div>
<Transition>
<div
v-if="confirmMsg"
class="ui positive tiny-margin message"
>
<span>
Le lien a été copié dans le presse-papier
</span>
&nbsp;
<i
class="close icon"
aria-hidden="true"
@click="confirmMsg = false"
/>
</div>
</Transition>
</div>
<div
v-if="arraysOffline.length > 0"
class="centered"
>
{{ arraysOffline.length }} modification<span v-if="arraysOffline.length > 1">s</span> en attente
<button
:disabled="!isOnline"
class="ui fluid labeled teal icon button"
@click="sendOfflineFeatures"
>
<i
class="upload icon"
aria-hidden="true"
/>
Envoyer au serveur
</button>
</div>
</div>
</div>
</template>
<script>
import TextareaMarkdown from 'textarea-markdown';
import { mapState, mapGetters, mapMutations } from 'vuex';
import featureAPI from '@/services/feature-api';
export default {
name: 'ProjectHeader',
props: {
arraysOffline: {
type: Array,
default: () => {
return [];
}
}
},
data() {
return {
slug: this.$route.params.slug,
confirmMsg: false,
showShareOptions: false,
};
},
computed: {
...mapState('projects', [
'project'
]),
...mapState([
'configuration',
]),
...mapState([
'user',
'user_permissions',
'isOnline',
]),
...mapGetters([
'permissions'
]),
DJANGO_BASE_URL() {
return this.configuration.VUE_APP_DJANGO_BASE;
},
isProjectAdmin() {
return this.user_permissions && this.user_permissions[this.slug] &&
this.user_permissions[this.slug].is_project_administrator;
},
isSharedProject() {
return this.$route.path.includes('projet-partage');
},
},
mounted() {
let textarea = document.querySelector('textarea');
new TextareaMarkdown(textarea);
window.addEventListener('mousedown', this.clickOutsideDropdown);
},
destroyed() {
window.removeEventListener('mousedown', this.clickOutsideDropdown);
},
methods: {
...mapMutations('modals', [
'OPEN_PROJECT_MODAL'
]),
refreshId() {
const crypto = window.crypto || window.msCrypto;
var array = new Uint32Array(1);
return '?ver=' + crypto.getRandomValues(array); // Compliant for security-sensitive use cases
},
toggleShareOptions() {
this.confirmMsg = false;
this.showShareOptions = !this.showShareOptions;
},
clickOutsideDropdown(e) {
// If the user click outside of the dropdown, close it
if (!e.target.closest('#share-button')) {
this.showShareOptions = false;
}
},
copyLink() {
const sharedLink = window.location.href.replace('projet', 'projet-partage');
navigator.clipboard.writeText(sharedLink).then(()=> {
this.confirmMsg = true;
setTimeout(() => {
this.confirmMsg = false;
}, 15000);
}, (e) => console.error('Failed to copy link: ', e));
},
copyCode() {
// Including <script> directly within template literals cause the JavaScript parser to raise syntax errors.
// The only working workaround, but ugly, is to split and concatenate the <script> tag.
const webComponent = `
<!-- Pour modifier la police, ajoutez l'attribut "font" avec le nom de la police souhaitée (par exemple: font="'Roboto Condensed', Lato, 'Helvetica Neue'"). -->
<!-- Dans le cas où la police souhaitée ne serait pas déjà disponible dans la page affichant le web component, incluez également une balise <style> pour l'importer. -->
<style>@import url('https://fonts.googleapis.com/css?family=Roboto Condensed:400,700,400italic,700italic&subset=latin');</style>
<scr` + `ipt src="${this.configuration.VUE_APP_DJANGO_BASE}/geocontrib/static/wc/project-preview.js"></scr` + `ipt>
<project-preview
domain="${this.configuration.VUE_APP_DJANGO_BASE}"
project-slug="${this.project.slug}"
color="${this.configuration.VUE_APP_PRIMARY_COLOR}"
font="${this.configuration.VUE_APP_FONT_FAMILY}"
width=""
></project-preview>`;
navigator.clipboard.writeText(webComponent).then(()=> {
this.confirmMsg = true;
setTimeout(() => {
this.confirmMsg = false;
}, 15000);
}, (e) => console.error('Failed to copy link: ', e));
},
sendOfflineFeatures() {
this.arraysOfflineErrors = [];
const promises = this.arraysOffline.map((feature) => featureAPI.postOrPutFeature({
data: feature.geojson,
feature_id: feature.featureId,
project__slug: feature.project,
feature_type__slug: feature.geojson.properties.feature_type,
method: feature.type.toUpperCase(),
})
.then((response) => {
if (!response) {
this.arraysOfflineErrors.push(feature);
}
})
.catch((error) => {
console.error(error);
this.arraysOfflineErrors.push(feature);
})
);
this.$store.commit('DISPLAY_LOADER', 'Envoi des signalements en cours.');
Promise.all(promises).then(() => {
this.$emit('updateLocalStorage');
this.$emit('retrieveInfo');
this.$store.commit('DISCARD_LOADER');
});
},
}
};
</script>
<style lang="less" scoped>
.project-header {
.row .right-column {
display: flex;
flex-direction: column;
.ui.buttons {
justify-content: flex-end;
.ui.button {
flex-grow: 0; /* avoid stretching buttons */
}
}
}
.centered {
margin: auto;
text-align: center;
}
.ui.dropdown > .left.menu {
display: block;
overflow: hidden;
opacity: 0;
max-height: 0;
&.transition {
transition: all .5s ease;
}
&.visible {
opacity: 1;
max-height: 6em;
}
.menu {
margin-right: 0 !important;
.item {
white-space: nowrap;
}
}
}
.v-enter-active,
.v-leave-active {
transition: opacity .5s ease;
}
.v-enter-from,
.v-leave-to {
opacity: 0;
}
}
#preview {
max-height: 10em;
overflow-y: scroll;
}
@media screen and (max-width: 767px) {
.middle.aligned.column {
text-align: center;
}
}
</style>
<template>
<div class="orange card">
<div class="content">
<div class="center aligned header">
Derniers commentaires
</div>
<div class="center aligned description">
<div
:class="{ active: loading }"
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">
<FeatureFetchOffsetRoute
:feature-id="item.related_feature.feature_id"
:properties="{
title: item.comment,
feature_type: { slug: item.related_feature.feature_type_slug }
}"
/>
<div class="description">
<em>[ {{ item.created_on
}}<span
v-if="user && item.display_author"
>, par {{ item.display_author }}
</span>
]</em>
</div>
</div>
</div>
<em
v-if="!last_comments || last_comments.length === 0"
>Aucun commentaire pour le moment.</em>
</div>
</div>
</div>
</div>
</template>
<script>
import { mapState } from 'vuex';
import FeatureFetchOffsetRoute from '@/components/Feature/FeatureFetchOffsetRoute';
export default {
name: 'ProjectsLastComments',
components: {
FeatureFetchOffsetRoute,
},
props: {
loading: {
type: Boolean,
default: false
}
},
computed: {
...mapState([
'user'
]),
...mapState('projects', [
'last_comments',
]),
},
};
</script>
<template>
<div class="red card">
<div class="content">
<div class="center aligned header">
Derniers signalements
</div>
<div class="center aligned description">
<div
:class="{ active: loading }"
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.slice(0,5)"
:key="item.properties.title + index"
class="item"
>
<div class="content">
<div>
<FeatureFetchOffsetRoute
:feature-id="item.id"
:properties="item.properties"
/>
</div>
<div class="description">
<em>
[{{ item.properties.created_on }}
<span v-if="user && item.properties.creator">
, par
{{
item.properties.creator.full_name
? item.properties.creator.full_name
: item.properties.creator.username
}}
</span>
]
</em>
</div>
</div>
</div>
<em
v-if="features.length === 0 && !loading"
>Aucun signalement pour le moment.</em>
</div>
</div>
</div>
</div>
</template>
<script>
import { mapState } from 'vuex';
import FeatureFetchOffsetRoute from '@/components/Feature/FeatureFetchOffsetRoute';
export default {
name: 'ProjectLastFeatures',
components: {
FeatureFetchOffsetRoute,
},
data() {
return {
loading: true,
};
},
computed: {
...mapState('feature', [
'features'
]),
...mapState([
'user'
]),
},
mounted() {
this.fetchLastFeatures();
},
methods: {
fetchLastFeatures() {
this.loading = true;
this.$store.dispatch('feature/GET_PROJECT_FEATURES', {
project_slug: this.$route.params.slug,
ordering: '-created_on',
limit: 5,
})
.then(() => {
this.loading = false;
})
.catch((err) => {
console.error(err);
this.loading = false;
});
}
}
};
</script>
<template>
<div
v-if="isProjectModalOpen"
class="ui dimmer modals page transition visible active"
style="display: flex !important"
>
<div
:class="[
'ui mini modal',
{ 'transition visible active': projectModalType },
]"
>
<i
class="close icon"
aria-hidden="true"
@click="CLOSE_PROJECT_MODAL"
/>
<div class="ui icon header">
<i
:class="[projectModalType === 'subscribe' ? 'envelope' : 'trash', 'icon']"
aria-hidden="true"
/>
{{
projectModalType === 'subscribe' ? 'Notifications' : 'Suppression'
}} du {{
projectModalType === 'deleteFeatureType' ? 'type de signalement ' + featureTypeToDelete.title : 'projet'
}}
</div>
<div class="content">
<div v-if="projectModalType !== 'subscribe'">
<p class="centered-text">
Confirmez vous la suppression du {{
projectModalType === 'deleteProject' ?
'projet, ainsi que les types de signalements' :
'type de signalement'
}} et tous les signalements associés&nbsp;?
</p>
<p class="centered-text alert">
Attention cette action est irreversible !
</p>
</div>
<button
id="validate-modal"
:class="['ui compact fluid button', projectModalType === 'subscribe' && !isSubscriber ? 'green' : 'red']"
@click="handleModalAction"
>
<span v-if="projectModalType === 'subscribe'">
{{
isSubscriber
? "Se désabonner de ce projet"
: "S'abonner à ce projet"
}}
</span>
<span v-else>
Supprimer le
{{
projectModalType === 'deleteProject'
? 'projet'
: 'type de signalement'
}}
</span>
</button>
</div>
</div>
</div>
</template>
<script>
import { mapState, mapMutations } from 'vuex';
export default {
name: 'ProjectModal',
props: {
isSubscriber: {
type: Boolean,
default: false
},
featureTypeToDelete: {
type: Object,
default: () => {
return {};
}
}
},
computed: {
...mapState('modals', [
'isProjectModalOpen',
'projectModalType'
])
},
methods: {
...mapMutations('modals', [
'CLOSE_PROJECT_MODAL'
]),
handleModalAction() {
this.$emit('action', this.projectModalType);
}
}
};
</script>
<style scoped>
.alert {
color: red;
}
.centered-text {
text-align: center;
}
</style>
<template>
<div class="ui grey segment">
<h3 class="ui header">
Paramètres du projet
</h3>
<div class="ui three stackable cards">
<div class="card">
<div class="content">
<h4 class="ui center aligned icon header">
<i
class="disabled grey eye icon"
aria-hidden="true"
/>
<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"
aria-hidden="true"
/>
<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"
aria-hidden="true"
/>
<div class="content">
Modération
</div>
</h4>
</div>
<div class="center aligned extra content">
{{ project.moderation ? "Oui" : "Non" }}
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'ProjectParameters',
props: {
project: {
type: Object,
default: () => {
return {};
}
}
}
};
</script>
<template>
<div class="field">
<label for="attribute-value">
{{ attribute.label }}
</label>
<div>
<ExtraForm
:id="`attribute-value-for-${attribute.name}`"
ref="extraForm"
name="attribute-value"
:field="{ ...attribute, value }"
:use-value-only="true"
@update:value="updateValue($event.toString(), attribute.id)"
/>
</div>
</div>
</template>
<script>
import ExtraForm from '@/components/ExtraForm';
export default {
name: 'ProjectAttributeForm',
components: {
ExtraForm,
},
props: {
attribute: {
type: Object,
default: () => {
return {};
}
},
formProjectAttributes: {
type: Array,
default: () => {
return [];
}
}
},
computed: {
/**
* Retrieves the current value of a specific project attribute.
* This computed property checks the array of project attributes to find the one that matches
* the current attribute's ID. If the attribute is found, its value is returned.
* Otherwise, null is returned to indicate that the attribute is not set for the current project.
*
* @returns {String|null} The value of the attribute if it exists in the project's attributes; otherwise, null.
*/
value() {
// Searches for the attribute within the array of attributes associated with the project.
const projectAttribute = this.formProjectAttributes.find(el => el.attribute_id === this.attribute.id);
// Returns the value of the attribute if it exists, or null if the attribute is not found.
return projectAttribute ? projectAttribute.value : null;
},
},
created() {
// Checks if the component is being used in the context of creating a new project and attribute's default value is set
if (this.$route.name === 'project_create' && this.attribute.default_value !== null) {
// If so, initializes the attribute's value with its default value as defined in the attribute's settings.
this.updateValue(this.attribute.default_value, this.attribute.id);
}
},
methods: {
/**
* Updates or adds a value for a specific attribute in the project.
* This method emits an event to update the project's attributes with a new value for a given attribute ID.
* It is typically called when the user changes the value of an attribute in the UI.
*
* @param {String} value - The new value for the attribute.
* @param {Number} attributeId - The unique ID of the attribute being updated or added to the project.
*/
updateValue(value, attributeId) {
// Emits an event to the parent component, requesting an update to the project's attributes.
this.$emit('update:project_attributes', { value, attributeId });
}
}
};
</script>