Browse Source

Add timezone handling

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
chapril
Thomas Citharel 10 months ago
parent
commit
d58ca5743d
No known key found for this signature in database
GPG Key ID: A061B9DDE0CA0773
  1. 1
      js/package.json
  2. 124
      js/src/components/Address/AddressInfo.vue
  3. 186
      js/src/components/Event/EventFullDate.vue
  4. 175
      js/src/components/Event/EventMap.vue
  5. 201
      js/src/components/Event/EventMetadataSidebar.vue
  6. 152
      js/src/components/Event/FullAddressAutoComplete.vue
  7. 9
      js/src/components/NavBar.vue
  8. 5
      js/src/filters/datetime.ts
  9. 1
      js/src/graphql/address.ts
  10. 25
      js/src/graphql/config.ts
  11. 1
      js/src/graphql/event.ts
  12. 11
      js/src/graphql/user.ts
  13. 46
      js/src/i18n/en_US.json
  14. 46
      js/src/i18n/fr_FR.json
  15. 16
      js/src/types/address.model.ts
  16. 3
      js/src/types/event-options.model.ts
  17. 21
      js/src/types/event.model.ts
  18. 199
      js/src/views/Event/Edit.vue
  19. 35
      js/src/views/Event/Event.vue
  20. 2
      js/tests/unit/specs/components/Post/PostElementItem.spec.ts
  21. 2
      js/tsconfig.json
  22. 5
      js/yarn.lock
  23. 4
      lib/federation/activity_pub/actor.ex
  24. 2
      lib/federation/activity_pub/permission.ex
  25. 2
      lib/federation/activity_pub/types/events.ex
  26. 6
      lib/graphql/api/events.ex
  27. 4
      lib/graphql/middleware/current_actor_provider.ex
  28. 2
      lib/graphql/resolvers/address.ex
  29. 99
      lib/graphql/resolvers/event.ex
  30. 2
      lib/graphql/schema/address.ex
  31. 6
      lib/graphql/schema/event.ex
  32. 1
      lib/mobilizon.ex
  33. 51
      lib/mobilizon/addresses/address.ex
  34. 3
      lib/mobilizon/events/event_options.ex
  35. 19
      lib/mobilizon/events/events.ex
  36. 5
      lib/service/geospatial/addok.ex
  37. 5
      lib/service/geospatial/google_maps.ex
  38. 5
      lib/service/geospatial/map_quest.ex
  39. 3
      lib/service/geospatial/mimirsbrunn.ex
  40. 3
      lib/service/geospatial/nominatim.ex
  41. 3
      lib/service/geospatial/pelias.ex
  42. 5
      lib/service/geospatial/photon.ex
  43. 13
      lib/service/geospatial/provider.ex
  44. 54
      lib/service/metadata/event.ex
  45. 40
      lib/service/timezone_detector.ex
  46. 33
      lib/web/auth/context.ex
  47. 1
      mix.exs
  48. 1
      mix.lock
  49. 9
      priv/repo/migrations/20211008084901_add_timezone_to_addresses.exs

1
js/package.json

@ -38,6 +38,7 @@
"bulma-divider": "^0.2.0",
"core-js": "^3.6.4",
"date-fns": "^2.16.0",
"date-fns-tz": "^1.1.6",
"graphql": "^15.0.0",
"graphql-tag": "^2.10.3",
"intersection-observer": "^0.12.0",

124
js/src/components/Address/AddressInfo.vue

