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:
1360
src/views/Event/EditView.vue
Normal file
1360
src/views/Event/EditView.vue
Normal file
File diff suppressed because it is too large
Load Diff
627
src/views/Event/EventView.vue
Executable file
627
src/views/Event/EventView.vue
Executable file
@@ -0,0 +1,627 @@
|
||||
<template>
|
||||
<div class="container mx-auto">
|
||||
<o-loading v-model:active="eventLoading" />
|
||||
<div class="flex flex-col mb-3">
|
||||
<event-banner :picture="event?.picture" />
|
||||
<div
|
||||
class="flex flex-col relative pb-2 bg-white dark:bg-zinc-700 my-4 rounded"
|
||||
>
|
||||
<div class="date-calendar-icon-wrapper relative" v-if="event?.beginsOn">
|
||||
<skeleton-date-calendar-icon
|
||||
v-if="eventLoading"
|
||||
class="absolute left-3 -top-16"
|
||||
/>
|
||||
<date-calendar-icon
|
||||
v-else
|
||||
:date="event.beginsOn.toString()"
|
||||
class="absolute left-3 -top-16"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section class="intro px-2 pt-4" dir="auto">
|
||||
<div class="flex flex-wrap gap-2 justify-end">
|
||||
<div class="flex-1 min-w-[300px]">
|
||||
<div
|
||||
v-if="eventLoading"
|
||||
class="animate-pulse mb-2 h-12 bg-slate-200 w-3/4"
|
||||
/>
|
||||
<h1
|
||||
v-else
|
||||
class="text-4xl font-bold m-0"
|
||||
dir="auto"
|
||||
:lang="event?.language"
|
||||
>
|
||||
{{ event?.title }}
|
||||
</h1>
|
||||
<div class="organizer">
|
||||
<div
|
||||
v-if="eventLoading"
|
||||
class="animate-pulse mb-2 h-6 space-y-6 bg-slate-200 w-64"
|
||||
/>
|
||||
<div v-else-if="event?.organizerActor && !event?.attributedTo">
|
||||
<popover-actor-card
|
||||
:actor="event.organizerActor"
|
||||
:inline="true"
|
||||
>
|
||||
<i18n-t
|
||||
keypath="By {username}"
|
||||
dir="auto"
|
||||
class="block truncate max-w-xs md:max-w-sm"
|
||||
>
|
||||
<template #username>
|
||||
<span dir="ltr">{{
|
||||
displayName(event.organizerActor)
|
||||
}}</span>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</popover-actor-card>
|
||||
</div>
|
||||
<span v-else-if="event?.attributedTo">
|
||||
<popover-actor-card
|
||||
:actor="event.attributedTo"
|
||||
:inline="true"
|
||||
>
|
||||
<i18n-t
|
||||
keypath="By {group}"
|
||||
dir="auto"
|
||||
class="block truncate max-w-xs md:max-w-sm"
|
||||
>
|
||||
<template #group>
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.GROUP,
|
||||
params: {
|
||||
preferredUsername: usernameWithDomain(
|
||||
event.attributedTo
|
||||
),
|
||||
},
|
||||
}"
|
||||
dir="ltr"
|
||||
>{{ displayName(event.attributedTo) }}</router-link
|
||||
>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</popover-actor-card>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2 gap-y-4 mt-2 my-3">
|
||||
<div
|
||||
v-if="eventLoading"
|
||||
class="animate-pulse mb-2 h-6 space-y-6 bg-slate-200 w-64"
|
||||
/>
|
||||
<p v-else-if="event?.status !== EventStatus.CONFIRMED">
|
||||
<tag
|
||||
variant="warning"
|
||||
v-if="event?.status === EventStatus.TENTATIVE"
|
||||
>{{ t("Event to be confirmed") }}</tag
|
||||
>
|
||||
<tag
|
||||
variant="danger"
|
||||
v-if="event?.status === EventStatus.CANCELLED"
|
||||
>{{ t("Event cancelled") }}</tag
|
||||
>
|
||||
</p>
|
||||
<template v-if="!eventLoading && !event?.draft">
|
||||
<p
|
||||
v-if="event?.visibility === EventVisibility.PUBLIC"
|
||||
class="inline-flex gap-1"
|
||||
>
|
||||
<Earth />
|
||||
{{ t("Public event") }}
|
||||
</p>
|
||||
<p
|
||||
v-if="event?.visibility === EventVisibility.UNLISTED"
|
||||
class="inline-flex gap-1"
|
||||
>
|
||||
<Link />
|
||||
{{ t("Private event") }}
|
||||
</p>
|
||||
</template>
|
||||
<template v-if="!event?.local && organizerDomain">
|
||||
<a :href="event?.url">
|
||||
<tag variant="info">{{ organizerDomain }}</tag>
|
||||
</a>
|
||||
</template>
|
||||
<div
|
||||
v-if="eventLoading"
|
||||
class="animate-pulse mb-2 h-6 space-y-6 bg-slate-200 w-64"
|
||||
/>
|
||||
<p v-else class="flex flex-wrap gap-1 items-center" dir="auto">
|
||||
<tag v-if="eventCategory" class="category" capitalize>{{
|
||||
eventCategory
|
||||
}}</tag>
|
||||
<router-link
|
||||
class="rounded-md truncate text-sm text-violet-title py-1 bg-purple-3 dark:text-violet-3 category"
|
||||
v-for="tag in event?.tags ?? []"
|
||||
:key="tag.title"
|
||||
:to="{ name: RouteName.TAG, params: { tag: tag.title } }"
|
||||
>
|
||||
<tag>{{ tag.title }}</tag>
|
||||
</router-link>
|
||||
</p>
|
||||
<tag variant="warning" size="medium" v-if="event?.draft"
|
||||
>{{ t("Draft") }}
|
||||
</tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="eventLoading">
|
||||
<div class="animate-pulse mb-2 h-6 bg-slate-200 w-64" />
|
||||
<div class="animate-pulse mb-2 h-6 bg-slate-200 w-64" />
|
||||
</div>
|
||||
<EventActionSection
|
||||
v-else-if="event"
|
||||
:event="event"
|
||||
:currentActor="currentActor"
|
||||
:participations="participations"
|
||||
:person="person"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded-lg dark:border-violet-title flex flex-wrap flex-col md:flex-row-reverse gap-4"
|
||||
>
|
||||
<aside
|
||||
class="rounded bg-white dark:bg-zinc-700 shadow-md h-min max-w-screen-sm"
|
||||
>
|
||||
<div class="sticky p-4">
|
||||
<aside
|
||||
v-if="eventLoading"
|
||||
class="animate-pulse rounded bg-white dark:bg-zinc-700 h-min max-w-screen-sm"
|
||||
>
|
||||
<div class="mb-6 p-2" v-for="i in 3" :key="i">
|
||||
<div class="mb-2 h-6 bg-slate-200 w-64" />
|
||||
<div class="flex space-x-4 flex-row">
|
||||
<div class="rounded-full bg-slate-200 h-10 w-10"></div>
|
||||
<div class="flex flex-col flex-1 space-y-2">
|
||||
<div class="h-3 bg-slate-200"></div>
|
||||
<div class="h-3 bg-slate-200"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<event-metadata-sidebar
|
||||
v-else-if="event"
|
||||
:event="event"
|
||||
:user="loggedUser"
|
||||
@showMapModal="showMap = true"
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="flex-1">
|
||||
<section
|
||||
class="event-description bg-white dark:bg-zinc-700 px-3 pt-1 pb-3 rounded mb-4"
|
||||
>
|
||||
<h2 class="text-2xl">{{ t("About this event") }}</h2>
|
||||
<div
|
||||
v-if="eventLoading"
|
||||
class="animate-pulse mb-2 h-6 space-y-6 bg-slate-200 w-3/4"
|
||||
/>
|
||||
<div
|
||||
v-if="eventLoading"
|
||||
class="animate-pulse mb-2 h-6 space-y-6 bg-slate-200 w-3/4"
|
||||
/>
|
||||
<div
|
||||
v-if="eventLoading"
|
||||
class="animate-pulse mb-2 h-6 space-y-6 bg-slate-200 w-1/4"
|
||||
/>
|
||||
<p v-else-if="!event?.description">
|
||||
{{ t("The event organizer didn't add any description.") }}
|
||||
</p>
|
||||
<div v-else>
|
||||
<div
|
||||
:lang="event?.language"
|
||||
dir="auto"
|
||||
class="mt-4 prose md:prose-lg lg:prose-xl dark:prose-invert prose-h1:text-xl prose-h1:font-semibold prose-h2:text-lg prose-h3:text-base md:prose-h1:text-2xl md:prose-h1:font-semibold md:prose-h2:text-xl md:prose-h3:text-lg lg:prose-h1:text-2xl lg:prose-h1:font-semibold lg:prose-h2:text-xl lg:prose-h3:text-lg"
|
||||
ref="eventDescriptionElement"
|
||||
v-html="event.description"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<section class="my-4">
|
||||
<component
|
||||
v-for="(metadata, integration) in integrations"
|
||||
:is="metadataToComponent[integration]"
|
||||
:key="integration"
|
||||
:metadata="metadata"
|
||||
class="my-2"
|
||||
/>
|
||||
</section>
|
||||
<section
|
||||
class="bg-white dark:bg-zinc-700 px-3 pt-1 pb-3 rounded my-4"
|
||||
ref="commentsObserver"
|
||||
>
|
||||
<a href="#comments">
|
||||
<h2 class="text-2xl" id="comments">{{ t("Comments") }}</h2>
|
||||
</a>
|
||||
<comment-tree v-if="event && loadComments" :event="event" />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section
|
||||
class="bg-white dark:bg-zinc-700 px-3 pt-1 pb-3 rounded my-4"
|
||||
v-if="(event?.relatedEvents ?? []).length > 0"
|
||||
>
|
||||
<h2 class="text-2xl mb-2">
|
||||
{{ t("These events may interest you") }}
|
||||
</h2>
|
||||
<multi-card :events="event?.relatedEvents ?? []" />
|
||||
</section>
|
||||
<o-modal
|
||||
v-model:active="showMap"
|
||||
:close-button-aria-label="t('Close')"
|
||||
class="map-modal"
|
||||
v-if="event?.physicalAddress?.geom"
|
||||
has-modal-card
|
||||
full-screen
|
||||
:can-cancel="['escape', 'outside']"
|
||||
>
|
||||
<template #default>
|
||||
<event-map
|
||||
:routingType="routingType ?? RoutingType.OPENSTREETMAP"
|
||||
:address="event.physicalAddress"
|
||||
@close="showMap = false"
|
||||
/>
|
||||
</template>
|
||||
</o-modal>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
EventStatus,
|
||||
ParticipantRole,
|
||||
RoutingType,
|
||||
EventVisibility,
|
||||
} from "@/types/enums";
|
||||
import {
|
||||
EVENT_PERSON_PARTICIPATION,
|
||||
// EVENT_PERSON_PARTICIPATION_SUBSCRIPTION_CHANGED,
|
||||
} from "@/graphql/event";
|
||||
import {
|
||||
displayName,
|
||||
IActor,
|
||||
IPerson,
|
||||
usernameWithDomain,
|
||||
} from "@/types/actor";
|
||||
import DateCalendarIcon from "@/components/Event/DateCalendarIcon.vue";
|
||||
import SkeletonDateCalendarIcon from "@/components/Event/SkeletonDateCalendarIcon.vue";
|
||||
import Earth from "vue-material-design-icons/Earth.vue";
|
||||
import Link from "vue-material-design-icons/Link.vue";
|
||||
import MultiCard from "@/components/Event/MultiCard.vue";
|
||||
import RouteName from "@/router/name";
|
||||
import CommentTree from "@/components/Comment/CommentTree.vue";
|
||||
import "intersection-observer";
|
||||
import Tag from "@/components/TagElement.vue";
|
||||
import EventMetadataSidebar from "@/components/Event/EventMetadataSidebar.vue";
|
||||
import EventBanner from "@/components/Event/EventBanner.vue";
|
||||
import EventActionSection from "@/components/Event/EventActionSection.vue";
|
||||
import PopoverActorCard from "@/components/Account/PopoverActorCard.vue";
|
||||
import { IEventMetadataDescription } from "@/types/event-metadata";
|
||||
import { eventMetaDataList } from "@/services/EventMetadata";
|
||||
import { useFetchEvent } from "@/composition/apollo/event";
|
||||
import {
|
||||
computed,
|
||||
onMounted,
|
||||
ref,
|
||||
watch,
|
||||
defineAsyncComponent,
|
||||
inject,
|
||||
} from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import {
|
||||
useCurrentActorClient,
|
||||
usePersonStatusGroup,
|
||||
} from "@/composition/apollo/actor";
|
||||
import { useLoggedUser } from "@/composition/apollo/user";
|
||||
import { useQuery } from "@vue/apollo-composable";
|
||||
import {
|
||||
useEventCategories,
|
||||
useRoutingType,
|
||||
} from "@/composition/apollo/config";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Notifier } from "@/plugins/notifier";
|
||||
import { AbsintheGraphQLErrors } from "@/types/errors.model";
|
||||
import { useHead } from "@vueuse/head";
|
||||
|
||||
const IntegrationTwitch = defineAsyncComponent(
|
||||
() => import("@/components/Event/Integrations/TwitchIntegration.vue")
|
||||
);
|
||||
const IntegrationPeertube = defineAsyncComponent(
|
||||
() => import("@/components/Event/Integrations/PeerTubeIntegration.vue")
|
||||
);
|
||||
const IntegrationYoutube = defineAsyncComponent(
|
||||
() => import("@/components/Event/Integrations/YouTubeIntegration.vue")
|
||||
);
|
||||
const IntegrationJitsiMeet = defineAsyncComponent(
|
||||
() => import("@/components/Event/Integrations/JitsiMeetIntegration.vue")
|
||||
);
|
||||
const IntegrationEtherpad = defineAsyncComponent(
|
||||
() => import("@/components/Event/Integrations/EtherpadIntegration.vue")
|
||||
);
|
||||
const EventMap = defineAsyncComponent(
|
||||
() => import("@/components/Event/EventMap.vue")
|
||||
);
|
||||
|
||||
const props = defineProps<{
|
||||
uuid: string;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
const propsUUID = computed(() => props.uuid);
|
||||
|
||||
const {
|
||||
event,
|
||||
onError: onFetchEventError,
|
||||
loading: eventLoading,
|
||||
refetch: refetchEvent,
|
||||
} = useFetchEvent(propsUUID);
|
||||
|
||||
watch(propsUUID, (newUUid) => {
|
||||
refetchEvent({ uuid: newUUid });
|
||||
});
|
||||
|
||||
const eventId = computed(() => event.value?.id);
|
||||
const { currentActor } = useCurrentActorClient();
|
||||
const currentActorId = computed(() => currentActor.value?.id);
|
||||
const { loggedUser } = useLoggedUser();
|
||||
const {
|
||||
result: participationsResult,
|
||||
// subscribeToMore: subscribeToMoreParticipation,
|
||||
} = useQuery<{ person: IPerson }>(
|
||||
EVENT_PERSON_PARTICIPATION,
|
||||
() => ({
|
||||
eventId: event.value?.id,
|
||||
actorId: currentActorId.value,
|
||||
}),
|
||||
() => ({
|
||||
enabled:
|
||||
currentActorId.value !== undefined &&
|
||||
currentActorId.value !== null &&
|
||||
eventId.value !== undefined,
|
||||
})
|
||||
);
|
||||
|
||||
// subscribeToMoreParticipation(() => ({
|
||||
// document: EVENT_PERSON_PARTICIPATION_SUBSCRIPTION_CHANGED,
|
||||
// variables: {
|
||||
// eventId: eventId,
|
||||
// actorId: currentActorId,
|
||||
// },
|
||||
// }));
|
||||
|
||||
const participations = computed(
|
||||
() => participationsResult.value?.person.participations?.elements ?? []
|
||||
);
|
||||
|
||||
const groupFederatedUsername = computed(() =>
|
||||
usernameWithDomain(event.value?.attributedTo)
|
||||
);
|
||||
|
||||
const { person } = usePersonStatusGroup(groupFederatedUsername);
|
||||
|
||||
const { eventCategories } = useEventCategories();
|
||||
|
||||
// metaInfo() {
|
||||
// return {
|
||||
// // eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// // @ts-ignore
|
||||
// title: this.eventTitle,
|
||||
// meta: [
|
||||
// // eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// // @ts-ignore
|
||||
// { name: "description", content: this.eventDescription },
|
||||
// ],
|
||||
// };
|
||||
// },
|
||||
|
||||
const identity = ref<IPerson | undefined | null>(null);
|
||||
|
||||
const oldParticipationRole = ref<string | undefined>(undefined);
|
||||
|
||||
const observer = ref<IntersectionObserver | null>(null);
|
||||
const commentsObserver = ref<Element | null>(null);
|
||||
|
||||
const loadComments = ref(false);
|
||||
|
||||
const eventTitle = computed((): undefined | string => {
|
||||
return event.value?.title;
|
||||
});
|
||||
|
||||
const eventDescription = computed((): undefined | string => {
|
||||
return event.value?.description;
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const eventDescriptionElement = ref<HTMLElement | null>(null);
|
||||
|
||||
onMounted(async () => {
|
||||
identity.value = currentActor.value;
|
||||
if (route.hash.includes("#comment-")) {
|
||||
loadComments.value = true;
|
||||
}
|
||||
|
||||
observer.value = new IntersectionObserver(
|
||||
(entries) => {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
for (const entry of entries) {
|
||||
if (entry) {
|
||||
loadComments.value = entry.isIntersecting || loadComments.value;
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
rootMargin: "-50px 0px -50px",
|
||||
}
|
||||
);
|
||||
if (commentsObserver.value) {
|
||||
observer.value.observe(commentsObserver.value);
|
||||
}
|
||||
|
||||
watch(eventDescription, () => {
|
||||
if (!eventDescription.value) return;
|
||||
if (!eventDescriptionElement.value) return;
|
||||
|
||||
eventDescriptionElement.value.addEventListener("click", ($event) => {
|
||||
// TODO: Find the right type for target
|
||||
let { target }: { target: any } = $event;
|
||||
while (target && target.tagName !== "A") target = target.parentNode;
|
||||
// handle only links that occur inside the component and do not reference external resources
|
||||
if (target && target.matches(".hashtag") && target.href) {
|
||||
// some sanity checks taken from vue-router:
|
||||
// https://github.com/vuejs/vue-router/blob/dev/src/components/link.js#L106
|
||||
const { altKey, ctrlKey, metaKey, shiftKey, button, defaultPrevented } =
|
||||
$event;
|
||||
// don't handle with control keys
|
||||
if (metaKey || altKey || ctrlKey || shiftKey) return;
|
||||
// don't handle when preventDefault called
|
||||
if (defaultPrevented) return;
|
||||
// don't handle right clicks
|
||||
if (button !== undefined && button !== 0) return;
|
||||
// don't handle if `target="_blank"`
|
||||
if (target && target.getAttribute) {
|
||||
const linkTarget = target.getAttribute("target");
|
||||
if (/\b_blank\b/i.test(linkTarget)) return;
|
||||
}
|
||||
// don't handle same page links/anchors
|
||||
const url = new URL(target.href);
|
||||
const to = url.pathname;
|
||||
if (window.location.pathname !== to && $event.preventDefault) {
|
||||
$event.preventDefault();
|
||||
router.push(to);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// this.$on("event-deleted", () => {
|
||||
// return router.push({ name: RouteName.HOME });
|
||||
// });
|
||||
});
|
||||
|
||||
const notifier = inject<Notifier>("notifier");
|
||||
|
||||
watch(participations, () => {
|
||||
if (participations.value.length > 0) {
|
||||
if (
|
||||
oldParticipationRole.value &&
|
||||
participations.value[0].role !== ParticipantRole.NOT_APPROVED &&
|
||||
oldParticipationRole.value !== participations.value[0].role
|
||||
) {
|
||||
switch (participations.value[0].role) {
|
||||
case ParticipantRole.PARTICIPANT:
|
||||
participationConfirmedMessage();
|
||||
break;
|
||||
case ParticipantRole.REJECTED:
|
||||
participationRejectedMessage();
|
||||
break;
|
||||
default:
|
||||
participationChangedMessage();
|
||||
break;
|
||||
}
|
||||
}
|
||||
oldParticipationRole.value = participations.value[0].role;
|
||||
}
|
||||
});
|
||||
|
||||
const participationConfirmedMessage = () => {
|
||||
notifier?.success(t("Your participation has been confirmed"));
|
||||
};
|
||||
|
||||
const participationRejectedMessage = () => {
|
||||
notifier?.error(t("Your participation has been rejected"));
|
||||
};
|
||||
|
||||
const participationChangedMessage = () => {
|
||||
notifier?.info(t("Your participation status has been changed"));
|
||||
};
|
||||
|
||||
const handleErrors = (errors: AbsintheGraphQLErrors): void => {
|
||||
if (
|
||||
errors.some((error) => error.status_code === 404) ||
|
||||
errors.some(({ message }) => message.includes("has invalid value $uuid"))
|
||||
) {
|
||||
router.replace({ name: RouteName.PAGE_NOT_FOUND });
|
||||
}
|
||||
};
|
||||
|
||||
onFetchEventError(({ graphQLErrors }) =>
|
||||
handleErrors(graphQLErrors as AbsintheGraphQLErrors)
|
||||
);
|
||||
|
||||
const metadataToComponent: Record<string, any> = {
|
||||
"mz:live:twitch:url": IntegrationTwitch,
|
||||
"mz:live:peertube:url": IntegrationPeertube,
|
||||
"mz:live:youtube:url": IntegrationYoutube,
|
||||
"mz:visio:jitsi_meet": IntegrationJitsiMeet,
|
||||
"mz:notes:etherpad:url": IntegrationEtherpad,
|
||||
};
|
||||
|
||||
const integrations = computed((): Record<string, IEventMetadataDescription> => {
|
||||
return (event.value?.metadata ?? [])
|
||||
.map((val) => {
|
||||
const def = eventMetaDataList.find((dat) => dat.key === val.key);
|
||||
return {
|
||||
...def,
|
||||
...val,
|
||||
};
|
||||
})
|
||||
.reduce((acc: Record<string, IEventMetadataDescription>, metadata) => {
|
||||
const component = metadataToComponent[metadata.key];
|
||||
if (component !== undefined) {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
acc[metadata.key] = metadata;
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
});
|
||||
|
||||
const showMap = ref(false);
|
||||
|
||||
const { routingType } = useRoutingType();
|
||||
|
||||
const eventCategory = computed((): string | undefined => {
|
||||
if (event.value?.category === "MEETING") {
|
||||
return undefined;
|
||||
}
|
||||
return (eventCategories.value ?? []).find((eventCategoryToFind) => {
|
||||
return eventCategoryToFind.id === event.value?.category;
|
||||
})?.label as string;
|
||||
});
|
||||
|
||||
const organizer = computed((): IActor | null => {
|
||||
if (event.value?.attributedTo?.id) {
|
||||
return event.value.attributedTo;
|
||||
}
|
||||
if (event.value?.organizerActor) {
|
||||
return event.value.organizerActor;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const organizerDomain = computed((): string | undefined => {
|
||||
return organizer.value?.domain ?? undefined;
|
||||
});
|
||||
|
||||
useHead({
|
||||
title: computed(() => eventTitle.value ?? ""),
|
||||
meta: [{ name: "description", content: eventDescription.value }],
|
||||
});
|
||||
</script>
|
||||
<style>
|
||||
.event-description a {
|
||||
@apply inline-block p-1 bg-mbz-yellow-alt-200 text-black;
|
||||
}
|
||||
|
||||
.event-description .mention.h-card {
|
||||
@apply inline-block border border-zinc-600 dark:border-zinc-300 rounded py-0.5 px-1;
|
||||
}
|
||||
</style>
|
||||
218
src/views/Event/GroupEvents.vue
Normal file
218
src/views/Event/GroupEvents.vue
Normal file
@@ -0,0 +1,218 @@
|
||||
<template>
|
||||
<div class="container mx-auto" v-if="group">
|
||||
<breadcrumbs-nav
|
||||
:links="[
|
||||
{
|
||||
name: RouteName.GROUP,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
text: displayName(group),
|
||||
},
|
||||
{
|
||||
name: RouteName.GROUP_EVENTS,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
text: t('Events'),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<section>
|
||||
<h1 class="" v-if="group">
|
||||
{{
|
||||
t("{group}'s events", {
|
||||
group: displayName(group),
|
||||
})
|
||||
}}
|
||||
</h1>
|
||||
<p v-if="isCurrentActorMember">
|
||||
{{
|
||||
t(
|
||||
"When a moderator from the group creates an event and attributes it to the group, it will show up here."
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<o-button
|
||||
tag="router-link"
|
||||
variant="primary"
|
||||
v-if="isCurrentActorAGroupModerator"
|
||||
:to="{
|
||||
name: RouteName.CREATE_EVENT,
|
||||
query: { actorId: group.id },
|
||||
}"
|
||||
>{{ t("+ Create an event") }}</o-button
|
||||
>
|
||||
<o-loading v-model:active="groupLoading"></o-loading>
|
||||
<section v-if="group">
|
||||
<h2 class="text-2xl">
|
||||
{{ showPassedEvents ? t("Past events") : t("Upcoming events") }}
|
||||
</h2>
|
||||
<o-switch class="mb-4" v-model="showPassedEvents">{{
|
||||
t("Past events")
|
||||
}}</o-switch>
|
||||
<grouped-multi-event-minimalist-card
|
||||
class="mb-6"
|
||||
:events="group.organizedEvents.elements"
|
||||
:isCurrentActorMember="isCurrentActorMember"
|
||||
:order="showPassedEvents ? 'DESC' : 'ASC'"
|
||||
/>
|
||||
<empty-content
|
||||
v-if="
|
||||
group.organizedEvents.elements.length === 0 &&
|
||||
groupLoading === false
|
||||
"
|
||||
icon="calendar"
|
||||
:inline="true"
|
||||
:center="true"
|
||||
>
|
||||
{{ t("No events found") }}
|
||||
<template v-if="group.domain !== null">
|
||||
<div class="mt-4">
|
||||
<p>
|
||||
{{
|
||||
t(
|
||||
"This group is a remote group, it's possible the original instance has more informations."
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<o-button variant="text" tag="a" :href="group.url">
|
||||
{{ t("View the group profile on the original instance") }}
|
||||
</o-button>
|
||||
</div>
|
||||
</template>
|
||||
</empty-content>
|
||||
<o-pagination
|
||||
v-if="group.organizedEvents.total > EVENTS_PAGE_LIMIT"
|
||||
class="mt-4"
|
||||
:total="group.organizedEvents.total"
|
||||
v-model:current="page"
|
||||
:per-page="EVENTS_PAGE_LIMIT"
|
||||
:aria-next-label="t('Next page')"
|
||||
:aria-previous-label="t('Previous page')"
|
||||
:aria-page-label="t('Page')"
|
||||
:aria-current-label="t('Current page')"
|
||||
>
|
||||
</o-pagination>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import RouteName from "@/router/name";
|
||||
import GroupedMultiEventMinimalistCard from "@/components/Event/GroupedMultiEventMinimalistCard.vue";
|
||||
import { PERSON_MEMBERSHIPS } from "@/graphql/actor";
|
||||
import { FETCH_GROUP_EVENTS } from "@/graphql/event";
|
||||
import EmptyContent from "../../components/Utils/EmptyContent.vue";
|
||||
import {
|
||||
displayName,
|
||||
IGroup,
|
||||
IPerson,
|
||||
usernameWithDomain,
|
||||
} from "../../types/actor";
|
||||
import { useQuery } from "@vue/apollo-composable";
|
||||
import { useCurrentActorClient } from "@/composition/apollo/actor";
|
||||
import { computed, watch } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import {
|
||||
booleanTransformer,
|
||||
integerTransformer,
|
||||
useRouteQuery,
|
||||
} from "vue-use-route-query";
|
||||
import { MemberRole } from "@/types/enums";
|
||||
import { useHead } from "@vueuse/head";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const EVENTS_PAGE_LIMIT = 10;
|
||||
|
||||
const { currentActor } = useCurrentActorClient();
|
||||
|
||||
const { result: membershipsResult } = useQuery<{
|
||||
person: Pick<IPerson, "memberships">;
|
||||
}>(
|
||||
PERSON_MEMBERSHIPS,
|
||||
() => ({ id: currentActor.value?.id }),
|
||||
() => ({
|
||||
enabled:
|
||||
currentActor.value?.id !== undefined && currentActor.value?.id !== null,
|
||||
})
|
||||
);
|
||||
const memberships = computed(
|
||||
() => membershipsResult.value?.person.memberships?.elements
|
||||
);
|
||||
|
||||
const route = useRoute();
|
||||
const page = useRouteQuery("page", 1, integerTransformer);
|
||||
const showPassedEvents = useRouteQuery(
|
||||
"showPassedEvents",
|
||||
false,
|
||||
booleanTransformer
|
||||
);
|
||||
|
||||
/**
|
||||
* Why is the following hack needed? Page doesn't want to be reactive!
|
||||
* TODO: investigate
|
||||
*/
|
||||
const variables = computed(() => ({
|
||||
name: route.params.preferredUsername as string,
|
||||
beforeDateTime: showPassedEvents.value ? new Date() : null,
|
||||
afterDateTime: showPassedEvents.value ? null : new Date(),
|
||||
order: "BEGINS_ON",
|
||||
orderDirection: showPassedEvents.value ? "DESC" : "ASC",
|
||||
organisedEventsPage: page.value,
|
||||
organisedEventsLimit: EVENTS_PAGE_LIMIT,
|
||||
}));
|
||||
|
||||
watch(
|
||||
variables,
|
||||
(newVariables) => {
|
||||
refetch(newVariables);
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
const {
|
||||
result: groupResult,
|
||||
loading: groupLoading,
|
||||
refetch: refetch,
|
||||
} = useQuery<
|
||||
{
|
||||
group: IGroup;
|
||||
},
|
||||
{
|
||||
name: string;
|
||||
beforeDateTime: Date | null;
|
||||
afterDateTime: Date | null;
|
||||
organisedEventsPage: number;
|
||||
organisedEventsLimit: number;
|
||||
}
|
||||
>(FETCH_GROUP_EVENTS, variables);
|
||||
const group = computed(() => groupResult.value?.group);
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
useHead({
|
||||
title: () =>
|
||||
t("{group} events", {
|
||||
group: displayName(group.value),
|
||||
}),
|
||||
});
|
||||
|
||||
const isCurrentActorMember = computed((): boolean => {
|
||||
if (!group.value || !memberships.value) return false;
|
||||
return (memberships.value ?? [])
|
||||
.map(({ parent: { id } }) => id)
|
||||
.includes(group.value.id);
|
||||
});
|
||||
|
||||
const isCurrentActorAGroupModerator = computed((): boolean => {
|
||||
return hasCurrentActorThisRole([
|
||||
MemberRole.MODERATOR,
|
||||
MemberRole.ADMINISTRATOR,
|
||||
]);
|
||||
});
|
||||
|
||||
const hasCurrentActorThisRole = (givenRole: string | string[]): boolean => {
|
||||
const roles = Array.isArray(givenRole) ? givenRole : [givenRole];
|
||||
return (
|
||||
memberships.value !== undefined &&
|
||||
memberships.value?.length > 0 &&
|
||||
roles.includes(memberships.value[0].role)
|
||||
);
|
||||
};
|
||||
</script>
|
||||
523
src/views/Event/MyEventsView.vue
Normal file
523
src/views/Event/MyEventsView.vue
Normal file
@@ -0,0 +1,523 @@
|
||||
<template>
|
||||
<div class="container mx-auto px-1 mb-6">
|
||||
<h1>
|
||||
{{ t("My events") }}
|
||||
</h1>
|
||||
<p>
|
||||
{{
|
||||
t(
|
||||
"You will find here all the events you have created or of which you are a participant, as well as events organized by groups you follow or are a member of."
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<div class="my-2" v-if="!hideCreateEventButton">
|
||||
<o-button
|
||||
tag="router-link"
|
||||
variant="primary"
|
||||
:to="{ name: RouteName.CREATE_EVENT }"
|
||||
>{{ t("Create event") }}</o-button
|
||||
>
|
||||
</div>
|
||||
<!-- <o-loading v-model:active="$apollo.loading"></o-loading> -->
|
||||
<div class="flex flex-wrap gap-4 items-start">
|
||||
<div
|
||||
class="rounded p-3 flex-auto md:flex-none bg-zinc-300 dark:bg-zinc-700"
|
||||
>
|
||||
<o-field>
|
||||
<o-switch v-model="showUpcoming">{{
|
||||
showUpcoming ? t("Upcoming events") : t("Past events")
|
||||
}}</o-switch>
|
||||
</o-field>
|
||||
<o-field v-if="showUpcoming">
|
||||
<o-checkbox v-model="showDrafts">{{ t("Drafts") }}</o-checkbox>
|
||||
</o-field>
|
||||
<o-field v-if="showUpcoming">
|
||||
<o-checkbox v-model="showAttending">{{ t("Attending") }}</o-checkbox>
|
||||
</o-field>
|
||||
<o-field v-if="showUpcoming">
|
||||
<o-checkbox v-model="showMyGroups">{{
|
||||
t("From my groups")
|
||||
}}</o-checkbox>
|
||||
</o-field>
|
||||
<p v-if="!showUpcoming">
|
||||
{{
|
||||
t(
|
||||
"You have attended {count} events in the past.",
|
||||
{
|
||||
count: pastParticipations.total,
|
||||
},
|
||||
pastParticipations.total
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<o-field
|
||||
class="date-filter"
|
||||
expanded
|
||||
:label="
|
||||
showUpcoming
|
||||
? t('Showing events starting on')
|
||||
: t('Showing events before')
|
||||
"
|
||||
labelFor="events-start-datepicker"
|
||||
>
|
||||
<o-datepicker
|
||||
v-model="dateFilter"
|
||||
:first-day-of-week="firstDayOfWeek"
|
||||
id="events-start-datepicker"
|
||||
/>
|
||||
<o-button
|
||||
@click="dateFilter = new Date()"
|
||||
class="reset-area !h-auto"
|
||||
icon-left="close"
|
||||
:title="t('Clear date filter field')"
|
||||
/>
|
||||
</o-field>
|
||||
</div>
|
||||
<div class="flex-1 min-w-[300px]">
|
||||
<section
|
||||
class="py-4 first:pt-0"
|
||||
v-if="showUpcoming && showDrafts && drafts && drafts.total > 0"
|
||||
>
|
||||
<h2 class="text-2xl mb-2">{{ t("Drafts") }}</h2>
|
||||
<multi-event-minimalist-card
|
||||
:events="drafts.elements"
|
||||
:showOrganizer="true"
|
||||
/>
|
||||
<o-pagination
|
||||
class="mt-4"
|
||||
v-show="drafts.total > LOGGED_USER_DRAFTS_LIMIT"
|
||||
:total="drafts.total"
|
||||
v-model:current="draftsPage"
|
||||
:per-page="LOGGED_USER_DRAFTS_LIMIT"
|
||||
:aria-next-label="t('Next page')"
|
||||
:aria-previous-label="t('Previous page')"
|
||||
:aria-page-label="t('Page')"
|
||||
:aria-current-label="t('Current page')"
|
||||
>
|
||||
</o-pagination>
|
||||
</section>
|
||||
<section
|
||||
class="py-4 first:pt-0"
|
||||
v-if="
|
||||
showUpcoming && monthlyFutureEvents && monthlyFutureEvents.size > 0
|
||||
"
|
||||
>
|
||||
<transition-group name="list" tag="p">
|
||||
<div
|
||||
class="mb-5"
|
||||
v-for="month of monthlyFutureEvents"
|
||||
:key="month[0]"
|
||||
>
|
||||
<h2 class="text-2xl">{{ month[0] }}</h2>
|
||||
<div v-for="element in month[1]" :key="element.id">
|
||||
<event-participation-card
|
||||
v-if="'role' in element"
|
||||
:participation="element"
|
||||
@event-deleted="eventDeleted"
|
||||
class="participation"
|
||||
/>
|
||||
<event-minimalist-card
|
||||
v-else-if="
|
||||
element.id &&
|
||||
!monthParticipationsIds(month[1]).includes(element?.id)
|
||||
"
|
||||
:event="element"
|
||||
class="participation"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</transition-group>
|
||||
<div class="columns is-centered">
|
||||
<o-button
|
||||
class="column is-narrow"
|
||||
v-if="
|
||||
hasMoreFutureParticipations &&
|
||||
futureParticipations &&
|
||||
futureParticipations.length === limit
|
||||
"
|
||||
@click="loadMoreFutureParticipations"
|
||||
size="large"
|
||||
variant="primary"
|
||||
>{{ t("Load more") }}</o-button
|
||||
>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
class="text-center not-found"
|
||||
v-if="
|
||||
showUpcoming &&
|
||||
monthlyFutureEvents &&
|
||||
monthlyFutureEvents.size === 0 &&
|
||||
true // !$apollo.loading
|
||||
"
|
||||
>
|
||||
<div class="img-container h-64 prose" />
|
||||
<div class="text-center prose dark:prose-invert max-w-full">
|
||||
<p>
|
||||
{{
|
||||
t(
|
||||
"You don't have any upcoming events. Maybe try another filter?"
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<i18n-t
|
||||
keypath="Do you wish to {create_event} or {explore_events}?"
|
||||
tag="p"
|
||||
>
|
||||
<template #create_event>
|
||||
<router-link :to="{ name: RouteName.CREATE_EVENT }">{{
|
||||
t("create an event")
|
||||
}}</router-link>
|
||||
</template>
|
||||
<template #explore_events>
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.SEARCH,
|
||||
query: { contentType: ContentType.EVENTS },
|
||||
}"
|
||||
>{{ t("explore the events") }}</router-link
|
||||
>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</div>
|
||||
</section>
|
||||
<section v-if="!showUpcoming && pastParticipations.elements.length > 0">
|
||||
<transition-group name="list" tag="p">
|
||||
<div v-for="month in monthlyPastParticipations" :key="month[0]">
|
||||
<h2 class="capitalize inline-block relative">{{ month[0] }}</h2>
|
||||
<event-participation-card
|
||||
v-for="participation in month[1]"
|
||||
:key="participation.id"
|
||||
:participation="participation as IParticipant"
|
||||
:options="{ hideDate: false }"
|
||||
@event-deleted="eventDeleted"
|
||||
class="participation"
|
||||
/>
|
||||
</div>
|
||||
</transition-group>
|
||||
<div class="columns is-centered">
|
||||
<o-button
|
||||
class="column is-narrow"
|
||||
v-if="
|
||||
hasMorePastParticipations &&
|
||||
pastParticipations.elements.length === limit
|
||||
"
|
||||
@click="loadMorePastParticipations"
|
||||
size="large"
|
||||
variant="primary"
|
||||
>{{ t("Load more") }}</o-button
|
||||
>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ParticipantRole, ContentType } from "@/types/enums";
|
||||
import RouteName from "@/router/name";
|
||||
import type { IParticipant } from "../../types/participant.model";
|
||||
import { LOGGED_USER_DRAFTS } from "../../graphql/actor";
|
||||
import type { IEvent } from "../../types/event.model";
|
||||
import MultiEventMinimalistCard from "../../components/Event/MultiEventMinimalistCard.vue";
|
||||
import EventMinimalistCard from "../../components/Event/EventMinimalistCard.vue";
|
||||
import {
|
||||
LOGGED_USER_PARTICIPATIONS,
|
||||
LOGGED_USER_UPCOMING_EVENTS,
|
||||
} from "@/graphql/participant";
|
||||
import { useApolloClient, useQuery } from "@vue/apollo-composable";
|
||||
import { computed, inject, ref, defineAsyncComponent } from "vue";
|
||||
import { IUser } from "@/types/current-user.model";
|
||||
import {
|
||||
booleanTransformer,
|
||||
integerTransformer,
|
||||
useRouteQuery,
|
||||
} from "vue-use-route-query";
|
||||
import { Locale } from "date-fns";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useRestrictions } from "@/composition/apollo/config";
|
||||
import { useHead } from "@vueuse/head";
|
||||
|
||||
const EventParticipationCard = defineAsyncComponent(
|
||||
() => import("@/components/Event/EventParticipationCard.vue")
|
||||
);
|
||||
|
||||
type Eventable = IParticipant | IEvent;
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
const futurePage = ref(1);
|
||||
const pastPage = ref(1);
|
||||
const limit = ref(10);
|
||||
|
||||
const showUpcoming = useRouteQuery("showUpcoming", true, booleanTransformer);
|
||||
const showDrafts = useRouteQuery("showDrafts", true, booleanTransformer);
|
||||
const showAttending = useRouteQuery("showAttending", true, booleanTransformer);
|
||||
const showMyGroups = useRouteQuery("showMyGroups", false, booleanTransformer);
|
||||
const dateFilter = useRouteQuery("dateFilter", new Date(), {
|
||||
fromQuery(query) {
|
||||
if (query && /(\d{4}-\d{2}-\d{2})/.test(query)) {
|
||||
return new Date(`${query}T00:00:00Z`);
|
||||
}
|
||||
return new Date();
|
||||
},
|
||||
toQuery(value: Date) {
|
||||
const pad = (number: number) => {
|
||||
if (number < 10) {
|
||||
return "0" + number;
|
||||
}
|
||||
return number;
|
||||
};
|
||||
return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(
|
||||
value.getDate()
|
||||
)}`;
|
||||
},
|
||||
});
|
||||
|
||||
const hasMoreFutureParticipations = ref(true);
|
||||
const hasMorePastParticipations = ref(true);
|
||||
|
||||
const {
|
||||
result: loggedUserUpcomingEventsResult,
|
||||
fetchMore: fetchMoreUpcomingEvents,
|
||||
} = useQuery<{
|
||||
loggedUser: IUser;
|
||||
}>(LOGGED_USER_UPCOMING_EVENTS, () => ({
|
||||
page: 1,
|
||||
limit: 10,
|
||||
afterDateTime: dateFilter.value,
|
||||
}));
|
||||
|
||||
const futureParticipations = computed(
|
||||
() =>
|
||||
loggedUserUpcomingEventsResult.value?.loggedUser.participations.elements ??
|
||||
[]
|
||||
);
|
||||
const groupEvents = computed(
|
||||
() =>
|
||||
loggedUserUpcomingEventsResult.value?.loggedUser.followedGroupEvents
|
||||
.elements ?? []
|
||||
);
|
||||
|
||||
const LOGGED_USER_DRAFTS_LIMIT = 10;
|
||||
const draftsPage = useRouteQuery("draftsPage", 1, integerTransformer);
|
||||
|
||||
const { result: draftsResult } = useQuery<{
|
||||
loggedUser: Pick<IUser, "drafts">;
|
||||
}>(
|
||||
LOGGED_USER_DRAFTS,
|
||||
() => ({ page: draftsPage.value, limit: LOGGED_USER_DRAFTS_LIMIT }),
|
||||
() => ({ fetchPolicy: "cache-and-network" })
|
||||
);
|
||||
const drafts = computed(() => draftsResult.value?.loggedUser.drafts);
|
||||
|
||||
const { result: participationsResult, fetchMore: fetchMoreParticipations } =
|
||||
useQuery<{
|
||||
loggedUser: Pick<IUser, "participations">;
|
||||
}>(LOGGED_USER_PARTICIPATIONS, () => ({ page: 1, limit: 10 }));
|
||||
const pastParticipations = computed(
|
||||
() =>
|
||||
participationsResult.value?.loggedUser.participations ?? {
|
||||
elements: [],
|
||||
total: 0,
|
||||
}
|
||||
);
|
||||
|
||||
const monthlyEvents = (
|
||||
elements: Eventable[],
|
||||
revertSort = false
|
||||
): Map<string, Eventable[]> => {
|
||||
const res = elements.filter((element: Eventable) => {
|
||||
if ("role" in element) {
|
||||
return (
|
||||
element.event.beginsOn != null &&
|
||||
element.role !== ParticipantRole.REJECTED
|
||||
);
|
||||
}
|
||||
return element.beginsOn != null;
|
||||
});
|
||||
if (revertSort) {
|
||||
res.sort((a: Eventable, b: Eventable) => {
|
||||
const aTime = "role" in a ? a.event.beginsOn : a.beginsOn;
|
||||
const bTime = "role" in b ? b.event.beginsOn : b.beginsOn;
|
||||
return new Date(bTime).getTime() - new Date(aTime).getTime();
|
||||
});
|
||||
} else {
|
||||
res.sort((a: Eventable, b: Eventable) => {
|
||||
const aTime = "role" in a ? a.event.beginsOn : a.beginsOn;
|
||||
const bTime = "role" in b ? b.event.beginsOn : b.beginsOn;
|
||||
return new Date(aTime).getTime() - new Date(bTime).getTime();
|
||||
});
|
||||
}
|
||||
return res.reduce((acc: Map<string, Eventable[]>, element: Eventable) => {
|
||||
const month = new Date(
|
||||
"role" in element ? element.event.beginsOn : element.beginsOn
|
||||
).toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
});
|
||||
const filteredElements: Eventable[] = acc.get(month) || [];
|
||||
filteredElements.push(element);
|
||||
acc.set(month, filteredElements);
|
||||
return acc;
|
||||
}, new Map());
|
||||
};
|
||||
|
||||
const monthlyFutureEvents = computed((): Map<string, Eventable[]> => {
|
||||
let eventable = [] as Eventable[];
|
||||
if (showAttending.value) {
|
||||
eventable = [...eventable, ...futureParticipations.value];
|
||||
}
|
||||
if (showMyGroups.value) {
|
||||
eventable = [...eventable, ...groupEvents.value.map(({ event }) => event)];
|
||||
}
|
||||
return monthlyEvents(eventable);
|
||||
});
|
||||
|
||||
const monthlyPastParticipations = computed((): Map<string, Eventable[]> => {
|
||||
return monthlyEvents(pastParticipations.value.elements, true);
|
||||
});
|
||||
|
||||
const monthParticipationsIds = (elements: Eventable[]): string[] => {
|
||||
const res = elements.filter((element: Eventable) => {
|
||||
return "role" in element;
|
||||
}) as IParticipant[];
|
||||
return res.map(({ event }: { event: IEvent }) => {
|
||||
return event.id as string;
|
||||
});
|
||||
};
|
||||
|
||||
const loadMoreFutureParticipations = (): void => {
|
||||
futurePage.value += 1;
|
||||
if (fetchMoreUpcomingEvents) {
|
||||
fetchMoreUpcomingEvents({
|
||||
// New variables
|
||||
variables: {
|
||||
page: futurePage.value,
|
||||
limit: limit.value,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const loadMorePastParticipations = (): void => {
|
||||
pastPage.value += 1;
|
||||
if (fetchMoreParticipations) {
|
||||
fetchMoreParticipations({
|
||||
// New variables
|
||||
variables: {
|
||||
page: pastPage.value,
|
||||
limit: limit.value,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const apollo = useApolloClient();
|
||||
|
||||
const eventDeleted = (eventid: string): void => {
|
||||
/**
|
||||
* Remove event from upcoming event participations
|
||||
*/
|
||||
const upcomingEventsData = apollo.client.cache.readQuery<{
|
||||
loggedUser: IUser;
|
||||
}>({
|
||||
query: LOGGED_USER_UPCOMING_EVENTS,
|
||||
variables: () => ({
|
||||
page: 1,
|
||||
limit: 10,
|
||||
afterDateTime: dateFilter.value,
|
||||
}),
|
||||
});
|
||||
if (!upcomingEventsData) return;
|
||||
const loggedUser = upcomingEventsData?.loggedUser;
|
||||
const participations = loggedUser?.participations;
|
||||
apollo.client.cache.writeQuery<{ loggedUser: IUser }>({
|
||||
query: LOGGED_USER_UPCOMING_EVENTS,
|
||||
variables: () => ({
|
||||
page: 1,
|
||||
limit: 10,
|
||||
afterDateTime: dateFilter.value,
|
||||
}),
|
||||
data: {
|
||||
loggedUser: {
|
||||
...loggedUser,
|
||||
participations: {
|
||||
total: participations.total - 1,
|
||||
elements: participations.elements.filter(
|
||||
(participation) => participation.event.id !== eventid
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Remove event from past event participations
|
||||
*/
|
||||
const participationData = apollo.client.cache.readQuery<{
|
||||
loggedUser: Pick<IUser, "participations">;
|
||||
}>({
|
||||
query: LOGGED_USER_PARTICIPATIONS,
|
||||
variables: () => ({ page: 1, limit: 10 }),
|
||||
});
|
||||
if (!participationData) return;
|
||||
const loggedUser2 = participationData?.loggedUser;
|
||||
const participations2 = loggedUser?.participations;
|
||||
apollo.client.cache.writeQuery<{
|
||||
loggedUser: Pick<IUser, "participations">;
|
||||
}>({
|
||||
query: LOGGED_USER_PARTICIPATIONS,
|
||||
variables: () => ({ page: 1, limit: 10 }),
|
||||
data: {
|
||||
loggedUser: {
|
||||
...loggedUser2,
|
||||
participations: {
|
||||
total: participations2.total - 1,
|
||||
elements: participations2.elements.filter(
|
||||
(participation) => participation.event.id !== eventid
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const { restrictions } = useRestrictions();
|
||||
|
||||
const hideCreateEventButton = computed((): boolean => {
|
||||
return restrictions.value?.onlyGroupsCanCreateEvents === true;
|
||||
});
|
||||
|
||||
const dateFnsLocale = inject<Locale>("dateFnsLocale");
|
||||
|
||||
const firstDayOfWeek = computed((): number => {
|
||||
return dateFnsLocale?.options?.weekStartsOn ?? 0;
|
||||
});
|
||||
|
||||
useHead({
|
||||
title: computed(() => t("My events")),
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.not-found {
|
||||
.img-container {
|
||||
background-image: url("../../../img/pics/event_creation-480w.webp");
|
||||
|
||||
@media (min-resolution: 2dppx) {
|
||||
& {
|
||||
background-image: url("../../../img/pics/event_creation-1024w.webp");
|
||||
}
|
||||
}
|
||||
|
||||
max-width: 450px;
|
||||
height: 300px;
|
||||
box-shadow: 0 0 8px 8px white inset;
|
||||
background-size: cover;
|
||||
border-radius: 10px;
|
||||
margin: auto auto 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
503
src/views/Event/ParticipantsView.vue
Normal file
503
src/views/Event/ParticipantsView.vue
Normal file
@@ -0,0 +1,503 @@
|
||||
<template>
|
||||
<section class="container mx-auto" v-if="event">
|
||||
<breadcrumbs-nav
|
||||
:links="[
|
||||
{ name: RouteName.MY_EVENTS, text: t('My events') },
|
||||
{
|
||||
name: RouteName.EVENT,
|
||||
params: { uuid: event.uuid },
|
||||
text: event.title,
|
||||
},
|
||||
{
|
||||
name: RouteName.PARTICIPATIONS,
|
||||
params: { uuid: event.uuid },
|
||||
text: t('Participants'),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<h1>{{ t("Participants") }}</h1>
|
||||
<div class="">
|
||||
<div class="">
|
||||
<div class="">
|
||||
<o-field :label="t('Status')" horizontal label-for="role-select">
|
||||
<o-select v-model="role" id="role-select">
|
||||
<option value="EVERYTHING">
|
||||
{{ t("Everything") }}
|
||||
</option>
|
||||
<option :value="ParticipantRole.CREATOR">
|
||||
{{ t("Organizer") }}
|
||||
</option>
|
||||
<option :value="ParticipantRole.PARTICIPANT">
|
||||
{{ t("Participant") }}
|
||||
</option>
|
||||
<option :value="ParticipantRole.NOT_APPROVED">
|
||||
{{ t("Not approved") }}
|
||||
</option>
|
||||
<option :value="ParticipantRole.REJECTED">
|
||||
{{ t("Rejected") }}
|
||||
</option>
|
||||
</o-select>
|
||||
</o-field>
|
||||
</div>
|
||||
<div class="" v-if="exportFormats.length > 0">
|
||||
<o-dropdown aria-role="list">
|
||||
<template #trigger="{ active }">
|
||||
<o-button
|
||||
:label="t('Export')"
|
||||
variant="primary"
|
||||
:icon-right="active ? 'menu-up' : 'menu-down'"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<o-dropdown-item
|
||||
has-link
|
||||
v-for="format in exportFormats"
|
||||
:key="format"
|
||||
aria-role="listitem"
|
||||
@click="
|
||||
exportParticipants({
|
||||
eventId: event.id ?? '',
|
||||
format,
|
||||
})
|
||||
"
|
||||
@keyup.enter="
|
||||
exportParticipants({
|
||||
eventId: event.id ?? '',
|
||||
format,
|
||||
})
|
||||
"
|
||||
>
|
||||
<button class="dropdown-button">
|
||||
<o-icon :icon="formatToIcon(format)"></o-icon>
|
||||
{{ format }}
|
||||
</button>
|
||||
</o-dropdown-item>
|
||||
</o-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<o-table
|
||||
:data="event.participants.elements"
|
||||
ref="queueTable"
|
||||
detailed
|
||||
detail-key="id"
|
||||
v-model:checked-rows="checkedRows"
|
||||
checkable
|
||||
:is-row-checkable="
|
||||
(row: IParticipant) => row.role !== ParticipantRole.CREATOR
|
||||
"
|
||||
checkbox-position="left"
|
||||
:show-detail-icon="false"
|
||||
:loading="participantsLoading"
|
||||
paginated
|
||||
:current-page="page"
|
||||
backend-pagination
|
||||
:pagination-simple="true"
|
||||
:aria-next-label="t('Next page')"
|
||||
:aria-previous-label="t('Previous page')"
|
||||
:aria-page-label="t('Page')"
|
||||
:aria-current-label="t('Current page')"
|
||||
:total="event.participants.total"
|
||||
:per-page="PARTICIPANTS_PER_PAGE"
|
||||
backend-sorting
|
||||
:default-sort-direction="'desc'"
|
||||
:default-sort="['insertedAt', 'desc']"
|
||||
@page-change="(newPage: number) => (page = newPage)"
|
||||
@sort="(field: string, order: string) => emit('sort', field, order)"
|
||||
>
|
||||
<o-table-column
|
||||
field="actor.preferredUsername"
|
||||
:label="t('Participant')"
|
||||
v-slot="props"
|
||||
>
|
||||
<article class="flex gap-2">
|
||||
<figure v-if="props.row.actor.avatar">
|
||||
<img
|
||||
class="rounded-full w-12 h-12 object-cover"
|
||||
:src="props.row.actor.avatar.url"
|
||||
alt=""
|
||||
height="48"
|
||||
width="48"
|
||||
/>
|
||||
</figure>
|
||||
<Incognito
|
||||
v-else-if="props.row.actor.preferredUsername === 'anonymous'"
|
||||
:size="48"
|
||||
/>
|
||||
<AccountCircle v-else :size="48" />
|
||||
<div>
|
||||
<div class="prose dark:prose-invert">
|
||||
<p v-if="props.row.actor.preferredUsername !== 'anonymous'">
|
||||
<span v-if="props.row.actor.name">{{
|
||||
props.row.actor.name
|
||||
}}</span
|
||||
><br />
|
||||
<span class="text-sm"
|
||||
>@{{ usernameWithDomain(props.row.actor) }}</span
|
||||
>
|
||||
</p>
|
||||
<span v-else>
|
||||
{{ t("Anonymous participant") }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</o-table-column>
|
||||
<o-table-column field="role" :label="t('Role')" v-slot="props">
|
||||
<tag
|
||||
variant="primary"
|
||||
v-if="props.row.role === ParticipantRole.CREATOR"
|
||||
>
|
||||
{{ t("Organizer") }}
|
||||
</tag>
|
||||
<tag v-else-if="props.row.role === ParticipantRole.PARTICIPANT">
|
||||
{{ t("Participant") }}
|
||||
</tag>
|
||||
<tag v-else-if="props.row.role === ParticipantRole.NOT_CONFIRMED">
|
||||
{{ t("Not confirmed") }}
|
||||
</tag>
|
||||
<tag
|
||||
variant="warning"
|
||||
v-else-if="props.row.role === ParticipantRole.NOT_APPROVED"
|
||||
>
|
||||
{{ t("Not approved") }}
|
||||
</tag>
|
||||
<tag
|
||||
variant="danger"
|
||||
v-else-if="props.row.role === ParticipantRole.REJECTED"
|
||||
>
|
||||
{{ t("Rejected") }}
|
||||
</tag>
|
||||
</o-table-column>
|
||||
<o-table-column
|
||||
field="metadata.message"
|
||||
class="column-message"
|
||||
:label="t('Message')"
|
||||
v-slot="props"
|
||||
>
|
||||
<div
|
||||
@click="toggleQueueDetails(props.row)"
|
||||
:class="{
|
||||
'ellipsed-message':
|
||||
props.row.metadata.message.length > MESSAGE_ELLIPSIS_LENGTH,
|
||||
}"
|
||||
v-if="props.row.metadata && props.row.metadata.message"
|
||||
>
|
||||
<p v-if="props.row.metadata.message.length > MESSAGE_ELLIPSIS_LENGTH">
|
||||
{{ ellipsize(props.row.metadata.message) }}
|
||||
</p>
|
||||
<p v-else>
|
||||
{{ props.row.metadata.message }}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="button is-text"
|
||||
v-if="props.row.metadata.message.length > MESSAGE_ELLIPSIS_LENGTH"
|
||||
@click.stop="toggleQueueDetails(props.row)"
|
||||
>
|
||||
{{
|
||||
openDetailedRows[props.row.id] ? t("View less") : t("View more")
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
<p v-else class="has-text-grey-dark">
|
||||
{{ t("No message") }}
|
||||
</p>
|
||||
</o-table-column>
|
||||
<o-table-column field="insertedAt" :label="t('Date')" v-slot="props">
|
||||
<span class="text-center">
|
||||
{{ formatDateString(props.row.insertedAt) }}<br />{{
|
||||
formatTimeString(props.row.insertedAt)
|
||||
}}
|
||||
</span>
|
||||
</o-table-column>
|
||||
<template #detail="props">
|
||||
<article v-html="nl2br(props.row.metadata.message)" />
|
||||
</template>
|
||||
<template #empty>
|
||||
<EmptyContent icon="account-circle" :inline="true">
|
||||
{{ t("No participant matches the filters") }}
|
||||
</EmptyContent>
|
||||
</template>
|
||||
<template #bottom-left>
|
||||
<div class="flex gap-2">
|
||||
<o-button
|
||||
@click="acceptParticipants(checkedRows)"
|
||||
variant="success"
|
||||
:disabled="!canAcceptParticipants"
|
||||
>
|
||||
{{
|
||||
t(
|
||||
"No participant to approve|Approve participant|Approve {number} participants",
|
||||
{ number: checkedRows.length },
|
||||
checkedRows.length
|
||||
)
|
||||
}}
|
||||
</o-button>
|
||||
<o-button
|
||||
@click="refuseParticipants(checkedRows)"
|
||||
variant="danger"
|
||||
:disabled="!canRefuseParticipants"
|
||||
>
|
||||
{{
|
||||
t(
|
||||
"No participant to reject|Reject participant|Reject {number} participants",
|
||||
{ number: checkedRows.length },
|
||||
checkedRows.length
|
||||
)
|
||||
}}
|
||||
</o-button>
|
||||
</div>
|
||||
</template>
|
||||
</o-table>
|
||||
<EventConversations :event="event" class="my-6" />
|
||||
<NewPrivateMessage :event="event" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ParticipantRole } from "@/types/enums";
|
||||
import { IParticipant } from "@/types/participant.model";
|
||||
import { IEvent } from "@/types/event.model";
|
||||
import {
|
||||
EXPORT_EVENT_PARTICIPATIONS,
|
||||
PARTICIPANTS,
|
||||
UPDATE_PARTICIPANT,
|
||||
} from "@/graphql/event";
|
||||
import { usernameWithDomain } from "@/types/actor";
|
||||
import { nl2br } from "@/utils/html";
|
||||
import { asyncForEach } from "@/utils/asyncForEach";
|
||||
import RouteName from "@/router/name";
|
||||
import { useCurrentActorClient } from "@/composition/apollo/actor";
|
||||
import { useParticipantsExportFormats } from "@/composition/config";
|
||||
import { useMutation, useQuery } from "@vue/apollo-composable";
|
||||
import {
|
||||
integerTransformer,
|
||||
enumTransformer,
|
||||
useRouteQuery,
|
||||
} from "vue-use-route-query";
|
||||
import { computed, inject, ref } from "vue";
|
||||
import { formatDateString, formatTimeString } from "@/filters/datetime";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import AccountCircle from "vue-material-design-icons/AccountCircle.vue";
|
||||
import Incognito from "vue-material-design-icons/Incognito.vue";
|
||||
import EmptyContent from "@/components/Utils/EmptyContent.vue";
|
||||
import { Notifier } from "@/plugins/notifier";
|
||||
import Tag from "@/components/TagElement.vue";
|
||||
import { useHead } from "@vueuse/head";
|
||||
import EventConversations from "../../components/Conversations/EventConversations.vue";
|
||||
import NewPrivateMessage from "../../components/Participation/NewPrivateMessage.vue";
|
||||
|
||||
const PARTICIPANTS_PER_PAGE = 10;
|
||||
const MESSAGE_ELLIPSIS_LENGTH = 130;
|
||||
|
||||
type exportFormat = "CSV" | "PDF" | "ODS";
|
||||
|
||||
const props = defineProps<{
|
||||
eventId: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(["sort"]);
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
const { currentActor } = useCurrentActorClient();
|
||||
const participantsExportFormats = useParticipantsExportFormats();
|
||||
|
||||
const ellipsize = (text?: string) =>
|
||||
text && text.substring(0, MESSAGE_ELLIPSIS_LENGTH).concat("…");
|
||||
|
||||
const eventId = computed(() => props.eventId);
|
||||
|
||||
const ParticipantAllRoles = { ...ParticipantRole, EVERYTHING: "EVERYTHING" };
|
||||
|
||||
const page = useRouteQuery("page", 1, integerTransformer);
|
||||
const role = useRouteQuery(
|
||||
"role",
|
||||
"EVERYTHING",
|
||||
enumTransformer(ParticipantAllRoles)
|
||||
);
|
||||
|
||||
const checkedRows = ref<IParticipant[]>([]);
|
||||
|
||||
const queueTable = ref();
|
||||
|
||||
const { result: participantsResult, loading: participantsLoading } = useQuery<{
|
||||
event: IEvent;
|
||||
}>(
|
||||
PARTICIPANTS,
|
||||
() => ({
|
||||
uuid: eventId.value,
|
||||
page: page.value,
|
||||
limit: PARTICIPANTS_PER_PAGE,
|
||||
roles: role.value === "EVERYTHING" ? undefined : role.value,
|
||||
}),
|
||||
() => ({
|
||||
enabled:
|
||||
currentActor.value?.id !== undefined &&
|
||||
page.value !== undefined &&
|
||||
role.value !== undefined,
|
||||
})
|
||||
);
|
||||
|
||||
const event = computed(() => participantsResult.value?.event);
|
||||
|
||||
// const participantStats = computed((): IEventParticipantStats | null => {
|
||||
// if (!event.value) return null;
|
||||
// return event.value.participantStats;
|
||||
// });
|
||||
|
||||
const { mutate: updateParticipant, onError: onUpdateParticipantError } =
|
||||
useMutation(UPDATE_PARTICIPANT);
|
||||
|
||||
onUpdateParticipantError((e) => console.error(e));
|
||||
|
||||
const acceptParticipants = async (
|
||||
participants: IParticipant[]
|
||||
): Promise<void> => {
|
||||
await asyncForEach(participants, async (participant: IParticipant) => {
|
||||
await updateParticipant({
|
||||
id: participant.id,
|
||||
role: ParticipantRole.PARTICIPANT,
|
||||
});
|
||||
});
|
||||
checkedRows.value = [];
|
||||
};
|
||||
|
||||
const refuseParticipants = async (
|
||||
participants: IParticipant[]
|
||||
): Promise<void> => {
|
||||
await asyncForEach(participants, async (participant: IParticipant) => {
|
||||
await updateParticipant({
|
||||
id: participant.id,
|
||||
role: ParticipantRole.REJECTED,
|
||||
});
|
||||
});
|
||||
checkedRows.value = [];
|
||||
};
|
||||
|
||||
const {
|
||||
mutate: exportParticipants,
|
||||
onDone: onExportParticipantsMutationDone,
|
||||
onError: onExportParticipantsMutationError,
|
||||
} = useMutation<
|
||||
{ exportEventParticipants: { path: string; format: string } },
|
||||
{ eventId: string; format?: exportFormat; roles?: string[] }
|
||||
>(EXPORT_EVENT_PARTICIPATIONS);
|
||||
|
||||
onExportParticipantsMutationDone(({ data }) => {
|
||||
const path = data?.exportEventParticipants?.path;
|
||||
const format = data?.exportEventParticipants?.format;
|
||||
const link = window.origin + "/exports/" + format?.toLowerCase() + "/" + path;
|
||||
console.debug(link);
|
||||
const a = document.createElement("a");
|
||||
a.style.display = "none";
|
||||
document.body.appendChild(a);
|
||||
a.href = link;
|
||||
a.setAttribute("download", "true");
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(a.href);
|
||||
document.body.removeChild(a);
|
||||
});
|
||||
|
||||
const notifier = inject<Notifier>("notifier");
|
||||
|
||||
onExportParticipantsMutationError((e) => {
|
||||
console.error(e);
|
||||
if (e.graphQLErrors && e.graphQLErrors.length > 0) {
|
||||
notifier?.error(e.graphQLErrors[0].message);
|
||||
}
|
||||
});
|
||||
|
||||
const exportFormats = computed((): exportFormat[] => {
|
||||
return (participantsExportFormats ?? []).map(
|
||||
(key) => key.toUpperCase() as exportFormat
|
||||
);
|
||||
});
|
||||
|
||||
const formatToIcon = (format: exportFormat): string => {
|
||||
switch (format) {
|
||||
case "CSV":
|
||||
return "file-delimited";
|
||||
case "PDF":
|
||||
return "file-pdf-box";
|
||||
case "ODS":
|
||||
return "google-spreadsheet";
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* We can accept participants if at least one of them is not approved
|
||||
*/
|
||||
const canAcceptParticipants = (): boolean => {
|
||||
return checkedRows.value.some((participant: IParticipant) =>
|
||||
[ParticipantRole.NOT_APPROVED, ParticipantRole.REJECTED].includes(
|
||||
participant.role
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* We can refuse participants if at least one of them is something different than not approved
|
||||
*/
|
||||
const canRefuseParticipants = (): boolean => {
|
||||
return checkedRows.value.some(
|
||||
(participant: IParticipant) => participant.role !== ParticipantRole.REJECTED
|
||||
);
|
||||
};
|
||||
|
||||
const toggleQueueDetails = (row: IParticipant): void => {
|
||||
if (
|
||||
row.metadata.message &&
|
||||
row.metadata.message.length < MESSAGE_ELLIPSIS_LENGTH
|
||||
)
|
||||
return;
|
||||
queueTable.value.toggleDetails(row);
|
||||
if (row.id) {
|
||||
openDetailedRows.value[row.id] = !openDetailedRows.value[row.id];
|
||||
}
|
||||
};
|
||||
|
||||
const openDetailedRows = ref<Record<string, boolean>>({});
|
||||
|
||||
useHead({
|
||||
title: computed(() =>
|
||||
t("Participants to {eventTitle}", { eventTitle: event.value?.title })
|
||||
),
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style lang="scss" scoped>
|
||||
section.container.container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.table {
|
||||
.column-message {
|
||||
vertical-align: middle;
|
||||
}
|
||||
.ellipsed-message {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
p {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
button {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nav.breadcrumb {
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user