Migrate to Vue 3 and Vite

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel
2022-07-12 10:55:28 +02:00
parent 8f4099ee33
commit ee20e03cc2
464 changed files with 31515 additions and 32758 deletions

View File

@@ -1,95 +1,103 @@
<template>
<div>
<section class="container">
<div class="columns">
<div class="column is-one-quarter-desktop">
<aside class="menu">
<p class="menu-list">
<router-link :to="{ name: RouteName.ABOUT_INSTANCE }">{{
$t("About this instance")
}}</router-link>
<section class="container mx-auto">
<div class="flex flex-wrap gap-4">
<aside class="w-64 mt-6">
<div
class="overflow-y-auto py-4 px-3 bg-gray-50 rounded dark:bg-gray-800"
>
<p>
<router-link
class="flex items-center p-2 text-base font-normal text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700"
:to="{ name: RouteName.ABOUT_INSTANCE }"
>{{ t("About this instance") }}</router-link
>
</p>
<p class="menu-label has-text-grey-dark">
{{ $t("Legal") }}
{{ t("Legal") }}
</p>
<ul class="menu-list">
<ul>
<li>
<router-link :to="{ name: RouteName.TERMS }">{{
$t("Terms of service")
}}</router-link>
<router-link
class="flex items-center p-2 text-base font-normal text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700"
:to="{ name: RouteName.TERMS }"
>{{ t("Terms of service") }}</router-link
>
</li>
<li>
<router-link :to="{ name: RouteName.PRIVACY }">{{
$t("Privacy policy")
}}</router-link>
<router-link
class="flex items-center p-2 text-base font-normal text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700"
:to="{ name: RouteName.PRIVACY }"
>{{ t("Privacy policy") }}</router-link
>
</li>
<li>
<router-link :to="{ name: RouteName.RULES }">{{
$t("Instance rules")
}}</router-link>
<router-link
class="flex items-center p-2 text-base font-normal text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700"
:to="{ name: RouteName.RULES }"
>{{ t("Instance rules") }}</router-link
>
</li>
<li>
<router-link :to="{ name: RouteName.GLOSSARY }">{{
$t("Glossary")
}}</router-link>
<router-link
class="flex items-center p-2 text-base font-normal text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700"
:to="{ name: RouteName.GLOSSARY }"
>{{ t("Glossary") }}</router-link
>
</li>
</ul>
</aside>
</div>
<div class="column router">
</div>
</aside>
<div class="container mx-auto flex-1 bg-white dark:bg-gray-700">
<router-view />
</div>
</div>
</section>
<div class="hero intro is-small is-secondary">
<div class="hero-body">
<div class="container">
<h1 class="title">{{ $t("Powered by Mobilizon") }}</h1>
<p>
{{
$t(
"A user-friendly, emancipatory and ethical tool for gathering, organising, and mobilising."
)
}}
</p>
<b-button
icon-left="open-in-new"
size="is-large"
type="is-primary"
tag="a"
href="https://joinmobilizon.org"
>{{ $t("Learn more") }}</b-button
>
</div>
<div class="bg-secondary dark:bg-gray-700 p-6">
<div class="container mx-auto">
<h1 class="text-4xl font-bold text-black/70">
{{ t("Powered by Mobilizon") }}
</h1>
<p>
{{
t(
"A user-friendly, emancipatory and ethical tool for gathering, organising, and mobilising."
)
}}
</p>
<o-button
tag="a"
icon-left="open-in-new"
class="text-2xl bg-primary text-white leading-6"
href="https://joinmobilizon.org"
>{{ t("Learn more") }}</o-button
>
</div>
</div>
<div
class="hero register is-primary is-medium"
v-if="!currentUser || !currentUser.id"
>
<div class="hero-body">
<div class="container has-text-centered">
<div class="columns">
<div class="column" v-if="config && config.registrationsOpen">
<h2 class="title">{{ $t("Register on this instance") }}</h2>
<b-button
type="is-secondary"
size="is-large"
tag="router-link"
:to="{ name: RouteName.REGISTER }"
>{{ $t("Create an account") }}</b-button
>
</div>
<div class="column">
<h2 class="title">{{ $t("Find another instance") }}</h2>
<b-button
type="is-secondary"
size="is-large"
tag="a"
href="https://mobilizon.org"
>{{ $t("Pick an instance") }}</b-button
>
</div>
<div v-if="!currentUser || !currentUser.id" class="bg-purple-2 pb-3">
<div class="container mx-auto text-center py-10 px-6">
<div class="flex flex-wrap">
<div class="flex-1" v-if="config && config.registrationsOpen">
<h2 class="text-4xl text-violet-1 font-bold">
{{ t("Register on this instance") }}
</h2>
<o-button
tag="router-link"
class="bg-secondary text-lg text-black"
:to="{ name: RouteName.REGISTER }"
>{{ t("Create an account") }}</o-button
>
</div>
<div class="flex-1">
<h2 class="text-4xl text-violet-1 font-bold">
{{ t("Find another instance") }}
</h2>
<o-button
tag="a"
class="bg-secondary text-lg text-black"
href="https://mobilizon.org"
>{{ t("Pick an instance") }}</o-button
>
</div>
</div>
</div>
@@ -97,68 +105,30 @@
</div>
</template>
<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
<script lang="ts" setup>
import { CONFIG } from "@/graphql/config";
import { IConfig } from "@/types/config.model";
import RouteName from "../router/name";
import { CURRENT_USER_CLIENT } from "@/graphql/user";
import { ICurrentUser } from "@/types/current-user.model";
import { useQuery } from "@vue/apollo-composable";
import { computed } from "vue";
import { useCurrentUserClient } from "@/composition/apollo/user";
import { useI18n } from "vue-i18n";
@Component({
apollo: {
config: CONFIG,
currentUser: CURRENT_USER_CLIENT,
},
metaInfo() {
return {
title: this.$t("About {instance}", {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
instance: this?.config?.name,
}) as string,
};
},
})
export default class About extends Vue {
config!: IConfig;
currentUser!: ICurrentUser;
const { currentUser } = useCurrentUserClient();
RouteName = RouteName;
}
const { result: configResult } = useQuery<{ config: IConfig }>(CONFIG);
const config = computed(() => configResult.value?.config);
const { t } = useI18n({ useScope: "global" });
// metaInfo() {
// return {
// title: this.t("About {instance}", {
// // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// // @ts-ignore
// instance: this?.config?.name,
// }) as string,
// };
// },
</script>
<style lang="scss" scoped>
.hero.is-primary {
background: $background-color;
.title {
margin: 30px auto 1rem auto;
}
p {
margin-bottom: 1rem;
}
}
.hero.register {
.title {
color: $violet-1;
}
background: $purple-2;
}
aside.menu {
position: sticky;
top: 2rem;
margin-top: 2rem;
}
.router.column {
background: $white;
}
ul.menu-list > li > a {
text-decoration: none;
}
</style>

View File

@@ -1,111 +1,117 @@
<template>
<div v-if="config">
<section class="hero is-primary">
<div class="hero-body">
<div class="container">
<h1 class="title" dir="auto">{{ config.name }}</h1>
<p dir="auto">{{ config.description }}</p>
</div>
</div>
<section class="p-6 bg-primary text-white">
<h1 dir="auto">{{ config.name }}</h1>
<p dir="auto">{{ config.description }}</p>
</section>
<section class="columns contact-statistics" v-if="statistics">
<div class="column is-three-quarters-desktop statistics">
<i18n tag="p" path="Home to {number} users">
<strong slot="number">{{ statistics.numberOfUsers }}</strong>
</i18n>
<i18n tag="p" path="and {number} groups">
<strong slot="number">{{ statistics.numberOfLocalGroups }}</strong>
</i18n>
<i18n tag="p" path="Who published {number} events">
<strong slot="number">{{ statistics.numberOfLocalEvents }}</strong>
</i18n>
<i18n tag="p" path="And {number} comments">
<strong slot="number">{{ statistics.numberOfLocalComments }}</strong>
</i18n>
<section
class="px-2 flex flex-wrap gap-2 contact-statistics"
v-if="statistics"
>
<div class="statistics flex-1 min-w-[20rem]">
<i18n-t tag="p" keypath="Home to {number} users">
<template v-slot:number>
<strong>{{ statistics.numberOfUsers }}</strong>
</template>
</i18n-t>
<i18n-t tag="p" keypath="and {number} groups">
<template v-slot:number>
<strong>{{ statistics.numberOfLocalGroups }}</strong>
</template>
</i18n-t>
<i18n-t tag="p" keypath="Who published {number} events">
<template v-slot:number>
<strong>{{ statistics.numberOfLocalEvents }}</strong>
</template>
</i18n-t>
<i18n-t tag="p" keypath="And {number} comments">
<template v-slot:number>
<strong>{{ statistics.numberOfLocalComments }}</strong>
</template>
</i18n-t>
</div>
<div class="column contact">
<p class="has-text-weight-bold">{{ $t("Contact") }}</p>
<div class="">
<p class="font-bold">{{ t("Contact") }}</p>
<instance-contact-link
v-if="config && config.contact"
:contact="config.contact"
/>
<p v-else>{{ $t("No information") }}</p>
<p v-else>{{ t("No information") }}</p>
</div>
</section>
<hr role="presentation" />
<hr role="presentation" v-if="config.longDescription" />
<section class="long-description content">
<div v-html="config.longDescription" />
</section>
<hr role="presentation" />
<section class="config">
<h2 class="subtitle">{{ $t("Instance configuration") }}</h2>
<table class="table is-fullwidth">
<tr>
<td>{{ $t("Instance languages") }}</td>
<section class="px-3">
<h2 class="text-xl">{{ t("Instance configuration") }}</h2>
<table class="border-collapse table-auto w-full">
<tr class="bg-white border-b dark:bg-gray-800 dark:border-gray-700">
<td>{{ t("Instance languages") }}</td>
<td
v-if="config.languages.length > 0"
:title="this.config ? this.config.languages.join(', ') : ''"
:title="config.languages.join(', ') ?? ''"
>
{{ formattedLanguageList }}
</td>
<td v-else>{{ $t("No information") }}</td>
<td v-else>{{ t("No information") }}</td>
</tr>
<tr>
<td>{{ $t("Mobilizon version") }}</td>
<tr class="bg-white border-b dark:bg-gray-800 dark:border-gray-700">
<td>{{ t("Mobilizon version") }}</td>
<td>{{ config.version }}</td>
</tr>
<tr>
<td>{{ $t("Registrations") }}</td>
<tr class="bg-white border-b dark:bg-gray-800 dark:border-gray-700">
<td>{{ t("Registrations") }}</td>
<td v-if="config.registrationsOpen && config.registrationsAllowlist">
{{ $t("Restricted") }}
{{ t("Restricted") }}
</td>
<td v-if="config.registrationsOpen && !config.registrationsAllowlist">
{{ $t("Open") }}
{{ t("Open") }}
</td>
<td v-else>{{ $t("Closed") }}</td>
<td v-else>{{ t("Closed") }}</td>
</tr>
<tr>
<td>{{ $t("Federation") }}</td>
<td v-if="config.federating">{{ $t("Enabled") }}</td>
<td v-else>{{ $t("Disabled") }}</td>
<tr class="bg-white border-b dark:bg-gray-800 dark:border-gray-700">
<td>{{ t("Federation") }}</td>
<td v-if="config.federating">{{ t("Enabled") }}</td>
<td v-else>{{ t("Disabled") }}</td>
</tr>
<tr>
<td>{{ $t("Anonymous participations") }}</td>
<tr class="bg-white border-b dark:bg-gray-800 dark:border-gray-700">
<td>{{ t("Anonymous participations") }}</td>
<td v-if="config.anonymous.participation.allowed">
{{ $t("If allowed by organizer") }}
{{ t("If allowed by organizer") }}
</td>
<td v-else>{{ $t("Disabled") }}</td>
<td v-else>{{ t("Disabled") }}</td>
</tr>
<tr class="instance-feeds">
<td>{{ $t("Instance feeds") }}</td>
<td v-if="config.instanceFeeds.enabled" class="buttons">
<b-button
<tr class="bg-white border-b dark:bg-gray-800 dark:border-gray-700">
<td>{{ t("Instance feeds") }}</td>
<td v-if="config.instanceFeeds.enabled" class="flex gap-2">
<o-button
tag="a"
size="is-small"
size="small"
icon-left="rss"
href="/feed/instance/atom"
target="_blank"
>{{ $t("RSS/Atom Feed") }}</b-button
>{{ t("RSS/Atom Feed") }}</o-button
>
<b-button
<o-button
tag="a"
size="is-small"
size="small"
icon-left="calendar-sync"
href="/feed/instance/ics"
target="_blank"
>{{ $t("ICS/WebCal Feed") }}</b-button
>{{ t("ICS/WebCal Feed") }}</o-button
>
</td>
<td v-else>{{ $t("Disabled") }}</td>
<td v-else>{{ t("Disabled") }}</td>
</tr>
</table>
</section>
</div>
</template>
<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
<script lang="ts" setup>
import { formatList } from "@/utils/i18n";
import InstanceContactLink from "@/components/About/InstanceContactLink.vue";
import { LANGUAGES_CODES } from "@/graphql/admin";
@@ -114,51 +120,51 @@ import { ABOUT } from "../../graphql/config";
import { STATISTICS } from "../../graphql/statistics";
import { IConfig } from "../../types/config.model";
import { IStatistics } from "../../types/statistics.model";
import { useQuery } from "@vue/apollo-composable";
import { computed } from "vue";
import { useI18n } from "vue-i18n";
@Component({
apollo: {
config: ABOUT,
statistics: STATISTICS,
languages: {
query: LANGUAGES_CODES,
variables() {
return {
codes: this?.config.languages,
};
},
skip() {
return !this.config || !this.config?.languages;
},
},
},
components: {
InstanceContactLink,
},
metaInfo() {
return {
title: this.$t("About {instance}", {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
instance: this?.config?.name,
}) as string,
};
},
})
export default class AboutInstance extends Vue {
config!: IConfig;
const { result: configResult } = useQuery<{ config: IConfig }>(ABOUT);
statistics!: IStatistics;
const config = computed(() => configResult.value?.config);
languages!: ILanguage[];
const { result: statisticsResult } = useQuery<{ statistics: IStatistics }>(
STATISTICS
);
get formattedLanguageList(): string {
if (this.languages) {
const list = this.languages.map(({ name }) => name);
return formatList(list);
}
return "";
const statistics = computed(() => statisticsResult.value?.statistics);
const { result: languagesResult } = useQuery<{ languages: ILanguage[] }>(
LANGUAGES_CODES,
() => ({
codes: config.value?.languages,
}),
() => ({
enabled: config.value?.languages !== undefined,
})
);
const languages = computed(() => languagesResult.value?.languages);
const formattedLanguageList = computed((): string => {
if (languages.value) {
const list = languages.value?.map(({ name }) => name) ?? [];
return formatList(list);
}
}
return "";
});
const { t } = useI18n({ useScope: "global" });
// metaInfo() {
// return {
// title: this.t("About {instance}", {
// // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// // @ts-ignore
// instance: this?.config?.name,
// }) as string,
// };
// }
</script>
<style lang="scss" scoped>
@@ -194,17 +200,6 @@ section {
}
}
}
.contact {
h3 {
font-weight: bold;
}
p {
width: 200px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
tr.instance-feeds {
height: 3rem;

View File

@@ -1,60 +1,65 @@
<template>
<div class="container section">
<h2 class="title">{{ $t("Glossary") }}</h2>
<div class="content" v-if="config">
<div class="container mx-auto px-2">
<h1>{{ t("Glossary") }}</h1>
<div class="prose dark:prose-invert" v-if="config">
<p>
{{
$t(
t(
"Some terms, technical or otherwise, used in the text below may cover concepts that are difficult to grasp. We have provided a glossary here to help you understand them better:"
)
}}
</p>
<dl>
<dt>{{ $t("Instance") }}</dt>
<i18n
<dt>{{ t("Instance") }}</dt>
<i18n-t
tag="dd"
path="An instance is an installed version of the Mobilizon software running on a server. An instance can be run by anyone using the {mobilizon_software} or other federated apps, aka the “fediverse”. This instance's name is {instance_name}. Mobilizon is a federated network of multiple instances (just like email servers), users registered on different instances may communicate even though they didn't register on the same instance."
keypath="An instance is an installed version of the Mobilizon software running on a server. An instance can be run by anyone using the {mobilizon_software} or other federated apps, aka the “fediverse”. This instance's name is {instance_name}. Mobilizon is a federated network of multiple instances (just like email servers), users registered on different instances may communicate even though they didn't register on the same instance."
>
<a slot="mobilizon_software" href="https://joinmobilizon.org">{{
$t("Mobilizon software")
}}</a>
<b slot="instance_name">{{ config.name }}</b>
</i18n>
<dt>{{ $t("Instance administrator") }}</dt>
<template #mobilizon_software
><a href="https://joinmobilizon.org">{{
t("Mobilizon software")
}}</a></template
>
<template #instance_name>
<b>{{ config.name }}</b>
</template>
</i18n-t>
<dt>{{ t("Instance administrator") }}</dt>
<dd>
{{
$t(
t(
"The instance administrator is the person or entity that runs this Mobilizon instance."
)
}}
</dd>
<dt>{{ $t("Application") }}</dt>
<dt>{{ t("Application") }}</dt>
<dd>
{{
$t(
t(
"In the following context, an application is a software, either provided by the Mobilizon team or by a 3rd-party, used to interact with your instance."
)
}}
</dd>
<dt>{{ $t("API") }}</dt>
<dt>{{ t("API") }}</dt>
<dd>
{{
$t(
t(
"An “application programming interface” or “API” is a communication protocol that allows software components to communicate with each other. The Mobilizon API, for example, can allow third-party software tools to communicate with Mobilizon instances to carry out certain actions, such as posting events on your behalf, automatically and remotely."
)
}}
</dd>
<dt>{{ $t("SSL/TLS") }}</dt>
<i18n
<dt>{{ t("SSL/TLS") }}</dt>
<i18n-t
tag="dd"
path="SSL and it's successor TLS are encryption technologies to secure data communications when using the service. You can recognize an encrypted connection in your browser's address line when the URL begins with {https} and the lock icon is displayed in your browser's address bar."
keypath="SSL and it's successor TLS are encryption technologies to secure data communications when using the service. You can recognize an encrypted connection in your browser's address line when the URL begins with {https} and the lock icon is displayed in your browser's address bar."
>
<code slot="https">https://</code>
</i18n>
<dt>{{ $t("Cookies and Local storage") }}</dt>
<template v-slot:https><code>https://</code></template>
</i18n-t>
<dt>{{ t("Cookies and Local storage") }}</dt>
<dd>
{{
$t(
t(
"A cookie is a small file containing information that is sent to your computer when you visit a website. When you visit the site again, the cookie allows that site to recognize your browser. Cookies may store user preferences and other information. You can configure your browser to refuse all cookies. However, this may result in some website features or services partially working. Local storage works the same way but allows you to store more data."
)
}}
@@ -64,28 +69,28 @@
</div>
</template>
<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
<script lang="ts" setup>
import { useQuery } from "@vue/apollo-composable";
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import { ABOUT } from "../../graphql/config";
import { IConfig } from "../../types/config.model";
@Component({
apollo: {
config: ABOUT,
},
metaInfo() {
return {
title: this.$t("Glossary") as string,
};
},
})
export default class Glossary extends Vue {
config!: IConfig;
}
const { result: configResult } = useQuery<{ config: IConfig }>(ABOUT);
const config = computed(() => configResult.value?.config);
const { t } = useI18n({ useScope: "global" });
// metaInfo() {
// return {
// title: this.t("Glossary") as string,
// };
// },
</script>
<style lang="scss" scoped>
::v-deep dt {
:deep(dt) {
font-weight: bold;
}
</style>

View File

@@ -1,72 +1,47 @@
<template>
<div class="container section">
<h2 class="title">{{ $t("Privacy Policy") }}</h2>
<div class="container mx-auto px-2">
<h1>{{ t("Privacy Policy") }}</h1>
<div
class="content"
v-if="config && config.privacy"
class="prose dark:prose-invert"
v-if="config?.privacy"
v-html="config.privacy.bodyHtml"
/>
</div>
</template>
<script lang="ts">
import { Component, Vue, Watch } from "vue-property-decorator";
<script lang="ts" setup>
import { PRIVACY } from "@/graphql/config";
import { IConfig } from "@/types/config.model";
import { InstancePrivacyType } from "@/types/enums";
import { useQuery } from "@vue/apollo-composable";
import { computed, watch } from "vue";
import { useI18n } from "vue-i18n";
@Component({
apollo: {
config: {
query: PRIVACY,
variables() {
return {
locale: this.locale,
};
},
skip() {
return !this.locale;
},
},
},
metaInfo() {
return {
title: this.$t("Privacy Policy") as string,
};
},
})
export default class Privacy extends Vue {
config!: IConfig;
const { locale } = useI18n({ useScope: "global" });
locale: string | null = null;
const { result: configResult } = useQuery<{ config: IConfig }>(
PRIVACY,
() => ({
locale: locale.value,
}),
() => ({
enabled: locale.value !== undefined,
})
);
created(): void {
this.locale = this.$i18n.locale;
const config = computed(() => configResult.value?.config);
const { t } = useI18n({ useScope: "global" });
// metaInfo() {
// return {
// title: this.t("Privacy Policy") as string,
// };
// },
watch(config, () => {
if (config.value?.privacy?.type === InstancePrivacyType.URL) {
window.location.replace(config.value?.privacy?.url);
}
@Watch("config", { deep: true })
watchConfig(config: IConfig): void {
if (config?.privacy?.type) {
this.redirectToUrl();
}
}
redirectToUrl(): void {
if (this.config?.privacy?.type === InstancePrivacyType.URL) {
window.location.replace(this.config?.privacy?.url);
}
}
}
});
</script>
<style lang="scss" scoped>
main > .container {
background: $white;
::v-deep dt {
font-weight: bold;
}
}
.content ::v-deep li {
margin-bottom: 1rem;
}
</style>

View File

@@ -1,40 +1,31 @@
<template>
<div class="container section" v-if="config">
<h2 class="title">{{ $t("Rules") }}</h2>
<div class="content" v-html="config.rules" v-if="config.rules" />
<p v-else>{{ $t("No rules defined yet.") }}</p>
<div class="container mx-auto px-2" v-if="config">
<h1>{{ t("Rules") }}</h1>
<div
class="prose dark:prose-invert"
v-html="config.rules"
v-if="config.rules"
/>
<p v-else>{{ t("No rules defined yet.") }}</p>
</div>
</template>
<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
<script lang="ts" setup>
import { RULES } from "@/graphql/config";
import { IConfig } from "@/types/config.model";
import RouteName from "../../router/name";
import { useQuery } from "@vue/apollo-composable";
import { computed } from "vue";
import { useI18n } from "vue-i18n";
@Component({
apollo: {
config: {
query: RULES,
},
},
metaInfo() {
return {
title: this.$t("Rules") as string,
};
},
})
export default class Rules extends Vue {
config!: IConfig;
const { result: configResult } = useQuery<{ config: IConfig }>(RULES);
RouteName = RouteName;
}
const config = computed(() => configResult.value?.config);
const { t } = useI18n({ useScope: "global" });
// metaInfo() {
// return {
// title: this.t("Rules") as string,
// };
// },
</script>
<style lang="scss" scoped>
main > .container {
background: $white;
}
.content ::v-deep li {
margin-bottom: 1rem;
}
</style>

View File

@@ -1,61 +1,53 @@
<template>
<div class="container section">
<h2 class="title">{{ $t("Terms") }}</h2>
<div class="content" v-if="config" v-html="config.terms.bodyHtml" />
<div class="container mx-auto px-2">
<h1>{{ t("Terms") }}</h1>
<o-loading v-model="termsLoading" />
<div
class="prose dark:prose-invert"
v-if="config"
v-html="config.terms.bodyHtml"
/>
</div>
</template>
<script lang="ts">
import { Component, Vue, Watch } from "vue-property-decorator";
<script lang="ts" setup>
import { TERMS } from "@/graphql/config";
import { IConfig } from "@/types/config.model";
import { InstanceTermsType } from "@/types/enums";
import { useQuery } from "@vue/apollo-composable";
import { useHead } from "@vueuse/head";
import { computed, watch } from "vue";
import { useI18n } from "vue-i18n";
@Component({
apollo: {
config: {
query: TERMS,
variables() {
return {
locale: this.locale,
};
},
skip() {
return !this.locale;
},
},
},
metaInfo() {
return {
title: this.$t("Terms") as string,
};
},
})
export default class Terms extends Vue {
config!: IConfig;
const { t, locale } = useI18n({ useScope: "global" });
locale: string | null = null;
const { result: termsResult, loading: termsLoading } = useQuery<{
config: IConfig;
}>(
TERMS,
() => ({
locale: locale.value,
}),
() => ({
enabled: locale.value !== undefined,
})
);
created(): void {
this.locale = this.$i18n.locale;
const config = computed(() => termsResult.value?.config);
watch(config, () => {
if (config.value?.terms?.type) {
redirectToUrl();
}
});
@Watch("config", { deep: true })
watchConfig(config: IConfig): void {
if (config?.terms?.type) {
this.redirectToUrl();
}
const redirectToUrl = (): void => {
if (config.value?.terms?.type === InstanceTermsType.URL) {
window.location.replace(config.value?.terms?.url);
}
};
redirectToUrl(): void {
if (this.config?.terms?.type === InstanceTermsType.URL) {
window.location.replace(this.config?.terms?.url);
}
}
}
useHead({
title: computed(() => t("Terms")),
});
</script>
<style lang="scss" scoped>
.content ::v-deep li {
margin-bottom: 1rem;
}
</style>

View File

@@ -1,34 +1,31 @@
<template>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">{{ $t("Pick an identity") }}</p>
<div class="p-6">
<header class="">
<h2 class="">{{ $t("Pick an identity") }}</h2>
</header>
<section class="modal-card-body">
<section class="">
<div class="list is-hoverable list-none">
<a
class="list-item"
class="my-2 block dark:bg-violet-3 rounded-xl p-2"
v-for="identity in identities"
:key="identity.id"
:class="{
'is-active': currentIdentity && identity.id === currentIdentity.id,
active: currentIdentity && identity.id === currentIdentity.id,
}"
@click="currentIdentity = identity"
>
<div class="media">
<div class="flex gap-2">
<img
class="media-left image is-48x48"
class="rounded"
v-if="identity.avatar"
:src="identity.avatar.url"
alt=""
width="48"
height="48"
/>
<b-icon
class="media-left"
v-else
size="is-large"
icon="account-circle"
/>
<div class="media-content">
<h3>@{{ identity.preferredUsername }}</h3>
<AccountCircle v-else :size="48" />
<div class="">
<p>@{{ identity.preferredUsername }}</p>
<small>{{ identity.name }}</small>
</div>
</div>
@@ -38,34 +35,34 @@
<slot name="footer" />
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
import { IActor } from "@/types/actor";
import { IDENTITIES } from "@/graphql/actor";
<script lang="ts" setup>
import { IPerson } from "@/types/actor";
import { useCurrentUserIdentities } from "@/composition/apollo/actor";
import { computed } from "vue";
import AccountCircle from "vue-material-design-icons/AccountCircle.vue";
import { useI18n } from "vue-i18n";
import { useHead } from "@vueuse/head";
@Component({
apollo: {
identities: {
query: IDENTITIES,
},
const { identities } = useCurrentUserIdentities();
const { t } = useI18n({ useScope: "global" });
useHead({
title: computed(() => t("Identities")),
});
const props = defineProps<{
modelValue: IPerson;
}>();
const emit = defineEmits(["update:modelValue"]);
const currentIdentity = computed<IPerson>({
get(): IPerson {
return props.modelValue;
},
metaInfo() {
return {
title: this.$t("Identities") as string,
};
set(identity: IPerson) {
emit("update:modelValue", identity);
},
})
export default class IdentityPicker extends Vue {
@Prop() value!: IActor;
identities: IActor[] = [];
get currentIdentity(): IActor {
return this.value;
}
set currentIdentity(identity: IActor) {
this.$emit("input", identity);
}
}
});
</script>

View File

@@ -1,122 +1,108 @@
<template>
<div class="identity-picker">
<div>
<div
v-if="inline && currentIdentity"
class="inline box"
:class="{
'has-background-grey-lighter': masked,
'no-other-identity': !hasOtherIdentities,
}"
class="inline box cursor-pointer"
@click="activateModal"
>
<div class="media">
<div class="media-left">
<figure class="image is-48x48" v-if="currentIdentity.avatar">
<div class="flex gap-1">
<div class="">
<figure class="" v-if="currentIdentity.avatar">
<img
class="image is-rounded"
class="rounded-full"
:src="currentIdentity.avatar.url"
alt=""
width="48"
height="48"
/>
</figure>
<b-icon v-else size="is-large" icon="account-circle" />
<AccountCircle v-else :size="48" />
</div>
<div class="media-content" v-if="currentIdentity.name">
<p class="is-4">{{ currentIdentity.name }}</p>
<p class="is-6 has-text-grey">
<div class="" v-if="currentIdentity.name">
<p class="">{{ currentIdentity.name }}</p>
<p class="">
{{ `@${currentIdentity.preferredUsername}` }}
<span v-if="masked">{{ $t("(Masked)") }}</span>
</p>
</div>
<div class="media-content" v-else>
<div class="" v-else>
{{ `@${currentIdentity.preferredUsername}` }}
</div>
<b-button
type="is-text"
v-if="identities.length > 1"
<o-button
variant="text"
v-if="identities && identities.length > 1"
@click="activateModal"
>
{{ $t("Change") }}
</b-button>
</o-button>
</div>
</div>
<span v-else-if="currentIdentity" class="block" @click="activateModal">
<figure class="image is-48x48" v-if="currentIdentity.avatar">
<img class="is-rounded" :src="currentIdentity.avatar.url" alt="" />
<span
v-else-if="currentIdentity"
class="cursor-pointer"
@click="activateModal"
>
<figure class="" v-if="currentIdentity.avatar">
<img
class="rounded"
:src="currentIdentity.avatar.url"
alt=""
width="48"
height="48"
/>
</figure>
<b-icon v-else size="is-large" icon="account-circle" />
<AccountCircle v-else :size="48" />
</span>
<b-modal
v-model="isComponentModalActive"
has-modal-card
<o-modal
v-model:active="isComponentModalActive"
:close-button-aria-label="$t('Close')"
>
<identity-picker v-model="currentIdentity" />
</b-modal>
<identity-picker v-if="currentIdentity" v-model="currentIdentity" />
</o-modal>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue, Watch } from "vue-property-decorator";
import { IDENTITIES } from "@/graphql/actor";
import { IActor } from "../../types/actor";
<script lang="ts" setup>
import { useCurrentUserIdentities } from "@/composition/apollo/actor";
import { computed, ref } from "vue";
import { IPerson } from "../../types/actor";
import IdentityPicker from "./IdentityPicker.vue";
import AccountCircle from "vue-material-design-icons/AccountCircle.vue";
@Component({
components: { IdentityPicker },
apollo: {
identities: {
query: IDENTITIES,
},
const { identities } = useCurrentUserIdentities();
const props = withDefaults(
defineProps<{
modelValue: IPerson;
inline?: boolean;
masked?: boolean;
}>(),
{
inline: true,
masked: false,
}
);
const emit = defineEmits(["update:modelValue"]);
const isComponentModalActive = ref(false);
const currentIdentity = computed({
get(): IPerson | undefined {
return props.modelValue;
},
})
export default class IdentityPickerWrapper extends Vue {
@Prop() value!: IActor;
set(identity: IPerson | undefined) {
emit("update:modelValue", identity);
isComponentModalActive.value = false;
},
});
@Prop({ default: true, type: Boolean }) inline!: boolean;
const hasOtherIdentities = computed((): boolean => {
return identities.value !== undefined && identities.value.length > 1;
});
@Prop({ type: Boolean, required: false, default: false }) masked!: boolean;
isComponentModalActive = false;
identities: IActor[] = [];
@Watch("value")
updateCurrentActor(value: IActor): void {
this.currentIdentity = value;
const activateModal = (): void => {
if (hasOtherIdentities.value) {
isComponentModalActive.value = true;
}
get currentIdentity(): IActor | undefined {
return this.value;
}
set currentIdentity(identity: IActor | undefined) {
this.$emit("input", identity);
this.isComponentModalActive = false;
}
get hasOtherIdentities(): boolean {
return this.identities.length > 1;
}
activateModal(): void {
if (this.hasOtherIdentities) {
this.isComponentModalActive = true;
}
}
}
};
</script>
<style lang="scss">
.identity-picker {
.block {
cursor: pointer;
}
.inline:not(.no-other-identity) {
cursor: pointer;
}
.media {
border-top: none;
padding-top: 0;
}
}
</style>

View File

@@ -1,43 +1,43 @@
<template>
<section class="section container">
<div class="columns is-mobile is-centered">
<div class="column is-half-desktop">
<h1 class="title" v-if="userAlreadyActivated">
<section class="container mx-auto">
<div class="">
<div class="">
<h1 class="text-2xl" v-if="userAlreadyActivated">
{{ $t("Congratulations, your account is now created!") }}
</h1>
<h1 class="title" v-else>
<h1 class="text-2xl" v-else>
{{
$t("Register an account on {instanceName}!", {
instanceName: config.name,
instanceName,
})
}}
</h1>
<p class="content" v-if="userAlreadyActivated">
<p class="prose dark:prose-invert" v-if="userAlreadyActivated">
{{ $t("Now, create your first profile:") }}
</p>
<form v-if="!validationSent" @submit.prevent="submit">
<b-field :label="$t('Displayed nickname')">
<b-input
<o-field :label="$t('Displayed nickname')">
<o-input
aria-required="true"
required
v-model="identity.name"
@input="autoUpdateUsername($event)"
@input="autoUpdateUsername"
/>
</b-field>
</o-field>
<b-field
<o-field
:label="$t('Username')"
:type="errors.preferred_username ? 'is-danger' : null"
:message="errors.preferred_username"
>
<b-field
<o-field
:message="
$t(
'Only alphanumeric lowercased characters and underscores are supported.'
)
"
>
<b-input
<o-input
aria-required="true"
required
expanded
@@ -53,8 +53,8 @@
<p class="control">
<span class="button is-static">@{{ host }}</span>
</p>
</b-field>
</b-field>
</o-field>
</o-field>
<p class="description">
{{
$t(
@@ -63,16 +63,16 @@
}}
</p>
<b-field :label="$t('Short bio')">
<b-input
<o-field :label="$t('Short bio')">
<o-input
type="textarea"
maxlength="100"
rows="2"
v-model="identity.summary"
/>
</b-field>
</o-field>
<p class="content">
<p class="prose dark:prose-invert">
{{
$t(
"You will be able to add an avatar and set other options in your account settings."
@@ -81,28 +81,30 @@
</p>
<p class="control has-text-centered">
<b-button
type="is-primary"
size="is-large"
<o-button
variant="primary"
size="large"
native-type="submit"
:disabled="sendingValidation"
>{{ $t("Create my profile") }}</b-button
>{{ $t("Create my profile") }}</o-button
>
</p>
</form>
<div v-if="validationSent && !userAlreadyActivated">
<b-message type="is-success" :closable="false">
<o-notification variant="success" :closable="false">
<h2 class="title">
{{
$t("Your account is nearly ready, {username}", {
username: identity.name || identity.preferredUsername,
username: identity.name ?? identity.preferredUsername,
})
}}
</h2>
<i18n path="A validation email was sent to {email}" tag="p">
<code slot="email">{{ email }}</code>
</i18n>
<i18n-t keypath="A validation email was sent to {email}" tag="p">
<template #email>
<code>{{ email }}</code>
</template>
</i18n-t>
<p>
{{
$t(
@@ -110,120 +112,99 @@
)
}}
</p>
</b-message>
</o-notification>
</div>
</div>
</div>
</section>
</template>
<script lang="ts">
import { Component, Prop } from "vue-property-decorator";
import { mixins } from "vue-class-component";
import { CONFIG } from "@/graphql/config";
import { IConfig } from "@/types/config.model";
import { IPerson } from "../../types/actor";
import { IDENTITIES, REGISTER_PERSON } from "../../graphql/actor";
<script lang="ts" setup>
import { Person } from "../../types/actor";
import { MOBILIZON_INSTANCE_HOST } from "../../api/_entrypoint";
import RouteName from "../../router/name";
import { changeIdentity } from "../../utils/auth";
import identityEditionMixin from "../../mixins/identityEdition";
import { ApolloCache, FetchResult } from "@apollo/client/core";
import { ActorType } from "@/types/enums";
import { changeIdentity } from "../../utils/identity";
import { useInstanceName } from "@/composition/apollo/config";
import { ref, computed, onBeforeMount } from "vue";
import { useRouter } from "vue-router";
import { registerAccount } from "@/composition/apollo/user";
import { convertToUsername } from "@/utils/username";
import { useI18n } from "vue-i18n";
import { useHead } from "@vueuse/head";
@Component({
apollo: {
config: CONFIG,
},
metaInfo() {
return {
title: this.$t("Register") as string,
};
},
})
export default class Register extends mixins(identityEditionMixin) {
@Prop({ type: String, required: true }) email!: string;
@Prop({ type: Boolean, required: false, default: false })
userAlreadyActivated!: boolean;
config!: IConfig;
host?: string = MOBILIZON_INSTANCE_HOST;
errors: Record<string, unknown> = {};
validationSent = false;
sendingValidation = false;
async created(): Promise<void> {
// Make sure no one goes to this page if we don't want to
if (!this.email) {
await this.$router.replace({ name: RouteName.PAGE_NOT_FOUND });
}
const props = withDefaults(
defineProps<{
email: string;
userAlreadyActivated?: boolean;
}>(),
{
userAlreadyActivated: false,
}
);
async submit(): Promise<void> {
try {
this.sendingValidation = true;
this.errors = {};
const { data } = await this.$apollo.mutate<{ registerPerson: IPerson }>({
mutation: REGISTER_PERSON,
variables: { email: this.email, ...this.identity },
update: (
store: ApolloCache<{ registerPerson: IPerson }>,
{ data: localData }: FetchResult
) => {
if (this.userAlreadyActivated) {
const identitiesData = store.readQuery<{ identities: IPerson[] }>({
query: IDENTITIES,
});
const { instanceName } = useInstanceName();
if (identitiesData && localData) {
const newPersonData = {
...localData.registerPerson,
type: ActorType.PERSON,
};
const router = useRouter();
store.writeQuery({
query: IDENTITIES,
data: {
...identitiesData,
identities: [...identitiesData.identities, newPersonData],
},
});
}
}
},
});
if (data) {
this.validationSent = true;
window.localStorage.setItem("new-registered-user", "yes");
const { t } = useI18n({ useScope: "global" });
if (this.userAlreadyActivated) {
await changeIdentity(
this.$apollo.provider.defaultClient,
data.registerPerson
);
useHead({
title: computed(() => t("Register")),
});
await this.$router.push({ name: RouteName.HOME });
}
}
} catch (errorCatched: any) {
this.errors = errorCatched.graphQLErrors.reduce(
(acc: { [key: string]: string }, error: any) => {
acc[error.details || error.field] = error.message;
return acc;
},
{}
);
console.error("Error while registering person", errorCatched);
console.error("Errors while registering person", this.errors);
this.sendingValidation = false;
}
const host: string = MOBILIZON_INSTANCE_HOST;
const errors = ref<Record<string, unknown>>({});
const validationSent = ref(false);
const sendingValidation = ref(false);
const identity = ref(new Person());
onBeforeMount(() => {
// Make sure no one goes to this page if we don't want to
if (!props.email) {
router.replace({ name: RouteName.PAGE_NOT_FOUND });
}
}
});
const autoUpdateUsername = () => {
identity.value.preferredUsername = convertToUsername(identity.value.name);
};
const submit = async (): Promise<void> => {
sendingValidation.value = true;
errors.value = {};
const { onDone, onError } = registerAccount(
{ email: props.email, ...identity.value },
props.userAlreadyActivated
);
onDone(async ({ data }) => {
validationSent.value = true;
window.localStorage.setItem("new-registered-user", "yes");
if (data && props.userAlreadyActivated) {
await changeIdentity(data.registerPerson);
await router.push({ name: RouteName.HOME });
}
});
onError((err) => {
errors.value = err.graphQLErrors.reduce(
(acc: { [key: string]: string }, error: any) => {
acc[error.details || error.field] = error.message;
return acc;
},
{}
);
console.error("Error while registering person", err);
console.error("Errors while registering person", errors);
sendingValidation.value = false;
});
};
</script>
<style lang="scss" scoped>

File diff suppressed because it is too large Load Diff

View File

@@ -2,10 +2,10 @@
<div v-if="group" class="section">
<breadcrumbs-nav
:links="[
{ name: RouteName.ADMIN, text: $t('Admin') },
{ name: RouteName.ADMIN, text: t('Admin') },
{
name: RouteName.ADMIN_GROUPS,
text: $t('Groups'),
text: t('Groups'),
},
{
name: RouteName.PROFILES,
@@ -39,7 +39,7 @@
/>
</router-link>
</div>
<table v-if="metadata.length > 0" class="table is-fullwidth">
<table v-if="metadata.length > 0" class="table w-full">
<tbody>
<tr v-for="{ key, value, link } in metadata" :key="key">
<td>{{ key }}</td>
@@ -52,52 +52,64 @@
</tr>
</tbody>
</table>
<div class="buttons">
<b-button
<div class="flex gap-1">
<o-button
@click="confirmSuspendProfile"
v-if="!group.suspended"
type="is-primary"
>{{ $t("Suspend") }}</b-button
variant="primary"
>{{ t("Suspend") }}</o-button
>
<b-button
@click="unsuspendProfile"
<o-button
@click="
unsuspendProfile({
id,
})
"
v-if="group.suspended"
type="is-primary"
>{{ $t("Unsuspend") }}</b-button
variant="primary"
>{{ t("Unsuspend") }}</o-button
>
<b-button
@click="refreshProfile"
<o-button
@click="
refreshProfile({
actorId: id,
})
"
v-if="group.domain"
type="is-primary"
variant="primary"
outlined
>{{ $t("Refresh profile") }}</b-button
>{{ t("Refresh profile") }}</o-button
>
</div>
<section>
<h2 class="subtitle">
<h2>
{{
$tc("{number} members", group.members.total, {
number: group.members.total,
})
t(
"{number} members",
{
number: group.members.total,
},
group.members.total
)
}}
</h2>
<b-table
<o-table
:data="group.members.elements"
:loading="$apollo.queries.group.loading"
:loading="loading"
paginated
backend-pagination
:current-page.sync="membersPage"
:aria-next-label="$t('Next page')"
:aria-previous-label="$t('Previous page')"
:aria-page-label="$t('Page')"
:aria-current-label="$t('Current page')"
v-model:current-page="membersPage"
:aria-next-label="t('Next page')"
:aria-previous-label="t('Previous page')"
:aria-page-label="t('Page')"
:aria-current-label="t('Current page')"
:total="group.members.total"
:per-page="MEMBERS_PER_PAGE"
@page-change="onMembersPageChange"
>
<b-table-column
<o-table-column
field="actor.preferredUsername"
:label="$t('Member')"
:label="t('Member')"
v-slot="props"
>
<article class="media">
@@ -111,14 +123,14 @@
alt=""
/>
</figure>
<b-icon
<o-icon
class="media-left"
v-else
size="is-large"
size="large"
icon="account-circle"
/>
<div class="media-content">
<div class="content">
<div class="prose dark:prose-invert">
<span v-if="props.row.actor.name">{{
props.row.actor.name
}}</span
@@ -132,163 +144,166 @@
</div>
</div>
</article>
</b-table-column>
<b-table-column field="role" :label="$t('Role')" v-slot="props">
</o-table-column>
<o-table-column field="role" :label="t('Role')" v-slot="props">
<b-tag
type="is-primary"
variant="primary"
v-if="props.row.role === MemberRole.ADMINISTRATOR"
>
{{ $t("Administrator") }}
{{ t("Administrator") }}
</b-tag>
<b-tag
type="is-primary"
variant="primary"
v-else-if="props.row.role === MemberRole.MODERATOR"
>
{{ $t("Moderator") }}
{{ t("Moderator") }}
</b-tag>
<b-tag v-else-if="props.row.role === MemberRole.MEMBER">
{{ $t("Member") }}
{{ t("Member") }}
</b-tag>
<b-tag
type="is-warning"
variant="warning"
v-else-if="props.row.role === MemberRole.NOT_APPROVED"
>
{{ $t("Not approved") }}
{{ t("Not approved") }}
</b-tag>
<b-tag
type="is-danger"
variant="danger"
v-else-if="props.row.role === MemberRole.REJECTED"
>
{{ $t("Rejected") }}
{{ t("Rejected") }}
</b-tag>
<b-tag
type="is-danger"
variant="danger"
v-else-if="props.row.role === MemberRole.INVITED"
>
{{ $t("Invited") }}
{{ t("Invited") }}
</b-tag>
</b-table-column>
<b-table-column field="insertedAt" :label="$t('Date')" v-slot="props">
</o-table-column>
<o-table-column field="insertedAt" :label="t('Date')" v-slot="props">
<span class="has-text-centered">
{{ props.row.insertedAt | formatDateString }}<br />{{
props.row.insertedAt | formatTimeString
{{ formatDateString(props.row.insertedAt) }}<br />{{
formatTimeString(props.row.insertedAt)
}}
</span>
</b-table-column>
<template slot="empty">
</o-table-column>
<template #empty>
<empty-content icon="account-group" :inline="true">
{{ $t("No members found") }}
{{ t("No members found") }}
</empty-content>
</template>
</b-table>
</o-table>
</section>
<section>
<h2 class="subtitle">
<h2>
{{
$tc("{number} organized events", group.organizedEvents.total, {
number: group.organizedEvents.total,
})
t(
"{number} organized events",
{
number: group.organizedEvents.total,
},
group.organizedEvents.total
)
}}
</h2>
<b-table
<o-table
:data="group.organizedEvents.elements"
:loading="$apollo.queries.group.loading"
:loading="loading"
paginated
backend-pagination
:current-page.sync="organizedEventsPage"
:aria-next-label="$t('Next page')"
:aria-previous-label="$t('Previous page')"
:aria-page-label="$t('Page')"
:aria-current-label="$t('Current page')"
v-model:current-page="organizedEventsPage"
:aria-next-label="t('Next page')"
:aria-previous-label="t('Previous page')"
:aria-page-label="t('Page')"
:aria-current-label="t('Current page')"
:total="group.organizedEvents.total"
:per-page="EVENTS_PER_PAGE"
@page-change="onOrganizedEventsPageChange"
>
<b-table-column field="title" :label="$t('Title')" v-slot="props">
<o-table-column field="title" :label="t('Title')" v-slot="props">
<router-link
:to="{ name: RouteName.EVENT, params: { uuid: props.row.uuid } }"
>
{{ props.row.title }}
<b-tag type="is-info" v-if="props.row.draft">{{
$t("Draft")
<b-tag variant="info" v-if="props.row.draft">{{
t("Draft")
}}</b-tag>
</router-link>
</b-table-column>
<b-table-column
field="beginsOn"
:label="$t('Begins on')"
v-slot="props"
>
{{ props.row.beginsOn | formatDateTimeString }}
</b-table-column>
<template slot="empty">
</o-table-column>
<o-table-column field="beginsOn" :label="t('Begins on')" v-slot="props">
{{ formatDateTimeString(props.row.beginsOn) }}
</o-table-column>
<template #empty>
<empty-content icon="account-group" :inline="true">
{{ $t("No organized events found") }}
{{ t("No organized events found") }}
</empty-content>
</template>
</b-table>
</o-table>
</section>
<section>
<h2 class="subtitle">
<h2>
{{
$tc("{number} posts", group.posts.total, {
number: group.posts.total,
})
t(
"{number} posts",
{
number: group.posts.total,
},
group.posts.total
)
}}
</h2>
<b-table
<o-table
:data="group.posts.elements"
:loading="$apollo.queries.group.loading"
:loading="loading"
paginated
backend-pagination
:current-page.sync="postsPage"
:aria-next-label="$t('Next page')"
:aria-previous-label="$t('Previous page')"
:aria-page-label="$t('Page')"
:aria-current-label="$t('Current page')"
v-model:current-page="postsPage"
:aria-next-label="t('Next page')"
:aria-previous-label="t('Previous page')"
:aria-page-label="t('Page')"
:aria-current-label="t('Current page')"
:total="group.posts.total"
:per-page="POSTS_PER_PAGE"
@page-change="onPostsPageChange"
>
<b-table-column field="title" :label="$t('Title')" v-slot="props">
<o-table-column field="title" :label="t('Title')" v-slot="props">
<router-link
:to="{ name: RouteName.POST, params: { slug: props.row.slug } }"
>
{{ props.row.title }}
<b-tag type="is-info" v-if="props.row.draft">{{
$t("Draft")
<b-tag variant="info" v-if="props.row.draft">{{
t("Draft")
}}</b-tag>
</router-link>
</b-table-column>
<b-table-column
</o-table-column>
<o-table-column
field="publishAt"
:label="$t('Publication date')"
:label="t('Publication date')"
v-slot="props"
>
{{ props.row.publishAt | formatDateTimeString }}
</b-table-column>
<template slot="empty">
{{ formatDateTimeString(props.row.publishAt) }}
</o-table-column>
<template #empty>
<empty-content icon="bullhorn" :inline="true">
{{ $t("No posts found") }}
{{ t("No posts found") }}
</empty-content>
</template>
</b-table>
</o-table>
</section>
</div>
<empty-content v-else-if="!$apollo.loading" icon="account-multiple">
{{ $t("This group was not found") }}
<empty-content v-else-if="!loading" icon="account-multiple">
{{ t("This group was not found") }}
<template #desc>
<b-button
<o-button
type="is-text"
tag="router-link"
:to="{ name: RouteName.ADMIN_GROUPS }"
>{{ $t("Back to group list") }}</b-button
>{{ t("Back to group list") }}</o-button
>
</template>
</empty-content>
</template>
<script lang="ts">
import { Component, Vue, Prop } from "vue-property-decorator";
<script lang="ts" setup>
import { GET_GROUP, REFRESH_PROFILE } from "@/graphql/group";
import { formatBytes } from "@/utils/datetime";
import { MemberRole } from "@/types/enums";
@@ -303,273 +318,216 @@ import RouteName from "../../router/name";
import ActorCard from "../../components/Account/ActorCard.vue";
import EmptyContent from "../../components/Utils/EmptyContent.vue";
import { ApolloCache, FetchResult } from "@apollo/client/core";
import VueRouter from "vue-router";
const { isNavigationFailure, NavigationFailureType } = VueRouter;
import { useMutation, useQuery } from "@vue/apollo-composable";
import { computed, inject } from "vue";
import { useHead } from "@vueuse/head";
import { integerTransformer, useRouteQuery } from "vue-use-route-query";
import { useI18n } from "vue-i18n";
import {
formatTimeString,
formatDateString,
formatDateTimeString,
} from "@/filters/datetime";
import { Dialog } from "@/plugins/dialog";
import { Notifier } from "@/plugins/notifier";
const EVENTS_PER_PAGE = 10;
const POSTS_PER_PAGE = 10;
const MEMBERS_PER_PAGE = 10;
@Component({
apollo: {
group: {
query: GET_GROUP,
fetchPolicy: "cache-and-network",
variables() {
return {
id: this.id,
organizedEventsPage: this.organizedEventsPage,
organizedEventsLimit: EVENTS_PER_PAGE,
postsPage: this.postsPage,
postsLimit: POSTS_PER_PAGE,
membersLimit: MEMBERS_PER_PAGE,
membersPage: this.membersPage,
};
},
skip() {
return !this.id;
},
update: (data) => data.getGroup,
const props = defineProps<{ id: string }>();
const organizedEventsPage = useRouteQuery(
"organizedEventsPage",
1,
integerTransformer
);
const membersPage = useRouteQuery("membersPage", 1, integerTransformer);
const postsPage = useRouteQuery("postsPage", 1, integerTransformer);
const {
result: groupResult,
loading,
fetchMore,
} = useQuery(
GET_GROUP,
() => ({
id: props.id,
organizedEventsPage: organizedEventsPage.value,
organizedEventsLimit: EVENTS_PER_PAGE,
postsPage: postsPage.value,
postsLimit: POSTS_PER_PAGE,
membersLimit: MEMBERS_PER_PAGE,
membersPage: membersPage.value,
}),
() => ({
enabled: props.id !== undefined,
})
);
const group = computed(() => groupResult.value?.getGroup);
const { t } = useI18n({ useScope: "global" });
useHead({
title: computed(() => displayName(group.value)),
});
const metadata = computed((): Array<Record<string, string>> => {
if (!group.value) return [];
const res: Record<string, string>[] = [
{
key: t("Status") as string,
value: (group.value.suspended ? t("Suspended") : t("Active")) as string,
},
},
components: {
ActorCard,
EmptyContent,
},
metaInfo() {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const { group } = this;
return {
title: group ? group.name || usernameWithDomain(group) : "",
};
},
})
export default class AdminGroupProfile extends Vue {
@Prop({ required: true }) id!: string;
{
key: t("Domain") as string,
value: (group.value.domain ? group.value.domain : t("Local")) as string,
},
{
key: t("Uploaded media size") as string,
value: formatBytes(group.value.mediaSize),
},
];
return res;
});
group!: IGroup;
const dialog = inject<Dialog>("dialog");
const notifier = inject<Notifier>("notifier");
usernameWithDomain = usernameWithDomain;
const confirmSuspendProfile = (): void => {
const message = (
group.value.domain
? t(
"Are you sure you want to <b>suspend</b> this group? As this group originates from instance {instance}, this will only remove local members and delete the local data, as well as rejecting all the future data.",
{ instance: group.value.domain }
)
: t(
"Are you sure you want to <b>suspend</b> this group? All members - including remote ones - will be notified and removed from the group, and <b>all of the group data (events, posts, discussions, todos…) will be irretrievably destroyed</b>."
)
) as string;
displayName = displayName;
dialog?.confirm({
title: t("Suspend group") as string,
message,
confirmText: t("Suspend group") as string,
cancelText: t("Cancel") as string,
type: "danger",
hasIcon: true,
onConfirm: () =>
suspendProfile({
id: props.id,
}),
});
};
RouteName = RouteName;
const { mutate: suspendProfile, onError: onSuspendProfileError } = useMutation<{
suspendProfile: { id: string };
}>(SUSPEND_PROFILE, () => ({
update: (
store: ApolloCache<{ suspendProfile: { id: string } }>,
{ data }: FetchResult
) => {
if (data == null) return;
const profileId = props.id;
EVENTS_PER_PAGE = EVENTS_PER_PAGE;
POSTS_PER_PAGE = POSTS_PER_PAGE;
MEMBERS_PER_PAGE = MEMBERS_PER_PAGE;
MemberRole = MemberRole;
get organizedEventsPage(): number {
return parseInt(
(this.$route.query.organizedEventsPage as string) || "1",
10
);
}
set organizedEventsPage(page: number) {
this.pushRouter({ organizedEventsPage: page.toString() });
}
get membersPage(): number {
return parseInt((this.$route.query.membersPage as string) || "1", 10);
}
set membersPage(page: number) {
this.pushRouter({ membersPage: page.toString() });
}
get postsPage(): number {
return parseInt((this.$route.query.postsPage as string) || "1", 10);
}
set postsPage(page: number) {
this.pushRouter({ postsPage: page.toString() });
}
get metadata(): Array<Record<string, string>> {
if (!this.group) return [];
const res: Record<string, string>[] = [
{
key: this.$t("Status") as string,
value: (this.group.suspended
? this.$t("Suspended")
: this.$t("Active")) as string,
},
{
key: this.$t("Domain") as string,
value: (this.group.domain
? this.group.domain
: this.$t("Local")) as string,
},
{
key: this.$i18n.t("Uploaded media size") as string,
value: formatBytes(this.group.mediaSize),
},
];
return res;
}
confirmSuspendProfile(): void {
const message = (
this.group.domain
? this.$t(
"Are you sure you want to <b>suspend</b> this group? As this group originates from instance {instance}, this will only remove local members and delete the local data, as well as rejecting all the future data.",
{ instance: this.group.domain }
)
: this.$t(
"Are you sure you want to <b>suspend</b> this group? All members - including remote ones - will be notified and removed from the group, and <b>all of the group data (events, posts, discussions, todos…) will be irretrievably destroyed</b>."
)
) as string;
this.$buefy.dialog.confirm({
title: this.$t("Suspend group") as string,
message,
confirmText: this.$t("Suspend group") as string,
cancelText: this.$t("Cancel") as string,
type: "is-danger",
hasIcon: true,
onConfirm: () => this.suspendProfile(),
});
}
async suspendProfile(): Promise<void> {
try {
await this.$apollo.mutate<{ suspendProfile: { id: string } }>({
mutation: SUSPEND_PROFILE,
variables: {
id: this.id,
},
update: (
store: ApolloCache<{ suspendProfile: { id: string } }>,
{ data }: FetchResult
) => {
if (data == null) return;
const profileId = this.id;
const profileData = store.readQuery<{ getGroup: IGroup }>({
query: GET_GROUP,
variables: {
id: profileId,
organizedEventsPage: this.organizedEventsPage,
organizedEventsLimit: EVENTS_PER_PAGE,
postsPage: this.postsPage,
postsLimit: POSTS_PER_PAGE,
},
});
if (!profileData) return;
store.writeQuery({
query: GET_GROUP,
variables: {
id: profileId,
},
data: {
getGroup: {
...profileData.getGroup,
suspended: true,
avatar: null,
name: "",
summary: "",
},
},
});
},
});
} catch (e) {
console.error(e);
this.$notifier.error(this.$t("Error while suspending group") as string);
}
}
async unsuspendProfile(): Promise<void> {
try {
const profileID = this.id;
await this.$apollo.mutate<{ unsuspendProfile: { id: string } }>({
mutation: UNSUSPEND_PROFILE,
variables: {
id: this.id,
},
refetchQueries: [
{
query: GET_GROUP,
variables: {
id: profileID,
},
},
],
});
} catch (e) {
console.error(e);
this.$notifier.error(this.$t("Error while suspending group") as string);
}
}
async refreshProfile(): Promise<void> {
try {
this.$apollo.mutate<{ refreshProfile: IActor }>({
mutation: REFRESH_PROFILE,
variables: {
actorId: this.id,
},
});
this.$notifier.success(
this.$t("Triggered profile refreshment") as string
);
} catch (e) {
console.error(e);
this.$notifier.error(this.$t("Error while suspending group") as string);
}
}
async onOrganizedEventsPageChange(page: number): Promise<void> {
this.organizedEventsPage = page;
await this.$apollo.queries.group.fetchMore({
const profileData = store.readQuery<{ getGroup: IGroup }>({
query: GET_GROUP,
variables: {
actorId: this.id,
organizedEventsPage: this.organizedEventsPage,
id: profileId,
organizedEventsPage: organizedEventsPage.value,
organizedEventsLimit: EVENTS_PER_PAGE,
postsPage: postsPage.value,
postsLimit: POSTS_PER_PAGE,
},
});
}
async onMembersPageChange(page: number): Promise<void> {
this.membersPage = page;
await this.$apollo.queries.group.fetchMore({
if (!profileData) return;
store.writeQuery({
query: GET_GROUP,
variables: {
actorId: this.id,
memberPage: this.membersPage,
memberLimit: EVENTS_PER_PAGE,
id: profileId,
},
data: {
getGroup: {
...profileData.getGroup,
suspended: true,
avatar: null,
name: "",
summary: "",
},
},
});
}
},
}));
async onPostsPageChange(page: number): Promise<void> {
this.postsPage = page;
await this.$apollo.queries.group.fetchMore({
variables: {
actorId: this.id,
postsPage: this.postsPage,
postLimit: POSTS_PER_PAGE,
onSuspendProfileError((e) => {
console.error(e);
notifier?.error(t("Error while suspending group"));
});
const { mutate: unsuspendProfile, onError: onUnsuspendProfileError } =
useMutation(UNSUSPEND_PROFILE, () => ({
refetchQueries: [
{
query: GET_GROUP,
variables: {
id: props.id,
},
},
});
}
],
}));
private async pushRouter(args: Record<string, string>): Promise<void> {
try {
await this.$router.push({
name: RouteName.ADMIN_GROUP_PROFILE,
query: { ...this.$route.query, ...args },
});
} catch (e) {
if (isNavigationFailure(e, NavigationFailureType.redirected)) {
throw Error(e.toString());
}
}
}
}
onUnsuspendProfileError((e) => {
console.error(e);
notifier?.error(t("Error while suspending group"));
});
const {
mutate: refreshProfile,
onDone: onRefreshProfileDone,
onError: onRefreshProfileError,
} = useMutation<{ refreshProfile: IActor }>(REFRESH_PROFILE);
onRefreshProfileDone(() => {
notifier?.success(t("Triggered profile refreshment"));
});
onRefreshProfileError((e) => {
console.error(e);
notifier?.error(t("Error while suspending group"));
});
const onOrganizedEventsPageChange = async (page: number): Promise<void> => {
organizedEventsPage.value = page;
await fetchMore({
variables: {
id: props.id,
organizedEventsPage: organizedEventsPage.value,
organizedEventsLimit: EVENTS_PER_PAGE,
},
});
};
const onMembersPageChange = async (page: number): Promise<void> => {
membersPage.value = page;
await fetchMore({
variables: {
id: props.id,
membersPage: membersPage.value,
membersLimit: EVENTS_PER_PAGE,
},
});
};
const onPostsPageChange = async (page: number): Promise<void> => {
postsPage.value = page;
await fetchMore({
variables: {
id: props.id,
postsPage: postsPage.value,
postsLimit: POSTS_PER_PAGE,
},
});
};
</script>

View File

@@ -24,7 +24,7 @@
/>
</div>
<section class="mt-4 mb-3">
<h2 class="text-lg font-bold">{{ $t("Details") }}</h2>
<h2 class="">{{ $t("Details") }}</h2>
<div class="flex flex-col">
<div class="overflow-x-auto sm:-mx-6 lg:-mx-8">
<div class="inline-block py-2 min-w-full sm:px-2 lg:px-8">
@@ -62,19 +62,19 @@
</div>
</section>
<section class="mt-4 mb-3">
<h2 class="text-lg font-bold">{{ $t("Actions") }}</h2>
<h2 class="">{{ $t("Actions") }}</h2>
<div class="buttons" v-if="person.domain">
<b-button
@click="suspendProfile"
<o-button
@click="suspendProfile({ id })"
v-if="person.domain && !person.suspended"
type="is-primary"
>{{ $t("Suspend") }}</b-button
variant="primary"
>{{ $t("Suspend") }}</o-button
>
<b-button
@click="unsuspendProfile"
<o-button
@click="unsuspendProfile({ id })"
v-if="person.domain && person.suspended"
type="is-primary"
>{{ $t("Unsuspend") }}</b-button
variant="primary"
>{{ $t("Unsuspend") }}</o-button
>
</div>
<p v-else></p>
@@ -83,8 +83,8 @@
class="p-4 mb-4 text-sm text-blue-700 bg-blue-100 rounded-lg"
role="alert"
>
<i18n
path="This profile is located on this instance, so you need to {access_the_corresponding_account} to suspend it."
<i18n-t
keypath="This profile is located on this instance, so you need to {access_the_corresponding_account} to suspend it."
>
<template #access_the_corresponding_account>
<router-link
@@ -96,17 +96,17 @@
>{{ $t("access the corresponding account") }}</router-link
>
</template>
</i18n>
</i18n-t>
</div>
</section>
<section class="mt-4 mb-3">
<h2 class="text-lg font-bold">{{ $t("Organized events") }}</h2>
<b-table
<h2 class="">{{ $t("Organized events") }}</h2>
<o-table
:data="person.organizedEvents.elements"
:loading="$apollo.queries.person.loading"
:loading="loading"
paginated
backend-pagination
:current-page.sync="organizedEventsPage"
v-model:current-page="organizedEventsPage"
:aria-next-label="$t('Next page')"
:aria-previous-label="$t('Previous page')"
:aria-page-label="$t('Page')"
@@ -115,39 +115,39 @@
:per-page="EVENTS_PER_PAGE"
@page-change="onOrganizedEventsPageChange"
>
<b-table-column
<o-table-column
field="beginsOn"
:label="$t('Begins on')"
v-slot="props"
>
{{ props.row.beginsOn | formatDateTimeString }}
</b-table-column>
<b-table-column field="title" :label="$t('Title')" v-slot="props">
{{ formatDateTimeString(props.row.beginsOn) }}
</o-table-column>
<o-table-column field="title" :label="$t('Title')" v-slot="props">
<router-link
:to="{ name: RouteName.EVENT, params: { uuid: props.row.uuid } }"
>
{{ props.row.title }}
</router-link>
</b-table-column>
<template slot="empty">
</o-table-column>
<template #empty>
<empty-content icon="account-group" :inline="true">
{{ $t("No organized events listed") }}
</empty-content>
</template>
</b-table>
</o-table>
</section>
<section class="mt-4 mb-3">
<h2 class="text-lg font-bold">{{ $t("Participations") }}</h2>
<b-table
<h2 class="">{{ $t("Participations") }}</h2>
<o-table
:data="
person.participations.elements.map(
(participation) => participation.event
)
"
:loading="$apollo.loading"
:loading="loading"
paginated
backend-pagination
:current-page.sync="participationsPage"
v-model:current-page="participationsPage"
:aria-next-label="$t('Next page')"
:aria-previous-label="$t('Previous page')"
:aria-page-label="$t('Page')"
@@ -156,35 +156,35 @@
:per-page="EVENTS_PER_PAGE"
@page-change="onParticipationsPageChange"
>
<b-table-column
<o-table-column
field="beginsOn"
:label="$t('Begins on')"
v-slot="props"
>
{{ props.row.beginsOn | formatDateTimeString }}
</b-table-column>
<b-table-column field="title" :label="$t('Title')" v-slot="props">
{{ formatDateTimeString(props.row.beginsOn) }}
</o-table-column>
<o-table-column field="title" :label="$t('Title')" v-slot="props">
<router-link
:to="{ name: RouteName.EVENT, params: { uuid: props.row.uuid } }"
>
{{ props.row.title }}
</router-link>
</b-table-column>
<template slot="empty">
</o-table-column>
<template #empty>
<empty-content icon="account-group" :inline="true">
{{ $t("No participations listed") }}
</empty-content>
</template>
</b-table>
</o-table>
</section>
<section class="mt-4 mb-3">
<h2 class="text-lg font-bold">{{ $t("Memberships") }}</h2>
<b-table
<h2 class="">{{ $t("Memberships") }}</h2>
<o-table
:data="person.memberships.elements"
:loading="$apollo.loading"
:loading="loading"
paginated
backend-pagination
:current-page.sync="membershipsPage"
v-model:current-page="membershipsPage"
:aria-next-label="$t('Next page')"
:aria-previous-label="$t('Previous page')"
:aria-page-label="$t('Page')"
@@ -193,30 +193,24 @@
:per-page="EVENTS_PER_PAGE"
@page-change="onMembershipsPageChange"
>
<b-table-column
<o-table-column
field="parent.preferredUsername"
:label="$t('Group')"
v-slot="props"
>
<article class="media">
<figure
class="media-left image is-48x48"
v-if="props.row.parent.avatar"
>
<article class="flex gap-2">
<figure class="" v-if="props.row.parent.avatar">
<img
class="is-rounded"
class="rounded-full"
:src="props.row.parent.avatar.url"
alt=""
width="48"
height="48"
/>
</figure>
<b-icon
class="media-left"
v-else
size="is-large"
icon="account-circle"
/>
<div class="media-content">
<div class="content">
<AccountCircle v-else :size="48" />
<div class="">
<div class="prose dark:prose-invert">
<span v-if="props.row.parent.name">{{
props.row.parent.name
}}</span
@@ -227,16 +221,16 @@
</div>
</div>
</article>
</b-table-column>
<b-table-column field="role" :label="$t('Role')" v-slot="props">
</o-table-column>
<o-table-column field="role" :label="$t('Role')" v-slot="props">
<b-tag
type="is-primary"
variant="primary"
v-if="props.row.role === MemberRole.ADMINISTRATOR"
>
{{ $t("Administrator") }}
</b-tag>
<b-tag
type="is-primary"
variant="primary"
v-else-if="props.row.role === MemberRole.MODERATOR"
>
{{ $t("Moderator") }}
@@ -245,301 +239,253 @@
{{ $t("Member") }}
</b-tag>
<b-tag
type="is-warning"
variant="warning"
v-else-if="props.row.role === MemberRole.NOT_APPROVED"
>
{{ $t("Not approved") }}
</b-tag>
<b-tag
type="is-danger"
variant="danger"
v-else-if="props.row.role === MemberRole.REJECTED"
>
{{ $t("Rejected") }}
</b-tag>
<b-tag
type="is-danger"
variant="danger"
v-else-if="props.row.role === MemberRole.INVITED"
>
{{ $t("Invited") }}
</b-tag>
</b-table-column>
<b-table-column field="insertedAt" :label="$t('Date')" v-slot="props">
</o-table-column>
<o-table-column field="insertedAt" :label="$t('Date')" v-slot="props">
<span class="has-text-centered">
{{ props.row.insertedAt | formatDateString }}<br />{{
props.row.insertedAt | formatTimeString
{{ formatDateString(props.row.insertedAt) }}<br />{{
formatTimeString(props.row.insertedAt)
}}
</span>
</b-table-column>
<template slot="empty">
</o-table-column>
<template #empty>
<empty-content icon="account-group" :inline="true">
{{ $t("No memberships found") }}
</empty-content>
</template>
</b-table>
</o-table>
</section>
</div>
<empty-content v-else-if="!$apollo.loading" icon="account">
<empty-content v-else-if="!loading" icon="account">
{{ $t("This profile was not found") }}
<template #desc>
<b-button
<o-button
type="is-text"
tag="router-link"
:to="{ name: RouteName.PROFILES }"
>{{ $t("Back to profile list") }}</b-button
>{{ $t("Back to profile list") }}</o-button
>
</template>
</empty-content>
</template>
<script lang="ts">
import { Component, Vue, Prop } from "vue-property-decorator";
<script lang="ts" setup>
import { formatBytes } from "@/utils/datetime";
import {
GET_PERSON,
SUSPEND_PROFILE,
UNSUSPEND_PROFILE,
} from "../../graphql/actor";
import { IPerson } from "../../types/actor";
import { displayName, usernameWithDomain } from "../../types/actor/actor.model";
import RouteName from "../../router/name";
import ActorCard from "../../components/Account/ActorCard.vue";
import EmptyContent from "../../components/Utils/EmptyContent.vue";
} from "@/graphql/actor";
import { IPerson } from "@/types/actor";
import { displayName, usernameWithDomain } from "@/types/actor/actor.model";
import RouteName from "@/router/name";
import ActorCard from "@/components/Account/ActorCard.vue";
import EmptyContent from "@/components/Utils/EmptyContent.vue";
import { ApolloCache, FetchResult } from "@apollo/client/core";
import VueRouter from "vue-router";
import { MemberRole } from "@/types/enums";
import cloneDeep from "lodash/cloneDeep";
const { isNavigationFailure, NavigationFailureType } = VueRouter;
import { useMutation, useQuery } from "@vue/apollo-composable";
import { integerTransformer, useRouteQuery } from "vue-use-route-query";
import { useHead } from "@vueuse/head";
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import {
formatDateString,
formatTimeString,
formatDateTimeString,
} from "@/filters/datetime";
import AccountCircle from "vue-material-design-icons/AccountCircle.vue";
const EVENTS_PER_PAGE = 10;
const PARTICIPATIONS_PER_PAGE = 10;
const MEMBERSHIPS_PER_PAGE = 10;
@Component({
apollo: {
person: {
query: GET_PERSON,
fetchPolicy: "cache-and-network",
variables() {
return {
actorId: this.id,
organizedEventsPage: this.organizedEventsPage,
organizedEventsLimit: EVENTS_PER_PAGE,
participationsPage: this.participationsPage,
participationLimit: PARTICIPATIONS_PER_PAGE,
membershipsPage: this.membershipsPage,
membershipsLimit: MEMBERSHIPS_PER_PAGE,
};
},
skip() {
return !this.id;
},
},
},
components: {
ActorCard,
EmptyContent,
},
metaInfo() {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const { person } = this;
return {
title: person ? person.name || usernameWithDomain(person) : "",
};
},
})
export default class AdminProfile extends Vue {
@Prop({ required: true }) id!: string;
const props = defineProps<{ id: string }>();
person!: IPerson;
const organizedEventsPage = useRouteQuery(
"organizedEventsPage",
1,
integerTransformer
);
const participationsPage = useRouteQuery(
"participationsPage",
1,
integerTransformer
);
const membershipsPage = useRouteQuery("membershipsPage", 1, integerTransformer);
usernameWithDomain = usernameWithDomain;
const {
result: personResult,
fetchMore,
loading,
} = useQuery<{ person: IPerson }>(GET_PERSON, () => ({
actorId: props.id,
organizedEventsPage: organizedEventsPage.value,
organizedEventsLimit: EVENTS_PER_PAGE,
participationsPage: participationsPage.value,
participationLimit: PARTICIPATIONS_PER_PAGE,
membershipsPage: membershipsPage.value,
membershipsLimit: MEMBERSHIPS_PER_PAGE,
}));
displayName = displayName;
const person = computed(() => personResult.value?.person);
RouteName = RouteName;
const { t } = useI18n({ useScope: "global" });
EVENTS_PER_PAGE = EVENTS_PER_PAGE;
useHead({
title: computed(() => displayName(person.value)),
});
PARTICIPATIONS_PER_PAGE = PARTICIPATIONS_PER_PAGE;
MEMBERSHIPS_PER_PAGE = MEMBERSHIPS_PER_PAGE;
MemberRole = MemberRole;
get organizedEventsPage(): number {
return parseInt(
(this.$route.query.organizedEventsPage as string) || "1",
10
);
}
set organizedEventsPage(page: number) {
this.pushRouter({ organizedEventsPage: page.toString() });
}
get participationsPage(): number {
return parseInt(
(this.$route.query.participationsPage as string) || "1",
10
);
}
set participationsPage(page: number) {
this.pushRouter({ participationsPage: page.toString() });
}
get membershipsPage(): number {
return parseInt((this.$route.query.membershipsPage as string) || "1", 10);
}
set membershipsPage(page: number) {
this.pushRouter({ membershipsPage: page.toString() });
}
get metadata(): Array<Record<string, unknown>> {
if (!this.person) return [];
const res: Record<string, unknown>[] = [
const metadata = computed(
(): Array<{
key: string;
value: string;
link?: { name: string; params: Record<string, any> };
}> => {
if (!person.value) return [];
const res: {
key: string;
value: string;
link?: { name: string; params: Record<string, any> };
}[] = [
{
key: this.$t("Status") as string,
value: this.person.suspended ? this.$t("Suspended") : this.$t("Active"),
key: t("Status"),
value: person.value.suspended ? t("Suspended") : t("Active"),
},
{
key: this.$t("Domain") as string,
value: this.person.domain ? this.person.domain : this.$t("Local"),
link: this.person.domain
key: t("Domain"),
value: person.value.domain ? person.value.domain : t("Local"),
link: person.value.domain
? {
name: RouteName.INSTANCE,
params: { domain: this.person.domain },
params: { domain: person.value.domain },
}
: undefined,
},
{
key: this.$i18n.t("Uploaded media size"),
value: formatBytes(this.person.mediaSize),
key: t("Uploaded media size"),
value: formatBytes(person.value.mediaSize ?? 0),
},
];
if (!this.person.domain && this.person.user) {
if (!person.value.domain && person.value.user) {
res.push({
key: this.$t("User") as string,
key: t("User"),
link: {
name: RouteName.ADMIN_USER_PROFILE,
params: { id: this.person.user.id },
params: { id: person.value.user.id },
},
value: this.person.user.email,
value: person.value.user.email,
});
}
return res;
}
);
async suspendProfile(): Promise<void> {
this.$apollo.mutate<{ suspendProfile: { id: string } }>({
mutation: SUSPEND_PROFILE,
const { mutate: suspendProfile } = useMutation<
{
suspendProfile: { id: string };
},
{ id: string }
>(SUSPEND_PROFILE, () => ({
update: (
store: ApolloCache<{ suspendProfile: { id: string } }>,
{ data }: FetchResult
) => {
if (data == null) return;
const profileId = props.id;
const profileData = store.readQuery<{ person: IPerson }>({
query: GET_PERSON,
variables: {
id: this.id,
},
update: (
store: ApolloCache<{ suspendProfile: { id: string } }>,
{ data }: FetchResult
) => {
if (data == null) return;
const profileId = this.id;
const profileData = store.readQuery<{ person: IPerson }>({
query: GET_PERSON,
variables: {
actorId: profileId,
organizedEventsPage: 1,
organizedEventsLimit: EVENTS_PER_PAGE,
participationsPage: 1,
participationLimit: PARTICIPATIONS_PER_PAGE,
membershipsPage: 1,
membershipsLimit: MEMBERSHIPS_PER_PAGE,
},
});
if (!profileData) return;
const { person } = profileData;
store.writeQuery({
query: GET_PERSON,
variables: {
actorId: profileId,
},
data: {
person: {
...cloneDeep(person),
participations: { total: 0, elements: [] },
suspended: true,
avatar: null,
name: "",
summary: "",
},
},
});
},
});
}
async unsuspendProfile(): Promise<void> {
const profileID = this.id;
this.$apollo.mutate<{ unsuspendProfile: { id: string } }>({
mutation: UNSUSPEND_PROFILE,
variables: {
id: this.id,
},
refetchQueries: [
{
query: GET_PERSON,
variables: {
actorId: profileID,
organizedEventsPage: 1,
organizedEventsLimit: EVENTS_PER_PAGE,
},
},
],
});
}
async onOrganizedEventsPageChange(): Promise<void> {
await this.$apollo.queries.person.fetchMore({
variables: {
actorId: this.id,
organizedEventsPage: this.organizedEventsPage,
actorId: profileId,
organizedEventsPage: 1,
organizedEventsLimit: EVENTS_PER_PAGE,
},
});
}
async onParticipationsPageChange(): Promise<void> {
await this.$apollo.queries.person.fetchMore({
variables: {
actorId: this.id,
participationPage: this.participationsPage,
participationsPage: 1,
participationLimit: PARTICIPATIONS_PER_PAGE,
},
});
}
async onMembershipsPageChange(): Promise<void> {
await this.$apollo.queries.person.fetchMore({
variables: {
actorId: this.id,
membershipsPage: this.participationsPage,
membershipsPage: 1,
membershipsLimit: MEMBERSHIPS_PER_PAGE,
},
});
}
private async pushRouter(args: Record<string, string>): Promise<void> {
try {
await this.$router.push({
name: RouteName.ADMIN_PROFILE,
query: { ...this.$route.query, ...args },
});
} catch (e) {
if (isNavigationFailure(e, NavigationFailureType.redirected)) {
throw Error(e.toString());
}
}
}
}
if (!profileData) return;
const { person } = profileData;
store.writeQuery({
query: GET_PERSON,
variables: {
actorId: profileId,
},
data: {
person: {
...cloneDeep(person),
participations: { total: 0, elements: [] },
suspended: true,
avatar: null,
name: "",
summary: "",
},
},
});
},
}));
const { mutate: unsuspendProfile } = useMutation<
{ unsuspendProfile: { id: string } },
{ id: string }
>(UNSUSPEND_PROFILE, () => ({
refetchQueries: [
{
query: GET_PERSON,
variables: {
actorId: props.id,
organizedEventsPage: 1,
organizedEventsLimit: EVENTS_PER_PAGE,
},
},
],
}));
const onOrganizedEventsPageChange = async (): Promise<void> => {
await fetchMore({
variables: {
actorId: props.id,
organizedEventsPage: organizedEventsPage.value,
organizedEventsLimit: EVENTS_PER_PAGE,
},
});
};
const onParticipationsPageChange = async (): Promise<void> => {
await fetchMore({
variables: {
actorId: props.id,
participationPage: participationsPage.value,
participationLimit: PARTICIPATIONS_PER_PAGE,
},
});
};
const onMembershipsPageChange = async (): Promise<void> => {
await fetchMore({
variables: {
actorId: props.id,
membershipsPage: participationsPage.value,
membershipsLimit: MEMBERSHIPS_PER_PAGE,
},
});
};
</script>

View File

@@ -2,10 +2,10 @@
<div v-if="user" class="section">
<breadcrumbs-nav
:links="[
{ name: RouteName.ADMIN, text: $t('Admin') },
{ name: RouteName.ADMIN, text: t('Admin') },
{
name: RouteName.USERS,
text: $t('Users'),
text: t('Users'),
},
{
name: RouteName.ADMIN_USER_PROFILE,
@@ -16,7 +16,7 @@
/>
<section>
<h2 class="text-lg font-bold mb-3">{{ $t("Details") }}</h2>
<h2 class="text-lg font-bold mb-3">{{ t("Details") }}</h2>
<div class="flex flex-col">
<div class="overflow-x-auto sm:-mx-6">
<div class="inline-block py-2 min-w-full sm:px-2">
@@ -25,19 +25,15 @@
<tbody>
<tr
class="odd:bg-white even:bg-gray-50 border-b"
v-for="{ key, value, link, type } in metadata"
v-for="{ key, value, type } in metadata"
:key="key"
>
<td class="py-4 px-2 whitespace-nowrap align-middle">
{{ key }}
</td>
<td v-if="link" class="py-4 px-2 whitespace-nowrap">
<router-link :to="link">
{{ value }}
</router-link>
</td>
<td
v-else-if="type === 'ip'"
v-if="type === 'ip'"
class="py-4 px-2 whitespace-nowrap"
>
<code>{{ value }}</code>
@@ -65,72 +61,72 @@
</td>
<td
v-if="type === 'email'"
class="py-4 px-2 whitespace-nowrap flex flex flex-col items-start"
class="py-4 px-2 whitespace-nowrap flex flex flex-col items-start gap-2"
>
<b-button
size="is-small"
<o-button
size="small"
v-if="!user.disabled"
@click="isEmailChangeModalActive = true"
type="is-text"
icon-left="pencil"
>{{ $t("Change email") }}</b-button
>{{ t("Change email") }}</o-button
>
<b-button
<o-button
tag="router-link"
:to="{
name: RouteName.USERS,
query: { emailFilter: `@${userEmailDomain}` },
}"
size="is-small"
size="small"
type="is-text"
icon-left="magnify"
>{{
$t("Other users with the same email domain")
}}</b-button
t("Other users with the same email domain")
}}</o-button
>
</td>
<td
v-else-if="type === 'confirmed'"
class="py-4 px-2 whitespace-nowrap flex items-center"
>
<b-button
size="is-small"
<o-button
size="small"
v-if="!user.confirmedAt || user.disabled"
@click="isConfirmationModalActive = true"
type="is-text"
icon-left="check"
>{{ $t("Confirm user") }}</b-button
>{{ t("Confirm user") }}</o-button
>
</td>
<td
v-else-if="type === 'role'"
class="py-4 px-2 whitespace-nowrap flex items-center"
>
<b-button
size="is-small"
<o-button
size="small"
v-if="!user.disabled"
@click="isRoleChangeModalActive = true"
type="is-text"
icon-left="chevron-double-up"
>{{ $t("Change role") }}</b-button
>{{ t("Change role") }}</o-button
>
</td>
<td
v-else-if="type === 'ip' && user.currentSignInIp"
class="py-4 px-2 whitespace-nowrap flex items-center"
>
<b-button
<o-button
tag="router-link"
:to="{
name: RouteName.USERS,
query: { ipFilter: user.currentSignInIp },
}"
size="is-small"
size="small"
type="is-text"
icon-left="web"
>{{
$t("Other users with the same IP address")
}}</b-button
t("Other users with the same IP address")
}}</o-button
>
</td>
<td v-else></td>
@@ -143,10 +139,10 @@
</div>
</section>
<section class="my-4">
<h2 class="text-lg font-bold mb-3">{{ $t("Profiles") }}</h2>
<h2 class="text-lg font-bold mb-3">{{ t("Profiles") }}</h2>
<div
class="flex flex-wrap justify-center sm:justify-start gap-4"
v-if="profiles.length > 0"
v-if="profiles && profiles.length > 0"
>
<router-link
v-for="profile in profiles"
@@ -161,422 +157,370 @@
/>
</router-link>
</div>
<empty-content v-else-if="!$apollo.loading" :inline="true" icon="account">
{{ $t("This user doesn't have any profiles") }}
<empty-content v-else-if="!loadingUser" :inline="true" icon="account">
{{ t("This user doesn't have any profiles") }}
</empty-content>
</section>
<section class="my-4">
<h2 class="text-lg font-bold mb-3">{{ $t("Actions") }}</h2>
<h2 class="text-lg font-bold mb-3">{{ t("Actions") }}</h2>
<div class="buttons" v-if="!user.disabled">
<b-button @click="suspendAccount" type="is-danger">{{
$t("Suspend")
}}</b-button>
<o-button @click="suspendAccount" variant="danger">{{
t("Suspend")
}}</o-button>
</div>
<div
v-else
class="p-4 mb-4 text-sm text-red-700 bg-red-100 rounded-lg"
role="alert"
>
{{ $t("The user has been disabled") }}
{{ t("The user has been disabled") }}
</div>
</section>
<b-modal
:active="isEmailChangeModalActive"
<o-modal
v-model:active="isEmailChangeModalActive"
trap-focus
:destroy-on-hide="false"
aria-role="dialog"
:aria-label="t('Edit user email')"
:close-button-aria-label="t('Close')"
aria-modal
>
<form @submit.prevent="updateUserEmail">
<div class="" style="width: auto">
<header class="">
<h2>{{ t("Change user email") }}</h2>
</header>
<section class="">
<o-field :label="t('Previous email')">
<o-input type="email" :value="user.email" disabled> </o-input>
</o-field>
<o-field :label="t('New email')">
<o-input
type="email"
v-model="newUser.email"
:placeholder="t(`new{'@'}email.com`)"
required
>
</o-input>
</o-field>
<o-checkbox v-model="newUser.notify">{{
t("Notify the user of the change")
}}</o-checkbox>
</section>
<footer class="mt-2 flex gap-2">
<o-button @click="isEmailChangeModalActive = false">{{
t("Close")
}}</o-button>
<o-button native-type="submit" variant="primary">{{
t("Change email")
}}</o-button>
</footer>
</div>
</form>
</o-modal>
<o-modal
v-model:active="isRoleChangeModalActive"
has-modal-card
trap-focus
:destroy-on-hide="false"
aria-role="dialog"
:aria-label="$t('Edit user email')"
:close-button-aria-label="$t('Close')"
:aria-label="t('Edit user email')"
:close-button-aria-label="t('Close')"
aria-modal
>
<template>
<form @submit.prevent="updateUserEmail">
<div class="modal-card" style="width: auto">
<header class="modal-card-head">
<p class="modal-card-title">{{ $t("Change user email") }}</p>
<button
type="button"
class="delete"
@click="isEmailChangeModalActive = false"
/>
</header>
<section class="modal-card-body">
<b-field :label="$t('Previous email')">
<b-input type="email" :value="user.email" disabled> </b-input>
</b-field>
<b-field :label="$t('New email')">
<b-input
type="email"
v-model="newUser.email"
:placeholder="$t('new@email.com')"
required
>
</b-input>
</b-field>
<b-checkbox v-model="newUser.notify">{{
$t("Notify the user of the change")
}}</b-checkbox>
</section>
<footer class="modal-card-foot">
<b-button @click="isEmailChangeModalActive = false">{{
$t("Close")
}}</b-button>
<b-button native-type="submit" type="is-primary">{{
$t("Change email")
}}</b-button>
</footer>
</div>
</form>
</template>
</b-modal>
<b-modal
:active="isRoleChangeModalActive"
<form @submit.prevent="updateUserRole">
<div>
<header>
<h2 class="modal-card-title">{{ t("Change user role") }}</h2>
</header>
<section>
<o-field>
<o-radio
v-model="newUser.role"
:native-value="ICurrentUserRole.ADMINISTRATOR"
>
{{ t("Administrator") }}
</o-radio>
</o-field>
<o-field>
<o-radio
v-model="newUser.role"
:native-value="ICurrentUserRole.MODERATOR"
>
{{ t("Moderator") }}
</o-radio>
</o-field>
<o-field>
<o-radio
v-model="newUser.role"
:native-value="ICurrentUserRole.USER"
>
{{ t("User") }}
</o-radio>
</o-field>
<o-checkbox v-model="newUser.notify">{{
t("Notify the user of the change")
}}</o-checkbox>
</section>
<footer class="mt-2 flex gap-2">
<o-button @click="isRoleChangeModalActive = false">{{
t("Close")
}}</o-button>
<o-button native-type="submit" variant="primary">{{
t("Change role")
}}</o-button>
</footer>
</div>
</form>
</o-modal>
<o-modal
v-model:active="isConfirmationModalActive"
has-modal-card
trap-focus
:destroy-on-hide="false"
aria-role="dialog"
:aria-label="$t('Edit user email')"
:close-button-aria-label="$t('Close')"
:aria-label="t('Edit user email')"
:close-button-aria-label="t('Close')"
aria-modal
>
<template>
<form @submit.prevent="updateUserRole">
<div class="modal-card" style="width: auto">
<header class="modal-card-head">
<p class="modal-card-title">{{ $t("Change user role") }}</p>
<button
type="button"
class="delete"
@click="isRoleChangeModalActive = false"
/>
</header>
<section class="modal-card-body">
<b-field>
<b-radio
v-model="newUser.role"
:native-value="ICurrentUserRole.ADMINISTRATOR"
>
{{ $t("Administrator") }}
</b-radio>
</b-field>
<b-field>
<b-radio
v-model="newUser.role"
:native-value="ICurrentUserRole.MODERATOR"
>
{{ $t("Moderator") }}
</b-radio>
</b-field>
<b-field>
<b-radio
v-model="newUser.role"
:native-value="ICurrentUserRole.USER"
>
{{ $t("User") }}
</b-radio>
</b-field>
<b-checkbox v-model="newUser.notify">{{
$t("Notify the user of the change")
}}</b-checkbox>
</section>
<footer class="modal-card-foot">
<b-button @click="isRoleChangeModalActive = false">{{
$t("Close")
}}</b-button>
<b-button native-type="submit" type="is-primary">{{
$t("Change role")
}}</b-button>
</footer>
</div>
</form>
</template>
</b-modal>
<b-modal
:active="isConfirmationModalActive"
has-modal-card
trap-focus
:destroy-on-hide="false"
aria-role="dialog"
:aria-label="$t('Edit user email')"
:close-button-aria-label="$t('Close')"
aria-modal
>
<template>
<form @submit.prevent="confirmUser">
<div class="modal-card" style="width: auto">
<header class="modal-card-head">
<p class="modal-card-title">{{ $t("Confirm user") }}</p>
<button
type="button"
class="delete"
@click="isConfirmationModalActive = false"
/>
</header>
<section class="modal-card-body">
<b-checkbox v-model="newUser.notify">{{
$t("Notify the user of the change")
}}</b-checkbox>
</section>
<footer class="modal-card-foot">
<b-button @click="isConfirmationModalActive = false">{{
$t("Close")
}}</b-button>
<b-button native-type="submit" type="is-primary">{{
$t("Confirm user")
}}</b-button>
</footer>
</div>
</form>
</template>
</b-modal>
<form @submit.prevent="confirmUser">
<div>
<header>
<h2>{{ t("Confirm user") }}</h2>
</header>
<section>
<o-checkbox v-model="newUser.notify">{{
t("Notify the user of the change")
}}</o-checkbox>
</section>
<footer>
<o-button @click="isConfirmationModalActive = false">{{
t("Close")
}}</o-button>
<o-button native-type="submit" variant="primary">{{
t("Confirm user")
}}</o-button>
</footer>
</div>
</form>
</o-modal>
</div>
<empty-content v-else-if="!$apollo.loading" icon="account">
{{ $t("This user was not found") }}
<empty-content v-else-if="!loadingUser" icon="account">
{{ t("This user was not found") }}
<template #desc>
<b-button
<o-button
type="is-text"
tag="router-link"
:to="{ name: RouteName.USERS }"
>{{ $t("Back to user list") }}</b-button
>{{ t("Back to user list") }}</o-button
>
</template>
</empty-content>
</template>
<script lang="ts">
import { Component, Vue, Prop, Watch } from "vue-property-decorator";
<script lang="ts" setup>
import { formatBytes } from "@/utils/datetime";
import { ICurrentUserRole } from "@/types/enums";
import { GET_USER, SUSPEND_USER } from "../../graphql/user";
import { IActor, usernameWithDomain } from "../../types/actor/actor.model";
import RouteName from "../../router/name";
import { IUser } from "../../types/current-user.model";
import EmptyContent from "../../components/Utils/EmptyContent.vue";
import ActorCard from "../../components/Account/ActorCard.vue";
import { ADMIN_UPDATE_USER, LANGUAGES_CODES } from "@/graphql/admin";
import { useMutation, useQuery } from "@vue/apollo-composable";
import { ILanguage } from "@/types/admin.model";
import { computed, inject, reactive, ref, watch } from "vue";
import { useHead } from "@vueuse/head";
import { useI18n } from "vue-i18n";
import { formatDateTimeString } from "@/filters/datetime";
import { useRouter } from "vue-router";
import { IPerson } from "@/types/actor";
import { Dialog } from "@/plugins/dialog";
@Component({
apollo: {
user: {
query: GET_USER,
fetchPolicy: "cache-and-network",
variables() {
return {
id: this.id,
};
},
skip() {
return !this.id;
},
},
languages: {
query: LANGUAGES_CODES,
variables() {
return {
codes: [this.languageCode],
};
},
skip() {
return !this.languageCode;
},
},
},
metaInfo() {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const { user } = this;
return {
title: user?.email,
};
},
components: {
EmptyContent,
ActorCard,
},
})
export default class AdminUserProfile extends Vue {
@Prop({ required: true }) id!: string;
const props = defineProps<{ id: string }>();
user!: IUser;
const { result: userResult, loading: loadingUser } = useQuery<{ user: IUser }>(
GET_USER,
() => ({
id: props.id,
})
);
languages!: Array<{ code: string; name: string }>;
const user = computed(() => userResult.value?.user);
usernameWithDomain = usernameWithDomain;
const languageCode = computed(() => user.value?.locale);
RouteName = RouteName;
const { result: languagesResult } = useQuery<{ languages: ILanguage[] }>(
LANGUAGES_CODES,
() => ({
codes: languageCode.value,
}),
() => ({
enabled: languageCode.value !== undefined,
})
);
ICurrentUserRole = ICurrentUserRole;
const languages = computed(() => languagesResult.value?.languages);
isEmailChangeModalActive = false;
const { t } = useI18n({ useScope: "global" });
isRoleChangeModalActive = false;
useHead({
title: computed(() => user.value?.email ?? ""),
});
isConfirmationModalActive = false;
const isEmailChangeModalActive = ref(false);
const isRoleChangeModalActive = ref(false);
const isConfirmationModalActive = ref(false);
newUser = {
email: "",
role: this?.user?.role,
confirm: false,
notify: true,
};
const newUser = reactive({
email: "",
role: user.value?.role,
confirm: false,
notify: true,
});
get metadata(): Array<Record<string, unknown>> {
if (!this.user) return [];
const metadata = computed(
(): Array<{ key: string; value: string; type?: string }> => {
if (!user.value) return [];
return [
{
key: this.$i18n.t("Email"),
value: this.user.email,
key: t("Email"),
value: user.value.email,
type: "email",
},
{
key: this.$i18n.t("Language"),
value: this.languages
? this.languages[0].name
: this.$i18n.t("Unknown"),
key: t("Language"),
value: languages.value ? languages.value[0].name : t("Unknown"),
},
{
key: this.$i18n.t("Role"),
value: this.roleName(this.user.role),
key: t("Role"),
value: roleName(user.value.role),
type: "role",
},
{
key: this.$i18n.t("Login status"),
value: this.user.disabled
? this.$i18n.t("Disabled")
: this.$t("Activated"),
key: t("Login status"),
value: user.value.disabled ? t("Disabled") : t("Activated"),
},
{
key: this.$i18n.t("Confirmed"),
value:
this.$options.filters && this.user.confirmedAt
? this.$options.filters.formatDateTimeString(this.user.confirmedAt)
: this.$i18n.t("Not confirmed"),
key: t("Confirmed"),
value: user.value.confirmedAt
? formatDateTimeString(user.value.confirmedAt)
: t("Not confirmed"),
type: "confirmed",
},
{
key: this.$i18n.t("Last sign-in"),
value:
this.$options.filters && this.user.currentSignInAt
? this.$options.filters.formatDateTimeString(
this.user.currentSignInAt
)
: this.$t("Unknown"),
key: t("Last sign-in"),
value: user.value.currentSignInAt
? formatDateTimeString(user.value.currentSignInAt)
: t("Unknown"),
},
{
key: this.$i18n.t("Last IP adress"),
value: this.user.currentSignInIp || this.$t("Unknown"),
type: this.user.currentSignInIp ? "ip" : undefined,
key: t("Last IP adress"),
value: user.value.currentSignInIp || t("Unknown"),
type: user.value.currentSignInIp ? "ip" : undefined,
},
{
key: this.$i18n.t("Total number of participations"),
value: this.user.participations.total,
key: t("Total number of participations"),
value: user.value.participations.total.toString(),
},
{
key: this.$i18n.t("Uploaded media total size"),
value: formatBytes(
this.user.mediaSize,
2,
this.$i18n.t("0 Bytes") as string
),
key: t("Uploaded media total size"),
value: formatBytes(user.value.mediaSize, 2, t("0 Bytes")),
},
];
}
);
roleName(role: ICurrentUserRole): string {
switch (role) {
case ICurrentUserRole.ADMINISTRATOR:
return this.$t("Administrator") as string;
case ICurrentUserRole.MODERATOR:
return this.$t("Moderator") as string;
case ICurrentUserRole.USER:
default:
return this.$t("User") as string;
}
const roleName = (role: ICurrentUserRole): string => {
switch (role) {
case ICurrentUserRole.ADMINISTRATOR:
return t("Administrator");
case ICurrentUserRole.MODERATOR:
return t("Moderator");
case ICurrentUserRole.USER:
default:
return t("User");
}
};
async suspendAccount(): Promise<void> {
this.$buefy.dialog.confirm({
title: this.$t("Suspend the account?") as string,
message: this.$t(
"Do you really want to suspend this account? All of the user's profiles will be deleted."
) as string,
confirmText: this.$t("Suspend the account") as string,
cancelText: this.$t("Cancel") as string,
type: "is-danger",
onConfirm: async () => {
await this.$apollo.mutate<{ suspendProfile: { id: string } }>({
mutation: SUSPEND_USER,
variables: {
userId: this.id,
},
});
return this.$router.push({ name: RouteName.USERS });
},
});
}
const router = useRouter();
get profiles(): IActor[] {
return this.user.actors;
}
const { mutate: suspendUser } = useMutation<
{ suspendProfile: { id: string } },
{ userId: string }
>(SUSPEND_USER);
get languageCode(): string | undefined {
return this.user?.locale;
}
const dialog = inject<Dialog>("dialog");
async confirmUser() {
this.isConfirmationModalActive = false;
await this.updateUser({
confirmed: true,
notify: this.newUser.notify,
});
}
const suspendAccount = async (): Promise<void> => {
dialog?.confirm({
title: t("Suspend the account?"),
message: t(
"Do you really want to suspend this account? All of the user's profiles will be deleted."
),
confirmText: t("Suspend the account"),
cancelText: t("Cancel"),
type: "is-danger",
onConfirm: async () => {
suspendUser({
userId: props.id,
});
return router.push({ name: RouteName.USERS });
},
});
};
async updateUserRole() {
this.isRoleChangeModalActive = false;
await this.updateUser({
role: this.newUser.role,
notify: this.newUser.notify,
});
}
const profiles = computed((): IPerson[] | undefined => {
return user.value?.actors;
});
async updateUserEmail() {
this.isEmailChangeModalActive = false;
await this.updateUser({
email: this.newUser.email,
notify: this.newUser.notify,
});
}
const confirmUser = async () => {
isConfirmationModalActive.value = false;
await updateUser({
id: props.id,
confirmed: true,
notify: newUser.notify,
});
};
async updateUser(properties: {
const updateUserRole = async () => {
isRoleChangeModalActive.value = false;
await updateUser({
id: props.id,
role: newUser.role,
notify: newUser.notify,
});
};
const updateUserEmail = async () => {
isEmailChangeModalActive.value = false;
await updateUser({
id: props.id,
email: newUser.email,
notify: newUser.notify,
});
};
const { mutate: updateUser } = useMutation<
{ adminUpdateUser: IUser },
{
id: string;
email?: string;
notify: boolean;
confirmed?: boolean;
role?: ICurrentUserRole;
}) {
await this.$apollo.mutate<{ adminUpdateUser: IUser }>({
mutation: ADMIN_UPDATE_USER,
variables: {
id: this.id,
...properties,
},
});
}
>(ADMIN_UPDATE_USER);
@Watch("user")
resetCurrentUserRole(
updatedUser: IUser | undefined,
oldUser: IUser | undefined
) {
if (updatedUser?.role !== oldUser?.role) {
this.newUser.role = updatedUser?.role;
}
watch(user, (updatedUser: IUser | undefined, oldUser: IUser | undefined) => {
if (updatedUser?.role !== oldUser?.role) {
newUser.role = updatedUser?.role;
}
});
get userEmailDomain(): string | undefined {
if (this?.user?.email) {
return this?.user?.email.split("@")[1];
}
return undefined;
const userEmailDomain = computed((): string | undefined => {
if (user.value?.email) {
return user.value?.email.split("@")[1];
}
}
return undefined;
});
</script>

View File

@@ -7,25 +7,26 @@
]"
/>
<section>
<h1 class="title">{{ $t("Administration") }}</h1>
<h1>{{ $t("Administration") }}</h1>
<div class="tile is-ancestor" v-if="dashboard">
<div class="tile is-vertical">
<div class="tile">
<div class="tile is-parent is-vertical is-6">
<article class="tile is-child box">
<p class="dashboard-number">{{ dashboard.numberOfEvents }}</p>
<p
v-html="
$t(
'Published events with <b>{comments}</b> comments and <b>{participations}</b> confirmed participations',
{
comments: dashboard.numberOfComments,
participations:
dashboard.numberOfConfirmedParticipationsToLocalEvents,
}
)
"
/>
<i18n-t
keypath="Published events with {comments} comments and {participations} confirmed participations"
tag="p"
>
<template #comments>
<b>{{ dashboard.numberOfComments }}</b>
</template>
<template #participations>
<b>{{
dashboard.numberOfConfirmedParticipationsToLocalEvents
}}</b>
</template>
</i18n-t>
</article>
<article class="tile is-child box">
<router-link :to="{ name: RouteName.ADMIN_GROUPS }">
@@ -133,38 +134,31 @@
</section>
</div>
</template>
<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
<script lang="ts" setup>
import { DASHBOARD } from "@/graphql/admin";
import { IDashboard } from "@/types/admin.model";
import { usernameWithDomain } from "@/types/actor";
import RouteName from "../../router/name";
import RouteName from "@/router/name";
import { useQuery } from "@vue/apollo-composable";
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import { useHead } from "@vueuse/head";
@Component({
apollo: {
dashboard: {
query: DASHBOARD,
fetchPolicy: "cache-and-network",
},
},
metaInfo() {
return {
title: this.$t("Administration") as string,
};
},
})
export default class Dashboard extends Vue {
dashboard!: IDashboard;
const { result: dashboardResult } = useQuery<{ dashboard: IDashboard }>(
DASHBOARD
);
RouteName = RouteName;
const dashboard = computed(() => dashboardResult.value?.dashboard);
usernameWithDomain = usernameWithDomain;
}
const { t } = useI18n({ useScope: "global" });
useHead({
title: computed(() => t("Administration")),
});
</script>
<style lang="scss" scoped>
.dashboard-number {
color: #3c376e;
font-size: 40px;
font-weight: 700;
line-height: 1.125;
@@ -172,7 +166,6 @@ export default class Dashboard extends Vue {
.tile a,
article.tile a {
color: #4a4a4a;
text-decoration: none;
}

View File

@@ -17,16 +17,18 @@
>
</div>
<div v-if="groups">
<b-switch v-model="local">{{ $t("Local") }}</b-switch>
<b-switch v-model="suspended">{{ $t("Suspended") }}</b-switch>
<b-table
<div class="flex gap-2">
<o-switch v-model="local">{{ $t("Local") }}</o-switch>
<o-switch v-model="suspended">{{ $t("Suspended") }}</o-switch>
</div>
<o-table
:data="groups.elements"
:loading="$apollo.queries.groups.loading"
:loading="loading"
paginated
backend-pagination
backend-filtering
:debounce-search="200"
:current-page.sync="page"
v-model:current-page="page"
:aria-next-label="$t('Next page')"
:aria-previous-label="$t('Previous page')"
:aria-page-label="$t('Page')"
@@ -36,13 +38,13 @@
@page-change="onPageChange"
@filters-change="onFiltersChange"
>
<b-table-column
<o-table-column
field="preferredUsername"
:label="$t('Username')"
searchable
>
<template #searchable="props">
<b-input
<o-input
:aria-label="$t('Filter')"
v-model="props.filters.preferredUsername"
:placeholder="$t('Filter')"
@@ -57,17 +59,19 @@
params: { id: props.row.id },
}"
>
<article class="media">
<figure class="media-left" v-if="props.row.avatar">
<p class="image is-48x48">
<img
:src="props.row.avatar.url"
:alt="props.row.avatar.alt || ''"
/>
</p>
<article class="flex gap-1">
<figure class="" v-if="props.row.avatar">
<img
:src="props.row.avatar.url"
:alt="props.row.avatar.alt || ''"
width="48"
height="48"
class="rounded-full"
/>
</figure>
<div class="media-content">
<div class="content">
<AccountGroup v-else :size="48" />
<div class="">
<div class="prose dark:prose-invert">
<strong v-if="props.row.name">{{ props.row.name }}</strong
><br v-if="props.row.name" />
<small>@{{ props.row.preferredUsername }}</small>
@@ -76,11 +80,11 @@
</article>
</router-link>
</template>
</b-table-column>
</o-table-column>
<b-table-column field="domain" :label="$t('Domain')" searchable>
<o-table-column field="domain" :label="$t('Domain')" searchable>
<template #searchable="props">
<b-input
<o-input
:aria-label="$t('Filter')"
v-model="props.filters.domain"
:placeholder="$t('Filter')"
@@ -90,150 +94,100 @@
<template v-slot:default="props">
{{ props.row.domain }}
</template>
</b-table-column>
<template slot="empty">
</o-table-column>
<template #empty>
<empty-content icon="account-group" :inline="true">
{{ $t("No group matches the filters") }}
</empty-content>
</template>
</b-table>
</o-table>
</div>
</div>
</template>
<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
import { CONFIG } from "@/graphql/config";
import { IConfig } from "@/types/config.model";
<script lang="ts" setup>
import { LIST_GROUPS } from "@/graphql/group";
import RouteName from "../../router/name";
import EmptyContent from "../../components/Utils/EmptyContent.vue";
import VueRouter from "vue-router";
const { isNavigationFailure, NavigationFailureType } = VueRouter;
import { useRestrictions } from "@/composition/apollo/config";
import { useQuery } from "@vue/apollo-composable";
import {
booleanTransformer,
integerTransformer,
useRouteQuery,
} from "vue-use-route-query";
import { useI18n } from "vue-i18n";
import { useHead } from "@vueuse/head";
import { computed } from "vue";
import { Paginate } from "@/types/paginate";
import { IGroup } from "@/types/actor";
import AccountGroup from "vue-material-design-icons/AccountGroup.vue";
const PROFILES_PER_PAGE = 10;
@Component({
apollo: {
config: CONFIG,
groups: {
query: LIST_GROUPS,
variables() {
return {
preferredUsername: this.preferredUsername,
name: this.name,
domain: this.domain,
local: this.local,
suspended: this.suspended,
page: this.page,
limit: PROFILES_PER_PAGE,
};
},
const { restrictions } = useRestrictions();
const preferredUsername = useRouteQuery("preferredUsername", "");
const name = useRouteQuery("name", "");
const domain = useRouteQuery("domain", "");
const local = useRouteQuery("local", false, booleanTransformer);
const suspended = useRouteQuery("suspended", false, booleanTransformer);
const page = useRouteQuery("page", 1, integerTransformer);
const {
result: groupsResult,
fetchMore,
loading,
} = useQuery<{
groups: Paginate<IGroup>;
}>(LIST_GROUPS, () => ({
preferredUsername: preferredUsername.value,
name: name.value,
domain: domain.value,
local: local.value,
suspended: suspended.value,
page: page.value,
limit: PROFILES_PER_PAGE,
}));
const groups = computed(() => groupsResult.value?.groups);
const { t } = useI18n({ useScope: "global" });
useHead({ title: computed(() => t("Groups")) });
const onPageChange = async (): Promise<void> => {
await doFetchMore();
};
const showCreateGroupsButton = computed((): boolean => {
return !!restrictions.value?.onlyAdminCanCreateGroups;
});
const onFiltersChange = ({
preferredUsername: newPreferredUsername,
domain: newDomain,
}: {
preferredUsername: string;
domain: string;
}): void => {
preferredUsername.value = newPreferredUsername;
domain.value = newDomain;
doFetchMore();
};
const doFetchMore = async (): Promise<void> => {
await fetchMore({
variables: {
preferredUsername: preferredUsername.value,
name: name.value,
domain: domain.value,
local: local.value,
suspended: suspended.value,
page: page.value,
limit: PROFILES_PER_PAGE,
},
},
metaInfo() {
return {
title: this.$t("Groups") as string,
};
},
components: {
EmptyContent,
},
})
export default class GroupProfiles extends Vue {
name = "";
PROFILES_PER_PAGE = PROFILES_PER_PAGE;
config!: IConfig;
RouteName = RouteName;
async onPageChange(): Promise<void> {
await this.doFetchMore();
}
get page(): number {
return parseInt((this.$route.query.page as string) || "1", 10);
}
set page(page: number) {
this.pushRouter({ page: page.toString() });
}
get domain(): string {
return (this.$route.query.domain as string) || "";
}
set domain(domain: string) {
this.pushRouter({ domain });
}
get preferredUsername(): string {
return (this.$route.query.preferredUsername as string) || "";
}
set preferredUsername(preferredUsername: string) {
this.pushRouter({ preferredUsername });
}
get local(): boolean {
return this.$route.query.local === "1";
}
set local(local: boolean) {
this.pushRouter({ local: local ? "1" : "0" });
}
get suspended(): boolean {
return this.$route.query.suspended === "1";
}
set suspended(suspended: boolean) {
this.pushRouter({ suspended: suspended ? "1" : "0" });
}
get showCreateGroupsButton(): boolean {
return !!this.config?.restrictions?.onlyAdminCanCreateGroups;
}
onFiltersChange({
preferredUsername,
domain,
}: {
preferredUsername: string;
domain: string;
}): void {
this.preferredUsername = preferredUsername;
this.domain = domain;
this.doFetchMore();
}
private async pushRouter(args: Record<string, string>): Promise<void> {
try {
await this.$router.push({
name: RouteName.ADMIN_GROUPS,
query: { ...this.$route.query, ...args },
});
} catch (e) {
if (isNavigationFailure(e, NavigationFailureType.redirected)) {
throw Error(e.toString());
}
}
}
private async doFetchMore(): Promise<void> {
await this.$apollo.queries.groups.fetchMore({
variables: {
preferredUsername: this.preferredUsername,
name: this.name,
domain: this.domain,
local: this.local,
suspended: this.suspended,
page: this.page,
limit: PROFILES_PER_PAGE,
},
});
}
}
});
};
</script>
<style lang="scss" scoped>
a.profile {

View File

@@ -72,14 +72,22 @@
v-if="instance.hasRelay"
>
<button
@click="removeInstanceFollow"
@click="
removeInstanceFollow({
address: instance?.relayAddress,
})
"
v-if="instance.followedStatus == InstanceFollowStatus.APPROVED"
class="bg-primary hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 focus:ring-offset-gray-50 text-white hover:text-white font-semibold h-12 px-6 rounded-lg w-full flex items-center justify-center sm:w-auto"
>
{{ $t("Stop following instance") }}
</button>
<button
@click="removeInstanceFollow"
@click="
removeInstanceFollow({
address: instance?.relayAddress,
})
"
v-else-if="instance.followedStatus == InstanceFollowStatus.PENDING"
class="bg-primary hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 focus:ring-offset-gray-50 text-white hover:text-white font-semibold h-12 px-6 rounded-lg w-full flex items-center justify-center sm:w-auto"
>
@@ -98,14 +106,22 @@
</div>
<div class="border bg-white p-6 shadow-md rounded-md flex flex-col gap-2">
<button
@click="acceptInstance"
@click="
acceptInstance({
address: instance?.relayAddress,
})
"
v-if="instance.followerStatus == InstanceFollowStatus.PENDING"
class="bg-green-700 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 focus:ring-offset-gray-50 text-white hover:text-white font-semibold h-12 px-6 rounded-lg w-full flex items-center justify-center sm:w-auto"
>
{{ $t("Accept follow") }}
</button>
<button
@click="rejectInstance"
@click="
rejectInstance({
address: instance?.relayAddress,
})
"
v-if="instance.followerStatus != InstanceFollowStatus.NONE"
class="bg-red-700 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 focus:ring-offset-gray-50 text-white hover:text-white font-semibold h-12 px-6 rounded-lg w-full flex items-center justify-center sm:w-auto"
>
@@ -118,151 +134,123 @@
</div>
</div>
</template>
<script lang="ts">
<script lang="ts" setup>
import {
ACCEPT_RELAY,
ADD_INSTANCE,
INSTANCE,
REJECT_RELAY,
REMOVE_RELAY,
} from "@/graphql/admin";
import { Component, Prop, Vue } from "vue-property-decorator";
import { formatBytes } from "@/utils/datetime";
import RouteName from "@/router/name";
import { IInstance } from "@/types/instance.model";
import { ApolloCache, gql, Reference } from "@apollo/client/core";
import { InstanceFollowStatus } from "@/types/enums";
import { useMutation, useQuery } from "@vue/apollo-composable";
import { computed, inject } from "vue";
import { Notifier } from "@/plugins/notifier";
@Component({
apollo: {
instance: {
query: INSTANCE,
variables() {
return {
domain: this.domain,
};
},
const props = defineProps<{ domain: string }>();
const { result: instanceResult } = useQuery<{ instance: IInstance }>(
INSTANCE,
() => ({ domain: props.domain })
);
const instance = computed(() => instanceResult.value?.instance);
const notifier = inject<Notifier>("notifier");
const { mutate: acceptInstance, onError: onAcceptInstanceError } = useMutation(
ACCEPT_RELAY,
() => ({
update(cache: ApolloCache<any>) {
cache.writeFragment({
id: cache.identify(instance as unknown as Reference),
fragment: gql`
fragment InstanceFollowerStatus on Instance {
followerStatus
}
`,
data: {
followerStatus: InstanceFollowStatus.APPROVED,
},
});
},
},
})
export default class Instance extends Vue {
@Prop({ type: String, required: true }) domain!: string;
})
);
instance!: IInstance;
onAcceptInstanceError((error) => {
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
notifier?.error(error.graphQLErrors[0].message);
}
});
InstanceFollowStatus = InstanceFollowStatus;
formatBytes = formatBytes;
RouteName = RouteName;
async acceptInstance(): Promise<void> {
try {
const { instance } = this;
await this.$apollo.mutate({
mutation: ACCEPT_RELAY,
variables: {
address: this.instance.relayAddress,
},
update(cache: ApolloCache<any>) {
cache.writeFragment({
id: cache.identify(instance as unknown as Reference),
fragment: gql`
fragment InstanceFollowerStatus on Instance {
followerStatus
}
`,
data: {
followerStatus: InstanceFollowStatus.APPROVED,
},
});
/**
* Reject instance follow
*/
const { mutate: rejectInstance, onError: onRejectInstanceError } = useMutation(
REJECT_RELAY,
() => ({
update(cache: ApolloCache<any>) {
cache.writeFragment({
id: cache.identify(instance as unknown as Reference),
fragment: gql`
fragment InstanceFollowerStatus on Instance {
followerStatus
}
`,
data: {
followerStatus: InstanceFollowStatus.NONE,
},
});
} catch (error: any) {
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
this.$notifier.error(error.graphQLErrors[0].message);
}
}
}
},
})
);
/**
* Reject instance follow
*/
async rejectInstance(): Promise<void> {
try {
const { instance } = this;
await this.$apollo.mutate({
mutation: REJECT_RELAY,
variables: {
address: this.instance.relayAddress,
},
update(cache: ApolloCache<any>) {
cache.writeFragment({
id: cache.identify(instance as unknown as Reference),
fragment: gql`
fragment InstanceFollowerStatus on Instance {
followerStatus
}
`,
data: {
followerStatus: InstanceFollowStatus.NONE,
},
});
onRejectInstanceError((error) => {
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
notifier?.error(error.graphQLErrors[0].message);
}
});
const { mutate: followInstanceMutation, onError: onFollowInstanceError } =
useMutation(ADD_INSTANCE);
onFollowInstanceError((error) => {
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
notifier?.error(error.graphQLErrors[0].message);
}
});
const followInstance = async (e: Event): Promise<void> => {
e.preventDefault();
followInstanceMutation({ domain: props.domain });
};
/**
* Stop following instance
*/
const { mutate: removeInstanceFollow, onError: onRemoveInstanceFollowError } =
useMutation(REJECT_RELAY, () => ({
update(cache: ApolloCache<any>) {
cache.writeFragment({
id: cache.identify(instance as unknown as Reference),
fragment: gql`
fragment InstanceFollowedStatus on Instance {
followedStatus
}
`,
data: {
followedStatus: InstanceFollowStatus.NONE,
},
});
} catch (error: any) {
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
this.$notifier.error(error.graphQLErrors[0].message);
}
}
}
},
}));
async followInstance(e: Event): Promise<void> {
e.preventDefault();
try {
await this.$apollo.mutate<{ addInstance: Instance }>({
mutation: ADD_INSTANCE,
variables: {
domain: this.domain,
},
});
} catch (error: any) {
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
this.$notifier.error(error.graphQLErrors[0].message);
}
}
onRemoveInstanceFollowError((error) => {
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
notifier?.error(error.graphQLErrors[0].message);
}
/**
* Stop following instance
*/
async removeInstanceFollow(): Promise<void> {
const { instance } = this;
try {
await this.$apollo.mutate({
mutation: REMOVE_RELAY,
variables: {
address: this.instance.relayAddress,
},
update(cache: ApolloCache<any>) {
cache.writeFragment({
id: cache.identify(instance as unknown as Reference),
fragment: gql`
fragment InstanceFollowedStatus on Instance {
followedStatus
}
`,
data: {
followedStatus: InstanceFollowStatus.NONE,
},
});
},
});
} catch (error: any) {
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
this.$notifier.error(error.graphQLErrors[0].message);
}
}
}
}
});
</script>

View File

@@ -9,57 +9,57 @@
<section>
<h1 class="title">{{ $t("Instances") }}</h1>
<form @submit="followInstance" class="my-4">
<b-field :label="$t('Follow a new instance')" horizontal>
<b-field grouped group-multiline expanded size="is-large">
<o-field :label="$t('Follow a new instance')" horizontal>
<o-field grouped group-multiline expanded size="large">
<p class="control">
<b-input
<o-input
v-model="newRelayAddress"
:placeholder="$t('Ex: mobilizon.fr')"
/>
</p>
<p class="control">
<b-button type="is-primary" native-type="submit">{{
<o-button variant="primary" native-type="submit">{{
$t("Add an instance")
}}</b-button>
<b-loading
}}</o-button>
<o-loading
:is-full-page="true"
v-model="followInstanceLoading"
:can-cancel="false"
/>
</p>
</b-field>
</b-field>
</o-field>
</o-field>
</form>
<div class="flex flex-wrap gap-2">
<b-field :label="$t('Follow status')">
<b-radio-button
<o-field :label="$t('Follow status')">
<o-radio
v-model="followStatus"
:native-value="InstanceFilterFollowStatus.ALL"
>{{ $t("All") }}</b-radio-button
>{{ $t("All") }}</o-radio
>
<b-radio-button
<o-radio
v-model="followStatus"
:native-value="InstanceFilterFollowStatus.FOLLOWING"
>{{ $t("Following") }}</b-radio-button
>{{ $t("Following") }}</o-radio
>
<b-radio-button
<o-radio
v-model="followStatus"
:native-value="InstanceFilterFollowStatus.FOLLOWED"
>{{ $t("Followed") }}</b-radio-button
>{{ $t("Followed") }}</o-radio
>
</b-field>
<b-field
</o-field>
<o-field
:label="$t('Domain')"
label-for="domain-filter"
class="flex-auto"
>
<b-input
<o-input
id="domain-filter"
:placeholder="$t('mobilizon-instance.tld')"
:value="filterDomain"
@input="debouncedUpdateDomainFilter"
/>
</b-field>
</o-field>
</div>
<div v-if="instances && instances.elements.length > 0" class="mt-3">
<router-link
@@ -78,7 +78,7 @@
src="../../assets/logo.svg"
alt=""
/>
<b-icon
<o-icon
class="is-large"
v-else
custom-size="mdi-36px"
@@ -90,7 +90,7 @@
class="text-sm"
v-if="instance.followedStatus === InstanceFollowStatus.APPROVED"
>
<b-icon icon="inbox-arrow-down" />
<o-icon icon="inbox-arrow-down" />
{{ $t("Followed") }}</span
>
<span
@@ -99,21 +99,21 @@
instance.followedStatus === InstanceFollowStatus.PENDING
"
>
<b-icon icon="inbox-arrow-down" />
<o-icon icon="inbox-arrow-down" />
{{ $t("Followed, pending response") }}</span
>
<span
class="text-sm"
v-if="instance.followerStatus == InstanceFollowStatus.APPROVED"
>
<b-icon icon="inbox-arrow-up" />
<o-icon icon="inbox-arrow-up" />
{{ $t("Follows us") }}</span
>
<span
class="text-sm"
v-if="instance.followerStatus == InstanceFollowStatus.PENDING"
>
<b-icon icon="inbox-arrow-up" />
<o-icon icon="inbox-arrow-up" />
{{ $t("Follows us, pending approval") }}</span
>
</div>
@@ -129,7 +129,7 @@
</p>
</div>
</router-link>
<b-pagination
<o-pagination
v-show="instances.total > INSTANCES_PAGE_LIMIT"
:total="instances.total"
v-model="instancePage"
@@ -139,7 +139,7 @@
:aria-page-label="$t('Page')"
:aria-current-label="$t('Current page')"
>
</b-pagination>
</o-pagination>
</div>
<div v-else-if="instances && instances.elements.length == 0">
<empty-content icon="lan-disconnect" :inline="true">
@@ -162,145 +162,107 @@
</div>
</template>
<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
<script lang="ts" setup>
import { ADD_INSTANCE, INSTANCES } from "@/graphql/admin";
import { Paginate } from "@/types/paginate";
import { IFollower } from "@/types/actor/follower.model";
import RouteName from "../../router/name";
import { IInstance } from "@/types/instance.model";
import EmptyContent from "@/components/Utils/EmptyContent.vue";
import VueRouter from "vue-router";
import { debounce } from "lodash";
import debounce from "lodash/debounce";
import {
InstanceFilterFollowStatus,
InstanceFollowStatus,
} from "@/types/enums";
const { isNavigationFailure, NavigationFailureType } = VueRouter;
import { useI18n } from "vue-i18n";
import {
enumTransformer,
integerTransformer,
useRouteQuery,
} from "vue-use-route-query";
import { useMutation, useQuery } from "@vue/apollo-composable";
import { computed, ref } from "vue";
import { useRouter } from "vue-router";
import { useHead } from "@vueuse/head";
const INSTANCES_PAGE_LIMIT = 10;
@Component({
apollo: {
instances: {
query: INSTANCES,
fetchPolicy: "cache-and-network",
variables() {
return {
page: this.instancePage,
limit: INSTANCES_PAGE_LIMIT,
filterDomain: this.filterDomain,
filterFollowStatus: this.followStatus,
};
},
},
},
metaInfo() {
return {
title: this.$t("Federation") as string,
};
},
components: {
EmptyContent,
},
})
export default class Follows extends Vue {
RouteName = RouteName;
const instancePage = useRouteQuery("page", 1, integerTransformer);
const filterDomain = useRouteQuery("filterDomain", "");
const followStatus = useRouteQuery(
"followStatus",
InstanceFilterFollowStatus.ALL,
enumTransformer(InstanceFilterFollowStatus)
);
followInstanceLoading = false;
const { result: instancesResult } = useQuery<{
instances: Paginate<IInstance>;
}>(INSTANCES, () => ({
page: instancePage.value,
limit: INSTANCES_PAGE_LIMIT,
filterDomain: filterDomain.value,
filterFollowStatus: followStatus.value,
}));
newRelayAddress = "";
const instances = computed(() => instancesResult.value?.instances);
instances!: Paginate<IInstance>;
const { t } = useI18n({ useScope: "global" });
useHead({
title: computed(() => t("Federation")),
});
instancePage = 1;
const followInstanceLoading = ref(false);
relayFollowings: Paginate<IFollower> = { elements: [], total: 0 };
const newRelayAddress = ref("");
relayFollowers: Paginate<IFollower> = { elements: [], total: 0 };
// relayFollowings: Paginate<IFollower> = { elements: [], total: 0 };
InstanceFilterFollowStatus = InstanceFilterFollowStatus;
// relayFollowers: Paginate<IFollower> = { elements: [], total: 0 };
InstanceFollowStatus = InstanceFollowStatus;
const updateDomainFilter = (domain: string) => {
filterDomain.value = domain;
};
INSTANCES_PAGE_LIMIT = INSTANCES_PAGE_LIMIT;
const debouncedUpdateDomainFilter = debounce(updateDomainFilter, 500);
data(): Record<string, unknown> {
return {
debouncedUpdateDomainFilter: debounce(this.updateDomainFilter, 500),
};
}
const hasFilter = computed((): boolean => {
return (
followStatus.value !== InstanceFilterFollowStatus.ALL ||
filterDomain.value !== ""
);
});
updateDomainFilter(domain: string) {
this.filterDomain = domain;
}
const router = useRouter();
get filterDomain(): string {
return (this.$route.query.domain as string) || "";
}
const { mutate, onDone, onError } = useMutation<{
addInstance: IInstance;
}>(ADD_INSTANCE);
set filterDomain(domain: string) {
this.pushRouter({ domain });
}
onDone(({ data }) => {
newRelayAddress.value = "";
followInstanceLoading.value = false;
router.push({
name: RouteName.INSTANCE,
params: { domain: data?.addInstance.domain },
});
});
get followStatus(): InstanceFilterFollowStatus {
return (
(this.$route.query.followStatus as InstanceFilterFollowStatus) ||
InstanceFilterFollowStatus.ALL
);
}
set followStatus(followStatus: InstanceFilterFollowStatus) {
this.pushRouter({ followStatus });
}
get hasFilter(): boolean {
return (
this.followStatus !== InstanceFilterFollowStatus.ALL ||
this.filterDomain !== ""
);
}
async followInstance(e: Event): Promise<void> {
e.preventDefault();
this.followInstanceLoading = true;
const domain = this.newRelayAddress.trim(); // trim to fix copy and paste domain name spaces and tabs
try {
await this.$apollo.mutate<{ relayFollowings: Paginate<IFollower> }>({
mutation: ADD_INSTANCE,
variables: {
domain,
},
});
this.newRelayAddress = "";
this.followInstanceLoading = false;
this.$router.push({
name: RouteName.INSTANCE,
params: { domain },
});
} catch (error: any) {
if (error.message) {
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
this.$notifier.error(error.graphQLErrors[0].message);
}
}
this.followInstanceLoading = false;
onError((error) => {
if (error.message) {
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
notifier?.error(error.graphQLErrors[0].message);
}
}
followInstanceLoading.value = false;
});
private async pushRouter(args: Record<string, string>): Promise<void> {
try {
await this.$router.push({
name: RouteName.INSTANCES,
query: { ...this.$route.query, ...args },
});
} catch (e) {
if (isNavigationFailure(e, NavigationFailureType.redirected)) {
throw Error(e.toString());
}
}
}
}
const followInstance = async (e: Event): Promise<void> => {
e.preventDefault();
followInstanceLoading.value = true;
const domain = newRelayAddress.value.trim(); // trim to fix copy and paste domain name spaces and tabs
mutate({
domain,
});
};
</script>
<style lang="scss" scoped>
.tab-item {

View File

@@ -10,16 +10,16 @@
]"
/>
<div v-if="persons">
<b-switch v-model="local">{{ $t("Local") }}</b-switch>
<b-switch v-model="suspended">{{ $t("Suspended") }}</b-switch>
<b-table
<o-switch v-model="local">{{ $t("Local") }}</o-switch>
<o-switch v-model="suspended">{{ $t("Suspended") }}</o-switch>
<o-table
:data="persons.elements"
:loading="$apollo.queries.persons.loading"
:loading="loading"
paginated
backend-pagination
backend-filtering
:debounce-search="200"
:current-page.sync="page"
v-model:current-page="page"
:aria-next-label="$t('Next page')"
:aria-previous-label="$t('Previous page')"
:aria-page-label="$t('Page')"
@@ -29,13 +29,13 @@
@page-change="onPageChange"
@filters-change="onFiltersChange"
>
<b-table-column
<o-table-column
field="preferredUsername"
:label="$t('Username')"
searchable
>
<template #searchable="props">
<b-input
<o-input
v-model="props.filters.preferredUsername"
:aria-label="$t('Filter')"
:placeholder="$t('Filter')"
@@ -50,17 +50,18 @@
params: { id: props.row.id },
}"
>
<article class="media">
<figure class="media-left" v-if="props.row.avatar">
<p class="image is-48x48">
<img
:src="props.row.avatar.url"
:alt="props.row.avatar.alt || ''"
/>
</p>
<article class="flex gap-2">
<figure class="" v-if="props.row.avatar">
<img
:src="props.row.avatar.url"
:alt="props.row.avatar.alt || ''"
width="48"
height="48"
/>
</figure>
<div class="media-content">
<div class="content">
<Account v-else :size="48" />
<div class="">
<div class="prose dark:prose-invert">
<strong v-if="props.row.name">{{ props.row.name }}</strong
><br v-if="props.row.name" />
<small>@{{ props.row.preferredUsername }}</small>
@@ -69,11 +70,11 @@
</article>
</router-link>
</template>
</b-table-column>
</o-table-column>
<b-table-column field="domain" :label="$t('Domain')" searchable>
<o-table-column field="domain" :label="$t('Domain')" searchable>
<template #searchable="props">
<b-input
<o-input
v-model="props.filters.domain"
:aria-label="$t('Filter')"
:placeholder="$t('Filter')"
@@ -83,140 +84,79 @@
<template v-slot:default="props">
{{ props.row.domain }}
</template>
</b-table-column>
<template slot="empty">
</o-table-column>
<template #empty>
<empty-content icon="account" :inline="true">
{{ $t("No profile matches the filters") }}
</empty-content>
</template>
</b-table>
</o-table>
</div>
</div>
</template>
<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
import { LIST_PROFILES } from "../../graphql/actor";
import RouteName from "../../router/name";
import EmptyContent from "../../components/Utils/EmptyContent.vue";
import VueRouter from "vue-router";
const { isNavigationFailure, NavigationFailureType } = VueRouter;
<script lang="ts" setup>
import { LIST_PROFILES } from "@/graphql/actor";
import RouteName from "@/router/name";
import EmptyContent from "@/components/Utils/EmptyContent.vue";
import { useQuery } from "@vue/apollo-composable";
import { useI18n } from "vue-i18n";
import { computed } from "vue";
import { useHead } from "@vueuse/head";
import {
useRouteQuery,
booleanTransformer,
integerTransformer,
} from "vue-use-route-query";
import { Paginate } from "@/types/paginate";
import { IPerson } from "@/types/actor/person.model";
import Account from "vue-material-design-icons/Account.vue";
const PROFILES_PER_PAGE = 10;
@Component({
apollo: {
persons: {
query: LIST_PROFILES,
fetchPolicy: "cache-and-network",
variables() {
return {
preferredUsername: this.preferredUsername,
name: this.name,
domain: this.domain,
local: this.local,
suspended: this.suspended,
page: this.page,
limit: PROFILES_PER_PAGE,
};
},
},
},
components: {
EmptyContent,
},
metaInfo() {
return {
title: this.$t("Profiles") as string,
};
},
})
export default class Profiles extends Vue {
PROFILES_PER_PAGE = PROFILES_PER_PAGE;
const preferredUsername = useRouteQuery("preferredUsername", "");
const name = useRouteQuery("name", "");
const domain = useRouteQuery("domain", "");
const local = useRouteQuery("local", false, booleanTransformer);
const suspended = useRouteQuery("suspended", false, booleanTransformer);
const page = useRouteQuery("page", 1, integerTransformer);
RouteName = RouteName;
const {
result: personResult,
loading,
fetchMore,
} = useQuery<{ persons: Paginate<IPerson> }>(LIST_PROFILES, () => ({
preferredUsername: preferredUsername.value,
name: name.value,
domain: domain.value,
local: local.value,
suspended: suspended.value,
page: page.value,
limit: PROFILES_PER_PAGE,
}));
async onPageChange(): Promise<void> {
await this.doFetchMore();
}
const persons = computed(() => personResult.value?.persons);
get page(): number {
return parseInt((this.$route.query.page as string) || "1", 10);
}
const { t } = useI18n({ useScope: "global" });
set page(page: number) {
this.pushRouter({ page: page.toString() });
}
useHead({
title: computed(() => t("Profiles")),
});
get domain(): string {
return (this.$route.query.domain as string) || "";
}
const onPageChange = async (): Promise<void> => {
await fetchMore();
};
set domain(domain: string) {
this.pushRouter({ domain });
}
get preferredUsername(): string {
return (this.$route.query.preferredUsername as string) || "";
}
set preferredUsername(preferredUsername: string) {
this.pushRouter({ preferredUsername });
}
get local(): boolean {
return this.$route.query.local === "1";
}
set local(local: boolean) {
this.pushRouter({ local: local ? "1" : "0" });
}
get suspended(): boolean {
return this.$route.query.suspended === "1";
}
set suspended(suspended: boolean) {
this.pushRouter({ suspended: suspended ? "1" : "0" });
}
private async pushRouter(args: Record<string, string>): Promise<void> {
try {
await this.$router.push({
name: RouteName.PROFILES,
query: { ...this.$route.query, ...args },
});
} catch (e) {
if (isNavigationFailure(e, NavigationFailureType.redirected)) {
throw Error(e.toString());
}
}
}
private async doFetchMore(): Promise<void> {
await this.$apollo.queries.persons.fetchMore({
variables: {
preferredUsername: this.preferredUsername,
domain: this.domain,
local: this.local,
suspended: this.suspended,
page: this.page,
limit: PROFILES_PER_PAGE,
},
});
}
onFiltersChange({
preferredUsername,
domain,
}: {
preferredUsername: string;
domain: string;
}): void {
this.preferredUsername = preferredUsername;
this.domain = domain;
this.doFetchMore();
}
}
const onFiltersChange = ({
preferredUsername: newPreferredUsername,
domain: newDomain,
}: {
preferredUsername: string;
domain: string;
}): void => {
preferredUsername.value = newPreferredUsername;
domain.value = newDomain;
fetchMore();
};
</script>
<style lang="scss" scoped>
a.profile {

View File

@@ -9,9 +9,9 @@
<section v-if="settingsToWrite">
<form @submit.prevent="updateSettings">
<b-field :label="$t('Instance Name')" label-for="instance-name">
<b-input v-model="settingsToWrite.instanceName" id="instance-name" />
</b-field>
<o-field :label="$t('Instance Name')" label-for="instance-name">
<o-input v-model="settingsToWrite.instanceName" id="instance-name" />
</o-field>
<div class="field">
<label class="label has-help" for="instance-description">{{
$t("Instance Short Description")
@@ -23,7 +23,7 @@
)
}}
</small>
<b-input
<o-input
type="textarea"
v-model="settingsToWrite.instanceDescription"
rows="2"
@@ -41,7 +41,7 @@
)
}}
</small>
<b-input
<o-input
v-model="settingsToWrite.instanceSlogan"
:placeholder="$t('Gather ⋅ Organize ⋅ Mobilize')"
id="instance-slogan"
@@ -54,16 +54,21 @@
<small>
{{ $t("Can be an email or a link, or just plain text.") }}
</small>
<b-input v-model="settingsToWrite.contact" id="instance-contact" />
<o-input v-model="settingsToWrite.contact" id="instance-contact" />
</div>
<b-field :label="$t('Allow registrations')">
<b-switch v-model="settingsToWrite.registrationsOpen">
<p class="content" v-if="settingsToWrite.registrationsOpen">
<o-field :label="$t('Allow registrations')">
<o-switch v-model="settingsToWrite.registrationsOpen">
<p
class="prose dark:prose-invert"
v-if="settingsToWrite.registrationsOpen"
>
{{ $t("Registration is allowed, anyone can register.") }}
</p>
<p class="content" v-else>{{ $t("Registration is closed.") }}</p>
</b-switch>
</b-field>
<p class="prose dark:prose-invert" v-else>
{{ $t("Registration is closed.") }}
</p>
</o-switch>
</o-field>
<div class="field">
<label class="label has-help" for="instance-languages">{{
$t("Instance languages")
@@ -71,7 +76,7 @@
<small>
{{ $t("Main languages you/your moderators speak") }}
</small>
<b-taginput
<o-taginput
v-model="instanceLanguages"
:data="filteredLanguages"
autocomplete
@@ -82,8 +87,8 @@
@typing="getFilteredLanguages"
id="instance-languages"
>
<template slot="empty">{{ $t("No languages found") }}</template>
</b-taginput>
<template #empty>{{ $t("No languages found") }}</template>
</o-taginput>
</div>
<div class="field">
<label class="label has-help" for="instance-long-description">{{
@@ -96,7 +101,7 @@
)
}}
</small>
<b-input
<o-input
type="textarea"
v-model="settingsToWrite.instanceLongDescription"
rows="4"
@@ -114,43 +119,43 @@
)
}}
</small>
<b-input
<o-input
type="textarea"
v-model="settingsToWrite.instanceRules"
id="instance-rules"
/>
</div>
<b-field :label="$t('Instance Terms Source')">
<o-field :label="$t('Instance Terms Source')">
<div class="columns">
<div class="column is-one-third-desktop">
<fieldset>
<legend>
{{ $t("Choose the source of the instance's Terms") }}
</legend>
<b-field>
<b-radio
<o-field>
<o-radio
v-model="settingsToWrite.instanceTermsType"
name="instanceTermsType"
:native-value="InstanceTermsType.DEFAULT"
>{{ $t("Default Mobilizon terms") }}</b-radio
>{{ $t("Default Mobilizon terms") }}</o-radio
>
</b-field>
<b-field>
<b-radio
</o-field>
<o-field>
<o-radio
v-model="settingsToWrite.instanceTermsType"
name="instanceTermsType"
:native-value="InstanceTermsType.URL"
>{{ $t("Custom URL") }}</b-radio
>{{ $t("Custom URL") }}</o-radio
>
</b-field>
<b-field>
<b-radio
</o-field>
<o-field>
<o-radio
v-model="settingsToWrite.instanceTermsType"
name="instanceTermsType"
:native-value="InstanceTermsType.CUSTOM"
>{{ $t("Custom text") }}</b-radio
>{{ $t("Custom text") }}</o-radio
>
</b-field>
</o-field>
</fieldset>
</div>
<div class="column">
@@ -162,19 +167,20 @@
"
>
<b>{{ $t("Default") }}</b>
<i18n
<i18n-t
tag="p"
class="content"
path="The {default_terms} will be used. They will be translated in the user's language."
class="prose dark:prose-invert"
keypath="The {default_terms} will be used. They will be translated in the user's language."
>
<a
slot="default_terms"
href="https://demo.mobilizon.org/terms"
target="_blank"
rel="noopener"
>{{ $t("default Mobilizon terms") }}</a
>
</i18n>
<template #default_terms>
<a
href="https://demo.mobilizon.org/terms"
target="_blank"
rel="noopener"
>{{ $t("default Mobilizon terms") }}</a
>
</template>
</i18n-t>
<b>{{
$t(
"NOTE! The default terms have not been checked over by a lawyer and thus are unlikely to provide full legal protection for all situations for an instance admin using them. They are also not specific to all countries and jurisdictions. If you are unsure, please check with a lawyer."
@@ -188,7 +194,7 @@
"
>
<b>{{ $t("URL") }}</b>
<p class="content">
<p class="prose dark:prose-invert">
{{ $t("Set an URL to a page with your own terms.") }}
</p>
</div>
@@ -199,77 +205,78 @@
"
>
<b>{{ $t("Custom") }}</b>
<i18n
<i18n-t
tag="p"
class="content"
path="Enter your own terms. HTML tags allowed. The {mobilizon_terms} are provided as template."
class="prose dark:prose-invert"
keypath="Enter your own terms. HTML tags allowed. The {mobilizon_terms} are provided as template."
>
<a
slot="mobilizon_terms"
href="https://demo.mobilizon.org/terms"
target="_blank"
rel="noopener"
>
{{ $t("default Mobilizon terms") }}</a
>
</i18n>
<template #mobilizon_terms>
<a
href="https://demo.mobilizon.org/terms"
target="_blank"
rel="noopener"
>
{{ $t("default Mobilizon terms") }}</a
>
</template>
</i18n-t>
</div>
</div>
</div>
</b-field>
<b-field
</o-field>
<o-field
:label="$t('Instance Terms URL')"
label-for="instanceTermsUrl"
v-if="settingsToWrite.instanceTermsType === InstanceTermsType.URL"
>
<b-input
<o-input
type="URL"
v-model="settingsToWrite.instanceTermsUrl"
id="instanceTermsUrl"
/>
</b-field>
<b-field
</o-field>
<o-field
:label="$t('Instance Terms')"
label-for="instanceTerms"
v-if="settingsToWrite.instanceTermsType === InstanceTermsType.CUSTOM"
>
<b-input
<o-input
type="textarea"
v-model="settingsToWrite.instanceTerms"
id="instanceTerms"
/>
</b-field>
<b-field :label="$t('Instance Privacy Policy Source')">
</o-field>
<o-field :label="$t('Instance Privacy Policy Source')">
<div class="columns">
<div class="column is-one-third-desktop">
<fieldset>
<legend>
{{ $t("Choose the source of the instance's Privacy Policy") }}
</legend>
<b-field>
<b-radio
<o-field>
<o-radio
v-model="settingsToWrite.instancePrivacyPolicyType"
name="instancePrivacyType"
:native-value="InstancePrivacyType.DEFAULT"
>{{ $t("Default Mobilizon privacy policy") }}</b-radio
>{{ $t("Default Mobilizon privacy policy") }}</o-radio
>
</b-field>
<b-field>
<b-radio
</o-field>
<o-field>
<o-radio
v-model="settingsToWrite.instancePrivacyPolicyType"
name="instancePrivacyType"
:native-value="InstancePrivacyType.URL"
>{{ $t("Custom URL") }}</b-radio
>{{ $t("Custom URL") }}</o-radio
>
</b-field>
<b-field>
<b-radio
</o-field>
<o-field>
<o-radio
v-model="settingsToWrite.instancePrivacyPolicyType"
name="instancePrivacyType"
:native-value="InstancePrivacyType.CUSTOM"
>{{ $t("Custom text") }}</b-radio
>{{ $t("Custom text") }}</o-radio
>
</b-field>
</o-field>
</fieldset>
</div>
<div class="column">
@@ -281,19 +288,20 @@
"
>
<b>{{ $t("Default") }}</b>
<i18n
<i18n-t
tag="p"
class="content"
path="The {default_privacy_policy} will be used. They will be translated in the user's language."
class="prose dark:prose-invert"
keypath="The {default_privacy_policy} will be used. They will be translated in the user's language."
>
<a
slot="default_privacy_policy"
href="https://demo.mobilizon.org/privacy"
target="_blank"
rel="noopener"
>{{ $t("default Mobilizon privacy policy") }}</a
>
</i18n>
<template #default_privacy_policy>
<a
href="https://demo.mobilizon.org/privacy"
target="_blank"
rel="noopener"
>{{ $t("default Mobilizon privacy policy") }}</a
>
</template>
</i18n-t>
</div>
<div
class="notification"
@@ -303,7 +311,7 @@
"
>
<b>{{ $t("URL") }}</b>
<p class="content">
<p class="prose dark:prose-invert">
{{ $t("Set an URL to a page with your own privacy policy.") }}
</p>
</div>
@@ -315,25 +323,26 @@
"
>
<b>{{ $t("Custom") }}</b>
<i18n
<i18n-t
tag="p"
class="content"
class="prose dark:prose-invert"
path="Enter your own privacy policy. HTML tags allowed. The {mobilizon_privacy_policy} is provided as template."
>
<a
slot="mobilizon_privacy_policy"
href="https://demo.mobilizon.org/privacy"
target="_blank"
rel="noopener"
>
{{ $t("default Mobilizon privacy policy") }}</a
>
</i18n>
<template #mobilizon_privacy_policy>
<a
href="https://demo.mobilizon.org/privacy"
target="_blank"
rel="noopener"
>
{{ $t("default Mobilizon privacy policy") }}</a
>
</template>
</i18n-t>
</div>
</div>
</div>
</b-field>
<b-field
</o-field>
<o-field
:label="$t('Instance Privacy Policy URL')"
label-for="instancePrivacyPolicyUrl"
v-if="
@@ -341,13 +350,13 @@
InstancePrivacyType.URL
"
>
<b-input
<o-input
type="URL"
v-model="settingsToWrite.instancePrivacyPolicyUrl"
id="instancePrivacyPolicyUrl"
/>
</b-field>
<b-field
</o-field>
<o-field
:label="$t('Instance Privacy Policy')"
label-for="instancePrivacyPolicy"
v-if="
@@ -355,142 +364,147 @@
InstancePrivacyType.CUSTOM
"
>
<b-input
<o-input
type="textarea"
v-model="settingsToWrite.instancePrivacyPolicy"
id="instancePrivacyPolicy"
/>
</b-field>
<b-button native-type="submit" type="is-primary">{{
</o-field>
<o-button native-type="submit" variant="primary">{{
$t("Save")
}}</b-button>
}}</o-button>
</form>
</section>
</div>
</template>
<script lang="ts">
import { Component, Vue, Watch } from "vue-property-decorator";
<script lang="ts" setup>
import {
ADMIN_SETTINGS,
SAVE_ADMIN_SETTINGS,
LANGUAGES,
} from "@/graphql/admin";
import { InstancePrivacyType, InstanceTermsType } from "@/types/enums";
import { IAdminSettings, ILanguage } from "../../types/admin.model";
import RouteName from "../../router/name";
import { IAdminSettings, ILanguage } from "@/types/admin.model";
import RouteName from "@/router/name";
import { useMutation, useQuery } from "@vue/apollo-composable";
import { ref, computed, watch, inject } from "vue";
import { useI18n } from "vue-i18n";
import { useHead } from "@vueuse/head";
import { Notifier } from "@/plugins/notifier";
@Component({
apollo: {
adminSettings: ADMIN_SETTINGS,
languages: LANGUAGES,
},
metaInfo() {
return {
title: this.$t("Settings") as string,
};
},
})
export default class Settings extends Vue {
adminSettings: IAdminSettings = {
instanceName: "",
instanceDescription: "",
instanceSlogan: "",
instanceLongDescription: "",
contact: "",
instanceTerms: "",
instanceTermsType: InstanceTermsType.DEFAULT,
instanceTermsUrl: null,
instancePrivacyPolicy: "",
instancePrivacyPolicyType: InstanceTermsType.DEFAULT,
instancePrivacyPolicyUrl: null,
instanceRules: "",
registrationsOpen: false,
instanceLanguages: [],
};
const { result: adminSettingsResult } = useQuery<{
adminSettings: IAdminSettings;
}>(ADMIN_SETTINGS);
const adminSettings = computed(
() =>
adminSettingsResult.value?.adminSettings ?? {
instanceName: "",
instanceDescription: "",
instanceSlogan: "",
instanceLongDescription: "",
contact: "",
instanceTerms: "",
instanceTermsType: InstanceTermsType.DEFAULT,
instanceTermsUrl: null,
instancePrivacyPolicy: "",
instancePrivacyPolicyType: InstanceTermsType.DEFAULT,
instancePrivacyPolicyUrl: null,
instanceRules: "",
registrationsOpen: false,
instanceLanguages: [],
}
);
settingsToWrite: IAdminSettings = { ...this.adminSettings };
const { result: languageResult } = useQuery<{ languages: ILanguage[] }>(
LANGUAGES
);
const languages = computed(() => languageResult.value?.languages);
@Watch("adminSettings")
updateSettingsToWrite(): void {
this.settingsToWrite = { ...this.adminSettings };
}
const { t } = useI18n({ useScope: "global" });
useHead({
title: computed(() => t("Settings")),
});
languages!: ILanguage[];
const settingsToWrite = ref<IAdminSettings>({ ...adminSettings });
filteredLanguages: string[] = [];
watch(adminSettings, () => {
// settingsToWrite.value = { ...adminSettings.value };
});
InstanceTermsType = InstanceTermsType;
const filteredLanguages = ref<string[]>([]);
InstancePrivacyType = InstancePrivacyType;
RouteName = RouteName;
get instanceLanguages(): string[] {
const languageCodes = [...this.adminSettings.instanceLanguages] || [];
const instanceLanguages = computed({
get() {
const languageCodes = [...adminSettings.value.instanceLanguages] || [];
return languageCodes
.map((code) => this.languageForCode(code))
.map((code) => languageForCode(code))
.filter((language) => language) as string[];
}
set instanceLanguages(instanceLanguages: string[]) {
const newInstanceLanguages = instanceLanguages
},
set(newInstanceLanguages: string[]) {
const newFilteredInstanceLanguages = newInstanceLanguages
.map((language) => {
return this.codeForLanguage(language);
return codeForLanguage(language);
})
.filter((code) => code !== undefined) as string[];
this.adminSettings = {
...this.adminSettings,
instanceLanguages: newInstanceLanguages,
};
}
// adminSettings = {
// ...adminSettings,
// instanceLanguages: newInstanceLanguages,
// };
},
});
async updateSettings(): Promise<void> {
const variables = { ...this.settingsToWrite };
try {
await this.$apollo.mutate({
mutation: SAVE_ADMIN_SETTINGS,
variables,
});
this.$notifier.success(
this.$t("Admin settings successfully saved.") as string
);
} catch (e) {
console.error(e);
this.$notifier.error(this.$t("Failed to save admin settings") as string);
}
}
const notifier = inject<Notifier>("notifier");
getFilteredLanguages(text: string): void {
this.filteredLanguages = this.languages
? this.languages
.filter((language: ILanguage) => {
return (
language.name
.toString()
.toLowerCase()
.indexOf(text.toLowerCase()) >= 0
);
})
.map(({ name }) => name)
: [];
}
const {
mutate: saveAdminSettings,
onDone: saveAdminSettingsDone,
onError: saveAdminSettingsError,
} = useMutation(SAVE_ADMIN_SETTINGS);
private codeForLanguage(language: string): string | undefined {
if (this.languages) {
const lang = this.languages.find(({ name }) => name === language);
if (lang) return lang.code;
}
return undefined;
}
saveAdminSettingsDone(() => {
notifier?.success(t("Admin settings successfully saved.") as string);
});
private languageForCode(codeGiven: string): string | undefined {
if (this.languages) {
const lang = this.languages.find(({ code }) => code === codeGiven);
if (lang) return lang.name;
}
return undefined;
saveAdminSettingsError((e) => {
console.error(e);
notifier?.error(t("Failed to save admin settings") as string);
});
const updateSettings = async (): Promise<void> => {
const variables = { ...settingsToWrite };
saveAdminSettings(variables);
};
const getFilteredLanguages = (text: string): void => {
filteredLanguages.value = languages.value
? languages.value
.filter((language: ILanguage) => {
return (
language.name
.toString()
.toLowerCase()
.indexOf(text.toLowerCase()) >= 0
);
})
.map(({ name }) => name)
: [];
};
const codeForLanguage = (language: string): string | undefined => {
if (languages.value) {
const lang = languages.value.find(({ name }) => name === language);
if (lang) return lang.code;
}
}
return undefined;
};
const languageForCode = (codeGiven: string): string | undefined => {
if (languages.value) {
const lang = languages.value.find(({ code }) => code === codeGiven);
if (lang) return lang.name;
}
return undefined;
};
</script>
<style lang="scss" scoped>
label.label.has-help {

View File

@@ -11,27 +11,27 @@
/>
<div v-if="users">
<form @submit.prevent="activateFilters">
<b-field class="mb-5" grouped group-multiline>
<b-field :label="$t('Email')" expanded>
<b-input trap-focus icon="email" v-model="emailFilterFieldValue" />
</b-field>
<b-field :label="$t('IP Address')" expanded>
<b-input icon="web" v-model="ipFilterFieldValue" />
</b-field>
<o-field class="mb-5" grouped group-multiline>
<o-field :label="$t('Email')" expanded>
<o-input trap-focus icon="email" v-model="emailFilterFieldValue" />
</o-field>
<o-field :label="$t('IP Address')" expanded>
<o-input icon="web" v-model="ipFilterFieldValue" />
</o-field>
<p class="control self-end mb-0">
<b-button type="is-primary" native-type="submit">{{
<o-button variant="primary" native-type="submit">{{
$t("Filter")
}}</b-button>
}}</o-button>
</p>
</b-field>
</o-field>
</form>
<b-table
<o-table
:data="users.elements"
:loading="$apollo.queries.users.loading"
:loading="usersLoading"
paginated
backend-pagination
:debounce-search="500"
:current-page.sync="page"
v-model:current-page="page"
:aria-next-label="$t('Next page')"
:aria-previous-label="$t('Previous page')"
:aria-page-label="$t('Page')"
@@ -41,10 +41,10 @@
:per-page="USERS_PER_PAGE"
@page-change="onPageChange"
>
<b-table-column field="id" width="40" numeric v-slot="props">
<o-table-column field="id" width="40" numeric v-slot="props">
{{ props.row.id }}
</b-table-column>
<b-table-column field="email" :label="$t('Email')">
</o-table-column>
<o-table-column field="email" :label="$t('Email')">
<template v-slot:default="props">
<router-link
:to="{
@@ -56,8 +56,8 @@
{{ props.row.email }}
</router-link>
</template>
</b-table-column>
<b-table-column
</o-table-column>
<o-table-column
field="confirmedAt"
:label="$t('Last seen on')"
:centered="true"
@@ -65,171 +65,129 @@
>
<template v-if="props.row.currentSignInAt">
<time :datetime="props.row.currentSignInAt">
{{ props.row.currentSignInAt | formatDateTimeString }}
{{ formatDateTimeString(props.row.currentSignInAt) }}
</time>
</template>
<template v-else-if="props.row.confirmedAt"> - </template>
<template v-else>
{{ $t("Not confirmed") }}
</template>
</b-table-column>
<b-table-column
</o-table-column>
<o-table-column
field="locale"
:label="$t('Language')"
:centered="true"
v-slot="props"
>
{{ getLanguageNameForCode(props.row.locale) }}
</b-table-column>
</o-table-column>
<template #empty>
<empty-content
v-if="!$apollo.loading && emailFilter"
v-if="!usersLoading && emailFilter"
:inline="true"
icon="account"
>
{{ $t("No user matches the filters") }}
<template #desc>
<b-button type="is-primary" @click="resetFilters">
<o-button variant="primary" @click="resetFilters">
{{ $t("Reset filters") }}
</b-button>
</o-button>
</template>
</empty-content>
</template>
</b-table>
</o-table>
</div>
</div>
</template>
<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
<script lang="ts" setup>
import { LIST_USERS } from "../../graphql/user";
import RouteName from "../../router/name";
import VueRouter from "vue-router";
import { LANGUAGES_CODES } from "@/graphql/admin";
import { IUser } from "@/types/current-user.model";
import { Paginate } from "@/types/paginate";
import EmptyContent from "../../components/Utils/EmptyContent.vue";
const { isNavigationFailure, NavigationFailureType } = VueRouter;
import { useQuery } from "@vue/apollo-composable";
import { ILanguage } from "@/types/admin.model";
import { computed, ref } from "vue";
import { useI18n } from "vue-i18n";
import { useHead } from "@vueuse/head";
import { integerTransformer, useRouteQuery } from "vue-use-route-query";
import { formatDateTimeString } from "@/filters/datetime";
const USERS_PER_PAGE = 10;
@Component({
apollo: {
users: {
query: LIST_USERS,
fetchPolicy: "cache-and-network",
variables() {
return {
email: this.emailFilter,
currentSignInIp: this.ipFilter,
page: this.page,
limit: USERS_PER_PAGE,
};
},
const emailFilter = useRouteQuery("emailFilter", "");
const ipFilter = useRouteQuery("ipFilter", "");
const page = useRouteQuery("page", 1, integerTransformer);
const languagesCodes = computed((): string[] => {
return (users.value?.elements ?? []).map((user: IUser) => user.locale);
});
const {
result: usersResult,
fetchMore,
loading: usersLoading,
} = useQuery<{ users: Paginate<IUser> }>(LIST_USERS, () => ({
email: emailFilter.value,
currentSignInIp: ipFilter.value,
page: page.value,
limit: USERS_PER_PAGE,
}));
const users = computed(() => usersResult.value?.users);
const { result: languagesResult } = useQuery<{ languages: ILanguage[] }>(
LANGUAGES_CODES,
() => ({
codes: languagesCodes.value,
}),
() => ({
enabled: languagesCodes.value !== undefined,
})
);
const languages = computed(() => languagesResult.value?.languages);
const { t } = useI18n({ useScope: "global" });
useHead({
title: computed(() => t("Users")),
});
const emailFilterFieldValue = ref(emailFilter.value);
const ipFilterFieldValue = ref(ipFilter.value);
const getLanguageNameForCode = (code: string): string => {
return (
(languages.value ?? []).find(({ code: languageCode }) => {
return languageCode === code;
})?.name || code
);
};
const onPageChange = async (newPage: number): Promise<void> => {
page.value = newPage;
await fetchMore({
variables: {
email: emailFilter.value,
currentSignInIp: ipFilter.value,
page: page.value,
limit: USERS_PER_PAGE,
},
languages: {
query: LANGUAGES_CODES,
variables() {
return {
codes: this.languagesCodes,
};
},
skip() {
return this.languagesCodes.length < 1;
},
},
},
metaInfo() {
return {
title: this.$t("Users") as string,
};
},
components: {
EmptyContent,
},
})
export default class Users extends Vue {
USERS_PER_PAGE = USERS_PER_PAGE;
});
};
RouteName = RouteName;
const activateFilters = (): void => {
emailFilter.value = emailFilterFieldValue.value;
ipFilter.value = ipFilterFieldValue.value;
};
users!: Paginate<IUser>;
languages!: Array<{ code: string; name: string }>;
emailFilterFieldValue = this.emailFilter;
ipFilterFieldValue = this.ipFilter;
get page(): number {
return parseInt((this.$route.query.page as string) || "1", 10);
}
set page(page: number) {
this.pushRouter({ page: page.toString() });
}
get emailFilter(): string {
return (this.$route.query.emailFilter as string) || "";
}
set emailFilter(emailFilter: string) {
this.pushRouter({ emailFilter });
}
get ipFilter(): string {
return (this.$route.query.ipFilter as string) || "";
}
set ipFilter(ipFilter: string) {
this.pushRouter({ ipFilter });
}
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> {
this.page = page;
await this.$apollo.queries.users.fetchMore({
variables: {
email: this.emailFilter,
currentSignInIp: this.ipFilter,
page: this.page,
limit: USERS_PER_PAGE,
},
});
}
activateFilters(): void {
this.emailFilter = this.emailFilterFieldValue;
this.ipFilter = this.ipFilterFieldValue;
}
resetFilters(): void {
this.emailFilterFieldValue = "";
this.ipFilterFieldValue = "";
this.activateFilters();
}
private async pushRouter(args: Record<string, string>): Promise<void> {
try {
await this.$router.push({
name: RouteName.USERS,
query: { ...this.$route.query, ...args },
});
} catch (e) {
if (isNavigationFailure(e, NavigationFailureType.redirected)) {
throw Error(e.toString());
}
}
}
}
const resetFilters = (): void => {
emailFilterFieldValue.value = "";
ipFilterFieldValue.value = "";
activateFilters();
};
</script>
<style lang="scss" scoped>

View File

@@ -0,0 +1,161 @@
<template>
<div class="container mx-auto py-4 md:py-12 px-2 md:px-60">
<main>
<div class="flex flex-wrap items-center justify-center gap-3 md:gap-4">
<CategoryCard
v-for="category in promotedCategories"
:key="category.key"
:category="category"
:with-details="true"
/>
</div>
<div
class="mx-auto w-full max-w-lg rounded-2xl dark:bg-gray-800 p-2 mt-10"
>
<div
class="card"
animation="slide"
:open="isLicencePanelOpen"
@open="isLicencePanelOpen = !isLicencePanelOpen"
:aria-id="'contentIdForA11y5'"
>
<div>
<button
class="flex w-full justify-between rounded-lg px-4 py-2 text-left text-sm font-medium dark:text-zinc-300"
>
{{ t("Category illustrations credits") }}
<svg
width="24"
height="24"
:class="isLicencePanelOpen ? 'transform rotate-90' : ''"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth="{2}"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 5l7 7-7 7"
/>
</svg>
</button>
</div>
<div class="flex flex-col dark:text-zinc-300 gap-2 py-4 px-1">
<p
v-for="(categoryLicence, key) in categoriesPicturesLicences"
:key="key"
class="flex flex-row gap-1 items-center"
>
<a
:href="categoryLicence.source.url"
target="_blank"
class="shrink-0"
>
<picture class="brightness-50">
<source
:srcset="`/img/categories/${key.toLowerCase()}.jpg 2x, /img/categories/${key.toLowerCase()}.jpg`"
media="(min-width: 1000px)"
/>
<source
:srcset="`/img/categories/${key.toLowerCase()}.jpg 2x, /img/categories/${key.toLowerCase()}-small.jpg`"
media="(min-width: 300px)"
/>
<img
class="w-full h-12 w-12 object-cover"
:src="`/img/categories/${key.toLowerCase()}.jpg`"
:srcset="`/img/categories/${key.toLowerCase()}-small.jpg `"
alt=""
/>
</picture>
</a>
<span
class="flex-0"
v-html="
t(
'Illustration picture for “{category}” by {author} on {source} ({license})',
{
category: eventCategoryLabel(key),
author: imageAuthor(categoryLicence.author),
source: imageSource(categoryLicence.source),
license: imageLicense(categoryLicence),
}
)
"
/>
</p>
</div>
</div>
</div>
</main>
</div>
</template>
<script lang="ts" setup>
import CategoryCard from "@/components/Categories/CategoryCard.vue";
import { computed, ref } from "vue";
import { CATEGORY_STATISTICS } from "@/graphql/statistics";
import { useQuery } from "@vue/apollo-composable";
import { CategoryStatsModel } from "@/types/stats.model";
import {
categoriesPicturesLicences,
CategoryPictureLicencing,
CategoryPictureLicencingElement,
} from "@/components/Categories/constants";
import { CONFIG } from "@/graphql/config";
import { IConfig } from "@/types/config.model";
import { useI18n } from "vue-i18n";
const { t } = useI18n({ useScope: "global" });
const { result: configResult } = useQuery<{ config: IConfig }>(CONFIG);
const config = computed(() => configResult.value?.config);
const eventCategories = computed(() => config.value?.eventCategories ?? []);
const eventCategoryLabel = (categoryId: string): string | undefined => {
return eventCategories.value.find(({ id }) => categoryId == id)?.label;
};
const { result: categoryStatsResult } = useQuery<{
categoryStatistics: CategoryStatsModel[];
}>(CATEGORY_STATISTICS);
const categoryStats = computed(
() => categoryStatsResult.value?.categoryStatistics ?? []
);
const promotedCategories = computed((): CategoryStatsModel[] => {
return categoryStats.value
.map(({ key, number }) => ({
key,
number,
label: eventCategoryLabel(key),
}))
.filter(
({ key, number, label }) =>
key !== "MEETING" && number >= 1 && label !== undefined
)
.sort((a, b) => a.label.localeCompare(b.label));
});
const imageAuthor = (author: CategoryPictureLicencingElement) =>
`<a target="_blank" class="underline font-medium" href="${author?.url}">${author?.name}</a>`;
const imageSource = (source: CategoryPictureLicencingElement) =>
`<a target="_blank" class="underline font-medium" href="${source?.url}">${source?.name}</a>`;
const imageLicense = (categoryLicence: CategoryPictureLicencing): string => {
let license = categoryLicence?.license;
if (categoryLicence?.source?.name === "Unsplash") {
license = {
name: "Unsplash License",
url: "https://unsplash.com/license",
};
}
return `<a target="_blank" class="underline font-medium" href="${license?.url}">${license?.name}</a>`;
};
const isLicencePanelOpen = ref(false);
</script>

View File

@@ -1,11 +1,11 @@
<template>
<section class="section container">
<section class="container mx-auto">
<breadcrumbs-nav
v-if="group"
:links="[
{
name: RouteName.MY_GROUPS,
text: $t('My groups'),
text: t('My groups'),
},
{
name: RouteName.GROUP,
@@ -15,137 +15,122 @@
{
name: RouteName.DISCUSSION_LIST,
params: { preferredUsername: usernameWithDomain(group) },
text: $t('Discussions'),
text: t('Discussions'),
},
{
name: RouteName.CREATE_DISCUSSION,
params: { preferredUsername: usernameWithDomain(group) },
text: $t('Create'),
text: t('Create'),
},
]"
/>
<h1 class="title">{{ $t("Create a discussion") }}</h1>
<h1 class="title">{{ t("Create a discussion") }}</h1>
<form @submit.prevent="createDiscussion">
<b-field
:label="$t('Title')"
<o-field
:label="t('Title')"
label-for="discussion-title"
:message="errors.title"
:type="errors.title ? 'is-danger' : undefined"
>
<b-input
<o-input
aria-required="true"
required
v-model="discussion.title"
id="discussion-title"
/>
</b-field>
</o-field>
<b-field :label="$t('Text')">
<editor v-model="discussion.text" :aria-label="$t('Comment body')" />
</b-field>
<o-field :label="t('Text')">
<Editor
v-model="discussion.text"
:aria-label="t('Comment body')"
v-if="currentActor"
:current-actor="currentActor"
/>
</o-field>
<button class="button is-primary" type="submit">
{{ $t("Create the discussion") }}
</button>
<o-button class="mt-2" native-type="submit">
{{ t("Create the discussion") }}
</o-button>
</form>
</section>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
import {
displayName,
IGroup,
IPerson,
usernameWithDomain,
} from "@/types/actor";
import { CURRENT_ACTOR_CLIENT } from "@/graphql/actor";
import { FETCH_GROUP } from "@/graphql/group";
<script lang="ts" setup>
import { displayName, usernameWithDomain } from "@/types/actor";
import { CREATE_DISCUSSION } from "@/graphql/discussion";
import RouteName from "../../router/name";
import { computed, defineAsyncComponent, reactive, inject } from "vue";
import { useCurrentActorClient } from "@/composition/apollo/actor";
import { useGroup } from "@/composition/apollo/group";
import { useI18n } from "vue-i18n";
import { useMutation } from "@vue/apollo-composable";
import { IDiscussion } from "@/types/discussions";
import { useRouter } from "vue-router";
import { useHead } from "@vueuse/head";
import { Notifier } from "@/plugins/notifier";
import { AbsintheGraphQLError } from "@/types/errors.model";
@Component({
components: {
editor: () =>
import(/* webpackChunkName: "editor" */ "@/components/Editor.vue"),
},
apollo: {
currentActor: CURRENT_ACTOR_CLIENT,
group: {
query: FETCH_GROUP,
variables() {
return {
name: this.preferredUsername,
};
},
skip() {
return !this.preferredUsername;
},
const Editor = defineAsyncComponent(() => import("@/components/Editor.vue"));
const props = defineProps<{ preferredUsername: string }>();
const { currentActor } = useCurrentActorClient();
const { group } = useGroup(props.preferredUsername);
const { t } = useI18n({ useScope: "global" });
useHead({
title: computed(() => t("Create a discussion")),
});
const discussion = reactive({ title: "", text: "" });
const errors = reactive({ title: "" });
const router = useRouter();
const notifier = inject<Notifier>("notifier");
const { mutate, onDone, onError } = useMutation<{
createDiscussion: IDiscussion;
}>(CREATE_DISCUSSION);
onDone(({ data }) => {
router.push({
name: RouteName.DISCUSSION,
params: {
id: data?.createDiscussion.id,
slug: data?.createDiscussion.slug,
},
},
metaInfo() {
return {
title: this.$t("Create a discussion") as string,
};
},
})
export default class CreateDiscussion extends Vue {
@Prop({ type: String, required: true }) preferredUsername!: string;
});
});
group!: IGroup;
currentActor!: IPerson;
discussion = { title: "", text: "" };
errors = { title: "" };
RouteName = RouteName;
usernameWithDomain = usernameWithDomain;
displayName = displayName;
async createDiscussion(): Promise<void> {
this.errors = { title: "" };
try {
if (!this.group.id || !this.currentActor.id) return;
const { data } = await this.$apollo.mutate({
mutation: CREATE_DISCUSSION,
variables: {
title: this.discussion.title,
text: this.discussion.text,
actorId: parseInt(this.group.id, 10),
},
});
await this.$router.push({
name: RouteName.DISCUSSION,
params: {
id: data.createDiscussion.id,
slug: data.createDiscussion.slug,
},
});
} catch (error: any) {
console.error(error);
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
if (error.graphQLErrors[0].field == "title") {
this.errors.title = error.graphQLErrors[0].message;
} else {
this.$notifier.error(error.graphQLErrors[0].message);
}
}
onError((error) => {
console.error(error);
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
const graphQLError = error.graphQLErrors[0] as AbsintheGraphQLError;
if (graphQLError.field == "title") {
errors.title = graphQLError.message;
} else {
notifier?.error(graphQLError.message);
}
}
}
});
const createDiscussion = async (): Promise<void> => {
errors.title = "";
if (!group.value?.id || !currentActor.value?.id) return;
mutate({
title: discussion.title,
text: discussion.text,
actorId: group.value.id,
});
};
</script>
<style lang="scss" scoped>
.container.section {
background: $white;
}
.markdown-render h1 {
font-size: 2em;
}

View File

@@ -1,5 +1,5 @@
<template>
<div class="container section" v-if="discussion">
<div class="container mx-auto" v-if="discussion">
<breadcrumbs-nav
v-if="group"
:links="[
@@ -24,57 +24,57 @@
},
]"
/>
<b-message v-if="error" type="is-danger">
<o-notification v-if="error" variant="danger">
{{ error }}
</b-message>
<section>
<div class="discussion-title" dir="auto">
<h1 class="title" v-if="discussion.title && !editTitleMode">
</o-notification>
<section v-if="currentActor">
<div class="flex items-center gap-2" dir="auto">
<h1 class="" v-if="discussion.title && !editTitleMode">
{{ discussion.title }}
</h1>
<b-button
<o-button
icon-right="pencil"
size="is-small"
size="small"
:title="$t('Update discussion title')"
v-if="
discussion.creator &&
!editTitleMode &&
(currentActor.id === discussion.creator.id ||
(currentActor?.id === discussion.creator.id ||
isCurrentActorAGroupModerator)
"
@click="
() => {
newTitle = discussion.title;
newTitle = discussion?.title ?? '';
editTitleMode = true;
}
"
>
</b-button>
<b-skeleton
v-else-if="!editTitleMode && $apollo.loading"
</o-button>
<o-skeleton
v-else-if="!editTitleMode && discussionLoading"
height="50px"
animated
/>
<form
v-else-if="!$apollo.loading && !error"
v-else-if="!discussionLoading && !error"
@submit.prevent="updateDiscussion"
class="title-edit"
class="w-full"
>
<b-field :label="$t('Title')" label-for="discussion-title">
<b-input
<o-field :label="$t('Title')" label-for="discussion-title">
<o-input
:value="discussion.title"
v-model="newTitle"
id="discussion-title"
/>
</b-field>
<div class="buttons">
<b-button
type="is-primary"
</o-field>
<div class="flex gap-2 mt-2">
<o-button
variant="primary"
native-type="submit"
icon-right="check"
:title="$t('Update discussion title')"
/>
<b-button
<o-button
@click="
() => {
editTitleMode = false;
@@ -84,12 +84,12 @@
icon-right="close"
:title="$t('Cancel discussion title edition')"
/>
<b-button
<o-button
@click="openDeleteDiscussionConfirmation"
type="is-danger"
variant="danger"
native-type="button"
icon-left="delete"
>{{ $t("Delete conversation") }}</b-button
>{{ $t("Delete conversation") }}</o-button
>
</div>
</form>
@@ -97,31 +97,45 @@
<discussion-comment
v-for="comment in discussion.comments.elements"
:key="comment.id"
:comment="comment"
@update-comment="updateComment"
@delete-comment="deleteComment"
:model-value="comment"
:current-actor="currentActor"
@update:modelValue="
(comment: IComment) =>
updateComment({
commentId: comment.id as string,
text: comment.text,
})
"
@delete-comment="(comment: IComment) => deleteComment({
commentId: comment.id as string,
})"
/>
<b-button
<o-button
v-if="discussion.comments.elements.length < discussion.comments.total"
@click="loadMoreComments"
>{{ $t("Fetch more") }}</b-button
>{{ $t("Fetch more") }}</o-button
>
<form @submit.prevent="reply" v-if="!error">
<b-field :label="$t('Text')">
<editor v-model="newComment" :aria-label="$t('Comment body')" />
</b-field>
<b-button
<o-field :label="$t('Text')">
<Editor
v-model="newComment"
:aria-label="$t('Comment body')"
v-if="currentActor"
:currentActor="currentActor"
/>
</o-field>
<o-button
class="my-2"
native-type="submit"
:disabled="['<p></p>', ''].includes(newComment)"
type="is-primary"
>{{ $t("Reply") }}</b-button
variant="primary"
>{{ $t("Reply") }}</o-button
>
</form>
</section>
</div>
</template>
<script lang="ts">
import { Component, Prop } from "vue-property-decorator";
<script lang="ts" setup>
import {
GET_DISCUSSION,
REPLY_TO_DISCUSSION,
@@ -130,311 +144,312 @@ import {
DISCUSSION_COMMENT_CHANGED,
} from "@/graphql/discussion";
import { IDiscussion } from "@/types/discussions";
import { Discussion as DiscussionModel } from "@/types/discussions";
import { displayName, usernameWithDomain } from "@/types/actor";
import { displayName, IPerson, usernameWithDomain } from "@/types/actor";
import DiscussionComment from "@/components/Discussion/DiscussionComment.vue";
import { GraphQLError } from "graphql";
import { DELETE_COMMENT, UPDATE_COMMENT } from "@/graphql/comment";
import RouteName from "../../router/name";
import { IComment } from "../../types/comment.model";
import { ApolloCache, FetchResult, gql, Reference } from "@apollo/client/core";
import { mixins } from "vue-class-component";
import GroupMixin from "@/mixins/group";
import { ApolloCache, FetchResult, gql } from "@apollo/client/core";
import { useMutation, useQuery } from "@vue/apollo-composable";
import {
defineAsyncComponent,
onMounted,
onUnmounted,
ref,
computed,
inject,
} from "vue";
import { useHead } from "@vueuse/head";
import { useRouter } from "vue-router";
import { useCurrentActorClient } from "@/composition/apollo/actor";
import { AbsintheGraphQLError } from "@/types/errors.model";
import { useGroup } from "@/composition/apollo/group";
import { MemberRole } from "@/types/enums";
import { PERSON_MEMBERSHIPS } from "@/graphql/actor";
import { Dialog } from "@/plugins/dialog";
import { useI18n } from "vue-i18n";
@Component({
apollo: {
discussion: {
const props = defineProps<{ slug: string }>();
const page = ref(1);
const COMMENTS_PER_PAGE = 10;
const { currentActor } = useCurrentActorClient();
const {
result: discussionResult,
onError: onDiscussionError,
subscribeToMore,
fetchMore,
loading: discussionLoading,
} = useQuery<{ discussion: IDiscussion }>(
GET_DISCUSSION,
() => ({
slug: props.slug,
page: page.value,
limit: COMMENTS_PER_PAGE,
}),
() => ({
enabled: props.slug !== undefined,
})
);
subscribeToMore({
document: DISCUSSION_COMMENT_CHANGED,
variables: {
slug: props.slug,
page: page.value,
limit: COMMENTS_PER_PAGE,
},
updateQuery(
previousResult: any,
{ subscriptionData }: { subscriptionData: any }
) {
const previousDiscussion = previousResult.discussion;
const lastComment =
subscriptionData.data.discussionCommentChanged.lastComment;
hasMoreComments.value = !previousDiscussion.comments.elements.some(
(comment: IComment) => comment.id === lastComment.id
);
if (hasMoreComments.value) {
return {
discussion: {
...previousDiscussion,
lastComment: lastComment,
comments: {
elements: [
...previousDiscussion.comments.elements.filter(
({ id }: { id: string }) => id !== lastComment.id
),
lastComment,
],
total: previousDiscussion.comments.total + 1,
},
},
};
}
return previousDiscussion;
},
});
const discussion = computed(() => discussionResult.value?.discussion);
const { group } = useGroup(usernameWithDomain(discussion.value?.actor));
const Editor = defineAsyncComponent(() => import("@/components/Editor.vue"));
useHead({
title: computed(() => discussion.value?.title ?? ""),
});
const newComment = ref("");
const newTitle = ref("");
const editTitleMode = ref(false);
const hasMoreComments = ref(true);
const error = ref<string | null>(null);
const { mutate: replyToDiscussionMutation } = useMutation(REPLY_TO_DISCUSSION);
const reply = () => {
if (newComment.value === "") return;
replyToDiscussionMutation({
discussionId: discussion.value?.id,
text: newComment.value,
});
newComment.value = "";
};
const { mutate: updateComment } = useMutation<
{ updateComment: IComment },
{ commentId: string; text: string }
>(UPDATE_COMMENT, () => ({
update: (
store: ApolloCache<{ deleteComment: IComment }>,
{ data }: FetchResult
) => {
if (!data || !data.deleteComment) return;
const discussionData = store.readQuery<{
discussion: IDiscussion;
}>({
query: GET_DISCUSSION,
variables() {
return {
slug: this.slug,
page: 1,
limit: this.COMMENTS_PER_PAGE,
};
variables: {
slug: props.slug,
page: page.value,
},
skip() {
return !this.slug;
},
error({ graphQLErrors }) {
this.handleErrors(graphQLErrors);
},
subscribeToMore: {
document: DISCUSSION_COMMENT_CHANGED,
variables() {
return {
slug: this.$route.params.slug,
page: this.page,
limit: this.COMMENTS_PER_PAGE,
};
},
updateQuery: function (
previousResult: any,
{ subscriptionData }: { subscriptionData: any }
) {
const previousDiscussion = previousResult.discussion;
const lastComment =
subscriptionData.data.discussionCommentChanged.lastComment;
this.hasMoreComments = !previousDiscussion.comments.elements.some(
(comment: IComment) => comment.id === lastComment.id
);
if (this.hasMoreComments) {
return {
discussion: {
...previousDiscussion,
lastComment: lastComment,
comments: {
elements: [
...previousDiscussion.comments.elements.filter(
({ id }: { id: string }) => id !== lastComment.id
),
lastComment,
],
total: previousDiscussion.comments.total + 1,
},
},
};
});
if (!discussionData) return;
const { discussion: discussionCached } = discussionData;
const index = discussionCached.comments.elements.findIndex(
({ id }) => id === data.deleteComment.id
);
if (index > -1) {
discussionCached.comments.elements.splice(index, 1);
discussionCached.comments.total -= 1;
}
store.writeQuery({
query: GET_DISCUSSION,
variables: { slug: props.slug, page: page.value },
data: { discussion: discussionCached },
});
},
}));
const { mutate: deleteComment } = useMutation<
{ deleteComment: { id: string } },
{ commentId: string }
>(DELETE_COMMENT, () => ({
update: (store: ApolloCache<{ deleteComment: IComment }>, { data }) => {
const id = data?.deleteComment?.id;
if (!id) return;
store.writeFragment({
id: `Comment:${id}`,
fragment: gql`
fragment CommentDeleted on Comment {
deletedAt
actor {
id
}
return previousDiscussion;
},
},
},
},
components: {
DiscussionComment,
editor: () =>
import(/* webpackChunkName: "editor" */ "@/components/Editor.vue"),
},
metaInfo() {
return {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
title: this.discussion.title,
};
},
})
export default class Discussion extends mixins(GroupMixin) {
@Prop({ type: String, required: true }) slug!: string;
discussion: IDiscussion = new DiscussionModel();
newComment = "";
newTitle = "";
editTitleMode = false;
page = 1;
hasMoreComments = true;
COMMENTS_PER_PAGE = 10;
RouteName = RouteName;
usernameWithDomain = usernameWithDomain;
displayName = displayName;
error: string | null = null;
async reply(): Promise<void> {
if (this.newComment === "") return;
await this.$apollo.mutate({
mutation: REPLY_TO_DISCUSSION,
variables: {
discussionId: this.discussion.id,
text: this.newComment,
},
});
this.newComment = "";
}
async updateComment(comment: IComment): Promise<void> {
await this.$apollo.mutate<{ deleteComment: IComment }>({
mutation: UPDATE_COMMENT,
variables: {
commentId: comment.id,
text: comment.text,
},
update: (
store: ApolloCache<{ deleteComment: IComment }>,
{ data }: FetchResult
) => {
if (!data || !data.deleteComment) return;
const discussionData = store.readQuery<{
discussion: IDiscussion;
}>({
query: GET_DISCUSSION,
variables: {
slug: this.slug,
page: this.page,
},
});
if (!discussionData) return;
const { discussion: discussionCached } = discussionData;
const index = discussionCached.comments.elements.findIndex(
({ id }) => id === data.deleteComment.id
);
if (index > -1) {
discussionCached.comments.elements.splice(index, 1);
discussionCached.comments.total -= 1;
text
}
store.writeQuery({
query: GET_DISCUSSION,
variables: { slug: this.slug, page: this.page },
data: { discussion: discussionCached },
});
`,
data: {
deletedAt: new Date(),
text: "",
actor: null,
},
});
}
},
}));
async deleteComment(comment: IComment): Promise<void> {
await this.$apollo.mutate<{ deleteComment: IComment }>({
mutation: DELETE_COMMENT,
const loadMoreComments = async (): Promise<void> => {
if (!hasMoreComments.value) return;
page.value++;
try {
await fetchMore({
// New variables
variables: {
commentId: comment.id,
slug: props.slug,
page: page.value,
limit: COMMENTS_PER_PAGE,
},
update: (store: ApolloCache<{ deleteComment: IComment }>) => {
store.writeFragment({
id: store.identify(comment as unknown as Reference),
fragment: gql`
fragment CommentDeleted on Comment {
deletedAt
actor {
id
}
text
}
`,
data: {
deletedAt: new Date(),
text: "",
actor: null,
},
});
});
hasMoreComments.value = !discussion.value?.comments.elements
.map(({ id }) => id)
.includes(discussion.value?.lastComment?.id);
} catch (e) {
console.error(e);
}
};
const { mutate: updateDiscussionMutation } = useMutation<{
updateDiscussion: IDiscussion;
}>(UPDATE_DISCUSSION);
const updateDiscussion = async (): Promise<void> => {
updateDiscussionMutation({
discussionId: discussion.value?.id,
title: newTitle.value,
});
editTitleMode.value = false;
};
const { t } = useI18n({ useScope: "global" });
const dialog = inject<Dialog>("dialog");
const openDeleteDiscussionConfirmation = (): void => {
dialog?.confirm({
type: "is-danger",
title: t("Delete this discussion"),
message: t("Are you sure you want to delete this entire discussion?"),
confirmText: t("Delete discussion"),
cancelText: t("Cancel"),
onConfirm: () =>
deleteConversation({
discussionId: discussion.value?.id,
}),
});
};
const router = useRouter();
const { mutate: deleteConversation, onDone: deleteConversationDone } =
useMutation(DELETE_DISCUSSION);
deleteConversationDone(() => {
if (discussion.value?.actor) {
router.push({
name: RouteName.DISCUSSION_LIST,
params: {
preferredUsername: usernameWithDomain(discussion.value.actor),
},
});
}
});
async loadMoreComments(): Promise<void> {
if (!this.hasMoreComments) return;
this.page++;
try {
await this.$apollo.queries.discussion.fetchMore({
// New variables
variables: {
slug: this.slug,
page: this.page,
limit: this.COMMENTS_PER_PAGE,
},
});
this.hasMoreComments = !this.discussion.comments.elements
.map(({ id }) => id)
.includes(this.discussion?.lastComment?.id);
} catch (e) {
console.error(e);
}
}
onDiscussionError((discussionError) =>
handleErrors(discussionError.graphQLErrors as AbsintheGraphQLError[])
);
async updateDiscussion(): Promise<void> {
await this.$apollo.mutate<{ updateDiscussion: IDiscussion }>({
mutation: UPDATE_DISCUSSION,
variables: {
discussionId: this.discussion.id,
title: this.newTitle,
},
});
this.editTitleMode = false;
const handleErrors = async (errors: AbsintheGraphQLError[]): Promise<void> => {
if (errors[0].message.includes("No such discussion")) {
await router.push({ name: RouteName.PAGE_NOT_FOUND });
}
if (errors[0].code === "unauthorized") {
error.value = errors[0].message;
}
};
openDeleteDiscussionConfirmation(): void {
this.$buefy.dialog.confirm({
type: "is-danger",
title: this.$t("Delete this discussion") as string,
message: this.$t(
"Are you sure you want to delete this entire discussion?"
) as string,
confirmText: this.$t("Delete discussion") as string,
cancelText: this.$t("Cancel") as string,
onConfirm: () => this.deleteConversation(),
});
}
onMounted(() => {
window.addEventListener("scroll", handleScroll);
});
async deleteConversation(): Promise<void> {
await this.$apollo.mutate({
mutation: DELETE_DISCUSSION,
variables: {
discussionId: this.discussion.id,
},
});
if (this.discussion.actor) {
this.$router.push({
name: RouteName.DISCUSSION_LIST,
params: {
preferredUsername: usernameWithDomain(this.discussion.actor),
},
});
}
}
onUnmounted(() => {
window.removeEventListener("scroll", handleScroll);
});
async handleErrors(errors: GraphQLError[]): Promise<void> {
if (errors[0].message.includes("No such discussion")) {
await this.$router.push({ name: RouteName.PAGE_NOT_FOUND });
}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
if (errors[0].code === "unauthorized") {
this.error = errors[0].message;
}
const handleScroll = (): void => {
const scrollTop =
(document.documentElement && document.documentElement.scrollTop) ||
document.body.scrollTop;
const scrollHeight =
(document.documentElement && document.documentElement.scrollHeight) ||
document.body.scrollHeight;
const clientHeight =
document.documentElement.clientHeight || window.innerHeight;
const scrolledToBottom =
Math.ceil(scrollTop + clientHeight + 800) >= scrollHeight;
if (scrolledToBottom) {
loadMoreComments();
}
};
mounted(): void {
window.addEventListener("scroll", this.handleScroll);
}
const isCurrentActorAGroupModerator = computed((): boolean => {
return hasCurrentActorThisRole([
MemberRole.MODERATOR,
MemberRole.ADMINISTRATOR,
]);
});
destroyed(): void {
window.removeEventListener("scroll", this.handleScroll);
}
const { result: membershipsResult } = useQuery<{
person: Pick<IPerson, "memberships">;
}>(
PERSON_MEMBERSHIPS,
() => ({ id: currentActor.value?.id }),
() => ({ enabled: currentActor.value?.id !== undefined })
);
const memberships = computed(() => membershipsResult.value?.person.memberships);
handleScroll(): void {
const scrollTop =
(document.documentElement && document.documentElement.scrollTop) ||
document.body.scrollTop;
const scrollHeight =
(document.documentElement && document.documentElement.scrollHeight) ||
document.body.scrollHeight;
const clientHeight =
document.documentElement.clientHeight || window.innerHeight;
const scrolledToBottom =
Math.ceil(scrollTop + clientHeight + 800) >= scrollHeight;
if (scrolledToBottom) {
this.loadMoreComments();
}
}
}
const hasCurrentActorThisRole = (givenRole: string | string[]): boolean => {
const roles = Array.isArray(givenRole)
? givenRole
: ([givenRole] as MemberRole[]);
return (
(memberships.value?.total ?? 0) > 0 &&
roles.includes(memberships.value?.elements[0].role as MemberRole)
);
};
</script>
<style lang="scss" scoped>
@use "@/styles/_mixins" as *;
div.container.section {
background: white;
padding: 1rem 5% 4rem;
div.discussion-title {
margin-bottom: 1.75rem;
display: flex;
align-items: center;
h1.title {
margin-bottom: 0;
@include margin-right(10px);
}
form.title-edit {
flex: 1;
div.control {
margin-bottom: 0.75rem;
}
}
}
}
</style>

View File

@@ -1,5 +1,5 @@
<template>
<div class="container section" v-if="group">
<div class="container mx-auto section" v-if="group">
<breadcrumbs-nav
:links="[
{
@@ -26,13 +26,13 @@
)
}}
</p>
<b-button
<o-button
tag="router-link"
:to="{
name: RouteName.CREATE_DISCUSSION,
params: { preferredUsername },
}"
>{{ $t("New discussion") }}</b-button
>{{ $t("New discussion") }}</o-button
>
<div v-if="group.discussions.elements.length > 0">
<discussion-list-item
@@ -40,7 +40,7 @@
v-for="discussion in group.discussions.elements"
:key="discussion.id"
/>
<b-pagination
<o-pagination
class="discussion-pagination"
:total="group.discussions.total"
v-model="page"
@@ -50,13 +50,13 @@
:aria-page-label="$t('Page')"
:aria-current-label="$t('Current page')"
>
</b-pagination>
</o-pagination>
</div>
<empty-content v-else icon="chat">
{{ $t("There's no discussions yet") }}
</empty-content>
</section>
<section class="section" v-else-if="!$apollo.loading">
<section class="section" v-else-if="!groupLoading && !personLoading">
<empty-content icon="chat">
{{ $t("Only group members can access discussions") }}
<template #desc>
@@ -70,157 +70,59 @@
</section>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
import { FETCH_GROUP } from "@/graphql/group";
import {
displayName,
IActor,
IGroup,
IPerson,
usernameWithDomain,
} from "@/types/actor";
<script lang="ts" setup>
import { displayName, usernameWithDomain } from "@/types/actor";
import DiscussionListItem from "@/components/Discussion/DiscussionListItem.vue";
import RouteName from "../../router/name";
import { MemberRole } from "@/types/enums";
import {
CURRENT_ACTOR_CLIENT,
GROUP_MEMBERSHIP_SUBSCRIPTION_CHANGED,
PERSON_STATUS_GROUP,
} from "@/graphql/actor";
import { IMember } from "@/types/actor/member.model";
import EmptyContent from "@/components/Utils/EmptyContent.vue";
import VueRouter from "vue-router";
const { isNavigationFailure, NavigationFailureType } = VueRouter;
import { useGroup } from "@/composition/apollo/group";
import { usePersonStatusGroup } from "@/composition/apollo/actor";
import { useI18n } from "vue-i18n";
import { useRouteQuery, integerTransformer } from "vue-use-route-query";
import { useHead } from "@vueuse/head";
import { computed } from "vue";
const page = useRouteQuery("page", 1, integerTransformer);
const DISCUSSIONS_PER_PAGE = 10;
@Component({
components: { DiscussionListItem, EmptyContent },
apollo: {
group: {
query: FETCH_GROUP,
fetchPolicy: "cache-and-network",
variables() {
return {
name: this.preferredUsername,
discussionsPage: this.page,
discussionsLimit: DISCUSSIONS_PER_PAGE,
};
},
skip() {
return !this.preferredUsername;
},
},
person: {
query: PERSON_STATUS_GROUP,
fetchPolicy: "cache-and-network",
variables() {
return {
id: this.currentActor.id,
group: this.preferredUsername,
};
},
subscribeToMore: {
document: GROUP_MEMBERSHIP_SUBSCRIPTION_CHANGED,
variables() {
return {
actorId: this.currentActor.id,
group: this.preferredUsername,
};
},
skip() {
return (
!this.currentActor ||
!this.currentActor.id ||
!this.preferredUsername
);
},
},
skip() {
return (
!this.currentActor || !this.currentActor.id || !this.preferredUsername
);
},
},
currentActor: CURRENT_ACTOR_CLIENT,
},
metaInfo() {
return {
title: this.$t("Discussions") as string,
};
},
})
export default class DiscussionsList extends Vue {
@Prop({ type: String, required: true }) preferredUsername!: string;
const props = defineProps<{ preferredUsername: string }>();
person!: IPerson;
const { group, loading: groupLoading } = useGroup(props.preferredUsername, {
discussionsPage: page.value,
discussionsLimit: DISCUSSIONS_PER_PAGE,
});
group!: IGroup;
const { person, loading: personLoading } = usePersonStatusGroup(
props.preferredUsername
);
currentActor!: IActor;
const { t } = useI18n({ useScope: "global" });
RouteName = RouteName;
useHead({
title: computed(() => t("Discussions")),
});
usernameWithDomain = usernameWithDomain;
displayName = displayName;
const groupMemberships = computed((): (string | undefined)[] => {
if (!person.value || !person.value.id) return [];
return person.value.memberships.elements
.filter(
(membership: IMember) =>
![
MemberRole.REJECTED,
MemberRole.NOT_APPROVED,
MemberRole.INVITED,
].includes(membership.role)
)
.map(({ parent: { id } }) => id);
});
DISCUSSIONS_PER_PAGE = DISCUSSIONS_PER_PAGE;
get page(): number {
return parseInt((this.$route.query.page as string) || "1", 10);
}
set page(page: number) {
this.pushRouter(RouteName.DISCUSSION_LIST, {
page: page.toString(),
});
}
get groupMemberships(): (string | undefined)[] {
if (!this.person || !this.person.id) return [];
return this.person.memberships.elements
.filter(
(membership: IMember) =>
![
MemberRole.REJECTED,
MemberRole.NOT_APPROVED,
MemberRole.INVITED,
].includes(membership.role)
)
.map(({ parent: { id } }) => id);
}
get isCurrentActorAGroupMember(): boolean {
return (
this.groupMemberships !== undefined &&
this.groupMemberships.includes(this.group.id)
);
}
protected async pushRouter(
routeName: string,
args: Record<string, string>
): Promise<void> {
try {
await this.$router.push({
name: routeName,
query: { ...this.$route.query, ...args },
});
} catch (e) {
if (isNavigationFailure(e, NavigationFailureType.redirected)) {
throw Error(e.toString());
}
}
}
}
const isCurrentActorAGroupMember = computed((): boolean => {
return (
groupMemberships.value !== undefined &&
groupMemberships.value.includes(group.value?.id)
);
});
</script>
<style lang="scss">
div.container.section {
background: white;
.discussion-pagination {
margin-top: 1rem;
}
}
</style>

View File

@@ -1,33 +0,0 @@
<template>
<section class="container">
<span v-if="code === ErrorCode.REGISTRATION_CLOSED">
{{ $t("Registration is currently closed.") }}
</span>
<span v-else>
{{ $t("Unknown error.") }}
</span>
</section>
</template>
<script lang="ts">
import { ErrorCode } from "@/types/enums";
import { Component, Vue } from "vue-property-decorator";
@Component({
metaInfo() {
return {
title: this.$t("Error") as string,
};
},
})
export default class ErrorPage extends Vue {
code: ErrorCode | null = null;
ErrorCode = ErrorCode;
mounted(): void {
this.code = this.$route.query.code as ErrorCode;
}
}
</script>

View File

@@ -0,0 +1,27 @@
<template>
<section class="container mx-auto">
<span v-if="code === ErrorCode.REGISTRATION_CLOSED">
{{ t("Registration is currently closed.") }}
</span>
<span v-else>
{{ t("Unknown error.") }}
</span>
</section>
</template>
<script lang="ts" setup>
import { ErrorCode } from "@/types/enums";
import { useHead } from "@vueuse/head";
import { useI18n } from "vue-i18n";
import { useRouteQuery } from "vue-use-route-query";
import { computed } from "vue";
const { t } = useI18n({ useScope: "global" });
useHead({
title: computed(() => t("Error")),
});
const code = useRouteQuery("code", null);
</script>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,55 +0,0 @@
<template>
<section class="container">
<h1>{{ $t("Event list") }}</h1>
<b-loading :active.sync="$apollo.loading"></b-loading>
<div v-if="events.length > 0" class="columns is-multiline">
<EventCard
v-for="event in events"
:key="event.uuid"
:event="event"
class="column is-one-quarter-desktop is-half-mobile"
/>
</div>
<b-message
v-if-else="events.length === 0 && $apollo.loading === false"
type="is-danger"
>{{ $t("No events found") }}</b-message
>
</section>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
import EventCard from "../../components/Event/EventCard.vue";
import RouteName from "../../router/name";
import { IEvent } from "../../types/event.model";
@Component({
components: {
EventCard,
},
metaInfo() {
return {
title: this.$t("Event list") as string,
};
},
})
export default class EventList extends Vue {
@Prop(String) location!: string;
events = [];
loading = true;
locationChip = false;
locationText = "";
viewEvent(event: IEvent): void {
this.$router.push({ name: RouteName.EVENT, params: { uuid: event.uuid } });
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped></style>

View File

@@ -1,5 +1,5 @@
<template>
<div class="container section" v-if="group">
<div class="container mx-auto" v-if="group">
<breadcrumbs-nav
:links="[
{
@@ -8,14 +8,14 @@
text: displayName(group),
},
{
name: RouteName.EVENTS,
name: RouteName.GROUP_EVENTS,
params: { preferredUsername: usernameWithDomain(group) },
text: $t('Events'),
},
]"
/>
<section>
<h1 class="title" v-if="group">
<h1 class="" v-if="group">
{{
$t("{group}'s events", {
group: displayName(group),
@@ -29,23 +29,24 @@
)
}}
</p>
<router-link
<o-button
tag="router-link"
variant="primary"
v-if="isCurrentActorAGroupModerator"
:to="{
name: RouteName.CREATE_EVENT,
query: { actorId: group.id },
}"
class="button is-primary"
>{{ $t("+ Create an event") }}</router-link
>{{ $t("+ Create an event") }}</o-button
>
<b-loading :active.sync="$apollo.loading"></b-loading>
<o-loading v-model:active="groupLoading"></o-loading>
<section v-if="group">
<subtitle>
<h2 class="text-2xl">
{{ showPassedEvents ? $t("Past events") : $t("Upcoming events") }}
</subtitle>
<b-switch class="mb-4" v-model="showPassedEvents">{{
</h2>
<o-switch class="mb-4" v-model="showPassedEvents">{{
$t("Past events")
}}</b-switch>
}}</o-switch>
<grouped-multi-event-minimalist-card
:events="group.organizedEvents.elements"
:isCurrentActorMember="isCurrentActorMember"
@@ -53,7 +54,7 @@
<empty-content
v-if="
group.organizedEvents.elements.length === 0 &&
$apollo.loading === false
groupLoading === false
"
icon="calendar"
:inline="true"
@@ -69,13 +70,13 @@
)
}}
</p>
<b-button type="is-text" tag="a" :href="group.url">
<o-button type="is-text" tag="a" :href="group.url">
{{ $t("View the group profile on the original instance") }}
</b-button>
</o-button>
</div>
</template>
</empty-content>
<b-pagination
<o-pagination
class="mt-4"
:total="group.organizedEvents.total"
v-model="page"
@@ -85,116 +86,93 @@
:aria-page-label="$t('Page')"
:aria-current-label="$t('Current page')"
>
</b-pagination>
</o-pagination>
</section>
</section>
</div>
</template>
<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
<script lang="ts" setup>
import RouteName from "@/router/name";
import Subtitle from "@/components/Utils/Subtitle.vue";
import GroupedMultiEventMinimalistCard from "@/components/Event/GroupedMultiEventMinimalistCard.vue";
import { PERSON_MEMBERSHIPS } from "@/graphql/actor";
import { IMember } from "@/types/actor/member.model";
import { FETCH_GROUP_EVENTS } from "@/graphql/event";
import EmptyContent from "../../components/Utils/EmptyContent.vue";
import { displayName, IGroup, usernameWithDomain } from "../../types/actor";
import { displayName, IPerson, usernameWithDomain } from "../../types/actor";
import { useQuery } from "@vue/apollo-composable";
import { useCurrentActorClient } from "@/composition/apollo/actor";
import { computed } from "vue";
import { useRoute } from "vue-router";
import {
booleanTransformer,
integerTransformer,
useRouteQuery,
} from "vue-use-route-query";
import { MemberRole } from "@/types/enums";
import { useHead } from "@vueuse/head";
import { useI18n } from "vue-i18n";
const EVENTS_PAGE_LIMIT = 10;
@Component({
apollo: {
memberships: {
query: PERSON_MEMBERSHIPS,
fetchPolicy: "cache-and-network",
variables() {
return {
id: this.currentActor.id,
};
},
update: (data) => data.person.memberships.elements,
skip() {
return !this.currentActor || !this.currentActor.id;
},
},
group: {
query: FETCH_GROUP_EVENTS,
variables() {
return {
name: this.$route.params.preferredUsername,
beforeDateTime: this.showPassedEvents ? new Date() : null,
afterDateTime: this.showPassedEvents ? null : new Date(),
organisedEventsPage: this.page,
organisedEventsLimit: EVENTS_PAGE_LIMIT,
};
},
update: (data) => data.group,
},
},
components: {
EmptyContent,
Subtitle,
GroupedMultiEventMinimalistCard,
},
metaInfo() {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const { group } = this;
return {
title: this.$t("{group} events", {
group: displayName(group),
}) as string,
};
},
})
export default class GroupEvents extends Vue {
group!: IGroup;
const { currentActor } = useCurrentActorClient();
memberships!: IMember[];
const { result: membershipsResult } = useQuery<{
person: Pick<IPerson, "memberships">;
}>(
PERSON_MEMBERSHIPS,
() => ({ id: currentActor.value?.id }),
() => ({ enabled: currentActor.value?.id !== undefined })
);
const memberships = computed(
() => membershipsResult.value?.person.memberships.elements
);
get page(): number {
return parseInt((this.$route.query.page as string) || "1", 10);
}
const route = useRoute();
const page = useRouteQuery("page", 1, integerTransformer);
const showPassedEvents = useRouteQuery(
"showPassedEvents",
false,
booleanTransformer
);
set page(page: number) {
this.$router.push({
name: RouteName.GROUP_EVENTS,
query: { ...this.$route.query, page: page.toString() },
});
this.$apollo.queries.group.refetch();
}
const { result: groupResult, loading: groupLoading } = useQuery(
FETCH_GROUP_EVENTS,
() => ({
name: route.params.preferredUsername,
beforeDateTime: showPassedEvents.value ? new Date() : null,
afterDateTime: showPassedEvents.value ? null : new Date(),
organisedEventsPage: page.value,
organisedEventsLimit: EVENTS_PAGE_LIMIT,
})
);
const group = computed(() => groupResult.value?.group);
usernameWithDomain = usernameWithDomain;
const { t } = useI18n({ useScope: "global" });
useHead({
title: t("{group} events", {
group: displayName(group.value),
}),
});
displayName = displayName;
const isCurrentActorMember = computed((): boolean => {
if (!group.value || !memberships.value) return false;
return (memberships.value ?? [])
.map(({ parent: { id } }) => id)
.includes(group.value.id);
});
RouteName = RouteName;
const isCurrentActorAGroupModerator = computed((): boolean => {
return hasCurrentActorThisRole([
MemberRole.MODERATOR,
MemberRole.ADMINISTRATOR,
]);
});
EVENTS_PAGE_LIMIT = EVENTS_PAGE_LIMIT;
get isCurrentActorMember(): boolean {
if (!this.group || !this.memberships) return false;
return this.memberships
.map(({ parent: { id } }) => id)
.includes(this.group.id);
}
get showPassedEvents(): boolean {
return this.$route.query.future === "false";
}
set showPassedEvents(value: boolean) {
this.$router.replace({ query: { future: (!value).toString() } });
}
}
const hasCurrentActorThisRole = (givenRole: string | string[]): boolean => {
const roles = Array.isArray(givenRole) ? givenRole : [givenRole];
return (
memberships.value !== undefined &&
memberships.value?.length > 0 &&
roles.includes(memberships.value[0].role)
);
};
</script>
<style lang="scss" scoped>
.container.section {
background: $white;
}
div.event-list {
margin-bottom: 1rem;
}
</style>

View File

@@ -1,78 +1,75 @@
<template>
<div class="section container">
<h1 class="title">
{{ $t("My events") }}
<div class="container mx-auto">
<h1 class="text-4xl">
{{ t("My events") }}
</h1>
<p>
{{
$t(
t(
"You will find here all the events you have created or of which you are a participant, as well as events organized by groups you follow or are a member of."
)
}}
</p>
<div class="buttons" v-if="!hideCreateEventButton">
<router-link
class="button is-primary"
<div class="my-2" v-if="!hideCreateEventButton">
<o-button
tag="router-link"
variant="primary"
:to="{ name: RouteName.CREATE_EVENT }"
>{{ $t("Create event") }}</router-link
>{{ t("Create event") }}</o-button
>
</div>
<b-loading :active.sync="$apollo.loading"></b-loading>
<div class="wrapper">
<div class="event-filter">
<b-field grouped group-multiline>
<b-field>
<b-switch v-model="showUpcoming">{{
showUpcoming ? $t("Upcoming events") : $t("Past events")
}}</b-switch>
</b-field>
<b-field v-if="showUpcoming">
<b-checkbox v-model="showDrafts">{{ $t("Drafts") }}</b-checkbox>
</b-field>
<b-field v-if="showUpcoming">
<b-checkbox v-model="showAttending">{{
$t("Attending")
}}</b-checkbox>
</b-field>
<b-field v-if="showUpcoming">
<b-checkbox v-model="showMyGroups">{{
$t("From my groups")
}}</b-checkbox>
</b-field>
<p v-if="!showUpcoming">
{{
$tc(
"You have attended {count} events in the past.",
pastParticipations.total,
{
count: pastParticipations.total,
}
)
}}
</p>
<b-field
class="date-filter"
expanded
:label="
showUpcoming
? $t('Showing events starting on')
: $t('Showing events before')
"
>
<b-datepicker
v-model="dateFilter"
:first-day-of-week="firstDayOfWeek"
/>
<b-button
@click="dateFilter = new Date()"
class="reset-area"
icon-left="close"
:title="$t('Clear date filter field')"
/>
</b-field>
</b-field>
<!-- <o-loading v-model:active="$apollo.loading"></o-loading> -->
<div class="wrapper flex flex-wrap gap-4 items-start">
<div class="event-filter text-violet-1 flex-auto md:flex-none">
<o-field>
<o-switch v-model="showUpcoming">{{
showUpcoming ? t("Upcoming events") : t("Past events")
}}</o-switch>
</o-field>
<o-field v-if="showUpcoming">
<o-checkbox v-model="showDrafts">{{ t("Drafts") }}</o-checkbox>
</o-field>
<o-field v-if="showUpcoming">
<o-checkbox v-model="showAttending">{{ t("Attending") }}</o-checkbox>
</o-field>
<o-field v-if="showUpcoming">
<o-checkbox v-model="showMyGroups">{{
t("From my groups")
}}</o-checkbox>
</o-field>
<p v-if="!showUpcoming">
{{
t(
"You have attended {count} events in the past.",
{
count: pastParticipations.total,
},
pastParticipations.total
)
}}
</p>
<o-field
class="date-filter"
expanded
:label="
showUpcoming
? t('Showing events starting on')
: t('Showing events before')
"
>
<o-datepicker
v-model="dateFilter"
:first-day-of-week="firstDayOfWeek"
/>
<o-button
@click="dateFilter = new Date()"
class="reset-area"
icon-left="close"
:title="t('Clear date filter field')"
/>
</o-field>
</div>
<div class="my-events">
<div class="my-events flex-1">
<section
class="py-4"
v-if="showUpcoming && showDrafts && drafts.length > 0"
@@ -82,13 +79,15 @@
<section
class="py-4"
v-if="
showUpcoming && monthlyFutureEvents && monthlyFutureEvents.size > 0
showUpcoming &&
monthlyFutureEvents &&
monthlyFutureEvents.length > 0
"
>
<transition-group name="list" tag="p">
<div
class="mb-5"
v-for="month in monthlyFutureEvents"
v-for="month of monthlyFutureEvents()"
:key="month[0]"
>
<span class="upcoming-month">{{ month[0] }}</span>
@@ -102,7 +101,8 @@
/>
<event-minimalist-card
v-else-if="
!monthParticipationsIds(month[1]).includes(element.id)
element.id &&
!monthParticipationsIds(month[1]).includes(element?.id)
"
:event="element"
class="participation"
@@ -111,7 +111,7 @@
</div>
</transition-group>
<div class="columns is-centered">
<b-button
<o-button
class="column is-narrow"
v-if="
hasMoreFutureParticipations &&
@@ -119,9 +119,9 @@
futureParticipations.length === limit
"
@click="loadMoreFutureParticipations"
size="is-large"
type="is-primary"
>{{ $t("Load more") }}</b-button
size="large"
variant="primary"
>{{ t("Load more") }}</o-button
>
</div>
</section>
@@ -130,34 +130,34 @@
v-if="
showUpcoming &&
monthlyFutureEvents &&
monthlyFutureEvents.size === 0 &&
!$apollo.loading
monthlyFutureEvents.length === 0 &&
true // !$apollo.loading
"
>
<div class="img-container" :class="{ webp: supportsWebPFormat }" />
<div class="img-container h-64" />
<div class="content has-text-centered">
<p>
{{
$t(
t(
"You don't have any upcoming events. Maybe try another filter?"
)
}}
</p>
<i18n
path="Do you wish to {create_event} or {explore_events}?"
<i18n-t
keypath="Do you wish to {create_event} or {explore_events}?"
tag="p"
>
<router-link
:to="{ name: RouteName.CREATE_EVENT }"
slot="create_event"
>{{ $t("create an event") }}</router-link
>
<router-link
:to="{ name: RouteName.SEARCH }"
slot="explore_events"
>{{ $t("explore the events") }}</router-link
>
</i18n>
<template v-slot:create_event>
<router-link :to="{ name: RouteName.CREATE_EVENT }">{{
t("create an event")
}}</router-link>
</template>
<template v-slot:explore_events>
<router-link :to="{ name: RouteName.SEARCH }">{{
t("explore the events")
}}</router-link>
</template>
</i18n-t>
</div>
</section>
<section v-if="!showUpcoming && pastParticipations.elements.length > 0">
@@ -167,7 +167,7 @@
<event-participation-card
v-for="participation in month[1]"
:key="participation.id"
:participation="participation"
:participation="(participation as IParticipant)"
:options="{ hideDate: false }"
@event-deleted="eventDeleted"
class="participation"
@@ -175,16 +175,16 @@
</div>
</transition-group>
<div class="columns is-centered">
<b-button
<o-button
class="column is-narrow"
v-if="
hasMorePastParticipations &&
pastParticipations.elements.length === limit
"
@click="loadMorePastParticipations"
size="is-large"
type="is-primary"
>{{ $t("Load more") }}</b-button
size="large"
variant="primary"
>{{ t("Load more") }}</o-button
>
</div>
</section>
@@ -193,302 +193,230 @@
</div>
</template>
<script lang="ts">
import { CONFIG } from "../../graphql/config";
import { IConfig } from "../../types/config.model";
import { Component, Vue } from "vue-property-decorator";
<script lang="ts" setup>
import { ParticipantRole } from "@/types/enums";
import RouteName from "@/router/name";
import { supportsWebPFormat } from "@/utils/support";
import { IParticipant, Participant } from "../../types/participant.model";
import { IParticipant } from "../../types/participant.model";
import { LOGGED_USER_DRAFTS } from "../../graphql/actor";
import { EventModel, IEvent } from "../../types/event.model";
import { IEvent } from "../../types/event.model";
import EventParticipationCard from "../../components/Event/EventParticipationCard.vue";
import MultiEventMinimalistCard from "../../components/Event/MultiEventMinimalistCard.vue";
import EventMinimalistCard from "../../components/Event/EventMinimalistCard.vue";
import Subtitle from "../../components/Utils/Subtitle.vue";
import {
LOGGED_USER_PARTICIPATIONS,
LOGGED_USER_UPCOMING_EVENTS,
} from "@/graphql/participant";
import { Paginate } from "@/types/paginate";
import { useQuery } from "@vue/apollo-composable";
import { computed, inject, ref } from "vue";
import { IUser } from "@/types/current-user.model";
import { booleanTransformer, useRouteQuery } from "vue-use-route-query";
import { Locale } from "date-fns";
import { useI18n } from "vue-i18n";
import { useRestrictions } from "@/composition/apollo/config";
type Eventable = IParticipant | IEvent;
@Component({
components: {
Subtitle,
MultiEventMinimalistCard,
EventParticipationCard,
EventMinimalistCard,
},
apollo: {
config: CONFIG,
userUpcomingEvents: {
query: LOGGED_USER_UPCOMING_EVENTS,
fetchPolicy: "cache-and-network",
variables() {
return {
page: 1,
limit: 10,
afterDateTime: this.dateFilter,
};
},
update(data) {
this.futureParticipations = data.loggedUser.participations.elements.map(
(participation: IParticipant) => new Participant(participation)
);
this.groupEvents = data.loggedUser.followedGroupEvents.elements.map(
({ event }: { event: IEvent }) => event
);
},
},
drafts: {
query: LOGGED_USER_DRAFTS,
fetchPolicy: "cache-and-network",
variables: {
page: 1,
limit: 10,
},
update: (data) =>
data.loggedUser.drafts.map((event: IEvent) => new EventModel(event)),
},
pastParticipations: {
query: LOGGED_USER_PARTICIPATIONS,
fetchPolicy: "cache-and-network",
variables() {
return {
page: 1,
limit: 10,
beforeDateTime: this.dateFilter,
};
},
update: (data) => data.loggedUser.participations,
},
},
metaInfo() {
return {
title: this.$t("My events") as string,
};
},
})
export default class MyEvents extends Vue {
futurePage = 1;
const { t } = useI18n({ useScope: "global" });
pastPage = 1;
const futurePage = ref(1);
const pastPage = ref(1);
const limit = ref(10);
limit = 10;
get showUpcoming(): boolean {
return ((this.$route.query.showUpcoming as string) || "true") === "true";
}
set showUpcoming(showUpcoming: boolean) {
this.$router.push({
name: RouteName.MY_EVENTS,
query: { ...this.$route.query, showUpcoming: showUpcoming.toString() },
});
}
get showDrafts(): boolean {
return ((this.$route.query.showDrafts as string) || "true") === "true";
}
set showDrafts(showDrafts: boolean) {
this.$router.push({
name: RouteName.MY_EVENTS,
query: { ...this.$route.query, showDrafts: showDrafts.toString() },
});
}
get showAttending(): boolean {
return ((this.$route.query.showAttending as string) || "true") === "true";
}
set showAttending(showAttending: boolean) {
this.$router.push({
name: RouteName.MY_EVENTS,
query: { ...this.$route.query, showAttending: showAttending.toString() },
});
}
get showMyGroups(): boolean {
return ((this.$route.query.showMyGroups as string) || "false") === "true";
}
set showMyGroups(showMyGroups: boolean) {
this.$router.push({
name: RouteName.MY_EVENTS,
query: { ...this.$route.query, showMyGroups: showMyGroups.toString() },
});
}
get dateFilter(): Date {
const query = this.$route.query.dateFilter as string;
const showUpcoming = useRouteQuery("showUpcoming", true, booleanTransformer);
const showDrafts = useRouteQuery("showDrafts", true, booleanTransformer);
const showAttending = useRouteQuery("showAttending", true, booleanTransformer);
const showMyGroups = useRouteQuery("showMyGroups", false, booleanTransformer);
const dateFilter = useRouteQuery("dateFilter", new Date(), {
fromQuery(query) {
if (query && /(\d{4}-\d{2}-\d{2})/.test(query)) {
return new Date(`${query}T00:00:00Z`);
}
return new Date();
}
set dateFilter(date: Date) {
},
toQuery(value: Date) {
const pad = (number: number) => {
if (number < 10) {
return "0" + number;
}
return number;
};
const stringifiedDate = `${date.getFullYear()}-${pad(
date.getMonth() + 1
)}-${pad(date.getDate())}`;
return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(
value.getDate()
)}`;
},
});
if (this.$route.query.dateFilter !== stringifiedDate) {
this.$router.push({
name: RouteName.MY_EVENTS,
query: {
...this.$route.query,
dateFilter: stringifiedDate,
},
});
const hasMoreFutureParticipations = ref(true);
const hasMorePastParticipations = ref(true);
// config: CONFIG
const {
result: loggedUserUpcomingEventsResult,
fetchMore: fetchMoreUpcomingEvents,
} = useQuery<{
loggedUser: IUser;
}>(LOGGED_USER_UPCOMING_EVENTS, () => ({
page: 1,
limit: 10,
afterDateTime: dateFilter.value,
}));
const futureParticipations = computed(
() =>
loggedUserUpcomingEventsResult.value?.loggedUser.participations.elements ??
[]
);
const groupEvents = computed(
() =>
loggedUserUpcomingEventsResult.value?.loggedUser.followedGroupEvents
.elements ?? []
);
const { result: draftsResult } = useQuery<{
loggedUser: Pick<IUser, "drafts">;
}>(LOGGED_USER_DRAFTS, () => ({ page: 1, limit: 10 }));
const drafts = computed(() => draftsResult.value?.loggedUser.drafts ?? []);
const { result: participationsResult, fetchMore: fetchMoreParticipations } =
useQuery<{
loggedUser: Pick<IUser, "participations">;
}>(LOGGED_USER_PARTICIPATIONS, () => ({ page: 1, limit: 10 }));
const pastParticipations = computed(
() =>
participationsResult.value?.loggedUser.participations ?? {
elements: [],
total: 0,
}
}
);
config!: IConfig;
// metaInfo() {
// return {
// title: this.t("My events") as string,
// };
// },
futureParticipations: IParticipant[] = [];
groupEvents: IEvent[] = [];
hasMoreFutureParticipations = true;
pastParticipations: Paginate<IParticipant> = { elements: [], total: 0 };
hasMorePastParticipations = true;
drafts: IEvent[] = [];
RouteName = RouteName;
supportsWebPFormat = supportsWebPFormat;
static monthlyEvents(
elements: Eventable[],
revertSort = false
): Map<string, Eventable[]> {
const res = elements.filter((element: Eventable) => {
if ("role" in element) {
return (
element.event.beginsOn != null &&
element.role !== ParticipantRole.REJECTED
);
}
return element.beginsOn != null;
const monthlyEvents = (
elements: Eventable[],
revertSort = false
): Map<string, Eventable[]> => {
const res = elements.filter((element: Eventable) => {
if ("role" in element) {
return (
element.event.beginsOn != null &&
element.role !== ParticipantRole.REJECTED
);
}
return element.beginsOn != null;
});
if (revertSort) {
res.sort((a: Eventable, b: Eventable) => {
const aTime = "role" in a ? a.event.beginsOn : a.beginsOn;
const bTime = "role" in b ? b.event.beginsOn : b.beginsOn;
return new Date(bTime).getTime() - new Date(aTime).getTime();
});
if (revertSort) {
res.sort((a: Eventable, b: Eventable) => {
const aTime = "role" in a ? a.event.beginsOn : a.beginsOn;
const bTime = "role" in b ? b.event.beginsOn : b.beginsOn;
return new Date(bTime).getTime() - new Date(aTime).getTime();
});
} else {
res.sort((a: Eventable, b: Eventable) => {
const aTime = "role" in a ? a.event.beginsOn : a.beginsOn;
const bTime = "role" in b ? b.event.beginsOn : b.beginsOn;
return new Date(aTime).getTime() - new Date(bTime).getTime();
});
}
return res.reduce((acc: Map<string, Eventable[]>, element: Eventable) => {
const month = new Date(
"role" in element ? element.event.beginsOn : element.beginsOn
).toLocaleDateString(undefined, {
year: "numeric",
month: "long",
});
const filteredElements: Eventable[] = acc.get(month) || [];
filteredElements.push(element);
acc.set(month, filteredElements);
return acc;
}, new Map());
}
get monthlyFutureEvents(): Map<string, Eventable[]> {
let eventable = [] as Eventable[];
if (this.showAttending) {
eventable = [...eventable, ...this.futureParticipations];
}
if (this.showMyGroups) {
eventable = [...eventable, ...this.groupEvents];
}
return MyEvents.monthlyEvents(eventable);
}
get monthlyPastParticipations(): Map<string, Eventable[]> {
return MyEvents.monthlyEvents(this.pastParticipations.elements, true);
}
monthParticipationsIds(elements: Eventable[]): string[] {
const res = elements.filter((element: Eventable) => {
return "role" in element;
}) as IParticipant[];
return res.map(({ event }: { event: IEvent }) => {
return event.id as string;
} else {
res.sort((a: Eventable, b: Eventable) => {
const aTime = "role" in a ? a.event.beginsOn : a.beginsOn;
const bTime = "role" in b ? b.event.beginsOn : b.beginsOn;
return new Date(aTime).getTime() - new Date(bTime).getTime();
});
}
return res.reduce((acc: Map<string, Eventable[]>, element: Eventable) => {
const month = new Date(
"role" in element ? element.event.beginsOn : element.beginsOn
).toLocaleDateString(undefined, {
year: "numeric",
month: "long",
});
const filteredElements: Eventable[] = acc.get(month) || [];
filteredElements.push(element);
acc.set(month, filteredElements);
return acc;
}, new Map());
};
loadMoreFutureParticipations(): void {
this.futurePage += 1;
if (this.$apollo.queries.futureParticipations) {
this.$apollo.queries.futureParticipations.fetchMore({
// New variables
variables: {
page: this.futurePage,
limit: this.limit,
},
});
}
const monthlyFutureEvents = (): Map<string, Eventable[]> => {
let eventable = [] as Eventable[];
if (showAttending.value) {
eventable = [...eventable, ...futureParticipations.value];
}
loadMorePastParticipations(): void {
this.pastPage += 1;
if (this.$apollo.queries.pastParticipations) {
this.$apollo.queries.pastParticipations.fetchMore({
// New variables
variables: {
page: this.pastPage,
limit: this.limit,
},
});
}
if (showMyGroups.value) {
eventable = [...eventable, ...groupEvents.value];
}
return monthlyEvents(eventable);
};
eventDeleted(eventid: string): void {
this.futureParticipations = this.futureParticipations.filter(
const monthlyPastParticipations = computed((): Map<string, Eventable[]> => {
return monthlyEvents(pastParticipations.value.elements, true);
});
const monthParticipationsIds = (elements: Eventable[]): string[] => {
const res = elements.filter((element: Eventable) => {
return "role" in element;
}) as IParticipant[];
return res.map(({ event }: { event: IEvent }) => {
return event.id as string;
});
};
const loadMoreFutureParticipations = (): void => {
futurePage.value += 1;
if (fetchMoreUpcomingEvents) {
fetchMoreUpcomingEvents({
// New variables
variables: {
page: futurePage.value,
limit: limit.value,
},
});
}
};
const loadMorePastParticipations = (): void => {
pastPage.value += 1;
if (fetchMoreParticipations) {
fetchMoreParticipations({
// New variables
variables: {
page: pastPage.value,
limit: limit.value,
},
});
}
};
const eventDeleted = (eventid: string): void => {
futureParticipations.value = futureParticipations.value.filter(
(participation) => participation.event.id !== eventid
);
pastParticipations.value = {
elements: pastParticipations.value.elements.filter(
(participation) => participation.event.id !== eventid
);
this.pastParticipations = {
elements: this.pastParticipations.elements.filter(
(participation) => participation.event.id !== eventid
),
total: this.pastParticipations.total - 1,
};
}
),
total: pastParticipations.value.total - 1,
};
};
get hideCreateEventButton(): boolean {
return !!this.config?.restrictions?.onlyGroupsCanCreateEvents;
}
const { restrictions } = useRestrictions();
get firstDayOfWeek(): number {
return this.$dateFnsLocale?.options?.weekStartsOn || 0;
}
}
const hideCreateEventButton = computed((): boolean => {
return restrictions.value?.onlyGroupsCanCreateEvents === true;
});
const dateFnsLocale = inject<Locale>("dateFnsLocale");
const firstDayOfWeek = computed((): number => {
return dateFnsLocale?.options?.weekStartsOn ?? 0;
});
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style lang="scss" scoped>
@import "~bulma/sass/utilities/mixins.sass";
// @import "node_modules/bulma/sass/utilities/mixins.sass";
main > .container {
background: $white;
// background: $white;
& > h1 {
margin: 10px auto 5px;
@@ -524,24 +452,18 @@ section {
.not-found {
margin-top: 2rem;
.img-container {
background-image: url("../../../public/img/pics/event_creation-480w.jpg");
background-image: url("/img/pics/event_creation-480w.webp");
@media (min-resolution: 2dppx) {
& {
background-image: url("../../../public/img/pics/event_creation-1024w.jpg");
}
}
&.webp {
background-image: url("../../../public/img/pics/event_creation-480w.webp");
@media (min-resolution: 2dppx) {
& {
background-image: url("../../../public/img/pics/event_creation-1024w.webp");
}
background-image: url("/img/pics/event_creation-1024w.webp");
}
}
max-width: 450px;
height: 300px;
box-shadow: 0 0 8px 8px white inset;
@media (prefers-color-scheme: dark) {
box-shadow: 0 0 8px 8px #374151 inset;
}
background-size: cover;
border-radius: 10px;
margin: auto auto 1rem;
@@ -549,15 +471,15 @@ section {
}
.wrapper {
display: grid;
grid-template-areas: "filter" "events";
align-items: start;
// display: grid;
// grid-template-areas: "filter" "events";
// align-items: start;
@include desktop {
gap: 2rem;
grid-template-columns: 1fr 3fr;
grid-template-areas: "filter events";
}
// // @include desktop {
// gap: 2rem;
// grid-template-columns: 1fr 3fr;
// grid-template-areas: "filter events";
// // }
.event-filter {
grid-area: filter;
@@ -565,18 +487,18 @@ section {
border-radius: 5px;
padding: 0.75rem 1.25rem 0.25rem;
@include desktop {
padding: 2rem 1.25rem;
::v-deep .field.is-grouped {
display: block;
}
}
// @include desktop {
// padding: 2rem 1.25rem;
// :deep(.field.is-grouped) {
// display: block;
// }
// }
::v-deep .field > .field {
:deep(.field > .field) {
margin: 0 auto 1.25rem !important;
}
.date-filter ::v-deep .field-body {
.date-filter :deep(.field-body) {
display: block;
}
}

View File

@@ -1,72 +1,82 @@
<template>
<section class="section container" v-if="event">
<section class="container mx-auto" v-if="event">
<breadcrumbs-nav
:links="[
{ name: RouteName.MY_EVENTS, text: $t('My events') },
{ name: RouteName.MY_EVENTS, text: t('My events') },
{
name: RouteName.EVENT,
params: { uuid: event.uuid },
text: event.title,
},
{
name: RouteName.PARTICIPANTS,
name: RouteName.PARTICIPATIONS,
params: { uuid: event.uuid },
text: $t('Participants'),
text: t('Participants'),
},
]"
/>
<h1 class="title">{{ $t("Participants") }}</h1>
<div class="level">
<div class="level-left">
<div class="level-item">
<b-field :label="$t('Status')" horizontal label-for="role-select">
<b-select v-model="role" id="role-select">
<h1>{{ t("Participants") }}</h1>
<div class="">
<div class="">
<div class="">
<o-field :label="t('Status')" horizontal label-for="role-select">
<o-select v-model="role" id="role-select">
<option :value="null">
{{ $t("Everything") }}
{{ t("Everything") }}
</option>
<option :value="ParticipantRole.CREATOR">
{{ $t("Organizer") }}
{{ t("Organizer") }}
</option>
<option :value="ParticipantRole.PARTICIPANT">
{{ $t("Participant") }}
{{ t("Participant") }}
</option>
<option :value="ParticipantRole.NOT_APPROVED">
{{ $t("Not approved") }}
{{ t("Not approved") }}
</option>
<option :value="ParticipantRole.REJECTED">
{{ $t("Rejected") }}
{{ t("Rejected") }}
</option>
</b-select>
</b-field>
</o-select>
</o-field>
</div>
<div class="level-item" v-if="exportFormats.length > 0">
<b-dropdown aria-role="list">
<div class="" v-if="exportFormats.length > 0">
<o-dropdown aria-role="list">
<template #trigger="{ active }">
<b-button
:label="$t('Export')"
type="is-primary"
<o-button
:label="t('Export')"
variant="primary"
:icon-right="active ? 'menu-up' : 'menu-down'"
/>
</template>
<b-dropdown-item
<o-dropdown-item
has-link
v-for="format in exportFormats"
:key="format"
aria-role="listitem"
@click="exportParticipants(format)"
@keyup.enter="exportParticipants(format)"
@click="
exportParticipants({
eventId: event?.id,
format,
})
"
@keyup.enter="
exportParticipants({
eventId: event.value?.id,
format,
})
"
>
<button class="dropdown-button">
<b-icon :icon="formatToIcon(format)"></b-icon>
<o-icon :icon="formatToIcon(format)"></o-icon>
{{ format }}
</button>
</b-dropdown-item>
</b-dropdown>
</o-dropdown-item>
</o-dropdown>
</div>
</div>
</div>
<b-table
<o-table
:data="event.participants.elements"
ref="queueTable"
detailed
@@ -76,26 +86,26 @@
:is-row-checkable="(row) => row.role !== ParticipantRole.CREATOR"
checkbox-position="left"
:show-detail-icon="false"
:loading="this.$apollo.loading"
:loading="participantsLoading"
paginated
:current-page="page"
backend-pagination
:pagination-simple="true"
:aria-next-label="$t('Next page')"
:aria-previous-label="$t('Previous page')"
:aria-page-label="$t('Page')"
:aria-current-label="$t('Current page')"
:aria-next-label="t('Next page')"
:aria-previous-label="t('Previous page')"
:aria-page-label="t('Page')"
:aria-current-label="t('Current page')"
:total="event.participants.total"
:per-page="PARTICIPANTS_PER_PAGE"
backend-sorting
:default-sort-direction="'desc'"
:default-sort="['insertedAt', 'desc']"
@page-change="(newPage) => (page = newPage)"
@sort="(field, order) => $emit('sort', field, order)"
@sort="(field, order) => emit('sort', field, order)"
>
<b-table-column
<o-table-column
field="actor.preferredUsername"
:label="$t('Participant')"
:label="t('Participant')"
v-slot="props"
>
<article class="media">
@@ -105,20 +115,13 @@
>
<img class="is-rounded" :src="props.row.actor.avatar.url" alt="" />
</figure>
<b-icon
class="media-left"
<Incognito
v-else-if="props.row.actor.preferredUsername === 'anonymous'"
size="is-large"
icon="incognito"
/>
<b-icon
class="media-left"
v-else
size="is-large"
icon="account-circle"
:size="48"
/>
<AccountCircle v-else :size="48" />
<div class="media-content">
<div class="content">
<div class="prose dark:prose-invert">
<span v-if="props.row.actor.preferredUsername !== 'anonymous'">
<span v-if="props.row.actor.name">{{
props.row.actor.name
@@ -129,42 +132,42 @@
>
</span>
<span v-else>
{{ $t("Anonymous participant") }}
{{ t("Anonymous participant") }}
</span>
</div>
</div>
</article>
</b-table-column>
<b-table-column field="role" :label="$t('Role')" v-slot="props">
</o-table-column>
<o-table-column field="role" :label="t('Role')" v-slot="props">
<b-tag
type="is-primary"
variant="primary"
v-if="props.row.role === ParticipantRole.CREATOR"
>
{{ $t("Organizer") }}
{{ t("Organizer") }}
</b-tag>
<b-tag v-else-if="props.row.role === ParticipantRole.PARTICIPANT">
{{ $t("Participant") }}
{{ t("Participant") }}
</b-tag>
<b-tag v-else-if="props.row.role === ParticipantRole.NOT_CONFIRMED">
{{ $t("Not confirmed") }}
{{ t("Not confirmed") }}
</b-tag>
<b-tag
type="is-warning"
variant="warning"
v-else-if="props.row.role === ParticipantRole.NOT_APPROVED"
>
{{ $t("Not approved") }}
{{ t("Not approved") }}
</b-tag>
<b-tag
type="is-danger"
variant="danger"
v-else-if="props.row.role === ParticipantRole.REJECTED"
>
{{ $t("Rejected") }}
{{ t("Rejected") }}
</b-tag>
</b-table-column>
<b-table-column
</o-table-column>
<o-table-column
field="metadata.message"
class="column-message"
:label="$t('Message')"
:label="t('Message')"
v-slot="props"
>
<div
@@ -176,7 +179,7 @@
v-if="props.row.metadata && props.row.metadata.message"
>
<p v-if="props.row.metadata.message.length > MESSAGE_ELLIPSIS_LENGTH">
{{ props.row.metadata.message | ellipsize }}
{{ ellipsize(props.row.metadata.message) }}
</p>
<p v-else>
{{ props.row.metadata.message }}
@@ -188,67 +191,64 @@
@click.stop="toggleQueueDetails(props.row)"
>
{{
openDetailedRows[props.row.id] ? $t("View less") : $t("View more")
openDetailedRows[props.row.id] ? t("View less") : t("View more")
}}
</button>
</div>
<p v-else class="has-text-grey-dark">
{{ $t("No message") }}
{{ t("No message") }}
</p>
</b-table-column>
<b-table-column field="insertedAt" :label="$t('Date')" v-slot="props">
<span class="has-text-centered">
{{ props.row.insertedAt | formatDateString }}<br />{{
props.row.insertedAt | formatTimeString
</o-table-column>
<o-table-column field="insertedAt" :label="t('Date')" v-slot="props">
<span class="text-center">
{{ formatDateString(props.row.insertedAt) }}<br />{{
formatTimeString(props.row.insertedAt)
}}
</span>
</b-table-column>
</o-table-column>
<template #detail="props">
<article v-html="nl2br(props.row.metadata.message)" />
</template>
<template slot="empty">
<section class="section">
<div class="content has-text-grey-dark has-text-centered">
<p>{{ $t("No participant matches the filters") }}</p>
</div>
</section>
<template #empty>
<EmptyContent icon="account-circle" :inline="true">
{{ t("No participant matches the filters") }}
</EmptyContent>
</template>
<template slot="bottom-left">
<div class="buttons">
<b-button
<template #bottom-left>
<div class="flex gap-2">
<o-button
@click="acceptParticipants(checkedRows)"
type="is-success"
variant="success"
:disabled="!canAcceptParticipants"
>
{{
$tc(
t(
"No participant to approve|Approve participant|Approve {number} participants",
checkedRows.length,
{ number: checkedRows.length }
{ number: checkedRows.length },
checkedRows.length
)
}}
</b-button>
<b-button
</o-button>
<o-button
@click="refuseParticipants(checkedRows)"
type="is-danger"
variant="danger"
:disabled="!canRefuseParticipants"
>
{{
$tc(
t(
"No participant to reject|Reject participant|Reject {number} participants",
checkedRows.length,
{ number: checkedRows.length }
{ number: checkedRows.length },
checkedRows.length
)
}}
</b-button>
</o-button>
</div>
</template>
</b-table>
</o-table>
</section>
</template>
<script lang="ts">
import { Component, Prop, Vue, Ref } from "vue-property-decorator";
<script lang="ts" setup>
import { ParticipantRole } from "@/types/enums";
import { IParticipant } from "../../types/participant.model";
import { IEvent, IEventParticipantStats } from "../../types/event.model";
@@ -257,263 +257,206 @@ import {
PARTICIPANTS,
UPDATE_PARTICIPANT,
} from "../../graphql/event";
import { CURRENT_ACTOR_CLIENT } from "../../graphql/actor";
import { IPerson, usernameWithDomain } from "../../types/actor";
import { EVENT_PARTICIPANTS } from "../../graphql/config";
import { IConfig } from "../../types/config.model";
import { usernameWithDomain } from "../../types/actor";
import { nl2br } from "../../utils/html";
import { asyncForEach } from "../../utils/asyncForEach";
import RouteName from "../../router/name";
import VueRouter from "vue-router";
const { isNavigationFailure, NavigationFailureType } = VueRouter;
import { useCurrentActorClient } from "@/composition/apollo/actor";
import { useParticipantsExportFormats } from "@/composition/config";
import { useMutation, useQuery } from "@vue/apollo-composable";
import {
integerTransformer,
enumTransformer,
useRouteQuery,
} from "vue-use-route-query";
import { computed, inject, ref } from "vue";
import { formatDateString, formatTimeString } from "@/filters/datetime";
import { useI18n } from "vue-i18n";
import AccountCircle from "vue-material-design-icons/AccountCircle.vue";
import Incognito from "vue-material-design-icons/Incognito.vue";
import EmptyContent from "@/components/Utils/EmptyContent.vue";
import { Notifier } from "@/plugins/notifier";
const PARTICIPANTS_PER_PAGE = 10;
const MESSAGE_ELLIPSIS_LENGTH = 130;
type exportFormat = "CSV" | "PDF" | "ODS";
@Component({
apollo: {
currentActor: {
query: CURRENT_ACTOR_CLIENT,
},
config: EVENT_PARTICIPANTS,
event: {
query: PARTICIPANTS,
variables() {
return {
uuid: this.eventId,
page: this.page,
limit: PARTICIPANTS_PER_PAGE,
roles: this.role,
};
},
skip() {
return !this.currentActor.id;
},
},
},
filters: {
ellipsize: (text?: string) =>
text && text.substr(0, MESSAGE_ELLIPSIS_LENGTH).concat(""),
},
metaInfo() {
return {
title: this.$t("Participants") as string,
};
},
})
export default class Participants extends Vue {
@Prop({ required: true }) eventId!: string;
const props = defineProps<{
eventId: string;
}>();
get page(): number {
return parseInt((this.$route.query.page as string) || "1", 10);
}
const { t } = useI18n({ useScope: "global" });
set page(page: number) {
this.pushRouter(RouteName.PARTICIPATIONS, {
page: page.toString(),
const { currentActor } = useCurrentActorClient();
const participantsExportFormats = useParticipantsExportFormats();
const ellipsize = (text?: string) =>
text && text.substring(0, MESSAGE_ELLIPSIS_LENGTH).concat("");
// metaInfo() {
// return {
// title: this.t("Participants") as string,
// };
// },
const page = useRouteQuery("page", 1, integerTransformer);
const role = useRouteQuery(
"role",
ParticipantRole.PARTICIPANT,
enumTransformer(ParticipantRole)
);
const limit = ref(PARTICIPANTS_PER_PAGE);
const checkedRows = ref<IParticipant[]>([]);
// const queueTable = ref(null);
const { result: participantsResult, loading: participantsLoading } = useQuery<{
event: IEvent;
}>(
PARTICIPANTS,
() => ({
uuid: props.eventId,
page: page.value,
limit: PARTICIPANTS_PER_PAGE,
roles: role.value,
}),
() => ({
enabled:
currentActor.value?.id !== undefined &&
page.value !== undefined &&
role.value !== undefined,
})
);
const event = computed(() => participantsResult.value?.event);
const participantStats = computed((): IEventParticipantStats | null => {
if (!event.value) return null;
return event.value.participantStats;
});
const { mutate: updateParticipant, onError: onUpdateParticipantError } =
useMutation(UPDATE_PARTICIPANT);
onUpdateParticipantError((e) => console.error(e));
const acceptParticipants = async (
participants: IParticipant[]
): Promise<void> => {
await asyncForEach(participants, async (participant: IParticipant) => {
await updateParticipant({
id: participant.id,
role: ParticipantRole.PARTICIPANT,
});
}
});
checkedRows.value = [];
};
get role(): ParticipantRole | null {
if (
Object.values(ParticipantRole).includes(
this.$route.query.role as ParticipantRole
)
) {
return this.$route.query.role as ParticipantRole;
}
return null;
}
set role(role: ParticipantRole | null) {
this.pushRouter(RouteName.PARTICIPATIONS, {
role: role || "",
const refuseParticipants = async (
participants: IParticipant[]
): Promise<void> => {
await asyncForEach(participants, async (participant: IParticipant) => {
await updateParticipant({
id: participant.id,
role: ParticipantRole.REJECTED,
});
});
checkedRows.value = [];
};
const {
mutate: exportParticipants,
onDone: onExportParticipantsMutationDone,
onError: onExportParticipantsMutationError,
} = useMutation(EXPORT_EVENT_PARTICIPATIONS);
onExportParticipantsMutationDone(({ data }) => {
const link =
window.origin +
"/exports/" +
type.toLowerCase() +
"/" +
exportEventParticipants;
console.log(link);
const a = document.createElement("a");
a.style.display = "none";
document.body.appendChild(a);
a.href = link;
a.setAttribute("download", "true");
a.click();
window.URL.revokeObjectURL(a.href);
document.body.removeChild(a);
});
const notifier = inject<Notifier>("notifier");
onExportParticipantsMutationError((e) => {
console.error(e);
if (e.graphQLErrors && e.graphQLErrors.length > 0) {
notifier?.error(e.graphQLErrors[0].message);
}
});
limit = PARTICIPANTS_PER_PAGE;
const exportFormats = computed((): exportFormat[] => {
return (participantsExportFormats ?? []).map(
(key) => key.toUpperCase() as exportFormat
);
});
event!: IEvent;
config!: IConfig;
ParticipantRole = ParticipantRole;
currentActor!: IPerson;
PARTICIPANTS_PER_PAGE = PARTICIPANTS_PER_PAGE;
checkedRows: IParticipant[] = [];
RouteName = RouteName;
usernameWithDomain = usernameWithDomain;
@Ref("queueTable") readonly queueTable!: any;
get participantStats(): IEventParticipantStats | null {
if (!this.event) return null;
return this.event.participantStats;
const formatToIcon = (format: exportFormat): string => {
switch (format) {
case "CSV":
return "file-delimited";
case "PDF":
return "file-pdf-box";
case "ODS":
return "google-spreadsheet";
}
};
async acceptParticipant(participant: IParticipant): Promise<void> {
try {
await this.$apollo.mutate({
mutation: UPDATE_PARTICIPANT,
variables: {
id: participant.id,
role: ParticipantRole.PARTICIPANT,
},
});
} catch (e) {
console.error(e);
}
}
async refuseParticipant(participant: IParticipant): Promise<void> {
try {
await this.$apollo.mutate({
mutation: UPDATE_PARTICIPANT,
variables: {
id: participant.id,
role: ParticipantRole.REJECTED,
},
});
} catch (e) {
console.error(e);
}
}
async acceptParticipants(participants: IParticipant[]): Promise<void> {
await asyncForEach(participants, async (participant: IParticipant) => {
await this.acceptParticipant(participant);
});
this.checkedRows = [];
}
async refuseParticipants(participants: IParticipant[]): Promise<void> {
await asyncForEach(participants, async (participant: IParticipant) => {
await this.refuseParticipant(participant);
});
this.checkedRows = [];
}
async exportParticipants(type: exportFormat): Promise<void> {
try {
const {
data: { exportEventParticipants },
} = await this.$apollo.mutate({
mutation: EXPORT_EVENT_PARTICIPATIONS,
variables: {
eventId: this.event.id,
format: type,
},
});
const link =
window.origin +
"/exports/" +
type.toLowerCase() +
"/" +
exportEventParticipants;
console.log(link);
const a = document.createElement("a");
a.style.display = "none";
document.body.appendChild(a);
a.href = link;
a.setAttribute("download", "true");
a.click();
window.URL.revokeObjectURL(a.href);
document.body.removeChild(a);
} catch (e: any) {
console.error(e);
if (e.graphQLErrors && e.graphQLErrors.length > 0) {
this.$notifier.error(e.graphQLErrors[0].message);
}
}
}
get exportFormats(): string[] {
return (this.config?.exportFormats?.eventParticipants || []).map((key) =>
key.toUpperCase()
);
}
formatToIcon(format: exportFormat): string {
switch (format) {
case "CSV":
return "file-delimited";
case "PDF":
return "file-pdf-box";
case "ODS":
return "google-spreadsheet";
}
}
/**
* We can accept participants if at least one of them is not approved
*/
get canAcceptParticipants(): boolean {
return this.checkedRows.some((participant: IParticipant) =>
[ParticipantRole.NOT_APPROVED, ParticipantRole.REJECTED].includes(
participant.role
)
);
}
/**
* We can refuse participants if at least one of them is something different than not approved
*/
get canRefuseParticipants(): boolean {
return this.checkedRows.some(
(participant: IParticipant) =>
participant.role !== ParticipantRole.REJECTED
);
}
MESSAGE_ELLIPSIS_LENGTH = MESSAGE_ELLIPSIS_LENGTH;
nl2br = nl2br;
toggleQueueDetails(row: IParticipant): void {
if (
row.metadata.message &&
row.metadata.message.length < MESSAGE_ELLIPSIS_LENGTH
/**
* We can accept participants if at least one of them is not approved
*/
const canAcceptParticipants = (): boolean => {
return checkedRows.value.some((participant: IParticipant) =>
[ParticipantRole.NOT_APPROVED, ParticipantRole.REJECTED].includes(
participant.role
)
return;
this.queueTable.toggleDetails(row);
if (row.id) {
this.openDetailedRows[row.id] = !this.openDetailedRows[row.id];
}
}
);
};
openDetailedRows: Record<string, boolean> = {};
/**
* We can refuse participants if at least one of them is something different than not approved
*/
const canRefuseParticipants = (): boolean => {
return checkedRows.value.some(
(participant: IParticipant) => participant.role !== ParticipantRole.REJECTED
);
};
async pushRouter(
routeName: string,
args: Record<string, string>
): Promise<void> {
try {
await this.$router.push({
name: routeName,
query: { ...this.$route.query, ...args },
});
this.$apollo.queries.event.refetch();
} catch (e) {
if (isNavigationFailure(e, NavigationFailureType.redirected)) {
throw Error(e.toString());
}
}
const toggleQueueDetails = (row: IParticipant): void => {
if (
row.metadata.message &&
row.metadata.message.length < MESSAGE_ELLIPSIS_LENGTH
)
return;
queueTable.value.toggleDetails(row);
if (row.id) {
openDetailedRows.value[row.id] = !openDetailedRows.value[row.id];
}
}
};
const openDetailedRows = <Record<string, boolean>>{};
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style lang="scss" scoped>
section.container.container {
padding: 1rem;
background: $white;
// background: $white;
}
.table {

View File

@@ -1,31 +1,35 @@
<template>
<section class="section container">
<h1 class="title">{{ $t("Create a new group") }}</h1>
<section class="container mx-auto">
<h1>{{ $t("Create a new group") }}</h1>
<b-message type="is-danger" v-for="(value, index) in errors" :key="index">
<o-notification
variant="danger"
v-for="(value, index) in errors"
:key="index"
>
{{ value }}
</b-message>
</o-notification>
<form @submit.prevent="createGroup">
<b-field :label="$t('Group display name')" label-for="group-display-name">
<b-input
<o-field :label="$t('Group display name')" label-for="group-display-name">
<o-input
aria-required="true"
required
v-model="group.name"
id="group-display-name"
/>
</b-field>
</o-field>
<div class="field">
<label class="label" for="group-preferred-username">{{
$t("Federated Group Name")
}}</label>
<div class="field-body">
<b-field
<o-field
:message="preferredUsernameErrors[0]"
:type="preferredUsernameErrors[1]"
>
<b-input
<o-input
ref="preferredUsernameInput"
aria-required="true"
required
@@ -45,26 +49,28 @@
<p class="control">
<span class="button is-static">@{{ host }}</span>
</p>
</b-field>
</o-field>
</div>
<p
v-html="
$t(
'This is like your federated username (<code>{username}</code>) for groups. It will allow the group to be found on the federation, and is guaranteed to be unique.',
{ username: usernameWithDomain(currentActor, true) }
)
"
/>
<i18n-t
v-if="currentActor"
keypath="This is like your federated username ({username}) for groups. It will allow the group to be found on the federation, and is guaranteed to be unique."
>
<template #username>
<code>
{{ usernameWithDomain(currentActor, true) }}
</code>
</template>
</i18n-t>
</div>
<b-field
<o-field
:label="$t('Description')"
label-for="group-summary"
:message="summaryErrors[0]"
:type="summaryErrors[1]"
>
<b-input v-model="group.summary" type="textarea" id="group-summary" />
</b-field>
<o-input v-model="group.summary" type="textarea" id="group-summary" />
</o-field>
<div>
<b>{{ $t("Avatar") }}</b>
@@ -91,198 +97,163 @@
</section>
</template>
<script lang="ts">
import { Component, Watch } from "vue-property-decorator";
import { Group, IPerson, usernameWithDomain } from "@/types/actor";
import { CURRENT_ACTOR_CLIENT, PERSON_MEMBERSHIPS } from "@/graphql/actor";
import { CREATE_GROUP } from "@/graphql/group";
import { mixins } from "vue-class-component";
import IdentityEditionMixin from "@/mixins/identityEdition";
import { MemberRole } from "@/types/enums";
<script lang="ts" setup>
import { Group, usernameWithDomain, displayName } from "@/types/actor";
import RouteName from "../../router/name";
import { convertToUsername } from "../../utils/username";
import PictureUpload from "../../components/PictureUpload.vue";
import { CONFIG } from "@/graphql/config";
import { IConfig } from "@/types/config.model";
import { ErrorResponse } from "@/types/errors.model";
import { ServerParseError } from "@apollo/client/link/http";
import { ApolloCache, FetchResult, InMemoryCache } from "@apollo/client/core";
import { useCurrentActorClient } from "@/composition/apollo/actor";
import { useUploadLimits } from "@/composition/apollo/config";
import { computed, inject, reactive, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { useI18n } from "vue-i18n";
import { useCreateGroup } from "@/composition/apollo/group";
import {
useAvatarMaxSize,
useBannerMaxSize,
useHost,
} from "@/composition/config";
import { Notifier } from "@/plugins/notifier";
@Component({
components: {
PictureUpload,
},
apollo: {
currentActor: {
query: CURRENT_ACTOR_CLIENT,
},
config: CONFIG,
},
metaInfo() {
return {
title: this.$t("Create a new group") as string,
};
},
})
export default class CreateGroup extends mixins(IdentityEditionMixin) {
currentActor!: IPerson;
const { currentActor } = useCurrentActorClient();
const { uploadLimits } = useUploadLimits();
group = new Group();
const { t } = useI18n({ useScope: "global" });
config!: IConfig;
useHead({
title: computed(() => t("Create a new group")),
});
avatarFile: File | null = null;
const group = ref(new Group());
bannerFile: File | null = null;
const avatarFile = ref<File | null>(null);
const bannerFile = ref<File | null>(null);
errors: string[] = [];
const errors = ref<string[]>([]);
fieldErrors: Record<string, string | undefined> = {
preferred_username: undefined,
summary: undefined,
const fieldErrors = reactive<Record<string, string | undefined>>({
preferred_username: undefined,
summary: undefined,
});
const router = useRouter();
const host = useHost();
const avatarMaxSize = useAvatarMaxSize();
const bannerMaxSize = useBannerMaxSize();
const notifier = inject<Notifier>("notifier");
const createGroup = async (): Promise<void> => {
errors.value = [];
fieldErrors.preferred_username = undefined;
fieldErrors.summary = undefined;
const variables = buildVariables();
const { onDone, onError } = useCreateGroup(variables);
onDone(() => {
notifier?.success(
t("Group {displayName} created", {
displayName: displayName(group),
})
);
router.push({
name: RouteName.GROUP,
params: { preferredUsername: usernameWithDomain(group.value) },
});
});
onError((err) => handleError(err as unknown as ErrorResponse));
};
watch(group, (newGroup) => {
group.value.preferredUsername = convertToUsername(newGroup.name);
});
const buildVariables = () => {
let avatarObj = {};
let bannerObj = {};
const groupBasic = {
preferredUsername: group.value.preferredUsername,
name: group.value.name,
summary: group.value.summary,
};
usernameWithDomain = usernameWithDomain;
async createGroup(): Promise<void> {
try {
this.errors = [];
this.fieldErrors = { preferred_username: undefined, summary: undefined };
await this.$apollo.mutate({
mutation: CREATE_GROUP,
variables: this.buildVariables(),
update: (store: ApolloCache<InMemoryCache>, { data }: FetchResult) => {
const query = {
query: PERSON_MEMBERSHIPS,
variables: {
id: this.currentActor.id,
},
};
const membershipData = store.readQuery<{ person: IPerson }>(query);
if (!membershipData) return;
const { person } = membershipData;
person.memberships.elements.push({
parent: data?.createGroup,
role: MemberRole.ADMINISTRATOR,
actor: this.currentActor,
insertedAt: new Date().toString(),
updatedAt: new Date().toString(),
});
store.writeQuery({ ...query, data: { person } });
if (avatarFile.value) {
avatarObj = {
avatar: {
media: {
name: avatarFile.value?.name,
alt: `${group.value.preferredUsername}'s avatar`,
file: avatarFile.value,
},
});
await this.$router.push({
name: RouteName.GROUP,
params: { preferredUsername: usernameWithDomain(this.group) },
});
this.$notifier.success(
this.$t("Group {displayName} created", {
displayName: this.group.displayName(),
}) as string
);
} catch (err: any) {
this.handleError(err);
}
}
// eslint-disable-next-line class-methods-use-this
get host(): string {
return window.location.hostname;
}
get avatarMaxSize(): number | undefined {
return this?.config?.uploadLimits?.avatar;
}
get bannerMaxSize(): number | undefined {
return this?.config?.uploadLimits?.banner;
}
@Watch("group.name")
updateUsername(groupName: string): void {
this.group.preferredUsername = convertToUsername(groupName);
}
private buildVariables() {
let avatarObj = {};
let bannerObj = {};
if (this.avatarFile) {
avatarObj = {
avatar: {
media: {
name: this.avatarFile.name,
alt: `${this.group.preferredUsername}'s avatar`,
file: this.avatarFile,
},
},
};
}
if (this.bannerFile) {
bannerObj = {
banner: {
media: {
name: this.bannerFile.name,
alt: `${this.group.preferredUsername}'s banner`,
file: this.bannerFile,
},
},
};
}
return {
...this.group,
...avatarObj,
...bannerObj,
},
};
}
private handleError(err: ErrorResponse) {
if (err?.networkError?.name === "ServerParseError") {
const error = err?.networkError as ServerParseError;
if (bannerFile.value) {
bannerObj = {
banner: {
media: {
name: bannerFile.value?.name,
alt: `${group.value.preferredUsername}'s banner`,
file: bannerFile.value,
},
},
};
}
if (error?.response?.status === 413) {
this.errors.push(
this.$t(
"Unable to create the group. One of the pictures may be too heavy."
) as string
);
}
return {
...groupBasic,
...avatarObj,
...bannerObj,
};
};
const handleError = (err: ErrorResponse) => {
if (err?.networkError?.name === "ServerParseError") {
const error = err?.networkError as ServerParseError;
if (error?.response?.status === 413) {
errors.value.push(
t(
"Unable to create the group. One of the pictures may be too heavy."
) as string
);
}
err.graphQLErrors?.forEach((error) => {
if (error.field) {
if (Array.isArray(error.message)) {
this.fieldErrors[error.field] = error.message[0];
} else {
this.fieldErrors[error.field] = error.message;
}
}
err.graphQLErrors?.forEach((error) => {
if (error.field) {
if (Array.isArray(error.message)) {
fieldErrors[error.field] = error.message[0];
} else {
this.errors.push(error.message);
fieldErrors[error.field] = error.message;
}
});
}
} else {
errors.value.push(error.message);
}
});
};
get summaryErrors() {
const message = this.fieldErrors.summary
? this.fieldErrors.summary
: undefined;
const type = this.fieldErrors.summary ? "is-danger" : undefined;
return [message, type];
}
const summaryErrors = computed(() => {
const message = fieldErrors.summary ? fieldErrors.summary : undefined;
const type = fieldErrors.summary ? "is-danger" : undefined;
return [message, type];
});
get preferredUsernameErrors() {
const message = this.fieldErrors.preferred_username
? this.fieldErrors.preferred_username
: this.$t(
"Only alphanumeric lowercased characters and underscores are supported."
);
const type = this.fieldErrors.preferred_username ? "is-danger" : undefined;
return [message, type];
}
}
const preferredUsernameErrors = computed(() => {
const message = fieldErrors.preferred_username
? fieldErrors.preferred_username
: t(
"Only alphanumeric lowercased characters and underscores are supported."
);
const type = fieldErrors.preferred_username ? "is-danger" : undefined;
return [message, type];
});
</script>
<style>

File diff suppressed because it is too large Load Diff

View File

@@ -20,22 +20,22 @@
},
]"
/>
<b-loading :active="$apollo.loading" />
<o-loading :active="loading" />
<section
class="container section"
class="container mx-auto section"
v-if="group && isCurrentActorAGroupAdmin && followers"
>
<h1>{{ $t("Group Followers") }} ({{ followers.total }})</h1>
<b-field :label="$t('Status')" horizontal>
<b-switch v-model="pending">{{ $t("Pending") }}</b-switch>
</b-field>
<b-table
<o-field :label="$t('Status')" horizontal>
<o-switch v-model="pending">{{ $t("Pending") }}</o-switch>
</o-field>
<o-table
:data="followers.elements"
ref="queueTable"
:loading="this.$apollo.loading"
:loading="loading"
paginated
backend-pagination
:current-page.sync="page"
v-model:current-page="page"
:pagination-simple="true"
:aria-next-label="$t('Next page')"
:aria-previous-label="$t('Previous page')"
@@ -46,204 +46,193 @@
backend-sorting
:default-sort-direction="'desc'"
:default-sort="['insertedAt', 'desc']"
@page-change="triggerLoadMoreFollowersPageChange"
@page-change="loadMoreFollowers"
@sort="(field, order) => $emit('sort', field, order)"
>
<b-table-column
<o-table-column
field="actor.preferredUsername"
:label="$t('Follower')"
v-slot="props"
>
<article class="media">
<figure
class="media-left image is-48x48"
v-if="props.row.actor.avatar"
>
<article class="flex gap-1">
<figure v-if="props.row.actor.avatar">
<img
class="is-rounded"
class="rounded"
:src="props.row.actor.avatar.url"
alt=""
width="48"
height="48"
/>
</figure>
<b-icon
class="media-left"
v-else
size="is-large"
icon="account-circle"
/>
<div class="media-content">
<div class="content">
<AccountCircle v-else :size="48" />
<div class="">
<div class="">
<span v-if="props.row.actor.name">{{
props.row.actor.name
}}</span
><br />
<span class="is-size-7 has-text-grey-dark"
>@{{ usernameWithDomain(props.row.actor) }}</span
>
<span class="">@{{ usernameWithDomain(props.row.actor) }}</span>
</div>
</div>
</article>
</b-table-column>
<b-table-column field="insertedAt" :label="$t('Date')" v-slot="props">
</o-table-column>
<o-table-column field="insertedAt" :label="$t('Date')" v-slot="props">
<span class="has-text-centered">
{{ props.row.insertedAt | formatDateString }}<br />{{
props.row.insertedAt | formatTimeString
{{ formatDateString(props.row.insertedAt) }}<br />{{
formatTimeString(props.row.insertedAt)
}}
</span>
</b-table-column>
<b-table-column field="actions" :label="$t('Actions')" v-slot="props">
</o-table-column>
<o-table-column field="actions" :label="$t('Actions')" v-slot="props">
<div class="buttons">
<b-button
<o-button
v-if="!props.row.approved"
@click="updateFollower(props.row, true)"
icon-left="check"
type="is-success"
>{{ $t("Accept") }}</b-button
variant="success"
>{{ $t("Accept") }}</o-button
>
<b-button
<o-button
@click="updateFollower(props.row, false)"
icon-left="close"
type="is-danger"
>{{ $t("Reject") }}</b-button
variant="danger"
>{{ $t("Reject") }}</o-button
>
</div>
</b-table-column>
<template slot="empty">
</o-table-column>
<template #empty>
<empty-content icon="account" inline>
{{ $t("No follower matches the filters") }}
</empty-content>
</template>
</b-table>
</o-table>
</section>
<b-message v-else-if="!$apollo.loading && group">
<o-notification v-else-if="!loading && group">
{{ $t("You are not an administrator for this group.") }}
</b-message>
</o-notification>
</div>
</template>
<script lang="ts">
import { Component, Watch } from "vue-property-decorator";
import GroupMixin from "@/mixins/group";
import { mixins } from "vue-class-component";
<script lang="ts" setup>
import { GROUP_FOLLOWERS, UPDATE_FOLLOWER } from "@/graphql/followers";
import RouteName from "../../router/name";
import { displayName, usernameWithDomain } from "../../types/actor";
import EmptyContent from "@/components/Utils/EmptyContent.vue";
import { IFollower } from "@/types/actor/follower.model";
import { Paginate } from "@/types/paginate";
import { useMutation, useQuery } from "@vue/apollo-composable";
import {
booleanTransformer,
integerTransformer,
useRouteQuery,
} from "vue-use-route-query";
import { computed, inject } from "vue";
import { useHead } from "@vueuse/head";
import { useI18n } from "vue-i18n";
import { usePersonStatusGroup } from "@/composition/apollo/actor";
import { MemberRole } from "@/types/enums";
import { formatTimeString, formatDateString } from "@/filters/datetime";
import AccountCircle from "vue-material-design-icons/AccountCircle.vue";
import { Notifier } from "@/plugins/notifier";
@Component({
apollo: {
followers: {
query: GROUP_FOLLOWERS,
variables() {
return {
name: this.$route.params.preferredUsername,
followersPage: this.page,
followersLimit: this.FOLLOWERS_PER_PAGE,
approved: this.pending === null ? null : !this.pending,
};
},
update: (data) => data.group.followers,
const props = defineProps<{ preferredUsername: string }>();
const page = useRouteQuery("page", 1, integerTransformer);
const pending = useRouteQuery("pending", false, booleanTransformer);
const FOLLOWERS_PER_PAGE = 10;
const {
result: followersResult,
fetchMore,
loading,
} = useQuery(GROUP_FOLLOWERS, () => ({
name: props.preferredUsername,
followersPage: page.value,
followersLimit: FOLLOWERS_PER_PAGE,
approved: !pending.value,
}));
const group = computed(() => followersResult.value?.group);
const followers = computed(
() => group.value?.followers ?? { total: 0, elements: [] }
);
const { t } = useI18n({ useScope: "global" });
useHead({ title: computed(() => t("Group Followers")) });
const loadMoreFollowers = async (): Promise<void> => {
await fetchMore({
// New variables
variables: {
name: usernameWithDomain(group.value),
followersPage: page.value,
followersLimit: FOLLOWERS_PER_PAGE,
approved: !pending.value,
},
},
components: {
EmptyContent,
},
metaInfo() {
return {
title: this.$t("Group Followers") as string,
};
},
})
export default class GroupFollowers extends mixins(GroupMixin) {
loading = true;
});
};
RouteName = RouteName;
const notifier = inject<Notifier>("notifier");
page = parseInt((this.$route.query.page as string) || "1", 10);
pending: boolean | null =
(this.$route.query.pending as string) == "1" || null;
FOLLOWERS_PER_PAGE = 10;
usernameWithDomain = usernameWithDomain;
displayName = displayName;
followers!: Paginate<IFollower>;
mounted(): void {
this.page = parseInt((this.$route.query.page as string) || "1", 10);
}
@Watch("page")
triggerLoadMoreFollowersPageChange(page: string): void {
this.$router.replace({
name: RouteName.GROUP_FOLLOWERS_SETTINGS,
query: { ...this.$route.query, page },
});
}
@Watch("pending")
triggerPendingStatusPageChange(pending: boolean): void {
this.$router.replace({
name: RouteName.GROUP_FOLLOWERS_SETTINGS,
query: { ...this.$route.query, ...{ pending: pending ? "1" : "0" } },
});
}
async loadMoreFollowers(): Promise<void> {
const { FOLLOWERS_PER_PAGE, group, page, pending } = this;
await this.$apollo.queries.followers.fetchMore({
// New variables
variables() {
return {
name: usernameWithDomain(group),
followersPage: page,
followersLimit: FOLLOWERS_PER_PAGE,
approved: !pending,
};
const { onDone, onError, mutate } = useMutation<{ updateFollower: IFollower }>(
UPDATE_FOLLOWER,
() => ({
refetchQueries: [
{
query: GROUP_FOLLOWERS,
},
});
}
],
})
);
async updateFollower(follower: IFollower, approved: boolean): Promise<void> {
const { FOLLOWERS_PER_PAGE, group, page, pending } = this;
try {
await this.$apollo.mutate<{ rejectFollower: IFollower }>({
mutation: UPDATE_FOLLOWER,
variables: {
id: follower.id,
approved,
},
refetchQueries: [
{
query: GROUP_FOLLOWERS,
variables: {
name: usernameWithDomain(group),
followersPage: page,
followersLimit: FOLLOWERS_PER_PAGE,
approved: !pending,
},
},
],
});
const message = approved
? this.$t("@{username}'s follow request was accepted", {
username: follower.actor.preferredUsername,
})
: this.$t("@{username}'s follow request was rejected", {
username: follower.actor.preferredUsername,
});
this.$notifier.success(message as string);
} catch (error: any) {
console.error(error);
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
this.$notifier.error(error.graphQLErrors[0].message);
}
}
onDone(({ data }) => {
const follower = data?.updateFollower;
const message =
data?.updateFollower.approved === true
? t("@{username}'s follow request was accepted", {
username: follower?.actor.preferredUsername,
})
: t("@{username}'s follow request was rejected", {
username: follower?.actor.preferredUsername,
});
notifier?.success(message);
});
onError((error) => {
console.error(error);
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
notifier?.error(error.graphQLErrors[0].message);
}
}
});
const updateFollower = async (
follower: IFollower,
approved: boolean
): Promise<void> => {
mutate({
id: follower.id,
approved,
});
};
const isCurrentActorAGroupAdmin = computed((): boolean => {
return hasCurrentActorThisRole(MemberRole.ADMINISTRATOR);
});
const hasCurrentActorThisRole = (givenRole: string | string[]): boolean => {
const roles = Array.isArray(givenRole) ? givenRole : [givenRole];
return (
personMemberships.value?.total > 0 &&
roles.includes(personMemberships.value?.elements[0].role)
);
};
const personMemberships = computed(
() => person.value?.memberships ?? { total: 0, elements: [] }
);
const { person } = usePersonStatusGroup(props.preferredUsername);
</script>

View File

@@ -11,194 +11,190 @@
{
name: RouteName.GROUP_SETTINGS,
params: { preferredUsername: usernameWithDomain(group) },
text: $t('Settings'),
text: t('Settings'),
},
{
name: RouteName.GROUP_MEMBERS_SETTINGS,
params: { preferredUsername: usernameWithDomain(group) },
text: $t('Members'),
text: t('Members'),
},
]"
/>
<b-loading :active="$apollo.loading" />
<o-loading :active="groupMembersLoading" />
<section
class="container section"
class="container mx-auto section"
v-if="group && isCurrentActorAGroupAdmin"
>
<form @submit.prevent="inviteMember">
<b-field
:label="$t('Invite a new member')"
<o-field
:label="t('Invite a new member')"
custom-class="add-relay"
label-for="new-member-field"
horizontal
>
<b-field
<o-field
grouped
expanded
size="is-large"
size="large"
:type="inviteError ? 'is-danger' : null"
:message="inviteError"
>
<p class="control">
<b-input
<o-input
id="new-member-field"
v-model="newMemberUsername"
:placeholder="$t('Ex: someone@mobilizon.org')"
:placeholder="t(`Ex: someone{'@'}mobilizon.org`)"
/>
</p>
<p class="control">
<b-button type="is-primary" native-type="submit">{{
$t("Invite member")
}}</b-button>
<o-button variant="primary" native-type="submit">{{
t("Invite member")
}}</o-button>
</p>
</b-field>
</b-field>
</o-field>
</o-field>
</form>
<h1>{{ $t("Group Members") }} ({{ group.members.total }})</h1>
<b-field
:label="$t('Status')"
<h1>{{ t("Group Members") }} ({{ group.members.total }})</h1>
<o-field
:label="t('Status')"
horizontal
label-for="group-members-status-filter"
>
<b-select v-model="roles" id="group-members-status-filter">
<o-select v-model="roles" id="group-members-status-filter">
<option value="">
{{ $t("Everything") }}
{{ t("Everything") }}
</option>
<option :value="MemberRole.ADMINISTRATOR">
{{ $t("Administrator") }}
{{ t("Administrator") }}
</option>
<option :value="MemberRole.MODERATOR">
{{ $t("Moderator") }}
{{ t("Moderator") }}
</option>
<option :value="MemberRole.MEMBER">
{{ $t("Member") }}
{{ t("Member") }}
</option>
<option :value="MemberRole.INVITED">
{{ $t("Invited") }}
{{ t("Invited") }}
</option>
<option :value="MemberRole.NOT_APPROVED">
{{ $t("Not approved") }}
{{ t("Not approved") }}
</option>
<option :value="MemberRole.REJECTED">
{{ $t("Rejected") }}
{{ t("Rejected") }}
</option>
</b-select>
</b-field>
<b-table
</o-select>
</o-field>
<o-table
v-if="members"
:data="members.elements"
ref="queueTable"
:loading="this.$apollo.loading"
:loading="groupMembersLoading"
paginated
backend-pagination
:current-page.sync="page"
v-model:current-page="page"
:pagination-simple="true"
:aria-next-label="$t('Next page')"
:aria-previous-label="$t('Previous page')"
:aria-page-label="$t('Page')"
:aria-current-label="$t('Current page')"
:aria-next-label="t('Next page')"
:aria-previous-label="t('Previous page')"
:aria-page-label="t('Page')"
:aria-current-label="t('Current page')"
:total="members.total"
:per-page="MEMBERS_PER_PAGE"
backend-sorting
:default-sort-direction="'desc'"
:default-sort="['insertedAt', 'desc']"
@page-change="triggerLoadMoreMemberPageChange"
@page-change="loadMoreMembers"
@sort="(field, order) => $emit('sort', field, order)"
>
<b-table-column
<o-table-column
field="actor.preferredUsername"
:label="$t('Member')"
:label="t('Member')"
v-slot="props"
>
<article class="media">
<figure
class="media-left image is-48x48"
v-if="props.row.actor.avatar"
>
<article class="flex">
<figure v-if="props.row.actor.avatar">
<img
class="is-rounded"
class="rounded"
:src="props.row.actor.avatar.url"
:alt="props.row.actor.avatar.alt || ''"
height="48"
width="48"
/>
</figure>
<b-icon
class="media-left"
v-else
size="is-large"
icon="account-circle"
/>
<div class="media-content">
<div class="content">
<AccountCircle v-else :size="48" />
<div class="">
<div class="">
<span v-if="props.row.actor.name">{{
props.row.actor.name
}}</span
><br />
<span class="is-size-7 has-text-grey-dark"
>@{{ usernameWithDomain(props.row.actor) }}</span
>
<span class="">@{{ usernameWithDomain(props.row.actor) }}</span>
</div>
</div>
</article>
</b-table-column>
<b-table-column field="role" :label="$t('Role')" v-slot="props">
</o-table-column>
<o-table-column field="role" :label="t('Role')" v-slot="props">
<b-tag
type="is-primary"
variant="info"
v-if="props.row.role === MemberRole.ADMINISTRATOR"
>
{{ $t("Administrator") }}
{{ t("Administrator") }}
</b-tag>
<b-tag
type="is-primary"
variant="info"
v-else-if="props.row.role === MemberRole.MODERATOR"
>
{{ $t("Moderator") }}
{{ t("Moderator") }}
</b-tag>
<b-tag v-else-if="props.row.role === MemberRole.MEMBER">
{{ $t("Member") }}
{{ t("Member") }}
</b-tag>
<b-tag
type="is-warning"
variant="warning"
v-else-if="props.row.role === MemberRole.NOT_APPROVED"
>
{{ $t("Not approved") }}
{{ t("Not approved") }}
</b-tag>
<b-tag
type="is-danger"
variant="danger"
v-else-if="props.row.role === MemberRole.REJECTED"
>
{{ $t("Rejected") }}
{{ t("Rejected") }}
</b-tag>
<b-tag
type="is-warning"
variant="warning"
v-else-if="props.row.role === MemberRole.INVITED"
>
{{ $t("Invited") }}
{{ t("Invited") }}
</b-tag>
</b-table-column>
<b-table-column field="insertedAt" :label="$t('Date')" v-slot="props">
</o-table-column>
<o-table-column field="insertedAt" :label="t('Date')" v-slot="props">
<span class="has-text-centered">
{{ props.row.insertedAt | formatDateString }}<br />{{
props.row.insertedAt | formatTimeString
{{ formatDateString(props.row.insertedAt) }}<br />{{
formatTimeString(props.row.insertedAt)
}}
</span>
</b-table-column>
<b-table-column field="actions" :label="$t('Actions')" v-slot="props">
<div class="buttons" v-if="props.row.actor.id !== currentActor.id">
<b-button
type="is-success"
</o-table-column>
<o-table-column field="actions" :label="t('Actions')" v-slot="props">
<div
class="flex flex-wrap gap-2"
v-if="props.row.actor.id !== currentActor?.id"
>
<o-button
variant="success"
v-if="props.row.role === MemberRole.NOT_APPROVED"
@click="approveMember(props.row)"
@click="approveMember(props.row.id)"
icon-left="check"
>{{ $t("Approve member") }}</b-button
>{{ t("Approve member") }}</o-button
>
<b-button
type="is-danger"
<o-button
variant="danger"
v-if="props.row.role === MemberRole.NOT_APPROVED"
@click="rejectMember(props.row)"
icon-left="exit-to-app"
>{{ $t("Reject member") }}</b-button
>{{ t("Reject member") }}</o-button
>
<b-button
<o-button
v-if="
[MemberRole.MEMBER, MemberRole.MODERATOR].includes(
props.row.role
@@ -206,9 +202,9 @@
"
@click="promoteMember(props.row)"
icon-left="chevron-double-up"
>{{ $t("Promote") }}</b-button
>{{ t("Promote") }}</o-button
>
<b-button
<o-button
v-if="
[MemberRole.ADMINISTRATOR, MemberRole.MODERATOR].includes(
props.row.role
@@ -216,293 +212,304 @@
"
@click="demoteMember(props.row)"
icon-left="chevron-double-down"
>{{ $t("Demote") }}</b-button
>{{ t("Demote") }}</o-button
>
<b-button
<o-button
v-if="props.row.role === MemberRole.MEMBER"
@click="removeMember(props.row)"
type="is-danger"
variant="danger"
icon-left="exit-to-app"
>{{ $t("Remove") }}</b-button
>{{ t("Remove") }}</o-button
>
</div>
</b-table-column>
<template slot="empty">
</o-table-column>
<template #empty>
<empty-content icon="account" inline>
{{ $t("No member matches the filters") }}
{{ t("No member matches the filters") }}
</empty-content>
</template>
</b-table>
</o-table>
</section>
<b-message v-else-if="!$apollo.loading && group">
{{ $t("You are not an administrator for this group.") }}
</b-message>
<o-notification v-else-if="!groupMembersLoading && group">
{{ t("You are not an administrator for this group.") }}
</o-notification>
</div>
</template>
<script lang="ts">
import { Component, Watch } from "vue-property-decorator";
import GroupMixin from "@/mixins/group";
import { mixins } from "vue-class-component";
<script lang="ts" setup>
import { FETCH_GROUP } from "@/graphql/group";
import { MemberRole } from "@/types/enums";
import { IMember } from "@/types/actor/member.model";
import RouteName from "../../router/name";
import RouteName from "@/router/name";
import {
INVITE_MEMBER,
GROUP_MEMBERS,
REMOVE_MEMBER,
UPDATE_MEMBER,
APPROVE_MEMBER,
} from "../../graphql/member";
import { usernameWithDomain, displayName } from "../../types/actor";
} from "@/graphql/member";
import { usernameWithDomain, displayName, IGroup } from "@/types/actor";
import EmptyContent from "@/components/Utils/EmptyContent.vue";
import { useHead } from "@vueuse/head";
import { useI18n } from "vue-i18n";
import { useMutation, useQuery } from "@vue/apollo-composable";
import { computed, inject, ref, watch } from "vue";
import {
enumTransformer,
integerTransformer,
useRouteQuery,
} from "vue-use-route-query";
import { useRoute, useRouter } from "vue-router";
import {
useCurrentActorClient,
usePersonStatusGroup,
} from "@/composition/apollo/actor";
import { formatTimeString, formatDateString } from "@/filters/datetime";
import AccountCircle from "vue-material-design-icons/AccountCircle.vue";
import { Notifier } from "@/plugins/notifier";
@Component({
apollo: {
members: {
const { t } = useI18n({ useScope: "global" });
useHead({
title: computed(() => t("Group members")),
});
const props = defineProps<{ preferredUsername: string }>();
const { currentActor } = useCurrentActorClient();
const loading = ref(true);
const newMemberUsername = ref("");
const inviteError = ref("");
const page = useRouteQuery("page", 1, integerTransformer);
const roles = useRouteQuery("roles", undefined, enumTransformer(MemberRole));
const MEMBERS_PER_PAGE = 10;
const notifier = inject<Notifier>("notifier");
const {
result: groupMembersResult,
fetchMore: fetchMoreGroupMembers,
loading: groupMembersLoading,
} = useQuery<{ group: IGroup }>(GROUP_MEMBERS, () => ({
groupName: props.preferredUsername,
page: page.value,
limit: MEMBERS_PER_PAGE,
roles: roles.value,
}));
const group = computed(() => groupMembersResult.value?.group);
const members = computed(
() => group.value?.members ?? { total: 0, elements: [] }
);
const {
mutate: inviteMemberMutation,
onDone: onInviteMemberDone,
onError: onInviteMemberError,
} = useMutation<{ inviteMember: IMember }>(INVITE_MEMBER, () => ({
refetchQueries: [
{
query: GROUP_MEMBERS,
variables() {
return {
groupName: this.$route.params.preferredUsername,
page: this.page,
limit: this.MEMBERS_PER_PAGE,
roles: this.roles,
};
},
update: (data) => data.group.members,
},
},
components: {
EmptyContent,
},
metaInfo() {
return {
title: this.$t("Group Members") as string,
};
},
})
export default class GroupMembers extends mixins(GroupMixin) {
loading = true;
],
}));
newMemberUsername = "";
inviteError = "";
MemberRole = MemberRole;
roles: MemberRole | "" = "";
RouteName = RouteName;
page = parseInt((this.$route.query.page as string) || "1", 10);
MEMBERS_PER_PAGE = 10;
usernameWithDomain = usernameWithDomain;
displayName = displayName;
mounted(): void {
const roleQuery = this.$route.query.role as string;
if (Object.values(MemberRole).includes(roleQuery as MemberRole)) {
this.roles = roleQuery as MemberRole;
}
this.page = parseInt((this.$route.query.page as string) || "1", 10);
onInviteMemberError((error) => {
console.error(error);
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
inviteError.value = error.graphQLErrors[0].message;
}
});
async inviteMember(): Promise<void> {
try {
this.inviteError = "";
const { roles, MEMBERS_PER_PAGE, group, page } = this;
const variables = {
groupName: usernameWithDomain(group),
onInviteMemberDone(() => {
notifier?.success(
t("{username} was invited to {group}", {
username: newMemberUsername.value,
group: displayName(group.value),
})
);
newMemberUsername.value = "";
});
const inviteMember = async (): Promise<void> => {
inviteError.value = "";
inviteMemberMutation({
groupId: group.value?.id,
targetActorUsername: newMemberUsername.value,
});
};
const router = useRouter();
const route = useRoute();
const loadMoreMembers = async (): Promise<void> => {
await fetchMoreGroupMembers({
// New variables
variables() {
return {
name: usernameWithDomain(group.value),
page,
limit: MEMBERS_PER_PAGE,
roles,
};
console.log("variables", variables);
await this.$apollo.mutate<{ inviteMember: IMember }>({
mutation: INVITE_MEMBER,
variables: {
groupId: this.group.id,
targetActorUsername: this.newMemberUsername,
},
refetchQueries: [
{
query: GROUP_MEMBERS,
variables,
},
],
});
this.$notifier.success(
this.$t("{username} was invited to {group}", {
username: this.newMemberUsername,
group: displayName(this.group),
}) as string
);
this.newMemberUsername = "";
} catch (error: any) {
console.error(error);
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
this.inviteError = error.graphQLErrors[0].message;
}
}
}
},
});
};
@Watch("page")
triggerLoadMoreMemberPageChange(page: string): void {
this.$router.replace({
name: RouteName.GROUP_MEMBERS_SETTINGS,
query: { ...this.$route.query, page },
});
}
const {
mutate: mutateRemoveMember,
onDone: onRemoveMemberDone,
onError: onRemoveMemberError,
} = useMutation(REMOVE_MEMBER, () => ({
refetchQueries: [
{
query: GROUP_MEMBERS,
},
],
}));
@Watch("roles")
triggerLoadMoreMemberRoleChange(roles: string): void {
this.$router.replace({
name: RouteName.GROUP_MEMBERS_SETTINGS,
query: { ...this.$route.query, roles },
});
onRemoveMemberDone(() => {
let message = t("The member was removed from the group {group}", {
group: displayName(group.value),
}) as string;
if (oldMember.role === MemberRole.NOT_APPROVED) {
message = t("The membership request from {profile} was rejected", {
group: displayName(oldMember.actor),
}) as string;
}
notifier?.success(message);
});
async loadMoreMembers(): Promise<void> {
const { roles, MEMBERS_PER_PAGE, group, page } = this;
await this.$apollo.queries.members.fetchMore({
// New variables
variables() {
return {
name: usernameWithDomain(group),
page,
limit: MEMBERS_PER_PAGE,
roles,
};
},
});
onRemoveMemberError((error) => {
console.error(error);
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
notifier?.error(error.graphQLErrors[0].message);
}
});
async removeMember(oldMember: IMember): Promise<void> {
const { roles, MEMBERS_PER_PAGE, group, page } = this;
const variables = {
groupName: usernameWithDomain(group),
page,
limit: MEMBERS_PER_PAGE,
roles,
};
try {
await this.$apollo.mutate<{ removeMember: IMember }>({
mutation: REMOVE_MEMBER,
variables: {
groupId: this.group.id,
memberId: oldMember.id,
},
refetchQueries: [
{
query: GROUP_MEMBERS,
variables,
},
],
});
let message = this.$t("The member was removed from the group {group}", {
group: displayName(this.group),
}) as string;
const removeMember = (oldMember: IMember) => {
mutateRemoveMember({
groupId: group.value?.id,
memberId: oldMember.id,
});
};
const promoteMember = (member: IMember): void => {
if (!member.id) return;
if (member.role === MemberRole.MODERATOR) {
updateMember(member, MemberRole.ADMINISTRATOR);
}
if (member.role === MemberRole.MEMBER) {
updateMember(member, MemberRole.MODERATOR);
}
};
const demoteMember = (member: IMember): void => {
if (!member.id) return;
if (member.role === MemberRole.MODERATOR) {
updateMember(member, MemberRole.MEMBER);
}
if (member.role === MemberRole.ADMINISTRATOR) {
updateMember(member, MemberRole.MODERATOR);
}
};
const {
mutate: approveMember,
onDone: onApproveMemberDone,
onError: onApproveMemberError,
} = useMutation<{ approveMember: IMember }, { memberId: string }>(
APPROVE_MEMBER
);
onApproveMemberDone(() => {
notifier?.success(t("The member was approved"));
});
onApproveMemberError((error) => {
console.error(error);
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
notifier?.error(error.graphQLErrors[0].message);
}
});
const rejectMember = (member: IMember): void => {
if (!member.id) return;
removeMember(member);
};
const {
mutate: updateMemberMutation,
onDone: onUpdateMutationDone,
onError: onUpdateMutationError,
} = useMutation<
{ id: string; role: MemberRole },
{ memberId: string; role: MemberRole; oldRole: MemberRole }
>(UPDATE_MEMBER, () => ({
refetchQueries: [
{
query: FETCH_GROUP,
variables: { name: props.preferredUsername },
},
],
}));
onUpdateMutationDone(({ data, context }) => {
let successMessage;
console.debug("onUpdateMutationDone", context);
switch (data?.role) {
case MemberRole.MODERATOR:
successMessage = "The member role was updated to moderator";
break;
case MemberRole.ADMINISTRATOR:
successMessage = "The member role was updated to administrator";
break;
case MemberRole.MEMBER:
if (oldMember.role === MemberRole.NOT_APPROVED) {
message = this.$t(
"The membership request from {profile} was rejected",
{
group: displayName(oldMember.actor),
}
) as string;
successMessage = "The member was approved";
} else {
successMessage = "The member role was updated to simple member";
}
this.$notifier.success(message);
} catch (error: any) {
console.error(error);
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
this.$notifier.error(error.graphQLErrors[0].message);
}
}
break;
default:
successMessage = "The member role was updated";
}
notifier?.success(t(successMessage));
});
promoteMember(member: IMember): void {
if (!member.id) return;
if (member.role === MemberRole.MODERATOR) {
this.updateMember(member, MemberRole.ADMINISTRATOR);
}
if (member.role === MemberRole.MEMBER) {
this.updateMember(member, MemberRole.MODERATOR);
}
onUpdateMutationError((error) => {
console.error(error);
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
notifier?.error(error.graphQLErrors[0].message);
}
});
demoteMember(member: IMember): void {
if (!member.id) return;
if (member.role === MemberRole.MODERATOR) {
this.updateMember(member, MemberRole.MEMBER);
}
if (member.role === MemberRole.ADMINISTRATOR) {
this.updateMember(member, MemberRole.MODERATOR);
}
}
const updateMember = async (
oldMember: IMember,
role: MemberRole
): Promise<void> => {
updateMemberMutation({
memberId: oldMember.id as string,
role,
oldRole: oldMember.role,
});
};
async approveMember(member: IMember): Promise<void> {
try {
await this.$apollo.mutate<{ approveMember: IMember }>({
mutation: APPROVE_MEMBER,
variables: { memberId: member.id },
});
this.$notifier.success(this.$t("The member was approved") as string);
} catch (error: any) {
console.error(error);
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
this.$notifier.error(error.graphQLErrors[0].message);
}
}
}
const isCurrentActorAGroupAdmin = computed((): boolean => {
return hasCurrentActorThisRole(MemberRole.ADMINISTRATOR);
});
rejectMember(member: IMember): void {
if (!member.id) return;
this.removeMember(member);
}
const hasCurrentActorThisRole = (givenRole: string | string[]): boolean => {
const roles = Array.isArray(givenRole) ? givenRole : [givenRole];
return (
personMemberships.value?.total > 0 &&
roles.includes(personMemberships.value?.elements[0].role)
);
};
async updateMember(oldMember: IMember, role: MemberRole): Promise<void> {
try {
await this.$apollo.mutate<{ updateMember: IMember }>({
mutation: UPDATE_MEMBER,
variables: {
memberId: oldMember.id,
role,
},
refetchQueries: [
{
query: FETCH_GROUP,
variables: { name: this.$route.params.preferredUsername },
},
],
});
let successMessage;
switch (role) {
case MemberRole.MODERATOR:
successMessage = "The member role was updated to moderator";
break;
case MemberRole.ADMINISTRATOR:
successMessage = "The member role was updated to administrator";
break;
case MemberRole.MEMBER:
if (oldMember.role === MemberRole.NOT_APPROVED) {
successMessage = "The member was approved";
} else {
successMessage = "The member role was updated to simple member";
}
break;
default:
successMessage = "The member role was updated";
}
this.$notifier.success(this.$t(successMessage) as string);
} catch (error: any) {
console.error(error);
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
this.$notifier.error(error.graphQLErrors[0].message);
}
}
}
}
const personMemberships = computed(
() => person.value?.memberships ?? { total: 0, elements: [] }
);
const { person } = usePersonStatusGroup(props.preferredUsername);
</script>

View File

@@ -20,42 +20,44 @@
},
]"
/>
<b-loading :active="$apollo.loading" />
<o-loading :active="loading" />
<section
class="container section"
class="container mx-auto section"
v-if="group && isCurrentActorAGroupAdmin"
>
<form @submit.prevent="updateGroup">
<b-field :label="$t('Group name')" label-for="group-settings-name">
<b-input v-model="editableGroup.name" id="group-settings-name" />
</b-field>
<b-field :label="$t('Group short description')">
<editor
<o-field :label="$t('Group name')" label-for="group-settings-name">
<o-input v-model="editableGroup.name" id="group-settings-name" />
</o-field>
<o-field :label="$t('Group short description')">
<Editor
mode="basic"
v-model="editableGroup.summary"
:maxSize="500"
:aria-label="$t('Group description body')"
/></b-field>
<b-field :label="$t('Avatar')">
v-if="currentActor"
:currentActor="currentActor"
/></o-field>
<o-field :label="$t('Avatar')">
<picture-upload
:textFallback="$t('Avatar')"
v-model="avatarFile"
:defaultImage="group.avatar"
:maxSize="avatarMaxSize"
/>
</b-field>
</o-field>
<b-field :label="$t('Banner')">
<o-field :label="$t('Banner')">
<picture-upload
:textFallback="$t('Banner')"
v-model="bannerFile"
:defaultImage="group.banner"
:maxSize="bannerMaxSize"
/>
</b-field>
</o-field>
<p class="label">{{ $t("Group visibility") }}</p>
<div class="field">
<b-radio
<o-radio
v-model="editableGroup.visibility"
name="groupVisibility"
:native-value="GroupVisibility.PUBLIC"
@@ -66,10 +68,10 @@
"The group will be publicly listed in search results and may be suggested in the explore section. Only public informations will be shown on it's page."
)
}}</small>
</b-radio>
</o-radio>
</div>
<div class="field">
<b-radio
<o-radio
v-model="editableGroup.visibility"
name="groupVisibility"
:native-value="GroupVisibility.UNLISTED"
@@ -79,31 +81,31 @@
"You'll need to transmit the group URL so people may access the group's profile. The group won't be findable in Mobilizon's search or regular search engines."
)
}}</small>
</b-radio>
<p class="control">
</o-radio>
<p class="pl-6">
<code>{{ group.url }}</code>
<b-tooltip
<o-tooltip
v-if="canShowCopyButton"
:label="$t('URL copied to clipboard')"
:active="showCopiedTooltip"
always
type="is-success"
position="is-left"
variant="success"
position="left"
>
<b-button
type="is-primary"
<o-button
variant="primary"
icon-right="content-paste"
native-type="button"
@click="copyURL"
@keyup.enter="copyURL"
/>
</b-tooltip>
</o-tooltip>
</p>
</div>
<p class="label">{{ $t("New members") }}</p>
<div class="field">
<b-radio
<o-radio
v-model="editableGroup.openness"
name="groupOpenness"
:native-value="Openness.OPEN"
@@ -114,10 +116,10 @@
"Anyone wanting to be a member from your group will be able to from your group page."
)
}}</small>
</b-radio>
</o-radio>
</div>
<div class="field">
<b-radio
<o-radio
v-model="editableGroup.openness"
name="groupOpenness"
:native-value="Openness.MODERATED"
@@ -127,10 +129,10 @@
"Anyone can request being a member, but an administrator needs to approve the membership."
)
}}</small>
</b-radio>
</o-radio>
</div>
<div class="field">
<b-radio
<o-radio
v-model="editableGroup.openness"
name="groupOpenness"
:native-value="Openness.INVITE_ONLY"
@@ -140,17 +142,17 @@
"The only way for your group to get new members is if an admininistrator invites them."
)
}}</small>
</b-radio>
</o-radio>
</div>
<b-field
<o-field
:label="$t('Followers')"
:message="$t('Followers will receive new public events and posts.')"
>
<b-checkbox v-model="editableGroup.manuallyApprovesFollowers">
<o-checkbox v-model="editableGroup.manuallyApprovesFollowers">
{{ $t("Manually approve new followers") }}
</b-checkbox>
</b-field>
</o-checkbox>
</o-field>
<full-address-auto-complete
:label="$t('Group address')"
@@ -158,229 +160,265 @@
:hideMap="true"
/>
<div class="buttons">
<b-button native-type="submit" type="is-primary">{{
<div class="flex flex-wrap gap-2 my-2">
<o-button native-type="submit" variant="primary">{{
$t("Update group")
}}</b-button>
<b-button @click="confirmDeleteGroup" type="is-danger">{{
}}</o-button>
<o-button @click="confirmDeleteGroup" variant="danger">{{
$t("Delete group")
}}</b-button>
}}</o-button>
</div>
</form>
<b-message type="is-danger" v-for="(value, index) in errors" :key="index">
<o-notification
variant="danger"
v-for="(value, index) in errors"
:key="index"
>
{{ value }}
</b-message>
</o-notification>
</section>
<b-message v-else-if="!$apollo.loading">
<o-notification v-else-if="!loading">
{{ $t("You are not an administrator for this group.") }}
</b-message>
</o-notification>
</div>
</template>
<script lang="ts">
import { Component, Watch } from "vue-property-decorator";
<script lang="ts" setup>
import FullAddressAutoComplete from "@/components/Event/FullAddressAutoComplete.vue";
import PictureUpload from "@/components/PictureUpload.vue";
import { mixins } from "vue-class-component";
import GroupMixin from "@/mixins/group";
import { GroupVisibility, Openness } from "@/types/enums";
import { UPDATE_GROUP } from "../../graphql/group";
import {
Group,
IGroup,
usernameWithDomain,
displayName,
} from "../../types/actor";
import { Address, IAddress } from "../../types/address.model";
import { CONFIG } from "@/graphql/config";
import { IConfig } from "@/types/config.model";
import { GroupVisibility, MemberRole, Openness } from "@/types/enums";
import { Group, IGroup, usernameWithDomain, displayName } from "@/types/actor";
import { Address, IAddress } from "@/types/address.model";
import { ServerParseError } from "@apollo/client/link/http";
import { ErrorResponse } from "@apollo/client/link/error";
import RouteName from "@/router/name";
import { buildFileFromIMedia } from "@/utils/image";
import { useAvatarMaxSize, useBannerMaxSize } from "@/composition/config";
import { useI18n } from "vue-i18n";
import { computed, ref, watch, defineAsyncComponent, inject } from "vue";
import { useGroup, useUpdateGroup } from "@/composition/apollo/group";
import {
useCurrentActorClient,
usePersonStatusGroup,
} from "@/composition/apollo/actor";
import { DELETE_GROUP } from "@/graphql/group";
import { useMutation } from "@vue/apollo-composable";
import { useRouter } from "vue-router";
import { Dialog } from "@/plugins/dialog";
import { useHead } from "@vueuse/head";
import { Notifier } from "@/plugins/notifier";
@Component({
components: {
FullAddressAutoComplete,
PictureUpload,
editor: () => import("../../components/Editor.vue"),
},
apollo: {
config: CONFIG,
},
metaInfo() {
return {
title: this.$t("Group settings") as string,
};
},
})
export default class GroupSettings extends mixins(GroupMixin) {
RouteName = RouteName;
const Editor = defineAsyncComponent(() => import("@/components/Editor.vue"));
config!: IConfig;
const props = defineProps<{ preferredUsername: string }>();
errors: string[] = [];
const { currentActor } = useCurrentActorClient();
avatarFile: File | null = null;
const { group, loading } = useGroup(props.preferredUsername);
bannerFile: File | null = null;
const { t } = useI18n({ useScope: "global" });
usernameWithDomain = usernameWithDomain;
useHead({
title: computed(() => t("Group settings")),
});
displayName = displayName;
const notifier = inject<Notifier>("notifier");
GroupVisibility = GroupVisibility;
const avatarFile = ref<File | null>(null);
const bannerFile = ref<File | null>(null);
Openness = Openness;
const errors = ref<string[]>([]);
showCopiedTooltip = false;
const showCopiedTooltip = ref(false);
editableGroup: IGroup = new Group();
const editableGroup = ref<IGroup>(new Group());
async updateGroup(): Promise<void> {
try {
const variables = this.buildVariables();
await this.$apollo.mutate<{ updateGroup: IGroup }>({
mutation: UPDATE_GROUP,
variables,
});
this.$notifier.success(this.$t("Group settings saved") as string);
} catch (err: any) {
this.handleError(err);
const updateGroup = async (): Promise<void> => {
const variables = buildVariables();
const { onDone, onError } = useUpdateGroup(variables);
onDone(() => {
notifier?.success(t("Group settings saved") as string);
});
onError((err) => {
handleError(err as unknown as ErrorResponse);
});
};
const copyURL = async (): Promise<void> => {
await window.navigator.clipboard.writeText(group.value?.url ?? "");
showCopiedTooltip.value = true;
setTimeout(() => {
showCopiedTooltip.value = false;
}, 2000);
};
watch(group, async (oldGroup: IGroup, newGroup: IGroup) => {
try {
if (
oldGroup?.avatar !== undefined &&
oldGroup?.avatar !== newGroup?.avatar
) {
avatarFile.value = await buildFileFromIMedia(group.value?.avatar);
}
if (
oldGroup?.banner !== undefined &&
oldGroup?.banner !== newGroup?.banner
) {
bannerFile.value = await buildFileFromIMedia(group.value?.banner);
}
} catch (e) {
// Catch errors while building media
console.error(e);
}
async copyURL(): Promise<void> {
await window.navigator.clipboard.writeText(this.group.url);
this.showCopiedTooltip = true;
setTimeout(() => {
this.showCopiedTooltip = false;
}, 2000);
editableGroup.value = { ...group.value };
});
const buildVariables = () => {
let avatarObj = {};
let bannerObj = {};
const variables = { ...editableGroup.value };
let physicalAddress;
if (variables.physicalAddress) {
physicalAddress = { ...variables.physicalAddress };
} else {
physicalAddress = variables.physicalAddress;
}
@Watch("group")
async watchUpdateGroup(oldGroup: IGroup, newGroup: IGroup): Promise<void> {
try {
if (
oldGroup?.avatar !== undefined &&
oldGroup?.avatar !== newGroup?.avatar
) {
this.avatarFile = await buildFileFromIMedia(this.group.avatar);
}
if (
oldGroup?.banner !== undefined &&
oldGroup?.banner !== newGroup?.banner
) {
this.bannerFile = await buildFileFromIMedia(this.group.banner);
}
} catch (e) {
// Catch errors while building media
console.error(e);
}
this.editableGroup = { ...this.group };
}
private buildVariables() {
let avatarObj = {};
let bannerObj = {};
const variables = { ...this.editableGroup };
let physicalAddress;
if (variables.physicalAddress) {
physicalAddress = { ...variables.physicalAddress };
} else {
physicalAddress = variables.physicalAddress;
}
// eslint-disable-next-line
// @ts-ignore
if (variables.__typename) {
// eslint-disable-next-line
// @ts-ignore
if (variables.__typename) {
// eslint-disable-next-line
// @ts-ignore
delete variables.__typename;
}
delete variables.__typename;
}
// eslint-disable-next-line
// @ts-ignore
if (physicalAddress && physicalAddress.__typename) {
// eslint-disable-next-line
// @ts-ignore
if (physicalAddress && physicalAddress.__typename) {
// eslint-disable-next-line
// @ts-ignore
delete physicalAddress.__typename;
}
delete variables.avatar;
delete variables.banner;
delete physicalAddress.__typename;
}
delete variables.avatar;
delete variables.banner;
if (this.avatarFile) {
avatarObj = {
avatar: {
media: {
name: this.avatarFile.name,
alt: `${this.editableGroup.preferredUsername}'s avatar`,
file: this.avatarFile,
},
if (avatarFile.value) {
avatarObj = {
avatar: {
media: {
name: avatarFile.value?.name,
alt: `${editableGroup.value?.preferredUsername}'s avatar`,
file: avatarFile,
},
};
}
if (this.bannerFile) {
bannerObj = {
banner: {
media: {
name: this.bannerFile.name,
alt: `${this.editableGroup.preferredUsername}'s banner`,
file: this.bannerFile,
},
},
};
}
return {
id: this.group.id,
name: this.editableGroup.name,
summary: this.editableGroup.summary,
visibility: this.editableGroup.visibility,
openness: this.editableGroup.openness,
manuallyApprovesFollowers: this.editableGroup.manuallyApprovesFollowers,
physicalAddress,
...avatarObj,
...bannerObj,
},
};
}
// eslint-disable-next-line class-methods-use-this
get canShowCopyButton(): boolean {
return window.isSecureContext;
if (bannerFile.value) {
bannerObj = {
banner: {
media: {
name: bannerFile.value?.name,
alt: `${editableGroup.value?.preferredUsername}'s banner`,
file: bannerFile,
},
},
};
}
return {
id: group.value?.id,
name: editableGroup.value?.name,
summary: editableGroup.value?.summary,
visibility: editableGroup.value?.visibility,
openness: editableGroup.value?.openness,
manuallyApprovesFollowers: editableGroup.value?.manuallyApprovesFollowers,
physicalAddress,
...avatarObj,
...bannerObj,
};
};
get currentAddress(): IAddress {
return new Address(this.editableGroup.physicalAddress);
}
const canShowCopyButton = computed((): boolean => {
return window.isSecureContext;
});
set currentAddress(address: IAddress) {
this.editableGroup.physicalAddress = address;
}
const currentAddress = computed({
get(): IAddress {
return new Address(editableGroup.value?.physicalAddress);
},
set(address: IAddress) {
editableGroup.value.physicalAddress = address;
},
});
get avatarMaxSize(): number | undefined {
return this?.config?.uploadLimits?.avatar;
}
const avatarMaxSize = useAvatarMaxSize();
const bannerMaxSize = useBannerMaxSize();
get bannerMaxSize(): number | undefined {
return this?.config?.uploadLimits?.banner;
}
const handleError = (err: ErrorResponse) => {
if (err?.networkError?.name === "ServerParseError") {
const error = err?.networkError as ServerParseError;
private handleError(err: ErrorResponse) {
if (err?.networkError?.name === "ServerParseError") {
const error = err?.networkError as ServerParseError;
if (error?.response?.status === 413) {
this.errors.push(
this.$t(
"Unable to create the group. One of the pictures may be too heavy."
) as string
);
}
if (error?.response?.status === 413) {
errors.value.push(
t(
"Unable to create the group. One of the pictures may be too heavy."
) as string
);
}
this.errors.push(
...(err.graphQLErrors || []).map(
({ message }: { message: string }) => message
)
);
}
}
errors.value.push(
...(err.graphQLErrors || []).map(
({ message }: { message: string }) => message
)
);
};
const isCurrentActorAGroupAdmin = computed((): boolean => {
return hasCurrentActorThisRole(MemberRole.ADMINISTRATOR);
});
const hasCurrentActorThisRole = (givenRole: string | string[]): boolean => {
const roles = Array.isArray(givenRole) ? givenRole : [givenRole];
return (
personMemberships.value?.total > 0 &&
roles.includes(personMemberships.value?.elements[0].role)
);
};
const personMemberships = computed(
() => person.value?.memberships ?? { total: 0, elements: [] }
);
const { person } = usePersonStatusGroup(props.preferredUsername);
const dialog = inject<Dialog>("dialog");
const confirmDeleteGroup = (): void => {
console.debug("confirm delete group", dialog);
dialog?.confirm({
title: t("Delete group"),
message: t(
"Are you sure you want to <b>completely delete</b> this group? All members - including remote ones - will be notified and removed from the group, and <b>all of the group data (events, posts, discussions, todos…) will be irretrievably destroyed</b>."
),
confirmText: t("Delete group"),
cancelText: t("Cancel"),
type: "danger",
hasIcon: true,
onConfirm: () =>
deleteGroupMutation({
groupId: group.value?.id,
}),
});
};
const { mutate: deleteGroupMutation, onDone: deleteGroupDone } = useMutation<{
deleteGroup: IGroup;
}>(DELETE_GROUP);
const router = useRouter();
deleteGroupDone(() => {
router.push({ name: RouteName.MY_GROUPS });
});
</script>

View File

@@ -1,5 +1,5 @@
<template>
<section class="section container">
<section class="container mx-auto">
<h1 class="title">{{ $t("My groups") }}</h1>
<p>
{{
@@ -8,14 +8,15 @@
)
}}
</p>
<div class="buttons" v-if="!hideCreateGroupButton">
<router-link
class="button is-primary"
<div class="flex my-3" v-if="!hideCreateGroupButton">
<o-button
tag="router-link"
variant="primary"
:to="{ name: RouteName.CREATE_GROUP }"
>{{ $t("Create group") }}</router-link
>{{ $t("Create group") }}</o-button
>
</div>
<b-loading :active.sync="$apollo.loading"></b-loading>
<o-loading v-model:active="loading"></o-loading>
<invitations
:invitations="invitations"
@accept-invitation="acceptInvitation"
@@ -27,10 +28,11 @@
v-for="member in memberships"
:key="member.id"
:member="member"
@leave="leaveGroup(member.parent)"
@leave="leaveGroup({ groupId: member.parent.id })"
/>
<b-pagination
<o-pagination
:total="membershipsPages.total"
v-show="membershipsPages.total > limit"
v-model="page"
:per-page="limit"
:aria-next-label="$t('Next page')"
@@ -38,30 +40,36 @@
:aria-page-label="$t('Page')"
:aria-current-label="$t('Current page')"
>
</b-pagination>
</o-pagination>
</section>
<section
class="has-text-centered not-found"
v-if="memberships.length === 0 && !$apollo.loading"
class="text-center not-found"
v-if="memberships.length === 0 && !loading"
>
<div class="columns is-vertical is-centered">
<div class="column is-three-quarters">
<div class="">
<div class="">
<div class="img-container" :class="{ webp: supportsWebPFormat }" />
<div class="content has-text-centered">
<div class="text-center">
<p>
{{ $t("You are not part of any group.") }}
<i18n path="Do you wish to {create_group} or {explore_groups}?">
<router-link
:to="{ name: RouteName.CREATE_GROUP }"
slot="create_group"
>{{ $t("create a group") }}</router-link
>
<router-link
:to="{ name: RouteName.SEARCH }"
slot="explore_groups"
>{{ $t("explore the groups") }}</router-link
>
</i18n>
<i18n-t
keypath="Do you wish to {create_group} or {explore_groups}?"
>
<template #create_group>
<o-button
tag="router-link"
:to="{ name: RouteName.CREATE_GROUP }"
>{{ $t("create a group") }}</o-button
>
</template>
<template #explore_groups>
<o-button
tag="router-link"
:to="{ name: RouteName.SEARCH }"
>{{ $t("explore the groups") }}</o-button
>
</template>
</i18n-t>
</p>
</div>
</div>
@@ -70,137 +78,117 @@
</section>
</template>
<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
import { CONFIG } from "@/graphql/config";
import { IConfig } from "@/types/config.model";
<script lang="ts" setup>
import { LOGGED_USER_MEMBERSHIPS } from "@/graphql/actor";
import { LEAVE_GROUP } from "@/graphql/group";
import GroupMemberCard from "@/components/Group/GroupMemberCard.vue";
import Invitations from "@/components/Group/Invitations.vue";
import { Paginate } from "@/types/paginate";
import { IGroup, usernameWithDomain } from "@/types/actor";
import { Route } from "vue-router";
import { IMember } from "@/types/actor/member.model";
import { MemberRole } from "@/types/enums";
import { supportsWebPFormat } from "@/utils/support";
import RouteName from "../../router/name";
import { useRestrictions } from "@/composition/apollo/config";
import { useMutation, useQuery } from "@vue/apollo-composable";
import { IUser } from "@/types/current-user.model";
import { integerTransformer, useRouteQuery } from "vue-use-route-query";
import { computed, inject } from "vue";
import { useHead } from "@vueuse/head";
import { useI18n } from "vue-i18n";
import { useRouter } from "vue-router";
import { Notifier } from "@/plugins/notifier";
@Component({
components: {
GroupMemberCard,
Invitations,
},
apollo: {
config: {
query: CONFIG,
},
membershipsPages: {
query: LOGGED_USER_MEMBERSHIPS,
fetchPolicy: "cache-and-network",
variables() {
return {
page: this.page,
limit: this.limit,
};
},
update: (data) => data.loggedUser.memberships,
},
},
metaInfo() {
return {
title: this.$t("My groups") as string,
titleTemplate: "%s | Mobilizon",
};
},
})
export default class MyGroups extends Vue {
membershipsPages!: Paginate<IMember>;
const page = useRouteQuery("page", 1, integerTransformer);
const limit = 10;
RouteName = RouteName;
const { result: membershipsResult, loading } = useQuery<{
loggedUser: Pick<IUser, "memberships">;
}>(LOGGED_USER_MEMBERSHIPS, () => ({
page: page.value,
limit,
}));
config!: IConfig;
page = 1;
limit = 10;
supportsWebPFormat = supportsWebPFormat;
acceptInvitation(member: IMember): Promise<Route> {
return this.$router.push({
name: RouteName.GROUP,
params: { preferredUsername: usernameWithDomain(member.parent) },
});
}
rejectInvitation({ id: memberId }: { id: string }): void {
const index = this.membershipsPages.elements.findIndex(
(membership) =>
membership.role === MemberRole.INVITED && membership.id === memberId
);
if (index > -1) {
this.membershipsPages.elements.splice(index, 1);
this.membershipsPages.total -= 1;
const membershipsPages = computed(
() =>
membershipsResult.value?.loggedUser?.memberships ?? {
total: 0,
elements: [],
}
}
);
async leaveGroup(group: IGroup): Promise<void> {
try {
const { page, limit } = this;
await this.$apollo.mutate({
mutation: LEAVE_GROUP,
const { t } = useI18n({ useScope: "global" });
useHead({
title: t("My groups"),
});
const notifier = inject<Notifier>("notifier");
const router = useRouter();
const acceptInvitation = (member: IMember) => {
return router.push({
name: RouteName.GROUP,
params: { preferredUsername: usernameWithDomain(member.parent) },
});
};
const rejectInvitation = ({ id: memberId }: { id: string }) => {
const index = membershipsPages.value.elements.findIndex(
(membership) =>
membership.role === MemberRole.INVITED && membership.id === memberId
);
if (index > -1) {
membershipsPages.value.elements.splice(index, 1);
membershipsPages.value.total -= 1;
}
};
const { mutate: leaveGroup, onError: onLeaveGroupError } = useMutation(
LEAVE_GROUP,
() => ({
refetchQueries: [
{
query: LOGGED_USER_MEMBERSHIPS,
variables: {
groupId: group.id,
page,
limit,
},
refetchQueries: [
{
query: LOGGED_USER_MEMBERSHIPS,
variables: {
page,
limit,
},
},
],
});
} catch (error: any) {
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
this.$notifier.error(error.graphQLErrors[0].message);
}
}
}
},
],
})
);
get invitations(): IMember[] {
if (!this.membershipsPages) return [];
return this.membershipsPages.elements.filter(
(member: IMember) => member.role === MemberRole.INVITED
);
onLeaveGroupError((error) => {
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
notifier?.error(error.graphQLErrors[0].message);
}
});
get memberships(): IMember[] {
if (!this.membershipsPages) return [];
return this.membershipsPages.elements.filter(
(member: IMember) =>
![MemberRole.INVITED, MemberRole.REJECTED].includes(member.role)
);
}
const invitations = computed((): IMember[] => {
if (!membershipsPages.value) return [];
return membershipsPages.value.elements.filter(
(member: IMember) => member.role === MemberRole.INVITED
);
});
get hideCreateGroupButton(): boolean {
return !!this.config?.restrictions?.onlyAdminCanCreateGroups;
}
}
const memberships = computed((): IMember[] => {
if (!membershipsPages.value) return [];
return membershipsPages.value.elements.filter(
(member: IMember) =>
![MemberRole.INVITED, MemberRole.REJECTED].includes(member.role)
);
});
const { restrictions } = useRestrictions();
const hideCreateGroupButton = computed((): boolean => {
return restrictions.value?.onlyAdminCanCreateGroups === true;
});
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style lang="scss" scoped>
main > .container {
background: $white;
& > h1 {
margin: 10px auto 5px;
}
}
.participation {
margin: 1rem auto;
}

View File

@@ -1,53 +1,47 @@
<template>
<aside class="section container">
<h1 class="title">{{ $t("Settings") }}</h1>
<div class="columns">
<aside class="column is-one-quarter-desktop">
<div class="container mx-auto">
<h1 class="">{{ t("Settings") }}</h1>
<div class="flex flex-wrap gap-2">
<aside class="max-w-xs flex-1">
<ul>
<SettingMenuSection
:title="$t('Settings')"
:title="t('Settings')"
:to="{ name: RouteName.GROUP_SETTINGS }"
>
<SettingMenuItem
:title="this.$t('Public')"
:title="t('Public')"
:to="{ name: RouteName.GROUP_PUBLIC_SETTINGS }"
/>
<SettingMenuItem
:title="this.$t('Members')"
:title="t('Members')"
:to="{ name: RouteName.GROUP_MEMBERS_SETTINGS }"
/>
<SettingMenuItem
:title="this.$t('Followers')"
:title="t('Followers')"
:to="{ name: RouteName.GROUP_FOLLOWERS_SETTINGS }"
/>
</SettingMenuSection>
</ul>
</aside>
<div class="column">
<div class="flex-1">
<router-view />
</div>
</div>
</aside>
</div>
</template>
<script lang="ts">
import { Component } from "vue-property-decorator";
import { mixins } from "vue-class-component";
import GroupMixin from "@/mixins/group";
import RouteName from "../../router/name";
import SettingMenuSection from "../../components/Settings/SettingMenuSection.vue";
import SettingMenuItem from "../../components/Settings/SettingMenuItem.vue";
<script lang="ts" setup>
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import { useHead } from "@vueuse/head";
import RouteName from "@/router/name";
import SettingMenuSection from "@/components/Settings/SettingMenuSection.vue";
import SettingMenuItem from "@/components/Settings/SettingMenuItem.vue";
@Component({
components: { SettingMenuSection, SettingMenuItem },
metaInfo() {
return {
title: this.$t("Group settings") as string,
};
},
})
export default class Settings extends mixins(GroupMixin) {
RouteName = RouteName;
}
const { t } = useI18n({ useScope: "global" });
useHead({
title: computed(() => t("Group settings")),
});
</script>
<style lang="scss" scoped>

View File

@@ -1,5 +1,5 @@
<template>
<div class="container section">
<div class="container mx-auto section">
<breadcrumbs-nav
v-if="group"
:links="[
@@ -17,87 +17,69 @@
/>
<section class="timeline">
<b-field>
<b-radio-button v-model="activityType" :native-value="undefined">
<b-icon icon="timeline-text"></b-icon>
{{ $t("All activities") }}</b-radio-button
<o-field>
<o-radio v-model="activityType" :native-value="undefined">
<TimelineText />
{{ $t("All activities") }}</o-radio
>
<b-radio-button
v-model="activityType"
:native-value="ActivityType.MEMBER"
<o-radio v-model="activityType" :native-value="ActivityType.MEMBER">
<o-icon icon="account-multiple-plus"></o-icon>
{{ $t("Members") }}</o-radio
>
<b-icon icon="account-multiple-plus"></b-icon>
{{ $t("Members") }}</b-radio-button
<o-radio v-model="activityType" :native-value="ActivityType.GROUP">
<o-icon icon="cog"></o-icon>
{{ $t("Settings") }}</o-radio
>
<b-radio-button
v-model="activityType"
:native-value="ActivityType.GROUP"
<o-radio v-model="activityType" :native-value="ActivityType.EVENT">
<o-icon icon="calendar"></o-icon>
{{ $t("Events") }}</o-radio
>
<b-icon icon="cog"></b-icon>
{{ $t("Settings") }}</b-radio-button
<o-radio v-model="activityType" :native-value="ActivityType.POST">
<o-icon icon="bullhorn"></o-icon>
{{ $t("Posts") }}</o-radio
>
<b-radio-button
v-model="activityType"
:native-value="ActivityType.EVENT"
<o-radio v-model="activityType" :native-value="ActivityType.DISCUSSION">
<o-icon icon="chat"></o-icon>
{{ $t("Discussions") }}</o-radio
>
<b-icon icon="calendar"></b-icon>
{{ $t("Events") }}</b-radio-button
<o-radio v-model="activityType" :native-value="ActivityType.RESOURCE">
<o-icon icon="link"></o-icon>
{{ $t("Resources") }}</o-radio
>
<b-radio-button
v-model="activityType"
:native-value="ActivityType.POST"
</o-field>
<o-field>
<o-radio v-model="activityAuthor" :native-value="undefined">
<TimelineText />
{{ $t("All activities") }}</o-radio
>
<b-icon icon="bullhorn"></b-icon>
{{ $t("Posts") }}</b-radio-button
>
<b-radio-button
v-model="activityType"
:native-value="ActivityType.DISCUSSION"
>
<b-icon icon="chat"></b-icon>
{{ $t("Discussions") }}</b-radio-button
>
<b-radio-button
v-model="activityType"
:native-value="ActivityType.RESOURCE"
>
<b-icon icon="link"></b-icon>
{{ $t("Resources") }}</b-radio-button
>
</b-field>
<b-field>
<b-radio-button v-model="activityAuthor" :native-value="undefined">
<b-icon icon="timeline-text"></b-icon>
{{ $t("All activities") }}</b-radio-button
>
<b-radio-button
<o-radio
v-model="activityAuthor"
:native-value="ActivityAuthorFilter.SELF"
>
<b-icon icon="account"></b-icon>
{{ $t("From yourself") }}</b-radio-button
<o-icon icon="account"></o-icon>
{{ $t("From yourself") }}</o-radio
>
<b-radio-button
<o-radio
v-model="activityAuthor"
:native-value="ActivityAuthorFilter.BY"
>
<b-icon icon="account-multiple"></b-icon>
{{ $t("By others") }}</b-radio-button
<o-icon icon="account-multiple"></o-icon>
{{ $t("By others") }}</o-radio
>
</b-field>
</o-field>
<transition-group name="timeline-list" tag="div">
<div
class="day"
v-for="[date, activityItems] in Object.entries(activities)"
:key="date"
>
<b-skeleton
<o-skeleton
v-if="date.search(/skeleton/) !== -1"
width="300px"
height="48px"
/>
<h2 class="is-size-3 has-text-weight-bold" v-else-if="isToday(date)">
<span v-tooltip="$options.filters.formatDateString(date)">
<span v-tooltip="formatDateString(date)">
{{ $t("Today") }}
</span>
</h2>
@@ -105,12 +87,12 @@
class="is-size-3 has-text-weight-bold"
v-else-if="isYesterday(date)"
>
<span v-tooltip="$options.filters.formatDateString(date)">{{
<span v-tooltip="formatDateString(date)">{{
$t("Yesterday")
}}</span>
</h2>
<h2 v-else class="is-size-3 has-text-weight-bold">
{{ date | formatDateString }}
{{ formatDateString(date) }}
</h2>
<ul>
<li v-for="activityItem in activityItems" :key="activityItem.id">
@@ -126,7 +108,7 @@
<empty-content
icon="timeline-text"
v-if="
!$apollo.loading &&
!loading &&
activity.elements.length > 0 &&
activity.elements.length >= activity.total
"
@@ -134,7 +116,7 @@
{{ $t("No more activity to display.") }}
</empty-content>
<empty-content
v-if="!$apollo.loading && activity.total === 0"
v-if="!loading && activity.total === 0"
icon="timeline-text"
>
{{
@@ -144,24 +126,30 @@
}}
</empty-content>
<observer @intersect="loadMore" />
<b-button
<o-button
v-if="activity.elements.length < activity.total"
@click="loadMore"
>{{ $t("Load more activities") }}</b-button
>{{ $t("Load more activities") }}</o-button
>
</section>
</div>
</template>
<script lang="ts">
<script lang="ts" setup>
import { GROUP_TIMELINE } from "@/graphql/group";
import { IGroup, usernameWithDomain, displayName } from "@/types/actor";
import { ActivityType } from "@/types/enums";
import { Paginate } from "@/types/paginate";
import { Component, Prop, Vue } from "vue-property-decorator";
import { IActivity } from "../../types/activity.model";
import Observer from "../../components/Utils/Observer.vue";
import SkeletonActivityItem from "../../components/Activity/SkeletonActivityItem.vue";
import RouteName from "../../router/name";
import TimelineText from "vue-material-design-icons/TimelineText.vue";
import { useQuery } from "@vue/apollo-composable";
import { useHead } from "@vueuse/head";
import { enumTransformer, useRouteQuery } from "vue-use-route-query";
import { computed, defineAsyncComponent, ref } from "vue";
import { useI18n } from "vue-i18n";
import { formatDateString } from "@/filters/datetime";
const PAGINATION_LIMIT = 25;
const SKELETON_DAY_ITEMS = 2;
@@ -173,223 +161,182 @@ enum ActivityAuthorFilter {
BY = "BY",
}
export type ActivityFilter = ActivityType | ActivityAuthorFilter | null;
type ActivityFilter = ActivityType | ActivityAuthorFilter | null;
@Component({
apollo: {
group: {
query: GROUP_TIMELINE,
fetchPolicy: "cache-and-network",
variables() {
return {
preferredUsername: this.preferredUsername,
page: 1,
limit: PAGINATION_LIMIT,
type: this.activityType,
author: this.activityAuthor,
};
},
},
},
components: {
Observer,
SkeletonActivityItem,
"event-activity-item": () =>
import("../../components/Activity/EventActivityItem.vue"),
"post-activity-item": () =>
import("../../components/Activity/PostActivityItem.vue"),
"member-activity-item": () =>
import("../../components/Activity/MemberActivityItem.vue"),
"resource-activity-item": () =>
import("../../components/Activity/ResourceActivityItem.vue"),
"discussion-activity-item": () =>
import("../../components/Activity/DiscussionActivityItem.vue"),
"group-activity-item": () =>
import("../../components/Activity/GroupActivityItem.vue"),
"empty-content": () => import("../../components/Utils/EmptyContent.vue"),
},
metaInfo() {
return {
title: this.$t("{group} activity timeline", {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
group: this.group?.name,
}) as string,
titleTemplate: "%s | Mobilizon",
};
},
})
export default class Timeline extends Vue {
@Prop({ required: true, type: String }) preferredUsername!: string;
const props = defineProps<{ preferredUsername: string }>();
group!: IGroup;
const { t } = useI18n({ useScope: "global" });
RouteName = RouteName;
const EventActivityItem = defineAsyncComponent(
() => import("../../components/Activity/EventActivityItem.vue")
);
const PostActivityItem = defineAsyncComponent(
() => import("../../components/Activity/PostActivityItem.vue")
);
const MemberActivityItem = defineAsyncComponent(
() => import("../../components/Activity/MemberActivityItem.vue")
);
const ResourceActivityItem = defineAsyncComponent(
() => import("../../components/Activity/ResourceActivityItem.vue")
);
const DiscussionActivityItem = defineAsyncComponent(
() => import("../../components/Activity/DiscussionActivityItem.vue")
);
const GroupActivityItem = defineAsyncComponent(
() => import("../../components/Activity/GroupActivityItem.vue")
);
const EmptyContent = defineAsyncComponent(
() => import("../../components/Utils/EmptyContent.vue")
);
usernameWithDomain = usernameWithDomain;
const activityType = useRouteQuery(
"activityType",
undefined,
enumTransformer(ActivityType)
);
const activityAuthor = useRouteQuery(
"activityAuthor",
undefined,
enumTransformer(ActivityAuthorFilter)
);
displayName = displayName;
const page = ref(1);
ActivityType = ActivityType;
const {
result: groupTimelineResult,
fetchMore: fetchMoreActivities,
loading,
} = useQuery<{ group: IGroup }>(GROUP_TIMELINE, () => ({
preferredUsername: props.preferredUsername,
page: page.value,
limit: PAGINATION_LIMIT,
type: activityType.value,
author: activityAuthor.value,
}));
ActivityAuthorFilter = ActivityAuthorFilter;
const group = computed(() => groupTimelineResult.value?.group);
get activityType(): ActivityType | undefined {
if (this.$route?.query?.type) {
return this.$route?.query?.type as ActivityType;
}
return undefined;
useHead({
title: computed(() =>
t("{group} activity timeline", { group: group.value?.name })
),
});
const activity = computed((): Paginate<IActivitySkeleton> => {
if (group.value) {
return group.value.activity;
}
return {
total: 0,
elements: skeletons.value.map((skeleton) => ({
skeleton,
})),
};
});
set activityType(type: ActivityType | undefined) {
this.$router.push({
name: RouteName.TIMELINE,
params: {
preferredUsername: this.preferredUsername,
},
query: { ...this.$route.query, type },
});
const limit = PAGINATION_LIMIT;
const component = (type: ActivityType): any | undefined => {
switch (type) {
case ActivityType.EVENT:
return EventActivityItem;
case ActivityType.POST:
return PostActivityItem;
case ActivityType.MEMBER:
return MemberActivityItem;
case ActivityType.RESOURCE:
return ResourceActivityItem;
case ActivityType.DISCUSSION:
return DiscussionActivityItem;
case ActivityType.GROUP:
return GroupActivityItem;
}
};
get activityAuthor(): ActivityAuthorFilter | undefined {
if (this.$route?.query?.author) {
return this.$route?.query?.author as ActivityAuthorFilter;
}
return undefined;
}
set activityAuthor(author: ActivityAuthorFilter | undefined) {
this.$router.push({
name: RouteName.TIMELINE,
params: {
preferredUsername: this.preferredUsername,
},
query: { ...this.$route.query, author },
});
}
get activity(): Paginate<IActivitySkeleton> {
if (this.group) {
return this.group.activity;
}
return {
total: 0,
elements: this.skeletons.map((skeleton) => ({
skeleton,
})),
};
}
page = 1;
limit = PAGINATION_LIMIT;
component(type: ActivityType): string | undefined {
switch (type) {
case ActivityType.EVENT:
return "event-activity-item";
case ActivityType.POST:
return "post-activity-item";
case ActivityType.MEMBER:
return "member-activity-item";
case ActivityType.RESOURCE:
return "resource-activity-item";
case ActivityType.DISCUSSION:
return "discussion-activity-item";
case ActivityType.GROUP:
return "group-activity-item";
}
}
get skeletons(): string[] {
return [...Array(SKELETON_DAY_ITEMS)]
.map((_, i) => {
return [...Array(SKELETON_ITEMS_PER_DAY)].map((_a, j) => {
return `${i}-${j}`;
});
})
.flat();
}
async loadMore(): Promise<void> {
if (this.page * PAGINATION_LIMIT >= this.activity.total) {
return;
}
this.page++;
try {
await this.$apollo.queries.group.fetchMore({
variables: {
page: this.page,
limit: PAGINATION_LIMIT,
},
const skeletons = computed((): string[] => {
return [...Array(SKELETON_DAY_ITEMS)]
.map((_, i) => {
return [...Array(SKELETON_ITEMS_PER_DAY)].map((_a, j) => {
return `${i}-${j}`;
});
} catch (e) {
console.error(e);
}
}
})
.flat();
});
get activities(): Record<string, IActivitySkeleton[]> {
return this.activity.elements.reduce(
(acc: Record<string, IActivitySkeleton[]>, elem) => {
let key;
if (this.isIActivity(elem)) {
const insertedAt = new Date(elem.insertedAt);
insertedAt.setHours(0);
insertedAt.setMinutes(0);
insertedAt.setSeconds(0);
insertedAt.setMilliseconds(0);
key = insertedAt.toISOString();
} else {
key = `skeleton-${elem.skeleton.split("-")[0]}`;
}
const existing = acc[key];
if (existing) {
acc[key] = [...existing, ...[elem]];
} else {
acc[key] = [elem];
}
return acc;
const loadMore = async (): Promise<void> => {
if (page.value * PAGINATION_LIMIT >= activity.value.total) {
return;
}
page.value++;
try {
await fetchMoreActivities({
variables: {
page: page.value,
limit: PAGINATION_LIMIT,
},
{}
);
});
} catch (e) {
console.error(e);
}
};
isIActivity(object: IActivitySkeleton): object is IActivity {
return !("skeleton" in object);
}
const activities = computed((): Record<string, IActivitySkeleton[]> => {
return activity.value.elements.reduce(
(acc: Record<string, IActivitySkeleton[]>, elem) => {
let key;
if (isIActivity(elem)) {
const insertedAt = new Date(elem.insertedAt);
insertedAt.setHours(0);
insertedAt.setMinutes(0);
insertedAt.setSeconds(0);
insertedAt.setMilliseconds(0);
key = insertedAt.toISOString();
} else {
key = `skeleton-${elem.skeleton.split("-")[0]}`;
}
const existing = acc[key];
if (existing) {
acc[key] = [...existing, ...[elem]];
} else {
acc[key] = [elem];
}
return acc;
},
{}
);
});
getRandomInt(min: number, max: number): number {
min = Math.ceil(min);
max = Math.floor(max);
return Math.floor(Math.random() * (max - min) + min);
}
const isIActivity = (object: IActivitySkeleton): object is IActivity => {
return !("skeleton" in object);
};
const getRandomInt = (min: number, max: number): number => {
min = Math.ceil(min);
max = Math.floor(max);
return Math.floor(Math.random() * (max - min) + min);
};
isToday(dateString: string): boolean {
const now = new Date();
const date = new Date(dateString);
return (
now.getFullYear() === date.getFullYear() &&
now.getMonth() === date.getMonth() &&
now.getDate() === date.getDate()
);
}
const isToday = (dateString: string): boolean => {
const now = new Date();
const date = new Date(dateString);
return (
now.getFullYear() === date.getFullYear() &&
now.getMonth() === date.getMonth() &&
now.getDate() === date.getDate()
);
};
isYesterday(dateString: string): boolean {
const date = new Date(dateString);
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
return (
yesterday.getFullYear() === date.getFullYear() &&
yesterday.getMonth() === date.getMonth() &&
yesterday.getDate() === date.getDate()
);
}
}
const isYesterday = (dateString: string): boolean => {
const date = new Date(dateString);
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
return (
yesterday.getFullYear() === date.getFullYear() &&
yesterday.getMonth() === date.getMonth() &&
yesterday.getDate() === date.getDate()
);
};
</script>
<style lang="scss" scoped>
.container.section {
background: $white;
}
.timeline {
ul {
// padding: 0.5rem 0;

View File

@@ -1,769 +0,0 @@
<template>
<div id="homepage">
<b-loading :active.sync="$apollo.loading" />
<section
class="hero"
:class="{ webp: supportsWebPFormat }"
v-if="config && (!currentUser.id || !currentActor.id)"
>
<div class="hero-body">
<div class="container">
<h1 class="title">
{{ config.slogan || $t("Gather ⋅ Organize ⋅ Mobilize") }}
</h1>
<p
v-html="
$t('Join <b>{instance}</b>, a Mobilizon instance', {
instance: config.name,
})
"
/>
<p class="instance-description">{{ config.description }}</p>
<!-- We don't invite to find other instances yet -->
<!-- <p v-if="!config.registrationsOpen">
{{ $t("This instance isn't opened to registrations, but you can register on other instances.") }}
</p>-->
<div class="buttons">
<b-button
type="is-primary"
tag="router-link"
:to="{ name: RouteName.REGISTER }"
v-if="config.registrationsOpen"
>{{ $t("Create an account") }}</b-button
>
<!-- We don't invite to find other instances yet -->
<!-- <b-button v-else type="is-link" tag="a" href="https://joinmastodon.org">{{ $t('Find an instance') }}</b-button> -->
<b-button
type="is-text"
tag="router-link"
:to="{ name: RouteName.ABOUT }"
>
{{ $t("Learn more about {instance}", { instance: config.name }) }}
</b-button>
</div>
</div>
</div>
</section>
<div
id="recent_events"
class="container section"
v-if="config && (!currentUser.id || !currentActor.id)"
>
<section class="events-recent">
<h2 class="title">
{{ $t("Last published events") }}
</h2>
<p>
<i18n tag="span" path="On {instance} and other federated instances">
<b slot="instance">{{ config.name }}</b>
</i18n>
</p>
<div v-if="events.total > 0">
<multi-card :events="events.elements.slice(0, 6)" />
<span class="view-all">
<router-link :to="{ name: RouteName.SEARCH }"
>{{ $t("View everything") }} >></router-link
>
</span>
</div>
<b-message v-else type="is-danger">{{
$t("No events found")
}}</b-message>
</section>
</div>
<div id="picture" v-if="config && (!currentUser.id || !currentActor.id)">
<div class="picture-container">
<picture>
<source
media="(max-width: 799px)"
srcset="/img/pics/homepage-480w.webp"
type="image/webp"
/>
<source
media="(max-width: 799px)"
srcset="/img/pics/homepage-480w.jpg"
type="image/jpeg"
/>
<source
media="(max-width: 1024px)"
srcset="/img/pics/homepage-1024w.webp"
type="image/webp"
/>
<source
media="(max-width: 1024px)"
srcset="/img/pics/homepage-1024w.jpg"
type="image/jpeg"
/>
<source
media="(max-width: 1920px)"
srcset="/img/pics/homepage-1920w.webp"
type="image/webp"
/>
<source
media="(max-width: 1920px)"
srcset="/img/pics/homepage-1920w.jpg"
type="image/jpeg"
/>
<source
media="(min-width: 1921px)"
srcset="/img/pics/homepage.webp"
type="image/webp"
/>
<source
media="(min-width: 1921px)"
srcset="/img/pics/homepage.jpg"
type="image/jpeg"
/>
<img
src="/img/pics/homepage-1024w.jpg"
width="3840"
height="2719"
alt=""
loading="lazy"
/>
</picture>
</div>
<div class="container section">
<div class="columns">
<div class="column">
<h3 class="title">{{ $t("A practical tool") }}</h3>
<p
v-html="
$t(
'Mobilizon is a tool that helps you <b>find, create and organise events</b>.'
)
"
/>
</div>
<div class="column">
<h3 class="title">{{ $t("An ethical alternative") }}</h3>
<p
v-html="
$t(
'Ethical alternative to Facebook events, groups and pages, Mobilizon is a <b>tool designed to serve you</b>. Period.'
)
"
/>
</div>
<div class="column">
<h3 class="title">{{ $t("A federated software") }}</h3>
<p
v-html="
$t(
'Mobilizon is not a giant platform, but a <b>multitude of interconnected Mobilizon websites</b>.'
)
"
/>
</div>
</div>
<div class="buttons">
<a
class="button is-primary is-large"
href="https://joinmobilizon.org"
>{{ $t("Learn more about Mobilizon") }}</a
>
</div>
</div>
</div>
<div class="container section" v-if="config && loggedUserSettings">
<section v-if="currentActor.id && (welcomeBack || newRegisteredUser)">
<b-message type="is-info" v-if="welcomeBack">{{
$t("Welcome back {username}!", {
username: currentActor.displayName(),
})
}}</b-message>
<b-message type="is-info" v-if="newRegisteredUser">{{
$t("Welcome to Mobilizon, {username}!", {
username: currentActor.displayName(),
})
}}</b-message>
</section>
<!-- Your upcoming events -->
<section v-if="canShowMyUpcomingEvents">
<h2 class="title">{{ $t("Your upcoming events") }}</h2>
<div v-for="row of goingToEvents" class="upcoming-events" :key="row[0]">
<p
class="date-component-container"
v-if="isInLessThanSevenDays(row[0])"
>
<span v-if="isToday(row[0])">{{
$tc("You have one event today.", row[1].length, {
count: row[1].length,
})
}}</span>
<span v-else-if="isTomorrow(row[0])">{{
$tc("You have one event tomorrow.", row[1].length, {
count: row[1].length,
})
}}</span>
<span v-else-if="isInLessThanSevenDays(row[0])">
{{
$tc("You have one event in {days} days.", row[1].length, {
count: row[1].length,
days: calculateDiffDays(row[0]),
})
}}
</span>
</p>
<div>
<event-participation-card
v-for="participation in thisWeek(row)"
@event-deleted="eventDeleted"
:key="participation[1].id"
:participation="participation[1]"
/>
</div>
</div>
<span class="view-all">
<router-link :to="{ name: RouteName.MY_EVENTS }"
>{{ $t("View everything") }} >></router-link
>
</span>
</section>
<hr
role="presentation"
class="home-separator"
v-if="canShowMyUpcomingEvents && canShowFollowedGroupEvents"
/>
<!-- Events from your followed groups -->
<section class="followActivity" v-if="canShowFollowedGroupEvents">
<h2 class="title">
{{ $t("Upcoming events from your groups") }}
</h2>
<p>{{ $t("That you follow or of which you are a member") }}</p>
<multi-card :events="filteredFollowedGroupsEvents" />
<span class="view-all">
<router-link
:to="{
name: RouteName.MY_EVENTS,
query: {
showUpcoming: 'true',
showDrafts: 'false',
showAttending: 'false',
showMyGroups: 'true',
},
}"
>{{ $t("View everything") }} >></router-link
>
</span>
</section>
<hr
role="presentation"
class="home-separator"
v-if="canShowFollowedGroupEvents && canShowCloseEvents"
/>
<!-- Events close to you -->
<section
class="events-close"
v-if="canShowCloseEvents && loggedUserSettings.location?.range"
>
<h2 class="title">
{{ $t("Events nearby") }}
</h2>
<p>
{{
$tc(
"Within {number} kilometers of {place}",
loggedUserSettings.location?.range,
{
number: loggedUserSettings.location?.range,
place: loggedUserSettings.location?.name,
}
)
}}
<router-link
:to="{ name: RouteName.PREFERENCES }"
:title="$t('Change')"
>
<b-icon class="clickable" icon="pencil" size="is-small" />
</router-link>
</p>
<multi-card :events="closeEvents.elements.slice(0, 4)" />
</section>
<hr
role="presentation"
class="home-separator"
v-if="canShowMyUpcomingEvents || canShowCloseEvents"
/>
<section class="events-recent">
<h2 class="title">
{{ $t("Last published events") }}
</h2>
<p>
<i18n tag="span" path="On {instance} and other federated instances">
<b slot="instance">{{ config.name }}</b>
</i18n>
</p>
<div v-if="events.total > 0">
<multi-card :events="events.elements.slice(0, 8)" />
<span class="view-all">
<router-link :to="{ name: RouteName.SEARCH }"
>{{ $t("View everything") }} >></router-link
>
</span>
</div>
<b-message v-else type="is-danger"
>{{ $t("No events found") }}<br />
<div v-if="goingToEvents.size > 0">
<b-icon size="is-small" icon="information-outline" />
<small>{{
$t("The events you created are not shown here.")
}}</small>
</div>
</b-message>
</section>
</div>
</div>
</template>
<script lang="ts">
import { Component, Vue, Watch } from "vue-property-decorator";
import { EventSortField, ParticipantRole, SortDirection } from "@/types/enums";
import { Paginate } from "@/types/paginate";
import { supportsWebPFormat } from "@/utils/support";
import { IParticipant, Participant } from "../types/participant.model";
import { FETCH_EVENTS } from "../graphql/event";
import EventParticipationCard from "../components/Event/EventParticipationCard.vue";
import MultiCard from "../components/Event/MultiCard.vue";
import { CURRENT_ACTOR_CLIENT } from "../graphql/actor";
import { IPerson, Person } from "../types/actor";
import {
ICurrentUser,
IUser,
IUserSettings,
} from "../types/current-user.model";
import { CURRENT_USER_CLIENT } from "../graphql/user";
import { CLOSE_CONTENT, HOME_USER_QUERIES } from "../graphql/home";
import RouteName from "../router/name";
import { IEvent } from "../types/event.model";
import DateComponent from "../components/Event/DateCalendarIcon.vue";
import { CONFIG } from "../graphql/config";
import { IConfig } from "../types/config.model";
import { IFollowedGroupEvent } from "../types/followedGroupEvent.model";
import Subtitle from "../components/Utils/Subtitle.vue";
@Component({
apollo: {
events: {
query: FETCH_EVENTS,
variables: {
orderBy: EventSortField.INSERTED_AT,
direction: SortDirection.DESC,
},
},
currentActor: {
query: CURRENT_ACTOR_CLIENT,
update: (data) => new Person(data.currentActor),
},
currentUser: CURRENT_USER_CLIENT,
config: CONFIG,
closeContent: {
query: CLOSE_CONTENT,
variables() {
return {
location: this.loggedUser?.settings?.location?.geohash,
radius: this.loggedUser?.settings?.location?.range,
};
},
update(data) {
this.closeEvents = data.searchEvents;
},
skip() {
return (
!this.currentUser?.isLoggedIn ||
!this.loggedUser?.settings?.location?.geohash ||
!this.loggedUser?.settings?.location?.range
);
},
},
userQueries: {
query: HOME_USER_QUERIES,
update(data) {
console.log("loggedUser", data.loggedUser);
this.loggedUser = data.loggedUser;
this.followedGroupEvents = data.loggedUser.followedGroupEvents;
this.currentUserParticipations =
data.loggedUser.participations.elements.map(
(participation: IParticipant) => new Participant(participation)
);
},
variables: {
afterDateTime: new Date().toISOString(),
},
skip() {
return !this.currentUser?.isLoggedIn;
},
},
},
components: {
Subtitle,
DateComponent,
EventParticipationCard,
MultiCard,
},
metaInfo() {
return {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
title: this.instanceName,
titleTemplate: "%s | Mobilizon",
};
},
})
export default class Home extends Vue {
events: Paginate<IEvent> = {
elements: [],
total: 0,
};
locations = [];
city = { name: null };
country = { name: null };
currentUser!: ICurrentUser;
loggedUser: IUser | null = null;
currentActor!: IPerson;
config!: IConfig;
RouteName = RouteName;
currentUserParticipations: IParticipant[] = [];
supportsWebPFormat = supportsWebPFormat;
closeEvents: Paginate<IEvent> = { elements: [], total: 0 };
followedGroupEvents: Paginate<IFollowedGroupEvent> = {
elements: [],
total: 0,
};
// get displayed_name() {
// return this.loggedPerson && this.loggedPerson.name === null
// ? this.loggedPerson.preferredUsername
// : this.loggedPerson.name;
// }
get instanceName(): string | undefined {
if (!this.config) return undefined;
return this.config.name;
}
// eslint-disable-next-line class-methods-use-this
get welcomeBack(): boolean {
return window.localStorage.getItem("welcome-back") === "yes";
}
// eslint-disable-next-line class-methods-use-this
get newRegisteredUser(): boolean {
return window.localStorage.getItem("new-registered-user") === "yes";
}
thisWeek(
row: [string, Map<string, IParticipant>]
): Map<string, IParticipant> {
if (this.isInLessThanSevenDays(row[0])) {
return row[1];
}
return new Map();
}
// eslint-disable-next-line class-methods-use-this
mounted(): void {
if (window.localStorage.getItem("welcome-back")) {
window.localStorage.removeItem("welcome-back");
}
if (window.localStorage.getItem("new-registered-user")) {
window.localStorage.removeItem("new-registered-user");
}
}
// eslint-disable-next-line class-methods-use-this
isToday(date: Date): boolean {
return new Date(date).toDateString() === new Date().toDateString();
}
isTomorrow(date: string): boolean {
return this.isInDays(date, 1);
}
isInDays(date: string, nbDays: number): boolean {
return this.calculateDiffDays(date) === nbDays;
}
isBefore(date: string, nbDays: number): boolean {
return this.calculateDiffDays(date) < nbDays;
}
isAfter(date: string, nbDays: number): boolean {
return this.calculateDiffDays(date) >= nbDays;
}
isInLessThanSevenDays(date: string): boolean {
return this.isBefore(date, 7);
}
// eslint-disable-next-line class-methods-use-this
calculateDiffDays(date: string): number {
return Math.ceil(
(new Date(date).getTime() - new Date().getTime()) / 1000 / 60 / 60 / 24
);
}
get thisWeekGoingToEvents(): IParticipant[] {
const res = this.currentUserParticipations.filter(
({ event, role }) =>
event.beginsOn != null &&
this.isAfter(event.beginsOn.toDateString(), 0) &&
this.isBefore(event.beginsOn.toDateString(), 7) &&
role !== ParticipantRole.REJECTED
);
res.sort(
(a: IParticipant, b: IParticipant) =>
a.event.beginsOn.getTime() - b.event.beginsOn.getTime()
);
return res;
}
get goingToEvents(): Map<string, Map<string, IParticipant>> {
return this.thisWeekGoingToEvents.reduce(
(
acc: Map<string, Map<string, IParticipant>>,
participation: IParticipant
) => {
const day = new Date(participation.event.beginsOn).toDateString();
const participations: Map<string, IParticipant> =
acc.get(day) || new Map();
participations.set(
`${participation.event.uuid}${participation.actor.id}`,
participation
);
acc.set(day, participations);
return acc;
},
new Map()
);
}
eventDeleted(eventid: string): void {
this.currentUserParticipations = this.currentUserParticipations.filter(
(participation) => participation.event.id !== eventid
);
}
viewEvent(event: IEvent): void {
this.$router.push({ name: RouteName.EVENT, params: { uuid: event.uuid } });
}
@Watch("loggedUser")
detectEmptyUserSettings(loggedUser: IUser): void {
console.debug("Try to detect empty user settings", loggedUser);
if (loggedUser?.id && loggedUser?.settings === null) {
console.debug("No user settings, pushing to onboarding assistant");
this.$router.push({
name: RouteName.WELCOME_SCREEN,
params: { step: "1" },
});
}
}
get loggedUserSettings(): IUserSettings | undefined {
return this.loggedUser?.settings;
}
get canShowMyUpcomingEvents(): boolean {
return this.currentActor.id != undefined && this.goingToEvents.size > 0;
}
get canShowCloseEvents(): boolean {
return (
this.loggedUser?.settings?.location != undefined &&
this.closeEvents.total > 0
);
}
get canShowFollowedGroupEvents(): boolean {
return this.filteredFollowedGroupsEvents.length > 0;
}
get filteredFollowedGroupsEvents(): IEvent[] {
return this.followedGroupEvents.elements
.map(({ event }: { event: IEvent }) => event)
.filter(
({ id }) =>
!this.thisWeekGoingToEvents
.map(({ event: { id: event_id } }) => event_id)
.includes(id)
)
.slice(0, 4);
}
}
</script>
<style lang="scss" scoped>
@use "@/styles/_mixins" as *;
@import "~bulma/sass/utilities/mixins.sass";
main > div > .container {
background: $white;
padding: 1rem 0.5rem 3rem;
}
.search-autocomplete {
border: 1px solid #dbdbdb;
color: rgba(0, 0, 0, 0.87);
}
.events-recent {
& > h3 {
@include padding-left(0.75rem);
}
.columns {
margin: 1rem auto 0;
}
}
.date-component-container {
display: flex;
align-items: center;
margin: 0.5rem auto 1rem;
h3.subtitle {
@include margin-left(7px);
}
}
span.view-all {
display: block;
margin-top: 1rem;
text-align: right;
a {
text-decoration: underline;
}
}
section.hero {
position: relative;
z-index: 1;
&::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0.3;
z-index: -1;
background: url("../../public/img/pics/homepage_background-1024w.png");
background-size: cover;
}
&.webp::before {
background-image: url("../../public/img/pics/homepage_background-1024w.webp");
}
& > .hero-body {
padding: 1rem 1.5rem 3rem;
}
.title {
color: $background-color;
}
.column figure.image img {
max-width: 400px;
}
.instance-description {
margin-bottom: 1rem;
}
}
#recent_events {
padding: 0;
min-height: 20vh;
z-index: 10;
.title {
margin: 20px auto 0;
}
.columns {
margin: 0 auto;
}
}
#picture {
.picture-container {
position: relative;
&::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
}
& > img {
object-fit: cover;
max-height: 80vh;
display: block;
margin: auto;
width: 100%;
}
}
.container.section {
background: $white;
@include tablet {
margin-top: -4rem;
}
z-index: 10;
.title {
margin: 0 0 10px;
font-size: 30px;
}
.buttons {
justify-content: center;
margin-top: 2rem;
}
}
}
#homepage {
background: $white;
}
.home-separator {
background-color: $orange-2;
}
.clickable {
cursor: pointer;
}
.title {
font-size: 27px;
&:not(:last-child) {
margin-bottom: 0.5rem;
}
}
</style>

640
js/src/views/HomeView.vue Normal file
View File

@@ -0,0 +1,640 @@
<template>
<div>
<!-- <o-loading v-model:active="$apollo.loading" /> -->
<section
class="mt-5 sm:mt-24"
v-if="
config &&
(!currentUser || !currentUser.id || !currentActor || !currentActor.id)
"
>
<div class="-z-10 overflow-hidden">
<img
alt=""
src="/img/shape-1.svg"
class="-z-10 absolute left-[2%] top-36"
width="300"
/>
<img
alt=""
src="/img/shape-2.svg"
class="-z-10 absolute left-[50%] top-[5%] -translate-x-2/4 opacity-60"
width="800"
/>
<img
alt=""
src="/img/shape-3.svg"
class="-z-10 absolute top-0 right-36"
width="200"
/>
</div>
</section>
<unlogged-introduction />
<search-fields v-model:search="search" v-model:location="location" />
<categories-preview />
<div
id="recent_events"
class="container mx-auto section"
v-if="config && (!currentUser || !currentActor)"
>
<section class="events-recent">
<h2 class="title">
{{ $t("Last published events") }}
</h2>
<p>
<i18n-t
tag="span"
keypath="On {instance} and other federated instances"
>
<template v-slot:instance>
<b>{{ config.name }}</b>
</template>
</i18n-t>
</p>
<div v-if="events.total > 0">
<multi-card :events="events.elements.slice(0, 6)" />
<span
class="block mt-2 text-right underline text-slate-700 dark:text-slate-300"
>
<router-link
:to="{ name: RouteName.SEARCH }"
class="hover:text-slate-800 hover:dark:text-slate-400"
>{{ $t("View everything") }} >></router-link
>
</span>
</div>
<o-notification v-else variant="danger">{{
$t("No events found")
}}</o-notification>
</section>
</div>
<div
id="picture"
v-if="config && (!currentUser || !currentUser.isLoggedIn)"
>
<div class="container mx-auto">
<close-events @doGeoLoc="performGeoLocation()" />
</div>
<div class="picture-container">
<picture>
<source
media="(max-width: 799px)"
:srcset="`/img/pics/homepage-480w.webp`"
type="image/webp"
/>
<source
media="(max-width: 1024px)"
:srcset="`/img/pics/homepage-1024w.webp`"
type="image/webp"
/>
<source
media="(max-width: 1920px)"
:srcset="`/img/pics/homepage-1920w.webp`"
type="image/webp"
/>
<source
media="(min-width: 1921px)"
:srcset="`/img/pics/homepage.webp`"
type="image/webp"
/>
<img
:src="`/img/pics/homepage-1024w.jpg`"
width="3840"
height="2719"
alt=""
loading="lazy"
/>
</picture>
</div>
<presentation />
</div>
<div class="container mx-auto" v-if="config && loggedUserSettings">
<section
v-if="
currentActor && currentActor.id && (welcomeBack || newRegisteredUser)
"
>
<o-notification variant="info" v-if="welcomeBack">{{
$t("Welcome back {username}!", {
username: currentActor.displayName(),
})
}}</o-notification>
<o-notification variant="info" v-if="newRegisteredUser">{{
$t("Welcome to Mobilizon, {username}!", {
username: currentActor.displayName(),
})
}}</o-notification>
</section>
<!-- Your upcoming events -->
<section v-if="canShowMyUpcomingEvents">
<h2 class="dark:text-white text-2xl font-bold">
{{ $t("Your upcoming events") }}
</h2>
<div
v-for="row of goingToEvents"
class="text-slate-700 dark:text-slate-300"
:key="row[0]"
>
<p
class="date-component-container"
v-if="isInLessThanSevenDays(row[0])"
>
<span v-if="isToday(row[0])">{{
$tc("You have one event today.", row[1].size, {
count: row[1].size,
})
}}</span>
<span v-else-if="isTomorrow(row[0])">{{
$tc("You have one event tomorrow.", row[1].size, {
count: row[1].size,
})
}}</span>
<span v-else-if="isInLessThanSevenDays(row[0])">
{{
$tc("You have one event in {days} days.", row[1].size, {
count: row[1].size,
days: calculateDiffDays(row[0]),
})
}}
</span>
</p>
<div>
<event-participation-card
v-for="participation in thisWeek(row)"
:key="participation[1].id"
:participation="participation[1]"
/>
</div>
</div>
<span
class="block mt-2 text-right underline text-slate-700 dark:text-slate-300"
>
<router-link
:to="{ name: RouteName.MY_EVENTS }"
class="hover:text-slate-800 hover:dark:text-slate-400"
>{{ $t("View everything") }} >></router-link
>
</span>
</section>
<hr
role="presentation"
class="home-separator"
v-if="canShowMyUpcomingEvents && canShowFollowedGroupEvents"
/>
<!-- Events from your followed groups -->
<section class="followActivity" v-if="canShowFollowedGroupEvents">
<h2 class="title">
{{ $t("Upcoming events from your groups") }}
</h2>
<p>{{ $t("That you follow or of which you are a member") }}</p>
<multi-card :events="filteredFollowedGroupsEvents" />
<span
class="block mt-2 text-right underline text-slate-700 dark:text-slate-300"
>
<router-link
class="hover:text-slate-800 hover:dark:text-slate-400"
:to="{
name: RouteName.MY_EVENTS,
query: {
showUpcoming: 'true',
showDrafts: 'false',
showAttending: 'false',
showMyGroups: 'true',
},
}"
>{{ $t("View everything") }} >></router-link
>
</span>
</section>
<hr
role="presentation"
class="home-separator"
v-if="canShowFollowedGroupEvents"
/>
<!-- Events close to you -->
<!-- <section class="events-close" v-if="canShowCloseEvents && radius">
<h2 class="title">
{{ $t("Events nearby") }}
</h2>
<p>
{{
$tc("Within {number} kilometers of {place}", radius, {
radius,
place: locationName,
})
}}
<router-link
:to="{ name: RouteName.PREFERENCES }"
:title="$t('Change')"
>
<o-icon class="clickable" icon="pencil" size="small" />
</router-link>
</p>
<multi-card :events="closeEvents.elements.slice(0, 4)" />
</section> -->
<hr
role="presentation"
class="home-separator"
v-if="canShowMyUpcomingEvents"
/>
<section class="events-recent">
<h2 class="dark:text-white text-2xl font-bold">
{{ $t("Last published events") }}
</h2>
<p class="mb-3">
<i18n-t
class="text-slate-700 dark:text-slate-300"
tag="span"
keypath="On {instance} and other federated instances"
>
<template v-slot:instance>
<b>{{ config.name }}</b>
</template>
</i18n-t>
</p>
<div v-if="events && events.total > 0">
<multi-card :events="events.elements.slice(0, 8)" />
<span
class="block mt-2 text-right underline text-slate-700 dark:text-slate-300"
>
<router-link
:to="{ name: RouteName.SEARCH }"
class="hover:text-slate-800 hover:dark:text-slate-400"
>{{ $t("View everything") }} >></router-link
>
</span>
</div>
<o-notification v-else variant="danger"
>{{ $t("No events found") }}<br />
<div v-if="goingToEvents.size > 0">
<!-- <o-icon size="small" icon="information-outline" /> -->
<small>{{
$t("The events you created are not shown here.")
}}</small>
</div>
</o-notification>
</section>
<close-events @doGeoLoc="performGeoLocation()" />
</div>
</div>
</template>
<script lang="ts" setup>
import { EventSortField, ParticipantRole, SortDirection } from "@/types/enums";
import { Paginate } from "@/types/paginate";
import { IParticipant, Participant } from "../types/participant.model";
import { FETCH_EVENTS } from "../graphql/event";
import EventParticipationCard from "../components/Event/EventParticipationCard.vue";
import MultiCard from "../components/Event/MultiCard.vue";
import { CURRENT_ACTOR_CLIENT } from "../graphql/actor";
import { displayName, IPerson, Person } from "../types/actor";
import ngeohash from "ngeohash";
import {
ICurrentUser,
IUser,
IUserSettings,
} from "../types/current-user.model";
import { CURRENT_USER_CLIENT } from "../graphql/user";
import { HOME_USER_QUERIES } from "../graphql/home";
import RouteName from "../router/name";
import { IEvent } from "../types/event.model";
import { CONFIG } from "../graphql/config";
import { IConfig } from "../types/config.model";
// import { IFollowedGroupEvent } from "../types/followedGroupEvent.model";
import CloseEvents from "@/components/Local/CloseEvents.vue";
import { computed, onMounted, reactive, watch } from "vue";
import { useMutation, useQuery } from "@vue/apollo-composable";
import { useRouter } from "vue-router";
import { REVERSE_GEOCODE } from "@/graphql/address";
import { IAddress } from "@/types/address.model";
import {
CURRENT_USER_LOCATION_CLIENT,
UPDATE_CURRENT_USER_LOCATION_CLIENT,
} from "@/graphql/location";
import { LocationType } from "@/types/user-location.model";
import Presentation from "@/components/Home/MobilizonPresentation.vue";
import CategoriesPreview from "@/components/Home/CategoriesPreview.vue";
import UnloggedIntroduction from "@/components/Home/UnloggedIntroduction.vue";
import SearchFields from "@/components/Home/SearchFields.vue";
import { useHead } from "@vueuse/head";
const { result: resultEvents } = useQuery<{ events: Paginate<IEvent> }>(
FETCH_EVENTS,
{
orderBy: EventSortField.INSERTED_AT,
direction: SortDirection.DESC,
}
);
const events = computed(
() => resultEvents.value?.events || { total: 0, elements: [] }
);
const { result: currentActorResult } = useQuery<{ currentActor: IPerson }>(
CURRENT_ACTOR_CLIENT
);
const currentActor = computed<Person | undefined>(
() => new Person(currentActorResult.value?.currentActor)
);
const { result: currentUserResult } = useQuery<{
currentUser: ICurrentUser;
}>(CURRENT_USER_CLIENT);
const currentUser = computed(() => currentUserResult.value?.currentUser);
const { result: configResult } = useQuery<{ config: IConfig }>(CONFIG);
const config = computed<IConfig | undefined>(() => configResult.value?.config);
const { result: userResult } = useQuery<{ loggedUser: IUser }>(
HOME_USER_QUERIES,
{ afterDateTime: new Date().toISOString() },
() => ({
enabled: currentUser.value?.id != undefined,
})
);
const loggedUser = computed(() => userResult.value?.loggedUser);
const followedGroupEvents = computed(
() => userResult.value?.loggedUser?.followedGroupEvents
);
const userSettingsLocation = computed(
() => loggedUser.value?.settings?.location
);
const { result: currentUserLocationResult } = useQuery<{
currentUserLocation: LocationType;
}>(CURRENT_USER_LOCATION_CLIENT);
const currentUserLocation = computed(() => {
return currentUserLocationResult.value?.currentUserLocation;
});
const { mutate: saveCurrentUserLocation } = useMutation<any, LocationType>(
UPDATE_CURRENT_USER_LOCATION_CLIENT
);
const reverseGeoCodeInformation = reactive<{
latitude: number | undefined;
longitude: number | undefined;
accuracy: number | undefined;
}>({
latitude: undefined,
longitude: undefined,
accuracy: undefined,
});
const { onResult: onReverseGeocodeResult } = useQuery<{
reverseGeocode: IAddress[];
}>(REVERSE_GEOCODE, reverseGeoCodeInformation, () => ({
enabled: reverseGeoCodeInformation.latitude !== undefined,
}));
onReverseGeocodeResult((result) => {
if (!result?.data) return;
const geoLocationInformation = result?.data?.reverseGeocode[0];
console.debug("geoLocationInformation", result.data);
const placeName =
geoLocationInformation.locality ??
geoLocationInformation.region ??
geoLocationInformation.country;
console.log("place name", placeName);
saveCurrentUserLocation({
lat: reverseGeoCodeInformation.latitude,
lon: reverseGeoCodeInformation.longitude,
accuracy: Math.round(reverseGeoCodeInformation.accuracy ?? 12) / 1000,
isIPLocation: false,
name: placeName,
picture: geoLocationInformation.pictureInfo,
});
});
const fetchAndSaveCurrentLocationName = async ({
coords: { latitude, longitude, accuracy },
}: GeolocationPosition) => {
console.debug(
"data found from navigator geocoding",
latitude,
longitude,
accuracy
);
reverseGeoCodeInformation.latitude = latitude;
reverseGeoCodeInformation.longitude = longitude;
reverseGeoCodeInformation.accuracy = accuracy;
};
const performGeoLocation = () => {
navigator.geolocation.getCurrentPosition(fetchAndSaveCurrentLocationName);
};
const GEOHASH_DEPTH = 9; // put enough accuracy, radius will be used anyway
const geohash = computed(
() => userSettingsLocation.value?.geohash ?? currentUserLocationGeoHash
);
const currentUserLocationGeoHash = computed(() => {
if (currentUserLocation.value?.lat && currentUserLocation.value?.lon) {
return ngeohash.encode(
currentUserLocation.value?.lat,
currentUserLocation.value?.lon,
GEOHASH_DEPTH
);
}
return undefined;
});
const radius = computed(
() => userSettingsLocation.value?.range ?? currentUserLocation.value?.accuracy
);
const locationName = computed(
() => userSettingsLocation.value?.name ?? currentUserLocation.value?.name
);
// const { result: closeEventsResult } = useQuery<{
// searchEvents: Paginate<IEvent>;
// }>(
// CLOSE_CONTENT,
// { location: geohash, radius },
// { enabled: geohash.value !== undefined }
// );
// const closeEvents = computed(
// () => closeEventsResult.value?.searchEvents || { total: 0, elements: [] }
// );
const currentUserParticipations = computed(() =>
loggedUser.value?.participations.elements.map(
(participation: IParticipant) => new Participant(participation)
)
);
const instanceName = computed((): string | undefined => config.value?.name);
const welcomeBack = computed<boolean>(
() => window.localStorage.getItem("welcome-back") === "yes"
);
const newRegisteredUser = computed<boolean>(
() => window.localStorage.getItem("new-registered-user") === "yes"
);
const isToday = (date: string): boolean => {
return new Date(date).toDateString() === new Date().toDateString();
};
const isTomorrow = (date: string): boolean => {
return isInDays(date, 1);
};
const isInDays = (date: string, nbDays: number): boolean => {
return calculateDiffDays(date) === nbDays;
};
const isBefore = (date: string, nbDays: number): boolean => {
return calculateDiffDays(date) < nbDays;
};
const isAfter = (date: string, nbDays: number): boolean => {
return calculateDiffDays(date) >= nbDays;
};
const isInLessThanSevenDays = (date: string): boolean => {
return isBefore(date, 7);
};
const thisWeek = (
row: [string, Map<string, IParticipant>]
): Map<string, IParticipant> => {
if (isInLessThanSevenDays(row[0])) {
return row[1];
}
return new Map();
};
const calculateDiffDays = (date: string): number => {
return Math.ceil(
(new Date(date).getTime() - new Date().getTime()) / 1000 / 60 / 60 / 24
);
};
const thisWeekGoingToEvents = computed<IParticipant[]>(() => {
const res = (currentUserParticipations.value || []).filter(
({ event, role }) =>
event.beginsOn != null &&
isAfter(event.beginsOn, 0) &&
isBefore(event.beginsOn, 7) &&
role !== ParticipantRole.REJECTED
);
res.sort(
(a: IParticipant, b: IParticipant) =>
new Date(a.event.beginsOn).getTime() -
new Date(b.event.beginsOn).getTime()
);
return res;
});
const goingToEvents = computed<Map<string, Map<string, IParticipant>>>(() => {
return thisWeekGoingToEvents.value?.reduce(
(
acc: Map<string, Map<string, IParticipant>>,
participation: IParticipant
) => {
const day = new Date(participation.event.beginsOn).toDateString();
const participations: Map<string, IParticipant> =
acc.get(day) || new Map();
participations.set(
`${participation.event.uuid}${participation.actor.id}`,
participation
);
acc.set(day, participations);
return acc;
},
new Map()
);
});
// const eventDeleted = (eventid: string): void => {
// currentUserParticipations = currentUserParticipations?.filter(
// (participation) => participation.event.id !== eventid
// );
// }
// viewEvent(event: IEvent): void {
// this.$router.push({ name: RouteName.EVENT, params: { uuid: event.uuid } });
// }
const loggedUserSettings = computed<IUserSettings | undefined>(() => {
return loggedUser.value?.settings;
});
const canShowMyUpcomingEvents = computed<boolean>(() => {
return currentActor.value?.id != undefined && goingToEvents.value.size > 0;
});
// const canShowCloseEvents = computed<boolean>(() => {
// return (
// loggedUser.value?.settings?.location != undefined &&
// closeEvents.value != undefined &&
// closeEvents.value?.total > 0
// );
// });
const canShowFollowedGroupEvents = computed<boolean>(() => {
return filteredFollowedGroupsEvents.value.length > 0;
});
const filteredFollowedGroupsEvents = computed<IEvent[]>(() => {
return (followedGroupEvents.value?.elements || [])
.map(({ event }: { event: IEvent }) => event)
.filter(
({ id }) =>
!thisWeekGoingToEvents.value
.map(({ event: { id: event_id } }) => event_id)
.includes(id)
)
.slice(0, 4);
});
onMounted(() => {
if (window.localStorage.getItem("welcome-back")) {
window.localStorage.removeItem("welcome-back");
}
if (window.localStorage.getItem("new-registered-user")) {
window.localStorage.removeItem("new-registered-user");
}
});
const router = useRouter();
watch(loggedUser, (loggedUserValue) => {
console.debug("Try to detect empty user settings", loggedUserValue);
if (loggedUserValue?.id && loggedUserValue?.settings === null) {
console.debug("No user settings, pushing to onboarding assistant");
router.push({
name: RouteName.WELCOME_SCREEN,
params: { step: "1" },
});
}
});
// metaInfo() {
// return {
// // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// // @ts-ignore
// title: this.instanceName,
// titleTemplate: "%s | Mobilizon",
// };
// },
useHead({
title: computed(() => instanceName.value),
});
</script>

View File

@@ -1,99 +1,103 @@
<template>
<div class="container section">
<b-notification v-if="$apollo.queries.interact.loading">
{{ $t("Redirecting to content") }}
</b-notification>
<b-notification v-if="$apollo.queries.interact.skip" type="is-danger">
{{ $t("Resource provided is not an URL") }}
</b-notification>
<b-message
:title="$t('Error')"
type="is-danger"
<div class="container mx-auto section">
<o-notification v-if="loading">
{{ t("Redirecting to content") }}
</o-notification>
<o-notification v-if="!isURI" variant="danger">
{{ t("Resource provided is not an URL") }}
</o-notification>
<o-notification
:title="t('Error')"
variant="danger"
has-icon
:closable="false"
v-if="!$apollo.loading && errors.length > 0"
v-if="!loading && errors.length > 0"
>
<p v-for="error in errors" :key="error">
<b>{{ error }}</b>
</p>
<p>
{{
$t(
t(
"It is possible that the content is not accessible on this instance, because this instance has blocked the profiles or groups behind this content."
)
}}
</p>
</b-message>
</o-notification>
</div>
</template>
<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
<script lang="ts" setup>
import { INTERACT } from "@/graphql/search";
import { IEvent } from "@/types/event.model";
import { IGroup, usernameWithDomain } from "@/types/actor";
import RouteName from "../router/name";
import { GraphQLError } from "graphql";
import { useQuery } from "@vue/apollo-composable";
import { computed, reactive } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useI18n } from "vue-i18n";
import { useRouteQuery } from "vue-use-route-query";
import { useHead } from "@vueuse/head";
@Component({
apollo: {
interact: {
query: INTERACT,
variables() {
return {
uri: this.$route.query.uri,
};
},
skip() {
try {
const url = this.$route.query.uri as string;
const uri = new URL(url);
return !(uri instanceof URL);
} catch (e) {
return true;
}
},
error({ graphQLErrors, networkError }) {
if (networkError) {
this.errors = [networkError.message];
}
this.errors = graphQLErrors.map((error: GraphQLError) => error.message);
},
async result({ data: { interact } }) {
switch (interact.__typename) {
case "Group":
await this.$router.replace({
name: RouteName.GROUP,
params: { preferredUsername: usernameWithDomain(interact) },
});
break;
case "Event":
await this.$router.replace({
name: RouteName.EVENT,
params: { uuid: interact.uuid },
});
break;
default:
this.error = [this.$t("This URL is not supported")];
}
// await this.$router.replace({
// name: RouteName.EVENT,
// params: { uuid: event.uuid },
// });
},
},
},
metaInfo() {
return {
title: this.$t("Interact with a remote content") as string,
};
},
})
export default class Interact extends Vue {
interact!: IEvent | IGroup;
const router = useRouter();
const route = useRoute();
const { t } = useI18n({ useScope: "global" });
errors: string[] = [];
}
const uri = useRouteQuery("uri", "");
const isURI = computed((): boolean => {
try {
const url = new URL(uri.value);
return !(url instanceof URL);
} catch (e) {
return true;
}
});
const errors = reactive<string[]>([]);
const { onResult, onError, loading } = useQuery<{
interact: (IEvent | IGroup) & { __typename: string };
}>(
INTERACT,
() => ({
uri: uri.value,
}),
() => ({
enabled: isURI.value !== false,
})
);
onResult(async ({ data: { interact } }) => {
switch (interact.__typename) {
case "Group":
await router.replace({
name: RouteName.GROUP,
params: { preferredUsername: usernameWithDomain(interact as IGroup) },
});
break;
case "Event":
await router.replace({
name: RouteName.EVENT,
params: { uuid: (interact as IEvent).uuid },
});
break;
default:
errors.push(t("This URL is not supported"));
}
});
onError(({ graphQLErrors, networkError }) => {
if (networkError) {
errors.push(networkError.message);
}
errors.push(...graphQLErrors.map((error: GraphQLError) => error.message));
});
useHead({
title: computed(() => t("Interact with a remote content")),
});
</script>
<style lang="scss">
main > .container {

View File

@@ -22,180 +22,193 @@
:alt="log.actor.avatar.alt || ''"
v-if="log.actor.avatar"
/>
<i18n
<i18n-t
v-if="log.action === ActionLogAction.REPORT_UPDATE_CLOSED"
tag="span"
path="{moderator} closed {report}"
keypath="{moderator} closed {report}"
>
<router-link
slot="moderator"
:to="{
name: RouteName.ADMIN_PROFILE,
params: { id: log.actor.id },
}"
>@{{ log.actor.preferredUsername }}</router-link
>
<router-link
:to="{
name: RouteName.REPORT,
params: { reportId: log.object.id },
}"
slot="report"
>{{
$t("report #{report_number}", {
report_number: log.object.id,
})
}}
</router-link>
</i18n>
<i18n
<template #moderator>
<router-link
:to="{
name: RouteName.ADMIN_PROFILE,
params: { id: log.actor.id },
}"
>@{{ log.actor.preferredUsername }}</router-link
>
</template>
<template #report>
<router-link
:to="{
name: RouteName.REPORT,
params: { reportId: log.object.id },
}"
>{{
$t("report #{report_number}", {
report_number: log.object.id,
})
}}
</router-link>
</template>
</i18n-t>
<i18n-t
v-else-if="log.action === ActionLogAction.REPORT_UPDATE_OPENED"
tag="span"
path="{moderator} reopened {report}"
keypath="{moderator} reopened {report}"
>
<router-link
slot="moderator"
:to="{
name: RouteName.ADMIN_PROFILE,
params: { id: log.actor.id },
}"
>@{{ log.actor.preferredUsername }}</router-link
>
<router-link
:to="{
name: RouteName.REPORT,
params: { reportId: log.object.id },
}"
slot="report"
>{{
$t("report #{report_number}", {
report_number: log.object.id,
})
}}
</router-link>
</i18n>
<i18n
<template #moderator>
<router-link
:to="{
name: RouteName.ADMIN_PROFILE,
params: { id: log.actor.id },
}"
>@{{ log.actor.preferredUsername }}</router-link
>
</template>
<template #report>
<router-link
:to="{
name: RouteName.REPORT,
params: { reportId: log.object.id },
}"
>{{
$t("report #{report_number}", {
report_number: log.object.id,
})
}}
</router-link>
</template>
</i18n-t>
<i18n-t
v-else-if="log.action === ActionLogAction.REPORT_UPDATE_RESOLVED"
tag="span"
path="{moderator} marked {report} as resolved"
keypath="{moderator} marked {report} as resolved"
>
<router-link
slot="moderator"
:to="{
name: RouteName.ADMIN_PROFILE,
params: { id: log.actor.id },
}"
>@{{ log.actor.preferredUsername }}</router-link
>
<router-link
:to="{
name: RouteName.REPORT,
params: { reportId: log.object.id },
}"
slot="report"
>{{
$t("report #{report_number}", {
report_number: log.object.id,
})
}}
</router-link>
</i18n>
<i18n
<template #moderator>
<router-link
:to="{
name: RouteName.ADMIN_PROFILE,
params: { id: log.actor.id },
}"
>@{{ log.actor.preferredUsername }}</router-link
>
</template>
<template #report>
<router-link
:to="{
name: RouteName.REPORT,
params: { reportId: log.object.id },
}"
>{{
$t("report #{report_number}", {
report_number: log.object.id,
})
}}
</router-link>
</template>
</i18n-t>
<i18n-t
v-else-if="log.action === ActionLogAction.NOTE_CREATION"
tag="span"
path="{moderator} added a note on {report}"
keypath="{moderator} added a note on {report}"
>
<router-link
slot="moderator"
:to="{
name: RouteName.ADMIN_PROFILE,
params: { id: log.actor.id },
}"
>@{{ log.actor.preferredUsername }}</router-link
>
<router-link
v-if="log.object.report"
:to="{
name: RouteName.REPORT,
params: { reportId: log.object.report.id },
}"
slot="report"
>{{
$t("report #{report_number}", {
report_number: log.object.report.id,
})
}}
</router-link>
<span v-else slot="report">{{
$t("a non-existent report")
}}</span>
</i18n>
<i18n
<template #moderator>
<router-link
:to="{
name: RouteName.ADMIN_PROFILE,
params: { id: log.actor.id },
}"
>@{{ log.actor.preferredUsername }}</router-link
>
</template>
<template #report>
<router-link
v-if="log.object.report"
:to="{
name: RouteName.REPORT,
params: { reportId: log.object.report.id },
}"
>{{
$t("report #{report_number}", {
report_number: log.object.report.id,
})
}}
</router-link>
<span v-else>{{ $t("a non-existent report") }}</span>
</template>
</i18n-t>
<i18n-t
v-else-if="log.action === ActionLogAction.EVENT_DELETION"
tag="span"
path='{moderator} deleted an event named "{title}"'
keypath='{moderator} deleted an event named "{title}"'
>
<router-link
slot="moderator"
:to="{
name: RouteName.ADMIN_PROFILE,
params: { id: log.actor.id },
}"
>@{{ log.actor.preferredUsername }}</router-link
>
<b slot="title">{{ log.object.title }}</b>
</i18n>
<i18n
<template #moderator>
<router-link
:to="{
name: RouteName.ADMIN_PROFILE,
params: { id: log.actor.id },
}"
>@{{ log.actor.preferredUsername }}</router-link
>
</template>
<template #title>
<b>{{ log.object.title }}</b>
</template>
</i18n-t>
<i18n-t
v-else-if="
log.action === ActionLogAction.ACTOR_SUSPENSION &&
log.object.__typename == 'Person'
"
tag="span"
path="{moderator} suspended profile {profile}"
keypath="{moderator} suspended profile {profile}"
>
<router-link
slot="moderator"
:to="{
name: RouteName.ADMIN_PROFILE,
params: { id: log.actor.id },
}"
>@{{ log.actor.preferredUsername }}</router-link
>
<router-link
slot="profile"
:to="{
name: RouteName.ADMIN_PROFILE,
params: { id: log.object.id },
}"
>{{ displayNameAndUsername(log.object) }}
</router-link>
</i18n>
<i18n
<template #moderator>
<router-link
:to="{
name: RouteName.ADMIN_PROFILE,
params: { id: log.actor.id },
}"
>@{{ log.actor.preferredUsername }}</router-link
>
</template>
<template #profile>
<router-link
:to="{
name: RouteName.ADMIN_PROFILE,
params: { id: log.object.id },
}"
>{{ displayNameAndUsername(log.object) }}
</router-link>
</template>
</i18n-t>
<i18n-t
v-else-if="
log.action === ActionLogAction.ACTOR_UNSUSPENSION &&
log.object.__typename == 'Person'
"
tag="span"
path="{moderator} has unsuspended profile {profile}"
keypath="{moderator} has unsuspended profile {profile}"
>
<router-link
slot="moderator"
:to="{
name: RouteName.ADMIN_PROFILE,
params: { id: log.actor.id },
}"
>@{{ log.actor.preferredUsername }}</router-link
>
<router-link
slot="profile"
:to="{
name: RouteName.ADMIN_PROFILE,
params: { id: log.object.id },
}"
>{{ displayNameAndUsername(log.object) }}
</router-link>
</i18n>
<i18n
<template #moderator>
<router-link
:to="{
name: RouteName.ADMIN_PROFILE,
params: { id: log.actor.id },
}"
>@{{ log.actor.preferredUsername }}</router-link
>
</template>
<template #profile>
<router-link
:to="{
name: RouteName.ADMIN_PROFILE,
params: { id: log.object.id },
}"
>{{ displayNameAndUsername(log.object) }}
</router-link>
</template>
</i18n-t>
<i18n-t
v-else-if="
log.action === ActionLogAction.ACTOR_SUSPENSION &&
log.object.__typename == 'Group'
@@ -203,155 +216,167 @@
tag="span"
path="{moderator} suspended group {profile}"
>
<router-link
slot="moderator"
:to="{
name: RouteName.ADMIN_PROFILE,
params: { id: log.actor.id },
}"
>@{{ log.actor.preferredUsername }}</router-link
>
<router-link
slot="profile"
:to="{
name: RouteName.ADMIN_GROUP_PROFILE,
params: { id: log.object.id },
}"
>{{ displayNameAndUsername(log.object) }}
</router-link>
</i18n>
<i18n
<template #moderator>
<router-link
:to="{
name: RouteName.ADMIN_PROFILE,
params: { id: log.actor.id },
}"
>@{{ log.actor.preferredUsername }}</router-link
>
</template>
<template #profile>
<router-link
:to="{
name: RouteName.ADMIN_GROUP_PROFILE,
params: { id: log.object.id },
}"
>{{ displayNameAndUsername(log.object) }}
</router-link>
</template>
</i18n-t>
<i18n-t
v-else-if="
log.action === ActionLogAction.ACTOR_UNSUSPENSION &&
log.object.__typename == 'Group'
"
tag="span"
path="{moderator} has unsuspended group {profile}"
keypath="{moderator} has unsuspended group {profile}"
>
<router-link
slot="moderator"
:to="{
name: RouteName.ADMIN_PROFILE,
params: { id: log.actor.id },
}"
>@{{ log.actor.preferredUsername }}</router-link
>
<router-link
slot="profile"
:to="{
name: RouteName.ADMIN_GROUP_PROFILE,
params: { id: log.object.id },
}"
>{{ displayNameAndUsername(log.object) }}
</router-link>
</i18n>
<i18n
<template #moderator>
<router-link
:to="{
name: RouteName.ADMIN_PROFILE,
params: { id: log.actor.id },
}"
>@{{ log.actor.preferredUsername }}</router-link
>
</template>
<template #profile>
<router-link
:to="{
name: RouteName.ADMIN_GROUP_PROFILE,
params: { id: log.object.id },
}"
>{{ displayNameAndUsername(log.object) }}
</router-link>
</template>
</i18n-t>
<i18n-t
v-else-if="log.action === ActionLogAction.USER_DELETION"
tag="span"
path="{moderator} has deleted user {user}"
keypath="{moderator} has deleted user {user}"
>
<router-link
slot="moderator"
:to="{
name: RouteName.ADMIN_PROFILE,
params: { id: log.actor.id },
}"
>@{{ log.actor.preferredUsername }}</router-link
>
<router-link
v-if="log.object.confirmedAt"
slot="user"
:to="{
name: RouteName.ADMIN_USER_PROFILE,
params: { id: log.object.id },
}"
>{{ log.object.email }}
</router-link>
<b v-else slot="user">{{ log.object.email }}</b>
</i18n>
<template #moderator>
<router-link
:to="{
name: RouteName.ADMIN_PROFILE,
params: { id: log.actor.id },
}"
>@{{ log.actor.preferredUsername }}</router-link
>
</template>
<template #user>
<router-link
v-if="log.object.confirmedAt"
:to="{
name: RouteName.ADMIN_USER_PROFILE,
params: { id: log.object.id },
}"
>{{ log.object.email }}
</router-link>
<b v-else>{{ log.object.email }}</b>
</template>
</i18n-t>
<span
v-else-if="
log.action === ActionLogAction.COMMENT_DELETION &&
log.object.event
"
>
<i18n
<i18n-t
tag="span"
path="{moderator} has deleted a comment from {author} under the event {event}"
keypath="{moderator} has deleted a comment from {author} under the event {event}"
>
<router-link
slot="moderator"
:to="{
name: RouteName.ADMIN_PROFILE,
params: { id: log.actor.id },
}"
>@{{ log.actor.preferredUsername }}</router-link
>
<router-link
v-if="log.object.event && log.object.event.uuid"
slot="event"
:to="{
name: RouteName.EVENT,
params: { uuid: log.object.event.uuid },
}"
>{{ log.object.event.title }}
</router-link>
<b v-else slot="event">{{ log.object.event.title }}</b>
<router-link
slot="author"
:to="{
name: RouteName.ADMIN_PROFILE,
params: { id: log.object.actor.id },
}"
>{{ displayNameAndUsername(log.object.actor) }}
</router-link>
</i18n>
<template #moderator>
<router-link
:to="{
name: RouteName.ADMIN_PROFILE,
params: { id: log.actor.id },
}"
>@{{ log.actor.preferredUsername }}</router-link
>
</template>
<template #event>
<router-link
v-if="log.object.event && log.object.event.uuid"
:to="{
name: RouteName.EVENT,
params: { uuid: log.object.event.uuid },
}"
>{{ log.object.event.title }}
</router-link>
<b v-else>{{ log.object.event.title }}</b>
</template>
<template #author>
<router-link
:to="{
name: RouteName.ADMIN_PROFILE,
params: { id: log.object.actor.id },
}"
>{{ displayNameAndUsername(log.object.actor) }}
</router-link>
</template>
</i18n-t>
<pre v-html="log.object.text" />
</span>
<span v-else-if="log.action === ActionLogAction.COMMENT_DELETION">
<i18n
<i18n-t
tag="span"
path="{moderator} has deleted a comment from {author}"
keypath="{moderator} has deleted a comment from {author}"
>
<template #moderator>
<router-link
:to="{
name: RouteName.ADMIN_PROFILE,
params: { id: log.actor.id },
}"
>@{{ log.actor.preferredUsername }}</router-link
>
</template>
<template #author>
<router-link
:to="{
name: RouteName.ADMIN_PROFILE,
params: { id: log.object.actor.id },
}"
>{{ displayNameAndUsername(log.object.actor) }}
</router-link>
</template>
</i18n-t>
<pre v-html="log.object.text" />
</span>
<i18n-t
v-else
tag="span"
keypath="{moderator} has done an unknown action"
>
<template #moderator>
<router-link
slot="moderator"
:to="{
name: RouteName.ADMIN_PROFILE,
params: { id: log.actor.id },
}"
>@{{ log.actor.preferredUsername }}</router-link
>
<router-link
slot="author"
:to="{
name: RouteName.ADMIN_PROFILE,
params: { id: log.object.actor.id },
}"
>{{ displayNameAndUsername(log.object.actor) }}
</router-link>
</i18n>
<pre v-html="log.object.text" />
</span>
<i18n
v-else
tag="span"
path="{moderator} has done an unknown action"
>
<router-link
slot="moderator"
:to="{
name: RouteName.ADMIN_PROFILE,
params: { id: log.actor.id },
}"
>@{{ log.actor.preferredUsername }}</router-link
>
</i18n>
</template>
</i18n-t>
<br />
<small>{{ log.insertedAt | formatDateTimeString }}</small>
<small>{{ formatDateTimeString(log.insertedAt) }}</small>
</div>
</li>
</ul>
<b-pagination
<o-pagination
:total="actionLogs.total"
v-model="page"
:per-page="LOGS_PER_PAGE"
@@ -360,70 +385,49 @@
:aria-page-label="$t('Page')"
:aria-current-label="$t('Current page')"
>
</b-pagination>
</o-pagination>
</section>
<div v-else>
<b-message type="is-info">{{ $t("No moderation logs yet") }}</b-message>
<o-notification variant="info">{{
$t("No moderation logs yet")
}}</o-notification>
</div>
</div>
</template>
<script lang="ts">
import { Component, Vue, Watch } from "vue-property-decorator";
<script lang="ts" setup>
import { IActionLog } from "@/types/report.model";
import { LOGS } from "@/graphql/report";
import ReportCard from "@/components/Report/ReportCard.vue";
import { ActionLogAction } from "@/types/enums";
import RouteName from "../../router/name";
import { displayNameAndUsername } from "../../types/actor";
import { Paginate } from "@/types/paginate";
import { useQuery } from "@vue/apollo-composable";
import { integerTransformer, useRouteQuery } from "vue-use-route-query";
import { useHead } from "@vueuse/head";
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import { formatDateTimeString } from "@/filters/datetime";
@Component({
components: {
ReportCard,
},
apollo: {
actionLogs: {
fetchPolicy: "cache-and-network",
query: LOGS,
variables() {
return {
page: this.page,
limit: this.LOGS_PER_PAGE,
};
},
},
},
metaInfo() {
return {
title: this.$t("Moderation logs") as string,
};
},
})
export default class ReportList extends Vue {
actionLogs?: Paginate<IActionLog> = { total: 0, elements: [] };
const LOGS_PER_PAGE = 10;
page = parseInt((this.$route.query.page as string) || "1", 10);
const page = useRouteQuery("page", 1, integerTransformer);
LOGS_PER_PAGE = 10;
const { result: actionLogsResult } = useQuery<{
actionLogs: Paginate<IActionLog>;
}>(LOGS, () => ({
page: page.value,
limit: LOGS_PER_PAGE,
}));
ActionLogAction = ActionLogAction;
const actionLogs = computed(
() => actionLogsResult.value?.actionLogs ?? { total: 0, elements: [] }
);
RouteName = RouteName;
const { t } = useI18n({ useScope: "global" });
displayNameAndUsername = displayNameAndUsername;
mounted(): void {
this.page = parseInt((this.$route.query.page as string) || "1", 10);
}
@Watch("page")
triggerLoadMoreMemberPageChange(page: string): void {
this.$router.replace({
name: RouteName.REPORT_LOGS,
query: { ...this.$route.query, page },
});
}
}
useHead({
title: computed(() => t("Moderation logs")),
});
</script>
<style lang="scss" scoped>
img.image {

View File

@@ -5,54 +5,54 @@
:links="[
{
name: RouteName.MODERATION,
text: $t('Moderation'),
text: t('Moderation'),
},
{
name: RouteName.REPORTS,
text: $t('Reports'),
text: t('Reports'),
},
{
name: RouteName.REPORT,
params: { id: report.id },
text: $t('Report #{reportNumber}', { reportNumber: report.id }),
text: t('Report #{reportNumber}', { reportNumber: report.id }),
},
]"
/>
<section>
<b-message
<o-notification
title="Error"
type="is-danger"
variant="danger"
v-for="error in errors"
:key="error"
>
{{ error }}
</b-message>
<div class="container" v-if="report">
<div class="buttons">
<b-button
</o-notification>
<div class="container mx-auto" v-if="report">
<div class="flex flex-wrap gap-2">
<o-button
v-if="report.status !== ReportStatusEnum.RESOLVED"
@click="updateReport(ReportStatusEnum.RESOLVED)"
type="is-primary"
>{{ $t("Mark as resolved") }}</b-button
variant="primary"
>{{ t("Mark as resolved") }}</o-button
>
<b-button
<o-button
v-if="report.status !== ReportStatusEnum.OPEN"
@click="updateReport(ReportStatusEnum.OPEN)"
type="is-success"
>{{ $t("Reopen") }}</b-button
variant="success"
>{{ t("Reopen") }}</o-button
>
<b-button
<o-button
v-if="report.status !== ReportStatusEnum.CLOSED"
@click="updateReport(ReportStatusEnum.CLOSED)"
type="is-danger"
>{{ $t("Close") }}</b-button
variant="danger"
>{{ t("Close") }}</o-button
>
</div>
<div class="table-container">
<table class="table is-striped is-fullwidth">
<div class="w-full">
<table class="table w-full">
<tbody>
<tr v-if="report.reported.__typename === 'Group'">
<td>{{ $t("Reported group") }}</td>
<td>{{ t("Reported group") }}</td>
<td>
<router-link
:to="{
@@ -72,7 +72,7 @@
</tr>
<tr v-else>
<td>
{{ $t("Reported identity") }}
{{ t("Reported identity") }}
</td>
<td>
<router-link
@@ -92,7 +92,7 @@
</td>
</tr>
<tr>
<td>{{ $t("Reported by") }}</td>
<td>{{ t("Reported by") }}</td>
<td v-if="report.reporter.type === ActorType.APPLICATION">
{{ report.reporter.domain }}
</td>
@@ -114,30 +114,30 @@
</td>
</tr>
<tr>
<td>{{ $t("Reported") }}</td>
<td>{{ report.insertedAt | formatDateTimeString }}</td>
<td>{{ t("Reported") }}</td>
<td>{{ formatDateTimeString(report.insertedAt) }}</td>
</tr>
<tr v-if="report.updatedAt !== report.insertedAt">
<td>{{ $t("Updated") }}</td>
<td>{{ report.updatedAt | formatDateTimeString }}</td>
<td>{{ t("Updated") }}</td>
<td>{{ formatDateTimeString(report.updatedAt) }}</td>
</tr>
<tr>
<td>{{ $t("Status") }}</td>
<td>{{ t("Status") }}</td>
<td>
<span v-if="report.status === ReportStatusEnum.OPEN">{{
$t("Open")
t("Open")
}}</span>
<span v-else-if="report.status === ReportStatusEnum.CLOSED">
{{ $t("Closed") }}
{{ t("Closed") }}
</span>
<span v-else-if="report.status === ReportStatusEnum.RESOLVED">
{{ $t("Resolved") }}
{{ t("Resolved") }}
</span>
<span v-else>{{ $t("Unknown") }}</span>
<span v-else>{{ t("Unknown") }}</span>
</td>
</tr>
<tr v-if="report.event && report.comments.length > 0">
<td>{{ $t("Event") }}</td>
<td>{{ t("Event") }}</td>
<td>
<router-link
:to="{
@@ -148,18 +148,18 @@
{{ report.event.title }}
</router-link>
<span class="is-pulled-right">
<!-- <b-button-->
<!-- <o-button-->
<!-- tag="router-link"-->
<!-- type="is-primary"-->
<!-- variant="primary"-->
<!-- :to="{ name: RouteName.EDIT_EVENT, params: {eventId: report.event.uuid } }"-->
<!-- icon-left="pencil"-->
<!-- size="is-small">{{ $t('Edit') }}</b-button>-->
<b-button
type="is-danger"
<!-- size="small">{{ t('Edit') }}</o-button>-->
<o-button
variant="danger"
@click="confirmEventDelete()"
icon-left="delete"
size="is-small"
>{{ $t("Delete") }}</b-button
size="small"
>{{ t("Delete") }}</o-button
>
</span>
</td>
@@ -168,68 +168,68 @@
</table>
</div>
<div class="box report-content">
<div class="">
<p v-if="report.content" v-html="nl2br(report.content)" />
<p v-else>{{ $t("No comment") }}</p>
<p v-else>{{ t("No comment") }}</p>
</div>
<div class="box" v-if="report.event && report.comments.length === 0">
<div class="" v-if="report.event && report.comments.length === 0">
<router-link
:to="{ name: RouteName.EVENT, params: { uuid: report.event.uuid } }"
>
<h3 class="title">{{ report.event.title }}</h3>
<p v-html="report.event.description" />
</router-link>
<!-- <b-button-->
<!-- <o-button-->
<!-- tag="router-link"-->
<!-- type="is-primary"-->
<!-- variant="primary"-->
<!-- :to="{ name: RouteName.EDIT_EVENT, params: {eventId: report.event.uuid } }"-->
<!-- icon-left="pencil"-->
<!-- size="is-small">{{ $t('Edit') }}</b-button>-->
<b-button
type="is-danger"
<!-- size="small">{{ t('Edit') }}</o-button>-->
<o-button
variant="danger"
@click="confirmEventDelete()"
icon-left="delete"
size="is-small"
>{{ $t("Delete") }}</b-button
size="small"
>{{ t("Delete") }}</o-button
>
</div>
<div v-if="report.comments.length > 0">
<ul v-for="comment in report.comments" :key="comment.id">
<li>
<div class="box" v-if="comment">
<article class="media">
<div class="media-left">
<div class="" v-if="comment">
<article class="flex gap-1">
<div class="">
<figure
class="image is-48x48"
class=""
v-if="comment.actor && comment.actor.avatar"
>
<img :src="comment.actor.avatar.url" alt="Image" />
<img
:src="comment.actor.avatar.url"
alt=""
width="48"
height="48"
/>
</figure>
<b-icon
class="media-left"
v-else
size="is-large"
icon="account-circle"
/>
<AccountCircle :size="48" v-else />
</div>
<div class="media-content">
<div class="content">
<div class="">
<div class="prose dark:prose-invert">
<span v-if="comment.actor">
<strong>{{ comment.actor.name }}</strong>
<small>@{{ comment.actor.preferredUsername }}</small>
</span>
<span v-else>{{ $t("Unknown actor") }}</span>
<span v-else>{{ t("Unknown actor") }}</span>
<br />
<p v-html="comment.text" />
</div>
<b-button
type="is-danger"
<o-button
variant="danger"
@click="confirmCommentDelete(comment)"
icon-left="delete"
size="is-small"
>{{ $t("Delete") }}</b-button
size="small"
>{{ t("Delete") }}</o-button
>
</div>
</article>
@@ -238,7 +238,7 @@
</ul>
</div>
<h2 class="title" v-if="report.notes.length > 0">{{ $t("Notes") }}</h2>
<h2 v-if="report.notes.length > 0">{{ t("Notes") }}</h2>
<div
class="box note"
v-for="note in report.notes"
@@ -253,8 +253,8 @@
}"
>
<img
alt
class="image"
alt=""
class="rounded-full"
:src="note.moderator.avatar.url"
v-if="note.moderator.avatar"
/>
@@ -263,243 +263,245 @@
<br />
<small>
<a :href="`#note-${note.id}`" v-if="note.insertedAt">
{{ note.insertedAt | formatDateTimeString }}
{{ formatDateTimeString(note.insertedAt) }}
</a>
</small>
</div>
<form @submit="addNote()">
<b-field :label="$t('New note')" label-for="newNoteInput">
<b-input
<form
@submit="
createReportNoteMutation({
reportId: report?.id,
content: noteContent,
})
"
>
<o-field :label="t('New note')" label-for="newNoteInput">
<o-input
type="textarea"
v-model="noteContent"
id="newNoteInput"
></b-input>
</b-field>
<b-button type="submit" @click="addNote">{{
$t("Add a note")
}}</b-button>
></o-input>
</o-field>
<o-button class="mt-2" type="submit">{{ t("Add a note") }}</o-button>
</form>
</div>
</section>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
<script lang="ts" setup>
import { CREATE_REPORT_NOTE, REPORT, UPDATE_REPORT } from "@/graphql/report";
import { IReport, IReportNote } from "@/types/report.model";
import { CURRENT_ACTOR_CLIENT } from "@/graphql/actor";
import { IPerson, displayNameAndUsername } from "@/types/actor";
import { displayNameAndUsername } from "@/types/actor";
import { DELETE_EVENT } from "@/graphql/event";
import uniq from "lodash/uniq";
import { nl2br } from "@/utils/html";
import { DELETE_COMMENT } from "@/graphql/comment";
import { IComment } from "@/types/comment.model";
import { ActorType, ReportStatusEnum } from "@/types/enums";
import RouteName from "../../router/name";
import RouteName from "@/router/name";
import { GraphQLError } from "graphql";
import { ApolloCache, FetchResult } from "@apollo/client/core";
import { useMutation, useQuery } from "@vue/apollo-composable";
import { useCurrentActorClient } from "@/composition/apollo/actor";
import { useHead } from "@vueuse/head";
import { useI18n } from "vue-i18n";
import { useRouter } from "vue-router";
import { ref, computed, inject } from "vue";
import { formatDateTimeString } from "@/filters/datetime";
import AccountCircle from "vue-material-design-icons/AccountCircle.vue";
import { Dialog } from "@/plugins/dialog";
import { Notifier } from "@/plugins/notifier";
@Component({
apollo: {
report: {
const router = useRouter();
const props = defineProps<{ reportId: string }>();
const { currentActor } = useCurrentActorClient();
const { result: reportResult, onError: onReportQueryError } = useQuery<{
report: IReport;
}>(REPORT, () => ({
id: props.reportId,
}));
const report = computed(() => reportResult.value?.report);
onReportQueryError(({ graphQLErrors }) => {
errors.value = uniq(
graphQLErrors.map(({ message }: GraphQLError) => message)
);
});
const { t } = useI18n({ useScope: "global" });
useHead({
title: computed(() => t("Report")),
});
const notifier = inject<Notifier>("notifier");
const errors = ref<string[]>([]);
const noteContent = ref("");
const {
mutate: createReportNoteMutation,
onDone: createReportNoteMutationDone,
onError: createReportNoteMutationError,
} = useMutation<{
createReportNote: IReportNote;
}>(CREATE_REPORT_NOTE, () => ({
update: (
store: ApolloCache<{ createReportNote: IReportNote }>,
{ data }: FetchResult
) => {
if (data == null) return;
const cachedData = store.readQuery<{ report: IReport }>({
query: REPORT,
fetchPolicy: "cache-and-network",
variables() {
return {
id: this.reportId,
};
},
error({ graphQLErrors }) {
this.errors = uniq(
graphQLErrors.map(({ message }: GraphQLError) => message)
);
},
},
currentActor: {
query: CURRENT_ACTOR_CLIENT,
},
variables: { id: report.value.id },
});
if (cachedData == null) return;
const { report } = cachedData;
if (report === null) {
console.error("Cannot update event notes cache, because of null value.");
return;
}
const note = data.createReportNote;
note.moderator = currentActor.value;
report.notes = report.notes.concat([note]);
store.writeQuery({
query: REPORT,
variables: { id: report.value.id },
data: { report },
});
},
metaInfo() {
return {
title: this.$t("Report") as string,
titleTemplate: "%s | Mobilizon",
}));
createReportNoteMutationDone(() => {
noteContent.value = "";
});
createReportNoteMutationError((error) => {
console.error(error);
});
const dialog = inject<Dialog>("dialog");
const confirmEventDelete = (): void => {
dialog?.confirm({
title: t("Deleting event"),
message: t(
"Are you sure you want to <b>delete</b> this event? This action cannot be undone. You may want to engage the discussion with the event creator or edit its event instead."
),
confirmText: t("Delete Event"),
type: "danger",
hasIcon: true,
onConfirm: () => deleteEvent(),
});
};
const confirmCommentDelete = (comment: IComment): void => {
dialog?.confirm({
title: t("Deleting comment"),
message: t(
"Are you sure you want to <b>delete</b> this comment? This action cannot be undone."
),
confirmText: t("Delete Comment"),
type: "danger",
hasIcon: true,
onConfirm: () => deleteCommentMutation({ commentId: comment.id }),
});
};
const {
mutate: deleteEventMutation,
onDone: deleteEventMutationDone,
onError: deleteEventMutationError,
} = useMutation<{ deleteEvent: { id: string } }>(DELETE_EVENT);
deleteEventMutationDone(() => {
const eventTitle = report.value?.event?.title;
notifier?.success(
t("Event {eventTitle} deleted", {
eventTitle,
})
);
});
deleteEventMutationError((error) => {
console.error(error);
});
const deleteEvent = async (): Promise<void> => {
if (!report.value?.event?.id) return;
deleteEventMutation({ eventId: report.value.event.id });
};
const {
mutate: deleteCommentMutation,
onDone: deleteCommentMutationDone,
onError: deleteCommentMutationError,
} = useMutation<{ deleteComment: { id: string } }>(DELETE_COMMENT);
deleteCommentMutationDone(() => {
notifier?.success(t("Comment deleted") as string);
});
deleteCommentMutationError((error) => {
console.error(error);
});
const {
mutate: updateReportMutation,
onDone: onUpdateReportMutation,
onError: onUpdateReportError,
} = useMutation(UPDATE_REPORT, () => ({
update: (
store: ApolloCache<{ updateReportStatus: IReport }>,
{ data }: FetchResult
) => {
if (data == null) return;
const reportCachedData = store.readQuery<{ report: IReport }>({
query: REPORT,
variables: { id: report.value.id },
});
if (reportCachedData == null) return;
const { report } = reportCachedData;
if (report === null) {
console.error("Cannot update event notes cache, because of null value.");
return;
}
const updatedReport = {
...report,
status: data.updateReportStatus.status,
};
store.writeQuery({
query: REPORT,
variables: { id: report.value.id },
data: { report: updatedReport },
});
},
})
export default class Report extends Vue {
@Prop({ required: true }) reportId!: number;
}));
report!: IReport;
onUpdateReportMutation(() => {
router.push({ name: RouteName.REPORTS });
});
currentActor!: IPerson;
onUpdateReportError((error) => {
console.error(error);
});
errors: string[] = [];
ReportStatusEnum = ReportStatusEnum;
RouteName = RouteName;
ActorType = ActorType;
nl2br = nl2br;
noteContent = "";
displayNameAndUsername = displayNameAndUsername;
addNote(): void {
try {
this.$apollo.mutate<{ createReportNote: IReportNote }>({
mutation: CREATE_REPORT_NOTE,
variables: {
reportId: this.report.id,
content: this.noteContent,
},
update: (
store: ApolloCache<{ createReportNote: IReportNote }>,
{ data }: FetchResult
) => {
if (data == null) return;
const cachedData = store.readQuery<{ report: IReport }>({
query: REPORT,
variables: { id: this.report.id },
});
if (cachedData == null) return;
const { report } = cachedData;
if (report === null) {
console.error(
"Cannot update event notes cache, because of null value."
);
return;
}
const note = data.createReportNote;
note.moderator = this.currentActor;
report.notes = report.notes.concat([note]);
store.writeQuery({
query: REPORT,
variables: { id: this.report.id },
data: { report },
});
},
});
this.noteContent = "";
} catch (error) {
console.error(error);
}
}
confirmEventDelete(): void {
this.$buefy.dialog.confirm({
title: this.$t("Deleting event") as string,
message: this.$t(
"Are you sure you want to <b>delete</b> this event? This action cannot be undone. You may want to engage the discussion with the event creator or edit its event instead."
) as string,
confirmText: this.$t("Delete Event") as string,
type: "is-danger",
hasIcon: true,
onConfirm: () => this.deleteEvent(),
});
}
confirmCommentDelete(comment: IComment): void {
this.$buefy.dialog.confirm({
title: this.$t("Deleting comment") as string,
message: this.$t(
"Are you sure you want to <b>delete</b> this comment? This action cannot be undone."
) as string,
confirmText: this.$t("Delete Comment") as string,
type: "is-danger",
hasIcon: true,
onConfirm: () => this.deleteComment(comment),
});
}
async deleteEvent(): Promise<void> {
if (!this.report.event || !this.report.event.id) return;
const eventTitle = this.report.event.title;
try {
await this.$apollo.mutate({
mutation: DELETE_EVENT,
variables: {
eventId: this.report.event.id.toString(),
},
});
this.$buefy.notification.open({
message: this.$t("Event {eventTitle} deleted", {
eventTitle,
}) as string,
type: "is-success",
position: "is-bottom-right",
duration: 5000,
});
} catch (error) {
console.error(error);
}
}
async deleteComment(comment: IComment): Promise<void> {
try {
await this.$apollo.mutate({
mutation: DELETE_COMMENT,
variables: {
commentId: comment.id,
},
});
this.$notifier.success(this.$t("Comment deleted") as string);
} catch (error) {
console.error(error);
}
}
async updateReport(status: ReportStatusEnum): Promise<void> {
try {
await this.$apollo.mutate<{ updateReportStatus: IReport }>({
mutation: UPDATE_REPORT,
variables: {
reportId: this.report.id,
status,
},
update: (
store: ApolloCache<{ updateReportStatus: IReport }>,
{ data }: FetchResult
) => {
if (data == null) return;
const reportCachedData = store.readQuery<{ report: IReport }>({
query: REPORT,
variables: { id: this.report.id },
});
if (reportCachedData == null) return;
const { report } = reportCachedData;
if (report === null) {
console.error(
"Cannot update event notes cache, because of null value."
);
return;
}
const updatedReport = {
...report,
status: data.updateReportStatus.status,
};
store.writeQuery({
query: REPORT,
variables: { id: this.report.id },
data: { report: updatedReport },
});
},
});
await this.$router.push({ name: RouteName.REPORTS });
} catch (error) {
console.error(error);
}
}
}
const updateReport = async (status: ReportStatusEnum): Promise<void> => {
updateReportMutation({
reportId: report.value?.id,
status,
});
};
</script>
<style lang="scss" scoped>
tbody td img.image,

View File

@@ -14,35 +14,29 @@
/>
<section>
<div class="flex flex-wrap gap-2">
<b-field :label="$t('Report status')">
<b-radio-button
v-model="status"
:native-value="ReportStatusEnum.OPEN"
>{{ $t("Open") }}</b-radio-button
>
<b-radio-button
v-model="status"
:native-value="ReportStatusEnum.RESOLVED"
>{{ $t("Resolved") }}</b-radio-button
>
<b-radio-button
v-model="status"
:native-value="ReportStatusEnum.CLOSED"
>{{ $t("Closed") }}</b-radio-button
>
</b-field>
<b-field
<o-field :label="$t('Report status')">
<o-radio v-model="status" :native-value="ReportStatusEnum.OPEN">{{
$t("Open")
}}</o-radio>
<o-radio v-model="status" :native-value="ReportStatusEnum.RESOLVED">{{
$t("Resolved")
}}</o-radio>
<o-radio v-model="status" :native-value="ReportStatusEnum.CLOSED">{{
$t("Closed")
}}</o-radio>
</o-field>
<o-field
:label="$t('Domain')"
label-for="domain-filter"
class="flex-auto"
>
<b-input
<o-input
id="domain-filter"
:placeholder="$t('mobilizon-instance.tld')"
:value="filterDomain"
@input="debouncedUpdateDomainFilter"
/>
</b-field>
</o-field>
</div>
<ul v-if="reports.elements.length > 0">
<li v-for="report in reports.elements" :key="report.id">
@@ -76,7 +70,7 @@
{{ $t("No closed reports yet") }}
</empty-content>
</div>
<b-pagination
<o-pagination
:total="reports.total"
v-model="page"
:simple="true"
@@ -86,116 +80,65 @@
:aria-page-label="$t('Page')"
:aria-current-label="$t('Current page')"
>
</b-pagination>
</o-pagination>
</section>
</div>
</template>
<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
<script lang="ts" setup>
import { IReport } from "@/types/report.model";
import { REPORTS } from "@/graphql/report";
import ReportCard from "@/components/Report/ReportCard.vue";
import EmptyContent from "@/components/Utils/EmptyContent.vue";
import { ReportStatusEnum } from "@/types/enums";
import RouteName from "../../router/name";
import VueRouter from "vue-router";
import { Paginate } from "@/types/paginate";
import debounce from "lodash/debounce";
const { isNavigationFailure, NavigationFailureType } = VueRouter;
import { useQuery } from "@vue/apollo-composable";
import { useI18n } from "vue-i18n";
import { useHead } from "@vueuse/head";
import { computed, ref } from "vue";
import {
enumTransformer,
integerTransformer,
useRouteQuery,
} from "vue-use-route-query";
const REPORT_PAGE_LIMIT = 10;
const page = useRouteQuery("page", 1, integerTransformer);
const filterDomain = useRouteQuery("filterDomain", "");
const status = useRouteQuery(
"status",
ReportStatusEnum.OPEN,
enumTransformer(ReportStatusEnum)
);
@Component({
components: {
ReportCard,
EmptyContent,
},
apollo: {
reports: {
fetchPolicy: "cache-and-network",
query: REPORTS,
variables() {
return {
page: this.page,
status: this.status,
limit: REPORT_PAGE_LIMIT,
domain: this.filterDomain,
};
},
pollInterval: 120000, // 2 minutes
},
},
metaInfo() {
return {
title: this.$t("Reports") as string,
};
},
})
export default class ReportList extends Vue {
reports?: Paginate<IReport> = { elements: [], total: 0 };
const { result: reportsResult } = useQuery<{ reports: Paginate<IReport> }>(
REPORTS,
() => ({
page: page.value,
status: status.value,
limit: REPORT_PAGE_LIMIT,
domain: filterDomain.value,
})
);
RouteName = RouteName;
const reports = computed(
() => reportsResult.value?.reports ?? { elements: [], total: 0 }
);
ReportStatusEnum = ReportStatusEnum;
const { t } = useI18n({ useScope: "global" });
filterReports: ReportStatusEnum = ReportStatusEnum.OPEN;
useHead({
title: computed(() => t("Reports")),
});
REPORT_PAGE_LIMIT = REPORT_PAGE_LIMIT;
const filterReports = ref<ReportStatusEnum>(ReportStatusEnum.OPEN);
data(): Record<string, unknown> {
return {
debouncedUpdateDomainFilter: debounce(this.updateDomainFilter, 500),
};
}
const updateDomainFilter = (event: InputEvent) => {
filterDomain.value = event.target?.value;
};
async updateDomainFilter(domain: string) {
this.filterDomain = domain;
}
get page(): number {
return parseInt((this.$route.query.page as string) || "1", 10);
}
set page(page: number) {
this.pushRouter({
page: page.toString(),
});
}
get status(): ReportStatusEnum {
const filter = (this.$route.query.status || "") as string;
if (filter in ReportStatusEnum) {
return filter as ReportStatusEnum;
}
return ReportStatusEnum.OPEN;
}
set status(status: ReportStatusEnum) {
this.pushRouter({ status });
}
get filterDomain(): string {
return (this.$route.query.domain as string) || "";
}
set filterDomain(domain: string) {
this.pushRouter({ domain });
}
protected async pushRouter(args: Record<string, string>): Promise<void> {
try {
await this.$router.push({
name: RouteName.REPORTS,
params: this.$route.params,
query: { ...this.$route.query, ...args },
});
} catch (e) {
if (isNavigationFailure(e, NavigationFailureType.redirected)) {
throw Error(e.toString());
}
}
}
}
const debouncedUpdateDomainFilter = debounce(updateDomainFilter, 500);
</script>
<style lang="scss" scoped>

View File

@@ -1,14 +1,14 @@
<template>
<section class="section container has-text-centered not-found">
<div class="columns is-vertical is-centered">
<div class="column is-half">
<section class="container mx-auto pt-4 is-max-desktop max-w-2xl">
<div class="">
<div class="">
<picture>
<source
srcset="/img/pics/error-480w.webp 1x, /img/pics/error-1024w.webp 2x"
:srcset="`/img/pics/error-480w.webp 1x, /img/pics/error-1024w.webp 2x`"
type="image/webp"
/>
<source
srcset="/img/pics/error-480w.jpg 1x, /img/pics/error-1024w.jpg 2x"
:srcset="`/img/pics/error-480w.jpg 1x, /img/pics/error-1024w.jpg 2x`"
type="image/jpeg"
/>
@@ -20,7 +20,7 @@
loading="lazy"
/>
</picture>
<h1 class="title">
<h1 class="text-4xl mb-3">
{{ $t("The page you're looking for doesn't exist.") }}
</h1>
<p>
@@ -38,9 +38,9 @@
}}
</p>
<!-- The following should just be replaced with the SearchField component but it fails for some reason -->
<form @submit.prevent="enter">
<b-field class="search">
<b-input
<form @submit.prevent="enter" class="flex flex-wrap mt-3">
<o-field expanded>
<o-input
expanded
icon="magnify"
type="search"
@@ -52,53 +52,35 @@
{{ $t("Search") }}
</button>
</p>
</b-field>
</o-field>
</form>
</div>
</div>
</section>
</template>
<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
import BField from "buefy/src/components/field/Field.vue";
<script lang="ts" setup>
import { useHead } from "@vueuse/head";
import { computed, ref } from "vue";
import { useI18n } from "vue-i18n";
import { useRouter } from "vue-router";
import RouteName from "../router/name";
@Component({
components: {
BField,
},
metaInfo() {
return {
title: this.$t("Page not found") as string,
};
},
})
export default class PageNotFound extends Vue {
searchText = "";
const searchText = ref("");
get searchPlaceHolder(): string {
return this.$t("Search events, groups, etc.") as string;
}
const { t } = useI18n({ useScope: "global" });
useHead({
title: computed(() => t("Page not found")),
});
const router = useRouter();
async enter(): Promise<void> {
await this.$router.push({
name: RouteName.SEARCH,
query: { term: this.searchText },
});
}
}
const searchPlaceHolder = computed((): string => {
return t("Search events, groups, etc.") as string;
});
const enter = async (): Promise<void> => {
await router.push({
name: RouteName.SEARCH,
query: { term: searchText.value },
});
};
</script>
<style lang="scss">
.container.not-found {
margin: auto;
background: $white;
img {
margin-top: 3rem;
}
p {
margin-bottom: 1em;
}
}
</style>

View File

@@ -1,45 +1,50 @@
<template>
<div>
<form @submit.prevent="publish(false)" v-if="isCurrentActorAGroupModerator">
<div class="container section">
<div class="container mx-auto">
<breadcrumbs-nav v-if="actualGroup" :links="breadcrumbLinks" />
<h1 class="title" v-if="isUpdate === true">
<h1 v-if="isUpdate === true">
{{ $t("Edit post") }}
</h1>
<h1 class="title" v-else>
<h1 v-else>
{{ $t("Add a new post") }}
</h1>
<subtitle>{{ $t("General information") }}</subtitle>
<h2>{{ $t("General information") }}</h2>
<picture-upload
v-model="pictureFile"
:textFallback="$t('Headline picture')"
:defaultImage="editablePost.picture"
/>
<b-field
<o-field
:label="$t('Title')"
label-for="post-title"
:type="errors.title ? 'is-danger' : null"
:message="errors.title"
>
<b-input
size="is-large"
<o-input
size="large"
aria-required="true"
required
v-model="editablePost.title"
id="post-title"
dir="auto"
/>
</b-field>
</o-field>
<tag-input v-model="editablePost.tags" />
<tag-input v-model="editablePost.tags" :fetch-tags="fetchTags" />
<div class="field">
<label class="label">{{ $t("Post") }}</label>
<o-field :label="t('Post')">
<p v-if="errors.body" class="help is-danger">{{ errors.body }}</p>
<editor v-model="editablePost.body" :aria-label="$t('Post body')" />
</div>
<subtitle>{{ $t("Who can view this post") }}</subtitle>
<editor
class="w-full"
v-if="currentActor"
v-model="editablePost.body"
:aria-label="$t('Post body')"
:current-actor="currentActor"
/>
</o-field>
<h2 class="mt-2">{{ $t("Who can view this post") }}</h2>
<fieldset>
<legend>
{{
@@ -49,358 +54,380 @@
}}
</legend>
<div class="field">
<b-radio
<o-radio
v-model="editablePost.visibility"
name="postVisibility"
:native-value="PostVisibility.PUBLIC"
>{{ $t("Visible everywhere on the web") }}</b-radio
>{{ $t("Visible everywhere on the web") }}</o-radio
>
</div>
<div class="field">
<b-radio
<o-radio
v-model="editablePost.visibility"
name="postVisibility"
:native-value="PostVisibility.UNLISTED"
>{{ $t("Only accessible through link") }}</b-radio
>{{ $t("Only accessible through link") }}</o-radio
>
</div>
<div class="field">
<b-radio
<o-radio
v-model="editablePost.visibility"
name="postVisibility"
:native-value="PostVisibility.PRIVATE"
>{{ $t("Only accessible to members of the group") }}</b-radio
>{{ $t("Only accessible to members of the group") }}</o-radio
>
</div>
</fieldset>
</div>
<nav class="navbar">
<div class="container">
<div class="navbar-menu">
<div class="navbar-end">
<span class="navbar-item">
<b-button type="is-text" @click="$router.go(-1)">{{
$t("Cancel")
}}</b-button>
</span>
<span class="navbar-item" v-if="this.isUpdate">
<b-button
type="is-danger is-outlined"
@click="openDeletePostModal"
>{{ $t("Delete post") }}</b-button
>
</span>
<div class="container mx-auto">
<div class="navbar-menu flex flex-wrap py-2">
<div class="flex flex-wrap justify-end ml-auto gap-1">
<o-button type="is-text" @click="$router.go(-1)">{{
$t("Cancel")
}}</o-button>
<o-button
v-if="isUpdate"
type="is-danger is-outlined"
@click="openDeletePostModal"
>{{ $t("Delete post") }}</o-button
>
<!-- If an post has been published we can't make it draft anymore -->
<span class="navbar-item" v-if="post.draft === true">
<b-button type="is-primary" outlined @click="publish(true)">{{
$t("Save draft")
}}</b-button>
</span>
<span class="navbar-item">
<b-button type="is-primary" native-type="submit">
<span v-if="isUpdate === false || post.draft === true">{{
$t("Publish")
}}</span>
<o-button
variant="primary"
v-if="post?.draft === true"
outlined
@click="publish(true)"
>{{ $t("Save draft") }}</o-button
>
<o-button variant="primary" native-type="submit">
<span v-if="isUpdate === false || post?.draft === true">{{
$t("Publish")
}}</span>
<span v-else>{{ $t("Update post") }}</span>
</b-button>
</span>
<span v-else>{{ $t("Update post") }}</span>
</o-button>
</div>
</div>
</div>
</nav>
</form>
<b-loading
v-else-if="$apollo.loading"
<o-loading
v-else-if="postLoading"
:is-full-page="false"
:active.sync="$apollo.loading"
v-model:active="postLoading"
:can-cancel="false"
></b-loading>
<div class="container section" v-else>
<b-message type="is-danger">
></o-loading>
<div class="container mx-auto" v-else>
<o-notification variant="danger">
{{ $t("Only group moderators can create, edit and delete posts.") }}
</b-message>
</o-notification>
</div>
</div>
</template>
<script lang="ts">
import { Component, Prop, Watch } from "vue-property-decorator";
import { mixins } from "vue-class-component";
<script lang="ts" setup>
import {
buildFileFromIMedia,
buildFileVariable,
readFileAsync,
} from "@/utils/image";
import GroupMixin from "@/mixins/group";
import { PostVisibility } from "@/types/enums";
import { CONFIG } from "../../graphql/config";
import { CREATE_POST, UPDATE_POST } from "../../graphql/post";
import { MemberRole, PostVisibility } from "@/types/enums";
import {
CREATE_POST,
DELETE_POST,
FETCH_POST,
UPDATE_POST,
} from "../../graphql/post";
import { IPost } from "../../types/post.model";
import Editor from "../../components/Editor.vue";
import { displayName, IActor, usernameWithDomain } from "../../types/actor";
import TagInput from "../../components/Event/TagInput.vue";
import RouteName from "../../router/name";
import Subtitle from "../../components/Utils/Subtitle.vue";
import PictureUpload from "../../components/PictureUpload.vue";
import { PERSON_STATUS_GROUP } from "@/graphql/actor";
import { FETCH_GROUP } from "@/graphql/group";
import PostMixin from "../../mixins/post";
import { useGroup } from "@/composition/apollo/group";
import {
useCurrentActorClient,
usePersonStatusGroup,
} from "@/composition/apollo/actor";
import { useHead } from "@vueuse/head";
import { useI18n } from "vue-i18n";
import { computed, inject, onMounted, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { useMutation, useQuery } from "@vue/apollo-composable";
import { fetchTags } from "@/composition/apollo/tags";
import { Dialog } from "@/plugins/dialog";
@Component({
apollo: {
config: CONFIG,
group: {
query: FETCH_GROUP,
variables() {
return {
name: this.preferredUsername,
};
},
skip() {
return !this.preferredUsername;
},
const props = withDefaults(
defineProps<{
slug?: string;
preferredUsername?: string;
isUpdate?: boolean;
}>(),
{ isUpdate: false }
);
const { currentActor } = useCurrentActorClient();
const { group } = useGroup(props.preferredUsername);
const { result: postResult, loading: postLoading } = useQuery<{
post: IPost;
}>(FETCH_POST, () => ({ slug: props.slug }));
const post = computed(() => postResult.value?.post);
const pictureFile = ref<File | null>(null);
const errors = ref<Record<string, unknown>>({});
const editablePost = ref<IPost>({
title: "",
body: "",
local: true,
draft: true,
visibility: PostVisibility.PUBLIC,
tags: [],
});
onMounted(async () => {
pictureFile.value = await buildFileFromIMedia(post.value?.picture);
});
watch(post, async (newPost: IPost, oldPost: IPost) => {
if (oldPost?.picture !== newPost.picture) {
pictureFile.value = await buildFileFromIMedia(post.value?.picture);
}
editablePost.value = { ...post.value };
});
const router = useRouter();
const { mutate: updatePost, onDone: onUpdateDone } = useMutation<{
updatePost: IPost;
}>(UPDATE_POST);
const {
mutate: createPost,
onDone: onCreateDone,
onError: onCreateError,
} = useMutation<{
createPost: IPost;
}>(CREATE_POST);
onUpdateDone(({ data }) => {
if (data && data.updatePost) {
router.push({
name: RouteName.POST,
params: { slug: data.updatePost.slug },
});
}
});
onCreateDone(({ data }) => {
if (data && data.createPost) {
router.push({
name: RouteName.POST,
params: { slug: data.createPost.slug },
});
}
});
onCreateError((error) => {
console.error(error);
errors.value = error.graphQLErrors.reduce(
(acc: { [key: string]: any }, localError: any) => {
acc[localError.field] = transformMessage(localError.message);
return acc;
},
person: {
query: PERSON_STATUS_GROUP,
fetchPolicy: "cache-and-network",
variables() {
return {
id: this.currentActor.id,
group: usernameWithDomain(this.actualGroup),
};
},
skip() {
return !this.currentActor?.id || !this.actualGroup?.preferredUsername;
{}
);
});
const publish = async (draft: boolean): Promise<void> => {
errors.value = {};
if (props.isUpdate) {
updatePost({
id: editablePost.value?.id,
title: editablePost.value?.title,
body: editablePost.value?.body,
tags: (editablePost.value?.tags || []).map(({ title }) => title),
visibility: editablePost.value?.visibility,
draft,
...(await buildPicture()),
});
} else {
createPost({
...editablePost.value,
...(await buildPicture()),
tags: (editablePost.value?.tags ?? []).map(({ title }) => title),
attributedToId: actualGroup.value.id,
draft,
});
}
};
const transformMessage = (message: string[] | string): string | undefined => {
if (Array.isArray(message) && message.length > 0) {
return message[0];
}
if (typeof message === "string") {
return message;
}
return undefined;
};
const buildPicture = async (): Promise<Record<string, unknown>> => {
let obj: { picture?: any } = {};
if (pictureFile.value) {
const pictureObj = buildFileVariable(pictureFile.value, "picture");
obj = { ...obj, ...pictureObj };
}
try {
if (editablePost.value?.picture && pictureFile.value) {
const oldPictureFile = (await buildFileFromIMedia(
editablePost.value.picture
)) as File;
const oldPictureFileContent = await readFileAsync(oldPictureFile);
const newPictureFileContent = await readFileAsync(
pictureFile.value as File
);
if (oldPictureFileContent === newPictureFileContent) {
obj.picture = { mediaId: editablePost.value.picture.id };
}
}
} catch (e: any) {
console.error(e);
}
return obj;
};
const actualGroup = computed((): IActor => {
if (!group.value?.id) {
return post.value?.attributedTo as IActor;
}
return group.value;
});
const actualPreferredUsername = computed(() =>
usernameWithDomain(actualGroup.value)
);
const { t } = useI18n({ useScope: "global" });
const breadcrumbLinks = computed(() => {
const links = [
{
name: RouteName.GROUP,
params: {
preferredUsername: usernameWithDomain(actualGroup.value),
},
text: displayName(actualGroup.value),
},
},
components: {
Editor,
TagInput,
Subtitle,
PictureUpload,
},
metaInfo() {
return {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
title: this.isUpdate
? (this.$t("Edit post") as string)
: (this.$t("Add a new post") as string),
// all titles will be injected into this template
titleTemplate: "%s | Mobilizon",
};
},
})
export default class EditPost extends mixins(GroupMixin, PostMixin) {
@Prop({ required: false, type: String }) slug: undefined | string;
@Prop({ required: false, type: String }) preferredUsername!: string;
@Prop({ type: Boolean, default: false }) isUpdate!: boolean;
post: IPost = {
title: "",
body: "",
local: true,
draft: true,
visibility: PostVisibility.PUBLIC,
tags: [],
};
PostVisibility = PostVisibility;
pictureFile: File | null = null;
errors: Record<string, unknown> = {};
RouteName = RouteName;
editablePost: IPost = this.post;
usernameWithDomain = usernameWithDomain;
async mounted(): Promise<void> {
this.pictureFile = await buildFileFromIMedia(this.post.picture);
}
@Watch("post")
async updatePostPicture(oldPost: IPost, newPost: IPost): Promise<void> {
if (oldPost.picture !== newPost.picture) {
this.pictureFile = await buildFileFromIMedia(this.post.picture);
}
this.editablePost = { ...this.post };
}
// eslint-disable-next-line consistent-return
async publish(draft: boolean): Promise<void> {
this.errors = {};
if (this.isUpdate) {
const { data } = await this.$apollo.mutate({
mutation: UPDATE_POST,
variables: {
id: this.editablePost.id,
title: this.editablePost.title,
body: this.editablePost.body,
tags: (this.editablePost.tags || []).map(({ title }) => title),
visibility: this.editablePost.visibility,
draft,
...(await this.buildPicture()),
},
});
if (data && data.updatePost) {
this.$router.push({
name: RouteName.POST,
params: { slug: data.updatePost.slug },
});
}
} else {
try {
const { data } = await this.$apollo.mutate({
mutation: CREATE_POST,
variables: {
...this.editablePost,
...(await this.buildPicture()),
tags: (this.editablePost.tags || []).map(({ title }) => title),
attributedToId: this.actualGroup.id,
draft,
},
});
if (data && data.createPost) {
this.$router.push({
name: RouteName.POST,
params: { slug: data.createPost.slug },
});
}
} catch (error: any) {
console.error(error);
this.errors = error.graphQLErrors.reduce(
(acc: { [key: string]: any }, localError: any) => {
acc[localError.field] = EditPost.transformMessage(
localError.message
);
return acc;
},
{}
);
}
}
}
static transformMessage(message: string[] | string): string | undefined {
if (Array.isArray(message) && message.length > 0) {
return message[0];
}
if (typeof message === "string") {
return message;
}
return undefined;
}
async buildPicture(): Promise<Record<string, unknown>> {
let obj: { picture?: any } = {};
if (this.pictureFile) {
const pictureObj = buildFileVariable(this.pictureFile, "picture");
obj = { ...obj, ...pictureObj };
}
try {
if (this.editablePost.picture && this.pictureFile) {
const oldPictureFile = (await buildFileFromIMedia(
this.editablePost.picture
)) as File;
const oldPictureFileContent = await readFileAsync(oldPictureFile);
const newPictureFileContent = await readFileAsync(
this.pictureFile as File
);
if (oldPictureFileContent === newPictureFileContent) {
obj.picture = { mediaId: this.editablePost.picture.id };
}
}
} catch (e: any) {
console.error(e);
}
return obj;
}
get actualGroup(): IActor {
if (!this.group?.id) {
return this.post.attributedTo as IActor;
}
return this.group;
}
get breadcrumbLinks() {
const links = [
{
name: RouteName.GROUP,
params: {
preferredUsername: usernameWithDomain(this.actualGroup),
},
text: displayName(this.actualGroup),
{
name: RouteName.POSTS,
params: {
preferredUsername: usernameWithDomain(actualGroup.value),
},
{
name: RouteName.POSTS,
params: {
preferredUsername: usernameWithDomain(this.actualGroup),
},
text: this.$t("Posts"),
},
];
if (this.preferredUsername) {
links.push({
text: this.$t("New post") as string,
name: RouteName.POST_EDIT,
params: { preferredUsername: usernameWithDomain(this.actualGroup) },
});
} else {
links.push({
text: this.$t("Edit post") as string,
name: RouteName.POST_EDIT,
params: { preferredUsername: usernameWithDomain(this.actualGroup) },
});
}
return links;
text: t("Posts"),
},
];
if (props.preferredUsername) {
links.push({
text: t("New post") as string,
name: RouteName.POST_EDIT,
params: { preferredUsername: usernameWithDomain(actualGroup.value) },
});
} else {
links.push({
text: t("Edit post") as string,
name: RouteName.POST_EDIT,
params: { preferredUsername: usernameWithDomain(actualGroup.value) },
});
}
}
return links;
});
const isCurrentActorAGroupModerator = computed((): boolean => {
return hasCurrentActorThisRole([
MemberRole.MODERATOR,
MemberRole.ADMINISTRATOR,
]);
});
const hasCurrentActorThisRole = (givenRole: string | string[]): boolean => {
const roles = Array.isArray(givenRole) ? givenRole : [givenRole];
return (
personMemberships.value?.total > 0 &&
roles.includes(personMemberships.value?.elements[0].role)
);
};
const { person } = usePersonStatusGroup(actualPreferredUsername);
const personMemberships = computed(
() => person.value?.memberships ?? { total: 0, elements: [] }
);
const dialog = inject<Dialog>("dialog");
const openDeletePostModal = async (): Promise<void> => {
dialog?.confirm({
type: "danger",
title: t("Delete post"),
message: t(
"Are you sure you want to delete this post? This action cannot be reverted."
),
onConfirm: () =>
deletePost({
id: post.value?.id,
}),
});
};
const {
mutate: deletePost,
onDone: onDeletePostDone,
onError: onDeletePostError,
} = useMutation(DELETE_POST);
onDeletePostDone(({ data }) => {
if (data && post.value?.attributedTo) {
router.push({
name: RouteName.POSTS,
params: {
preferredUsername: usernameWithDomain(post.value?.attributedTo),
},
});
}
});
useHead({
title: computed(() =>
props.isUpdate ? t("Edit post") : t("Add a new post")
),
});
</script>
<style lang="scss" scoped>
@use "@/styles/_mixins" as *;
.container.section {
background: $white;
}
form {
nav.navbar {
min-height: 2rem !important;
// min-height: 2rem !important;
background: lighten($secondary, 10%);
.container {
min-height: 2rem;
// min-height: 2rem;
.navbar-menu,
.navbar-end {
display: flex !important;
// display: flex !important;
background: lighten($secondary, 10%);
flex-wrap: wrap;
// flex-wrap: wrap;
}
.navbar-end {
justify-content: flex-end;
@include margin-left(auto);
// justify-content: flex-end;
// @include margin-left(auto);
}
}
}
h2 {
margin: 15px 0 7.5px;
}
legend {
margin-bottom: 0.75rem;
}
}
.breadcrumb li.is-active > span {
padding: 0 0.75em;
}
</style>

View File

@@ -1,5 +1,5 @@
<template>
<div class="container section" v-if="group">
<div class="container mx-auto section" v-if="group">
<breadcrumbs-nav
:links="[
{
@@ -26,14 +26,16 @@
<p v-if="isCurrentActorMember">
{{ $t("Only group moderators can create, edit and delete posts.") }}
</p>
<router-link
<o-button
tag="router-link"
v-if="isCurrentActorAGroupModerator"
:to="{
name: RouteName.POST_CREATE,
params: { preferredUsername: usernameWithDomain(group) },
}"
class="button is-primary"
>{{ $t("+ Create a post") }}</router-link
variant="primary"
class="my-2"
>{{ $t("+ Create a post") }}</o-button
>
</div>
<div class="post-list">
@@ -42,14 +44,18 @@
:isCurrentActorMember="isCurrentActorMember"
/>
</div>
<b-loading :active.sync="$apollo.loading"></b-loading>
<b-message
v-if="group.posts.elements.length === 0 && $apollo.loading === false"
type="is-danger"
<o-loading v-model:active="loading"></o-loading>
<o-notification
v-if="
group.posts.elements.length === 0 &&
membershipsLoading === false &&
groupLoading === false
"
variant="danger"
>
{{ $t("No posts found") }}
</b-message>
<b-pagination
</o-notification>
<o-pagination
:total="group.posts.total"
v-model="postsPage"
:per-page="POSTS_PAGE_LIMIT"
@@ -58,105 +64,92 @@
:aria-page-label="$t('Page')"
:aria-current-label="$t('Current page')"
>
</b-pagination>
</o-pagination>
</section>
</div>
</template>
<script lang="ts">
import { Component, Prop } from "vue-property-decorator";
import { CURRENT_ACTOR_CLIENT, PERSON_MEMBERSHIPS } from "@/graphql/actor";
import { mixins } from "vue-class-component";
import GroupMixin from "@/mixins/group";
import { IMember } from "@/types/actor/member.model";
<script lang="ts" setup>
import { PERSON_MEMBERSHIPS } from "@/graphql/actor";
import { FETCH_GROUP_POSTS } from "../../graphql/post";
import { Paginate } from "../../types/paginate";
import { IPost } from "../../types/post.model";
import { usernameWithDomain, displayName } from "../../types/actor";
import {
usernameWithDomain,
displayName,
IPerson,
IGroup,
} from "../../types/actor";
import RouteName from "../../router/name";
import MultiPostListItem from "../../components/Post/MultiPostListItem.vue";
import { useCurrentActorClient } from "@/composition/apollo/actor";
import { useQuery } from "@vue/apollo-composable";
import { computed } from "vue";
import { useHead } from "@vueuse/head";
import { integerTransformer, useRouteQuery } from "vue-use-route-query";
import { useI18n } from "vue-i18n";
import { MemberRole } from "@/types/enums";
const props = defineProps<{ preferredUsername: string }>();
const postsPage = useRouteQuery("page", 1, integerTransformer);
const POSTS_PAGE_LIMIT = 10;
@Component({
apollo: {
currentActor: CURRENT_ACTOR_CLIENT,
memberships: {
query: PERSON_MEMBERSHIPS,
fetchPolicy: "cache-and-network",
variables() {
return {
id: this.currentActor.id,
};
},
update: (data) => data.person.memberships.elements,
skip() {
return !this.currentActor || !this.currentActor.id;
},
},
group: {
query: FETCH_GROUP_POSTS,
fetchPolicy: "cache-and-network",
variables() {
return {
preferredUsername: this.preferredUsername,
page: this.postsPage,
limit: POSTS_PAGE_LIMIT,
};
},
skip() {
return !this.preferredUsername;
},
},
},
components: {
MultiPostListItem,
},
metaInfo() {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const { group } = this;
return {
title: this.$t("{group} posts", {
group: group?.name || usernameWithDomain(group),
}) as string,
};
},
})
export default class PostList extends mixins(GroupMixin) {
@Prop({ required: true, type: String }) preferredUsername!: string;
const { currentActor } = useCurrentActorClient();
posts!: Paginate<IPost>;
const { result: membershipsResult, loading: membershipsLoading } = useQuery<{
person: Pick<IPerson, "memberships">;
}>(
PERSON_MEMBERSHIPS,
() => ({ id: currentActor.value?.id }),
() => ({ enabled: currentActor.value?.id !== undefined })
);
const memberships = computed(() => membershipsResult.value?.person.memberships);
memberships!: IMember[];
const { result: groupPostsResult, loading: groupLoading } = useQuery<{
group: IGroup;
}>(
FETCH_GROUP_POSTS,
() => ({
preferredUsername: props.preferredUsername,
page: postsPage.value,
limit: POSTS_PAGE_LIMIT,
}),
() => ({ enabled: props.preferredUsername !== undefined })
);
postsPage = 1;
const group = computed(() => groupPostsResult.value?.group);
RouteName = RouteName;
const { t } = useI18n({ useScope: "global" });
usernameWithDomain = usernameWithDomain;
useHead({
title: computed(() =>
t("{group} posts", {
group: displayName(group.value),
})
),
});
displayName = displayName;
const loading = computed(() => membershipsLoading.value || groupLoading.value);
POSTS_PAGE_LIMIT = POSTS_PAGE_LIMIT;
const isCurrentActorMember = computed((): boolean => {
if (!group.value || !memberships.value) return false;
return memberships.value.elements
.map(({ parent: { id } }) => id)
.includes(group.value.id);
});
get isCurrentActorMember(): boolean {
if (!this.group || !this.memberships) return false;
return this.memberships
.map(({ parent: { id } }) => id)
.includes(this.group.id);
}
}
const isCurrentActorAGroupModerator = computed((): boolean => {
return hasCurrentActorThisRole([
MemberRole.MODERATOR,
MemberRole.ADMINISTRATOR,
]);
});
const hasCurrentActorThisRole = (givenRole: string | string[]): boolean => {
const roles = Array.isArray(givenRole) ? givenRole : [givenRole];
return (
memberships.value !== undefined &&
memberships.value?.total > 0 &&
roles.includes(memberships.value?.elements[0].role)
);
};
</script>
<style lang="scss" scoped>
.container.section {
background: $white;
}
section {
div.intro,
.post-list {
margin-bottom: 1rem;
}
}
</style>

View File

@@ -1,27 +1,29 @@
<template>
<article class="container post" v-if="post">
<article class="container mx-auto post" v-if="post">
<header>
<div class="banner-container">
<div class="flex justify-center">
<lazy-image-wrapper :picture="post.picture" />
</div>
<div class="heading-section">
<div class="heading-wrapper" dir="auto">
<div class="title-metadata">
<div class="title-wrapper">
<div class="relative flex flex-col">
<div
class="px-2 py-3 flex flex-wrap justify-center items-center"
dir="auto"
>
<div class="flex-1">
<div class="inline">
<b-tag
class="mr-2"
type="is-warning"
variant="warning"
size="is-medium"
v-if="post.draft"
>{{ $t("Draft") }}</b-tag
>
<h1 class="title text-3xl" :lang="post.language">
<h1 class="inline" :lang="post.language">
{{ post.title }}
</h1>
</div>
<p class="metadata">
<p class="mt-2 flex flex-col flex-wrap justify-start">
<router-link
slot="author"
:to="{
name: RouteName.GROUP,
params: {
@@ -29,65 +31,65 @@
},
}"
>
<actor-inline :actor="post.attributedTo" />
<actor-inline
v-if="post.attributedTo"
:actor="post.attributedTo"
/>
</router-link>
<span class="published has-text-grey-dark" v-if="!post.draft">
<b-icon icon="clock" size="is-small" />
{{ post.publishAt | formatDateTimeString }}
<span
class="inline-flex gap-2 items-center mt-2"
v-if="!post.draft && post.publishAt"
>
<Clock :size="16" />
{{ formatDateTimeString(post.publishAt) }}
</span>
<span
class="published has-text-grey-dark"
class="inline-flex gap-2 items-center mt-2"
:title="
$options.filters.formatDateTimeString(
post.updatedAt,
undefined,
true,
'short'
)
formatDateTimeString(post.updatedAt, undefined, true, 'short')
"
v-else
v-else-if="post.updatedAt"
>
<b-icon icon="clock" size="is-small" />
<Clock :size="16" />
{{
$t("Edited {relative_time} ago", {
relative_time: formatDistanceToNowStrict(
new Date(post.updatedAt),
{
locale: $dateFnsLocale,
locale: dateFnsLocale,
}
),
})
}}
</span>
<span
v-if="post.visibility === PostVisibility.UNLISTED"
class="has-text-grey-dark"
>
<b-icon icon="link" size="is-small" />
<span v-if="post.visibility === PostVisibility.UNLISTED" class="">
<o-icon icon="link" size="small" />
{{ $t("Accessible only by link") }}
</span>
<span
v-else-if="post.visibility === PostVisibility.PRIVATE"
class="has-text-grey-dark"
class=""
>
<b-icon icon="lock" size="is-small" />
<Lock :size="16" />
{{
$t("Accessible only to members", {
group: post.attributedTo.name,
group: post.attributedTo?.name,
})
}}
</span>
</p>
</div>
<b-dropdown position="is-bottom-left" aria-role="list">
<b-button slot="trigger" role="button" icon-right="dots-horizontal">
{{ $t("Actions") }}
</b-button>
<b-dropdown-item
<o-dropdown position="bottom-left" aria-role="list">
<template #trigger>
<o-button role="button" icon-right="dots-horizontal">
{{ $t("Actions") }}
</o-button>
</template>
<o-dropdown-item
aria-role="listitem"
has-link
v-if="
currentActor.id === post.author.id ||
currentActor?.id === post?.author?.id ||
isCurrentActorAGroupModerator
"
>
@@ -96,32 +98,32 @@
name: RouteName.POST_EDIT,
params: { slug: post.slug },
}"
>{{ $t("Edit") }} <b-icon icon="pencil"
>{{ $t("Edit") }} <o-icon icon="pencil"
/></router-link>
</b-dropdown-item>
<b-dropdown-item
</o-dropdown-item>
<o-dropdown-item
aria-role="listitem"
v-if="
currentActor.id === post.author.id ||
currentActor?.id === post?.author?.id ||
isCurrentActorAGroupModerator
"
@click="openDeletePostModal"
@keyup.enter="openDeletePostModal"
>
{{ $t("Delete") }}
<b-icon icon="delete" />
</b-dropdown-item>
<o-icon icon="delete" />
</o-dropdown-item>
<hr
role="presentation"
class="dropdown-divider"
aria-role="menuitem"
v-if="
currentActor.id === post.author.id ||
currentActor?.id === post?.author?.id ||
isCurrentActorAGroupModerator
"
/>
<b-dropdown-item
<o-dropdown-item
aria-role="listitem"
v-if="!post.draft"
@click="triggerShare()"
@@ -129,11 +131,11 @@
>
<span>
{{ $t("Share this event") }}
<b-icon icon="share" />
<o-icon icon="share" />
</span>
</b-dropdown-item>
</o-dropdown-item>
<b-dropdown-item
<o-dropdown-item
aria-role="listitem"
v-if="ableToReport"
@click="isReportModalActive = true"
@@ -141,20 +143,21 @@
>
<span>
{{ $t("Report") }}
<b-icon icon="flag" />
<o-icon icon="flag" />
</span>
</b-dropdown-item>
</b-dropdown>
</o-dropdown-item>
</o-dropdown>
</div>
</div>
</header>
<b-message
<o-notification
:title="$t('Members-only post')"
class="mx-4"
type="is-warning"
variant="warning"
:closable="false"
v-if="
!$apollo.loading &&
!membershipsLoading &&
!postLoading &&
isInstanceModerator &&
!isCurrentActorAGroupMember &&
post.visibility === PostVisibility.PRIVATE
@@ -165,15 +168,15 @@
"This post is accessible only for members. You have access to it for moderation purposes only because you are an instance moderator."
)
}}
</b-message>
</o-notification>
<section
v-html="post.body"
dir="auto"
class="content"
class="px-1 prose lg:prose-xl prose-p:mt-6 dark:prose-invert"
:lang="post.language"
/>
<section class="tags" dir="auto">
<section class="flex gap-2 my-6" dir="auto">
<router-link
v-for="tag in post.tags"
:key="tag.title"
@@ -182,9 +185,9 @@
<tag>{{ tag.title }}</tag>
</router-link>
</section>
<b-modal
<o-modal
:close-button-aria-label="$t('Close')"
:active.sync="isReportModalActive"
v-model:active="isReportModalActive"
has-modal-card
ref="reportModal"
>
@@ -192,198 +195,228 @@
:on-confirm="reportPost"
:title="$t('Report this post')"
:outside-domain="groupDomain"
@close="$refs.reportModal.close()"
@close="isReportModalActive = false"
/>
</b-modal>
<b-modal
:active.sync="isShareModalActive"
</o-modal>
<o-modal
v-model:active="isShareModalActive"
has-modal-card
ref="shareModal"
:close-button-aria-label="$t('Close')"
>
<share-post-modal :post="post" />
</b-modal>
</o-modal>
</article>
</template>
<script lang="ts">
import { Component, Prop } from "vue-property-decorator";
import { mixins } from "vue-class-component";
import GroupMixin from "@/mixins/group";
import { ICurrentUserRole, PostVisibility } from "@/types/enums";
import { IMember } from "@/types/actor/member.model";
<script lang="ts" setup>
import { ICurrentUserRole, MemberRole, PostVisibility } from "@/types/enums";
import { PERSON_MEMBERSHIPS } from "@/graphql/actor";
import {
CURRENT_ACTOR_CLIENT,
PERSON_MEMBERSHIPS,
PERSON_STATUS_GROUP,
} from "../../graphql/actor";
import { usernameWithDomain } from "../../types/actor";
import RouteName from "../../router/name";
import Tag from "../../components/Tag.vue";
import LazyImageWrapper from "../../components/Image/LazyImageWrapper.vue";
import ActorInline from "../../components/Account/ActorInline.vue";
import { formatDistanceToNowStrict } from "date-fns";
import { CURRENT_USER_CLIENT } from "@/graphql/user";
import { ICurrentUser } from "@/types/current-user.model";
import { CONFIG } from "@/graphql/config";
import { IConfig } from "@/types/config.model";
import SharePostModal from "../../components/Post/SharePostModal.vue";
import { IReport } from "@/types/report.model";
IGroup,
IPerson,
usernameWithDomain,
displayName,
} from "@/types/actor";
import RouteName from "@/router/name";
import Tag from "@/components/Tag.vue";
import LazyImageWrapper from "@/components/Image/LazyImageWrapper.vue";
import ActorInline from "@/components/Account/ActorInline.vue";
import { formatDistanceToNowStrict, Locale } from "date-fns";
import SharePostModal from "@/components/Post/SharePostModal.vue";
import { CREATE_REPORT } from "@/graphql/report";
import ReportModal from "../../components/Report/ReportModal.vue";
import PostMixin from "../../mixins/post";
import ReportModal from "@/components/Report/ReportModal.vue";
import { useAnonymousReportsConfig } from "@/composition/apollo/config";
import {
useCurrentActorClient,
usePersonStatusGroup,
} from "@/composition/apollo/actor";
import { useCurrentUserClient } from "@/composition/apollo/user";
import { useMutation, useQuery } from "@vue/apollo-composable";
import { computed, inject, ref } from "vue";
import { IPost } from "@/types/post.model";
import { DELETE_POST, FETCH_POST } from "@/graphql/post";
import { useHead } from "@vueuse/head";
import { formatDateTimeString } from "@/filters/datetime";
import { useRouter } from "vue-router";
import { useCreateReport } from "@/composition/apollo/report";
import Clock from "vue-material-design-icons/Clock.vue";
import Lock from "vue-material-design-icons/Lock.vue";
import { Dialog } from "@/plugins/dialog";
import { useI18n } from "vue-i18n";
import { Notifier } from "@/plugins/notifier";
@Component({
apollo: {
config: CONFIG,
currentUser: CURRENT_USER_CLIENT,
currentActor: CURRENT_ACTOR_CLIENT,
memberships: {
query: PERSON_MEMBERSHIPS,
fetchPolicy: "cache-and-network",
variables() {
return {
id: this.currentActor.id,
};
const props = defineProps<{
slug: string;
}>();
const { anonymousReportsConfig } = useAnonymousReportsConfig();
const { currentUser } = useCurrentUserClient();
const { currentActor } = useCurrentActorClient();
const { result: membershipsResult, loading: membershipsLoading } = useQuery<{
person: Pick<IPerson, "memberships">;
}>(
PERSON_MEMBERSHIPS,
() => ({ id: currentActor.value?.id }),
() => ({ enabled: currentActor.value?.id !== undefined })
);
const memberships = computed(() => membershipsResult.value?.person.memberships);
const { result: postResult, loading: postLoading } = useQuery<{
post: IPost;
}>(FETCH_POST, () => ({ slug: props.slug }));
const post = computed(() => postResult.value?.post);
usePersonStatusGroup(usernameWithDomain(post.value?.attributedTo as IGroup));
useHead({
title: computed(
() => `${post.value?.title} - ${displayName(post.value?.attributedTo)}`
),
});
const notifier = inject<Notifier>("notifier");
const isShareModalActive = ref(false);
const isReportModalActive = ref(false);
const reportModal = ref();
const isCurrentActorMember = computed((): boolean => {
if (!post.value?.attributedTo || !memberships.value) return false;
return memberships.value.elements
.map(({ parent: { id } }) => id)
.includes(post.value?.attributedTo.id);
});
const isInstanceModerator = computed((): boolean => {
return (
currentUser.value?.role !== undefined &&
[ICurrentUserRole.ADMINISTRATOR, ICurrentUserRole.MODERATOR].includes(
currentUser.value?.role
)
);
});
const ableToReport = computed((): boolean => {
return (
currentActor.value?.id != undefined ||
anonymousReportsConfig.value?.allowed === true
);
});
const triggerShare = (): void => {
if (navigator.share) {
navigator
.share({
title: post.value?.title,
url: post.value?.url,
})
.then(() => console.log("Successful share"))
.catch((error: any) => console.log("Error sharing", error));
} else {
isShareModalActive.value = true;
// send popup
}
};
const {
mutate: createReportMutation,
onDone: onCreateReportDone,
onError: onCreateReportError,
} = useCreateReport();
onCreateReportDone(() => {
isReportModalActive.value = false;
reportModal.value.close();
const postTitle = post.value?.title;
notifier?.success(t("Post {eventTitle} reported", { postTitle }));
});
onCreateReportError((error) => {
console.error(error);
});
const reportPost = async (content: string, forward: boolean): Promise<void> => {
createReportMutation({
// postId: post.value?.id,
reportedId: post.value?.attributedTo?.id as string,
content,
forward,
});
};
const groupDomain = computed((): string | undefined | null => {
return post.value?.attributedTo?.domain;
});
const dateFnsLocale = inject<Locale>("dateFnsLocale");
const isCurrentActorAGroupModerator = computed((): boolean => {
return hasCurrentActorThisRole([
MemberRole.MODERATOR,
MemberRole.ADMINISTRATOR,
]);
});
const hasCurrentActorThisRole = (givenRole: string | string[]): boolean => {
const roles = Array.isArray(givenRole)
? givenRole
: ([givenRole] as MemberRole[]);
return (
(memberships.value?.total ?? 0) > 0 &&
roles.includes(memberships.value?.elements[0].role as MemberRole)
);
};
const isCurrentActorAGroupMember = computed((): boolean => {
return hasCurrentActorThisRole([
MemberRole.MODERATOR,
MemberRole.ADMINISTRATOR,
MemberRole.MEMBER,
]);
});
const { t } = useI18n({ useScope: "global" });
const dialog = inject<Dialog>("dialog");
const openDeletePostModal = async (): Promise<void> => {
dialog?.confirm({
type: "danger",
title: t("Delete post"),
message: t(
"Are you sure you want to delete this post? This action cannot be reverted."
),
onConfirm: () =>
deletePost({
id: post.value?.id,
}),
});
};
const router = useRouter();
const {
mutate: deletePost,
onDone: onDeletePostDone,
onError: onDeletePostError,
} = useMutation(DELETE_POST);
onDeletePostDone(({ data }) => {
if (data && post.value?.attributedTo) {
router.push({
name: RouteName.POSTS,
params: {
preferredUsername: usernameWithDomain(post.value?.attributedTo),
},
update: (data) => data.person.memberships.elements,
skip() {
return !this.currentActor || !this.currentActor.id;
},
},
person: {
query: PERSON_STATUS_GROUP,
fetchPolicy: "cache-and-network",
variables() {
return {
id: this.currentActor.id,
group: usernameWithDomain(this.post.attributedTo),
};
},
skip() {
return (
!this.currentActor ||
!this.currentActor.id ||
!this.post?.attributedTo
);
},
},
},
components: {
Tag,
LazyImageWrapper,
ActorInline,
SharePostModal,
ReportModal,
},
metaInfo() {
return {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
title: this.post ? this.post.title : "",
// all titles will be injected into this template
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
titleTemplate: this.post ? "%s | Mobilizon" : "Mobilizon",
};
},
})
export default class Post extends mixins(GroupMixin, PostMixin) {
@Prop({ required: true, type: String }) slug!: string;
memberships!: IMember[];
config!: IConfig;
RouteName = RouteName;
currentUser!: ICurrentUser;
usernameWithDomain = usernameWithDomain;
formatDistanceToNowStrict = formatDistanceToNowStrict;
PostVisibility = PostVisibility;
isShareModalActive = false;
isReportModalActive = false;
get isCurrentActorMember(): boolean {
if (!this.post.attributedTo || !this.memberships) return false;
return this.memberships
.map(({ parent: { id } }) => id)
.includes(this.post.attributedTo.id);
});
}
get isInstanceModerator(): boolean {
return [
ICurrentUserRole.ADMINISTRATOR,
ICurrentUserRole.MODERATOR,
].includes(this.currentUser.role);
}
get ableToReport(): boolean {
return (
this.config &&
(this.currentActor.id != null || this.config.anonymous.reports.allowed)
);
}
triggerShare(): void {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore-start
if (navigator.share) {
navigator
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
.share({
title: this.post.title,
url: this.post.url,
})
.then(() => console.log("Successful share"))
.catch((error: any) => console.log("Error sharing", error));
} else {
this.isShareModalActive = true;
// send popup
}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore-end
}
async reportPost(content: string, forward: boolean): Promise<void> {
this.isReportModalActive = false;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
this.$refs.reportModal.close();
const postTitle = this.post.title;
try {
await this.$apollo.mutate<IReport>({
mutation: CREATE_REPORT,
variables: {
postId: this.post.id,
reportedId: this.post.attributedTo?.id,
content,
forward,
},
});
this.$notifier.success(
this.$t("Post {eventTitle} reported", { postTitle }) as string
);
} catch (error) {
console.error(error);
}
}
get groupDomain(): string | undefined | null {
return this.post.attributedTo?.domain;
}
}
});
</script>
<style lang="scss" scoped>
@use "@/styles/_mixins" as *;
article.post {
background: $white !important;
// background: $white !important;
header {
display: flex;
flex-direction: column;
@@ -463,36 +496,6 @@ article.post {
}
}
& > section {
margin: 0 2rem;
&.content {
font-size: 1.1rem;
}
&.tags {
padding-bottom: 5rem;
a {
text-decoration: none;
}
span {
&.tag {
margin: 0 2px;
}
}
}
}
margin: 0 auto;
a.dropdown-item,
.dropdown .dropdown-menu .has-link a,
button.dropdown-item {
white-space: nowrap;
width: 100%;
@include padding-right(1rem);
text-align: right;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,757 +0,0 @@
<template>
<div class="section container">
<h1 class="title">{{ $t("Explore") }}</h1>
<section v-if="tag">
<i18n path="Events tagged with {tag}">
<b-tag slot="tag" type="is-light">{{ $t("#{tag}", { tag }) }}</b-tag>
</i18n>
</section>
<section class="hero is-light" v-else>
<div class="hero-body">
<form @submit.prevent="submit()">
<b-field
class="searchQuery"
:label="$t('Key words')"
label-for="search"
>
<b-input
icon="magnify"
type="search"
id="search"
ref="autocompleteSearchInput"
:value="search"
@input="debouncedUpdateSearchQuery"
dir="auto"
:placeholder="
$t('For instance: London, Taekwondo, Architecture…')
"
/>
</b-field>
<full-address-auto-complete
class="searchLocation"
:label="$t('Location')"
v-model="location"
id="location"
ref="aac"
:placeholder="$t('For instance: London')"
@input="locchange"
:hideMap="true"
:hideSelected="true"
/>
<b-field
:label="$t('Radius')"
label-for="radius"
class="searchRadius"
>
<b-select expanded v-model="radius" id="radius">
<option
v-for="(radiusOption, index) in radiusOptions"
:key="index"
:value="radiusOption"
>
{{ radiusString(radiusOption) }}
</option>
</b-select>
</b-field>
<b-field :label="$t('Date')" label-for="date" class="searchDate">
<b-select
expanded
v-model="when"
id="date"
:disabled="activeTab !== 0"
>
<option
v-for="(option, index) in dateOptions"
:key="index"
:value="index"
>
{{ option.label }}
</option>
</b-select>
</b-field>
<b-field
expanded
:label="$t('Type')"
label-for="type"
class="searchType"
>
<b-select
expanded
v-model="type"
id="type"
:disabled="activeTab !== 0"
>
<option :value="null">
{{ $t("Any type") }}
</option>
<option :value="'ONLINE'">
{{ $t("Online") }}
</option>
<option :value="'IN_PERSON'">
{{ $t("In person") }}
</option>
</b-select>
</b-field>
<b-field
v-if="config"
expanded
:label="$t('Category')"
label-for="category"
class="searchCategory"
>
<b-select
expanded
v-model="eventCategory"
id="category"
:disabled="activeTab !== 0"
>
<option :value="null">
{{ $t("Any category") }}
</option>
<option
:value="category.id"
v-for="category in config.eventCategories"
:key="category.id"
>
{{ category.label }}
</option>
</b-select>
</b-field>
</form>
</div>
</section>
<section
class="events-featured"
v-if="!canSearchEvents && !canSearchGroups"
>
<b-loading :active.sync="$apollo.loading"></b-loading>
<h2 class="title">{{ $t("Featured events") }}</h2>
<div v-if="events.elements.length > 0">
<multi-card class="my-4" :events="events.elements" />
<div class="pagination" v-if="events.total > EVENT_PAGE_LIMIT">
<b-pagination
:total="events.total"
v-model="featuredEventPage"
:per-page="EVENT_PAGE_LIMIT"
:aria-next-label="$t('Next page')"
:aria-previous-label="$t('Previous page')"
:aria-page-label="$t('Page')"
:aria-current-label="$t('Current page')"
>
</b-pagination>
</div>
</div>
<b-message
v-else-if="events.elements.length === 0 && $apollo.loading === false"
type="is-danger"
>{{ $t("No events found") }}</b-message
>
</section>
<b-tabs v-else v-model="activeTab" type="is-boxed" class="mt-3 searchTabs">
<b-loading :active.sync="$apollo.loading"></b-loading>
<b-tab-item>
<template slot="header">
<b-icon icon="calendar"></b-icon>
<span>
{{ $t("Events") }}
<b-tag rounded>{{ searchEvents.total }}</b-tag>
</span>
</template>
<div v-if="searchEvents.total > 0">
<multi-card class="my-4" :events="searchEvents.elements" />
<div class="pagination" v-if="searchEvents.total > EVENT_PAGE_LIMIT">
<b-pagination
:total="searchEvents.total"
v-model="eventPage"
:per-page="EVENT_PAGE_LIMIT"
:aria-next-label="$t('Next page')"
:aria-previous-label="$t('Previous page')"
:aria-page-label="$t('Page')"
:aria-current-label="$t('Current page')"
>
</b-pagination>
</div>
</div>
<b-message v-else-if="$apollo.loading === false" type="is-danger">
<p>{{ $t("No events found") }}</p>
<p v-if="searchIsUrl && !currentUser.id">
{{
$t(
"Only registered users may fetch remote events from their URL."
)
}}
</p>
</b-message>
</b-tab-item>
<b-tab-item v-if="!tag">
<template slot="header">
<b-icon icon="account-multiple"></b-icon>
<span>
{{ $t("Groups") }} <b-tag rounded>{{ searchGroups.total }}</b-tag>
</span>
</template>
<b-message v-if="config && !config.features.groups" type="is-danger">
{{ $t("Groups are not enabled on this instance.") }}
</b-message>
<div v-else-if="searchGroups.total > 0">
<multi-group-card class="my-4" :groups="searchGroups.elements" />
<div class="pagination">
<b-pagination
:total="searchGroups.total"
v-model="groupPage"
:per-page="GROUP_PAGE_LIMIT"
:aria-next-label="$t('Next page')"
:aria-previous-label="$t('Previous page')"
:aria-page-label="$t('Page')"
:aria-current-label="$t('Current page')"
>
</b-pagination>
</div>
</div>
<b-message v-else-if="$apollo.loading === false" type="is-danger">
{{ $t("No groups found") }}
</b-message>
</b-tab-item>
</b-tabs>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
import ngeohash, { GeographicPoint } from "ngeohash";
import {
endOfToday,
addDays,
startOfDay,
endOfDay,
endOfWeek,
addWeeks,
startOfWeek,
endOfMonth,
addMonths,
startOfMonth,
eachWeekendOfInterval,
} from "date-fns";
import { SearchTabs } from "@/types/enums";
import MultiCard from "../components/Event/MultiCard.vue";
import { FETCH_EVENTS } from "../graphql/event";
import { EventType, IEvent } from "../types/event.model";
import RouteName from "../router/name";
import { IAddress, Address } from "../types/address.model";
import FullAddressAutoComplete from "../components/Event/FullAddressAutoComplete.vue";
import { SEARCH_EVENTS_AND_GROUPS } from "../graphql/search";
import { Paginate } from "../types/paginate";
import { IGroup } from "../types/actor";
import MultiGroupCard from "../components/Group/MultiGroupCard.vue";
import { CONFIG } from "../graphql/config";
import { REVERSE_GEOCODE } from "../graphql/address";
import debounce from "lodash/debounce";
import { CURRENT_USER_CLIENT } from "@/graphql/user";
import { ICurrentUser } from "@/types/current-user.model";
interface ISearchTimeOption {
label: string;
start?: Date | null;
end?: Date | null;
}
const EVENT_PAGE_LIMIT = 12;
const GROUP_PAGE_LIMIT = 12;
const DEFAULT_RADIUS = 25; // value to set if radius is null but location set
const DEFAULT_ZOOM = 11; // zoom on a city
const GEOHASH_DEPTH = 9; // put enough accuracy, radius will be used anyway
@Component({
components: {
MultiCard,
FullAddressAutoComplete,
MultiGroupCard,
},
apollo: {
config: CONFIG,
events: {
query: FETCH_EVENTS,
variables() {
return {
page: this.featuredEventPage,
limit: EVENT_PAGE_LIMIT,
};
},
},
searchElements: {
query: SEARCH_EVENTS_AND_GROUPS,
fetchPolicy: "cache-and-network",
variables() {
return {
term: this.search,
tags: this.tag,
location: this.geohash,
beginsOn: this.start,
endsOn: this.end,
radius: this.radius,
eventPage: this.eventPage,
groupPage: this.groupPage,
limit: EVENT_PAGE_LIMIT,
type: this.type,
eventCategory: this.eventCategory,
};
},
update(data) {
this.searchEvents = data.searchEvents;
this.searchGroups = data.searchGroups;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
this.$refs.autocompleteSearchInput?.focus();
},
},
currentUser: CURRENT_USER_CLIENT,
},
metaInfo() {
return {
title: this.$t("Explore events") as string,
titleTemplate: "%s | Mobilizon",
};
},
})
export default class Search extends Vue {
@Prop({ type: String, required: false }) tag!: string;
events: Paginate<IEvent> = {
total: 0,
elements: [],
};
searchEvents: Paginate<IEvent> = {
total: 0,
elements: [],
};
searchGroups: Paginate<IGroup> = { total: 0, elements: [] };
location: IAddress = new Address();
currentUser!: ICurrentUser;
dateOptions: Record<string, ISearchTimeOption> = {
past: {
label: this.$t("In the past") as string,
start: null,
end: new Date(),
},
today: {
label: this.$t("Today") as string,
start: new Date(),
end: endOfToday(),
},
tomorrow: {
label: this.$t("Tomorrow") as string,
start: startOfDay(addDays(new Date(), 1)),
end: endOfDay(addDays(new Date(), 1)),
},
weekend: {
label: this.$t("This weekend") as string,
start: this.weekend.start,
end: this.weekend.end,
},
week: {
label: this.$t("This week") as string,
start: new Date(),
end: endOfWeek(new Date(), { locale: this.$dateFnsLocale }),
},
next_week: {
label: this.$t("Next week") as string,
start: startOfWeek(addWeeks(new Date(), 1), {
locale: this.$dateFnsLocale,
}),
end: endOfWeek(addWeeks(new Date(), 1), { locale: this.$dateFnsLocale }),
},
month: {
label: this.$t("This month") as string,
start: new Date(),
end: endOfMonth(new Date()),
},
next_month: {
label: this.$t("Next month") as string,
start: startOfMonth(addMonths(new Date(), 1)),
end: endOfMonth(addMonths(new Date(), 1)),
},
any: {
label: this.$t("Any day") as string,
start: undefined,
end: undefined,
},
};
EVENT_PAGE_LIMIT = EVENT_PAGE_LIMIT;
GROUP_PAGE_LIMIT = GROUP_PAGE_LIMIT;
$refs!: {
aac: FullAddressAutoComplete;
autocompleteSearchInput: any;
};
data(): Record<string, unknown> {
return {
debouncedUpdateSearchQuery: debounce(this.updateSearchQuery, 500),
};
}
mounted(): void {
this.prepareLocation(this.$route.query.geohash as string);
}
radiusString = (radius: number | null): string => {
if (radius) {
return this.$tc("{nb} km", radius, { nb: radius }) as string;
}
return this.$t("any distance") as string;
};
radiusOptions: (number | null)[] = [1, 5, 10, 25, 50, 100, 150, null];
submit(): void {
this.$apollo.queries.searchEvents.refetch();
}
updateSearchQuery(searchQuery: string): void {
this.search = searchQuery;
}
get featuredEventPage(): number {
return parseInt(this.$route.query.featuredEventPage as string, 10) || 1;
}
set featuredEventPage(page: number) {
this.$router.push({
name: this.$route.name || RouteName.SEARCH,
query: { ...this.$route.query, featuredEventPage: page.toString() },
});
}
get eventPage(): number {
return parseInt(this.$route.query.eventPage as string, 10) || 1;
}
set eventPage(page: number) {
this.$router.push({
name: this.$route.name || RouteName.SEARCH,
query: { ...this.$route.query, eventPage: page.toString() },
});
}
get groupPage(): number {
return parseInt(this.$route.query.groupPage as string, 10) || 1;
}
set groupPage(page: number) {
this.$router.push({
name: this.$route.name || RouteName.SEARCH,
query: { ...this.$route.query, groupPage: page.toString() },
});
}
get search(): string | undefined {
return this.$route.query.term as string;
}
set search(term: string | undefined) {
this.$router.replace({
name: RouteName.SEARCH,
query: { ...this.$route.query, term },
});
}
get activeTab(): SearchTabs {
return (
parseInt(this.$route.query.searchType as string, 10) || SearchTabs.EVENTS
);
}
set activeTab(value: SearchTabs) {
this.$router.replace({
name: RouteName.SEARCH,
query: { ...this.$route.query, searchType: value.toString() },
});
}
get geohash(): string | undefined {
if (this.location?.geom) {
const [lon, lat] = this.location.geom.split(";");
return ngeohash.encode(lat, lon, GEOHASH_DEPTH);
}
return undefined;
}
set geohash(value: string | undefined) {
this.$router.replace({
name: RouteName.SEARCH,
query: { ...this.$route.query, geohash: value },
});
}
get radius(): number | null {
if (this.$route.query.radius === "any") {
return null;
}
return parseInt(this.$route.query.radius as string, 10) || null;
}
set radius(value: number | null) {
const radius = value === null ? "any" : value.toString();
this.$router.replace({
name: RouteName.SEARCH,
query: { ...this.$route.query, radius },
});
}
get when(): string {
return (this.$route.query.when as string) || "any";
}
set when(value: string) {
this.$router.replace({
name: RouteName.SEARCH,
query: { ...this.$route.query, when: value },
});
}
get type(): EventType {
return this.$route.query.type as EventType;
}
set type(type: EventType) {
const query = { ...this.$route.query, type };
if (type == null) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
delete query.type;
}
this.$router.replace({
name: RouteName.SEARCH,
query,
});
}
get eventCategory(): string | null {
return (this.$route.query.eventCategory as string) || null;
}
set eventCategory(eventCategory: string | null) {
const query = { ...this.$route.query, eventCategory };
if (query.eventCategory === null) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
delete query.eventCategory;
}
this.$router.replace({
name: RouteName.SEARCH,
query,
});
}
get weekend(): { start: Date; end: Date } {
const now = new Date();
const endOfWeekDate = endOfWeek(now, { locale: this.$dateFnsLocale });
const startOfWeekDate = startOfWeek(now, { locale: this.$dateFnsLocale });
const [start, end] = eachWeekendOfInterval({
start: startOfWeekDate,
end: endOfWeekDate,
});
return { start: startOfDay(start), end: endOfDay(end) };
}
private prepareLocation(value: string | undefined): void {
if (value !== undefined) {
// decode
const latlon = ngeohash.decode(value);
// set location
this.reverseGeoCode(latlon, DEFAULT_ZOOM);
}
}
async reverseGeoCode(e: GeographicPoint, zoom: number): Promise<void> {
const result = await this.$apollo.query({
query: REVERSE_GEOCODE,
variables: {
latitude: e.latitude,
longitude: e.longitude,
zoom,
locale: this.$i18n.locale,
},
});
const addressData = result.data.reverseGeocode.map(
(address: IAddress) => new Address(address)
);
if (addressData.length > 0) {
this.location = addressData[0];
}
}
locchange = (e: IAddress): void => {
if (this.radius === undefined || this.radius === null) {
this.radius = DEFAULT_RADIUS;
}
if (e?.geom) {
const [lon, lat] = e.geom.split(";");
this.geohash = ngeohash.encode(lat, lon, GEOHASH_DEPTH);
} else {
this.geohash = undefined;
}
};
get start(): Date | undefined | null {
if (this.dateOptions[this.when]) {
return this.dateOptions[this.when].start;
}
return undefined;
}
get end(): Date | undefined | null {
if (this.dateOptions[this.when]) {
return this.dateOptions[this.when].end;
}
return undefined;
}
get canSearchGroups(): boolean {
return (
this.stringExists(this.search) ||
(this.stringExists(this.geohash) && this.valueExists(this.radius))
);
}
get canSearchEvents(): boolean {
return (
this.stringExists(this.search) ||
this.stringExists(this.tag) ||
this.stringExists(this.type) ||
(this.stringExists(this.geohash) && this.valueExists(this.radius)) ||
this.valueExists(this.end)
);
}
// helper functions for skip
private valueExists(value: any): boolean {
return value !== undefined && value !== null;
}
private stringExists(value: string | null | undefined): boolean {
return this.valueExists(value) && (value as string).length > 0;
}
get searchIsUrl(): boolean {
let url;
if (!this.search) return false;
try {
url = new URL(this.search);
} catch (_) {
return false;
}
return url.protocol === "http:" || url.protocol === "https:";
}
}
</script>
<style scoped lang="scss">
@import "~bulma/sass/utilities/mixins.sass";
main > .container {
background: $white;
.hero-body {
padding: 1rem 1.5rem;
}
}
h1.title {
margin-top: 1.5rem;
}
h3.title {
margin-bottom: 1.5rem;
}
.events-featured {
margin: 25px auto;
.columns {
margin: 1rem auto 3rem;
}
}
form {
display: grid;
grid-gap: 0 15px;
grid-template-areas: "query" "location" "radius" "date" "type" "category";
& > * {
margin-bottom: 0 !important;
}
@include desktop {
grid-template-areas: "query . ." "location radius ." "date type category";
}
.searchQuery {
grid-area: query;
@include tablet {
grid-column: span 4;
}
@include desktop {
grid-column-start: 1;
grid-column-end: 5;
}
}
.searchLocation {
grid-area: location;
:v-deep .column {
padding-bottom: 0;
}
@include tablet {
grid-column: span 4;
}
@include desktop {
grid-column-start: 1;
grid-column-end: 4;
}
}
.searchRadius {
grid-area: radius;
@include desktop {
grid-column-start: 4;
grid-column-end: 5;
}
}
.searchDate {
grid-area: date;
@include desktop {
grid-column-start: 1;
grid-column-end: 2;
}
}
.searchType {
grid-area: type;
@include desktop {
grid-column-start: 2;
grid-column-end: 3;
}
}
.searchCategory {
grid-area: category;
@include desktop {
grid-column-start: 3;
grid-column-end: 5;
}
}
}
</style>

554
js/src/views/SearchView.vue Normal file
View File

@@ -0,0 +1,554 @@
<template>
<div class="container mx-auto mb-4">
<h1 class="">{{ $t("Explore") }}</h1>
<section v-if="tag">
<i18n-t keypath="Events tagged with {tag}">
<template v-slot:tag>
<b-tag variant="light">{{ $t("#{tag}", { tag }) }}</b-tag>
</template>
</i18n-t>
</section>
<section class="" v-else>
<div class="">
<form @submit.prevent="submit()">
<o-field
:label="$t('Key words')"
label-for="search"
class="text-black"
>
<o-input
icon="Magnify"
type="search"
id="search"
ref="autocompleteSearchInput"
:modelValue="search"
@modelValue:update="debouncedUpdateSearchQuery"
dir="auto"
:placeholder="
$t('For instance: London, Taekwondo, Architecture…')
"
/>
</o-field>
<full-address-auto-complete
:label="$t('Location')"
v-model="location"
id="location"
ref="aac"
:placeholder="$t('For instance: London')"
:hideMap="true"
:hideSelected="true"
/>
<o-field
:label="$t('Radius')"
label-for="radius"
class="searchRadius"
>
<o-select expanded v-model="radius" id="radius">
<option
v-for="(radiusOption, index) in radiusOptions"
:key="index"
:value="radiusOption"
>
{{ radiusString(radiusOption) }}
</option>
</o-select>
</o-field>
<o-field :label="$t('Date')" label-for="date">
<o-select
expanded
v-model="when"
id="date"
:disabled="activeTab !== 0"
>
<option
v-for="(option, index) in dateOptions"
:key="index"
:value="index"
>
{{ option.label }}
</option>
</o-select>
</o-field>
<o-field
expanded
:label="$t('Type')"
label-for="type"
class="searchType"
>
<o-select
expanded
v-model="type"
id="type"
:disabled="activeTab !== 0"
>
<option :value="null">
{{ $t("Any type") }}
</option>
<option :value="'ONLINE'">
{{ $t("Online") }}
</option>
<option :value="'IN_PERSON'">
{{ $t("In person") }}
</option>
</o-select>
</o-field>
<o-field
v-if="config"
expanded
:label="$t('Category')"
label-for="category"
class="searchCategory"
>
<o-select
expanded
v-model="eventCategory"
id="category"
:disabled="activeTab !== 0"
>
<option :value="null">
{{ $t("Any category") }}
</option>
<option
:value="category.id"
v-for="category in config.eventCategories"
:key="category.id"
>
{{ category.label }}
</option>
</o-select>
</o-field>
</form>
</div>
</section>
<section class="mt-4" v-if="!canSearchEvents && !canSearchGroups">
<!-- <o-loading v-model:active="$apollo.loading"></o-loading> -->
<h2>{{ $t("Featured events") }}</h2>
<div v-if="events && events.elements.length > 0">
<multi-card class="my-4" :events="events?.elements" />
<div
class="pagination"
v-if="events && events.total > EVENT_PAGE_LIMIT"
>
<o-pagination
:total="events.total"
v-model="featuredEventPage"
:per-page="EVENT_PAGE_LIMIT"
:aria-next-label="$t('Next page')"
:aria-previous-label="$t('Previous page')"
:aria-page-label="$t('Page')"
:aria-current-label="$t('Current page')"
>
</o-pagination>
</div>
</div>
<o-notification
v-else-if="
events &&
events.elements.length === 0 &&
featuredEventsLoading === false
"
variant="info"
>{{ $t("No events found") }}</o-notification
>
</section>
<o-tabs v-else v-model="activeTab" type="is-boxed">
<!-- <o-loading v-model:active="searchLoading"></o-loading> -->
<o-tab-item :value="SearchTabs.EVENTS">
<template #header>
<Calendar />
<span>
{{ $t("Events") }}
<b-tag rounded>{{ searchEvents?.total }}</b-tag>
</span>
</template>
<div v-if="searchEvents && searchEvents.total > 0">
<multi-card class="my-4" :events="searchEvents?.elements" />
<div
class="pagination"
v-if="searchEvents && searchEvents?.total > EVENT_PAGE_LIMIT"
>
<o-pagination
:total="searchEvents.total"
v-model="eventPage"
:per-page="EVENT_PAGE_LIMIT"
:aria-next-label="$t('Next page')"
:aria-previous-label="$t('Previous page')"
:aria-page-label="$t('Page')"
:aria-current-label="$t('Current page')"
>
</o-pagination>
</div>
</div>
<o-notification v-else-if="searchLoading === false" variant="primary">
<p>{{ $t("No events found") }}</p>
<p v-if="searchIsUrl && !currentUser?.id">
{{
$t(
"Only registered users may fetch remote events from their URL."
)
}}
</p>
</o-notification>
</o-tab-item>
<o-tab-item v-if="!tag" :value="SearchTabs.GROUPS">
<template #header>
<AccountMultiple />
<span>
{{ $t("Groups") }} <b-tag rounded>{{ searchGroups?.total }}</b-tag>
</span>
</template>
<o-notification
v-if="config && !config.features.groups"
variant="danger"
>
{{ $t("Groups are not enabled on this instance.") }}
</o-notification>
<div v-else-if="searchGroups && searchGroups?.total > 0">
<multi-group-card class="my-4" :groups="searchGroups?.elements" />
<div class="pagination">
<o-pagination
:total="searchGroups?.total"
v-model="groupPage"
:per-page="GROUP_PAGE_LIMIT"
:aria-next-label="$t('Next page')"
:aria-previous-label="$t('Previous page')"
:aria-page-label="$t('Page')"
:aria-current-label="$t('Current page')"
>
</o-pagination>
</div>
</div>
<o-notification v-else-if="searchLoading === false" variant="danger">
{{ $t("No groups found") }}
</o-notification>
</o-tab-item>
</o-tabs>
</div>
</template>
<script lang="ts" setup>
import ngeohash, { GeographicPoint } from "ngeohash";
import {
endOfToday,
addDays,
startOfDay,
endOfDay,
endOfWeek,
addWeeks,
startOfWeek,
endOfMonth,
addMonths,
startOfMonth,
eachWeekendOfInterval,
} from "date-fns";
import { SearchTabs } from "@/types/enums";
import MultiCard from "../components/Event/MultiCard.vue";
import { FETCH_EVENTS } from "../graphql/event";
import { IEvent } from "../types/event.model";
import { IAddress, Address } from "../types/address.model";
import FullAddressAutoComplete from "../components/Event/FullAddressAutoComplete.vue";
import { SEARCH_EVENTS_AND_GROUPS } from "../graphql/search";
import { Paginate } from "../types/paginate";
import { IGroup } from "../types/actor";
import MultiGroupCard from "../components/Group/MultiGroupCard.vue";
import { CONFIG } from "../graphql/config";
import { REVERSE_GEOCODE } from "../graphql/address";
import debounce from "lodash/debounce";
import { CURRENT_USER_CLIENT } from "@/graphql/user";
import { ICurrentUser } from "@/types/current-user.model";
import { useQuery } from "@vue/apollo-composable";
import { computed, inject, onMounted, ref, reactive } from "vue";
import { useI18n } from "vue-i18n";
import {
floatTransformer,
integerTransformer,
useRouteQuery,
} from "vue-use-route-query";
import { IConfig } from "@/types/config.model";
import Calendar from "vue-material-design-icons/Calendar.vue";
import AccountMultiple from "vue-material-design-icons/AccountMultiple.vue";
import { useHead } from "@vueuse/head";
import type { Locale } from "date-fns";
const search = useRouteQuery("search", "");
interface ISearchTimeOption {
label: string;
start?: Date | null;
end?: Date | null;
}
const featuredEventPage = useRouteQuery(
"featuredEventPage",
1,
integerTransformer
);
const eventPage = useRouteQuery("eventPage", 1, integerTransformer);
const groupPage = useRouteQuery("groupPage", 1, integerTransformer);
const activeTab = useRouteQuery(
"searchType",
SearchTabs.EVENTS,
integerTransformer
);
const geohash = useRouteQuery("geohash", "");
const radius = useRouteQuery("radius", null, floatTransformer);
const when = useRouteQuery("when", "any");
const type = useRouteQuery("type", "");
const eventCategory = useRouteQuery("eventCategory", "any");
const EVENT_PAGE_LIMIT = 12;
const GROUP_PAGE_LIMIT = 12;
// const DEFAULT_RADIUS = 25; // value to set if radius is null but location set
const DEFAULT_ZOOM = 11; // zoom on a city
// const GEOHASH_DEPTH = 9; // put enough accuracy, radius will be used anyway
const props = defineProps<{
tag?: string;
}>();
const { result: configResult } = useQuery<{ config: IConfig }>(CONFIG);
const config = computed(() => configResult.value?.config);
const searchEvents = computed(() => searchElementsResult.value?.searchEvents);
const searchGroups = computed(() => searchElementsResult.value?.searchGroups);
const { result: currentUserResult } = useQuery<{ currentUser: ICurrentUser }>(
CURRENT_USER_CLIENT
);
const currentUser = computed(() => currentUserResult.value?.currentUser);
const { t, locale } = useI18n({ useScope: "global" });
useHead({
title: computed(() => t("Explore events")),
});
const location = ref<IAddress>(new Address());
const dateFnsLocale = inject<Locale>("dateFnsLocale");
const weekend = computed((): { start: Date; end: Date } => {
const now = new Date();
const endOfWeekDate = endOfWeek(now, { locale: dateFnsLocale });
const startOfWeekDate = startOfWeek(now, { locale: dateFnsLocale });
const [start, end] = eachWeekendOfInterval({
start: startOfWeekDate,
end: endOfWeekDate,
});
return { start: startOfDay(start), end: endOfDay(end) };
});
const dateOptions: Record<string, ISearchTimeOption> = {
past: {
label: t("In the past") as string,
start: null,
end: new Date(),
},
today: {
label: t("Today") as string,
start: new Date(),
end: endOfToday(),
},
tomorrow: {
label: t("Tomorrow") as string,
start: startOfDay(addDays(new Date(), 1)),
end: endOfDay(addDays(new Date(), 1)),
},
weekend: {
label: t("This weekend") as string,
start: weekend.value.start,
end: weekend.value.end,
},
week: {
label: t("This week") as string,
start: new Date(),
end: endOfWeek(new Date(), { locale: dateFnsLocale }),
},
next_week: {
label: t("Next week") as string,
start: startOfWeek(addWeeks(new Date(), 1), {
locale: dateFnsLocale,
}),
end: endOfWeek(addWeeks(new Date(), 1), { locale: dateFnsLocale }),
},
month: {
label: t("This month") as string,
start: new Date(),
end: endOfMonth(new Date()),
},
next_month: {
label: t("Next month") as string,
start: startOfMonth(addMonths(new Date(), 1)),
end: endOfMonth(addMonths(new Date(), 1)),
},
any: {
label: t("Any day") as string,
start: undefined,
end: undefined,
},
};
// $refs!: {
// aac: FullAddressAutoComplete;
// autocompleteSearchInput: any;
// };
onMounted(() => {
prepareLocation(geohash.value);
});
const radiusString = (radiusValue: number | null): string => {
if (radiusValue) {
return t("{nb} km", { nb: radiusValue }, radiusValue) as string;
}
return t("any distance") as string;
};
const radiusOptions: (number | null)[] = [1, 5, 10, 25, 50, 100, 150, null];
const submit = (): void => {
refetchSearchElements();
};
const updateSearchQuery = (searchQuery: string): void => {
search.value = searchQuery;
};
const debouncedUpdateSearchQuery = debounce(updateSearchQuery, 500);
const prepareLocation = (value: string | undefined): void => {
if (value !== undefined) {
// decode
const latlon = ngeohash.decode(value);
// set location
reverseGeoCode(latlon, DEFAULT_ZOOM);
}
};
// const { onResult: onReverseGeocodeResult, load: loadReverseGeocode } =
// useReverseGeocode();
const reverseGeoCode = async (
e: GeographicPoint,
zoom: number
): Promise<void> => {
const { result: reverseGeocodeResult } = useQuery<{
reverseGeocode: IAddress[];
}>(REVERSE_GEOCODE, () => ({
latitude: e.latitude,
longitude: e.longitude,
zoom,
locale: locale.value,
}));
const addressData = computed(
() => reverseGeocodeResult.value?.reverseGeocode
);
if (addressData.value && addressData.value.length > 0) {
location.value = addressData.value[0];
}
};
// const locchange = (e: IAddress): void => {
// if (radius.value === undefined || radius.value === null) {
// radius.value = DEFAULT_RADIUS;
// }
// if (e?.geom) {
// const [lon, lat] = e.geom.split(";");
// geohash.value = ngeohash.encode(lat, lon, GEOHASH_DEPTH);
// } else {
// geohash.value = "";
// }
// };
const start = computed((): Date | undefined | null => {
if (dateOptions[when.value]) {
return dateOptions[when.value].start;
}
return undefined;
});
const end = computed((): Date | undefined | null => {
if (dateOptions[when.value]) {
return dateOptions[when.value].end;
}
return undefined;
});
const canSearchGroups = computed((): boolean => {
return (
stringExists(search.value) ||
(stringExists(geohash.value) && valueExists(radius.value))
);
});
const canSearchEvents = computed((): boolean => {
return (
stringExists(search.value) ||
stringExists(props.tag) ||
stringExists(type.value) ||
(stringExists(geohash.value) && valueExists(radius.value)) ||
valueExists(end.value)
);
});
// helper functions for skip
const valueExists = (value: any): boolean => {
return value !== undefined && value !== null;
};
const stringExists = (value: string | null | undefined): boolean => {
return valueExists(value) && (value as string).length > 0;
};
const searchIsUrl = computed((): boolean => {
let url;
if (!search.value) return false;
try {
url = new URL(search.value);
} catch (_) {
return false;
}
return url.protocol === "http:" || url.protocol === "https:";
});
const { result: eventResult, loading: featuredEventsLoading } = useQuery<{
events: Paginate<IEvent>;
}>(FETCH_EVENTS, () => ({
page: featuredEventPage.value,
limit: EVENT_PAGE_LIMIT,
}));
const events = computed(() => eventResult.value?.events);
const searchVariables = reactive({
term: search,
tags: props.tag,
location: geohash,
beginsOn: start,
endsOn: end,
radius: radius,
eventPage: eventPage,
groupPage: groupPage,
limit: EVENT_PAGE_LIMIT,
type: type.value === "" ? undefined : type,
eventCategory: eventCategory,
});
const {
result: searchElementsResult,
refetch: refetchSearchElements,
loading: searchLoading,
} = useQuery<{
searchEvents: Paginate<IEvent>;
searchGroups: Paginate<IGroup>;
}>(SEARCH_EVENTS_AND_GROUPS, searchVariables);
</script>

View File

@@ -1,53 +0,0 @@
<template>
<div class="section container">
<h1 class="title">{{ $t("Settings") }}</h1>
<div class="columns">
<SettingsMenu class="column is-one-quarter-desktop" />
<div class="column">
<router-view />
</div>
</div>
</div>
</template>
<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
import SettingsMenu from "../components/Settings/SettingsMenu.vue";
import RouteName from "../router/name";
import { IPerson, Person } from "../types/actor";
import { IDENTITIES } from "../graphql/actor";
import { CURRENT_USER_CLIENT } from "../graphql/user";
import { ICurrentUser } from "../types/current-user.model";
@Component({
components: { SettingsMenu },
apollo: {
identities: {
query: IDENTITIES,
update: (data) =>
data.identities.map((identity: IPerson) => new Person(identity)),
},
currentUser: CURRENT_USER_CLIENT,
},
metaInfo() {
return {
title: this.$t("Settings") as string,
};
},
})
export default class Settings extends Vue {
RouteName = RouteName;
identities!: IPerson[];
currentUser!: ICurrentUser;
}
</script>
<style lang="scss" scoped>
aside.section {
padding-top: 1rem;
}
.breadcrumb ul li a {
text-decoration: none;
}
</style>

View File

@@ -4,44 +4,48 @@
:links="[
{
name: RouteName.ACCOUNT_SETTINGS,
text: $t('Account'),
text: t('Account'),
},
{
name: RouteName.ACCOUNT_SETTINGS_GENERAL,
text: $t('General'),
text: t('General'),
},
]"
/>
<section>
<div class="setting-title">
<h2>{{ $t("Email") }}</h2>
</div>
<i18n
<h2>{{ t("Email") }}</h2>
<i18n-t
tag="p"
class="content"
class="prose dark:prose-invert"
v-if="loggedUser"
path="Your current email is {email}. You use it to log in."
keypath="Your current email is {email}. You use it to log in."
>
<template #email>
<b>{{ loggedUser.email }}</b>
</template>
</i18n-t>
<o-notification
v-if="!canChangeEmail && loggedUser.provider"
variant="warning"
:closable="false"
>
<b slot="email">{{ loggedUser.email }}</b>
</i18n>
<b-message v-if="!canChangeEmail" type="is-warning" :closable="false">
{{
$t(
t(
"Your email address was automatically set based on your {provider} account.",
{
provider: providerName(loggedUser.provider),
}
)
}}
</b-message>
<b-notification
type="is-danger"
</o-notification>
<o-notification
variant="danger"
has-icon
aria-close-label="Close notification"
role="alert"
:key="error"
v-for="error in changeEmailErrors"
>{{ error }}</b-notification
>{{ error }}</o-notification
>
<form
@submit.prevent="resetEmailAction"
@@ -49,18 +53,18 @@
class="form"
v-if="canChangeEmail"
>
<b-field :label="$t('New email')" label-for="account-email">
<b-input
<o-field :label="t('New email')" label-for="account-email">
<o-input
aria-required="true"
required
type="email"
id="account-email"
v-model="newEmail"
/>
</b-field>
<p class="help">{{ $t("You'll receive a confirmation email.") }}</p>
<b-field :label="$t('Password')" label-for="account-password">
<b-input
</o-field>
<p class="help">{{ t("You'll receive a confirmation email.") }}</p>
<o-field :label="t('Password')" label-for="account-password">
<o-input
aria-required="true"
required
type="password"
@@ -69,35 +73,38 @@
minlength="6"
v-model="passwordForEmailChange"
/>
</b-field>
<button
class="button is-primary"
:disabled="!($refs.emailForm && $refs.emailForm.checkValidity())"
</o-field>
<o-button
class="mt-2"
variant="primary"
:disabled="!(emailForm && emailForm.checkValidity())"
>
{{ $t("Change my email") }}
</button>
{{ t("Change my email") }}
</o-button>
</form>
<div class="setting-title">
<h2>{{ $t("Password") }}</h2>
</div>
<b-message v-if="!canChangePassword" type="is-warning" :closable="false">
<h2 class="mt-2">{{ t("Password") }}</h2>
<o-notification
v-if="!canChangePassword"
variant="warning"
:closable="false"
>
{{
$t(
t(
"You can't change your password because you are registered through {provider}.",
{
provider: providerName(loggedUser.provider),
}
)
}}
</b-message>
<b-notification
type="is-danger"
</o-notification>
<o-notification
variant="danger"
has-icon
aria-close-label="Close notification"
role="alert"
:key="error"
v-for="error in changePasswordErrors"
>{{ error }}</b-notification
>{{ error }}</o-notification
>
<form
@submit.prevent="resetPasswordAction"
@@ -105,8 +112,8 @@
class="form"
v-if="canChangePassword"
>
<b-field :label="$t('Old password')" label-for="account-old-password">
<b-input
<o-field :label="t('Old password')" label-for="account-old-password">
<o-input
aria-required="true"
required
type="password"
@@ -115,9 +122,9 @@
id="account-old-password"
v-model="oldPassword"
/>
</b-field>
<b-field :label="$t('New password')" label-for="account-new-password">
<b-input
</o-field>
<o-field :label="t('New password')" label-for="account-new-password">
<o-input
aria-required="true"
required
type="password"
@@ -126,302 +133,287 @@
id="account-new-password"
v-model="newPassword"
/>
</b-field>
<button
class="button is-primary"
:disabled="
!($refs.passwordForm && $refs.passwordForm.checkValidity())
"
</o-field>
<o-button
class="mt-2"
variant="primary"
:disabled="!(passwordForm && passwordForm.checkValidity())"
>
{{ $t("Change my password") }}
</button>
{{ t("Change my password") }}
</o-button>
</form>
<div class="setting-title">
<h2>{{ $t("Delete account") }}</h2>
</div>
<p class="content">
{{ $t("Deleting my account will delete all of my identities.") }}
<h2 class="mt-2">{{ t("Delete account") }}</h2>
<p class="prose dark:prose-invert">
{{ t("Deleting my account will delete all of my identities.") }}
</p>
<b-button @click="openDeleteAccountModal" type="is-danger">
{{ $t("Delete my account") }}
</b-button>
<o-button @click="openDeleteAccountModal" variant="danger" class="mb-4">
{{ t("Delete my account") }}
</o-button>
<b-modal
:close-button-aria-label="$t('Close')"
:active.sync="isDeleteAccountModalActive"
<o-modal
:close-button-aria-label="t('Close')"
v-model:active="isDeleteAccountModalActive"
has-modal-card
full-screen
:can-cancel="false"
>
<section class="hero is-primary is-fullheight">
<div class="hero-body has-text-centered">
<div class="container">
<div class="columns">
<div
class="column is-one-third-desktop is-offset-one-third-desktop"
>
<section class="">
<div class="">
<div class="container mx-auto max-w-md">
<div class="">
<div class="">
<h1 class="title">
{{ $t("Deleting your Mobilizon account") }}
{{ t("Deleting your Mobilizon account") }}
</h1>
<p class="content">
<p class="prose dark:prose-invert">
{{
$t(
t(
"Are you really sure you want to delete your whole account? You'll lose everything. Identities, settings, events created, messages and participations will be gone forever."
)
}}
<br />
<b>{{
$t("There will be no way to recover your data.")
}}</b>
<b>{{ t("There will be no way to recover your data.") }}</b>
</p>
<p class="content" v-if="hasUserGotAPassword">
<p class="prose dark:prose-invert" v-if="hasUserGotAPassword">
{{
$t("Please enter your password to confirm this action.")
t("Please enter your password to confirm this action.")
}}
</p>
<form @submit.prevent="deleteAccount">
<b-field
<o-field
:type="deleteAccountPasswordFieldType"
v-if="hasUserGotAPassword"
label-for="account-deletion-password"
>
<b-input
<o-input
type="password"
v-model="passwordForAccountDeletion"
password-reveal
id="account-deletion-password"
:aria-label="$t('Password')"
:aria-label="t('Password')"
icon="lock"
:placeholder="$t('Password')"
:placeholder="t('Password')"
/>
<template #message>
<b-message
type="is-danger"
<o-notification
variant="danger"
v-for="message in deletePasswordErrors"
:key="message"
>
{{ message }}
</b-message>
</o-notification>
</template>
</b-field>
<b-button
</o-field>
<o-button
class="mt-2"
native-type="submit"
type="is-danger"
size="is-large"
variant="danger"
size="large"
>
{{ $t("Delete everything") }}
</b-button>
{{ t("Delete everything") }}
</o-button>
</form>
<div class="cancel-button">
<b-button
type="is-light"
<div class="mt-4">
<o-button
variant="light"
@click="isDeleteAccountModalActive = false"
>
{{ $t("Cancel") }}
</b-button>
{{ t("Cancel") }}
</o-button>
</div>
</div>
</div>
</div>
</div>
</section>
</b-modal>
</o-modal>
</section>
</div>
</template>
<script lang="ts">
<script lang="ts" setup>
import { useLoggedUser } from "@/composition/apollo/user";
import { Notifier } from "@/plugins/notifier";
import { IAuthProvider } from "@/types/enums";
import { useMutation } from "@vue/apollo-composable";
import { useHead } from "@vueuse/head";
import { GraphQLError } from "graphql/error/GraphQLError";
import { Component, Vue, Ref } from "vue-property-decorator";
import { Route } from "vue-router";
import { computed, inject, ref } from "vue";
import { useI18n } from "vue-i18n";
import { useRouter } from "vue-router";
import {
CHANGE_EMAIL,
CHANGE_PASSWORD,
DELETE_ACCOUNT,
LOGGED_USER,
} from "../../graphql/user";
import RouteName from "../../router/name";
import { IUser } from "../../types/current-user.model";
import { logout, SELECTED_PROVIDERS } from "../../utils/auth";
import { useProgrammatic } from "@oruga-ui/oruga-next";
@Component({
apollo: {
loggedUser: LOGGED_USER,
},
metaInfo() {
return {
title: this.$t("General settings") as string,
};
},
})
export default class AccountSettings extends Vue {
@Ref("passwordForm") readonly passwordForm!: HTMLElement;
const { loggedUser } = useLoggedUser();
loggedUser!: IUser;
const { t } = useI18n({ useScope: "global" });
passwordForEmailChange = "";
useHead({
title: computed(() => t("General settings")),
});
newEmail = "";
const passwordForm = ref<HTMLElement>();
const emailForm = ref<HTMLElement>();
changeEmailErrors: string[] = [];
const passwordForEmailChange = ref("");
const newEmail = ref("");
const changeEmailErrors = ref<string[]>([]);
const oldPassword = ref("");
const newPassword = ref("");
const changePasswordErrors = ref<string[]>([]);
const deletePasswordErrors = ref<string[]>([]);
const isDeleteAccountModalActive = ref(false);
const passwordForAccountDeletion = ref("");
oldPassword = "";
const notifier = inject<Notifier>("notifier");
newPassword = "";
const {
mutate: changeEmailMutation,
onDone: changeEmailMutationDone,
onError: changeEmailMutationError,
} = useMutation(CHANGE_EMAIL);
changePasswordErrors: string[] = [];
changeEmailMutationDone(() => {
notifier?.info(
t(
"The account's email address was changed. Check your emails to verify it."
)
);
newEmail.value = "";
passwordForEmailChange.value = "";
});
deletePasswordErrors: string[] = [];
changeEmailMutationError((err) => {
handleErrors("email", err);
});
isDeleteAccountModalActive = false;
const resetEmailAction = async (): Promise<void> => {
changeEmailErrors.value = [];
passwordForAccountDeletion = "";
changeEmailMutation({
email: newEmail.value,
password: passwordForEmailChange.value,
});
};
RouteName = RouteName;
const {
mutate: changePasswordMutation,
onDone: onChangePasswordMutationDone,
onError: onChangePasswordMutationError,
} = useMutation(CHANGE_PASSWORD);
async resetEmailAction(): Promise<void> {
this.changeEmailErrors = [];
onChangePasswordMutationDone(() => {
oldPassword.value = "";
newPassword.value = "";
notifier?.success(t("The password was successfully changed"));
});
try {
await this.$apollo.mutate({
mutation: CHANGE_EMAIL,
variables: {
email: this.newEmail,
password: this.passwordForEmailChange,
},
});
onChangePasswordMutationError((err) => {
handleErrors("password", err);
});
this.$notifier.info(
this.$t(
"The account's email address was changed. Check your emails to verify it."
) as string
);
this.newEmail = "";
this.passwordForEmailChange = "";
} catch (err: any) {
this.handleErrors("email", err);
}
const resetPasswordAction = async (): Promise<void> => {
changePasswordErrors.value = [];
changePasswordMutation({
oldPassword: oldPassword.value,
newPassword: newPassword.value,
});
};
const openDeleteAccountModal = (): void => {
passwordForAccountDeletion.value = "";
isDeleteAccountModalActive.value = true;
};
const router = useRouter();
const {
mutate: deleteAccountMutation,
onDone: deleteAccountMutationDone,
onError: deleteAccountMutationError,
} = useMutation<{ deleteAccount: { id: string } }, { password?: string }>(
DELETE_ACCOUNT
);
const { oruga } = useProgrammatic();
deleteAccountMutationDone(async () => {
console.debug("Deleted account, logging out client...");
await logout(false);
oruga.notification.open({
message: t("Your account has been successfully deleted"),
variant: "success",
position: "bottom-right",
duration: 5000,
});
return router.push({ name: RouteName.HOME });
});
deleteAccountMutationError((err) => {
deletePasswordErrors.value = err.graphQLErrors.map(
({ message }: GraphQLError) => message
);
});
const deleteAccount = () => {
deletePasswordErrors.value = [];
console.debug("Asking to delete account...");
deleteAccountMutation({
password: hasUserGotAPassword.value
? passwordForAccountDeletion.value
: undefined,
});
};
const canChangePassword = computed((): boolean => {
return !loggedUser.value?.provider;
});
const canChangeEmail = computed((): boolean => {
return !loggedUser.value?.provider;
});
const providerName = (id: string): string => {
if (SELECTED_PROVIDERS[id]) {
return SELECTED_PROVIDERS[id];
}
return id;
};
async resetPasswordAction(): Promise<void> {
this.changePasswordErrors = [];
const hasUserGotAPassword = computed((): boolean => {
return (
loggedUser.value?.provider == null ||
loggedUser.value?.provider === IAuthProvider.LDAP
);
});
try {
await this.$apollo.mutate({
mutation: CHANGE_PASSWORD,
variables: {
oldPassword: this.oldPassword,
newPassword: this.newPassword,
},
});
const deleteAccountPasswordFieldType = computed((): string | null => {
return deletePasswordErrors.value.length > 0 ? "is-danger" : null;
});
this.oldPassword = "";
this.newPassword = "";
this.$notifier.success(
this.$t("The password was successfully changed") as string
);
} catch (err: any) {
this.handleErrors("password", err);
}
const handleErrors = (type: string, err: any) => {
console.error(err);
if (err.graphQLErrors !== undefined) {
err.graphQLErrors.forEach(({ message }: { message: string }) => {
switch (type) {
case "password":
changePasswordErrors.value.push(message);
break;
case "email":
default:
changeEmailErrors.value.push(message);
break;
}
});
}
protected openDeleteAccountModal(): void {
this.passwordForAccountDeletion = "";
this.isDeleteAccountModalActive = true;
}
async deleteAccount(): Promise<Route | void> {
try {
this.deletePasswordErrors = [];
console.debug("Asking to delete account...");
await this.$apollo.mutate({
mutation: DELETE_ACCOUNT,
variables: {
password: this.hasUserGotAPassword
? this.passwordForAccountDeletion
: null,
},
});
console.debug("Deleted account, logging out client...");
await logout(this.$apollo.provider.defaultClient, false);
this.$buefy.notification.open({
message: this.$t(
"Your account has been successfully deleted"
) as string,
type: "is-success",
position: "is-bottom-right",
duration: 5000,
});
return await this.$router.push({ name: RouteName.HOME });
} catch (err: any) {
this.deletePasswordErrors = err.graphQLErrors.map(
({ message }: GraphQLError) => message
);
}
}
get canChangePassword(): boolean {
return !this.loggedUser.provider;
}
get canChangeEmail(): boolean {
return !this.loggedUser.provider;
}
// eslint-disable-next-line class-methods-use-this
providerName(id: string): string {
if (SELECTED_PROVIDERS[id]) {
return SELECTED_PROVIDERS[id];
}
return id;
}
get hasUserGotAPassword(): boolean {
return (
this.loggedUser &&
(this.loggedUser.provider == null ||
this.loggedUser.provider === IAuthProvider.LDAP)
);
}
get deleteAccountPasswordFieldType(): string | null {
return this.deletePasswordErrors.length > 0 ? "is-danger" : null;
}
private handleErrors(type: string, err: any) {
console.error(err);
if (err.graphQLErrors !== undefined) {
err.graphQLErrors.forEach(({ message }: { message: string }) => {
switch (type) {
case "password":
this.changePasswordErrors.push(message);
break;
case "email":
default:
this.changeEmailErrors.push(message);
break;
}
});
}
}
}
};
</script>
<style lang="scss" scoped>
.modal.is-active.is-full-screen {
.help.is-danger {
font-size: 1rem;
}
}
.cancel-button {
margin-top: 2rem;
}
::v-deep .modal .modal-background {
background-color: initial;
}
</style>

View File

@@ -1,800 +0,0 @@
<template>
<div v-if="loggedUser">
<breadcrumbs-nav
:links="[
{
name: RouteName.ACCOUNT_SETTINGS,
text: $t('Account'),
},
{
name: RouteName.NOTIFICATIONS,
text: $t('Notifications'),
},
]"
/>
<section>
<div class="setting-title">
<h2>{{ $t("Browser notifications") }}</h2>
</div>
<b-button
v-if="subscribed"
@click="unsubscribeToWebPush()"
@keyup.enter="unsubscribeToWebPush()"
>{{ $t("Unsubscribe to browser push notifications") }}</b-button
>
<b-button
icon-left="rss"
@click="subscribeToWebPush"
@keyup.enter="subscribeToWebPush"
v-else-if="canShowWebPush && webPushEnabled"
>{{ $t("Activate browser push notifications") }}</b-button
>
<b-message type="is-warning" v-else-if="!webPushEnabled">
{{ $t("This instance hasn't got push notifications enabled.") }}
<i18n path="Ask your instance admin to {enable_feature}.">
<a
slot="enable_feature"
href="https://docs.joinmobilizon.org/administration/configure/push/"
target="_blank"
rel="noopener noreferer"
>{{ $t("enable the feature") }}</a
>
</i18n>
</b-message>
<b-message type="is-danger" v-else>{{
$t("You can't use push notifications in this browser.")
}}</b-message>
</section>
<section>
<div class="setting-title">
<h2>{{ $t("Notification settings") }}</h2>
</div>
<p>
{{
$t(
"Select the activities for which you wish to receive an email or a push notification."
)
}}
</p>
<table class="table">
<tbody>
<template v-for="notificationType in notificationTypes">
<tr :key="`${notificationType.label}-title`">
<th colspan="3">
{{ notificationType.label }}
</th>
</tr>
<tr :key="`${notificationType.label}-subtitle`">
<th v-for="(method, key) in notificationMethods" :key="key">
{{ method }}
</th>
<th>{{ $t("Description") }}</th>
</tr>
<tr v-for="subType in notificationType.subtypes" :key="subType.id">
<td v-for="(method, key) in notificationMethods" :key="key">
<b-checkbox
:value="notificationValues[subType.id][key].enabled"
@input="(e) => updateNotificationValue(subType.id, key, e)"
:disabled="notificationValues[subType.id][key].disabled"
/>
</td>
<td>
{{ subType.label }}
</td>
</tr>
</template>
</tbody>
</table>
<b-field
:label="$t('Send notification e-mails')"
label-for="groupNotifications"
:message="
$t(
'Announcements and mentions notifications are always sent straight away.'
)
"
>
<b-select
v-model="groupNotifications"
@input="updateSetting({ groupNotifications })"
id="groupNotifications"
>
<option
v-for="(value, key) in groupNotificationsValues"
:value="key"
:key="key"
>
{{ value }}
</option>
</b-select>
</b-field>
</section>
<section>
<div class="setting-title">
<h2>{{ $t("Participation notifications") }}</h2>
</div>
<div class="field">
<strong>{{
$t(
"Mobilizon will send you an email when the events you are attending have important changes: date and time, address, confirmation or cancellation, etc."
)
}}</strong>
</div>
<p>
{{ $t("Other notification options:") }}
</p>
<div class="field">
<b-checkbox
v-model="notificationOnDay"
@input="updateSetting({ notificationOnDay })"
>
<strong>{{ $t("Notification on the day of the event") }}</strong>
<p>
{{
$t(
"We'll use your timezone settings to send a recap of the morning of the event."
)
}}
</p>
<div v-if="loggedUser.settings && loggedUser.settings.timezone">
<em>{{
$t("Your timezone is currently set to {timezone}.", {
timezone: loggedUser.settings.timezone,
})
}}</em>
<router-link
class="change-timezone"
:to="{ name: RouteName.PREFERENCES }"
>{{ $t("Change timezone") }}</router-link
>
</div>
<span v-else>{{
$t("You can pick your timezone into your preferences.")
}}</span>
</b-checkbox>
</div>
<div class="field">
<b-checkbox
v-model="notificationEachWeek"
@input="updateSetting({ notificationEachWeek })"
>
<strong>{{ $t("Recap every week") }}</strong>
<p>
{{
$t(
"You'll get a weekly recap every Monday for upcoming events, if you have any."
)
}}
</p>
</b-checkbox>
</div>
<div class="field">
<b-checkbox
v-model="notificationBeforeEvent"
@input="updateSetting({ notificationBeforeEvent })"
>
<strong>{{ $t("Notification before the event") }}</strong>
<p>
{{
$t(
"We'll send you an email one hour before the event begins, to be sure you won't forget about it."
)
}}
</p>
</b-checkbox>
</div>
</section>
<section>
<div class="setting-title">
<h2>{{ $t("Organizer notifications") }}</h2>
</div>
<div class="field is-primary">
<label
class="has-text-weight-bold"
for="notificationPendingParticipation"
>{{
$t("Notifications for manually approved participations to an event")
}}</label
>
<p>
{{
$t(
"If you have opted for manual validation of participants, Mobilizon will send you an email to inform you of new participations to be processed. You can choose the frequency of these notifications below."
)
}}
</p>
<b-select
v-model="notificationPendingParticipation"
id="notificationPendingParticipation"
@input="updateSetting({ notificationPendingParticipation })"
>
<option
v-for="(value, key) in notificationPendingParticipationValues"
:value="key"
:key="key"
>
{{ value }}
</option>
</b-select>
</div>
</section>
<section>
<div class="setting-title">
<h2>{{ $t("Personal feeds") }}</h2>
</div>
<p>
{{
$t(
"These feeds contain event data for the events for which any of your profiles is a participant or creator. You should keep these private. You can find feeds for specific profiles on each profile edition page."
)
}}
</p>
<div v-if="feedTokens && feedTokens.length > 0">
<div
class="buttons"
v-for="feedToken in feedTokens"
:key="feedToken.token"
>
<b-tooltip
:label="$t('URL copied to clipboard')"
:active="showCopiedTooltip.atom"
always
type="is-success"
position="is-left"
>
<b-button
tag="a"
icon-left="rss"
@click="
(e) => copyURL(e, tokenToURL(feedToken.token, 'atom'), 'atom')
"
@keyup.enter="
(e) => copyURL(e, tokenToURL(feedToken.token, 'atom'), 'atom')
"
:href="tokenToURL(feedToken.token, 'atom')"
target="_blank"
>{{ $t("RSS/Atom Feed") }}</b-button
>
</b-tooltip>
<b-tooltip
:label="$t('URL copied to clipboard')"
:active="showCopiedTooltip.ics"
always
type="is-success"
position="is-left"
>
<b-button
tag="a"
@click="
(e) => copyURL(e, tokenToURL(feedToken.token, 'ics'), 'ics')
"
@keyup.enter="
(e) => copyURL(e, tokenToURL(feedToken.token, 'ics'), 'ics')
"
icon-left="calendar-sync"
:href="tokenToURL(feedToken.token, 'ics')"
target="_blank"
>{{ $t("ICS/WebCal Feed") }}</b-button
>
</b-tooltip>
<b-button
icon-left="refresh"
type="is-text"
@click="openRegenerateFeedTokensConfirmation"
@keyup.enter="openRegenerateFeedTokensConfirmation"
>{{ $t("Regenerate new links") }}</b-button
>
</div>
</div>
<div v-else>
<b-button
icon-left="refresh"
type="is-text"
@click="generateFeedTokens"
@keyup.enter="generateFeedTokens"
>{{ $t("Create new links") }}</b-button
>
</div>
</section>
</div>
</template>
<script lang="ts">
import { Component, Vue, Watch } from "vue-property-decorator";
import { INotificationPendingEnum } from "@/types/enums";
import {
SET_USER_SETTINGS,
FEED_TOKENS_LOGGED_USER,
USER_NOTIFICATIONS,
UPDATE_ACTIVITY_SETTING,
} from "../../graphql/user";
import { IActivitySettingMethod, IUser } from "../../types/current-user.model";
import RouteName from "../../router/name";
import { IFeedToken } from "@/types/feedtoken.model";
import { CREATE_FEED_TOKEN, DELETE_FEED_TOKEN } from "@/graphql/feed_tokens";
import {
subscribeUserToPush,
unsubscribeUserToPush,
} from "../../services/push-subscription";
import {
REGISTER_PUSH_MUTATION,
UNREGISTER_PUSH_MUTATION,
} from "@/graphql/webPush";
import merge from "lodash/merge";
import { WEB_PUSH } from "@/graphql/config";
type NotificationSubType = { label: string; id: string };
type NotificationType = { label: string; subtypes: NotificationSubType[] };
@Component({
apollo: {
loggedUser: USER_NOTIFICATIONS,
feedTokens: {
query: FEED_TOKENS_LOGGED_USER,
update: (data) =>
data.loggedUser.feedTokens.filter(
(token: IFeedToken) => token.actor === null
),
},
webPushEnabled: {
query: WEB_PUSH,
update: (data) => data.config.webPush.enabled,
},
},
metaInfo() {
return {
title: this.$t("Notification settings") as string,
};
},
})
export default class Notifications extends Vue {
loggedUser!: IUser;
feedTokens: IFeedToken[] = [];
notificationOnDay: boolean | undefined = true;
notificationEachWeek: boolean | undefined = false;
notificationBeforeEvent: boolean | undefined = false;
notificationPendingParticipation: INotificationPendingEnum | undefined =
INotificationPendingEnum.NONE;
groupNotifications: INotificationPendingEnum | undefined =
INotificationPendingEnum.ONE_DAY;
notificationPendingParticipationValues: Record<string, unknown> = {};
groupNotificationsValues: Record<string, unknown> = {};
RouteName = RouteName;
showCopiedTooltip = { ics: false, atom: false };
subscribed = false;
canShowWebPush = false;
webPushEnabled = false;
notificationMethods = {
email: this.$t("Email") as string,
push: this.$t("Push") as string,
};
defaultNotificationValues = {
participation_event_updated: {
email: { enabled: true, disabled: true },
push: { enabled: true, disabled: true },
},
participation_event_comment: {
email: { enabled: true, disabled: false },
push: { enabled: true, disabled: false },
},
event_new_pending_participation: {
email: { enabled: true, disabled: false },
push: { enabled: true, disabled: false },
},
event_new_participation: {
email: { enabled: false, disabled: false },
push: { enabled: false, disabled: false },
},
event_created: {
email: { enabled: true, disabled: false },
push: { enabled: false, disabled: false },
},
event_updated: {
email: { enabled: false, disabled: false },
push: { enabled: false, disabled: false },
},
discussion_updated: {
email: { enabled: false, disabled: false },
push: { enabled: false, disabled: false },
},
post_published: {
email: { enabled: false, disabled: false },
push: { enabled: false, disabled: false },
},
post_updated: {
email: { enabled: false, disabled: false },
push: { enabled: false, disabled: false },
},
resource_updated: {
email: { enabled: false, disabled: false },
push: { enabled: false, disabled: false },
},
member_request: {
email: { enabled: true, disabled: false },
push: { enabled: true, disabled: false },
},
member_updated: {
email: { enabled: false, disabled: false },
push: { enabled: false, disabled: false },
},
user_email_password_updated: {
email: { enabled: true, disabled: true },
push: { enabled: false, disabled: true },
},
event_comment_mention: {
email: { enabled: true, disabled: false },
push: { enabled: true, disabled: false },
},
discussion_mention: {
email: { enabled: true, disabled: false },
push: { enabled: false, disabled: false },
},
event_new_comment: {
email: { enabled: true, disabled: false },
push: { enabled: false, disabled: false },
},
};
notificationTypes: NotificationType[] = [
{
label: this.$t("Mentions") as string,
subtypes: [
{
id: "event_comment_mention",
label: this.$t(
"I've been mentionned in a comment under an event"
) as string,
},
{
id: "discussion_mention",
label: this.$t(
"I've been mentionned in a group discussion"
) as string,
},
],
},
{
label: this.$t("Participations") as string,
subtypes: [
{
id: "participation_event_updated",
label: this.$t("An event I'm going to has been updated") as string,
},
{
id: "participation_event_comment",
label: this.$t(
"An event I'm going to has posted an announcement"
) as string,
},
],
},
{
label: this.$t("Organizers") as string,
subtypes: [
{
id: "event_new_pending_participation",
label: this.$t(
"An event I'm organizing has a new pending participation"
) as string,
},
{
id: "event_new_participation",
label: this.$t(
"An event I'm organizing has a new participation"
) as string,
},
{
id: "event_new_comment",
label: this.$t("An event I'm organizing has a new comment") as string,
},
],
},
{
label: this.$t("Group activity") as string,
subtypes: [
{
id: "event_created",
label: this.$t(
"An event from one of my groups has been published"
) as string,
},
{
id: "event_updated",
label: this.$t(
"An event from one of my groups has been updated or deleted"
) as string,
},
{
id: "discussion_updated",
label: this.$t("A discussion has been created or updated") as string,
},
{
id: "post_published",
label: this.$t("A post has been published") as string,
},
{
id: "post_updated",
label: this.$t("A post has been updated") as string,
},
{
id: "resource_updated",
label: this.$t("A resource has been created or updated") as string,
},
{
id: "member_request",
label: this.$t(
"A member requested to join one of my groups"
) as string,
},
{
id: "member_updated",
label: this.$t("A member has been updated") as string,
},
],
},
{
label: this.$t("User settings") as string,
subtypes: [
{
id: "user_email_password_updated",
label: this.$t("You changed your email or password") as string,
},
],
},
];
get userNotificationValues(): Record<
string,
Record<IActivitySettingMethod, { enabled: boolean; disabled: boolean }>
> {
return this.loggedUser.activitySettings.reduce((acc, activitySetting) => {
acc[activitySetting.key] = acc[activitySetting.key] || {};
acc[activitySetting.key][activitySetting.method] =
acc[activitySetting.key][activitySetting.method] || {};
acc[activitySetting.key][activitySetting.method].enabled =
activitySetting.enabled;
return acc;
}, {} as Record<string, Record<IActivitySettingMethod, { enabled: boolean; disabled: boolean }>>);
}
get notificationValues(): Record<
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> {
this.notificationPendingParticipationValues = {
[INotificationPendingEnum.NONE]: this.$t("Do not receive any mail"),
[INotificationPendingEnum.DIRECT]: this.$t(
"Receive one email per request"
),
[INotificationPendingEnum.ONE_HOUR]: this.$t("Hourly email summary"),
[INotificationPendingEnum.ONE_DAY]: this.$t("Daily email summary"),
[INotificationPendingEnum.ONE_WEEK]: this.$t("Weekly email summary"),
};
this.groupNotificationsValues = {
[INotificationPendingEnum.NONE]: this.$t("Do not receive any mail"),
[INotificationPendingEnum.DIRECT]: this.$t(
"Receive one email for each activity"
),
[INotificationPendingEnum.ONE_HOUR]: this.$t("Hourly email summary"),
[INotificationPendingEnum.ONE_DAY]: this.$t("Daily email summary"),
[INotificationPendingEnum.ONE_WEEK]: this.$t("Weekly email summary"),
};
this.canShowWebPush = await this.checkCanShowWebPush();
}
@Watch("loggedUser")
setSettings(): void {
if (this.loggedUser && this.loggedUser.settings) {
this.notificationOnDay = this.loggedUser.settings.notificationOnDay;
this.notificationEachWeek = this.loggedUser.settings.notificationEachWeek;
this.notificationBeforeEvent =
this.loggedUser.settings.notificationBeforeEvent;
this.notificationPendingParticipation =
this.loggedUser.settings.notificationPendingParticipation;
this.groupNotifications = this.loggedUser.settings.groupNotifications;
}
}
async updateSetting(variables: Record<string, unknown>): Promise<void> {
await this.$apollo.mutate<{ setUserSettings: string }>({
mutation: SET_USER_SETTINGS,
variables,
refetchQueries: [{ query: USER_NOTIFICATIONS }],
});
}
tokenToURL(token: string, format: string): string {
return `${window.location.origin}/events/going/${token}/${format}`;
}
copyURL(e: Event, url: string, format: "ics" | "atom"): void {
if (navigator.clipboard) {
e.preventDefault();
navigator.clipboard.writeText(url);
this.showCopiedTooltip[format] = true;
setTimeout(() => {
this.showCopiedTooltip[format] = false;
}, 2000);
}
}
openRegenerateFeedTokensConfirmation(): void {
this.$buefy.dialog.confirm({
type: "is-warning",
title: this.$t("Regenerate new links") as string,
message: this.$t(
"You'll need to change the URLs where there were previously entered."
) as string,
confirmText: this.$t("Regenerate new links") as string,
cancelText: this.$t("Cancel") as string,
onConfirm: () => this.regenerateFeedTokens(),
});
}
async regenerateFeedTokens(): Promise<void> {
if (this.feedTokens.length < 1) return;
await this.deleteFeedToken(this.feedTokens[0].token);
const newToken = await this.createNewFeedToken();
this.feedTokens.pop();
this.feedTokens.push(newToken);
}
async generateFeedTokens(): Promise<void> {
const newToken = await this.createNewFeedToken();
this.feedTokens.push(newToken);
}
async subscribeToWebPush(): Promise<void> {
try {
if (this.canShowWebPush) {
const subscription = await subscribeUserToPush();
if (subscription) {
const subscriptionJSON = subscription?.toJSON();
await this.$apollo.mutate({
mutation: REGISTER_PUSH_MUTATION,
variables: {
endpoint: subscriptionJSON.endpoint,
auth: subscriptionJSON?.keys?.auth,
p256dh: subscriptionJSON?.keys?.p256dh,
},
});
this.subscribed = true;
} else {
this.$notifier.error(
this.$t("Error while subscribing to push notifications") as string
);
}
} else {
console.error("can't do webpush");
}
} catch (e) {
console.error(e);
}
}
async unsubscribeToWebPush(): Promise<void> {
try {
const endpoint = await unsubscribeUserToPush();
if (endpoint) {
const { data } = await this.$apollo.mutate({
mutation: UNREGISTER_PUSH_MUTATION,
variables: {
endpoint,
},
});
console.log(data);
this.subscribed = false;
}
} catch (e) {
console.error(e);
}
}
async checkCanShowWebPush(): Promise<boolean> {
try {
if (!window.isSecureContext || !("serviceWorker" in navigator))
return Promise.resolve(false);
const registration = await navigator.serviceWorker.getRegistration();
return registration !== undefined;
} catch (e) {
console.error(e);
return Promise.resolve(false);
}
}
async created(): Promise<void> {
this.subscribed = await this.isSubscribed();
}
async updateNotificationValue(
key: string,
method: string,
enabled: boolean
): Promise<void> {
await this.$apollo.mutate({
mutation: UPDATE_ACTIVITY_SETTING,
variables: {
key,
method,
enabled,
userId: this.loggedUser.id,
},
});
}
private async isSubscribed(): Promise<boolean> {
try {
if (!("serviceWorker" in navigator)) return Promise.resolve(false);
const registration = await navigator.serviceWorker.getRegistration();
return (await registration?.pushManager?.getSubscription()) != null;
} catch (e) {
console.error(e);
return Promise.resolve(false);
}
}
private async deleteFeedToken(token: string): Promise<void> {
await this.$apollo.mutate({
mutation: DELETE_FEED_TOKEN,
variables: { token },
});
}
private async createNewFeedToken(): Promise<IFeedToken> {
const { data } = await this.$apollo.mutate({
mutation: CREATE_FEED_TOKEN,
});
return data.createFeedToken;
}
}
</script>
<style lang="scss" scoped>
@use "@/styles/_mixins" as *;
.field {
&:not(:last-child) {
margin-bottom: 1.5rem;
}
a.change-timezone {
color: $primary;
text-decoration: underline;
text-decoration-color: #fea72b;
text-decoration-thickness: 2px;
@include margin-left(5px);
}
}
::v-deep .buttons > *:not(:last-child) .button {
margin-right: 0.5rem;
@include margin-right(0.5rem);
}
</style>

View File

@@ -0,0 +1,847 @@
<template>
<div v-if="loggedUser">
<breadcrumbs-nav
:links="[
{
name: RouteName.ACCOUNT_SETTINGS,
text: $t('Account'),
},
{
name: RouteName.NOTIFICATIONS,
text: $t('Notifications'),
},
]"
/>
<section class="my-4">
<h2>{{ $t("Browser notifications") }}</h2>
<o-button
v-if="subscribed"
@click="unsubscribeToWebPush()"
@keyup.enter="unsubscribeToWebPush()"
>{{ $t("Unsubscribe to browser push notifications") }}</o-button
>
<o-button
icon-left="rss"
@click="subscribeToWebPush"
@keyup.enter="subscribeToWebPush"
v-else-if="canShowWebPush && webPushEnabled"
>{{ $t("Activate browser push notifications") }}</o-button
>
<o-notification variant="warning" v-else-if="!webPushEnabled">
{{ $t("This instance hasn't got push notifications enabled.") }}
<i18n-t keypath="Ask your instance admin to {enable_feature}.">
<template #enable_feature>
<a
href="https://docs.joinmobilizon.org/administration/configure/push/"
target="_blank"
rel="noopener noreferer"
>{{ $t("enable the feature") }}</a
>
</template>
</i18n-t>
</o-notification>
<o-notification variant="danger" v-else>{{
$t("You can't use push notifications in this browser.")
}}</o-notification>
</section>
<section class="my-4">
<h2>{{ $t("Notification settings") }}</h2>
<p>
{{
$t(
"Select the activities for which you wish to receive an email or a push notification."
)
}}
</p>
<table class="table">
<tbody>
<template
v-for="notificationType in notificationTypes"
:key="notificationType"
>
<tr>
<th colspan="3">
{{ notificationType.label }}
</th>
</tr>
<tr>
<th v-for="(method, key) in notificationMethods" :key="key">
{{ method }}
</th>
<th>{{ $t("Description") }}</th>
</tr>
<tr v-for="subType in notificationType.subtypes" :key="subType.id">
<td v-for="(method, key) in notificationMethods" :key="key">
<o-checkbox
:value="notificationValues[subType.id][key].enabled"
@input="
(e: boolean) =>
updateNotificationValue({
key: subType.id,
method: key,
enabled: e,
})
"
:disabled="notificationValues[subType.id][key].disabled"
/>
</td>
<td>
{{ subType.label }}
</td>
</tr>
</template>
</tbody>
</table>
<o-field
:label="$t('Send notification e-mails')"
label-for="groupNotifications"
:message="
$t(
'Announcements and mentions notifications are always sent straight away.'
)
"
>
<o-select
v-model="groupNotifications"
@input="updateSetting({ groupNotifications })"
id="groupNotifications"
>
<option
v-for="(value, key) in groupNotificationsValues"
:value="key"
:key="key"
>
{{ value }}
</option>
</o-select>
</o-field>
</section>
<section class="my-4">
<h2>{{ $t("Participation notifications") }}</h2>
<div class="field">
<strong>{{
$t(
"Mobilizon will send you an email when the events you are attending have important changes: date and time, address, confirmation or cancellation, etc."
)
}}</strong>
</div>
<p>
{{ $t("Other notification options:") }}
</p>
<div class="field">
<o-checkbox
v-model="notificationOnDay"
@input="updateSetting({ notificationOnDay })"
>
<strong>{{ $t("Notification on the day of the event") }}</strong>
<p>
{{
$t(
"We'll use your timezone settings to send a recap of the morning of the event."
)
}}
</p>
<div v-if="loggedUser.settings && loggedUser.settings.timezone">
<em>{{
$t("Your timezone is currently set to {timezone}.", {
timezone: loggedUser.settings.timezone,
})
}}</em>
<router-link
class="change-timezone"
:to="{ name: RouteName.PREFERENCES }"
>{{ $t("Change timezone") }}</router-link
>
</div>
<span v-else>{{
$t("You can pick your timezone into your preferences.")
}}</span>
</o-checkbox>
</div>
<div class="field">
<o-checkbox
v-model="notificationEachWeek"
@input="updateSetting({ notificationEachWeek })"
>
<strong>{{ $t("Recap every week") }}</strong>
<p>
{{
$t(
"You'll get a weekly recap every Monday for upcoming events, if you have any."
)
}}
</p>
</o-checkbox>
</div>
<div class="field">
<o-checkbox
v-model="notificationBeforeEvent"
@input="updateSetting({ notificationBeforeEvent })"
>
<strong>{{ $t("Notification before the event") }}</strong>
<p>
{{
$t(
"We'll send you an email one hour before the event begins, to be sure you won't forget about it."
)
}}
</p>
</o-checkbox>
</div>
</section>
<section class="my-4">
<h2>{{ $t("Organizer notifications") }}</h2>
<div class="field is-primary">
<label
class="has-text-weight-bold"
for="notificationPendingParticipation"
>{{
$t("Notifications for manually approved participations to an event")
}}</label
>
<p>
{{
$t(
"If you have opted for manual validation of participants, Mobilizon will send you an email to inform you of new participations to be processed. You can choose the frequency of these notifications below."
)
}}
</p>
<o-select
v-model="notificationPendingParticipation"
id="notificationPendingParticipation"
@input="updateSetting({ notificationPendingParticipation })"
>
<option
v-for="(value, key) in notificationPendingParticipationValues"
:value="key"
:key="key"
>
{{ value }}
</option>
</o-select>
</div>
</section>
<section class="my-4">
<h2>{{ $t("Personal feeds") }}</h2>
<p>
{{
$t(
"These feeds contain event data for the events for which any of your profiles is a participant or creator. You should keep these private. You can find feeds for specific profiles on each profile edition page."
)
}}
</p>
<div v-if="feedTokens && feedTokens.length > 0">
<div
class="flex gap-2"
v-for="feedToken in feedTokens"
:key="feedToken.token"
>
<o-tooltip
:label="$t('URL copied to clipboard')"
:active="showCopiedTooltip.atom"
always
variant="success"
position="left"
>
<o-button
tag="a"
icon-left="rss"
@click="
(e: Event) => copyURL(e, tokenToURL(feedToken.token, 'atom'), 'atom')
"
@keyup.enter="
(e: Event) => copyURL(e, tokenToURL(feedToken.token, 'atom'), 'atom')
"
:href="tokenToURL(feedToken.token, 'atom')"
target="_blank"
>{{ $t("RSS/Atom Feed") }}</o-button
>
</o-tooltip>
<o-tooltip
:label="$t('URL copied to clipboard')"
:active="showCopiedTooltip.ics"
always
variant="success"
position="left"
>
<o-button
tag="a"
@click="
(e: Event) => copyURL(e, tokenToURL(feedToken.token, 'ics'), 'ics')
"
@keyup.enter="
(e: Event) => copyURL(e, tokenToURL(feedToken.token, 'ics'), 'ics')
"
icon-left="calendar-sync"
:href="tokenToURL(feedToken.token, 'ics')"
target="_blank"
>{{ $t("ICS/WebCal Feed") }}</o-button
>
</o-tooltip>
<o-button
icon-left="refresh"
type="is-text"
@click="openRegenerateFeedTokensConfirmation"
@keyup.enter="openRegenerateFeedTokensConfirmation"
>{{ $t("Regenerate new links") }}</o-button
>
</div>
</div>
<div v-else>
<o-button
icon-left="refresh"
type="is-text"
@click="generateFeedTokens"
@keyup.enter="generateFeedTokens"
>{{ $t("Create new links") }}</o-button
>
</div>
</section>
</div>
</template>
<script lang="ts" setup>
import { INotificationPendingEnum } from "@/types/enums";
import {
SET_USER_SETTINGS,
USER_NOTIFICATIONS,
UPDATE_ACTIVITY_SETTING,
USER_FRAGMENT_FEED_TOKENS,
} from "../../graphql/user";
import {
IActivitySetting,
IActivitySettingMethod,
IUser,
} from "../../types/current-user.model";
import RouteName from "../../router/name";
import { IFeedToken } from "@/types/feedtoken.model";
import { CREATE_FEED_TOKEN, DELETE_FEED_TOKEN } from "@/graphql/feed_tokens";
import {
subscribeUserToPush,
unsubscribeUserToPush,
} from "../../services/push-subscription";
import {
REGISTER_PUSH_MUTATION,
UNREGISTER_PUSH_MUTATION,
} from "@/graphql/webPush";
import merge from "lodash/merge";
import { WEB_PUSH } from "@/graphql/config";
import { useMutation, useQuery } from "@vue/apollo-composable";
import {
computed,
inject,
onBeforeMount,
onMounted,
reactive,
ref,
watch,
} from "vue";
import { IConfig } from "@/types/config.model";
import { useI18n } from "vue-i18n";
import { useHead } from "@vueuse/head";
import { Dialog } from "@/plugins/dialog";
type NotificationSubType = { label: string; id: string };
type NotificationType = { label: string; subtypes: NotificationSubType[] };
const { result: loggedUserResult } = useQuery<{ loggedUser: IUser }>(
USER_NOTIFICATIONS
);
const loggedUser = computed(() => loggedUserResult.value?.loggedUser);
const feedTokens = computed(() =>
loggedUser.value?.feedTokens.filter(
(token: IFeedToken) => token.actor === null
)
);
const { result: webPushEnabledResult } = useQuery<{
config: Pick<IConfig, "webPush">;
}>(WEB_PUSH);
const webPushEnabled = computed(
() => webPushEnabledResult.value?.config?.webPush.enabled
);
const { t } = useI18n({ useScope: "global" });
useHead({
title: computed(() => t("Notification settings")),
});
const notificationOnDay = ref<boolean | undefined>(true);
const notificationEachWeek = ref<boolean | undefined>(false);
const notificationBeforeEvent = ref<boolean | undefined>(false);
const notificationPendingParticipation = ref<
INotificationPendingEnum | undefined
>(INotificationPendingEnum.NONE);
const groupNotifications = ref<INotificationPendingEnum | undefined>(
INotificationPendingEnum.ONE_DAY
);
const notificationPendingParticipationValues = ref<Record<string, unknown>>({});
const groupNotificationsValues = ref<Record<string, unknown>>({});
const showCopiedTooltip = reactive({ ics: false, atom: false });
const subscribed = ref(false);
const canShowWebPush = ref(false);
const notificationMethods = {
email: t("Email"),
push: t("Push"),
};
const defaultNotificationValues = {
participation_event_updated: {
email: { enabled: true, disabled: true },
push: { enabled: true, disabled: true },
},
participation_event_comment: {
email: { enabled: true, disabled: false },
push: { enabled: true, disabled: false },
},
event_new_pending_participation: {
email: { enabled: true, disabled: false },
push: { enabled: true, disabled: false },
},
event_new_participation: {
email: { enabled: false, disabled: false },
push: { enabled: false, disabled: false },
},
event_created: {
email: { enabled: true, disabled: false },
push: { enabled: false, disabled: false },
},
event_updated: {
email: { enabled: false, disabled: false },
push: { enabled: false, disabled: false },
},
discussion_updated: {
email: { enabled: false, disabled: false },
push: { enabled: false, disabled: false },
},
post_published: {
email: { enabled: false, disabled: false },
push: { enabled: false, disabled: false },
},
post_updated: {
email: { enabled: false, disabled: false },
push: { enabled: false, disabled: false },
},
resource_updated: {
email: { enabled: false, disabled: false },
push: { enabled: false, disabled: false },
},
member_request: {
email: { enabled: true, disabled: false },
push: { enabled: true, disabled: false },
},
member_updated: {
email: { enabled: false, disabled: false },
push: { enabled: false, disabled: false },
},
user_email_password_updated: {
email: { enabled: true, disabled: true },
push: { enabled: false, disabled: true },
},
event_comment_mention: {
email: { enabled: true, disabled: false },
push: { enabled: true, disabled: false },
},
discussion_mention: {
email: { enabled: true, disabled: false },
push: { enabled: false, disabled: false },
},
event_new_comment: {
email: { enabled: true, disabled: false },
push: { enabled: false, disabled: false },
},
};
const notificationTypes: NotificationType[] = [
{
label: t("Mentions") as string,
subtypes: [
{
id: "event_comment_mention",
label: t("I've been mentionned in a comment under an event") as string,
},
{
id: "discussion_mention",
label: t("I've been mentionned in a group discussion") as string,
},
],
},
{
label: t("Participations") as string,
subtypes: [
{
id: "participation_event_updated",
label: t("An event I'm going to has been updated") as string,
},
{
id: "participation_event_comment",
label: t("An event I'm going to has posted an announcement") as string,
},
],
},
{
label: t("Organizers") as string,
subtypes: [
{
id: "event_new_pending_participation",
label: t(
"An event I'm organizing has a new pending participation"
) as string,
},
{
id: "event_new_participation",
label: t("An event I'm organizing has a new participation") as string,
},
{
id: "event_new_comment",
label: t("An event I'm organizing has a new comment") as string,
},
],
},
{
label: t("Group activity") as string,
subtypes: [
{
id: "event_created",
label: t("An event from one of my groups has been published") as string,
},
{
id: "event_updated",
label: t(
"An event from one of my groups has been updated or deleted"
) as string,
},
{
id: "discussion_updated",
label: t("A discussion has been created or updated") as string,
},
{
id: "post_published",
label: t("A post has been published") as string,
},
{
id: "post_updated",
label: t("A post has been updated") as string,
},
{
id: "resource_updated",
label: t("A resource has been created or updated") as string,
},
{
id: "member_request",
label: t("A member requested to join one of my groups") as string,
},
{
id: "member_updated",
label: t("A member has been updated") as string,
},
],
},
{
label: t("User settings") as string,
subtypes: [
{
id: "user_email_password_updated",
label: t("You changed your email or password") as string,
},
],
},
];
const userNotificationValues = computed(
(): Record<
string,
Record<IActivitySettingMethod, { enabled: boolean; disabled: boolean }>
> => {
return (loggedUser.value?.activitySettings ?? []).reduce(
(acc, activitySetting) => {
acc[activitySetting.key] = acc[activitySetting.key] || {};
acc[activitySetting.key][activitySetting.method] =
acc[activitySetting.key][activitySetting.method] || {};
acc[activitySetting.key][activitySetting.method].enabled =
activitySetting.enabled;
return acc;
},
{} as Record<
string,
Record<IActivitySettingMethod, { enabled: boolean; disabled: boolean }>
>
);
}
);
const notificationValues = computed(
(): Record<
string,
Record<IActivitySettingMethod, { enabled: boolean; disabled: boolean }>
> => {
const values = merge(
defaultNotificationValues,
userNotificationValues.value
);
for (const value in values) {
if (!canShowWebPush.value) {
values[value].push.disabled = true;
}
}
return values;
}
);
onMounted(async () => {
notificationPendingParticipationValues.value = {
[INotificationPendingEnum.NONE]: t("Do not receive any mail"),
[INotificationPendingEnum.DIRECT]: t("Receive one email per request"),
[INotificationPendingEnum.ONE_HOUR]: t("Hourly email summary"),
[INotificationPendingEnum.ONE_DAY]: t("Daily email summary"),
[INotificationPendingEnum.ONE_WEEK]: t("Weekly email summary"),
};
groupNotificationsValues.value = {
[INotificationPendingEnum.NONE]: t("Do not receive any mail"),
[INotificationPendingEnum.DIRECT]: t("Receive one email for each activity"),
[INotificationPendingEnum.ONE_HOUR]: t("Hourly email summary"),
[INotificationPendingEnum.ONE_DAY]: t("Daily email summary"),
[INotificationPendingEnum.ONE_WEEK]: t("Weekly email summary"),
};
canShowWebPush.value = await checkCanShowWebPush();
});
watch(loggedUser, () => {
if (loggedUser.value?.settings) {
notificationOnDay.value = loggedUser.value.settings.notificationOnDay;
notificationEachWeek.value = loggedUser.value.settings.notificationEachWeek;
notificationBeforeEvent.value =
loggedUser.value.settings.notificationBeforeEvent;
notificationPendingParticipation.value =
loggedUser.value.settings.notificationPendingParticipation;
groupNotifications.value = loggedUser.value.settings.groupNotifications;
}
});
const { mutate: updateSetting } = useMutation<{ setUserSettings: string }>(
SET_USER_SETTINGS,
() => ({ refetchQueries: [{ query: USER_NOTIFICATIONS }] })
);
const tokenToURL = (token: string, format: string): string => {
return `${window.location.origin}/events/going/${token}/${format}`;
};
const copyURL = (e: Event, url: string, format: "ics" | "atom"): void => {
if (navigator.clipboard) {
e.preventDefault();
navigator.clipboard.writeText(url);
showCopiedTooltip[format] = true;
setTimeout(() => {
showCopiedTooltip[format] = false;
}, 2000);
}
};
const dialog = inject<Dialog>("dialog");
const openRegenerateFeedTokensConfirmation = () => {
dialog?.confirm({
type: "warning",
title: t("Regenerate new links") as string,
message: t(
"You'll need to change the URLs where there were previously entered."
) as string,
confirmText: t("Regenerate new links") as string,
cancelText: t("Cancel") as string,
onConfirm: () => regenerateFeedTokens(),
});
};
const regenerateFeedTokens = async (): Promise<void> => {
if (!feedTokens.value || feedTokens.value?.length < 1) return;
await deleteFeedToken({ token: feedTokens.value[0].token });
await createNewFeedToken(
{},
{
update(cache, { data }) {
const userId = data?.createFeedToken.user?.id;
const newFeedToken = data?.createFeedToken.token;
if (!newFeedToken) return;
let cachedData = cache.readFragment<{
id: string | undefined;
feedTokens: { token: string }[];
}>({
id: `User:${userId}`,
fragment: USER_FRAGMENT_FEED_TOKENS,
});
// Remove the old token
cachedData = {
id: cachedData?.id,
feedTokens: [
...(cachedData?.feedTokens ?? []).slice(0, -1),
{ token: newFeedToken },
],
};
cache.writeFragment({
id: `User:${userId}`,
fragment: USER_FRAGMENT_FEED_TOKENS,
data: cachedData,
});
},
}
);
};
const generateFeedTokens = async (): Promise<void> => {
await createNewFeedToken();
};
const {
mutate: registerPushMutation,
onDone: registerPushMutationDone,
onError: registerPushMutationError,
} = useMutation(REGISTER_PUSH_MUTATION);
registerPushMutationDone(() => {
subscribed.value = true;
});
registerPushMutationError((err) => {
console.error(err);
});
const subscribeToWebPush = async (): Promise<void> => {
if (canShowWebPush.value) {
const subscription = await subscribeUserToPush();
if (subscription) {
const subscriptionJSON = subscription?.toJSON();
registerPushMutation({
endpoint: subscriptionJSON.endpoint,
auth: subscriptionJSON?.keys?.auth,
p256dh: subscriptionJSON?.keys?.p256dh,
});
subscribed.value = true;
} else {
// tnotifier.error(
// t("Error while subscribing to push notifications") as string
// );
}
} else {
console.error("can't do webpush");
}
};
const {
mutate: unregisterPushMutation,
onDone: onUnregisterPushMutationDone,
onError: onUnregisterPushMutationError,
} = useMutation(UNREGISTER_PUSH_MUTATION);
onUnregisterPushMutationDone(({ data }) => {
console.log(data);
subscribed.value = false;
});
onUnregisterPushMutationError((e) => {
console.error(e);
});
const unsubscribeToWebPush = async (): Promise<void> => {
const endpoint = await unsubscribeUserToPush();
if (endpoint) {
unregisterPushMutation({
endpoint,
});
}
};
const checkCanShowWebPush = async (): Promise<boolean> => {
try {
if (!window.isSecureContext || !("serviceWorker" in navigator))
return Promise.resolve(false);
const registration = await navigator.serviceWorker.getRegistration();
return registration !== undefined;
} catch (e) {
console.error(e);
return Promise.resolve(false);
}
};
onBeforeMount(async () => {
subscribed.value = await isSubscribed();
});
const { mutate: updateNotificationValue } = useMutation<
{
updateActivitySetting: IActivitySetting;
},
{
key: string;
method: IActivitySettingMethod;
enabled: boolean;
}
>(UPDATE_ACTIVITY_SETTING);
const isSubscribed = async (): Promise<boolean> => {
try {
if (!("serviceWorker" in navigator)) return Promise.resolve(false);
const registration = await navigator.serviceWorker.getRegistration();
return (await registration?.pushManager?.getSubscription()) != null;
} catch (e) {
console.error(e);
return Promise.resolve(false);
}
};
const { mutate: deleteFeedToken } = useMutation(DELETE_FEED_TOKEN);
const { mutate: createNewFeedToken } = useMutation(CREATE_FEED_TOKEN, () => ({
update(cache, { data }) {
const userId = data?.createFeedToken.user?.id;
const newFeedToken = data?.createFeedToken.token;
if (!newFeedToken) return;
let cachedData = cache.readFragment<{
id: string | undefined;
feedTokens: { token: string }[];
}>({
id: `User:${userId}`,
fragment: USER_FRAGMENT_FEED_TOKENS,
});
// Add the new token to the list
cachedData = {
id: cachedData?.id,
feedTokens: [...(cachedData?.feedTokens ?? []), { token: newFeedToken }],
};
cache.writeFragment({
id: `User:${userId}`,
fragment: USER_FRAGMENT_FEED_TOKENS,
data: cachedData,
});
},
}));
</script>
<style lang="scss" scoped>
@use "@/styles/_mixins" as *;
.field {
&:not(:last-child) {
margin-bottom: 1.5rem;
}
a.change-timezone {
color: $primary;
text-decoration: underline;
text-decoration-color: #fea72b;
text-decoration-thickness: 2px;
@include margin-left(5px);
}
}
:deep(.buttons > *:not(:last-child) .button) {
margin-right: 0.5rem;
@include margin-right(0.5rem);
}
</style>

View File

@@ -1,308 +0,0 @@
<template>
<div>
<breadcrumbs-nav
:links="[
{
name: RouteName.ACCOUNT_SETTINGS,
text: $t('Account'),
},
{
name: RouteName.PREFERENCES,
text: $t('Preferences'),
},
]"
/>
<div>
<b-field :label="$t('Language')" label-for="setting-language">
<b-select
:loading="!config || !loggedUser"
v-model="locale"
:placeholder="$t('Select a language')"
id="setting-language"
>
<option v-for="(language, lang) in langs" :value="lang" :key="lang">
{{ language }}
</option>
</b-select>
</b-field>
<b-field
:label="$t('Timezone')"
v-if="selectedTimezone"
label-for="setting-timezone"
>
<b-select
:placeholder="$t('Select a timezone')"
:loading="!config || !loggedUser"
v-model="selectedTimezone"
id="setting-timezone"
>
<optgroup
:label="group"
v-for="(groupTimezones, group) in timezones"
:key="group"
>
<option
v-for="timezone in groupTimezones"
:value="`${group}/${timezone}`"
:key="timezone"
>
{{ sanitize(timezone) }}
</option>
</optgroup>
</b-select>
</b-field>
<em v-if="Intl.DateTimeFormat().resolvedOptions().timeZone">{{
$t("Timezone detected as {timezone}.", {
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
})
}}</em>
<b-message v-else type="is-danger">{{
$t("Unable to detect timezone.")
}}</b-message>
<hr role="presentation" />
<b-field grouped>
<b-field
:label="$t('City or region')"
expanded
label-for="setting-city"
>
<address-auto-complete
v-if="loggedUser && loggedUser.settings"
:type="AddressSearchType.ADMINISTRATIVE"
:doGeoLocation="false"
v-model="address"
id="setting-city"
>
</address-auto-complete>
</b-field>
<b-field :label="$t('Radius')" label-for="setting-radius">
<b-select
:placeholder="$t('Select a radius')"
v-model="locationRange"
id="setting-radius"
>
<option
v-for="index in [1, 5, 10, 25, 50, 100]"
:key="index"
:value="index"
>
{{ $tc("{count} km", index, { count: index }) }}
</option>
</b-select>
</b-field>
<b-button
:disabled="address == undefined"
@click="resetArea"
@keyup.enter="resetArea"
class="reset-area"
icon-left="close"
:aria-label="$t('Reset')"
/>
</b-field>
<p>
{{
$t(
"Your city or region and the radius will only be used to suggest you events nearby. The event radius will consider the administrative center of the area."
)
}}
</p>
</div>
</div>
</template>
<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
import ngeohash from "ngeohash";
import { saveLocaleData } from "@/utils/auth";
import { TIMEZONES } from "../../graphql/config";
import {
USER_SETTINGS,
SET_USER_SETTINGS,
UPDATE_USER_LOCALE,
} from "../../graphql/user";
import { IConfig } from "../../types/config.model";
import { IUser, IUserSettings } from "../../types/current-user.model";
import langs from "../../i18n/langs.json";
import RouteName from "../../router/name";
import AddressAutoComplete from "../../components/Event/AddressAutoComplete.vue";
import { AddressSearchType } from "@/types/enums";
import { Address, IAddress } from "@/types/address.model";
@Component({
apollo: {
config: TIMEZONES,
loggedUser: USER_SETTINGS,
},
components: {
AddressAutoComplete,
},
metaInfo() {
return {
title: this.$t("Preferences") as string,
};
},
})
export default class Preferences extends Vue {
config!: IConfig;
loggedUser!: IUser;
RouteName = RouteName;
langs: Record<string, string> = langs;
AddressSearchType = AddressSearchType;
get selectedTimezone(): string {
if (this.loggedUser?.settings?.timezone) {
return this.loggedUser.settings.timezone;
}
const detectedTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
if (this.loggedUser?.settings?.timezone === null) {
this.updateUserSettings({ timezone: detectedTimezone });
}
return detectedTimezone;
}
set selectedTimezone(selectedTimezone: string) {
if (selectedTimezone !== this.loggedUser?.settings?.timezone) {
this.updateUserSettings({ timezone: selectedTimezone });
}
}
get locale(): string {
if (this.loggedUser?.locale) {
return this.loggedUser?.locale;
}
return this.$i18n.locale;
}
set locale(locale: string) {
if (locale) {
this.$apollo.mutate({
mutation: UPDATE_USER_LOCALE,
variables: {
locale,
},
});
saveLocaleData(locale);
}
}
// eslint-disable-next-line class-methods-use-this
sanitize(timezone: string): string {
return timezone
.split("_")
.join(" ")
.replace("St ", "St. ")
.split("/")
.join(" - ");
}
get timezones(): Record<string, string[]> {
if (!this.config || !this.config.timezones) return {};
return this.config.timezones.reduce(
(acc: { [key: string]: Array<string> }, val: string) => {
const components = val.split("/");
const [prefix, suffix] = [
components.shift() as string,
components.join("/"),
];
const pushOrCreate = (
acc2: { [key: string]: Array<string> },
prefix2: string,
suffix2: string
) => {
// eslint-disable-next-line no-param-reassign
(acc2[prefix2] = acc2[prefix2] || []).push(suffix2);
return acc2;
};
if (suffix) {
return pushOrCreate(acc, prefix, suffix);
}
return pushOrCreate(acc, this.$t("Other") as string, prefix);
},
{}
);
}
get address(): IAddress | null {
if (
this.loggedUser?.settings?.location?.name &&
this.loggedUser?.settings?.location?.geohash
) {
const { latitude, longitude } = ngeohash.decode(
this.loggedUser?.settings?.location?.geohash
);
const name = this.loggedUser?.settings?.location?.name;
return {
description: name,
locality: "",
type: "administrative",
geom: `${longitude};${latitude}`,
street: "",
postalCode: "",
region: "",
country: "",
};
}
return null;
}
set address(address: IAddress | null) {
if (address && address.geom) {
const { geom } = address;
const addressObject = new Address(address);
const queryText = addressObject.poiInfos.name;
const [lon, lat] = geom.split(";");
const geohash = ngeohash.encode(lat, lon, 6);
if (queryText && geom) {
this.updateUserSettings({
location: {
geohash,
name: queryText,
},
});
}
}
}
get locationRange(): number | undefined | null {
return this.loggedUser?.settings?.location?.range;
}
set locationRange(locationRange: number | undefined | null) {
if (locationRange) {
this.updateUserSettings({
location: {
range: locationRange,
},
});
}
}
resetArea(): void {
this.updateUserSettings({
location: {
geohash: null,
name: null,
range: null,
},
});
}
private async updateUserSettings(userSettings: IUserSettings) {
await this.$apollo.mutate<{ setUserSetting: string }>({
mutation: SET_USER_SETTINGS,
variables: userSettings,
refetchQueries: [{ query: USER_SETTINGS }],
});
}
}
</script>
<style lang="scss" scoped>
.reset-area {
align-self: center;
position: relative;
top: 10px;
}
</style>

View File

@@ -0,0 +1,296 @@
<template>
<div>
<breadcrumbs-nav
:links="[
{
name: RouteName.ACCOUNT_SETTINGS,
text: t('Account'),
},
{
name: RouteName.PREFERENCES,
text: t('Preferences'),
},
]"
/>
<div>
<o-field :label="t('Language')" label-for="setting-language">
<o-select
:loading="loadingTimezones || loadingUserSettings"
v-model="locale"
:placeholder="t('Select a language')"
id="setting-language"
>
<option v-for="(language, lang) in langs" :value="lang" :key="lang">
{{ language }}
</option>
</o-select>
</o-field>
<o-field
:label="t('Timezone')"
v-if="selectedTimezone"
label-for="setting-timezone"
>
<o-select
:placeholder="t('Select a timezone')"
:loading="loadingTimezones || loadingUserSettings"
v-model="selectedTimezone"
id="setting-timezone"
>
<optgroup
:label="group"
v-for="(groupTimezones, group) in timezones"
:key="group"
>
<option
v-for="timezone in groupTimezones"
:value="`${group}/${timezone}`"
:key="timezone"
>
{{ sanitize(timezone) }}
</option>
</optgroup>
</o-select>
</o-field>
<em v-if="Intl.DateTimeFormat().resolvedOptions().timeZone">{{
t("Timezone detected as {timezone}.", {
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
})
}}</em>
<o-notification v-else variant="danger">{{
t("Unable to detect timezone.")
}}</o-notification>
<hr role="presentation" />
<o-field grouped>
<o-field :label="t('City or region')" expanded label-for="setting-city">
<full-address-auto-complete
v-if="loggedUser?.settings"
:type="AddressSearchType.ADMINISTRATIVE"
:doGeoLocation="false"
v-model="address"
id="setting-city"
class="grid"
:placeholder="t('e.g. Nantes, Berlin, Cork, …')"
/>
</o-field>
<o-field :label="t('Radius')" label-for="setting-radius">
<o-select
:placeholder="t('Select a radius')"
v-model="locationRange"
id="setting-radius"
>
<option
v-for="index in [1, 5, 10, 25, 50, 100]"
:key="index"
:value="index"
>
{{ t("{count} km", { count: index }, index) }}
</option>
</o-select>
</o-field>
<o-button
:disabled="address == undefined"
@click="resetArea"
@keyup.enter="resetArea"
class="reset-area self-center"
icon-left="close"
:aria-label="t('Reset')"
/>
</o-field>
<p>
{{
t(
"Your city or region and the radius will only be used to suggest you events nearby. The event radius will consider the administrative center of the area."
)
}}
</p>
</div>
</div>
</template>
<script lang="ts" setup>
import ngeohash from "ngeohash";
import { saveLocaleData } from "@/utils/auth";
import {
USER_SETTINGS,
SET_USER_SETTINGS,
UPDATE_USER_LOCALE,
} from "../../graphql/user";
import langs from "../../i18n/langs.json";
import RouteName from "../../router/name";
import FullAddressAutoComplete from "@/components/Event/FullAddressAutoComplete.vue";
import { AddressSearchType } from "@/types/enums";
import { Address, IAddress } from "@/types/address.model";
import { useTimezones } from "@/composition/apollo/config";
import { useUserSettings } from "@/composition/apollo/user";
import { useHead } from "@vueuse/head";
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import { useMutation } from "@vue/apollo-composable";
const { timezones: serverTimezones, loading: loadingTimezones } =
useTimezones();
const { loggedUser, loading: loadingUserSettings } = useUserSettings();
const { t, locale: i18nLocale } = useI18n({ useScope: "global" });
useHead({
title: computed(() => t("Preferences")),
});
// langs: Record<string, string> = langs;
const selectedTimezone = computed({
get() {
if (loggedUser.value?.settings?.timezone) {
return loggedUser.value.settings.timezone;
}
const detectedTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
if (loggedUser.value?.settings?.timezone === null) {
updateUserSettings({ timezone: detectedTimezone });
}
return detectedTimezone;
},
set(newSelectedTimezone: string) {
if (newSelectedTimezone !== loggedUser.value?.settings?.timezone) {
updateUserSettings({ timezone: newSelectedTimezone });
}
},
});
const { mutate: updateUserLocale } = useMutation(UPDATE_USER_LOCALE);
const locale = computed({
get(): string {
if (loggedUser.value?.locale) {
return loggedUser.value?.locale;
}
return i18nLocale.value as string;
},
set(newLocale: string) {
if (newLocale) {
updateUserLocale({
locale: newLocale,
});
saveLocaleData(newLocale);
console.log("changing locale", i18nLocale, newLocale);
i18nLocale.value = newLocale;
}
},
});
const sanitize = (timezone: string): string => {
return timezone
.split("_")
.join(" ")
.replace("St ", "St. ")
.split("/")
.join(" - ");
};
const timezones = computed((): Record<string, string[]> => {
if (!serverTimezones.value) return {};
return serverTimezones.value.reduce(
(acc: { [key: string]: Array<string> }, val: string) => {
const components = val.split("/");
const [prefix, suffix] = [
components.shift() as string,
components.join("/"),
];
const pushOrCreate = (
acc2: { [key: string]: Array<string> },
prefix2: string,
suffix2: string
) => {
// eslint-disable-next-line no-param-reassign
(acc2[prefix2] = acc2[prefix2] || []).push(suffix2);
return acc2;
};
if (suffix) {
return pushOrCreate(acc, prefix, suffix);
}
return pushOrCreate(acc, t("Other") as string, prefix);
},
{}
);
});
const address = computed({
get(): IAddress | null {
if (
loggedUser.value?.settings?.location?.name &&
loggedUser.value?.settings?.location?.geohash
) {
const { latitude, longitude } = ngeohash.decode(
loggedUser.value?.settings?.location?.geohash
);
const name = loggedUser.value?.settings?.location?.name;
return {
description: name,
locality: "",
type: "administrative",
geom: `${longitude};${latitude}`,
street: "",
postalCode: "",
region: "",
country: "",
};
}
return null;
},
set(newAddress: IAddress | null) {
if (newAddress?.geom) {
const { geom } = newAddress;
const addressObject = new Address(newAddress);
const queryText = addressObject.poiInfos.name;
const [lon, lat] = geom.split(";");
const geohash = ngeohash.encode(lat, lon, 6);
if (queryText && geom) {
updateUserSettings({
location: {
geohash,
name: queryText,
},
});
}
}
},
});
const locationRange = computed({
get(): number | undefined | null {
return loggedUser.value?.settings?.location?.range;
},
set(newLocationRange: number | undefined | null) {
if (newLocationRange) {
updateUserSettings({
location: {
range: newLocationRange,
},
});
}
},
});
const resetArea = (): void => {
updateUserSettings({
location: {
geohash: null,
name: null,
range: null,
},
});
};
const { mutate: updateUserSettings } = useMutation<{ setUserSetting: string }>(
SET_USER_SETTINGS,
() => ({
refetchQueries: [{ query: USER_SETTINGS }],
})
);
</script>
<style lang="scss" scoped>
.reset-area {
align-self: center;
position: relative;
top: 10px;
}
</style>

View File

@@ -0,0 +1,23 @@
<template>
<div class="container mx-auto">
<h1 class="">{{ t("Settings") }}</h1>
<div class="flex flex-wrap gap-4">
<SettingsMenu class="max-w-xs w-full" />
<div class="flex-1">
<router-view />
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import SettingsMenu from "../components/Settings/SettingsMenu.vue";
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import { useHead } from "@vueuse/head";
const { t } = useI18n({ useScope: "global" });
useHead({
title: computed(() => t("Settings")),
});
</script>

View File

@@ -1,11 +1,11 @@
<template>
<section class="container section" v-if="todoList">
<section class="container mx-auto" v-if="todoList">
<breadcrumbs-nav
:links="[
{
name: RouteName.GROUP,
params: { preferredUsername: usernameWithDomain(todoList.actor) },
text: displayName(group),
text: groupDisplayName,
},
{
name: RouteName.TODO_LISTS,
@@ -23,101 +23,87 @@
<div v-for="todo in todoList.todos.elements" :key="todo.id">
<compact-todo :todo="todo" />
</div>
<form class="form box" @submit.prevent="createNewTodo">
<b-field>
<b-checkbox v-model="newTodo.status" />
<b-input expanded v-model="newTodo.title" />
</b-field>
<b-button native-type="submit">{{ $t("Add a todo") }}</b-button>
<form
class="form box"
@submit.prevent="
createNewTodo({
title: newTodo.title,
status: newTodo.status,
todoListId: props.id,
})
"
>
<o-field>
<o-checkbox v-model="newTodo.status" />
<o-input expanded v-model="newTodo.title" />
</o-field>
<o-button native-type="submit">{{ $t("Add a todo") }}</o-button>
</form>
</section>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
<script lang="ts" setup>
import { ITodo } from "@/types/todos";
import { CREATE_TODO, FETCH_TODO_LIST } from "@/graphql/todos";
import CompactTodo from "@/components/Todo/CompactTodo.vue";
import { CURRENT_ACTOR_CLIENT } from "@/graphql/actor";
import { displayName, IActor, usernameWithDomain } from "@/types/actor";
import { displayName, usernameWithDomain } from "@/types/actor";
import { ITodoList } from "@/types/todolist";
import RouteName from "../../router/name";
import { ApolloCache, FetchResult, InMemoryCache } from "@apollo/client/core";
import { useMutation, useQuery } from "@vue/apollo-composable";
import { useCurrentActorClient } from "@/composition/apollo/actor";
import { useHead } from "@vueuse/head";
import { computed, ref } from "vue";
@Component({
components: {
CompactTodo,
},
apollo: {
todoList: {
const props = defineProps<{ id: string }>();
const { currentActor } = useCurrentActorClient();
const { result: totoListResult } = useQuery<{ todoList: ITodoList }>(
FETCH_TODO_LIST,
() => ({
id: props.id,
})
);
const todoList = computed(() => totoListResult.value?.todoList);
const groupDisplayName = computed(() => displayName(todoList.value?.actor));
useHead({
title: computed(() => todoList.value?.title ?? ""),
});
const newTodo = ref<ITodo>({ title: "", status: false });
const { mutate: createNewTodo, onDone } = useMutation(CREATE_TODO, () => ({
update: (store: ApolloCache<InMemoryCache>, { data }: FetchResult) => {
if (data == null) return;
const cachedData = store.readQuery<{ todoList: ITodoList }>({
query: FETCH_TODO_LIST,
fetchPolicy: "cache-and-network",
variables() {
return {
id: this.$route.params.id,
};
},
},
currentActor: CURRENT_ACTOR_CLIENT,
},
metaInfo() {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const { todoList } = this;
return {
title: todoList.title,
};
},
})
export default class TodoList extends Vue {
@Prop({ type: String, required: true }) id!: string;
todoList!: ITodoList;
currentActor!: IActor;
newTodo: ITodo = { title: "", status: false };
RouteName = RouteName;
displayName = displayName;
usernameWithDomain = usernameWithDomain;
async createNewTodo(): Promise<void> {
await this.$apollo.mutate({
mutation: CREATE_TODO,
variables: {
title: this.newTodo.title,
status: this.newTodo.status,
todoListId: this.id,
},
update: (store: ApolloCache<InMemoryCache>, { data }: FetchResult) => {
if (data == null) return;
const cachedData = store.readQuery<{ todoList: ITodoList }>({
query: FETCH_TODO_LIST,
variables: { id: this.todoList.id },
});
if (cachedData == null) return;
const { todoList } = cachedData;
if (todoList === null) {
console.error(
"Cannot update event notes cache, because of null value."
);
return;
}
const newTodo: ITodo = data.createTodo;
newTodo.creator = this.currentActor;
todoList.todos.elements = todoList.todos.elements.concat([newTodo]);
store.writeQuery({
query: FETCH_TODO_LIST,
variables: { id: this.todoList.id },
data: { todoList },
});
},
variables: { id: todoList.value?.id },
});
this.newTodo = { title: "", status: false };
}
}
if (cachedData == null) return;
const { todoList: todoListCached } = cachedData;
if (todoListCached === null) {
console.error("Cannot update event notes cache, because of null value.");
return;
}
const newTodoCached: ITodo = data.createTodo;
newTodoCached.creator = currentActor.value;
todoListCached.todos.elements = todoListCached.todos.elements.concat([
newTodoCached,
]);
store.writeQuery({
query: FETCH_TODO_LIST,
variables: { id: todoListCached.id },
data: { todoListCached },
});
},
}));
onDone(() => {
newTodo.value = { title: "", status: false };
});
</script>

View File

@@ -1,5 +1,5 @@
<template>
<div class="container section" v-if="group">
<div class="container mx-auto" v-if="group">
<breadcrumbs-nav
:links="[
{
@@ -22,11 +22,19 @@
)
}}
</p>
<form class="form" @submit.prevent="createNewTodoList">
<b-field :label="$t('List title')">
<b-input v-model="newTodoList.title" />
</b-field>
<b-button native-type="submit">{{ $t("Create a new list") }}</b-button>
<form
class="form"
@submit.prevent="
createNewTodoList({
title: newTodoList.title,
groupId: group?.id,
})
"
>
<o-field :label="$t('List title')">
<o-input v-model="newTodoList.title" />
</o-field>
<o-button native-type="submit">{{ $t("Create a new list") }}</o-button>
</form>
<div v-for="todoList in todoLists" :key="todoList.id">
<router-link
@@ -50,74 +58,43 @@
</section>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
import { FETCH_GROUP } from "@/graphql/group";
import { IGroup, usernameWithDomain, displayName } from "@/types/actor";
<script lang="ts" setup>
import { usernameWithDomain, displayName } from "@/types/actor";
import { CREATE_TODO_LIST } from "@/graphql/todos";
import CompactTodo from "@/components/Todo/CompactTodo.vue";
import { ITodoList } from "@/types/todolist";
import RouteName from "../../router/name";
import { useGroup } from "@/composition/apollo/group";
import { computed, reactive } from "vue";
import { useHead } from "@vueuse/head";
import { useI18n } from "vue-i18n";
import { useMutation } from "@vue/apollo-composable";
@Component({
apollo: {
group: {
query: FETCH_GROUP,
fetchPolicy: "cache-and-network",
variables() {
return {
name: this.$route.params.preferredUsername,
};
},
},
},
components: {
CompactTodo,
},
metaInfo() {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const { group } = this;
return {
title: this.$t("{group}'s todolists", {
group: group.name || usernameWithDomain(group),
}) as string,
};
},
})
export default class TodoLists extends Vue {
@Prop({ type: String, required: true }) preferredUsername!: string;
const props = defineProps<{ preferredUsername: string }>();
group!: IGroup;
const { group } = useGroup(props.preferredUsername);
newTodoList: ITodoList = {
title: "",
id: "",
todos: { elements: [], total: 0 },
};
const { t } = useI18n({ useScope: "global" });
RouteName = RouteName;
useHead({
title: computed(() =>
t("{group}'s todolists", { group: displayName(group.value) })
),
});
usernameWithDomain = usernameWithDomain;
const newTodoList = reactive<ITodoList>({
title: "",
id: "",
todos: { elements: [], total: 0 },
});
displayName = displayName;
const todoLists = computed((): ITodoList[] => {
return group.value?.todoLists.elements ?? [];
});
get todoLists(): ITodoList[] {
return this.group.todoLists.elements;
}
// const todoListsCount = computed((): number => {
// return group.value?.todoLists.total ?? 0;
// });
get todoListsCount(): number {
return this.group.todoLists.total;
}
async createNewTodoList(): Promise<void> {
await this.$apollo.mutate({
mutation: CREATE_TODO_LIST,
variables: {
title: this.newTodoList.title,
groupId: this.group.id,
},
});
}
}
const { mutate: createNewTodoList } = useMutation(CREATE_TODO_LIST);
</script>

View File

@@ -1,5 +1,5 @@
<template>
<section class="section container" v-if="todo">
<section class="container mx-auto" v-if="todo">
<breadcrumbs-nav
:links="[
{
@@ -27,47 +27,25 @@
<full-todo :todo="todo" />
</section>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
<script lang="ts" setup>
import { GET_TODO } from "@/graphql/todos";
import { ITodo } from "@/types/todos";
import FullTodo from "@/components/Todo/FullTodo.vue";
import RouteName from "../../router/name";
import { displayName, usernameWithDomain } from "@/types/actor";
import { useQuery } from "@vue/apollo-composable";
import { useHead } from "@vueuse/head";
import { computed } from "vue";
@Component({
components: {
FullTodo,
},
apollo: {
todo: {
query: GET_TODO,
fetchPolicy: "cache-and-network",
variables() {
return {
id: this.$route.params.todoId,
};
},
},
},
metaInfo() {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const { todo } = this;
return {
title: todo.title,
};
},
})
export default class Todo extends Vue {
@Prop({ type: String, required: true }) todoId!: string;
const props = defineProps<{ todoId: string }>();
todo!: ITodo;
const { result: todoResult } = useQuery<{ todo: ITodo }>(GET_TODO, () => ({
id: props.todoId,
}));
RouteName = RouteName;
const todo = computed(() => todoResult.value?.todo);
usernameWithDomain = usernameWithDomain;
displayName = displayName;
}
useHead({
title: computed(() => todo.value?.title),
});
</script>

View File

@@ -1,62 +1,59 @@
<template>
<section class="section container">
<section class="container mx-auto">
<h1 class="title" v-if="loading">
{{ $t("Your email is being changed") }}
{{ t("Your email is being changed") }}
</h1>
<div v-else>
<div v-if="failed">
<b-message :title="$t('Error while changing email')" type="is-danger">
<o-notification
:title="t('Error while changing email')"
variant="danger"
>
{{
$t(
t(
"Either the email has already been changed, either the validation token is incorrect."
)
}}
</b-message>
</o-notification>
</div>
<h1 class="title" v-else>{{ $t("Your email has been changed") }}</h1>
<h1 class="title" v-else>{{ t("Your email has been changed") }}</h1>
</div>
</section>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
<script lang="ts" setup>
import { useMutation } from "@vue/apollo-composable";
import { ref, onBeforeMount } from "vue";
import { useI18n } from "vue-i18n";
import { useRouter } from "vue-router";
import { VALIDATE_EMAIL } from "../../graphql/user";
import RouteName from "../../router/name";
import { ICurrentUser } from "../../types/current-user.model";
@Component({
metaInfo() {
return {
title: this.$t("Validating email") as string,
};
},
})
export default class Validate extends Vue {
@Prop({ type: String, required: true }) token!: string;
// metaInfo() {
// return {
// title: this.t("Validating email") as string,
// };
// },
const props = defineProps<{
token: string;
}>();
loading = true;
const loading = ref(true);
const failed = ref(false);
const router = useRouter();
const { t } = useI18n({ useScope: "global" });
failed = false;
onBeforeMount(() => validateEmail({ token: props.token }));
async created(): Promise<void> {
await this.validateAction();
}
const { mutate: validateEmail, onDone, onError } = useMutation(VALIDATE_EMAIL);
async validateAction(): Promise<void> {
try {
await this.$apollo.mutate<{ validateEmail: ICurrentUser }>({
mutation: VALIDATE_EMAIL,
variables: {
token: this.token,
},
});
this.loading = false;
await this.$router.push({ name: RouteName.HOME });
} catch (err) {
this.loading = false;
console.error(err);
this.failed = true;
}
}
}
onDone(async () => {
loading.value = false;
await router.push({ name: RouteName.HOME });
});
onError((err) => {
loading.value = false;
console.error(err);
failed.value = true;
});
</script>

View File

@@ -1,323 +0,0 @@
<template>
<section class="section container" v-if="!currentUser.isLoggedIn">
<div class="columns is-mobile is-centered">
<div class="column is-half-desktop">
<h1 class="title">{{ $t("Welcome back!") }}</h1>
<b-message
v-if="errorCode === LoginErrorCode.NEED_TO_LOGIN"
title="Info"
type="is-info"
:aria-close-label="$t('Close')"
>{{ $t("You need to login.") }}</b-message
>
<b-message
v-else-if="errorCode === LoginError.LOGIN_PROVIDER_ERROR"
type="is-danger"
:aria-close-label="$t('Close')"
>{{
$t(
"Error while login with {provider}. Retry or login another way.",
{
provider:
SELECTED_PROVIDERS[$route.query.provider] ||
"unknown provider",
}
)
}}</b-message
>
<b-message
v-else-if="errorCode === LoginError.LOGIN_PROVIDER_NOT_FOUND"
type="is-danger"
:aria-close-label="$t('Close')"
>{{
$t(
"Error while login with {provider}. This login provider doesn't exist.",
{
provider:
SELECTED_PROVIDERS[$route.query.provider] ||
"unknown provider",
}
)
}}</b-message
>
<b-message
:title="$t('Error')"
type="is-danger"
v-for="error in errors"
:key="error"
>
{{ error }}
</b-message>
<form @submit="loginAction">
<b-field
:label="$t('Email')"
label-for="email"
:message="caseWarningText"
:type="caseWarningType"
>
<b-input
aria-required="true"
required
id="email"
type="email"
v-model="credentials.email"
/>
</b-field>
<b-field :label="$t('Password')" label-for="password">
<b-input
aria-required="true"
id="password"
required
type="password"
password-reveal
v-model="credentials.password"
/>
</b-field>
<p class="control has-text-centered" v-if="!submitted">
<button type="submit" class="button is-primary is-large">
{{ $t("Login") }}
</button>
</p>
<b-loading :is-full-page="false" v-model="submitted" />
<div
class="control"
v-if="config && config.auth.oauthProviders.length > 0"
>
<auth-providers :oauthProviders="config.auth.oauthProviders" />
</div>
<p class="control">
<router-link
class="button is-text"
:to="{
name: RouteName.SEND_PASSWORD_RESET,
params: { email: credentials.email },
}"
>{{ $t("Forgot your password ?") }}</router-link
>
</p>
<router-link
class="button is-text"
:to="{
name: RouteName.RESEND_CONFIRMATION,
params: { email: credentials.email },
}"
>{{ $t("Didn't receive the instructions?") }}</router-link
>
<p class="control" v-if="config && config.registrationsOpen">
<router-link
class="button is-text"
:to="{
name: RouteName.REGISTER,
params: {
default_email: credentials.email,
default_password: credentials.password,
},
}"
>{{ $t("Create an account") }}</router-link
>
</p>
</form>
</div>
</div>
</section>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
import { Route } from "vue-router";
import { ICurrentUser } from "@/types/current-user.model";
import { LoginError, LoginErrorCode } from "@/types/enums";
import { LOGIN } from "../../graphql/auth";
import {
validateEmailField,
validateRequiredField,
} from "../../utils/validators";
import {
initializeCurrentActor,
NoIdentitiesException,
saveUserData,
SELECTED_PROVIDERS,
} from "../../utils/auth";
import { ILogin } from "../../types/login.model";
import {
CURRENT_USER_CLIENT,
UPDATE_CURRENT_USER_CLIENT,
} from "../../graphql/user";
import RouteName from "../../router/name";
import { CONFIG } from "../../graphql/config";
import { IConfig } from "../../types/config.model";
import AuthProviders from "../../components/User/AuthProviders.vue";
@Component({
apollo: {
config: {
query: CONFIG,
},
currentUser: {
query: CURRENT_USER_CLIENT,
},
},
components: {
AuthProviders,
},
metaInfo() {
return {
title: this.$t("Login on Mobilizon!") as string,
titleTemplate: "%s | Mobilizon",
meta: [{ name: "robots", content: "noindex" }],
};
},
})
export default class Login extends Vue {
@Prop({ type: String, required: false, default: "" }) email!: string;
@Prop({ type: String, required: false, default: "" }) password!: string;
LoginErrorCode = LoginErrorCode;
LoginError = LoginError;
errorCode: LoginErrorCode | null = null;
config!: IConfig;
currentUser!: ICurrentUser;
RouteName = RouteName;
credentials = {
email: "",
password: "",
};
redirect: string | undefined = "";
errors: string[] = [];
rules = {
required: validateRequiredField,
email: validateEmailField,
};
SELECTED_PROVIDERS = SELECTED_PROVIDERS;
submitted = false;
mounted(): void {
this.credentials.email = this.email;
this.credentials.password = this.password;
const { query } = this.$route;
this.errorCode = query.code as LoginErrorCode;
this.redirect = query.redirect as string | undefined;
// Already-logged-in and accessing /login
if (this.currentUser.isLoggedIn) {
this.$router.push("/");
}
}
async loginAction(e: Event): Promise<Route | void> {
e.preventDefault();
if (this.submitted) {
return;
}
this.errors = [];
try {
this.submitted = true;
const { data } = await this.$apollo.mutate<{ login: ILogin }>({
mutation: LOGIN,
variables: {
email: this.credentials.email,
password: this.credentials.password,
},
});
if (data == null) {
throw new Error("Data is undefined");
}
saveUserData(data.login);
await this.setupClientUserAndActors(data.login);
if (this.redirect) {
this.$router.push(this.redirect as string);
return;
}
if (window.localStorage) {
window.localStorage.setItem("welcome-back", "yes");
}
this.$router.replace({ name: RouteName.HOME });
return;
} catch (err: any) {
this.submitted = false;
if (err.graphQLErrors) {
err.graphQLErrors.forEach(({ message }: { message: string }) => {
this.errors.push(message);
});
} else if (err.networkError) {
this.errors.push(err.networkError.message);
}
}
}
private async setupClientUserAndActors(login: ILogin): Promise<void> {
await this.$apollo.mutate({
mutation: UPDATE_CURRENT_USER_CLIENT,
variables: {
id: login.user.id,
email: this.credentials.email,
isLoggedIn: true,
role: login.user.role,
},
});
try {
await initializeCurrentActor(this.$apollo.provider.defaultClient);
} catch (err: any) {
if (err instanceof NoIdentitiesException) {
await this.$router.push({
name: RouteName.REGISTER_PROFILE,
params: {
email: this.currentUser.email,
userAlreadyActivated: "true",
},
});
}
}
}
get hasCaseWarning(): boolean {
return this.credentials.email !== this.credentials.email.toLowerCase();
}
get caseWarningText(): string | undefined {
if (this.hasCaseWarning) {
return this.$t(
"Emails usually don't contain capitals, make sure you haven't made a typo."
) as string;
}
return undefined;
}
get caseWarningType(): string | undefined {
if (this.hasCaseWarning) {
return "is-warning";
}
return undefined;
}
}
</script>
<style lang="scss" scoped>
.container .columns {
margin: 1rem auto 3rem;
}
::v-deep .help.is-warning {
color: #755033;
}
</style>

View File

@@ -0,0 +1,309 @@
<template>
<section
class="container mx-auto max-w-screen-sm"
v-if="!currentUser?.isLoggedIn"
>
<h1 class="text-4xl">{{ t("Welcome back!") }}</h1>
<o-notification
v-if="errorCode === LoginErrorCode.NEED_TO_LOGIN"
title="Info"
variant="info"
:aria-close-label="t('Close')"
>{{ t("You need to login.") }}</o-notification
>
<o-notification
v-else-if="errorCode === LoginError.LOGIN_PROVIDER_ERROR"
variant="danger"
:aria-close-label="t('Close')"
>{{
t("Error while login with {provider}. Retry or login another way.", {
provider: currentProvider,
})
}}</o-notification
>
<o-notification
v-else-if="errorCode === LoginError.LOGIN_PROVIDER_NOT_FOUND"
variant="danger"
:aria-close-label="t('Close')"
>{{
t(
"Error while login with {provider}. This login provider doesn't exist.",
{
provider: currentProvider,
}
)
}}</o-notification
>
<o-notification
:title="t('Error')"
variant="danger"
v-for="error in errors"
:key="error"
>
{{ error }}
</o-notification>
<form @submit="loginAction">
<o-field
:label="t('Email')"
label-for="email"
:message="caseWarningText"
:type="caseWarningType"
>
<o-input
aria-required="true"
required
id="email"
type="email"
v-model="credentials.email"
/>
</o-field>
<o-field :label="t('Password')" label-for="password">
<o-input
aria-required="true"
id="password"
required
type="password"
password-reveal
v-model="credentials.password"
/>
</o-field>
<p class="control has-text-centered" v-if="!submitted">
<o-button
variant="primary"
size="large"
native-type="submit"
:disabled="submitted"
>
{{ t("Login") }}
</o-button>
</p>
<!-- <o-loading :is-full-page="false" v-model="submitted" /> -->
<div
class="control"
v-if="config && config?.auth.oauthProviders.length > 0"
>
<auth-providers :oauthProviders="config.auth.oauthProviders" />
</div>
<div class="flex flex-wrap gap-2 mt-3">
<o-button
tag="router-link"
variant="text"
:to="{
name: RouteName.SEND_PASSWORD_RESET,
params: { email: credentials.email },
}"
>{{ t("Forgot your password ?") }}</o-button
>
<o-button
tag="router-link"
variant="text"
:to="{
name: RouteName.RESEND_CONFIRMATION,
params: { email: credentials.email },
}"
>{{ t("Didn't receive the instructions?") }}</o-button
>
<p class="control" v-if="config && config.registrationsOpen">
<o-button
tag="router-link"
variant="text"
:to="{
name: RouteName.REGISTER,
params: {
default_email: credentials.email,
default_password: credentials.password,
},
}"
>{{ t("Create an account") }}</o-button
>
</p>
</div>
</form>
</section>
</template>
<script setup lang="ts">
import { LOGIN } from "@/graphql/auth";
import { LOGIN_CONFIG } from "@/graphql/config";
import { UPDATE_CURRENT_USER_CLIENT } from "@/graphql/user";
import { IConfig } from "@/types/config.model";
import { ILogin } from "@/types/login.model";
import { saveUserData, SELECTED_PROVIDERS } from "@/utils/auth";
import {
initializeCurrentActor,
NoIdentitiesException,
} from "@/utils/identity";
import { useMutation, useQuery } from "@vue/apollo-composable";
import { computed, reactive, ref, onMounted } from "vue";
import { useI18n } from "vue-i18n";
import { useRoute, useRouter } from "vue-router";
import AuthProviders from "@/components/User/AuthProviders.vue";
import RouteName from "@/router/name";
import { LoginError, LoginErrorCode } from "@/types/enums";
import { useCurrentUserClient } from "@/composition/apollo/user";
const props = withDefaults(
defineProps<{
email?: string;
password?: string;
}>(),
{ email: "", password: "" }
);
const { t } = useI18n({ useScope: "global" });
const router = useRouter();
const route = useRoute();
const { currentUser } = useCurrentUserClient();
const { result: configResult } = useQuery<{
config: Pick<IConfig, "auth" | "registrationsOpen">;
}>(LOGIN_CONFIG);
const config = computed(() => configResult.value?.config);
const errors = ref<string[]>([]);
const submitted = ref(false);
const credentials = reactive({
email: "",
password: "",
});
const redirect = ref<string | undefined>("");
const errorCode = ref<LoginErrorCode | null>(null);
const {
onDone: onLoginMutationDone,
onError: onLoginMutationError,
mutate: loginMutation,
} = useMutation(LOGIN);
onLoginMutationDone(async (result) => {
const data = result.data;
submitted.value = false;
if (data == null) {
throw new Error("Data is undefined");
}
saveUserData(data.login);
await setupClientUserAndActors(data.login);
if (redirect.value) {
router.push(redirect.value);
return;
}
if (window.localStorage) {
window.localStorage.setItem("welcome-back", "yes");
}
router.replace({ name: RouteName.HOME });
return;
});
onLoginMutationError((err) => {
console.error(err);
submitted.value = false;
if (err.graphQLErrors) {
err.graphQLErrors.forEach(({ message }: { message: string }) => {
errors.value.push(message);
});
} else if (err.networkError) {
errors.value.push(err.networkError.message);
}
});
const loginAction = (e: Event) => {
e.preventDefault();
if (submitted.value) {
return;
}
submitted.value = true;
loginMutation({
email: credentials.email,
password: credentials.password,
});
};
const { onDone: onCurrentUserMutationDone, mutate: updateCurrentUserMutation } =
useMutation(UPDATE_CURRENT_USER_CLIENT);
onCurrentUserMutationDone(async () => {
console.debug("saved current user client, now for actor client");
try {
await initializeCurrentActor();
} catch (err: any) {
if (err instanceof NoIdentitiesException && currentUser.value) {
await router.push({
name: RouteName.REGISTER_PROFILE,
params: {
email: currentUser.value.email,
userAlreadyActivated: "true",
},
});
}
}
});
const setupClientUserAndActors = async (login: ILogin): Promise<void> => {
console.debug("setuping client user and actors after login", login);
updateCurrentUserMutation({
id: login.user.id,
email: credentials.email,
isLoggedIn: true,
role: login.user.role,
});
};
const hasCaseWarning = computed<boolean>(() => {
return credentials.email !== credentials.email.toLowerCase();
});
const caseWarningText = computed<string | undefined>(() => {
if (hasCaseWarning.value) {
return t(
"Emails usually don't contain capitals, make sure you haven't made a typo."
) as string;
}
return undefined;
});
const caseWarningType = computed<string | undefined>(() => {
if (hasCaseWarning.value) {
return "is-warning";
}
return undefined;
});
const currentProvider = computed(() => {
const queryProvider = route?.query.provider as string | undefined;
if (queryProvider) {
return SELECTED_PROVIDERS[queryProvider];
}
return "unknown provider";
});
onMounted(() => {
credentials.email = props.email;
credentials.password = props.password;
const query = route?.query;
errorCode.value = query?.code as LoginErrorCode;
redirect.value = query?.redirect as string | undefined;
// Already-logged-in and accessing /login
if (currentUser.value?.isLoggedIn) {
console.debug(
"Current user is already logged-in, redirecting to Homepage",
currentUser
);
router.push("/");
}
});
</script>

View File

@@ -1,120 +1,115 @@
<template>
<section class="section container">
<div class="columns is-mobile is-centered">
<div class="column is-half-desktop">
<h1 class="title">
{{ $t("Password reset") }}
</h1>
<b-message
:title="$t('Error')"
type="is-danger"
v-for="error in errors"
:key="error"
>{{ error }}</b-message
>
<form @submit="resetAction">
<b-field :label="$t('Password')">
<b-input
aria-required="true"
required
type="password"
password-reveal
minlength="6"
v-model="credentials.password"
/>
</b-field>
<b-field :label="$t('Password (confirmation)')">
<b-input
aria-required="true"
required
type="password"
password-reveal
minlength="6"
v-model="credentials.password_confirmation"
/>
</b-field>
<button class="button is-primary">
{{ $t("Reset my password") }}
</button>
</form>
</div>
</div>
<section class="container mx-auto">
<h1 class="">
{{ $t("Password reset") }}
</h1>
<o-notification
:title="$t('Error')"
variant="danger"
v-for="error in errors"
:key="error"
>{{ error }}</o-notification
>
<form @submit="resetAction">
<o-field :label="$t('Password')">
<o-input
aria-required="true"
required
type="password"
password-reveal
minlength="6"
v-model="credentials.password"
/>
</o-field>
<o-field :label="$t('Password (confirmation)')">
<o-input
aria-required="true"
required
type="password"
password-reveal
minlength="6"
v-model="credentials.passwordConfirmation"
/>
</o-field>
<button class="button is-primary">
{{ $t("Reset my password") }}
</button>
</form>
</section>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
import { validateRequiredField } from "../../utils/validators";
import { RESET_PASSWORD } from "../../graphql/auth";
import { saveUserData } from "../../utils/auth";
import { ILogin } from "../../types/login.model";
import RouteName from "../../router/name";
<script lang="ts" setup>
import { RESET_PASSWORD } from "@/graphql/auth";
import { saveUserData } from "@/utils/auth";
import { ILogin } from "@/types/login.model";
import RouteName from "@/router/name";
import { reactive, ref, computed } from "vue";
import { useMutation } from "@vue/apollo-composable";
import { useRouter } from "vue-router";
import { useHead } from "@vueuse/head";
import { useI18n } from "vue-i18n";
@Component({
metaInfo() {
return {
title: this.$t("Password reset") as string,
};
},
})
export default class PasswordReset extends Vue {
@Prop({ type: String, required: true }) token!: string;
const props = defineProps<{ token: string }>();
credentials = {
password: "",
passwordConfirmation: "",
} as { password: string; passwordConfirmation: string };
const { t } = useI18n({ useScope: "global" });
useHead({ title: computed(() => t("Password reset")) });
errors: string[] = [];
const credentials = reactive<{
password: string;
passwordConfirmation: string;
}>({
password: "",
passwordConfirmation: "",
});
rules = {
passwordLength: (value: string): boolean | string =>
value.length > 6 || "Password must be at least 6 characters long",
required: validateRequiredField,
passwordEqual: (value: string): boolean | string =>
value === this.credentials.password || "Passwords must be the same",
};
const errors = ref<string[]>([]);
get samePasswords(): boolean {
return (
this.rules.passwordLength(this.credentials.password) === true &&
this.credentials.password === this.credentials.passwordConfirmation
);
// rules = {
// passwordLength: (value: string): boolean | string =>
// value.length > 6 || "Password must be at least 6 characters long",
// required: validateRequiredField,
// passwordEqual: (value: string): boolean | string =>
// value === this.credentials.password || "Passwords must be the same",
// };
// get samePasswords(): boolean {
// return (
// this.rules.passwordLength(this.credentials.password) === true &&
// this.credentials.password === this.credentials.passwordConfirmation
// );
// }
const router = useRouter();
const {
mutate: resetPasswordMutation,
onDone: resetPasswordMutationDone,
onError: resetPasswordMutationError,
} = useMutation<{ resetPassword: ILogin }>(RESET_PASSWORD);
resetPasswordMutationDone(({ data }) => {
if (data == null) {
throw new Error("Data is undefined");
}
async resetAction(e: Event): Promise<void> {
e.preventDefault();
this.errors.splice(0);
saveUserData(data.resetPassword);
router.push({ name: RouteName.HOME });
return;
});
try {
const { data } = await this.$apollo.mutate<{ resetPassword: ILogin }>({
mutation: RESET_PASSWORD,
variables: {
password: this.credentials.password,
token: this.token,
},
});
if (data == null) {
throw new Error("Data is undefined");
}
resetPasswordMutationError((err) => {
err.graphQLErrors.forEach(({ message }: { message: any }) => {
errors.value.push(message);
});
});
saveUserData(data.resetPassword);
this.$router.push({ name: RouteName.HOME });
return;
} catch (err: any) {
err.graphQLErrors.forEach(({ message }: { message: any }) => {
this.errors.push(message);
});
}
}
}
const resetAction = (e: Event) => {
e.preventDefault();
errors.value.splice(0);
resetPasswordMutation({
password: credentials.password,
token: props.token,
});
};
</script>
<style lang="scss" scoped>
section.section.container {
background: $white;
}
.container .columns {
margin: 1rem auto 3rem;
}
</style>

View File

@@ -1,78 +1,79 @@
<template>
<p>{{ $t("Redirecting in progress…") }}</p>
<p>{{ t("Redirecting in progress…") }}</p>
</template>
<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
<script lang="ts" setup>
import { ICurrentUserRole } from "@/types/enums";
import { UPDATE_CURRENT_USER_CLIENT, LOGGED_USER } from "../../graphql/user";
import RouteName from "../../router/name";
import { saveUserData, changeIdentity } from "../../utils/auth";
import { IUser } from "../../types/current-user.model";
import { saveUserData } from "../../utils/auth";
import { changeIdentity } from "../../utils/identity";
import { ICurrentUser, IUser } from "../../types/current-user.model";
import { useRouter } from "vue-router";
import { useMutation, useQuery } from "@vue/apollo-composable";
import { useI18n } from "vue-i18n";
import { useHead } from "@vueuse/head";
import { computed } from "vue";
@Component({
metaInfo() {
return {
title: this.$t("Redirecting to Mobilizon") as string,
};
},
})
export default class ProviderValidate extends Vue {
async mounted(): Promise<void> {
const accessToken = this.getValueFromMeta("auth-access-token");
const refreshToken = this.getValueFromMeta("auth-refresh-token");
const userId = this.getValueFromMeta("auth-user-id");
const userEmail = this.getValueFromMeta("auth-user-email");
const userRole = this.getValueFromMeta(
"auth-user-role"
) as ICurrentUserRole;
const { t } = useI18n({ useScope: "global" });
useHead({
title: computed(() => t("Redirecting to Mobilizon")),
});
if (!(userId && userEmail && userRole && accessToken && refreshToken)) {
await this.$router.push("/");
const getValueFromMeta = (name: string): string | null => {
const element = document.querySelector(`meta[name="${name}"]`);
if (element && element.getAttribute("content")) {
return element.getAttribute("content");
}
return null;
};
const accessToken = getValueFromMeta("auth-access-token");
const refreshToken = getValueFromMeta("auth-refresh-token");
const userId = getValueFromMeta("auth-user-id");
const userEmail = getValueFromMeta("auth-user-email");
const userRole = getValueFromMeta("auth-user-role") as ICurrentUserRole;
const router = useRouter();
const { onDone, mutate } = useMutation<{ updateCurrentUser: ICurrentUser }>(
UPDATE_CURRENT_USER_CLIENT
);
onDone(() => {
const { onResult: onLoggedUserResult } = useQuery<{ loggedUser: IUser }>(
LOGGED_USER
);
onLoggedUserResult(async ({ data: { loggedUser } }) => {
if (loggedUser.defaultActor) {
await changeIdentity(loggedUser.defaultActor);
await router.push({ name: RouteName.HOME });
} else {
const login = {
user: {
id: userId,
email: userEmail,
role: userRole,
isLoggedIn: true,
},
accessToken,
refreshToken,
};
saveUserData(login);
await this.$apollo.mutate({
mutation: UPDATE_CURRENT_USER_CLIENT,
variables: {
id: userId,
email: userEmail,
isLoggedIn: true,
role: userRole,
},
});
const { data } = await this.$apollo.query<{ loggedUser: IUser }>({
query: LOGGED_USER,
});
const { loggedUser } = data;
if (loggedUser.defaultActor) {
await changeIdentity(
this.$apollo.provider.defaultClient,
loggedUser.defaultActor
);
await this.$router.push({ name: RouteName.HOME });
} else {
// No need to push to REGISTER_PROFILE, the navbar will do it for us
}
// No need to push to REGISTER_PROFILE, the navbar will do it for us
}
}
});
});
// eslint-disable-next-line class-methods-use-this
getValueFromMeta(name: string): string | null {
const element = document.querySelector(`meta[name="${name}"]`);
if (element && element.getAttribute("content")) {
return element.getAttribute("content");
}
return null;
}
if (!(userId && userEmail && userRole && accessToken && refreshToken)) {
await router.push("/");
} else {
const login = {
user: {
id: userId,
email: userEmail,
role: userRole,
isLoggedIn: true,
},
accessToken,
refreshToken,
};
saveUserData(login);
mutate({
id: userId,
email: userEmail,
isLoggedIn: true,
role: userRole,
});
}
</script>

View File

@@ -1,366 +0,0 @@
<template>
<div class="section container">
<section class="hero">
<div class="hero-body">
<h1 class="title">
{{
$t("Register an account on {instanceName}!", {
instanceName: config.name,
})
}}
</h1>
<i18n
tag="p"
path="{instanceName} is an instance of the {mobilizon} software."
>
<b slot="instanceName">{{ config.name }}</b>
<a
href="https://joinmobilizon.org"
target="_blank"
class="out"
slot="mobilizon"
>{{ $t("Mobilizon") }}</a
>
</i18n>
</div>
</section>
<section>
<div class="columns">
<div class="column">
<div>
<subtitle>{{ $t("Why create an account?") }}</subtitle>
<div class="content">
<ul>
<li>{{ $t("To create and manage your events") }}</li>
<li>
{{
$t(
"To create and manage multiples identities from a same account"
)
}}
</li>
<li>
{{
$t(
"To register for an event by choosing one of your identities"
)
}}
</li>
<li v-if="config.features.groups">
{{
$t(
"To create or join an group and start organizing with other people"
)
}}
</li>
<li v-if="config.features.groups">
{{
$t(
"To follow groups and be informed of their latest events"
)
}}
</li>
</ul>
</div>
</div>
<router-link class="out" :to="{ name: RouteName.ABOUT }">{{
$t("Learn more")
}}</router-link>
<hr role="presentation" />
<div class="content">
<subtitle>{{
$t("About {instance}", { instance: config.name })
}}</subtitle>
<div class="content" v-html="config.description"></div>
<i18n
path="Please read the {fullRules} published by {instance}'s administrators."
tag="p"
>
<router-link slot="fullRules" :to="{ name: RouteName.RULES }">{{
$t("full rules")
}}</router-link>
<b slot="instance">{{ config.name }}</b>
</i18n>
</div>
</div>
<div class="column">
<b-message type="is-warning" v-if="config.registrationsAllowlist">
{{ $t("Registrations are restricted by allowlisting.") }}
</b-message>
<form v-on:submit.prevent="submit()">
<b-field
:label="$t('Email')"
:type="errorEmailType"
:message="errorEmailMessages"
label-for="email"
>
<b-input
aria-required="true"
required
id="email"
type="email"
v-model="credentials.email"
@blur="showGravatar = true"
@focus="showGravatar = false"
/>
</b-field>
<b-field
:label="$t('Password')"
:type="errorPasswordType"
:message="errorPasswordMessages"
label-for="password"
>
<b-input
aria-required="true"
required
id="password"
type="password"
password-reveal
minlength="6"
v-model="credentials.password"
/>
</b-field>
<b-checkbox required>
<i18n
tag="span"
path="I agree to the {instanceRules} and {termsOfService}"
>
<router-link
class="out"
slot="instanceRules"
:to="{ name: RouteName.RULES }"
>{{ $t("instance rules") }}</router-link
>
<router-link
class="out"
slot="termsOfService"
:to="{ name: RouteName.TERMS }"
>{{ $t("terms of service") }}</router-link
>
</i18n>
</b-checkbox>
<p class="create-account control has-text-centered">
<b-button
type="is-primary"
size="is-large"
:disabled="sendingForm"
native-type="submit"
>
{{ $t("Create an account") }}
</b-button>
</p>
<p class="control has-text-centered">
<router-link
class="button is-text"
:to="{
name: RouteName.RESEND_CONFIRMATION,
params: { email: credentials.email },
}"
>{{ $t("Didn't receive the instructions?") }}</router-link
>
</p>
<p class="control has-text-centered">
<router-link
class="button is-text"
:to="{
name: RouteName.LOGIN,
params: {
email: credentials.email,
password: credentials.password,
},
}"
>{{ $t("Login") }}</router-link
>
</p>
<hr role="presentation" />
<div
class="control"
v-if="config && config.auth.oauthProviders.length > 0"
>
<auth-providers :oauthProviders="config.auth.oauthProviders" />
</div>
</form>
</div>
</div>
</section>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue, Watch } from "vue-property-decorator";
import { CREATE_USER } from "../../graphql/user";
import RouteName from "../../router/name";
import { IConfig } from "../../types/config.model";
import { CONFIG } from "../../graphql/config";
import Subtitle from "../../components/Utils/Subtitle.vue";
import AuthProviders from "../../components/User/AuthProviders.vue";
import { AbsintheGraphQLError } from "../../types/apollo";
type errorType = "is-danger" | "is-warning";
type errorMessage = { type: errorType; message: string };
type credentials = { email: string; password: string; locale: string };
@Component({
components: { Subtitle, AuthProviders },
metaInfo() {
return {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
title: this.title,
titleTemplate: "%s | Mobilizon",
};
},
apollo: {
config: CONFIG,
},
})
export default class Register extends Vue {
@Prop({ type: String, required: false, default: "" }) email!: string;
@Prop({ type: String, required: false, default: "" }) password!: string;
credentials: credentials = {
email: this.email,
password: this.password,
locale: "en",
};
emailErrors: errorMessage[] = [];
passwordErrors: errorMessage[] = [];
sendingForm = false;
RouteName = RouteName;
config!: IConfig;
get title(): string {
if (this.config) {
return this.$t("Register an account on {instanceName}!", {
instanceName: this.config.name,
}) as string;
}
return "";
}
async submit(): Promise<void> {
this.sendingForm = true;
this.credentials.locale = this.$i18n.locale;
try {
this.emailErrors = [];
this.passwordErrors = [];
await this.$apollo.mutate({
mutation: CREATE_USER,
variables: this.credentials,
});
this.$router.push({
name: RouteName.REGISTER_PROFILE,
params: { email: this.credentials.email },
});
} catch (error: any) {
error.graphQLErrors.forEach(
({ field, message }: AbsintheGraphQLError) => {
switch (field) {
case "email":
this.emailErrors.push({
type: "is-danger" as errorType,
message: message[0] as string,
});
break;
case "password":
this.passwordErrors.push({
type: "is-danger" as errorType,
message: message[0] as string,
});
break;
default:
}
}
);
this.sendingForm = false;
}
}
@Watch("credentials", { deep: true })
watchCredentials(credentials: credentials): void {
if (credentials.email !== credentials.email.toLowerCase()) {
const error = {
type: "is-warning" as errorType,
message: this.$t(
"Emails usually don't contain capitals, make sure you haven't made a typo."
) as string,
};
this.emailErrors = [error];
this.$forceUpdate();
}
}
maxErrorType(errors: errorMessage[]): errorType | undefined {
if (!errors || errors.length === 0) return undefined;
return errors.reduce<errorType>((acc, error) => {
if (error.type === "is-danger" || acc === "is-danger") return "is-danger";
return "is-warning";
}, "is-warning");
}
get errorEmailType(): errorType | undefined {
return this.maxErrorType(this.emailErrors);
}
get errorPasswordType(): errorType | undefined {
return this.maxErrorType(this.passwordErrors);
}
get errorEmailMessages(): string[] {
return this.emailErrors.map(({ message }) => message);
}
get errorPasswordMessages(): string[] {
return this.passwordErrors?.map(({ message }) => message);
}
}
</script>
<style lang="scss" scoped>
.avatar-enter-active {
transition: opacity 1s ease;
}
.avatar-enter,
.avatar-leave-to {
opacity: 0;
}
.avatar-leave {
display: none;
}
.container .columns {
margin: 1rem auto 3rem;
}
h2.title {
color: $primary;
font-size: 2.5rem;
text-decoration: underline;
text-decoration-color: $secondary;
display: inline;
}
p.create-account {
::v-deep button {
margin: 1rem auto 2rem;
}
}
::v-deep .help.is-warning {
color: #755033;
}
</style>

View File

@@ -0,0 +1,345 @@
<template>
<div class="container mx-auto pt-6">
<section class="">
<h1>
{{
t("Register an account on {instanceName}!", {
instanceName: config?.name,
})
}}
</h1>
<i18n-t
tag="p"
keypath="{instanceName} is an instance of the {mobilizon} software."
>
<template v-slot:instanceName>
<b>{{ config?.name }}</b>
</template>
<template v-slot:mobilizon>
<a href="https://joinmobilizon.org" target="_blank" class="out">{{
t("Mobilizon")
}}</a>
</template>
</i18n-t>
</section>
<section class="flex flex-wrap gap-6">
<div class="">
<div class="my-4">
<h2 class="text-xl">{{ t("Why create an account?") }}</h2>
<div class="prose dark:prose-invert">
<ul>
<li>{{ t("To create and manage your events") }}</li>
<li>
{{
t(
"To create and manage multiples identities from a same account"
)
}}
</li>
<li>
{{
t(
"To register for an event by choosing one of your identities"
)
}}
</li>
<li v-if="config?.features.groups">
{{
t(
"To create or join an group and start organizing with other people"
)
}}
</li>
<li v-if="config?.features.groups">
{{
t("To follow groups and be informed of their latest events")
}}
</li>
</ul>
</div>
</div>
<router-link class="out block my-4" :to="{ name: RouteName.ABOUT }">{{
t("Learn more")
}}</router-link>
<hr role="presentation" />
<div class="my-4">
<h2 class="text-xl">
{{ t("About {instance}", { instance: config?.name }) }}
</h2>
<div
class="prose dark:prose-invert"
v-html="config?.description"
></div>
<i18n-t
keypath="Please read the {fullRules} published by {instance}'s administrators."
tag="p"
><template v-slot:fullRules>
<router-link class="out" :to="{ name: RouteName.RULES }">{{
t("full rules")
}}</router-link>
</template>
<template v-slot:instance>
<b>{{ config?.name }}</b>
</template>
</i18n-t>
</div>
</div>
<div class="">
<o-notification variant="warning" v-if="config?.registrationsAllowlist">
{{ t("Registrations are restricted by allowlisting.") }}
</o-notification>
<form @submit.prevent="submit">
<o-field
:label="t('Email')"
:type="errorEmailType"
:message="errorEmailMessage"
label-for="email"
>
<o-input
aria-required="true"
required
id="email"
type="email"
v-model="credentials.email"
@blur="showGravatar = true"
@focus="showGravatar = false"
/>
</o-field>
<o-field
:label="t('Password')"
:type="errorPasswordType"
:message="errorPasswordMessage"
label-for="password"
>
<o-input
aria-required="true"
required
id="password"
type="password"
password-reveal
minlength="6"
v-model="credentials.password"
/>
</o-field>
<div class="flex items-start mb-6">
<div class="flex items-center h-5">
<input
type="checkbox"
id="accept_rules_terms"
class="w-4 h-4 bg-gray-50 rounded border border-gray-300 focus:ring-3 focus:ring-blue-300 dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-blue-600 dark:ring-offset-gray-800"
required
/>
</div>
<label
for="accept_rules_terms"
class="ml-2 text-gray-900 dark:text-gray-300"
>
<i18n-t
tag="span"
keypath="I agree to the {instanceRules} and {termsOfService}"
>
<template v-slot:instanceRules>
<router-link class="out" :to="{ name: RouteName.RULES }">{{
t("instance rules")
}}</router-link>
</template>
<template v-slot:termsOfService>
<router-link class="out" :to="{ name: RouteName.TERMS }">{{
t("terms of service")
}}</router-link>
</template>
</i18n-t>
</label>
</div>
<p class="create-account control has-text-centered">
<o-button
variant="primary"
size="large"
:disabled="sendingForm"
native-type="submit"
>
{{ t("Create an account") }}
</o-button>
</p>
<p class="control has-text-centered">
<router-link
class="button is-text"
:to="{
name: RouteName.RESEND_CONFIRMATION,
params: { email: credentials.email },
}"
>{{ t("Didn't receive the instructions?") }}</router-link
>
</p>
<p class="control has-text-centered">
<router-link
class="button is-text"
:to="{
name: RouteName.LOGIN,
params: {
email: credentials.email,
password: credentials.password,
},
}"
>{{ t("Login") }}</router-link
>
</p>
<hr role="presentation" />
<div
class="control"
v-if="config && config.auth.oauthProviders.length > 0"
>
<auth-providers :oauthProviders="config.auth.oauthProviders" />
</div>
</form>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { CREATE_USER } from "../../graphql/user";
import RouteName from "../../router/name";
import { IConfig } from "../../types/config.model";
import { CONFIG } from "../../graphql/config";
import AuthProviders from "../../components/User/AuthProviders.vue";
import { AbsintheGraphQLError } from "../../types/apollo";
import { computed, reactive, ref, watch } from "vue";
import { useMutation, useQuery } from "@vue/apollo-composable";
import { useI18n } from "vue-i18n";
import { useRouter } from "vue-router";
import { useHead } from "@vueuse/head";
type errorType = "is-danger" | "is-warning";
type errorMessage = { type: errorType; message: string };
type credentialsType = { email: string; password: string; locale: string };
const { t, locale } = useI18n({ useScope: "global" });
const router = useRouter();
const { result: configResult } = useQuery<{ config: IConfig }>(CONFIG);
const config = computed(() => configResult.value?.config);
const showGravatar = ref(false);
const props = withDefaults(
defineProps<{
email?: string;
password?: string;
}>(),
{ email: "", password: "" }
);
const credentials = reactive<credentialsType>({
email: props.email,
password: props.password,
locale: "en",
});
const emailErrors = ref<errorMessage[]>([]);
const passwordErrors = ref<errorMessage[]>([]);
const sendingForm = ref(false);
const title = computed((): string => {
if (config.value) {
return t("Register an account on {instanceName}!", {
instanceName: config.value?.name,
}) as string;
}
return "";
});
useHead({
title: title.value,
});
const { onDone, onError, mutate } = useMutation(CREATE_USER);
onDone(() => {
router.push({
name: RouteName.REGISTER_PROFILE,
params: { email: credentials.email },
});
});
onError((error) => {
// @ts-ignore
error.graphQLErrors.forEach(({ field, message }: AbsintheGraphQLError) => {
switch (field) {
case "email":
emailErrors.value.push({
type: "is-danger" as errorType,
message: message[0] as string,
});
break;
case "password":
passwordErrors.value.push({
type: "is-danger" as errorType,
message: message[0] as string,
});
break;
default:
}
});
sendingForm.value = false;
});
const submit = async (): Promise<void> => {
sendingForm.value = true;
credentials.locale = locale.value as string;
try {
emailErrors.value = [];
passwordErrors.value = [];
mutate(credentials);
} catch (error: any) {
console.error(error);
sendingForm.value = false;
}
};
watch(credentials, () => {
if (credentials.email !== credentials.email.toLowerCase()) {
const error = {
type: "is-warning" as errorType,
message: t(
"Emails usually don't contain capitals, make sure you haven't made a typo."
) as string,
};
emailErrors.value = [error];
}
});
const maxErrorType = (errors: errorMessage[]): errorType | undefined => {
if (!errors || errors.length === 0) return undefined;
return errors.reduce<errorType>((acc, error) => {
if (error.type === "is-danger" || acc === "is-danger") return "is-danger";
return "is-warning";
}, "is-warning");
};
const errorEmailType = computed((): errorType | undefined => {
return maxErrorType(emailErrors.value);
});
const errorPasswordType = computed((): errorType | undefined => {
return maxErrorType(passwordErrors.value);
});
const errorEmailMessage = computed((): string => {
return emailErrors.value.map(({ message }) => message).join(" ");
});
const errorPasswordMessage = computed((): string => {
return passwordErrors.value?.map(({ message }) => message).join(" ");
});
</script>

View File

@@ -1,121 +1,92 @@
<template>
<section class="section container">
<div class="columns is-mobile is-centered">
<div class="column is-half-desktop">
<h1 class="title">
{{ $t("Resend confirmation email") }}
</h1>
<form v-if="!validationSent" @submit="resendConfirmationAction">
<b-field :label="$t('Email address')">
<b-input
aria-required="true"
required
type="email"
v-model="credentials.email"
/>
</b-field>
<p class="control">
<b-button type="is-primary" native-type="submit">
{{ $t("Send the confirmation email again") }}
</b-button>
<router-link
:to="{ name: RouteName.LOGIN }"
class="button is-text"
>{{ $t("Cancel") }}</router-link
>
</p>
</form>
<div v-else>
<b-message type="is-success" :closable="false" title="Success">
{{
$t(
"If an account with this email exists, we just sent another confirmation email to {email}",
{ email: credentials.email }
)
}}
</b-message>
<b-message type="is-info">
{{
$t(
"Please check your spam folder if you didn't receive the email."
)
}}
</b-message>
</div>
</div>
<section class="container mx-auto pt-4 max-w-2xl">
<h1>
{{ $t("Resend confirmation email") }}
</h1>
<form v-if="!validationSent" @submit="resendConfirmationAction">
<o-field :label="$t('Email address')">
<o-input
aria-required="true"
required
type="email"
v-model="credentials.email"
/>
</o-field>
<p class="flex flex-wrap gap-1">
<o-button variant="primary" native-type="submit">
{{ $t("Send the confirmation email again") }}
</o-button>
<o-button
variant="primary"
outlined
tag="router-link"
:to="{ name: RouteName.LOGIN }"
>{{ $t("Cancel") }}</o-button
>
</p>
</form>
<div v-else>
<o-notification variant="success" :closable="false" title="Success">
{{
$t(
"If an account with this email exists, we just sent another confirmation email to {email}",
{ email: credentials.email }
)
}}
</o-notification>
<o-notification variant="info">
{{
$t("Please check your spam folder if you didn't receive the email.")
}}
</o-notification>
</div>
</section>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
import {
validateEmailField,
validateRequiredField,
} from "../../utils/validators";
import { RESEND_CONFIRMATION_EMAIL } from "../../graphql/auth";
import RouteName from "../../router/name";
<script lang="ts" setup>
import { RESEND_CONFIRMATION_EMAIL } from "@/graphql/auth";
import RouteName from "@/router/name";
import { reactive, ref, computed } from "vue";
import { useMutation } from "@vue/apollo-composable";
import { useI18n } from "vue-i18n";
import { useHead } from "@vueuse/head";
@Component({
metaInfo() {
return {
title: this.$t("Resend confirmation email") as string,
meta: [{ name: "robots", content: "noindex" }],
};
},
})
export default class ResendConfirmation extends Vue {
@Prop({ type: String, required: false, default: "" }) email!: string;
const { t } = useI18n({ useScope: "global" });
useHead({
title: computed(() => t("Resend confirmation email")),
meta: [{ name: "robots", content: "noindex" }],
});
credentials = {
email: "",
};
const props = withDefaults(defineProps<{ email: string }>(), { email: "" });
validationSent = false;
const credentials = reactive({
email: props.email,
});
error = false;
const validationSent = ref(false);
const error = ref(false);
RouteName = RouteName;
const {
mutate: resendConfirmationEmail,
onDone: resentConfirmationEmail,
onError: resentConfirmationEmailError,
} = useMutation(RESEND_CONFIRMATION_EMAIL);
state = {
email: {
status: null,
msg: "",
},
};
resentConfirmationEmail(() => {
validationSent.value = true;
});
rules = {
required: validateRequiredField,
email: validateEmailField,
};
resentConfirmationEmailError((err) => {
console.error(err);
error.value = true;
});
mounted(): void {
this.credentials.email = this.email;
}
const resendConfirmationAction = async (e: Event): Promise<void> => {
e.preventDefault();
error.value = false;
async resendConfirmationAction(e: Event): Promise<void> {
e.preventDefault();
this.error = false;
try {
await this.$apollo.mutate({
mutation: RESEND_CONFIRMATION_EMAIL,
variables: {
email: this.credentials.email,
},
});
} catch (err) {
console.error(err);
this.error = true;
} finally {
this.validationSent = true;
}
}
}
resendConfirmationEmail({
email: credentials.email,
});
};
</script>
<style lang="scss" scoped>
.container .columns {
margin: 1rem auto 3rem;
}
</style>

View File

@@ -1,153 +1,116 @@
<template>
<section class="section container">
<div class="columns is-mobile is-centered">
<div class="column is-half-desktop">
<h1 class="title">
{{ $t("Forgot your password?") }}
</h1>
<p>
{{
$t(
"Enter your email address below, and we'll email you instructions on how to change your password."
)
}}
</p>
<b-message
title="Error"
type="is-danger"
v-for="error in errors"
:key="error"
@close="removeError(error)"
>
{{ error }}
</b-message>
<form @submit="sendResetPasswordTokenAction" v-if="!validationSent">
<b-field :label="$t('Email address')">
<b-input
aria-required="true"
required
type="email"
v-model="credentials.email"
/>
</b-field>
<p class="control">
<b-button type="is-primary" native-type="submit">
{{ $t("Submit") }}
</b-button>
<router-link
:to="{ name: RouteName.LOGIN }"
class="button is-text"
>{{ $t("Cancel") }}</router-link
>
</p>
</form>
<div v-else>
<b-message type="is-success" :closable="false" title="Success">
{{
$t("We just sent an email to {email}", {
email: credentials.email,
})
}}
</b-message>
<b-message type="is-info">
{{
$t(
"Please check your spam folder if you didn't receive the email."
)
}}
</b-message>
</div>
</div>
<section class="container mx-auto">
<h1>
{{ t("Forgot your password?") }}
</h1>
<p>
{{
t(
"Enter your email address below, and we'll email you instructions on how to change your password."
)
}}
</p>
<o-notification
title="Error"
variant="danger"
v-for="error in errors"
:key="error"
@close="removeError(error)"
>
{{ error }}
</o-notification>
<form @submit="sendResetPasswordTokenAction" v-if="!validationSent">
<o-field :label="t('Email address')">
<o-input
aria-required="true"
required
type="email"
v-model="credentials.email"
/>
</o-field>
<p class="control">
<o-button variant="primary" native-type="submit">
{{ t("Submit") }}
</o-button>
<router-link :to="{ name: RouteName.LOGIN }" class="button is-text">{{
t("Cancel")
}}</router-link>
</p>
</form>
<div v-else>
<o-notification variant="success" :closable="false" title="Success">
{{
t("We just sent an email to {email}", {
email: credentials.email,
})
}}
</o-notification>
<o-notification variant="info">
{{
t("Please check your spam folder if you didn't receive the email.")
}}
</o-notification>
</div>
</section>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
import {
validateEmailField,
validateRequiredField,
} from "../../utils/validators";
<script lang="ts" setup>
import { SEND_RESET_PASSWORD } from "../../graphql/auth";
import RouteName from "../../router/name";
import { computed, reactive, ref } from "vue";
import { useMutation } from "@vue/apollo-composable";
import { useHead } from "@vueuse/head";
import { useI18n } from "vue-i18n";
@Component({
metaInfo() {
return {
title: this.$t("Reset password") as string,
};
},
})
export default class SendPasswordReset extends Vue {
@Prop({ type: String, required: false, default: "" }) email!: string;
const { t } = useI18n({ useScope: "global" });
useHead({
title: computed(() => t("Reset password")),
});
credentials = {
email: "",
} as { email: string };
const props = withDefaults(
defineProps<{
email?: string;
}>(),
{ email: "" }
);
validationSent = false;
const credentials = reactive<{ email: string }>({
email: props.email,
});
RouteName = RouteName;
const validationSent = ref(false);
errors: string[] = [];
const errors = ref<string[]>([]);
state = {
email: {
status: null,
msg: "",
} as { status: boolean | null; msg: string },
};
const removeError = (message: string): void => {
errors.value.splice(errors.value.indexOf(message));
};
rules = {
required: validateRequiredField,
email: validateEmailField,
};
const {
mutate: sendResetPasswordMutation,
onDone: sendResetPasswordDone,
onError: sendResetPasswordError,
} = useMutation(SEND_RESET_PASSWORD);
mounted(): void {
this.credentials.email = this.email;
}
removeError(message: string): void {
this.errors.splice(this.errors.indexOf(message));
}
async sendResetPasswordTokenAction(e: Event): Promise<void> {
e.preventDefault();
try {
await this.$apollo.mutate({
mutation: SEND_RESET_PASSWORD,
variables: {
email: this.credentials.email,
},
});
this.validationSent = true;
} catch (err: any) {
console.error(err);
if (err.graphQLErrors) {
err.graphQLErrors.forEach(({ message }: { message: string }) => {
if (this.errors.indexOf(message) < 0) {
this.errors.push(message);
}
});
sendResetPasswordDone(() => {
validationSent.value = true;
});
sendResetPasswordError((err) => {
console.error(err);
if (err.graphQLErrors) {
err.graphQLErrors.forEach(({ message }: { message: string }) => {
if (errors.value.indexOf(message) < 0) {
errors.value.push(message);
}
}
});
}
});
resetState(): void {
this.state = {
email: {
status: null,
msg: "",
},
};
}
}
const sendResetPasswordTokenAction = async (e: Event): Promise<void> => {
e.preventDefault();
sendResetPasswordMutation({
email: credentials.email,
});
};
</script>
<style lang="scss" scoped>
.container .columns {
margin: 1rem auto 3rem;
}
</style>

View File

@@ -1,19 +1,23 @@
<template>
<div class="section container">
<div class="container mx-auto">
<h1 class="title">{{ $t("Let's define a few settings") }}</h1>
<b-steps v-model="stepIndex" :has-navigation="false">
<b-step-item step="1" :label="$t('Settings')">
<o-steps v-model="stepIndex" :has-navigation="false">
<o-step-item step="1" :label="$t('Settings')">
<settings-onboarding />
</b-step-item>
<b-step-item step="2" :label="$t('Participation notifications')">
</o-step-item>
<o-step-item step="2" :label="$t('Participation notifications')">
<notifications-onboarding />
</b-step-item>
<b-step-item step="3" :label="$t('Profiles and federation')">
<profile-onboarding />
</b-step-item>
</b-steps>
</o-step-item>
<o-step-item step="3" :label="$t('Profiles and federation')">
<ProfileOnboarding
v-if="currentActor && instanceName"
:current-actor="currentActor"
:instance-name="instanceName"
/>
</o-step-item>
</o-steps>
<section class="has-text-centered section buttons">
<b-button
<o-button
outlined
:disabled="stepIndex < 1"
tag="router-link"
@@ -23,10 +27,10 @@
}"
>
{{ $t("Previous") }}
</b-button>
<b-button
</o-button>
<o-button
outlined
type="is-success"
variant="success"
v-if="stepIndex < 2"
tag="router-link"
:to="{
@@ -35,61 +39,63 @@
}"
>
{{ $t("Next") }}
</b-button>
<b-button
</o-button>
<o-button
v-if="stepIndex >= 2"
type="is-success"
variant="success"
size="is-big"
tag="router-link"
:to="{ name: RouteName.HOME }"
>
{{ $t("All good, let's continue!") }}
</b-button>
</o-button>
</section>
</div>
</template>
<script lang="ts">
<script lang="ts" setup>
import { USER_SETTINGS } from "@/graphql/user";
import { Component, Prop, Vue, Watch } from "vue-property-decorator";
import { TIMEZONES } from "../../graphql/config";
import RouteName from "../../router/name";
import { IConfig } from "../../types/config.model";
import { IUser } from "@/types/current-user.model";
import { useQuery } from "@vue/apollo-composable";
import { useHead } from "@vueuse/head";
import { computed, defineAsyncComponent, watch } from "vue";
import { useI18n } from "vue-i18n";
import RouteName from "@/router/name";
import { useCurrentActorClient } from "@/composition/apollo/actor";
import { useInstanceName } from "@/composition/apollo/config";
@Component({
components: {
NotificationsOnboarding: () =>
import("../../components/Settings/NotificationsOnboarding.vue"),
SettingsOnboarding: () =>
import("../../components/Settings/SettingsOnboarding.vue"),
ProfileOnboarding: () =>
import("../../components/Account/ProfileOnboarding.vue"),
},
apollo: {
config: TIMEZONES,
loggedUser: USER_SETTINGS,
},
metaInfo() {
return {
title: this.$t("First steps") as string,
};
},
})
export default class SettingsOnboard extends Vue {
@Prop({ required: false, default: 1, type: Number }) step!: number;
const { currentActor } = useCurrentActorClient();
const { instanceName } = useInstanceName();
const { refetch } = useQuery<{ loggedUser: IUser }>(USER_SETTINGS);
config!: IConfig;
const NotificationsOnboarding = defineAsyncComponent(
() => import("@/components/Settings/NotificationsOnboarding.vue")
);
const SettingsOnboarding = defineAsyncComponent(
() => import("@/components/Settings/SettingsOnboarding.vue")
);
const ProfileOnboarding = defineAsyncComponent(
() => import("@/components/Account/ProfileOnboarding.vue")
);
RouteName = RouteName;
get stepIndex(): number {
return this.step - 1;
const props = withDefaults(
defineProps<{
step?: number;
}>(),
{
step: 1,
}
);
@Watch("stepIndex")
refetchUserSettings() {
this.$apollo.queries.loggedUser.refetch();
}
}
const stepIndex = computed(() => props.step - 1);
watch(stepIndex, () => {
refetch();
});
const { t } = useI18n({ useScope: "global" });
useHead({
title: computed(() => t("First steps")),
});
</script>
<style scoped lang="scss">
.section.container {

View File

@@ -1,97 +0,0 @@
<template>
<section class="section container">
<h1 class="title" v-if="loading">
{{ $t("Your account is being validated") }}
</h1>
<div v-else>
<div v-if="failed">
<b-message
:title="$t('Error while validating account')"
type="is-danger"
>
{{
$t(
"Either the account is already validated, either the validation token is incorrect."
)
}}
</b-message>
</div>
<h1 class="title" v-else>{{ $t("Your account has been validated") }}</h1>
</div>
</section>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
import { ICurrentUserRole } from "@/types/enums";
import { VALIDATE_USER, UPDATE_CURRENT_USER_CLIENT } from "../../graphql/user";
import RouteName from "../../router/name";
import { saveUserData, saveTokenData, changeIdentity } from "../../utils/auth";
import { ILogin } from "../../types/login.model";
@Component({
metaInfo() {
return {
title: this.$t("Validating account") as string,
};
},
})
export default class Validate extends Vue {
@Prop({ type: String, required: true }) token!: string;
loading = true;
failed = false;
async created(): Promise<void> {
await this.validateAction();
}
async validateAction(): Promise<void> {
try {
const { data } = await this.$apollo.mutate<{ validateUser: ILogin }>({
mutation: VALIDATE_USER,
variables: {
token: this.token,
},
});
if (data) {
saveUserData(data.validateUser);
saveTokenData(data.validateUser);
const { user } = data.validateUser;
await this.$apollo.mutate({
mutation: UPDATE_CURRENT_USER_CLIENT,
variables: {
id: user.id,
email: user.email,
isLoggedIn: true,
role: ICurrentUserRole.USER,
},
});
if (user.defaultActor) {
await changeIdentity(
this.$apollo.provider.defaultClient,
user.defaultActor
);
await this.$router.push({ name: RouteName.HOME });
} else {
// If the user didn't register any profile yet, let's create one for them
await this.$router.push({
name: RouteName.REGISTER_PROFILE,
params: { email: user.email, userAlreadyActivated: "true" },
});
}
}
} catch (err) {
console.error(err);
this.failed = true;
} finally {
this.loading = false;
}
}
}
</script>

View File

@@ -0,0 +1,103 @@
<template>
<section class="container mx-auto">
<h1 class="title" v-if="loading">
{{ $t("Your account is being validated") }}
</h1>
<div v-else>
<div v-if="failed">
<o-notification
:title="$t('Error while validating account')"
variant="danger"
>
{{
$t(
"Either the account is already validated, either the validation token is incorrect."
)
}}
</o-notification>
</div>
<h1 class="title" v-else>{{ $t("Your account has been validated") }}</h1>
</div>
</section>
</template>
<script lang="ts" setup>
import { ICurrentUserRole } from "@/types/enums";
import { VALIDATE_USER, UPDATE_CURRENT_USER_CLIENT } from "../../graphql/user";
import RouteName from "../../router/name";
import { saveUserData, saveTokenData } from "../../utils/auth";
import { changeIdentity } from "../../utils/identity";
import { ref, onBeforeMount, computed } from "vue";
import { useRouter } from "vue-router";
import { useMutation } from "@vue/apollo-composable";
import { IUser } from "@/types/current-user.model";
import { useHead } from "@vueuse/head";
import { useI18n } from "vue-i18n";
const { t } = useI18n({ useScope: "global" });
useHead({
title: computed(() => t("Validating account")),
});
const props = defineProps<{
token: string;
}>();
const loading = ref(true);
const failed = ref(false);
onBeforeMount(() => {
validateAction({ token: props.token });
});
const router = useRouter();
const user = ref<IUser | null>(null);
const {
mutate: validateAction,
onDone: onValidatingUserMutationDone,
onError: onValidatingUserMutationError,
} = useMutation(VALIDATE_USER);
const {
onDone: onUpdatingCurrentUserClientDone,
mutate: updateCurrentUserClient,
} = useMutation(UPDATE_CURRENT_USER_CLIENT);
onUpdatingCurrentUserClientDone(async () => {
if (user.value?.defaultActor) {
await changeIdentity(user.value?.defaultActor);
await router.push({ name: RouteName.HOME });
} else {
// If the user didn't register any profile yet, let's create one for them
await router.push({
name: RouteName.REGISTER_PROFILE,
params: { email: user.value?.email, userAlreadyActivated: "true" },
});
}
});
onValidatingUserMutationDone(async ({ data }) => {
if (data) {
saveUserData(data.validateUser);
saveTokenData(data.validateUser);
const { user: validatedUser } = data.validateUser;
user.value = validatedUser;
updateCurrentUserClient({
id: validatedUser.id,
email: validatedUser.email,
isLoggedIn: true,
role: ICurrentUserRole.USER,
});
}
});
onValidatingUserMutationError((error) => {
console.error(error);
failed.value = true;
loading.value = false;
});
</script>