@ -0,0 +1,124 @@
<template>
<address>
<b-icon
v-if="showIcon"
:icon="address.poiInfos.poiIcon.icon"
size="is-medium"
class="icon"
/>
<p>
<span
class="addressDescription"
:title="address.poiInfos.name"
v-if="address.poiInfos.name"
>
{{ address.poiInfos.name }}
</span>
<br v-if="address.poiInfos.name" />
<span class="has-text-grey-dark">
{{ address.poiInfos.alternativeName }}
</span>
<br />
<small
v-if="
userTimezoneDifferent &&
longShortTimezoneNamesDifferent &&
timezoneLongNameValid
"
class="has-text-grey-dark"
>
🌐
{{
$t("{timezoneLongName} ({timezoneShortName})", {
timezoneLongName,
timezoneShortName,
})
}}
</small>
<small v-else-if="userTimezoneDifferent" class="has-text-grey-dark">
🌐 {{ timezoneShortName }}
</small>
</p>
</address>
</template>
<script lang="ts">
import { IAddress } from "@/types/address.model";
import { PropType } from "vue";
import { Component, Prop, Vue } from "vue-property-decorator";
@Component
export default class AddressInfo extends Vue {
@Prop({ required: true, type: Object as PropType<IAddress> })
address!: IAddress;
@Prop({ required: false, default: false, type: Boolean }) showIcon!: boolean;
@Prop({ required: false, default: false, type: Boolean })
showTimezone!: boolean;
@Prop({ required: false, type: String }) userTimezone!: string;
get userTimezoneDifferent(): boolean {
return (
this.userTimezone != undefined &&
this.address.timezone != undefined &&
this.userTimezone !== this.address.timezone
);
}
get longShortTimezoneNamesDifferent(): boolean {
return (
this.timezoneLongName != undefined &&
this.timezoneShortName != undefined &&
this.timezoneLongName !== this.timezoneShortName
);
}
get timezoneLongName(): string | undefined {
return this.timezoneName("long");
}
get timezoneShortName(): string | undefined {
return this.timezoneName("short");
}
get timezoneLongNameValid(): boolean {
return (
this.timezoneLongName != undefined && !this.timezoneLongName.match(/UTC/)
);
}
private timezoneName(format: "long" | "short"): string | undefined {
return this.extractTimezone(
new Intl.DateTimeFormat(undefined, {
timeZoneName: format,
timeZone: this.address.timezone,
}).formatToParts()
);
}
private extractTimezone(
parts: Intl.DateTimeFormatPart[]
): string | undefined {
return parts.find((part) => part.type === "timeZoneName")?.value;
}
}
</script>
<style lang="scss" scoped>
address {
font-style: normal;
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 {
padding-right: 1rem;
}
}
</style>

186
js/src/components/Event/EventFullDate.vue

