Introduce Mimirsbrunn geocoder and improve addresses & maps

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
master
Thomas Citharel 3 years ago
parent 0e7cf89492
commit c599a47d58
No known key found for this signature in database
GPG Key ID: A061B9DDE0CA0773

@ -137,6 +137,9 @@ config :mobilizon, Mobilizon.Service.Geospatial.GoogleMaps,
config :mobilizon, Mobilizon.Service.Geospatial.MapQuest,
api_key: System.get_env("GEOSPATIAL_MAP_QUEST_API_KEY") || nil
config :mobilizon, Mobilizon.Service.Geospatial.Mimirsbrunn,
endpoint: System.get_env("GEOSPATIAL_MIMIRSBRUNN_ENDPOINT") || nil
config :mobilizon, Oban,
repo: Mobilizon.Storage.Repo,
prune: {:maxlen, 10_000},

@ -52,7 +52,7 @@ config :mobilizon, MobilizonWeb.Endpoint,
# Do not include metadata nor timestamps in development logs
config :logger, :console, format: "[$level] $message\n", level: :debug
config :mobilizon, Mobilizon.Service.Geospatial, service: Mobilizon.Service.Geospatial.GoogleMaps
config :mobilizon, Mobilizon.Service.Geospatial, service: Mobilizon.Service.Geospatial.Nominatim
# Set a higher stacktrace during development. Avoid configuring such
# in production as building large stacktraces may be expensive.

