Merge branch 'front-end-issues' into 'master'

A11y improvements

Closes #821

See merge request framasoft/mobilizon!1057
This commit is contained in:
Thomas Citharel 2021-09-08 08:41:24 +00:00
commit 4f4c92e917
42 changed files with 941 additions and 646 deletions

View File

@ -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;
} }

View File

@ -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);
} }

View File

@ -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>

View File

@ -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;
} }

View File

@ -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>

View File

@ -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" />

View File

@ -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" />

View File

@ -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) {

View File

@ -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>

View File

@ -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>

View File

@ -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')"

View File

@ -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>

View File

@ -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>

View File

@ -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)}`,
}) })

View File

@ -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"
} }

View File

@ -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"
} }

View 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;
}
}

View File

@ -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;
} }

View File

@ -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;

View File

@ -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;
} }

View File

@ -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>

View File

@ -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;
} }

View File

@ -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">

View File

@ -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">

View File

@ -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;
} }

View File

@ -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({

View File

@ -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;
} }

View File

@ -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;
} }

View File

@ -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 {

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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"

View File

@ -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>

View File

@ -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

View File

@ -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 {

View File

@ -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">{{

View File

@ -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> {

View File

@ -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>

View File

@ -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"'

View File

@ -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>

View File

@ -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>"