@ -18,64 +18,97 @@
</docs>
<template>
<span v-if="!endsOn">{{
beginsOn | formatDateTimeString(showStartTime)
}}</span>
<span v-else-if="isSameDay() && showStartTime && showEndTime">
{{
<p v-if="!endsOn">
<span>{{
formatDateTimeString(beginsOn, timezoneToShow, showStartTime)
}}</span>
<br />
<b-switch
size="is-small"
v-model="showLocalTimezone"
v-if="differentFromUserTimezone"
>
{{ singleTimeZone }}
</b-switch>
</p>
<p v-else-if="isSameDay() && showStartTime && showEndTime">
<span>{{
$t("On {date} from {startTime} to {endTime}", {
date: formatDate(beginsOn),
startTime: formatTime(beginsOn),
endTime: formatTime(endsOn),
startTime: formatTime(beginsOn, timezoneToShow),
endTime: formatTime(endsOn, timezoneToShow),
})
}}
</span>
<span v-else-if="isSameDay() && !showStartTime && showEndTime">
{{
$t("On {date} ending at {endTime}", {
date: formatDate(beginsOn),
endTime: formatTime(endsOn),
})
}}
</span>
<span v-else-if="isSameDay() && showStartTime && !showEndTime">
}}</span>
<br />
<b-switch
size="is-small"
v-model="showLocalTimezone"
v-if="differentFromUserTimezone"
>
{{ singleTimeZone }}
</b-switch>
</p>
<p v-else-if="isSameDay() && showStartTime && !showEndTime">
{{
$t("On {date} starting at {startTime}", {
date: formatDate(beginsOn),
startTime: formatTime(beginsOn),
})
}}
</span>
<span v-else-if="isSameDay()">{{
$t("On {date}", { date: formatDate(beginsOn) })
}}</span>
<span v-else-if="endsOn && showStartTime && showEndTime">
{{
$t("From the {startDate} at {startTime} to the {endDate} at {endTime}", {
startDate: formatDate(beginsOn),
startTime: formatTime(beginsOn),
endDate: formatDate(endsOn),
endTime: formatTime(endsOn),
})
}}
</span>
<span v-else-if="endsOn && showStartTime">
{{
$t("From the {startDate} at {startTime} to the {endDate}", {
startDate: formatDate(beginsOn),
startTime: formatTime(beginsOn),
endDate: formatDate(endsOn),
})
}}
</span>
<span v-else-if="endsOn">
</p>
<p v-else-if="isSameDay()">
{{ $t("On {date}", { date: formatDate(beginsOn) }) }}
</p>
<p v-else-if="endsOn && showStartTime && showEndTime">
<span>
{{
$t(
"From the {startDate} at {startTime} to the {endDate} at {endTime}",
{
startDate: formatDate(beginsOn),
startTime: formatTime(beginsOn, timezoneToShow),
endDate: formatDate(endsOn),
endTime: formatTime(endsOn, timezoneToShow),
}
)
}}
</span>
<br />
<b-switch
size="is-small"
v-model="showLocalTimezone"
v-if="differentFromUserTimezone"
>
{{ multipleTimeZones }}
</b-switch>
</p>
<p v-else-if="endsOn && showStartTime">
<span>
{{
$t("From the {startDate} at {startTime} to the {endDate}", {
startDate: formatDate(beginsOn),
startTime: formatTime(beginsOn, timezoneToShow),
endDate: formatDate(endsOn),
})
}}
</span>
<br />
<b-switch
size="is-small"
v-model="showLocalTimezone"
v-if="differentFromUserTimezone"
>
{{ singleTimeZone }}
</b-switch>
</p>
<p v-else-if="endsOn">
{{
$t("From the {startDate} to the {endDate}", {
startDate: formatDate(beginsOn),
endDate: formatDate(endsOn),
})
}}
</span>
</p>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
@ -90,14 +123,47 @@ export default class EventFullDate extends Vue {
@Prop({ required: false, default: true }) showEndTime!: boolean;
@Prop({ required: false }) timezone!: string;
@Prop({ required: false }) userTimezone!: string;
showLocalTimezone = true;
get timezoneToShow(): string {
if (this.showLocalTimezone) {
return this.timezone;
}
return this.userActualTimezone;
}
get userActualTimezone(): string {
if (this.userTimezone) {
return this.userTimezone;
}
return Intl.DateTimeFormat().resolvedOptions().timeZone;
}
formatDate(value: Date): string | undefined {
if (!this.$options.filters) return undefined;
return this.$options.filters.formatDateString(value);
}
formatTime(value: Date): string | undefined {
formatTime(value: Date, timezone: string): string | undefined {
if (!this.$options.filters) return undefined;
return this.$options.filters.formatTimeString(value);
return this.$options.filters.formatTimeString(value, timezone || undefined);
}
formatDateTimeString(
value: Date,
timezone: string,
showTime: boolean
): string | undefined {
if (!this.$options.filters) return undefined;
return this.$options.filters.formatDateTimeString(
value,
timezone,
showTime
);
}
isSameDay(): boolean {
@ -106,5 +172,35 @@ export default class EventFullDate extends Vue {
new Date(this.endsOn).toDateString();
return this.endsOn !== undefined && sameDay;
}
get differentFromUserTimezone(): boolean {
return (
!!this.timezone &&
!!this.userActualTimezone &&
this.timezone !== this.userActualTimezone
);
}
get singleTimeZone(): string {
if (this.showLocalTimezone) {
return this.$t("Local time ({timezone})", {
timezone: this.timezoneToShow,
}) as string;
}
return this.$t("Time in your timezone ({timezone})", {
timezone: this.timezoneToShow,
}) as string;
}
get multipleTimeZones(): string {
if (this.showLocalTimezone) {
return this.$t("Local time ({timezone})", {
timezone: this.timezoneToShow,
}) as string;
}
return this.$t("Times in your timezone ({timezone})", {
timezone: this.timezoneToShow,
}) as string;
}
}
</script>

175
js/src/components/Event/EventMap.vue

@ -0,0 +1,175 @@
<template>
<div class="modal-card">
<header class="modal-card-head">
<button type="button" class="delete" @click="$emit('close')" />
</header>
<div class="modal-card-body">
<section class="map">
<map-leaflet
:coords="physicalAddress.geom"
:marker="{
text: physicalAddress.fullName,
icon: physicalAddress.poiInfos.poiIcon.icon,
}"
/>
</section>
<section class="columns is-centered map-footer">
<div class="column is-half has-text-centered">
<p class="address">
<i class="mdi mdi-map-marker"></i>
{{ physicalAddress.fullName }}
</p>
<p class="getting-there">{{ $t("Getting there") }}</p>
<div
class="buttons"
v-if="
addressLinkToRouteByCar ||
addressLinkToRouteByBike ||
addressLinkToRouteByFeet
"
>
<a
class="button"
target="_blank"
v-if="addressLinkToRouteByFeet"
:href="addressLinkToRouteByFeet"
>
<i class="mdi mdi-walk"></i
></a>
<a
class="button"
target="_blank"
v-if="addressLinkToRouteByBike"
:href="addressLinkToRouteByBike"
>
<i class="mdi mdi-bike"></i
></a>
<a
class="button"
target="_blank"
v-if="addressLinkToRouteByTransit"
:href="addressLinkToRouteByTransit"
>
<i class="mdi mdi-bus"></i
></a>
<a
class="button"
target="_blank"
v-if="addressLinkToRouteByCar"
:href="addressLinkToRouteByCar"
>
<i class="mdi mdi-car"></i>
</a>
</div>
</div>
</section>
</div>
</div>
</template>
<script lang="ts">
import { Address, IAddress } from "@/types/address.model";
import { RoutingTransportationType, RoutingType } from "@/types/enums";
import { PropType } from "vue";
import { Component, Vue, Prop } from "vue-property-decorator";
const RoutingParamType = {
[RoutingType.OPENSTREETMAP]: {
[RoutingTransportationType.FOOT]: "engine=fossgis_osrm_foot",
[RoutingTransportationType.BIKE]: "engine=fossgis_osrm_bike",
[RoutingTransportationType.TRANSIT]: null,
[RoutingTransportationType.CAR]: "engine=fossgis_osrm_car",
},
[RoutingType.GOOGLE_MAPS]: {
[RoutingTransportationType.FOOT]: "dirflg=w",
[RoutingTransportationType.BIKE]: "dirflg=b",
[RoutingTransportationType.TRANSIT]: "dirflg=r",
[RoutingTransportationType.CAR]: "driving",
},
};
@Component({
components: {
"map-leaflet": () =>
import(/* webpackChunkName: "map" */ "../../components/Map.vue"),
},
})
export default class EventMap extends Vue {
@Prop({ type: Object as PropType<IAddress> }) address!: IAddress;
@Prop({ type: String }) routingType!: RoutingType;
get physicalAddress(): Address | null {
if (!this.address) return null;
return new Address(this.address);
}
makeNavigationPath(
transportationType: RoutingTransportationType
): string | undefined {
const geometry = this.physicalAddress?.geom;
if (geometry) {
/**
* build urls to routing map
*/
if (!RoutingParamType[this.routingType][transportationType]) {
return;
}
const urlGeometry = geometry.split(";").reverse().join(",");
switch (this.routingType) {
case RoutingType.GOOGLE_MAPS:
return `https://maps.google.com/?saddr=Current+Location&daddr=${urlGeometry}&${
RoutingParamType[this.routingType][transportationType]
}`;
case RoutingType.OPENSTREETMAP:
default: {
const bboxX = geometry.split(";").reverse()[0];
const bboxY = geometry.split(";").reverse()[1];
return `https://www.openstreetmap.org/directions?from=&to=${urlGeometry}&${
RoutingParamType[this.routingType][transportationType]
}#map=14/${bboxX}/${bboxY}`;
}
}
}
}
get addressLinkToRouteByCar(): undefined | string {
return this.makeNavigationPath(RoutingTransportationType.CAR);
}
get addressLinkToRouteByBike(): undefined | string {
return this.makeNavigationPath(RoutingTransportationType.BIKE);
}
get addressLinkToRouteByFeet(): undefined | string {
return this.makeNavigationPath(RoutingTransportationType.FOOT);
}
get addressLinkToRouteByTransit(): undefined | string {
return this.makeNavigationPath(RoutingTransportationType.TRANSIT);
}
}
</script>
<style lang="scss" scoped>
.modal-card-head {
justify-content: flex-end;
button.delete {
margin-right: 1rem;
}
}
section.map {
height: calc(100% - 8rem);
width: calc(100% - 20px);
}
section.map-footer {
p.address {
margin: 1rem auto;
}
div.buttons {
justify-content: center;
}
}
</style>

201
js/src/components/Event/EventMetadataSidebar.vue

@ -11,7 +11,7 @@
<b-button
type="is-text"
class="map-show-button"
@click="showMap = !showMap"
@click="$emit('showMapModal', true)"
v-if="physicalAddress.geom"
>
{{ $t("Show map") }}
@ -24,6 +24,8 @@
:beginsOn="event.beginsOn"
:show-start-time="event.options.showStartTime"
:show-end-time="event.options.showEndTime"
:timezone="event.options.timezone"
:userTimezone="userTimezone"
:endsOn="event.endsOn"
/>
</event-metadata-block>
@ -130,91 +132,12 @@
>
<span v-else>{{ extra.value }}</span>
</event-metadata-block>
<b-modal
class="map-modal"
v-if="physicalAddress && physicalAddress.geom"
:active.sync="showMap"
has-modal-card
full-screen
>
<div class="modal-card">
<header class="modal-card-head">
<button type="button" class="delete" @click="showMap = false" />
</header>
<div class="modal-card-body">
<section class="map">
<map-leaflet
:coords="physicalAddress.geom"
:marker="{
text: physicalAddress.fullName,
icon: physicalAddress.poiInfos.poiIcon.icon,
}"
/>
</section>
<section class="columns is-centered map-footer">
<div class="column is-half has-text-centered">
<p class="address">
<i class="mdi mdi-map-marker"></i>
{{ physicalAddress.fullName }}
</p>
<p class="getting-there">{{ $t("Getting there") }}</p>
<div
class="buttons"
v-if="
addressLinkToRouteByCar ||
addressLinkToRouteByBike ||
addressLinkToRouteByFeet
"
>
<a
class="button"
target="_blank"
v-if="addressLinkToRouteByFeet"
:href="addressLinkToRouteByFeet"
>
<i class="mdi mdi-walk"></i
></a>
<a
class="button"
target="_blank"
v-if="addressLinkToRouteByBike"
:href="addressLinkToRouteByBike"
>
<i class="mdi mdi-bike"></i
></a>
<a
class="button"
target="_blank"
v-if="addressLinkToRouteByTransit"
:href="addressLinkToRouteByTransit"
>
<i class="mdi mdi-bus"></i
></a>
<a
class="button"
target="_blank"
v-if="addressLinkToRouteByCar"
:href="addressLinkToRouteByCar"
>
<i class="mdi mdi-car"></i>
</a>
</div>
</div>
</section>
</div>
</div>
</b-modal>
</div>
</template>
<script lang="ts">
import { Address } from "@/types/address.model";
import { IConfig } from "@/types/config.model";
import {
EventMetadataKeyType,
EventMetadataType,
RoutingTransportationType,
RoutingType,
} from "@/types/enums";
import { EventMetadataKeyType, EventMetadataType } from "@/types/enums";
import { IEvent } from "@/types/event.model";
import { PropType } from "vue";
import { Component, Prop, Vue } from "vue-property-decorator";
@ -224,11 +147,13 @@ import EventMetadataBlock from "./EventMetadataBlock.vue";
import EventFullDate from "./EventFullDate.vue";
import PopoverActorCard from "../Account/PopoverActorCard.vue";
import ActorCard from "../../components/Account/ActorCard.vue";
import AddressInfo from "../../components/Address/AddressInfo.vue";
import {
IEventMetadata,
IEventMetadataDescription,
} from "@/types/event-metadata";
import { eventMetaDataList } from "../../services/EventMetadata";
import { IUser } from "@/types/current-user.model";
@Component({
components: {
@ -236,15 +161,14 @@ import { eventMetaDataList } from "../../services/EventMetadata";
EventFullDate,
PopoverActorCard,
ActorCard,
"map-leaflet": () =>
import(/* webpackChunkName: "map" */ "../../components/Map.vue"),
AddressInfo,
},
})
export default class EventMetadataSidebar extends Vue {
@Prop({ type: Object as PropType<IEvent>, required: true }) event!: IEvent;
@Prop({ type: Object as PropType<IConfig>, required: true }) config!: IConfig;
showMap = false;
@Prop({ required: true }) user!: IUser | undefined;
@Prop({ required: false, default: false }) showMap!: boolean;
RouteName = RouteName;
@ -255,21 +179,6 @@ export default class EventMetadataSidebar extends Vue {
EventMetadataType = EventMetadataType;
EventMetadataKeyType = EventMetadataKeyType;
RoutingParamType = {
[RoutingType.OPENSTREETMAP]: {
[RoutingTransportationType.FOOT]: "engine=fossgis_osrm_foot",
[RoutingTransportationType.BIKE]: "engine=fossgis_osrm_bike",
[RoutingTransportationType.TRANSIT]: null,
[RoutingTransportationType.CAR]: "engine=fossgis_osrm_car",
},
[RoutingType.GOOGLE_MAPS]: {
[RoutingTransportationType.FOOT]: "dirflg=w",
[RoutingTransportationType.BIKE]: "dirflg=b",
[RoutingTransportationType.TRANSIT]: "dirflg=r",
[RoutingTransportationType.CAR]: "driving",
},
};
get physicalAddress(): Address | null {
if (!this.event.physicalAddress) return null;
@ -286,50 +195,6 @@ export default class EventMetadataSidebar extends Vue {
});
}
makeNavigationPath(
transportationType: RoutingTransportationType
): string | undefined {
const geometry = this.physicalAddress?.geom;
if (geometry) {
const routingType = this.config.maps.routing.type;
/**
* build urls to routing map
*/
if (!this.RoutingParamType[routingType][transportationType]) {
return;
}
const urlGeometry = geometry.split(";").reverse().join(",");
switch (routingType) {
case RoutingType.GOOGLE_MAPS:
return `https://maps.google.com/?saddr=Current+Location&daddr=${urlGeometry}&${this.RoutingParamType[routingType][transportationType]}`;
case RoutingType.OPENSTREETMAP:
default: {
const bboxX = geometry.split(";").reverse()[0];
const bboxY = geometry.split(";").reverse()[1];
return `https://www.openstreetmap.org/directions?from=&to=${urlGeometry}&${this.RoutingParamType[routingType][transportationType]}#map=14/${bboxX}/${bboxY}`;
}
}
}
}
get addressLinkToRouteByCar(): undefined | string {
return this.makeNavigationPath(RoutingTransportationType.CAR);
}
get addressLinkToRouteByBike(): undefined | string {
return this.makeNavigationPath(RoutingTransportationType.BIKE);
}
get addressLinkToRouteByFeet(): undefined | string {
return this.makeNavigationPath(RoutingTransportationType.FOOT);
}
get addressLinkToRouteByTransit(): undefined | string {
return this.makeNavigationPath(RoutingTransportationType.TRANSIT);
}
urlToHostname(url: string): string | null {
try {
return new URL(url).hostname;
@ -362,6 +227,10 @@ export default class EventMetadataSidebar extends Vue {
}
}
}
get userTimezone(): string | undefined {
return this.user?.settings?.timezone;
}
}
</script>
<style lang="scss" scoped>
@ -391,50 +260,6 @@ div.address-wrapper {
.map-show-button {
cursor: pointer;
}
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) {
flex: 1;
min-width: 100%;
}
}
}
}
.map-modal {
.modal-card-head {
justify-content: flex-end;
button.delete {
margin-right: 1rem;
}
}
section.map {
height: calc(100% - 8rem);
width: calc(100% - 20px);
}
section.map-footer {
p.address {
margin: 1rem auto;
}
div.buttons {
justify-content: center;
}
}
}
</style>