@ -26,6 +26,7 @@
"graphql-tag": "^2.10.1",
"intersection-observer": "^0.7.0",
"leaflet": "^1.4.0",
"leaflet.locatecontrol": "^0.68.0",
"lodash": "^4.17.11",
"ngeohash": "^0.6.3",
"register-service-worker": "^1.6.2",
@ -44,6 +45,7 @@
"devDependencies": {
"@types/chai": "^4.2.3",
"@types/leaflet": "^1.5.2",
"@types/leaflet.locatecontrol": "^0.60.7",
"@types/lodash": "^4.14.141",
"@types/mocha": "^5.2.6",
"@vue/cli-plugin-babel": "^4.0.3",

@ -1,125 +1,242 @@
<template>
<div>
<b-field :label="$t('Find an address')">
<b-field expanded>
<template slot="label">
{{ $t('Find an address') }}
<b-button v-if="!gettingLocation" size="is-small" icon-right="map-marker" @click="locateMe" />
<span v-else>{{ $t('Getting location') }}</span>
</template>
<b-autocomplete
:data="data"
v-model="queryText"
:placeholder="$t('e.g. 10 Rue Jangot')"
field="description"
field="fullName"
:loading="isFetching"
@typing="getAsyncData"
icon="map-marker"
@select="option => selected = option">
expanded
@select="updateSelected">
<template slot-scope="{option}">
<b>{{ option.description }}</b><br />
<i v-if="option.url != null">Local</i>
<p>
<small>{{ option.street }},&#32; {{ option.postalCode }} {{ option.locality }}</small>
</p>
<b-icon :icon="option.poiInfos.poiIcon.icon" />
<b>{{ option.poiInfos.name }}</b><br />
<small>{{ option.poiInfos.alternativeName }}</small>
</template>
<template slot="empty">
<span v-if="queryText.length < 5">{{ $t('Please type at least 5 characters') }}</span>
<span v-else-if="isFetching">{{ $t('Searching…') }}</span>
<span v-if="isFetching">{{ $t('Searching') }}</span>
<div v-else class="is-enabled">
<span>{{ $t('No results for "{queryText}"', { queryText }) }}</span>
<p class="control" @click="addressModalActive = true">
<button type="button" class="button is-primary">{{ $t('Add') }}</button>
</p>
<span>{{ $t('No results for "{queryText}". You can try another search term or drag and drop the marker on the map', { queryText }) }}</span>
<!-- <p class="control" @click="openNewAddressModal">-->
<!-- <button type="button" class="button is-primary">{{ $t('Add') }}</button>-->
<!-- </p>-->
</div>
</template>
</b-autocomplete>
</b-field>
<b-modal :active.sync="addressModalActive" :width="640" has-modal-card scroll="keep">
<div class="modal-card" style="width: auto">
<header class="modal-card-head">
<p class="modal-card-title">{{ $t('Add an address') }}</p>
</header>
<section class="modal-card-body">
<form>
<b-field :label="$t('Name')">
<b-input aria-required="true" required v-model="selected.description" />
</b-field>
<b-field :label="$t('Street')">
<b-input v-model="selected.street" />
</b-field>
<b-field :label="$t('Postal Code')">
<b-input v-model="selected.postalCode" />
</b-field>
<b-field :label="$t('Locality')">
<b-input v-model="selected.locality" />
</b-field>
<b-field :label="$t('Region')">
<b-input v-model="selected.region" />
</b-field>
<b-field :label="$t('Country')">
<b-input v-model="selected.country" />
</b-field>
</form>
</section>
<footer class="modal-card-foot">
<button class="button" type="button" @click="resetPopup()">{{ $t('Clear') }}</button>
</footer>
</div>
</b-modal>
<div class="map" v-if="selected && selected.geom">
<map-leaflet
:coords="selected.geom"
:marker="{ text: [selected.poiInfos.name, selected.poiInfos.alternativeName], icon: selected.poiInfos.poiIcon.icon}"
:updateDraggableMarkerCallback="reverseGeoCode"
:options="{ zoom: mapDefaultZoom }"
:readOnly="false"
/>
</div>
<!-- <b-modal v-if="selected" :active.sync="addressModalActive" :width="640" has-modal-card scroll="keep">-->
<!-- <div class="modal-card" style="width: auto">-->
<!-- <header class="modal-card-head">-->
<!-- <p class="modal-card-title">{{ $t('Add an address') }}</p>-->
<!-- </header>-->
<!-- <section class="modal-card-body">-->
<!-- <form>-->
<!-- <b-field :label="$t('Name')">-->
<!-- <b-input aria-required="true" required v-model="selected.description" />-->
<!-- </b-field>-->
<!-- <b-field :label="$t('Street')">-->
<!-- <b-input v-model="selected.street" />-->
<!-- </b-field>-->
<!-- <b-field grouped>-->
<!-- <b-field :label="$t('Postal Code')">-->
<!-- <b-input v-model="selected.postalCode" />-->
<!-- </b-field>-->
<!-- <b-field :label="$t('Locality')">-->
<!-- <b-input v-model="selected.locality" />-->
<!-- </b-field>-->
<!-- </b-field>-->
<!-- <b-field grouped>-->
<!-- <b-field :label="$t('Region')">-->
<!-- <b-input v-model="selected.region" />-->
<!-- </b-field>-->
<!-- <b-field :label="$t('Country')">-->
<!-- <b-input v-model="selected.country" />-->
<!-- </b-field>-->
<!-- </b-field>-->
<!-- </form>-->
<!-- </section>-->
<!-- <footer class="modal-card-foot">-->
<!-- <button class="button" type="button" @click="resetPopup()">{{ $t('Clear') }}</button>-->
<!-- </footer>-->
<!-- </div>-->
<!-- </b-modal>-->
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue, Watch } from 'vue-property-decorator';
import { Address, IAddress } from '@/types/address.model';
import { ADDRESS } from '@/graphql/address';
import { ADDRESS, REVERSE_GEOCODE } from '@/graphql/address';
import { Modal } from 'buefy/dist/components/dialog';
import { LatLng } from 'leaflet';
@Component({
components: {
'map-leaflet': () => import(/* webpackChunkName: "map" */ '@/components/Map.vue'),
Modal,
},
})
export default class AddressAutoComplete extends Vue {
@Prop({ required: false, default: () => [] }) initialData!: IAddress[];
@Prop({ required: false }) value!: IAddress;
@Prop({ required: true }) value!: IAddress;
data: IAddress[] = this.initialData;
selected: IAddress|null = new Address();
data: IAddress[] = [];
selected!: IAddress;
isFetching: boolean = false;
queryText: string = this.value && this.value.description || '';
queryText: string = this.value && (new Address(this.value)).fullName || '';
addressModalActive: boolean = false;
private gettingLocation: boolean = false;
private location!: Position;
private gettingLocationError: any;
private mapDefaultZoom: number = 15;
@Watch('value')
updateEditing() {
this.selected = this.value;
const address = new Address(this.selected);
this.queryText = `${address.poiInfos.name} ${address.poiInfos.alternativeName}`;
}
async getAsyncData(query) {
if (query.length < 5) {
if (!query.length) {
this.data = [];
this.selected = new Address();
return;
}
if (query.length < 3) {
this.data = [];
return;
}
this.isFetching = true;
const result = await this.$apollo.query({
query: ADDRESS,
fetchPolicy: 'no-cache',
variables: { query },
fetchPolicy: 'network-only',
variables: {
query,
locale: this.$i18n.locale,
},
});
this.data = result.data.searchAddress as IAddress[];
this.data = result.data.searchAddress.map(address => new Address(address));
this.isFetching = false;
}
// Watch deep because of subproperties
@Watch('selected', { deep: true })
updateSelected() {
updateSelected(option) {
if (option == null) return;
this.selected = option;
console.log('update selected', this.selected);
this.$emit('input', this.selected);
}
resetPopup() {
this.selected = new Address();
}
openNewAddressModal() {
this.resetPopup();
this.addressModalActive = true;
}
async reverseGeoCode(e: LatLng, zoom: Number) {
// If the position has been updated through autocomplete selection, no need to geocode it !
if (this.checkCurrentPosition(e)) return;
const result = await this.$apollo.query({
query: REVERSE_GEOCODE,
variables: {
latitude: e.lat,
longitude: e.lng,
zoom,
locale: this.$i18n.locale,
},
});
this.data = result.data.reverseGeocode.map(address => new Address(address));
const defaultAddress = new Address(this.data[0]);
this.selected = defaultAddress;
this.$emit('input', this.selected);
this.queryText = `${defaultAddress.poiInfos.name} ${defaultAddress.poiInfos.alternativeName}`;
}
checkCurrentPosition(e: LatLng) {
if (!this.selected || !this.selected.geom) return false;
const lat = parseFloat(this.selected.geom.split(';')[1]);
const lon = parseFloat(this.selected.geom.split(';')[0]);
return e.lat === lat && e.lng === lon;
}
async locateMe(): Promise<void> {
this.gettingLocation = true;
try {
this.gettingLocation = false;
this.location = await this.getLocation();
this.mapDefaultZoom = 12;
this.reverseGeoCode(new LatLng(this.location.coords.latitude, this.location.coords.longitude), 12);
} catch (e) {
this.gettingLocation = false;
this.gettingLocationError = e.message;
}
}
async getLocation(): Promise<Position> {
return new Promise((resolve, reject) => {
if (!('geolocation' in navigator)) {
reject(new Error('Geolocation is not available.'));
}
navigator.geolocation.getCurrentPosition(pos => {
resolve(pos);
}, err => {
reject(err);
});
});
}
}
</script>
<style lang="scss">
.autocomplete .dropdown-item.is-disabled .is-enabled {
opacity: 1 !important;
cursor: auto;
.autocomplete {
.dropdown-menu {
z-index: 2000;
}
.dropdown-item.is-disabled {
opacity: 1 !important;
cursor: auto;
}
}
.read-only {
cursor: pointer;
}
.map {
height: 400px;
width: 100%;
}
</style>

@ -5,40 +5,54 @@
:style="`height: ${mergedOptions.height}; width: ${mergedOptions.width}`"
class="leaflet-map"
:center="[lat, lon]"
@click="clickMap"
@update:zoom="updateZoom"
>
<l-tile-layer
url="https://{s}.tile.osm.org/{z}/{x}/{y}.png"
attribution="© OpenStreetMap contributors"
url="https://{s}.tile.openstreetmap.fr/osmfr/{z}/{x}/{y}.png"
:attribution="$t('© The OpenStreetMap Contributors')"
>
</l-tile-layer>
<l-marker :lat-lng="[lat, lon]" >
<l-popup v-if="popup">{{ popup }}</l-popup>
<v-locatecontrol :options="{icon: 'mdi mdi-map-marker'}"/>
<l-marker :lat-lng="[lat, lon]" @add="openPopup" @update:latLng="updateDraggableMarkerPosition" :draggable="!readOnly">
<l-popup v-if="popupMultiLine">
<span v-for="line in popupMultiLine" :key="line">{{ line }}<br /></span>
</l-popup>
</l-marker>
</l-map>
</div>
</template>
<script lang="ts">
import { Icon } from 'leaflet';
import { Icon, LatLng, LeafletMouseEvent } from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { Component, Prop, Vue } from 'vue-property-decorator';
import { LMap, LTileLayer, LMarker, LPopup } from 'vue2-leaflet';
import { LMap, LTileLayer, LMarker, LPopup, LIcon } from 'vue2-leaflet';
import Vue2LeafletLocateControl from '@/components/Map/Vue2LeafletLocateControl.vue';
@Component({
components: { LTileLayer, LMap, LMarker, LPopup },
components: { LTileLayer, LMap, LMarker, LPopup, LIcon, 'v-locatecontrol': Vue2LeafletLocateControl },
})
export default class Map extends Vue {
@Prop({ type: Boolean, required: false, default: true }) readOnly!: boolean;
@Prop({ type: String, required: true }) coords!: string;
@Prop({ type: String, required: false }) popup!: string;
@Prop({ type: Object, required: false }) marker!: { text: String|String[], icon: String };
@Prop({ type: Object, required: false }) options!: object;
@Prop({ type: Function, required: false, default: () => {} }) updateDraggableMarkerCallback!: Function;
defaultOptions: object = {
defaultOptions: {
zoom: Number;
height: String;
width: String;
} = {
zoom: 15,
height: '100%',
width: '100%',
};
zoom = this.defaultOptions.zoom;
mounted() {
// this part resolve an issue where the markers would not appear
// @ts-ignore
@ -51,12 +65,38 @@ export default class Map extends Vue {
});
}
openPopup(event) {
this.$nextTick(() => {
event.target.openPopup();
});
}
get mergedOptions(): object {
return { ...this.defaultOptions, ...this.options };
}
get lat() { return this.$props.coords.split(';')[1]; }
get lon() { return this.$props.coords.split(';')[0]; }
get popupMultiLine() {
if (Array.isArray(this.marker.text)) {
return this.marker.text;
}
return [this.marker.text];
}
clickMap(event: LeafletMouseEvent) {
this.updateDraggableMarkerPosition(event.latlng);
}
updateDraggableMarkerPosition(e: LatLng) {
console.log('updateDraggableMarkerPosition', e);
this.updateDraggableMarkerCallback(e, this.zoom);
}
updateZoom(zoom: Number) {
this.zoom = zoom;
}
}
</script>
<style lang="scss" scoped>

@ -0,0 +1,47 @@
<template>
<div style="display: none;">
<slot v-if="ready"></slot>
</div>
</template>
<script lang="ts">
/**
* Fork of https://github.com/domoritz/leaflet-locatecontrol to try to trigger location manually (not done ATM)
*/
import L, { DomEvent } from 'leaflet';
import { findRealParent, propsBinder } from 'vue2-leaflet';
import 'leaflet.locatecontrol';
import { Component, Prop, Vue } from 'vue-property-decorator';
@Component({
beforeDestroy() {
// @ts-ignore
this.parentContainer.removeLayer(this);
},
})
export default class Vue2LeafletLocateControl extends Vue {
@Prop({ type: Object, default: () => { return {}; } }) options;
@Prop({ type: Boolean, default: true }) visible = true;
ready: boolean = false;
mapObject!: any;
parentContainer: any;
mounted() {
this.mapObject = L.control.locate(this.options);
DomEvent.on(this.mapObject, this.$listeners as any);
propsBinder(this, this.mapObject, this.$props);
this.ready = true;
this.parentContainer = findRealParent(this.$parent);
this.mapObject.addTo(this.parentContainer.mapObject, !this.visible);
}
public locate() {
this.mapObject.start();
}
}
</script>
<style>
@import "~leaflet.locatecontrol/dist/L.Control.Locate.css";
</style>

@ -1,20 +1,34 @@
import gql from 'graphql-tag';
const $addressFragment = `
id,
description,
geom,
street,
locality,
postalCode,
region,
country,
type,
url,
originId
`;
export const ADDRESS = gql`
query($query:String!) {
query($query:String!, $locale: String) {
searchAddress(
query: $query
query: $query,
locale: $locale
) {
id,
description,
geom,
street,
locality,
postalCode,
region,
country,
url,
originId
${$addressFragment}
}
}
`;
export const REVERSE_GEOCODE = gql`
query($latitude: Float!, $longitude: Float!, $zoom: Int, $locale: String) {
reverseGeocode(latitude: $latitude, longitude: $longitude, zoom: $zoom, locale: $locale) {
${$addressFragment}
}
}
`;

@ -5,7 +5,13 @@ query {
config {
name,
description,
registrationsOpen
registrationsOpen,
countryCode,
location {
latitude,
longitude,
accuracyRadius
}
}
}
`;

@ -24,7 +24,9 @@ const physicalAddressQuery = `
region,
country,
geom,
id
type,
id,
originId
`;
const tagsQuery = `

@ -110,6 +110,7 @@
"From the {startDate} to the {endDate}": "From the {startDate} to the {endDate}",
"Gather ⋅ Organize ⋅ Mobilize": "Gather ⋅ Organize ⋅ Mobilize",
"General information": "General information",
"Getting location": "Getting location",
"Going as {name}": "Going as {name}",
"Group List": "Group List",
"Group full name": "Group full name",
@ -160,7 +161,7 @@
"No events found": "No events found",
"No group found": "No group found",
"No groups found": "No groups found",
"No results for \"{queryText}\"": "No results for \"{queryText}\"",
"No results for \"{queryText}\". You can try another search term or drag and drop the marker on the map": "No results for \"{queryText}\". You can try another search term or drag and drop the marker on the map",
"No user account with this email was found. Maybe you made a typo?": "No user account with this email was found. Maybe you made a typo?",
"Number of places": "Number of places",
"OK": "OK",
@ -195,7 +196,6 @@
"Please make sure the address is correct and that the page hasn't been moved.": "Please make sure the address is correct and that the page hasn't been moved.",
"Please read the full rules": "Please read the full rules",
"Please refresh the page and retry.": "Please refresh the page and retry.",
"Please type at least 5 characters": "Please type at least 5 characters",
"Postal Code": "Postal Code",
"Private event": "Private event",
"Private feeds": "Private feeds",
@ -327,5 +327,6 @@
"{count} participants": "No participants yet | One participant | {count} participants",
"{count} requests waiting": "{count} requests waiting",
"{license} guarantees {respect} of the people who will use it. Since {source}, anyone can audit it, which guarantees its transparency.": "{license} guarantees {respect} of the people who will use it. Since {source}, anyone can audit it, which guarantees its transparency.",
"© The Mobilizon Contributors {date} - Made with Elixir, Phoenix, VueJS & with some love and some weeks": "© The Mobilizon Contributors {date} - Made with Elixir, Phoenix, VueJS & with some love and some weeks"
"© The Mobilizon Contributors {date} - Made with Elixir, Phoenix, VueJS & with some love and some weeks": "© The Mobilizon Contributors {date} - Made with Elixir, Phoenix, VueJS & with some love and some weeks",
"© The OpenStreetMap Contributors": "© The OpenStreetMap Contributors"
}

@ -3,14 +3,14 @@
"A user-friendly, emancipatory and ethical tool for gathering, organising, and mobilising.": "Un outil convivial, émancipateur et éthique pour se rassembler, s'organiser et se mobiliser.",
"A validation email was sent to {email}": "Un email de validation a été envoyé à {email}",
"Abandon edition": "Abandonner l'édition",
"About": "À propos",
"About Mobilizon": "À propos de Mobilizon",
"About this event": "À propos de cet événement",
"About this instance": "À propos de cette instance",
"Add": "Ajouter",
"About": "À propos",
"Add an address": "Ajouter une adresse",
"Add some tags": "Ajouter des tags",
"Add to my calendar": "Ajouter à mon agenda",
"Add": "Ajouter",
"Additional comments": "Commentaires additionnels",
"Administration": "Administration",
"All data will be deleted every 48 hours, so please don't use this for anything real.": "Toutes les données seront effacées toutes les 48 heures, donc n'utilisez pas ce site à des fins autres que de démonstration.",
@ -25,28 +25,27 @@
"Avatar": "Avatar",
"Before you can login, you need to click on the link inside it to validate your account": "Avant que vous puissiez vous enregistrer, vous devez cliquer sur le lien à l'intérieur pour valider votre compte",
"By {name}": "Par {name}",
"Cancel": "Annuler",
"Cancel creation": "Annuler la création",
"Cancel edition": "Annuler l'édition",
"Cancel my participation request…": "Annuler ma demande de participation…",
"Cancel my participation…": "Annuler ma participation…",
"Cancelled: Won't happen": "Annulé : N'aura pas lieu",
"Cancel": "Annuler",
"Cancelled: Won't happen": "Annulé : N'aura pas lieu",
"Category": "Catégorie",
"Change": "Modifier",
"Change my identity…": "Changer mon identité…",
"Change my password": "Modifier mon mot de passe",
"Change password": "Modifier mot de passe",
"Change": "Modifier",
"Clear": "Effacer",
"Click to select": "Cliquez pour sélectionner",
"Click to upload": "Cliquez pour uploader",
"Close comments for all (except for admins)": "Fermer les commentaires à tout le monde (excepté les administrateurs)",
"Comments": "Commentaires",
"Comments on the event page": "Commentaires sur la page de l'événement",
"Comments": "Commentaires",
"Confirm my particpation": "Confirmer ma participation",
"Confirmed: Will happen": "Confirmé : aura lieu",
"Continue editing": "Continuer l'édition",
"Country": "Pays",
"Create": "Créer",
"Create a new event": "Créer un nouvel événement",
"Create a new group": "Créer un nouveau groupe",
"Create a new identity": "Créer une nouvelle identité",
@ -57,16 +56,17 @@
"Create my profile": "Créer mon profil",
"Create token": "Créer un jeton",
"Create, edit or delete events": "Créer, modifier ou supprimer des événements",
"Create": "Créer",
"Creator": "Créateur",
"Current identity has been changed to {identityName} in order to manage this event.": "L'identité actuelle a été changée à {identityName} pour pouvoir gérer cet événement.",
"Date and time settings": "Paramètres de date et d'heure",
"Date parameters": "Paramètres de date",
"Delete": "Supprimer",
"Delete event": "Supprimer un événement",
"Delete this identity": "Supprimer cette identité",
"Delete your identity": "Supprimer votre identité",
"Delete {eventTitle}": "Supprimer {eventTitle}",
"Delete {preferredUsername}": "Supprimer {preferredUsername}",
"Delete": "Supprimer",
"Description": "Description",
"Didn't receive the instructions ?": "Vous n'avez pas reçu les instructions ?",
"Display name": "Nom affiché",
@ -84,7 +84,6 @@
"Error while communicating with the server.": "Erreur de communication avec le serveur.",
"Error while saving report.": "Erreur lors de l'enregistrement du signalement.",
"Error while validating account": "Erreur lors de la validation du compte",
"Event": "Événement",
"Event already passed": "Événement déjà passé",
"Event cancelled": "Événement annulé",
"Event creation": "Création d'événement",
@ -95,6 +94,7 @@
"Event to be confirmed": "Événement à confirmer",
"Event {eventTitle} deleted": "Événement {eventTitle} supprimé",
"Event {eventTitle} reported": "Événement {eventTitle} signalé",
"Event": "Événement",
"Events": "Événements",
"Exclude": "Exclure",
"Explore": "Explorer",
@ -102,14 +102,15 @@
"Features": "Fonctionnalités",
"Find an address": "Trouver une adresse",
"Find an instance": "Trouver une instance",
"For instance: London, Taekwondo, Architecture…": "Par exemple : Lyon, Taekwondo, Architecture…",
"For instance: London, Taekwondo, Architecture…": "Par exemple : Lyon, Taekwondo, Architecture…",
"Forgot your password ?": "Mot de passe oublié ?",
"From a birthday party with friends and family to a march for climate change, right now, our gatherings are <b>trapped inside the tech giants platforms</b>. How can we organize, how can we click “Attend,” without <b>providing private data</b> to Facebook or <b>locking ourselves up</b> inside MeetUp?": "De lanniversaire entre ami·e·s à une marche pour le climat, aujourdhui, les bonnes raisons de se rassembler sont <b>captées par les géants du web</b>. Comment sorganiser, comment cliquer sur «je participe» sans <b>livrer des données intimes</b> à Facebook ou<b> senfermer</b> dans MeetUp?",
"From the {startDate} at {startTime} to the {endDate}": "Du {startDate} à {startTime} jusqu'au {endDate}",
"From the {startDate} at {startTime} to the {endDate} at {endTime}": "Du {startDate} à {startTime} au {endDate} à {endTime}",
"From the {startDate} at {startTime} to the {endDate}": "Du {startDate} à {startTime} jusqu'au {endDate}",
"From the {startDate} to the {endDate}": "Du {startDate} au {endDate}",
"Gather ⋅ Organize ⋅ Mobilize": "Rassembler ⋅ Organiser ⋅ Mobiliser",
"General information": "Informations générales",
"Getting location": "Récupération de la position",
"Going as {name}": "En tant que {name}",
"Group List": "Liste de groupes",
"Group full name": "Nom complet du groupe",
@ -131,8 +132,8 @@
"Join {instance}, a Mobilizon instance": "Rejoignez {instance}, une instance Mobilizon",
"Last published event": "Dernier événement publié",
"Last week": "La semaine dernière",
"Learn more": "En apprendre plus",
"Learn more about Mobilizon": "En apprendre plus à propos de Mobilizon",
"Learn more": "En apprendre plus",
"Leave event": "Annuler ma participation à l'événement",
"Leaving event \"{title}\"": "Annuler ma participation à l'événement",
"Let's create a new common": "Créons un nouveau Common",
@ -142,8 +143,8 @@
"Locality": "Commune",
"Log in": "Se connecter",
"Log out": "Se déconnecter",
"Login": "Se connecter",
"Login on Mobilizon!": "Se connecter sur Mobilizon !",
"Login": "Se connecter",
"Manage participations": "Gérer les participations",
"Members": "Membres",
"Mobilizon is a free/libre software that will allow communities to create <b>their own spaces</b> to publish events in order to better emancipate themselves from tech giants.": "Mobilizon est un logiciel libre qui permettra à des communautés de <b>créer leurs propres espaces</b> de publication dévénements, afin de mieux sémanciper des géants du web.",
@ -161,19 +162,20 @@
"No group found": "Aucun groupe trouvé",
"No groups found": "Aucun groupe trouvé",
"No results for \"{queryText}\"": "Pas de résultats pour « {queryText} »",
"No results for \"{queryText}\". You can try another search term or drag and drop the marker on the map": "Pas de résultats pour « {queryText} ». Vous pouvez essayer avec d'autres termes de recherche ou bien glisser et déposer le marqueur sur la carte",
"No user account with this email was found. Maybe you made a typo?": "Aucun compte utilisateur trouvé pour cet email. Peut-être avez-vous fait une faute de frappe ?",
"Number of places": "Nombre de places",
"OK": "OK",
"Old password": "Ancien mot de passe",
"On {date}": "Le {date}",
"On {date} ending at {endTime}": "Le {date}, se terminant à {endTime}",
"On {date} from {startTime} to {endTime}": "Le {date} de {startTime} à {endTime}",
"On {date} starting at {startTime}": "Le {date} à partir de {startTime}",
"On {date}": "Le {date}",
"One person is going": "Personne n'y va | Une personne y va | {approved} personnes y vont",
"Only accessible through link and search (private)": "Uniquement accessibles par lien et la recherche (privé)",
"Opened reports": "Signalements ouverts",
"Organized": "Organisés",
"Organized by {name}": "Organisé par {name}",
"Organized": "Organisés",
"Organizer": "Organisateur",
"Otherwise this identity will just be removed from the group administrators.": "Sinon cette identité sera juste supprimée des administrateurs du groupe.",
"Page limited to my group (asks for auth)": "Accès limité à mon groupe (demande authentification)",
@ -184,10 +186,10 @@
"Participate": "Participer",
"Participation approval": "Validation des participations",
"Participation requested!": "Participation demandée !",
"Password": "Mot de passe",
"Password (confirmation)": "Mot de passe (confirmation)",
"Password change": "Changement de mot de passe",
"Password reset": "Réinitialisation du mot de passe",
"Password": "Mot de passe",
"Past events": "Événements passés",
"Pick an identity": "Choisissez une identité",
"Please check your spam folder if you didn't receive the email.": "Merci de vérifier votre dossier des indésirables si vous n'avez pas reçu l'email.",
@ -209,23 +211,23 @@
"RSS/Atom Feed": "Flux RSS/Atom",
"Read Framasofts statement of intent on the Framablog": "Lire la note dintention de Framasoft sur le Framablog",
"Region": "Région",
"Register": "S'inscrire",
"Register an account on Mobilizon!": "S'inscrire sur Mobilizon !",
"Register for an event by choosing one of your identities": "S'inscrire à un événement en choisissant une de vos identités",
"Register": "S'inscrire",
"Registration is currently closed.": "Les inscriptions sont actuellement fermées.",
"Reject": "Rejetter",
"Rejected": "Rejetés",
"Rejected participations": "Participations rejetées",
"Report": "Signaler",
"Rejected": "Rejetés",
"Report this event": "Signaler cet événement",
"Report": "Signaler",
"Requests": "Requêtes",
"Resend confirmation email": "Envoyer à nouveau l'email de confirmation",
"Reset my password": "Réinitialiser mon mot de passe",
"Save": "Enregistrer",
"Save draft": "Enregistrer le brouillon",
"Search": "Rechercher",
"Save": "Enregistrer",
"Search events, groups, etc.": "Rechercher des événements, des groupes, etc.",
"Search results: \"{search}\"": "Résultats de recherche : « {search} »",
"Search results: \"{search}\"": "Résultats de recherche : « {search} »",
"Search": "Rechercher",
"Searching…": "Recherche en cours…",
"Send me an email to reset my password": "Envoyez-moi un email pour réinitialiser mon mot de passe",
"Send me the confirmation email once again": "Envoyez-moi l'email de confirmation encore une fois",
@ -246,8 +248,8 @@
"The draft event has been updated": "L'événement brouillon a été mis à jour",
"The event has been created as a draft": "L'événement a été créé en tant que brouillon",
"The event has been published": "L'événement a été publié",
"The event has been updated": "L'événement a été mis à jour",
"The event has been updated and published": "L'événement a été mis à jour et publié",
"The event has been updated": "L'événement a été mis à jour",
"The event organizer didn't add any description.": "L'organisateur de l'événement n'a pas ajouté de description.",
"The event title will be ellipsed.": "Le titre de l'événement sera ellipsé.",
"The page you're looking for doesn't exist.": "La page que vous recherchez n'existe pas.",
@ -327,5 +329,6 @@
"{count} participants": "Aucun⋅e participant⋅e | Un⋅e participant⋅e | {count} participant⋅e⋅s",
"{count} requests waiting": "Une demande en attente|{count} demandes en attente",
"{license} guarantees {respect} of the people who will use it. Since {source}, anyone can audit it, which guarantees its transparency.": "{license} garantit {respect} des personnes qui l'utiliseront. Puisque {source}, il est publiquement auditable, ce qui garantit sa transparence.",
"© The Mobilizon Contributors {date} - Made with Elixir, Phoenix, VueJS & with some love and some weeks": "© Les contributeurs de Mobilizon {date} - Fait avec Elixir, Phoenix, VueJS & et de l'amour et des semaines"
"© The Mobilizon Contributors {date} - Made with Elixir, Phoenix, VueJS & with some love and some weeks": "© Les contributeurs de Mobilizon {date} - Fait avec Elixir, Phoenix, VueJS & et de l'amour et des semaines",
"© The OpenStreetMap Contributors": "© Les Contributeur⋅ices OpenStreetMap"
}

@ -1,11 +1,14 @@
import poiIcons from '@/utils/poiIcons';
export interface IAddress {
id?: number;
id?: string;
description: string;
street: string;
locality: string;
postalCode: string;
region: string;
country: string;
type: string;
geom?: string;
url?: string;
originId?: string;
@ -18,4 +21,86 @@ export class Address implements IAddress {
postalCode: string = '';
region: string = '';
street: string = '';
type: string = '';
id?: string = '';
originId?: string = '';
url?: string = '';
geom?: string = '';
constructor(hash?) {
if (!hash) return;
this.id = hash.id;
this.description = hash.description;
this.street = hash.street;
this.locality = hash.locality;
this.postalCode = hash.postalCode;
this.region = hash.region;
this.country = hash.country;
this.type = hash.type;
this.geom = hash.geom;
this.url = hash.url;
this.originId = hash.originId;
}
get poiInfos() {
/* generate name corresponding to poi type */
let name = '';
let alternativeName = '';
let poiIcon = poiIcons.default;
// Google Maps doesn't have a type
if (this.type == null && this.description === this.street) this.type = 'house';
switch (this.type) {
case 'house':
name = this.description;
alternativeName = [this.postalCode, this.locality, this.country].filter(zone => zone).join(', ');
poiIcon = poiIcons.defaultAddress;
break;
case 'street':
case 'secondary':
name = this.description;
alternativeName = [this.postalCode, this.locality, this.country].filter(zone => zone).join(', ');
poiIcon = poiIcons.defaultStreet;
break;
case 'zone':
case 'city':
case 'administrative':
name = this.postalCode ? `${this.description} (${this.postalCode})` : this.description;
alternativeName = [this.region, this.country].filter(zone => zone).join(', ');
poiIcon = poiIcons.defaultAdministrative;
break;
default:
// POI
name = this.description;
alternativeName = '';
if (this.street && this.street.trim()) {
alternativeName = `${this.street}`;
if (this.locality) {
alternativeName += ` (${this.locality})`;
}
} else if (this.locality && this.locality.trim()) {
alternativeName = `${this.locality}, ${this.region}, ${this.country}`;
} else {
alternativeName = `${this.region}, ${this.country}`;
}
poiIcon = this.iconForPOI;
break;
}
return { name, alternativeName, poiIcon };
}
get fullName() {
const { name, alternativeName } = this.poiInfos;
return `${name}, ${alternativeName}`;
}
get iconForPOI() {
if (this.type == null) {
return poiIcons.default;
}
const type = this.type.split(':').pop() || '';
if (poiIcons[type]) return poiIcons[type];
return poiIcons.default;
}
}

@ -3,4 +3,10 @@ export interface IConfig {
description: string;
registrationsOpen: boolean;
countryCode: string;
location: {
latitude: number;
longitude: number;
accuracyRadius: number;
};
}

@ -1,5 +1,5 @@
import { Actor, IActor, IPerson } from './actor';
import { IAddress } from '@/types/address.model';
import { Address, IAddress } from '@/types/address.model';
import { ITag } from '@/types/tag.model';
import { IPicture } from '@/types/picture.model';
@ -239,7 +239,7 @@ export class EventModel implements IEvent {
this.onlineAddress = hash.onlineAddress;
this.phoneAddress = hash.phoneAddress;
this.physicalAddress = hash.physicalAddress;
this.physicalAddress = new Address(hash.physicalAddress);
this.participantStats = hash.participantStats;
this.tags = hash.tags;

@ -0,0 +1,22 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
max_line_length = 120
tab_width = 4
trim_trailing_whitespace = true
[*.ex]
indent_size = 2
tab_width = 2
[*.scss]
indent_size = 2
[*.ts]
indent_size = 2
tab_width = 2

@ -0,0 +1,61 @@
export default {
default: {
icon: 'map-marker',
color: '#5C6F84',
},
defaultAdministrative: {
icon: 'city',
color: '#5c6f84',
},
defaultStreet: {
icon: 'road-variant',
color: '#5c6f84',
},
defaultAddress: {
icon: 'home',
color: '#5c6f84',
},
place_house: {
icon: 'home',
color: '#5c6f84',
},
theatre: {
icon: 'drama-masks',
},
parking: {
icon: 'parking',
},
police: {
icon: 'police-badge',
},
post_office: {
icon: 'email',
},
university: {
icon: 'school',
},
college: {
icon: 'school',
},
park: {
icon: 'pine-tree',
},
garden: {
icon: 'pine-tree',
},
bicycle_rental: {
icon: 'bicycle',
},
hospital: {
icon: 'hospital-box',
},
townhall: {
icon: 'office-building',
},
toilets: {
icon: 'human-male-female',
},
hairdresser: {
icon: 'content-cut',
},
};

@ -1,4 +1,3 @@
import {ParticipantRole} from "@/types/event.model";
<template>
<div class="container">
<b-loading :active.sync="$apollo.loading"></b-loading>
@ -15,7 +14,7 @@ import {ParticipantRole} from "@/types/event.model";
<div class="title-and-informations">
<h1 class="title">{{ event.title }}</h1>
<span>
<router-link v-if="actorIsOrganizer" :to="{ name: RouteName.PARTICIPATIONS, params: {eventId: event.uuid}}">
<router-link v-if="actorIsOrganizer && event.draft === false" :to="{ name: RouteName.PARTICIPATIONS, params: {eventId: event.uuid}}">
<small v-if="event.participantStats.going > 0 && !actorIsParticipant">
{{ $tc('One person is going', event.participantStats.going, {approved: event.participantStats.going}) }}
</small>
@ -111,23 +110,27 @@ import {ParticipantRole} from "@/types/event.model";
</p>
</div>
<div class="address-wrapper">
<b-icon icon="map" />
<span v-if="!event.physicalAddress">{{ $t('No address defined') }}</span>
<div class="address" v-if="event.physicalAddress">
<address>
<span class="addressDescription" :title="event.physicalAddress.description">{{ event.physicalAddress.description }}</span>
<span>{{ event.physicalAddress.street }}</span>
<span>{{ event.physicalAddress.postalCode }} {{ event.physicalAddress.locality }}</span>
</address>
<span class="map-show-button" @click="showMap = !showMap" v-if="event.physicalAddress && event.physicalAddress.geom">
<span v-if="!physicalAddress">
<b-icon icon="map" />
{{ $t('No address defined') }}
</span>
<div class="address" v-if="physicalAddress">
<span>
<b-icon :icon="physicalAddress.poiInfos.poiIcon.icon" />
<address>
<span class="addressDescription" :title="physicalAddress.poiInfos.name">{{ physicalAddress.poiInfos.name }}</span>
<span>{{ physicalAddress.poiInfos.alternativeName }}</span>
</address>
</span>
<span class="map-show-button" @click="showMap = !showMap" v-if="physicalAddress && physicalAddress.geom">
{{ $t('Show map') }}
</span>
</div>
<b-modal v-if="event.physicalAddress && event.physicalAddress.geom" :active.sync="showMap" scroll="keep">
<b-modal v-if="physicalAddress && physicalAddress.geom" :active.sync="showMap" scroll="keep">
<div class="map">
<map-leaflet
:coords="event.physicalAddress.geom"
:popup="event.physicalAddress.description"
:coords="physicalAddress.geom"
:marker="{ text: physicalAddress.fullName, icon: physicalAddress.poiInfos.poiIcon.icon }"
/>
</div>
</b-modal>
@ -254,7 +257,7 @@ import IdentityPicker from '@/views/Account/IdentityPicker.vue';
import ParticipationButton from '@/components/Event/ParticipationButton.vue';
import { GraphQLError } from 'graphql';
import { RouteName } from '@/router';
import HTML = Mocha.reporters.HTML;
import { Address } from '@/types/address.model';
@Component({
components: {
@ -596,11 +599,13 @@ export default class Event extends EventMixin {
}
get eventCapacityOK(): boolean {
if (this.event.draft) return true;
if (!this.event.options.maximumAttendeeCapacity) return true;
return this.event.options.maximumAttendeeCapacity > this.event.participantStats.participant;
}
get numberOfPlacesStillAvailable(): number {
if (this.event.draft) return this.event.options.maximumAttendeeCapacity;
return this.event.options.maximumAttendeeCapacity - this.event.participantStats.participant;
}
@ -611,6 +616,11 @@ export default class Event extends EventMixin {
return null;
}
}
get physicalAddress(): Address|null {
if (!this.event.physicalAddress) return null;
return new Address(this.event.physicalAddress);
}
}
</script>
<style lang="scss" scoped>
@ -664,25 +674,33 @@ export default class Event extends EventMixin {
cursor: pointer;
}
address {
font-style: normal;
flex-wrap: wrap;
span:first-child {
display: flex;
justify-content: flex-start;
span.addressDescription {
text-overflow: ellipsis;
white-space: nowrap;
flex: 1 0 auto;
min-width: 100%;
max-width: 4rem;
overflow: hidden;
span.icon {
align-self: center;
}
:not(.addressDescription) {
color: rgba(46, 62, 72, .6);
flex: 1;
min-width: 100%;
address {
font-style: normal;
flex-wrap: wrap;
display: flex;
justify-content: flex-start;
span.addressDescription {
text-overflow: ellipsis;
white-space: nowrap;
flex: 1 0 auto;
min-width: 100%;
max-width: 4rem;
overflow: hidden;
}
:not(.addressDescription) {
color: rgba(46, 62, 72, .6);
flex: 1;
min-width: 100%;
}
}
}
}

@ -1,7 +1,7 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { ApolloLink, Observable } from 'apollo-link';
import { InMemoryCache, IntrospectionFragmentMatcher } from 'apollo-cache-inmemory';
import { defaultDataIdFromObject, InMemoryCache, IntrospectionFragmentMatcher } from 'apollo-cache-inmemory';
import { onError } from 'apollo-link-error';
import { createLink } from 'apollo-absinthe-upload-link';
import { GRAPHQL_API_ENDPOINT, GRAPHQL_API_FULL_PATH } from './api/_entrypoint';
@ -132,6 +132,13 @@ const link = authMiddleware
const cache = new InMemoryCache({
fragmentMatcher,
dataIdFromObject: object => {
if (object.__typename === 'Address') {
// @ts-ignore
return object.origin_id;
}
return defaultDataIdFromObject(object);
},
});
const apolloClient = new ApolloClient({

@ -927,7 +927,14 @@
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.3.tgz#bdfd69d61e464dcc81b25159c270d75a73c1a636"
integrity sha512-Il2DtDVRGDcqjDtE+rF8iqg1CArehSK84HZJCT7AMITlyXRBpuPhqGLDQMowraqqu1coEaimg4ZOqggt6L6L+A==
"@types/leaflet@^1.5.2":
"@types/leaflet.locatecontrol@^0.60.7":
version "0.60.7"
resolved "https://registry.yarnpkg.com/@types/leaflet.locatecontrol/-/leaflet.locatecontrol-0.60.7.tgz#96d258bf27376b53bb4b3e9276a14e38f270215b"
integrity sha512-sac/MeK4gB+3XTJ3JzCe3HqLwKNHblIpZrxUJ6FapWK8uISZ0wcy8motVO7+v/yO47tQgsnYaobwFZ//beWHBQ==
dependencies:
"@types/leaflet" "*"
"@types/leaflet@*", "@types/leaflet@^1.5.2":
version "1.5.5"
resolved "https://registry.yarnpkg.com/@types/leaflet/-/leaflet-1.5.5.tgz#006c0aa89c4b5e62941717fa71a09e846423536c"
integrity sha512-Eyh1LMmW4OFgafL6rjLyGkMqFS5IzgwWHMSgTKbrsvwLjLaWH8Ae8CV5liRe8HSM731oOVDwAMIZgg9P0SO9tg==
@ -7404,6 +7411,11 @@ lcid@^2.0.0:
dependencies:
invert-kv "^2.0.0"
leaflet.locatecontrol@^0.68.0:
version "0.68.0"
resolved "https://registry.yarnpkg.com/leaflet.locatecontrol/-/leaflet.locatecontrol-0.68.0.tgz#fc0d173ef0f6670af192641e5a448f0c58c814d3"
integrity sha512-jXJCpBvkyH6shjPEOK/DWu/tKX/WdkNeO96jyPrnGelYp9u6wSDj4V1V4aX9+CMTIrEyVB4/4XuU+T7VTRpb6w==
leaflet@^1.4.0:
version "1.5.1"
resolved "https://registry.yarnpkg.com/leaflet/-/leaflet-1.5.1.tgz#9afb9d963d66c870066b1342e7a06f92840f46bf"

@ -17,6 +17,7 @@ defmodule Mobilizon.Addresses.Address do
geom: Geo.PostGIS.Geometry.t(),
postal_code: String.t(),
street: String.t(),
type: String.t(),
url: String.t(),
origin_id: String.t(),
events: [Event.t()]
@ -31,7 +32,8 @@ defmodule Mobilizon.Addresses.Address do
:region,
:postal_code,
:street,
:origin_id
:origin_id,
:type
]
@attrs @required_attrs ++ @optional_attrs
@ -43,6 +45,7 @@ defmodule Mobilizon.Addresses.Address do
field(:geom, Geo.PostGIS.Geometry)
field(:postal_code, :string)
field(:street, :string)
field(:type, :string)
field(:url, :string)
field(:origin_id, :string)

@ -28,6 +28,7 @@ defmodule Mobilizon.Events.Event do
alias Mobilizon.Media
alias Mobilizon.Media.Picture
alias Mobilizon.Mention
alias Mobilizon.Storage.Repo
alias MobilizonWeb.Endpoint
alias MobilizonWeb.Router.Helpers, as: Routes
@ -105,7 +106,7 @@ defmodule Mobilizon.Events.Event do
embeds_one(:participant_stats, EventParticipantStats, on_replace: :update)
belongs_to(:organizer_actor, Actor, foreign_key: :organizer_actor_id)
belongs_to(:attributed_to, Actor, foreign_key: :attributed_to_id)
belongs_to(:physical_address, Address, on_replace: :update)
belongs_to(:physical_address, Address, on_replace: :nilify)
belongs_to(:picture, Picture, on_replace: :update)
has_many(:tracks, Track)
has_many(:sessions, Session)
@ -194,11 +195,23 @@ defmodule Mobilizon.Events.Event do
put_assoc(changeset, :physical_address, address)
_ ->
changeset
cast_assoc(changeset, :physical_address)
end
end
# In case it's a new address but the origin_id is an existing one
defp put_address(%Changeset{} = changeset, %{physical_address: %{origin_id: origin_id}})
when not is_nil(origin_id) do