build: switch from yarn to npm to manage js dependencies and move js contents to root

yarn v1 is being deprecated and starts to have some issues

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel
2023-11-14 17:24:42 +01:00
parent 32055122c3
commit 2e72f6faf4
595 changed files with 12078 additions and 7843 deletions

View File

@@ -0,0 +1,397 @@
<template>
<section class="container mx-auto">
<h1>{{ t("Create a new group") }}</h1>
<o-notification
variant="danger"
v-for="(value, index) in errors"
:key="index"
>
{{ value }}
</o-notification>
<form @submit.prevent="createGroup">
<o-field :label="t('Group display name')" label-for="group-display-name">
<o-input
aria-required="true"
required
v-model="group.name"
id="group-display-name"
/>
</o-field>
<div class="field">
<label class="label" for="group-preferred-username">{{
t("Federated Group Name")
}}</label>
<div class="field-body">
<o-field
:message="preferredUsernameErrors[0]"
:type="preferredUsernameErrors[1]"
>
<o-input
ref="preferredUsernameInput"
aria-required="true"
required
expanded
v-model="group.preferredUsername"
pattern="[a-z0-9_]+"
id="group-preferred-username"
:useHtml5Validation="true"
:validation-message="
group.preferredUsername
? t(
'Only alphanumeric lowercased characters and underscores are supported.'
)
: null
"
/>
<p class="control">
<span class="button is-static">@{{ host }}</span>
</p>
</o-field>
</div>
<i18n-t
v-if="currentActor"
keypath="This is like your federated username ({username}) for groups. It will allow the group to be found on the federation, and is guaranteed to be unique."
>
<template #username>
<code>
{{ usernameWithDomain(currentActor, true) }}
</code>
</template>
</i18n-t>
</div>
<o-field
:label="t('Description')"
label-for="group-summary"
:message="summaryErrors[0]"
:type="summaryErrors[1]"
>
<editor
v-if="currentActor"
id="group-summary"
mode="basic"
class="mb-3"
v-model="group.summary"
:maxSize="500"
:aria-label="$t('Group description body')"
:current-actor="currentActor"
/>
</o-field>
<full-address-auto-complete
:label="$t('Group address')"
v-model="group.physicalAddress"
/>
<div class="field">
<b class="field-label">{{ t("Avatar") }}</b>
<picture-upload
:textFallback="t('Avatar')"
v-model="avatarFile"
:maxSize="avatarMaxSize"
/>
</div>
<div class="field">
<b class="field-label">{{ t("Banner") }}</b>
<picture-upload
:textFallback="t('Banner')"
v-model="bannerFile"
:maxSize="bannerMaxSize"
/>
</div>
<fieldset>
<legend class="field-label !mb-0 mt-2">
{{ t("Group visibility") }}
</legend>
<o-radio
v-model="group.visibility"
name="groupVisibility"
:native-value="GroupVisibility.PUBLIC"
>
{{ $t("Visible everywhere on the web") }}<br />
<small>{{
$t(
"The group will be publicly listed in search results and may be suggested in the explore section. Only public informations will be shown on it's page."
)
}}</small>
</o-radio>
<o-radio
v-model="group.visibility"
name="groupVisibility"
:native-value="GroupVisibility.UNLISTED"
>{{ $t("Only accessible through link") }}<br />
<small>{{
$t(
"You'll need to transmit the group URL so people may access the group's profile. The group won't be findable in Mobilizon's search or regular search engines."
)
}}</small>
</o-radio>
</fieldset>
<fieldset>
<legend class="mt-2">
<span class="field-label !mb-0">{{ t("New members") }} </span>
<span>
{{
t(
"Members will also access private sections like discussions, resources and restricted posts."
)
}}
</span>
</legend>
<o-field>
<o-radio
v-model="group.openness"
name="groupOpenness"
:native-value="Openness.OPEN"
>
{{ $t("Anyone can join freely") }}<br />
<small>{{
$t(
"Anyone wanting to be a member from your group will be able to from your group page."
)
}}</small>
</o-radio>
</o-field>
<o-field>
<o-radio
v-model="group.openness"
name="groupOpenness"
:native-value="Openness.MODERATED"
>{{ $t("Moderate new members") }}<br />
<small>{{
$t(
"Anyone can request being a member, but an administrator needs to approve the membership."
)
}}</small>
</o-radio>
</o-field>
<o-field>
<o-radio
v-model="group.openness"
name="groupOpenness"
:native-value="Openness.INVITE_ONLY"
>{{ $t("Manually invite new members") }}<br />
<small>{{
$t(
"The only way for your group to get new members is if an admininistrator invites them."
)
}}</small>
</o-radio>
</o-field>
</fieldset>
<fieldset>
<legend class="mt-2">
<span class="field-label !mb-0">
{{ t("Followers") }}
</span>
<span>
{{ t("Followers will receive new public events and posts.") }}
</span>
</legend>
<o-checkbox v-model="group.manuallyApprovesFollowers">
{{ t("Manually approve new followers") }}
</o-checkbox>
</fieldset>
<o-button variant="primary" native-type="submit" class="mt-3">
{{ t("Create my group") }}
</o-button>
</form>
</section>
</template>
<script lang="ts" setup>
import { Group, usernameWithDomain, displayName } from "@/types/actor";
import RouteName from "../../router/name";
import { convertToUsername } from "../../utils/username";
import PictureUpload from "../../components/PictureUpload.vue";
import { ErrorResponse } from "@/types/errors.model";
import { ServerParseError } from "@apollo/client/link/http";
import { useCurrentActorClient } from "@/composition/apollo/actor";
import {
computed,
defineAsyncComponent,
inject,
reactive,
ref,
watch,
} from "vue";
import { useRouter } from "vue-router";
import { useI18n } from "vue-i18n";
import { useCreateGroup } from "@/composition/apollo/group";
import {
useAvatarMaxSize,
useBannerMaxSize,
useHost,
} from "@/composition/config";
import { Notifier } from "@/plugins/notifier";
import { useHead } from "@vueuse/head";
import { Openness, GroupVisibility } from "@/types/enums";
import FullAddressAutoComplete from "@/components/Event/FullAddressAutoComplete.vue";
const Editor = defineAsyncComponent(
() => import("@/components/TextEditor.vue")
);
const { currentActor } = useCurrentActorClient();
const { t } = useI18n({ useScope: "global" });
useHead({
title: computed(() => t("Create a new group")),
});
const group = ref(new Group());
const avatarFile = ref<File | null>(null);
const bannerFile = ref<File | null>(null);
const errors = ref<string[]>([]);
const fieldErrors = reactive<Record<string, string | undefined>>({
preferred_username: undefined,
summary: undefined,
});
const router = useRouter();
const host = useHost();
const avatarMaxSize = useAvatarMaxSize();
const bannerMaxSize = useBannerMaxSize();
const notifier = inject<Notifier>("notifier");
watch(
() => group.value.name,
(newGroupName) => {
group.value.preferredUsername = convertToUsername(newGroupName);
}
);
const buildVariables = computed(() => {
let avatarObj = {};
let bannerObj = {};
const cloneGroup = group.value;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
delete cloneGroup.physicalAddress.__typename;
delete cloneGroup.physicalAddress.pictureInfo;
const groupBasic = {
preferredUsername: group.value.preferredUsername,
name: group.value.name,
summary: group.value.summary,
physicalAddress: cloneGroup.physicalAddress,
visibility: group.value.visibility,
openness: group.value.openness,
manuallyApprovesFollowers: group.value.manuallyApprovesFollowers,
};
if (avatarFile.value) {
avatarObj = {
avatar: {
media: {
name: avatarFile.value?.name,
alt: `${group.value.preferredUsername}'s avatar`,
file: avatarFile.value,
},
},
};
}
if (bannerFile.value) {
bannerObj = {
banner: {
media: {
name: bannerFile.value?.name,
alt: `${group.value.preferredUsername}'s banner`,
file: bannerFile.value,
},
},
};
}
return {
...groupBasic,
...avatarObj,
...bannerObj,
};
});
const handleError = (err: ErrorResponse) => {
if (err?.networkError?.name === "ServerParseError") {
const error = err?.networkError as ServerParseError;
if (error?.response?.status === 413) {
errors.value.push(
t(
"Unable to create the group. One of the pictures may be too heavy."
) as string
);
}
}
err.graphQLErrors?.forEach((error) => {
if (error.field) {
if (Array.isArray(error.message)) {
fieldErrors[error.field] = error.message[0];
} else {
fieldErrors[error.field] = error.message;
}
} else {
errors.value.push(error.message);
}
});
};
const summaryErrors = computed(() => {
const message = fieldErrors.summary ? fieldErrors.summary : undefined;
const type = fieldErrors.summary ? "danger" : undefined;
return [message, type];
});
const preferredUsernameErrors = computed(() => {
const message = fieldErrors.preferred_username
? fieldErrors.preferred_username
: t(
"Only alphanumeric lowercased characters and underscores are supported."
);
const type = fieldErrors.preferred_username ? "danger" : undefined;
return [message, type];
});
const { onDone, onError, mutate } = useCreateGroup();
onDone(() => {
notifier?.success(
t("Group {displayName} created", {
displayName: displayName(group.value),
})
);
router.push({
name: RouteName.GROUP,
params: { preferredUsername: usernameWithDomain(group.value) },
});
});
onError((err) => handleError(err as unknown as ErrorResponse));
const createGroup = async (): Promise<void> => {
errors.value = [];
fieldErrors.preferred_username = undefined;
fieldErrors.summary = undefined;
mutate(buildVariables.value);
};
</script>
<style>
.markdown-render h1 {
font-size: 2em;
}
</style>

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,223 @@
<template>
<section class="container mx-auto px-1 mb-6">
<h1 class="title">{{ t("My groups") }}</h1>
<p>
{{
t(
"Groups are spaces for coordination and preparation to better organize events and manage your community."
)
}}
</p>
<div class="flex my-3" v-if="!hideCreateGroupButton">
<o-button
tag="router-link"
variant="primary"
:to="{ name: RouteName.CREATE_GROUP }"
>{{ t("Create group") }}</o-button
>
</div>
<o-loading v-model:active="loading"></o-loading>
<InvitationsList
:invitations="invitations"
@accept-invitation="acceptInvitation"
@reject-invitation="rejectInvitation"
/>
<section v-if="memberships && memberships.length > 0">
<GroupMemberCard
class="group-member-card"
v-for="member in memberships"
:key="member.id"
:member="member"
@leave="leaveGroup({ groupId: member.parent.id })"
/>
<o-pagination
:total="membershipsPages.total"
v-show="membershipsPages.total > limit"
v-model:current="page"
:per-page="limit"
:aria-next-label="t('Next page')"
:aria-previous-label="t('Previous page')"
:aria-page-label="t('Page')"
:aria-current-label="t('Current page')"
>
</o-pagination>
</section>
<section
class="text-center not-found"
v-if="memberships.length === 0 && !loading"
>
<div class="">
<div class="">
<div class="img-container" />
<div class="text-center prose dark:prose-invert max-w-full">
<p>
{{ t("You are not part of any group.") }}
<i18n-t
keypath="Do you wish to {create_group} or {explore_groups}?"
>
<template #create_group>
<router-link :to="{ name: RouteName.CREATE_GROUP }">{{
t("create a group")
}}</router-link>
</template>
<template #explore_groups>
<router-link
:to="{
name: RouteName.SEARCH,
query: { contentType: ContentType.GROUPS },
}"
>{{ t("explore the groups") }}</router-link
>
</template>
</i18n-t>
</p>
</div>
</div>
</div>
</section>
</section>
</template>
<script lang="ts" setup>
import { LOGGED_USER_MEMBERSHIPS } from "@/graphql/actor";
import { LEAVE_GROUP } from "@/graphql/group";
import GroupMemberCard from "@/components/Group/GroupMemberCard.vue";
import InvitationsList from "@/components/Group/InvitationsList.vue";
import { usernameWithDomain } from "@/types/actor";
import { IMember } from "@/types/actor/member.model";
import { MemberRole, ContentType } from "@/types/enums";
import RouteName from "../../router/name";
import { useRestrictions } from "@/composition/apollo/config";
import { useMutation, useQuery } from "@vue/apollo-composable";
import { IUser } from "@/types/current-user.model";
import { integerTransformer, useRouteQuery } from "vue-use-route-query";
import { computed, inject } from "vue";
import { useHead } from "@vueuse/head";
import { useI18n } from "vue-i18n";
import { useRouter } from "vue-router";
import { Notifier } from "@/plugins/notifier";
const page = useRouteQuery("page", 1, integerTransformer);
const limit = 10;
const { result: membershipsResult, loading } = useQuery<{
loggedUser: Pick<IUser, "memberships">;
}>(LOGGED_USER_MEMBERSHIPS, () => ({
page: page.value,
limit,
}));
const membershipsPages = computed(
() =>
membershipsResult.value?.loggedUser?.memberships ?? {
total: 0,
elements: [],
}
);
const { t } = useI18n({ useScope: "global" });
useHead({
title: t("My groups"),
});
const notifier = inject<Notifier>("notifier");
const router = useRouter();
const acceptInvitation = (member: IMember) => {
return router.push({
name: RouteName.GROUP,
params: { preferredUsername: usernameWithDomain(member.parent) },
});
};
const rejectInvitation = ({ id: memberId }: { id: string }) => {
const index = membershipsPages.value.elements.findIndex(
(membership) =>
membership.role === MemberRole.INVITED && membership.id === memberId
);
if (index > -1) {
membershipsPages.value.elements.splice(index, 1);
membershipsPages.value.total -= 1;
}
};
const { mutate: leaveGroup, onError: onLeaveGroupError } = useMutation(
LEAVE_GROUP,
() => ({
refetchQueries: [
{
query: LOGGED_USER_MEMBERSHIPS,
variables: {
page,
limit,
},
},
],
})
);
onLeaveGroupError((error) => {
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
notifier?.error(error.graphQLErrors[0].message);
}
});
const invitations = computed((): IMember[] => {
if (!membershipsPages.value) return [];
return membershipsPages.value.elements.filter(
(member: IMember) => member.role === MemberRole.INVITED
);
});
const memberships = computed((): IMember[] => {
if (!membershipsPages.value) return [];
return membershipsPages.value.elements.filter(
(member: IMember) =>
![MemberRole.INVITED, MemberRole.REJECTED].includes(member.role)
);
});
const { restrictions } = useRestrictions();
const hideCreateGroupButton = computed((): boolean => {
return restrictions.value?.onlyAdminCanCreateGroups === true;
});
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style lang="scss" scoped>
.participation {
margin: 1rem auto;
}
section {
.upcoming-month {
text-transform: capitalize;
}
}
.group-member-card {
margin-bottom: 1rem;
}
.not-found {
.img-container {
background-image: url("../../../img/pics/group-480w.webp");
@media (min-resolution: 2dppx) {
& {
background-image: url("../../../img/pics/group-1024w.webp");
}
}
max-width: 450px;
height: 300px;
box-shadow: 0 0 8px 8px white inset;
background-size: cover;
border-radius: 10px;
margin: auto auto 1rem;
}
}
</style>

