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:
Thomas Citharel
2023-11-14 17:24:42 +01:00
parent 32055122c3
commit 2e72f6faf4
595 changed files with 12078 additions and 7843 deletions

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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
View File

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

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

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

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

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

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

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

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

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

File diff suppressed because it is too large Load Diff

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

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

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

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

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

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

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

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

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

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

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

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

View 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

File diff suppressed because it is too large Load Diff

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

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