Merge branch 'front-end-issues' into 'master'
A11y improvements Closes #821 See merge request framasoft/mobilizon!1057
This commit is contained in:
commit
4f4c92e917
@ -172,7 +172,8 @@ export default class ResourceActivityItem extends mixins(ActivityMixin) {
|
|||||||
if (this.subjectParams.resource_path) {
|
if (this.subjectParams.resource_path) {
|
||||||
const parentPath = this.parentPath(this.subjectParams.resource_path);
|
const parentPath = this.parentPath(this.subjectParams.resource_path);
|
||||||
const directory = parentPath.split("/");
|
const directory = parentPath.split("/");
|
||||||
return directory.pop();
|
const res = directory.pop();
|
||||||
|
res === "" ? null : res;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
@ -128,7 +128,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Component, Mixins, Ref } from "vue-property-decorator";
|
import { Component, Mixins } from "vue-property-decorator";
|
||||||
import { SnackbarProgrammatic as Snackbar } from "buefy";
|
import { SnackbarProgrammatic as Snackbar } from "buefy";
|
||||||
import { formatDistanceToNow } from "date-fns";
|
import { formatDistanceToNow } from "date-fns";
|
||||||
import {
|
import {
|
||||||
@ -173,8 +173,6 @@ export default class Followers extends Mixins(RelayMixin) {
|
|||||||
|
|
||||||
FOLLOWERS_PER_PAGE = FOLLOWERS_PER_PAGE;
|
FOLLOWERS_PER_PAGE = FOLLOWERS_PER_PAGE;
|
||||||
|
|
||||||
@Ref("table") readonly table!: any;
|
|
||||||
|
|
||||||
toggle(row: Record<string, unknown>): void {
|
toggle(row: Record<string, unknown>): void {
|
||||||
this.table.toggleDetails(row);
|
this.table.toggleDetails(row);
|
||||||
}
|
}
|
||||||
|
@ -16,6 +16,7 @@
|
|||||||
:class="{ 'is-active': editor.isActive('bold') }"
|
:class="{ 'is-active': editor.isActive('bold') }"
|
||||||
@click="editor.chain().focus().toggleBold().run()"
|
@click="editor.chain().focus().toggleBold().run()"
|
||||||
type="button"
|
type="button"
|
||||||
|
:title="$t('Bold')"
|
||||||
>
|
>
|
||||||
<b-icon icon="format-bold" />
|
<b-icon icon="format-bold" />
|
||||||
</button>
|
</button>
|
||||||
@ -25,6 +26,7 @@
|
|||||||
:class="{ 'is-active': editor.isActive('italic') }"
|
:class="{ 'is-active': editor.isActive('italic') }"
|
||||||
@click="editor.chain().focus().toggleItalic().run()"
|
@click="editor.chain().focus().toggleItalic().run()"
|
||||||
type="button"
|
type="button"
|
||||||
|
:title="$t('Italic')"
|
||||||
>
|
>
|
||||||
<b-icon icon="format-italic" />
|
<b-icon icon="format-italic" />
|
||||||
</button>
|
</button>
|
||||||
@ -34,6 +36,7 @@
|
|||||||
:class="{ 'is-active': editor.isActive('underline') }"
|
:class="{ 'is-active': editor.isActive('underline') }"
|
||||||
@click="editor.chain().focus().toggleUnderline().run()"
|
@click="editor.chain().focus().toggleUnderline().run()"
|
||||||
type="button"
|
type="button"
|
||||||
|
:title="$t('Underline')"
|
||||||
>
|
>
|
||||||
<b-icon icon="format-underline" />
|
<b-icon icon="format-underline" />
|
||||||
</button>
|
</button>
|
||||||
@ -44,6 +47,7 @@
|
|||||||
:class="{ 'is-active': editor.isActive('heading', { level: 1 }) }"
|
:class="{ 'is-active': editor.isActive('heading', { level: 1 }) }"
|
||||||
@click="editor.chain().focus().toggleHeading({ level: 1 }).run()"
|
@click="editor.chain().focus().toggleHeading({ level: 1 }).run()"
|
||||||
type="button"
|
type="button"
|
||||||
|
:title="$t('Heading Level 1')"
|
||||||
>
|
>
|
||||||
<b-icon icon="format-header-1" />
|
<b-icon icon="format-header-1" />
|
||||||
</button>
|
</button>
|
||||||
@ -54,6 +58,7 @@
|
|||||||
:class="{ 'is-active': editor.isActive('heading', { level: 2 }) }"
|
:class="{ 'is-active': editor.isActive('heading', { level: 2 }) }"
|
||||||
@click="editor.chain().focus().toggleHeading({ level: 2 }).run()"
|
@click="editor.chain().focus().toggleHeading({ level: 2 }).run()"
|
||||||
type="button"
|
type="button"
|
||||||
|
:title="$t('Heading Level 2')"
|
||||||
>
|
>
|
||||||
<b-icon icon="format-header-2" />
|
<b-icon icon="format-header-2" />
|
||||||
</button>
|
</button>
|
||||||
@ -64,6 +69,7 @@
|
|||||||
:class="{ 'is-active': editor.isActive('heading', { level: 3 }) }"
|
:class="{ 'is-active': editor.isActive('heading', { level: 3 }) }"
|
||||||
@click="editor.chain().focus().toggleHeading({ level: 3 }).run()"
|
@click="editor.chain().focus().toggleHeading({ level: 3 }).run()"
|
||||||
type="button"
|
type="button"
|
||||||
|
:title="$t('Heading Level 3')"
|
||||||
>
|
>
|
||||||
<b-icon icon="format-header-3" />
|
<b-icon icon="format-header-3" />
|
||||||
</button>
|
</button>
|
||||||
@ -73,6 +79,7 @@
|
|||||||
@click="showLinkMenu()"
|
@click="showLinkMenu()"
|
||||||
:class="{ 'is-active': editor.isActive('link') }"
|
:class="{ 'is-active': editor.isActive('link') }"
|
||||||
type="button"
|
type="button"
|
||||||
|
:title="$t('Add link')"
|
||||||
>
|
>
|
||||||
<b-icon icon="link" />
|
<b-icon icon="link" />
|
||||||
</button>
|
</button>
|
||||||
@ -82,6 +89,7 @@
|
|||||||
class="menubar__button"
|
class="menubar__button"
|
||||||
@click="editor.chain().focus().unsetLink().run()"
|
@click="editor.chain().focus().unsetLink().run()"
|
||||||
type="button"
|
type="button"
|
||||||
|
:title="$t('Remove link')"
|
||||||
>
|
>
|
||||||
<b-icon icon="link-off" />
|
<b-icon icon="link-off" />
|
||||||
</button>
|
</button>
|
||||||
@ -91,6 +99,7 @@
|
|||||||
v-if="!isBasicMode"
|
v-if="!isBasicMode"
|
||||||
@click="showImagePrompt()"
|
@click="showImagePrompt()"
|
||||||
type="button"
|
type="button"
|
||||||
|
:title="$t('Add picture')"
|
||||||
>
|
>
|
||||||
<b-icon icon="image" />
|
<b-icon icon="image" />
|
||||||
</button>
|
</button>
|
||||||
@ -101,6 +110,7 @@
|
|||||||
:class="{ 'is-active': editor.isActive('bulletList') }"
|
:class="{ 'is-active': editor.isActive('bulletList') }"
|
||||||
@click="editor.chain().focus().toggleBulletList().run()"
|
@click="editor.chain().focus().toggleBulletList().run()"
|
||||||
type="button"
|
type="button"
|
||||||
|
:title="$t('Bullet list')"
|
||||||
>
|
>
|
||||||
<b-icon icon="format-list-bulleted" />
|
<b-icon icon="format-list-bulleted" />
|
||||||
</button>
|
</button>
|
||||||
@ -111,6 +121,7 @@
|
|||||||
:class="{ 'is-active': editor.isActive('orderedList') }"
|
:class="{ 'is-active': editor.isActive('orderedList') }"
|
||||||
@click="editor.chain().focus().toggleOrderedList().run()"
|
@click="editor.chain().focus().toggleOrderedList().run()"
|
||||||
type="button"
|
type="button"
|
||||||
|
:title="$t('Ordered list')"
|
||||||
>
|
>
|
||||||
<b-icon icon="format-list-numbered" />
|
<b-icon icon="format-list-numbered" />
|
||||||
</button>
|
</button>
|
||||||
@ -121,6 +132,7 @@
|
|||||||
:class="{ 'is-active': editor.isActive('blockquote') }"
|
:class="{ 'is-active': editor.isActive('blockquote') }"
|
||||||
@click="editor.chain().focus().toggleBlockquote().run()"
|
@click="editor.chain().focus().toggleBlockquote().run()"
|
||||||
type="button"
|
type="button"
|
||||||
|
:title="$t('Quote')"
|
||||||
>
|
>
|
||||||
<b-icon icon="format-quote-close" />
|
<b-icon icon="format-quote-close" />
|
||||||
</button>
|
</button>
|
||||||
@ -130,6 +142,7 @@
|
|||||||
class="menubar__button"
|
class="menubar__button"
|
||||||
@click="editor.chain().focus().undo().run()"
|
@click="editor.chain().focus().undo().run()"
|
||||||
type="button"
|
type="button"
|
||||||
|
:title="$t('Undo')"
|
||||||
>
|
>
|
||||||
<b-icon icon="undo" />
|
<b-icon icon="undo" />
|
||||||
</button>
|
</button>
|
||||||
@ -139,6 +152,7 @@
|
|||||||
class="menubar__button"
|
class="menubar__button"
|
||||||
@click="editor.chain().focus().redo().run()"
|
@click="editor.chain().focus().redo().run()"
|
||||||
type="button"
|
type="button"
|
||||||
|
:title="$t('Redo')"
|
||||||
>
|
>
|
||||||
<b-icon icon="redo" />
|
<b-icon icon="redo" />
|
||||||
</button>
|
</button>
|
||||||
@ -155,6 +169,7 @@
|
|||||||
:class="{ 'is-active': editor.isActive('bold') }"
|
:class="{ 'is-active': editor.isActive('bold') }"
|
||||||
@click="editor.chain().focus().toggleBold().run()"
|
@click="editor.chain().focus().toggleBold().run()"
|
||||||
type="button"
|
type="button"
|
||||||
|
:title="$t('Bold')"
|
||||||
>
|
>
|
||||||
<b-icon icon="format-bold" />
|
<b-icon icon="format-bold" />
|
||||||
<span class="visually-hidden">{{ $t("Bold") }}</span>
|
<span class="visually-hidden">{{ $t("Bold") }}</span>
|
||||||
@ -165,6 +180,7 @@
|
|||||||
:class="{ 'is-active': editor.isActive('italic') }"
|
:class="{ 'is-active': editor.isActive('italic') }"
|
||||||
@click="editor.chain().focus().toggleItalic().run()"
|
@click="editor.chain().focus().toggleItalic().run()"
|
||||||
type="button"
|
type="button"
|
||||||
|
:title="$t('Italic')"
|
||||||
>
|
>
|
||||||
<b-icon icon="format-italic" />
|
<b-icon icon="format-italic" />
|
||||||
<span class="visually-hidden">{{ $t("Italic") }}</span>
|
<span class="visually-hidden">{{ $t("Italic") }}</span>
|
||||||
|
@ -11,6 +11,7 @@
|
|||||||
icon="map-marker"
|
icon="map-marker"
|
||||||
expanded
|
expanded
|
||||||
@select="updateSelected"
|
@select="updateSelected"
|
||||||
|
v-bind="$attrs"
|
||||||
>
|
>
|
||||||
<template #default="{ option }">
|
<template #default="{ option }">
|
||||||
<b-icon :icon="option.poiInfos.poiIcon.icon" />
|
<b-icon :icon="option.poiInfos.poiIcon.icon" />
|
||||||
@ -20,7 +21,11 @@
|
|||||||
</template>
|
</template>
|
||||||
</b-autocomplete>
|
</b-autocomplete>
|
||||||
</b-field>
|
</b-field>
|
||||||
<b-field v-if="canDoGeoLocation">
|
<b-field
|
||||||
|
v-if="canDoGeoLocation"
|
||||||
|
:message="fieldErrors"
|
||||||
|
:type="{ 'is-danger': fieldErrors.length }"
|
||||||
|
>
|
||||||
<b-button
|
<b-button
|
||||||
type="is-text"
|
type="is-text"
|
||||||
v-if="!gettingLocation"
|
v-if="!gettingLocation"
|
||||||
@ -52,26 +57,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Component, Prop, Vue, Watch } from "vue-property-decorator";
|
import { Component, Mixins, Prop, Watch } from "vue-property-decorator";
|
||||||
import { LatLng } from "leaflet";
|
|
||||||
import debounce from "lodash/debounce";
|
|
||||||
import { DebouncedFunc } from "lodash";
|
|
||||||
import { Address, IAddress } from "../../types/address.model";
|
import { Address, IAddress } from "../../types/address.model";
|
||||||
import { ADDRESS, REVERSE_GEOCODE } from "../../graphql/address";
|
import AddressAutoCompleteMixin from "@/mixins/AddressAutoCompleteMixin";
|
||||||
import { CONFIG } from "../../graphql/config";
|
|
||||||
import { IConfig } from "../../types/config.model";
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
components: {
|
inheritAttrs: false,
|
||||||
"map-leaflet": () =>
|
|
||||||
import(/* webpackChunkName: "map" */ "@/components/Map.vue"),
|
|
||||||
},
|
|
||||||
apollo: {
|
|
||||||
config: CONFIG,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
export default class AddressAutoComplete extends Vue {
|
export default class AddressAutoComplete extends Mixins(
|
||||||
@Prop({ required: true }) value!: IAddress;
|
AddressAutoCompleteMixin
|
||||||
|
) {
|
||||||
@Prop({ required: false, default: false }) type!: string | false;
|
@Prop({ required: false, default: false }) type!: string | false;
|
||||||
@Prop({ required: false, default: true, type: Boolean })
|
@Prop({ required: false, default: true, type: Boolean })
|
||||||
doGeoLocation!: boolean;
|
doGeoLocation!: boolean;
|
||||||
@ -80,84 +75,20 @@ export default class AddressAutoComplete extends Vue {
|
|||||||
|
|
||||||
selected: IAddress = new Address();
|
selected: IAddress = new Address();
|
||||||
|
|
||||||
isFetching = false;
|
|
||||||
|
|
||||||
initialQueryText = "";
|
initialQueryText = "";
|
||||||
|
|
||||||
addressModalActive = false;
|
addressModalActive = false;
|
||||||
|
|
||||||
showmap = false;
|
showmap = false;
|
||||||
|
|
||||||
private gettingLocation = false;
|
get queryText2(): string {
|
||||||
|
|
||||||
// eslint-disable-next-line no-undef
|
|
||||||
private location!: GeolocationPosition;
|
|
||||||
|
|
||||||
private gettingLocationError: any;
|
|
||||||
|
|
||||||
private mapDefaultZoom = 15;
|
|
||||||
|
|
||||||
config!: IConfig;
|
|
||||||
|
|
||||||
fetchAsyncData!: DebouncedFunc<(query: string) => Promise<void>>;
|
|
||||||
|
|
||||||
// We put this in data because of issues like
|
|
||||||
// https://github.com/vuejs/vue-class-component/issues/263
|
|
||||||
data(): Record<string, unknown> {
|
|
||||||
return {
|
|
||||||
fetchAsyncData: debounce(this.asyncData, 200),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async asyncData(query: string): Promise<void> {
|
|
||||||
if (!query.length) {
|
|
||||||
this.addressData = [];
|
|
||||||
this.selected = new Address();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (query.length < 3) {
|
|
||||||
this.addressData = [];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.isFetching = true;
|
|
||||||
const variables: { query: string; locale: string; type?: string } = {
|
|
||||||
query,
|
|
||||||
locale: this.$i18n.locale,
|
|
||||||
};
|
|
||||||
if (this.type) {
|
|
||||||
variables.type = this.type;
|
|
||||||
}
|
|
||||||
const result = await this.$apollo.query({
|
|
||||||
query: ADDRESS,
|
|
||||||
fetchPolicy: "network-only",
|
|
||||||
variables,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.addressData = result.data.searchAddress.map(
|
|
||||||
(address: IAddress) => new Address(address)
|
|
||||||
);
|
|
||||||
this.isFetching = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Watch("config")
|
|
||||||
watchConfig(config: IConfig): void {
|
|
||||||
if (!config.geocoding.autocomplete) {
|
|
||||||
// If autocomplete is disabled, we put a larger debounce value
|
|
||||||
// so that we don't request with incomplete address
|
|
||||||
this.fetchAsyncData = debounce(this.asyncData, 2000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get queryText(): string {
|
|
||||||
if (this.value !== undefined) {
|
if (this.value !== undefined) {
|
||||||
return new Address(this.value).fullName;
|
return new Address(this.value).fullName;
|
||||||
}
|
}
|
||||||
return this.initialQueryText;
|
return this.initialQueryText;
|
||||||
}
|
}
|
||||||
|
|
||||||
set queryText(queryText: string) {
|
set queryText2(queryText: string) {
|
||||||
this.initialQueryText = queryText;
|
this.initialQueryText = queryText;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -186,80 +117,6 @@ export default class AddressAutoComplete extends Vue {
|
|||||||
this.showmap = !this.showmap;
|
this.showmap = !this.showmap;
|
||||||
}
|
}
|
||||||
|
|
||||||
async reverseGeoCode(e: LatLng, zoom: number): Promise<void> {
|
|
||||||
// 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.addressData = result.data.reverseGeocode.map(
|
|
||||||
(address: IAddress) => new Address(address)
|
|
||||||
);
|
|
||||||
if (this.addressData.length > 0) {
|
|
||||||
const defaultAddress = new Address(this.addressData[0]);
|
|
||||||
this.selected = defaultAddress;
|
|
||||||
this.$emit("input", this.selected);
|
|
||||||
this.queryText = `${defaultAddress.poiInfos.name} ${defaultAddress.poiInfos.alternativeName}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
checkCurrentPosition(e: LatLng): boolean {
|
|
||||||
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.location = await AddressAutoComplete.getLocation();
|
|
||||||
this.mapDefaultZoom = 12;
|
|
||||||
this.reverseGeoCode(
|
|
||||||
new LatLng(
|
|
||||||
this.location.coords.latitude,
|
|
||||||
this.location.coords.longitude
|
|
||||||
),
|
|
||||||
12
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
this.gettingLocationError = e.message;
|
|
||||||
}
|
|
||||||
this.gettingLocation = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line no-undef
|
|
||||||
static async getLocation(): Promise<GeolocationPosition> {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line class-methods-use-this
|
|
||||||
get isSecureContext(): boolean {
|
|
||||||
return window.isSecureContext;
|
|
||||||
}
|
|
||||||
|
|
||||||
get canDoGeoLocation(): boolean {
|
get canDoGeoLocation(): boolean {
|
||||||
return this.isSecureContext && this.doGeoLocation;
|
return this.isSecureContext && this.doGeoLocation;
|
||||||
}
|
}
|
||||||
|
@ -12,7 +12,7 @@
|
|||||||
<h2 class="title">{{ event.title }}</h2>
|
<h2 class="title">{{ event.title }}</h2>
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
<div class="participation-actor has-text-grey">
|
<div class="participation-actor has-text-grey-dark">
|
||||||
<span v-if="event.physicalAddress && event.physicalAddress.locality">
|
<span v-if="event.physicalAddress && event.physicalAddress.locality">
|
||||||
{{ event.physicalAddress.locality }}
|
{{ event.physicalAddress.locality }}
|
||||||
</span>
|
</span>
|
||||||
|
@ -9,6 +9,7 @@
|
|||||||
:src="`/img/${metadataItem.icon.substring(8)}_monochrome.svg`"
|
:src="`/img/${metadataItem.icon.substring(8)}_monochrome.svg`"
|
||||||
width="24"
|
width="24"
|
||||||
height="24"
|
height="24"
|
||||||
|
alt=""
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<b-icon v-else-if="metadataItem.icon" :icon="metadataItem.icon" />
|
<b-icon v-else-if="metadataItem.icon" :icon="metadataItem.icon" />
|
||||||
|
@ -9,7 +9,11 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<b-field grouped :label="$t('Find or add an element')">
|
<b-field
|
||||||
|
grouped
|
||||||
|
:label="$t('Find or add an element')"
|
||||||
|
label-for="event-metadata-autocomplete"
|
||||||
|
>
|
||||||
<b-autocomplete
|
<b-autocomplete
|
||||||
expanded
|
expanded
|
||||||
v-model="search"
|
v-model="search"
|
||||||
@ -19,6 +23,7 @@
|
|||||||
group-options="items"
|
group-options="items"
|
||||||
open-on-focus
|
open-on-focus
|
||||||
:placeholder="$t('e.g. Accessibility, Twitch, PeerTube')"
|
:placeholder="$t('e.g. Accessibility, Twitch, PeerTube')"
|
||||||
|
id="event-metadata-autocomplete"
|
||||||
@select="(option) => addElement(option)"
|
@select="(option) => addElement(option)"
|
||||||
>
|
>
|
||||||
<template slot-scope="props">
|
<template slot-scope="props">
|
||||||
@ -32,6 +37,7 @@
|
|||||||
:src="`/img/${props.option.icon.substring(8)}_monochrome.svg`"
|
:src="`/img/${props.option.icon.substring(8)}_monochrome.svg`"
|
||||||
width="24"
|
width="24"
|
||||||
height="24"
|
height="24"
|
||||||
|
alt=""
|
||||||
/>
|
/>
|
||||||
<b-icon v-else-if="props.option.icon" :icon="props.option.icon" />
|
<b-icon v-else-if="props.option.icon" :icon="props.option.icon" />
|
||||||
<b-icon v-else icon="help-circle" />
|
<b-icon v-else icon="help-circle" />
|
||||||
|
@ -1,6 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="address-autocomplete">
|
<div class="address-autocomplete">
|
||||||
<b-field expanded>
|
<b-field
|
||||||
|
:label-for="id"
|
||||||
|
expanded
|
||||||
|
:message="fieldErrors"
|
||||||
|
:type="{ 'is-danger': fieldErrors.length }"
|
||||||
|
>
|
||||||
<template slot="label">
|
<template slot="label">
|
||||||
{{ actualLabel }}
|
{{ actualLabel }}
|
||||||
<b-button
|
<b-button
|
||||||
@ -8,8 +13,13 @@
|
|||||||
size="is-small"
|
size="is-small"
|
||||||
icon-right="map-marker"
|
icon-right="map-marker"
|
||||||
@click="locateMe"
|
@click="locateMe"
|
||||||
|
:title="$t('Use my location')"
|
||||||
/>
|
/>
|
||||||
<span v-else-if="gettingLocation">{{ $t("Getting location") }}</span>
|
<span
|
||||||
|
class="is-size-6 has-text-weight-normal"
|
||||||
|
v-else-if="gettingLocation"
|
||||||
|
>{{ $t("Getting location") }}</span
|
||||||
|
>
|
||||||
</template>
|
</template>
|
||||||
<b-autocomplete
|
<b-autocomplete
|
||||||
:data="addressData"
|
:data="addressData"
|
||||||
@ -21,6 +31,8 @@
|
|||||||
icon="map-marker"
|
icon="map-marker"
|
||||||
expanded
|
expanded
|
||||||
@select="updateSelected"
|
@select="updateSelected"
|
||||||
|
v-bind="$attrs"
|
||||||
|
:id="id"
|
||||||
>
|
>
|
||||||
<template #default="{ option }">
|
<template #default="{ option }">
|
||||||
<b-icon :icon="option.poiInfos.poiIcon.icon" />
|
<b-icon :icon="option.poiInfos.poiIcon.icon" />
|
||||||
@ -51,6 +63,7 @@
|
|||||||
@click="resetAddress"
|
@click="resetAddress"
|
||||||
class="reset-area"
|
class="reset-area"
|
||||||
icon-left="close"
|
icon-left="close"
|
||||||
|
:title="$t('Clear address field')"
|
||||||
/>
|
/>
|
||||||
</b-field>
|
</b-field>
|
||||||
<div class="map" v-if="selected && selected.geom && selected.poiInfos">
|
<div class="map" v-if="selected && selected.geom && selected.poiInfos">
|
||||||
@ -109,95 +122,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Component, Prop, Vue, Watch } from "vue-property-decorator";
|
import { Component, Prop, Watch, Mixins } from "vue-property-decorator";
|
||||||
import { LatLng } from "leaflet";
|
import { LatLng } from "leaflet";
|
||||||
import debounce from "lodash/debounce";
|
|
||||||
import { DebouncedFunc } from "lodash";
|
|
||||||
import { Address, IAddress } from "../../types/address.model";
|
import { Address, IAddress } from "../../types/address.model";
|
||||||
import { ADDRESS, REVERSE_GEOCODE } from "../../graphql/address";
|
import AddressAutoCompleteMixin from "../../mixins/AddressAutoCompleteMixin";
|
||||||
import { CONFIG } from "../../graphql/config";
|
|
||||||
import { IConfig } from "../../types/config.model";
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
components: {
|
inheritAttrs: false,
|
||||||
"map-leaflet": () =>
|
|
||||||
import(/* webpackChunkName: "map" */ "@/components/Map.vue"),
|
|
||||||
},
|
|
||||||
apollo: {
|
|
||||||
config: CONFIG,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
export default class FullAddressAutoComplete extends Vue {
|
export default class FullAddressAutoComplete extends Mixins(
|
||||||
@Prop({ required: true }) value!: IAddress;
|
AddressAutoCompleteMixin
|
||||||
|
) {
|
||||||
@Prop({ required: false, default: "" }) label!: string;
|
@Prop({ required: false, default: "" }) label!: string;
|
||||||
|
|
||||||
addressData: IAddress[] = [];
|
|
||||||
|
|
||||||
selected: IAddress = new Address();
|
|
||||||
|
|
||||||
isFetching = false;
|
|
||||||
|
|
||||||
queryText: string = (this.value && new Address(this.value).fullName) || "";
|
|
||||||
|
|
||||||
addressModalActive = false;
|
addressModalActive = false;
|
||||||
|
|
||||||
private gettingLocation = false;
|
private static componentId = 0;
|
||||||
|
|
||||||
// eslint-disable-next-line no-undef
|
created(): void {
|
||||||
private location!: GeolocationPosition;
|
FullAddressAutoComplete.componentId += 1;
|
||||||
|
|
||||||
private gettingLocationError: any;
|
|
||||||
|
|
||||||
private mapDefaultZoom = 15;
|
|
||||||
|
|
||||||
config!: IConfig;
|
|
||||||
|
|
||||||
fetchAsyncData!: DebouncedFunc<(query: string) => Promise<void>>;
|
|
||||||
|
|
||||||
// We put this in data because of issues like
|
|
||||||
// https://github.com/vuejs/vue-class-component/issues/263
|
|
||||||
data(): Record<string, unknown> {
|
|
||||||
return {
|
|
||||||
fetchAsyncData: debounce(this.asyncData, 200),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async asyncData(query: string): Promise<void> {
|
get id(): string {
|
||||||
if (!query.length) {
|
return `full-address-autocomplete-${FullAddressAutoComplete.componentId}`;
|
||||||
this.addressData = [];
|
|
||||||
this.selected = new Address();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (query.length < 3) {
|
|
||||||
this.addressData = [];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.isFetching = true;
|
|
||||||
const result = await this.$apollo.query({
|
|
||||||
query: ADDRESS,
|
|
||||||
fetchPolicy: "network-only",
|
|
||||||
variables: {
|
|
||||||
query,
|
|
||||||
locale: this.$i18n.locale,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
this.addressData = result.data.searchAddress.map(
|
|
||||||
(address: IAddress) => new Address(address)
|
|
||||||
);
|
|
||||||
this.isFetching = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Watch("config")
|
|
||||||
watchConfig(config: IConfig): void {
|
|
||||||
if (!config.geocoding.autocomplete) {
|
|
||||||
// If autocomplete is disabled, we put a larger debounce value
|
|
||||||
// so that we don't request with incomplete address
|
|
||||||
this.fetchAsyncData = debounce(this.asyncData, 2000);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Watch("value")
|
@Watch("value")
|
||||||
@ -225,30 +172,6 @@ export default class FullAddressAutoComplete extends Vue {
|
|||||||
this.addressModalActive = true;
|
this.addressModalActive = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async reverseGeoCode(e: LatLng, zoom: number): Promise<void> {
|
|
||||||
// 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.addressData = result.data.reverseGeocode.map(
|
|
||||||
(address: IAddress) => new Address(address)
|
|
||||||
);
|
|
||||||
if (this.addressData.length > 0) {
|
|
||||||
const defaultAddress = new Address(this.addressData[0]);
|
|
||||||
this.selected = defaultAddress;
|
|
||||||
this.$emit("input", this.selected);
|
|
||||||
this.queryText = `${defaultAddress.poiInfos.name} ${defaultAddress.poiInfos.alternativeName}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
checkCurrentPosition(e: LatLng): boolean {
|
checkCurrentPosition(e: LatLng): boolean {
|
||||||
if (!this.selected || !this.selected.geom) return false;
|
if (!this.selected || !this.selected.geom) return false;
|
||||||
const lat = parseFloat(this.selected.geom.split(";")[1]);
|
const lat = parseFloat(this.selected.geom.split(";")[1]);
|
||||||
@ -257,25 +180,6 @@ export default class FullAddressAutoComplete extends Vue {
|
|||||||
return e.lat === lat && e.lng === lon;
|
return e.lat === lat && e.lng === lon;
|
||||||
}
|
}
|
||||||
|
|
||||||
async locateMe(): Promise<void> {
|
|
||||||
this.gettingLocation = true;
|
|
||||||
try {
|
|
||||||
this.gettingLocation = false;
|
|
||||||
this.location = await FullAddressAutoComplete.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get actualLabel(): string {
|
get actualLabel(): string {
|
||||||
return this.label || (this.$t("Find an address") as string);
|
return this.label || (this.$t("Find an address") as string);
|
||||||
}
|
}
|
||||||
@ -285,24 +189,6 @@ export default class FullAddressAutoComplete extends Vue {
|
|||||||
return window.isSecureContext;
|
return window.isSecureContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line no-undef
|
|
||||||
static async getLocation(): Promise<GeolocationPosition> {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@Watch("queryText")
|
@Watch("queryText")
|
||||||
resetAddressOnEmptyField(queryText: string): void {
|
resetAddressOnEmptyField(queryText: string): void {
|
||||||
if (queryText === "" && this.selected?.id) {
|
if (queryText === "" && this.selected?.id) {
|
||||||
|
@ -12,14 +12,14 @@
|
|||||||
<img
|
<img
|
||||||
class="image is-rounded"
|
class="image is-rounded"
|
||||||
:src="selectedActor.avatar.url"
|
:src="selectedActor.avatar.url"
|
||||||
:alt="selectedActor.avatar.alt"
|
:alt="selectedActor.avatar.alt || ''"
|
||||||
/>
|
/>
|
||||||
</figure>
|
</figure>
|
||||||
<b-icon v-else size="is-large" icon="account-circle" />
|
<b-icon v-else size="is-large" icon="account-circle" />
|
||||||
</div>
|
</div>
|
||||||
<div class="media-content" v-if="selectedActor.name">
|
<div class="media-content" v-if="selectedActor.name">
|
||||||
<p class="is-4">{{ selectedActor.name }}</p>
|
<p class="is-4">{{ selectedActor.name }}</p>
|
||||||
<p class="is-6 has-text-grey">
|
<p class="is-6 has-text-grey-dark">
|
||||||
{{ `@${selectedActor.preferredUsername}` }}
|
{{ `@${selectedActor.preferredUsername}` }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<b-field>
|
<b-field :label-for="id">
|
||||||
<template slot="label">
|
<template slot="label">
|
||||||
{{ $t("Add some tags") }}
|
{{ $t("Add some tags") }}
|
||||||
<b-tooltip
|
<b-tooltip
|
||||||
@ -22,6 +22,7 @@
|
|||||||
maxtags="10"
|
maxtags="10"
|
||||||
:placeholder="$t('Eg: Stockholm, Dance, Chess…')"
|
:placeholder="$t('Eg: Stockholm, Dance, Chess…')"
|
||||||
@typing="getFilteredTags"
|
@typing="getFilteredTags"
|
||||||
|
:id="id"
|
||||||
>
|
>
|
||||||
</b-taginput>
|
</b-taginput>
|
||||||
</b-field>
|
</b-field>
|
||||||
@ -32,24 +33,7 @@ import get from "lodash/get";
|
|||||||
import differenceBy from "lodash/differenceBy";
|
import differenceBy from "lodash/differenceBy";
|
||||||
import { ITag } from "../../types/tag.model";
|
import { ITag } from "../../types/tag.model";
|
||||||
|
|
||||||
@Component({
|
@Component
|
||||||
computed: {
|
|
||||||
tagsStrings: {
|
|
||||||
get() {
|
|
||||||
return this.$props.value.map((tag: ITag) => tag.title);
|
|
||||||
},
|
|
||||||
set(tagStrings) {
|
|
||||||
const tagEntities = tagStrings.map((tag: string | ITag) => {
|
|
||||||
if (typeof tag !== "string") {
|
|
||||||
return tag;
|
|
||||||
}
|
|
||||||
return { title: tag, slug: tag } as ITag;
|
|
||||||
});
|
|
||||||
this.$emit("input", tagEntities);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
export default class TagInput extends Vue {
|
export default class TagInput extends Vue {
|
||||||
@Prop({ required: false, default: () => [] }) data!: ITag[];
|
@Prop({ required: false, default: () => [] }) data!: ITag[];
|
||||||
|
|
||||||
@ -59,6 +43,16 @@ export default class TagInput extends Vue {
|
|||||||
|
|
||||||
filteredTags: ITag[] = [];
|
filteredTags: ITag[] = [];
|
||||||
|
|
||||||
|
private static componentId = 0;
|
||||||
|
|
||||||
|
created(): void {
|
||||||
|
TagInput.componentId += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
get id(): string {
|
||||||
|
return `tag-input-${TagInput.componentId}`;
|
||||||
|
}
|
||||||
|
|
||||||
getFilteredTags(text: string): void {
|
getFilteredTags(text: string): void {
|
||||||
this.filteredTags = differenceBy(this.data, this.value, "id").filter(
|
this.filteredTags = differenceBy(this.data, this.value, "id").filter(
|
||||||
(option) =>
|
(option) =>
|
||||||
@ -68,5 +62,19 @@ export default class TagInput extends Vue {
|
|||||||
.indexOf(text.toLowerCase()) >= 0
|
.indexOf(text.toLowerCase()) >= 0
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get tagsStrings(): string[] {
|
||||||
|
return (this.value || []).map((tag: ITag) => tag.title);
|
||||||
|
}
|
||||||
|
|
||||||
|
set tagsStrings(tagsStrings: string[]) {
|
||||||
|
const tagEntities = tagsStrings.map((tag: string | ITag) => {
|
||||||
|
if (typeof tag !== "string") {
|
||||||
|
return tag;
|
||||||
|
}
|
||||||
|
return { title: tag, slug: tag } as ITag;
|
||||||
|
});
|
||||||
|
this.$emit("input", tagEntities);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
@ -20,6 +20,7 @@
|
|||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
<b-select
|
<b-select
|
||||||
|
:aria-label="$t('Language')"
|
||||||
v-if="$i18n"
|
v-if="$i18n"
|
||||||
v-model="locale"
|
v-model="locale"
|
||||||
:placeholder="$t('Select a language')"
|
:placeholder="$t('Select a language')"
|
||||||
|
@ -24,8 +24,8 @@
|
|||||||
},
|
},
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<h3>{{ member.parent.name }}</h3>
|
<h2>{{ member.parent.name }}</h2>
|
||||||
<p class="is-6 has-text-grey">
|
<p class="is-6 has-text-grey-dark">
|
||||||
<span v-if="member.parent.domain">{{
|
<span v-if="member.parent.domain">{{
|
||||||
`@${member.parent.preferredUsername}@${member.parent.domain}`
|
`@${member.parent.preferredUsername}@${member.parent.domain}`
|
||||||
}}</span>
|
}}</span>
|
||||||
|
@ -19,7 +19,10 @@
|
|||||||
:zoomInTitle="$t('Zoom in')"
|
:zoomInTitle="$t('Zoom in')"
|
||||||
:zoomOutTitle="$t('Zoom out')"
|
:zoomOutTitle="$t('Zoom out')"
|
||||||
></l-control-zoom>
|
></l-control-zoom>
|
||||||
<v-locatecontrol :options="{ icon: 'mdi mdi-map-marker' }" />
|
<v-locatecontrol
|
||||||
|
v-if="canDoGeoLocation"
|
||||||
|
:options="{ icon: 'mdi mdi-map-marker' }"
|
||||||
|
/>
|
||||||
<l-marker
|
<l-marker
|
||||||
:lat-lng="[lat, lon]"
|
:lat-lng="[lat, lon]"
|
||||||
@add="openPopup"
|
@add="openPopup"
|
||||||
@ -152,6 +155,10 @@ export default class Map extends Vue {
|
|||||||
(this.$t("© The OpenStreetMap Contributors") as string)
|
(this.$t("© The OpenStreetMap Contributors") as string)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get canDoGeoLocation(): boolean {
|
||||||
|
return window.isSecureContext;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
@ -22,13 +22,13 @@
|
|||||||
post.visibility === PostVisibility.PUBLIC &&
|
post.visibility === PostVisibility.PUBLIC &&
|
||||||
isCurrentActorMember
|
isCurrentActorMember
|
||||||
"
|
"
|
||||||
class="has-text-grey"
|
class="has-text-grey-dark"
|
||||||
>
|
>
|
||||||
<b-icon icon="earth" size="is-small" />{{ $t("Public") }}</small
|
<b-icon icon="earth" size="is-small" />{{ $t("Public") }}</small
|
||||||
>
|
>
|
||||||
<small
|
<small
|
||||||
v-else-if="post.visibility === PostVisibility.UNLISTED"
|
v-else-if="post.visibility === PostVisibility.UNLISTED"
|
||||||
class="has-text-grey"
|
class="has-text-grey-dark"
|
||||||
>
|
>
|
||||||
<b-icon icon="link" size="is-small" />{{
|
<b-icon icon="link" size="is-small" />{{
|
||||||
$t("Accessible through link")
|
$t("Accessible through link")
|
||||||
@ -36,7 +36,7 @@
|
|||||||
>
|
>
|
||||||
<small
|
<small
|
||||||
v-else-if="post.visibility === PostVisibility.PRIVATE"
|
v-else-if="post.visibility === PostVisibility.PRIVATE"
|
||||||
class="has-text-grey"
|
class="has-text-grey-dark"
|
||||||
>
|
>
|
||||||
<b-icon icon="lock" size="is-small" />{{
|
<b-icon icon="lock" size="is-small" />{{
|
||||||
$t("Accessible only to members", {
|
$t("Accessible only to members", {
|
||||||
@ -44,13 +44,13 @@
|
|||||||
})
|
})
|
||||||
}}</small
|
}}</small
|
||||||
>
|
>
|
||||||
<small class="has-text-grey">{{
|
<small class="has-text-grey-dark">{{
|
||||||
$options.filters.formatDateTimeString(
|
$options.filters.formatDateTimeString(
|
||||||
new Date(post.insertedAt),
|
new Date(post.insertedAt),
|
||||||
false
|
false
|
||||||
)
|
)
|
||||||
}}</small>
|
}}</small>
|
||||||
<small class="has-text-grey" v-if="isCurrentActorMember">{{
|
<small class="has-text-grey-dark" v-if="isCurrentActorMember">{{
|
||||||
$t("Created by {username}", {
|
$t("Created by {username}", {
|
||||||
username: `@${usernameWithDomain(post.author)}`,
|
username: `@${usernameWithDomain(post.author)}`,
|
||||||
})
|
})
|
||||||
|
@ -1142,5 +1142,35 @@
|
|||||||
"The Big Blue Button video teleconference URL": "The Big Blue Button video teleconference URL",
|
"The Big Blue Button video teleconference URL": "The Big Blue Button video teleconference URL",
|
||||||
"Etherpad notes": "Etherpad notes",
|
"Etherpad notes": "Etherpad notes",
|
||||||
"The URL of a pad where notes are being taken collaboratively": "The URL of a pad where notes are being taken collaboratively",
|
"The URL of a pad where notes are being taken collaboratively": "The URL of a pad where notes are being taken collaboratively",
|
||||||
"https://mensuel.framapad.org/p/some-secret-token": "https://mensuel.framapad.org/p/some-secret-token"
|
"https://mensuel.framapad.org/p/some-secret-token": "https://mensuel.framapad.org/p/some-secret-token",
|
||||||
|
"Failed to get location.": "Failed to get location.",
|
||||||
|
"The geolocation prompt was denied.": "The geolocation prompt was denied.",
|
||||||
|
"Your position was not available.": "Your position was not available.",
|
||||||
|
"Geolocation was not determined in time.": "Geolocation was not determined in time.",
|
||||||
|
"Underline": "Underline",
|
||||||
|
"Heading Level 1": "Heading Level 1",
|
||||||
|
"Heading Level 2": "Heading Level 2",
|
||||||
|
"Heading Level 3": "Heading Level 3",
|
||||||
|
"Add link": "Add link",
|
||||||
|
"Remove link": "Remove link",
|
||||||
|
"Add picture": "Add picture",
|
||||||
|
"Bullet list": "Bullet list",
|
||||||
|
"Ordered list": "Ordered list",
|
||||||
|
"Quote": "Quote",
|
||||||
|
"Undo": "Undo",
|
||||||
|
"Redo": "Redo",
|
||||||
|
"Clear address field": "Clear address field",
|
||||||
|
"Filter": "Filter",
|
||||||
|
"Choose the source of the instance's Terms": "Choose the source of the instance's Terms",
|
||||||
|
"Choose the source of the instance's Privacy Policy": "Choose the source of the instance's Privacy Policy",
|
||||||
|
"Update discussion title": "Update discussion title",
|
||||||
|
"Cancel discussion title edition": "Cancel discussion title edition",
|
||||||
|
"Previous month": "Previous month",
|
||||||
|
"When the event is private, you'll need to share the link around.": "When the event is private, you'll need to share the link around.",
|
||||||
|
"Decrease": "Decrease",
|
||||||
|
"Increase": "Increase",
|
||||||
|
"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"
|
||||||
}
|
}
|
||||||
|
@ -1233,5 +1233,35 @@
|
|||||||
"The Big Blue Button video teleconference URL": "L'URL de visio-conférence Big Blue Button",
|
"The Big Blue Button video teleconference URL": "L'URL de visio-conférence Big Blue Button",
|
||||||
"Etherpad notes": "Notes sur Etherpad",
|
"Etherpad notes": "Notes sur Etherpad",
|
||||||
"The URL of a pad where notes are being taken collaboratively": "L'URL d'un pad où les notes sont prises collaborativement",
|
"The URL of a pad where notes are being taken collaboratively": "L'URL d'un pad où les notes sont prises collaborativement",
|
||||||
"https://mensuel.framapad.org/p/some-secret-token": "https://mensuel.framapad.org/p/un-jeton-secret"
|
"https://mensuel.framapad.org/p/some-secret-token": "https://mensuel.framapad.org/p/un-jeton-secret",
|
||||||
|
"Failed to get location.": "Impossible de récupérer la localisation.",
|
||||||
|
"The geolocation prompt was denied.": "La demande de localisation a été refusée.",
|
||||||
|
"Your position was not available.": "Votre position n'était pas disponible.",
|
||||||
|
"Geolocation was not determined in time.": "La localisation n'a pas été déterminée à temps.",
|
||||||
|
"Underline": "Souligné",
|
||||||
|
"Heading Level 1": "Titre de niveau 1",
|
||||||
|
"Heading Level 2": "Titre de niveau 2",
|
||||||
|
"Heading Level 3": "Titre de niveau 3",
|
||||||
|
"Add link": "Ajouter un lien",
|
||||||
|
"Remove link": "Enlever un lien",
|
||||||
|
"Add picture": "Ajouter une image",
|
||||||
|
"Bullet list": "Liste à puce",
|
||||||
|
"Ordered list": "Liste ordonnée",
|
||||||
|
"Quote": "Citation",
|
||||||
|
"Undo": "Annuler",
|
||||||
|
"Redo": "Refaire",
|
||||||
|
"Clear address field": "Vider le champ addresse",
|
||||||
|
"Filter": "Filtrer",
|
||||||
|
"Choose the source of the instance's Terms": "Choisissez la source des conditions d'utilisation de l'instance",
|
||||||
|
"Choose the source of the instance's Privacy Policy": "Choisissez la source de la politique de confidentialité de l'instance",
|
||||||
|
"Update discussion title": "Mettre à jour le titre de la discussion",
|
||||||
|
"Cancel discussion title edition": "Annuler la mise à jour du titre de la discussion",
|
||||||
|
"Previous month": "Mois précédent",
|
||||||
|
"When the event is private, you'll need to share the link around.": "Lorsque l'événement est privé, vous aurez besoin de partager le lien.",
|
||||||
|
"Decrease": "Augmenter",
|
||||||
|
"Increase": "Baisser",
|
||||||
|
"Who can post a comment?": "Who can post a comment?",
|
||||||
|
"Does the event needs to be confirmed later or is it cancelled?": "Est-ce que l'événement doit être confirmé plus tard ou bien est-il annulé ?",
|
||||||
|
"When the post is private, you'll need to share the link around.": "Lorsque le billet est privé, vous aurez besoin de partager le lien.",
|
||||||
|
"Reset": "Remettre à zéro"
|
||||||
}
|
}
|
||||||
|
188
js/src/mixins/AddressAutoCompleteMixin.ts
Normal file
188
js/src/mixins/AddressAutoCompleteMixin.ts
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
import { mixins } from "vue-class-component";
|
||||||
|
import { Component, Prop, Vue, Watch } from "vue-property-decorator";
|
||||||
|
import { LatLng } from "leaflet";
|
||||||
|
import { Address, IAddress } from "../types/address.model";
|
||||||
|
import { ADDRESS, REVERSE_GEOCODE } from "../graphql/address";
|
||||||
|
import { CONFIG } from "../graphql/config";
|
||||||
|
import { IConfig } from "../types/config.model";
|
||||||
|
import debounce from "lodash/debounce";
|
||||||
|
import { DebouncedFunc } from "lodash";
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
components: {
|
||||||
|
"map-leaflet": () =>
|
||||||
|
import(/* webpackChunkName: "map" */ "@/components/Map.vue"),
|
||||||
|
},
|
||||||
|
apollo: {
|
||||||
|
config: CONFIG,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
export default class AddressAutoCompleteMixin extends mixins(Vue) {
|
||||||
|
@Prop({ required: true }) value!: IAddress;
|
||||||
|
gettingLocationError: string | null = null;
|
||||||
|
|
||||||
|
gettingLocation = false;
|
||||||
|
|
||||||
|
mapDefaultZoom = 15;
|
||||||
|
|
||||||
|
addressData: IAddress[] = [];
|
||||||
|
|
||||||
|
selected: IAddress = new Address();
|
||||||
|
|
||||||
|
queryText: string = (this.value && new Address(this.value).fullName) || "";
|
||||||
|
|
||||||
|
config!: IConfig;
|
||||||
|
|
||||||
|
isFetching = false;
|
||||||
|
|
||||||
|
fetchAsyncData!: DebouncedFunc<(query: string) => Promise<void>>;
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-undef
|
||||||
|
protected location!: GeolocationPosition;
|
||||||
|
|
||||||
|
// We put this in data because of issues like
|
||||||
|
// https://github.com/vuejs/vue-class-component/issues/263
|
||||||
|
data(): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
fetchAsyncData: debounce(this.asyncData, 200),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Watch("config")
|
||||||
|
watchConfig(config: IConfig): void {
|
||||||
|
if (!config.geocoding.autocomplete) {
|
||||||
|
// If autocomplete is disabled, we put a larger debounce value
|
||||||
|
// so that we don't request with incomplete address
|
||||||
|
this.fetchAsyncData = debounce(this.asyncData, 2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async asyncData(query: string): Promise<void> {
|
||||||
|
if (!query.length) {
|
||||||
|
this.addressData = [];
|
||||||
|
this.selected = new Address();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.length < 3) {
|
||||||
|
this.addressData = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isFetching = true;
|
||||||
|
const result = await this.$apollo.query({
|
||||||
|
query: ADDRESS,
|
||||||
|
fetchPolicy: "network-only",
|
||||||
|
variables: {
|
||||||
|
query,
|
||||||
|
locale: this.$i18n.locale,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.addressData = result.data.searchAddress.map(
|
||||||
|
(address: IAddress) => new Address(address)
|
||||||
|
);
|
||||||
|
this.isFetching = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async locateMe(): Promise<void> {
|
||||||
|
this.gettingLocation = true;
|
||||||
|
this.gettingLocationError = null;
|
||||||
|
try {
|
||||||
|
this.location = await this.getLocation();
|
||||||
|
this.mapDefaultZoom = 12;
|
||||||
|
this.reverseGeoCode(
|
||||||
|
new LatLng(
|
||||||
|
this.location.coords.latitude,
|
||||||
|
this.location.coords.longitude
|
||||||
|
),
|
||||||
|
12
|
||||||
|
);
|
||||||
|
} catch (e: any) {
|
||||||
|
this.gettingLocationError = e.message;
|
||||||
|
}
|
||||||
|
this.gettingLocation = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async reverseGeoCode(e: LatLng, zoom: number): Promise<void> {
|
||||||
|
// 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.addressData = result.data.reverseGeocode.map(
|
||||||
|
(address: IAddress) => new Address(address)
|
||||||
|
);
|
||||||
|
if (this.addressData.length > 0) {
|
||||||
|
const defaultAddress = new Address(this.addressData[0]);
|
||||||
|
this.selected = defaultAddress;
|
||||||
|
this.$emit("input", this.selected);
|
||||||
|
this.queryText = `${defaultAddress.poiInfos.name} ${defaultAddress.poiInfos.alternativeName}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkCurrentPosition(e: LatLng): boolean {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-undef
|
||||||
|
async getLocation(): Promise<GeolocationPosition> {
|
||||||
|
let errorMessage = this.$t("Failed to get location.");
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!("geolocation" in navigator)) {
|
||||||
|
reject(new Error(errorMessage as string));
|
||||||
|
}
|
||||||
|
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
(pos) => {
|
||||||
|
resolve(pos);
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
switch (err.code) {
|
||||||
|
// eslint-disable-next-line no-undef
|
||||||
|
case GeolocationPositionError.PERMISSION_DENIED:
|
||||||
|
errorMessage = this.$t("The geolocation prompt was denied.");
|
||||||
|
break;
|
||||||
|
// eslint-disable-next-line no-undef
|
||||||
|
case GeolocationPositionError.POSITION_UNAVAILABLE:
|
||||||
|
errorMessage = this.$t("Your position was not available.");
|
||||||
|
break;
|
||||||
|
// eslint-disable-next-line no-undef
|
||||||
|
case GeolocationPositionError.TIMEOUT:
|
||||||
|
errorMessage = this.$t("Geolocation was not determined in time.");
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
errorMessage = err.message;
|
||||||
|
}
|
||||||
|
reject(new Error(errorMessage as string));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get fieldErrors(): Array<Record<string, boolean>> {
|
||||||
|
const errors = [];
|
||||||
|
if (this.gettingLocationError) {
|
||||||
|
errors.push({
|
||||||
|
[this.gettingLocationError]: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line class-methods-use-this
|
||||||
|
get isSecureContext(): boolean {
|
||||||
|
return window.isSecureContext;
|
||||||
|
}
|
||||||
|
}
|
@ -29,9 +29,11 @@ export interface IUserSettings {
|
|||||||
location?: IUserPreferredLocation;
|
location?: IUserPreferredLocation;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type IActivitySettingMethod = "email" | "push";
|
||||||
|
|
||||||
export interface IActivitySetting {
|
export interface IActivitySetting {
|
||||||
key: string;
|
key: string;
|
||||||
method: string;
|
method: IActivitySettingMethod;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -54,7 +54,7 @@ $success-invert: findColorInvert($success);
|
|||||||
$info: #36bcd4;
|
$info: #36bcd4;
|
||||||
$info-invert: findColorInvert($info);
|
$info-invert: findColorInvert($info);
|
||||||
$danger: #ff2e54;
|
$danger: #ff2e54;
|
||||||
$danger-invert: #000;
|
$danger-invert: findColorInvert($danger);
|
||||||
$link: $primary;
|
$link: $primary;
|
||||||
$link-invert: $primary-invert;
|
$link-invert: $primary-invert;
|
||||||
$text: $violet-1;
|
$text: $violet-1;
|
||||||
|
@ -31,7 +31,7 @@
|
|||||||
$t("About this instance")
|
$t("About this instance")
|
||||||
}}</router-link>
|
}}</router-link>
|
||||||
</p>
|
</p>
|
||||||
<p class="menu-label">
|
<p class="menu-label has-text-grey-dark">
|
||||||
{{ $t("Legal") }}
|
{{ $t("Legal") }}
|
||||||
</p>
|
</p>
|
||||||
<ul class="menu-list">
|
<ul class="menu-list">
|
||||||
@ -64,8 +64,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- We hide the "Find an instance button until https://joinmobilizon.org gets a instance picker -->
|
<div
|
||||||
<div class="hero register is-primary is-medium">
|
class="hero register is-primary is-medium"
|
||||||
|
v-if="!currentUser || !currentUser.id"
|
||||||
|
>
|
||||||
<div class="hero-body">
|
<div class="hero-body">
|
||||||
<div class="container has-text-centered">
|
<div class="container has-text-centered">
|
||||||
<div class="columns">
|
<div class="columns">
|
||||||
@ -101,12 +103,13 @@ import { Component, Vue } from "vue-property-decorator";
|
|||||||
import { CONFIG } from "@/graphql/config";
|
import { CONFIG } from "@/graphql/config";
|
||||||
import { IConfig } from "@/types/config.model";
|
import { IConfig } from "@/types/config.model";
|
||||||
import RouteName from "../router/name";
|
import RouteName from "../router/name";
|
||||||
|
import { CURRENT_USER_CLIENT } from "@/graphql/user";
|
||||||
|
import { ICurrentUser } from "@/types/current-user.model";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
apollo: {
|
apollo: {
|
||||||
config: {
|
config: CONFIG,
|
||||||
query: CONFIG,
|
currentUser: CURRENT_USER_CLIENT,
|
||||||
},
|
|
||||||
},
|
},
|
||||||
metaInfo() {
|
metaInfo() {
|
||||||
return {
|
return {
|
||||||
@ -120,6 +123,7 @@ import RouteName from "../router/name";
|
|||||||
})
|
})
|
||||||
export default class About extends Vue {
|
export default class About extends Vue {
|
||||||
config!: IConfig;
|
config!: IConfig;
|
||||||
|
currentUser!: ICurrentUser;
|
||||||
|
|
||||||
RouteName = RouteName;
|
RouteName = RouteName;
|
||||||
}
|
}
|
||||||
|
@ -114,7 +114,6 @@ import { ABOUT } from "../../graphql/config";
|
|||||||
import { STATISTICS } from "../../graphql/statistics";
|
import { STATISTICS } from "../../graphql/statistics";
|
||||||
import { IConfig } from "../../types/config.model";
|
import { IConfig } from "../../types/config.model";
|
||||||
import { IStatistics } from "../../types/statistics.model";
|
import { IStatistics } from "../../types/statistics.model";
|
||||||
import langs from "../../i18n/langs.json";
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
apollo: {
|
apollo: {
|
||||||
@ -159,12 +158,6 @@ export default class AboutInstance extends Vue {
|
|||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line class-methods-use-this
|
|
||||||
getLanguageNameForCode(code: string): string {
|
|
||||||
const languageMaps = langs as Record<string, any>;
|
|
||||||
return languageMaps[code];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
@ -36,12 +36,17 @@
|
|||||||
class="picture-upload"
|
class="picture-upload"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<b-field horizontal :label="$t('Display name')">
|
<b-field
|
||||||
|
horizontal
|
||||||
|
:label="$t('Display name')"
|
||||||
|
label-for="identity-display-name"
|
||||||
|
>
|
||||||
<b-input
|
<b-input
|
||||||
aria-required="true"
|
aria-required="true"
|
||||||
required
|
required
|
||||||
v-model="identity.name"
|
v-model="identity.name"
|
||||||
@input="autoUpdateUsername($event)"
|
@input="autoUpdateUsername($event)"
|
||||||
|
id="identity-display-name"
|
||||||
/>
|
/>
|
||||||
</b-field>
|
</b-field>
|
||||||
|
|
||||||
@ -50,6 +55,7 @@
|
|||||||
custom-class="username-field"
|
custom-class="username-field"
|
||||||
expanded
|
expanded
|
||||||
:label="$t('Username')"
|
:label="$t('Username')"
|
||||||
|
label-for="identity-username"
|
||||||
:message="message"
|
:message="message"
|
||||||
>
|
>
|
||||||
<b-field expanded>
|
<b-field expanded>
|
||||||
@ -60,6 +66,7 @@
|
|||||||
:disabled="isUpdate"
|
:disabled="isUpdate"
|
||||||
:use-html5-validation="!isUpdate"
|
:use-html5-validation="!isUpdate"
|
||||||
pattern="[a-z0-9_]+"
|
pattern="[a-z0-9_]+"
|
||||||
|
id="identity-username"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<p class="control">
|
<p class="control">
|
||||||
@ -68,11 +75,16 @@
|
|||||||
</b-field>
|
</b-field>
|
||||||
</b-field>
|
</b-field>
|
||||||
|
|
||||||
<b-field horizontal :label="$t('Description')">
|
<b-field
|
||||||
|
horizontal
|
||||||
|
:label="$t('Description')"
|
||||||
|
label-for="identity-summary"
|
||||||
|
>
|
||||||
<b-input
|
<b-input
|
||||||
type="textarea"
|
type="textarea"
|
||||||
aria-required="false"
|
aria-required="false"
|
||||||
v-model="identity.summary"
|
v-model="identity.summary"
|
||||||
|
id="identity-summary"
|
||||||
/>
|
/>
|
||||||
</b-field>
|
</b-field>
|
||||||
|
|
||||||
@ -94,11 +106,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</b-field>
|
</b-field>
|
||||||
|
|
||||||
<div class="delete-identity" v-if="isUpdate">
|
<b-field class="delete-identity">
|
||||||
<span @click="openDeleteIdentityConfirmation()">{{
|
<b-button
|
||||||
$t("Delete this identity")
|
v-if="isUpdate"
|
||||||
}}</span>
|
@click="openDeleteIdentityConfirmation()"
|
||||||
</div>
|
type="is-text"
|
||||||
|
>
|
||||||
|
{{ $t("Delete this identity") }}
|
||||||
|
</b-button>
|
||||||
|
</b-field>
|
||||||
|
|
||||||
<section v-if="isUpdate">
|
<section v-if="isUpdate">
|
||||||
<div class="setting-title">
|
<div class="setting-title">
|
||||||
@ -194,12 +210,6 @@ h1 {
|
|||||||
margin: 30px 0;
|
margin: 30px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.delete-identity {
|
|
||||||
text-decoration: underline;
|
|
||||||
cursor: pointer;
|
|
||||||
margin-top: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.username-field + .field {
|
.username-field + .field {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
@ -41,10 +41,10 @@
|
|||||||
>
|
>
|
||||||
<template #searchable="props">
|
<template #searchable="props">
|
||||||
<b-input
|
<b-input
|
||||||
|
:aria-label="$t('Filter')"
|
||||||
v-model="props.filters.preferredUsername"
|
v-model="props.filters.preferredUsername"
|
||||||
placeholder="Search..."
|
:placeholder="$t('Filter')"
|
||||||
icon="magnify"
|
icon="magnify"
|
||||||
size="is-small"
|
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template v-slot:default="props">
|
<template v-slot:default="props">
|
||||||
@ -58,7 +58,10 @@
|
|||||||
<article class="media">
|
<article class="media">
|
||||||
<figure class="media-left" v-if="props.row.avatar">
|
<figure class="media-left" v-if="props.row.avatar">
|
||||||
<p class="image is-48x48">
|
<p class="image is-48x48">
|
||||||
<img :src="props.row.avatar.url" />
|
<img
|
||||||
|
:src="props.row.avatar.url"
|
||||||
|
:alt="props.row.avatar.alt || ''"
|
||||||
|
/>
|
||||||
</p>
|
</p>
|
||||||
</figure>
|
</figure>
|
||||||
<div class="media-content">
|
<div class="media-content">
|
||||||
@ -76,10 +79,10 @@
|
|||||||
<b-table-column field="domain" :label="$t('Domain')" searchable>
|
<b-table-column field="domain" :label="$t('Domain')" searchable>
|
||||||
<template #searchable="props">
|
<template #searchable="props">
|
||||||
<b-input
|
<b-input
|
||||||
|
:aria-label="$t('Filter')"
|
||||||
v-model="props.filters.domain"
|
v-model="props.filters.domain"
|
||||||
placeholder="Search..."
|
:placeholder="$t('Filter')"
|
||||||
icon="magnify"
|
icon="magnify"
|
||||||
size="is-small"
|
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template v-slot:default="props">
|
<template v-slot:default="props">
|
||||||
|
@ -42,9 +42,9 @@
|
|||||||
<template #searchable="props">
|
<template #searchable="props">
|
||||||
<b-input
|
<b-input
|
||||||
v-model="props.filters.preferredUsername"
|
v-model="props.filters.preferredUsername"
|
||||||
placeholder="Search..."
|
:aria-label="$t('Filter')"
|
||||||
|
:placeholder="$t('Filter')"
|
||||||
icon="magnify"
|
icon="magnify"
|
||||||
size="is-small"
|
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template v-slot:default="props">
|
<template v-slot:default="props">
|
||||||
@ -58,7 +58,10 @@
|
|||||||
<article class="media">
|
<article class="media">
|
||||||
<figure class="media-left" v-if="props.row.avatar">
|
<figure class="media-left" v-if="props.row.avatar">
|
||||||
<p class="image is-48x48">
|
<p class="image is-48x48">
|
||||||
<img :src="props.row.avatar.url" />
|
<img
|
||||||
|
:src="props.row.avatar.url"
|
||||||
|
:alt="props.row.avatar.alt || ''"
|
||||||
|
/>
|
||||||
</p>
|
</p>
|
||||||
</figure>
|
</figure>
|
||||||
<div class="media-content">
|
<div class="media-content">
|
||||||
@ -77,9 +80,9 @@
|
|||||||
<template #searchable="props">
|
<template #searchable="props">
|
||||||
<b-input
|
<b-input
|
||||||
v-model="props.filters.domain"
|
v-model="props.filters.domain"
|
||||||
placeholder="Search..."
|
:aria-label="$t('Filter')"
|
||||||
|
:placeholder="$t('Filter')"
|
||||||
icon="magnify"
|
icon="magnify"
|
||||||
size="is-small"
|
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template v-slot:default="props">
|
<template v-slot:default="props">
|
||||||
|
@ -16,11 +16,11 @@
|
|||||||
</nav>
|
</nav>
|
||||||
<section v-if="adminSettings">
|
<section v-if="adminSettings">
|
||||||
<form @submit.prevent="updateSettings">
|
<form @submit.prevent="updateSettings">
|
||||||
<b-field :label="$t('Instance Name')">
|
<b-field :label="$t('Instance Name')" label-for="instance-name">
|
||||||
<b-input v-model="adminSettings.instanceName" />
|
<b-input v-model="adminSettings.instanceName" id="instance-name" />
|
||||||
</b-field>
|
</b-field>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label has-help">{{
|
<label class="label has-help" for="instance-description">{{
|
||||||
$t("Instance Short Description")
|
$t("Instance Short Description")
|
||||||
}}</label>
|
}}</label>
|
||||||
<small>
|
<small>
|
||||||
@ -34,10 +34,13 @@
|
|||||||
type="textarea"
|
type="textarea"
|
||||||
v-model="adminSettings.instanceDescription"
|
v-model="adminSettings.instanceDescription"
|
||||||
rows="2"
|
rows="2"
|
||||||
|
id="instance-name"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label has-help">{{ $t("Instance Slogan") }}</label>
|
<label class="label has-help" for="instance-slogan">{{
|
||||||
|
$t("Instance Slogan")
|
||||||
|
}}</label>
|
||||||
<small>
|
<small>
|
||||||
{{
|
{{
|
||||||
$t(
|
$t(
|
||||||
@ -48,14 +51,17 @@
|
|||||||
<b-input
|
<b-input
|
||||||
v-model="adminSettings.instanceSlogan"
|
v-model="adminSettings.instanceSlogan"
|
||||||
:placeholder="$t('Gather ⋅ Organize ⋅ Mobilize')"
|
:placeholder="$t('Gather ⋅ Organize ⋅ Mobilize')"
|
||||||
|
id="instance-slogan"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label has-help">{{ $t("Contact") }}</label>
|
<label class="label has-help" for="instance-contact">{{
|
||||||
|
$t("Contact")
|
||||||
|
}}</label>
|
||||||
<small>
|
<small>
|
||||||
{{ $t("Can be an email or a link, or just plain text.") }}
|
{{ $t("Can be an email or a link, or just plain text.") }}
|
||||||
</small>
|
</small>
|
||||||
<b-input v-model="adminSettings.contact" />
|
<b-input v-model="adminSettings.contact" id="instance-contact" />
|
||||||
</div>
|
</div>
|
||||||
<b-field :label="$t('Allow registrations')">
|
<b-field :label="$t('Allow registrations')">
|
||||||
<b-switch v-model="adminSettings.registrationsOpen">
|
<b-switch v-model="adminSettings.registrationsOpen">
|
||||||
@ -66,7 +72,9 @@
|
|||||||
</b-switch>
|
</b-switch>
|
||||||
</b-field>
|
</b-field>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label has-help">{{ $t("Instance languages") }}</label>
|
<label class="label has-help" for="instance-languages">{{
|
||||||
|
$t("Instance languages")
|
||||||
|
}}</label>
|
||||||
<small>
|
<small>
|
||||||
{{ $t("Main languages you/your moderators speak") }}
|
{{ $t("Main languages you/your moderators speak") }}
|
||||||
</small>
|
</small>
|
||||||
@ -79,12 +87,13 @@
|
|||||||
icon="label"
|
icon="label"
|
||||||
:placeholder="$t('Select languages')"
|
:placeholder="$t('Select languages')"
|
||||||
@typing="getFilteredLanguages"
|
@typing="getFilteredLanguages"
|
||||||
|
id="instance-languages"
|
||||||
>
|
>
|
||||||
<template slot="empty">{{ $t("No languages found") }}</template>
|
<template slot="empty">{{ $t("No languages found") }}</template>
|
||||||
</b-taginput>
|
</b-taginput>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label has-help">{{
|
<label class="label has-help" for="instance-long-description">{{
|
||||||
$t("Instance Long Description")
|
$t("Instance Long Description")
|
||||||
}}</label>
|
}}</label>
|
||||||
<small>
|
<small>
|
||||||
@ -98,10 +107,13 @@
|
|||||||
type="textarea"
|
type="textarea"
|
||||||
v-model="adminSettings.instanceLongDescription"
|
v-model="adminSettings.instanceLongDescription"
|
||||||
rows="4"
|
rows="4"
|
||||||
|
id="instance-long-description"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label has-help">{{ $t("Instance Rules") }}</label>
|
<label class="label has-help" for="instance-rules">{{
|
||||||
|
$t("Instance Rules")
|
||||||
|
}}</label>
|
||||||
<small>
|
<small>
|
||||||
{{
|
{{
|
||||||
$t(
|
$t(
|
||||||
@ -109,35 +121,44 @@
|
|||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</small>
|
</small>
|
||||||
<b-input type="textarea" v-model="adminSettings.instanceRules" />
|
<b-input
|
||||||
|
type="textarea"
|
||||||
|
v-model="adminSettings.instanceRules"
|
||||||
|
id="instance-rules"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<b-field :label="$t('Instance Terms Source')">
|
<b-field :label="$t('Instance Terms Source')">
|
||||||
<div class="columns">
|
<div class="columns">
|
||||||
<div class="column is-one-third-desktop">
|
<div class="column is-one-third-desktop">
|
||||||
<b-field>
|
<fieldset>
|
||||||
<b-radio
|
<legend>
|
||||||
v-model="adminSettings.instanceTermsType"
|
{{ $t("Choose the source of the instance's Terms") }}
|
||||||
name="instanceTermsType"
|
</legend>
|
||||||
:native-value="InstanceTermsType.DEFAULT"
|
<b-field>
|
||||||
>{{ $t("Default Mobilizon terms") }}</b-radio
|
<b-radio
|
||||||
>
|
v-model="adminSettings.instanceTermsType"
|
||||||
</b-field>
|
name="instanceTermsType"
|
||||||
<b-field>
|
:native-value="InstanceTermsType.DEFAULT"
|
||||||
<b-radio
|
>{{ $t("Default Mobilizon terms") }}</b-radio
|
||||||
v-model="adminSettings.instanceTermsType"
|
>
|
||||||
name="instanceTermsType"
|
</b-field>
|
||||||
:native-value="InstanceTermsType.URL"
|
<b-field>
|
||||||
>{{ $t("Custom URL") }}</b-radio
|
<b-radio
|
||||||
>
|
v-model="adminSettings.instanceTermsType"
|
||||||
</b-field>
|
name="instanceTermsType"
|
||||||
<b-field>
|
:native-value="InstanceTermsType.URL"
|
||||||
<b-radio
|
>{{ $t("Custom URL") }}</b-radio
|
||||||
v-model="adminSettings.instanceTermsType"
|
>
|
||||||
name="instanceTermsType"
|
</b-field>
|
||||||
:native-value="InstanceTermsType.CUSTOM"
|
<b-field>
|
||||||
>{{ $t("Custom text") }}</b-radio
|
<b-radio
|
||||||
>
|
v-model="adminSettings.instanceTermsType"
|
||||||
</b-field>
|
name="instanceTermsType"
|
||||||
|
:native-value="InstanceTermsType.CUSTOM"
|
||||||
|
>{{ $t("Custom text") }}</b-radio
|
||||||
|
>
|
||||||
|
</b-field>
|
||||||
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
<div class="column">
|
<div class="column">
|
||||||
<div
|
<div
|
||||||
@ -215,30 +236,35 @@
|
|||||||
<b-field :label="$t('Instance Privacy Policy Source')">
|
<b-field :label="$t('Instance Privacy Policy Source')">
|
||||||
<div class="columns">
|
<div class="columns">
|
||||||
<div class="column is-one-third-desktop">
|
<div class="column is-one-third-desktop">
|
||||||
<b-field>
|
<fieldset>
|
||||||
<b-radio
|
<legend>
|
||||||
v-model="adminSettings.instancePrivacyPolicyType"
|
{{ $t("Choose the source of the instance's Privacy Policy") }}
|
||||||
name="instancePrivacyType"
|
</legend>
|
||||||
:native-value="InstancePrivacyType.DEFAULT"
|
<b-field>
|
||||||
>{{ $t("Default Mobilizon privacy policy") }}</b-radio
|
<b-radio
|
||||||
>
|
v-model="adminSettings.instancePrivacyPolicyType"
|
||||||
</b-field>
|
name="instancePrivacyType"
|
||||||
<b-field>
|
:native-value="InstancePrivacyType.DEFAULT"
|
||||||
<b-radio
|
>{{ $t("Default Mobilizon privacy policy") }}</b-radio
|
||||||
v-model="adminSettings.instancePrivacyPolicyType"
|
>
|
||||||
name="instancePrivacyType"
|
</b-field>
|
||||||
:native-value="InstancePrivacyType.URL"
|
<b-field>
|
||||||
>{{ $t("Custom URL") }}</b-radio
|
<b-radio
|
||||||
>
|
v-model="adminSettings.instancePrivacyPolicyType"
|
||||||
</b-field>
|
name="instancePrivacyType"
|
||||||
<b-field>
|
:native-value="InstancePrivacyType.URL"
|
||||||
<b-radio
|
>{{ $t("Custom URL") }}</b-radio
|
||||||
v-model="adminSettings.instancePrivacyPolicyType"
|
>
|
||||||
name="instancePrivacyType"
|
</b-field>
|
||||||
:native-value="InstancePrivacyType.CUSTOM"
|
<b-field>
|
||||||
>{{ $t("Custom text") }}</b-radio
|
<b-radio
|
||||||
>
|
v-model="adminSettings.instancePrivacyPolicyType"
|
||||||
</b-field>
|
name="instancePrivacyType"
|
||||||
|
:native-value="InstancePrivacyType.CUSTOM"
|
||||||
|
>{{ $t("Custom text") }}</b-radio
|
||||||
|
>
|
||||||
|
</b-field>
|
||||||
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
<div class="column">
|
<div class="column">
|
||||||
<div
|
<div
|
||||||
@ -430,13 +456,6 @@ export default class Settings extends Vue {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.notification a {
|
|
||||||
color: $primary !important;
|
|
||||||
text-decoration: underline !important;
|
|
||||||
text-decoration-color: #fea72b !important;
|
|
||||||
text-decoration-thickness: 2px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
label.label.has-help {
|
label.label.has-help {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
@ -41,9 +41,9 @@
|
|||||||
<template #searchable="props">
|
<template #searchable="props">
|
||||||
<b-input
|
<b-input
|
||||||
v-model="props.filters.email"
|
v-model="props.filters.email"
|
||||||
:placeholder="$t('Search…')"
|
:aria-label="$t('Filter')"
|
||||||
|
:placeholder="$t('Filter')"
|
||||||
icon="magnify"
|
icon="magnify"
|
||||||
size="is-small"
|
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template v-slot:default="props">
|
<template v-slot:default="props">
|
||||||
@ -78,7 +78,7 @@
|
|||||||
:centered="true"
|
:centered="true"
|
||||||
v-slot="props"
|
v-slot="props"
|
||||||
>
|
>
|
||||||
{{ props.row.locale }}
|
{{ getLanguageNameForCode(props.row.locale) }}
|
||||||
</b-table-column>
|
</b-table-column>
|
||||||
|
|
||||||
<template #detail="props">
|
<template #detail="props">
|
||||||
@ -114,6 +114,9 @@ import { Component, Vue } from "vue-property-decorator";
|
|||||||
import { LIST_USERS } from "../../graphql/user";
|
import { LIST_USERS } from "../../graphql/user";
|
||||||
import RouteName from "../../router/name";
|
import RouteName from "../../router/name";
|
||||||
import VueRouter from "vue-router";
|
import VueRouter from "vue-router";
|
||||||
|
import { LANGUAGES_CODES } from "@/graphql/admin";
|
||||||
|
import { IUser } from "@/types/current-user.model";
|
||||||
|
import { Paginate } from "@/types/paginate";
|
||||||
const { isNavigationFailure, NavigationFailureType } = VueRouter;
|
const { isNavigationFailure, NavigationFailureType } = VueRouter;
|
||||||
|
|
||||||
const USERS_PER_PAGE = 10;
|
const USERS_PER_PAGE = 10;
|
||||||
@ -131,6 +134,17 @@ const USERS_PER_PAGE = 10;
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
languages: {
|
||||||
|
query: LANGUAGES_CODES,
|
||||||
|
variables() {
|
||||||
|
return {
|
||||||
|
codes: this.languagesCodes,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
skip() {
|
||||||
|
return this.languagesCodes.length < 1;
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
metaInfo() {
|
metaInfo() {
|
||||||
return {
|
return {
|
||||||
@ -143,6 +157,9 @@ export default class Users extends Vue {
|
|||||||
|
|
||||||
RouteName = RouteName;
|
RouteName = RouteName;
|
||||||
|
|
||||||
|
users!: Paginate<IUser>;
|
||||||
|
languages!: Array<{ code: string; name: string }>;
|
||||||
|
|
||||||
get page(): number {
|
get page(): number {
|
||||||
return parseInt((this.$route.query.page as string) || "1", 10);
|
return parseInt((this.$route.query.page as string) || "1", 10);
|
||||||
}
|
}
|
||||||
@ -159,6 +176,18 @@ export default class Users extends Vue {
|
|||||||
this.pushRouter({ email });
|
this.pushRouter({ email });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get languagesCodes(): string[] {
|
||||||
|
return (this.users?.elements || []).map((user: IUser) => user.locale);
|
||||||
|
}
|
||||||
|
|
||||||
|
getLanguageNameForCode(code: string): string {
|
||||||
|
return (
|
||||||
|
(this.languages || []).find(({ code: languageCode }) => {
|
||||||
|
return languageCode === code;
|
||||||
|
})?.name || code
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async onPageChange(page: number): Promise<void> {
|
async onPageChange(page: number): Promise<void> {
|
||||||
this.page = page;
|
this.page = page;
|
||||||
await this.$apollo.queries.users.fetchMore({
|
await this.$apollo.queries.users.fetchMore({
|
||||||
|
@ -1,14 +1,57 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="section container">
|
<section class="section container">
|
||||||
<h1>{{ $t("Create a discussion") }}</h1>
|
<nav class="breadcrumb" aria-label="breadcrumbs">
|
||||||
|
<ul v-if="group">
|
||||||
|
<li>
|
||||||
|
<router-link :to="{ name: RouteName.MY_GROUPS }">{{
|
||||||
|
$t("My groups")
|
||||||
|
}}</router-link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<router-link
|
||||||
|
:to="{
|
||||||
|
name: RouteName.GROUP,
|
||||||
|
params: { preferredUsername: usernameWithDomain(group) },
|
||||||
|
}"
|
||||||
|
>{{ group.name }}</router-link
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<router-link
|
||||||
|
:to="{
|
||||||
|
name: RouteName.DISCUSSION_LIST,
|
||||||
|
params: { preferredUsername: usernameWithDomain(group) },
|
||||||
|
}"
|
||||||
|
>{{ $t("Discussions") }}</router-link
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li class="is-active">
|
||||||
|
<router-link
|
||||||
|
:to="{
|
||||||
|
name: RouteName.CREATE_DISCUSSION,
|
||||||
|
params: { preferredUsername: usernameWithDomain(group) },
|
||||||
|
}"
|
||||||
|
>{{ $t("Create") }}</router-link
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<b-skeleton v-else-if="$apollo.loading" :animated="animated"></b-skeleton>
|
||||||
|
</nav>
|
||||||
|
<h1 class="title">{{ $t("Create a discussion") }}</h1>
|
||||||
|
|
||||||
<form @submit.prevent="createDiscussion">
|
<form @submit.prevent="createDiscussion">
|
||||||
<b-field
|
<b-field
|
||||||
:label="$t('Title')"
|
:label="$t('Title')"
|
||||||
|
label-for="discussion-title"
|
||||||
:message="errors.title"
|
:message="errors.title"
|
||||||
:type="errors.title ? 'is-danger' : undefined"
|
:type="errors.title ? 'is-danger' : undefined"
|
||||||
>
|
>
|
||||||
<b-input aria-required="true" required v-model="discussion.title" />
|
<b-input
|
||||||
|
aria-required="true"
|
||||||
|
required
|
||||||
|
v-model="discussion.title"
|
||||||
|
id="discussion-title"
|
||||||
|
/>
|
||||||
</b-field>
|
</b-field>
|
||||||
|
|
||||||
<b-field :label="$t('Text')">
|
<b-field :label="$t('Text')">
|
||||||
@ -24,7 +67,7 @@
|
|||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Component, Prop, Vue } from "vue-property-decorator";
|
import { Component, Prop, Vue } from "vue-property-decorator";
|
||||||
import { IGroup, IPerson } from "@/types/actor";
|
import { IGroup, IPerson, usernameWithDomain } from "@/types/actor";
|
||||||
import { CURRENT_ACTOR_CLIENT } from "@/graphql/actor";
|
import { CURRENT_ACTOR_CLIENT } from "@/graphql/actor";
|
||||||
import { FETCH_GROUP } from "@/graphql/group";
|
import { FETCH_GROUP } from "@/graphql/group";
|
||||||
import { CREATE_DISCUSSION } from "@/graphql/discussion";
|
import { CREATE_DISCUSSION } from "@/graphql/discussion";
|
||||||
@ -66,6 +109,10 @@ export default class CreateDiscussion extends Vue {
|
|||||||
|
|
||||||
errors = { title: "" };
|
errors = { title: "" };
|
||||||
|
|
||||||
|
RouteName = RouteName;
|
||||||
|
|
||||||
|
usernameWithDomain = usernameWithDomain;
|
||||||
|
|
||||||
async createDiscussion(): Promise<void> {
|
async createDiscussion(): Promise<void> {
|
||||||
this.errors = { title: "" };
|
this.errors = { title: "" };
|
||||||
try {
|
try {
|
||||||
@ -100,7 +147,11 @@ export default class CreateDiscussion extends Vue {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style lang="scss" scoped>
|
||||||
|
.container.section {
|
||||||
|
background: $white;
|
||||||
|
}
|
||||||
|
|
||||||
.markdown-render h1 {
|
.markdown-render h1 {
|
||||||
font-size: 2em;
|
font-size: 2em;
|
||||||
}
|
}
|
||||||
|
@ -46,23 +46,27 @@
|
|||||||
</b-message>
|
</b-message>
|
||||||
<section>
|
<section>
|
||||||
<div class="discussion-title">
|
<div class="discussion-title">
|
||||||
<h2 class="title" v-if="discussion.title && !editTitleMode">
|
<h1 class="title" v-if="discussion.title && !editTitleMode">
|
||||||
{{ discussion.title }}
|
{{ discussion.title }}
|
||||||
<span
|
</h1>
|
||||||
v-if="
|
<b-button
|
||||||
currentActor.id === discussion.creator.id ||
|
icon-right="pencil"
|
||||||
isCurrentActorAGroupModerator
|
size="is-small"
|
||||||
"
|
:title="$t('Update discussion title')"
|
||||||
@click="
|
v-if="
|
||||||
() => {
|
discussion.creator &&
|
||||||
newTitle = discussion.title;
|
!editTitleMode &&
|
||||||
editTitleMode = true;
|
(currentActor.id === discussion.creator.id ||
|
||||||
}
|
isCurrentActorAGroupModerator)
|
||||||
"
|
"
|
||||||
>
|
@click="
|
||||||
<b-icon icon="pencil" />
|
() => {
|
||||||
</span>
|
newTitle = discussion.title;
|
||||||
</h2>
|
editTitleMode = true;
|
||||||
|
}
|
||||||
|
"
|
||||||
|
>
|
||||||
|
</b-button>
|
||||||
<b-skeleton
|
<b-skeleton
|
||||||
v-else-if="!editTitleMode && $apollo.loading"
|
v-else-if="!editTitleMode && $apollo.loading"
|
||||||
height="50px"
|
height="50px"
|
||||||
@ -73,12 +77,19 @@
|
|||||||
@submit.prevent="updateDiscussion"
|
@submit.prevent="updateDiscussion"
|
||||||
class="title-edit"
|
class="title-edit"
|
||||||
>
|
>
|
||||||
<b-input :value="discussion.title" v-model="newTitle" />
|
<b-field :label="$t('Title')" label-for="discussion-title">
|
||||||
|
<b-input
|
||||||
|
:value="discussion.title"
|
||||||
|
v-model="newTitle"
|
||||||
|
id="discussion-title"
|
||||||
|
/>
|
||||||
|
</b-field>
|
||||||
<div class="buttons">
|
<div class="buttons">
|
||||||
<b-button
|
<b-button
|
||||||
type="is-primary"
|
type="is-primary"
|
||||||
native-type="submit"
|
native-type="submit"
|
||||||
icon-right="check"
|
icon-right="check"
|
||||||
|
:title="$t('Update discussion title')"
|
||||||
/>
|
/>
|
||||||
<b-button
|
<b-button
|
||||||
@click="
|
@click="
|
||||||
@ -88,6 +99,7 @@
|
|||||||
}
|
}
|
||||||
"
|
"
|
||||||
icon-right="close"
|
icon-right="close"
|
||||||
|
:title="$t('Cancel discussion title edition')"
|
||||||
/>
|
/>
|
||||||
<b-button
|
<b-button
|
||||||
@click="openDeleteDiscussionConfirmation"
|
@click="openDeleteDiscussionConfirmation"
|
||||||
@ -489,15 +501,17 @@ div.container.section {
|
|||||||
padding: 1rem 5% 4rem;
|
padding: 1rem 5% 4rem;
|
||||||
|
|
||||||
div.discussion-title {
|
div.discussion-title {
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 1.75rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
h2.title {
|
h1.title {
|
||||||
span {
|
margin-bottom: 0;
|
||||||
cursor: pointer;
|
margin-right: 10px;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
form.title-edit {
|
form.title-edit {
|
||||||
|
flex: 1;
|
||||||
div.control {
|
div.control {
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
}
|
}
|
||||||
|
@ -18,6 +18,7 @@
|
|||||||
|
|
||||||
<b-field
|
<b-field
|
||||||
:label="$t('Title')"
|
:label="$t('Title')"
|
||||||
|
label-for="title"
|
||||||
:type="checkTitleLength[0]"
|
:type="checkTitleLength[0]"
|
||||||
:message="checkTitleLength[1]"
|
:message="checkTitleLength[1]"
|
||||||
>
|
>
|
||||||
@ -26,12 +27,18 @@
|
|||||||
aria-required="true"
|
aria-required="true"
|
||||||
required
|
required
|
||||||
v-model="event.title"
|
v-model="event.title"
|
||||||
|
id="title"
|
||||||
/>
|
/>
|
||||||
</b-field>
|
</b-field>
|
||||||
|
|
||||||
<tag-input v-model="event.tags" :data="tags" path="title" />
|
<tag-input v-model="event.tags" :data="tags" path="title" />
|
||||||
|
|
||||||
<b-field horizontal :label="$t('Starts on…')" class="begins-on-field">
|
<b-field
|
||||||
|
horizontal
|
||||||
|
:label="$t('Starts on…')"
|
||||||
|
class="begins-on-field"
|
||||||
|
label-for="begins-on-field"
|
||||||
|
>
|
||||||
<b-datetimepicker
|
<b-datetimepicker
|
||||||
class="datepicker starts-on"
|
class="datepicker starts-on"
|
||||||
:placeholder="$t('Type or select a date…')"
|
:placeholder="$t('Type or select a date…')"
|
||||||
@ -40,11 +47,16 @@
|
|||||||
v-model="event.beginsOn"
|
v-model="event.beginsOn"
|
||||||
horizontal-time-picker
|
horizontal-time-picker
|
||||||
editable
|
editable
|
||||||
|
:datepicker="{
|
||||||
|
id: 'begins-on-field',
|
||||||
|
'aria-next-label': $t('Next month'),
|
||||||
|
'aria-previous-label': $t('Previous month'),
|
||||||
|
}"
|
||||||
>
|
>
|
||||||
</b-datetimepicker>
|
</b-datetimepicker>
|
||||||
</b-field>
|
</b-field>
|
||||||
|
|
||||||
<b-field horizontal :label="$t('Ends on…')">
|
<b-field horizontal :label="$t('Ends on…')" label-for="ends-on-field">
|
||||||
<b-datetimepicker
|
<b-datetimepicker
|
||||||
class="datepicker ends-on"
|
class="datepicker ends-on"
|
||||||
:placeholder="$t('Type or select a date…')"
|
:placeholder="$t('Type or select a date…')"
|
||||||
@ -54,6 +66,11 @@
|
|||||||
horizontal-time-picker
|
horizontal-time-picker
|
||||||
:min-datetime="event.beginsOn"
|
:min-datetime="event.beginsOn"
|
||||||
editable
|
editable
|
||||||
|
:datepicker="{
|
||||||
|
id: 'ends-on-field',
|
||||||
|
'aria-next-label': $t('Next month'),
|
||||||
|
'aria-previous-label': $t('Previous month'),
|
||||||
|
}"
|
||||||
>
|
>
|
||||||
</b-datetimepicker>
|
</b-datetimepicker>
|
||||||
</b-field>
|
</b-field>
|
||||||
@ -70,12 +87,13 @@
|
|||||||
<editor v-model="event.description" />
|
<editor v-model="event.description" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<b-field :label="$t('Website / URL')">
|
<b-field :label="$t('Website / URL')" label-for="website-url">
|
||||||
<b-input
|
<b-input
|
||||||
icon="link"
|
icon="link"
|
||||||
type="url"
|
type="url"
|
||||||
v-model="event.onlineAddress"
|
v-model="event.onlineAddress"
|
||||||
placeholder="URL"
|
placeholder="URL"
|
||||||
|
id="website-url"
|
||||||
/>
|
/>
|
||||||
</b-field>
|
</b-field>
|
||||||
|
|
||||||
@ -132,22 +150,31 @@
|
|||||||
</p>
|
</p>
|
||||||
<event-metadata-list v-model="event.metadata" />
|
<event-metadata-list v-model="event.metadata" />
|
||||||
<subtitle>{{ $t("Who can view this event and participate") }}</subtitle>
|
<subtitle>{{ $t("Who can view this event and participate") }}</subtitle>
|
||||||
<div class="field">
|
<fieldset>
|
||||||
<b-radio
|
<legend>
|
||||||
v-model="event.visibility"
|
{{
|
||||||
name="eventVisibility"
|
$t(
|
||||||
:native-value="EventVisibility.PUBLIC"
|
"When the event is private, you'll need to share the link around."
|
||||||
>{{ $t("Visible everywhere on the web (public)") }}</b-radio
|
)
|
||||||
>
|
}}
|
||||||
</div>
|
</legend>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<b-radio
|
<b-radio
|
||||||
v-model="event.visibility"
|
v-model="event.visibility"
|
||||||
name="eventVisibility"
|
name="eventVisibility"
|
||||||
:native-value="EventVisibility.UNLISTED"
|
:native-value="EventVisibility.PUBLIC"
|
||||||
>{{ $t("Only accessible through link (private)") }}</b-radio
|
>{{ $t("Visible everywhere on the web (public)") }}</b-radio
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<b-radio
|
||||||
|
v-model="event.visibility"
|
||||||
|
name="eventVisibility"
|
||||||
|
:native-value="EventVisibility.UNLISTED"
|
||||||
|
>{{ $t("Only accessible through link (private)") }}</b-radio
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
<!-- <div class="field">
|
<!-- <div class="field">
|
||||||
<b-radio v-model="event.visibility"
|
<b-radio v-model="event.visibility"
|
||||||
name="eventVisibility"
|
name="eventVisibility"
|
||||||
@ -196,11 +223,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="box" v-if="limitedPlaces">
|
<div class="box" v-if="limitedPlaces">
|
||||||
<b-field :label="$t('Number of places')">
|
<b-field :label="$t('Number of places')" label-for="number-of-places">
|
||||||
<b-numberinput
|
<b-numberinput
|
||||||
controls-position="compact"
|
controls-position="compact"
|
||||||
|
:aria-minus-label="$t('Decrease')"
|
||||||
|
:aria-plus-label="$t('Increase')"
|
||||||
min="1"
|
min="1"
|
||||||
v-model="eventOptions.maximumAttendeeCapacity"
|
v-model="eventOptions.maximumAttendeeCapacity"
|
||||||
|
id="number-of-places"
|
||||||
/>
|
/>
|
||||||
</b-field>
|
</b-field>
|
||||||
<!--
|
<!--
|
||||||
@ -219,63 +249,75 @@
|
|||||||
|
|
||||||
<subtitle>{{ $t("Public comment moderation") }}</subtitle>
|
<subtitle>{{ $t("Public comment moderation") }}</subtitle>
|
||||||
|
|
||||||
<div class="field">
|
<fieldset>
|
||||||
<b-radio
|
<legend>{{ $t("Who can post a comment?") }}</legend>
|
||||||
v-model="eventOptions.commentModeration"
|
<div class="field">
|
||||||
name="commentModeration"
|
<b-radio
|
||||||
:native-value="CommentModeration.ALLOW_ALL"
|
v-model="eventOptions.commentModeration"
|
||||||
>{{ $t("Allow all comments from users with accounts") }}</b-radio
|
name="commentModeration"
|
||||||
>
|
:native-value="CommentModeration.ALLOW_ALL"
|
||||||
</div>
|
>{{ $t("Allow all comments from users with accounts") }}</b-radio
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- <div class="field">-->
|
<!-- <div class="field">-->
|
||||||
<!-- <b-radio v-model="eventOptions.commentModeration"-->
|
<!-- <b-radio v-model="eventOptions.commentModeration"-->
|
||||||
<!-- name="commentModeration"-->
|
<!-- name="commentModeration"-->
|
||||||
<!-- :native-value="CommentModeration.MODERATED">-->
|
<!-- :native-value="CommentModeration.MODERATED">-->
|
||||||
<!-- {{ $t('Moderated comments (shown after approval)') }}-->
|
<!-- {{ $t('Moderated comments (shown after approval)') }}-->
|
||||||
<!-- </b-radio>-->
|
<!-- </b-radio>-->
|
||||||
<!-- </div>-->
|
<!-- </div>-->
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<b-radio
|
<b-radio
|
||||||
v-model="eventOptions.commentModeration"
|
v-model="eventOptions.commentModeration"
|
||||||
name="commentModeration"
|
name="commentModeration"
|
||||||
:native-value="CommentModeration.CLOSED"
|
:native-value="CommentModeration.CLOSED"
|
||||||
>{{ $t("Close comments for all (except for admins)") }}</b-radio
|
>{{ $t("Close comments for all (except for admins)") }}</b-radio
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
<subtitle>{{ $t("Status") }}</subtitle>
|
<subtitle>{{ $t("Status") }}</subtitle>
|
||||||
|
|
||||||
<b-field class="event__status__field">
|
<fieldset>
|
||||||
<b-radio-button
|
<legend>
|
||||||
v-model="event.status"
|
{{
|
||||||
name="status"
|
$t(
|
||||||
type="is-warning"
|
"Does the event needs to be confirmed later or is it cancelled?"
|
||||||
:native-value="EventStatus.TENTATIVE"
|
)
|
||||||
>
|
}}
|
||||||
<b-icon icon="calendar-question" />
|
</legend>
|
||||||
{{ $t("Tentative: Will be confirmed later") }}
|
<b-field class="event__status__field">
|
||||||
</b-radio-button>
|
<b-radio-button
|
||||||
<b-radio-button
|
v-model="event.status"
|
||||||
v-model="event.status"
|
name="status"
|
||||||
name="status"
|
type="is-warning"
|
||||||
type="is-success"
|
:native-value="EventStatus.TENTATIVE"
|
||||||
:native-value="EventStatus.CONFIRMED"
|
>
|
||||||
>
|
<b-icon icon="calendar-question" />
|
||||||
<b-icon icon="calendar-check" />
|
{{ $t("Tentative: Will be confirmed later") }}
|
||||||
{{ $t("Confirmed: Will happen") }}
|
</b-radio-button>
|
||||||
</b-radio-button>
|
<b-radio-button
|
||||||
<b-radio-button
|
v-model="event.status"
|
||||||
v-model="event.status"
|
name="status"
|
||||||
name="status"
|
type="is-success"
|
||||||
type="is-danger"
|
:native-value="EventStatus.CONFIRMED"
|
||||||
:native-value="EventStatus.CANCELLED"
|
>
|
||||||
>
|
<b-icon icon="calendar-check" />
|
||||||
<b-icon icon="calendar-remove" />
|
{{ $t("Confirmed: Will happen") }}
|
||||||
{{ $t("Cancelled: Won't happen") }}
|
</b-radio-button>
|
||||||
</b-radio-button>
|
<b-radio-button
|
||||||
</b-field>
|
v-model="event.status"
|
||||||
|
name="status"
|
||||||
|
type="is-danger"
|
||||||
|
:native-value="EventStatus.CANCELLED"
|
||||||
|
>
|
||||||
|
<b-icon icon="calendar-remove" />
|
||||||
|
{{ $t("Cancelled: Won't happen") }}
|
||||||
|
</b-radio-button>
|
||||||
|
</b-field>
|
||||||
|
</fieldset>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="container section" v-else>
|
<div class="container section" v-else>
|
||||||
@ -370,6 +412,16 @@
|
|||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
main section > .container {
|
main section > .container {
|
||||||
background: $white;
|
background: $white;
|
||||||
|
|
||||||
|
form {
|
||||||
|
h2 {
|
||||||
|
margin: 15px 0 7.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
legend {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.save__navbar {
|
.save__navbar {
|
||||||
@ -412,6 +464,9 @@ h2.subtitle {
|
|||||||
section {
|
section {
|
||||||
& > .container {
|
& > .container {
|
||||||
padding: 2rem 1.5rem;
|
padding: 2rem 1.5rem;
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
padding: 2rem 0.5rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.begins-on-field {
|
.begins-on-field {
|
||||||
|
@ -7,12 +7,19 @@
|
|||||||
</b-message>
|
</b-message>
|
||||||
|
|
||||||
<form @submit.prevent="createGroup">
|
<form @submit.prevent="createGroup">
|
||||||
<b-field :label="$t('Group display name')">
|
<b-field :label="$t('Group display name')" label-for="group-display-name">
|
||||||
<b-input aria-required="true" required v-model="group.name" />
|
<b-input
|
||||||
|
aria-required="true"
|
||||||
|
required
|
||||||
|
v-model="group.name"
|
||||||
|
id="group-display-name"
|
||||||
|
/>
|
||||||
</b-field>
|
</b-field>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label">{{ $t("Federated Group Name") }}</label>
|
<label class="label" for="group-preferred-username">{{
|
||||||
|
$t("Federated Group Name")
|
||||||
|
}}</label>
|
||||||
<div class="field-body">
|
<div class="field-body">
|
||||||
<b-field
|
<b-field
|
||||||
:message="
|
:message="
|
||||||
@ -28,6 +35,7 @@
|
|||||||
expanded
|
expanded
|
||||||
v-model="group.preferredUsername"
|
v-model="group.preferredUsername"
|
||||||
pattern="[a-z0-9_]+"
|
pattern="[a-z0-9_]+"
|
||||||
|
id="group-preferred-username"
|
||||||
:useHtml5Validation="true"
|
:useHtml5Validation="true"
|
||||||
:validation-message="
|
:validation-message="
|
||||||
group.preferredUsername
|
group.preferredUsername
|
||||||
@ -52,8 +60,8 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<b-field :label="$t('Description')">
|
<b-field :label="$t('Description')" label-for="group-summary">
|
||||||
<b-input v-model="group.summary" type="textarea" />
|
<b-input v-model="group.summary" type="textarea" id="group-summary" />
|
||||||
</b-field>
|
</b-field>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
@ -87,7 +87,7 @@
|
|||||||
props.row.actor.name
|
props.row.actor.name
|
||||||
}}</span
|
}}</span
|
||||||
><br />
|
><br />
|
||||||
<span class="is-size-7 has-text-grey"
|
<span class="is-size-7 has-text-grey-dark"
|
||||||
>@{{ usernameWithDomain(props.row.actor) }}</span
|
>@{{ usernameWithDomain(props.row.actor) }}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
@ -39,6 +39,7 @@
|
|||||||
<b-field
|
<b-field
|
||||||
:label="$t('Invite a new member')"
|
:label="$t('Invite a new member')"
|
||||||
custom-class="add-relay"
|
custom-class="add-relay"
|
||||||
|
label-for="new-member-field"
|
||||||
horizontal
|
horizontal
|
||||||
>
|
>
|
||||||
<b-field
|
<b-field
|
||||||
@ -50,6 +51,7 @@
|
|||||||
>
|
>
|
||||||
<p class="control">
|
<p class="control">
|
||||||
<b-input
|
<b-input
|
||||||
|
id="new-member-field"
|
||||||
v-model="newMemberUsername"
|
v-model="newMemberUsername"
|
||||||
:placeholder="$t('Ex: someone@mobilizon.org')"
|
:placeholder="$t('Ex: someone@mobilizon.org')"
|
||||||
/>
|
/>
|
||||||
@ -63,8 +65,12 @@
|
|||||||
</b-field>
|
</b-field>
|
||||||
</form>
|
</form>
|
||||||
<h1>{{ $t("Group Members") }} ({{ group.members.total }})</h1>
|
<h1>{{ $t("Group Members") }} ({{ group.members.total }})</h1>
|
||||||
<b-field :label="$t('Status')" horizontal>
|
<b-field
|
||||||
<b-select v-model="roles">
|
:label="$t('Status')"
|
||||||
|
horizontal
|
||||||
|
label-for="group-members-status-filter"
|
||||||
|
>
|
||||||
|
<b-select v-model="roles" id="group-members-status-filter">
|
||||||
<option value="">
|
<option value="">
|
||||||
{{ $t("Everything") }}
|
{{ $t("Everything") }}
|
||||||
</option>
|
</option>
|
||||||
@ -122,7 +128,7 @@
|
|||||||
<img
|
<img
|
||||||
class="is-rounded"
|
class="is-rounded"
|
||||||
:src="props.row.actor.avatar.url"
|
:src="props.row.actor.avatar.url"
|
||||||
alt=""
|
:alt="props.row.actor.avatar.alt || ''"
|
||||||
/>
|
/>
|
||||||
</figure>
|
</figure>
|
||||||
<b-icon
|
<b-icon
|
||||||
@ -137,7 +143,7 @@
|
|||||||
props.row.actor.name
|
props.row.actor.name
|
||||||
}}</span
|
}}</span
|
||||||
><br />
|
><br />
|
||||||
<span class="is-size-7 has-text-grey"
|
<span class="is-size-7 has-text-grey-dark"
|
||||||
>@{{ usernameWithDomain(props.row.actor) }}</span
|
>@{{ usernameWithDomain(props.row.actor) }}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
@ -37,8 +37,8 @@
|
|||||||
v-if="group && isCurrentActorAGroupAdmin"
|
v-if="group && isCurrentActorAGroupAdmin"
|
||||||
>
|
>
|
||||||
<form @submit.prevent="updateGroup">
|
<form @submit.prevent="updateGroup">
|
||||||
<b-field :label="$t('Group name')">
|
<b-field :label="$t('Group name')" label-for="group-settings-name">
|
||||||
<b-input v-model="editableGroup.name" />
|
<b-input v-model="editableGroup.name" id="group-settings-name" />
|
||||||
</b-field>
|
</b-field>
|
||||||
<b-field :label="$t('Group short description')">
|
<b-field :label="$t('Group short description')">
|
||||||
<editor mode="basic" v-model="editableGroup.summary" :maxSize="500"
|
<editor mode="basic" v-model="editableGroup.summary" :maxSize="500"
|
||||||
|
@ -278,13 +278,11 @@
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
<router-link :to="{ name: RouteName.PREFERENCES }">
|
<router-link
|
||||||
<b-icon
|
:to="{ name: RouteName.PREFERENCES }"
|
||||||
class="clickable"
|
:title="$t('Change')"
|
||||||
icon="pencil"
|
>
|
||||||
:title="$t('Change')"
|
<b-icon class="clickable" icon="pencil" size="is-small" />
|
||||||
size="is-small"
|
|
||||||
/>
|
|
||||||
</router-link>
|
</router-link>
|
||||||
<b-loading :active.sync="$apollo.loading" />
|
<b-loading :active.sync="$apollo.loading" />
|
||||||
</p>
|
</p>
|
||||||
|
@ -21,6 +21,7 @@
|
|||||||
<img
|
<img
|
||||||
class="image"
|
class="image"
|
||||||
:src="log.actor.avatar.url"
|
:src="log.actor.avatar.url"
|
||||||
|
:alt="log.actor.avatar.alt || ''"
|
||||||
v-if="log.actor.avatar"
|
v-if="log.actor.avatar"
|
||||||
/>
|
/>
|
||||||
<i18n
|
<i18n
|
||||||
|
@ -54,6 +54,7 @@
|
|||||||
|
|
||||||
<b-field
|
<b-field
|
||||||
:label="$t('Title')"
|
:label="$t('Title')"
|
||||||
|
label-for="post-title"
|
||||||
:type="errors.title ? 'is-danger' : null"
|
:type="errors.title ? 'is-danger' : null"
|
||||||
:message="errors.title"
|
:message="errors.title"
|
||||||
>
|
>
|
||||||
@ -62,6 +63,7 @@
|
|||||||
aria-required="true"
|
aria-required="true"
|
||||||
required
|
required
|
||||||
v-model="editablePost.title"
|
v-model="editablePost.title"
|
||||||
|
id="post-title"
|
||||||
/>
|
/>
|
||||||
</b-field>
|
</b-field>
|
||||||
|
|
||||||
@ -73,30 +75,39 @@
|
|||||||
<editor v-model="editablePost.body" />
|
<editor v-model="editablePost.body" />
|
||||||
</div>
|
</div>
|
||||||
<subtitle>{{ $t("Who can view this post") }}</subtitle>
|
<subtitle>{{ $t("Who can view this post") }}</subtitle>
|
||||||
<div class="field">
|
<fieldset>
|
||||||
<b-radio
|
<legend>
|
||||||
v-model="editablePost.visibility"
|
{{
|
||||||
name="postVisibility"
|
$t(
|
||||||
:native-value="PostVisibility.PUBLIC"
|
"When the post is private, you'll need to share the link around."
|
||||||
>{{ $t("Visible everywhere on the web") }}</b-radio
|
)
|
||||||
>
|
}}
|
||||||
</div>
|
</legend>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<b-radio
|
<b-radio
|
||||||
v-model="editablePost.visibility"
|
v-model="editablePost.visibility"
|
||||||
name="postVisibility"
|
name="postVisibility"
|
||||||
:native-value="PostVisibility.UNLISTED"
|
:native-value="PostVisibility.PUBLIC"
|
||||||
>{{ $t("Only accessible through link") }}</b-radio
|
>{{ $t("Visible everywhere on the web") }}</b-radio
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<b-radio
|
<b-radio
|
||||||
v-model="editablePost.visibility"
|
v-model="editablePost.visibility"
|
||||||
name="postVisibility"
|
name="postVisibility"
|
||||||
:native-value="PostVisibility.PRIVATE"
|
:native-value="PostVisibility.UNLISTED"
|
||||||
>{{ $t("Only accessible to members of the group") }}</b-radio
|
>{{ $t("Only accessible through link") }}</b-radio
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<b-radio
|
||||||
|
v-model="editablePost.visibility"
|
||||||
|
name="postVisibility"
|
||||||
|
:native-value="PostVisibility.PRIVATE"
|
||||||
|
>{{ $t("Only accessible to members of the group") }}</b-radio
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
<nav class="navbar">
|
<nav class="navbar">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@ -414,6 +425,13 @@ form {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
h2 {
|
||||||
|
margin: 15px 0 7.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
legend {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.breadcrumb li.is-active > span {
|
.breadcrumb li.is-active > span {
|
||||||
|
@ -187,8 +187,12 @@
|
|||||||
<div class="modal-card">
|
<div class="modal-card">
|
||||||
<section class="modal-card-body">
|
<section class="modal-card-body">
|
||||||
<form @submit.prevent="createResource">
|
<form @submit.prevent="createResource">
|
||||||
<b-field :label="$t('Title')">
|
<b-field :label="$t('Title')" label-for="new-resource-title">
|
||||||
<b-input aria-required="true" v-model="newResource.title" />
|
<b-input
|
||||||
|
aria-required="true"
|
||||||
|
v-model="newResource.title"
|
||||||
|
id="new-resource-title"
|
||||||
|
/>
|
||||||
</b-field>
|
</b-field>
|
||||||
|
|
||||||
<b-button native-type="submit">{{
|
<b-button native-type="submit">{{
|
||||||
@ -202,6 +206,7 @@
|
|||||||
:active.sync="createLinkResourceModal"
|
:active.sync="createLinkResourceModal"
|
||||||
has-modal-card
|
has-modal-card
|
||||||
class="link-resource-modal"
|
class="link-resource-modal"
|
||||||
|
aria-modal
|
||||||
>
|
>
|
||||||
<div class="modal-card">
|
<div class="modal-card">
|
||||||
<section class="modal-card-body">
|
<section class="modal-card-body">
|
||||||
@ -209,8 +214,9 @@
|
|||||||
{{ modalError }}
|
{{ modalError }}
|
||||||
</b-message>
|
</b-message>
|
||||||
<form @submit.prevent="createResource">
|
<form @submit.prevent="createResource">
|
||||||
<b-field :label="$t('URL')">
|
<b-field :label="$t('URL')" label-for="new-resource-url">
|
||||||
<b-input
|
<b-input
|
||||||
|
id="new-resource-url"
|
||||||
type="url"
|
type="url"
|
||||||
required
|
required
|
||||||
v-model="newResource.resourceUrl"
|
v-model="newResource.resourceUrl"
|
||||||
@ -222,12 +228,23 @@
|
|||||||
<resource-item :resource="newResource" :preview="true" />
|
<resource-item :resource="newResource" :preview="true" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<b-field :label="$t('Title')">
|
<b-field :label="$t('Title')" label-for="new-resource-link-title">
|
||||||
<b-input aria-required="true" v-model="newResource.title" />
|
<b-input
|
||||||
|
aria-required="true"
|
||||||
|
v-model="newResource.title"
|
||||||
|
id="new-resource-link-title"
|
||||||
|
/>
|
||||||
</b-field>
|
</b-field>
|
||||||
|
|
||||||
<b-field :label="$t('Text')">
|
<b-field
|
||||||
<b-input type="textarea" v-model="newResource.summary" />
|
:label="$t('Description')"
|
||||||
|
label-for="new-resource-summary"
|
||||||
|
>
|
||||||
|
<b-input
|
||||||
|
type="textarea"
|
||||||
|
v-model="newResource.summary"
|
||||||
|
id="new-resource-summary"
|
||||||
|
/>
|
||||||
</b-field>
|
</b-field>
|
||||||
|
|
||||||
<b-button native-type="submit">{{
|
<b-button native-type="submit">{{
|
||||||
|
@ -66,14 +66,14 @@
|
|||||||
<th v-for="(method, key) in notificationMethods" :key="key">
|
<th v-for="(method, key) in notificationMethods" :key="key">
|
||||||
{{ method }}
|
{{ method }}
|
||||||
</th>
|
</th>
|
||||||
<th></th>
|
<th>{{ $t("Description") }}</th>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-for="subType in notificationType.subtypes" :key="subType.id">
|
<tr v-for="subType in notificationType.subtypes" :key="subType.id">
|
||||||
<td v-for="(method, key) in notificationMethods" :key="key">
|
<td v-for="(method, key) in notificationMethods" :key="key">
|
||||||
<b-checkbox
|
<b-checkbox
|
||||||
:value="notificationValues[subType.id][key]"
|
:value="notificationValues[subType.id][key].enabled"
|
||||||
@input="(e) => updateNotificationValue(subType.id, key, e)"
|
@input="(e) => updateNotificationValue(subType.id, key, e)"
|
||||||
:disabled="notificationValues[subType.id].disabled"
|
:disabled="notificationValues[subType.id][key].disabled"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@ -86,6 +86,7 @@
|
|||||||
|
|
||||||
<b-field
|
<b-field
|
||||||
:label="$t('Send notification e-mails')"
|
:label="$t('Send notification e-mails')"
|
||||||
|
label-for="groupNotifications"
|
||||||
:message="
|
:message="
|
||||||
$t(
|
$t(
|
||||||
'Announcements and mentions notifications are always sent straight away.'
|
'Announcements and mentions notifications are always sent straight away.'
|
||||||
@ -95,6 +96,7 @@
|
|||||||
<b-select
|
<b-select
|
||||||
v-model="groupNotifications"
|
v-model="groupNotifications"
|
||||||
@input="updateSetting({ groupNotifications })"
|
@input="updateSetting({ groupNotifications })"
|
||||||
|
id="groupNotifications"
|
||||||
>
|
>
|
||||||
<option
|
<option
|
||||||
v-for="(value, key) in groupNotificationsValues"
|
v-for="(value, key) in groupNotificationsValues"
|
||||||
@ -186,9 +188,13 @@
|
|||||||
<h2>{{ $t("Organizer notifications") }}</h2>
|
<h2>{{ $t("Organizer notifications") }}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="field is-primary">
|
<div class="field is-primary">
|
||||||
<strong>{{
|
<label
|
||||||
$t("Notifications for manually approved participations to an event")
|
class="has-text-weight-bold"
|
||||||
}}</strong>
|
for="notificationPendingParticipation"
|
||||||
|
>{{
|
||||||
|
$t("Notifications for manually approved participations to an event")
|
||||||
|
}}</label
|
||||||
|
>
|
||||||
<p>
|
<p>
|
||||||
{{
|
{{
|
||||||
$t(
|
$t(
|
||||||
@ -198,6 +204,7 @@
|
|||||||
</p>
|
</p>
|
||||||
<b-select
|
<b-select
|
||||||
v-model="notificationPendingParticipation"
|
v-model="notificationPendingParticipation"
|
||||||
|
id="notificationPendingParticipation"
|
||||||
@input="updateSetting({ notificationPendingParticipation })"
|
@input="updateSetting({ notificationPendingParticipation })"
|
||||||
>
|
>
|
||||||
<option
|
<option
|
||||||
@ -291,7 +298,7 @@ import {
|
|||||||
USER_NOTIFICATIONS,
|
USER_NOTIFICATIONS,
|
||||||
UPDATE_ACTIVITY_SETTING,
|
UPDATE_ACTIVITY_SETTING,
|
||||||
} from "../../graphql/user";
|
} from "../../graphql/user";
|
||||||
import { IUser } from "../../types/current-user.model";
|
import { IActivitySettingMethod, IUser } from "../../types/current-user.model";
|
||||||
import RouteName from "../../router/name";
|
import RouteName from "../../router/name";
|
||||||
import { IFeedToken } from "@/types/feedtoken.model";
|
import { IFeedToken } from "@/types/feedtoken.model";
|
||||||
import { CREATE_FEED_TOKEN, DELETE_FEED_TOKEN } from "@/graphql/feed_tokens";
|
import { CREATE_FEED_TOKEN, DELETE_FEED_TOKEN } from "@/graphql/feed_tokens";
|
||||||
@ -367,70 +374,68 @@ export default class Notifications extends Vue {
|
|||||||
|
|
||||||
defaultNotificationValues = {
|
defaultNotificationValues = {
|
||||||
participation_event_updated: {
|
participation_event_updated: {
|
||||||
email: true,
|
email: { enabled: true, disabled: true },
|
||||||
push: true,
|
push: { enabled: true, disabled: true },
|
||||||
disabled: true,
|
|
||||||
},
|
},
|
||||||
participation_event_comment: {
|
participation_event_comment: {
|
||||||
email: true,
|
email: { enabled: true, disabled: false },
|
||||||
push: true,
|
push: { enabled: true, disabled: false },
|
||||||
},
|
},
|
||||||
event_new_pending_participation: {
|
event_new_pending_participation: {
|
||||||
email: true,
|
email: { enabled: true, disabled: false },
|
||||||
push: true,
|
push: { enabled: true, disabled: false },
|
||||||
},
|
},
|
||||||
event_new_participation: {
|
event_new_participation: {
|
||||||
email: false,
|
email: { enabled: false, disabled: false },
|
||||||
push: false,
|
push: { enabled: false, disabled: false },
|
||||||
},
|
},
|
||||||
event_created: {
|
event_created: {
|
||||||
email: false,
|
email: { enabled: false, disabled: false },
|
||||||
push: false,
|
push: { enabled: false, disabled: false },
|
||||||
},
|
},
|
||||||
event_updated: {
|
event_updated: {
|
||||||
email: false,
|
email: { enabled: false, disabled: false },
|
||||||
push: false,
|
push: { enabled: false, disabled: false },
|
||||||
},
|
},
|
||||||
discussion_updated: {
|
discussion_updated: {
|
||||||
email: false,
|
email: { enabled: false, disabled: false },
|
||||||
push: false,
|
push: { enabled: false, disabled: false },
|
||||||
},
|
},
|
||||||
post_published: {
|
post_published: {
|
||||||
email: false,
|
email: { enabled: false, disabled: false },
|
||||||
push: false,
|
push: { enabled: false, disabled: false },
|
||||||
},
|
},
|
||||||
post_updated: {
|
post_updated: {
|
||||||
email: false,
|
email: { enabled: false, disabled: false },
|
||||||
push: false,
|
push: { enabled: false, disabled: false },
|
||||||
},
|
},
|
||||||
resource_updated: {
|
resource_updated: {
|
||||||
email: false,
|
email: { enabled: false, disabled: false },
|
||||||
push: false,
|
push: { enabled: false, disabled: false },
|
||||||
},
|
},
|
||||||
member_request: {
|
member_request: {
|
||||||
email: true,
|
email: { enabled: true, disabled: false },
|
||||||
push: true,
|
push: { enabled: true, disabled: false },
|
||||||
},
|
},
|
||||||
member_updated: {
|
member_updated: {
|
||||||
email: false,
|
email: { enabled: false, disabled: false },
|
||||||
push: false,
|
push: { enabled: false, disabled: false },
|
||||||
},
|
},
|
||||||
user_email_password_updated: {
|
user_email_password_updated: {
|
||||||
email: true,
|
email: { enabled: true, disabled: true },
|
||||||
push: false,
|
push: { enabled: false, disabled: true },
|
||||||
disabled: true,
|
|
||||||
},
|
},
|
||||||
event_comment_mention: {
|
event_comment_mention: {
|
||||||
email: true,
|
email: { enabled: true, disabled: false },
|
||||||
push: true,
|
push: { enabled: true, disabled: false },
|
||||||
},
|
},
|
||||||
discussion_mention: {
|
discussion_mention: {
|
||||||
email: true,
|
email: { enabled: true, disabled: false },
|
||||||
push: false,
|
push: { enabled: false, disabled: false },
|
||||||
},
|
},
|
||||||
event_new_comment: {
|
event_new_comment: {
|
||||||
email: true,
|
email: { enabled: true, disabled: false },
|
||||||
push: false,
|
push: { enabled: false, disabled: false },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -542,17 +547,34 @@ export default class Notifications extends Vue {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
get userNotificationValues(): Record<string, Record<string, boolean>> {
|
get userNotificationValues(): Record<
|
||||||
|
string,
|
||||||
|
Record<IActivitySettingMethod, { enabled: boolean; disabled: boolean }>
|
||||||
|
> {
|
||||||
return this.loggedUser.activitySettings.reduce((acc, activitySetting) => {
|
return this.loggedUser.activitySettings.reduce((acc, activitySetting) => {
|
||||||
acc[activitySetting.key] = acc[activitySetting.key] || {};
|
acc[activitySetting.key] = acc[activitySetting.key] || {};
|
||||||
acc[activitySetting.key][activitySetting.method] =
|
acc[activitySetting.key][activitySetting.method] =
|
||||||
|
acc[activitySetting.key][activitySetting.method] || {};
|
||||||
|
acc[activitySetting.key][activitySetting.method].enabled =
|
||||||
activitySetting.enabled;
|
activitySetting.enabled;
|
||||||
return acc;
|
return acc;
|
||||||
}, {} as Record<string, Record<string, boolean>>);
|
}, {} as Record<string, Record<IActivitySettingMethod, { enabled: boolean; disabled: boolean }>>);
|
||||||
}
|
}
|
||||||
|
|
||||||
get notificationValues(): Record<string, Record<string, boolean>> {
|
get notificationValues(): Record<
|
||||||
return merge(this.defaultNotificationValues, this.userNotificationValues);
|
string,
|
||||||
|
Record<IActivitySettingMethod, { enabled: boolean; disabled: boolean }>
|
||||||
|
> {
|
||||||
|
const values = merge(
|
||||||
|
this.defaultNotificationValues,
|
||||||
|
this.userNotificationValues
|
||||||
|
);
|
||||||
|
for (const value in values) {
|
||||||
|
if (!this.canShowWebPush) {
|
||||||
|
values[value].push.disabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return values;
|
||||||
}
|
}
|
||||||
|
|
||||||
async mounted(): Promise<void> {
|
async mounted(): Promise<void> {
|
||||||
|
@ -15,22 +15,28 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
<div>
|
<div>
|
||||||
<b-field :label="$t('Language')">
|
<b-field :label="$t('Language')" label-for="setting-language">
|
||||||
<b-select
|
<b-select
|
||||||
:loading="!config || !loggedUser"
|
:loading="!config || !loggedUser"
|
||||||
v-model="locale"
|
v-model="locale"
|
||||||
:placeholder="$t('Select a language')"
|
:placeholder="$t('Select a language')"
|
||||||
|
id="setting-language"
|
||||||
>
|
>
|
||||||
<option v-for="(language, lang) in langs" :value="lang" :key="lang">
|
<option v-for="(language, lang) in langs" :value="lang" :key="lang">
|
||||||
{{ language }}
|
{{ language }}
|
||||||
</option>
|
</option>
|
||||||
</b-select>
|
</b-select>
|
||||||
</b-field>
|
</b-field>
|
||||||
<b-field :label="$t('Timezone')" v-if="selectedTimezone">
|
<b-field
|
||||||
|
:label="$t('Timezone')"
|
||||||
|
v-if="selectedTimezone"
|
||||||
|
label-for="setting-timezone"
|
||||||
|
>
|
||||||
<b-select
|
<b-select
|
||||||
:placeholder="$t('Select a timezone')"
|
:placeholder="$t('Select a timezone')"
|
||||||
:loading="!config || !loggedUser"
|
:loading="!config || !loggedUser"
|
||||||
v-model="selectedTimezone"
|
v-model="selectedTimezone"
|
||||||
|
id="setting-timezone"
|
||||||
>
|
>
|
||||||
<optgroup
|
<optgroup
|
||||||
:label="group"
|
:label="group"
|
||||||
@ -57,19 +63,25 @@
|
|||||||
}}</b-message>
|
}}</b-message>
|
||||||
<hr />
|
<hr />
|
||||||
<b-field grouped>
|
<b-field grouped>
|
||||||
<b-field :label="$t('City or region')" expanded>
|
<b-field
|
||||||
|
:label="$t('City or region')"
|
||||||
|
expanded
|
||||||
|
label-for="setting-city"
|
||||||
|
>
|
||||||
<address-auto-complete
|
<address-auto-complete
|
||||||
v-if="loggedUser && loggedUser.settings"
|
v-if="loggedUser && loggedUser.settings"
|
||||||
:type="AddressSearchType.ADMINISTRATIVE"
|
:type="AddressSearchType.ADMINISTRATIVE"
|
||||||
:doGeoLocation="false"
|
:doGeoLocation="false"
|
||||||
v-model="address"
|
v-model="address"
|
||||||
|
id="setting-city"
|
||||||
>
|
>
|
||||||
</address-auto-complete>
|
</address-auto-complete>
|
||||||
</b-field>
|
</b-field>
|
||||||
<b-field :label="$t('Radius')">
|
<b-field :label="$t('Radius')" label-for="setting-radius">
|
||||||
<b-select
|
<b-select
|
||||||
:placeholder="$t('Select a radius')"
|
:placeholder="$t('Select a radius')"
|
||||||
v-model="locationRange"
|
v-model="locationRange"
|
||||||
|
id="setting-radius"
|
||||||
>
|
>
|
||||||
<option
|
<option
|
||||||
v-for="index in [1, 5, 10, 25, 50, 100]"
|
v-for="index in [1, 5, 10, 25, 50, 100]"
|
||||||
@ -85,6 +97,7 @@
|
|||||||
@click="resetArea"
|
@click="resetArea"
|
||||||
class="reset-area"
|
class="reset-area"
|
||||||
icon-left="close"
|
icon-left="close"
|
||||||
|
:aria-label="$t('Reset')"
|
||||||
/>
|
/>
|
||||||
</b-field>
|
</b-field>
|
||||||
<p>
|
<p>
|
||||||
|
@ -3,6 +3,7 @@ import { routes } from "@/router";
|
|||||||
import App from "@/App.vue";
|
import App from "@/App.vue";
|
||||||
import VueRouter from "vue-router";
|
import VueRouter from "vue-router";
|
||||||
import Buefy from "buefy";
|
import Buefy from "buefy";
|
||||||
|
import flushPromises from "flush-promises";
|
||||||
|
|
||||||
const localVue = createLocalVue();
|
const localVue = createLocalVue();
|
||||||
config.mocks.$t = (key: string): string => key;
|
config.mocks.$t = (key: string): string => key;
|
||||||
@ -36,8 +37,7 @@ describe("routing", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.push("/about");
|
router.push("/about");
|
||||||
await wrapper.vm.$nextTick();
|
await flushPromises();
|
||||||
await wrapper.vm.$nextTick();
|
|
||||||
expect(wrapper.vm.$route.path).toBe("/about/instance");
|
expect(wrapper.vm.$route.path).toBe("/about/instance");
|
||||||
expect(wrapper.html()).toContain(
|
expect(wrapper.html()).toContain(
|
||||||
'<a href="/about/instance" aria-current="page"'
|
'<a href="/about/instance" aria-current="page"'
|
||||||
|
@ -9,7 +9,7 @@ exports[`PostElementItem renders post with basic informations 1`] = `
|
|||||||
<p class="post-minimalist-title">My Blog Post</p>
|
<p class="post-minimalist-title">My Blog Post</p>
|
||||||
<div class="metadata">
|
<div class="metadata">
|
||||||
<!---->
|
<!---->
|
||||||
<!----> <small class="has-text-grey">December 2, 2020</small>
|
<!----> <small class="has-text-grey-dark">December 2, 2020</small>
|
||||||
<!---->
|
<!---->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -26,7 +26,7 @@ exports[`PostElementItem shows the author if actor is a group member 1`] = `
|
|||||||
<div class="media-content">
|
<div class="media-content">
|
||||||
<p class="post-minimalist-title">My Blog Post</p>
|
<p class="post-minimalist-title">My Blog Post</p>
|
||||||
<div class="metadata">
|
<div class="metadata">
|
||||||
<!----> <small class="has-text-grey"><span class="icon is-small"><i class="mdi mdi-earth"></i></span>Public</small> <small class="has-text-grey">December 2, 2020</small> <small class="has-text-grey">Created by {username}</small>
|
<!----> <small class="has-text-grey-dark"><span class="icon is-small"><i class="mdi mdi-earth"></i></span>Public</small> <small class="has-text-grey-dark">December 2, 2020</small> <small class="has-text-grey-dark">Created by {username}</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -43,7 +43,7 @@ exports[`PostElementItem shows the draft tag if post is a draft 1`] = `
|
|||||||
<p class="post-minimalist-title">My Blog Post</p>
|
<p class="post-minimalist-title">My Blog Post</p>
|
||||||
<div class="metadata"><span class="tag is-warning is-small"><span class="">Draft</span>
|
<div class="metadata"><span class="tag is-warning is-small"><span class="">Draft</span>
|
||||||
<!----></span>
|
<!----></span>
|
||||||
<!----> <small class="has-text-grey">December 2, 2020</small>
|
<!----> <small class="has-text-grey-dark">December 2, 2020</small>
|
||||||
<!---->
|
<!---->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -60,7 +60,7 @@ exports[`PostElementItem tells if the post is accessible only through link 1`] =
|
|||||||
<div class="media-content">
|
<div class="media-content">
|
||||||
<p class="post-minimalist-title">My Blog Post</p>
|
<p class="post-minimalist-title">My Blog Post</p>
|
||||||
<div class="metadata">
|
<div class="metadata">
|
||||||
<!----> <small class="has-text-grey"><span class="icon is-small"><i class="mdi mdi-link"></i></span>Accessible through link</small> <small class="has-text-grey">December 2, 2020</small>
|
<!----> <small class="has-text-grey-dark"><span class="icon is-small"><i class="mdi mdi-link"></i></span>Accessible through link</small> <small class="has-text-grey-dark">December 2, 2020</small>
|
||||||
<!---->
|
<!---->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -77,7 +77,7 @@ exports[`PostElementItem tells if the post is accessible only to members 1`] = `
|
|||||||
<div class="media-content">
|
<div class="media-content">
|
||||||
<p class="post-minimalist-title">My Blog Post</p>
|
<p class="post-minimalist-title">My Blog Post</p>
|
||||||
<div class="metadata">
|
<div class="metadata">
|
||||||
<!----> <small class="has-text-grey"><span class="icon is-small"><i class="mdi mdi-lock"></i></span>Accessible only to members</small> <small class="has-text-grey">December 2, 2020</small>
|
<!----> <small class="has-text-grey-dark"><span class="icon is-small"><i class="mdi mdi-lock"></i></span>Accessible only to members</small> <small class="has-text-grey-dark">December 2, 2020</small>
|
||||||
<!---->
|
<!---->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -94,7 +94,7 @@ exports[`PostElementItem tells if the post is public when the actor is a group m
|
|||||||
<div class="media-content">
|
<div class="media-content">
|
||||||
<p class="post-minimalist-title">My Blog Post</p>
|
<p class="post-minimalist-title">My Blog Post</p>
|
||||||
<div class="metadata">
|
<div class="metadata">
|
||||||
<!----> <small class="has-text-grey"><span class="icon is-small"><i class="mdi mdi-earth"></i></span>Public</small> <small class="has-text-grey">December 2, 2020</small> <small class="has-text-grey">Created by {username}</small>
|
<!----> <small class="has-text-grey-dark"><span class="icon is-small"><i class="mdi mdi-earth"></i></span>Public</small> <small class="has-text-grey-dark">December 2, 2020</small> <small class="has-text-grey-dark">Created by {username}</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -382,7 +382,7 @@ defmodule Mobilizon.Config do
|
|||||||
|
|
||||||
cond do
|
cond do
|
||||||
is_nil(contact) ->
|
is_nil(contact) ->
|
||||||
nil
|
"<b>Contact information not filled</b>"
|
||||||
|
|
||||||
String.contains?(contact, "@") ->
|
String.contains?(contact, "@") ->
|
||||||
"<a href=\"mailto:#{contact}\">#{contact}</a>"
|
"<a href=\"mailto:#{contact}\">#{contact}</a>"
|
||||||
|
Loading…
Reference in New Issue
Block a user