build: switch from yarn to npm to manage js dependencies and move js contents to root
yarn v1 is being deprecated and starts to have some issues Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
209
src/views/About/AboutInstanceView.vue
Normal file
209
src/views/About/AboutInstanceView.vue
Normal file
@@ -0,0 +1,209 @@
|
||||
<template>
|
||||
<div v-if="config">
|
||||
<section class="p-6 bg-primary text-white">
|
||||
<h1 dir="auto">{{ config.name }}</h1>
|
||||
<p dir="auto">{{ config.description }}</p>
|
||||
</section>
|
||||
<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 #number>
|
||||
<strong>{{ statistics.numberOfUsers }}</strong>
|
||||
</template>
|
||||
</i18n-t>
|
||||
<i18n-t tag="p" keypath="and {number} groups">
|
||||
<template #number>
|
||||
<strong>{{ statistics.numberOfLocalGroups }}</strong>
|
||||
</template>
|
||||
</i18n-t>
|
||||
<i18n-t tag="p" keypath="Who published {number} events">
|
||||
<template #number>
|
||||
<strong>{{ statistics.numberOfLocalEvents }}</strong>
|
||||
</template>
|
||||
</i18n-t>
|
||||
<i18n-t tag="p" keypath="And {number} comments">
|
||||
<template #number>
|
||||
<strong>{{ statistics.numberOfLocalComments }}</strong>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
<hr role="presentation" v-if="config.longDescription" />
|
||||
<section class="long-description content">
|
||||
<div v-html="config.longDescription" />
|
||||
</section>
|
||||
<hr role="presentation" />
|
||||
<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="config.languages.join(', ') ?? ''"
|
||||
>
|
||||
{{ formattedLanguageList }}
|
||||
</td>
|
||||
<td v-else>{{ t("No information") }}</td>
|
||||
</tr>
|
||||
<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 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") }}
|
||||
</td>
|
||||
<td v-if="config.registrationsOpen && !config.registrationsAllowlist">
|
||||
{{ t("Open") }}
|
||||
</td>
|
||||
<td v-else>{{ t("Closed") }}</td>
|
||||
</tr>
|
||||
<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 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") }}
|
||||
</td>
|
||||
<td v-else>{{ t("Disabled") }}</td>
|
||||
</tr>
|
||||
<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="small"
|
||||
icon-left="rss"
|
||||
href="/feed/instance/atom"
|
||||
target="_blank"
|
||||
>{{ t("RSS/Atom Feed") }}</o-button
|
||||
>
|
||||
|
||||
<o-button
|
||||
tag="a"
|
||||
size="small"
|
||||
icon-left="calendar-sync"
|
||||
href="/feed/instance/ics"
|
||||
target="_blank"
|
||||
>{{ t("ICS/WebCal Feed") }}</o-button
|
||||
>
|
||||
</td>
|
||||
<td v-else>{{ t("Disabled") }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { formatList } from "@/utils/i18n";
|
||||
import InstanceContactLink from "@/components/About/InstanceContactLink.vue";
|
||||
import { LANGUAGES_CODES } from "@/graphql/admin";
|
||||
import { ILanguage } from "@/types/admin.model";
|
||||
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";
|
||||
import { useHead } from "@vueuse/head";
|
||||
|
||||
const { result: configResult } = useQuery<{ config: IConfig }>(ABOUT);
|
||||
|
||||
const config = computed(() => configResult.value?.config);
|
||||
|
||||
const { result: statisticsResult } = useQuery<{ statistics: IStatistics }>(
|
||||
STATISTICS
|
||||
);
|
||||
|
||||
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" });
|
||||
|
||||
useHead({
|
||||
title: t("About {instance}", { instance: config.value?.name }),
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
section {
|
||||
&:not(:first-child) {
|
||||
margin: 2rem auto;
|
||||
}
|
||||
|
||||
&.hero {
|
||||
h1.title {
|
||||
margin: auto;
|
||||
}
|
||||
}
|
||||
|
||||
&.contact-statistics {
|
||||
margin: 2px auto;
|
||||
.statistics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, 150px);
|
||||
gap: 2rem 0;
|
||||
p {
|
||||
text-align: right;
|
||||
padding: 0 15px;
|
||||
|
||||
& > * {
|
||||
display: block;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-weight: 500;
|
||||
font-size: 32px;
|
||||
line-height: 48px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
tr.instance-feeds {
|
||||
height: 3rem;
|
||||
td:first-child {
|
||||
vertical-align: middle;
|
||||
}
|
||||
td:last-child {
|
||||
height: 3rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
95
src/views/About/GlossaryView.vue
Normal file
95
src/views/About/GlossaryView.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<div class="container mx-auto px-2">
|
||||
<h1>{{ t("Glossary") }}</h1>
|
||||
<div class="prose dark:prose-invert" v-if="config">
|
||||
<p>
|
||||
{{
|
||||
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 class="mt-3">{{ t("Instance") }}</dt>
|
||||
<i18n-t
|
||||
tag="dd"
|
||||
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."
|
||||
>
|
||||
<template #mobilizon_software
|
||||
><a href="https://joinmobilizon.org">{{
|
||||
t("Mobilizon software")
|
||||
}}</a></template
|
||||
>
|
||||
|
||||
<template #instance_name>
|
||||
<b>{{ config.name }}</b>
|
||||
</template>
|
||||
</i18n-t>
|
||||
<dt class="mt-3">{{ t("Instance administrator") }}</dt>
|
||||
<dd class="mb-2">
|
||||
{{
|
||||
t(
|
||||
"The instance administrator is the person or entity that runs this Mobilizon instance."
|
||||
)
|
||||
}}
|
||||
</dd>
|
||||
<dt class="mt-3">{{ t("Application") }}</dt>
|
||||
<dd class="mb-2">
|
||||
{{
|
||||
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 class="mt-3">{{ t("API") }}</dt>
|
||||
<dd class="mb-2">
|
||||
{{
|
||||
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 class="mt-3">{{ t("SSL/TLS") }}</dt>
|
||||
<i18n-t
|
||||
tag="dd"
|
||||
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."
|
||||
>
|
||||
<template #https><code>https://</code></template>
|
||||
</i18n-t>
|
||||
<dt class="mt-3">{{ t("Cookies and Local storage") }}</dt>
|
||||
<dd class="mb-2">
|
||||
{{
|
||||
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."
|
||||
)
|
||||
}}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useQuery } from "@vue/apollo-composable";
|
||||
import { useHead } from "@vueuse/head";
|
||||
import { computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { ABOUT } from "../../graphql/config";
|
||||
import { IConfig } from "../../types/config.model";
|
||||
|
||||
const { result: configResult } = useQuery<{ config: IConfig }>(ABOUT);
|
||||
|
||||
const config = computed(() => configResult.value?.config);
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
useHead({
|
||||
title: t("Glossary"),
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:deep(dt) {
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
46
src/views/About/PrivacyView.vue
Normal file
46
src/views/About/PrivacyView.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<div class="container mx-auto px-2">
|
||||
<h1>{{ t("Privacy Policy") }}</h1>
|
||||
<div
|
||||
class="prose dark:prose-invert"
|
||||
v-if="config?.privacy"
|
||||
v-html="config.privacy.bodyHtml"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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 { useHead } from "@vueuse/head";
|
||||
import { computed, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const { locale } = useI18n({ useScope: "global" });
|
||||
|
||||
const { result: configResult } = useQuery<{ config: IConfig }>(
|
||||
PRIVACY,
|
||||
() => ({
|
||||
locale: locale,
|
||||
}),
|
||||
() => ({
|
||||
enabled: locale !== undefined,
|
||||
})
|
||||
);
|
||||
|
||||
const config = computed(() => configResult.value?.config);
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
useHead({
|
||||
title: t("Privacy Policy"),
|
||||
});
|
||||
|
||||
watch(config, () => {
|
||||
if (config.value?.privacy?.type === InstancePrivacyType.URL) {
|
||||
window.location.replace(config.value?.privacy?.url);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
30
src/views/About/RulesView.vue
Normal file
30
src/views/About/RulesView.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<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" setup>
|
||||
import { RULES } from "@/graphql/config";
|
||||
import { IConfig } from "@/types/config.model";
|
||||
import { useQuery } from "@vue/apollo-composable";
|
||||
import { useHead } from "@vueuse/head";
|
||||
import { computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const { result: configResult } = useQuery<{ config: IConfig }>(RULES);
|
||||
|
||||
const config = computed(() => configResult.value?.config);
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
useHead({
|
||||
title: t("Rules"),
|
||||
});
|
||||
</script>
|
||||
53
src/views/About/TermsView.vue
Normal file
53
src/views/About/TermsView.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<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" 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";
|
||||
|
||||
const { t, locale } = useI18n({ useScope: "global" });
|
||||
|
||||
const { result: termsResult, loading: termsLoading } = useQuery<{
|
||||
config: IConfig;
|
||||
}>(
|
||||
TERMS,
|
||||
() => ({
|
||||
locale: locale,
|
||||
}),
|
||||
() => ({
|
||||
enabled: locale !== undefined,
|
||||
})
|
||||
);
|
||||
|
||||
const config = computed(() => termsResult.value?.config);
|
||||
|
||||
watch(config, () => {
|
||||
if (config.value?.terms?.type) {
|
||||
redirectToUrl();
|
||||
}
|
||||
});
|
||||
|
||||
const redirectToUrl = (): void => {
|
||||
if (config.value?.terms?.type === InstanceTermsType.URL) {
|
||||
window.location.replace(config.value?.terms?.url);
|
||||
}
|
||||
};
|
||||
|
||||
useHead({
|
||||
title: computed(() => t("Terms")),
|
||||
});
|
||||
</script>
|
||||
141
src/views/AboutView.vue
Normal file
141
src/views/AboutView.vue
Normal file
@@ -0,0 +1,141 @@
|
||||
<template>
|
||||
<div>
|
||||
<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") }}
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<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
|
||||
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
|
||||
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
|
||||
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>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="container mx-auto flex-1 bg-white dark:bg-gray-700">
|
||||
<router-view />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<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 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>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ABOUT } 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 { useCurrentUserClient } from "@/composition/apollo/user";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useHead } from "@vueuse/head";
|
||||
|
||||
const { currentUser } = useCurrentUserClient();
|
||||
|
||||
const { result: configResult } = useQuery<{ config: IConfig }>(ABOUT);
|
||||
|
||||
const config = computed(() => configResult.value?.config);
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
useHead({
|
||||
title: computed(() =>
|
||||
t("About {instance}", { instance: config.value?.name })
|
||||
),
|
||||
});
|
||||
|
||||
// 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>
|
||||
87
src/views/Account/IdentityPicker.vue
Normal file
87
src/views/Account/IdentityPicker.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<div>
|
||||
<header class="">
|
||||
<h2 class="">{{ t("Pick an identity") }}</h2>
|
||||
</header>
|
||||
<section class="">
|
||||
<transition-group
|
||||
tag="ul"
|
||||
class="grid grid-cols-1 gap-y-3 m-5 max-w-md"
|
||||
enter-active-class="duration-300 ease-out"
|
||||
enter-from-class="transform opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="duration-200 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="transform opacity-0"
|
||||
>
|
||||
<li
|
||||
class="relative focus-within:shadow-lg"
|
||||
v-for="identity in identities"
|
||||
:key="identity?.id"
|
||||
>
|
||||
<input
|
||||
class="sr-only peer"
|
||||
type="radio"
|
||||
:value="identity"
|
||||
name="availableActors"
|
||||
v-model="currentIdentity"
|
||||
:id="`availableActor-${identity?.id}`"
|
||||
/>
|
||||
<label
|
||||
class="flex items-center gap-2 p-3 bg-white hover:bg-gray-50 dark:bg-violet-3 dark:hover:bg-violet-3/60 border border-gray-300 rounded-lg cursor-pointer peer-checked:ring-primary peer-checked:ring-2 peer-checked:border-transparent"
|
||||
:for="`availableActor-${identity?.id}`"
|
||||
>
|
||||
<figure class="h-12 w-12" v-if="identity?.avatar">
|
||||
<img
|
||||
class="rounded-full h-full w-full object-cover"
|
||||
:src="identity.avatar.url"
|
||||
alt=""
|
||||
width="48"
|
||||
height="48"
|
||||
/>
|
||||
</figure>
|
||||
<AccountCircle v-else :size="48" />
|
||||
<div class="flex-1 w-px">
|
||||
<h3 class="line-clamp-2">{{ identity?.name }}</h3>
|
||||
<small class="flex truncate">{{
|
||||
`@${identity?.preferredUsername}`
|
||||
}}</small>
|
||||
</div>
|
||||
</label>
|
||||
</li>
|
||||
</transition-group>
|
||||
</section>
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</template>
|
||||
<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";
|
||||
|
||||
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;
|
||||
},
|
||||
set(identity: IPerson) {
|
||||
emit("update:modelValue", identity);
|
||||
},
|
||||
});
|
||||
</script>
|
||||
111
src/views/Account/IdentityPickerWrapper.vue
Normal file
111
src/views/Account/IdentityPickerWrapper.vue
Normal file
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
v-if="inline && currentIdentity"
|
||||
class="inline box cursor-pointer"
|
||||
@click="activateModal"
|
||||
>
|
||||
<div class="flex gap-1">
|
||||
<div class="">
|
||||
<figure class="" v-if="currentIdentity.avatar">
|
||||
<img
|
||||
class="rounded-full"
|
||||
:src="currentIdentity.avatar.url"
|
||||
alt=""
|
||||
width="48"
|
||||
height="48"
|
||||
/>
|
||||
</figure>
|
||||
<AccountCircle v-else :size="48" />
|
||||
</div>
|
||||
<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="" v-else>
|
||||
{{ `@${currentIdentity.preferredUsername}` }}
|
||||
</div>
|
||||
<o-button
|
||||
variant="text"
|
||||
v-if="identities && identities.length > 1"
|
||||
@click="activateModal"
|
||||
>
|
||||
{{ t("Change") }}
|
||||
</o-button>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
v-else-if="currentIdentity"
|
||||
class="cursor-pointer"
|
||||
@click="activateModal"
|
||||
>
|
||||
<figure class="h-12 w-12" v-if="currentIdentity.avatar">
|
||||
<img
|
||||
class="rounded-full object-cover h-full"
|
||||
:src="currentIdentity.avatar.url"
|
||||
alt=""
|
||||
width="48"
|
||||
height="48"
|
||||
/>
|
||||
</figure>
|
||||
<AccountCircle v-else :size="48" />
|
||||
</span>
|
||||
<o-modal
|
||||
v-model:active="isComponentModalActive"
|
||||
:close-button-aria-label="t('Close')"
|
||||
>
|
||||
<identity-picker v-if="currentIdentity" v-model="currentIdentity" />
|
||||
</o-modal>
|
||||
</div>
|
||||
</template>
|
||||
<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";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const { identities } = useCurrentUserIdentities();
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: IPerson;
|
||||
inline?: boolean;
|
||||
masked?: boolean;
|
||||
}>(),
|
||||
{
|
||||
inline: true,
|
||||
masked: false,
|
||||
}
|
||||
);
|
||||
|
||||
const emit = defineEmits(["update:modelValue"]);
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
const isComponentModalActive = ref(false);
|
||||
|
||||
const currentIdentity = computed({
|
||||
get(): IPerson | undefined {
|
||||
return props.modelValue;
|
||||
},
|
||||
set(identity: IPerson | undefined) {
|
||||
emit("update:modelValue", identity);
|
||||
isComponentModalActive.value = false;
|
||||
},
|
||||
});
|
||||
|
||||
const hasOtherIdentities = computed((): boolean => {
|
||||
return identities.value !== undefined && identities.value.length > 1;
|
||||
});
|
||||
|
||||
const activateModal = (): void => {
|
||||
if (hasOtherIdentities.value) {
|
||||
isComponentModalActive.value = true;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
230
src/views/Account/RegisterView.vue
Normal file
230
src/views/Account/RegisterView.vue
Normal file
@@ -0,0 +1,230 @@
|
||||
<template>
|
||||
<section class="container mx-auto max-w-screen-sm">
|
||||
<h1 class="text-2xl" v-if="userAlreadyActivated">
|
||||
{{ t("Congratulations, your account is now created!") }}
|
||||
</h1>
|
||||
<h1 class="text-2xl" v-else>
|
||||
{{
|
||||
t("Register an account on {instanceName}!", {
|
||||
instanceName,
|
||||
})
|
||||
}}
|
||||
</h1>
|
||||
<p class="prose dark:prose-invert" v-if="userAlreadyActivated">
|
||||
{{ t("Now, create your first profile:") }}
|
||||
</p>
|
||||
<form v-if="!validationSent" @submit.prevent="submit">
|
||||
<o-notification variant="danger" v-if="errors.extra">
|
||||
{{ errors.extra }}
|
||||
</o-notification>
|
||||
|
||||
<o-field :label="t('Displayed nickname')" labelFor="identityName">
|
||||
<o-input
|
||||
aria-required="true"
|
||||
required
|
||||
v-model="identity.name"
|
||||
id="identityName"
|
||||
@input="(event: any) => updateUsername(event.target.value)"
|
||||
/>
|
||||
</o-field>
|
||||
|
||||
<o-field
|
||||
:label="t('Username')"
|
||||
:variant="errors.preferred_username ? 'danger' : null"
|
||||
:message="errors.preferred_username"
|
||||
labelFor="identityPreferredUsername"
|
||||
>
|
||||
<o-field
|
||||
:message="
|
||||
t(
|
||||
'Only alphanumeric lowercased characters and underscores are supported.'
|
||||
)
|
||||
"
|
||||
>
|
||||
<o-input
|
||||
aria-required="true"
|
||||
required
|
||||
expanded
|
||||
id="identityPreferredUsername"
|
||||
v-model="identity.preferredUsername"
|
||||
:validation-message="
|
||||
identity.preferredUsername
|
||||
? t(
|
||||
'Only alphanumeric lowercased characters and underscores are supported.'
|
||||
)
|
||||
: null
|
||||
"
|
||||
/>
|
||||
<p class="control">
|
||||
<span class="button">@{{ host }}</span>
|
||||
</p>
|
||||
</o-field>
|
||||
</o-field>
|
||||
<p class="prose dark:prose-invert">
|
||||
{{
|
||||
t(
|
||||
"This identifier is unique to your profile. It allows others to find you."
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
|
||||
<o-field :label="t('Short bio')" labelFor="identitySummary">
|
||||
<o-input
|
||||
type="textarea"
|
||||
maxlength="100"
|
||||
rows="2"
|
||||
id="identitySummary"
|
||||
v-model="identity.summary"
|
||||
/>
|
||||
</o-field>
|
||||
|
||||
<p class="prose dark:prose-invert">
|
||||
{{
|
||||
t(
|
||||
"You will be able to add an avatar and set other options in your account settings."
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
|
||||
<p class="text-center">
|
||||
<o-button
|
||||
variant="primary"
|
||||
size="large"
|
||||
native-type="submit"
|
||||
:disabled="sendingValidation"
|
||||
>{{ t("Create my profile") }}</o-button
|
||||
>
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<div v-if="validationSent && !userAlreadyActivated">
|
||||
<o-notification variant="success" :closable="false">
|
||||
<h2 class="title">
|
||||
{{
|
||||
t("Your account is nearly ready, {username}", {
|
||||
username: identity.name ?? identity.preferredUsername,
|
||||
})
|
||||
}}
|
||||
</h2>
|
||||
<i18n-t keypath="A validation email was sent to {email}" tag="p">
|
||||
<template #email>
|
||||
<code>{{ email }}</code>
|
||||
</template>
|
||||
</i18n-t>
|
||||
<p>
|
||||
{{
|
||||
t(
|
||||
"Before you can login, you need to click on the link inside it to validate your account."
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<o-button tag="router-link" :to="{ name: RouteName.HOME }">{{
|
||||
t("Back to homepage")
|
||||
}}</o-button>
|
||||
</o-notification>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<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/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";
|
||||
import { getValueFromMeta } from "@/utils/html";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
email: string;
|
||||
userAlreadyActivated?: boolean;
|
||||
}>(),
|
||||
{
|
||||
userAlreadyActivated: false,
|
||||
}
|
||||
);
|
||||
|
||||
const { instanceName } = useInstanceName();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
useHead({
|
||||
title: computed(() => t("Register")),
|
||||
});
|
||||
|
||||
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 username = getValueFromMeta("auth-user-suggested-actor-username");
|
||||
const name = getValueFromMeta("auth-user-suggested-actor-name");
|
||||
if (username) {
|
||||
identity.value.preferredUsername = convertToUsername(username);
|
||||
}
|
||||
if (name) {
|
||||
identity.value.name = name;
|
||||
}
|
||||
});
|
||||
|
||||
const updateUsername = (value: string) => {
|
||||
identity.value.preferredUsername = convertToUsername(value);
|
||||
};
|
||||
|
||||
const { onDone, onError, mutate } = registerAccount();
|
||||
|
||||
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 ?? "extra"] = Array.isArray(
|
||||
error.message
|
||||
)
|
||||
? (error.message as string[]).join(",")
|
||||
: error.message;
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
);
|
||||
console.error("Error while registering person", err);
|
||||
console.error("Errors while registering person", errors);
|
||||
sendingValidation.value = false;
|
||||
});
|
||||
|
||||
const submit = async (): Promise<void> => {
|
||||
sendingValidation.value = true;
|
||||
errors.value = {};
|
||||
mutate(
|
||||
{ email: props.email, ...identity.value },
|
||||
{ context: { userAlreadyActivated: props.userAlreadyActivated } }
|
||||
);
|
||||
};
|
||||
</script>
|
||||
759
src/views/Account/children/EditIdentity.vue
Normal file
759
src/views/Account/children/EditIdentity.vue
Normal file
@@ -0,0 +1,759 @@
|
||||
<template>
|
||||
<div>
|
||||
<breadcrumbs-nav :links="breadcrumbsLinks" />
|
||||
<div v-if="identity">
|
||||
<h1 class="flex justify-center">
|
||||
<span v-if="isUpdate" class="line-clamp-2">{{
|
||||
displayName(identity)
|
||||
}}</span>
|
||||
<span v-else>{{ t("I create an identity") }}</span>
|
||||
</h1>
|
||||
<o-field horizontal :label="t('Avatar')">
|
||||
<picture-upload
|
||||
v-model="avatarFile"
|
||||
:defaultImage="identity.avatar"
|
||||
:maxSize="avatarMaxSize"
|
||||
/>
|
||||
</o-field>
|
||||
|
||||
<o-field
|
||||
horizontal
|
||||
:label="t('Display name')"
|
||||
label-for="identity-display-name"
|
||||
>
|
||||
<o-input
|
||||
aria-required="true"
|
||||
required
|
||||
v-model="identity.name"
|
||||
@input="(event: any) => updateUsername(event.target.value)"
|
||||
id="identity-display-name"
|
||||
dir="auto"
|
||||
/>
|
||||
</o-field>
|
||||
|
||||
<o-field
|
||||
horizontal
|
||||
class="username-field"
|
||||
:label="t('Username')"
|
||||
label-for="identity-username"
|
||||
:message="message"
|
||||
>
|
||||
<o-field class="!mt-0">
|
||||
<o-input
|
||||
expanded
|
||||
class="!mt-0"
|
||||
aria-required="true"
|
||||
required
|
||||
v-model="identity.preferredUsername"
|
||||
:disabled="isUpdate"
|
||||
dir="auto"
|
||||
:use-html5-validation="!isUpdate"
|
||||
pattern="[a-z0-9_]+"
|
||||
id="identity-username"
|
||||
/>
|
||||
|
||||
<p class="control">
|
||||
<span class="button is-static !h-auto">@{{ getInstanceHost }}</span>
|
||||
</p>
|
||||
</o-field>
|
||||
</o-field>
|
||||
|
||||
<o-field
|
||||
horizontal
|
||||
:label="t('Description')"
|
||||
label-for="identity-summary"
|
||||
>
|
||||
<o-input
|
||||
type="textarea"
|
||||
dir="auto"
|
||||
aria-required="false"
|
||||
v-model="identity.summary"
|
||||
id="identity-summary"
|
||||
/>
|
||||
</o-field>
|
||||
|
||||
<o-notification
|
||||
variant="danger"
|
||||
has-icon
|
||||
aria-close-label="Close notification"
|
||||
role="alert"
|
||||
:key="error"
|
||||
v-for="error in errors"
|
||||
>{{ error }}</o-notification
|
||||
>
|
||||
|
||||
<o-field class="flex justify-center !my-6">
|
||||
<div class="control">
|
||||
<o-button type="button" variant="primary" @click="submit()">
|
||||
{{ t("Save") }}
|
||||
</o-button>
|
||||
</div>
|
||||
</o-field>
|
||||
|
||||
<o-field class="flex justify-center">
|
||||
<o-button
|
||||
v-if="isUpdate"
|
||||
@click="openDeleteIdentityConfirmation()"
|
||||
variant="text"
|
||||
>
|
||||
{{ t("Delete this identity") }}
|
||||
</o-button>
|
||||
</o-field>
|
||||
|
||||
<section v-if="isUpdate">
|
||||
<h2>{{ t("Profile feeds") }}</h2>
|
||||
<p>
|
||||
{{
|
||||
t(
|
||||
"These feeds contain event data for the events for which this specific profile is a participant or creator. You should keep these private. You can find feeds for all of your profiles into your notification settings."
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<div v-if="identity.feedTokens && identity.feedTokens.length > 0">
|
||||
<div
|
||||
class="flex flex-wrap gap-2"
|
||||
v-for="feedToken in identity.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')
|
||||
"
|
||||
: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')
|
||||
"
|
||||
icon-left="calendar-sync"
|
||||
:href="tokenToURL(feedToken.token, 'ics')"
|
||||
target="_blank"
|
||||
>{{ t("ICS/WebCal Feed") }}</o-button
|
||||
>
|
||||
</o-tooltip>
|
||||
<o-button
|
||||
icon-left="refresh"
|
||||
variant="text"
|
||||
@click="openRegenerateFeedTokensConfirmation"
|
||||
>{{ t("Regenerate new links") }}</o-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<o-button
|
||||
icon-left="refresh"
|
||||
variant="text"
|
||||
@click="generateFeedTokens"
|
||||
>{{ t("Create new links") }}</o-button
|
||||
>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "@/styles/_mixins" as *;
|
||||
// h1 {
|
||||
// display: flex;
|
||||
// justify-content: center;
|
||||
// }
|
||||
|
||||
// .username-field + .field {
|
||||
// margin-bottom: 0;
|
||||
// }
|
||||
|
||||
:deep(.buttons > *:not(:last-child) .button) {
|
||||
@include margin-right(0.5rem);
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
CREATE_PERSON,
|
||||
DELETE_PERSON,
|
||||
FETCH_PERSON,
|
||||
IDENTITIES,
|
||||
PERSON_FRAGMENT,
|
||||
PERSON_FRAGMENT_FEED_TOKENS,
|
||||
UPDATE_PERSON,
|
||||
} from "@/graphql/actor";
|
||||
import { IPerson, displayName } from "@/types/actor";
|
||||
import PictureUpload from "@/components/PictureUpload.vue";
|
||||
import { MOBILIZON_INSTANCE_HOST } from "@/api/_entrypoint";
|
||||
import RouteName from "@/router/name";
|
||||
import { buildFileFromIMedia, buildFileVariable } from "@/utils/image";
|
||||
import { changeIdentity } from "@/utils/identity";
|
||||
import {
|
||||
CREATE_FEED_TOKEN_ACTOR,
|
||||
DELETE_FEED_TOKEN,
|
||||
} from "@/graphql/feed_tokens";
|
||||
import { IFeedToken } from "@/types/feedtoken.model";
|
||||
import { ServerParseError } from "@apollo/client/link/http";
|
||||
import { ApolloCache, FetchResult, InMemoryCache } from "@apollo/client/core";
|
||||
import pick from "lodash/pick";
|
||||
import { ActorType } from "@/types/enums";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useCurrentActorClient } from "@/composition/apollo/actor";
|
||||
import { useMutation, useQuery, useApolloClient } from "@vue/apollo-composable";
|
||||
import { useAvatarMaxSize } from "@/composition/config";
|
||||
import { computed, inject, reactive, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { convertToUsername } from "@/utils/username";
|
||||
import { Dialog } from "@/plugins/dialog";
|
||||
import { Notifier } from "@/plugins/notifier";
|
||||
import { AbsintheGraphQLErrors } from "@/types/errors.model";
|
||||
import { ICurrentUser } from "@/types/current-user.model";
|
||||
import { useHead } from "@vueuse/head";
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
const router = useRouter();
|
||||
|
||||
const props = defineProps<{ isUpdate: boolean; identityName?: string }>();
|
||||
|
||||
const { currentActor } = useCurrentActorClient();
|
||||
|
||||
const {
|
||||
result: personResult,
|
||||
onError: onPersonError,
|
||||
onResult: onPersonResult,
|
||||
} = useQuery<{
|
||||
fetchPerson: IPerson;
|
||||
}>(
|
||||
FETCH_PERSON,
|
||||
() => ({
|
||||
username: props.identityName,
|
||||
}),
|
||||
() => ({
|
||||
enabled: props.identityName !== undefined,
|
||||
})
|
||||
);
|
||||
|
||||
onPersonResult(async ({ data }) => {
|
||||
avatarFile.value = await buildFileFromIMedia(data?.fetchPerson?.avatar);
|
||||
});
|
||||
|
||||
onPersonError((err) => handleErrors(err as unknown as AbsintheGraphQLErrors));
|
||||
|
||||
const person = computed(() => personResult.value?.fetchPerson);
|
||||
|
||||
const baseIdentity: IPerson = {
|
||||
id: undefined,
|
||||
avatar: null,
|
||||
name: "",
|
||||
preferredUsername: "",
|
||||
summary: "",
|
||||
feedTokens: [],
|
||||
url: "",
|
||||
domain: null,
|
||||
type: ActorType.PERSON,
|
||||
suspended: false,
|
||||
};
|
||||
|
||||
const identity = ref<IPerson>(baseIdentity);
|
||||
|
||||
watch(person, () => {
|
||||
console.debug("person changed", person.value);
|
||||
if (person.value) {
|
||||
identity.value = { ...person.value };
|
||||
}
|
||||
});
|
||||
|
||||
const avatarMaxSize = useAvatarMaxSize();
|
||||
|
||||
const errors = ref<string[]>([]);
|
||||
const avatarFile = ref<File | null>(null);
|
||||
const showCopiedTooltip = reactive({ ics: false, atom: false });
|
||||
|
||||
const isUpdate = computed(() => props.isUpdate);
|
||||
const identityName = computed(() => props.identityName);
|
||||
|
||||
const message = computed((): string | null => {
|
||||
if (props.isUpdate) return null;
|
||||
return t(
|
||||
"Only alphanumeric lowercased characters and underscores are supported."
|
||||
);
|
||||
});
|
||||
|
||||
watch(isUpdate, () => {
|
||||
resetFields();
|
||||
});
|
||||
|
||||
watch(identityName, async () => {
|
||||
// Only used when we update the identity
|
||||
if (!isUpdate.value) {
|
||||
identity.value = baseIdentity;
|
||||
return;
|
||||
}
|
||||
|
||||
await redirectIfNoIdentitySelected(identityName.value);
|
||||
|
||||
if (!identityName.value) {
|
||||
router.push({ name: "CreateIdentity" });
|
||||
}
|
||||
|
||||
if (identityName.value && identity.value) {
|
||||
avatarFile.value = null;
|
||||
}
|
||||
});
|
||||
|
||||
const submit = (): Promise<void> => {
|
||||
if (props.isUpdate) return updateIdentity();
|
||||
|
||||
return createIdentity();
|
||||
};
|
||||
|
||||
const {
|
||||
mutate: deletePersonMutation,
|
||||
onDone: deletePersonDone,
|
||||
onError: deletePersonError,
|
||||
} = useMutation(DELETE_PERSON, () => ({
|
||||
update: (store: ApolloCache<InMemoryCache>) => {
|
||||
const data = store.readQuery<{ loggedUser: Pick<ICurrentUser, "actors"> }>({
|
||||
query: IDENTITIES,
|
||||
});
|
||||
|
||||
if (data) {
|
||||
store.writeQuery({
|
||||
query: IDENTITIES,
|
||||
data: {
|
||||
loggedUser: {
|
||||
...data.loggedUser,
|
||||
actors: data.loggedUser.actors.filter(
|
||||
(i) => i.id !== identity.value.id
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
const notifier = inject<Notifier>("notifier");
|
||||
|
||||
const { resolveClient } = useApolloClient();
|
||||
|
||||
deletePersonDone(async () => {
|
||||
notifier?.success(
|
||||
t("Identity {displayName} deleted", {
|
||||
displayName: displayName(identity.value),
|
||||
})
|
||||
);
|
||||
/**
|
||||
* If we just deleted the current identity,
|
||||
* we need to change it to the next one
|
||||
*/
|
||||
const client = resolveClient();
|
||||
const data = client.readQuery<{
|
||||
loggedUser: Pick<ICurrentUser, "actors">;
|
||||
}>({ query: IDENTITIES });
|
||||
if (data) {
|
||||
await maybeUpdateCurrentActorCache(data.loggedUser.actors[0]);
|
||||
}
|
||||
|
||||
await redirectIfNoIdentitySelected();
|
||||
});
|
||||
|
||||
deletePersonError((err) => handleError(err));
|
||||
|
||||
/**
|
||||
* Delete an identity
|
||||
*/
|
||||
const deleteIdentity = async (): Promise<void> => {
|
||||
deletePersonMutation({
|
||||
id: identity.value?.id,
|
||||
});
|
||||
};
|
||||
|
||||
const {
|
||||
mutate: updateIdentityMutation,
|
||||
onDone: updateIdentityDone,
|
||||
onError: updateIdentityError,
|
||||
} = useMutation(UPDATE_PERSON, () => ({
|
||||
update: (
|
||||
store: ApolloCache<InMemoryCache>,
|
||||
{ data: updateData }: FetchResult
|
||||
) => {
|
||||
const data = store.readQuery<{ loggedUser: Pick<ICurrentUser, "actors"> }>({
|
||||
query: IDENTITIES,
|
||||
});
|
||||
|
||||
if (data && updateData?.updatePerson) {
|
||||
maybeUpdateCurrentActorCache(updateData?.updatePerson);
|
||||
|
||||
store.writeFragment({
|
||||
fragment: PERSON_FRAGMENT,
|
||||
id: `Person:${updateData?.updatePerson.id}`,
|
||||
data: {
|
||||
...updateData?.updatePerson,
|
||||
type: ActorType.PERSON,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
updateIdentityDone(() => {
|
||||
notifier?.success(
|
||||
t("Identity {displayName} updated", {
|
||||
displayName: displayName(identity.value),
|
||||
}) as string
|
||||
);
|
||||
});
|
||||
|
||||
updateIdentityError((err) => handleError(err));
|
||||
|
||||
const updateIdentity = async (): Promise<void> => {
|
||||
const variables = await buildVariables();
|
||||
|
||||
updateIdentityMutation(variables);
|
||||
};
|
||||
|
||||
const {
|
||||
mutate: createIdentityMutation,
|
||||
onDone: createIdentityDone,
|
||||
onError: createIdentityError,
|
||||
} = useMutation(CREATE_PERSON, () => ({
|
||||
update: (
|
||||
store: ApolloCache<InMemoryCache>,
|
||||
{ data: updateData }: FetchResult
|
||||
) => {
|
||||
const data = store.readQuery<{ loggedUser: Pick<ICurrentUser, "actors"> }>({
|
||||
query: IDENTITIES,
|
||||
});
|
||||
|
||||
if (data && updateData?.createPerson) {
|
||||
store.writeQuery({
|
||||
query: IDENTITIES,
|
||||
data: {
|
||||
loggedUser: {
|
||||
...data.loggedUser,
|
||||
actors: [
|
||||
...data.loggedUser.actors,
|
||||
{ ...updateData?.createPerson, type: ActorType.PERSON },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
createIdentityDone(() => {
|
||||
notifier?.success(
|
||||
t("Identity {displayName} created", {
|
||||
displayName: displayName(identity.value),
|
||||
})
|
||||
);
|
||||
|
||||
router.push({
|
||||
name: RouteName.UPDATE_IDENTITY,
|
||||
params: { identityName: identity.value.preferredUsername },
|
||||
});
|
||||
});
|
||||
|
||||
createIdentityError((err) => handleError(err));
|
||||
|
||||
const createIdentity = async (): Promise<void> => {
|
||||
const variables = await buildVariables();
|
||||
|
||||
createIdentityMutation(variables);
|
||||
};
|
||||
|
||||
const handleErrors = (absintheErrors: AbsintheGraphQLErrors): void => {
|
||||
if (absintheErrors.some((error) => error.status_code === 401)) {
|
||||
router.push({ name: RouteName.LOGIN });
|
||||
}
|
||||
};
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
const getInstanceHost = computed((): string => {
|
||||
return MOBILIZON_INSTANCE_HOST;
|
||||
});
|
||||
|
||||
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 generateFeedTokens = async (): Promise<void> => {
|
||||
await createNewFeedToken({ actorId: identity.value?.id });
|
||||
};
|
||||
|
||||
const regenerateFeedTokens = async (): Promise<void> => {
|
||||
if (identity.value?.feedTokens.length < 1) return;
|
||||
await deleteFeedToken({ token: identity.value.feedTokens[0].token });
|
||||
await createNewFeedToken(
|
||||
{ actorId: identity.value?.id },
|
||||
{
|
||||
update(cache, { data }) {
|
||||
const actorId = data?.createFeedToken.actor?.id;
|
||||
const newFeedToken = data?.createFeedToken.token;
|
||||
|
||||
if (!newFeedToken) return;
|
||||
|
||||
let cachedData = cache.readFragment<{
|
||||
id: string | undefined;
|
||||
feedTokens: { token: string }[];
|
||||
}>({
|
||||
id: `Person:${actorId}`,
|
||||
fragment: PERSON_FRAGMENT_FEED_TOKENS,
|
||||
});
|
||||
// Remove the old token
|
||||
cachedData = {
|
||||
id: cachedData?.id,
|
||||
feedTokens: [
|
||||
...(cachedData?.feedTokens ?? []).slice(0, -1),
|
||||
{ token: newFeedToken },
|
||||
],
|
||||
};
|
||||
cache.writeFragment({
|
||||
id: `Person:${actorId}`,
|
||||
fragment: PERSON_FRAGMENT_FEED_TOKENS,
|
||||
data: cachedData,
|
||||
});
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const { mutate: deleteFeedToken } = useMutation(DELETE_FEED_TOKEN);
|
||||
|
||||
const { mutate: createNewFeedToken } = useMutation<{
|
||||
createFeedToken: IFeedToken;
|
||||
}>(CREATE_FEED_TOKEN_ACTOR, () => ({
|
||||
update(cache, { data }) {
|
||||
const actorId = data?.createFeedToken.actor?.id;
|
||||
const newFeedToken = data?.createFeedToken.token;
|
||||
|
||||
if (!newFeedToken) return;
|
||||
|
||||
let cachedData = cache.readFragment<{
|
||||
id: string | undefined;
|
||||
feedTokens: { token: string }[];
|
||||
}>({
|
||||
id: `Person:${actorId}`,
|
||||
fragment: PERSON_FRAGMENT_FEED_TOKENS,
|
||||
});
|
||||
// Add the new token to the list
|
||||
cachedData = {
|
||||
id: cachedData?.id,
|
||||
feedTokens: [...(cachedData?.feedTokens ?? []), { token: newFeedToken }],
|
||||
};
|
||||
cache.writeFragment({
|
||||
id: `Person:${actorId}`,
|
||||
fragment: PERSON_FRAGMENT_FEED_TOKENS,
|
||||
data: cachedData,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
const dialog = inject<Dialog>("dialog");
|
||||
|
||||
const openRegenerateFeedTokensConfirmation = (): void => {
|
||||
dialog?.confirm({
|
||||
variant: "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 openDeleteIdentityConfirmation = (): void => {
|
||||
dialog?.prompt({
|
||||
variant: "danger",
|
||||
title: t("Delete your identity") as string,
|
||||
message: `${t(
|
||||
"This will delete / anonymize all content (events, comments, messages, participations…) created from this identity."
|
||||
)}
|
||||
<br /><br />
|
||||
${t(
|
||||
"If this identity is the only administrator of some groups, you need to delete them before being able to delete this identity."
|
||||
)}
|
||||
${t(
|
||||
"Otherwise this identity will just be removed from the group administrators."
|
||||
)}
|
||||
<br /><br />
|
||||
${t(
|
||||
'To confirm, type your identity username "{preferredUsername}"',
|
||||
{
|
||||
preferredUsername: identity.value.preferredUsername,
|
||||
}
|
||||
)}`,
|
||||
confirmText: t("Delete {preferredUsername}", {
|
||||
preferredUsername: identity.value.preferredUsername,
|
||||
}),
|
||||
inputAttrs: {
|
||||
placeholder: identity.value.preferredUsername,
|
||||
pattern: identity.value.preferredUsername,
|
||||
},
|
||||
onConfirm: () => deleteIdentity(),
|
||||
});
|
||||
};
|
||||
|
||||
const handleError = (err: any) => {
|
||||
console.error(err);
|
||||
|
||||
if (err?.networkError?.name === "ServerParseError") {
|
||||
const error = err?.networkError as ServerParseError;
|
||||
|
||||
if (error?.response?.status === 413) {
|
||||
const errorMessage = props.isUpdate
|
||||
? t(
|
||||
"Unable to update the profile. The avatar picture may be too heavy."
|
||||
)
|
||||
: t(
|
||||
"Unable to create the profile. The avatar picture may be too heavy."
|
||||
);
|
||||
errors.value.push(errorMessage as string);
|
||||
}
|
||||
}
|
||||
|
||||
if (err.graphQLErrors !== undefined) {
|
||||
err.graphQLErrors.forEach(
|
||||
({ message: errorMessage }: { message: string }) => {
|
||||
notifier?.error(errorMessage);
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const buildVariables = async (): Promise<Record<string, unknown>> => {
|
||||
/**
|
||||
* We set the avatar only if user has selected one
|
||||
*/
|
||||
let avatarObj: Record<string, unknown> = { avatar: null };
|
||||
if (avatarFile.value) {
|
||||
avatarObj = buildFileVariable(
|
||||
avatarFile.value,
|
||||
"avatar",
|
||||
`${identity.value.preferredUsername}'s avatar`
|
||||
);
|
||||
}
|
||||
return pick({ ...identity.value, ...avatarObj }, [
|
||||
"id",
|
||||
"preferredUsername",
|
||||
"name",
|
||||
"summary",
|
||||
"avatar",
|
||||
]);
|
||||
};
|
||||
|
||||
const redirectIfNoIdentitySelected = async (identityParam?: string) => {
|
||||
if (identityParam) return;
|
||||
|
||||
// await loadLoggedPersonIfNeeded();
|
||||
|
||||
if (currentActor.value) {
|
||||
await router.push({
|
||||
params: { identityName: currentActor.value?.preferredUsername },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const maybeUpdateCurrentActorCache = async (newIdentity: IPerson) => {
|
||||
if (currentActor.value) {
|
||||
if (
|
||||
currentActor.value.preferredUsername === identity.value.preferredUsername
|
||||
) {
|
||||
await changeIdentity(newIdentity);
|
||||
}
|
||||
// currentActor.value = newIdentity;
|
||||
}
|
||||
};
|
||||
|
||||
// const loadLoggedPersonIfNeeded = async (bypassCache = false) => {
|
||||
// if (currentActor.value) return;
|
||||
|
||||
// const result = await this.$apollo.query({
|
||||
// query: CURRENT_ACTOR_CLIENT,
|
||||
// fetchPolicy: bypassCache ? "network-only" : undefined,
|
||||
// });
|
||||
|
||||
// currentActor.value = result.data.currentActor;
|
||||
// };
|
||||
|
||||
const resetFields = () => {
|
||||
// identity.value = new Person();
|
||||
// oldDisplayName.value = null;
|
||||
avatarFile.value = null;
|
||||
};
|
||||
|
||||
const breadcrumbsLinks = computed(
|
||||
(): { name: string; params: Record<string, any>; text: string }[] => {
|
||||
const links = [
|
||||
{
|
||||
name: RouteName.IDENTITIES,
|
||||
params: {},
|
||||
text: t("Profiles") as string,
|
||||
},
|
||||
];
|
||||
if (props.isUpdate && identity.value) {
|
||||
links.push({
|
||||
name: RouteName.UPDATE_IDENTITY,
|
||||
params: { identityName: identity.value.preferredUsername },
|
||||
text: identity.value.name,
|
||||
});
|
||||
} else {
|
||||
links.push({
|
||||
name: RouteName.CREATE_IDENTITY,
|
||||
params: {},
|
||||
text: t("New profile") as string,
|
||||
});
|
||||
}
|
||||
return links;
|
||||
}
|
||||
);
|
||||
|
||||
const updateUsername = (value: string) => {
|
||||
identity.value.preferredUsername = convertToUsername(value);
|
||||
};
|
||||
|
||||
useHead({
|
||||
title: computed(() => {
|
||||
let title = t("Create a new profile") as string;
|
||||
if (isUpdate.value) {
|
||||
title = t("Edit profile {profile}", {
|
||||
profile: identityName.value,
|
||||
}) as string;
|
||||
}
|
||||
return title;
|
||||
}),
|
||||
});
|
||||
</script>
|
||||
549
src/views/Admin/AdminGroupProfile.vue
Normal file
549
src/views/Admin/AdminGroupProfile.vue
Normal file
@@ -0,0 +1,549 @@
|
||||
<template>
|
||||
<div v-if="group" class="section">
|
||||
<breadcrumbs-nav
|
||||
:links="[
|
||||
{ name: RouteName.ADMIN, text: t('Admin') },
|
||||
{
|
||||
name: RouteName.ADMIN_GROUPS,
|
||||
text: t('Groups'),
|
||||
},
|
||||
{
|
||||
name: RouteName.PROFILES,
|
||||
params: { id: group.id },
|
||||
text: displayName(group),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<div>
|
||||
<p v-if="group.suspended" class="mx-auto max-w-sm block mb-2">
|
||||
<actor-card
|
||||
:actor="group"
|
||||
:full="true"
|
||||
:popover="false"
|
||||
:limit="false"
|
||||
/>
|
||||
</p>
|
||||
<router-link
|
||||
class="mx-auto max-w-sm block mb-2"
|
||||
v-else
|
||||
:to="{
|
||||
name: RouteName.GROUP,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
}"
|
||||
>
|
||||
<actor-card
|
||||
:actor="group"
|
||||
:full="true"
|
||||
:popover="false"
|
||||
:limit="false"
|
||||
/>
|
||||
</router-link>
|
||||
</div>
|
||||
<table v-if="metadata.length > 0" class="table w-full">
|
||||
<tbody>
|
||||
<tr v-for="{ key, value, link } in metadata" :key="key">
|
||||
<td>{{ key }}</td>
|
||||
<td v-if="link">
|
||||
<router-link :to="link">
|
||||
{{ value }}
|
||||
</router-link>
|
||||
</td>
|
||||
<td v-else>{{ value }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="flex gap-1">
|
||||
<o-button
|
||||
@click="confirmSuspendProfile"
|
||||
v-if="!group.suspended"
|
||||
variant="primary"
|
||||
>{{ t("Suspend") }}</o-button
|
||||
>
|
||||
<o-button
|
||||
@click="
|
||||
unsuspendProfile({
|
||||
id,
|
||||
})
|
||||
"
|
||||
v-if="group.suspended"
|
||||
variant="primary"
|
||||
>{{ t("Unsuspend") }}</o-button
|
||||
>
|
||||
<o-button
|
||||
@click="
|
||||
refreshProfile({
|
||||
actorId: id,
|
||||
})
|
||||
"
|
||||
v-if="group.domain"
|
||||
variant="primary"
|
||||
outlined
|
||||
>{{ t("Refresh profile") }}</o-button
|
||||
>
|
||||
</div>
|
||||
<section>
|
||||
<h2>
|
||||
{{
|
||||
t(
|
||||
"{number} members",
|
||||
{
|
||||
number: group.members.total,
|
||||
},
|
||||
group.members.total
|
||||
)
|
||||
}}
|
||||
</h2>
|
||||
<o-table
|
||||
:data="group.members.elements"
|
||||
:loading="loading"
|
||||
paginated
|
||||
backend-pagination
|
||||
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"
|
||||
>
|
||||
<o-table-column
|
||||
field="actor.preferredUsername"
|
||||
:label="t('Member')"
|
||||
v-slot="props"
|
||||
>
|
||||
<article class="flex gap-1">
|
||||
<div class="flex-none">
|
||||
<router-link
|
||||
class="no-underline"
|
||||
:to="{
|
||||
name: RouteName.ADMIN_PROFILE,
|
||||
params: { id: props.row.actor.id },
|
||||
}"
|
||||
>
|
||||
<figure v-if="props.row.actor.avatar">
|
||||
<img
|
||||
class="rounded"
|
||||
:src="props.row.actor.avatar.url"
|
||||
alt=""
|
||||
width="48"
|
||||
height="48"
|
||||
/>
|
||||
</figure>
|
||||
<AccountCircle :size="48" v-else />
|
||||
</router-link>
|
||||
</div>
|
||||
<div>
|
||||
<div class="prose dark:prose-invert">
|
||||
<router-link
|
||||
class="no-underline"
|
||||
:to="{
|
||||
name: RouteName.ADMIN_PROFILE,
|
||||
params: { id: props.row.actor.id },
|
||||
}"
|
||||
v-if="props.row.actor.name"
|
||||
>{{ props.row.actor.name }}</router-link
|
||||
><router-link
|
||||
class="no-underline"
|
||||
:to="{
|
||||
name: RouteName.ADMIN_PROFILE,
|
||||
params: { id: props.row.actor.id },
|
||||
}"
|
||||
v-else
|
||||
>@{{ usernameWithDomain(props.row.actor) }}</router-link
|
||||
><br />
|
||||
<router-link
|
||||
class="no-underline"
|
||||
:to="{
|
||||
name: RouteName.ADMIN_PROFILE,
|
||||
params: { id: props.row.actor.id },
|
||||
}"
|
||||
v-if="props.row.actor.name"
|
||||
>@{{ usernameWithDomain(props.row.actor) }}</router-link
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</o-table-column>
|
||||
<o-table-column field="role" :label="t('Role')" v-slot="props">
|
||||
<tag
|
||||
variant="primary"
|
||||
v-if="props.row.role === MemberRole.ADMINISTRATOR"
|
||||
>
|
||||
{{ t("Administrator") }}
|
||||
</tag>
|
||||
<tag
|
||||
variant="primary"
|
||||
v-else-if="props.row.role === MemberRole.MODERATOR"
|
||||
>
|
||||
{{ t("Moderator") }}
|
||||
</tag>
|
||||
<tag v-else-if="props.row.role === MemberRole.MEMBER">
|
||||
{{ t("Member") }}
|
||||
</tag>
|
||||
<tag
|
||||
variant="warning"
|
||||
v-else-if="props.row.role === MemberRole.NOT_APPROVED"
|
||||
>
|
||||
{{ t("Not approved") }}
|
||||
</tag>
|
||||
<tag
|
||||
variant="danger"
|
||||
v-else-if="props.row.role === MemberRole.REJECTED"
|
||||
>
|
||||
{{ t("Rejected") }}
|
||||
</tag>
|
||||
<tag
|
||||
variant="danger"
|
||||
v-else-if="props.row.role === MemberRole.INVITED"
|
||||
>
|
||||
{{ t("Invited") }}
|
||||
</tag>
|
||||
</o-table-column>
|
||||
<o-table-column field="insertedAt" :label="t('Date')" v-slot="props">
|
||||
<span class="has-text-centered">
|
||||
{{ formatDateString(props.row.insertedAt) }}<br />{{
|
||||
formatTimeString(props.row.insertedAt)
|
||||
}}
|
||||
</span>
|
||||
</o-table-column>
|
||||
<template #empty>
|
||||
<empty-content icon="account-group" :inline="true">
|
||||
{{ t("No members found") }}
|
||||
</empty-content>
|
||||
</template>
|
||||
</o-table>
|
||||
</section>
|
||||
<section>
|
||||
<h2>
|
||||
{{
|
||||
t(
|
||||
"{number} organized events",
|
||||
{
|
||||
number: group.organizedEvents.total,
|
||||
},
|
||||
group.organizedEvents.total
|
||||
)
|
||||
}}
|
||||
</h2>
|
||||
<o-table
|
||||
:data="group.organizedEvents.elements"
|
||||
:loading="loading"
|
||||
paginated
|
||||
backend-pagination
|
||||
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"
|
||||
>
|
||||
<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 }}
|
||||
<tag variant="info" v-if="props.row.draft">{{ t("Draft") }}</tag>
|
||||
</router-link>
|
||||
</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") }}
|
||||
</empty-content>
|
||||
</template>
|
||||
</o-table>
|
||||
</section>
|
||||
<section>
|
||||
<h2>
|
||||
{{
|
||||
t(
|
||||
"{number} posts",
|
||||
{
|
||||
number: group.posts.total,
|
||||
},
|
||||
group.posts.total
|
||||
)
|
||||
}}
|
||||
</h2>
|
||||
<o-table
|
||||
:data="group.posts.elements"
|
||||
:loading="loading"
|
||||
paginated
|
||||
backend-pagination
|
||||
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"
|
||||
>
|
||||
<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 }}
|
||||
<tag variant="info" v-if="props.row.draft">{{ t("Draft") }}</tag>
|
||||
</router-link>
|
||||
</o-table-column>
|
||||
<o-table-column
|
||||
field="publishAt"
|
||||
:label="t('Publication date')"
|
||||
v-slot="props"
|
||||
>
|
||||
{{ formatDateTimeString(props.row.publishAt) }}
|
||||
</o-table-column>
|
||||
<template #empty>
|
||||
<empty-content icon="bullhorn" :inline="true">
|
||||
{{ t("No posts found") }}
|
||||
</empty-content>
|
||||
</template>
|
||||
</o-table>
|
||||
</section>
|
||||
</div>
|
||||
<empty-content v-else-if="!loading" icon="account-multiple">
|
||||
{{ t("This group was not found") }}
|
||||
<template #desc>
|
||||
<o-button
|
||||
variant="text"
|
||||
tag="router-link"
|
||||
:to="{ name: RouteName.ADMIN_GROUPS }"
|
||||
>{{ t("Back to group list") }}</o-button
|
||||
>
|
||||
</template>
|
||||
</empty-content>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { GET_GROUP, REFRESH_PROFILE } from "@/graphql/group";
|
||||
import { formatBytes } from "@/utils/datetime";
|
||||
import { MemberRole } from "@/types/enums";
|
||||
import { SUSPEND_PROFILE, UNSUSPEND_PROFILE } from "../../graphql/actor";
|
||||
import { IGroup } from "../../types/actor";
|
||||
import {
|
||||
usernameWithDomain,
|
||||
displayName,
|
||||
IActor,
|
||||
} 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 { 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";
|
||||
import AccountCircle from "vue-material-design-icons/AccountCircle.vue";
|
||||
import Tag from "@/components/TagElement.vue";
|
||||
|
||||
const EVENTS_PER_PAGE = 10;
|
||||
const POSTS_PER_PAGE = 10;
|
||||
const MEMBERS_PER_PAGE = 10;
|
||||
|
||||
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,
|
||||
},
|
||||
{
|
||||
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;
|
||||
});
|
||||
|
||||
const dialog = inject<Dialog>("dialog");
|
||||
const notifier = inject<Notifier>("notifier");
|
||||
|
||||
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>."
|
||||
);
|
||||
|
||||
dialog?.confirm({
|
||||
title: t("Suspend group"),
|
||||
message,
|
||||
confirmText: t("Suspend group"),
|
||||
cancelText: t("Cancel"),
|
||||
variant: "danger",
|
||||
hasIcon: true,
|
||||
onConfirm: () =>
|
||||
suspendProfile({
|
||||
id: props.id,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
const profileData = store.readQuery<{ getGroup: IGroup }>({
|
||||
query: GET_GROUP,
|
||||
variables: {
|
||||
id: profileId,
|
||||
organizedEventsPage: organizedEventsPage.value,
|
||||
organizedEventsLimit: EVENTS_PER_PAGE,
|
||||
postsPage: postsPage.value,
|
||||
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: "",
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
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,
|
||||
},
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
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>
|
||||
510
src/views/Admin/AdminProfile.vue
Normal file
510
src/views/Admin/AdminProfile.vue
Normal file
@@ -0,0 +1,510 @@
|
||||
<template>
|
||||
<div v-if="person" class="section">
|
||||
<breadcrumbs-nav
|
||||
:links="[
|
||||
{ name: RouteName.ADMIN, text: $t('Admin') },
|
||||
{
|
||||
name: RouteName.PROFILES,
|
||||
text: $t('Profiles'),
|
||||
},
|
||||
{
|
||||
name: RouteName.PROFILES,
|
||||
params: { id: person.id },
|
||||
text: displayName(person),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
|
||||
<div class="flex justify-center">
|
||||
<actor-card
|
||||
:actor="person"
|
||||
:full="true"
|
||||
:popover="false"
|
||||
:limit="false"
|
||||
/>
|
||||
</div>
|
||||
<section class="mt-4 mb-3">
|
||||
<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">
|
||||
<div class="overflow-hidden shadow-md sm:rounded-lg">
|
||||
<table v-if="metadata.length > 0" class="min-w-full">
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="{ key, value, link } in metadata"
|
||||
:key="key"
|
||||
class="odd:bg-white dark:odd:bg-zinc-800 even:bg-gray-50 dark:even:bg-zinc-700 border-b"
|
||||
>
|
||||
<td class="py-4 px-2 whitespace-nowrap">
|
||||
{{ key }}
|
||||
</td>
|
||||
<td
|
||||
v-if="link"
|
||||
class="py-4 px-2 text-sm text-gray-500 dark:text-gray-200 whitespace-nowrap"
|
||||
>
|
||||
<router-link :to="link">
|
||||
{{ value }}
|
||||
</router-link>
|
||||
</td>
|
||||
<td
|
||||
v-else
|
||||
class="py-4 px-2 text-sm text-gray-500 dark:text-gray-200 whitespace-nowrap"
|
||||
>
|
||||
{{ value }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="mt-4 mb-3">
|
||||
<h2 class="">{{ $t("Actions") }}</h2>
|
||||
<div class="buttons" v-if="person.domain">
|
||||
<o-button
|
||||
@click="suspendProfile({ id })"
|
||||
v-if="person.domain && !person.suspended"
|
||||
variant="primary"
|
||||
>{{ $t("Suspend") }}</o-button
|
||||
>
|
||||
<o-button
|
||||
@click="unsuspendProfile({ id })"
|
||||
v-if="person.domain && person.suspended"
|
||||
variant="primary"
|
||||
>{{ $t("Unsuspend") }}</o-button
|
||||
>
|
||||
</div>
|
||||
<p v-else></p>
|
||||
<div
|
||||
v-if="person.user"
|
||||
class="p-4 mb-4 text-sm text-blue-700 bg-blue-100 rounded-lg"
|
||||
role="alert"
|
||||
>
|
||||
<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
|
||||
class="underline"
|
||||
:to="{
|
||||
name: RouteName.ADMIN_USER_PROFILE,
|
||||
params: { id: person.user.id },
|
||||
}"
|
||||
>{{ $t("access the corresponding account") }}</router-link
|
||||
>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</div>
|
||||
</section>
|
||||
<section class="mt-4 mb-3">
|
||||
<h2 class="">{{ $t("Organized events") }}</h2>
|
||||
<o-table
|
||||
:data="person.organizedEvents?.elements"
|
||||
:loading="loading"
|
||||
paginated
|
||||
backend-pagination
|
||||
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="person.organizedEvents?.total"
|
||||
:per-page="EVENTS_PER_PAGE"
|
||||
@page-change="onOrganizedEventsPageChange"
|
||||
>
|
||||
<o-table-column
|
||||
field="beginsOn"
|
||||
:label="$t('Begins on')"
|
||||
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>
|
||||
</o-table-column>
|
||||
<template #empty>
|
||||
<empty-content icon="account-group" :inline="true">
|
||||
{{ $t("No organized events listed") }}
|
||||
</empty-content>
|
||||
</template>
|
||||
</o-table>
|
||||
</section>
|
||||
<section class="mt-4 mb-3">
|
||||
<h2 class="">{{ $t("Participations") }}</h2>
|
||||
<o-table
|
||||
:data="
|
||||
person.participations?.elements.map(
|
||||
(participation) => participation.event
|
||||
)
|
||||
"
|
||||
:loading="loading"
|
||||
paginated
|
||||
backend-pagination
|
||||
v-model:current-page="participationsPage"
|
||||
:aria-next-label="$t('Next page')"
|
||||
:aria-previous-label="$t('Previous page')"
|
||||
:aria-page-label="$t('Page')"
|
||||
:aria-current-label="$t('Current page')"
|
||||
:total="person.participations?.total"
|
||||
:per-page="EVENTS_PER_PAGE"
|
||||
@page-change="onParticipationsPageChange"
|
||||
>
|
||||
<o-table-column
|
||||
field="beginsOn"
|
||||
:label="$t('Begins on')"
|
||||
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>
|
||||
</o-table-column>
|
||||
<template #empty>
|
||||
<empty-content icon="account-group" :inline="true">
|
||||
{{ $t("No participations listed") }}
|
||||
</empty-content>
|
||||
</template>
|
||||
</o-table>
|
||||
</section>
|
||||
<section class="mt-4 mb-3">
|
||||
<h2 class="">{{ $t("Memberships") }}</h2>
|
||||
<o-table
|
||||
:data="person.memberships?.elements"
|
||||
:loading="loading"
|
||||
paginated
|
||||
backend-pagination
|
||||
v-model:current-page="membershipsPage"
|
||||
:aria-next-label="$t('Next page')"
|
||||
:aria-previous-label="$t('Previous page')"
|
||||
:aria-page-label="$t('Page')"
|
||||
:aria-current-label="$t('Current page')"
|
||||
:total="person.memberships?.total"
|
||||
:per-page="EVENTS_PER_PAGE"
|
||||
@page-change="onMembershipsPageChange"
|
||||
>
|
||||
<o-table-column
|
||||
field="parent.preferredUsername"
|
||||
:label="$t('Group')"
|
||||
v-slot="props"
|
||||
>
|
||||
<article class="flex gap-2">
|
||||
<router-link
|
||||
class="no-underline"
|
||||
:to="{
|
||||
name: RouteName.ADMIN_GROUP_PROFILE,
|
||||
params: { id: props.row.parent.id },
|
||||
}"
|
||||
>
|
||||
<figure class="" v-if="props.row.parent.avatar">
|
||||
<img
|
||||
class="rounded-full"
|
||||
:src="props.row.parent.avatar.url"
|
||||
alt=""
|
||||
width="48"
|
||||
height="48"
|
||||
/>
|
||||
</figure>
|
||||
<AccountCircle v-else :size="48" />
|
||||
</router-link>
|
||||
<div class="">
|
||||
<div class="prose dark:prose-invert">
|
||||
<router-link
|
||||
class="no-underline"
|
||||
:to="{
|
||||
name: RouteName.ADMIN_GROUP_PROFILE,
|
||||
params: { id: props.row.parent.id },
|
||||
}"
|
||||
v-if="props.row.parent.name"
|
||||
>{{ props.row.parent.name }}</router-link
|
||||
><br />
|
||||
<router-link
|
||||
class="no-underline"
|
||||
:to="{
|
||||
name: RouteName.ADMIN_GROUP_PROFILE,
|
||||
params: { id: props.row.parent.id },
|
||||
}"
|
||||
>@{{ usernameWithDomain(props.row.parent) }}</router-link
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</o-table-column>
|
||||
<o-table-column field="role" :label="$t('Role')" v-slot="props">
|
||||
<tag
|
||||
variant="primary"
|
||||
v-if="props.row.role === MemberRole.ADMINISTRATOR"
|
||||
>
|
||||
{{ $t("Administrator") }}
|
||||
</tag>
|
||||
<tag
|
||||
variant="primary"
|
||||
v-else-if="props.row.role === MemberRole.MODERATOR"
|
||||
>
|
||||
{{ $t("Moderator") }}
|
||||
</tag>
|
||||
<tag v-else-if="props.row.role === MemberRole.MEMBER">
|
||||
{{ $t("Member") }}
|
||||
</tag>
|
||||
<tag
|
||||
variant="warning"
|
||||
v-else-if="props.row.role === MemberRole.NOT_APPROVED"
|
||||
>
|
||||
{{ $t("Not approved") }}
|
||||
</tag>
|
||||
<tag
|
||||
variant="danger"
|
||||
v-else-if="props.row.role === MemberRole.REJECTED"
|
||||
>
|
||||
{{ $t("Rejected") }}
|
||||
</tag>
|
||||
<tag
|
||||
variant="danger"
|
||||
v-else-if="props.row.role === MemberRole.INVITED"
|
||||
>
|
||||
{{ $t("Invited") }}
|
||||
</tag>
|
||||
</o-table-column>
|
||||
<o-table-column field="insertedAt" :label="$t('Date')" v-slot="props">
|
||||
<span class="has-text-centered">
|
||||
{{ formatDateString(props.row.insertedAt) }}<br />{{
|
||||
formatTimeString(props.row.insertedAt)
|
||||
}}
|
||||
</span>
|
||||
</o-table-column>
|
||||
<template #empty>
|
||||
<empty-content icon="account-group" :inline="true">
|
||||
{{ $t("No memberships found") }}
|
||||
</empty-content>
|
||||
</template>
|
||||
</o-table>
|
||||
</section>
|
||||
</div>
|
||||
<empty-content v-else-if="!loading" icon="account">
|
||||
{{ $t("This profile was not found") }}
|
||||
<template #desc>
|
||||
<o-button
|
||||
variant="text"
|
||||
tag="router-link"
|
||||
:to="{ name: RouteName.PROFILES }"
|
||||
>{{ $t("Back to profile list") }}</o-button
|
||||
>
|
||||
</template>
|
||||
</empty-content>
|
||||
</template>
|
||||
<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";
|
||||
import { ApolloCache, FetchResult } from "@apollo/client/core";
|
||||
import { MemberRole } from "@/types/enums";
|
||||
import cloneDeep from "lodash/cloneDeep";
|
||||
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";
|
||||
import Tag from "@/components/TagElement.vue";
|
||||
|
||||
const EVENTS_PER_PAGE = 10;
|
||||
const PARTICIPATIONS_PER_PAGE = 10;
|
||||
const MEMBERSHIPS_PER_PAGE = 10;
|
||||
|
||||
const props = defineProps<{ id: string }>();
|
||||
|
||||
const organizedEventsPage = useRouteQuery(
|
||||
"organizedEventsPage",
|
||||
1,
|
||||
integerTransformer
|
||||
);
|
||||
const participationsPage = useRouteQuery(
|
||||
"participationsPage",
|
||||
1,
|
||||
integerTransformer
|
||||
);
|
||||
const membershipsPage = useRouteQuery("membershipsPage", 1, integerTransformer);
|
||||
|
||||
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,
|
||||
}));
|
||||
|
||||
const person = computed(() => personResult.value?.person);
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
useHead({
|
||||
title: computed(() => displayName(person.value)),
|
||||
});
|
||||
|
||||
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: t("Status"),
|
||||
value: person.value.suspended ? t("Suspended") : t("Active"),
|
||||
},
|
||||
{
|
||||
key: t("Domain"),
|
||||
value: person.value.domain ? person.value.domain : t("Local"),
|
||||
link: person.value.domain
|
||||
? {
|
||||
name: RouteName.INSTANCE,
|
||||
params: { domain: person.value.domain },
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
key: t("Uploaded media size"),
|
||||
value: formatBytes(person.value.mediaSize ?? 0),
|
||||
},
|
||||
];
|
||||
if (!person.value.domain && person.value.user) {
|
||||
res.push({
|
||||
key: t("User"),
|
||||
link: {
|
||||
name: RouteName.ADMIN_USER_PROFILE,
|
||||
params: { id: person.value.user.id },
|
||||
},
|
||||
value: person.value.user.email,
|
||||
});
|
||||
}
|
||||
return res;
|
||||
}
|
||||
);
|
||||
|
||||
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: {
|
||||
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: cachedPerson } = profileData;
|
||||
store.writeQuery({
|
||||
query: GET_PERSON,
|
||||
variables: {
|
||||
actorId: profileId,
|
||||
},
|
||||
data: {
|
||||
person: {
|
||||
...cloneDeep(cachedPerson),
|
||||
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>
|
||||
524
src/views/Admin/AdminUserProfile.vue
Normal file
524
src/views/Admin/AdminUserProfile.vue
Normal file
@@ -0,0 +1,524 @@
|
||||
<template>
|
||||
<div v-if="user">
|
||||
<breadcrumbs-nav
|
||||
:links="[
|
||||
{ name: RouteName.ADMIN, text: t('Admin') },
|
||||
{
|
||||
name: RouteName.USERS,
|
||||
text: t('Users'),
|
||||
},
|
||||
{
|
||||
name: RouteName.ADMIN_USER_PROFILE,
|
||||
params: { id: user.id },
|
||||
text: user.email,
|
||||
},
|
||||
]"
|
||||
/>
|
||||
|
||||
<section>
|
||||
<h2 class="text-lg font-bold mb-3">{{ t("Details") }}</h2>
|
||||
<div class="flex flex-col">
|
||||
<div class="overflow-x-auto">
|
||||
<div class="inline-block py-2 min-w-full sm:px-2">
|
||||
<div class="overflow-hidden shadow-md sm:rounded-lg">
|
||||
<table v-if="metadata.length > 0" class="table w-full">
|
||||
<tbody>
|
||||
<tr
|
||||
class="border-b"
|
||||
v-for="{ key, value, type } in metadata"
|
||||
:key="key"
|
||||
>
|
||||
<td class="py-4 px-2 whitespace-nowrap align-middle">
|
||||
{{ key }}
|
||||
</td>
|
||||
|
||||
<td
|
||||
v-if="type === 'ip'"
|
||||
class="py-4 px-2 whitespace-nowrap"
|
||||
>
|
||||
<code class="truncate block max-w-[15rem]">{{
|
||||
value
|
||||
}}</code>
|
||||
</td>
|
||||
<td
|
||||
v-else-if="type === 'role'"
|
||||
class="py-4 px-2 whitespace-nowrap"
|
||||
>
|
||||
<span
|
||||
:class="{
|
||||
'bg-red-100 text-red-800':
|
||||
user.role == ICurrentUserRole.ADMINISTRATOR,
|
||||
'bg-yellow-100 text-yellow-800':
|
||||
user.role == ICurrentUserRole.MODERATOR,
|
||||
'bg-blue-100 text-blue-800':
|
||||
user.role == ICurrentUserRole.USER,
|
||||
}"
|
||||
class="text-sm font-medium mr-2 px-2.5 py-0.5 rounded"
|
||||
>
|
||||
{{ value }}
|
||||
</span>
|
||||
</td>
|
||||
<td v-else class="py-4 px-2 align-middle">
|
||||
{{ value }}
|
||||
</td>
|
||||
<td
|
||||
v-if="type === 'email'"
|
||||
class="py-4 px-2 whitespace-nowrap flex flex flex-col items-start gap-2"
|
||||
>
|
||||
<o-button
|
||||
size="small"
|
||||
v-if="!user.disabled"
|
||||
@click="isEmailChangeModalActive = true"
|
||||
variant="text"
|
||||
icon-left="pencil"
|
||||
>{{ t("Change email") }}</o-button
|
||||
>
|
||||
<o-button
|
||||
tag="router-link"
|
||||
:to="{
|
||||
name: RouteName.USERS,
|
||||
query: { emailFilter: `@${userEmailDomain}` },
|
||||
}"
|
||||
size="small"
|
||||
variant="text"
|
||||
icon-left="magnify"
|
||||
>{{
|
||||
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"
|
||||
>
|
||||
<o-button
|
||||
size="small"
|
||||
v-if="!user.confirmedAt || user.disabled"
|
||||
@click="isConfirmationModalActive = true"
|
||||
variant="text"
|
||||
icon-left="check"
|
||||
>{{ t("Confirm user") }}</o-button
|
||||
>
|
||||
</td>
|
||||
<td
|
||||
v-else-if="type === 'role'"
|
||||
class="py-4 px-2 whitespace-nowrap flex items-center"
|
||||
>
|
||||
<o-button
|
||||
size="small"
|
||||
v-if="!user.disabled"
|
||||
@click="isRoleChangeModalActive = true"
|
||||
variant="text"
|
||||
icon-left="chevron-double-up"
|
||||
>{{ t("Change role") }}</o-button
|
||||
>
|
||||
</td>
|
||||
<td
|
||||
v-else-if="type === 'ip' && user.currentSignInIp"
|
||||
class="py-4 px-2 whitespace-nowrap flex items-center"
|
||||
>
|
||||
<o-button
|
||||
tag="router-link"
|
||||
:to="{
|
||||
name: RouteName.USERS,
|
||||
query: { ipFilter: user.currentSignInIp },
|
||||
}"
|
||||
size="small"
|
||||
variant="text"
|
||||
icon-left="web"
|
||||
>{{
|
||||
t("Other users with the same IP address")
|
||||
}}</o-button
|
||||
>
|
||||
</td>
|
||||
<td v-else></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="my-4">
|
||||
<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 && profiles.length > 0"
|
||||
>
|
||||
<router-link
|
||||
v-for="profile in profiles"
|
||||
:key="profile.id"
|
||||
:to="{ name: RouteName.ADMIN_PROFILE, params: { id: profile.id } }"
|
||||
>
|
||||
<actor-card
|
||||
:actor="profile"
|
||||
:full="true"
|
||||
:popover="false"
|
||||
:limit="true"
|
||||
/>
|
||||
</router-link>
|
||||
</div>
|
||||
<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>
|
||||
<div class="buttons" v-if="!user.disabled">
|
||||
<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") }}
|
||||
</div>
|
||||
</section>
|
||||
<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" v-model="user.email" disabled />
|
||||
</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 outlined @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-modal
|
||||
>
|
||||
<form @submit.prevent="updateUserRole">
|
||||
<header>
|
||||
<h2>{{ 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" outlined>{{
|
||||
t("Close")
|
||||
}}</o-button>
|
||||
<o-button native-type="submit" variant="primary">{{
|
||||
t("Change role")
|
||||
}}</o-button>
|
||||
</footer>
|
||||
</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-modal
|
||||
>
|
||||
<form @submit.prevent="confirmUser">
|
||||
<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>
|
||||
</form>
|
||||
</o-modal>
|
||||
</div>
|
||||
<empty-content v-else-if="!loadingUser" icon="account">
|
||||
{{ t("This user was not found") }}
|
||||
<template #desc>
|
||||
<o-button
|
||||
variant="text"
|
||||
tag="router-link"
|
||||
:to="{ name: RouteName.USERS }"
|
||||
>{{ t("Back to user list") }}</o-button
|
||||
>
|
||||
</template>
|
||||
</empty-content>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { formatBytes } from "@/utils/datetime";
|
||||
import { ICurrentUserRole } from "@/types/enums";
|
||||
import { GET_USER, SUSPEND_USER } from "../../graphql/user";
|
||||
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";
|
||||
|
||||
const props = defineProps<{ id: string }>();
|
||||
|
||||
const { result: userResult, loading: loadingUser } = useQuery<{ user: IUser }>(
|
||||
GET_USER,
|
||||
() => ({
|
||||
id: props.id,
|
||||
})
|
||||
);
|
||||
|
||||
const user = computed(() => userResult.value?.user);
|
||||
|
||||
const languageCode = computed(() => user.value?.locale);
|
||||
|
||||
const { result: languagesResult } = useQuery<{ languages: ILanguage[] }>(
|
||||
LANGUAGES_CODES,
|
||||
() => ({
|
||||
codes: languageCode.value,
|
||||
}),
|
||||
() => ({
|
||||
enabled: languageCode.value !== undefined,
|
||||
})
|
||||
);
|
||||
|
||||
const languages = computed(() => languagesResult.value?.languages);
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
useHead({
|
||||
title: computed(() => user.value?.email ?? ""),
|
||||
});
|
||||
|
||||
const isEmailChangeModalActive = ref(false);
|
||||
const isRoleChangeModalActive = ref(false);
|
||||
const isConfirmationModalActive = ref(false);
|
||||
|
||||
const newUser = reactive({
|
||||
email: "",
|
||||
role: user.value?.role,
|
||||
confirm: false,
|
||||
notify: true,
|
||||
});
|
||||
|
||||
const metadata = computed(
|
||||
(): Array<{ key: string; value: string; type?: string }> => {
|
||||
if (!user.value) return [];
|
||||
return [
|
||||
{
|
||||
key: t("Email"),
|
||||
value: user.value.email,
|
||||
type: "email",
|
||||
},
|
||||
{
|
||||
key: t("Language"),
|
||||
value: languages.value ? languages.value[0].name : t("Unknown"),
|
||||
},
|
||||
{
|
||||
key: t("Role"),
|
||||
value: roleName(user.value.role),
|
||||
type: "role",
|
||||
},
|
||||
{
|
||||
key: t("Login status"),
|
||||
value: user.value.disabled ? t("Disabled") : t("Activated"),
|
||||
},
|
||||
{
|
||||
key: t("Confirmed"),
|
||||
value: user.value.confirmedAt
|
||||
? formatDateTimeString(user.value.confirmedAt)
|
||||
: t("Not confirmed"),
|
||||
type: "confirmed",
|
||||
},
|
||||
{
|
||||
key: t("Last sign-in"),
|
||||
value: user.value.currentSignInAt
|
||||
? formatDateTimeString(user.value.currentSignInAt)
|
||||
: t("Unknown"),
|
||||
},
|
||||
{
|
||||
key: t("Last IP adress"),
|
||||
value: user.value.currentSignInIp || t("Unknown"),
|
||||
type: user.value.currentSignInIp ? "ip" : undefined,
|
||||
},
|
||||
{
|
||||
key: t("Total number of participations"),
|
||||
value: user.value.participations.total.toString(),
|
||||
},
|
||||
{
|
||||
key: t("Uploaded media total size"),
|
||||
value: formatBytes(user.value.mediaSize),
|
||||
},
|
||||
];
|
||||
}
|
||||
);
|
||||
|
||||
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");
|
||||
}
|
||||
};
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const { mutate: suspendUser } = useMutation<
|
||||
{ suspendProfile: { id: string } },
|
||||
{ userId: string }
|
||||
>(SUSPEND_USER);
|
||||
|
||||
const dialog = inject<Dialog>("dialog");
|
||||
|
||||
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"),
|
||||
variant: "danger",
|
||||
onConfirm: async () => {
|
||||
suspendUser({
|
||||
userId: props.id,
|
||||
});
|
||||
return router.push({ name: RouteName.USERS });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const profiles = computed((): IPerson[] | undefined => {
|
||||
return user.value?.actors;
|
||||
});
|
||||
|
||||
const confirmUser = async () => {
|
||||
isConfirmationModalActive.value = false;
|
||||
await updateUser({
|
||||
id: props.id,
|
||||
confirmed: true,
|
||||
notify: newUser.notify,
|
||||
});
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
>(ADMIN_UPDATE_USER);
|
||||
|
||||
watch(user, (updatedUser: IUser | undefined, oldUser: IUser | undefined) => {
|
||||
if (updatedUser?.role !== oldUser?.role) {
|
||||
newUser.role = updatedUser?.role;
|
||||
}
|
||||
});
|
||||
|
||||
const userEmailDomain = computed((): string | undefined => {
|
||||
if (user.value?.email) {
|
||||
return user.value?.email.split("@")[1];
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
</script>
|
||||
111
src/views/Admin/DashboardView.vue
Normal file
111
src/views/Admin/DashboardView.vue
Normal file
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<div>
|
||||
<breadcrumbs-nav
|
||||
:links="[
|
||||
{ name: RouteName.ADMIN, text: t('Admin') },
|
||||
{ text: t('Dashboard') },
|
||||
]"
|
||||
/>
|
||||
<section>
|
||||
<h1>{{ t("Administration") }}</h1>
|
||||
<div
|
||||
class="grid grid-cols-1 lg:grid-rows-2 lg:grid-flow-col gap-x-4 items-stretch"
|
||||
>
|
||||
<NumberDashboardTile :number="dashboard?.numberOfEvents">
|
||||
<template #subtitle>
|
||||
<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>
|
||||
</template>
|
||||
</NumberDashboardTile>
|
||||
<LinkedNumberDashboardTile
|
||||
:number="dashboard?.numberOfGroups"
|
||||
:subtitle="t('Groups', dashboard?.numberOfGroups ?? 0)"
|
||||
:to="{ name: RouteName.ADMIN_GROUPS }"
|
||||
/>
|
||||
<LinkedNumberDashboardTile
|
||||
:number="dashboard?.numberOfUsers"
|
||||
:subtitle="t('Users', dashboard?.numberOfUsers ?? 0)"
|
||||
:to="{ name: RouteName.ADMIN_GROUPS }"
|
||||
/>
|
||||
<LinkedNumberDashboardTile
|
||||
:number="dashboard?.numberOfReports"
|
||||
:subtitle="t('Opened reports', dashboard?.numberOfReports ?? 0)"
|
||||
:to="{ name: RouteName.REPORTS }"
|
||||
/>
|
||||
<LinkedNumberDashboardTile
|
||||
:number="dashboard?.numberOfFollowers"
|
||||
:subtitle="
|
||||
t('Instances following you', dashboard?.numberOfFollowers ?? 0)
|
||||
"
|
||||
:to="{
|
||||
name: RouteName.INSTANCES,
|
||||
query: { followStatus: InstanceFilterFollowStatus.FOLLOWING },
|
||||
}"
|
||||
/>
|
||||
<LinkedNumberDashboardTile
|
||||
:number="dashboard?.numberOfFollowings"
|
||||
:subtitle="
|
||||
t('Instances you follow', dashboard?.numberOfFollowings ?? 0)
|
||||
"
|
||||
:to="{
|
||||
name: RouteName.INSTANCES,
|
||||
query: { followStatus: InstanceFilterFollowStatus.FOLLOWED },
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<div>
|
||||
<h2>{{ t("Last published event") }}</h2>
|
||||
<event-card
|
||||
v-if="dashboard?.lastPublicEventPublished"
|
||||
:event="dashboard?.lastPublicEventPublished"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h2>{{ t("Last group created") }}</h2>
|
||||
<group-card
|
||||
v-if="dashboard?.lastGroupCreated"
|
||||
:group="dashboard?.lastGroupCreated"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { DASHBOARD } from "@/graphql/admin";
|
||||
import { IDashboard } from "@/types/admin.model";
|
||||
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";
|
||||
import NumberDashboardTile from "@/components/Dashboard/NumberDashboardTile.vue";
|
||||
import LinkedNumberDashboardTile from "@/components/Dashboard/LinkedNumberDashboardTile.vue";
|
||||
import { InstanceFilterFollowStatus } from "@/types/enums";
|
||||
import GroupCard from "@/components/Group/GroupCard.vue";
|
||||
import EventCard from "@/components/Event/EventCard.vue";
|
||||
|
||||
const { result: dashboardResult } = useQuery<{ dashboard: IDashboard }>(
|
||||
DASHBOARD
|
||||
);
|
||||
|
||||
const dashboard = computed(() => dashboardResult.value?.dashboard);
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
useHead({
|
||||
title: computed(() => t("Administration")),
|
||||
});
|
||||
</script>
|
||||
200
src/views/Admin/GroupProfiles.vue
Normal file
200
src/views/Admin/GroupProfiles.vue
Normal file
@@ -0,0 +1,200 @@
|
||||
<template>
|
||||
<div>
|
||||
<breadcrumbs-nav
|
||||
:links="[
|
||||
{ name: RouteName.MODERATION, text: t('Moderation') },
|
||||
{
|
||||
name: RouteName.ADMIN_GROUPS,
|
||||
text: t('Groups'),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<div class="buttons" v-if="showCreateGroupsButton">
|
||||
<router-link
|
||||
class="button is-primary"
|
||||
:to="{ name: RouteName.CREATE_GROUP }"
|
||||
>{{ t("Create group") }}</router-link
|
||||
>
|
||||
</div>
|
||||
<div v-if="groups">
|
||||
<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="loading"
|
||||
paginated
|
||||
backend-pagination
|
||||
backend-filtering
|
||||
:debounce-search="500"
|
||||
v-model:current-page="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="groups.total"
|
||||
:per-page="PROFILES_PER_PAGE"
|
||||
@page-change="onPageChange"
|
||||
@filters-change="onFiltersChange"
|
||||
>
|
||||
<o-table-column
|
||||
field="preferredUsername"
|
||||
:label="t('Username')"
|
||||
searchable
|
||||
>
|
||||
<template #searchable="props">
|
||||
<o-input
|
||||
:aria-label="t('Filter')"
|
||||
v-model="props.filters.preferredUsername"
|
||||
:placeholder="t('Filter')"
|
||||
icon="magnify"
|
||||
/>
|
||||
</template>
|
||||
<template #default="props">
|
||||
<router-link
|
||||
class="profile"
|
||||
:to="{
|
||||
name: RouteName.ADMIN_GROUP_PROFILE,
|
||||
params: { id: props.row.id },
|
||||
}"
|
||||
>
|
||||
<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>
|
||||
<AccountGroup v-else :size="48" />
|
||||
<div class="">
|
||||
<div class="prose dark:prose-invert">
|
||||
<p v-if="props.row.name" class="font-bold mb-0">
|
||||
{{ props.row.name }}
|
||||
</p>
|
||||
<span class="text-sm"
|
||||
>@{{ props.row.preferredUsername }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</router-link>
|
||||
</template>
|
||||
</o-table-column>
|
||||
|
||||
<o-table-column field="domain" :label="t('Domain')" searchable>
|
||||
<template #searchable="props">
|
||||
<o-input
|
||||
:aria-label="t('Filter')"
|
||||
v-model="props.filters.domain"
|
||||
:placeholder="t('Filter')"
|
||||
icon="magnify"
|
||||
/>
|
||||
</template>
|
||||
<template #default="props">
|
||||
{{ props.row.domain }}
|
||||
</template>
|
||||
</o-table-column>
|
||||
<template #empty>
|
||||
<empty-content icon="account-group" :inline="true">
|
||||
{{ t("No group matches the filters") }}
|
||||
</empty-content>
|
||||
</template>
|
||||
</o-table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { LIST_GROUPS } from "@/graphql/group";
|
||||
import RouteName from "../../router/name";
|
||||
import EmptyContent from "../../components/Utils/EmptyContent.vue";
|
||||
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;
|
||||
|
||||
const { restrictions } = useRestrictions();
|
||||
|
||||
const preferredUsername = useRouteQuery("preferredUsername", "");
|
||||
const name = useRouteQuery("name", "");
|
||||
const domain = useRouteQuery("domain", "");
|
||||
|
||||
const local = useRouteQuery("local", domain.value === "", 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,
|
||||
},
|
||||
});
|
||||
};
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
a.profile {
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
259
src/views/Admin/InstanceView.vue
Normal file
259
src/views/Admin/InstanceView.vue
Normal file
@@ -0,0 +1,259 @@
|
||||
<template>
|
||||
<div v-if="instance">
|
||||
<breadcrumbs-nav
|
||||
:links="[
|
||||
{ name: RouteName.ADMIN, text: $t('Admin') },
|
||||
{ name: RouteName.INSTANCES, text: $t('Instances') },
|
||||
{ text: instance.domain },
|
||||
]"
|
||||
/>
|
||||
<h1 class="text-2xl">{{ instance.domain }}</h1>
|
||||
<div
|
||||
class="grid md:grid-cols-2 xl:grid-cols-4 gap-2 content-center text-center mt-2"
|
||||
>
|
||||
<div class="bg-zinc-50 dark:bg-mbz-purple-500 rounded-xl p-8">
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.PROFILES,
|
||||
query: { domain: instance.domain },
|
||||
}"
|
||||
>
|
||||
<span class="mb-4 text-xl font-semibold block">{{
|
||||
instance.personCount
|
||||
}}</span>
|
||||
<span class="text-sm block">{{ $t("Profiles") }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="bg-gray-50 dark:bg-mbz-purple-500 rounded-xl p-8">
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.ADMIN_GROUPS,
|
||||
query: { domain: instance.domain },
|
||||
}"
|
||||
>
|
||||
<span class="mb-4 text-xl font-semibold block">{{
|
||||
instance.groupCount
|
||||
}}</span>
|
||||
<span class="text-sm block">{{ $t("Groups") }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="bg-zinc-50 dark:bg-mbz-purple-500 rounded-xl p-8">
|
||||
<span class="mb-4 text-xl font-semibold block">{{
|
||||
instance.followingsCount
|
||||
}}</span>
|
||||
<span class="text-sm block">{{ $t("Followings") }}</span>
|
||||
</div>
|
||||
<div class="bg-zinc-50 dark:bg-mbz-purple-500 rounded-xl p-8">
|
||||
<span class="mb-4 text-xl font-semibold block">{{
|
||||
instance.followersCount
|
||||
}}</span>
|
||||
<span class="text-sm block">{{ $t("Followers") }}</span>
|
||||
</div>
|
||||
<div class="bg-zinc-50 dark:bg-mbz-purple-500 rounded-xl p-8">
|
||||
<router-link
|
||||
:to="{ name: RouteName.REPORTS, query: { domain: instance.domain } }"
|
||||
>
|
||||
<span class="mb-4 text-xl font-semibold block">{{
|
||||
instance.reportsCount
|
||||
}}</span>
|
||||
<span class="text-sm block">{{ $t("Reports") }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="bg-zinc-50 dark:bg-mbz-purple-500 rounded-xl p-8">
|
||||
<span class="mb-4 font-semibold block">{{
|
||||
formatBytes(instance.mediaSize)
|
||||
}}</span>
|
||||
<span class="text-sm block">{{ $t("Uploaded media size") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 grid xl:grid-cols-2 gap-4">
|
||||
<div
|
||||
class="border bg-white dark:bg-mbz-purple-500 dark:border-mbz-purple-700 p-6 shadow-md rounded-md"
|
||||
v-if="instance.hasRelay"
|
||||
>
|
||||
<button
|
||||
@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({
|
||||
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"
|
||||
>
|
||||
{{ $t("Cancel follow request") }}
|
||||
</button>
|
||||
<button
|
||||
@click="followInstance"
|
||||
v-else
|
||||
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("Follow instance") }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="md:h-48 py-16 text-center opacity-50">
|
||||
{{ $t("Only Mobilizon instances can be followed") }}
|
||||
</div>
|
||||
<div
|
||||
class="border bg-white dark:bg-mbz-purple-500 dark:border-mbz-purple-700 p-6 shadow-md rounded-md flex flex-col gap-2"
|
||||
>
|
||||
<button
|
||||
@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({
|
||||
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"
|
||||
>
|
||||
{{ $t("Reject follow") }}
|
||||
</button>
|
||||
<p v-if="instance.followerStatus == InstanceFollowStatus.NONE">
|
||||
{{ $t("This instance doesn't follow yours.") }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
ACCEPT_RELAY,
|
||||
ADD_INSTANCE,
|
||||
INSTANCE,
|
||||
REJECT_RELAY,
|
||||
REMOVE_RELAY,
|
||||
} from "@/graphql/admin";
|
||||
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";
|
||||
|
||||
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.value as unknown as Reference),
|
||||
fragment: gql`
|
||||
fragment InstanceFollowerStatus on Instance {
|
||||
followerStatus
|
||||
}
|
||||
`,
|
||||
data: {
|
||||
followerStatus: InstanceFollowStatus.APPROVED,
|
||||
},
|
||||
});
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
onAcceptInstanceError((error) => {
|
||||
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
|
||||
notifier?.error(error.graphQLErrors[0].message);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 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,
|
||||
},
|
||||
});
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
onRejectInstanceError((error) => {
|
||||
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
|
||||
notifier?.error(error.graphQLErrors[0].message);
|
||||
}
|
||||
});
|
||||
|
||||
const { mutate: followInstanceMutation, onError: onFollowInstanceError } =
|
||||
useMutation<{ addInstance: IInstance }>(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(REMOVE_RELAY, () => ({
|
||||
update(cache: ApolloCache<any>) {
|
||||
cache.writeFragment({
|
||||
id: cache.identify(instance.value as unknown as Reference),
|
||||
fragment: gql`
|
||||
fragment InstanceFollowedStatus on Instance {
|
||||
followedStatus
|
||||
}
|
||||
`,
|
||||
data: {
|
||||
followedStatus: InstanceFollowStatus.NONE,
|
||||
},
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
onRemoveInstanceFollowError((error) => {
|
||||
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
|
||||
notifier?.error(error.graphQLErrors[0].message);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
283
src/views/Admin/InstancesView.vue
Normal file
283
src/views/Admin/InstancesView.vue
Normal file
@@ -0,0 +1,283 @@
|
||||
<template>
|
||||
<div>
|
||||
<breadcrumbs-nav
|
||||
:links="[
|
||||
{ name: RouteName.ADMIN, text: t('Admin') },
|
||||
{ text: t('Instances') },
|
||||
]"
|
||||
/>
|
||||
<section>
|
||||
<h1 class="title">{{ t("Instances") }}</h1>
|
||||
<form @submit="followInstance" class="my-4">
|
||||
<o-field
|
||||
:label="t('Follow a new instance')"
|
||||
horizontal
|
||||
label-for="newRelayAddress"
|
||||
>
|
||||
<o-field grouped group-multiline expanded size="large">
|
||||
<p class="control">
|
||||
<o-input
|
||||
id="newRelayAddress"
|
||||
v-model="newRelayAddress"
|
||||
:placeholder="t('Ex: mobilizon.fr')"
|
||||
/>
|
||||
</p>
|
||||
<p class="control">
|
||||
<o-button variant="primary" native-type="submit">{{
|
||||
t("Add an instance")
|
||||
}}</o-button>
|
||||
<o-loading
|
||||
:is-full-page="true"
|
||||
v-model="followInstanceLoading"
|
||||
:can-cancel="false"
|
||||
/>
|
||||
</p>
|
||||
</o-field>
|
||||
</o-field>
|
||||
</form>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<o-field :label="t('Follow status')">
|
||||
<o-radio
|
||||
v-model="followStatus"
|
||||
:native-value="InstanceFilterFollowStatus.ALL"
|
||||
>{{ t("All") }}</o-radio
|
||||
>
|
||||
<o-radio
|
||||
v-model="followStatus"
|
||||
:native-value="InstanceFilterFollowStatus.FOLLOWING"
|
||||
>{{ t("Following") }}</o-radio
|
||||
>
|
||||
<o-radio
|
||||
v-model="followStatus"
|
||||
:native-value="InstanceFilterFollowStatus.FOLLOWED"
|
||||
>{{ t("Followed") }}</o-radio
|
||||
>
|
||||
</o-field>
|
||||
<o-field
|
||||
:label="t('Domain')"
|
||||
label-for="domain-filter"
|
||||
class="flex-auto"
|
||||
>
|
||||
<o-input
|
||||
id="domain-filter"
|
||||
:placeholder="t('mobilizon-instance.tld')"
|
||||
:value="filterDomain"
|
||||
@input="debouncedUpdateDomainFilter"
|
||||
/>
|
||||
</o-field>
|
||||
</div>
|
||||
<div v-if="instances && instances.elements.length > 0" class="my-3">
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.INSTANCE,
|
||||
params: { domain: instance.domain },
|
||||
}"
|
||||
class="flex items-center mb-2 rounded bg-mbz-yellow-alt-300 dark:bg-mbz-purple-400 p-4 flex-wrap justify-center gap-x-2 gap-y-3"
|
||||
v-for="instance in instances.elements"
|
||||
:key="instance.domain"
|
||||
>
|
||||
<div class="grow overflow-hidden flex items-center gap-1">
|
||||
<img
|
||||
class="w-12"
|
||||
v-if="instance.hasRelay"
|
||||
src="../../../public/img/logo.svg"
|
||||
alt=""
|
||||
/>
|
||||
<CloudQuestion v-else :size="36" />
|
||||
|
||||
<div class="">
|
||||
<h3 class="text-lg truncate">{{ instance.domain }}</h3>
|
||||
<span
|
||||
class="text-sm"
|
||||
v-if="instance.followedStatus === InstanceFollowStatus.APPROVED"
|
||||
>
|
||||
<o-icon icon="inbox-arrow-down" />
|
||||
{{ t("Followed") }}</span
|
||||
>
|
||||
<span
|
||||
class="text-sm"
|
||||
v-else-if="
|
||||
instance.followedStatus === InstanceFollowStatus.PENDING
|
||||
"
|
||||
>
|
||||
<o-icon icon="inbox-arrow-down" />
|
||||
{{ t("Followed, pending response") }}</span
|
||||
>
|
||||
<span
|
||||
class="text-sm"
|
||||
v-if="instance.followerStatus == InstanceFollowStatus.APPROVED"
|
||||
>
|
||||
<o-icon icon="inbox-arrow-up" />
|
||||
{{ t("Follows us") }}</span
|
||||
>
|
||||
<span
|
||||
class="text-sm"
|
||||
v-if="instance.followerStatus == InstanceFollowStatus.PENDING"
|
||||
>
|
||||
<o-icon icon="inbox-arrow-up" />
|
||||
{{ t("Follows us, pending approval") }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-none flex gap-3 ltr:ml-3 rtl:mr-3">
|
||||
<p class="flex flex-col text-center">
|
||||
<span class="text-xl">{{ instance.eventCount }}</span
|
||||
><span class="text-sm">{{ t("Events") }}</span>
|
||||
</p>
|
||||
<p class="flex flex-col text-center">
|
||||
<span class="text-xl">{{ instance.personCount }}</span
|
||||
><span class="text-sm">{{ t("Profiles") }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</router-link>
|
||||
<o-pagination
|
||||
v-show="instances.total > INSTANCES_PAGE_LIMIT"
|
||||
:total="instances.total"
|
||||
v-model:current="instancePage"
|
||||
:per-page="INSTANCES_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 v-else-if="instances && instances.elements.length == 0">
|
||||
<empty-content icon="lan-disconnect" :inline="true">
|
||||
{{ t("No instance found.") }}
|
||||
<template #desc>
|
||||
<span v-if="hasFilter">
|
||||
{{
|
||||
t(
|
||||
"No instances match this filter. Try resetting filter fields?"
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ t("You haven't interacted with other instances yet.") }}
|
||||
</span>
|
||||
</template>
|
||||
</empty-content>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ADD_INSTANCE, INSTANCES } from "@/graphql/admin";
|
||||
import { Paginate } from "@/types/paginate";
|
||||
import RouteName from "../../router/name";
|
||||
import { IInstance } from "@/types/instance.model";
|
||||
import EmptyContent from "@/components/Utils/EmptyContent.vue";
|
||||
import debounce from "lodash/debounce";
|
||||
import {
|
||||
InstanceFilterFollowStatus,
|
||||
InstanceFollowStatus,
|
||||
} from "@/types/enums";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import {
|
||||
enumTransformer,
|
||||
integerTransformer,
|
||||
useRouteQuery,
|
||||
} from "vue-use-route-query";
|
||||
import { useMutation, useQuery } from "@vue/apollo-composable";
|
||||
import { computed, inject, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useHead } from "@vueuse/head";
|
||||
import CloudQuestion from "../../../node_modules/vue-material-design-icons/CloudQuestion.vue";
|
||||
import { Notifier } from "@/plugins/notifier";
|
||||
|
||||
const INSTANCES_PAGE_LIMIT = 10;
|
||||
|
||||
const instancePage = useRouteQuery("page", 1, integerTransformer);
|
||||
const filterDomain = useRouteQuery("filterDomain", "");
|
||||
const followStatus = useRouteQuery(
|
||||
"followStatus",
|
||||
InstanceFilterFollowStatus.ALL,
|
||||
enumTransformer(InstanceFilterFollowStatus)
|
||||
);
|
||||
|
||||
const { result: instancesResult } = useQuery<{
|
||||
instances: Paginate<IInstance>;
|
||||
}>(INSTANCES, () => ({
|
||||
page: instancePage.value,
|
||||
limit: INSTANCES_PAGE_LIMIT,
|
||||
filterDomain: filterDomain.value,
|
||||
filterFollowStatus: followStatus.value,
|
||||
}));
|
||||
|
||||
const instances = computed(() => instancesResult.value?.instances);
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
useHead({
|
||||
title: computed(() => t("Federation")),
|
||||
});
|
||||
|
||||
const followInstanceLoading = ref(false);
|
||||
|
||||
const newRelayAddress = ref("");
|
||||
|
||||
// relayFollowings: Paginate<IFollower> = { elements: [], total: 0 };
|
||||
|
||||
// relayFollowers: Paginate<IFollower> = { elements: [], total: 0 };
|
||||
|
||||
const updateDomainFilter = (event: InputEvent) => {
|
||||
const newValue = (event.target as HTMLInputElement).value;
|
||||
filterDomain.value = newValue;
|
||||
};
|
||||
|
||||
const debouncedUpdateDomainFilter = debounce(updateDomainFilter, 500);
|
||||
|
||||
const hasFilter = computed((): boolean => {
|
||||
return (
|
||||
followStatus.value !== InstanceFilterFollowStatus.ALL ||
|
||||
filterDomain.value !== ""
|
||||
);
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const { mutate, onDone, onError } = useMutation<{
|
||||
addInstance: IInstance;
|
||||
}>(ADD_INSTANCE);
|
||||
|
||||
onDone(({ data }) => {
|
||||
newRelayAddress.value = "";
|
||||
followInstanceLoading.value = false;
|
||||
router.push({
|
||||
name: RouteName.INSTANCE,
|
||||
params: { domain: data?.addInstance.domain },
|
||||
});
|
||||
});
|
||||
|
||||
const notifier = inject<Notifier>("notifier");
|
||||
|
||||
onError((error) => {
|
||||
if (error.message) {
|
||||
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
|
||||
notifier?.error(error.graphQLErrors[0].message);
|
||||
}
|
||||
}
|
||||
followInstanceLoading.value = false;
|
||||
});
|
||||
|
||||
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 {
|
||||
form {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none !important;
|
||||
}
|
||||
</style>
|
||||
164
src/views/Admin/ProfilesView.vue
Normal file
164
src/views/Admin/ProfilesView.vue
Normal file
@@ -0,0 +1,164 @@
|
||||
<template>
|
||||
<div>
|
||||
<breadcrumbs-nav
|
||||
:links="[
|
||||
{ name: RouteName.MODERATION, text: t('Moderation') },
|
||||
{
|
||||
name: RouteName.PROFILES,
|
||||
text: t('Profiles'),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<div v-if="persons">
|
||||
<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="persons.elements"
|
||||
:loading="loading"
|
||||
paginated
|
||||
backend-pagination
|
||||
backend-filtering
|
||||
:debounce-search="500"
|
||||
v-model:current-page="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="persons.total"
|
||||
:per-page="PROFILES_PER_PAGE"
|
||||
@filters-change="onFiltersChange"
|
||||
>
|
||||
<o-table-column
|
||||
field="preferredUsername"
|
||||
:label="t('Username')"
|
||||
searchable
|
||||
>
|
||||
<template #searchable="props">
|
||||
<o-input
|
||||
v-model="props.filters.preferredUsername"
|
||||
:aria-label="t('Filter')"
|
||||
:placeholder="t('Filter')"
|
||||
icon="magnify"
|
||||
/>
|
||||
</template>
|
||||
<template #default="props">
|
||||
<router-link
|
||||
class="profile"
|
||||
:to="{
|
||||
name: RouteName.ADMIN_PROFILE,
|
||||
params: { id: props.row.id },
|
||||
}"
|
||||
>
|
||||
<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"
|
||||
class="rounded-full"
|
||||
/>
|
||||
</figure>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</router-link>
|
||||
</template>
|
||||
</o-table-column>
|
||||
|
||||
<o-table-column field="domain" :label="t('Domain')" searchable>
|
||||
<template #searchable="props">
|
||||
<o-input
|
||||
v-model="props.filters.domain"
|
||||
:aria-label="t('Filter')"
|
||||
:placeholder="t('Filter')"
|
||||
icon="magnify"
|
||||
/>
|
||||
</template>
|
||||
<template #default="props">
|
||||
{{ props.row.domain }}
|
||||
</template>
|
||||
</o-table-column>
|
||||
<template #empty>
|
||||
<empty-content icon="account" :inline="true">
|
||||
{{ t("No profile matches the filters") }}
|
||||
</empty-content>
|
||||
</template>
|
||||
</o-table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<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;
|
||||
|
||||
const preferredUsername = useRouteQuery("preferredUsername", "");
|
||||
const name = useRouteQuery("name", "");
|
||||
const domain = useRouteQuery("domain", "");
|
||||
|
||||
const local = useRouteQuery("local", domain.value === "", booleanTransformer);
|
||||
const suspended = useRouteQuery("suspended", false, booleanTransformer);
|
||||
const page = useRouteQuery("page", 1, integerTransformer);
|
||||
|
||||
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,
|
||||
}));
|
||||
|
||||
const persons = computed(() => personResult.value?.persons);
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
useHead({
|
||||
title: computed(() => t("Profiles")),
|
||||
});
|
||||
|
||||
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 {
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
511
src/views/Admin/SettingsView.vue
Normal file
511
src/views/Admin/SettingsView.vue
Normal file
@@ -0,0 +1,511 @@
|
||||
<template>
|
||||
<div>
|
||||
<breadcrumbs-nav
|
||||
:links="[
|
||||
{ name: RouteName.ADMIN, text: t('Admin') },
|
||||
{ text: t('Instance settings') },
|
||||
]"
|
||||
/>
|
||||
|
||||
<section v-if="settingsToWrite">
|
||||
<form @submit.prevent="updateSettings">
|
||||
<o-field :label="t('Instance Name')" label-for="instance-name">
|
||||
<o-input v-model="settingsToWrite.instanceName" id="instance-name" />
|
||||
</o-field>
|
||||
<div class="field flex flex-col">
|
||||
<label class="" for="instance-description">{{
|
||||
t("Instance Short Description")
|
||||
}}</label>
|
||||
<small>
|
||||
{{
|
||||
t(
|
||||
"Displayed on homepage and meta tags. Describe what Mobilizon is and what makes this instance special in a single paragraph."
|
||||
)
|
||||
}}
|
||||
</small>
|
||||
<o-input
|
||||
type="textarea"
|
||||
v-model="settingsToWrite.instanceDescription"
|
||||
rows="2"
|
||||
id="instance-description"
|
||||
/>
|
||||
</div>
|
||||
<div class="field flex flex-col">
|
||||
<label class="" for="instance-slogan">{{
|
||||
t("Instance Slogan")
|
||||
}}</label>
|
||||
<small>
|
||||
{{
|
||||
t(
|
||||
'A short tagline for your instance homepage. Defaults to "Gather ⋅ Organize ⋅ Mobilize"'
|
||||
)
|
||||
}}
|
||||
</small>
|
||||
<o-input
|
||||
v-model="settingsToWrite.instanceSlogan"
|
||||
:placeholder="t('Gather ⋅ Organize ⋅ Mobilize')"
|
||||
id="instance-slogan"
|
||||
/>
|
||||
</div>
|
||||
<div class="field flex flex-col">
|
||||
<label class="" for="instance-contact">{{ t("Contact") }}</label>
|
||||
<small>
|
||||
{{ t("Can be an email or a link, or just plain text.") }}
|
||||
</small>
|
||||
<o-input v-model="settingsToWrite.contact" id="instance-contact" />
|
||||
</div>
|
||||
<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="prose dark:prose-invert" v-else>
|
||||
{{ t("Registration is closed.") }}
|
||||
</p>
|
||||
</o-switch>
|
||||
</o-field>
|
||||
<div class="field flex flex-col">
|
||||
<label class="" for="instance-languages">{{
|
||||
t("Instance languages")
|
||||
}}</label>
|
||||
<small>
|
||||
{{ t("Main languages you/your moderators speak") }}
|
||||
</small>
|
||||
<o-inputitems
|
||||
v-model="instanceLanguages"
|
||||
:data="filteredLanguages"
|
||||
allow-autocomplete
|
||||
:open-on-focus="true"
|
||||
field="name"
|
||||
icon="label"
|
||||
:placeholder="t('Select languages')"
|
||||
@typing="getFilteredLanguages"
|
||||
id="instance-languages"
|
||||
>
|
||||
<template #empty>{{ t("No languages found") }}</template>
|
||||
</o-inputitems>
|
||||
</div>
|
||||
<div class="field flex flex-col">
|
||||
<label class="" for="instance-long-description">{{
|
||||
t("Instance Long Description")
|
||||
}}</label>
|
||||
<small>
|
||||
{{
|
||||
t(
|
||||
"A place to explain who you are and the things that set your instance apart. You can use HTML tags."
|
||||
)
|
||||
}}
|
||||
</small>
|
||||
<o-input
|
||||
type="textarea"
|
||||
v-model="settingsToWrite.instanceLongDescription"
|
||||
rows="4"
|
||||
id="instance-long-description"
|
||||
/>
|
||||
</div>
|
||||
<div class="field flex flex-col">
|
||||
<label class="" for="instance-rules">{{ t("Instance Rules") }}</label>
|
||||
<small>
|
||||
{{
|
||||
t(
|
||||
"A place for your code of conduct, rules or guidelines. You can use HTML tags."
|
||||
)
|
||||
}}
|
||||
</small>
|
||||
<o-input
|
||||
type="textarea"
|
||||
v-model="settingsToWrite.instanceRules"
|
||||
id="instance-rules"
|
||||
/>
|
||||
</div>
|
||||
<o-field :label="t('Instance Terms Source')">
|
||||
<div class="">
|
||||
<div class="">
|
||||
<fieldset>
|
||||
<legend>
|
||||
{{ t("Choose the source of the instance's Terms") }}
|
||||
</legend>
|
||||
<o-field>
|
||||
<o-radio
|
||||
v-model="settingsToWrite.instanceTermsType"
|
||||
name="instanceTermsType"
|
||||
:native-value="InstanceTermsType.DEFAULT"
|
||||
>{{ t("Default Mobilizon terms") }}</o-radio
|
||||
>
|
||||
</o-field>
|
||||
<o-field>
|
||||
<o-radio
|
||||
v-model="settingsToWrite.instanceTermsType"
|
||||
name="instanceTermsType"
|
||||
:native-value="InstanceTermsType.URL"
|
||||
>{{ t("Custom URL") }}</o-radio
|
||||
>
|
||||
</o-field>
|
||||
<o-field>
|
||||
<o-radio
|
||||
v-model="settingsToWrite.instanceTermsType"
|
||||
name="instanceTermsType"
|
||||
:native-value="InstanceTermsType.CUSTOM"
|
||||
>{{ t("Custom text") }}</o-radio
|
||||
>
|
||||
</o-field>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div class="">
|
||||
<o-notification
|
||||
class="bg-slate-700"
|
||||
v-if="
|
||||
settingsToWrite.instanceTermsType ===
|
||||
InstanceTermsType.DEFAULT
|
||||
"
|
||||
>
|
||||
<b>{{ t("Default") }}</b>
|
||||
<i18n-t
|
||||
tag="p"
|
||||
class="prose dark:prose-invert"
|
||||
keypath="The {default_terms} will be used. They will be translated in the user's language."
|
||||
>
|
||||
<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."
|
||||
)
|
||||
}}</b>
|
||||
</o-notification>
|
||||
<div
|
||||
class="notification"
|
||||
v-if="
|
||||
settingsToWrite.instanceTermsType === InstanceTermsType.URL
|
||||
"
|
||||
>
|
||||
<b>{{ t("URL") }}</b>
|
||||
<p class="prose dark:prose-invert">
|
||||
{{ t("Set an URL to a page with your own terms.") }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="notification"
|
||||
v-if="
|
||||
settingsToWrite.instanceTermsType === InstanceTermsType.CUSTOM
|
||||
"
|
||||
>
|
||||
<b>{{ t("Custom") }}</b>
|
||||
<i18n-t
|
||||
tag="p"
|
||||
class="prose dark:prose-invert"
|
||||
keypath="Enter your own terms. HTML tags allowed. The {mobilizon_terms} are provided as template."
|
||||
>
|
||||
<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>
|
||||
</o-field>
|
||||
<o-field
|
||||
:label="t('Instance Terms URL')"
|
||||
label-for="instanceTermsUrl"
|
||||
v-if="settingsToWrite.instanceTermsType === InstanceTermsType.URL"
|
||||
>
|
||||
<o-input
|
||||
type="URL"
|
||||
v-model="settingsToWrite.instanceTermsUrl"
|
||||
id="instanceTermsUrl"
|
||||
/>
|
||||
</o-field>
|
||||
<o-field
|
||||
:label="t('Instance Terms')"
|
||||
label-for="instanceTerms"
|
||||
v-if="settingsToWrite.instanceTermsType === InstanceTermsType.CUSTOM"
|
||||
>
|
||||
<o-input
|
||||
type="textarea"
|
||||
v-model="settingsToWrite.instanceTerms"
|
||||
id="instanceTerms"
|
||||
/>
|
||||
</o-field>
|
||||
<o-field :label="t('Instance Privacy Policy Source')">
|
||||
<div class="">
|
||||
<div class="">
|
||||
<fieldset>
|
||||
<legend>
|
||||
{{ t("Choose the source of the instance's Privacy Policy") }}
|
||||
</legend>
|
||||
<o-field>
|
||||
<o-radio
|
||||
v-model="settingsToWrite.instancePrivacyPolicyType"
|
||||
name="instancePrivacyType"
|
||||
:native-value="InstancePrivacyType.DEFAULT"
|
||||
>{{ t("Default Mobilizon privacy policy") }}</o-radio
|
||||
>
|
||||
</o-field>
|
||||
<o-field>
|
||||
<o-radio
|
||||
v-model="settingsToWrite.instancePrivacyPolicyType"
|
||||
name="instancePrivacyType"
|
||||
:native-value="InstancePrivacyType.URL"
|
||||
>{{ t("Custom URL") }}</o-radio
|
||||
>
|
||||
</o-field>
|
||||
<o-field>
|
||||
<o-radio
|
||||
v-model="settingsToWrite.instancePrivacyPolicyType"
|
||||
name="instancePrivacyType"
|
||||
:native-value="InstancePrivacyType.CUSTOM"
|
||||
>{{ t("Custom text") }}</o-radio
|
||||
>
|
||||
</o-field>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div class="">
|
||||
<div
|
||||
class="notification"
|
||||
v-if="
|
||||
settingsToWrite.instancePrivacyPolicyType ===
|
||||
InstancePrivacyType.DEFAULT
|
||||
"
|
||||
>
|
||||
<b>{{ t("Default") }}</b>
|
||||
<i18n-t
|
||||
tag="p"
|
||||
class="prose dark:prose-invert"
|
||||
keypath="The {default_privacy_policy} will be used. They will be translated in the user's language."
|
||||
>
|
||||
<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"
|
||||
v-if="
|
||||
settingsToWrite.instancePrivacyPolicyType ===
|
||||
InstancePrivacyType.URL
|
||||
"
|
||||
>
|
||||
<b>{{ t("URL") }}</b>
|
||||
<p class="prose dark:prose-invert">
|
||||
{{ t("Set an URL to a page with your own privacy policy.") }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="notification"
|
||||
v-if="
|
||||
settingsToWrite.instancePrivacyPolicyType ===
|
||||
InstancePrivacyType.CUSTOM
|
||||
"
|
||||
>
|
||||
<b>{{ t("Custom") }}</b>
|
||||
<i18n-t
|
||||
tag="p"
|
||||
class="prose dark:prose-invert"
|
||||
keypath="Enter your own privacy policy. HTML tags allowed. The {mobilizon_privacy_policy} is provided as template."
|
||||
>
|
||||
<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>
|
||||
</o-field>
|
||||
<o-field
|
||||
:label="t('Instance Privacy Policy URL')"
|
||||
label-for="instancePrivacyPolicyUrl"
|
||||
v-if="
|
||||
settingsToWrite.instancePrivacyPolicyType ===
|
||||
InstancePrivacyType.URL
|
||||
"
|
||||
>
|
||||
<o-input
|
||||
type="URL"
|
||||
v-model="settingsToWrite.instancePrivacyPolicyUrl"
|
||||
id="instancePrivacyPolicyUrl"
|
||||
/>
|
||||
</o-field>
|
||||
<o-field
|
||||
:label="t('Instance Privacy Policy')"
|
||||
label-for="instancePrivacyPolicy"
|
||||
v-if="
|
||||
settingsToWrite.instancePrivacyPolicyType ===
|
||||
InstancePrivacyType.CUSTOM
|
||||
"
|
||||
>
|
||||
<o-input
|
||||
type="textarea"
|
||||
v-model="settingsToWrite.instancePrivacyPolicy"
|
||||
id="instancePrivacyPolicy"
|
||||
/>
|
||||
</o-field>
|
||||
<o-button native-type="submit" variant="primary">{{
|
||||
t("Save")
|
||||
}}</o-button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<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 { useMutation, useQuery } from "@vue/apollo-composable";
|
||||
import { ref, computed, watch, inject } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useHead } from "@vueuse/head";
|
||||
import type { Notifier } from "@/plugins/notifier";
|
||||
|
||||
const defaultAdminSettings: IAdminSettings = {
|
||||
instanceName: "",
|
||||
instanceDescription: "",
|
||||
instanceSlogan: "",
|
||||
instanceLongDescription: "",
|
||||
contact: "",
|
||||
instanceTerms: "",
|
||||
instanceTermsType: InstanceTermsType.DEFAULT,
|
||||
instanceTermsUrl: null,
|
||||
instancePrivacyPolicy: "",
|
||||
instancePrivacyPolicyType: InstancePrivacyType.DEFAULT,
|
||||
instancePrivacyPolicyUrl: null,
|
||||
instanceRules: "",
|
||||
registrationsOpen: false,
|
||||
instanceLanguages: [],
|
||||
};
|
||||
|
||||
const { result: adminSettingsResult } = useQuery<{
|
||||
adminSettings: IAdminSettings;
|
||||
}>(ADMIN_SETTINGS);
|
||||
const adminSettings = computed(
|
||||
() => adminSettingsResult.value?.adminSettings ?? defaultAdminSettings
|
||||
);
|
||||
|
||||
const { result: languageResult } = useQuery<{ languages: ILanguage[] }>(
|
||||
LANGUAGES
|
||||
);
|
||||
const languages = computed(() => languageResult.value?.languages);
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
useHead({
|
||||
title: computed(() => t("Settings")),
|
||||
});
|
||||
|
||||
const settingsToWrite = ref<IAdminSettings>(defaultAdminSettings);
|
||||
|
||||
watch(adminSettings, () => {
|
||||
settingsToWrite.value = { ...adminSettings.value };
|
||||
});
|
||||
|
||||
const filteredLanguages = ref<string[]>([]);
|
||||
|
||||
const instanceLanguages = computed({
|
||||
get() {
|
||||
const languageCodes = [...(adminSettings.value?.instanceLanguages ?? [])];
|
||||
return languageCodes
|
||||
.map((code) => languageForCode(code))
|
||||
.filter((language) => language) as string[];
|
||||
},
|
||||
set(newInstanceLanguages: string[]) {
|
||||
const newFilteredInstanceLanguages = newInstanceLanguages
|
||||
.map((language) => {
|
||||
return codeForLanguage(language);
|
||||
})
|
||||
.filter((code) => code !== undefined) as string[];
|
||||
settingsToWrite.value = {
|
||||
...settingsToWrite.value,
|
||||
instanceLanguages: newFilteredInstanceLanguages,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const notifier = inject<Notifier>("notifier");
|
||||
|
||||
const {
|
||||
mutate: saveAdminSettings,
|
||||
onDone: saveAdminSettingsDone,
|
||||
onError: saveAdminSettingsError,
|
||||
} = useMutation(SAVE_ADMIN_SETTINGS);
|
||||
|
||||
saveAdminSettingsDone(() => {
|
||||
notifier?.success(t("Admin settings successfully saved.") as string);
|
||||
});
|
||||
|
||||
saveAdminSettingsError((e) => {
|
||||
console.error(e);
|
||||
notifier?.error(t("Failed to save admin settings") as string);
|
||||
});
|
||||
|
||||
const updateSettings = async (): Promise<void> => {
|
||||
const variables = { ...settingsToWrite.value };
|
||||
console.debug("updating settings with variables", variables);
|
||||
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 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
201
src/views/Admin/UsersView.vue
Normal file
201
src/views/Admin/UsersView.vue
Normal file
@@ -0,0 +1,201 @@
|
||||
<template>
|
||||
<div>
|
||||
<breadcrumbs-nav
|
||||
:links="[
|
||||
{ name: RouteName.MODERATION, text: $t('Moderation') },
|
||||
{
|
||||
name: RouteName.USERS,
|
||||
text: $t('Users'),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<div v-if="users">
|
||||
<form @submit.prevent="activateFilters">
|
||||
<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">
|
||||
<o-button variant="primary" native-type="submit">{{
|
||||
$t("Filter")
|
||||
}}</o-button>
|
||||
</p>
|
||||
</o-field>
|
||||
</form>
|
||||
<o-table
|
||||
:data="users.elements"
|
||||
:loading="usersLoading"
|
||||
paginated
|
||||
backend-pagination
|
||||
:debounce-search="500"
|
||||
v-model:current-page="page"
|
||||
:aria-next-label="$t('Next page')"
|
||||
:aria-previous-label="$t('Previous page')"
|
||||
:aria-page-label="$t('Page')"
|
||||
:aria-current-label="$t('Current page')"
|
||||
:show-detail-icon="true"
|
||||
:total="users.total"
|
||||
:per-page="USERS_PER_PAGE"
|
||||
@page-change="onPageChange"
|
||||
>
|
||||
<o-table-column field="id" width="40" numeric v-slot="props">
|
||||
{{ props.row.id }}
|
||||
</o-table-column>
|
||||
<o-table-column field="email" :label="$t('Email')">
|
||||
<template #default="props">
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.ADMIN_USER_PROFILE,
|
||||
params: { id: props.row.id },
|
||||
}"
|
||||
:class="{ disabled: props.row.disabled }"
|
||||
>
|
||||
{{ props.row.email }}
|
||||
</router-link>
|
||||
</template>
|
||||
</o-table-column>
|
||||
<o-table-column
|
||||
field="confirmedAt"
|
||||
:label="$t('Last seen on')"
|
||||
:centered="true"
|
||||
v-slot="props"
|
||||
>
|
||||
<template v-if="props.row.currentSignInAt">
|
||||
<time :datetime="props.row.currentSignInAt">
|
||||
{{ formatDateTimeString(props.row.currentSignInAt) }}
|
||||
</time>
|
||||
</template>
|
||||
<template v-else-if="props.row.confirmedAt"> - </template>
|
||||
<template v-else>
|
||||
{{ $t("Not confirmed") }}
|
||||
</template>
|
||||
</o-table-column>
|
||||
<o-table-column
|
||||
field="locale"
|
||||
:label="$t('Language')"
|
||||
:centered="true"
|
||||
v-slot="props"
|
||||
>
|
||||
{{ getLanguageNameForCode(props.row.locale) }}
|
||||
</o-table-column>
|
||||
<template #empty>
|
||||
<empty-content
|
||||
v-if="!usersLoading && emailFilter"
|
||||
:inline="true"
|
||||
icon="account"
|
||||
>
|
||||
{{ $t("No user matches the filters") }}
|
||||
<template #desc>
|
||||
<o-button variant="primary" @click="resetFilters">
|
||||
{{ $t("Reset filters") }}
|
||||
</o-button>
|
||||
</template>
|
||||
</empty-content>
|
||||
</template>
|
||||
</o-table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { LIST_USERS } from "../../graphql/user";
|
||||
import RouteName from "../../router/name";
|
||||
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";
|
||||
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;
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const activateFilters = (): void => {
|
||||
emailFilter.value = emailFilterFieldValue.value;
|
||||
ipFilter.value = ipFilterFieldValue.value;
|
||||
};
|
||||
|
||||
const resetFilters = (): void => {
|
||||
emailFilterFieldValue.value = "";
|
||||
ipFilterFieldValue.value = "";
|
||||
activateFilters();
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
a.profile,
|
||||
a.user-profile {
|
||||
text-decoration: none;
|
||||
}
|
||||
a.disabled {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
</style>
|
||||
158
src/views/CategoriesView.vue
Normal file
158
src/views/CategoriesView.vue
Normal file
@@ -0,0 +1,158 @@
|
||||
<template>
|
||||
<div class="container mx-auto py-4 md:py-12 px-2 md:px-60">
|
||||
<main>
|
||||
<h1>{{ t("Category list") }}</h1>
|
||||
<div
|
||||
class="flex flex-wrap items-center justify-center gap-3 md:gap-4"
|
||||
v-if="promotedCategories.length > 0"
|
||||
>
|
||||
<CategoryCard
|
||||
v-for="category in promotedCategories"
|
||||
:key="category.key"
|
||||
:category="category"
|
||||
:with-details="true"
|
||||
/>
|
||||
</div>
|
||||
<div v-else>
|
||||
<EmptyContent icon="image" :inline="true">
|
||||
{{
|
||||
t(
|
||||
"No categories with public upcoming events on this instance were found."
|
||||
)
|
||||
}}
|
||||
</EmptyContent>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mx-auto w-full max-w-lg rounded-2xl dark:bg-gray-800 p-2 mt-10"
|
||||
>
|
||||
<o-collapse
|
||||
v-model:open="isLicencePanelOpen"
|
||||
:aria-id="'contentIdForA11y5'"
|
||||
>
|
||||
<template #trigger>
|
||||
<o-button
|
||||
aria-controls="contentIdForA11y1"
|
||||
:icon-right="isLicencePanelOpen ? 'chevron-up' : 'chevron-down'"
|
||||
>
|
||||
{{ t("Category illustrations credits") }}
|
||||
</o-button>
|
||||
</template>
|
||||
<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()}.webp 2x, /img/categories/${key.toLowerCase()}.webp`"
|
||||
media="(min-width: 1000px)"
|
||||
/>
|
||||
<source
|
||||
:srcset="`/img/categories/${key.toLowerCase()}.webp 2x, /img/categories/${key.toLowerCase()}-small.webp`"
|
||||
media="(min-width: 300px)"
|
||||
/>
|
||||
<img
|
||||
loading="lazy"
|
||||
class="w-full h-12 w-12 object-cover"
|
||||
:src="`/img/categories/${key.toLowerCase()}.webp`"
|
||||
:srcset="`/img/categories/${key.toLowerCase()}-small.webp `"
|
||||
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>
|
||||
</o-collapse>
|
||||
</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 { useI18n } from "vue-i18n";
|
||||
import { useEventCategories } from "@/composition/apollo/config";
|
||||
import EmptyContent from "@/components/Utils/EmptyContent.vue";
|
||||
import { useHead } from "@vueuse/head";
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
const { eventCategories } = useEventCategories();
|
||||
|
||||
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) as string,
|
||||
}))
|
||||
.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);
|
||||
|
||||
useHead({
|
||||
title: computed(() => t("Category list")),
|
||||
});
|
||||
</script>
|
||||
94
src/views/Conversations/ConversationListView.vue
Normal file
94
src/views/Conversations/ConversationListView.vue
Normal file
@@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<div class="container mx-auto" v-if="conversations">
|
||||
<breadcrumbs-nav
|
||||
:links="[
|
||||
{
|
||||
name: RouteName.CONVERSATION_LIST,
|
||||
text: t('Conversations'),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<o-notification v-if="error" variant="danger">
|
||||
{{ error }}
|
||||
</o-notification>
|
||||
<section>
|
||||
<h1>{{ t("Conversations") }}</h1>
|
||||
<o-button @click="openNewMessageModal">{{
|
||||
t("New private message")
|
||||
}}</o-button>
|
||||
<div v-if="conversations.elements.length > 0" class="my-2">
|
||||
<conversation-list-item
|
||||
:conversation="conversation"
|
||||
v-for="conversation in conversations.elements"
|
||||
:key="conversation.id"
|
||||
/>
|
||||
<o-pagination
|
||||
v-show="conversations.total > CONVERSATIONS_PER_PAGE"
|
||||
class="conversation-pagination"
|
||||
:total="conversations.total"
|
||||
v-model:current="page"
|
||||
:per-page="CONVERSATIONS_PER_PAGE"
|
||||
: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>
|
||||
<empty-content v-else icon="chat">
|
||||
{{ t("There's no conversations yet") }}
|
||||
</empty-content>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import RouteName from "../../router/name";
|
||||
import { useQuery } from "@vue/apollo-composable";
|
||||
import { computed, defineAsyncComponent, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { integerTransformer, useRouteQuery } from "vue-use-route-query";
|
||||
import { PROFILE_CONVERSATIONS } from "@/graphql/event";
|
||||
import ConversationListItem from "../../components/Conversations/ConversationListItem.vue";
|
||||
import EmptyContent from "../../components/Utils/EmptyContent.vue";
|
||||
import { useHead } from "@vueuse/head";
|
||||
import { IPerson } from "@/types/actor";
|
||||
import { useProgrammatic } from "@oruga-ui/oruga-next";
|
||||
|
||||
const page = useRouteQuery("page", 1, integerTransformer);
|
||||
const CONVERSATIONS_PER_PAGE = 10;
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
useHead({
|
||||
title: computed(() => t("List of conversations")),
|
||||
});
|
||||
|
||||
const error = ref(false);
|
||||
|
||||
const { result: conversationsResult } = useQuery<{
|
||||
loggedPerson: Pick<IPerson, "conversations">;
|
||||
}>(PROFILE_CONVERSATIONS, () => ({
|
||||
page: page.value,
|
||||
}));
|
||||
|
||||
const conversations = computed(
|
||||
() =>
|
||||
conversationsResult.value?.loggedPerson.conversations || {
|
||||
elements: [],
|
||||
total: 0,
|
||||
}
|
||||
);
|
||||
|
||||
const { oruga } = useProgrammatic();
|
||||
|
||||
const NewConversation = defineAsyncComponent(
|
||||
() => import("@/components/Conversations/NewConversation.vue")
|
||||
);
|
||||
|
||||
const openNewMessageModal = () => {
|
||||
oruga.modal.open({
|
||||
component: NewConversation,
|
||||
trapFocus: true,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
527
src/views/Conversations/ConversationView.vue
Normal file
527
src/views/Conversations/ConversationView.vue
Normal file
@@ -0,0 +1,527 @@
|
||||
<template>
|
||||
<div class="container mx-auto" v-if="conversation">
|
||||
<breadcrumbs-nav
|
||||
:links="[
|
||||
{
|
||||
name: RouteName.CONVERSATION_LIST,
|
||||
text: t('Conversations'),
|
||||
},
|
||||
{
|
||||
name: RouteName.CONVERSATION,
|
||||
params: { id: conversation.id },
|
||||
text: title,
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<div
|
||||
v-if="conversation.event"
|
||||
class="bg-mbz-yellow p-6 mb-6 rounded flex gap-2 items-center"
|
||||
>
|
||||
<Calendar :size="36" />
|
||||
<i18n-t
|
||||
tag="p"
|
||||
keypath="This is a announcement from the organizers of event {event}"
|
||||
>
|
||||
<template #event>
|
||||
<b>
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.EVENT,
|
||||
params: { uuid: conversation.event.uuid },
|
||||
}"
|
||||
>{{ conversation.event.title }}</router-link
|
||||
>
|
||||
</b>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</div>
|
||||
<div
|
||||
v-if="currentActor && currentActor.id !== conversation.actor?.id"
|
||||
class="bg-mbz-info p-6 rounded flex gap-2 items-center my-3"
|
||||
>
|
||||
<i18n-t
|
||||
keypath="You have access to this conversation as a member of the {group} group"
|
||||
tag="p"
|
||||
>
|
||||
<template #group>
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.GROUP,
|
||||
params: {
|
||||
preferredUsername: usernameWithDomain(conversation.actor),
|
||||
},
|
||||
}"
|
||||
><b>{{ displayName(conversation.actor) }}</b></router-link
|
||||
>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</div>
|
||||
<o-notification v-if="error" variant="danger">
|
||||
{{ error }}
|
||||
</o-notification>
|
||||
<section v-if="currentActor">
|
||||
<discussion-comment
|
||||
v-for="comment in conversation.comments.elements"
|
||||
:key="comment.id"
|
||||
:model-value="comment"
|
||||
:current-actor="currentActor"
|
||||
:can-report="true"
|
||||
@update:modelValue="
|
||||
(comment: IComment) =>
|
||||
updateComment({
|
||||
commentId: comment.id as string,
|
||||
text: comment.text,
|
||||
})
|
||||
"
|
||||
@delete-comment="
|
||||
(comment: IComment) =>
|
||||
deleteComment({
|
||||
commentId: comment.id as string,
|
||||
})
|
||||
"
|
||||
/>
|
||||
<o-button
|
||||
v-if="
|
||||
conversation.comments.elements.length < conversation.comments.total
|
||||
"
|
||||
@click="loadMoreComments"
|
||||
>{{ t("Fetch more") }}</o-button
|
||||
>
|
||||
<form @submit.prevent="reply" v-if="!error && !conversation.event">
|
||||
<o-field :label="t('Text')">
|
||||
<Editor
|
||||
v-model="newComment"
|
||||
:aria-label="t('Message body')"
|
||||
v-if="currentActor"
|
||||
:currentActor="currentActor"
|
||||
:placeholder="t('Write a new message')"
|
||||
/>
|
||||
</o-field>
|
||||
<o-button
|
||||
class="my-2"
|
||||
native-type="submit"
|
||||
:disabled="['<p></p>', ''].includes(newComment)"
|
||||
variant="primary"
|
||||
>{{ t("Reply") }}</o-button
|
||||
>
|
||||
</form>
|
||||
<div
|
||||
v-else-if="conversation.event"
|
||||
class="bg-mbz-yellow p-6 rounded flex gap-2 items-center mt-6"
|
||||
>
|
||||
<Calendar :size="36" />
|
||||
<i18n-t
|
||||
tag="p"
|
||||
keypath="This is a announcement from the organizers of event {event}. You can't reply to it, but you can send a private message to event organizers."
|
||||
>
|
||||
<template #event>
|
||||
<b>
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.EVENT,
|
||||
params: { uuid: conversation.event.uuid },
|
||||
}"
|
||||
>{{ conversation.event.title }}</router-link
|
||||
>
|
||||
</b>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
CONVERSATION_COMMENT_CHANGED,
|
||||
GET_CONVERSATION,
|
||||
MARK_CONVERSATION_AS_READ,
|
||||
REPLY_TO_PRIVATE_MESSAGE_MUTATION,
|
||||
} from "../../graphql/conversations";
|
||||
import DiscussionComment from "../../components/Discussion/DiscussionComment.vue";
|
||||
import { DELETE_COMMENT, UPDATE_COMMENT } from "../../graphql/comment";
|
||||
import RouteName from "../../router/name";
|
||||
import { IComment } from "../../types/comment.model";
|
||||
import {
|
||||
ApolloCache,
|
||||
FetchResult,
|
||||
InMemoryCache,
|
||||
gql,
|
||||
} from "@apollo/client/core";
|
||||
import { useMutation, useQuery } from "@vue/apollo-composable";
|
||||
import {
|
||||
defineAsyncComponent,
|
||||
ref,
|
||||
computed,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
} 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 { useI18n } from "vue-i18n";
|
||||
import { IConversation } from "@/types/conversation";
|
||||
import { usernameWithDomain, displayName } from "@/types/actor";
|
||||
import { formatList } from "@/utils/i18n";
|
||||
import throttle from "lodash/throttle";
|
||||
import Calendar from "vue-material-design-icons/Calendar.vue";
|
||||
import { ActorType } from "@/types/enums";
|
||||
|
||||
const props = defineProps<{ id: string }>();
|
||||
|
||||
const conversationId = computed(() => props.id);
|
||||
|
||||
const page = ref(1);
|
||||
const COMMENTS_PER_PAGE = 10;
|
||||
|
||||
const { currentActor } = useCurrentActorClient();
|
||||
|
||||
const {
|
||||
result: conversationResult,
|
||||
onResult: onConversationResult,
|
||||
onError: onConversationError,
|
||||
subscribeToMore,
|
||||
fetchMore,
|
||||
} = useQuery<{ conversation: IConversation }>(
|
||||
GET_CONVERSATION,
|
||||
() => ({
|
||||
id: conversationId.value,
|
||||
page: page.value,
|
||||
limit: COMMENTS_PER_PAGE,
|
||||
}),
|
||||
() => ({
|
||||
enabled: conversationId.value !== undefined,
|
||||
})
|
||||
);
|
||||
|
||||
subscribeToMore({
|
||||
document: CONVERSATION_COMMENT_CHANGED,
|
||||
variables: {
|
||||
id: conversationId.value,
|
||||
},
|
||||
updateQuery(
|
||||
previousResult: any,
|
||||
{ subscriptionData }: { subscriptionData: any }
|
||||
) {
|
||||
const previousConversation = previousResult.conversation;
|
||||
const lastComment =
|
||||
subscriptionData.data.conversationCommentChanged.lastComment;
|
||||
hasMoreComments.value = !previousConversation.comments.elements.some(
|
||||
(comment: IComment) => comment.id === lastComment.id
|
||||
);
|
||||
if (hasMoreComments.value) {
|
||||
return {
|
||||
conversation: {
|
||||
...previousConversation,
|
||||
lastComment: lastComment,
|
||||
comments: {
|
||||
elements: [
|
||||
...previousConversation.comments.elements.filter(
|
||||
({ id }: { id: string }) => id !== lastComment.id
|
||||
),
|
||||
lastComment,
|
||||
],
|
||||
total: previousConversation.comments.total + 1,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return previousConversation;
|
||||
},
|
||||
});
|
||||
|
||||
const conversation = computed(() => conversationResult.value?.conversation);
|
||||
const otherParticipants = computed(
|
||||
() =>
|
||||
conversation.value?.participants.filter(
|
||||
(participant) => participant.id !== currentActor.value?.id
|
||||
) ?? []
|
||||
);
|
||||
|
||||
const Editor = defineAsyncComponent(
|
||||
() => import("../../components/TextEditor.vue")
|
||||
);
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
const title = computed(() =>
|
||||
t("Conversation with {participants}", {
|
||||
participants: formatList(
|
||||
otherParticipants.value.map((participant) => displayName(participant))
|
||||
),
|
||||
})
|
||||
);
|
||||
|
||||
useHead({
|
||||
title: title.value,
|
||||
});
|
||||
|
||||
const newComment = ref("");
|
||||
// const newTitle = ref("");
|
||||
// const editTitleMode = ref(false);
|
||||
const hasMoreComments = ref(true);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
const { mutate: replyToConversationMutation } = useMutation<
|
||||
{
|
||||
postPrivateMessage: IConversation;
|
||||
},
|
||||
{
|
||||
text: string;
|
||||
actorId: string;
|
||||
language?: string;
|
||||
conversationId: string;
|
||||
mentions?: string[];
|
||||
attributedToId?: string;
|
||||
}
|
||||
>(REPLY_TO_PRIVATE_MESSAGE_MUTATION, () => ({
|
||||
update: (store: ApolloCache<InMemoryCache>, { data }) => {
|
||||
console.debug("update after reply to", [conversationId.value, page.value]);
|
||||
const conversationData = store.readQuery<{
|
||||
conversation: IConversation;
|
||||
}>({
|
||||
query: GET_CONVERSATION,
|
||||
variables: {
|
||||
id: conversationId.value,
|
||||
},
|
||||
});
|
||||
console.debug("update after reply to", conversationData);
|
||||
if (!conversationData) return;
|
||||
const { conversation: conversationCached } = conversationData;
|
||||
|
||||
console.debug("got cache", conversationCached);
|
||||
|
||||
store.writeQuery({
|
||||
query: GET_CONVERSATION,
|
||||
variables: {
|
||||
id: conversationId.value,
|
||||
},
|
||||
data: {
|
||||
conversation: {
|
||||
...conversationCached,
|
||||
lastComment: data?.postPrivateMessage.lastComment,
|
||||
comments: {
|
||||
elements: [
|
||||
...conversationCached.comments.elements,
|
||||
data?.postPrivateMessage.lastComment,
|
||||
],
|
||||
total: conversationCached.comments.total + 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
const reply = () => {
|
||||
if (
|
||||
newComment.value === "" ||
|
||||
!conversation.value?.id ||
|
||||
!currentActor.value?.id
|
||||
)
|
||||
return;
|
||||
|
||||
replyToConversationMutation({
|
||||
conversationId: conversation.value?.id,
|
||||
text: newComment.value,
|
||||
actorId: currentActor.value?.id,
|
||||
mentions: otherParticipants.value.map((participant) =>
|
||||
usernameWithDomain(participant)
|
||||
),
|
||||
attributedToId:
|
||||
conversation.value?.actor?.type === ActorType.GROUP
|
||||
? conversation.value?.actor.id
|
||||
: undefined,
|
||||
});
|
||||
|
||||
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<{
|
||||
conversation: IConversation;
|
||||
}>({
|
||||
query: GET_CONVERSATION,
|
||||
variables: {
|
||||
id: conversationId.value,
|
||||
page: page.value,
|
||||
},
|
||||
});
|
||||
if (!discussionData) return;
|
||||
const { conversation: 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_CONVERSATION,
|
||||
variables: { id: conversationId.value, page: page.value },
|
||||
data: { conversation: 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
|
||||
}
|
||||
text
|
||||
}
|
||||
`,
|
||||
data: {
|
||||
deletedAt: new Date(),
|
||||
text: "",
|
||||
actor: null,
|
||||
},
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
const loadMoreComments = async (): Promise<void> => {
|
||||
if (!hasMoreComments.value) return;
|
||||
console.debug("Loading more comments");
|
||||
page.value++;
|
||||
try {
|
||||
await fetchMore({
|
||||
// New variables
|
||||
variables: () => ({
|
||||
id: conversationId.value,
|
||||
page: page.value,
|
||||
limit: COMMENTS_PER_PAGE,
|
||||
}),
|
||||
});
|
||||
hasMoreComments.value = !conversation.value?.comments.elements
|
||||
.map(({ id }) => id)
|
||||
.includes(conversation.value?.lastComment?.id);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
// const dialog = inject<Dialog>("dialog");
|
||||
|
||||
// const openDeleteDiscussionConfirmation = (): void => {
|
||||
// dialog?.confirm({
|
||||
// variant: "danger",
|
||||
// title: t("Delete this conversation"),
|
||||
// message: t("Are you sure you want to delete this entire conversation?"),
|
||||
// confirmText: t("Delete conversation"),
|
||||
// cancelText: t("Cancel"),
|
||||
// onConfirm: () =>
|
||||
// deleteConversation({
|
||||
// discussionId: conversation.value?.id,
|
||||
// }),
|
||||
// });
|
||||
// };
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
// const { mutate: deleteConversation, onDone: deleteConversationDone } =
|
||||
// useMutation(DELETE_DISCUSSION);
|
||||
|
||||
// deleteConversationDone(() => {
|
||||
// if (conversation.value?.actor) {
|
||||
// router.push({
|
||||
// name: RouteName.DISCUSSION_LIST,
|
||||
// params: {
|
||||
// preferredUsername: usernameWithDomain(conversation.value.actor),
|
||||
// },
|
||||
// });
|
||||
// }
|
||||
// });
|
||||
|
||||
onConversationError((discussionError) =>
|
||||
handleErrors(discussionError.graphQLErrors as AbsintheGraphQLError[])
|
||||
);
|
||||
|
||||
onConversationResult(({ data }) => {
|
||||
if (
|
||||
page.value === 1 &&
|
||||
data?.conversation?.comments?.total &&
|
||||
data?.conversation?.comments?.total < COMMENTS_PER_PAGE
|
||||
) {
|
||||
markConversationAsRead();
|
||||
}
|
||||
});
|
||||
|
||||
const handleErrors = async (errors: AbsintheGraphQLError[]): Promise<void> => {
|
||||
if (errors[0].code === "not_found") {
|
||||
await router.push({ name: RouteName.PAGE_NOT_FOUND });
|
||||
}
|
||||
if (errors[0].code === "unauthorized") {
|
||||
error.value = errors[0].message;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener("scroll", handleScroll);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener("scroll", handleScroll);
|
||||
});
|
||||
|
||||
const { mutate: markConversationAsRead } = useMutation<
|
||||
{
|
||||
updateConversation: IConversation;
|
||||
},
|
||||
{
|
||||
id: string;
|
||||
read: boolean;
|
||||
}
|
||||
>(MARK_CONVERSATION_AS_READ, {
|
||||
variables: {
|
||||
id: conversationId.value,
|
||||
read: true,
|
||||
},
|
||||
});
|
||||
|
||||
const loadMoreCommentsThrottled = throttle(async () => {
|
||||
console.log("Throttled");
|
||||
await loadMoreComments();
|
||||
if (!hasMoreComments.value && conversation.value?.unread) {
|
||||
console.debug("marking as read");
|
||||
markConversationAsRead();
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
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) {
|
||||
console.debug("Scrolled to bottom");
|
||||
loadMoreCommentsThrottled();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
141
src/views/Discussions/CreateView.vue
Normal file
141
src/views/Discussions/CreateView.vue
Normal file
@@ -0,0 +1,141 @@
|
||||
<template>
|
||||
<section class="container mx-auto">
|
||||
<breadcrumbs-nav
|
||||
v-if="group"
|
||||
:links="[
|
||||
{
|
||||
name: RouteName.MY_GROUPS,
|
||||
text: t('My groups'),
|
||||
},
|
||||
{
|
||||
name: RouteName.GROUP,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
text: displayName(group),
|
||||
},
|
||||
{
|
||||
name: RouteName.DISCUSSION_LIST,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
text: t('Discussions'),
|
||||
},
|
||||
{
|
||||
name: RouteName.CREATE_DISCUSSION,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
text: t('Create'),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<h1 class="title">{{ t("Create a discussion") }}</h1>
|
||||
|
||||
<form @submit.prevent="createDiscussion">
|
||||
<o-field
|
||||
:label="t('Title')"
|
||||
label-for="discussion-title"
|
||||
:message="errors.title"
|
||||
:type="errors.title ? 'is-danger' : undefined"
|
||||
>
|
||||
<o-input
|
||||
aria-required="true"
|
||||
required
|
||||
v-model="discussion.title"
|
||||
id="discussion-title"
|
||||
/>
|
||||
</o-field>
|
||||
|
||||
<o-field :label="t('Text')">
|
||||
<Editor
|
||||
v-model="discussion.text"
|
||||
:aria-label="t('Message body')"
|
||||
v-if="currentActor"
|
||||
:current-actor="currentActor"
|
||||
:placeholder="t('Write a new message')"
|
||||
/>
|
||||
</o-field>
|
||||
|
||||
<o-button class="mt-2" native-type="submit">
|
||||
{{ t("Create the discussion") }}
|
||||
</o-button>
|
||||
</form>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<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";
|
||||
|
||||
const Editor = defineAsyncComponent(
|
||||
() => import("@/components/TextEditor.vue")
|
||||
);
|
||||
|
||||
const props = defineProps<{ preferredUsername: string }>();
|
||||
|
||||
const { currentActor } = useCurrentActorClient();
|
||||
const preferredUsername = computed(() => props.preferredUsername);
|
||||
|
||||
const { group } = useGroup(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,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
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>
|
||||
.markdown-render h1 {
|
||||
font-size: 2em;
|
||||
}
|
||||
</style>
|
||||
502
src/views/Discussions/DiscussionView.vue
Normal file
502
src/views/Discussions/DiscussionView.vue
Normal file
@@ -0,0 +1,502 @@
|
||||
<template>
|
||||
<div class="container mx-auto" v-if="discussion">
|
||||
<breadcrumbs-nav
|
||||
v-if="group"
|
||||
:links="[
|
||||
{
|
||||
name: RouteName.MY_GROUPS,
|
||||
text: t('My groups'),
|
||||
},
|
||||
{
|
||||
name: RouteName.GROUP,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
text: displayName(group),
|
||||
},
|
||||
{
|
||||
name: RouteName.DISCUSSION_LIST,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
text: t('Discussions'),
|
||||
},
|
||||
{
|
||||
name: RouteName.DISCUSSION,
|
||||
params: { id: discussion.id },
|
||||
text: discussion.title,
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<o-notification v-if="error" variant="danger">
|
||||
{{ error }}
|
||||
</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>
|
||||
<o-button
|
||||
icon-right="pencil"
|
||||
size="small"
|
||||
:title="t('Update discussion title')"
|
||||
v-if="
|
||||
discussion.creator &&
|
||||
!editTitleMode &&
|
||||
(currentActor?.id === discussion.creator.id ||
|
||||
isCurrentActorAGroupModerator)
|
||||
"
|
||||
@click="
|
||||
() => {
|
||||
newTitle = discussion?.title ?? '';
|
||||
editTitleMode = true;
|
||||
}
|
||||
"
|
||||
>
|
||||
</o-button>
|
||||
<o-skeleton
|
||||
v-else-if="!editTitleMode && discussionLoading"
|
||||
height="50px"
|
||||
animated
|
||||
/>
|
||||
<form
|
||||
v-else-if="!discussionLoading && !error"
|
||||
v-show="editTitleMode"
|
||||
@submit.prevent="updateDiscussion"
|
||||
class="w-full"
|
||||
>
|
||||
<o-field :label="t('Title')" label-for="discussion-title">
|
||||
<o-input
|
||||
:value="discussion.title"
|
||||
v-model="newTitle"
|
||||
id="discussion-title"
|
||||
/>
|
||||
</o-field>
|
||||
<div class="flex gap-2 mt-2">
|
||||
<o-button
|
||||
variant="primary"
|
||||
native-type="submit"
|
||||
icon-right="check"
|
||||
:title="t('Update discussion title')"
|
||||
/>
|
||||
<o-button
|
||||
@click="
|
||||
() => {
|
||||
editTitleMode = false;
|
||||
newTitle = '';
|
||||
}
|
||||
"
|
||||
outlined
|
||||
icon-right="close"
|
||||
:title="t('Cancel discussion title edition')"
|
||||
/>
|
||||
<o-button
|
||||
@click="openDeleteDiscussionConfirmation"
|
||||
variant="danger"
|
||||
native-type="button"
|
||||
icon-left="delete"
|
||||
>{{ t("Delete conversation") }}</o-button
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<discussion-comment
|
||||
class="border rounded-md p-2 mt-4"
|
||||
v-for="comment in discussion.comments.elements"
|
||||
:key="comment.id"
|
||||
: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,
|
||||
})
|
||||
"
|
||||
/>
|
||||
<o-button
|
||||
v-if="discussion.comments.elements.length < discussion.comments.total"
|
||||
@click="loadMoreComments"
|
||||
>{{ t("Fetch more") }}</o-button
|
||||
>
|
||||
<form @submit.prevent="reply" v-if="!error">
|
||||
<o-field :label="t('Text')">
|
||||
<Editor
|
||||
v-model="newComment"
|
||||
:aria-label="t('Message body')"
|
||||
v-if="currentActor"
|
||||
:currentActor="currentActor"
|
||||
:placeholder="t('Write a new message')"
|
||||
/>
|
||||
</o-field>
|
||||
<o-button
|
||||
class="my-2"
|
||||
native-type="submit"
|
||||
:disabled="['<p></p>', ''].includes(newComment)"
|
||||
variant="primary"
|
||||
>{{ t("Reply") }}</o-button
|
||||
>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
GET_DISCUSSION,
|
||||
REPLY_TO_DISCUSSION,
|
||||
UPDATE_DISCUSSION,
|
||||
DELETE_DISCUSSION,
|
||||
DISCUSSION_COMMENT_CHANGED,
|
||||
} from "@/graphql/discussion";
|
||||
import { IDiscussion } from "@/types/discussions";
|
||||
import { displayName, IPerson, usernameWithDomain } from "@/types/actor";
|
||||
import DiscussionComment from "@/components/Discussion/DiscussionComment.vue";
|
||||
import { DELETE_COMMENT, UPDATE_COMMENT } from "@/graphql/comment";
|
||||
import RouteName from "../../router/name";
|
||||
import { IComment } from "../../types/comment.model";
|
||||
import {
|
||||
ApolloCache,
|
||||
FetchResult,
|
||||
InMemoryCache,
|
||||
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 { MemberRole } from "@/types/enums";
|
||||
import { PERSON_MEMBERSHIPS } from "@/graphql/actor";
|
||||
import { Dialog } from "@/plugins/dialog";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
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 = computed(() => discussion.value?.actor);
|
||||
|
||||
const Editor = defineAsyncComponent(
|
||||
() => import("@/components/TextEditor.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<{
|
||||
replyToDiscussion: IDiscussion;
|
||||
}>(REPLY_TO_DISCUSSION, () => ({
|
||||
update: (store: ApolloCache<InMemoryCache>, { data }: FetchResult) => {
|
||||
const discussionData = store.readQuery<{
|
||||
discussion: IDiscussion;
|
||||
}>({
|
||||
query: GET_DISCUSSION,
|
||||
variables: {
|
||||
slug: props.slug,
|
||||
page: page.value,
|
||||
},
|
||||
});
|
||||
if (!discussionData) return;
|
||||
const { discussion: discussionCached } = discussionData;
|
||||
|
||||
store.writeQuery({
|
||||
query: GET_DISCUSSION,
|
||||
variables: { slug: props.slug, page: page.value },
|
||||
data: {
|
||||
discussion: {
|
||||
...discussionCached,
|
||||
lastComment: data?.replyToDiscussion.lastComment,
|
||||
comments: {
|
||||
elements: [
|
||||
...discussionCached.comments.elements,
|
||||
data?.replyToDiscussion.lastComment,
|
||||
],
|
||||
total: discussionCached.comments.total + 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
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: {
|
||||
slug: props.slug,
|
||||
page: page.value,
|
||||
},
|
||||
});
|
||||
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
|
||||
}
|
||||
text
|
||||
}
|
||||
`,
|
||||
data: {
|
||||
deletedAt: new Date(),
|
||||
text: "",
|
||||
actor: null,
|
||||
},
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
const loadMoreComments = async (): Promise<void> => {
|
||||
if (!hasMoreComments.value) return;
|
||||
page.value++;
|
||||
try {
|
||||
await fetchMore({
|
||||
// New variables
|
||||
variables: {
|
||||
slug: props.slug,
|
||||
page: page.value,
|
||||
limit: COMMENTS_PER_PAGE,
|
||||
},
|
||||
});
|
||||
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({
|
||||
variant: "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),
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
onDiscussionError((discussionError) =>
|
||||
handleErrors(discussionError.graphQLErrors as AbsintheGraphQLError[])
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener("scroll", handleScroll);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener("scroll", handleScroll);
|
||||
});
|
||||
|
||||
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();
|
||||
}
|
||||
};
|
||||
|
||||
const isCurrentActorAGroupModerator = computed((): boolean => {
|
||||
return hasCurrentActorThisRole([
|
||||
MemberRole.MODERATOR,
|
||||
MemberRole.ADMINISTRATOR,
|
||||
]);
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
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>
|
||||
133
src/views/Discussions/DiscussionsListView.vue
Normal file
133
src/views/Discussions/DiscussionsListView.vue
Normal file
@@ -0,0 +1,133 @@
|
||||
<template>
|
||||
<div class="container mx-auto section" v-if="group">
|
||||
<breadcrumbs-nav
|
||||
:links="[
|
||||
{
|
||||
name: RouteName.MY_GROUPS,
|
||||
text: t('My groups'),
|
||||
},
|
||||
{
|
||||
name: RouteName.GROUP,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
text: displayName(group),
|
||||
},
|
||||
{
|
||||
name: RouteName.DISCUSSION_LIST,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
text: t('Discussions'),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<section v-if="isCurrentActorAGroupMember">
|
||||
<h1>{{ t("Discussions") }}</h1>
|
||||
<p>
|
||||
{{
|
||||
t(
|
||||
"Keep the entire conversation about a specific topic together on a single page."
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<o-button
|
||||
tag="router-link"
|
||||
:to="{
|
||||
name: RouteName.CREATE_DISCUSSION,
|
||||
params: { preferredUsername },
|
||||
}"
|
||||
>{{ t("New discussion") }}</o-button
|
||||
>
|
||||
<div v-if="group.discussions.elements.length > 0">
|
||||
<discussion-list-item
|
||||
:discussion="discussion"
|
||||
v-for="discussion in group.discussions.elements"
|
||||
:key="discussion.id"
|
||||
/>
|
||||
<o-pagination
|
||||
v-show="group.discussions.total > DISCUSSIONS_PER_PAGE"
|
||||
class="discussion-pagination"
|
||||
:total="group.discussions.total"
|
||||
v-model:current="page"
|
||||
:per-page="DISCUSSIONS_PER_PAGE"
|
||||
: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>
|
||||
<empty-content v-else icon="chat">
|
||||
{{ t("There's no discussions yet") }}
|
||||
</empty-content>
|
||||
</section>
|
||||
<section class="section" v-else-if="!groupLoading && !personLoading">
|
||||
<empty-content icon="chat">
|
||||
{{ t("Only group members can access discussions") }}
|
||||
<template #desc>
|
||||
<router-link
|
||||
:to="{ name: RouteName.GROUP, params: { preferredUsername } }"
|
||||
>
|
||||
{{ t("Return to the group page") }}
|
||||
</router-link>
|
||||
</template>
|
||||
</empty-content>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<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 { useGroupDiscussionsList } from "@/composition/apollo/discussions";
|
||||
import { IMember } from "@/types/actor/member.model";
|
||||
import EmptyContent from "@/components/Utils/EmptyContent.vue";
|
||||
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;
|
||||
|
||||
const props = defineProps<{ preferredUsername: string }>();
|
||||
const preferredUsername = computed(() => props.preferredUsername);
|
||||
|
||||
const { group, loading: groupLoading } = useGroupDiscussionsList(
|
||||
preferredUsername.value,
|
||||
{
|
||||
discussionsPage: page.value,
|
||||
discussionsLimit: DISCUSSIONS_PER_PAGE,
|
||||
}
|
||||
);
|
||||
|
||||
const { person, loading: personLoading } = usePersonStatusGroup(
|
||||
preferredUsername.value
|
||||
);
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
useHead({
|
||||
title: computed(() => t("Discussions")),
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
const isCurrentActorAGroupMember = computed((): boolean => {
|
||||
return (
|
||||
groupMemberships.value !== undefined &&
|
||||
groupMemberships.value.includes(group.value?.id)
|
||||
);
|
||||
});
|
||||
</script>
|
||||
27
src/views/ErrorView.vue
Normal file
27
src/views/ErrorView.vue
Normal 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>
|
||||
1360
src/views/Event/EditView.vue
Normal file
1360
src/views/Event/EditView.vue
Normal file
File diff suppressed because it is too large
Load Diff
627
src/views/Event/EventView.vue
Executable file
627
src/views/Event/EventView.vue
Executable file
@@ -0,0 +1,627 @@
|
||||
<template>
|
||||
<div class="container mx-auto">
|
||||
<o-loading v-model:active="eventLoading" />
|
||||
<div class="flex flex-col mb-3">
|
||||
<event-banner :picture="event?.picture" />
|
||||
<div
|
||||
class="flex flex-col relative pb-2 bg-white dark:bg-zinc-700 my-4 rounded"
|
||||
>
|
||||
<div class="date-calendar-icon-wrapper relative" v-if="event?.beginsOn">
|
||||
<skeleton-date-calendar-icon
|
||||
v-if="eventLoading"
|
||||
class="absolute left-3 -top-16"
|
||||
/>
|
||||
<date-calendar-icon
|
||||
v-else
|
||||
:date="event.beginsOn.toString()"
|
||||
class="absolute left-3 -top-16"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section class="intro px-2 pt-4" dir="auto">
|
||||
<div class="flex flex-wrap gap-2 justify-end">
|
||||
<div class="flex-1 min-w-[300px]">
|
||||
<div
|
||||
v-if="eventLoading"
|
||||
class="animate-pulse mb-2 h-12 bg-slate-200 w-3/4"
|
||||
/>
|
||||
<h1
|
||||
v-else
|
||||
class="text-4xl font-bold m-0"
|
||||
dir="auto"
|
||||
:lang="event?.language"
|
||||
>
|
||||
{{ event?.title }}
|
||||
</h1>
|
||||
<div class="organizer">
|
||||
<div
|
||||
v-if="eventLoading"
|
||||
class="animate-pulse mb-2 h-6 space-y-6 bg-slate-200 w-64"
|
||||
/>
|
||||
<div v-else-if="event?.organizerActor && !event?.attributedTo">
|
||||
<popover-actor-card
|
||||
:actor="event.organizerActor"
|
||||
:inline="true"
|
||||
>
|
||||
<i18n-t
|
||||
keypath="By {username}"
|
||||
dir="auto"
|
||||
class="block truncate max-w-xs md:max-w-sm"
|
||||
>
|
||||
<template #username>
|
||||
<span dir="ltr">{{
|
||||
displayName(event.organizerActor)
|
||||
}}</span>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</popover-actor-card>
|
||||
</div>
|
||||
<span v-else-if="event?.attributedTo">
|
||||
<popover-actor-card
|
||||
:actor="event.attributedTo"
|
||||
:inline="true"
|
||||
>
|
||||
<i18n-t
|
||||
keypath="By {group}"
|
||||
dir="auto"
|
||||
class="block truncate max-w-xs md:max-w-sm"
|
||||
>
|
||||
<template #group>
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.GROUP,
|
||||
params: {
|
||||
preferredUsername: usernameWithDomain(
|
||||
event.attributedTo
|
||||
),
|
||||
},
|
||||
}"
|
||||
dir="ltr"
|
||||
>{{ displayName(event.attributedTo) }}</router-link
|
||||
>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</popover-actor-card>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2 gap-y-4 mt-2 my-3">
|
||||
<div
|
||||
v-if="eventLoading"
|
||||
class="animate-pulse mb-2 h-6 space-y-6 bg-slate-200 w-64"
|
||||
/>
|
||||
<p v-else-if="event?.status !== EventStatus.CONFIRMED">
|
||||
<tag
|
||||
variant="warning"
|
||||
v-if="event?.status === EventStatus.TENTATIVE"
|
||||
>{{ t("Event to be confirmed") }}</tag
|
||||
>
|
||||
<tag
|
||||
variant="danger"
|
||||
v-if="event?.status === EventStatus.CANCELLED"
|
||||
>{{ t("Event cancelled") }}</tag
|
||||
>
|
||||
</p>
|
||||
<template v-if="!eventLoading && !event?.draft">
|
||||
<p
|
||||
v-if="event?.visibility === EventVisibility.PUBLIC"
|
||||
class="inline-flex gap-1"
|
||||
>
|
||||
<Earth />
|
||||
{{ t("Public event") }}
|
||||
</p>
|
||||
<p
|
||||
v-if="event?.visibility === EventVisibility.UNLISTED"
|
||||
class="inline-flex gap-1"
|
||||
>
|
||||
<Link />
|
||||
{{ t("Private event") }}
|
||||
</p>
|
||||
</template>
|
||||
<template v-if="!event?.local && organizerDomain">
|
||||
<a :href="event?.url">
|
||||
<tag variant="info">{{ organizerDomain }}</tag>
|
||||
</a>
|
||||
</template>
|
||||
<div
|
||||
v-if="eventLoading"
|
||||
class="animate-pulse mb-2 h-6 space-y-6 bg-slate-200 w-64"
|
||||
/>
|
||||
<p v-else class="flex flex-wrap gap-1 items-center" dir="auto">
|
||||
<tag v-if="eventCategory" class="category" capitalize>{{
|
||||
eventCategory
|
||||
}}</tag>
|
||||
<router-link
|
||||
class="rounded-md truncate text-sm text-violet-title py-1 bg-purple-3 dark:text-violet-3 category"
|
||||
v-for="tag in event?.tags ?? []"
|
||||
:key="tag.title"
|
||||
:to="{ name: RouteName.TAG, params: { tag: tag.title } }"
|
||||
>
|
||||
<tag>{{ tag.title }}</tag>
|
||||
</router-link>
|
||||
</p>
|
||||
<tag variant="warning" size="medium" v-if="event?.draft"
|
||||
>{{ t("Draft") }}
|
||||
</tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="eventLoading">
|
||||
<div class="animate-pulse mb-2 h-6 bg-slate-200 w-64" />
|
||||
<div class="animate-pulse mb-2 h-6 bg-slate-200 w-64" />
|
||||
</div>
|
||||
<EventActionSection
|
||||
v-else-if="event"
|
||||
:event="event"
|
||||
:currentActor="currentActor"
|
||||
:participations="participations"
|
||||
:person="person"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded-lg dark:border-violet-title flex flex-wrap flex-col md:flex-row-reverse gap-4"
|
||||
>
|
||||
<aside
|
||||
class="rounded bg-white dark:bg-zinc-700 shadow-md h-min max-w-screen-sm"
|
||||
>
|
||||
<div class="sticky p-4">
|
||||
<aside
|
||||
v-if="eventLoading"
|
||||
class="animate-pulse rounded bg-white dark:bg-zinc-700 h-min max-w-screen-sm"
|
||||
>
|
||||
<div class="mb-6 p-2" v-for="i in 3" :key="i">
|
||||
<div class="mb-2 h-6 bg-slate-200 w-64" />
|
||||
<div class="flex space-x-4 flex-row">
|
||||
<div class="rounded-full bg-slate-200 h-10 w-10"></div>
|
||||
<div class="flex flex-col flex-1 space-y-2">
|
||||
<div class="h-3 bg-slate-200"></div>
|
||||
<div class="h-3 bg-slate-200"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<event-metadata-sidebar
|
||||
v-else-if="event"
|
||||
:event="event"
|
||||
:user="loggedUser"
|
||||
@showMapModal="showMap = true"
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="flex-1">
|
||||
<section
|
||||
class="event-description bg-white dark:bg-zinc-700 px-3 pt-1 pb-3 rounded mb-4"
|
||||
>
|
||||
<h2 class="text-2xl">{{ t("About this event") }}</h2>
|
||||
<div
|
||||
v-if="eventLoading"
|
||||
class="animate-pulse mb-2 h-6 space-y-6 bg-slate-200 w-3/4"
|
||||
/>
|
||||
<div
|
||||
v-if="eventLoading"
|
||||
class="animate-pulse mb-2 h-6 space-y-6 bg-slate-200 w-3/4"
|
||||
/>
|
||||
<div
|
||||
v-if="eventLoading"
|
||||
class="animate-pulse mb-2 h-6 space-y-6 bg-slate-200 w-1/4"
|
||||
/>
|
||||
<p v-else-if="!event?.description">
|
||||
{{ t("The event organizer didn't add any description.") }}
|
||||
</p>
|
||||
<div v-else>
|
||||
<div
|
||||
:lang="event?.language"
|
||||
dir="auto"
|
||||
class="mt-4 prose md:prose-lg lg:prose-xl dark:prose-invert prose-h1:text-xl prose-h1:font-semibold prose-h2:text-lg prose-h3:text-base md:prose-h1:text-2xl md:prose-h1:font-semibold md:prose-h2:text-xl md:prose-h3:text-lg lg:prose-h1:text-2xl lg:prose-h1:font-semibold lg:prose-h2:text-xl lg:prose-h3:text-lg"
|
||||
ref="eventDescriptionElement"
|
||||
v-html="event.description"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<section class="my-4">
|
||||
<component
|
||||
v-for="(metadata, integration) in integrations"
|
||||
:is="metadataToComponent[integration]"
|
||||
:key="integration"
|
||||
:metadata="metadata"
|
||||
class="my-2"
|
||||
/>
|
||||
</section>
|
||||
<section
|
||||
class="bg-white dark:bg-zinc-700 px-3 pt-1 pb-3 rounded my-4"
|
||||
ref="commentsObserver"
|
||||
>
|
||||
<a href="#comments">
|
||||
<h2 class="text-2xl" id="comments">{{ t("Comments") }}</h2>
|
||||
</a>
|
||||
<comment-tree v-if="event && loadComments" :event="event" />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section
|
||||
class="bg-white dark:bg-zinc-700 px-3 pt-1 pb-3 rounded my-4"
|
||||
v-if="(event?.relatedEvents ?? []).length > 0"
|
||||
>
|
||||
<h2 class="text-2xl mb-2">
|
||||
{{ t("These events may interest you") }}
|
||||
</h2>
|
||||
<multi-card :events="event?.relatedEvents ?? []" />
|
||||
</section>
|
||||
<o-modal
|
||||
v-model:active="showMap"
|
||||
:close-button-aria-label="t('Close')"
|
||||
class="map-modal"
|
||||
v-if="event?.physicalAddress?.geom"
|
||||
has-modal-card
|
||||
full-screen
|
||||
:can-cancel="['escape', 'outside']"
|
||||
>
|
||||
<template #default>
|
||||
<event-map
|
||||
:routingType="routingType ?? RoutingType.OPENSTREETMAP"
|
||||
:address="event.physicalAddress"
|
||||
@close="showMap = false"
|
||||
/>
|
||||
</template>
|
||||
</o-modal>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
EventStatus,
|
||||
ParticipantRole,
|
||||
RoutingType,
|
||||
EventVisibility,
|
||||
} from "@/types/enums";
|
||||
import {
|
||||
EVENT_PERSON_PARTICIPATION,
|
||||
// EVENT_PERSON_PARTICIPATION_SUBSCRIPTION_CHANGED,
|
||||
} from "@/graphql/event";
|
||||
import {
|
||||
displayName,
|
||||
IActor,
|
||||
IPerson,
|
||||
usernameWithDomain,
|
||||
} from "@/types/actor";
|
||||
import DateCalendarIcon from "@/components/Event/DateCalendarIcon.vue";
|
||||
import SkeletonDateCalendarIcon from "@/components/Event/SkeletonDateCalendarIcon.vue";
|
||||
import Earth from "vue-material-design-icons/Earth.vue";
|
||||
import Link from "vue-material-design-icons/Link.vue";
|
||||
import MultiCard from "@/components/Event/MultiCard.vue";
|
||||
import RouteName from "@/router/name";
|
||||
import CommentTree from "@/components/Comment/CommentTree.vue";
|
||||
import "intersection-observer";
|
||||
import Tag from "@/components/TagElement.vue";
|
||||
import EventMetadataSidebar from "@/components/Event/EventMetadataSidebar.vue";
|
||||
import EventBanner from "@/components/Event/EventBanner.vue";
|
||||
import EventActionSection from "@/components/Event/EventActionSection.vue";
|
||||
import PopoverActorCard from "@/components/Account/PopoverActorCard.vue";
|
||||
import { IEventMetadataDescription } from "@/types/event-metadata";
|
||||
import { eventMetaDataList } from "@/services/EventMetadata";
|
||||
import { useFetchEvent } from "@/composition/apollo/event";
|
||||
import {
|
||||
computed,
|
||||
onMounted,
|
||||
ref,
|
||||
watch,
|
||||
defineAsyncComponent,
|
||||
inject,
|
||||
} from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import {
|
||||
useCurrentActorClient,
|
||||
usePersonStatusGroup,
|
||||
} from "@/composition/apollo/actor";
|
||||
import { useLoggedUser } from "@/composition/apollo/user";
|
||||
import { useQuery } from "@vue/apollo-composable";
|
||||
import {
|
||||
useEventCategories,
|
||||
useRoutingType,
|
||||
} from "@/composition/apollo/config";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Notifier } from "@/plugins/notifier";
|
||||
import { AbsintheGraphQLErrors } from "@/types/errors.model";
|
||||
import { useHead } from "@vueuse/head";
|
||||
|
||||
const IntegrationTwitch = defineAsyncComponent(
|
||||
() => import("@/components/Event/Integrations/TwitchIntegration.vue")
|
||||
);
|
||||
const IntegrationPeertube = defineAsyncComponent(
|
||||
() => import("@/components/Event/Integrations/PeerTubeIntegration.vue")
|
||||
);
|
||||
const IntegrationYoutube = defineAsyncComponent(
|
||||
() => import("@/components/Event/Integrations/YouTubeIntegration.vue")
|
||||
);
|
||||
const IntegrationJitsiMeet = defineAsyncComponent(
|
||||
() => import("@/components/Event/Integrations/JitsiMeetIntegration.vue")
|
||||
);
|
||||
const IntegrationEtherpad = defineAsyncComponent(
|
||||
() => import("@/components/Event/Integrations/EtherpadIntegration.vue")
|
||||
);
|
||||
const EventMap = defineAsyncComponent(
|
||||
() => import("@/components/Event/EventMap.vue")
|
||||
);
|
||||
|
||||
const props = defineProps<{
|
||||
uuid: string;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
const propsUUID = computed(() => props.uuid);
|
||||
|
||||
const {
|
||||
event,
|
||||
onError: onFetchEventError,
|
||||
loading: eventLoading,
|
||||
refetch: refetchEvent,
|
||||
} = useFetchEvent(propsUUID);
|
||||
|
||||
watch(propsUUID, (newUUid) => {
|
||||
refetchEvent({ uuid: newUUid });
|
||||
});
|
||||
|
||||
const eventId = computed(() => event.value?.id);
|
||||
const { currentActor } = useCurrentActorClient();
|
||||
const currentActorId = computed(() => currentActor.value?.id);
|
||||
const { loggedUser } = useLoggedUser();
|
||||
const {
|
||||
result: participationsResult,
|
||||
// subscribeToMore: subscribeToMoreParticipation,
|
||||
} = useQuery<{ person: IPerson }>(
|
||||
EVENT_PERSON_PARTICIPATION,
|
||||
() => ({
|
||||
eventId: event.value?.id,
|
||||
actorId: currentActorId.value,
|
||||
}),
|
||||
() => ({
|
||||
enabled:
|
||||
currentActorId.value !== undefined &&
|
||||
currentActorId.value !== null &&
|
||||
eventId.value !== undefined,
|
||||
})
|
||||
);
|
||||
|
||||
// subscribeToMoreParticipation(() => ({
|
||||
// document: EVENT_PERSON_PARTICIPATION_SUBSCRIPTION_CHANGED,
|
||||
// variables: {
|
||||
// eventId: eventId,
|
||||
// actorId: currentActorId,
|
||||
// },
|
||||
// }));
|
||||
|
||||
const participations = computed(
|
||||
() => participationsResult.value?.person.participations?.elements ?? []
|
||||
);
|
||||
|
||||
const groupFederatedUsername = computed(() =>
|
||||
usernameWithDomain(event.value?.attributedTo)
|
||||
);
|
||||
|
||||
const { person } = usePersonStatusGroup(groupFederatedUsername);
|
||||
|
||||
const { eventCategories } = useEventCategories();
|
||||
|
||||
// metaInfo() {
|
||||
// return {
|
||||
// // eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// // @ts-ignore
|
||||
// title: this.eventTitle,
|
||||
// meta: [
|
||||
// // eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// // @ts-ignore
|
||||
// { name: "description", content: this.eventDescription },
|
||||
// ],
|
||||
// };
|
||||
// },
|
||||
|
||||
const identity = ref<IPerson | undefined | null>(null);
|
||||
|
||||
const oldParticipationRole = ref<string | undefined>(undefined);
|
||||
|
||||
const observer = ref<IntersectionObserver | null>(null);
|
||||
const commentsObserver = ref<Element | null>(null);
|
||||
|
||||
const loadComments = ref(false);
|
||||
|
||||
const eventTitle = computed((): undefined | string => {
|
||||
return event.value?.title;
|
||||
});
|
||||
|
||||
const eventDescription = computed((): undefined | string => {
|
||||
return event.value?.description;
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const eventDescriptionElement = ref<HTMLElement | null>(null);
|
||||
|
||||
onMounted(async () => {
|
||||
identity.value = currentActor.value;
|
||||
if (route.hash.includes("#comment-")) {
|
||||
loadComments.value = true;
|
||||
}
|
||||
|
||||
observer.value = new IntersectionObserver(
|
||||
(entries) => {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
for (const entry of entries) {
|
||||
if (entry) {
|
||||
loadComments.value = entry.isIntersecting || loadComments.value;
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
rootMargin: "-50px 0px -50px",
|
||||
}
|
||||
);
|
||||
if (commentsObserver.value) {
|
||||
observer.value.observe(commentsObserver.value);
|
||||
}
|
||||
|
||||
watch(eventDescription, () => {
|
||||
if (!eventDescription.value) return;
|
||||
if (!eventDescriptionElement.value) return;
|
||||
|
||||
eventDescriptionElement.value.addEventListener("click", ($event) => {
|
||||
// TODO: Find the right type for target
|
||||
let { target }: { target: any } = $event;
|
||||
while (target && target.tagName !== "A") target = target.parentNode;
|
||||
// handle only links that occur inside the component and do not reference external resources
|
||||
if (target && target.matches(".hashtag") && target.href) {
|
||||
// some sanity checks taken from vue-router:
|
||||
// https://github.com/vuejs/vue-router/blob/dev/src/components/link.js#L106
|
||||
const { altKey, ctrlKey, metaKey, shiftKey, button, defaultPrevented } =
|
||||
$event;
|
||||
// don't handle with control keys
|
||||
if (metaKey || altKey || ctrlKey || shiftKey) return;
|
||||
// don't handle when preventDefault called
|
||||
if (defaultPrevented) return;
|
||||
// don't handle right clicks
|
||||
if (button !== undefined && button !== 0) return;
|
||||
// don't handle if `target="_blank"`
|
||||
if (target && target.getAttribute) {
|
||||
const linkTarget = target.getAttribute("target");
|
||||
if (/\b_blank\b/i.test(linkTarget)) return;
|
||||
}
|
||||
// don't handle same page links/anchors
|
||||
const url = new URL(target.href);
|
||||
const to = url.pathname;
|
||||
if (window.location.pathname !== to && $event.preventDefault) {
|
||||
$event.preventDefault();
|
||||
router.push(to);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// this.$on("event-deleted", () => {
|
||||
// return router.push({ name: RouteName.HOME });
|
||||
// });
|
||||
});
|
||||
|
||||
const notifier = inject<Notifier>("notifier");
|
||||
|
||||
watch(participations, () => {
|
||||
if (participations.value.length > 0) {
|
||||
if (
|
||||
oldParticipationRole.value &&
|
||||
participations.value[0].role !== ParticipantRole.NOT_APPROVED &&
|
||||
oldParticipationRole.value !== participations.value[0].role
|
||||
) {
|
||||
switch (participations.value[0].role) {
|
||||
case ParticipantRole.PARTICIPANT:
|
||||
participationConfirmedMessage();
|
||||
break;
|
||||
case ParticipantRole.REJECTED:
|
||||
participationRejectedMessage();
|
||||
break;
|
||||
default:
|
||||
participationChangedMessage();
|
||||
break;
|
||||
}
|
||||
}
|
||||
oldParticipationRole.value = participations.value[0].role;
|
||||
}
|
||||
});
|
||||
|
||||
const participationConfirmedMessage = () => {
|
||||
notifier?.success(t("Your participation has been confirmed"));
|
||||
};
|
||||
|
||||
const participationRejectedMessage = () => {
|
||||
notifier?.error(t("Your participation has been rejected"));
|
||||
};
|
||||
|
||||
const participationChangedMessage = () => {
|
||||
notifier?.info(t("Your participation status has been changed"));
|
||||
};
|
||||
|
||||
const handleErrors = (errors: AbsintheGraphQLErrors): void => {
|
||||
if (
|
||||
errors.some((error) => error.status_code === 404) ||
|
||||
errors.some(({ message }) => message.includes("has invalid value $uuid"))
|
||||
) {
|
||||
router.replace({ name: RouteName.PAGE_NOT_FOUND });
|
||||
}
|
||||
};
|
||||
|
||||
onFetchEventError(({ graphQLErrors }) =>
|
||||
handleErrors(graphQLErrors as AbsintheGraphQLErrors)
|
||||
);
|
||||
|
||||
const metadataToComponent: Record<string, any> = {
|
||||
"mz:live:twitch:url": IntegrationTwitch,
|
||||
"mz:live:peertube:url": IntegrationPeertube,
|
||||
"mz:live:youtube:url": IntegrationYoutube,
|
||||
"mz:visio:jitsi_meet": IntegrationJitsiMeet,
|
||||
"mz:notes:etherpad:url": IntegrationEtherpad,
|
||||
};
|
||||
|
||||
const integrations = computed((): Record<string, IEventMetadataDescription> => {
|
||||
return (event.value?.metadata ?? [])
|
||||
.map((val) => {
|
||||
const def = eventMetaDataList.find((dat) => dat.key === val.key);
|
||||
return {
|
||||
...def,
|
||||
...val,
|
||||
};
|
||||
})
|
||||
.reduce((acc: Record<string, IEventMetadataDescription>, metadata) => {
|
||||
const component = metadataToComponent[metadata.key];
|
||||
if (component !== undefined) {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
acc[metadata.key] = metadata;
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
});
|
||||
|
||||
const showMap = ref(false);
|
||||
|
||||
const { routingType } = useRoutingType();
|
||||
|
||||
const eventCategory = computed((): string | undefined => {
|
||||
if (event.value?.category === "MEETING") {
|
||||
return undefined;
|
||||
}
|
||||
return (eventCategories.value ?? []).find((eventCategoryToFind) => {
|
||||
return eventCategoryToFind.id === event.value?.category;
|
||||
})?.label as string;
|
||||
});
|
||||
|
||||
const organizer = computed((): IActor | null => {
|
||||
if (event.value?.attributedTo?.id) {
|
||||
return event.value.attributedTo;
|
||||
}
|
||||
if (event.value?.organizerActor) {
|
||||
return event.value.organizerActor;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const organizerDomain = computed((): string | undefined => {
|
||||
return organizer.value?.domain ?? undefined;
|
||||
});
|
||||
|
||||
useHead({
|
||||
title: computed(() => eventTitle.value ?? ""),
|
||||
meta: [{ name: "description", content: eventDescription.value }],
|
||||
});
|
||||
</script>
|
||||
<style>
|
||||
.event-description a {
|
||||
@apply inline-block p-1 bg-mbz-yellow-alt-200 text-black;
|
||||
}
|
||||
|
||||
.event-description .mention.h-card {
|
||||
@apply inline-block border border-zinc-600 dark:border-zinc-300 rounded py-0.5 px-1;
|
||||
}
|
||||
</style>
|
||||
218
src/views/Event/GroupEvents.vue
Normal file
218
src/views/Event/GroupEvents.vue
Normal file
@@ -0,0 +1,218 @@
|
||||
<template>
|
||||
<div class="container mx-auto" v-if="group">
|
||||
<breadcrumbs-nav
|
||||
:links="[
|
||||
{
|
||||
name: RouteName.GROUP,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
text: displayName(group),
|
||||
},
|
||||
{
|
||||
name: RouteName.GROUP_EVENTS,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
text: t('Events'),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<section>
|
||||
<h1 class="" v-if="group">
|
||||
{{
|
||||
t("{group}'s events", {
|
||||
group: displayName(group),
|
||||
})
|
||||
}}
|
||||
</h1>
|
||||
<p v-if="isCurrentActorMember">
|
||||
{{
|
||||
t(
|
||||
"When a moderator from the group creates an event and attributes it to the group, it will show up here."
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<o-button
|
||||
tag="router-link"
|
||||
variant="primary"
|
||||
v-if="isCurrentActorAGroupModerator"
|
||||
:to="{
|
||||
name: RouteName.CREATE_EVENT,
|
||||
query: { actorId: group.id },
|
||||
}"
|
||||
>{{ t("+ Create an event") }}</o-button
|
||||
>
|
||||
<o-loading v-model:active="groupLoading"></o-loading>
|
||||
<section v-if="group">
|
||||
<h2 class="text-2xl">
|
||||
{{ showPassedEvents ? t("Past events") : t("Upcoming events") }}
|
||||
</h2>
|
||||
<o-switch class="mb-4" v-model="showPassedEvents">{{
|
||||
t("Past events")
|
||||
}}</o-switch>
|
||||
<grouped-multi-event-minimalist-card
|
||||
class="mb-6"
|
||||
:events="group.organizedEvents.elements"
|
||||
:isCurrentActorMember="isCurrentActorMember"
|
||||
:order="showPassedEvents ? 'DESC' : 'ASC'"
|
||||
/>
|
||||
<empty-content
|
||||
v-if="
|
||||
group.organizedEvents.elements.length === 0 &&
|
||||
groupLoading === false
|
||||
"
|
||||
icon="calendar"
|
||||
:inline="true"
|
||||
:center="true"
|
||||
>
|
||||
{{ t("No events found") }}
|
||||
<template v-if="group.domain !== null">
|
||||
<div class="mt-4">
|
||||
<p>
|
||||
{{
|
||||
t(
|
||||
"This group is a remote group, it's possible the original instance has more informations."
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<o-button variant="text" tag="a" :href="group.url">
|
||||
{{ t("View the group profile on the original instance") }}
|
||||
</o-button>
|
||||
</div>
|
||||
</template>
|
||||
</empty-content>
|
||||
<o-pagination
|
||||
v-if="group.organizedEvents.total > EVENTS_PAGE_LIMIT"
|
||||
class="mt-4"
|
||||
:total="group.organizedEvents.total"
|
||||
v-model:current="page"
|
||||
:per-page="EVENTS_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>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import RouteName from "@/router/name";
|
||||
import GroupedMultiEventMinimalistCard from "@/components/Event/GroupedMultiEventMinimalistCard.vue";
|
||||
import { PERSON_MEMBERSHIPS } from "@/graphql/actor";
|
||||
import { FETCH_GROUP_EVENTS } from "@/graphql/event";
|
||||
import EmptyContent from "../../components/Utils/EmptyContent.vue";
|
||||
import {
|
||||
displayName,
|
||||
IGroup,
|
||||
IPerson,
|
||||
usernameWithDomain,
|
||||
} from "../../types/actor";
|
||||
import { useQuery } from "@vue/apollo-composable";
|
||||
import { useCurrentActorClient } from "@/composition/apollo/actor";
|
||||
import { computed, watch } 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;
|
||||
|
||||
const { currentActor } = useCurrentActorClient();
|
||||
|
||||
const { result: membershipsResult } = useQuery<{
|
||||
person: Pick<IPerson, "memberships">;
|
||||
}>(
|
||||
PERSON_MEMBERSHIPS,
|
||||
() => ({ id: currentActor.value?.id }),
|
||||
() => ({
|
||||
enabled:
|
||||
currentActor.value?.id !== undefined && currentActor.value?.id !== null,
|
||||
})
|
||||
);
|
||||
const memberships = computed(
|
||||
() => membershipsResult.value?.person.memberships?.elements
|
||||
);
|
||||
|
||||
const route = useRoute();
|
||||
const page = useRouteQuery("page", 1, integerTransformer);
|
||||
const showPassedEvents = useRouteQuery(
|
||||
"showPassedEvents",
|
||||
false,
|
||||
booleanTransformer
|
||||
);
|
||||
|
||||
/**
|
||||
* Why is the following hack needed? Page doesn't want to be reactive!
|
||||
* TODO: investigate
|
||||
*/
|
||||
const variables = computed(() => ({
|
||||
name: route.params.preferredUsername as string,
|
||||
beforeDateTime: showPassedEvents.value ? new Date() : null,
|
||||
afterDateTime: showPassedEvents.value ? null : new Date(),
|
||||
order: "BEGINS_ON",
|
||||
orderDirection: showPassedEvents.value ? "DESC" : "ASC",
|
||||
organisedEventsPage: page.value,
|
||||
organisedEventsLimit: EVENTS_PAGE_LIMIT,
|
||||
}));
|
||||
|
||||
watch(
|
||||
variables,
|
||||
(newVariables) => {
|
||||
refetch(newVariables);
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
const {
|
||||
result: groupResult,
|
||||
loading: groupLoading,
|
||||
refetch: refetch,
|
||||
} = useQuery<
|
||||
{
|
||||
group: IGroup;
|
||||
},
|
||||
{
|
||||
name: string;
|
||||
beforeDateTime: Date | null;
|
||||
afterDateTime: Date | null;
|
||||
organisedEventsPage: number;
|
||||
organisedEventsLimit: number;
|
||||
}
|
||||
>(FETCH_GROUP_EVENTS, variables);
|
||||
const group = computed(() => groupResult.value?.group);
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
useHead({
|
||||
title: () =>
|
||||
t("{group} events", {
|
||||
group: displayName(group.value),
|
||||
}),
|
||||
});
|
||||
|
||||
const isCurrentActorMember = computed((): boolean => {
|
||||
if (!group.value || !memberships.value) return false;
|
||||
return (memberships.value ?? [])
|
||||
.map(({ parent: { id } }) => id)
|
||||
.includes(group.value.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?.length > 0 &&
|
||||
roles.includes(memberships.value[0].role)
|
||||
);
|
||||
};
|
||||
</script>
|
||||
523
src/views/Event/MyEventsView.vue
Normal file
523
src/views/Event/MyEventsView.vue
Normal file
@@ -0,0 +1,523 @@
|
||||
<template>
|
||||
<div class="container mx-auto px-1 mb-6">
|
||||
<h1>
|
||||
{{ t("My events") }}
|
||||
</h1>
|
||||
<p>
|
||||
{{
|
||||
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="my-2" v-if="!hideCreateEventButton">
|
||||
<o-button
|
||||
tag="router-link"
|
||||
variant="primary"
|
||||
:to="{ name: RouteName.CREATE_EVENT }"
|
||||
>{{ t("Create event") }}</o-button
|
||||
>
|
||||
</div>
|
||||
<!-- <o-loading v-model:active="$apollo.loading"></o-loading> -->
|
||||
<div class="flex flex-wrap gap-4 items-start">
|
||||
<div
|
||||
class="rounded p-3 flex-auto md:flex-none bg-zinc-300 dark:bg-zinc-700"
|
||||
>
|
||||
<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')
|
||||
"
|
||||
labelFor="events-start-datepicker"
|
||||
>
|
||||
<o-datepicker
|
||||
v-model="dateFilter"
|
||||
:first-day-of-week="firstDayOfWeek"
|
||||
id="events-start-datepicker"
|
||||
/>
|
||||
<o-button
|
||||
@click="dateFilter = new Date()"
|
||||
class="reset-area !h-auto"
|
||||
icon-left="close"
|
||||
:title="t('Clear date filter field')"
|
||||
/>
|
||||
</o-field>
|
||||
</div>
|
||||
<div class="flex-1 min-w-[300px]">
|
||||
<section
|
||||
class="py-4 first:pt-0"
|
||||
v-if="showUpcoming && showDrafts && drafts && drafts.total > 0"
|
||||
>
|
||||
<h2 class="text-2xl mb-2">{{ t("Drafts") }}</h2>
|
||||
<multi-event-minimalist-card
|
||||
:events="drafts.elements"
|
||||
:showOrganizer="true"
|
||||
/>
|
||||
<o-pagination
|
||||
class="mt-4"
|
||||
v-show="drafts.total > LOGGED_USER_DRAFTS_LIMIT"
|
||||
:total="drafts.total"
|
||||
v-model:current="draftsPage"
|
||||
:per-page="LOGGED_USER_DRAFTS_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>
|
||||
</section>
|
||||
<section
|
||||
class="py-4 first:pt-0"
|
||||
v-if="
|
||||
showUpcoming && monthlyFutureEvents && monthlyFutureEvents.size > 0
|
||||
"
|
||||
>
|
||||
<transition-group name="list" tag="p">
|
||||
<div
|
||||
class="mb-5"
|
||||
v-for="month of monthlyFutureEvents"
|
||||
:key="month[0]"
|
||||
>
|
||||
<h2 class="text-2xl">{{ month[0] }}</h2>
|
||||
<div v-for="element in month[1]" :key="element.id">
|
||||
<event-participation-card
|
||||
v-if="'role' in element"
|
||||
:participation="element"
|
||||
@event-deleted="eventDeleted"
|
||||
class="participation"
|
||||
/>
|
||||
<event-minimalist-card
|
||||
v-else-if="
|
||||
element.id &&
|
||||
!monthParticipationsIds(month[1]).includes(element?.id)
|
||||
"
|
||||
:event="element"
|
||||
class="participation"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</transition-group>
|
||||
<div class="columns is-centered">
|
||||
<o-button
|
||||
class="column is-narrow"
|
||||
v-if="
|
||||
hasMoreFutureParticipations &&
|
||||
futureParticipations &&
|
||||
futureParticipations.length === limit
|
||||
"
|
||||
@click="loadMoreFutureParticipations"
|
||||
size="large"
|
||||
variant="primary"
|
||||
>{{ t("Load more") }}</o-button
|
||||
>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
class="text-center not-found"
|
||||
v-if="
|
||||
showUpcoming &&
|
||||
monthlyFutureEvents &&
|
||||
monthlyFutureEvents.size === 0 &&
|
||||
true // !$apollo.loading
|
||||
"
|
||||
>
|
||||
<div class="img-container h-64 prose" />
|
||||
<div class="text-center prose dark:prose-invert max-w-full">
|
||||
<p>
|
||||
{{
|
||||
t(
|
||||
"You don't have any upcoming events. Maybe try another filter?"
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<i18n-t
|
||||
keypath="Do you wish to {create_event} or {explore_events}?"
|
||||
tag="p"
|
||||
>
|
||||
<template #create_event>
|
||||
<router-link :to="{ name: RouteName.CREATE_EVENT }">{{
|
||||
t("create an event")
|
||||
}}</router-link>
|
||||
</template>
|
||||
<template #explore_events>
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.SEARCH,
|
||||
query: { contentType: ContentType.EVENTS },
|
||||
}"
|
||||
>{{ t("explore the events") }}</router-link
|
||||
>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</div>
|
||||
</section>
|
||||
<section v-if="!showUpcoming && pastParticipations.elements.length > 0">
|
||||
<transition-group name="list" tag="p">
|
||||
<div v-for="month in monthlyPastParticipations" :key="month[0]">
|
||||
<h2 class="capitalize inline-block relative">{{ month[0] }}</h2>
|
||||
<event-participation-card
|
||||
v-for="participation in month[1]"
|
||||
:key="participation.id"
|
||||
:participation="participation as IParticipant"
|
||||
:options="{ hideDate: false }"
|
||||
@event-deleted="eventDeleted"
|
||||
class="participation"
|
||||
/>
|
||||
</div>
|
||||
</transition-group>
|
||||
<div class="columns is-centered">
|
||||
<o-button
|
||||
class="column is-narrow"
|
||||
v-if="
|
||||
hasMorePastParticipations &&
|
||||
pastParticipations.elements.length === limit
|
||||
"
|
||||
@click="loadMorePastParticipations"
|
||||
size="large"
|
||||
variant="primary"
|
||||
>{{ t("Load more") }}</o-button
|
||||
>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ParticipantRole, ContentType } from "@/types/enums";
|
||||
import RouteName from "@/router/name";
|
||||
import type { IParticipant } from "../../types/participant.model";
|
||||
import { LOGGED_USER_DRAFTS } from "../../graphql/actor";
|
||||
import type { IEvent } from "../../types/event.model";
|
||||
import MultiEventMinimalistCard from "../../components/Event/MultiEventMinimalistCard.vue";
|
||||
import EventMinimalistCard from "../../components/Event/EventMinimalistCard.vue";
|
||||
import {
|
||||
LOGGED_USER_PARTICIPATIONS,
|
||||
LOGGED_USER_UPCOMING_EVENTS,
|
||||
} from "@/graphql/participant";
|
||||
import { useApolloClient, useQuery } from "@vue/apollo-composable";
|
||||
import { computed, inject, ref, defineAsyncComponent } from "vue";
|
||||
import { IUser } from "@/types/current-user.model";
|
||||
import {
|
||||
booleanTransformer,
|
||||
integerTransformer,
|
||||
useRouteQuery,
|
||||
} from "vue-use-route-query";
|
||||
import { Locale } from "date-fns";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useRestrictions } from "@/composition/apollo/config";
|
||||
import { useHead } from "@vueuse/head";
|
||||
|
||||
const EventParticipationCard = defineAsyncComponent(
|
||||
() => import("@/components/Event/EventParticipationCard.vue")
|
||||
);
|
||||
|
||||
type Eventable = IParticipant | IEvent;
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
const futurePage = ref(1);
|
||||
const pastPage = ref(1);
|
||||
const limit = ref(10);
|
||||
|
||||
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();
|
||||
},
|
||||
toQuery(value: Date) {
|
||||
const pad = (number: number) => {
|
||||
if (number < 10) {
|
||||
return "0" + number;
|
||||
}
|
||||
return number;
|
||||
};
|
||||
return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(
|
||||
value.getDate()
|
||||
)}`;
|
||||
},
|
||||
});
|
||||
|
||||
const hasMoreFutureParticipations = ref(true);
|
||||
const hasMorePastParticipations = ref(true);
|
||||
|
||||
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 LOGGED_USER_DRAFTS_LIMIT = 10;
|
||||
const draftsPage = useRouteQuery("draftsPage", 1, integerTransformer);
|
||||
|
||||
const { result: draftsResult } = useQuery<{
|
||||
loggedUser: Pick<IUser, "drafts">;
|
||||
}>(
|
||||
LOGGED_USER_DRAFTS,
|
||||
() => ({ page: draftsPage.value, limit: LOGGED_USER_DRAFTS_LIMIT }),
|
||||
() => ({ fetchPolicy: "cache-and-network" })
|
||||
);
|
||||
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,
|
||||
}
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
} 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());
|
||||
};
|
||||
|
||||
const monthlyFutureEvents = computed((): Map<string, Eventable[]> => {
|
||||
let eventable = [] as Eventable[];
|
||||
if (showAttending.value) {
|
||||
eventable = [...eventable, ...futureParticipations.value];
|
||||
}
|
||||
if (showMyGroups.value) {
|
||||
eventable = [...eventable, ...groupEvents.value.map(({ event }) => event)];
|
||||
}
|
||||
return monthlyEvents(eventable);
|
||||
});
|
||||
|
||||
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 apollo = useApolloClient();
|
||||
|
||||
const eventDeleted = (eventid: string): void => {
|
||||
/**
|
||||
* Remove event from upcoming event participations
|
||||
*/
|
||||
const upcomingEventsData = apollo.client.cache.readQuery<{
|
||||
loggedUser: IUser;
|
||||
}>({
|
||||
query: LOGGED_USER_UPCOMING_EVENTS,
|
||||
variables: () => ({
|
||||
page: 1,
|
||||
limit: 10,
|
||||
afterDateTime: dateFilter.value,
|
||||
}),
|
||||
});
|
||||
if (!upcomingEventsData) return;
|
||||
const loggedUser = upcomingEventsData?.loggedUser;
|
||||
const participations = loggedUser?.participations;
|
||||
apollo.client.cache.writeQuery<{ loggedUser: IUser }>({
|
||||
query: LOGGED_USER_UPCOMING_EVENTS,
|
||||
variables: () => ({
|
||||
page: 1,
|
||||
limit: 10,
|
||||
afterDateTime: dateFilter.value,
|
||||
}),
|
||||
data: {
|
||||
loggedUser: {
|
||||
...loggedUser,
|
||||
participations: {
|
||||
total: participations.total - 1,
|
||||
elements: participations.elements.filter(
|
||||
(participation) => participation.event.id !== eventid
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Remove event from past event participations
|
||||
*/
|
||||
const participationData = apollo.client.cache.readQuery<{
|
||||
loggedUser: Pick<IUser, "participations">;
|
||||
}>({
|
||||
query: LOGGED_USER_PARTICIPATIONS,
|
||||
variables: () => ({ page: 1, limit: 10 }),
|
||||
});
|
||||
if (!participationData) return;
|
||||
const loggedUser2 = participationData?.loggedUser;
|
||||
const participations2 = loggedUser?.participations;
|
||||
apollo.client.cache.writeQuery<{
|
||||
loggedUser: Pick<IUser, "participations">;
|
||||
}>({
|
||||
query: LOGGED_USER_PARTICIPATIONS,
|
||||
variables: () => ({ page: 1, limit: 10 }),
|
||||
data: {
|
||||
loggedUser: {
|
||||
...loggedUser2,
|
||||
participations: {
|
||||
total: participations2.total - 1,
|
||||
elements: participations2.elements.filter(
|
||||
(participation) => participation.event.id !== eventid
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const { restrictions } = useRestrictions();
|
||||
|
||||
const hideCreateEventButton = computed((): boolean => {
|
||||
return restrictions.value?.onlyGroupsCanCreateEvents === true;
|
||||
});
|
||||
|
||||
const dateFnsLocale = inject<Locale>("dateFnsLocale");
|
||||
|
||||
const firstDayOfWeek = computed((): number => {
|
||||
return dateFnsLocale?.options?.weekStartsOn ?? 0;
|
||||
});
|
||||
|
||||
useHead({
|
||||
title: computed(() => t("My events")),
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.not-found {
|
||||
.img-container {
|
||||
background-image: url("../../../img/pics/event_creation-480w.webp");
|
||||
|
||||
@media (min-resolution: 2dppx) {
|
||||
& {
|
||||
background-image: url("../../../img/pics/event_creation-1024w.webp");
|
||||
}
|
||||
}
|
||||
|
||||
max-width: 450px;
|
||||
height: 300px;
|
||||
box-shadow: 0 0 8px 8px white inset;
|
||||
background-size: cover;
|
||||
border-radius: 10px;
|
||||
margin: auto auto 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
503
src/views/Event/ParticipantsView.vue
Normal file
503
src/views/Event/ParticipantsView.vue
Normal file
@@ -0,0 +1,503 @@
|
||||
<template>
|
||||
<section class="container mx-auto" v-if="event">
|
||||
<breadcrumbs-nav
|
||||
:links="[
|
||||
{ name: RouteName.MY_EVENTS, text: t('My events') },
|
||||
{
|
||||
name: RouteName.EVENT,
|
||||
params: { uuid: event.uuid },
|
||||
text: event.title,
|
||||
},
|
||||
{
|
||||
name: RouteName.PARTICIPATIONS,
|
||||
params: { uuid: event.uuid },
|
||||
text: t('Participants'),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<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="EVERYTHING">
|
||||
{{ t("Everything") }}
|
||||
</option>
|
||||
<option :value="ParticipantRole.CREATOR">
|
||||
{{ t("Organizer") }}
|
||||
</option>
|
||||
<option :value="ParticipantRole.PARTICIPANT">
|
||||
{{ t("Participant") }}
|
||||
</option>
|
||||
<option :value="ParticipantRole.NOT_APPROVED">
|
||||
{{ t("Not approved") }}
|
||||
</option>
|
||||
<option :value="ParticipantRole.REJECTED">
|
||||
{{ t("Rejected") }}
|
||||
</option>
|
||||
</o-select>
|
||||
</o-field>
|
||||
</div>
|
||||
<div class="" v-if="exportFormats.length > 0">
|
||||
<o-dropdown aria-role="list">
|
||||
<template #trigger="{ active }">
|
||||
<o-button
|
||||
:label="t('Export')"
|
||||
variant="primary"
|
||||
:icon-right="active ? 'menu-up' : 'menu-down'"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<o-dropdown-item
|
||||
has-link
|
||||
v-for="format in exportFormats"
|
||||
:key="format"
|
||||
aria-role="listitem"
|
||||
@click="
|
||||
exportParticipants({
|
||||
eventId: event.id ?? '',
|
||||
format,
|
||||
})
|
||||
"
|
||||
@keyup.enter="
|
||||
exportParticipants({
|
||||
eventId: event.id ?? '',
|
||||
format,
|
||||
})
|
||||
"
|
||||
>
|
||||
<button class="dropdown-button">
|
||||
<o-icon :icon="formatToIcon(format)"></o-icon>
|
||||
{{ format }}
|
||||
</button>
|
||||
</o-dropdown-item>
|
||||
</o-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<o-table
|
||||
:data="event.participants.elements"
|
||||
ref="queueTable"
|
||||
detailed
|
||||
detail-key="id"
|
||||
v-model:checked-rows="checkedRows"
|
||||
checkable
|
||||
:is-row-checkable="
|
||||
(row: IParticipant) => row.role !== ParticipantRole.CREATOR
|
||||
"
|
||||
checkbox-position="left"
|
||||
:show-detail-icon="false"
|
||||
: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')"
|
||||
:total="event.participants.total"
|
||||
:per-page="PARTICIPANTS_PER_PAGE"
|
||||
backend-sorting
|
||||
:default-sort-direction="'desc'"
|
||||
:default-sort="['insertedAt', 'desc']"
|
||||
@page-change="(newPage: number) => (page = newPage)"
|
||||
@sort="(field: string, order: string) => emit('sort', field, order)"
|
||||
>
|
||||
<o-table-column
|
||||
field="actor.preferredUsername"
|
||||
:label="t('Participant')"
|
||||
v-slot="props"
|
||||
>
|
||||
<article class="flex gap-2">
|
||||
<figure v-if="props.row.actor.avatar">
|
||||
<img
|
||||
class="rounded-full w-12 h-12 object-cover"
|
||||
:src="props.row.actor.avatar.url"
|
||||
alt=""
|
||||
height="48"
|
||||
width="48"
|
||||
/>
|
||||
</figure>
|
||||
<Incognito
|
||||
v-else-if="props.row.actor.preferredUsername === 'anonymous'"
|
||||
:size="48"
|
||||
/>
|
||||
<AccountCircle v-else :size="48" />
|
||||
<div>
|
||||
<div class="prose dark:prose-invert">
|
||||
<p v-if="props.row.actor.preferredUsername !== 'anonymous'">
|
||||
<span v-if="props.row.actor.name">{{
|
||||
props.row.actor.name
|
||||
}}</span
|
||||
><br />
|
||||
<span class="text-sm"
|
||||
>@{{ usernameWithDomain(props.row.actor) }}</span
|
||||
>
|
||||
</p>
|
||||
<span v-else>
|
||||
{{ t("Anonymous participant") }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</o-table-column>
|
||||
<o-table-column field="role" :label="t('Role')" v-slot="props">
|
||||
<tag
|
||||
variant="primary"
|
||||
v-if="props.row.role === ParticipantRole.CREATOR"
|
||||
>
|
||||
{{ t("Organizer") }}
|
||||
</tag>
|
||||
<tag v-else-if="props.row.role === ParticipantRole.PARTICIPANT">
|
||||
{{ t("Participant") }}
|
||||
</tag>
|
||||
<tag v-else-if="props.row.role === ParticipantRole.NOT_CONFIRMED">
|
||||
{{ t("Not confirmed") }}
|
||||
</tag>
|
||||
<tag
|
||||
variant="warning"
|
||||
v-else-if="props.row.role === ParticipantRole.NOT_APPROVED"
|
||||
>
|
||||
{{ t("Not approved") }}
|
||||
</tag>
|
||||
<tag
|
||||
variant="danger"
|
||||
v-else-if="props.row.role === ParticipantRole.REJECTED"
|
||||
>
|
||||
{{ t("Rejected") }}
|
||||
</tag>
|
||||
</o-table-column>
|
||||
<o-table-column
|
||||
field="metadata.message"
|
||||
class="column-message"
|
||||
:label="t('Message')"
|
||||
v-slot="props"
|
||||
>
|
||||
<div
|
||||
@click="toggleQueueDetails(props.row)"
|
||||
:class="{
|
||||
'ellipsed-message':
|
||||
props.row.metadata.message.length > MESSAGE_ELLIPSIS_LENGTH,
|
||||
}"
|
||||
v-if="props.row.metadata && props.row.metadata.message"
|
||||
>
|
||||
<p v-if="props.row.metadata.message.length > MESSAGE_ELLIPSIS_LENGTH">
|
||||
{{ ellipsize(props.row.metadata.message) }}
|
||||
</p>
|
||||
<p v-else>
|
||||
{{ props.row.metadata.message }}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="button is-text"
|
||||
v-if="props.row.metadata.message.length > MESSAGE_ELLIPSIS_LENGTH"
|
||||
@click.stop="toggleQueueDetails(props.row)"
|
||||
>
|
||||
{{
|
||||
openDetailedRows[props.row.id] ? t("View less") : t("View more")
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
<p v-else class="has-text-grey-dark">
|
||||
{{ t("No message") }}
|
||||
</p>
|
||||
</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>
|
||||
</o-table-column>
|
||||
<template #detail="props">
|
||||
<article v-html="nl2br(props.row.metadata.message)" />
|
||||
</template>
|
||||
<template #empty>
|
||||
<EmptyContent icon="account-circle" :inline="true">
|
||||
{{ t("No participant matches the filters") }}
|
||||
</EmptyContent>
|
||||
</template>
|
||||
<template #bottom-left>
|
||||
<div class="flex gap-2">
|
||||
<o-button
|
||||
@click="acceptParticipants(checkedRows)"
|
||||
variant="success"
|
||||
:disabled="!canAcceptParticipants"
|
||||
>
|
||||
{{
|
||||
t(
|
||||
"No participant to approve|Approve participant|Approve {number} participants",
|
||||
{ number: checkedRows.length },
|
||||
checkedRows.length
|
||||
)
|
||||
}}
|
||||
</o-button>
|
||||
<o-button
|
||||
@click="refuseParticipants(checkedRows)"
|
||||
variant="danger"
|
||||
:disabled="!canRefuseParticipants"
|
||||
>
|
||||
{{
|
||||
t(
|
||||
"No participant to reject|Reject participant|Reject {number} participants",
|
||||
{ number: checkedRows.length },
|
||||
checkedRows.length
|
||||
)
|
||||
}}
|
||||
</o-button>
|
||||
</div>
|
||||
</template>
|
||||
</o-table>
|
||||
<EventConversations :event="event" class="my-6" />
|
||||
<NewPrivateMessage :event="event" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ParticipantRole } from "@/types/enums";
|
||||
import { IParticipant } from "@/types/participant.model";
|
||||
import { IEvent } from "@/types/event.model";
|
||||
import {
|
||||
EXPORT_EVENT_PARTICIPATIONS,
|
||||
PARTICIPANTS,
|
||||
UPDATE_PARTICIPANT,
|
||||
} from "@/graphql/event";
|
||||
import { usernameWithDomain } from "@/types/actor";
|
||||
import { nl2br } from "@/utils/html";
|
||||
import { asyncForEach } from "@/utils/asyncForEach";
|
||||
import RouteName from "@/router/name";
|
||||
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";
|
||||
import Tag from "@/components/TagElement.vue";
|
||||
import { useHead } from "@vueuse/head";
|
||||
import EventConversations from "../../components/Conversations/EventConversations.vue";
|
||||
import NewPrivateMessage from "../../components/Participation/NewPrivateMessage.vue";
|
||||
|
||||
const PARTICIPANTS_PER_PAGE = 10;
|
||||
const MESSAGE_ELLIPSIS_LENGTH = 130;
|
||||
|
||||
type exportFormat = "CSV" | "PDF" | "ODS";
|
||||
|
||||
const props = defineProps<{
|
||||
eventId: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(["sort"]);
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
const { currentActor } = useCurrentActorClient();
|
||||
const participantsExportFormats = useParticipantsExportFormats();
|
||||
|
||||
const ellipsize = (text?: string) =>
|
||||
text && text.substring(0, MESSAGE_ELLIPSIS_LENGTH).concat("…");
|
||||
|
||||
const eventId = computed(() => props.eventId);
|
||||
|
||||
const ParticipantAllRoles = { ...ParticipantRole, EVERYTHING: "EVERYTHING" };
|
||||
|
||||
const page = useRouteQuery("page", 1, integerTransformer);
|
||||
const role = useRouteQuery(
|
||||
"role",
|
||||
"EVERYTHING",
|
||||
enumTransformer(ParticipantAllRoles)
|
||||
);
|
||||
|
||||
const checkedRows = ref<IParticipant[]>([]);
|
||||
|
||||
const queueTable = ref();
|
||||
|
||||
const { result: participantsResult, loading: participantsLoading } = useQuery<{
|
||||
event: IEvent;
|
||||
}>(
|
||||
PARTICIPANTS,
|
||||
() => ({
|
||||
uuid: eventId.value,
|
||||
page: page.value,
|
||||
limit: PARTICIPANTS_PER_PAGE,
|
||||
roles: role.value === "EVERYTHING" ? undefined : 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 = [];
|
||||
};
|
||||
|
||||
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<
|
||||
{ exportEventParticipants: { path: string; format: string } },
|
||||
{ eventId: string; format?: exportFormat; roles?: string[] }
|
||||
>(EXPORT_EVENT_PARTICIPATIONS);
|
||||
|
||||
onExportParticipantsMutationDone(({ data }) => {
|
||||
const path = data?.exportEventParticipants?.path;
|
||||
const format = data?.exportEventParticipants?.format;
|
||||
const link = window.origin + "/exports/" + format?.toLowerCase() + "/" + path;
|
||||
console.debug(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);
|
||||
}
|
||||
});
|
||||
|
||||
const exportFormats = computed((): exportFormat[] => {
|
||||
return (participantsExportFormats ?? []).map(
|
||||
(key) => key.toUpperCase() as exportFormat
|
||||
);
|
||||
});
|
||||
|
||||
const 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
|
||||
*/
|
||||
const canAcceptParticipants = (): boolean => {
|
||||
return checkedRows.value.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
|
||||
*/
|
||||
const canRefuseParticipants = (): boolean => {
|
||||
return checkedRows.value.some(
|
||||
(participant: IParticipant) => participant.role !== ParticipantRole.REJECTED
|
||||
);
|
||||
};
|
||||
|
||||
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 = ref<Record<string, boolean>>({});
|
||||
|
||||
useHead({
|
||||
title: computed(() =>
|
||||
t("Participants to {eventTitle}", { eventTitle: event.value?.title })
|
||||
),
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style lang="scss" scoped>
|
||||
section.container.container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.table {
|
||||
.column-message {
|
||||
vertical-align: middle;
|
||||
}
|
||||
.ellipsed-message {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
p {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
button {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nav.breadcrumb {
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
397
src/views/Group/CreateView.vue
Normal file
397
src/views/Group/CreateView.vue
Normal file
@@ -0,0 +1,397 @@
|
||||
<template>
|
||||
<section class="container mx-auto">
|
||||
<h1>{{ t("Create a new group") }}</h1>
|
||||
|
||||
<o-notification
|
||||
variant="danger"
|
||||
v-for="(value, index) in errors"
|
||||
:key="index"
|
||||
>
|
||||
{{ value }}
|
||||
</o-notification>
|
||||
|
||||
<form @submit.prevent="createGroup">
|
||||
<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"
|
||||
/>
|
||||
</o-field>
|
||||
|
||||
<div class="field">
|
||||
<label class="label" for="group-preferred-username">{{
|
||||
t("Federated Group Name")
|
||||
}}</label>
|
||||
<div class="field-body">
|
||||
<o-field
|
||||
:message="preferredUsernameErrors[0]"
|
||||
:type="preferredUsernameErrors[1]"
|
||||
>
|
||||
<o-input
|
||||
ref="preferredUsernameInput"
|
||||
aria-required="true"
|
||||
required
|
||||
expanded
|
||||
v-model="group.preferredUsername"
|
||||
pattern="[a-z0-9_]+"
|
||||
id="group-preferred-username"
|
||||
:useHtml5Validation="true"
|
||||
:validation-message="
|
||||
group.preferredUsername
|
||||
? t(
|
||||
'Only alphanumeric lowercased characters and underscores are supported.'
|
||||
)
|
||||
: null
|
||||
"
|
||||
/>
|
||||
<p class="control">
|
||||
<span class="button is-static">@{{ host }}</span>
|
||||
</p>
|
||||
</o-field>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<o-field
|
||||
:label="t('Description')"
|
||||
label-for="group-summary"
|
||||
:message="summaryErrors[0]"
|
||||
:type="summaryErrors[1]"
|
||||
>
|
||||
<editor
|
||||
v-if="currentActor"
|
||||
id="group-summary"
|
||||
mode="basic"
|
||||
class="mb-3"
|
||||
v-model="group.summary"
|
||||
:maxSize="500"
|
||||
:aria-label="$t('Group description body')"
|
||||
:current-actor="currentActor"
|
||||
/>
|
||||
</o-field>
|
||||
|
||||
<full-address-auto-complete
|
||||
:label="$t('Group address')"
|
||||
v-model="group.physicalAddress"
|
||||
/>
|
||||
|
||||
<div class="field">
|
||||
<b class="field-label">{{ t("Avatar") }}</b>
|
||||
<picture-upload
|
||||
:textFallback="t('Avatar')"
|
||||
v-model="avatarFile"
|
||||
:maxSize="avatarMaxSize"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<b class="field-label">{{ t("Banner") }}</b>
|
||||
<picture-upload
|
||||
:textFallback="t('Banner')"
|
||||
v-model="bannerFile"
|
||||
:maxSize="bannerMaxSize"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<fieldset>
|
||||
<legend class="field-label !mb-0 mt-2">
|
||||
{{ t("Group visibility") }}
|
||||
</legend>
|
||||
<o-radio
|
||||
v-model="group.visibility"
|
||||
name="groupVisibility"
|
||||
:native-value="GroupVisibility.PUBLIC"
|
||||
>
|
||||
{{ $t("Visible everywhere on the web") }}<br />
|
||||
<small>{{
|
||||
$t(
|
||||
"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>
|
||||
</o-radio>
|
||||
<o-radio
|
||||
v-model="group.visibility"
|
||||
name="groupVisibility"
|
||||
:native-value="GroupVisibility.UNLISTED"
|
||||
>{{ $t("Only accessible through link") }}<br />
|
||||
<small>{{
|
||||
$t(
|
||||
"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>
|
||||
</o-radio>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend class="mt-2">
|
||||
<span class="field-label !mb-0">{{ t("New members") }} </span>
|
||||
<span>
|
||||
{{
|
||||
t(
|
||||
"Members will also access private sections like discussions, resources and restricted posts."
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</legend>
|
||||
<o-field>
|
||||
<o-radio
|
||||
v-model="group.openness"
|
||||
name="groupOpenness"
|
||||
:native-value="Openness.OPEN"
|
||||
>
|
||||
{{ $t("Anyone can join freely") }}<br />
|
||||
<small>{{
|
||||
$t(
|
||||
"Anyone wanting to be a member from your group will be able to from your group page."
|
||||
)
|
||||
}}</small>
|
||||
</o-radio>
|
||||
</o-field>
|
||||
<o-field>
|
||||
<o-radio
|
||||
v-model="group.openness"
|
||||
name="groupOpenness"
|
||||
:native-value="Openness.MODERATED"
|
||||
>{{ $t("Moderate new members") }}<br />
|
||||
<small>{{
|
||||
$t(
|
||||
"Anyone can request being a member, but an administrator needs to approve the membership."
|
||||
)
|
||||
}}</small>
|
||||
</o-radio>
|
||||
</o-field>
|
||||
<o-field>
|
||||
<o-radio
|
||||
v-model="group.openness"
|
||||
name="groupOpenness"
|
||||
:native-value="Openness.INVITE_ONLY"
|
||||
>{{ $t("Manually invite new members") }}<br />
|
||||
<small>{{
|
||||
$t(
|
||||
"The only way for your group to get new members is if an admininistrator invites them."
|
||||
)
|
||||
}}</small>
|
||||
</o-radio>
|
||||
</o-field>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend class="mt-2">
|
||||
<span class="field-label !mb-0">
|
||||
{{ t("Followers") }}
|
||||
</span>
|
||||
<span>
|
||||
{{ t("Followers will receive new public events and posts.") }}
|
||||
</span>
|
||||
</legend>
|
||||
<o-checkbox v-model="group.manuallyApprovesFollowers">
|
||||
{{ t("Manually approve new followers") }}
|
||||
</o-checkbox>
|
||||
</fieldset>
|
||||
|
||||
<o-button variant="primary" native-type="submit" class="mt-3">
|
||||
{{ t("Create my group") }}
|
||||
</o-button>
|
||||
</form>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<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 { ErrorResponse } from "@/types/errors.model";
|
||||
import { ServerParseError } from "@apollo/client/link/http";
|
||||
import { useCurrentActorClient } from "@/composition/apollo/actor";
|
||||
import {
|
||||
computed,
|
||||
defineAsyncComponent,
|
||||
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";
|
||||
import { useHead } from "@vueuse/head";
|
||||
import { Openness, GroupVisibility } from "@/types/enums";
|
||||
import FullAddressAutoComplete from "@/components/Event/FullAddressAutoComplete.vue";
|
||||
|
||||
const Editor = defineAsyncComponent(
|
||||
() => import("@/components/TextEditor.vue")
|
||||
);
|
||||
|
||||
const { currentActor } = useCurrentActorClient();
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
useHead({
|
||||
title: computed(() => t("Create a new group")),
|
||||
});
|
||||
|
||||
const group = ref(new Group());
|
||||
|
||||
const avatarFile = ref<File | null>(null);
|
||||
const bannerFile = ref<File | null>(null);
|
||||
|
||||
const errors = ref<string[]>([]);
|
||||
|
||||
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");
|
||||
|
||||
watch(
|
||||
() => group.value.name,
|
||||
(newGroupName) => {
|
||||
group.value.preferredUsername = convertToUsername(newGroupName);
|
||||
}
|
||||
);
|
||||
|
||||
const buildVariables = computed(() => {
|
||||
let avatarObj = {};
|
||||
let bannerObj = {};
|
||||
|
||||
const cloneGroup = group.value;
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
delete cloneGroup.physicalAddress.__typename;
|
||||
delete cloneGroup.physicalAddress.pictureInfo;
|
||||
|
||||
const groupBasic = {
|
||||
preferredUsername: group.value.preferredUsername,
|
||||
name: group.value.name,
|
||||
summary: group.value.summary,
|
||||
physicalAddress: cloneGroup.physicalAddress,
|
||||
visibility: group.value.visibility,
|
||||
openness: group.value.openness,
|
||||
manuallyApprovesFollowers: group.value.manuallyApprovesFollowers,
|
||||
};
|
||||
|
||||
if (avatarFile.value) {
|
||||
avatarObj = {
|
||||
avatar: {
|
||||
media: {
|
||||
name: avatarFile.value?.name,
|
||||
alt: `${group.value.preferredUsername}'s avatar`,
|
||||
file: avatarFile.value,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (bannerFile.value) {
|
||||
bannerObj = {
|
||||
banner: {
|
||||
media: {
|
||||
name: bannerFile.value?.name,
|
||||
alt: `${group.value.preferredUsername}'s banner`,
|
||||
file: bannerFile.value,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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)) {
|
||||
fieldErrors[error.field] = error.message[0];
|
||||
} else {
|
||||
fieldErrors[error.field] = error.message;
|
||||
}
|
||||
} else {
|
||||
errors.value.push(error.message);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const summaryErrors = computed(() => {
|
||||
const message = fieldErrors.summary ? fieldErrors.summary : undefined;
|
||||
const type = fieldErrors.summary ? "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 ? "danger" : undefined;
|
||||
return [message, type];
|
||||
});
|
||||
|
||||
const { onDone, onError, mutate } = useCreateGroup();
|
||||
|
||||
onDone(() => {
|
||||
notifier?.success(
|
||||
t("Group {displayName} created", {
|
||||
displayName: displayName(group.value),
|
||||
})
|
||||
);
|
||||
|
||||
router.push({
|
||||
name: RouteName.GROUP,
|
||||
params: { preferredUsername: usernameWithDomain(group.value) },
|
||||
});
|
||||
});
|
||||
|
||||
onError((err) => handleError(err as unknown as ErrorResponse));
|
||||
|
||||
const createGroup = async (): Promise<void> => {
|
||||
errors.value = [];
|
||||
fieldErrors.preferred_username = undefined;
|
||||
fieldErrors.summary = undefined;
|
||||
mutate(buildVariables.value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.markdown-render h1 {
|
||||
font-size: 2em;
|
||||
}
|
||||
</style>
|
||||
247
src/views/Group/GroupFollowers.vue
Normal file
247
src/views/Group/GroupFollowers.vue
Normal file
@@ -0,0 +1,247 @@
|
||||
<template>
|
||||
<div>
|
||||
<breadcrumbs-nav
|
||||
v-if="group"
|
||||
:links="[
|
||||
{
|
||||
name: RouteName.GROUP,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
text: displayName(group),
|
||||
},
|
||||
{
|
||||
name: RouteName.GROUP_SETTINGS,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
text: t('Settings'),
|
||||
},
|
||||
{
|
||||
name: RouteName.GROUP_FOLLOWERS_SETTINGS,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
text: t('Followers'),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<o-loading :active="loading" />
|
||||
<section
|
||||
class="container mx-auto section"
|
||||
v-if="group && isCurrentActorAGroupAdmin && followers"
|
||||
>
|
||||
<h1>{{ t("Group Followers") }} ({{ followers.total }})</h1>
|
||||
<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="loading"
|
||||
paginated
|
||||
backend-pagination
|
||||
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')"
|
||||
:total="followers.total"
|
||||
:per-page="FOLLOWERS_PER_PAGE"
|
||||
backend-sorting
|
||||
:default-sort-direction="'desc'"
|
||||
:default-sort="['insertedAt', 'desc']"
|
||||
@page-change="loadMoreFollowers"
|
||||
@sort="(field: any, order: any) => $emit('sort', field, order)"
|
||||
>
|
||||
<o-table-column
|
||||
field="actor.preferredUsername"
|
||||
:label="t('Follower')"
|
||||
v-slot="props"
|
||||
>
|
||||
<article class="flex gap-1">
|
||||
<figure v-if="props.row.actor.avatar">
|
||||
<img
|
||||
class="rounded"
|
||||
:src="props.row.actor.avatar.url"
|
||||
alt=""
|
||||
width="48"
|
||||
height="48"
|
||||
/>
|
||||
</figure>
|
||||
<AccountCircle v-else :size="48" />
|
||||
<div class="">
|
||||
<div class="">
|
||||
<span v-if="props.row.actor.name">{{
|
||||
props.row.actor.name
|
||||
}}</span
|
||||
><br />
|
||||
<span class="">@{{ usernameWithDomain(props.row.actor) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</o-table-column>
|
||||
<o-table-column field="insertedAt" :label="t('Date')" v-slot="props">
|
||||
<span class="has-text-centered">
|
||||
{{ formatDateString(props.row.insertedAt) }}<br />{{
|
||||
formatTimeString(props.row.insertedAt)
|
||||
}}
|
||||
</span>
|
||||
</o-table-column>
|
||||
<o-table-column field="actions" :label="t('Actions')" v-slot="props">
|
||||
<div class="flex gap-2">
|
||||
<o-button
|
||||
v-if="!props.row.approved"
|
||||
@click="updateFollower(props.row, true)"
|
||||
icon-left="check"
|
||||
variant="success"
|
||||
>{{ t("Accept") }}</o-button
|
||||
>
|
||||
<o-button
|
||||
@click="updateFollower(props.row, false)"
|
||||
icon-left="close"
|
||||
variant="danger"
|
||||
>{{ t("Reject") }}</o-button
|
||||
>
|
||||
</div>
|
||||
</o-table-column>
|
||||
<template #empty>
|
||||
<empty-content icon="account" inline>
|
||||
{{ t("No follower matches the filters") }}
|
||||
</empty-content>
|
||||
</template>
|
||||
</o-table>
|
||||
</section>
|
||||
<o-notification v-else-if="!loading && group">
|
||||
{{ t("You are not an administrator for this group.") }}
|
||||
</o-notification>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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 { 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";
|
||||
|
||||
const props = defineProps<{ preferredUsername: string }>();
|
||||
|
||||
const preferredUsername = computed(() => props.preferredUsername);
|
||||
|
||||
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> => {
|
||||
console.debug("load more followers");
|
||||
await fetchMore({
|
||||
// New variables
|
||||
variables: {
|
||||
name: usernameWithDomain(group.value),
|
||||
followersPage: page.value,
|
||||
followersLimit: FOLLOWERS_PER_PAGE,
|
||||
approved: !pending.value,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const notifier = inject<Notifier>("notifier");
|
||||
|
||||
const { onDone, onError, mutate } = useMutation<{ updateFollower: IFollower }>(
|
||||
UPDATE_FOLLOWER,
|
||||
() => ({
|
||||
refetchQueries: [
|
||||
{
|
||||
query: GROUP_FOLLOWERS,
|
||||
variables: {
|
||||
name: usernameWithDomain(group.value),
|
||||
followersPage: page.value,
|
||||
followersLimit: FOLLOWERS_PER_PAGE,
|
||||
approved: !pending.value,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
onDone(({ data }) => {
|
||||
const follower = data?.updateFollower;
|
||||
const message =
|
||||
data?.updateFollower.approved === true
|
||||
? t("{user}'s follow request was accepted", {
|
||||
user: displayName(follower?.actor),
|
||||
})
|
||||
: t("{user}'s follow request was rejected", {
|
||||
user: displayName(follower?.actor),
|
||||
});
|
||||
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(preferredUsername.value);
|
||||
</script>
|
||||
552
src/views/Group/GroupMembers.vue
Normal file
552
src/views/Group/GroupMembers.vue
Normal file
@@ -0,0 +1,552 @@
|
||||
<template>
|
||||
<div>
|
||||
<breadcrumbs-nav
|
||||
v-if="group"
|
||||
:links="[
|
||||
{
|
||||
name: RouteName.GROUP,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
text: displayName(group),
|
||||
},
|
||||
{
|
||||
name: RouteName.GROUP_SETTINGS,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
text: t('Settings'),
|
||||
},
|
||||
{
|
||||
name: RouteName.GROUP_MEMBERS_SETTINGS,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
text: t('Members'),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<o-loading :active="groupMembersLoading" />
|
||||
<section
|
||||
class="container mx-auto section"
|
||||
v-if="group && isCurrentActorAGroupAdmin"
|
||||
>
|
||||
<h1>{{ t("Group Members") }} ({{ group.members.total }})</h1>
|
||||
<form @submit.prevent="inviteMember">
|
||||
<o-field
|
||||
:label="t('Invite a new member')"
|
||||
custom-class="add-relay"
|
||||
label-for="new-member-field"
|
||||
horizontal
|
||||
>
|
||||
<o-field
|
||||
grouped
|
||||
expanded
|
||||
size="large"
|
||||
:type="inviteError ? 'is-danger' : null"
|
||||
:message="inviteError"
|
||||
>
|
||||
<p class="control">
|
||||
<o-input
|
||||
id="new-member-field"
|
||||
v-model="newMemberUsername"
|
||||
:placeholder="t(`Ex: someone{'@'}mobilizon.org`)"
|
||||
/>
|
||||
</p>
|
||||
<p class="control">
|
||||
<o-button variant="primary" native-type="submit">{{
|
||||
t("Invite member")
|
||||
}}</o-button>
|
||||
</p>
|
||||
</o-field>
|
||||
</o-field>
|
||||
</form>
|
||||
<o-field
|
||||
class="my-2"
|
||||
:label="t('Status')"
|
||||
horizontal
|
||||
label-for="group-members-status-filter"
|
||||
>
|
||||
<o-select v-model="roles" id="group-members-status-filter">
|
||||
<option :value="undefined">
|
||||
{{ t("Everything") }}
|
||||
</option>
|
||||
<option :value="MemberRole.ADMINISTRATOR">
|
||||
{{ t("Administrator") }}
|
||||
</option>
|
||||
<option :value="MemberRole.MODERATOR">
|
||||
{{ t("Moderator") }}
|
||||
</option>
|
||||
<option :value="MemberRole.MEMBER">
|
||||
{{ t("Member") }}
|
||||
</option>
|
||||
<option :value="MemberRole.INVITED">
|
||||
{{ t("Invited") }}
|
||||
</option>
|
||||
<option :value="MemberRole.NOT_APPROVED">
|
||||
{{ t("Not approved") }}
|
||||
</option>
|
||||
<option :value="MemberRole.REJECTED">
|
||||
{{ t("Rejected") }}
|
||||
</option>
|
||||
</o-select>
|
||||
</o-field>
|
||||
<o-table
|
||||
v-if="members"
|
||||
:data="members.elements"
|
||||
ref="queueTable"
|
||||
:loading="groupMembersLoading"
|
||||
paginated
|
||||
backend-pagination
|
||||
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')"
|
||||
:total="members.total"
|
||||
:per-page="MEMBERS_PER_PAGE"
|
||||
backend-sorting
|
||||
:default-sort-direction="'desc'"
|
||||
:default-sort="['insertedAt', 'desc']"
|
||||
@page-change="loadMoreMembers"
|
||||
@sort="(field: string, order: string) => emit('sort', field, order)"
|
||||
>
|
||||
<o-table-column
|
||||
field="actor.preferredUsername"
|
||||
:label="t('Member')"
|
||||
v-slot="props"
|
||||
>
|
||||
<article class="flex">
|
||||
<figure v-if="props.row.actor.avatar" class="h-10 w-10">
|
||||
<img
|
||||
class="rounded-full object-cover h-full"
|
||||
:src="props.row.actor.avatar.url"
|
||||
:alt="props.row.actor.avatar.alt || ''"
|
||||
height="48"
|
||||
width="48"
|
||||
/>
|
||||
</figure>
|
||||
<AccountCircle v-else :size="48" />
|
||||
|
||||
<div class="">
|
||||
<div class="text-start">
|
||||
<span v-if="props.row.actor.name">{{
|
||||
props.row.actor.name
|
||||
}}</span
|
||||
><br />
|
||||
<span class="">@{{ usernameWithDomain(props.row.actor) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</o-table-column>
|
||||
<o-table-column field="role" :label="t('Role')" v-slot="props">
|
||||
<tag
|
||||
variant="info"
|
||||
v-if="props.row.role === MemberRole.ADMINISTRATOR"
|
||||
>
|
||||
{{ t("Administrator") }}
|
||||
</tag>
|
||||
<tag
|
||||
variant="info"
|
||||
v-else-if="props.row.role === MemberRole.MODERATOR"
|
||||
>
|
||||
{{ t("Moderator") }}
|
||||
</tag>
|
||||
<tag v-else-if="props.row.role === MemberRole.MEMBER">
|
||||
{{ t("Member") }}
|
||||
</tag>
|
||||
<tag
|
||||
variant="warning"
|
||||
v-else-if="props.row.role === MemberRole.NOT_APPROVED"
|
||||
>
|
||||
{{ t("Not approved") }}
|
||||
</tag>
|
||||
<tag
|
||||
variant="danger"
|
||||
v-else-if="props.row.role === MemberRole.REJECTED"
|
||||
>
|
||||
{{ t("Rejected") }}
|
||||
</tag>
|
||||
<tag
|
||||
variant="warning"
|
||||
v-else-if="props.row.role === MemberRole.INVITED"
|
||||
>
|
||||
{{ t("Invited") }}
|
||||
</tag>
|
||||
</o-table-column>
|
||||
<o-table-column field="insertedAt" :label="t('Date')" v-slot="props">
|
||||
<span class="has-text-centered">
|
||||
{{ formatDateString(props.row.insertedAt) }}<br />{{
|
||||
formatTimeString(props.row.insertedAt)
|
||||
}}
|
||||
</span>
|
||||
</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({ memberId: props.row.id })"
|
||||
icon-left="check"
|
||||
>{{ t("Approve member") }}</o-button
|
||||
>
|
||||
<o-button
|
||||
variant="danger"
|
||||
v-if="props.row.role === MemberRole.NOT_APPROVED"
|
||||
@click="rejectMember(props.row)"
|
||||
icon-left="exit-to-app"
|
||||
>{{ t("Reject member") }}</o-button
|
||||
>
|
||||
<o-button
|
||||
v-if="
|
||||
[MemberRole.MEMBER, MemberRole.MODERATOR].includes(
|
||||
props.row.role
|
||||
)
|
||||
"
|
||||
@click="promoteMember(props.row)"
|
||||
icon-left="chevron-double-up"
|
||||
>{{ t("Promote") }}</o-button
|
||||
>
|
||||
<o-button
|
||||
v-if="
|
||||
[MemberRole.ADMINISTRATOR, MemberRole.MODERATOR].includes(
|
||||
props.row.role
|
||||
)
|
||||
"
|
||||
@click="demoteMember(props.row)"
|
||||
icon-left="chevron-double-down"
|
||||
>{{ t("Demote") }}</o-button
|
||||
>
|
||||
<o-button
|
||||
v-if="props.row.role === MemberRole.MEMBER"
|
||||
@click="removeMember(props.row)"
|
||||
variant="danger"
|
||||
icon-left="exit-to-app"
|
||||
>{{ t("Remove") }}</o-button
|
||||
>
|
||||
</div>
|
||||
</o-table-column>
|
||||
<template #empty>
|
||||
<empty-content icon="account" inline>
|
||||
{{ t("No member matches the filters") }}
|
||||
</empty-content>
|
||||
</template>
|
||||
</o-table>
|
||||
</section>
|
||||
<o-notification v-else-if="!groupMembersLoading && group">
|
||||
{{ t("You are not an administrator for this group.") }}
|
||||
</o-notification>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { MemberRole } from "@/types/enums";
|
||||
import { IMember } from "@/types/actor/member.model";
|
||||
import RouteName from "@/router/name";
|
||||
import {
|
||||
INVITE_MEMBER,
|
||||
GROUP_MEMBERS,
|
||||
REMOVE_MEMBER,
|
||||
UPDATE_MEMBER,
|
||||
APPROVE_MEMBER,
|
||||
} 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 } from "vue";
|
||||
import {
|
||||
enumTransformer,
|
||||
integerTransformer,
|
||||
useRouteQuery,
|
||||
} from "vue-use-route-query";
|
||||
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";
|
||||
import Tag from "@/components/TagElement.vue";
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
useHead({
|
||||
title: computed(() => t("Group members")),
|
||||
});
|
||||
|
||||
const props = defineProps<{ preferredUsername: string }>();
|
||||
const preferredUsername = computed(() => props.preferredUsername);
|
||||
|
||||
const emit = defineEmits(["sort"]);
|
||||
|
||||
const { currentActor } = useCurrentActorClient();
|
||||
|
||||
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: {
|
||||
groupName: props.preferredUsername,
|
||||
page: page.value,
|
||||
limit: MEMBERS_PER_PAGE,
|
||||
roles: roles.value,
|
||||
},
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
onInviteMemberError((error) => {
|
||||
console.error(error);
|
||||
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
|
||||
inviteError.value = error.graphQLErrors[0].message;
|
||||
}
|
||||
});
|
||||
|
||||
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 loadMoreMembers = async (): Promise<void> => {
|
||||
await fetchMoreGroupMembers({
|
||||
// New variables
|
||||
variables() {
|
||||
return {
|
||||
name: usernameWithDomain(group.value),
|
||||
page,
|
||||
limit: MEMBERS_PER_PAGE,
|
||||
roles,
|
||||
};
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const {
|
||||
mutate: mutateRemoveMember,
|
||||
onDone: onRemoveMemberDone,
|
||||
onError: onRemoveMemberError,
|
||||
} = useMutation(REMOVE_MEMBER, () => ({
|
||||
refetchQueries: [
|
||||
{
|
||||
query: GROUP_MEMBERS,
|
||||
variables: {
|
||||
groupName: props.preferredUsername,
|
||||
page: page.value,
|
||||
limit: MEMBERS_PER_PAGE,
|
||||
roles: roles.value,
|
||||
},
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
onRemoveMemberDone(({ context }) => {
|
||||
let message = t("The member was removed from the group {group}", {
|
||||
group: displayName(group.value),
|
||||
}) as string;
|
||||
if (context?.oldMember.role === MemberRole.NOT_APPROVED) {
|
||||
message = t("The membership request from {profile} was rejected", {
|
||||
group: displayName(context?.oldMember.actor),
|
||||
}) as string;
|
||||
}
|
||||
notifier?.success(message);
|
||||
});
|
||||
|
||||
onRemoveMemberError((error) => {
|
||||
console.error(error);
|
||||
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
|
||||
notifier?.error(error.graphQLErrors[0].message);
|
||||
}
|
||||
});
|
||||
|
||||
const removeMember = (oldMember: IMember) => {
|
||||
mutateRemoveMember(
|
||||
{
|
||||
groupId: group.value?.id,
|
||||
memberId: oldMember.id,
|
||||
},
|
||||
{
|
||||
context: { oldMember },
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
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,
|
||||
{
|
||||
refetchQueries: [
|
||||
{
|
||||
query: GROUP_MEMBERS,
|
||||
variables: {
|
||||
groupName: preferredUsername.value,
|
||||
page: page.value,
|
||||
limit: MEMBERS_PER_PAGE,
|
||||
roles: roles.value,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
);
|
||||
|
||||
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: GROUP_MEMBERS,
|
||||
variables: {
|
||||
groupName: props.preferredUsername,
|
||||
page: page.value,
|
||||
limit: MEMBERS_PER_PAGE,
|
||||
roles: roles.value,
|
||||
},
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
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 (context?.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";
|
||||
}
|
||||
notifier?.success(t(successMessage));
|
||||
});
|
||||
|
||||
onUpdateMutationError((error) => {
|
||||
console.error(error);
|
||||
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
|
||||
notifier?.error(error.graphQLErrors[0].message);
|
||||
}
|
||||
});
|
||||
|
||||
const updateMember = async (
|
||||
oldMember: IMember,
|
||||
role: MemberRole
|
||||
): Promise<void> => {
|
||||
updateMemberMutation(
|
||||
{
|
||||
memberId: oldMember.id as string,
|
||||
role,
|
||||
oldRole: oldMember.role,
|
||||
},
|
||||
{ context: { oldMember } }
|
||||
);
|
||||
};
|
||||
|
||||
const isCurrentActorAGroupAdmin = computed((): boolean => {
|
||||
return hasCurrentActorThisRole(MemberRole.ADMINISTRATOR);
|
||||
});
|
||||
|
||||
const hasCurrentActorThisRole = (givenRole: string | string[]): boolean => {
|
||||
const rolesToConsider = Array.isArray(givenRole) ? givenRole : [givenRole];
|
||||
return (
|
||||
personMemberships.value?.total > 0 &&
|
||||
rolesToConsider.includes(personMemberships.value?.elements[0].role)
|
||||
);
|
||||
};
|
||||
|
||||
const personMemberships = computed(
|
||||
() => person.value?.memberships ?? { total: 0, elements: [] }
|
||||
);
|
||||
|
||||
const { person } = usePersonStatusGroup(preferredUsername.value);
|
||||
</script>
|
||||
426
src/views/Group/GroupSettings.vue
Normal file
426
src/views/Group/GroupSettings.vue
Normal file
@@ -0,0 +1,426 @@
|
||||
<template>
|
||||
<div>
|
||||
<breadcrumbs-nav
|
||||
v-if="group"
|
||||
:links="[
|
||||
{
|
||||
name: RouteName.GROUP,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
text: displayName(group),
|
||||
},
|
||||
{
|
||||
name: RouteName.GROUP_SETTINGS,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
text: t('Settings'),
|
||||
},
|
||||
{
|
||||
name: RouteName.GROUP_PUBLIC_SETTINGS,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
text: t('Group settings'),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<o-loading :active="loading" />
|
||||
<section
|
||||
class="container mx-auto mb-6"
|
||||
v-if="group && isCurrentActorAGroupAdmin"
|
||||
>
|
||||
<form @submit.prevent="updateGroup(buildVariables)" v-if="editableGroup">
|
||||
<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')"
|
||||
v-if="currentActor"
|
||||
:currentActor="currentActor"
|
||||
:placeholder="t('A few lines about your group')"
|
||||
/></o-field>
|
||||
<o-field :label="t('Avatar')">
|
||||
<picture-upload
|
||||
:textFallback="t('Avatar')"
|
||||
v-model="avatarFile"
|
||||
:defaultImage="group.avatar"
|
||||
:maxSize="avatarMaxSize"
|
||||
/>
|
||||
</o-field>
|
||||
|
||||
<o-field :label="t('Banner')">
|
||||
<picture-upload
|
||||
:textFallback="t('Banner')"
|
||||
v-model="bannerFile"
|
||||
:defaultImage="group.banner"
|
||||
:maxSize="bannerMaxSize"
|
||||
/>
|
||||
</o-field>
|
||||
<p class="label">{{ t("Group visibility") }}</p>
|
||||
<div class="field">
|
||||
<o-radio
|
||||
v-model="editableGroup.visibility"
|
||||
name="groupVisibility"
|
||||
:native-value="GroupVisibility.PUBLIC"
|
||||
>
|
||||
{{ t("Visible everywhere on the web") }}<br />
|
||||
<small>{{
|
||||
t(
|
||||
"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>
|
||||
</o-radio>
|
||||
</div>
|
||||
<div class="field">
|
||||
<o-radio
|
||||
v-model="editableGroup.visibility"
|
||||
name="groupVisibility"
|
||||
:native-value="GroupVisibility.UNLISTED"
|
||||
>{{ t("Only accessible through link") }}<br />
|
||||
<small>{{
|
||||
t(
|
||||
"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>
|
||||
</o-radio>
|
||||
<p class="pl-6">
|
||||
<code>{{ group.url }}</code>
|
||||
<o-tooltip
|
||||
v-if="canShowCopyButton"
|
||||
:label="t('URL copied to clipboard')"
|
||||
:active="showCopiedTooltip"
|
||||
always
|
||||
variant="success"
|
||||
position="left"
|
||||
>
|
||||
<o-button
|
||||
variant="primary"
|
||||
icon-right="content-paste"
|
||||
native-type="button"
|
||||
@click="copyURL"
|
||||
@keyup.enter="copyURL"
|
||||
/>
|
||||
</o-tooltip>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p class="label">{{ t("New members") }}</p>
|
||||
<div class="field">
|
||||
<o-radio
|
||||
v-model="editableGroup.openness"
|
||||
name="groupOpenness"
|
||||
:native-value="Openness.OPEN"
|
||||
>
|
||||
{{ t("Anyone can join freely") }}<br />
|
||||
<small>{{
|
||||
t(
|
||||
"Anyone wanting to be a member from your group will be able to from your group page."
|
||||
)
|
||||
}}</small>
|
||||
</o-radio>
|
||||
</div>
|
||||
<div class="field">
|
||||
<o-radio
|
||||
v-model="editableGroup.openness"
|
||||
name="groupOpenness"
|
||||
:native-value="Openness.MODERATED"
|
||||
>{{ t("Moderate new members") }}<br />
|
||||
<small>{{
|
||||
t(
|
||||
"Anyone can request being a member, but an administrator needs to approve the membership."
|
||||
)
|
||||
}}</small>
|
||||
</o-radio>
|
||||
</div>
|
||||
<div class="field">
|
||||
<o-radio
|
||||
v-model="editableGroup.openness"
|
||||
name="groupOpenness"
|
||||
:native-value="Openness.INVITE_ONLY"
|
||||
>{{ t("Manually invite new members") }}<br />
|
||||
<small>{{
|
||||
t(
|
||||
"The only way for your group to get new members is if an admininistrator invites them."
|
||||
)
|
||||
}}</small>
|
||||
</o-radio>
|
||||
</div>
|
||||
|
||||
<o-field
|
||||
:label="t('Followers')"
|
||||
:message="t('Followers will receive new public events and posts.')"
|
||||
>
|
||||
<o-checkbox v-model="editableGroup.manuallyApprovesFollowers">
|
||||
{{ t("Manually approve new followers") }}
|
||||
</o-checkbox>
|
||||
</o-field>
|
||||
|
||||
<full-address-auto-complete
|
||||
:label="t('Group address')"
|
||||
v-model="currentAddress"
|
||||
:allowManualDetails="true"
|
||||
:hideMap="true"
|
||||
/>
|
||||
|
||||
<div class="flex flex-wrap gap-2 my-2">
|
||||
<o-button native-type="submit" variant="primary">{{
|
||||
t("Update group")
|
||||
}}</o-button>
|
||||
<o-button @click="confirmDeleteGroup" variant="danger">{{
|
||||
t("Delete group")
|
||||
}}</o-button>
|
||||
</div>
|
||||
</form>
|
||||
<o-notification
|
||||
variant="danger"
|
||||
v-for="(value, index) in errors"
|
||||
:key="index"
|
||||
>
|
||||
{{ value }}
|
||||
</o-notification>
|
||||
</section>
|
||||
<o-notification v-else-if="!loading">
|
||||
{{ t("You are not an administrator for this group.") }}
|
||||
</o-notification>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import PictureUpload from "@/components/PictureUpload.vue";
|
||||
import { GroupVisibility, MemberRole, Openness } from "@/types/enums";
|
||||
import { IGroup, usernameWithDomain, displayName } from "@/types/actor";
|
||||
import { 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, 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";
|
||||
|
||||
const Editor = defineAsyncComponent(
|
||||
() => import("@/components/TextEditor.vue")
|
||||
);
|
||||
|
||||
const FullAddressAutoComplete = defineAsyncComponent(
|
||||
() => import("@/components/Event/FullAddressAutoComplete.vue")
|
||||
);
|
||||
|
||||
const props = defineProps<{ preferredUsername: string }>();
|
||||
const preferredUsername = computed(() => props.preferredUsername);
|
||||
|
||||
const { currentActor } = useCurrentActorClient();
|
||||
|
||||
const { group, loading, onResult: onGroupResult } = useGroup(preferredUsername);
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
useHead({
|
||||
title: computed(() => t("Group settings")),
|
||||
});
|
||||
|
||||
const notifier = inject<Notifier>("notifier");
|
||||
|
||||
const avatarFile = ref<File | null>(null);
|
||||
const bannerFile = ref<File | null>(null);
|
||||
|
||||
const errors = ref<string[]>([]);
|
||||
|
||||
const showCopiedTooltip = ref(false);
|
||||
|
||||
const editableGroup = ref<IGroup>();
|
||||
|
||||
const { onDone, onError, mutate: updateGroup } = useUpdateGroup();
|
||||
|
||||
onDone(() => {
|
||||
notifier?.success(t("Group settings saved"));
|
||||
});
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
onGroupResult(async ({ data }) => {
|
||||
if (!data) return;
|
||||
editableGroup.value = data.group;
|
||||
try {
|
||||
avatarFile.value = await buildFileFromIMedia(editableGroup.value?.avatar);
|
||||
bannerFile.value = await buildFileFromIMedia(editableGroup.value?.banner);
|
||||
} catch (e) {
|
||||
// Catch errors while building media
|
||||
console.error(e);
|
||||
}
|
||||
});
|
||||
|
||||
const buildVariables = computed(() => {
|
||||
let avatarObj = {};
|
||||
let bannerObj = {};
|
||||
const variables = { ...editableGroup.value };
|
||||
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
|
||||
delete variables.__typename;
|
||||
}
|
||||
// eslint-disable-next-line
|
||||
// @ts-ignore
|
||||
if (physicalAddress && physicalAddress.__typename) {
|
||||
// eslint-disable-next-line
|
||||
// @ts-ignore
|
||||
delete physicalAddress.__typename;
|
||||
}
|
||||
delete physicalAddress?.pictureInfo;
|
||||
delete variables.avatar;
|
||||
delete variables.banner;
|
||||
|
||||
if (avatarFile.value) {
|
||||
avatarObj = {
|
||||
avatar: {
|
||||
media: {
|
||||
name: avatarFile.value?.name,
|
||||
alt: `${editableGroup.value?.preferredUsername}'s avatar`,
|
||||
file: avatarFile.value,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (bannerFile.value) {
|
||||
bannerObj = {
|
||||
banner: {
|
||||
media: {
|
||||
name: bannerFile.value?.name,
|
||||
alt: `${editableGroup.value?.preferredUsername}'s banner`,
|
||||
file: bannerFile.value,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
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,
|
||||
};
|
||||
});
|
||||
|
||||
const canShowCopyButton = computed((): boolean => {
|
||||
return window.isSecureContext;
|
||||
});
|
||||
|
||||
const currentAddress = computed({
|
||||
get(): IAddress | null {
|
||||
return editableGroup.value?.physicalAddress ?? null;
|
||||
},
|
||||
set(address: IAddress | null) {
|
||||
if (editableGroup.value && address) {
|
||||
editableGroup.value = {
|
||||
...editableGroup.value,
|
||||
physicalAddress: address,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const avatarMaxSize = useAvatarMaxSize();
|
||||
const bannerMaxSize = useBannerMaxSize();
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
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(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"),
|
||||
variant: "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>
|
||||
1313
src/views/Group/GroupView.vue
Normal file
1313
src/views/Group/GroupView.vue
Normal file
File diff suppressed because it is too large
Load Diff
223
src/views/Group/MyGroups.vue
Normal file
223
src/views/Group/MyGroups.vue
Normal file
@@ -0,0 +1,223 @@
|
||||
<template>
|
||||
<section class="container mx-auto px-1 mb-6">
|
||||
<h1 class="title">{{ t("My groups") }}</h1>
|
||||
<p>
|
||||
{{
|
||||
t(
|
||||
"Groups are spaces for coordination and preparation to better organize events and manage your community."
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<div class="flex my-3" v-if="!hideCreateGroupButton">
|
||||
<o-button
|
||||
tag="router-link"
|
||||
variant="primary"
|
||||
:to="{ name: RouteName.CREATE_GROUP }"
|
||||
>{{ t("Create group") }}</o-button
|
||||
>
|
||||
</div>
|
||||
<o-loading v-model:active="loading"></o-loading>
|
||||
<InvitationsList
|
||||
:invitations="invitations"
|
||||
@accept-invitation="acceptInvitation"
|
||||
@reject-invitation="rejectInvitation"
|
||||
/>
|
||||
<section v-if="memberships && memberships.length > 0">
|
||||
<GroupMemberCard
|
||||
class="group-member-card"
|
||||
v-for="member in memberships"
|
||||
:key="member.id"
|
||||
:member="member"
|
||||
@leave="leaveGroup({ groupId: member.parent.id })"
|
||||
/>
|
||||
<o-pagination
|
||||
:total="membershipsPages.total"
|
||||
v-show="membershipsPages.total > limit"
|
||||
v-model:current="page"
|
||||
:per-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>
|
||||
</section>
|
||||
<section
|
||||
class="text-center not-found"
|
||||
v-if="memberships.length === 0 && !loading"
|
||||
>
|
||||
<div class="">
|
||||
<div class="">
|
||||
<div class="img-container" />
|
||||
<div class="text-center prose dark:prose-invert max-w-full">
|
||||
<p>
|
||||
{{ t("You are not part of any group.") }}
|
||||
<i18n-t
|
||||
keypath="Do you wish to {create_group} or {explore_groups}?"
|
||||
>
|
||||
<template #create_group>
|
||||
<router-link :to="{ name: RouteName.CREATE_GROUP }">{{
|
||||
t("create a group")
|
||||
}}</router-link>
|
||||
</template>
|
||||
<template #explore_groups>
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.SEARCH,
|
||||
query: { contentType: ContentType.GROUPS },
|
||||
}"
|
||||
>{{ t("explore the groups") }}</router-link
|
||||
>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<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 InvitationsList from "@/components/Group/InvitationsList.vue";
|
||||
import { usernameWithDomain } from "@/types/actor";
|
||||
import { IMember } from "@/types/actor/member.model";
|
||||
import { MemberRole, ContentType } from "@/types/enums";
|
||||
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";
|
||||
|
||||
const page = useRouteQuery("page", 1, integerTransformer);
|
||||
const limit = 10;
|
||||
|
||||
const { result: membershipsResult, loading } = useQuery<{
|
||||
loggedUser: Pick<IUser, "memberships">;
|
||||
}>(LOGGED_USER_MEMBERSHIPS, () => ({
|
||||
page: page.value,
|
||||
limit,
|
||||
}));
|
||||
|
||||
const membershipsPages = computed(
|
||||
() =>
|
||||
membershipsResult.value?.loggedUser?.memberships ?? {
|
||||
total: 0,
|
||||
elements: [],
|
||||
}
|
||||
);
|
||||
|
||||
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: {
|
||||
page,
|
||||
limit,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
onLeaveGroupError((error) => {
|
||||
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
|
||||
notifier?.error(error.graphQLErrors[0].message);
|
||||
}
|
||||
});
|
||||
|
||||
const invitations = computed((): IMember[] => {
|
||||
if (!membershipsPages.value) return [];
|
||||
return membershipsPages.value.elements.filter(
|
||||
(member: IMember) => member.role === MemberRole.INVITED
|
||||
);
|
||||
});
|
||||
|
||||
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>
|
||||
.participation {
|
||||
margin: 1rem auto;
|
||||
}
|
||||
|
||||
section {
|
||||
.upcoming-month {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
}
|
||||
|
||||
.group-member-card {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.not-found {
|
||||
.img-container {
|
||||
background-image: url("../../../img/pics/group-480w.webp");
|
||||
|
||||
@media (min-resolution: 2dppx) {
|
||||
& {
|
||||
background-image: url("../../../img/pics/group-1024w.webp");
|
||||
}
|
||||
}
|
||||
|
||||
max-width: 450px;
|
||||
height: 300px;
|
||||
box-shadow: 0 0 8px 8px white inset;
|
||||
background-size: cover;
|
||||
border-radius: 10px;
|
||||
margin: auto auto 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
45
src/views/Group/SettingsView.vue
Normal file
45
src/views/Group/SettingsView.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<div class="container mx-auto">
|
||||
<h1 class="">{{ t("Settings") }}</h1>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<aside class="sm:max-w-xs flex-1 min-w-[320px]">
|
||||
<ul>
|
||||
<SettingMenuSection
|
||||
:title="t('Settings')"
|
||||
:to="{ name: RouteName.GROUP_SETTINGS }"
|
||||
>
|
||||
<SettingMenuItem
|
||||
:title="t('Public')"
|
||||
:to="{ name: RouteName.GROUP_PUBLIC_SETTINGS }"
|
||||
/>
|
||||
<SettingMenuItem
|
||||
:title="t('Members')"
|
||||
:to="{ name: RouteName.GROUP_MEMBERS_SETTINGS }"
|
||||
/>
|
||||
<SettingMenuItem
|
||||
:title="t('Followers')"
|
||||
:to="{ name: RouteName.GROUP_FOLLOWERS_SETTINGS }"
|
||||
/>
|
||||
</SettingMenuSection>
|
||||
</ul>
|
||||
</aside>
|
||||
<div class="flex-1">
|
||||
<router-view />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<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";
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
useHead({
|
||||
title: computed(() => t("Group settings")),
|
||||
});
|
||||
</script>
|
||||
387
src/views/Group/TimelineView.vue
Normal file
387
src/views/Group/TimelineView.vue
Normal file
@@ -0,0 +1,387 @@
|
||||
<template>
|
||||
<div class="container mx-auto section">
|
||||
<breadcrumbs-nav
|
||||
v-if="group"
|
||||
:links="[
|
||||
{
|
||||
name: RouteName.GROUP,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
text: displayName(group),
|
||||
},
|
||||
{
|
||||
name: RouteName.TIMELINE,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
text: t('Activity'),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
|
||||
<section class="timeline">
|
||||
<o-field>
|
||||
<o-radio class="pr-4" v-model="activityType" :native-value="undefined">
|
||||
<TimelineText />
|
||||
{{ t("All activities") }}</o-radio
|
||||
>
|
||||
<o-radio
|
||||
class="pr-4"
|
||||
v-model="activityType"
|
||||
:native-value="ActivityType.MEMBER"
|
||||
>
|
||||
<o-icon icon="account-multiple-plus"></o-icon>
|
||||
{{ t("Members") }}</o-radio
|
||||
>
|
||||
<o-radio
|
||||
class="pr-4"
|
||||
v-model="activityType"
|
||||
:native-value="ActivityType.GROUP"
|
||||
>
|
||||
<o-icon icon="cog"></o-icon>
|
||||
{{ t("Settings") }}</o-radio
|
||||
>
|
||||
<o-radio
|
||||
class="pr-4"
|
||||
v-model="activityType"
|
||||
:native-value="ActivityType.EVENT"
|
||||
>
|
||||
<o-icon icon="calendar"></o-icon>
|
||||
{{ t("Events") }}</o-radio
|
||||
>
|
||||
<o-radio
|
||||
class="pr-4"
|
||||
v-model="activityType"
|
||||
:native-value="ActivityType.POST"
|
||||
>
|
||||
<o-icon icon="bullhorn"></o-icon>
|
||||
{{ t("Posts") }}</o-radio
|
||||
>
|
||||
<o-radio
|
||||
class="pr-4"
|
||||
v-model="activityType"
|
||||
:native-value="ActivityType.DISCUSSION"
|
||||
>
|
||||
<o-icon icon="chat"></o-icon>
|
||||
{{ t("Discussions") }}</o-radio
|
||||
>
|
||||
<o-radio v-model="activityType" :native-value="ActivityType.RESOURCE">
|
||||
<o-icon icon="link"></o-icon>
|
||||
{{ t("Resources") }}</o-radio
|
||||
>
|
||||
</o-field>
|
||||
<o-field>
|
||||
<o-radio
|
||||
class="pr-4"
|
||||
v-model="activityAuthor"
|
||||
:native-value="undefined"
|
||||
>
|
||||
<TimelineText />
|
||||
{{ t("All activities") }}</o-radio
|
||||
>
|
||||
<o-radio
|
||||
class="pr-4"
|
||||
v-model="activityAuthor"
|
||||
:native-value="ActivityAuthorFilter.SELF"
|
||||
>
|
||||
<o-icon icon="account"></o-icon>
|
||||
{{ t("From yourself") }}</o-radio
|
||||
>
|
||||
<o-radio
|
||||
class="pr-4"
|
||||
v-model="activityAuthor"
|
||||
:native-value="ActivityAuthorFilter.BY"
|
||||
>
|
||||
<o-icon icon="account-multiple"></o-icon>
|
||||
{{ t("By others") }}</o-radio
|
||||
>
|
||||
</o-field>
|
||||
<transition-group name="timeline-list" tag="div">
|
||||
<div
|
||||
class="day"
|
||||
v-for="[date, activityItems] in Object.entries(activities)"
|
||||
:key="date"
|
||||
>
|
||||
<o-skeleton
|
||||
v-if="date.search(/skeleton/) !== -1"
|
||||
width="300px"
|
||||
height="48px"
|
||||
/>
|
||||
<h2 v-else-if="isToday(date)">
|
||||
<span v-tooltip="formatDateString(date)">
|
||||
{{ t("Today") }}
|
||||
</span>
|
||||
</h2>
|
||||
<h2 v-else-if="isYesterday(date)">
|
||||
<span v-tooltip="formatDateString(date)">{{ t("Yesterday") }}</span>
|
||||
</h2>
|
||||
<h2 v-else>
|
||||
{{ formatDateString(date) }}
|
||||
</h2>
|
||||
<ul class="before:opacity-10">
|
||||
<li v-for="activityItem in activityItems" :key="activityItem.id">
|
||||
<skeleton-activity-item v-if="activityItem.type === 'skeleton'" />
|
||||
<component
|
||||
v-else
|
||||
:is="component(activityItem.type)"
|
||||
:activity="activityItem"
|
||||
/>
|
||||
</li>
|
||||
</ul></div
|
||||
></transition-group>
|
||||
<empty-content
|
||||
icon="timeline-text"
|
||||
v-if="
|
||||
!loading &&
|
||||
activity.elements.length > 0 &&
|
||||
activity.elements.length >= activity.total
|
||||
"
|
||||
>
|
||||
{{ t("No more activity to display.") }}
|
||||
</empty-content>
|
||||
<empty-content
|
||||
v-if="!loading && activity.total === 0"
|
||||
icon="timeline-text"
|
||||
>
|
||||
{{
|
||||
t(
|
||||
"There is no activity yet. Start doing some things to see activity appear here."
|
||||
)
|
||||
}}
|
||||
</empty-content>
|
||||
<observer @intersect="loadMore" />
|
||||
<o-button
|
||||
v-if="activity.elements.length < activity.total"
|
||||
@click="loadMore"
|
||||
>{{ t("Load more activities") }}</o-button
|
||||
>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<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 { IActivity } from "../../types/activity.model";
|
||||
import Observer from "../../components/Utils/ObserverElement.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;
|
||||
const SKELETON_ITEMS_PER_DAY = 5;
|
||||
type IActivitySkeleton =
|
||||
| IActivity
|
||||
| { skeleton: string; id: string; type: "skeleton" };
|
||||
|
||||
enum ActivityAuthorFilter {
|
||||
SELF = "SELF",
|
||||
BY = "BY",
|
||||
}
|
||||
|
||||
// type ActivityFilter = ActivityType | ActivityAuthorFilter | null;
|
||||
|
||||
const props = defineProps<{ preferredUsername: string }>();
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
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")
|
||||
);
|
||||
|
||||
const activityType = useRouteQuery(
|
||||
"activityType",
|
||||
undefined,
|
||||
enumTransformer(ActivityType)
|
||||
);
|
||||
const activityAuthor = useRouteQuery(
|
||||
"activityAuthor",
|
||||
undefined,
|
||||
enumTransformer(ActivityAuthorFilter)
|
||||
);
|
||||
|
||||
const page = ref(1);
|
||||
|
||||
const {
|
||||
result: groupTimelineResult,
|
||||
fetchMore: fetchMoreActivities,
|
||||
onError: onGroupTLError,
|
||||
loading,
|
||||
} = useQuery<{ group: IGroup }>(GROUP_TIMELINE, () => ({
|
||||
preferredUsername: props.preferredUsername,
|
||||
page: page.value,
|
||||
limit: PAGINATION_LIMIT,
|
||||
type: activityType.value,
|
||||
author: activityAuthor.value,
|
||||
}));
|
||||
|
||||
onGroupTLError((err) => console.error(err));
|
||||
|
||||
const group = computed(() => groupTimelineResult.value?.group);
|
||||
|
||||
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,
|
||||
id: skeleton,
|
||||
type: "skeleton",
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
const skeletons = computed((): string[] => {
|
||||
return [...Array(SKELETON_DAY_ITEMS)]
|
||||
.map((_, i) => {
|
||||
return [...Array(SKELETON_ITEMS_PER_DAY)].map((_a, j) => {
|
||||
return `${i}-${j}`;
|
||||
});
|
||||
})
|
||||
.flat();
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
},
|
||||
{}
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
// };
|
||||
|
||||
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()
|
||||
);
|
||||
};
|
||||
|
||||
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>
|
||||
.timeline {
|
||||
ul {
|
||||
// padding: 0.5rem 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
position: relative;
|
||||
&::before {
|
||||
content: "";
|
||||
height: 100%;
|
||||
width: 1px;
|
||||
background-color: #d9d9d9;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 1rem;
|
||||
}
|
||||
li {
|
||||
display: flex;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
543
src/views/HomeView.vue
Normal file
543
src/views/HomeView.vue
Normal file
@@ -0,0 +1,543 @@
|
||||
<template>
|
||||
<!-- <o-loading v-model:active="$apollo.loading" /> -->
|
||||
<!-- Nice looking SVGs -->
|
||||
<section class="mt-5 sm:mt-24">
|
||||
<div class="-z-10 overflow-hidden">
|
||||
<img
|
||||
alt=""
|
||||
src="../../public/img/shape-1.svg"
|
||||
class="-z-10 absolute left-[2%] top-36"
|
||||
width="300"
|
||||
/>
|
||||
<img
|
||||
alt=""
|
||||
src="../../public/img/shape-2.svg"
|
||||
class="-z-10 absolute left-[50%] top-[5%] -translate-x-2/4 opacity-60"
|
||||
width="800"
|
||||
/>
|
||||
<img
|
||||
alt=""
|
||||
src="../../public/img/shape-3.svg"
|
||||
class="-z-10 absolute top-0 right-36"
|
||||
width="200"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Unlogged introduction -->
|
||||
<unlogged-introduction :config="config" v-if="config && !isLoggedIn" />
|
||||
<!-- Search fields -->
|
||||
<search-fields v-model:search="search" v-model:location="location" />
|
||||
<!-- Categories preview -->
|
||||
<categories-preview />
|
||||
<!-- Welcome back -->
|
||||
<section
|
||||
class="container mx-auto"
|
||||
v-if="currentActor?.id && (welcomeBack || newRegisteredUser)"
|
||||
>
|
||||
<o-notification variant="info" v-if="welcomeBack">{{
|
||||
t("Welcome back {username}!", {
|
||||
username: displayName(currentActor),
|
||||
})
|
||||
}}</o-notification>
|
||||
<o-notification variant="info" v-if="newRegisteredUser">{{
|
||||
t("Welcome to Mobilizon, {username}!", {
|
||||
username: displayName(currentActor),
|
||||
})
|
||||
}}</o-notification>
|
||||
</section>
|
||||
<!-- Your upcoming events -->
|
||||
<section v-if="canShowMyUpcomingEvents" class="container mx-auto">
|
||||
<h2 class="dark:text-white 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])">{{
|
||||
t(
|
||||
"You have one event today.",
|
||||
{
|
||||
count: row[1].size,
|
||||
},
|
||||
row[1].size
|
||||
)
|
||||
}}</span>
|
||||
<span v-else-if="isTomorrow(row[0])">{{
|
||||
t(
|
||||
"You have one event tomorrow.",
|
||||
{
|
||||
count: row[1].size,
|
||||
},
|
||||
row[1].size
|
||||
)
|
||||
}}</span>
|
||||
<span v-else-if="isInLessThanSevenDays(row[0])">
|
||||
{{
|
||||
t(
|
||||
"You have one event in {days} days.",
|
||||
{
|
||||
count: row[1].size,
|
||||
days: calculateDiffDays(row[0]),
|
||||
},
|
||||
row[1].size
|
||||
)
|
||||
}}
|
||||
</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>
|
||||
<!-- Events from your followed groups -->
|
||||
<section
|
||||
class="relative pt-10 px-2 container mx-auto px-2"
|
||||
v-if="canShowFollowedGroupEvents"
|
||||
>
|
||||
<h2
|
||||
class="text-xl font-bold tracking-tight text-gray-900 dark:text-gray-100 mt-0"
|
||||
>
|
||||
{{ 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>
|
||||
<!-- Recent events -->
|
||||
<CloseEvents
|
||||
@doGeoLoc="performGeoLocation()"
|
||||
:userLocation="userLocation"
|
||||
:doingGeoloc="doingGeoloc"
|
||||
/>
|
||||
<CloseGroups :userLocation="userLocation" @doGeoLoc="performGeoLocation()" />
|
||||
<OnlineEvents />
|
||||
<LastEvents v-if="instanceName" :instanceName="instanceName" />
|
||||
<!-- Unlogged content section -->
|
||||
<picture v-if="!currentUser?.isLoggedIn">
|
||||
<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.webp`"
|
||||
width="3840"
|
||||
height="2719"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
/>
|
||||
</picture>
|
||||
<presentation v-if="!currentUser?.isLoggedIn" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ParticipantRole } from "@/types/enums";
|
||||
import { IParticipant } from "../types/participant.model";
|
||||
import MultiCard from "../components/Event/MultiCard.vue";
|
||||
import { CURRENT_ACTOR_CLIENT } from "../graphql/actor";
|
||||
import { IPerson, displayName } from "../types/actor";
|
||||
import { ICurrentUser, IUser } 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 { IFollowedGroupEvent } from "../types/followedGroupEvent.model";
|
||||
import CloseEvents from "@/components/Local/CloseEvents.vue";
|
||||
import CloseGroups from "@/components/Local/CloseGroups.vue";
|
||||
import LastEvents from "@/components/Local/LastEvents.vue";
|
||||
import OnlineEvents from "@/components/Local/OnlineEvents.vue";
|
||||
import {
|
||||
computed,
|
||||
onMounted,
|
||||
reactive,
|
||||
ref,
|
||||
watch,
|
||||
defineAsyncComponent,
|
||||
} 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";
|
||||
import { geoHashToCoords } from "@/utils/location";
|
||||
import { useServerProvidedLocation } from "@/composition/apollo/config";
|
||||
import { ABOUT } from "@/graphql/config";
|
||||
import { IConfig } from "@/types/config.model";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
const EventParticipationCard = defineAsyncComponent(
|
||||
() => import("@/components/Event/EventParticipationCard.vue")
|
||||
);
|
||||
|
||||
const { result: aboutConfigResult } = useQuery<{
|
||||
config: Pick<
|
||||
IConfig,
|
||||
"name" | "description" | "slogan" | "registrationsOpen"
|
||||
>;
|
||||
}>(ABOUT);
|
||||
|
||||
const config = computed(() => aboutConfigResult.value?.config);
|
||||
|
||||
const { result: currentActorResult } = useQuery<{ currentActor: IPerson }>(
|
||||
CURRENT_ACTOR_CLIENT
|
||||
);
|
||||
const currentActor = computed<IPerson | undefined>(
|
||||
() => currentActorResult.value?.currentActor
|
||||
);
|
||||
|
||||
const { result: currentUserResult } = useQuery<{
|
||||
currentUser: ICurrentUser;
|
||||
}>(CURRENT_USER_CLIENT);
|
||||
|
||||
const currentUser = computed(() => currentUserResult.value?.currentUser);
|
||||
|
||||
const instanceName = computed(() => config.value?.name);
|
||||
|
||||
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 currentUserParticipations = computed(
|
||||
() => loggedUser.value?.participations.elements
|
||||
);
|
||||
|
||||
const location = ref(null);
|
||||
const search = ref("");
|
||||
|
||||
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 canShowMyUpcomingEvents = computed<boolean>(() => {
|
||||
return currentActor.value?.id != undefined && goingToEvents.value.size > 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);
|
||||
});
|
||||
|
||||
const welcomeBack = ref(false);
|
||||
const newRegisteredUser = ref(false);
|
||||
|
||||
onMounted(() => {
|
||||
if (window.localStorage.getItem("welcome-back")) {
|
||||
welcomeBack.value = true;
|
||||
window.localStorage.removeItem("welcome-back");
|
||||
}
|
||||
if (window.localStorage.getItem("new-registered-user")) {
|
||||
newRegisteredUser.value = true;
|
||||
window.localStorage.removeItem("new-registered-user");
|
||||
}
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
watch(loggedUser, (loggedUserValue) => {
|
||||
if (
|
||||
loggedUserValue?.id &&
|
||||
loggedUserValue?.settings === null &&
|
||||
loggedUserValue.defaultActor?.id
|
||||
) {
|
||||
console.info("No user settings, going to onboarding", loggedUserValue);
|
||||
router.push({
|
||||
name: RouteName.WELCOME_SCREEN,
|
||||
params: { step: "1" },
|
||||
});
|
||||
}
|
||||
});
|
||||
const isLoggedIn = computed(() => loggedUser.value?.id !== undefined);
|
||||
|
||||
/**
|
||||
* Geolocation stuff
|
||||
*/
|
||||
|
||||
// The location hash saved in the user settings (should be the default)
|
||||
const userSettingsLocationGeoHash = computed(
|
||||
() => loggedUser.value?.settings?.location?.geohash
|
||||
);
|
||||
|
||||
// The location provided by the server
|
||||
const { location: serverLocation } = useServerProvidedLocation();
|
||||
|
||||
// The coords from the user location or the server provided location
|
||||
const coords = computed(() => {
|
||||
const userSettingsCoords = geoHashToCoords(
|
||||
userSettingsLocationGeoHash.value ?? undefined
|
||||
);
|
||||
|
||||
if (userSettingsCoords) {
|
||||
return { ...userSettingsCoords, isIPLocation: false };
|
||||
}
|
||||
|
||||
return { ...serverLocation.value, isIPLocation: true };
|
||||
});
|
||||
|
||||
const { result: reverseGeocodeResult } = useQuery<{
|
||||
reverseGeocode: IAddress[];
|
||||
}>(REVERSE_GEOCODE, coords, () => ({
|
||||
enabled: coords.value?.longitude != undefined,
|
||||
}));
|
||||
|
||||
const userSettingsLocation = computed(() => {
|
||||
const address = reverseGeocodeResult.value?.reverseGeocode[0];
|
||||
const placeName = address?.locality ?? address?.region ?? address?.country;
|
||||
return {
|
||||
lat: coords.value?.latitude,
|
||||
lon: coords.value?.longitude,
|
||||
name: placeName,
|
||||
picture: address?.pictureInfo,
|
||||
isIPLocation: coords.value?.isIPLocation,
|
||||
};
|
||||
});
|
||||
|
||||
const { result: currentUserLocationResult } = useQuery<{
|
||||
currentUserLocation: LocationType;
|
||||
}>(CURRENT_USER_LOCATION_CLIENT);
|
||||
|
||||
// The user's location currently in the Apollo cache
|
||||
const currentUserLocation = computed(() => {
|
||||
return {
|
||||
...(currentUserLocationResult.value?.currentUserLocation ?? {
|
||||
lat: undefined,
|
||||
lon: undefined,
|
||||
accuracy: undefined,
|
||||
isIPLocation: undefined,
|
||||
name: undefined,
|
||||
picture: undefined,
|
||||
}),
|
||||
isIPLocation: false,
|
||||
};
|
||||
});
|
||||
|
||||
const userLocation = computed(() => {
|
||||
if (
|
||||
!userSettingsLocation.value ||
|
||||
(userSettingsLocation.value?.isIPLocation &&
|
||||
currentUserLocation.value?.name)
|
||||
) {
|
||||
return currentUserLocation.value;
|
||||
}
|
||||
return userSettingsLocation.value;
|
||||
});
|
||||
|
||||
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];
|
||||
const placeName =
|
||||
geoLocationInformation.locality ??
|
||||
geoLocationInformation.region ??
|
||||
geoLocationInformation.country;
|
||||
|
||||
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 },
|
||||
}: // eslint-disable-next-line no-undef
|
||||
GeolocationPosition) => {
|
||||
reverseGeoCodeInformation.latitude = latitude;
|
||||
reverseGeoCodeInformation.longitude = longitude;
|
||||
reverseGeoCodeInformation.accuracy = accuracy;
|
||||
doingGeoloc.value = false;
|
||||
};
|
||||
|
||||
const doingGeoloc = ref(false);
|
||||
|
||||
const performGeoLocation = () => {
|
||||
doingGeoloc.value = true;
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
fetchAndSaveCurrentLocationName,
|
||||
() => {
|
||||
doingGeoloc.value = false;
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* View Head
|
||||
*/
|
||||
useHead({
|
||||
title: computed(() => instanceName.value ?? ""),
|
||||
});
|
||||
</script>
|
||||
106
src/views/InteractView.vue
Normal file
106
src/views/InteractView.vue
Normal file
@@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<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="!loading && errors.length > 0"
|
||||
>
|
||||
<p v-for="error in errors" :key="error">
|
||||
<b>{{ error }}</b>
|
||||
</p>
|
||||
<p>
|
||||
{{
|
||||
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>
|
||||
</o-notification>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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 { useRouter } from "vue-router";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useRouteQuery } from "vue-use-route-query";
|
||||
import { useHead } from "@vueuse/head";
|
||||
|
||||
const router = useRouter();
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
const uri = useRouteQuery("uri", "");
|
||||
|
||||
const isURI = computed((): boolean => {
|
||||
try {
|
||||
const url = new URL(uri.value);
|
||||
return url instanceof URL;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
const errors = reactive<string[]>([]);
|
||||
|
||||
const { onResult, onError, loading } = useQuery<{
|
||||
interact: (IEvent | IGroup) & { __typename: string };
|
||||
}>(
|
||||
INTERACT,
|
||||
() => ({
|
||||
uri: uri.value,
|
||||
}),
|
||||
() => ({
|
||||
enabled: isURI.value === true,
|
||||
})
|
||||
);
|
||||
|
||||
onResult(async (result) => {
|
||||
if (result.loading) return;
|
||||
if (!result.data) {
|
||||
errors.push(t("This URL is not supported"));
|
||||
return;
|
||||
}
|
||||
const interact = result.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>
|
||||
482
src/views/Moderation/LogsView.vue
Normal file
482
src/views/Moderation/LogsView.vue
Normal file
@@ -0,0 +1,482 @@
|
||||
<template>
|
||||
<div>
|
||||
<breadcrumbs-nav
|
||||
:links="[
|
||||
{
|
||||
name: RouteName.MODERATION,
|
||||
text: $t('Moderation'),
|
||||
},
|
||||
{
|
||||
name: RouteName.REPORT_LOGS,
|
||||
text: $t('Moderation log'),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<section v-if="actionLogs.total > 0 && actionLogs.elements.length > 0">
|
||||
<ul>
|
||||
<li
|
||||
v-for="log in actionLogs.elements"
|
||||
:key="log.id"
|
||||
class="bg-mbz-yellow-alt-50 hover:bg-mbz-yellow-alt-100 dark:bg-zinc-700 hover:dark:bg-zinc-600 rounded p-2 my-1"
|
||||
>
|
||||
<div class="flex gap-1">
|
||||
<div class="flex gap-1">
|
||||
<figure class="h-10 w-10" v-if="log.actor?.avatar">
|
||||
<img
|
||||
alt=""
|
||||
:src="log.actor.avatar?.url"
|
||||
class="object-cover rounded-full h-full w-full"
|
||||
width="36"
|
||||
height="36"
|
||||
/>
|
||||
</figure>
|
||||
<AccountCircle v-else :size="36" />
|
||||
</div>
|
||||
<div>
|
||||
<i18n-t
|
||||
v-if="log.action === ActionLogAction.REPORT_UPDATE_CLOSED"
|
||||
tag="span"
|
||||
keypath="{moderator} closed {report}"
|
||||
>
|
||||
<template #moderator>
|
||||
<router-link
|
||||
class="underline"
|
||||
:to="{
|
||||
name: RouteName.ADMIN_PROFILE,
|
||||
params: { id: log.actor.id },
|
||||
}"
|
||||
>{{ displayName(log.actor) }}</router-link
|
||||
>
|
||||
</template>
|
||||
<template #report>
|
||||
<router-link
|
||||
class="underline"
|
||||
: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"
|
||||
keypath="{moderator} reopened {report}"
|
||||
>
|
||||
<template #moderator>
|
||||
<router-link
|
||||
class="underline"
|
||||
:to="{
|
||||
name: RouteName.ADMIN_PROFILE,
|
||||
params: { id: log.actor.id },
|
||||
}"
|
||||
>{{ displayName(log.actor) }}</router-link
|
||||
>
|
||||
</template>
|
||||
<template #report>
|
||||
<router-link
|
||||
class="underline"
|
||||
: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"
|
||||
keypath="{moderator} marked {report} as resolved"
|
||||
>
|
||||
<template #moderator>
|
||||
<router-link
|
||||
class="underline"
|
||||
:to="{
|
||||
name: RouteName.ADMIN_PROFILE,
|
||||
params: { id: log.actor.id },
|
||||
}"
|
||||
>{{ displayName(log.actor) }}</router-link
|
||||
>
|
||||
</template>
|
||||
<template #report>
|
||||
<router-link
|
||||
class="underline"
|
||||
: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"
|
||||
keypath="{moderator} added a note on {report}"
|
||||
>
|
||||
<template #moderator>
|
||||
<router-link
|
||||
class="underline"
|
||||
:to="{
|
||||
name: RouteName.ADMIN_PROFILE,
|
||||
params: { id: log.actor.id },
|
||||
}"
|
||||
>{{ displayName(log.actor) }}</router-link
|
||||
>
|
||||
</template>
|
||||
<template #report>
|
||||
<router-link
|
||||
class="underline"
|
||||
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"
|
||||
keypath='{moderator} deleted an event named "{title}"'
|
||||
>
|
||||
<template #moderator>
|
||||
<router-link
|
||||
class="underline"
|
||||
:to="{
|
||||
name: RouteName.ADMIN_PROFILE,
|
||||
params: { id: log.actor.id },
|
||||
}"
|
||||
>{{ displayName(log.actor) }}</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"
|
||||
keypath="{moderator} suspended profile {profile}"
|
||||
>
|
||||
<template #moderator>
|
||||
<router-link
|
||||
class="underline"
|
||||
:to="{
|
||||
name: RouteName.ADMIN_PROFILE,
|
||||
params: { id: log.actor.id },
|
||||
}"
|
||||
>{{ displayName(log.actor) }}</router-link
|
||||
>
|
||||
</template>
|
||||
<template #profile>
|
||||
<router-link
|
||||
class="underline"
|
||||
: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"
|
||||
keypath="{moderator} has unsuspended profile {profile}"
|
||||
>
|
||||
<template #moderator>
|
||||
<router-link
|
||||
class="underline"
|
||||
:to="{
|
||||
name: RouteName.ADMIN_PROFILE,
|
||||
params: { id: log.actor.id },
|
||||
}"
|
||||
>{{ displayName(log.actor) }}</router-link
|
||||
>
|
||||
</template>
|
||||
<template #profile>
|
||||
<router-link
|
||||
class="underline"
|
||||
: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'
|
||||
"
|
||||
tag="span"
|
||||
keypath="{moderator} suspended group {profile}"
|
||||
>
|
||||
<template #moderator>
|
||||
<router-link
|
||||
class="underline"
|
||||
:to="{
|
||||
name: RouteName.ADMIN_PROFILE,
|
||||
params: { id: log.actor.id },
|
||||
}"
|
||||
>{{ displayName(log.actor) }}</router-link
|
||||
>
|
||||
</template>
|
||||
<template #profile>
|
||||
<router-link
|
||||
class="underline"
|
||||
: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"
|
||||
keypath="{moderator} has unsuspended group {profile}"
|
||||
>
|
||||
<template #moderator>
|
||||
<router-link
|
||||
class="underline"
|
||||
:to="{
|
||||
name: RouteName.ADMIN_PROFILE,
|
||||
params: { id: log.actor.id },
|
||||
}"
|
||||
>{{ displayName(log.actor) }}</router-link
|
||||
>
|
||||
</template>
|
||||
<template #profile>
|
||||
<router-link
|
||||
class="underline"
|
||||
: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"
|
||||
keypath="{moderator} has deleted user {user}"
|
||||
>
|
||||
<template #moderator>
|
||||
<router-link
|
||||
class="underline"
|
||||
:to="{
|
||||
name: RouteName.ADMIN_PROFILE,
|
||||
params: { id: log.actor.id },
|
||||
}"
|
||||
>{{ displayName(log.actor) }}</router-link
|
||||
>
|
||||
</template>
|
||||
<template #user>
|
||||
<router-link
|
||||
class="underline"
|
||||
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-t
|
||||
tag="span"
|
||||
keypath="{moderator} has deleted a comment from {author} under the event {event}"
|
||||
>
|
||||
<template #moderator>
|
||||
<router-link
|
||||
class="underline"
|
||||
:to="{
|
||||
name: RouteName.ADMIN_PROFILE,
|
||||
params: { id: log.actor.id },
|
||||
}"
|
||||
>{{ displayName(log.actor) }}</router-link
|
||||
>
|
||||
</template>
|
||||
<template #event>
|
||||
<router-link
|
||||
class="underline"
|
||||
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
|
||||
class="underline"
|
||||
: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-t
|
||||
tag="span"
|
||||
keypath="{moderator} has deleted a comment from {author}"
|
||||
>
|
||||
<template #moderator>
|
||||
<router-link
|
||||
class="underline"
|
||||
:to="{
|
||||
name: RouteName.ADMIN_PROFILE,
|
||||
params: { id: log.actor.id },
|
||||
}"
|
||||
>{{ displayName(log.actor) }}</router-link
|
||||
>
|
||||
</template>
|
||||
<template #author>
|
||||
<router-link
|
||||
class="underline"
|
||||
: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
|
||||
class="underline"
|
||||
:to="{
|
||||
name: RouteName.ADMIN_PROFILE,
|
||||
params: { id: log.actor.id },
|
||||
}"
|
||||
>{{ displayName(log.actor) }}</router-link
|
||||
>
|
||||
</template>
|
||||
</i18n-t>
|
||||
<br />
|
||||
<small>{{ formatDateTimeString(log.insertedAt) }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<o-pagination
|
||||
:total="actionLogs.total"
|
||||
v-model:current="page"
|
||||
:per-page="LOGS_PER_PAGE"
|
||||
: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>
|
||||
</section>
|
||||
<div v-else>
|
||||
<o-notification variant="info">{{
|
||||
$t("No moderation logs yet")
|
||||
}}</o-notification>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { IActionLog } from "@/types/report.model";
|
||||
import { LOGS } from "@/graphql/report";
|
||||
import { ActionLogAction } from "@/types/enums";
|
||||
import RouteName from "../../router/name";
|
||||
import { displayNameAndUsername, displayName } 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";
|
||||
import AccountCircle from "vue-material-design-icons/AccountCircle.vue";
|
||||
|
||||
const LOGS_PER_PAGE = 10;
|
||||
|
||||
const page = useRouteQuery("page", 1, integerTransformer);
|
||||
|
||||
const { result: actionLogsResult } = useQuery<{
|
||||
actionLogs: Paginate<IActionLog>;
|
||||
}>(LOGS, () => ({
|
||||
page: page.value,
|
||||
limit: LOGS_PER_PAGE,
|
||||
}));
|
||||
|
||||
const actionLogs = computed(
|
||||
() => actionLogsResult.value?.actionLogs ?? { total: 0, elements: [] }
|
||||
);
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
useHead({
|
||||
title: computed(() => t("Moderation logs")),
|
||||
});
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
img.image {
|
||||
display: inline-block;
|
||||
height: 1.5em;
|
||||
vertical-align: text-bottom;
|
||||
}
|
||||
|
||||
section ul li {
|
||||
margin: 0.5rem auto;
|
||||
}
|
||||
</style>
|
||||
160
src/views/Moderation/ReportListView.vue
Normal file
160
src/views/Moderation/ReportListView.vue
Normal file
@@ -0,0 +1,160 @@
|
||||
<template>
|
||||
<div>
|
||||
<breadcrumbs-nav
|
||||
:links="[
|
||||
{
|
||||
name: RouteName.MODERATION,
|
||||
text: t('Moderation'),
|
||||
},
|
||||
{
|
||||
name: RouteName.REPORTS,
|
||||
text: t('Reports'),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<section>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<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"
|
||||
>
|
||||
<o-input
|
||||
id="domain-filter"
|
||||
:placeholder="t('mobilizon-instance.tld')"
|
||||
:value="filterDomain"
|
||||
@input="debouncedUpdateDomainFilter"
|
||||
/>
|
||||
</o-field>
|
||||
</div>
|
||||
<ul v-if="reports.elements.length > 0">
|
||||
<li v-for="report in reports.elements" :key="report.id">
|
||||
<router-link
|
||||
:to="{ name: RouteName.REPORT, params: { reportId: report.id } }"
|
||||
>
|
||||
<report-card :report="report" />
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else class="no-reports">
|
||||
<empty-content
|
||||
icon="chat-alert"
|
||||
inline
|
||||
v-if="status === ReportStatusEnum.OPEN"
|
||||
>
|
||||
{{ t("No open reports yet") }}
|
||||
</empty-content>
|
||||
<empty-content
|
||||
icon="chat-alert"
|
||||
inline
|
||||
v-if="status === ReportStatusEnum.RESOLVED"
|
||||
>
|
||||
{{ t("No resolved reports yet") }}
|
||||
</empty-content>
|
||||
<empty-content
|
||||
icon="chat-alert"
|
||||
inline
|
||||
v-if="status === ReportStatusEnum.CLOSED"
|
||||
>
|
||||
{{ t("No closed reports yet") }}
|
||||
</empty-content>
|
||||
</div>
|
||||
<o-pagination
|
||||
:total="reports.total"
|
||||
v-model:current="page"
|
||||
:simple="true"
|
||||
:per-page="REPORT_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>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<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 { Paginate } from "@/types/paginate";
|
||||
import debounce from "lodash/debounce";
|
||||
import { useQuery } from "@vue/apollo-composable";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useHead } from "@vueuse/head";
|
||||
import { computed } 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)
|
||||
);
|
||||
|
||||
const { result: reportsResult } = useQuery<{ reports: Paginate<IReport> }>(
|
||||
REPORTS,
|
||||
() => ({
|
||||
page: page.value,
|
||||
status: status.value,
|
||||
limit: REPORT_PAGE_LIMIT,
|
||||
domain: filterDomain.value,
|
||||
}),
|
||||
{
|
||||
fetchPolicy: "cache-and-network",
|
||||
}
|
||||
);
|
||||
|
||||
const reports = computed(
|
||||
() => reportsResult.value?.reports ?? { elements: [], total: 0 }
|
||||
);
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
useHead({
|
||||
title: computed(() => t("Reports")),
|
||||
});
|
||||
|
||||
// const filterReports = ref<ReportStatusEnum>(ReportStatusEnum.OPEN);
|
||||
|
||||
const updateDomainFilter = (event: InputEvent) => {
|
||||
filterDomain.value = event.target?.value;
|
||||
};
|
||||
|
||||
const debouncedUpdateDomainFilter = debounce(updateDomainFilter, 500);
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
section {
|
||||
.no-reports {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
& > ul li {
|
||||
margin: 0.5rem auto;
|
||||
|
||||
& > a {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
868
src/views/Moderation/ReportView.vue
Normal file
868
src/views/Moderation/ReportView.vue
Normal file
@@ -0,0 +1,868 @@
|
||||
<template>
|
||||
<breadcrumbs-nav
|
||||
v-if="report"
|
||||
:links="[
|
||||
{
|
||||
name: RouteName.MODERATION,
|
||||
text: t('Moderation'),
|
||||
},
|
||||
{
|
||||
name: RouteName.REPORTS,
|
||||
text: t('Reports'),
|
||||
},
|
||||
{
|
||||
name: RouteName.REPORT,
|
||||
params: { id: report.id },
|
||||
text: t('Report #{reportNumber}', { reportNumber: report.id }),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<o-notification
|
||||
title="Error"
|
||||
variant="danger"
|
||||
v-for="error in errors"
|
||||
:key="error"
|
||||
>
|
||||
{{ error }}
|
||||
</o-notification>
|
||||
<div class="container mx-auto" v-if="report">
|
||||
<div class="flex flex-wrap gap-2 my-2">
|
||||
<o-button
|
||||
v-if="report.status !== ReportStatusEnum.RESOLVED"
|
||||
@click="updateReport(ReportStatusEnum.RESOLVED)"
|
||||
variant="primary"
|
||||
>{{ t("Mark as resolved") }}</o-button
|
||||
>
|
||||
<o-button
|
||||
v-if="report.status !== ReportStatusEnum.OPEN"
|
||||
@click="updateReport(ReportStatusEnum.OPEN)"
|
||||
variant="success"
|
||||
>{{ t("Reopen") }}</o-button
|
||||
>
|
||||
<o-button
|
||||
v-if="report.status !== ReportStatusEnum.CLOSED"
|
||||
@click="updateReport(ReportStatusEnum.CLOSED)"
|
||||
variant="danger"
|
||||
>{{ t("Close") }}</o-button
|
||||
>
|
||||
<o-button
|
||||
v-if="antispamEnabled"
|
||||
outlined
|
||||
@click="reportToAntispam(true)"
|
||||
variant="text"
|
||||
class="!text-mbz-danger"
|
||||
>{{ t("Report as spam") }}</o-button
|
||||
>
|
||||
<o-button
|
||||
v-if="antispamEnabled"
|
||||
outlined
|
||||
@click="reportToAntispam(false)"
|
||||
variant="text"
|
||||
class="!text-mbz-success"
|
||||
>{{ t("Report as ham") }}</o-button
|
||||
>
|
||||
</div>
|
||||
<section class="w-full">
|
||||
<table class="table w-full">
|
||||
<tbody>
|
||||
<tr v-if="report.reported?.type === ActorType.GROUP">
|
||||
<td>{{ t("Reported group") }}</td>
|
||||
<td>
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.ADMIN_GROUP_PROFILE,
|
||||
params: { id: report.reported.id },
|
||||
}"
|
||||
>
|
||||
<img
|
||||
v-if="report.reported.avatar"
|
||||
class="image"
|
||||
:src="report.reported.avatar.url"
|
||||
alt=""
|
||||
/>
|
||||
{{ displayNameAndUsername(report.reported) }}
|
||||
</router-link>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-else-if="report.reported?.type === ActorType.PERSON">
|
||||
<td>
|
||||
{{ t("Reported identity") }}
|
||||
</td>
|
||||
<td class="flex items-center justify-between pr-6">
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.ADMIN_PROFILE,
|
||||
params: { id: report.reported.id },
|
||||
}"
|
||||
class="inline-flex gap-1"
|
||||
>
|
||||
<img
|
||||
v-if="report.reported.avatar"
|
||||
class="image rounded-full"
|
||||
:src="report.reported.avatar.url"
|
||||
alt=""
|
||||
/>
|
||||
<template v-if="report.reported.suspended">
|
||||
<i18n-t keypath="{profileName} (suspended)">
|
||||
<template #profileName>
|
||||
{{ displayNameAndUsername(report.reported) }}
|
||||
</template>
|
||||
</i18n-t>
|
||||
</template>
|
||||
<template v-else>{{
|
||||
displayNameAndUsername(report.reported)
|
||||
}}</template>
|
||||
</router-link>
|
||||
<o-button
|
||||
v-if="report.reported.domain && !report.reported.suspended"
|
||||
variant="danger"
|
||||
@click="suspendProfile(report.reported.id as string)"
|
||||
icon-left="delete"
|
||||
size="small"
|
||||
>{{ t("Suspend the profile") }}</o-button
|
||||
>
|
||||
<o-button
|
||||
v-else-if="
|
||||
(report.reported as IPerson).user &&
|
||||
!((report.reported as IPerson).user as IUser).disabled
|
||||
"
|
||||
variant="danger"
|
||||
@click="suspendUser((report.reported as IPerson).user as IUser)"
|
||||
icon-left="delete"
|
||||
size="small"
|
||||
>{{ t("Suspend the account") }}</o-button
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-else>
|
||||
<td>
|
||||
{{ t("Reported identity") }}
|
||||
</td>
|
||||
<td>
|
||||
{{ t("Unknown actor") }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{ t("Reported by") }}</td>
|
||||
<td v-if="report.reporter?.type === ActorType.APPLICATION">
|
||||
{{ report.reporter.domain }}
|
||||
</td>
|
||||
<td v-else-if="report.reporter?.type === ActorType.PERSON">
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.ADMIN_PROFILE,
|
||||
params: { id: report.reporter.id },
|
||||
}"
|
||||
>
|
||||
<img
|
||||
v-if="report.reporter.avatar"
|
||||
class="image rounded-full"
|
||||
:src="report.reporter.avatar.url"
|
||||
alt=""
|
||||
/>
|
||||
{{ displayNameAndUsername(report.reporter) }}
|
||||
</router-link>
|
||||
</td>
|
||||
<td v-else>
|
||||
{{ t("Unknown actor") }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{ t("Reported at") }}</td>
|
||||
<td>{{ formatDateTimeString(report.insertedAt) }}</td>
|
||||
</tr>
|
||||
<tr v-if="report.updatedAt !== report.insertedAt">
|
||||
<td>{{ t("Updated at") }}</td>
|
||||
<td>{{ formatDateTimeString(report.updatedAt) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{ t("Status") }}</td>
|
||||
<td>
|
||||
<span v-if="report.status === ReportStatusEnum.OPEN">{{
|
||||
t("Open")
|
||||
}}</span>
|
||||
<span v-else-if="report.status === ReportStatusEnum.CLOSED">
|
||||
{{ t("Closed") }}
|
||||
</span>
|
||||
<span v-else-if="report.status === ReportStatusEnum.RESOLVED">
|
||||
{{ t("Resolved") }}
|
||||
</span>
|
||||
<span v-else>{{ t("Unknown") }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="bg-white dark:bg-zinc-700 rounded px-2 pt-1 pb-2 my-3">
|
||||
<h2 class="mb-1">{{ t("Report reason") }}</h2>
|
||||
<div class="">
|
||||
<div class="flex gap-1">
|
||||
<figure class="" v-if="report.reported?.avatar">
|
||||
<img
|
||||
alt=""
|
||||
:src="report.reported.avatar.url"
|
||||
class="rounded-full"
|
||||
width="36"
|
||||
height="36"
|
||||
/>
|
||||
</figure>
|
||||
<AccountCircle v-else :size="36" />
|
||||
<div class="" v-if="report.reported">
|
||||
<p class="" v-if="report.reported?.name">
|
||||
{{ report.reported.name }}
|
||||
</p>
|
||||
<p class="">@{{ usernameWithDomain(report.reported) }}</p>
|
||||
</div>
|
||||
<p v-else>{{ t("Unknown actor") }}</p>
|
||||
</div>
|
||||
<div
|
||||
class="prose dark:prose-invert"
|
||||
v-if="report.content"
|
||||
v-html="nl2br(report.content)"
|
||||
/>
|
||||
<p v-else>{{ t("No comment") }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class="bg-white dark:bg-zinc-700 rounded px-2 pt-1 pb-2 my-3"
|
||||
v-if="
|
||||
report.events &&
|
||||
report.events?.length > 0 &&
|
||||
report.comments.length === 0
|
||||
"
|
||||
>
|
||||
<h2 class="mb-1">{{ t("Reported content") }}</h2>
|
||||
<ul>
|
||||
<li v-for="event in report.events" :key="event.id">
|
||||
<EventCard :event="event" mode="row" class="my-2 max-w-4xl" />
|
||||
<o-button
|
||||
variant="danger"
|
||||
@click="confirmEventDelete(event)"
|
||||
icon-left="delete"
|
||||
><template v-if="isOnlyReportedContent">{{
|
||||
t("Delete event and resolve report")
|
||||
}}</template
|
||||
><template v-else>{{ t("Delete event") }}</template></o-button
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class="bg-white dark:bg-zinc-700 rounded px-2 pt-1 pb-2 my-3"
|
||||
v-if="report.comments.length > 0"
|
||||
>
|
||||
<h2 class="mb-1">{{ t("Reported content") }}</h2>
|
||||
<ul v-for="comment in report.comments" :key="comment.id">
|
||||
<li>
|
||||
<template v-if="comment.conversation && comment.event">
|
||||
<i18n-t keypath="Comment from an event announcement" tag="p">
|
||||
<template #eventTitle>
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.EVENT,
|
||||
params: { uuid: comment.event?.uuid },
|
||||
}"
|
||||
>
|
||||
<b>{{ comment.event?.title }}</b>
|
||||
</router-link>
|
||||
</template>
|
||||
</i18n-t>
|
||||
<DiscussionComment
|
||||
:modelValue="comment"
|
||||
:current-actor="currentActor as IPerson"
|
||||
:readOnly="true"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="comment.conversation">
|
||||
<i18n-t keypath="Comment from a private conversation" tag="p">
|
||||
<template #eventTitle>
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.EVENT,
|
||||
params: { uuid: comment.event?.uuid },
|
||||
}"
|
||||
>
|
||||
<b>{{ comment.event?.title }}</b>
|
||||
</router-link>
|
||||
</template>
|
||||
</i18n-t>
|
||||
<DiscussionComment
|
||||
:modelValue="comment"
|
||||
:current-actor="currentActor as IPerson"
|
||||
:readOnly="true"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<i18n-t keypath="Comment under event {eventTitle}" tag="p">
|
||||
<template #eventTitle>
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.EVENT,
|
||||
params: { uuid: comment.event?.uuid },
|
||||
}"
|
||||
>
|
||||
<b>{{ comment.event?.title }}</b>
|
||||
</router-link>
|
||||
</template>
|
||||
</i18n-t>
|
||||
<EventComment
|
||||
:root-comment="true"
|
||||
:comment="comment"
|
||||
:event="comment.event as IEvent"
|
||||
:current-actor="currentActor as IPerson"
|
||||
:readOnly="true"
|
||||
/>
|
||||
</template>
|
||||
<o-button
|
||||
v-if="!comment.deletedAt"
|
||||
variant="danger"
|
||||
@click="confirmCommentDelete(comment)"
|
||||
icon-left="delete"
|
||||
><template v-if="isOnlyReportedContent">{{
|
||||
t("Delete comment and resolve report")
|
||||
}}</template
|
||||
><template v-else>{{ t("Delete comment") }}</template></o-button
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class="bg-white dark:bg-zinc-700 rounded px-2 pt-1 pb-2 my-3"
|
||||
v-if="
|
||||
report.events &&
|
||||
report.events?.length === 0 &&
|
||||
report.comments.length === 0
|
||||
"
|
||||
>
|
||||
<EmptyContent inline center icon="alert-circle">
|
||||
{{ t("No content found") }}
|
||||
<template #desc>
|
||||
{{ t("Maybe the content was removed by the author or a moderator") }}
|
||||
</template>
|
||||
</EmptyContent>
|
||||
</section>
|
||||
|
||||
<section class="bg-white dark:bg-zinc-700 rounded px-2 pt-1 pb-2 my-3">
|
||||
<h2 class="mb-1">{{ t("Notes") }}</h2>
|
||||
<div
|
||||
class=""
|
||||
v-for="note in report.notes"
|
||||
:id="`note-${note.id}`"
|
||||
:key="note.id"
|
||||
>
|
||||
<p>{{ note.content }}</p>
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.ADMIN_PROFILE,
|
||||
params: { id: note.moderator.id },
|
||||
}"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
class="rounded-full"
|
||||
:src="note.moderator.avatar.url"
|
||||
v-if="note.moderator.avatar"
|
||||
/>
|
||||
@{{ note.moderator.preferredUsername }}
|
||||
</router-link>
|
||||
<br />
|
||||
<small>
|
||||
<a :href="`#note-${note.id}`" v-if="note.insertedAt">
|
||||
{{ formatDateTimeString(note.insertedAt) }}
|
||||
</a>
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<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"
|
||||
></o-input>
|
||||
</o-field>
|
||||
<o-button class="mt-2" type="submit">{{ t("Add a note") }}</o-button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { CREATE_REPORT_NOTE, REPORT, UPDATE_REPORT } from "@/graphql/report";
|
||||
import { IReport, IReportNote } from "@/types/report.model";
|
||||
import {
|
||||
IPerson,
|
||||
displayNameAndUsername,
|
||||
usernameWithDomain,
|
||||
} 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, AntiSpamFeedback, ReportStatusEnum } from "@/types/enums";
|
||||
import RouteName from "@/router/name";
|
||||
import { GraphQLError } from "graphql";
|
||||
import { ApolloCache, FetchResult } from "@apollo/client/core";
|
||||
import { useLazyQuery, 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";
|
||||
import EventCard from "@/components/Event/EventCard.vue";
|
||||
import { useFeatures } from "@/composition/apollo/config";
|
||||
import { IEvent } from "@/types/event.model";
|
||||
import EmptyContent from "@/components/Utils/EmptyContent.vue";
|
||||
import EventComment from "@/components/Comment/EventComment.vue";
|
||||
import DiscussionComment from "@/components/Discussion/DiscussionComment.vue";
|
||||
import { SUSPEND_PROFILE } from "@/graphql/actor";
|
||||
import { GET_USER, SUSPEND_USER } from "@/graphql/user";
|
||||
import { IUser } from "@/types/current-user.model";
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const props = defineProps<{ reportId: string }>();
|
||||
|
||||
const { currentActor } = useCurrentActorClient();
|
||||
|
||||
const { features } = useFeatures();
|
||||
|
||||
const antispamEnabled = computed(() => features.value?.antispam);
|
||||
|
||||
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 reportedContent = computed(() => {
|
||||
return [...(report.value?.events ?? []), ...(report.value?.comments ?? [])];
|
||||
});
|
||||
|
||||
const isOnlyReportedContent = computed(
|
||||
() => reportedContent.value.length === 1
|
||||
);
|
||||
|
||||
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,
|
||||
variables: { id: report.value?.id },
|
||||
});
|
||||
if (cachedData == null) return;
|
||||
const { report: cachedReport } = cachedData;
|
||||
if (cachedReport === null) {
|
||||
console.error("Cannot update event notes cache, because of null value.");
|
||||
return;
|
||||
}
|
||||
const note = data.createReportNote;
|
||||
note.moderator = currentActor.value;
|
||||
|
||||
cachedReport.notes = cachedReport.notes.concat([note]);
|
||||
|
||||
store.writeQuery({
|
||||
query: REPORT,
|
||||
variables: { id: report.value?.id },
|
||||
data: { report },
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
createReportNoteMutationDone(() => {
|
||||
noteContent.value = "";
|
||||
});
|
||||
|
||||
createReportNoteMutationError((error) => {
|
||||
console.error(error);
|
||||
});
|
||||
|
||||
const dialog = inject<Dialog>("dialog");
|
||||
|
||||
const addResolveReportPart = computed(() => {
|
||||
if (isOnlyReportedContent.value) {
|
||||
return "<p>" + t("This will also resolve the report.") + "</p>";
|
||||
}
|
||||
return "";
|
||||
});
|
||||
|
||||
const confirmEventDelete = (event: IEvent): void => {
|
||||
dialog?.confirm({
|
||||
title: t("Deleting event"),
|
||||
message:
|
||||
t(
|
||||
"Are you sure you want to <b>delete</b> this event? <b>This action cannot be undone</b>. You may want to engage the discussion with the event creator and ask them to edit their event instead."
|
||||
) + addResolveReportPart.value,
|
||||
confirmText: isOnlyReportedContent.value
|
||||
? t("Delete event and resolve report")
|
||||
: t("Delete event"),
|
||||
variant: "danger",
|
||||
hasIcon: true,
|
||||
onConfirm: () => deleteEvent(event),
|
||||
});
|
||||
};
|
||||
|
||||
const confirmCommentDelete = (comment: IComment): void => {
|
||||
dialog?.confirm({
|
||||
title: t("Deleting comment"),
|
||||
message:
|
||||
t(
|
||||
"Are you sure you want to <b>delete</b> this comment? <b>This action cannot be undone</b>."
|
||||
) + addResolveReportPart.value,
|
||||
confirmText: isOnlyReportedContent.value
|
||||
? t("Delete comment and resolve report")
|
||||
: t("Delete comment"),
|
||||
variant: "danger",
|
||||
hasIcon: true,
|
||||
onConfirm: () => deleteCommentMutation({ commentId: comment.id }),
|
||||
});
|
||||
};
|
||||
|
||||
const {
|
||||
mutate: deleteEventMutation,
|
||||
onDone: deleteEventMutationDone,
|
||||
onError: deleteEventMutationError,
|
||||
} = useMutation<{ deleteEvent: { id: string } }>(DELETE_EVENT, () => ({
|
||||
update: (
|
||||
store: ApolloCache<{ deleteEvent: { id: string } }>,
|
||||
{ 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: cachedReport } = reportCachedData;
|
||||
if (cachedReport === null) {
|
||||
console.error(
|
||||
"Cannot update report events cache, because of null value."
|
||||
);
|
||||
return;
|
||||
}
|
||||
const updatedReport = {
|
||||
...cachedReport,
|
||||
events: cachedReport.events?.filter(
|
||||
(cachedEvent) => cachedEvent.id !== data.deleteEvent.id
|
||||
),
|
||||
};
|
||||
|
||||
store.writeQuery({
|
||||
query: REPORT,
|
||||
variables: { id: report.value?.id },
|
||||
data: { report: updatedReport },
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
deleteEventMutationDone(async () => {
|
||||
if (reportedContent.value.length === 0) {
|
||||
await updateReport(ReportStatusEnum.RESOLVED);
|
||||
notifier?.success(t("Event deleted and report resolved"));
|
||||
} else {
|
||||
notifier?.success(t("Event deleted"));
|
||||
}
|
||||
});
|
||||
|
||||
deleteEventMutationError((error) => {
|
||||
console.error(error);
|
||||
});
|
||||
|
||||
const deleteEvent = async (event: IEvent): Promise<void> => {
|
||||
if (!event?.id) return;
|
||||
|
||||
deleteEventMutation(
|
||||
{ eventId: event.id },
|
||||
{ context: { eventTitle: event.title } }
|
||||
);
|
||||
};
|
||||
|
||||
const {
|
||||
mutate: deleteCommentMutation,
|
||||
onDone: deleteCommentMutationDone,
|
||||
onError: deleteCommentMutationError,
|
||||
} = useMutation<{ deleteComment: { id: string } }>(DELETE_COMMENT, () => ({
|
||||
update: (
|
||||
store: ApolloCache<{ deleteComment: { id: string } }>,
|
||||
{ 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: cachedReport } = reportCachedData;
|
||||
if (cachedReport === null) {
|
||||
console.error(
|
||||
"Cannot update report comments cache, because of null value."
|
||||
);
|
||||
return;
|
||||
}
|
||||
const updatedReport = {
|
||||
...cachedReport,
|
||||
comments: cachedReport.comments.filter(
|
||||
(cachedComment) => cachedComment.id !== data.deleteComment.id
|
||||
),
|
||||
};
|
||||
|
||||
store.writeQuery({
|
||||
query: REPORT,
|
||||
variables: { id: report.value?.id },
|
||||
data: { report: updatedReport },
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
deleteCommentMutationDone(async () => {
|
||||
if (reportedContent.value.length === 0) {
|
||||
await updateReport(ReportStatusEnum.RESOLVED);
|
||||
notifier?.success(t("Comment deleted and report resolved"));
|
||||
} else {
|
||||
notifier?.success(t("Comment deleted"));
|
||||
}
|
||||
});
|
||||
|
||||
deleteCommentMutationError((error) => {
|
||||
console.error(error);
|
||||
});
|
||||
|
||||
const {
|
||||
mutate: updateReportMutation,
|
||||
onDone: onUpdateReportMutation,
|
||||
onError: onUpdateReportError,
|
||||
} = useMutation<
|
||||
Record<string, any>,
|
||||
{
|
||||
reportId: string;
|
||||
status: ReportStatusEnum;
|
||||
antispamFeedback?: AntiSpamFeedback;
|
||||
}
|
||||
>(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: cachedReport } = reportCachedData;
|
||||
if (cachedReport === null) {
|
||||
console.error("Cannot update event notes cache, because of null value.");
|
||||
return;
|
||||
}
|
||||
const updatedReport = {
|
||||
...cachedReport,
|
||||
status: data.updateReportStatus.status,
|
||||
};
|
||||
|
||||
store.writeQuery({
|
||||
query: REPORT,
|
||||
variables: { id: report.value?.id },
|
||||
data: { report: updatedReport },
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
onUpdateReportMutation(async () => {
|
||||
await router.push({ name: RouteName.REPORTS });
|
||||
});
|
||||
|
||||
onUpdateReportError((error) => {
|
||||
console.error(error);
|
||||
});
|
||||
|
||||
const updateReport = async (status: ReportStatusEnum): Promise<void> => {
|
||||
if (report.value) {
|
||||
updateReportMutation({
|
||||
reportId: report.value?.id,
|
||||
status,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const reportToAntispam = (spam: boolean) => {
|
||||
dialog?.confirm({
|
||||
title: spam ? t("Report as undetected spam") : t("Report as ham"),
|
||||
message: t(
|
||||
"The report contents (eventual comments and event) and the reported profile details will be transmitted to Akismet."
|
||||
),
|
||||
confirmText: t("Submit to Akismet"),
|
||||
variant: "warning",
|
||||
hasIcon: true,
|
||||
onConfirm: () => {
|
||||
if (report.value) {
|
||||
updateReportMutation({
|
||||
reportId: report.value.id,
|
||||
status: report.value.status,
|
||||
antispamFeedback: spam ? AntiSpamFeedback.SPAM : AntiSpamFeedback.HAM,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const { mutate: doSuspendProfile, onDone: onSuspendProfileDone } = useMutation<
|
||||
{
|
||||
suspendProfile: { id: string };
|
||||
},
|
||||
{ id: string }
|
||||
>(SUSPEND_PROFILE);
|
||||
|
||||
const { mutate: doSuspendUser, onDone: onSuspendUserDone } = useMutation<
|
||||
{ suspendProfile: { id: string } },
|
||||
{ userId: string }
|
||||
>(SUSPEND_USER);
|
||||
|
||||
const { load: loadUserLazyQuery } = useLazyQuery<
|
||||
{ user: IUser },
|
||||
{ id: string }
|
||||
>(GET_USER);
|
||||
|
||||
const suspendProfile = async (actorId: string): Promise<void> => {
|
||||
dialog?.confirm({
|
||||
title: t("Suspend the profile?"),
|
||||
message:
|
||||
t(
|
||||
"Do you really want to suspend this profile? All of the profiles content will be deleted."
|
||||
) +
|
||||
`<p><b>` +
|
||||
t("There will be no way to restore the profile's data!") +
|
||||
`</b></p>`,
|
||||
confirmText: t("Suspend the profile"),
|
||||
cancelText: t("Cancel"),
|
||||
variant: "danger",
|
||||
onConfirm: async () => {
|
||||
doSuspendProfile({
|
||||
id: actorId,
|
||||
});
|
||||
return router.push({ name: RouteName.USERS });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const userSuspendedProfilesMessages = (user: IUser) => {
|
||||
return (
|
||||
t("The following user's profiles will be deleted, with all their data:") +
|
||||
`<ul class="list-disc pl-3">` +
|
||||
user.actors
|
||||
.map((person) => `<li>${displayNameAndUsername(person)}</li>`)
|
||||
.join("") +
|
||||
`</ul><b>`
|
||||
);
|
||||
};
|
||||
|
||||
const cachedReportedUser = ref<IUser | undefined>();
|
||||
|
||||
const suspendUser = async (user: IUser): Promise<void> => {
|
||||
try {
|
||||
if (!cachedReportedUser.value) {
|
||||
try {
|
||||
const result = await loadUserLazyQuery(GET_USER, { id: user.id });
|
||||
if (!result) return;
|
||||
cachedReportedUser.value = result.user;
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
dialog?.confirm({
|
||||
title: t("Suspend the account?"),
|
||||
message:
|
||||
t("Do you really want to suspend the account « {emailAccount} » ?", {
|
||||
emailAccount: cachedReportedUser.value.email,
|
||||
}) +
|
||||
" " +
|
||||
userSuspendedProfilesMessages(cachedReportedUser.value) +
|
||||
"<b>" +
|
||||
t("There will be no way to restore the user's data!") +
|
||||
`</b>`,
|
||||
confirmText: t("Suspend the account"),
|
||||
cancelText: t("Cancel"),
|
||||
variant: "danger",
|
||||
onConfirm: async () => {
|
||||
doSuspendUser({
|
||||
userId: user.id,
|
||||
});
|
||||
return router.push({ name: RouteName.USERS });
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
onSuspendUserDone(async () => {
|
||||
await router.push({ name: RouteName.REPORTS });
|
||||
notifier?.success(t("User suspended and report resolved"));
|
||||
});
|
||||
|
||||
onSuspendProfileDone(async () => {
|
||||
await router.push({ name: RouteName.REPORTS });
|
||||
notifier?.success(t("Profile suspended and report resolved"));
|
||||
});
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
tbody td img.image,
|
||||
.note img.image {
|
||||
display: inline;
|
||||
height: 1.5em;
|
||||
vertical-align: text-bottom;
|
||||
}
|
||||
|
||||
.dialog .modal-card-foot {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.box a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
</style>
|
||||
125
src/views/OAuth/AuthorizeView.vue
Normal file
125
src/views/OAuth/AuthorizeView.vue
Normal file
@@ -0,0 +1,125 @@
|
||||
<template>
|
||||
<div class="container mx-auto w-96">
|
||||
<div v-show="authApplicationLoading && !resultCode">
|
||||
<o-skeleton active size="large" class="mt-6" />
|
||||
<o-skeleton active width="80%" />
|
||||
<div
|
||||
class="rounded-lg bg-mbz-warning shadow-xl my-6 p-4 flex items-center gap-2"
|
||||
>
|
||||
<div>
|
||||
<o-skeleton circle active width="42px" height="42px" />
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<o-skeleton active />
|
||||
<o-skeleton active />
|
||||
<o-skeleton active />
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg bg-white shadow-xl my-6">
|
||||
<div class="p-4 pb-0">
|
||||
<p class="text-3xl"><o-skeleton active size="large" /></p>
|
||||
<o-skeleton active width="40%" />
|
||||
</div>
|
||||
<div class="flex gap-3 p-4">
|
||||
<o-skeleton active />
|
||||
<o-skeleton active />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AuthorizeApplication
|
||||
v-if="authApplication"
|
||||
v-show="!authApplicationLoading && !authApplicationError && !resultCode"
|
||||
:auth-application="authApplication"
|
||||
:redirectURI="redirectURI"
|
||||
:state="state"
|
||||
:scope="scope"
|
||||
/>
|
||||
<div v-show="authApplicationError">
|
||||
<div
|
||||
class="rounded-lg text-white bg-mbz-danger shadow-xl my-6 p-4 flex items-center gap-2"
|
||||
v-if="authApplicationGraphError?.code === 'application_not_found'"
|
||||
>
|
||||
<AlertCircle :size="42" />
|
||||
<div>
|
||||
<p class="font-bold">
|
||||
{{ t("Application not found") }}
|
||||
</p>
|
||||
<p>{{ t("The provided application was not found.") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<o-button
|
||||
variant="text"
|
||||
tag="router-link"
|
||||
:to="{ name: RouteName.HOME }"
|
||||
>{{ t("Back to homepage") }}</o-button
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
v-if="resultCode"
|
||||
class="rounded-lg bg-white shadow-xl my-6 p-4 flex items-center gap-2"
|
||||
>
|
||||
<div>
|
||||
<p class="font-bold">
|
||||
{{ t("Your application code") }}
|
||||
</p>
|
||||
<p>
|
||||
{{
|
||||
t(
|
||||
"You need to provide the following code to your application. It will only be valid for a few minutes."
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<p class="text-4xl">{{ resultCode }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useRouteQuery } from "vue-use-route-query";
|
||||
import { useHead } from "@vueuse/head";
|
||||
import { computed, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useQuery } from "@vue/apollo-composable";
|
||||
import { AUTH_APPLICATION } from "@/graphql/application";
|
||||
import { IApplication } from "@/types/application.model";
|
||||
import AlertCircle from "vue-material-design-icons/AlertCircle.vue";
|
||||
import type { AbsintheGraphQLError } from "@/types/errors.model";
|
||||
import RouteName from "@/router/name";
|
||||
import AuthorizeApplication from "@/components/OAuth/AuthorizeApplication.vue";
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
const clientId = useRouteQuery("client_id", null);
|
||||
const redirectURI = useRouteQuery("redirect_uri", null);
|
||||
const state = useRouteQuery("state", null);
|
||||
const scope = useRouteQuery("scope", null);
|
||||
|
||||
const resultCode = ref<string | null>(null);
|
||||
|
||||
const {
|
||||
result: authApplicationResult,
|
||||
loading: authApplicationLoading,
|
||||
error: authApplicationError,
|
||||
} = useQuery<{ authApplication: IApplication }, { clientId: string }>(
|
||||
AUTH_APPLICATION,
|
||||
() => ({
|
||||
clientId: clientId.value as string,
|
||||
}),
|
||||
() => ({
|
||||
enabled: clientId.value !== null,
|
||||
})
|
||||
);
|
||||
|
||||
const authApplication = computed(
|
||||
() => authApplicationResult.value?.authApplication
|
||||
);
|
||||
|
||||
const authApplicationGraphError = computed(
|
||||
() => authApplicationError.value?.graphQLErrors[0] as AbsintheGraphQLError
|
||||
);
|
||||
|
||||
useHead({
|
||||
title: computed(() => t("Authorize application")),
|
||||
});
|
||||
</script>
|
||||
163
src/views/OAuth/DeviceActivationView.vue
Normal file
163
src/views/OAuth/DeviceActivationView.vue
Normal file
@@ -0,0 +1,163 @@
|
||||
<template>
|
||||
<div class="container mx-auto w-96">
|
||||
<form
|
||||
@submit.prevent="() => validateCode({ userCode: code })"
|
||||
@paste="pasteCode"
|
||||
class="rounded-lg bg-white dark:bg-zinc-900 shadow-xl my-6 p-4"
|
||||
v-if="!application"
|
||||
>
|
||||
<h1 class="text-3xl text-center">
|
||||
{{ t("Device activation") }}
|
||||
</h1>
|
||||
<p class="mb-4 text-center">
|
||||
{{ t("Enter the code displayed on your device") }}
|
||||
</p>
|
||||
|
||||
<div class="flex items-center justify-between mb-4 gap-2">
|
||||
<div
|
||||
v-for="i in Array(9).keys()"
|
||||
:key="i"
|
||||
:class="i === 4 ? 'w-6' : 'w-8'"
|
||||
>
|
||||
<span
|
||||
:id="`user-code-${i}`"
|
||||
v-if="i === 4"
|
||||
class="block text-3xl text-center"
|
||||
>-</span
|
||||
>
|
||||
<o-input
|
||||
autocapitalize="characters"
|
||||
@update:modelValue="
|
||||
(val: string) => (inputs[i] = val.toUpperCase())
|
||||
"
|
||||
:useHtml5Validation="true"
|
||||
:id="`user-code-${i}`"
|
||||
:ref="(el: Element) => (userCodeInputs[i] = el)"
|
||||
:modelValue="inputs[i]"
|
||||
v-else
|
||||
size="large"
|
||||
style="font-size: 22px; padding: 0.5rem 0.15rem 0.5rem 0.25rem"
|
||||
required
|
||||
maxlength="1"
|
||||
pattern="[A-Z]{1}"
|
||||
:autofocus="i === 0 ? true : undefined"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded-lg bg-mbz-danger shadow-xl my-6 p-4 flex items-center gap-2"
|
||||
v-if="error"
|
||||
>
|
||||
<AlertCircle :size="42" />
|
||||
<div>
|
||||
<p>{{ error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<o-button native-type="submit">{{ t("Continue") }}</o-button>
|
||||
</div>
|
||||
</form>
|
||||
<AuthorizeApplication
|
||||
v-if="application"
|
||||
:auth-application="application"
|
||||
:user-code="code"
|
||||
:scope="scope"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { DEVICE_ACTIVATION } from "@/graphql/application";
|
||||
import { useMutation } from "@vue/apollo-composable";
|
||||
import { useHead } from "@vueuse/head";
|
||||
import { computed, reactive, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import AuthorizeApplication from "@/components/OAuth/AuthorizeApplication.vue";
|
||||
import { IApplication } from "@/types/application.model";
|
||||
import { AbsintheGraphQLErrors } from "@/types/errors.model";
|
||||
import AlertCircle from "vue-material-design-icons/AlertCircle.vue";
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
const {
|
||||
mutate: validateCode,
|
||||
onDone: onDeviceActivationDone,
|
||||
onError: onDeviceActivationError,
|
||||
} = useMutation<{
|
||||
deviceActivation: { application: IApplication; id: string; scope: string };
|
||||
}>(DEVICE_ACTIVATION);
|
||||
|
||||
const inputs = reactive<string[]>([]);
|
||||
|
||||
const application = ref<IApplication | null>(null);
|
||||
const scope = ref<string | null>(null);
|
||||
|
||||
onDeviceActivationDone(({ data }) => {
|
||||
console.debug("onDeviceActivationDone", data);
|
||||
const foundApplication = data?.deviceActivation?.application;
|
||||
if (foundApplication) {
|
||||
application.value = foundApplication;
|
||||
scope.value = data?.deviceActivation?.scope;
|
||||
}
|
||||
});
|
||||
|
||||
const code = computed(() => {
|
||||
return inputs.join("");
|
||||
});
|
||||
|
||||
const userCodeInputs = reactive<Record<number, Element>>([]);
|
||||
|
||||
watch(inputs, (localInputs) => {
|
||||
localInputs.forEach((input, index) => {
|
||||
if (input && index < 8) {
|
||||
if (index === 3) {
|
||||
index = 4;
|
||||
}
|
||||
(userCodeInputs[index + 1] as HTMLInputElement).focus();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
onDeviceActivationError(
|
||||
({ graphQLErrors }: { graphQLErrors: AbsintheGraphQLErrors }) => {
|
||||
const err = graphQLErrors[0];
|
||||
if (
|
||||
err.status_code === 400 &&
|
||||
err.code === "device_application_code_expired"
|
||||
) {
|
||||
error.value = t("The device code is incorrect or no longer valid.");
|
||||
}
|
||||
resetInputs();
|
||||
(userCodeInputs[0] as HTMLInputElement).focus();
|
||||
setTimeout(() => {
|
||||
error.value = null;
|
||||
}, 10000);
|
||||
}
|
||||
);
|
||||
|
||||
const resetInputs = () => {
|
||||
inputs.splice(0);
|
||||
};
|
||||
|
||||
const pasteCode = (e: ClipboardEvent) => {
|
||||
let pastedCode = e.clipboardData?.getData("text").trim();
|
||||
if (!pastedCode) return;
|
||||
if (pastedCode.match(/^[A-Z]{4}-[A-Z]{4}$/)) {
|
||||
pastedCode = pastedCode.slice(0, 4) + pastedCode.slice(5);
|
||||
}
|
||||
if (pastedCode.match(/^[A-Z]{8}$/)) {
|
||||
pastedCode.split("").forEach((val, index) => {
|
||||
const realIndex = index > 3 ? index + 1 : index;
|
||||
inputs[realIndex] = val;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useHead({
|
||||
title: computed(() => t("Device activation")),
|
||||
});
|
||||
</script>
|
||||
82
src/views/PageNotFound.vue
Normal file
82
src/views/PageNotFound.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<section class="container mx-auto py-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`"
|
||||
type="image/webp"
|
||||
/>
|
||||
|
||||
<img
|
||||
:src="`/img/pics/error-480w.webp`"
|
||||
alt=""
|
||||
width="2616"
|
||||
height="1698"
|
||||
loading="lazy"
|
||||
/>
|
||||
</picture>
|
||||
<h1 class="text-4xl mb-3">
|
||||
{{ $t("The page you're looking for doesn't exist.") }}
|
||||
</h1>
|
||||
<p>
|
||||
{{
|
||||
$t(
|
||||
"Please make sure the address is correct and that the page hasn't been moved."
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<p>
|
||||
{{
|
||||
$t(
|
||||
"Please contact this instance's Mobilizon admin if you think this is a mistake."
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<!-- The following should just be replaced with the SearchField component but it fails for some reason -->
|
||||
<form @submit.prevent="enter" class="flex flex-wrap mt-3">
|
||||
<o-field expanded>
|
||||
<o-input
|
||||
expanded
|
||||
icon="magnify"
|
||||
type="search"
|
||||
:placeholder="searchPlaceHolder"
|
||||
v-model="searchText"
|
||||
/>
|
||||
<p class="control">
|
||||
<button type="submit" class="button is-primary">
|
||||
{{ $t("Search") }}
|
||||
</button>
|
||||
</p>
|
||||
</o-field>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
<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";
|
||||
|
||||
const searchText = ref("");
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
useHead({
|
||||
title: computed(() => t("Page not found")),
|
||||
});
|
||||
const router = useRouter();
|
||||
|
||||
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>
|
||||
410
src/views/Posts/EditView.vue
Normal file
410
src/views/Posts/EditView.vue
Normal file
@@ -0,0 +1,410 @@
|
||||
<template>
|
||||
<div>
|
||||
<form @submit.prevent="publish(false)" v-if="isCurrentActorAGroupModerator">
|
||||
<div class="container mx-auto">
|
||||
<breadcrumbs-nav v-if="actualGroup" :links="breadcrumbLinks" />
|
||||
<h1 v-if="isUpdate === true">
|
||||
{{ t("Edit post") }}
|
||||
</h1>
|
||||
<h1 v-else>
|
||||
{{ t("Add a new post") }}
|
||||
</h1>
|
||||
<h2>{{ t("General information") }}</h2>
|
||||
<picture-upload
|
||||
v-model="pictureFile"
|
||||
:textFallback="t('Headline picture')"
|
||||
:defaultImage="editablePost.picture"
|
||||
/>
|
||||
|
||||
<o-field
|
||||
:label="t('Title')"
|
||||
label-for="post-title"
|
||||
:type="errors.title ? 'is-danger' : null"
|
||||
:message="errors.title"
|
||||
>
|
||||
<o-input
|
||||
size="large"
|
||||
aria-required="true"
|
||||
required
|
||||
v-model="editablePost.title"
|
||||
id="post-title"
|
||||
dir="auto"
|
||||
/>
|
||||
</o-field>
|
||||
|
||||
<tag-input v-model="editablePost.tags" :fetch-tags="fetchTags" />
|
||||
|
||||
<o-field :label="t('Post')">
|
||||
<p v-if="errors.body" class="help is-danger">{{ errors.body }}</p>
|
||||
<editor
|
||||
class="w-full"
|
||||
v-if="currentActor"
|
||||
v-model="editablePost.body"
|
||||
:aria-label="t('Post body')"
|
||||
:current-actor="currentActor"
|
||||
:placeholder="t('Write your post')"
|
||||
:headingLevel="[2, 3, 4]"
|
||||
/>
|
||||
</o-field>
|
||||
<h2 class="mt-2">{{ t("Who can view this post") }}</h2>
|
||||
<fieldset>
|
||||
<legend>
|
||||
{{
|
||||
t(
|
||||
"When the post is private, you'll need to share the link around."
|
||||
)
|
||||
}}
|
||||
</legend>
|
||||
<div class="field">
|
||||
<o-radio
|
||||
v-model="editablePost.visibility"
|
||||
name="postVisibility"
|
||||
:native-value="PostVisibility.PUBLIC"
|
||||
>{{ t("Visible everywhere on the web") }}</o-radio
|
||||
>
|
||||
</div>
|
||||
<div class="field">
|
||||
<o-radio
|
||||
v-model="editablePost.visibility"
|
||||
name="postVisibility"
|
||||
:native-value="PostVisibility.UNLISTED"
|
||||
>{{ t("Only accessible through link") }}</o-radio
|
||||
>
|
||||
</div>
|
||||
<div class="field">
|
||||
<o-radio
|
||||
v-model="editablePost.visibility"
|
||||
name="postVisibility"
|
||||
:native-value="PostVisibility.PRIVATE"
|
||||
>{{ t("Only accessible to members of the group") }}</o-radio
|
||||
>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
<nav class="navbar">
|
||||
<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 variant="text" @click="$router.go(-1)">{{
|
||||
t("Cancel")
|
||||
}}</o-button>
|
||||
<o-button
|
||||
v-if="isUpdate"
|
||||
variant="danger"
|
||||
outlined
|
||||
@click="openDeletePostModal"
|
||||
>{{ t("Delete post") }}</o-button
|
||||
>
|
||||
<!-- If an post has been published we can't make it draft anymore -->
|
||||
<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>
|
||||
</o-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</form>
|
||||
<o-loading
|
||||
v-else-if="postLoading"
|
||||
:is-full-page="false"
|
||||
v-model:active="postLoading"
|
||||
:can-cancel="false"
|
||||
></o-loading>
|
||||
<div class="container mx-auto" v-else>
|
||||
<o-notification variant="danger">
|
||||
{{ t("Only group moderators can create, edit and delete posts.") }}
|
||||
</o-notification>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
buildFileFromIMedia,
|
||||
buildFileVariable,
|
||||
readFileAsync,
|
||||
} from "@/utils/image";
|
||||
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/TextEditor.vue";
|
||||
import { displayName, IActor, usernameWithDomain } from "../../types/actor";
|
||||
import TagInput from "../../components/Event/TagInput.vue";
|
||||
import RouteName from "../../router/name";
|
||||
import PictureUpload from "../../components/PictureUpload.vue";
|
||||
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";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
slug?: string;
|
||||
preferredUsername?: string;
|
||||
isUpdate?: boolean;
|
||||
}>(),
|
||||
{ isUpdate: false }
|
||||
);
|
||||
const preferredUsername = computed(() => props.preferredUsername);
|
||||
|
||||
const { currentActor } = useCurrentActorClient();
|
||||
const { group } = useGroup(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 | undefined, oldPost: IPost | undefined) => {
|
||||
if (oldPost?.picture !== newPost?.picture) {
|
||||
pictureFile.value = await buildFileFromIMedia(post.value?.picture);
|
||||
}
|
||||
if (newPost) {
|
||||
editablePost.value = { ...newPost };
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
},
|
||||
{}
|
||||
);
|
||||
});
|
||||
|
||||
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),
|
||||
},
|
||||
{
|
||||
name: RouteName.POSTS,
|
||||
params: {
|
||||
preferredUsername: usernameWithDomain(actualGroup.value),
|
||||
},
|
||||
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({
|
||||
variant: "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 } =
|
||||
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>
|
||||
155
src/views/Posts/ListView.vue
Normal file
155
src/views/Posts/ListView.vue
Normal file
@@ -0,0 +1,155 @@
|
||||
<template>
|
||||
<div class="container mx-auto section" v-if="group">
|
||||
<breadcrumbs-nav
|
||||
:links="[
|
||||
{
|
||||
name: RouteName.GROUP,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
text: displayName(group),
|
||||
},
|
||||
{
|
||||
name: RouteName.POSTS,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
text: $t('Posts'),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<section>
|
||||
<div class="intro">
|
||||
<p v-if="isCurrentActorMember">
|
||||
{{
|
||||
$t(
|
||||
"A place to publish something to the whole world, your community or just your group members."
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<p v-if="isCurrentActorMember">
|
||||
{{ $t("Only group moderators can create, edit and delete posts.") }}
|
||||
</p>
|
||||
<o-button
|
||||
tag="router-link"
|
||||
v-if="isCurrentActorAGroupModerator"
|
||||
:to="{
|
||||
name: RouteName.POST_CREATE,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
}"
|
||||
variant="primary"
|
||||
class="my-2"
|
||||
>{{ $t("+ Create a post") }}</o-button
|
||||
>
|
||||
</div>
|
||||
<div class="post-list">
|
||||
<multi-post-list-item
|
||||
:posts="group.posts.elements"
|
||||
:isCurrentActorMember="isCurrentActorMember"
|
||||
/>
|
||||
</div>
|
||||
<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") }}
|
||||
</o-notification>
|
||||
<o-pagination
|
||||
:total="group.posts.total"
|
||||
v-model:current="postsPage"
|
||||
:per-page="POSTS_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>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { PERSON_MEMBERSHIPS } from "@/graphql/actor";
|
||||
import { FETCH_GROUP_POSTS } from "../../graphql/post";
|
||||
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;
|
||||
|
||||
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: groupPostsResult, loading: groupLoading } = useQuery<{
|
||||
group: IGroup;
|
||||
}>(
|
||||
FETCH_GROUP_POSTS,
|
||||
() => ({
|
||||
preferredUsername: props.preferredUsername,
|
||||
page: postsPage.value,
|
||||
limit: POSTS_PAGE_LIMIT,
|
||||
}),
|
||||
() => ({ enabled: props.preferredUsername !== undefined })
|
||||
);
|
||||
|
||||
const group = computed(() => groupPostsResult.value?.group);
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
useHead({
|
||||
title: computed(() =>
|
||||
t("{group} posts", {
|
||||
group: displayName(group.value),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
const loading = computed(() => membershipsLoading.value || groupLoading.value);
|
||||
|
||||
const isCurrentActorMember = computed((): boolean => {
|
||||
if (!group.value || !memberships.value) return false;
|
||||
return memberships.value.elements
|
||||
.map(({ parent: { id } }) => id)
|
||||
.includes(group.value.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>
|
||||
547
src/views/Posts/PostView.vue
Normal file
547
src/views/Posts/PostView.vue
Normal file
@@ -0,0 +1,547 @@
|
||||
<template>
|
||||
<article class="container mx-auto post" v-if="post">
|
||||
<breadcrumbs-nav
|
||||
v-if="post.attributedTo"
|
||||
:links="[
|
||||
{ name: RouteName.MY_GROUPS, text: t('My groups') },
|
||||
{
|
||||
name: RouteName.GROUP,
|
||||
params: { preferredUsername: usernameWithDomain(post.attributedTo) },
|
||||
text: displayName(post.attributedTo),
|
||||
},
|
||||
{
|
||||
name: RouteName.POST,
|
||||
params: { slug: post.slug },
|
||||
text: post.title,
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<header>
|
||||
<div class="flex justify-center">
|
||||
<lazy-image-wrapper :picture="post.picture" />
|
||||
</div>
|
||||
<div class="relative flex flex-col">
|
||||
<div
|
||||
class="px-2 py-3 flex flex-wrap gap-4 justify-center items-center"
|
||||
dir="auto"
|
||||
>
|
||||
<div class="flex-auto min-w-[300px] max-w-screen-lg">
|
||||
<div class="inline">
|
||||
<tag
|
||||
class="mr-2"
|
||||
variant="warning"
|
||||
size="medium"
|
||||
v-if="post.draft"
|
||||
>{{ t("Draft") }}</tag
|
||||
>
|
||||
<h1 class="inline" :lang="post.language">
|
||||
{{ post.title }}
|
||||
</h1>
|
||||
</div>
|
||||
<p class="mt-2 flex flex-col flex-wrap justify-start">
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.GROUP,
|
||||
params: {
|
||||
preferredUsername: usernameWithDomain(post.attributedTo),
|
||||
},
|
||||
}"
|
||||
>
|
||||
<actor-inline
|
||||
v-if="post.attributedTo"
|
||||
:actor="post.attributedTo"
|
||||
/>
|
||||
</router-link>
|
||||
<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="inline-flex gap-2 items-center mt-2"
|
||||
:title="
|
||||
formatDateTimeString(post.updatedAt, undefined, true, 'short')
|
||||
"
|
||||
v-else-if="post.updatedAt"
|
||||
>
|
||||
<Clock :size="16" />
|
||||
{{
|
||||
t("Edited {relative_time} ago", {
|
||||
relative_time: formatDistanceToNowStrict(
|
||||
new Date(post.updatedAt),
|
||||
{
|
||||
locale: dateFnsLocale,
|
||||
}
|
||||
),
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<span
|
||||
v-if="post.visibility === PostVisibility.UNLISTED"
|
||||
class="flex gap-2 items-center"
|
||||
>
|
||||
<Link :size="16" />
|
||||
{{ t("Accessible only by link") }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="post.visibility === PostVisibility.PRIVATE"
|
||||
class="flex gap-2 items-center"
|
||||
>
|
||||
<Lock :size="16" />
|
||||
{{
|
||||
t("Accessible only to members", {
|
||||
group: post.attributedTo?.name,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<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
|
||||
tabIndex="-1"
|
||||
v-if="
|
||||
currentActor?.id === post?.author?.id ||
|
||||
isCurrentActorAGroupModerator
|
||||
"
|
||||
>
|
||||
<router-link
|
||||
class="flex gap-1 whitespace-nowrap flex-1"
|
||||
:to="{
|
||||
name: RouteName.POST_EDIT,
|
||||
params: { slug: post.slug },
|
||||
}"
|
||||
>
|
||||
<Pencil />
|
||||
{{ t("Edit") }}
|
||||
</router-link>
|
||||
</o-dropdown-item>
|
||||
<o-dropdown-item
|
||||
aria-role="listitem"
|
||||
v-if="
|
||||
currentActor?.id === post?.author?.id ||
|
||||
isCurrentActorAGroupModerator
|
||||
"
|
||||
tabIndex="-1"
|
||||
>
|
||||
<button
|
||||
@click="openDeletePostModal"
|
||||
class="flex gap-1 whitespace-nowrap"
|
||||
>
|
||||
<Delete />
|
||||
{{ t("Delete") }}
|
||||
</button>
|
||||
</o-dropdown-item>
|
||||
|
||||
<hr
|
||||
role="presentation"
|
||||
class="dropdown-divider"
|
||||
aria-role="menuitem"
|
||||
v-if="
|
||||
currentActor?.id === post?.author?.id ||
|
||||
isCurrentActorAGroupModerator
|
||||
"
|
||||
/>
|
||||
<o-dropdown-item
|
||||
aria-role="listitem"
|
||||
v-if="!post.draft"
|
||||
tabIndex="-1"
|
||||
>
|
||||
<button
|
||||
@click="triggerShare()"
|
||||
class="flex gap-1 whitespace-nowrap"
|
||||
>
|
||||
<Share />
|
||||
{{ t("Share this event") }}
|
||||
</button>
|
||||
</o-dropdown-item>
|
||||
|
||||
<o-dropdown-item
|
||||
aria-role="listitem"
|
||||
v-if="ableToReport"
|
||||
tabIndex="-1"
|
||||
>
|
||||
<button
|
||||
@click="isReportModalActive = true"
|
||||
class="flex gap-1 whitespace-nowrap"
|
||||
>
|
||||
<Flag />
|
||||
{{ t("Report") }}
|
||||
</button>
|
||||
</o-dropdown-item>
|
||||
</o-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<o-notification
|
||||
:title="t('Members-only post')"
|
||||
class="mx-4"
|
||||
variant="warning"
|
||||
:closable="false"
|
||||
v-if="
|
||||
!membershipsLoading &&
|
||||
!postLoading &&
|
||||
isInstanceModerator &&
|
||||
!isCurrentActorAGroupMember &&
|
||||
post.visibility === PostVisibility.PRIVATE
|
||||
"
|
||||
>
|
||||
{{
|
||||
t(
|
||||
"This post is accessible only for members. You have access to it for moderation purposes only because you are an instance moderator."
|
||||
)
|
||||
}}
|
||||
</o-notification>
|
||||
|
||||
<section
|
||||
v-html="post.body"
|
||||
dir="auto"
|
||||
class="px-2 md:px-4 py-4 prose lg:prose-xl prose-p:mt-6 dark:prose-invert bg-white dark:bg-zinc-700 mx-auto"
|
||||
:lang="post.language"
|
||||
/>
|
||||
<section class="flex gap-2 my-6 justify-center" dir="auto">
|
||||
<router-link
|
||||
v-for="tag in post.tags"
|
||||
:key="tag.title"
|
||||
:to="{ name: RouteName.TAG, params: { tag: tag.title } }"
|
||||
>
|
||||
<tag>{{ tag.title }}</tag>
|
||||
</router-link>
|
||||
</section>
|
||||
<o-modal
|
||||
:close-button-aria-label="t('Close')"
|
||||
v-model:active="isReportModalActive"
|
||||
has-modal-card
|
||||
ref="reportModal"
|
||||
:autoFocus="false"
|
||||
:trapFocus="false"
|
||||
>
|
||||
<ReportModal
|
||||
:on-confirm="reportPost"
|
||||
:title="t('Report this post')"
|
||||
:outside-domain="groupDomain"
|
||||
@close="isReportModalActive = false"
|
||||
/>
|
||||
</o-modal>
|
||||
<o-modal
|
||||
v-model:active="isShareModalActive"
|
||||
has-modal-card
|
||||
ref="shareModal"
|
||||
:close-button-aria-label="t('Close')"
|
||||
>
|
||||
<share-post-modal :post="post" />
|
||||
</o-modal>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ICurrentUserRole, MemberRole, PostVisibility } from "@/types/enums";
|
||||
import { PERSON_MEMBERSHIPS } from "@/graphql/actor";
|
||||
import {
|
||||
IGroup,
|
||||
IPerson,
|
||||
usernameWithDomain,
|
||||
displayName,
|
||||
} from "@/types/actor";
|
||||
import RouteName from "@/router/name";
|
||||
import Tag from "@/components/TagElement.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 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 Pencil from "vue-material-design-icons/Pencil.vue";
|
||||
import Delete from "vue-material-design-icons/Delete.vue";
|
||||
import Share from "vue-material-design-icons/Share.vue";
|
||||
import Flag from "vue-material-design-icons/Flag.vue";
|
||||
import Link from "vue-material-design-icons/Link.vue";
|
||||
import { Dialog } from "@/plugins/dialog";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Notifier } from "@/plugins/notifier";
|
||||
import { AbsintheGraphQLErrors } from "@/types/errors.model";
|
||||
|
||||
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 && currentActor.value?.id !== null,
|
||||
})
|
||||
);
|
||||
const memberships = computed(() => membershipsResult.value?.person.memberships);
|
||||
|
||||
const {
|
||||
result: postResult,
|
||||
loading: postLoading,
|
||||
onError: onFetchPostError,
|
||||
} = useQuery<{
|
||||
post: IPost;
|
||||
}>(FETCH_POST, () => ({ slug: props.slug }));
|
||||
|
||||
const handleErrors = (errors: AbsintheGraphQLErrors): void => {
|
||||
if (
|
||||
errors.some((error) => error.status_code === 404) ||
|
||||
errors.some(({ message }) => message.includes("has invalid value $uuid"))
|
||||
) {
|
||||
router.replace({ name: RouteName.PAGE_NOT_FOUND });
|
||||
}
|
||||
};
|
||||
|
||||
onFetchPostError(({ graphQLErrors }) =>
|
||||
handleErrors(graphQLErrors as AbsintheGraphQLErrors)
|
||||
);
|
||||
|
||||
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 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.debug("Successful share"))
|
||||
.catch((error: any) => console.debug("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({
|
||||
variant: "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 } =
|
||||
useMutation(DELETE_POST);
|
||||
|
||||
onDeletePostDone(({ data }) => {
|
||||
if (data && post.value?.attributedTo) {
|
||||
router.push({
|
||||
name: RouteName.POSTS,
|
||||
params: {
|
||||
preferredUsername: usernameWithDomain(post.value?.attributedTo),
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
@use "@/styles/_mixins" as *;
|
||||
article.post {
|
||||
header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
.banner-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
height: 30vh;
|
||||
}
|
||||
|
||||
.heading-section {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 2rem;
|
||||
|
||||
.heading-wrapper {
|
||||
padding: 15px 10px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
.title-metadata {
|
||||
min-width: 300px;
|
||||
flex: 20;
|
||||
|
||||
.title-wrapper {
|
||||
display: inline;
|
||||
|
||||
.tag {
|
||||
height: 38px;
|
||||
vertical-align: text-bottom;
|
||||
}
|
||||
|
||||
& > h1 {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
p.metadata {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: column;
|
||||
|
||||
*:not(:first-child) {
|
||||
@include padding-left(5px);
|
||||
}
|
||||
}
|
||||
}
|
||||
p.buttons {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
h1.title {
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
font-family: "Roboto", "Helvetica", "Arial", serif;
|
||||
}
|
||||
|
||||
.authors {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
&::after {
|
||||
height: 0.2rem;
|
||||
content: " ";
|
||||
display: block;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
||||
753
src/views/Resources/ResourceFolder.vue
Normal file
753
src/views/Resources/ResourceFolder.vue
Normal file
@@ -0,0 +1,753 @@
|
||||
<template>
|
||||
<div class="container mx-auto" v-if="resource">
|
||||
<breadcrumbs-nav :links="breadcrumbLinks">
|
||||
<li>
|
||||
<o-dropdown aria-role="list">
|
||||
<template #trigger>
|
||||
<o-button variant="primary">+</o-button>
|
||||
</template>
|
||||
|
||||
<o-dropdown-item aria-role="listitem" @click="createFolderModal">
|
||||
<Folder />
|
||||
{{ t("New folder") }}
|
||||
</o-dropdown-item>
|
||||
<o-dropdown-item aria-role="listitem" @click="createLinkModal">
|
||||
<Link />
|
||||
{{ t("New link") }}
|
||||
</o-dropdown-item>
|
||||
<hr
|
||||
role="presentation"
|
||||
class="dropdown-divider"
|
||||
v-if="resourceProviders?.length"
|
||||
/>
|
||||
<o-dropdown-item
|
||||
aria-role="listitem"
|
||||
v-for="resourceProvider in resourceProviders"
|
||||
:key="resourceProvider.software"
|
||||
@click="createResourceFromProvider(resourceProvider)"
|
||||
>
|
||||
<o-icon :icon="mapServiceTypeToIcon[resourceProvider.software]" />
|
||||
{{ createSentenceForType(resourceProvider.software) }}
|
||||
</o-dropdown-item>
|
||||
</o-dropdown>
|
||||
</li>
|
||||
</breadcrumbs-nav>
|
||||
<DraggableList
|
||||
:resources="resource.children.elements"
|
||||
:isRoot="resource.path === '/'"
|
||||
:group="resource.actor"
|
||||
@delete="
|
||||
(resourceID: string) =>
|
||||
deleteResource({
|
||||
id: resourceID,
|
||||
})
|
||||
"
|
||||
@update="updateResource"
|
||||
@rename="handleRename"
|
||||
@move="handleMove"
|
||||
/>
|
||||
<o-pagination
|
||||
v-if="resource.children.total > RESOURCES_PER_PAGE"
|
||||
:total="resource.children.total"
|
||||
v-model:current="page"
|
||||
:per-page="RESOURCES_PER_PAGE"
|
||||
: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>
|
||||
<o-modal
|
||||
v-model:active="renameModal"
|
||||
has-modal-card
|
||||
:close-button-aria-label="t('Close')"
|
||||
>
|
||||
<div class="w-full md:w-[640px]">
|
||||
<section>
|
||||
<form @submit.prevent="renameResource">
|
||||
<o-field :label="t('Title')">
|
||||
<o-input
|
||||
ref="resourceRenameInput"
|
||||
aria-required="true"
|
||||
v-model="updatedResource.title"
|
||||
/>
|
||||
</o-field>
|
||||
|
||||
<o-button native-type="submit">{{ t("Rename resource") }}</o-button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</o-modal>
|
||||
<o-modal
|
||||
v-model:active="moveModal"
|
||||
has-modal-card
|
||||
:close-button-aria-label="t('Close')"
|
||||
>
|
||||
<div class="w-full">
|
||||
<section>
|
||||
<resource-selector
|
||||
:initialResource="updatedResource"
|
||||
:username="usernameWithDomain(resource.actor)"
|
||||
@update-resource="moveResource"
|
||||
@close-move-modal="moveModal = false"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</o-modal>
|
||||
<o-modal
|
||||
v-model:active="createResourceModal"
|
||||
has-modal-card
|
||||
:close-button-aria-label="t('Close')"
|
||||
trap-focus
|
||||
>
|
||||
<section class="w-full md:w-[640px]">
|
||||
<o-notification variant="danger" v-if="modalError">
|
||||
{{ modalError }}
|
||||
</o-notification>
|
||||
<form @submit.prevent="createResource">
|
||||
<p v-if="newResource.type !== 'folder'">
|
||||
{{
|
||||
t("The pad will be created on {service}", {
|
||||
service: newResourceHost,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<o-field :label="t('Title')" label-for="new-resource-title">
|
||||
<o-input
|
||||
ref="modalNewResourceInput"
|
||||
aria-required="true"
|
||||
v-model="newResource.title"
|
||||
id="new-resource-title"
|
||||
/>
|
||||
</o-field>
|
||||
|
||||
<o-button class="mt-2" native-type="submit">{{
|
||||
createResourceButtonLabel
|
||||
}}</o-button>
|
||||
</form>
|
||||
</section>
|
||||
</o-modal>
|
||||
<o-modal
|
||||
v-model:active="createLinkResourceModal"
|
||||
has-modal-card
|
||||
aria-modal
|
||||
:close-button-aria-label="t('Close')"
|
||||
trap-focus
|
||||
:width="640"
|
||||
>
|
||||
<div class="w-full md:w-[640px]">
|
||||
<section class="p-10">
|
||||
<o-notification variant="danger" v-if="modalError">
|
||||
{{ modalError }}
|
||||
</o-notification>
|
||||
<form @submit.prevent="createResource">
|
||||
<o-field
|
||||
expanded
|
||||
:label="t('URL')"
|
||||
label-for="new-resource-url"
|
||||
:variant="modalFieldErrors['resource_url'] ? 'danger' : undefined"
|
||||
:message="modalFieldErrors['resource_url']"
|
||||
>
|
||||
<o-input
|
||||
id="new-resource-url"
|
||||
type="url"
|
||||
required
|
||||
v-model="newResource.resourceUrl"
|
||||
@blur="previewResource"
|
||||
ref="modalNewResourceLinkInput"
|
||||
/>
|
||||
</o-field>
|
||||
|
||||
<div class="new-resource-preview" v-if="newResource.title">
|
||||
<resource-item :resource="newResource" :preview="true" />
|
||||
</div>
|
||||
|
||||
<o-field
|
||||
:label="t('Title')"
|
||||
label-for="new-resource-link-title"
|
||||
:variant="modalFieldErrors['title'] ? 'danger' : undefined"
|
||||
:message="modalFieldErrors['title']"
|
||||
>
|
||||
<o-input
|
||||
aria-required="true"
|
||||
v-model="newResource.title"
|
||||
id="new-resource-link-title"
|
||||
/>
|
||||
</o-field>
|
||||
|
||||
<o-field
|
||||
:label="t('Description')"
|
||||
label-for="new-resource-summary"
|
||||
:variant="modalFieldErrors['summary'] ? 'danger' : undefined"
|
||||
:message="modalFieldErrors['summary']"
|
||||
>
|
||||
<o-input
|
||||
type="textarea"
|
||||
v-model="newResource.summary"
|
||||
id="new-resource-summary"
|
||||
/>
|
||||
</o-field>
|
||||
|
||||
<o-button native-type="submit" class="mt-2">{{
|
||||
t("Create resource")
|
||||
}}</o-button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</o-modal>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import ResourceItem from "@/components/Resource/ResourceItem.vue";
|
||||
import { displayName, usernameWithDomain } from "@/types/actor";
|
||||
import RouteName from "@/router/name";
|
||||
import {
|
||||
IResource,
|
||||
mapServiceTypeToIcon,
|
||||
IProvider,
|
||||
IResourceMetadata,
|
||||
} from "@/types/resource";
|
||||
import {
|
||||
CREATE_RESOURCE,
|
||||
DELETE_RESOURCE,
|
||||
PREVIEW_RESOURCE_LINK,
|
||||
GET_RESOURCE,
|
||||
UPDATE_RESOURCE,
|
||||
} from "@/graphql/resources";
|
||||
import ResourceSelector from "@/components/Resource/ResourceSelector.vue";
|
||||
import {
|
||||
ApolloCache,
|
||||
FetchResult,
|
||||
InternalRefetchQueriesInclude,
|
||||
} from "@apollo/client/core";
|
||||
import { useMutation, useQuery } from "@vue/apollo-composable";
|
||||
import { computed, nextTick, reactive, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { integerTransformer, useRouteQuery } from "vue-use-route-query";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useHead } from "@vueuse/head";
|
||||
import { useResourceProviders } from "@/composition/apollo/config";
|
||||
import Folder from "vue-material-design-icons/Folder.vue";
|
||||
import Link from "vue-material-design-icons/Link.vue";
|
||||
import DraggableList from "@/components/Resource/DraggableList.vue";
|
||||
import { resourcePathArray } from "@/components/Resource/utils";
|
||||
import {
|
||||
AbsintheGraphQLError,
|
||||
AbsintheGraphQLErrors,
|
||||
} from "@/types/errors.model";
|
||||
|
||||
const RESOURCES_PER_PAGE = 10;
|
||||
const page = useRouteQuery("page", 1, integerTransformer);
|
||||
|
||||
const props = defineProps<{
|
||||
path: string | string[];
|
||||
preferredUsername: string;
|
||||
}>();
|
||||
|
||||
const {
|
||||
result: resourceResult,
|
||||
onError: onGetResourceError,
|
||||
fetchMore,
|
||||
} = useQuery<{
|
||||
resource: IResource;
|
||||
}>(GET_RESOURCE, () => {
|
||||
let path = Array.isArray(props.path) ? props.path.join("/") : props.path;
|
||||
path = path[0] !== "/" ? `/${path}` : path;
|
||||
return {
|
||||
path,
|
||||
username: props.preferredUsername,
|
||||
page: page.value,
|
||||
limit: RESOURCES_PER_PAGE,
|
||||
};
|
||||
});
|
||||
|
||||
const resource = computed(() => resourceResult.value?.resource);
|
||||
|
||||
onGetResourceError(({ graphQLErrors }) => {
|
||||
handleErrors(graphQLErrors);
|
||||
});
|
||||
|
||||
const { resourceProviders } = useResourceProviders();
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
// config: CONFIG,
|
||||
|
||||
const newResource = reactive<IResource>({
|
||||
title: "",
|
||||
summary: "",
|
||||
resourceUrl: "",
|
||||
children: { elements: [], total: 0 },
|
||||
metadata: {},
|
||||
type: "link",
|
||||
});
|
||||
|
||||
const updatedResource = ref<IResource>({
|
||||
title: "",
|
||||
resourceUrl: "",
|
||||
metadata: {},
|
||||
children: { elements: [], total: 0 },
|
||||
path: undefined,
|
||||
});
|
||||
|
||||
const createResourceModal = ref(false);
|
||||
const createLinkResourceModal = ref(false);
|
||||
const moveModal = ref(false);
|
||||
const renameModal = ref(false);
|
||||
const modalError = ref("");
|
||||
const modalFieldErrors: Record<string, string> = reactive({});
|
||||
|
||||
const resourceRenameInput = ref<any>();
|
||||
const modalNewResourceInput = ref<HTMLElement>();
|
||||
const modalNewResourceLinkInput = ref<HTMLElement>();
|
||||
|
||||
const actualPath = computed((): string => {
|
||||
const path = Array.isArray(props.path) ? props.path.join("/") : props.path;
|
||||
return path[0] !== "/" ? `/${path}` : path;
|
||||
});
|
||||
|
||||
const filteredPath = computed((): string[] => {
|
||||
if (resource.value?.path !== "/") {
|
||||
return resourcePathArray(resource.value);
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const isRoot = computed((): boolean => {
|
||||
return actualPath.value === "/";
|
||||
});
|
||||
|
||||
const lastFragment = computed((): string | undefined => {
|
||||
return filteredPath.value.slice(-1)[0];
|
||||
});
|
||||
|
||||
const {
|
||||
mutate: createResourceMutation,
|
||||
onDone: createResourceDone,
|
||||
onError: createResourceError,
|
||||
} = useMutation(CREATE_RESOURCE, () => ({
|
||||
refetchQueries: () => postRefreshQueries(),
|
||||
}));
|
||||
|
||||
createResourceDone(() => {
|
||||
createLinkResourceModal.value = false;
|
||||
createResourceModal.value = false;
|
||||
newResource.title = "";
|
||||
newResource.summary = "";
|
||||
newResource.resourceUrl = "";
|
||||
});
|
||||
|
||||
createResourceError((err) => {
|
||||
console.error(err);
|
||||
const error = err.graphQLErrors[0] as AbsintheGraphQLError;
|
||||
if (error.field) {
|
||||
modalFieldErrors[error.field] = (error.message as unknown as string[]).join(
|
||||
","
|
||||
);
|
||||
} else {
|
||||
modalError.value = (error.message as unknown as string[]).join(",");
|
||||
}
|
||||
});
|
||||
|
||||
const createResource = () => {
|
||||
if (!resource.value?.actor) return;
|
||||
modalError.value = "";
|
||||
createResourceMutation({
|
||||
title: newResource.title,
|
||||
summary: newResource.summary,
|
||||
actorId: resource.value.actor?.id,
|
||||
resourceUrl: newResource.resourceUrl,
|
||||
parentId: resource.value?.id?.startsWith("root_")
|
||||
? null
|
||||
: resource.value?.id,
|
||||
type: newResource.type,
|
||||
});
|
||||
};
|
||||
|
||||
const {
|
||||
mutate: previewResourceLinkMutation,
|
||||
onDone: previewDone,
|
||||
onError: previewError,
|
||||
} = useMutation<{ previewResourceLink: IResourceMetadata }>(
|
||||
PREVIEW_RESOURCE_LINK
|
||||
);
|
||||
|
||||
previewDone(({ data }) => {
|
||||
if (!data?.previewResourceLink) return;
|
||||
newResource.title = data?.previewResourceLink.title ?? "";
|
||||
newResource.summary = data?.previewResourceLink?.description?.substring(
|
||||
0,
|
||||
390
|
||||
);
|
||||
newResource.metadata = data?.previewResourceLink;
|
||||
newResource.type = "link";
|
||||
});
|
||||
|
||||
previewError((err) => {
|
||||
console.error(err);
|
||||
const error = err.graphQLErrors[0] as AbsintheGraphQLError;
|
||||
if (error.field) {
|
||||
modalFieldErrors[error.field] = error.message;
|
||||
} else {
|
||||
modalError.value = err.graphQLErrors[0].message;
|
||||
}
|
||||
});
|
||||
|
||||
const previewResource = async (): Promise<void> => {
|
||||
modalError.value = "";
|
||||
if (newResource.resourceUrl === "") return;
|
||||
previewResourceLinkMutation({
|
||||
resourceUrl: newResource.resourceUrl,
|
||||
});
|
||||
};
|
||||
|
||||
const createSentenceForType = (type: string): string => {
|
||||
switch (type) {
|
||||
case "folder":
|
||||
return t("Create a folder") as string;
|
||||
case "pad":
|
||||
return t("Create a pad") as string;
|
||||
case "calc":
|
||||
return t("Create a calc") as string;
|
||||
case "visio":
|
||||
return t("Create a videoconference") as string;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const createLinkModal = async (): Promise<void> => {
|
||||
createLinkResourceModal.value = true;
|
||||
await nextTick();
|
||||
modalNewResourceLinkInput.value?.focus();
|
||||
};
|
||||
|
||||
const createFolderModal = async (): Promise<void> => {
|
||||
newResource.type = "folder";
|
||||
createResourceModal.value = true;
|
||||
await nextTick();
|
||||
modalNewResourceInput.value?.focus();
|
||||
};
|
||||
|
||||
const createResourceFromProvider = async (
|
||||
provider: IProvider
|
||||
): Promise<void> => {
|
||||
newResource.resourceUrl = generateFullResourceUrl(provider);
|
||||
newResource.type = provider.software;
|
||||
createResourceModal.value = true;
|
||||
await nextTick();
|
||||
modalNewResourceInput.value?.focus();
|
||||
};
|
||||
|
||||
const generateFullResourceUrl = (provider: IProvider): string => {
|
||||
const randomString = [...Array(10)]
|
||||
.map(() => Math.random().toString(36)[3])
|
||||
.join("")
|
||||
.replace(/(.|$)/g, (c) =>
|
||||
c[!Math.round(Math.random()) ? "toString" : "toLowerCase"]()
|
||||
);
|
||||
switch (provider.type) {
|
||||
case "ethercalc":
|
||||
case "etherpad":
|
||||
case "jitsi":
|
||||
default:
|
||||
return `${provider.endpoint}${randomString}`;
|
||||
}
|
||||
};
|
||||
|
||||
const createResourceButtonLabel = computed((): string => {
|
||||
if (!newResource.type) return "";
|
||||
return createSentenceForType(newResource.type);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
const postRefreshQueries = (): InternalRefetchQueriesInclude => {
|
||||
return [
|
||||
{
|
||||
query: GET_RESOURCE,
|
||||
variables: {
|
||||
path: actualPath.value,
|
||||
username: props.preferredUsername,
|
||||
page: page.value,
|
||||
limit: RESOURCES_PER_PAGE,
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const { mutate: deleteResource, onError: onDeleteResourceError } = useMutation(
|
||||
DELETE_RESOURCE,
|
||||
() => ({
|
||||
refetchQueries: () => postRefreshQueries(),
|
||||
})
|
||||
);
|
||||
|
||||
onDeleteResourceError((e) => console.error(e));
|
||||
|
||||
const handleRename = async (resourceToRename: IResource): Promise<void> => {
|
||||
renameModal.value = true;
|
||||
updatedResource.value = { ...resourceToRename };
|
||||
await nextTick();
|
||||
resourceRenameInput.value?.$el.focus();
|
||||
resourceRenameInput.value?.$el.querySelector("input")?.select();
|
||||
};
|
||||
|
||||
const handleMove = (resourceToMove: IResource): void => {
|
||||
moveModal.value = true;
|
||||
updatedResource.value = { ...resourceToMove };
|
||||
};
|
||||
|
||||
const moveResource = async (
|
||||
resourceToMove: IResource,
|
||||
oldParent: IResource | undefined
|
||||
): Promise<void> => {
|
||||
const parentPath = oldParent && oldParent.path ? oldParent.path || "/" : "/";
|
||||
await updateResource(resourceToMove, parentPath);
|
||||
moveModal.value = false;
|
||||
};
|
||||
|
||||
const renameResource = async (): Promise<void> => {
|
||||
await updateResource(updatedResource.value);
|
||||
renameModal.value = false;
|
||||
};
|
||||
|
||||
const { mutate: updateResourceMutation } = useMutation<{
|
||||
updateResource: IResource;
|
||||
}>(UPDATE_RESOURCE, () => ({
|
||||
refetchQueries: () => postRefreshQueries(),
|
||||
update: (
|
||||
store: ApolloCache<{ updateResource: IResource }>,
|
||||
{ data }: FetchResult,
|
||||
{ context }
|
||||
) => {
|
||||
const parentPath = context?.parentPath;
|
||||
if (!data || data.updateResource == null || parentPath == null) return;
|
||||
if (!resource.value?.actor) return;
|
||||
|
||||
console.debug("Removing ressource from old parent");
|
||||
const oldParentCachedData = store.readQuery<{ resource: IResource }>({
|
||||
query: GET_RESOURCE,
|
||||
variables: {
|
||||
path: parentPath,
|
||||
username: resource.value.actor.preferredUsername,
|
||||
},
|
||||
});
|
||||
if (oldParentCachedData == null) return;
|
||||
const { resource: oldParentCachedResource } = oldParentCachedData;
|
||||
if (oldParentCachedResource == null) {
|
||||
console.error("Cannot update resource cache, because of null value.");
|
||||
return;
|
||||
}
|
||||
const postUpdatedResource: IResource = data.updateResource;
|
||||
|
||||
const updatedElementList = oldParentCachedResource.children.elements.filter(
|
||||
(cachedResource) => cachedResource.id !== postUpdatedResource.id
|
||||
);
|
||||
|
||||
store.writeQuery({
|
||||
query: GET_RESOURCE,
|
||||
variables: {
|
||||
path: parentPath,
|
||||
username: resource.value.actor.preferredUsername,
|
||||
},
|
||||
data: {
|
||||
resource: {
|
||||
...oldParentCachedResource,
|
||||
children: {
|
||||
...oldParentCachedResource.children,
|
||||
elements: [...updatedElementList],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
console.debug("Finished removing ressource from old parent");
|
||||
|
||||
console.debug("Adding resource to new parent");
|
||||
if (!postUpdatedResource.parent || !postUpdatedResource.parent.path) {
|
||||
console.debug("No cache found for new parent");
|
||||
return;
|
||||
}
|
||||
const newParentCachedData = store.readQuery<{ resource: IResource }>({
|
||||
query: GET_RESOURCE,
|
||||
variables: {
|
||||
path: postUpdatedResource.parent.path,
|
||||
username: resource.value.actor.preferredUsername,
|
||||
},
|
||||
});
|
||||
if (newParentCachedData == null) return;
|
||||
const { resource: newParentCachedResource } = newParentCachedData;
|
||||
if (newParentCachedResource == null) {
|
||||
console.error("Cannot update resource cache, because of null value.");
|
||||
return;
|
||||
}
|
||||
|
||||
store.writeQuery({
|
||||
query: GET_RESOURCE,
|
||||
variables: {
|
||||
path: postUpdatedResource.parent.path,
|
||||
username: resource.value.actor.preferredUsername,
|
||||
},
|
||||
data: {
|
||||
resource: {
|
||||
...newParentCachedResource,
|
||||
children: {
|
||||
...newParentCachedResource.children,
|
||||
elements: [...newParentCachedResource.children.elements, resource],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
console.debug("Finished adding resource to new parent");
|
||||
},
|
||||
}));
|
||||
|
||||
const updateResource = async (
|
||||
resourceToUpdate: IResource,
|
||||
parentPath: string | null = null
|
||||
): Promise<void> => {
|
||||
console.debug(
|
||||
`Update resource « ${resourceToUpdate.title} » at path ${resourceToUpdate.path}`
|
||||
);
|
||||
updateResourceMutation(
|
||||
{
|
||||
id: resourceToUpdate.id,
|
||||
title: resourceToUpdate.title,
|
||||
parentId: resourceToUpdate.parent ? resourceToUpdate.parent.id : null,
|
||||
path: resourceToUpdate.path,
|
||||
},
|
||||
{ context: { parentPath } }
|
||||
);
|
||||
};
|
||||
|
||||
watch(page, () => {
|
||||
fetchMore({
|
||||
// New variables
|
||||
variables: {
|
||||
page: page.value,
|
||||
limit: RESOURCES_PER_PAGE,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const handleErrors = (errors: AbsintheGraphQLErrors): void => {
|
||||
if (errors.some((error) => error.status_code === 404)) {
|
||||
router.replace({ name: RouteName.PAGE_NOT_FOUND });
|
||||
}
|
||||
};
|
||||
|
||||
const breadcrumbLinks = computed(() => {
|
||||
if (!resource.value?.actor) return [];
|
||||
const resourceActor = resource.value.actor;
|
||||
const links = [
|
||||
{
|
||||
name: RouteName.GROUP,
|
||||
params: { preferredUsername: usernameWithDomain(resource.value.actor) },
|
||||
text: displayName(resource.value.actor),
|
||||
},
|
||||
{
|
||||
name: RouteName.RESOURCE_FOLDER_ROOT,
|
||||
params: { preferredUsername: usernameWithDomain(resource.value.actor) },
|
||||
text: t("Resources") as string,
|
||||
},
|
||||
];
|
||||
|
||||
links.push(
|
||||
...filteredPath.value.map((pathFragment, index) => {
|
||||
return {
|
||||
name: RouteName.RESOURCE_FOLDER,
|
||||
params: {
|
||||
path: resourcePathArray(resource.value).slice(
|
||||
0,
|
||||
index + 1
|
||||
) as unknown as string,
|
||||
preferredUsername: usernameWithDomain(resourceActor),
|
||||
},
|
||||
text: pathFragment,
|
||||
};
|
||||
})
|
||||
);
|
||||
return links;
|
||||
});
|
||||
|
||||
const newResourceHost = computed(() => {
|
||||
if (!newResource.resourceUrl) return;
|
||||
return new URL(newResource.resourceUrl).host;
|
||||
});
|
||||
|
||||
useHead({
|
||||
title: computed(() =>
|
||||
isRoot.value
|
||||
? t("Resources")
|
||||
: t("{folder} - Resources", {
|
||||
folder: lastFragment.value,
|
||||
})
|
||||
),
|
||||
});
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
@use "@/styles/_mixins" as *;
|
||||
|
||||
nav.breadcrumb ul {
|
||||
align-items: center;
|
||||
|
||||
li:last-child .dropdown {
|
||||
@include margin-left(5px);
|
||||
|
||||
a {
|
||||
justify-content: left;
|
||||
color: inherit;
|
||||
padding: 0.375rem 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.list-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
.list-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
:deep(.b-checkbox.checkbox) {
|
||||
@include margin-left(10px);
|
||||
}
|
||||
|
||||
.actions {
|
||||
@include margin-right(5px);
|
||||
|
||||
& > * {
|
||||
@include margin-left(5px);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.resource-item,
|
||||
.new-resource-preview {
|
||||
display: flex;
|
||||
font-size: 14px;
|
||||
border: 1px solid #c0cdd9;
|
||||
border-radius: 4px;
|
||||
// color: #444b5d;
|
||||
margin-top: 14px;
|
||||
margin-bottom: 14px;
|
||||
|
||||
.resource-checkbox {
|
||||
align-self: center;
|
||||
@include padding-left(10px);
|
||||
opacity: 0.3;
|
||||
|
||||
:deep(.b-checkbox.checkbox) {
|
||||
@include margin-right(0.25rem);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .resource-checkbox,
|
||||
.resource-checkbox.checked {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
1311
src/views/SearchView.vue
Normal file
1311
src/views/SearchView.vue
Normal file
File diff suppressed because it is too large
Load Diff
418
src/views/Settings/AccountSettings.vue
Normal file
418
src/views/Settings/AccountSettings.vue
Normal file
@@ -0,0 +1,418 @@
|
||||
<template>
|
||||
<div v-if="loggedUser">
|
||||
<breadcrumbs-nav
|
||||
:links="[
|
||||
{
|
||||
name: RouteName.ACCOUNT_SETTINGS,
|
||||
text: t('Account'),
|
||||
},
|
||||
{
|
||||
name: RouteName.ACCOUNT_SETTINGS_GENERAL,
|
||||
text: t('General'),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<section>
|
||||
<h2>{{ t("Email") }}</h2>
|
||||
<i18n-t
|
||||
tag="p"
|
||||
class="prose dark:prose-invert"
|
||||
v-if="loggedUser"
|
||||
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"
|
||||
>
|
||||
{{
|
||||
t(
|
||||
"Your email address was automatically set based on your {provider} account.",
|
||||
{
|
||||
provider: providerName(loggedUser.provider),
|
||||
}
|
||||
)
|
||||
}}
|
||||
</o-notification>
|
||||
<o-notification
|
||||
variant="danger"
|
||||
has-icon
|
||||
aria-close-label="Close notification"
|
||||
role="alert"
|
||||
:key="error"
|
||||
v-for="error in changeEmailErrors"
|
||||
>{{ error }}</o-notification
|
||||
>
|
||||
<form
|
||||
@submit.prevent="resetEmailAction"
|
||||
ref="emailForm"
|
||||
class="form"
|
||||
v-if="canChangeEmail"
|
||||
>
|
||||
<o-field :label="t('New email')" label-for="account-email">
|
||||
<o-input
|
||||
aria-required="true"
|
||||
required
|
||||
type="email"
|
||||
id="account-email"
|
||||
v-model="newEmail"
|
||||
/>
|
||||
</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"
|
||||
id="account-password"
|
||||
password-reveal
|
||||
minlength="6"
|
||||
v-model="passwordForEmailChange"
|
||||
/>
|
||||
</o-field>
|
||||
<o-button class="mt-2" variant="primary" nativeType="submit">
|
||||
{{ t("Change my email") }}
|
||||
</o-button>
|
||||
</form>
|
||||
<h2 class="mt-2">{{ t("Password") }}</h2>
|
||||
<o-notification
|
||||
v-if="!canChangePassword && loggedUser.provider"
|
||||
variant="warning"
|
||||
:closable="false"
|
||||
>
|
||||
{{
|
||||
t(
|
||||
"You can't change your password because you are registered through {provider}.",
|
||||
{
|
||||
provider: providerName(loggedUser.provider),
|
||||
}
|
||||
)
|
||||
}}
|
||||
</o-notification>
|
||||
<o-notification
|
||||
variant="danger"
|
||||
has-icon
|
||||
aria-close-label="Close notification"
|
||||
role="alert"
|
||||
:key="error"
|
||||
v-for="error in changePasswordErrors"
|
||||
>{{ error }}</o-notification
|
||||
>
|
||||
<form
|
||||
@submit.prevent="resetPasswordAction"
|
||||
ref="passwordForm"
|
||||
class="form"
|
||||
v-if="canChangePassword"
|
||||
>
|
||||
<o-field :label="t('Old password')" label-for="account-old-password">
|
||||
<o-input
|
||||
aria-required="true"
|
||||
required
|
||||
type="password"
|
||||
password-reveal
|
||||
minlength="6"
|
||||
id="account-old-password"
|
||||
v-model="oldPassword"
|
||||
/>
|
||||
</o-field>
|
||||
<o-field :label="t('New password')" label-for="account-new-password">
|
||||
<o-input
|
||||
aria-required="true"
|
||||
required
|
||||
type="password"
|
||||
password-reveal
|
||||
minlength="6"
|
||||
id="account-new-password"
|
||||
v-model="newPassword"
|
||||
/>
|
||||
</o-field>
|
||||
<o-button class="mt-2" variant="primary" nativeType="submit">
|
||||
{{ t("Change my password") }}
|
||||
</o-button>
|
||||
</form>
|
||||
<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>
|
||||
<o-button @click="openDeleteAccountModal" variant="danger" class="mb-4">
|
||||
{{ t("Delete my account") }}
|
||||
</o-button>
|
||||
|
||||
<o-modal
|
||||
:close-button-aria-label="t('Close')"
|
||||
v-model:active="isDeleteAccountModalActive"
|
||||
has-modal-card
|
||||
full-screen
|
||||
:can-cancel="false"
|
||||
>
|
||||
<section class="">
|
||||
<div class="">
|
||||
<div class="container mx-auto max-w-md">
|
||||
<div class="">
|
||||
<div class="">
|
||||
<h1 class="title">
|
||||
{{ t("Deleting your Mobilizon account") }}
|
||||
</h1>
|
||||
<p class="prose dark:prose-invert">
|
||||
{{
|
||||
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>
|
||||
</p>
|
||||
<p class="prose dark:prose-invert" v-if="hasUserGotAPassword">
|
||||
{{
|
||||
t("Please enter your password to confirm this action.")
|
||||
}}
|
||||
</p>
|
||||
<form @submit.prevent="deleteAccount">
|
||||
<o-field
|
||||
:type="deleteAccountPasswordFieldType"
|
||||
v-if="hasUserGotAPassword"
|
||||
label-for="account-deletion-password"
|
||||
>
|
||||
<o-input
|
||||
type="password"
|
||||
v-model="passwordForAccountDeletion"
|
||||
password-reveal
|
||||
id="account-deletion-password"
|
||||
:aria-label="t('Password')"
|
||||
icon="lock"
|
||||
:placeholder="t('Password')"
|
||||
/>
|
||||
<template #message>
|
||||
<o-notification
|
||||
class="mt-2 not-italic text-base"
|
||||
variant="danger"
|
||||
v-for="message in deletePasswordErrors"
|
||||
:key="message"
|
||||
>
|
||||
{{ message }}
|
||||
</o-notification>
|
||||
</template>
|
||||
</o-field>
|
||||
<div class="flex items-center justify-center">
|
||||
<o-button
|
||||
class="mt-2"
|
||||
native-type="submit"
|
||||
variant="danger"
|
||||
size="large"
|
||||
>
|
||||
{{ t("Delete everything") }}
|
||||
</o-button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="mt-4 text-center">
|
||||
<o-button
|
||||
variant="light"
|
||||
@click="isDeleteAccountModalActive = false"
|
||||
>
|
||||
{{ t("Cancel") }}
|
||||
</o-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</o-modal>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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 { computed, inject, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useRouter } from "vue-router";
|
||||
import {
|
||||
CHANGE_EMAIL,
|
||||
CHANGE_PASSWORD,
|
||||
DELETE_ACCOUNT,
|
||||
} from "../../graphql/user";
|
||||
import RouteName from "../../router/name";
|
||||
import { logout, SELECTED_PROVIDERS } from "../../utils/auth";
|
||||
import { useProgrammatic } from "@oruga-ui/oruga-next";
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
const { loggedUser } = useLoggedUser();
|
||||
|
||||
useHead({
|
||||
title: computed(() => t("General settings")),
|
||||
});
|
||||
|
||||
const passwordForm = ref<HTMLFormElement>();
|
||||
const emailForm = ref<HTMLFormElement>();
|
||||
|
||||
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("");
|
||||
|
||||
const notifier = inject<Notifier>("notifier");
|
||||
|
||||
const {
|
||||
mutate: changeEmailMutation,
|
||||
onDone: changeEmailMutationDone,
|
||||
onError: changeEmailMutationError,
|
||||
} = useMutation(CHANGE_EMAIL);
|
||||
|
||||
changeEmailMutationDone(() => {
|
||||
notifier?.info(
|
||||
t(
|
||||
"The account's email address was changed. Check your emails to verify it."
|
||||
)
|
||||
);
|
||||
newEmail.value = "";
|
||||
passwordForEmailChange.value = "";
|
||||
});
|
||||
|
||||
changeEmailMutationError((err) => {
|
||||
handleErrors("email", err);
|
||||
});
|
||||
|
||||
const resetEmailAction = async (): Promise<void> => {
|
||||
if (emailForm.value?.reportValidity()) {
|
||||
changeEmailErrors.value = [];
|
||||
|
||||
changeEmailMutation({
|
||||
email: newEmail.value,
|
||||
password: passwordForEmailChange.value,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const {
|
||||
mutate: changePasswordMutation,
|
||||
onDone: onChangePasswordMutationDone,
|
||||
onError: onChangePasswordMutationError,
|
||||
} = useMutation(CHANGE_PASSWORD);
|
||||
|
||||
onChangePasswordMutationDone(() => {
|
||||
oldPassword.value = "";
|
||||
newPassword.value = "";
|
||||
notifier?.success(t("The password was successfully changed"));
|
||||
});
|
||||
|
||||
onChangePasswordMutationError((err) => {
|
||||
handleErrors("password", err);
|
||||
});
|
||||
|
||||
const resetPasswordAction = async (): Promise<void> => {
|
||||
if (passwordForm.value?.reportValidity()) {
|
||||
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;
|
||||
};
|
||||
|
||||
const hasUserGotAPassword = computed((): boolean => {
|
||||
return (
|
||||
loggedUser.value?.provider == null ||
|
||||
loggedUser.value?.provider === IAuthProvider.LDAP
|
||||
);
|
||||
});
|
||||
|
||||
const deleteAccountPasswordFieldType = computed((): string | null => {
|
||||
return deletePasswordErrors.value.length > 0 ? "is-danger" : null;
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
</script>
|
||||
153
src/views/Settings/AppsView.vue
Normal file
153
src/views/Settings/AppsView.vue
Normal file
@@ -0,0 +1,153 @@
|
||||
<template>
|
||||
<div v-if="loggedUser">
|
||||
<breadcrumbs-nav
|
||||
:links="[
|
||||
{
|
||||
name: RouteName.AUTHORIZED_APPS,
|
||||
text: t('Apps'),
|
||||
},
|
||||
{
|
||||
name: RouteName.ACCOUNT_SETTINGS_GENERAL,
|
||||
text: t('General'),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<section>
|
||||
<h2>{{ t("Apps") }}</h2>
|
||||
<p>
|
||||
{{
|
||||
t(
|
||||
"These apps can access your account through the API. If you see here apps that you don't recognize, that don't work as expected or that you don't use anymore, you can revoke their access."
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<div v-if="authAuthorizedApplications.length > 0">
|
||||
<div
|
||||
class="flex justify-between items-center rounded-lg bg-white shadow-xl my-6"
|
||||
v-for="authAuthorizedApplication in authAuthorizedApplications"
|
||||
:key="authAuthorizedApplication.id"
|
||||
>
|
||||
<div class="p-4">
|
||||
<p class="text-3xl font-bold">
|
||||
{{ authAuthorizedApplication.application.name }}
|
||||
</p>
|
||||
<a
|
||||
v-if="authAuthorizedApplication.application.website"
|
||||
target="_blank"
|
||||
:href="authAuthorizedApplication.application.website"
|
||||
>{{
|
||||
urlToHostname(authAuthorizedApplication.application.website)
|
||||
}}</a
|
||||
>
|
||||
<p>
|
||||
<span v-if="authAuthorizedApplication.lastUsedAt">{{
|
||||
t("Last used on {last_used_date}", {
|
||||
last_used_date: formatDateString(
|
||||
authAuthorizedApplication.lastUsedAt
|
||||
),
|
||||
})
|
||||
}}</span>
|
||||
<span v-else>{{ t("Never used") }}</span> ⋅
|
||||
{{
|
||||
t("Authorized on {authorization_date}", {
|
||||
authorization_date: formatDateString(
|
||||
authAuthorizedApplication.insertedAt
|
||||
),
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<o-button
|
||||
@click="
|
||||
() => revoke({ appTokenId: authAuthorizedApplication.id })
|
||||
"
|
||||
variant="danger"
|
||||
>{{ t("Revoke") }}</o-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<EmptyContent v-else icon="apps" inline>
|
||||
{{ t("No apps authorized yet") }}
|
||||
</EmptyContent>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useLoggedUser } from "@/composition/apollo/user";
|
||||
import {
|
||||
AUTH_AUTHORIZED_APPLICATIONS,
|
||||
REVOKED_AUTHORIZED_APPLICATION,
|
||||
} from "@/graphql/application";
|
||||
import { useMutation, useQuery } from "@vue/apollo-composable";
|
||||
import { useHead } from "@vueuse/head";
|
||||
import { computed, inject } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import RouteName from "../../router/name";
|
||||
import { IUser } from "@/types/current-user.model";
|
||||
import { formatDateString } from "@/filters/datetime";
|
||||
import { Notifier } from "@/plugins/notifier";
|
||||
import EmptyContent from "@/components/Utils/EmptyContent.vue";
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
const { loggedUser } = useLoggedUser();
|
||||
|
||||
const { result: authAuthorizedApplicationsResult } = useQuery<{
|
||||
loggedUser: Pick<IUser, "authAuthorizedApplications">;
|
||||
}>(AUTH_AUTHORIZED_APPLICATIONS);
|
||||
|
||||
const authAuthorizedApplications = computed(
|
||||
() =>
|
||||
authAuthorizedApplicationsResult.value?.loggedUser
|
||||
?.authAuthorizedApplications ?? []
|
||||
);
|
||||
|
||||
const urlToHostname = (url: string | undefined): string | null => {
|
||||
if (!url) return null;
|
||||
try {
|
||||
return new URL(url).hostname;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const { mutate: revoke, onDone: onRevokedApplication } = useMutation<
|
||||
{ revokeApplicationToken: { id: string } },
|
||||
{ appTokenId: string }
|
||||
>(REVOKED_AUTHORIZED_APPLICATION, {
|
||||
update: (cache, { data: returnedData }) => {
|
||||
const data = cache.readQuery<{
|
||||
loggedUser: Pick<IUser, "authAuthorizedApplications">;
|
||||
}>({ query: AUTH_AUTHORIZED_APPLICATIONS });
|
||||
if (!data) return;
|
||||
if (!returnedData) return;
|
||||
const authorizedApplications =
|
||||
data.loggedUser.authAuthorizedApplications.filter(
|
||||
(app) => app.id !== returnedData.revokeApplicationToken.id
|
||||
);
|
||||
cache.writeQuery({
|
||||
query: AUTH_AUTHORIZED_APPLICATIONS,
|
||||
data: {
|
||||
...data,
|
||||
loggedUser: {
|
||||
...data.loggedUser,
|
||||
authAuthorizedApplications: authorizedApplications,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const notifier = inject<Notifier>("notifier");
|
||||
|
||||
onRevokedApplication(() => {
|
||||
notifier?.success(t("Application was revoked"));
|
||||
});
|
||||
|
||||
useHead({
|
||||
title: computed(() => t("Apps")),
|
||||
});
|
||||
</script>
|
||||
858
src/views/Settings/NotificationsView.vue
Normal file
858
src/views/Settings/NotificationsView.vue
Normal file
@@ -0,0 +1,858 @@
|
||||
<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 table-auto">
|
||||
<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
|
||||
:modelValue="notificationValues?.[subType.id]?.[key]?.enabled"
|
||||
@update:modelValue="
|
||||
(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"
|
||||
@update:modelValue="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"
|
||||
variant="text"
|
||||
@click="openRegenerateFeedTokensConfirmation"
|
||||
@keyup.enter="openRegenerateFeedTokensConfirmation"
|
||||
>{{ $t("Regenerate new links") }}</o-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<o-button
|
||||
icon-left="refresh"
|
||||
variant="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 },
|
||||
},
|
||||
conversation_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: "conversation_mention",
|
||||
label: t("I've been mentionned in a conversation") as string,
|
||||
},
|
||||
{
|
||||
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({
|
||||
variant: "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.debug(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 {
|
||||
text-decoration: underline;
|
||||
text-decoration-thickness: 2px;
|
||||
@include margin-left(5px);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.buttons > *:not(:last-child) .button) {
|
||||
margin-right: 0.5rem;
|
||||
@include margin-right(0.5rem);
|
||||
}
|
||||
</style>
|
||||
351
src/views/Settings/PreferencesView.vue
Normal file
351
src/views/Settings/PreferencesView.vue
Normal file
@@ -0,0 +1,351 @@
|
||||
<template>
|
||||
<div>
|
||||
<breadcrumbs-nav
|
||||
:links="[
|
||||
{
|
||||
name: RouteName.ACCOUNT_SETTINGS,
|
||||
text: t('Account'),
|
||||
},
|
||||
{
|
||||
name: RouteName.PREFERENCES,
|
||||
text: t('Preferences'),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<div>
|
||||
<o-field :label="t('Theme')" addonsClass="flex flex-col">
|
||||
<o-field>
|
||||
<o-checkbox v-model="systemTheme">{{
|
||||
t("Adapt to system theme")
|
||||
}}</o-checkbox>
|
||||
</o-field>
|
||||
<o-field>
|
||||
<fieldset>
|
||||
<legend class="sr-only">{{ t("Theme") }}</legend>
|
||||
<o-radio
|
||||
:class="{ 'border-mbz-bluegreen': theme === 'light' }"
|
||||
class="p-4 bg-white text-zinc-800 rounded-md mt-2 mr-2 border-2"
|
||||
:disabled="systemTheme"
|
||||
v-model="theme"
|
||||
name="theme"
|
||||
native-value="light"
|
||||
>{{ t("Light") }}</o-radio
|
||||
>
|
||||
<o-radio
|
||||
:class="{ 'border-mbz-bluegreen': theme === 'dark' }"
|
||||
class="p-4 bg-zinc-800 rounded-md text-white mt-2 ml-2 border-2"
|
||||
:disabled="systemTheme"
|
||||
v-model="theme"
|
||||
name="theme"
|
||||
native-value="dark"
|
||||
>{{ t("Dark") }}</o-radio
|
||||
>
|
||||
</fieldset>
|
||||
</o-field>
|
||||
</o-field>
|
||||
<o-field :label="t('Language')" label-for="setting-language">
|
||||
<o-select
|
||||
:loading="loadingTimezones || loadingUserSettings"
|
||||
v-model="$i18n.locale"
|
||||
@update:modelValue="updateLanguage"
|
||||
: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"
|
||||
:resultType="AddressSearchType.ADMINISTRATIVE"
|
||||
v-model="address"
|
||||
:default-text="address?.description"
|
||||
id="setting-city"
|
||||
class="grid"
|
||||
:hideMap="true"
|
||||
:hideSelected="true"
|
||||
labelClass="sr-only"
|
||||
: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 { USER_SETTINGS, SET_USER_SETTINGS } from "../../graphql/user";
|
||||
import langs from "../../i18n/langs.json";
|
||||
import RouteName from "../../router/name";
|
||||
import { AddressSearchType } from "@/types/enums";
|
||||
import { Address, IAddress } from "@/types/address.model";
|
||||
import { useTimezones } from "@/composition/apollo/config";
|
||||
import { useUserSettings, updateLocale } from "@/composition/apollo/user";
|
||||
import { useHead } from "@vueuse/head";
|
||||
import { computed, defineAsyncComponent, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useMutation } from "@vue/apollo-composable";
|
||||
|
||||
const FullAddressAutoComplete = defineAsyncComponent(
|
||||
() => import("@/components/Event/FullAddressAutoComplete.vue")
|
||||
);
|
||||
|
||||
const { timezones: serverTimezones, loading: loadingTimezones } =
|
||||
useTimezones();
|
||||
const { loggedUser, loading: loadingUserSettings } = useUserSettings();
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
useHead({
|
||||
title: computed(() => t("Preferences")),
|
||||
});
|
||||
|
||||
// langs: Record<string, string> = langs;
|
||||
|
||||
const theme = ref(localStorage.getItem("theme"));
|
||||
const systemTheme = ref(!("theme" in localStorage));
|
||||
|
||||
const { mutate: doUpdateLocale } = updateLocale();
|
||||
|
||||
const updateLanguage = (newLocale: string) => {
|
||||
doUpdateLocale({ locale: newLocale });
|
||||
};
|
||||
|
||||
watch(systemTheme, (newSystemTheme) => {
|
||||
console.debug("changing system theme", newSystemTheme);
|
||||
if (newSystemTheme) {
|
||||
theme.value = null;
|
||||
localStorage.removeItem("theme");
|
||||
} else {
|
||||
theme.value = "light";
|
||||
localStorage.setItem("theme", theme.value);
|
||||
}
|
||||
changeTheme();
|
||||
});
|
||||
|
||||
watch(theme, (newTheme) => {
|
||||
console.debug("changing theme value", newTheme);
|
||||
if (newTheme) {
|
||||
localStorage.setItem("theme", newTheme);
|
||||
}
|
||||
changeTheme();
|
||||
});
|
||||
|
||||
const changeTheme = () => {
|
||||
console.debug("changing theme to apply");
|
||||
if (
|
||||
localStorage.getItem("theme") === "dark" ||
|
||||
(!("theme" in localStorage) &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches)
|
||||
) {
|
||||
console.debug("applying dark theme");
|
||||
document.documentElement.classList.add("dark");
|
||||
} else {
|
||||
console.debug("removing dark theme");
|
||||
document.documentElement.classList.remove("dark");
|
||||
}
|
||||
};
|
||||
|
||||
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 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>
|
||||
23
src/views/SettingsView.vue
Normal file
23
src/views/SettingsView.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<div class="container mx-auto">
|
||||
<h1 class="text-violet-3 dark:text-white">{{ 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>
|
||||
109
src/views/Todos/TodoList.vue
Normal file
109
src/views/Todos/TodoList.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<section class="container mx-auto" v-if="todoList">
|
||||
<breadcrumbs-nav
|
||||
:links="[
|
||||
{
|
||||
name: RouteName.GROUP,
|
||||
params: { preferredUsername: usernameWithDomain(todoList.actor) },
|
||||
text: groupDisplayName,
|
||||
},
|
||||
{
|
||||
name: RouteName.TODO_LISTS,
|
||||
params: { preferredUsername: usernameWithDomain(todoList.actor) },
|
||||
text: $t('Task lists'),
|
||||
},
|
||||
{
|
||||
name: RouteName.TODO_LIST,
|
||||
params: { id: todoList.id },
|
||||
text: todoList.title,
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<h2 class="title">{{ todoList.title }}</h2>
|
||||
<div v-for="todo in todoList.todos.elements" :key="todo.id">
|
||||
<compact-todo :todo="todo" />
|
||||
</div>
|
||||
<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" setup>
|
||||
import { ITodo } from "@/types/todos";
|
||||
import { CREATE_TODO, FETCH_TODO_LIST } from "@/graphql/todos";
|
||||
import CompactTodo from "@/components/Todo/CompactTodo.vue";
|
||||
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";
|
||||
|
||||
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,
|
||||
variables: { id: todoList.value?.id },
|
||||
});
|
||||
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>
|
||||
105
src/views/Todos/TodoLists.vue
Normal file
105
src/views/Todos/TodoLists.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<div class="container mx-auto" v-if="group">
|
||||
<breadcrumbs-nav
|
||||
:links="[
|
||||
{
|
||||
name: RouteName.GROUP,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
text: displayName(group),
|
||||
},
|
||||
{
|
||||
name: RouteName.TODO_LISTS,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
text: $t('Task lists'),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<section>
|
||||
<p>
|
||||
{{
|
||||
$t(
|
||||
"Create to-do lists for all the tasks you need to do, assign them and set due dates."
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<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
|
||||
:to="{ name: RouteName.TODO_LIST, params: { id: todoList.id } }"
|
||||
>
|
||||
<h3>
|
||||
{{
|
||||
$t(
|
||||
"{title} ({count} todos)",
|
||||
{
|
||||
count: todoList.todos.total,
|
||||
title: todoList.title,
|
||||
},
|
||||
todoList.todos.total
|
||||
)
|
||||
}}
|
||||
</h3>
|
||||
</router-link>
|
||||
<compact-todo
|
||||
:todo="todo"
|
||||
v-for="todo in todoList.todos.elements"
|
||||
:key="todo.id"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<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";
|
||||
|
||||
const props = defineProps<{ preferredUsername: string }>();
|
||||
const preferredUsername = computed(() => props.preferredUsername);
|
||||
|
||||
const { group } = useGroup(preferredUsername);
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
useHead({
|
||||
title: computed(() =>
|
||||
t("{group}'s todolists", { group: displayName(group.value) })
|
||||
),
|
||||
});
|
||||
|
||||
const newTodoList = reactive<ITodoList>({
|
||||
title: "",
|
||||
id: "",
|
||||
todos: { elements: [], total: 0 },
|
||||
});
|
||||
|
||||
const todoLists = computed((): ITodoList[] => {
|
||||
return group.value?.todoLists.elements ?? [];
|
||||
});
|
||||
|
||||
// const todoListsCount = computed((): number => {
|
||||
// return group.value?.todoLists.total ?? 0;
|
||||
// });
|
||||
|
||||
const { mutate: createNewTodoList } = useMutation(CREATE_TODO_LIST);
|
||||
</script>
|
||||
51
src/views/Todos/TodoView.vue
Normal file
51
src/views/Todos/TodoView.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<section class="container mx-auto" v-if="todo">
|
||||
<breadcrumbs-nav
|
||||
:links="[
|
||||
{
|
||||
name: RouteName.GROUP,
|
||||
params: {
|
||||
preferredUsername: usernameWithDomain(todo.todoList.actor),
|
||||
},
|
||||
text: displayName(todo.todoList.actor),
|
||||
},
|
||||
{
|
||||
name: RouteName.TODO_LISTS,
|
||||
params: {
|
||||
preferredUsername: usernameWithDomain(todo.todoList.actor),
|
||||
},
|
||||
text: $t('Task lists'),
|
||||
},
|
||||
{
|
||||
name: RouteName.TODO_LIST,
|
||||
params: { id: todo.todoList.id },
|
||||
text: todo.todoList.title,
|
||||
},
|
||||
{ name: RouteName.TODO, text: todo.title },
|
||||
]"
|
||||
/>
|
||||
<full-todo :todo="todo" />
|
||||
</section>
|
||||
</template>
|
||||
<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";
|
||||
|
||||
const props = defineProps<{ todoId: string }>();
|
||||
|
||||
const { result: todoResult } = useQuery<{ todo: ITodo }>(GET_TODO, () => ({
|
||||
id: props.todoId,
|
||||
}));
|
||||
|
||||
const todo = computed(() => todoResult.value?.todo);
|
||||
|
||||
useHead({
|
||||
title: computed(() => todo.value?.title),
|
||||
});
|
||||
</script>
|
||||
59
src/views/User/EmailValidate.vue
Normal file
59
src/views/User/EmailValidate.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<section class="container mx-auto">
|
||||
<h1 class="title" v-if="loading">
|
||||
{{ t("Your email is being changed") }}
|
||||
</h1>
|
||||
<div v-else>
|
||||
<div v-if="failed">
|
||||
<o-notification
|
||||
:title="t('Error while changing email')"
|
||||
variant="danger"
|
||||
>
|
||||
{{
|
||||
t(
|
||||
"Either the email has already been changed, either the validation token is incorrect."
|
||||
)
|
||||
}}
|
||||
</o-notification>
|
||||
</div>
|
||||
<h1 class="title" v-else>{{ t("Your email has been changed") }}</h1>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<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";
|
||||
|
||||
// metaInfo() {
|
||||
// return {
|
||||
// title: this.t("Validating email") as string,
|
||||
// };
|
||||
// },
|
||||
const props = defineProps<{
|
||||
token: string;
|
||||
}>();
|
||||
|
||||
const loading = ref(true);
|
||||
const failed = ref(false);
|
||||
const router = useRouter();
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
onBeforeMount(() => validateEmail({ token: props.token }));
|
||||
|
||||
const { mutate: validateEmail, onDone, onError } = useMutation(VALIDATE_EMAIL);
|
||||
|
||||
onDone(async () => {
|
||||
loading.value = false;
|
||||
await router.push({ name: RouteName.HOME });
|
||||
});
|
||||
onError((err) => {
|
||||
loading.value = false;
|
||||
console.error(err);
|
||||
failed.value = true;
|
||||
});
|
||||
</script>
|
||||
313
src/views/User/LoginView.vue
Normal file
313
src/views/User/LoginView.vue
Normal file
@@ -0,0 +1,313 @@
|
||||
<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" v-if="config?.auth?.databaseLogin">
|
||||
<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="text-center my-2">
|
||||
<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="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="canRegister">
|
||||
<o-button
|
||||
tag="router-link"
|
||||
variant="text"
|
||||
:to="{
|
||||
name: RouteName.REGISTER,
|
||||
query: {
|
||||
default_email: credentials.email,
|
||||
default_password: credentials.password,
|
||||
},
|
||||
}"
|
||||
>{{ t("Create an account") }}</o-button
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
<div v-if="config && config?.auth?.oauthProviders?.length > 0">
|
||||
<auth-providers :oauthProviders="config.auth.oauthProviders" />
|
||||
</div>
|
||||
</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";
|
||||
import { useHead } from "@vueuse/head";
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const { currentUser } = useCurrentUserClient();
|
||||
|
||||
const { result: configResult } = useQuery<{
|
||||
config: Pick<
|
||||
IConfig,
|
||||
"auth" | "registrationsOpen" | "registrationsAllowlist"
|
||||
>;
|
||||
}>(LOGIN_CONFIG);
|
||||
|
||||
const config = computed(() => configResult.value?.config);
|
||||
|
||||
const canRegister = computed(() => {
|
||||
return (
|
||||
(config.value?.registrationsOpen || config.value?.registrationsAllowlist) &&
|
||||
config.value?.auth?.databaseLogin
|
||||
);
|
||||
});
|
||||
|
||||
const errors = ref<string[]>([]);
|
||||
const submitted = ref(false);
|
||||
|
||||
const credentials = reactive({
|
||||
email: typeof route.query.email === "string" ? route.query.email : "",
|
||||
password:
|
||||
typeof route.query.password === "string" ? route.query.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;
|
||||
}
|
||||
console.debug("No redirect, going to homepage");
|
||||
if (window.localStorage) {
|
||||
console.debug("Has localstorage, setting welcome back");
|
||||
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;
|
||||
errors.value = [];
|
||||
|
||||
loginMutation({
|
||||
email: credentials.email,
|
||||
password: credentials.password,
|
||||
});
|
||||
};
|
||||
|
||||
const { onDone: onCurrentUserMutationDone, mutate: updateCurrentUserMutation } =
|
||||
useMutation(UPDATE_CURRENT_USER_CLIENT);
|
||||
|
||||
onCurrentUserMutationDone(async () => {
|
||||
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",
|
||||
},
|
||||
});
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const setupClientUserAndActors = async (login: ILogin): Promise<void> => {
|
||||
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 "warning";
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const currentProvider = computed(() => {
|
||||
const queryProvider = route?.query.provider as string | undefined;
|
||||
if (queryProvider) {
|
||||
return SELECTED_PROVIDERS[queryProvider];
|
||||
}
|
||||
return "unknown provider";
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
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("/");
|
||||
}
|
||||
});
|
||||
|
||||
useHead({
|
||||
title: computed(() => t("Login")),
|
||||
});
|
||||
</script>
|
||||
115
src/views/User/PasswordReset.vue
Normal file
115
src/views/User/PasswordReset.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<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" 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";
|
||||
|
||||
const props = defineProps<{ token: string }>();
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
useHead({ title: computed(() => t("Password reset")) });
|
||||
|
||||
const credentials = reactive<{
|
||||
password: string;
|
||||
passwordConfirmation: string;
|
||||
}>({
|
||||
password: "",
|
||||
passwordConfirmation: "",
|
||||
});
|
||||
|
||||
const errors = ref<string[]>([]);
|
||||
|
||||
// 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");
|
||||
}
|
||||
|
||||
saveUserData(data.resetPassword);
|
||||
router.push({ name: RouteName.HOME });
|
||||
return;
|
||||
});
|
||||
|
||||
resetPasswordMutationError((err) => {
|
||||
err.graphQLErrors.forEach(({ message }: { message: any }) => {
|
||||
errors.value.push(message);
|
||||
});
|
||||
});
|
||||
|
||||
const resetAction = (e: Event) => {
|
||||
e.preventDefault();
|
||||
errors.value.splice(0);
|
||||
|
||||
resetPasswordMutation({
|
||||
password: credentials.password,
|
||||
token: props.token,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
86
src/views/User/ProviderValidation.vue
Normal file
86
src/views/User/ProviderValidation.vue
Normal file
@@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<p>{{ t("Redirecting in progress…") }}</p>
|
||||
</template>
|
||||
|
||||
<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 } from "../../utils/auth";
|
||||
import { changeIdentity } from "../../utils/identity";
|
||||
import { ICurrentUser, IUser } from "../../types/current-user.model";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useLazyQuery, useMutation } from "@vue/apollo-composable";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useHead } from "@vueuse/head";
|
||||
import { computed, onMounted } from "vue";
|
||||
import { getValueFromMeta } from "@/utils/html";
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
useHead({
|
||||
title: computed(() => t("Redirecting to Mobilizon")),
|
||||
});
|
||||
|
||||
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: onUpdateCurrentUserClientDone,
|
||||
mutate: updateCurrentUserClient,
|
||||
} = useMutation<
|
||||
{ updateCurrentUser: ICurrentUser },
|
||||
{ id: string; email: string; isLoggedIn: boolean; role: ICurrentUserRole }
|
||||
>(UPDATE_CURRENT_USER_CLIENT);
|
||||
|
||||
const { load: loadUser } = useLazyQuery<{
|
||||
loggedUser: IUser;
|
||||
}>(LOGGED_USER);
|
||||
|
||||
onUpdateCurrentUserClientDone(async () => {
|
||||
try {
|
||||
const result = await loadUser();
|
||||
if (!result) return;
|
||||
const loggedUser = result.loggedUser;
|
||||
if (loggedUser.defaultActor) {
|
||||
await changeIdentity(loggedUser.defaultActor);
|
||||
await router.push({ name: RouteName.HOME });
|
||||
} else {
|
||||
// No need to push to REGISTER_PROFILE, the navbar will do it for us
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
if (!(userId && userEmail && userRole && accessToken && refreshToken)) {
|
||||
await router.push("/");
|
||||
} else {
|
||||
const login = {
|
||||
user: {
|
||||
id: userId,
|
||||
email: userEmail,
|
||||
role: userRole,
|
||||
isLoggedIn: true,
|
||||
defaultActor: undefined,
|
||||
actors: [],
|
||||
},
|
||||
accessToken,
|
||||
refreshToken,
|
||||
};
|
||||
saveUserData(login);
|
||||
|
||||
updateCurrentUserClient({
|
||||
id: userId,
|
||||
email: userEmail,
|
||||
isLoggedIn: true,
|
||||
role: userRole,
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
340
src/views/User/RegisterView.vue
Normal file
340
src/views/User/RegisterView.vue
Normal file
@@ -0,0 +1,340 @@
|
||||
<template>
|
||||
<div class="container mx-auto py-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 #instanceName>
|
||||
<b>{{ config?.name }}</b>
|
||||
</template>
|
||||
<template #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 #fullRules>
|
||||
<router-link class="out" :to="{ name: RouteName.RULES }">{{
|
||||
t("full rules")
|
||||
}}</router-link>
|
||||
</template>
|
||||
<template #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')"
|
||||
:variant="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 mt-2">
|
||||
<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 #instanceRules>
|
||||
<router-link class="out" :to="{ name: RouteName.RULES }">{{
|
||||
t("instance rules")
|
||||
}}</router-link>
|
||||
</template>
|
||||
<template #termsOfService>
|
||||
<router-link class="out" :to="{ name: RouteName.TERMS }">{{
|
||||
t("terms of service")
|
||||
}}</router-link>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
<o-button
|
||||
variant="primary"
|
||||
size="large"
|
||||
:disabled="sendingForm"
|
||||
native-type="submit"
|
||||
>
|
||||
{{ t("Create an account") }}
|
||||
</o-button>
|
||||
</p>
|
||||
|
||||
<p class="my-6">
|
||||
<o-button
|
||||
tag="router-link"
|
||||
variant="text"
|
||||
:to="{
|
||||
name: RouteName.RESEND_CONFIRMATION,
|
||||
params: { email: credentials.email },
|
||||
}"
|
||||
>{{ t("Didn't receive the instructions?") }}</o-button
|
||||
>
|
||||
<o-button
|
||||
tag="router-link"
|
||||
variant="text"
|
||||
:to="{
|
||||
name: RouteName.LOGIN,
|
||||
query: {
|
||||
email: credentials.email,
|
||||
password: credentials.password,
|
||||
},
|
||||
}"
|
||||
>{{ t("Login") }}</o-button
|
||||
>
|
||||
</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 { computed, reactive, ref, watch } from "vue";
|
||||
import { useMutation, useQuery } from "@vue/apollo-composable";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useHead } from "@vueuse/head";
|
||||
import { AbsintheGraphQLErrors } from "@/types/errors.model";
|
||||
|
||||
type errorType = "danger" | "warning";
|
||||
type errorMessage = { type: errorType; message: string };
|
||||
type credentialsType = { email: string; password: string; locale: string };
|
||||
|
||||
const { t, locale } = useI18n({ useScope: "global" });
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const { result: configResult } = useQuery<{ config: IConfig }>(CONFIG);
|
||||
|
||||
const config = computed(() => configResult.value?.config);
|
||||
|
||||
const showGravatar = ref(false);
|
||||
|
||||
const credentials = reactive<credentialsType>({
|
||||
email: typeof route.query.email === "string" ? route.query.email : "",
|
||||
password:
|
||||
typeof route.query.password === "string" ? route.query.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,
|
||||
});
|
||||
}
|
||||
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) => {
|
||||
(error.graphQLErrors as AbsintheGraphQLErrors).forEach(
|
||||
({ field, message }) => {
|
||||
switch (field) {
|
||||
case "email":
|
||||
emailErrors.value.push({
|
||||
type: "danger" as errorType,
|
||||
message: message[0] as string,
|
||||
});
|
||||
break;
|
||||
case "password":
|
||||
passwordErrors.value.push({
|
||||
type: "danger" as errorType,
|
||||
message: message[0] as string,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
}
|
||||
}
|
||||
);
|
||||
sendingForm.value = false;
|
||||
});
|
||||
|
||||
const submit = async (): Promise<void> => {
|
||||
sendingForm.value = true;
|
||||
credentials.locale = locale as unknown 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: "warning" as errorType,
|
||||
message: t(
|
||||
"Emails usually don't contain capitals, make sure you haven't made a typo."
|
||||
),
|
||||
};
|
||||
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 === "danger" || acc === "danger") return "danger";
|
||||
return "warning";
|
||||
}, "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>
|
||||
97
src/views/User/ResendConfirmation.vue
Normal file
97
src/views/User/ResendConfirmation.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<section class="container mx-auto pt-4 max-w-2xl">
|
||||
<h1>
|
||||
{{ $t("Resend confirmation email") }}
|
||||
</h1>
|
||||
<o-notification v-if="error" variant="danger">
|
||||
{{ errorMessage }}
|
||||
</o-notification>
|
||||
<form v-if="!validationSent" @submit="resendConfirmationAction">
|
||||
<o-field :label="$t('Email address')" labelFor="emailAddress">
|
||||
<o-input
|
||||
aria-required="true"
|
||||
required
|
||||
type="email"
|
||||
id="emailAddress"
|
||||
v-model="emailValue"
|
||||
/>
|
||||
</o-field>
|
||||
<p class="flex flex-wrap gap-1 mt-2">
|
||||
<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: emailValue }
|
||||
)
|
||||
}}
|
||||
</o-notification>
|
||||
<o-notification variant="info" class="mt-2">
|
||||
{{
|
||||
$t("Please check your spam folder if you didn't receive the email.")
|
||||
}}
|
||||
</o-notification>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { RESEND_CONFIRMATION_EMAIL } from "@/graphql/auth";
|
||||
import RouteName from "@/router/name";
|
||||
import { ref, computed } from "vue";
|
||||
import { useMutation } from "@vue/apollo-composable";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useHead } from "@vueuse/head";
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
useHead({
|
||||
title: computed(() => t("Resend confirmation email")),
|
||||
meta: [{ name: "robots", content: "noindex" }],
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<{ email: string }>(), { email: "" });
|
||||
const defaultEmail = computed(() => props.email);
|
||||
|
||||
const emailValue = ref<string>(defaultEmail.value);
|
||||
|
||||
const validationSent = ref(false);
|
||||
const error = ref(false);
|
||||
const errorMessage = ref<string>();
|
||||
|
||||
const {
|
||||
mutate: resendConfirmationEmail,
|
||||
onDone: resentConfirmationEmail,
|
||||
onError: resentConfirmationEmailError,
|
||||
} = useMutation(RESEND_CONFIRMATION_EMAIL);
|
||||
|
||||
resentConfirmationEmail(() => {
|
||||
validationSent.value = true;
|
||||
});
|
||||
|
||||
resentConfirmationEmailError((err) => {
|
||||
console.error(err);
|
||||
error.value = true;
|
||||
errorMessage.value = err.graphQLErrors[0].message;
|
||||
});
|
||||
|
||||
const resendConfirmationAction = async (e: Event): Promise<void> => {
|
||||
e.preventDefault();
|
||||
error.value = false;
|
||||
|
||||
resendConfirmationEmail({
|
||||
email: emailValue.value,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
115
src/views/User/SendPasswordReset.vue
Normal file
115
src/views/User/SendPasswordReset.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<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="emailValue"
|
||||
/>
|
||||
</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: emailValue,
|
||||
})
|
||||
}}
|
||||
</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" setup>
|
||||
import { SEND_RESET_PASSWORD } from "../../graphql/auth";
|
||||
import RouteName from "../../router/name";
|
||||
import { computed, ref } from "vue";
|
||||
import { useMutation } from "@vue/apollo-composable";
|
||||
import { useHead } from "@vueuse/head";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
useHead({
|
||||
title: computed(() => t("Reset password")),
|
||||
});
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
email?: string;
|
||||
}>(),
|
||||
{ email: "" }
|
||||
);
|
||||
|
||||
const defaultEmail = computed(() => props.email);
|
||||
const emailValue = ref<string>(defaultEmail.value);
|
||||
|
||||
const validationSent = ref(false);
|
||||
|
||||
const errors = ref<string[]>([]);
|
||||
|
||||
const removeError = (message: string): void => {
|
||||
errors.value.splice(errors.value.indexOf(message));
|
||||
};
|
||||
|
||||
const {
|
||||
mutate: sendResetPasswordMutation,
|
||||
onDone: sendResetPasswordDone,
|
||||
onError: sendResetPasswordError,
|
||||
} = useMutation(SEND_RESET_PASSWORD);
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const sendResetPasswordTokenAction = async (e: Event): Promise<void> => {
|
||||
e.preventDefault();
|
||||
|
||||
sendResetPasswordMutation({
|
||||
email: emailValue.value,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
106
src/views/User/SettingsOnboard.vue
Normal file
106
src/views/User/SettingsOnboard.vue
Normal file
@@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<div class="container mx-auto">
|
||||
<h1 class="title">{{ $t("Let's define a few settings") }}</h1>
|
||||
<o-steps v-model="stepIndex" :has-navigation="false">
|
||||
<o-step-item step="1" :label="$t('Settings')">
|
||||
<settings-onboarding />
|
||||
</o-step-item>
|
||||
<o-step-item step="2" :label="$t('Participation notifications')">
|
||||
<notifications-onboarding />
|
||||
</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">
|
||||
<o-button
|
||||
outlined
|
||||
:disabled="stepIndex < 1"
|
||||
tag="router-link"
|
||||
:to="{
|
||||
name: RouteName.WELCOME_SCREEN,
|
||||
params: { step: stepIndex },
|
||||
}"
|
||||
>
|
||||
{{ $t("Previous") }}
|
||||
</o-button>
|
||||
<o-button
|
||||
outlined
|
||||
variant="success"
|
||||
v-if="stepIndex < 2"
|
||||
tag="router-link"
|
||||
:to="{
|
||||
name: RouteName.WELCOME_SCREEN,
|
||||
params: { step: stepIndex + 2 },
|
||||
}"
|
||||
>
|
||||
{{ $t("Next") }}
|
||||
</o-button>
|
||||
<o-button
|
||||
v-if="stepIndex >= 2"
|
||||
variant="success"
|
||||
size="big"
|
||||
tag="router-link"
|
||||
:to="{ name: RouteName.HOME }"
|
||||
>
|
||||
{{ $t("All good, let's continue!") }}
|
||||
</o-button>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { USER_SETTINGS } from "@/graphql/user";
|
||||
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";
|
||||
|
||||
const { currentActor } = useCurrentActorClient();
|
||||
const { instanceName } = useInstanceName();
|
||||
const { refetch } = useQuery<{ loggedUser: IUser }>(USER_SETTINGS);
|
||||
|
||||
const NotificationsOnboarding = defineAsyncComponent(
|
||||
() => import("@/components/Settings/NotificationsOnboarding.vue")
|
||||
);
|
||||
const SettingsOnboarding = defineAsyncComponent(
|
||||
() => import("@/components/Settings/SettingsOnboarding.vue")
|
||||
);
|
||||
const ProfileOnboarding = defineAsyncComponent(
|
||||
() => import("@/components/Account/ProfileOnboarding.vue")
|
||||
);
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
step?: number;
|
||||
}>(),
|
||||
{
|
||||
step: 1,
|
||||
}
|
||||
);
|
||||
|
||||
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 {
|
||||
.has-text-centered.section.buttons {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
103
src/views/User/ValidateUser.vue
Normal file
103
src/views/User/ValidateUser.vue
Normal 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>
|
||||
Reference in New Issue
Block a user