152
js/src/components/Event/FullAddressAutoComplete.vue

@ -1,72 +1,89 @@
<template>
<div class="address-autocomplete">
<b-field
:label-for="id"
expanded
:message="fieldErrors"
:type="{ 'is-danger': fieldErrors.length }"
>
<template slot="label">
{{ actualLabel }}
<b-button
v-if="canShowLocateMeButton && !gettingLocation"
size="is-small"
icon-right="map-marker"
@click="locateMe"
:title="$t('Use my location')"
/>
<span
class="is-size-6 has-text-weight-normal"
v-else-if="gettingLocation"
>{{ $t("Getting location") }}</span
>
</template>
<b-autocomplete
:data="addressData"
v-model="queryText"
:placeholder="$t('e.g. 10 Rue Jangot')"
field="fullName"
:loading="isFetching"
@typing="fetchAsyncData"
icon="map-marker"
<div class="address-autocomplete columns is-desktop">
<div class="column">
<b-field
:label-for="id"
expanded
@select="updateSelected"
v-bind="$attrs"
:id="id"
:message="fieldErrors"
:type="{ 'is-danger': fieldErrors.length }"
>
<template #default="{ option }">
<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="isFetching">{{ $t("Searching
") }}</span>
<div v-else-if="queryText.length >= 3" class="is-enabled">
<span>{{ $t('No results for "{queryText}"', { queryText }) }}</span>
<span>{{
$t(
"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 slot="label">
{{ actualLabel }}
<b-button
v-if="canShowLocateMeButton && !gettingLocation"
size="is-small"
icon-right="map-marker"
@click="locateMe"
:title="$t('Use my location')"
/>
<span
class="is-size-6 has-text-weight-normal"
v-else-if="gettingLocation"
>{{ $t("Getting location") }}</span
>
</template>
</b-autocomplete>
<b-button
:disabled="!queryText"
@click="resetAddress"
class="reset-area"
icon-left="close"
:title="$t('Clear address field')"
/>
</b-field>
<div class="map" v-if="selected && selected.geom && selected.poiInfos">
<b-autocomplete
:data="addressData"
v-model="queryText"
:placeholder="$t('e.g. 10 Rue Jangot')"
field="fullName"
:loading="isFetching"
@typing="fetchAsyncData"
icon="map-marker"
expanded
@select="updateSelected"
v-bind="$attrs"
:id="id"
>
<template #default="{ option }">
<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="isFetching">{{ $t("Searching
") }}</span>
<div v-else-if="queryText.length >= 3" class="is-enabled">
<span>{{
$t('No results for "{queryText}"', { queryText })
}}</span>
<span>{{
$t(
"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-button
:disabled="!queryText"
@click="resetAddress"
class="reset-area"
icon-left="close"
:title="$t('Clear address field')"
/>
</b-field>
<div class="card" v-if="selected.originId || selected.url">
<div class="card-content">
<address-info
:address="selected"
:show-icon="true"
:show-timezone="true"
:user-timezone="userTimezone"
/>
</div>
</div>
</div>
<div
class="map column"
v-if="selected && selected.geom && selected.poiInfos"
>
<map-leaflet
:coords="selected.geom"
:marker="{
@ -126,14 +143,19 @@ import { Component, Prop, Watch, Mixins } from "vue-property-decorator";
import { LatLng } from "leaflet";
import { Address, IAddress } from "../../types/address.model";
import AddressAutoCompleteMixin from "../../mixins/AddressAutoCompleteMixin";
import AddressInfo from "../../components/Address/AddressInfo.vue";
@Component({
inheritAttrs: false,
components: {
AddressInfo,
},
})
export default class FullAddressAutoComplete extends Mixins(
AddressAutoCompleteMixin
) {
@Prop({ required: false, default: "" }) label!: string;
@Prop({ required: false }) userTimezone!: string;
addressModalActive = false;

9
js/src/components/NavBar.vue

@ -187,7 +187,7 @@
</template>
<script lang="ts">
import { Component, Vue, Watch } from "vue-property-decorator";
import { Component, Ref, Vue, Watch } from "vue-property-decorator";
import Logo from "@/components/Logo.vue";
import { GraphQLError } from "graphql";
import { loadLanguageAsync } from "@/utils/i18n";
@ -259,6 +259,13 @@ export default class NavBar extends Vue {
displayName = displayName;
@Ref("user-dropdown") userDropDown!: any;
toggleMenu(): void {
console.debug("called toggleMenu");
this.userDropDown.showMenu();
}
@Watch("currentActor")
async initializeListOfIdentities(): Promise<void> {
if (!this.currentUser.isLoggedIn) return;

5
js/src/filters/datetime.ts

@ -14,10 +14,11 @@ function formatDateString(value: string): string {
});
}
function formatTimeString(value: string): string {
function formatTimeString(value: string, timeZone: string): string {
return parseDateTime(value).toLocaleTimeString(locale(), {
hour: "numeric",
minute: "numeric",
timeZone,
});
}
@ -55,6 +56,7 @@ const SHORT_TIME_FORMAT_OPTIONS: DateTimeFormatOptions = {
function formatDateTimeString(
value: string,
timeZone: string | undefined = undefined,
showTime = true,
dateFormat = "long"
): string {
@ -66,6 +68,7 @@ function formatDateTimeString(
options = {
...options,
...(isLongFormat ? LONG_TIME_FORMAT_OPTIONS : SHORT_TIME_FORMAT_OPTIONS),
timeZone,
};
}
const format = new Intl.DateTimeFormat(locale(), options);

1
js/src/graphql/address.ts

@ -13,6 +13,7 @@ export const ADDRESS_FRAGMENT = gql`
type
url
originId
timezone
}
`;

25
js/src/graphql/config.ts

@ -96,6 +96,31 @@ export const CONFIG = gql`
}
`;
export const CONFIG_EDIT_EVENT = gql`
query EditEventConfig {
config {
timezones
features {
groups
}
anonymous {
participation {
allowed
validation {
email {
enabled
confirmationRequired
}
captcha {
enabled
}
}
}
}
}
}
`;
export const TERMS = gql`
query Terms($locale: String) {
config {

1
js/src/graphql/event.ts

@ -46,6 +46,7 @@ const EVENT_OPTIONS_FRAGMENT = gql`
anonymousParticipation
showStartTime
showEndTime
timezone
offers {
price
priceCurrency

11
js/src/graphql/user.ts

@ -147,6 +147,17 @@ export const USER_SETTINGS = gql`
${USER_SETTINGS_FRAGMENT}
`;
export const LOGGED_USER_TIMEZONE = gql`
query LoggedUserTimezone {
loggedUser {
id
settings {
timezone
}
}
}
`;
export const SET_USER_SETTINGS = gql`
mutation SetUserSettings(
$timezone: String

46
js/src/i18n/en_US.json

@ -1158,5 +1158,47 @@
"Who can post a comment?": "Who can post a comment?",
"Does the event needs to be confirmed later or is it cancelled?": "Does the event needs to be confirmed later or is it cancelled?",
"When the post is private, you'll need to share the link around.": "When the post is private, you'll need to share the link around.",
"Reset": "Reset"
}
"Reset": "Reset",
"Local time ({timezone})": "Local time ({timezone})",
"Time in your timezone ({timezone})": "Time in your timezone ({timezone})",
"Export": "Export",
"Times in your timezone ({timezone})": "Times in your timezone ({timezone})",
"Skip to main": "Skip to main",
"Comment body": "Comment body",
"has loaded": "has loaded",
"Follows": "Follows",
"Event description body": "Event description body",
"Event timezone will default to the timezone of the event's address if there is one, or to your own timezone setting.": "Event timezone will default to the timezone of the event's address if there is one, or to your own timezone setting.",
"Clear timezone field": "Clear timezone field",
"Group description body": "Group description body",
"Moderation logs": "Moderation logs",
"Post body": "Post body",
"{group} posts": "{group} posts",
"{group}'s todolists": "{group}'s todolists",
"Validating email": "Validating email",
"Redirecting to Mobilizon": "Redirecting to Mobilizon",
"Reset password": "Reset password",
"First steps": "First steps",
"Validating account": "Validating account",
"Navigated to {pageTitle}": "Navigated to {pageTitle}",
"Confirm participation": "Confirm participation",
"Participation with account": "Participation with account",
"Participation without account": "Participation without account",
"Unlogged participation": "Unlogged participation",
"Discussions list": "Discussions list",
"Create discussion": "Create discussion",
"Tag search": "Tag search",
"Homepage": "Homepage",
"About instance": "About instance",
"Privacy": "Privacy",
"Interact": "Interact",
"Account settings": "Account settings",
"Admin dashboard": "Admin dashboard",
"Admin settings": "Admin settings",
"Group profiles": "Group profiles",
"Reports list": "Reports list",
"Create identity": "Create identity",
"Resent confirmation email": "Resent confirmation email",
"Send password reset": "Send password reset",
"Email validate": "Email validate"
}

46
js/src/i18n/fr_FR.json

@ -1262,5 +1262,47 @@
"{profile} updated the member {member}.": "{profile} a mis Ă  jour le ou la membre {member}.",
"{title} ({count} todos)": "{title} ({count} todos)",
"{username} was invited to {group}": "{username} a été invité à {group}",
"© The OpenStreetMap Contributors": "© Les Contributeur⋅ices OpenStreetMap"
}
"© The OpenStreetMap Contributors": "© Les Contributeur⋅ices OpenStreetMap",
"Local time ({timezone})": "Heure locale ({timezone})",
"Time in your timezone ({timezone})": "Heure dans votre fuseau horaire ({timezone})",
"Export": "Export",
"Times in your timezone ({timezone})": "Heures dans votre fuseau horaire ({timezone})",
"has loaded": "a chargé",
"Skip to main": "",
"Navigated to {pageTitle}": "Navigué vers {pageTitle}",
"Comment body": "Corps du commentaire",
"Confirm participation": "Confirmer la participation",
"Participation with account": "Participation avec compte",
"Participation without account": "Participation sans compte",
"Unlogged participation": "Participation non connecté⋅e",
"Discussions list": "Liste des discussions",
"Create discussion": "Créer une discussion",
"Tag search": "Recherche par tag",
"Homepage": "Page d'accueil",
"About instance": "À propos de l'instance",
"Privacy": "Vie privée",
"Interact": "Interagir",
"Redirecting to Mobilizon": "Redirection vers Mobilizon",
"First steps": "",
"Account settings": "",
"Admin dashboard": "",
"Admin settings": "",
"Group profiles": "",
"Reports list": "",
"Moderation logs": "",
"Create identity": "",
"Resent confirmation email": "",
"Send password reset": "",
"Email validate": "",
"Validating account": "",
"Follows": "",
"Event description body": "",
"Event timezone will default to the timezone of the event's address if there is one, or to your own timezone setting.": "Le fuseau horaire de l'événement sera mis par défaut au fuseau horaire de l'addresse de l'événement s'il y en a une, ou bien à votre propre paramÚtre de fuseau horaire.",
"Clear timezone field": "",
"Group description body": "",
"Post body": "Corps du billet",
"{group} posts": "Billets de {group}",
"{group}'s todolists": "Liste de tĂąches de {group}",
"Validating email": "",
"Reset password": ""
}

16
js/src/types/address.model.ts

@ -13,6 +13,7 @@ export interface IAddress {
geom?: string;
url?: string;
originId?: string;
timezone?: string;
}
export interface IPoiInfo {
@ -44,20 +45,23 @@ export class Address implements IAddress {
geom?: string = "";
timezone?: string = "";
constructor(hash?: IAddress) {
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.description = hash.description?.trim();
this.street = hash.street?.trim();
this.locality = hash.locality?.trim();
this.postalCode = hash.postalCode?.trim();
this.region = hash.region?.trim();
this.country = hash.country?.trim();
this.type = hash.type;
this.geom = hash.geom;
this.url = hash.url;
this.originId = hash.originId;
this.timezone = hash.timezone;
}
get poiInfos(): IPoiInfo {

3
js/src/types/event-options.model.ts

@ -26,6 +26,7 @@ export interface IEventOptions {
showParticipationPrice: boolean;
showStartTime: boolean;
showEndTime: boolean;
timezone: string | null;
}
export class EventOptions implements IEventOptions {
@ -54,4 +55,6 @@ export class EventOptions implements IEventOptions {
showStartTime = true;
showEndTime = true;
timezone = null;
}

21
js/src/types/event.model.ts

@ -35,7 +35,7 @@ interface IEventEditJSON {
id?: string;
title: string;
description: string;
beginsOn: string;
beginsOn: string | null;
endsOn: string | null;
status: EventStatus;
visibility: EventVisibility;
@ -92,6 +92,9 @@ export interface IEvent {
toEditJSON(): IEventEditJSON;
}
export interface IEditableEvent extends Omit<IEvent, "beginsOn"> {
beginsOn: Date | null;
}