View File

@@ -0,0 +1,45 @@
<template>
<div class="container mx-auto">
<h1 class="">{{ t("Settings") }}</h1>
<div class="flex flex-wrap gap-2">
<aside class="sm:max-w-xs flex-1 min-w-[320px]">
<ul>
<SettingMenuSection
:title="t('Settings')"
:to="{ name: RouteName.GROUP_SETTINGS }"
>
<SettingMenuItem
:title="t('Public')"
:to="{ name: RouteName.GROUP_PUBLIC_SETTINGS }"
/>
<SettingMenuItem
:title="t('Members')"
:to="{ name: RouteName.GROUP_MEMBERS_SETTINGS }"
/>
<SettingMenuItem
:title="t('Followers')"
:to="{ name: RouteName.GROUP_FOLLOWERS_SETTINGS }"
/>
</SettingMenuSection>
</ul>
</aside>
<div class="flex-1">
<router-view />
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import { useHead } from "@vueuse/head";
import RouteName from "@/router/name";
import SettingMenuSection from "@/components/Settings/SettingMenuSection.vue";
import SettingMenuItem from "@/components/Settings/SettingMenuItem.vue";
const { t } = useI18n({ useScope: "global" });
useHead({
title: computed(() => t("Group settings")),
});
</script>

View File

