Add global search

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel
2022-08-26 16:08:58 +02:00
parent bfc936f57c
commit 48935e2168
216 changed files with 3646 additions and 2806 deletions

View File

@@ -106,22 +106,29 @@
</template>
<script lang="ts" setup>
import { CONFIG } from "@/graphql/config";
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 }>(CONFIG);
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}", {

View File

@@ -91,7 +91,7 @@
<o-button
v-if="isUpdate"
@click="openDeleteIdentityConfirmation()"
type="is-text"
variant="text"
>
{{ $t("Delete this identity") }}
</o-button>
@@ -593,7 +593,7 @@ const dialog = inject<Dialog>("dialog");
const openRegenerateFeedTokensConfirmation = (): void => {
dialog?.confirm({
type: "is-warning",
variant: "warning",
title: t("Regenerate new links") as string,
message: t(
"You'll need to change the URLs where there were previously entered."
@@ -606,7 +606,7 @@ const openRegenerateFeedTokensConfirmation = (): void => {
const openDeleteIdentityConfirmation = (): void => {
dialog?.prompt({
type: "danger",
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."

View File

@@ -112,33 +112,27 @@
:label="t('Member')"
v-slot="props"
>
<article class="media">
<figure
class="media-left image is-48x48"
v-if="props.row.actor.avatar"
>
<img
class="is-rounded"
:src="props.row.actor.avatar.url"
alt=""
/>
</figure>
<o-icon
class="media-left"
v-else
size="large"
icon="account-circle"
/>
<div class="media-content">
<article class="flex gap-1">
<div class="flex-none">
<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 />
</div>
<div>
<div class="prose dark:prose-invert">
<span v-if="props.row.actor.name">{{
props.row.actor.name
}}</span
><span v-else>@{{ usernameWithDomain(props.row.actor) }}</span
><br />
<span
v-if="props.row.actor.name"
class="is-size-7 has-text-grey"
<span v-if="props.row.actor.name"
>@{{ usernameWithDomain(props.row.actor) }}</span
>
</div>
@@ -146,39 +140,39 @@
</article>
</o-table-column>
<o-table-column field="role" :label="t('Role')" v-slot="props">
<b-tag
<tag
variant="primary"
v-if="props.row.role === MemberRole.ADMINISTRATOR"
>
{{ t("Administrator") }}
</b-tag>
<b-tag
</tag>
<tag
variant="primary"
v-else-if="props.row.role === MemberRole.MODERATOR"
>
{{ t("Moderator") }}
</b-tag>
<b-tag v-else-if="props.row.role === MemberRole.MEMBER">
</tag>
<tag v-else-if="props.row.role === MemberRole.MEMBER">
{{ t("Member") }}
</b-tag>
<b-tag
</tag>
<tag
variant="warning"
v-else-if="props.row.role === MemberRole.NOT_APPROVED"
>
{{ t("Not approved") }}
</b-tag>
<b-tag
</tag>
<tag
variant="danger"
v-else-if="props.row.role === MemberRole.REJECTED"
>
{{ t("Rejected") }}
</b-tag>
<b-tag
</tag>
<tag
variant="danger"
v-else-if="props.row.role === MemberRole.INVITED"
>
{{ t("Invited") }}
</b-tag>
</tag>
</o-table-column>
<o-table-column field="insertedAt" :label="t('Date')" v-slot="props">
<span class="has-text-centered">
@@ -225,9 +219,7 @@
:to="{ name: RouteName.EVENT, params: { uuid: props.row.uuid } }"
>
{{ props.row.title }}
<b-tag variant="info" v-if="props.row.draft">{{
t("Draft")
}}</b-tag>
<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">
@@ -271,9 +263,7 @@
:to="{ name: RouteName.POST, params: { slug: props.row.slug } }"
>
{{ props.row.title }}
<b-tag variant="info" v-if="props.row.draft">{{
t("Draft")
}}</b-tag>
<tag variant="info" v-if="props.row.draft">{{ t("Draft") }}</tag>
</router-link>
</o-table-column>
<o-table-column
@@ -295,7 +285,7 @@
{{ t("This group was not found") }}
<template #desc>
<o-button
type="is-text"
variant="text"
tag="router-link"
:to="{ name: RouteName.ADMIN_GROUPS }"
>{{ t("Back to group list") }}</o-button
@@ -330,6 +320,8 @@ import {
} 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/Tag.vue";
const EVENTS_PER_PAGE = 10;
const POSTS_PER_PAGE = 10;
@@ -396,23 +388,21 @@ 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>."
)
) as string;
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") as string,
title: t("Suspend group"),
message,
confirmText: t("Suspend group") as string,
cancelText: t("Cancel") as string,
type: "danger",
confirmText: t("Suspend group"),
cancelText: t("Cancel"),
variant: "danger",
hasIcon: true,
onConfirm: () =>
suspendProfile({

View File

@@ -34,14 +34,14 @@
<tr
v-for="{ key, value, link } in metadata"
:key="key"
class="odd:bg-white even:bg-gray-50 border-b"
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 whitespace-nowrap"
class="py-4 px-2 text-sm text-gray-500 dark:text-gray-200 whitespace-nowrap"
>
<router-link :to="link">
{{ value }}
@@ -49,7 +49,7 @@
</td>
<td
v-else
class="py-4 px-2 text-sm text-gray-500 whitespace-nowrap"
class="py-4 px-2 text-sm text-gray-500 dark:text-gray-200 whitespace-nowrap"
>
{{ value }}
</td>
@@ -102,7 +102,7 @@
<section class="mt-4 mb-3">
<h2 class="">{{ $t("Organized events") }}</h2>
<o-table
:data="person.organizedEvents.elements"
:data="person.organizedEvents?.elements"
:loading="loading"
paginated
backend-pagination
@@ -111,7 +111,7 @@
:aria-previous-label="$t('Previous page')"
:aria-page-label="$t('Page')"
:aria-current-label="$t('Current page')"
:total="person.organizedEvents.total"
:total="person.organizedEvents?.total"
:per-page="EVENTS_PER_PAGE"
@page-change="onOrganizedEventsPageChange"
>
@@ -140,7 +140,7 @@
<h2 class="">{{ $t("Participations") }}</h2>
<o-table
:data="
person.participations.elements.map(
person.participations?.elements.map(
(participation) => participation.event
)
"
@@ -152,7 +152,7 @@
:aria-previous-label="$t('Previous page')"
:aria-page-label="$t('Page')"
:aria-current-label="$t('Current page')"
:total="person.participations.total"
:total="person.participations?.total"
:per-page="EVENTS_PER_PAGE"
@page-change="onParticipationsPageChange"
>
@@ -180,7 +180,7 @@
<section class="mt-4 mb-3">
<h2 class="">{{ $t("Memberships") }}</h2>
<o-table
:data="person.memberships.elements"
:data="person.memberships?.elements"
:loading="loading"
paginated
backend-pagination
@@ -189,7 +189,7 @@
:aria-previous-label="$t('Previous page')"
:aria-page-label="$t('Page')"
:aria-current-label="$t('Current page')"
:total="person.memberships.total"
:total="person.memberships?.total"
:per-page="EVENTS_PER_PAGE"
@page-change="onMembershipsPageChange"
>
@@ -215,47 +215,45 @@
props.row.parent.name
}}</span
><br />
<span class="is-size-7 has-text-grey"
>@{{ usernameWithDomain(props.row.parent) }}</span
>
<span>@{{ usernameWithDomain(props.row.parent) }}</span>
</div>
</div>
</article>
</o-table-column>
<o-table-column field="role" :label="$t('Role')" v-slot="props">
<b-tag
<tag
variant="primary"
v-if="props.row.role === MemberRole.ADMINISTRATOR"
>
{{ $t("Administrator") }}
</b-tag>
<b-tag
</tag>
<tag
variant="primary"
v-else-if="props.row.role === MemberRole.MODERATOR"
>
{{ $t("Moderator") }}
</b-tag>
<b-tag v-else-if="props.row.role === MemberRole.MEMBER">
</tag>
<tag v-else-if="props.row.role === MemberRole.MEMBER">
{{ $t("Member") }}
</b-tag>
<b-tag
</tag>
<tag
variant="warning"
v-else-if="props.row.role === MemberRole.NOT_APPROVED"
>
{{ $t("Not approved") }}
</b-tag>
<b-tag
</tag>
<tag
variant="danger"
v-else-if="props.row.role === MemberRole.REJECTED"
>
{{ $t("Rejected") }}
</b-tag>
<b-tag
</tag>
<tag
variant="danger"
v-else-if="props.row.role === MemberRole.INVITED"
>
{{ $t("Invited") }}
</b-tag>
</tag>
</o-table-column>
<o-table-column field="insertedAt" :label="$t('Date')" v-slot="props">
<span class="has-text-centered">
@@ -276,7 +274,7 @@
{{ $t("This profile was not found") }}
<template #desc>
<o-button
type="is-text"
variant="text"
tag="router-link"
:to="{ name: RouteName.PROFILES }"
>{{ $t("Back to profile list") }}</o-button
@@ -310,6 +308,7 @@ import {
formatDateTimeString,
} from "@/filters/datetime";
import AccountCircle from "vue-material-design-icons/AccountCircle.vue";
import Tag from "@/components/Tag.vue";
const EVENTS_PER_PAGE = 10;
const PARTICIPATIONS_PER_PAGE = 10;
@@ -423,7 +422,7 @@ const { mutate: suspendProfile } = useMutation<
});
if (!profileData) return;
const { person } = profileData;
const { person: cachedPerson } = profileData;
store.writeQuery({
query: GET_PERSON,
variables: {
@@ -431,7 +430,7 @@ const { mutate: suspendProfile } = useMutation<
},
data: {
person: {
...cloneDeep(person),
...cloneDeep(cachedPerson),
participations: { total: 0, elements: [] },
suspended: true,
avatar: null,

View File

@@ -24,7 +24,7 @@
<table v-if="metadata.length > 0" class="min-w-full">
<tbody>
<tr
class="odd:bg-white even:bg-gray-50 border-b"
class="border-b"
v-for="{ key, value, type } in metadata"
:key="key"
>
@@ -67,7 +67,7 @@
size="small"
v-if="!user.disabled"
@click="isEmailChangeModalActive = true"
type="is-text"
variant="text"
icon-left="pencil"
>{{ t("Change email") }}</o-button
>
@@ -78,7 +78,7 @@
query: { emailFilter: `@${userEmailDomain}` },
}"
size="small"
type="is-text"
variant="text"
icon-left="magnify"
>{{
t("Other users with the same email domain")
@@ -93,7 +93,7 @@
size="small"
v-if="!user.confirmedAt || user.disabled"
@click="isConfirmationModalActive = true"
type="is-text"
variant="text"
icon-left="check"
>{{ t("Confirm user") }}</o-button
>
@@ -106,7 +106,7 @@
size="small"
v-if="!user.disabled"
@click="isRoleChangeModalActive = true"
type="is-text"
variant="text"
icon-left="chevron-double-up"
>{{ t("Change role") }}</o-button
>
@@ -122,7 +122,7 @@
query: { ipFilter: user.currentSignInIp },
}"
size="small"
type="is-text"
variant="text"
icon-left="web"
>{{
t("Other users with the same IP address")
@@ -192,7 +192,7 @@
</header>
<section class="">
<o-field :label="t('Previous email')">
<o-input type="email" :value="user.email" disabled> </o-input>
<o-input type="email" v-model="user.email" disabled />
</o-field>
<o-field :label="t('New email')">
<o-input
@@ -208,7 +208,7 @@
}}</o-checkbox>
</section>
<footer class="mt-2 flex gap-2">
<o-button @click="isEmailChangeModalActive = false">{{
<o-button outlined @click="isEmailChangeModalActive = false">{{
t("Close")
}}</o-button>
<o-button native-type="submit" variant="primary">{{
@@ -309,7 +309,7 @@
{{ t("This user was not found") }}
<template #desc>
<o-button
type="is-text"
variant="text"
tag="router-link"
:to="{ name: RouteName.USERS }"
>{{ t("Back to user list") }}</o-button
@@ -459,7 +459,7 @@ const suspendAccount = async (): Promise<void> => {
),
confirmText: t("Suspend the account"),
cancelText: t("Cancel"),
type: "is-danger",
variant: "danger",
onConfirm: async () => {
suspendUser({
userId: props.id,

View File

@@ -2,10 +2,10 @@
<div>
<breadcrumbs-nav
:links="[
{ name: RouteName.MODERATION, text: $t('Moderation') },
{ name: RouteName.MODERATION, text: t('Moderation') },
{
name: RouteName.ADMIN_GROUPS,
text: $t('Groups'),
text: t('Groups'),
},
]"
/>
@@ -13,13 +13,13 @@
<router-link
class="button is-primary"
:to="{ name: RouteName.CREATE_GROUP }"
>{{ $t("Create group") }}</router-link
>{{ 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>
<o-switch v-model="local">{{ t("Local") }}</o-switch>
<o-switch v-model="suspended">{{ t("Suspended") }}</o-switch>
</div>
<o-table
:data="groups.elements"
@@ -29,10 +29,10 @@
backend-filtering
:debounce-search="200"
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')"
: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"
@@ -40,14 +40,14 @@
>
<o-table-column
field="preferredUsername"
:label="$t('Username')"
:label="t('Username')"
searchable
>
<template #searchable="props">
<o-input
:aria-label="$t('Filter')"
:aria-label="t('Filter')"
v-model="props.filters.preferredUsername"
:placeholder="$t('Filter')"
:placeholder="t('Filter')"
icon="magnify"
/>
</template>
@@ -82,12 +82,12 @@
</template>
</o-table-column>
<o-table-column field="domain" :label="$t('Domain')" searchable>
<o-table-column field="domain" :label="t('Domain')" searchable>
<template #searchable="props">
<o-input
:aria-label="$t('Filter')"
:aria-label="t('Filter')"
v-model="props.filters.domain"
:placeholder="$t('Filter')"
:placeholder="t('Filter')"
icon="magnify"
/>
</template>
@@ -97,7 +97,7 @@
</o-table-column>
<template #empty>
<empty-content icon="account-group" :inline="true">
{{ $t("No group matches the filters") }}
{{ t("No group matches the filters") }}
</empty-content>
</template>
</o-table>

View File

@@ -11,7 +11,7 @@
<div
class="grid md:grid-cols-2 xl:grid-cols-4 gap-2 content-center text-center mt-2"
>
<div class="bg-gray-50 rounded-xl p-8">
<div class="bg-zinc-50 dark:bg-mbz-purple-500 rounded-xl p-8">
<router-link
:to="{
name: RouteName.PROFILES,
@@ -24,7 +24,7 @@
<span class="text-sm block">{{ $t("Profiles") }}</span>
</router-link>
</div>
<div class="bg-gray-50 rounded-xl p-8">
<div class="bg-gray-50 dark:bg-mbz-purple-500 rounded-xl p-8">
<router-link
:to="{
name: RouteName.ADMIN_GROUPS,
@@ -37,19 +37,19 @@
<span class="text-sm block">{{ $t("Groups") }}</span>
</router-link>
</div>
<div class="bg-gray-50 rounded-xl p-8">
<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-gray-50 rounded-xl p-8">
<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-gray-50 rounded-xl p-8">
<div class="bg-zinc-50 dark:bg-mbz-purple-500 rounded-xl p-8">
<router-link
:to="{ name: RouteName.REPORTS, query: { domain: instance.domain } }"
>
@@ -59,7 +59,7 @@
<span class="text-sm block">{{ $t("Reports") }}</span>
</router-link>
</div>
<div class="bg-gray-50 rounded-xl p-8">
<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>
@@ -68,7 +68,7 @@
</div>
<div class="mt-3 grid xl:grid-cols-2 gap-4">
<div
class="border bg-white p-6 shadow-md rounded-md"
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
@@ -104,7 +104,9 @@
<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 p-6 shadow-md rounded-md flex flex-col gap-2">
<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({
@@ -140,6 +142,7 @@ import {
ADD_INSTANCE,
INSTANCE,
REJECT_RELAY,
REMOVE_RELAY,
} from "@/graphql/admin";
import { formatBytes } from "@/utils/datetime";
import RouteName from "@/router/name";
@@ -215,7 +218,7 @@ onRejectInstanceError((error) => {
});
const { mutate: followInstanceMutation, onError: onFollowInstanceError } =
useMutation(ADD_INSTANCE);
useMutation<{ addInstance: IInstance }>(ADD_INSTANCE);
onFollowInstanceError((error) => {
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
@@ -232,10 +235,10 @@ const followInstance = async (e: Event): Promise<void> => {
* Stop following instance
*/
const { mutate: removeInstanceFollow, onError: onRemoveInstanceFollowError } =
useMutation(REJECT_RELAY, () => ({
useMutation(REMOVE_RELAY, () => ({
update(cache: ApolloCache<any>) {
cache.writeFragment({
id: cache.identify(instance as unknown as Reference),
id: cache.identify(instance.value as unknown as Reference),
fragment: gql`
fragment InstanceFollowedStatus on Instance {
followedStatus

View File

@@ -2,24 +2,29 @@
<div>
<breadcrumbs-nav
:links="[
{ name: RouteName.ADMIN, text: $t('Admin') },
{ text: $t('Instances') },
{ name: RouteName.ADMIN, text: t('Admin') },
{ text: t('Instances') },
]"
/>
<section>
<h1 class="title">{{ $t("Instances") }}</h1>
<h1 class="title">{{ t("Instances") }}</h1>
<form @submit="followInstance" class="my-4">
<o-field :label="$t('Follow a new instance')" horizontal>
<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')"
:placeholder="t('Ex: mobilizon.fr')"
/>
</p>
<p class="control">
<o-button variant="primary" native-type="submit">{{
$t("Add an instance")
t("Add an instance")
}}</o-button>
<o-loading
:is-full-page="true"
@@ -31,31 +36,31 @@
</o-field>
</form>
<div class="flex flex-wrap gap-2">
<o-field :label="$t('Follow status')">
<o-field :label="t('Follow status')">
<o-radio
v-model="followStatus"
:native-value="InstanceFilterFollowStatus.ALL"
>{{ $t("All") }}</o-radio
>{{ t("All") }}</o-radio
>
<o-radio
v-model="followStatus"
:native-value="InstanceFilterFollowStatus.FOLLOWING"
>{{ $t("Following") }}</o-radio
>{{ t("Following") }}</o-radio
>
<o-radio
v-model="followStatus"
:native-value="InstanceFilterFollowStatus.FOLLOWED"
>{{ $t("Followed") }}</o-radio
>{{ t("Followed") }}</o-radio
>
</o-field>
<o-field
:label="$t('Domain')"
:label="t('Domain')"
label-for="domain-filter"
class="flex-auto"
>
<o-input
id="domain-filter"
:placeholder="$t('mobilizon-instance.tld')"
:placeholder="t('mobilizon-instance.tld')"
:value="filterDomain"
@input="debouncedUpdateDomainFilter"
/>
@@ -67,7 +72,7 @@
name: RouteName.INSTANCE,
params: { domain: instance.domain },
}"
class="flex items-center mb-2 rounded bg-secondary p-4 flex-wrap justify-center gap-x-2 gap-y-3"
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"
>
@@ -75,19 +80,19 @@
<img
class="w-12"
v-if="instance.hasRelay"
src="../../assets/logo.svg"
src="/img/logo.svg"
alt=""
/>
<CloudQuestion v-else :size="36" />
<div class="">
<h4 class="text-lg truncate">{{ instance.domain }}</h4>
<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
{{ t("Followed") }}</span
>
<span
class="text-sm"
@@ -96,32 +101,32 @@
"
>
<o-icon icon="inbox-arrow-down" />
{{ $t("Followed, pending response") }}</span
{{ 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
{{ 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
{{ 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>
><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>
><span class="text-sm">{{ t("Profiles") }}</span>
</p>
</div>
</router-link>
@@ -130,26 +135,26 @@
:total="instances.total"
v-model="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')"
: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.") }}
{{ t("No instance found.") }}
<template #desc>
<span v-if="hasFilter">
{{
$t(
t(
"No instances match this filter. Try resetting filter fields?"
)
}}
</span>
<span v-else>
{{ $t("You haven't interacted with other instances yet.") }}
{{ t("You haven't interacted with other instances yet.") }}
</span>
</template>
</empty-content>
@@ -176,10 +181,11 @@ import {
useRouteQuery,
} from "vue-use-route-query";
import { useMutation, useQuery } from "@vue/apollo-composable";
import { computed, ref } from "vue";
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;
@@ -243,6 +249,8 @@ onDone(({ data }) => {
});
});
const notifier = inject<Notifier>("notifier");
onError((error) => {
if (error.message) {
if (error.graphQLErrors && error.graphQLErrors.length > 0) {

View File

@@ -2,16 +2,18 @@
<div>
<breadcrumbs-nav
:links="[
{ name: RouteName.MODERATION, text: $t('Moderation') },
{ name: RouteName.MODERATION, text: t('Moderation') },
{
name: RouteName.PROFILES,
text: $t('Profiles'),
text: t('Profiles'),
},
]"
/>
<div v-if="persons">
<o-switch v-model="local">{{ $t("Local") }}</o-switch>
<o-switch v-model="suspended">{{ $t("Suspended") }}</o-switch>
<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"
@@ -20,25 +22,24 @@
backend-filtering
:debounce-search="200"
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')"
: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"
@page-change="onPageChange"
@filters-change="onFiltersChange"
>
<o-table-column
field="preferredUsername"
:label="$t('Username')"
:label="t('Username')"
searchable
>
<template #searchable="props">
<o-input
v-model="props.filters.preferredUsername"
:aria-label="$t('Filter')"
:placeholder="$t('Filter')"
:aria-label="t('Filter')"
:placeholder="t('Filter')"
icon="magnify"
/>
</template>
@@ -57,6 +58,7 @@
:alt="props.row.avatar.alt || ''"
width="48"
height="48"
class="rounded-full"
/>
</figure>
<Account v-else :size="48" />
@@ -72,12 +74,12 @@
</template>
</o-table-column>
<o-table-column field="domain" :label="$t('Domain')" searchable>
<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')"
:aria-label="t('Filter')"
:placeholder="t('Filter')"
icon="magnify"
/>
</template>
@@ -87,7 +89,7 @@
</o-table-column>
<template #empty>
<empty-content icon="account" :inline="true">
{{ $t("No profile matches the filters") }}
{{ t("No profile matches the filters") }}
</empty-content>
</template>
</o-table>
@@ -142,10 +144,6 @@ useHead({
title: computed(() => t("Profiles")),
});
const onPageChange = async (): Promise<void> => {
await fetchMore();
};
const onFiltersChange = ({
preferredUsername: newPreferredUsername,
domain: newDomain,
@@ -155,7 +153,7 @@ const onFiltersChange = ({
}): void => {
preferredUsername.value = newPreferredUsername;
domain.value = newDomain;
fetchMore();
fetchMore({});
};
</script>
<style lang="scss" scoped>

View File

@@ -104,20 +104,15 @@ import {
CategoryPictureLicencing,
CategoryPictureLicencingElement,
} from "@/components/Categories/constants";
import { CONFIG } from "@/graphql/config";
import { IConfig } from "@/types/config.model";
import { useI18n } from "vue-i18n";
import { useEventCategories } from "@/composition/apollo/config";
const { t } = useI18n({ useScope: "global" });
const { result: configResult } = useQuery<{ config: IConfig }>(CONFIG);
const config = computed(() => configResult.value?.config);
const eventCategories = computed(() => config.value?.eventCategories ?? []);
const { eventCategories } = useEventCategories();
const eventCategoryLabel = (categoryId: string): string | undefined => {
return eventCategories.value.find(({ id }) => categoryId == id)?.label;
return eventCategories.value?.find(({ id }) => categoryId == id)?.label;
};
const { result: categoryStatsResult } = useQuery<{
@@ -132,7 +127,7 @@ const promotedCategories = computed((): CategoryStatsModel[] => {
.map(({ key, number }) => ({
key,
number,
label: eventCategoryLabel(key),
label: eventCategoryLabel(key) as string,
}))
.filter(
({ key, number, label }) =>

View File

@@ -72,7 +72,9 @@ import { useHead } from "@vueuse/head";
import { Notifier } from "@/plugins/notifier";
import { AbsintheGraphQLError } from "@/types/errors.model";
const Editor = defineAsyncComponent(() => import("@/components/Editor.vue"));
const Editor = defineAsyncComponent(
() => import("@/components/TextEditor.vue")
);
const props = defineProps<{ preferredUsername: string }>();

View File

@@ -95,6 +95,7 @@
</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"
@@ -237,7 +238,9 @@ const discussion = computed(() => discussionResult.value?.discussion);
const { group } = useGroup(usernameWithDomain(discussion.value?.actor));
const Editor = defineAsyncComponent(() => import("@/components/Editor.vue"));
const Editor = defineAsyncComponent(
() => import("@/components/TextEditor.vue")
);
useHead({
title: computed(() => discussion.value?.title ?? ""),
@@ -362,7 +365,7 @@ const dialog = inject<Dialog>("dialog");
const openDeleteDiscussionConfirmation = (): void => {
dialog?.confirm({
type: "is-danger",
variant: "danger",
title: t("Delete this discussion"),
message: t("Are you sure you want to delete this entire discussion?"),
confirmText: t("Delete discussion"),

View File

@@ -41,6 +41,7 @@
:key="discussion.id"
/>
<o-pagination
v-show="group.discussions.total > DISCUSSIONS_PER_PAGE"
class="discussion-pagination"
:total="group.discussions.total"
v-model="page"

View File

@@ -1,23 +1,23 @@
<template>
<div class="container mx-auto" v-if="hasCurrentActorPermissionsToEdit">
<h1 class="" v-if="isUpdate === true">
{{ $t("Update event {name}", { name: event.title }) }}
{{ t("Update event {name}", { name: event.title }) }}
</h1>
<h1 class="" v-else>
{{ $t("Create a new event") }}
{{ t("Create a new event") }}
</h1>
<form ref="form">
<h2>{{ $t("General information") }}</h2>
<h2>{{ t("General information") }}</h2>
<picture-upload
v-if="pictureFile"
v-model:pictureFile="pictureFile"
:textFallback="$t('Headline picture')"
v-model:modelValue="pictureFile"
:textFallback="t('Headline picture')"
:defaultImage="event.picture"
/>
<o-field
:label="$t('Title')"
:label="t('Title')"
label-for="title"
:type="checkTitleLength[0]"
:message="checkTitleLength[1]"
@@ -35,12 +35,12 @@
<div class="flex flex-wrap gap-4">
<o-field
v-if="eventCategories"
:label="$t('Category')"
:label="t('Category')"
label-for="category"
class="w-full md:max-w-fit"
>
<o-select
:placeholder="$t('Select a category')"
:placeholder="t('Select a category')"
v-model="event.category"
expanded
>
@@ -62,13 +62,13 @@
<o-field
horizontal
:label="$t('Starts on…')"
:label="t('Starts on…')"
class="begins-on-field"
label-for="begins-on-field"
>
<o-datetimepicker
class="datepicker starts-on"
:placeholder="$t('Type or select a date…')"
:placeholder="t('Type or select a date…')"
icon="calendar-today"
:locale="$i18n.locale"
v-model="beginsOn"
@@ -78,17 +78,17 @@
:first-day-of-week="firstDayOfWeek"
:datepicker="{
id: 'begins-on-field',
'aria-next-label': $t('Next month'),
'aria-previous-label': $t('Previous month'),
'aria-next-label': t('Next month'),
'aria-previous-label': t('Previous month'),
}"
>
</o-datetimepicker>
</o-field>
<o-field horizontal :label="$t('Ends on…')" label-for="ends-on-field">
<o-field horizontal :label="t('Ends on…')" label-for="ends-on-field">
<o-datetimepicker
class="datepicker ends-on"
:placeholder="$t('Type or select a date…')"
:placeholder="t('Type or select a date…')"
icon="calendar-today"
:locale="$i18n.locale"
v-model="endsOn"
@@ -99,41 +99,40 @@
:first-day-of-week="firstDayOfWeek"
:datepicker="{
id: 'ends-on-field',
'aria-next-label': $t('Next month'),
'aria-previous-label': $t('Previous month'),
'aria-next-label': t('Next month'),
'aria-previous-label': t('Previous month'),
}"
>
</o-datetimepicker>
</o-field>
<o-button type="is-text" @click="dateSettingsIsOpen = true">
{{ $t("Date parameters") }}
<o-button class="block" variant="text" @click="dateSettingsIsOpen = true">
{{ t("Date parameters") }}
</o-button>
<div class="address">
<div class="my-6">
<full-address-auto-complete
v-model="eventPhysicalAddress"
:user-timezone="userActualTimezone"
:disabled="isOnline"
:disabled="event.options.isOnline"
:hideSelected="true"
/>
<o-switch class="is-online" v-model="isOnline">{{
$t("The event is fully online")
<o-switch class="my-4" v-model="isOnline">{{
t("The event is fully online")
}}</o-switch>
</div>
<div class="o-field field">
<label class="o-field__label field-label">{{
$t("Description")
}}</label>
<label class="o-field__label field-label">{{ t("Description") }}</label>
<editor-component
v-if="currentActor"
:current-actor="(currentActor as IPerson)"
v-model="event.description"
:aria-label="$t('Event description body')"
:aria-label="t('Event description body')"
/>
</div>
<o-field :label="$t('Website / URL')" label-for="website-url">
<o-field :label="t('Website / URL')" label-for="website-url">
<o-input
icon="link"
type="url"
@@ -144,7 +143,7 @@
</o-field>
<section class="my-4">
<h2>{{ $t("Organizers") }}</h2>
<h2>{{ t("Organizers") }}</h2>
<div v-if="features?.groups && organizerActor?.id">
<o-field>
@@ -155,21 +154,21 @@
</o-field>
<p v-if="!attributedToAGroup && organizerActorEqualToCurrentActor">
{{
$t("The event will show as attributed to your personal profile.")
t("The event will show as attributed to your personal profile.")
}}
</p>
<p v-else-if="!attributedToAGroup">
{{ $t("The event will show as attributed to this profile.") }}
{{ t("The event will show as attributed to this profile.") }}
</p>
<p v-else>
<span>{{
$t("The event will show as attributed to this group.")
t("The event will show as attributed to this group.")
}}</span>
<span
v-if="event.contacts && event.contacts.length"
v-html="
' ' +
$t(
t(
'<b>{contact}</b> will be displayed as contact.',
{
@@ -184,16 +183,16 @@
"
/>
<span v-else>
{{ $t("You may show some members as contacts.") }}
{{ t("You may show some members as contacts.") }}
</span>
</p>
</div>
</section>
<section class="my-4">
<h2>{{ $t("Event metadata") }}</h2>
<h2>{{ t("Event metadata") }}</h2>
<p>
{{
$t(
t(
"Integrate this event with 3rd-party tools and show metadata for the event."
)
}}
@@ -202,12 +201,12 @@
</section>
<section class="my-4">
<h2>
{{ $t("Who can view this event and participate") }}
{{ t("Who can view this event and participate") }}
</h2>
<fieldset>
<legend>
{{
$t(
t(
"When the event is private, you'll need to share the link around."
)
}}
@@ -217,7 +216,7 @@
v-model="event.visibility"
name="eventVisibility"
:native-value="EventVisibility.PUBLIC"
>{{ $t("Visible everywhere on the web (public)") }}</o-radio
>{{ t("Visible everywhere on the web (public)") }}</o-radio
>
</div>
<div class="field">
@@ -225,7 +224,7 @@
v-model="event.visibility"
name="eventVisibility"
:native-value="EventVisibility.UNLISTED"
>{{ $t("Only accessible through link (private)") }}</o-radio
>{{ t("Only accessible through link (private)") }}</o-radio
>
</div>
</fieldset>
@@ -233,7 +232,7 @@
<o-radio v-model="event.visibility"
name="eventVisibility"
:native-value="EventVisibility.PRIVATE">
{{ $t('Page limited to my group (asks for auth)') }}
{{ t('Page limited to my group (asks for auth)') }}
</o-radio>
</div>-->
@@ -242,9 +241,7 @@
:label="t('Anonymous participations')"
>
<o-switch v-model="eventOptions.anonymousParticipation">
{{
$t("I want to allow people to participate without an account.")
}}
{{ t("I want to allow people to participate without an account.") }}
<small
v-if="
anonymousParticipationConfig?.validation.email
@@ -253,7 +250,7 @@
>
<br />
{{
$t(
t(
"Anonymous participants will be asked to confirm their participation through e-mail."
)
}}
@@ -263,23 +260,23 @@
<o-field :label="t('Participation approval')">
<o-switch v-model="needsApproval">{{
$t("I want to approve every participation request")
t("I want to approve every participation request")
}}</o-switch>
</o-field>
<o-field :label="t('Number of places')">
<o-switch v-model="limitedPlaces">{{
$t("Limited number of places")
t("Limited number of places")
}}</o-switch>
</o-field>
<div class="" v-if="limitedPlaces">
<o-field :label="$t('Number of places')" label-for="number-of-places">
<o-field :label="t('Number of places')" label-for="number-of-places">
<o-input
type="number"
controls-position="compact"
:aria-minus-label="$t('Decrease')"
:aria-plus-label="$t('Increase')"
:aria-minus-label="t('Decrease')"
:aria-plus-label="t('Increase')"
min="1"
v-model="eventOptions.maximumAttendeeCapacity"
id="number-of-places"
@@ -288,28 +285,28 @@
<!--
<o-field>
<o-switch v-model="eventOptions.showRemainingAttendeeCapacity">
{{ $t('Show remaining number of places') }}
{{ t('Show remaining number of places') }}
</o-switch>
</o-field>
<o-field>
<o-switch v-model="eventOptions.showParticipationPrice">
{{ $t('Display participation price') }}
{{ t('Display participation price') }}
</o-switch>
</o-field>-->
</div>
</section>
<section class="my-4">
<h2>{{ $t("Public comment moderation") }}</h2>
<h2>{{ t("Public comment moderation") }}</h2>
<fieldset>
<legend>{{ $t("Who can post a comment?") }}</legend>
<legend>{{ t("Who can post a comment?") }}</legend>
<o-field>
<o-radio
v-model="eventOptions.commentModeration"
name="commentModeration"
:native-value="CommentModeration.ALLOW_ALL"
>{{ $t("Allow all comments from users with accounts") }}</o-radio
>{{ t("Allow all comments from users with accounts") }}</o-radio
>
</o-field>
@@ -317,7 +314,7 @@
<!-- <o-radio v-model="eventOptions.commentModeration"-->
<!-- name="commentModeration"-->
<!-- :native-value="CommentModeration.MODERATED">-->
<!-- {{ $t('Moderated comments (shown after approval)') }}-->
<!-- {{ t('Moderated comments (shown after approval)') }}-->
<!-- </o-radio>-->
<!-- </div>-->
@@ -326,18 +323,18 @@
v-model="eventOptions.commentModeration"
name="commentModeration"
:native-value="CommentModeration.CLOSED"
>{{ $t("Close comments for all (except for admins)") }}</o-radio
>{{ t("Close comments for all (except for admins)") }}</o-radio
>
</o-field>
</fieldset>
</section>
<section class="my-4">
<h2>{{ $t("Status") }}</h2>
<h2>{{ t("Status") }}</h2>
<fieldset>
<legend>
{{
$t(
t(
"Does the event needs to be confirmed later or is it cancelled?"
)
}}
@@ -350,7 +347,7 @@
:native-value="EventStatus.TENTATIVE"
>
<o-icon icon="calendar-question" />
{{ $t("Tentative: Will be confirmed later") }}
{{ t("Tentative: Will be confirmed later") }}
</o-radio>
<o-radio
v-model="event.status"
@@ -359,7 +356,7 @@
:native-value="EventStatus.CONFIRMED"
>
<o-icon icon="calendar-check" />
{{ $t("Confirmed: Will happen") }}
{{ t("Confirmed: Will happen") }}
</o-radio>
<o-radio
v-model="event.status"
@@ -368,7 +365,7 @@
:native-value="EventStatus.CANCELLED"
>
<o-icon icon="calendar-remove" />
{{ $t("Cancelled: Won't happen") }}
{{ t("Cancelled: Won't happen") }}
</o-radio>
</o-field>
</fieldset>
@@ -377,30 +374,30 @@
</div>
<div class="container mx-auto" v-else>
<o-notification variant="danger">
{{ $t("Only group moderators can create, edit and delete events.") }}
{{ t("Only group moderators can create, edit and delete events.") }}
</o-notification>
</div>
<o-modal
v-model:active="dateSettingsIsOpen"
has-modal-card
trap-focus
:close-button-aria-label="$t('Close')"
:close-button-aria-label="t('Close')"
>
<form class="p-3">
<header class="">
<h2 class="">{{ $t("Date and time settings") }}</h2>
<h2 class="">{{ t("Date and time settings") }}</h2>
</header>
<section class="">
<p>
{{
$t(
t(
"Event timezone will default to the timezone of the event's address if there is one, or to your own timezone setting."
)
}}
</p>
<o-field :label="$t('Timezone')" label-for="timezone" expanded>
<o-field :label="t('Timezone')" label-for="timezone" expanded>
<o-select
:placeholder="$t('Select a timezone')"
:placeholder="t('Select a timezone')"
:loading="timezoneLoading"
v-model="timezone"
id="timezone"
@@ -424,23 +421,23 @@
@click="timezone = null"
class="reset-area"
icon-left="close"
:title="$t('Clear timezone field')"
:title="t('Clear timezone field')"
/>
</o-field>
<o-field :label="$t('Event page settings')">
<o-field :label="t('Event page settings')">
<o-switch v-model="eventOptions.showStartTime">{{
$t("Show the time when the event begins")
t("Show the time when the event begins")
}}</o-switch>
</o-field>
<o-field>
<o-switch v-model="eventOptions.showEndTime">{{
$t("Show the time when the event ends")
t("Show the time when the event ends")
}}</o-switch>
</o-field>
</section>
<footer class="mt-2">
<o-button @click="dateSettingsIsOpen = false">
{{ $t("OK") }}
{{ t("OK") }}
</o-button>
</footer>
</form>
@@ -449,23 +446,22 @@
<nav
role="navigation"
aria-label="main navigation"
class="bg-secondary/70"
class="bg-mbz-yellow-alt-200 py-3"
:class="{ 'is-fixed-bottom': showFixedNavbar }"
v-if="hasCurrentActorPermissionsToEdit"
>
<div class="container mx-auto">
<div class="flex justify-between">
<div class="">
<span class="dark:text-gray-900" v-if="isEventModified">{{
$t("Unsaved changes")
}}</span>
</div>
<div class="flex justify-between items-center">
<span class="dark:text-gray-900" v-if="isEventModified">
{{ t("Unsaved changes") }}
</span>
<div class="flex flex-wrap gap-3 items-center">
<span class="">
<o-button type="is-text" @click="confirmGoBack">{{
$t("Cancel")
}}</o-button>
</span>
<o-button
variant="text"
@click="confirmGoBack"
class="dark:!text-black"
>{{ t("Cancel") }}</o-button
>
<!-- If an event has been published we can't make it draft anymore -->
<span class="" v-if="event.draft === true">
<o-button
@@ -473,7 +469,7 @@
outlined
@click="createOrUpdateDraft"
:disabled="saving"
>{{ $t("Save draft") }}</o-button
>{{ t("Save draft") }}</o-button
>
</span>
<span class="">
@@ -483,9 +479,9 @@
@click="createOrUpdatePublish"
@keyup.enter="createOrUpdatePublish"
>
<span v-if="isUpdate === false">{{ $t("Create my event") }}</span>
<span v-else-if="event.draft === true">{{ $t("Publish") }}</span>
<span v-else>{{ $t("Update my event") }}</span>
<span v-if="isUpdate === false">{{ t("Create my event") }}</span>
<span v-else-if="event.draft === true">{{ t("Publish") }}</span>
<span v-else>{{ t("Update my event") }}</span>
</o-button>
</span>
</div>
@@ -596,7 +592,7 @@
<script lang="ts" setup>
import { getTimezoneOffset } from "date-fns-tz";
import PictureUpload from "@/components/PictureUpload.vue";
import EditorComponent from "@/components/Editor.vue";
import EditorComponent from "@/components/TextEditor.vue";
import TagInput from "@/components/Event/TagInput.vue";
import FullAddressAutoComplete from "@/components/Event/FullAddressAutoComplete.vue";
import EventMetadataList from "@/components/Event/EventMetadataList.vue";
@@ -617,27 +613,33 @@ import {
MemberRole,
ParticipantRole,
} from "@/types/enums";
import OrganizerPickerWrapper from "../../components/Event/OrganizerPickerWrapper.vue";
import OrganizerPickerWrapper from "@/components/Event/OrganizerPickerWrapper.vue";
import {
CREATE_EVENT,
EDIT_EVENT,
EVENT_PERSON_PARTICIPATION,
} from "../../graphql/event";
} from "@/graphql/event";
import {
EventModel,
IEditableEvent,
IEvent,
removeTypeName,
toEditJSON,
} from "../../types/event.model";
import { LOGGED_USER_DRAFTS } from "../../graphql/actor";
import { IActor, IGroup, IPerson, usernameWithDomain } from "../../types/actor";
} from "@/types/event.model";
import { LOGGED_USER_DRAFTS } from "@/graphql/actor";
import {
IActor,
IGroup,
IPerson,
usernameWithDomain,
displayNameAndUsername,
} from "@/types/actor";
import {
buildFileFromIMedia,
buildFileVariable,
readFileAsync,
} from "../../utils/image";
import RouteName from "../../router/name";
} from "@/utils/image";
import RouteName from "@/router/name";
import "intersection-observer";
import {
ApolloCache,
@@ -759,7 +761,7 @@ const unmodifiedEvent = ref<IEditableEvent>(new EventModel());
const pictureFile = ref<File | null>(null);
const canPromote = ref(true);
// const canPromote = ref(true);
const limitedPlaces = ref(false);
const showFixedNavbar = ref(true);
@@ -905,7 +907,7 @@ onCreateEventMutationDone(async ({ data }) => {
message: (event.value.draft
? t("The event has been created as a draft")
: t("The event has been published")) as string,
variant: "is-success",
variant: "success",
position: "bottom-right",
duration: 5000,
});
@@ -964,6 +966,7 @@ onEditEventMutationError((err) => {
const updateEvent = async (): Promise<void> => {
saving.value = true;
const variables = await buildVariables();
console.debug("update event", variables);
editEventMutation(variables);
};
@@ -1016,7 +1019,6 @@ const handleError = (err: any) => {
*/
const postCreateOrUpdate = (store: any, updatedEvent: IEvent) => {
const resultEvent: IEvent = { ...updatedEvent };
console.debug("resultEvent", resultEvent);
if (!updatedEvent.draft) {
store.writeQuery({
query: EVENT_PERSON_PARTICIPATION,
@@ -1056,7 +1058,6 @@ const postCreateOrUpdate = (store: any, updatedEvent: IEvent) => {
/**
* Refresh drafts or participation cache depending if the event is still draft or not
*/
// eslint-disable-next-line class-methods-use-this
const postRefetchQueries = (
updatedEvent: IEvent
): InternalRefetchQueriesInclude => {
@@ -1093,11 +1094,11 @@ const buildVariables = async () => {
options: eventOptions.value,
};
// const organizerActor = event.value?.organizerActor?.id
// ? event.value.organizerActor
// : organizerActor.value;
const localOrganizerActor = event.value?.organizerActor?.id
? event.value.organizerActor
: organizerActor.value;
if (organizerActor.value) {
res = { ...res, organizerActorId: organizerActor.value?.id };
res = { ...res, organizerActorId: localOrganizerActor?.id };
}
const attributedToId = event.value?.attributedTo?.id
? event.value?.attributedTo.id
@@ -1155,7 +1156,7 @@ const needsApproval = computed({
const checkTitleLength = computed((): Array<string | undefined> => {
return event.value.title.length > 80
? ["is-info", t("The event title will be ellipsed.") as string]
? ["info", t("The event title will be ellipsed.")]
: [undefined, undefined];
});
@@ -1189,7 +1190,7 @@ const confirmGoElsewhere = (): Promise<boolean> => {
message,
confirmText: t("Abandon editing") as string,
cancelText: t("Continue editing") as string,
type: "is-warning",
variant: "warning",
hasIcon: true,
onConfirm: () => resolve(true),
onCancel: () => resolve(false),
@@ -1356,6 +1357,13 @@ const isOnline = computed({
};
},
});
watch(isOnline, (newIsOnline) => {
if (newIsOnline === true) {
eventPhysicalAddress.value = null;
}
});
const dateFnsLocale = inject<Locale>("dateFnsLocale");
const firstDayOfWeek = computed((): number => {
@@ -1366,6 +1374,15 @@ const { event: fetchedEvent, onResult: onFetchEventResult } = useFetchEvent(
eventId.value
);
watch(
fetchedEvent,
() => {
if (!fetchedEvent.value) return;
event.value = { ...fetchedEvent.value };
},
{ immediate: true }
);
onFetchEventResult((result) => {
if (!result.loading && result.data?.event) {
event.value = { ...result.data?.event };

View File

@@ -65,36 +65,35 @@
</popover-actor-card>
</span>
</div>
<p class="flex gap-1 items-center" dir="auto">
<tag v-if="eventCategory" class="category" capitalize>{{
eventCategory
}}</tag>
<router-link
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="is-medium" v-if="event?.draft"
>{{ t("Draft") }}
</tag>
<span
class="event-status"
v-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
>
</span>
<div class="inline-flex items-center gap-1">
<p v-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>
<p class="flex gap-1 items-center" dir="auto">
<tag v-if="eventCategory" class="category" capitalize>{{
eventCategory
}}</tag>
<router-link
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 class="">
@@ -375,7 +374,7 @@
ref="reportModal"
:close-button-aria-label="t('Close')"
>
<report-modal
<ReportModal
:on-confirm="reportEvent"
:title="t('Report this event')"
:outside-domain="organizerDomain"
@@ -456,7 +455,7 @@
<o-field :label="t('Message')">
<o-input
type="textarea"
size="is-medium"
size="medium"
v-model="messageForConfirmation"
minlength="10"
></o-input>
@@ -522,35 +521,28 @@ import {
FETCH_EVENT,
JOIN_EVENT,
} from "@/graphql/event";
import { CURRENT_ACTOR_CLIENT, PERSON_STATUS_GROUP } from "@/graphql/actor";
import { EventModel, IEvent } from "@/types/event.model";
import { IEvent } from "@/types/event.model";
import {
displayName,
IActor,
IPerson,
Person,
usernameWithDomain,
} from "@/types/actor";
import { GRAPHQL_API_ENDPOINT } from "@/api/_entrypoint";
import DateCalendarIcon from "@/components/Event/DateCalendarIcon.vue";
import MultiCard from "@/components/Event/MultiCard.vue";
import ReportModal from "@/components/Report/ReportModal.vue";
import { IReport } from "@/types/report.model";
import { CREATE_REPORT } from "@/graphql/report";
import EventMixin from "@/mixins/event";
import IdentityPicker from "../Account/IdentityPicker.vue";
import IdentityPicker from "@/views/Account/IdentityPicker.vue";
import ParticipationSection from "@/components/Participation/ParticipationSection.vue";
import RouteName from "@/router/name";
import CommentTree from "@/components/Comment/CommentTree.vue";
import "intersection-observer";
import { CONFIG } from "@/graphql/config";
import {
AnonymousParticipationNotFoundError,
getLeaveTokenForParticipation,
isParticipatingInThisEvent,
removeAnonymousParticipation,
} from "@/services/AnonymousParticipationStorage";
import { IConfig } from "@/types/config.model";
import Tag from "@/components/Tag.vue";
import EventMetadataSidebar from "@/components/Event/EventMetadataSidebar.vue";
import EventBanner from "@/components/Event/EventBanner.vue";
@@ -560,12 +552,9 @@ import { IParticipant } from "@/types/participant.model";
import { ApolloCache, FetchResult } from "@apollo/client/core";
import { IEventMetadataDescription } from "@/types/event-metadata";
import { eventMetaDataList } from "@/services/EventMetadata";
import { USER_SETTINGS } from "@/graphql/user";
import { IUser } from "@/types/current-user.model";
import { useDeleteEvent, useFetchEvent } from "@/composition/apollo/event";
import {
computed,
handleError,
onMounted,
ref,
watch,
@@ -601,24 +590,26 @@ import { useI18n } from "vue-i18n";
import { useProgrammatic } from "@oruga-ui/oruga-next";
import { Dialog } from "@/plugins/dialog";
import { Notifier } from "@/plugins/notifier";
import { AbsintheGraphQLErrors } from "@/types/errors.model";
import { useHead } from "@vueuse/head";
const ShareEventModal = defineAsyncComponent(
() => import("@/components/Event/ShareEventModal.vue")
);
const IntegrationTwitch = defineAsyncComponent(
() => import("@/components/Event/Integrations/Twitch.vue")
() => import("@/components/Event/Integrations/TwitchIntegration.vue")
);
const IntegrationPeertube = defineAsyncComponent(
() => import("@/components/Event/Integrations/PeerTube.vue")
() => import("@/components/Event/Integrations/PeerTubeIntegration.vue")
);
const IntegrationYoutube = defineAsyncComponent(
() => import("@/components/Event/Integrations/YouTube.vue")
() => import("@/components/Event/Integrations/YouTubeIntegration.vue")
);
const IntegrationJitsiMeet = defineAsyncComponent(
() => import("@/components/Event/Integrations/JitsiMeet.vue")
() => import("@/components/Event/Integrations/JitsiMeetIntegration.vue")
);
const IntegrationEtherpad = defineAsyncComponent(
() => import("@/components/Event/Integrations/Etherpad.vue")
() => import("@/components/Event/Integrations/EtherpadIntegration.vue")
);
const props = defineProps<{
@@ -1057,8 +1048,8 @@ const triggerShare = (): void => {
title: event.value?.title,
url: event.value?.url,
})
.then(() => console.log("Successful share"))
.catch((error: any) => console.log("Error sharing", error));
.then(() => console.debug("Successful share"))
.catch((error: any) => console.debug("Error sharing", error));
} else {
isShareModalActive.value = true;
// send popup
@@ -1067,7 +1058,7 @@ const triggerShare = (): void => {
// @ts-ignore-end
};
const handleErrors = (errors: any[]): void => {
const handleErrors = (errors: AbsintheGraphQLErrors): void => {
if (
errors.some((error) => error.status_code === 404) ||
errors.some(({ message }) => message.includes("has invalid value $uuid"))
@@ -1076,7 +1067,9 @@ const handleErrors = (errors: any[]): void => {
}
};
onFetchEventError(({ graphQlErrors }) => handleErrors(graphQLErrors));
onFetchEventError(({ graphQLErrors }) =>
handleErrors(graphQLErrors as AbsintheGraphQLErrors)
);
const actorIsParticipant = computed((): boolean => {
if (actorIsOrganizer.value) return true;
@@ -1108,7 +1101,7 @@ const canManageEvent = computed((): boolean => {
return actorIsOrganizer.value || hasGroupPrivileges.value;
});
const endDate = computed((): Date | undefined => {
const endDate = computed((): string | undefined => {
return event.value?.endsOn && event.value.endsOn > event.value.beginsOn
? event.value.endsOn
: event.value?.beginsOn;
@@ -1211,6 +1204,11 @@ const eventCategory = computed((): string | undefined => {
return eventCategory.id === event.value?.category;
})?.label as string;
});
useHead({
title: computed(() => eventTitle.value ?? ""),
meta: [{ name: "description", content: eventDescription.value }],
});
</script>
<style lang="scss" scoped>
@use "@/styles/_mixins" as *;
@@ -1380,10 +1378,6 @@ a.participations-link {
text-decoration: none;
}
.event-status .tag {
font-size: 1rem;
}
.no-border {
border: 0;
cursor: auto;

View File

@@ -70,7 +70,7 @@
)
}}
</p>
<o-button type="is-text" tag="a" :href="group.url">
<o-button variant="text" tag="a" :href="group.url">
{{ $t("View the group profile on the original instance") }}
</o-button>
</div>

View File

@@ -20,7 +20,9 @@
</div>
<!-- <o-loading v-model:active="$apollo.loading"></o-loading> -->
<div class="wrapper flex flex-wrap gap-4 items-start">
<div class="event-filter text-violet-1 flex-auto md:flex-none">
<div
class="event-filter 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")
@@ -56,10 +58,12 @@
? 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()"
@@ -469,9 +473,6 @@ section {
.event-filter {
grid-area: filter;
background: lightgray;
border-radius: 5px;
padding: 0.75rem 1.25rem 0.25rem;
// @include desktop {
// padding: 2rem 1.25rem;

View File

@@ -55,16 +55,22 @@
:key="format"
aria-role="listitem"
@click="
exportParticipants({
eventId: event?.id,
format,
})
exportParticipants(
{
eventId: event?.id,
format,
},
{ context: { type: format } }
)
"
@keyup.enter="
exportParticipants({
eventId: event.value?.id,
format,
})
exportParticipants(
{
eventId: event?.id,
format,
},
{ context: { type: format } }
)
"
>
<button class="dropdown-button">
@@ -81,9 +87,9 @@
ref="queueTable"
detailed
detail-key="id"
:checked-rows.sync="checkedRows"
v-model:checked-rows="checkedRows"
checkable
:is-row-checkable="(row) => row.role !== ParticipantRole.CREATOR"
:is-row-checkable="(row: IParticipant) => row.role !== ParticipantRole.CREATOR"
checkbox-position="left"
:show-detail-icon="false"
:loading="participantsLoading"
@@ -100,37 +106,38 @@
backend-sorting
:default-sort-direction="'desc'"
:default-sort="['insertedAt', 'desc']"
@page-change="(newPage) => (page = newPage)"
@sort="(field, order) => emit('sort', field, order)"
@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="media">
<figure
class="media-left image is-48x48"
v-if="props.row.actor.avatar"
>
<img class="is-rounded" :src="props.row.actor.avatar.url" alt="" />
<article>
<figure v-if="props.row.actor.avatar">
<img
class="rounded"
: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 class="media-content">
<div>
<div class="prose dark:prose-invert">
<span v-if="props.row.actor.preferredUsername !== 'anonymous'">
<span v-if="props.row.actor.name">{{
props.row.actor.name
}}</span
><br />
<span class="is-size-7 has-text-grey-dark"
>@{{ usernameWithDomain(props.row.actor) }}</span
>
</span>
>@{{ usernameWithDomain(props.row.actor) }}</span
>
<span v-else>
{{ t("Anonymous participant") }}
</span>
@@ -139,30 +146,30 @@
</article>
</o-table-column>
<o-table-column field="role" :label="t('Role')" v-slot="props">
<b-tag
<tag
variant="primary"
v-if="props.row.role === ParticipantRole.CREATOR"
>
{{ t("Organizer") }}
</b-tag>
<b-tag v-else-if="props.row.role === ParticipantRole.PARTICIPANT">
</tag>
<tag v-else-if="props.row.role === ParticipantRole.PARTICIPANT">
{{ t("Participant") }}
</b-tag>
<b-tag v-else-if="props.row.role === ParticipantRole.NOT_CONFIRMED">
</tag>
<tag v-else-if="props.row.role === ParticipantRole.NOT_CONFIRMED">
{{ t("Not confirmed") }}
</b-tag>
<b-tag
</tag>
<tag
variant="warning"
v-else-if="props.row.role === ParticipantRole.NOT_APPROVED"
>
{{ t("Not approved") }}
</b-tag>
<b-tag
</tag>
<tag
variant="danger"
v-else-if="props.row.role === ParticipantRole.REJECTED"
>
{{ t("Rejected") }}
</b-tag>
</tag>
</o-table-column>
<o-table-column
field="metadata.message"
@@ -250,17 +257,17 @@
<script lang="ts" setup>
import { ParticipantRole } from "@/types/enums";
import { IParticipant } from "../../types/participant.model";
import { IEvent, IEventParticipantStats } from "../../types/event.model";
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";
} 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";
@@ -276,6 +283,7 @@ 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/Tag.vue";
const PARTICIPANTS_PER_PAGE = 10;
const MESSAGE_ELLIPSIS_LENGTH = 130;
@@ -286,6 +294,8 @@ const props = defineProps<{
eventId: string;
}>();
const emit = defineEmits(["sort"]);
const { t } = useI18n({ useScope: "global" });
const { currentActor } = useCurrentActorClient();
@@ -307,11 +317,9 @@ const role = useRouteQuery(
enumTransformer(ParticipantRole)
);
const limit = ref(PARTICIPANTS_PER_PAGE);
const checkedRows = ref<IParticipant[]>([]);
// const queueTable = ref(null);
const queueTable = ref();
const { result: participantsResult, loading: participantsLoading } = useQuery<{
event: IEvent;
@@ -333,10 +341,10 @@ const { result: participantsResult, loading: participantsLoading } = useQuery<{
const event = computed(() => participantsResult.value?.event);
const participantStats = computed((): IEventParticipantStats | null => {
if (!event.value) return null;
return event.value.participantStats;
});
// const participantStats = computed((): IEventParticipantStats | null => {
// if (!event.value) return null;
// return event.value.participantStats;
// });
const { mutate: updateParticipant, onError: onUpdateParticipantError } =
useMutation(UPDATE_PARTICIPANT);
@@ -373,14 +381,14 @@ const {
onError: onExportParticipantsMutationError,
} = useMutation(EXPORT_EVENT_PARTICIPATIONS);
onExportParticipantsMutationDone(({ data }) => {
onExportParticipantsMutationDone(({ data, context }) => {
const link =
window.origin +
"/exports/" +
type.toLowerCase() +
context?.type.toLowerCase() +
"/" +
exportEventParticipants;
console.log(link);
data?.exportEventParticipants;
console.debug(link);
const a = document.createElement("a");
a.style.display = "none";
document.body.appendChild(a);
@@ -449,7 +457,7 @@ const toggleQueueDetails = (row: IParticipant): void => {
}
};
const openDetailedRows = <Record<string, boolean>>{};
const openDetailedRows = ref<Record<string, boolean>>({});
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
@@ -485,21 +493,4 @@ nav.breadcrumb {
text-decoration: none;
}
}
button.dropdown-button {
&:hover {
background-color: #f5f5f5;
color: #0a0a0a;
}
width: 100%;
display: flex;
flex: 1;
background: white;
border: none;
cursor: pointer;
color: #4a4a4a;
font-size: 0.875rem;
line-height: 1.5;
padding: 0.375rem 1rem;
}
</style>

View File

@@ -1,6 +1,6 @@
<template>
<section class="container mx-auto">
<h1>{{ $t("Create a new group") }}</h1>
<h1>{{ t("Create a new group") }}</h1>
<o-notification
variant="danger"
@@ -11,7 +11,7 @@
</o-notification>
<form @submit.prevent="createGroup">
<o-field :label="$t('Group display name')" label-for="group-display-name">
<o-field :label="t('Group display name')" label-for="group-display-name">
<o-input
aria-required="true"
required
@@ -22,7 +22,7 @@
<div class="field">
<label class="label" for="group-preferred-username">{{
$t("Federated Group Name")
t("Federated Group Name")
}}</label>
<div class="field-body">
<o-field
@@ -40,7 +40,7 @@
:useHtml5Validation="true"
:validation-message="
group.preferredUsername
? $t(
? t(
'Only alphanumeric lowercased characters and underscores are supported.'
)
: null
@@ -64,7 +64,7 @@
</div>
<o-field
:label="$t('Description')"
:label="t('Description')"
label-for="group-summary"
:message="summaryErrors[0]"
:type="summaryErrors[1]"
@@ -73,26 +73,26 @@
</o-field>
<div>
<b>{{ $t("Avatar") }}</b>
<b>{{ t("Avatar") }}</b>
<picture-upload
:textFallback="$t('Avatar')"
:textFallback="t('Avatar')"
v-model="avatarFile"
:maxSize="avatarMaxSize"
/>
</div>
<div>
<b>{{ $t("Banner") }}</b>
<b>{{ t("Banner") }}</b>
<picture-upload
:textFallback="$t('Banner')"
:textFallback="t('Banner')"
v-model="bannerFile"
:maxSize="bannerMaxSize"
/>
</div>
<button class="button is-primary" native-type="submit">
{{ $t("Create my group") }}
</button>
<o-button variant="primary" native-type="submit">
{{ t("Create my group") }}
</o-button>
</form>
</section>
</template>
@@ -105,7 +105,6 @@ 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 { useUploadLimits } from "@/composition/apollo/config";
import { computed, inject, reactive, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { useI18n } from "vue-i18n";
@@ -116,9 +115,9 @@ import {
useHost,
} from "@/composition/config";
import { Notifier } from "@/plugins/notifier";
import { useHead } from "@vueuse/head";
const { currentActor } = useCurrentActorClient();
const { uploadLimits } = useUploadLimits();
const { t } = useI18n({ useScope: "global" });
@@ -146,34 +145,14 @@ const bannerMaxSize = useBannerMaxSize();
const notifier = inject<Notifier>("notifier");
const createGroup = async (): Promise<void> => {
errors.value = [];
fieldErrors.preferred_username = undefined;
fieldErrors.summary = undefined;
const variables = buildVariables();
const { onDone, onError } = useCreateGroup(variables);
watch(
() => group.value.name,
(newGroupName) => {
group.value.preferredUsername = convertToUsername(newGroupName);
}
);
onDone(() => {
notifier?.success(
t("Group {displayName} created", {
displayName: displayName(group),
})
);
router.push({
name: RouteName.GROUP,
params: { preferredUsername: usernameWithDomain(group.value) },
});
});
onError((err) => handleError(err as unknown as ErrorResponse));
};
watch(group, (newGroup) => {
group.value.preferredUsername = convertToUsername(newGroup.name);
});
const buildVariables = () => {
const buildVariables = computed(() => {
let avatarObj = {};
let bannerObj = {};
@@ -212,7 +191,7 @@ const buildVariables = () => {
...avatarObj,
...bannerObj,
};
};
});
const handleError = (err: ErrorResponse) => {
if (err?.networkError?.name === "ServerParseError") {
@@ -241,7 +220,7 @@ const handleError = (err: ErrorResponse) => {
const summaryErrors = computed(() => {
const message = fieldErrors.summary ? fieldErrors.summary : undefined;
const type = fieldErrors.summary ? "is-danger" : undefined;
const type = fieldErrors.summary ? "danger" : undefined;
return [message, type];
});
@@ -251,9 +230,33 @@ const preferredUsernameErrors = computed(() => {
: t(
"Only alphanumeric lowercased characters and underscores are supported."
);
const type = fieldErrors.preferred_username ? "is-danger" : undefined;
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>

View File

@@ -25,6 +25,7 @@
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')"
@@ -54,14 +55,14 @@
</o-field>
</o-field>
</form>
<h1>{{ t("Group Members") }} ({{ group.members.total }})</h1>
<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="">
<option :value="undefined">
{{ t("Everything") }}
</option>
<option :value="MemberRole.ADMINISTRATOR">
@@ -103,7 +104,7 @@
:default-sort-direction="'desc'"
:default-sort="['insertedAt', 'desc']"
@page-change="loadMoreMembers"
@sort="(field, order) => $emit('sort', field, order)"
@sort="(field: string, order: string) => emit('sort', field, order)"
>
<o-table-column
field="actor.preferredUsername"
@@ -123,7 +124,7 @@
<AccountCircle v-else :size="48" />
<div class="">
<div class="">
<div class="text-start">
<span v-if="props.row.actor.name">{{
props.row.actor.name
}}</span
@@ -134,39 +135,39 @@
</article>
</o-table-column>
<o-table-column field="role" :label="t('Role')" v-slot="props">
<b-tag
<tag
variant="info"
v-if="props.row.role === MemberRole.ADMINISTRATOR"
>
{{ t("Administrator") }}
</b-tag>
<b-tag
</tag>
<tag
variant="info"
v-else-if="props.row.role === MemberRole.MODERATOR"
>
{{ t("Moderator") }}
</b-tag>
<b-tag v-else-if="props.row.role === MemberRole.MEMBER">
</tag>
<tag v-else-if="props.row.role === MemberRole.MEMBER">
{{ t("Member") }}
</b-tag>
<b-tag
</tag>
<tag
variant="warning"
v-else-if="props.row.role === MemberRole.NOT_APPROVED"
>
{{ t("Not approved") }}
</b-tag>
<b-tag
</tag>
<tag
variant="danger"
v-else-if="props.row.role === MemberRole.REJECTED"
>
{{ t("Rejected") }}
</b-tag>
<b-tag
</tag>
<tag
variant="warning"
v-else-if="props.row.role === MemberRole.INVITED"
>
{{ t("Invited") }}
</b-tag>
</tag>
</o-table-column>
<o-table-column field="insertedAt" :label="t('Date')" v-slot="props">
<span class="has-text-centered">
@@ -253,13 +254,12 @@ import EmptyContent from "@/components/Utils/EmptyContent.vue";
import { useHead } from "@vueuse/head";
import { useI18n } from "vue-i18n";
import { useMutation, useQuery } from "@vue/apollo-composable";
import { computed, inject, ref, watch } from "vue";
import { computed, inject, ref } from "vue";
import {
enumTransformer,
integerTransformer,
useRouteQuery,
} from "vue-use-route-query";
import { useRoute, useRouter } from "vue-router";
import {
useCurrentActorClient,
usePersonStatusGroup,
@@ -267,6 +267,7 @@ import {
import { formatTimeString, formatDateString } from "@/filters/datetime";
import AccountCircle from "vue-material-design-icons/AccountCircle.vue";
import { Notifier } from "@/plugins/notifier";
import Tag from "@/components/Tag.vue";
const { t } = useI18n({ useScope: "global" });
@@ -276,9 +277,10 @@ useHead({
const props = defineProps<{ preferredUsername: string }>();
const emit = defineEmits(["sort"]);
const { currentActor } = useCurrentActorClient();
const loading = ref(true);
const newMemberUsername = ref("");
const inviteError = ref("");
const page = useRouteQuery("page", 1, integerTransformer);
@@ -339,9 +341,6 @@ const inviteMember = async (): Promise<void> => {
});
};
const router = useRouter();
const route = useRoute();
const loadMoreMembers = async (): Promise<void> => {
await fetchMoreGroupMembers({
// New variables
@@ -368,13 +367,13 @@ const {
],
}));
onRemoveMemberDone(() => {
onRemoveMemberDone(({ context }) => {
let message = t("The member was removed from the group {group}", {
group: displayName(group.value),
}) as string;
if (oldMember.role === MemberRole.NOT_APPROVED) {
if (context?.oldMember.role === MemberRole.NOT_APPROVED) {
message = t("The membership request from {profile} was rejected", {
group: displayName(oldMember.actor),
group: displayName(context?.oldMember.actor),
}) as string;
}
notifier?.success(message);
@@ -388,10 +387,15 @@ onRemoveMemberError((error) => {
});
const removeMember = (oldMember: IMember) => {
mutateRemoveMember({
groupId: group.value?.id,
memberId: oldMember.id,
});
mutateRemoveMember(
{
groupId: group.value?.id,
memberId: oldMember.id,
},
{
context: { oldMember },
}
);
};
const promoteMember = (member: IMember): void => {
@@ -465,7 +469,7 @@ onUpdateMutationDone(({ data, context }) => {
successMessage = "The member role was updated to administrator";
break;
case MemberRole.MEMBER:
if (oldMember.role === MemberRole.NOT_APPROVED) {
if (context?.oldMember.role === MemberRole.NOT_APPROVED) {
successMessage = "The member was approved";
} else {
successMessage = "The member role was updated to simple member";
@@ -488,11 +492,14 @@ const updateMember = async (
oldMember: IMember,
role: MemberRole
): Promise<void> => {
updateMemberMutation({
memberId: oldMember.id as string,
role,
oldRole: oldMember.role,
});
updateMemberMutation(
{
memberId: oldMember.id as string,
role,
oldRole: oldMember.role,
},
{ context: { oldMember } }
);
};
const isCurrentActorAGroupAdmin = computed((): boolean => {
@@ -500,10 +507,10 @@ const isCurrentActorAGroupAdmin = computed((): boolean => {
});
const hasCurrentActorThisRole = (givenRole: string | string[]): boolean => {
const roles = Array.isArray(givenRole) ? givenRole : [givenRole];
const rolesToConsider = Array.isArray(givenRole) ? givenRole : [givenRole];
return (
personMemberships.value?.total > 0 &&
roles.includes(personMemberships.value?.elements[0].role)
rolesToConsider.includes(personMemberships.value?.elements[0].role)
);
};

View File

@@ -11,60 +11,60 @@
{
name: RouteName.GROUP_SETTINGS,
params: { preferredUsername: usernameWithDomain(group) },
text: $t('Settings'),
text: t('Settings'),
},
{
name: RouteName.GROUP_PUBLIC_SETTINGS,
params: { preferredUsername: usernameWithDomain(group) },
text: $t('Group settings'),
text: t('Group settings'),
},
]"
/>
<o-loading :active="loading" />
<section
class="container mx-auto section"
class="container mx-auto mb-6"
v-if="group && isCurrentActorAGroupAdmin"
>
<form @submit.prevent="updateGroup">
<o-field :label="$t('Group name')" label-for="group-settings-name">
<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')">
<o-field :label="t('Group short description')">
<Editor
mode="basic"
v-model="editableGroup.summary"
:maxSize="500"
:aria-label="$t('Group description body')"
:aria-label="t('Group description body')"
v-if="currentActor"
:currentActor="currentActor"
/></o-field>
<o-field :label="$t('Avatar')">
<o-field :label="t('Avatar')">
<picture-upload
:textFallback="$t('Avatar')"
:textFallback="t('Avatar')"
v-model="avatarFile"
:defaultImage="group.avatar"
:maxSize="avatarMaxSize"
/>
</o-field>
<o-field :label="$t('Banner')">
<o-field :label="t('Banner')">
<picture-upload
:textFallback="$t('Banner')"
:textFallback="t('Banner')"
v-model="bannerFile"
:defaultImage="group.banner"
:maxSize="bannerMaxSize"
/>
</o-field>
<p class="label">{{ $t("Group visibility") }}</p>
<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 />
{{ t("Visible everywhere on the web") }}<br />
<small>{{
$t(
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>
@@ -75,9 +75,9 @@
v-model="editableGroup.visibility"
name="groupVisibility"
:native-value="GroupVisibility.UNLISTED"
>{{ $t("Only accessible through link") }}<br />
>{{ t("Only accessible through link") }}<br />
<small>{{
$t(
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>
@@ -86,7 +86,7 @@
<code>{{ group.url }}</code>
<o-tooltip
v-if="canShowCopyButton"
:label="$t('URL copied to clipboard')"
:label="t('URL copied to clipboard')"
:active="showCopiedTooltip"
always
variant="success"
@@ -103,16 +103,16 @@
</p>
</div>
<p class="label">{{ $t("New members") }}</p>
<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 />
{{ t("Anyone can join freely") }}<br />
<small>{{
$t(
t(
"Anyone wanting to be a member from your group will be able to from your group page."
)
}}</small>
@@ -123,9 +123,9 @@
v-model="editableGroup.openness"
name="groupOpenness"
:native-value="Openness.MODERATED"
>{{ $t("Moderate new members") }}<br />
>{{ t("Moderate new members") }}<br />
<small>{{
$t(
t(
"Anyone can request being a member, but an administrator needs to approve the membership."
)
}}</small>
@@ -136,9 +136,9 @@
v-model="editableGroup.openness"
name="groupOpenness"
:native-value="Openness.INVITE_ONLY"
>{{ $t("Manually invite new members") }}<br />
>{{ t("Manually invite new members") }}<br />
<small>{{
$t(
t(
"The only way for your group to get new members is if an admininistrator invites them."
)
}}</small>
@@ -146,26 +146,26 @@
</div>
<o-field
:label="$t('Followers')"
:message="$t('Followers will receive new public events and posts.')"
:label="t('Followers')"
:message="t('Followers will receive new public events and posts.')"
>
<o-checkbox v-model="editableGroup.manuallyApprovesFollowers">
{{ $t("Manually approve new followers") }}
{{ t("Manually approve new followers") }}
</o-checkbox>
</o-field>
<full-address-auto-complete
:label="$t('Group address')"
:label="t('Group address')"
v-model="currentAddress"
:hideMap="true"
/>
<div class="flex flex-wrap gap-2 my-2">
<o-button native-type="submit" variant="primary">{{
$t("Update group")
t("Update group")
}}</o-button>
<o-button @click="confirmDeleteGroup" variant="danger">{{
$t("Delete group")
t("Delete group")
}}</o-button>
</div>
</form>
@@ -178,7 +178,7 @@
</o-notification>
</section>
<o-notification v-else-if="!loading">
{{ $t("You are not an administrator for this group.") }}
{{ t("You are not an administrator for this group.") }}
</o-notification>
</div>
</template>
@@ -187,7 +187,7 @@
import FullAddressAutoComplete from "@/components/Event/FullAddressAutoComplete.vue";
import PictureUpload from "@/components/PictureUpload.vue";
import { GroupVisibility, MemberRole, Openness } from "@/types/enums";
import { Group, IGroup, usernameWithDomain, displayName } from "@/types/actor";
import { IGroup, usernameWithDomain, displayName } from "@/types/actor";
import { Address, IAddress } from "@/types/address.model";
import { ServerParseError } from "@apollo/client/link/http";
import { ErrorResponse } from "@apollo/client/link/error";
@@ -208,13 +208,19 @@ import { Dialog } from "@/plugins/dialog";
import { useHead } from "@vueuse/head";
import { Notifier } from "@/plugins/notifier";
const Editor = defineAsyncComponent(() => import("@/components/Editor.vue"));
const Editor = defineAsyncComponent(
() => import("@/components/TextEditor.vue")
);
const props = defineProps<{ preferredUsername: string }>();
const { currentActor } = useCurrentActorClient();
const { group, loading } = useGroup(props.preferredUsername);
const {
group,
loading,
onResult: onGroupResult,
} = useGroup(props.preferredUsername);
const { t } = useI18n({ useScope: "global" });
@@ -231,20 +237,17 @@ const errors = ref<string[]>([]);
const showCopiedTooltip = ref(false);
const editableGroup = ref<IGroup>(new Group());
const editableGroup = ref<IGroup>();
const updateGroup = async (): Promise<void> => {
const variables = buildVariables();
const { onDone, onError } = useUpdateGroup(variables);
const { onDone, onError, mutate: updateGroup } = useUpdateGroup();
onDone(() => {
notifier?.success(t("Group settings saved") as string);
});
onDone(() => {
notifier?.success(t("Group settings saved"));
});
onError((err) => {
handleError(err as unknown as ErrorResponse);
});
};
onError((err) => {
handleError(err as unknown as ErrorResponse);
});
const copyURL = async (): Promise<void> => {
await window.navigator.clipboard.writeText(group.value?.url ?? "");
@@ -254,28 +257,40 @@ const copyURL = async (): Promise<void> => {
}, 2000);
};
watch(group, async (oldGroup: IGroup, newGroup: IGroup) => {
try {
if (
oldGroup?.avatar !== undefined &&
oldGroup?.avatar !== newGroup?.avatar
) {
avatarFile.value = await buildFileFromIMedia(group.value?.avatar);
}
if (
oldGroup?.banner !== undefined &&
oldGroup?.banner !== newGroup?.banner
) {
bannerFile.value = await buildFileFromIMedia(group.value?.banner);
}
} catch (e) {
// Catch errors while building media
console.error(e);
}
editableGroup.value = { ...group.value };
onGroupResult(({ data }) => {
editableGroup.value = data.group;
});
const buildVariables = () => {
watch(
group,
async (newGroup: IGroup, oldGroup: IGroup) => {
console.debug("watching group");
if (!newGroup) return;
try {
if (
oldGroup?.avatar !== undefined &&
oldGroup?.avatar !== newGroup?.avatar
) {
avatarFile.value = await buildFileFromIMedia(newGroup?.avatar);
}
if (
oldGroup?.banner !== undefined &&
oldGroup?.banner !== newGroup?.banner
) {
bannerFile.value = await buildFileFromIMedia(newGroup?.banner);
}
} catch (e) {
// Catch errors while building media
console.error(e);
}
editableGroup.value = { ...newGroup };
},
{
immediate: true,
}
);
const buildVariables = computed(() => {
let avatarObj = {};
let bannerObj = {};
const variables = { ...editableGroup.value };
@@ -309,7 +324,7 @@ const buildVariables = () => {
media: {
name: avatarFile.value?.name,
alt: `${editableGroup.value?.preferredUsername}'s avatar`,
file: avatarFile,
file: avatarFile.value,
},
},
};
@@ -321,13 +336,13 @@ const buildVariables = () => {
media: {
name: bannerFile.value?.name,
alt: `${editableGroup.value?.preferredUsername}'s banner`,
file: bannerFile,
file: bannerFile.value,
},
},
};
}
return {
id: group.value?.id,
id: group.value?.id ?? "",
name: editableGroup.value?.name,
summary: editableGroup.value?.summary,
visibility: editableGroup.value?.visibility,
@@ -337,7 +352,7 @@ const buildVariables = () => {
...avatarObj,
...bannerObj,
};
};
});
const canShowCopyButton = computed((): boolean => {
return window.isSecureContext;
@@ -348,7 +363,9 @@ const currentAddress = computed({
return new Address(editableGroup.value?.physicalAddress);
},
set(address: IAddress) {
editableGroup.value.physicalAddress = address;
if (editableGroup.value) {
editableGroup.value.physicalAddress = address;
}
},
});

View File

@@ -21,7 +21,7 @@
<div class="flex self-center h-0 mt-4 items-end">
<figure class="" v-if="group.avatar">
<img
class="rounded-full border"
class="rounded-full border h-32 w-32"
:src="group.avatar.url"
alt=""
width="128"
@@ -382,7 +382,7 @@
</header>
</div>
<div
v-if="isCurrentActorAGroupMember && !previewPublic"
v-if="isCurrentActorAGroupMember && !previewPublic && group"
class="block-container flex gap-2 flex-wrap mt-3"
>
<!-- Private things -->
@@ -419,14 +419,20 @@
<aside class="group-metadata">
<div class="sticky">
<o-notification v-if="group.domain && !isCurrentActorAGroupMember">
{{
t(
"This profile is from another instance, the informations shown here may be incomplete."
)
}}
<a :href="group.url" rel="noopener noreferrer external">{{
t("View full profile")
}}</a>
<p>
{{
t(
"This profile is from another instance, the informations shown here may be incomplete."
)
}}
</p>
<o-button
variant="text"
tag="a"
:href="group.url"
rel="noopener noreferrer external"
>{{ t("View full profile") }}</o-button
>
</o-notification>
<event-metadata-block
:title="t('About')"
@@ -480,7 +486,7 @@
</div>
<o-button
class="map-show-button"
type="is-text"
variant="text"
@click="showMap = !showMap"
@keyup.enter="showMap = !showMap"
v-if="physicalAddress.geom"
@@ -528,7 +534,7 @@
<o-button
tag="router-link"
class="my-2 self-center"
type="is-text"
variant="text"
:to="{
name: RouteName.GROUP_EVENTS,
params: { preferredUsername: usernameWithDomain(group) },
@@ -543,7 +549,7 @@
<o-button
tag="router-link"
class="my-4"
type="is-text"
variant="text"
v-if="organizedEvents.total > 0"
:to="{
name: RouteName.GROUP_EVENTS,
@@ -579,7 +585,7 @@
class="self-center my-2"
v-if="posts.total > 0"
tag="router-link"
type="is-text"
variant="text"
:to="{
name: RouteName.POSTS,
params: { preferredUsername: usernameWithDomain(group) },
@@ -606,6 +612,7 @@
</div>
<o-modal v-if="group" v-model:active="isReportModalActive">
<report-modal
ref="reportModalRef"
:on-confirm="reportGroup"
:title="t('Report this group')"
:outside-domain="group.domain"
@@ -637,7 +644,7 @@ import { JOIN_GROUP } from "@/graphql/member";
import { MemberRole, Openness, PostVisibility } from "@/types/enums";
import { IMember } from "@/types/actor/member.model";
import RouteName from "../../router/name";
import ReportModal from "../../components/Report/ReportModal.vue";
import ReportModal from "@/components/Report/ReportModal.vue";
import {
GROUP_MEMBERSHIP_SUBSCRIPTION_CHANGED,
PERSON_STATUS_GROUP,
@@ -648,11 +655,7 @@ import EmptyContent from "../../components/Utils/EmptyContent.vue";
import { Paginate } from "@/types/paginate";
import { IEvent } from "@/types/event.model";
import { IPost } from "@/types/post.model";
import {
FOLLOW_GROUP,
UNFOLLOW_GROUP,
UPDATE_GROUP_FOLLOW,
} from "@/graphql/followers";
import { FOLLOW_GROUP, UPDATE_GROUP_FOLLOW } from "@/graphql/followers";
import { useAnonymousReportsConfig } from "../../composition/apollo/config";
import { computed, defineAsyncComponent, inject, ref, watch } from "vue";
import { useCurrentActorClient } from "@/composition/apollo/actor";
@@ -670,10 +673,10 @@ import AccountMultiplePlus from "vue-material-design-icons/AccountMultiplePlus.v
import { useI18n } from "vue-i18n";
import { useCreateReport } from "@/composition/apollo/report";
import { useHead } from "@vueuse/head";
import Discussions from "@/components/Group/Sections/Discussions.vue";
import Resources from "@/components/Group/Sections/Resources.vue";
import Posts from "@/components/Group/Sections/Posts.vue";
import Events from "@/components/Group/Sections/Events.vue";
import Discussions from "@/components/Group/Sections/DiscussionsSection.vue";
import Resources from "@/components/Group/Sections/ResourcesSection.vue";
import Posts from "@/components/Group/Sections/PostsSection.vue";
import Events from "@/components/Group/Sections/EventsSection.vue";
import { Dialog } from "@/plugins/dialog";
import { Notifier } from "@/plugins/notifier";
@@ -718,28 +721,32 @@ subscribeToMore<{ actorId: string; group: string }>({
const person = computed(() => result.value?.person);
const MapLeaflet = defineAsyncComponent(
() => import("../../components/Map.vue")
() => import("@/components/LeafletMap.vue")
);
const ShareGroupModal = defineAsyncComponent(
() => import("../../components/Group/ShareGroupModal.vue")
() => import("@/components/Group/ShareGroupModal.vue")
);
const showMap = ref(false);
const isReportModalActive = ref(false);
const reportModalRef = ref();
const isShareModalActive = ref(false);
const previewPublic = ref(false);
const notifier = inject<Notifier>("notifier");
watch(currentActor, (watchedCurrentActor: IActor, oldActor: IActor) => {
if (
watchedCurrentActor.id &&
oldActor &&
watchedCurrentActor.id !== oldActor.id
) {
refetchGroup();
watch(
currentActor,
(watchedCurrentActor: IActor | undefined, oldActor: IActor | undefined) => {
if (
watchedCurrentActor?.id &&
oldActor &&
watchedCurrentActor?.id !== oldActor.id
) {
refetchGroup();
}
}
});
);
const { mutate: joinGroupMutation, onError: onJoinGroupError } =
useMutation(JOIN_GROUP);
@@ -785,7 +792,7 @@ const dialog = inject<Dialog>("dialog");
const openLeaveGroupModal = async (): Promise<void> => {
dialog?.confirm({
type: "danger",
variant: "danger",
title: t("Leave group"),
message: t(
"Are you sure you want to leave the group {groupName}? You'll loose access to this group's private content. This action cannot be undone.",
@@ -902,7 +909,7 @@ const toggleFollowNotify = () => {
const reportGroup = async (content: string, forward: boolean) => {
isReportModalActive.value = false;
reportModal.value.close();
reportModalRef.value.close();
const {
mutate: createReportMutation,
@@ -937,8 +944,8 @@ const triggerShare = (): void => {
title: displayName(group.value),
url: group.value?.url,
})
.then(() => console.log("Successful share"))
.catch((error: any) => console.log("Error sharing", error));
.then(() => console.debug("Successful share"))
.catch((error: any) => console.debug("Error sharing", error));
} else {
isShareModalActive.value = true;
// send popup
@@ -1100,7 +1107,7 @@ const isCurrentActorAPendingGroupMember = computed((): boolean => {
});
const currentActorFollow = computed((): IFollower | undefined => {
if (person?.value && person?.value?.follows?.total > 0) {
if (person?.value?.follows?.total && person?.value?.follows?.total > 0) {
return person?.value?.follows?.elements[0];
}
return undefined;

View File

@@ -2,7 +2,7 @@
<div class="container mx-auto">
<h1 class="">{{ t("Settings") }}</h1>
<div class="flex flex-wrap gap-2">
<aside class="max-w-xs flex-1">
<aside class="sm:max-w-xs flex-1 min-w-[320px]">
<ul>
<SettingMenuSection
:title="t('Settings')"

View File

@@ -11,7 +11,7 @@
{
name: RouteName.TIMELINE,
params: { preferredUsername: usernameWithDomain(group) },
text: $t('Activity'),
text: t('Activity'),
},
]"
/>
@@ -20,51 +20,51 @@
<o-field>
<o-radio v-model="activityType" :native-value="undefined">
<TimelineText />
{{ $t("All activities") }}</o-radio
{{ t("All activities") }}</o-radio
>
<o-radio v-model="activityType" :native-value="ActivityType.MEMBER">
<o-icon icon="account-multiple-plus"></o-icon>
{{ $t("Members") }}</o-radio
{{ t("Members") }}</o-radio
>
<o-radio v-model="activityType" :native-value="ActivityType.GROUP">
<o-icon icon="cog"></o-icon>
{{ $t("Settings") }}</o-radio
{{ t("Settings") }}</o-radio
>
<o-radio v-model="activityType" :native-value="ActivityType.EVENT">
<o-icon icon="calendar"></o-icon>
{{ $t("Events") }}</o-radio
{{ t("Events") }}</o-radio
>
<o-radio v-model="activityType" :native-value="ActivityType.POST">
<o-icon icon="bullhorn"></o-icon>
{{ $t("Posts") }}</o-radio
{{ t("Posts") }}</o-radio
>
<o-radio v-model="activityType" :native-value="ActivityType.DISCUSSION">
<o-icon icon="chat"></o-icon>
{{ $t("Discussions") }}</o-radio
{{ t("Discussions") }}</o-radio
>
<o-radio v-model="activityType" :native-value="ActivityType.RESOURCE">
<o-icon icon="link"></o-icon>
{{ $t("Resources") }}</o-radio
{{ t("Resources") }}</o-radio
>
</o-field>
<o-field>
<o-radio v-model="activityAuthor" :native-value="undefined">
<TimelineText />
{{ $t("All activities") }}</o-radio
{{ t("All activities") }}</o-radio
>
<o-radio
v-model="activityAuthor"
:native-value="ActivityAuthorFilter.SELF"
>
<o-icon icon="account"></o-icon>
{{ $t("From yourself") }}</o-radio
{{ t("From yourself") }}</o-radio
>
<o-radio
v-model="activityAuthor"
:native-value="ActivityAuthorFilter.BY"
>
<o-icon icon="account-multiple"></o-icon>
{{ $t("By others") }}</o-radio
{{ t("By others") }}</o-radio
>
</o-field>
<transition-group name="timeline-list" tag="div">
@@ -78,25 +78,20 @@
width="300px"
height="48px"
/>
<h2 class="is-size-3 has-text-weight-bold" v-else-if="isToday(date)">
<h2 v-else-if="isToday(date)">
<span v-tooltip="formatDateString(date)">
{{ $t("Today") }}
{{ t("Today") }}
</span>
</h2>
<h2
class="is-size-3 has-text-weight-bold"
v-else-if="isYesterday(date)"
>
<span v-tooltip="formatDateString(date)">{{
$t("Yesterday")
}}</span>
<h2 v-else-if="isYesterday(date)">
<span v-tooltip="formatDateString(date)">{{ t("Yesterday") }}</span>
</h2>
<h2 v-else class="is-size-3 has-text-weight-bold">
<h2 v-else>
{{ formatDateString(date) }}
</h2>
<ul>
<li v-for="activityItem in activityItems" :key="activityItem.id">
<skeleton-activity-item v-if="activityItem.skeleton" />
<skeleton-activity-item v-if="activityItem.type === 'skeleton'" />
<component
v-else
:is="component(activityItem.type)"
@@ -113,14 +108,14 @@
activity.elements.length >= activity.total
"
>
{{ $t("No more activity to display.") }}
{{ t("No more activity to display.") }}
</empty-content>
<empty-content
v-if="!loading && activity.total === 0"
icon="timeline-text"
>
{{
$t(
t(
"There is no activity yet. Start doing some things to see activity appear here."
)
}}
@@ -129,7 +124,7 @@
<o-button
v-if="activity.elements.length < activity.total"
@click="loadMore"
>{{ $t("Load more activities") }}</o-button
>{{ t("Load more activities") }}</o-button
>
</section>
</div>
@@ -154,14 +149,16 @@ 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 };
type IActivitySkeleton =
| IActivity
| { skeleton: string; id: string; type: "skeleton" };
enum ActivityAuthorFilter {
SELF = "SELF",
BY = "BY",
}
type ActivityFilter = ActivityType | ActivityAuthorFilter | null;
// type ActivityFilter = ActivityType | ActivityAuthorFilter | null;
const props = defineProps<{ preferredUsername: string }>();
@@ -230,12 +227,12 @@ const activity = computed((): Paginate<IActivitySkeleton> => {
total: 0,
elements: skeletons.value.map((skeleton) => ({
skeleton,
id: skeleton,
type: "skeleton",
})),
};
});
const limit = PAGINATION_LIMIT;
const component = (type: ActivityType): any | undefined => {
switch (type) {
case ActivityType.EVENT:
@@ -309,11 +306,11 @@ const activities = computed((): Record<string, IActivitySkeleton[]> => {
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 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();

View File

@@ -130,7 +130,8 @@
<!-- Recent events -->
<CloseEvents @doGeoLoc="performGeoLocation()" :userLocation="userLocation" />
<CloseGroups :userLocation="userLocation" @doGeoLoc="performGeoLocation()" />
<LastEvents v-if="config" :instanceName="config.name" />
<OnlineEvents />
<LastEvents v-if="instanceName" :instanceName="instanceName" />
<!-- Unlogged content section -->
<picture v-if="!currentUser?.isLoggedIn">
<source
@@ -169,30 +170,23 @@
</template>
<script lang="ts" setup>
import { EventSortField, ParticipantRole, SortDirection } from "@/types/enums";
import { Paginate } from "@/types/paginate";
import { ParticipantRole } from "@/types/enums";
import { IParticipant } from "../types/participant.model";
import { FETCH_EVENTS } from "../graphql/event";
import EventParticipationCard from "../components/Event/EventParticipationCard.vue";
import MultiCard from "../components/Event/MultiCard.vue";
import { CURRENT_ACTOR_CLIENT } from "../graphql/actor";
import { IPerson, displayName } from "../types/actor";
import {
ICurrentUser,
IUser,
IUserSettings,
} from "../types/current-user.model";
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 { CONFIG } from "../graphql/config";
import { IConfig } from "../types/config.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 { computed, onMounted, reactive, watch } from "vue";
import OnlineEvents from "@/components/Local/OnlineEvents.vue";
import { computed, onMounted, reactive, ref, watch } from "vue";
import { useMutation, useQuery } from "@vue/apollo-composable";
import { useRouter } from "vue-router";
import { REVERSE_GEOCODE } from "@/graphql/address";
@@ -208,17 +202,18 @@ 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";
const { result: resultEvents } = useQuery<{ events: Paginate<IEvent> }>(
FETCH_EVENTS,
{
orderBy: EventSortField.INSERTED_AT,
direction: SortDirection.DESC,
}
);
const events = computed(
() => resultEvents.value?.events || { total: 0, elements: [] }
);
const { result: 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
@@ -233,9 +228,7 @@ const { result: currentUserResult } = useQuery<{
const currentUser = computed(() => currentUserResult.value?.currentUser);
const { result: configResult } = useQuery<{ config: IConfig }>(CONFIG);
const config = computed<IConfig | undefined>(() => configResult.value?.config);
const instanceName = computed(() => config.value?.name);
const { result: userResult } = useQuery<{ loggedUser: IUser }>(
HOME_USER_QUERIES,
@@ -254,14 +247,8 @@ const currentUserParticipations = computed(
() => loggedUser.value?.participations.elements
);
const instanceName = computed((): string | undefined => config.value?.name);
const welcomeBack = computed<boolean>(
() => window.localStorage.getItem("welcome-back") === "yes"
);
const newRegisteredUser = computed<boolean>(
() => window.localStorage.getItem("new-registered-user") === "yes"
);
const location = ref(null);
const search = ref("");
const isToday = (date: string): boolean => {
return new Date(date).toDateString() === new Date().toDateString();
@@ -338,10 +325,6 @@ const goingToEvents = computed<Map<string, Map<string, IParticipant>>>(() => {
);
});
const loggedUserSettings = computed<IUserSettings | undefined>(() => {
return loggedUser.value?.settings;
});
const canShowMyUpcomingEvents = computed<boolean>(() => {
return currentActor.value?.id != undefined && goingToEvents.value.size > 0;
});
@@ -362,11 +345,16 @@ const filteredFollowedGroupsEvents = computed<IEvent[]>(() => {
.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");
}
});
@@ -393,7 +381,7 @@ const userSettingsLocationGeoHash = computed(
);
// The location provided by the server
const serverLocation = computed(() => config.value?.location);
const { location: serverLocation } = useServerProvidedLocation();
// The coords from the user location or the server provided location
const coords = computed(() => {
@@ -496,7 +484,8 @@ onReverseGeocodeResult((result) => {
const fetchAndSaveCurrentLocationName = async ({
coords: { latitude, longitude, accuracy },
}: GeolocationPosition) => {
}: // eslint-disable-next-line no-undef
GeolocationPosition) => {
reverseGeoCodeInformation.latitude = latitude;
reverseGeoCodeInformation.longitude = longitude;
reverseGeoCodeInformation.accuracy = accuracy;

View File

@@ -35,13 +35,12 @@ import RouteName from "../router/name";
import { GraphQLError } from "graphql";
import { useQuery } from "@vue/apollo-composable";
import { computed, reactive } from "vue";
import { useRoute, useRouter } from "vue-router";
import { 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 route = useRoute();
const { t } = useI18n({ useScope: "global" });
const uri = useRouteQuery("uri", "");
@@ -49,9 +48,9 @@ const uri = useRouteQuery("uri", "");
const isURI = computed((): boolean => {
try {
const url = new URL(uri.value);
return !(url instanceof URL);
return url instanceof URL;
} catch (e) {
return true;
return false;
}
});
@@ -65,7 +64,7 @@ const { onResult, onError, loading } = useQuery<{
uri: uri.value,
}),
() => ({
enabled: isURI.value !== false,
enabled: isURI.value === true,
})
);

View File

@@ -1,526 +0,0 @@
<template>
<div>
<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 }),
},
]"
/>
<section>
<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">
<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
>
</div>
<div class="w-full">
<table class="table w-full">
<tbody>
<tr v-if="report.reported.__typename === '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>
<td>
{{ t("Reported identity") }}
</td>
<td>
<router-link
:to="{
name: RouteName.ADMIN_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>
<td>{{ t("Reported by") }}</td>
<td v-if="report.reporter.type === ActorType.APPLICATION">
{{ report.reporter.domain }}
</td>
<td v-else>
<router-link
:to="{
name: RouteName.ADMIN_PROFILE,
params: { id: report.reporter.id },
}"
>
<img
v-if="report.reporter.avatar"
class="image"
:src="report.reporter.avatar.url"
alt=""
/>
{{ displayNameAndUsername(report.reporter) }}
</router-link>
</td>
</tr>
<tr>
<td>{{ t("Reported") }}</td>
<td>{{ formatDateTimeString(report.insertedAt) }}</td>
</tr>
<tr v-if="report.updatedAt !== report.insertedAt">
<td>{{ t("Updated") }}</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>
<tr v-if="report.event && report.comments.length > 0">
<td>{{ t("Event") }}</td>
<td>
<router-link
:to="{
name: RouteName.EVENT,
params: { uuid: report.event.uuid },
}"
>
{{ report.event.title }}
</router-link>
<span class="is-pulled-right">
<!-- <o-button-->
<!-- tag="router-link"-->
<!-- variant="primary"-->
<!-- :to="{ name: RouteName.EDIT_EVENT, params: {eventId: report.event.uuid } }"-->
<!-- icon-left="pencil"-->
<!-- size="small">{{ t('Edit') }}</o-button>-->
<o-button
variant="danger"
@click="confirmEventDelete()"
icon-left="delete"
size="small"
>{{ t("Delete") }}</o-button
>
</span>
</td>
</tr>
</tbody>
</table>
</div>
<div class="">
<p v-if="report.content" v-html="nl2br(report.content)" />
<p v-else>{{ t("No comment") }}</p>
</div>
<div class="" v-if="report.event && report.comments.length === 0">
<router-link
:to="{ name: RouteName.EVENT, params: { uuid: report.event.uuid } }"
>
<h3 class="title">{{ report.event.title }}</h3>
<p v-html="report.event.description" />
</router-link>
<!-- <o-button-->
<!-- tag="router-link"-->
<!-- variant="primary"-->
<!-- :to="{ name: RouteName.EDIT_EVENT, params: {eventId: report.event.uuid } }"-->
<!-- icon-left="pencil"-->
<!-- size="small">{{ t('Edit') }}</o-button>-->
<o-button
variant="danger"
@click="confirmEventDelete()"
icon-left="delete"
size="small"
>{{ t("Delete") }}</o-button
>
</div>
<div v-if="report.comments.length > 0">
<ul v-for="comment in report.comments" :key="comment.id">
<li>
<div class="" v-if="comment">
<article class="flex gap-1">
<div class="">
<figure
class=""
v-if="comment.actor && comment.actor.avatar"
>
<img
:src="comment.actor.avatar.url"
alt=""
width="48"
height="48"
/>
</figure>
<AccountCircle :size="48" v-else />
</div>
<div class="">
<div class="prose dark:prose-invert">
<span v-if="comment.actor">
<strong>{{ comment.actor.name }}</strong>
<small>@{{ comment.actor.preferredUsername }}</small>
</span>
<span v-else>{{ t("Unknown actor") }}</span>
<br />
<p v-html="comment.text" />
</div>
<o-button
variant="danger"
@click="confirmCommentDelete(comment)"
icon-left="delete"
size="small"
>{{ t("Delete") }}</o-button
>
</div>
</article>
</div>
</li>
</ul>
</div>
<h2 v-if="report.notes.length > 0">{{ t("Notes") }}</h2>
<div
class="box note"
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>
</div>
</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 { displayNameAndUsername } from "@/types/actor";
import { DELETE_EVENT } from "@/graphql/event";
import uniq from "lodash/uniq";
import { nl2br } from "@/utils/html";
import { DELETE_COMMENT } from "@/graphql/comment";
import { IComment } from "@/types/comment.model";
import { ActorType, ReportStatusEnum } from "@/types/enums";
import RouteName from "@/router/name";
import { GraphQLError } from "graphql";
import { ApolloCache, FetchResult } from "@apollo/client/core";
import { useMutation, useQuery } from "@vue/apollo-composable";
import { useCurrentActorClient } from "@/composition/apollo/actor";
import { useHead } from "@vueuse/head";
import { useI18n } from "vue-i18n";
import { useRouter } from "vue-router";
import { ref, computed, inject } from "vue";
import { formatDateTimeString } from "@/filters/datetime";
import AccountCircle from "vue-material-design-icons/AccountCircle.vue";
import { Dialog } from "@/plugins/dialog";
import { Notifier } from "@/plugins/notifier";
const router = useRouter();
const props = defineProps<{ reportId: string }>();
const { currentActor } = useCurrentActorClient();
const { result: reportResult, onError: onReportQueryError } = useQuery<{
report: IReport;
}>(REPORT, () => ({
id: props.reportId,
}));
const report = computed(() => reportResult.value?.report);
onReportQueryError(({ graphQLErrors }) => {
errors.value = uniq(
graphQLErrors.map(({ message }: GraphQLError) => message)
);
});
const { t } = useI18n({ useScope: "global" });
useHead({
title: computed(() => t("Report")),
});
const notifier = inject<Notifier>("notifier");
const errors = ref<string[]>([]);
const noteContent = ref("");
const {
mutate: createReportNoteMutation,
onDone: createReportNoteMutationDone,
onError: createReportNoteMutationError,
} = useMutation<{
createReportNote: IReportNote;
}>(CREATE_REPORT_NOTE, () => ({
update: (
store: ApolloCache<{ createReportNote: IReportNote }>,
{ data }: FetchResult
) => {
if (data == null) return;
const cachedData = store.readQuery<{ report: IReport }>({
query: REPORT,
variables: { id: report.value.id },
});
if (cachedData == null) return;
const { report } = cachedData;
if (report === null) {
console.error("Cannot update event notes cache, because of null value.");
return;
}
const note = data.createReportNote;
note.moderator = currentActor.value;
report.notes = report.notes.concat([note]);
store.writeQuery({
query: REPORT,
variables: { id: report.value.id },
data: { report },
});
},
}));
createReportNoteMutationDone(() => {
noteContent.value = "";
});
createReportNoteMutationError((error) => {
console.error(error);
});
const dialog = inject<Dialog>("dialog");
const confirmEventDelete = (): void => {
dialog?.confirm({
title: t("Deleting event"),
message: t(
"Are you sure you want to <b>delete</b> this event? This action cannot be undone. You may want to engage the discussion with the event creator or edit its event instead."
),
confirmText: t("Delete Event"),
type: "danger",
hasIcon: true,
onConfirm: () => deleteEvent(),
});
};
const confirmCommentDelete = (comment: IComment): void => {
dialog?.confirm({
title: t("Deleting comment"),
message: t(
"Are you sure you want to <b>delete</b> this comment? This action cannot be undone."
),
confirmText: t("Delete Comment"),
type: "danger",
hasIcon: true,
onConfirm: () => deleteCommentMutation({ commentId: comment.id }),
});
};
const {
mutate: deleteEventMutation,
onDone: deleteEventMutationDone,
onError: deleteEventMutationError,
} = useMutation<{ deleteEvent: { id: string } }>(DELETE_EVENT);
deleteEventMutationDone(() => {
const eventTitle = report.value?.event?.title;
notifier?.success(
t("Event {eventTitle} deleted", {
eventTitle,
})
);
});
deleteEventMutationError((error) => {
console.error(error);
});
const deleteEvent = async (): Promise<void> => {
if (!report.value?.event?.id) return;
deleteEventMutation({ eventId: report.value.event.id });
};
const {
mutate: deleteCommentMutation,
onDone: deleteCommentMutationDone,
onError: deleteCommentMutationError,
} = useMutation<{ deleteComment: { id: string } }>(DELETE_COMMENT);
deleteCommentMutationDone(() => {
notifier?.success(t("Comment deleted") as string);
});
deleteCommentMutationError((error) => {
console.error(error);
});
const {
mutate: updateReportMutation,
onDone: onUpdateReportMutation,
onError: onUpdateReportError,
} = useMutation(UPDATE_REPORT, () => ({
update: (
store: ApolloCache<{ updateReportStatus: IReport }>,
{ data }: FetchResult
) => {
if (data == null) return;
const reportCachedData = store.readQuery<{ report: IReport }>({
query: REPORT,
variables: { id: report.value.id },
});
if (reportCachedData == null) return;
const { report } = reportCachedData;
if (report === null) {
console.error("Cannot update event notes cache, because of null value.");
return;
}
const updatedReport = {
...report,
status: data.updateReportStatus.status,
};
store.writeQuery({
query: REPORT,
variables: { id: report.value.id },
data: { report: updatedReport },
});
},
}));
onUpdateReportMutation(() => {
router.push({ name: RouteName.REPORTS });
});
onUpdateReportError((error) => {
console.error(error);
});
const updateReport = async (status: ReportStatusEnum): Promise<void> => {
updateReportMutation({
reportId: report.value?.id,
status,
});
};
</script>
<style lang="scss" scoped>
tbody td img.image,
.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;
}
td > a {
text-decoration: none;
}
</style>

View File

@@ -4,35 +4,35 @@
:links="[
{
name: RouteName.MODERATION,
text: $t('Moderation'),
text: t('Moderation'),
},
{
name: RouteName.REPORTS,
text: $t('Reports'),
text: t('Reports'),
},
]"
/>
<section>
<div class="flex flex-wrap gap-2">
<o-field :label="$t('Report status')">
<o-field :label="t('Report status')">
<o-radio v-model="status" :native-value="ReportStatusEnum.OPEN">{{
$t("Open")
t("Open")
}}</o-radio>
<o-radio v-model="status" :native-value="ReportStatusEnum.RESOLVED">{{
$t("Resolved")
t("Resolved")
}}</o-radio>
<o-radio v-model="status" :native-value="ReportStatusEnum.CLOSED">{{
$t("Closed")
t("Closed")
}}</o-radio>
</o-field>
<o-field
:label="$t('Domain')"
:label="t('Domain')"
label-for="domain-filter"
class="flex-auto"
>
<o-input
id="domain-filter"
:placeholder="$t('mobilizon-instance.tld')"
:placeholder="t('mobilizon-instance.tld')"
:value="filterDomain"
@input="debouncedUpdateDomainFilter"
/>
@@ -53,21 +53,21 @@
inline
v-if="status === ReportStatusEnum.OPEN"
>
{{ $t("No open reports yet") }}
{{ t("No open reports yet") }}
</empty-content>
<empty-content
icon="chat-alert"
inline
v-if="status === ReportStatusEnum.RESOLVED"
>
{{ $t("No resolved reports yet") }}
{{ t("No resolved reports yet") }}
</empty-content>
<empty-content
icon="chat-alert"
inline
v-if="status === ReportStatusEnum.CLOSED"
>
{{ $t("No closed reports yet") }}
{{ t("No closed reports yet") }}
</empty-content>
</div>
<o-pagination
@@ -75,10 +75,10 @@
v-model="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')"
: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>
@@ -96,7 +96,7 @@ import debounce from "lodash/debounce";
import { useQuery } from "@vue/apollo-composable";
import { useI18n } from "vue-i18n";
import { useHead } from "@vueuse/head";
import { computed, ref } from "vue";
import { computed } from "vue";
import {
enumTransformer,
integerTransformer,
@@ -132,7 +132,7 @@ useHead({
title: computed(() => t("Reports")),
});
const filterReports = ref<ReportStatusEnum>(ReportStatusEnum.OPEN);
// const filterReports = ref<ReportStatusEnum>(ReportStatusEnum.OPEN);
const updateDomainFilter = (event: InputEvent) => {
filterDomain.value = event.target?.value;

View File

@@ -0,0 +1,543 @@
<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
>
</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>
<td>
{{ t("Reported identity") }}
</td>
<td>
<router-link
:to="{
name: RouteName.ADMIN_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>
<td>{{ t("Reported by") }}</td>
<td v-if="report.reporter.type === ActorType.APPLICATION">
{{ report.reporter.domain }}
</td>
<td v-else>
<router-link
:to="{
name: RouteName.ADMIN_PROFILE,
params: { id: report.reporter.id },
}"
>
<img
v-if="report.reporter.avatar"
class="image"
:src="report.reporter.avatar.url"
alt=""
/>
{{ displayNameAndUsername(report.reporter) }}
</router-link>
</td>
</tr>
<tr>
<td>{{ t("Reported") }}</td>
<td>{{ formatDateTimeString(report.insertedAt) }}</td>
</tr>
<tr v-if="report.updatedAt !== report.insertedAt">
<td>{{ t("Updated") }}</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>
<tr v-if="report.event && report.comments.length > 0">
<td>{{ t("Event") }}</td>
<td>
<router-link
:to="{
name: RouteName.EVENT,
params: { uuid: report.event.uuid },
}"
>
{{ report.event.title }}
</router-link>
<span>
<!-- <o-button-->
<!-- tag="router-link"-->
<!-- variant="primary"-->
<!-- :to="{ name: RouteName.EDIT_EVENT, params: {eventId: report.event.uuid } }"-->
<!-- icon-left="pencil"-->
<!-- size="small">{{ t('Edit') }}</o-button>-->
<o-button
variant="danger"
@click="confirmEventDelete()"
icon-left="delete"
size="small"
>{{ t("Delete") }}</o-button
>
</span>
</td>
</tr>
</tbody>
</table>
</section>
<section>
<h2>{{ t("Report reason") }}</h2>
<div class="dark:bg-zinc-700 p-2 rounded my-2">
<div class="flex gap-1">
<figure class="" v-if="report.reported.avatar">
<img
alt=""
:src="report.reported.avatar.url"
class="rounded-full"
width="48"
height="48"
/>
</figure>
<AccountCircle v-else :size="48" />
<div class="">
<p class="" v-if="report.reported.name">
{{ report.reported.name }}
</p>
<p class="">@{{ usernameWithDomain(report.reported) }}</p>
</div>
</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="" v-if="report.event && report.comments.length === 0">
<h2>{{ t("Reported content") }}</h2>
<EventCard :event="report.event" mode="row" class="my-2" />
<!-- <o-button-->
<!-- tag="router-link"-->
<!-- variant="primary"-->
<!-- :to="{ name: RouteName.EDIT_EVENT, params: {eventId: report.event.uuid } }"-->
<!-- icon-left="pencil"-->
<!-- size="small">{{ t('Edit') }}</o-button>-->
<o-button
variant="danger"
@click="confirmEventDelete()"
icon-left="delete"
size="small"
>{{ t("Delete") }}</o-button
>
</section>
<div v-if="report.comments.length > 0">
<ul v-for="comment in report.comments" :key="comment.id">
<li>
<div class="" v-if="comment">
<article class="flex gap-1">
<div class="">
<figure class="" v-if="comment.actor && comment.actor.avatar">
<img
:src="comment.actor.avatar.url"
alt=""
width="48"
height="48"
/>
</figure>
<AccountCircle :size="48" v-else />
</div>
<div class="">
<div class="prose dark:prose-invert">
<span v-if="comment.actor">
<strong>{{ comment.actor.name }}</strong>
<small>@{{ comment.actor.preferredUsername }}</small>
</span>
<span v-else>{{ t("Unknown actor") }}</span>
<br />
<p v-html="comment.text" />
</div>
<o-button
variant="danger"
@click="confirmCommentDelete(comment)"
icon-left="delete"
size="small"
>{{ t("Delete") }}</o-button
>
</div>
</article>
</div>
</li>
</ul>
</div>
<section>
<h2>{{ t("Notes") }}</h2>
<div
class="box note"
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 { 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, ReportStatusEnum } from "@/types/enums";
import RouteName from "@/router/name";
import { GraphQLError } from "graphql";
import { ApolloCache, FetchResult } from "@apollo/client/core";
import { useMutation, useQuery } from "@vue/apollo-composable";
import { useCurrentActorClient } from "@/composition/apollo/actor";
import { useHead } from "@vueuse/head";
import { useI18n } from "vue-i18n";
import { useRouter } from "vue-router";
import { ref, computed, inject } from "vue";
import { formatDateTimeString } from "@/filters/datetime";
import AccountCircle from "vue-material-design-icons/AccountCircle.vue";
import { Dialog } from "@/plugins/dialog";
import { Notifier } from "@/plugins/notifier";
import EventCard from "@/components/Event/EventCard.vue";
const router = useRouter();
const props = defineProps<{ reportId: string }>();
const { currentActor } = useCurrentActorClient();
const { result: reportResult, onError: onReportQueryError } = useQuery<{
report: IReport;
}>(REPORT, () => ({
id: props.reportId,
}));
const report = computed(() => reportResult.value?.report);
onReportQueryError(({ graphQLErrors }) => {
errors.value = uniq(
graphQLErrors.map(({ message }: GraphQLError) => message)
);
});
const { t } = useI18n({ useScope: "global" });
useHead({
title: computed(() => t("Report")),
});
const notifier = inject<Notifier>("notifier");
const errors = ref<string[]>([]);
const noteContent = ref("");
const {
mutate: createReportNoteMutation,
onDone: createReportNoteMutationDone,
onError: createReportNoteMutationError,
} = useMutation<{
createReportNote: IReportNote;
}>(CREATE_REPORT_NOTE, () => ({
update: (
store: ApolloCache<{ createReportNote: IReportNote }>,
{ data }: FetchResult
) => {
if (data == null) return;
const cachedData = store.readQuery<{ report: IReport }>({
query: REPORT,
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 confirmEventDelete = (): void => {
dialog?.confirm({
title: t("Deleting event"),
message: t(
"Are you sure you want to <b>delete</b> this event? This action cannot be undone. You may want to engage the discussion with the event creator or edit its event instead."
),
confirmText: t("Delete Event"),
variant: "danger",
hasIcon: true,
onConfirm: () => deleteEvent(),
});
};
const confirmCommentDelete = (comment: IComment): void => {
dialog?.confirm({
title: t("Deleting comment"),
message: t(
"Are you sure you want to <b>delete</b> this comment? This action cannot be undone."
),
confirmText: t("Delete Comment"),
variant: "danger",
hasIcon: true,
onConfirm: () => deleteCommentMutation({ commentId: comment.id }),
});
};
const {
mutate: deleteEventMutation,
onDone: deleteEventMutationDone,
onError: deleteEventMutationError,
} = useMutation<{ deleteEvent: { id: string } }>(DELETE_EVENT);
deleteEventMutationDone(() => {
const eventTitle = report.value?.event?.title;
notifier?.success(
t("Event {eventTitle} deleted", {
eventTitle,
})
);
});
deleteEventMutationError((error) => {
console.error(error);
});
const deleteEvent = async (): Promise<void> => {
if (!report.value?.event?.id) return;
deleteEventMutation({ eventId: report.value.event.id });
};
const {
mutate: deleteCommentMutation,
onDone: deleteCommentMutationDone,
onError: deleteCommentMutationError,
} = useMutation<{ deleteComment: { id: string } }>(DELETE_COMMENT);
deleteCommentMutationDone(() => {
notifier?.success(t("Comment deleted") as string);
});
deleteCommentMutationError((error) => {
console.error(error);
});
const {
mutate: updateReportMutation,
onDone: onUpdateReportMutation,
onError: onUpdateReportError,
} = useMutation(UPDATE_REPORT, () => ({
update: (
store: ApolloCache<{ updateReportStatus: IReport }>,
{ data }: FetchResult
) => {
if (data == null) return;
const reportCachedData = store.readQuery<{ report: IReport }>({
query: REPORT,
variables: { id: report.value?.id },
});
if (reportCachedData == null) return;
const { report: 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(() => {
router.push({ name: RouteName.REPORTS });
});
onUpdateReportError((error) => {
console.error(error);
});
const updateReport = async (status: ReportStatusEnum): Promise<void> => {
updateReportMutation({
reportId: report.value?.id,
status,
});
};
</script>
<style lang="scss" scoped>
tbody td img.image,
.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;
}
td > a {
text-decoration: none;
}
</style>

View File

@@ -83,12 +83,13 @@
<div class="container mx-auto">
<div class="navbar-menu flex flex-wrap py-2">
<div class="flex flex-wrap justify-end ml-auto gap-1">
<o-button type="is-text" @click="$router.go(-1)">{{
<o-button variant="text" @click="$router.go(-1)">{{
$t("Cancel")
}}</o-button>
<o-button
v-if="isUpdate"
type="is-danger is-outlined"
variant="danger"
outlined
@click="openDeletePostModal"
>{{ $t("Delete post") }}</o-button
>
@@ -140,7 +141,7 @@ import {
} from "../../graphql/post";
import { IPost } from "../../types/post.model";
import Editor from "../../components/Editor.vue";
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";
@@ -191,11 +192,13 @@ onMounted(async () => {
pictureFile.value = await buildFileFromIMedia(post.value?.picture);
});
watch(post, async (newPost: IPost, oldPost: IPost) => {
if (oldPost?.picture !== newPost.picture) {
watch(post, async (newPost: IPost | undefined, oldPost: IPost | undefined) => {
if (oldPost?.picture !== newPost?.picture) {
pictureFile.value = await buildFileFromIMedia(post.value?.picture);
}
editablePost.value = { ...post.value };
if (newPost) {
editablePost.value = { ...newPost };
}
});
const router = useRouter();
@@ -370,7 +373,7 @@ const dialog = inject<Dialog>("dialog");
const openDeletePostModal = async (): Promise<void> => {
dialog?.confirm({
type: "danger",
variant: "danger",
title: t("Delete post"),
message: t(
"Are you sure you want to delete this post? This action cannot be reverted."
@@ -382,11 +385,8 @@ const openDeletePostModal = async (): Promise<void> => {
});
};
const {
mutate: deletePost,
onDone: onDeletePostDone,
onError: onDeletePostError,
} = useMutation(DELETE_POST);
const { mutate: deletePost, onDone: onDeletePostDone } =
useMutation(DELETE_POST);
onDeletePostDone(({ data }) => {
if (data && post.value?.attributedTo) {
@@ -405,27 +405,3 @@ useHead({
),
});
</script>
<style lang="scss" scoped>
@use "@/styles/_mixins" as *;
form {
nav.navbar {
// min-height: 2rem !important;
.container {
// min-height: 2rem;
.navbar-menu,
.navbar-end {
// display: flex !important;
// flex-wrap: wrap;
}
.navbar-end {
// justify-content: flex-end;
// @include margin-left(auto);
}
}
}
}
</style>

View File

@@ -11,12 +11,12 @@
>
<div class="flex-1">
<div class="inline">
<b-tag
<tag
class="mr-2"
variant="warning"
size="is-medium"
size="medium"
v-if="post.draft"
>{{ $t("Draft") }}</b-tag
>{{ $t("Draft") }}</tag
>
<h1 class="inline" :lang="post.language">
{{ post.title }}
@@ -191,7 +191,7 @@
has-modal-card
ref="reportModal"
>
<report-modal
<ReportModal
:on-confirm="reportPost"
:title="$t('Report this post')"
:outside-domain="groupDomain"
@@ -224,7 +224,6 @@ import LazyImageWrapper from "@/components/Image/LazyImageWrapper.vue";
import ActorInline from "@/components/Account/ActorInline.vue";
import { formatDistanceToNowStrict, Locale } from "date-fns";
import SharePostModal from "@/components/Post/SharePostModal.vue";
import { CREATE_REPORT } from "@/graphql/report";
import ReportModal from "@/components/Report/ReportModal.vue";
import { useAnonymousReportsConfig } from "@/composition/apollo/config";
import {
@@ -283,13 +282,6 @@ const isShareModalActive = ref(false);
const isReportModalActive = ref(false);
const reportModal = ref();
const isCurrentActorMember = computed((): boolean => {
if (!post.value?.attributedTo || !memberships.value) return false;
return memberships.value.elements
.map(({ parent: { id } }) => id)
.includes(post.value?.attributedTo.id);
});
const isInstanceModerator = computed((): boolean => {
return (
currentUser.value?.role !== undefined &&
@@ -313,8 +305,8 @@ const triggerShare = (): void => {
title: post.value?.title,
url: post.value?.url,
})
.then(() => console.log("Successful share"))
.catch((error: any) => console.log("Error sharing", error));
.then(() => console.debug("Successful share"))
.catch((error: any) => console.debug("Error sharing", error));
} else {
isShareModalActive.value = true;
// send popup
@@ -382,7 +374,7 @@ const dialog = inject<Dialog>("dialog");
const openDeletePostModal = async (): Promise<void> => {
dialog?.confirm({
type: "danger",
variant: "danger",
title: t("Delete post"),
message: t(
"Are you sure you want to delete this post? This action cannot be reverted."
@@ -396,11 +388,8 @@ const openDeletePostModal = async (): Promise<void> => {
const router = useRouter();
const {
mutate: deletePost,
onDone: onDeletePostDone,
onError: onDeletePostError,
} = useMutation(DELETE_POST);
const { mutate: deletePost, onDone: onDeletePostDone } =
useMutation(DELETE_POST);
onDeletePostDone(({ data }) => {
if (data && post.value?.attributedTo) {

View File

@@ -9,11 +9,11 @@
<o-dropdown-item aria-role="listitem" @click="createFolderModal">
<Folder />
{{ $t("New folder") }}
{{ t("New folder") }}
</o-dropdown-item>
<o-dropdown-item aria-role="listitem" @click="createLinkModal">
<Link />
{{ $t("New link") }}
{{ t("New link") }}
</o-dropdown-item>
<hr
role="presentation"
@@ -48,21 +48,21 @@
:total="resource.children.total"
v-model="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')"
: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')"
:close-button-aria-label="t('Close')"
>
<div class="w-full md:w-[640px]">
<section>
<form @submit.prevent="renameResource">
<o-field :label="$t('Title')">
<o-field :label="t('Title')">
<o-input
ref="resourceRenameInput"
aria-required="true"
@@ -70,9 +70,7 @@
/>
</o-field>
<o-button native-type="submit">{{
$t("Rename resource")
}}</o-button>
<o-button native-type="submit">{{ t("Rename resource") }}</o-button>
</form>
</section>
</div>
@@ -80,7 +78,7 @@
<o-modal
v-model:active="moveModal"
has-modal-card
:close-button-aria-label="$t('Close')"
:close-button-aria-label="t('Close')"
>
<div class="w-full md:w-[640px]">
<section>
@@ -96,36 +94,41 @@
<o-modal
v-model:active="createResourceModal"
has-modal-card
:close-button-aria-label="$t('Close')"
:close-button-aria-label="t('Close')"
trap-focus
>
<div class="w-full md:w-[640px]">
<section>
<o-notification variant="danger" v-if="modalError">
{{ modalError }}
</o-notification>
<form @submit.prevent="createResource">
<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>
<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 native-type="submit">{{
createResourceButtonLabel
}}</o-button>
</form>
</section>
</div>
<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')"
:close-button-aria-label="t('Close')"
trap-focus
:width="640"
>
@@ -135,7 +138,7 @@
{{ modalError }}
</o-notification>
<form @submit.prevent="createResource">
<o-field expanded :label="$t('URL')" label-for="new-resource-url">
<o-field expanded :label="t('URL')" label-for="new-resource-url">
<o-input
id="new-resource-url"
type="url"
@@ -150,7 +153,7 @@
<resource-item :resource="newResource" :preview="true" />
</div>
<o-field :label="$t('Title')" label-for="new-resource-link-title">
<o-field :label="t('Title')" label-for="new-resource-link-title">
<o-input
aria-required="true"
v-model="newResource.title"
@@ -158,10 +161,7 @@
/>
</o-field>
<o-field
:label="$t('Description')"
label-for="new-resource-summary"
>
<o-field :label="t('Description')" label-for="new-resource-summary">
<o-input
type="textarea"
v-model="newResource.summary"
@@ -170,7 +170,7 @@
</o-field>
<o-button native-type="submit" class="mt-2">{{
$t("Create resource")
t("Create resource")
}}</o-button>
</form>
</section>
@@ -246,8 +246,6 @@ onGetResourceError(({ graphQLErrors }) => {
handleErrors(graphQLErrors);
});
const { currentActor } = useCurrentActorClient();
const { resourceProviders } = useResourceProviders();
const { t } = useI18n({ useScope: "global" });
@@ -489,7 +487,7 @@ const { mutate: updateResourceMutation } = useMutation<{
if (!data || data.updateResource == null || parentPath == null) return;
if (!resource.value?.actor) return;
console.log("Removing ressource from old parent");
console.debug("Removing ressource from old parent");
const oldParentCachedData = store.readQuery<{ resource: IResource }>({
query: GET_RESOURCE,
variables: {
@@ -525,11 +523,11 @@ const { mutate: updateResourceMutation } = useMutation<{
},
},
});
console.log("Finished removing ressource from old parent");
console.debug("Finished removing ressource from old parent");
console.log("Adding resource to new parent");
console.debug("Adding resource to new parent");
if (!updatedResource.parent || !updatedResource.parent.path) {
console.log("No cache found for new parent");
console.debug("No cache found for new parent");
return;
}
const newParentCachedData = store.readQuery<{ resource: IResource }>({
@@ -562,7 +560,7 @@ const { mutate: updateResourceMutation } = useMutation<{
},
},
});
console.log("Finished adding resource to new parent");
console.debug("Finished adding resource to new parent");
},
}));
@@ -633,12 +631,17 @@ const breadcrumbLinks = computed(() => {
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,
folder: lastFragment.value,
})
),
});

View File

@@ -1,9 +1,17 @@
<template>
<div class="max-w-4xl mx-auto">
<SearchFields
class="md:ml-10 mr-2"
v-model:search="searchDebounced"
v-model:location="location"
:locationDefaultText="locationName"
/>
</div>
<div
class="container mx-auto md:py-3 md:px-4 flex flex-col lg:flex-row gap-x-5 gap-y-1"
>
<aside
class="flex-none lg:block lg:sticky top-8 rounded-md px-2 pt-2 w-full lg:w-80 flex-col justify-between mt-2 lg:pb-10 lg:px-8 overflow-y-auto dark:text-slate-100 bg-white dark:bg-mbz-purple"
class="flex-none lg:block lg:sticky top-8 rounded-md w-full lg:w-80 flex-col justify-between mt-2 lg:pb-10 lg:px-8 overflow-y-auto dark:text-slate-100 bg-white dark:bg-mbz-purple"
>
<o-button
@click="toggleFilters"
@@ -16,7 +24,7 @@
<form
@submit.prevent="doNewSearch"
:class="{ hidden: filtersPanelOpened }"
class="lg:block mt-2"
class="lg:block mt-4 px-2"
>
<p class="sr-only">{{ t("Type") }}</p>
<ul
@@ -53,6 +61,46 @@
</li>
</ul>
<div
class="py-4 border-b border-gray-200 dark:border-gray-500"
v-show="globalSearchEnabled"
>
<fieldset class="flex flex-col">
<legend class="sr-only">{{ t("Search target") }}</legend>
<div>
<input
id="internalTarget"
v-model="searchTarget"
type="radio"
name="searchTarget"
:value="SearchTargets.INTERNAL"
class="w-4 h-4 border-gray-300 focus:ring-2 focus:ring-blue-300 dark:focus:ring-blue-600 dark:focus:bg-blue-600 dark:bg-gray-700 dark:border-gray-600"
/>
<label
for="internalTarget"
class="ml-3 font-medium text-gray-900 dark:text-gray-300"
>{{ t("In this instance's network") }}</label
>
</div>
<div>
<input
id="globalTarget"
v-model="searchTarget"
type="radio"
name="searchTarget"
:value="SearchTargets.GLOBAL"
class="w-4 h-4 border-gray-300 focus:ring-2 focus:ring-blue-300 dark:focus:ring-blue-600 dark:focus:bg-blue-600 dark:bg-gray-700 dark:border-gray-600"
/>
<label
for="globalTarget"
class="ml-3 font-medium text-gray-900 dark:text-gray-300"
>{{ t("On the Fediverse") }}</label
>
</div>
</fieldset>
</div>
<div
class="py-4 border-b border-gray-200 dark:border-gray-500"
v-show="contentType !== 'GROUPS'"
@@ -374,81 +422,48 @@
</form>
</aside>
<div class="flex-1 px-2">
<o-tabs type="boxed" v-if="contentType == ContentType.ALL">
<o-tab-item>
<template #header>
<Calendar />
<span>
{{ t("Events") }}
<b-tag rounded>{{ searchEvents?.total }}</b-tag>
</span>
</template>
<div v-if="searchEvents && searchEvents.total > 0">
<multi-card class="my-4" :events="searchEvents?.elements" />
<div
class="pagination"
v-if="searchEvents && searchEvents?.total > EVENT_PAGE_LIMIT"
>
<o-pagination
:total="searchEvents.total"
v-model:current="eventPage"
:per-page="EVENT_PAGE_LIMIT"
:aria-next-label="t('Next page')"
:aria-previous-label="t('Previous page')"
:aria-page-label="t('Page')"
:aria-current-label="t('Current page')"
>
</o-pagination>
</div>
</div>
<o-notification v-else-if="searchLoading === false" variant="info">
<p>{{ t("No events found") }}</p>
<p v-if="searchIsUrl && !currentUser?.id">
{{
t(
"Only registered users may fetch remote events from their URL."
)
}}
</p>
</o-notification>
</o-tab-item>
<o-tab-item>
<template #header>
<AccountMultiple />
<span>
{{ t("Groups") }} <b-tag rounded>{{ searchGroups?.total }}</b-tag>
</span>
</template>
<o-notification v-if="features && !features.groups" variant="danger">
{{ t("Groups are not enabled on this instance.") }}
</o-notification>
<div v-else-if="searchGroups && searchGroups?.total > 0">
<multi-group-card class="my-4" :groups="searchGroups?.elements" />
<div class="pagination">
<o-pagination
:total="searchGroups?.total"
v-model:current="groupPage"
:per-page="GROUP_PAGE_LIMIT"
:aria-next-label="t('Next page')"
:aria-previous-label="t('Previous page')"
:aria-page-label="t('Page')"
:aria-current-label="t('Current page')"
>
</o-pagination>
</div>
</div>
<o-notification v-else-if="searchLoading === false" variant="danger">
{{ t("No groups found") }}
</o-notification>
</o-tab-item>
</o-tabs>
<div v-else-if="contentType === ContentType.EVENTS">
<div v-if="searchEvents && searchEvents.total > 0">
<multi-card class="my-4" :events="searchEvents?.elements" />
<template v-if="contentType === ContentType.ALL">
<o-notification v-if="features && !features.groups" variant="danger">
{{ t("Groups are not enabled on this instance.") }}
</o-notification>
<div v-else-if="searchGroups && searchGroups?.total > 0">
<GroupCard
v-for="group in searchGroups?.elements"
:group="group"
:key="group.id"
:isRemoteGroup="group.__typename === 'GroupResult'"
:isLoggedIn="currentUser?.isLoggedIn"
mode="row"
/>
<o-pagination
v-show="searchEvents && searchEvents?.total > EVENT_PAGE_LIMIT"
v-if="searchGroups && searchGroups?.total > GROUP_PAGE_LIMIT"
:total="searchGroups?.total"
v-model:current="groupPage"
:per-page="GROUP_PAGE_LIMIT"
:aria-next-label="t('Next page')"
:aria-previous-label="t('Previous page')"
:aria-page-label="t('Page')"
:aria-current-label="t('Current page')"
>
</o-pagination>
</div>
<o-notification v-else-if="searchLoading === false" variant="danger">
{{ t("No groups found") }}
</o-notification>
<div v-if="searchEvents && searchEvents.total > 0">
<event-card
mode="row"
v-for="event in searchEvents?.elements"
:event="event"
:key="event.uuid"
:options="{
isRemoteEvent: event.__typename === 'EventResult',
isLoggedIn: currentUser?.isLoggedIn,
}"
class="my-4"
/>
<o-pagination
v-if="searchEvents && searchEvents?.total > EVENT_PAGE_LIMIT"
:total="searchEvents.total"
v-model:current="eventPage"
:per-page="EVENT_PAGE_LIMIT"
@@ -467,13 +482,55 @@
}}
</p>
</o-notification>
</div>
<div v-else-if="contentType === ContentType.GROUPS">
</template>
<template v-else-if="contentType === ContentType.EVENTS">
<template v-if="searchEvents && searchEvents.total > 0">
<event-card
mode="row"
v-for="event in searchEvents?.elements"
:event="event"
:key="event.uuid"
:options="{
isRemoteEvent: event.__typename === 'EventResult',
isLoggedIn: currentUser?.isLoggedIn,
}"
class="my-4"
/>
<o-pagination
v-show="searchEvents && searchEvents?.total > EVENT_PAGE_LIMIT"
:total="searchEvents.total"
v-model:current="eventPage"
:per-page="EVENT_PAGE_LIMIT"
:aria-next-label="t('Next page')"
:aria-previous-label="t('Previous page')"
:aria-page-label="t('Page')"
:aria-current-label="t('Current page')"
>
</o-pagination>
</template>
<o-notification v-else-if="searchLoading === false" variant="info">
<p>{{ t("No events found") }}</p>
<p v-if="searchIsUrl && !currentUser?.id">
{{
t("Only registered users may fetch remote events from their URL.")
}}
</p>
</o-notification>
</template>
<template v-else-if="contentType === ContentType.GROUPS">
<o-notification v-if="features && !features.groups" variant="danger">
{{ t("Groups are not enabled on this instance.") }}
</o-notification>
<div v-else-if="searchGroups && searchGroups?.total > 0">
<multi-group-card class="my-4" :groups="searchGroups?.elements" />
<template v-else-if="searchGroups && searchGroups?.total > 0">
<GroupCard
v-for="group in searchGroups?.elements"
:group="group"
:key="group.id"
:isRemoteGroup="group.__typename === 'GroupResult'"
:isLoggedIn="currentUser?.isLoggedIn"
mode="row"
/>
<o-pagination
v-show="searchGroups && searchGroups?.total > GROUP_PAGE_LIMIT"
:total="searchGroups?.total"
@@ -485,11 +542,11 @@
:aria-current-label="t('Current page')"
>
</o-pagination>
</div>
</template>
<o-notification v-else-if="searchLoading === false" variant="danger">
{{ t("No groups found") }}
</o-notification>
</div>
</template>
</div>
</div>
</template>
@@ -508,18 +565,17 @@ import {
startOfMonth,
eachWeekendOfInterval,
} from "date-fns";
import { ContentType, EventStatus } from "@/types/enums";
import MultiCard from "../components/Event/MultiCard.vue";
import { IEvent } from "../types/event.model";
import FullAddressAutoComplete from "../components/Event/FullAddressAutoComplete.vue";
import { SEARCH_EVENTS_AND_GROUPS } from "../graphql/search";
import { Paginate } from "../types/paginate";
import { IGroup } from "../types/actor";
import MultiGroupCard from "../components/Group/MultiGroupCard.vue";
import { ContentType, EventStatus, SearchTargets } from "@/types/enums";
import EventCard from "@/components/Event/EventCard.vue";
import { IEvent } from "@/types/event.model";
import { SEARCH_EVENTS_AND_GROUPS } from "@/graphql/search";
import { Paginate } from "@/types/paginate";
import { IGroup } from "@/types/actor";
import GroupCard from "@/components/Group/GroupCard.vue";
import { CURRENT_USER_CLIENT } from "@/graphql/user";
import { ICurrentUser } from "@/types/current-user.model";
import { useQuery } from "@vue/apollo-composable";
import { computed, inject, ref } from "vue";
import { computed, inject, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import {
floatTransformer,
@@ -538,11 +594,35 @@ import type { Locale } from "date-fns";
import FilterSection from "@/components/Search/filters/FilterSection.vue";
import { listShortDisjunctionFormatter } from "@/utils/listFormat";
import langs from "@/i18n/langs.json";
import { useEventCategories, useFeatures } from "@/composition/apollo/config";
import geohash from "ngeohash";
import {
useEventCategories,
useFeatures,
useSearchConfig,
} from "@/composition/apollo/config";
import { coordsToGeoHash } from "@/utils/location";
import SearchFields from "@/components/Home/SearchFields.vue";
import { refDebounced } from "@vueuse/core";
import { IAddress } from "@/types/address.model";
import { IConfig } from "@/types/config.model";
const search = useRouteQuery("search", "");
const searchDebounced = refDebounced(search, 1000);
const locationName = useRouteQuery("locationName", null);
const location = ref<IAddress | null>(null);
watch(location, (newLocation) => {
console.debug("location change");
if (newLocation?.geom) {
latitude.value = parseFloat(newLocation?.geom.split(";")[1]);
longitude.value = parseFloat(newLocation?.geom.split(";")[0]);
locationName.value = newLocation?.description;
} else {
console.debug("location emptied");
latitude.value = undefined;
longitude.value = undefined;
locationName.value = null;
}
});
interface ISearchTimeOption {
label: string;
@@ -580,6 +660,11 @@ const statusOneOf = useRouteQuery(
arrayTransformer
);
const languageOneOf = useRouteQuery("languageOneOf", [], arrayTransformer);
const searchTarget = useRouteQuery(
"target",
SearchTargets.INTERNAL,
enumTransformer(SearchTargets)
);
const EVENT_PAGE_LIMIT = 16;
@@ -667,8 +752,8 @@ const dateOptions: Record<string, ISearchTimeOption> = {
},
any: {
label: t("Any day") as string,
start: undefined,
end: undefined,
start: new Date().toISOString(),
end: null,
},
};
@@ -688,9 +773,9 @@ const end = computed((): string | undefined | null => {
const searchIsUrl = computed((): boolean => {
let url;
if (!search.value) return false;
if (!searchDebounced.value) return false;
try {
url = new URL(search.value);
url = new URL(searchDebounced.value);
} catch (_) {
return false;
}
@@ -820,21 +905,48 @@ const geoHashLocation = computed(() =>
const radius = computed(() => Number.parseInt(distance.value.slice(0, -3)));
const { searchConfig, onResult: onSearchConfigResult } = useSearchConfig();
onSearchConfigResult(({ data }) =>
handleSearchConfigChanged(data?.config?.search)
);
const handleSearchConfigChanged = (
searchConfigChanged: IConfig["search"] | undefined
) => {
if (
searchConfigChanged?.global?.isEnabled &&
searchConfigChanged?.global?.isDefault
) {
searchTarget.value = SearchTargets.GLOBAL;
}
};
watch(searchConfig, (newSearchConfig) =>
handleSearchConfigChanged(newSearchConfig)
);
const globalSearchEnabled = computed(
() => searchConfig.value?.global?.isEnabled
);
const { result: searchElementsResult, loading: searchLoading } = useQuery<{
searchEvents: Paginate<IEvent>;
searchGroups: Paginate<IGroup>;
}>(SEARCH_EVENTS_AND_GROUPS, () => ({
term: search.value,
term: searchDebounced.value,
tags: props.tag,
location: geoHashLocation.value,
beginsOn: start.value,
endsOn: end.value,
radius: radius.value,
radius: geoHashLocation.value ? radius.value : undefined,
eventPage: eventPage.value,
groupPage: groupPage.value,
limit: EVENT_PAGE_LIMIT,
type: isOnline.value ? "ONLINE" : "IN_PERSON",
type: isOnline.value ? "ONLINE" : undefined,
categoryOneOf: categoryOneOf.value,
statusOneOf: statusOneOf.value,
languageOneOf: languageOneOf.value,
searchTarget: searchTarget.value,
}));
</script>

View File

@@ -281,7 +281,7 @@
</o-tooltip>
<o-button
icon-left="refresh"
type="is-text"
variant="text"
@click="openRegenerateFeedTokensConfirmation"
@keyup.enter="openRegenerateFeedTokensConfirmation"
>{{ $t("Regenerate new links") }}</o-button
@@ -291,7 +291,7 @@
<div v-else>
<o-button
icon-left="refresh"
type="is-text"
variant="text"
@click="generateFeedTokens"
@keyup.enter="generateFeedTokens"
>{{ $t("Create new links") }}</o-button
@@ -739,7 +739,7 @@ const {
} = useMutation(UNREGISTER_PUSH_MUTATION);
onUnregisterPushMutationDone(({ data }) => {
console.log(data);
console.debug(data);
subscribed.value = false;
});

View File

@@ -171,7 +171,7 @@ const locale = computed({
locale: newLocale,
});
saveLocaleData(newLocale);
console.log("changing locale", i18nLocale, newLocale);
console.debug("changing locale", i18nLocale, newLocale);
i18nLocale.value = newLocale;
}
},

View File

@@ -40,7 +40,7 @@
<router-link
:to="{ name: RouteName.TODO_LIST, params: { id: todoList.id } }"
>
<h3 class="is-size-3">
<h3>
{{
$t(
"{title} ({count} todos)",

View File

@@ -96,7 +96,7 @@
name: RouteName.SEND_PASSWORD_RESET,
params: { email: credentials.email },
}"
>{{ t("Forgot your password ?") }}</o-button
>{{ t("Forgot your password?") }}</o-button
>
<o-button
tag="router-link"
@@ -145,6 +145,7 @@ 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 props = withDefaults(
defineProps<{
@@ -198,7 +199,9 @@ onLoginMutationDone(async (result) => {
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 });
@@ -235,7 +238,6 @@ const { onDone: onCurrentUserMutationDone, mutate: updateCurrentUserMutation } =
useMutation(UPDATE_CURRENT_USER_CLIENT);
onCurrentUserMutationDone(async () => {
console.debug("saved current user client, now for actor client");
try {
await initializeCurrentActor();
} catch (err: any) {
@@ -252,7 +254,6 @@ onCurrentUserMutationDone(async () => {
});
const setupClientUserAndActors = async (login: ILogin): Promise<void> => {
console.debug("setuping client user and actors after login", login);
updateCurrentUserMutation({
id: login.user.id,
email: credentials.email,
@@ -276,7 +277,7 @@ const caseWarningText = computed<string | undefined>(() => {
const caseWarningType = computed<string | undefined>(() => {
if (hasCaseWarning.value) {
return "is-warning";
return "warning";
}
return undefined;
});
@@ -306,4 +307,8 @@ onMounted(() => {
router.push("/");
}
});
useHead({
title: computed(() => t("Login")),
});
</script>

View File

@@ -209,14 +209,14 @@ import RouteName from "../../router/name";
import { IConfig } from "../../types/config.model";
import { CONFIG } from "../../graphql/config";
import AuthProviders from "../../components/User/AuthProviders.vue";
import { AbsintheGraphQLError } from "../../types/apollo";
import { computed, reactive, ref, watch } from "vue";
import { useMutation, useQuery } from "@vue/apollo-composable";
import { useI18n } from "vue-i18n";
import { useRouter } from "vue-router";
import { useHead } from "@vueuse/head";
import { AbsintheGraphQLErrors } from "@/types/errors.model";
type errorType = "is-danger" | "is-warning";
type errorType = "danger" | "warning";
type errorMessage = { type: errorType; message: string };
type credentialsType = { email: string; password: string; locale: string };
@@ -271,24 +271,25 @@ onDone(() => {
});
onError((error) => {
// @ts-ignore
error.graphQLErrors.forEach(({ field, message }: AbsintheGraphQLError) => {
switch (field) {
case "email":
emailErrors.value.push({
type: "is-danger" as errorType,
message: message[0] as string,
});
break;
case "password":
passwordErrors.value.push({
type: "is-danger" as errorType,
message: message[0] as string,
});
break;
default:
(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;
});
@@ -310,10 +311,10 @@ const submit = async (): Promise<void> => {
watch(credentials, () => {
if (credentials.email !== credentials.email.toLowerCase()) {
const error = {
type: "is-warning" as errorType,
type: "warning" as errorType,
message: t(
"Emails usually don't contain capitals, make sure you haven't made a typo."
) as string,
),
};
emailErrors.value = [error];
}
@@ -322,9 +323,9 @@ watch(credentials, () => {
const maxErrorType = (errors: errorMessage[]): errorType | undefined => {
if (!errors || errors.length === 0) return undefined;
return errors.reduce<errorType>((acc, error) => {
if (error.type === "is-danger" || acc === "is-danger") return "is-danger";
return "is-warning";
}, "is-warning");
if (error.type === "danger" || acc === "danger") return "danger";
return "warning";
}, "warning");
};
const errorEmailType = computed((): errorType | undefined => {

View File

@@ -43,7 +43,7 @@
<o-button
v-if="stepIndex >= 2"
variant="success"
size="is-big"
size="big"
tag="router-link"
:to="{ name: RouteName.HOME }"
>