@@ -0,0 +1,387 @@
<template>
<div class="container mx-auto section">
<breadcrumbs-nav
v-if="group"
:links="[
{
name: RouteName.GROUP,
params: { preferredUsername: usernameWithDomain(group) },
text: displayName(group),
},
{
name: RouteName.TIMELINE,
params: { preferredUsername: usernameWithDomain(group) },
text: t('Activity'),
},
]"
/>
<section class="timeline">
<o-field>
<o-radio class="pr-4" v-model="activityType" :native-value="undefined">
<TimelineText />
{{ t("All activities") }}</o-radio
>
<o-radio
class="pr-4"
v-model="activityType"
:native-value="ActivityType.MEMBER"
>
<o-icon icon="account-multiple-plus"></o-icon>
{{ t("Members") }}</o-radio
>
<o-radio
class="pr-4"
v-model="activityType"
:native-value="ActivityType.GROUP"
>
<o-icon icon="cog"></o-icon>
{{ t("Settings") }}</o-radio
>
<o-radio
class="pr-4"
v-model="activityType"
:native-value="ActivityType.EVENT"
>
<o-icon icon="calendar"></o-icon>
{{ t("Events") }}</o-radio
>
<o-radio
class="pr-4"
v-model="activityType"
:native-value="ActivityType.POST"
>
<o-icon icon="bullhorn"></o-icon>
{{ t("Posts") }}</o-radio
>
<o-radio
class="pr-4"
v-model="activityType"
:native-value="ActivityType.DISCUSSION"
>
<o-icon icon="chat"></o-icon>
{{ t("Discussions") }}</o-radio
>
<o-radio v-model="activityType" :native-value="ActivityType.RESOURCE">
<o-icon icon="link"></o-icon>
{{ t("Resources") }}</o-radio
>
</o-field>
<o-field>
<o-radio
class="pr-4"
v-model="activityAuthor"
:native-value="undefined"
>
<TimelineText />
{{ t("All activities") }}</o-radio
>
<o-radio
class="pr-4"
v-model="activityAuthor"
:native-value="ActivityAuthorFilter.SELF"
>
<o-icon icon="account"></o-icon>
{{ t("From yourself") }}</o-radio
>
<o-radio
class="pr-4"
v-model="activityAuthor"
:native-value="ActivityAuthorFilter.BY"
>
<o-icon icon="account-multiple"></o-icon>
{{ t("By others") }}</o-radio
>
</o-field>
<transition-group name="timeline-list" tag="div">
<div
class="day"
v-for="[date, activityItems] in Object.entries(activities)"
:key="date"
>
<o-skeleton
v-if="date.search(/skeleton/) !== -1"
width="300px"
height="48px"
/>
<h2 v-else-if="isToday(date)">
<span v-tooltip="formatDateString(date)">
{{ t("Today") }}
</span>
</h2>
<h2 v-else-if="isYesterday(date)">
<span v-tooltip="formatDateString(date)">{{ t("Yesterday") }}</span>
</h2>
<h2 v-else>
{{ formatDateString(date) }}
</h2>
<ul class="before:opacity-10">
<li v-for="activityItem in activityItems" :key="activityItem.id">
<skeleton-activity-item v-if="activityItem.type === 'skeleton'" />
<component
v-else
:is="component(activityItem.type)"
:activity="activityItem"
/>
</li>
</ul></div
></transition-group>
<empty-content
icon="timeline-text"
v-if="
!loading &&
activity.elements.length > 0 &&
activity.elements.length >= activity.total
"
>
{{ t("No more activity to display.") }}
</empty-content>
<empty-content
v-if="!loading && activity.total === 0"
icon="timeline-text"
>
{{
t(
"There is no activity yet. Start doing some things to see activity appear here."
)
}}
</empty-content>
<observer @intersect="loadMore" />
<o-button
v-if="activity.elements.length < activity.total"
@click="loadMore"
>{{ t("Load more activities") }}</o-button
>
</section>
</div>
</template>
<script lang="ts" setup>
import { GROUP_TIMELINE } from "@/graphql/group";
import { IGroup, usernameWithDomain, displayName } from "@/types/actor";
import { ActivityType } from "@/types/enums";
import { Paginate } from "@/types/paginate";
import { IActivity } from "../../types/activity.model";
import Observer from "../../components/Utils/ObserverElement.vue";
import SkeletonActivityItem from "../../components/Activity/SkeletonActivityItem.vue";
import RouteName from "../../router/name";
import TimelineText from "vue-material-design-icons/TimelineText.vue";
import { useQuery } from "@vue/apollo-composable";
import { useHead } from "@vueuse/head";
import { enumTransformer, useRouteQuery } from "vue-use-route-query";
import { computed, defineAsyncComponent, ref } from "vue";
import { useI18n } from "vue-i18n";
import { formatDateString } from "@/filters/datetime";
const PAGINATION_LIMIT = 25;
const SKELETON_DAY_ITEMS = 2;
const SKELETON_ITEMS_PER_DAY = 5;
type IActivitySkeleton =
| IActivity
| { skeleton: string; id: string; type: "skeleton" };
enum ActivityAuthorFilter {
SELF = "SELF",
BY = "BY",
}
// type ActivityFilter = ActivityType | ActivityAuthorFilter | null;
const props = defineProps<{ preferredUsername: string }>();
const { t } = useI18n({ useScope: "global" });
const EventActivityItem = defineAsyncComponent(
() => import("../../components/Activity/EventActivityItem.vue")
);
const PostActivityItem = defineAsyncComponent(
() => import("../../components/Activity/PostActivityItem.vue")
);
const MemberActivityItem = defineAsyncComponent(
() => import("../../components/Activity/MemberActivityItem.vue")
);
const ResourceActivityItem = defineAsyncComponent(
() => import("../../components/Activity/ResourceActivityItem.vue")
);
const DiscussionActivityItem = defineAsyncComponent(
() => import("../../components/Activity/DiscussionActivityItem.vue")
);
const GroupActivityItem = defineAsyncComponent(
() => import("../../components/Activity/GroupActivityItem.vue")
);
const EmptyContent = defineAsyncComponent(
() => import("../../components/Utils/EmptyContent.vue")
);
const activityType = useRouteQuery(
"activityType",
undefined,
enumTransformer(ActivityType)
);
const activityAuthor = useRouteQuery(
"activityAuthor",
undefined,
enumTransformer(ActivityAuthorFilter)
);
const page = ref(1);
const {
result: groupTimelineResult,
fetchMore: fetchMoreActivities,
onError: onGroupTLError,
loading,
} = useQuery<{ group: IGroup }>(GROUP_TIMELINE, () => ({
preferredUsername: props.preferredUsername,
page: page.value,
limit: PAGINATION_LIMIT,
type: activityType.value,
author: activityAuthor.value,
}));
onGroupTLError((err) => console.error(err));
const group = computed(() => groupTimelineResult.value?.group);
useHead({
title: computed(() =>
t("{group} activity timeline", { group: group.value?.name })
),
});
const activity = computed((): Paginate<IActivitySkeleton> => {
if (group.value) {
return group.value.activity;
}
return {
total: 0,
elements: skeletons.value.map((skeleton) => ({
skeleton,
id: skeleton,
type: "skeleton",
})),
};
});
const component = (type: ActivityType): any | undefined => {
switch (type) {
case ActivityType.EVENT:
return EventActivityItem;
case ActivityType.POST:
return PostActivityItem;
case ActivityType.MEMBER:
return MemberActivityItem;
case ActivityType.RESOURCE:
return ResourceActivityItem;
case ActivityType.DISCUSSION:
return DiscussionActivityItem;
case ActivityType.GROUP:
return GroupActivityItem;
}
};
const skeletons = computed((): string[] => {
return [...Array(SKELETON_DAY_ITEMS)]
.map((_, i) => {
return [...Array(SKELETON_ITEMS_PER_DAY)].map((_a, j) => {
return `${i}-${j}`;
});
})
.flat();
});
const loadMore = async (): Promise<void> => {
if (page.value * PAGINATION_LIMIT >= activity.value.total) {
return;
}
page.value++;
try {
await fetchMoreActivities({
variables: {
page: page.value,
limit: PAGINATION_LIMIT,
},
});
} catch (e) {
console.error(e);
}
};
const activities = computed((): Record<string, IActivitySkeleton[]> => {
return activity.value.elements.reduce(
(acc: Record<string, IActivitySkeleton[]>, elem) => {
let key;
if (isIActivity(elem)) {
const insertedAt = new Date(elem.insertedAt);
insertedAt.setHours(0);
insertedAt.setMinutes(0);
insertedAt.setSeconds(0);
insertedAt.setMilliseconds(0);
key = insertedAt.toISOString();
} else {
key = `skeleton-${elem.skeleton.split("-")[0]}`;
}
const existing = acc[key];
if (existing) {
acc[key] = [...existing, ...[elem]];
} else {
acc[key] = [elem];
}
return acc;
},
{}
);
});
const isIActivity = (object: IActivitySkeleton): object is IActivity => {
return !("skeleton" in object);
};
// const getRandomInt = (min: number, max: number): number => {
// min = Math.ceil(min);
// max = Math.floor(max);
// return Math.floor(Math.random() * (max - min) + min);
// };
const isToday = (dateString: string): boolean => {
const now = new Date();
const date = new Date(dateString);
return (
now.getFullYear() === date.getFullYear() &&
now.getMonth() === date.getMonth() &&
now.getDate() === date.getDate()
);
};
const isYesterday = (dateString: string): boolean => {
const date = new Date(dateString);
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
return (
yesterday.getFullYear() === date.getFullYear() &&
yesterday.getMonth() === date.getMonth() &&
yesterday.getDate() === date.getDate()
);
};
</script>
<style lang="scss" scoped>
.timeline {
ul {
// padding: 0.5rem 0;
margin: 0;
list-style: none;
position: relative;
&::before {
content: "";
height: 100%;
width: 1px;
background-color: #d9d9d9;
position: absolute;
top: 0;
left: 1rem;
}
li {
display: flex;
margin: 0.5rem 0;
}
}
}
</style>