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:
14
src/components/Event/DateCalendarIcon.story.vue
Normal file
14
src/components/Event/DateCalendarIcon.story.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<template>
|
||||
<Story>
|
||||
<Variant title="new">
|
||||
<DateCalendarIcon :date="new Date().toString()" />
|
||||
</Variant>
|
||||
<Variant title="small">
|
||||
<DateCalendarIcon :date="new Date().toString()" :small="true" />
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import DateCalendarIcon from "./DateCalendarIcon.vue";
|
||||
</script>
|
||||
70
src/components/Event/DateCalendarIcon.vue
Normal file
70
src/components/Event/DateCalendarIcon.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<div
|
||||
class="datetime-container flex flex-col rounded-lg text-center justify-center overflow-hidden items-stretch bg-white dark:bg-gray-700 text-violet-3 dark:text-white"
|
||||
:class="{ small }"
|
||||
:style="`--small: ${smallStyle}`"
|
||||
>
|
||||
<div class="datetime-container-header" />
|
||||
<div class="datetime-container-content">
|
||||
<time :datetime="dateObj.toISOString()" class="day block font-semibold">{{
|
||||
day
|
||||
}}</time>
|
||||
<time
|
||||
:datetime="dateObj.toISOString()"
|
||||
class="month font-semibold block uppercase py-1 px-0"
|
||||
>{{ month }}</time
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
date: string;
|
||||
small?: boolean;
|
||||
}>(),
|
||||
{ small: false }
|
||||
);
|
||||
|
||||
const dateObj = computed<Date>(() => new Date(props.date));
|
||||
|
||||
const month = computed<string>(() =>
|
||||
dateObj.value.toLocaleString(undefined, { month: "short" })
|
||||
);
|
||||
|
||||
const day = computed<string>(() =>
|
||||
dateObj.value.toLocaleString(undefined, { day: "numeric" })
|
||||
);
|
||||
|
||||
const smallStyle = computed<string>(() => (props.small ? "1.2" : "2"));
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
div.datetime-container {
|
||||
width: calc(40px * var(--small));
|
||||
box-shadow: 0 0 12px rgba(0, 0, 0, 0.2);
|
||||
height: calc(40px * var(--small));
|
||||
|
||||
.datetime-container-header {
|
||||
height: calc(10px * var(--small));
|
||||
background: #f3425f;
|
||||
}
|
||||
.datetime-container-content {
|
||||
height: calc(30px * var(--small));
|
||||
}
|
||||
|
||||
time {
|
||||
&.month {
|
||||
font-size: 12px;
|
||||
line-height: 12px;
|
||||
}
|
||||
|
||||
&.day {
|
||||
font-size: calc(1rem * var(--small));
|
||||
line-height: calc(1rem * var(--small));
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
898
src/components/Event/EventActionSection.vue
Normal file
898
src/components/Event/EventActionSection.vue
Normal file
@@ -0,0 +1,898 @@
|
||||
<template>
|
||||
<div class="">
|
||||
<external-participation-button
|
||||
v-if="event && event.joinOptions === EventJoinOptions.EXTERNAL"
|
||||
:event="event"
|
||||
:current-actor="currentActor"
|
||||
/>
|
||||
|
||||
<participation-section
|
||||
v-else-if="event && anonymousParticipationConfig"
|
||||
:participation="participations[0]"
|
||||
:event="event"
|
||||
:anonymousParticipation="anonymousParticipation"
|
||||
:currentActor="currentActor"
|
||||
:identities="identities"
|
||||
:anonymousParticipationConfig="anonymousParticipationConfig"
|
||||
@join-event="joinEvent"
|
||||
@join-modal="isJoinModalActive = true"
|
||||
@join-event-with-confirmation="joinEventWithConfirmation"
|
||||
@confirm-leave="confirmLeave"
|
||||
@cancel-anonymous-participation="cancelAnonymousParticipation"
|
||||
/>
|
||||
<div class="flex flex-col gap-1 mt-1">
|
||||
<p
|
||||
class="inline-flex gap-2 ml-auto"
|
||||
v-if="event.joinOptions !== EventJoinOptions.EXTERNAL"
|
||||
>
|
||||
<TicketConfirmationOutline />
|
||||
<router-link
|
||||
class="participations-link"
|
||||
v-if="canManageEvent && event?.draft === false"
|
||||
:to="{
|
||||
name: RouteName.PARTICIPATIONS,
|
||||
params: { eventId: event.uuid },
|
||||
}"
|
||||
>
|
||||
<!-- We retire one because of the event creator who is a
|
||||
participant -->
|
||||
<span v-if="maximumAttendeeCapacity">
|
||||
{{
|
||||
t(
|
||||
"{available}/{capacity} available places",
|
||||
{
|
||||
available:
|
||||
maximumAttendeeCapacity -
|
||||
event.participantStats.participant,
|
||||
capacity: maximumAttendeeCapacity,
|
||||
},
|
||||
maximumAttendeeCapacity - event.participantStats.participant
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{
|
||||
t(
|
||||
"No one is participating|One person participating|{going} people participating",
|
||||
{
|
||||
going: event.participantStats.participant,
|
||||
},
|
||||
event.participantStats.participant
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</router-link>
|
||||
<span v-else>
|
||||
<span v-if="maximumAttendeeCapacity">
|
||||
{{
|
||||
t(
|
||||
"{available}/{capacity} available places",
|
||||
{
|
||||
available:
|
||||
maximumAttendeeCapacity -
|
||||
(event?.participantStats.participant ?? 0),
|
||||
capacity: maximumAttendeeCapacity,
|
||||
},
|
||||
maximumAttendeeCapacity -
|
||||
(event?.participantStats.participant ?? 0)
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{
|
||||
t(
|
||||
"No one is participating|One person participating|{going} people participating",
|
||||
{
|
||||
going: event?.participantStats.participant,
|
||||
},
|
||||
event?.participantStats.participant ?? 0
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</span>
|
||||
<VTooltip v-if="event?.local === false">
|
||||
<HelpCircleOutline :size="16" />
|
||||
<template #popper>
|
||||
{{
|
||||
t(
|
||||
"The actual number of participants may differ, as this event is hosted on another instance."
|
||||
)
|
||||
}}
|
||||
</template>
|
||||
</VTooltip>
|
||||
</p>
|
||||
<o-dropdown class="ml-auto">
|
||||
<template #trigger>
|
||||
<o-button icon-right="dots-horizontal">
|
||||
{{ t("Actions") }}
|
||||
</o-button>
|
||||
</template>
|
||||
<o-dropdown-item
|
||||
aria-role="listitem"
|
||||
has-link
|
||||
v-if="canManageEvent || event?.draft"
|
||||
>
|
||||
<router-link
|
||||
class="flex gap-1"
|
||||
:to="{
|
||||
name: RouteName.EDIT_EVENT,
|
||||
params: { eventId: event?.uuid },
|
||||
}"
|
||||
>
|
||||
<Pencil />
|
||||
{{ t("Edit") }}
|
||||
</router-link>
|
||||
</o-dropdown-item>
|
||||
<o-dropdown-item
|
||||
aria-role="listitem"
|
||||
has-link
|
||||
v-if="canManageEvent || event?.draft"
|
||||
>
|
||||
<router-link
|
||||
class="flex gap-1"
|
||||
:to="{
|
||||
name: RouteName.DUPLICATE_EVENT,
|
||||
params: { eventId: event?.uuid },
|
||||
}"
|
||||
>
|
||||
<ContentDuplicate />
|
||||
{{ t("Duplicate") }}
|
||||
</router-link>
|
||||
</o-dropdown-item>
|
||||
<o-dropdown-item
|
||||
aria-role="listitem"
|
||||
v-if="canManageEvent || event?.draft"
|
||||
@click="openDeleteEventModal"
|
||||
@keyup.enter="openDeleteEventModal"
|
||||
><span class="flex gap-1">
|
||||
<Delete />
|
||||
{{ t("Delete") }}
|
||||
</span>
|
||||
</o-dropdown-item>
|
||||
|
||||
<hr
|
||||
role="presentation"
|
||||
class="dropdown-divider"
|
||||
aria-role="o-dropdown-item"
|
||||
v-if="canManageEvent || event?.draft"
|
||||
/>
|
||||
<o-dropdown-item
|
||||
aria-role="listitem"
|
||||
v-if="event?.draft === false"
|
||||
@click="triggerShare()"
|
||||
@keyup.enter="triggerShare()"
|
||||
class="p-1"
|
||||
>
|
||||
<span class="flex gap-1">
|
||||
<Share />
|
||||
{{ t("Share this event") }}
|
||||
</span>
|
||||
</o-dropdown-item>
|
||||
<o-dropdown-item
|
||||
aria-role="listitem"
|
||||
@click="downloadIcsEvent()"
|
||||
@keyup.enter="downloadIcsEvent()"
|
||||
v-if="event?.draft === false"
|
||||
>
|
||||
<span class="flex gap-1">
|
||||
<CalendarPlus />
|
||||
{{ t("Add to my calendar") }}
|
||||
</span>
|
||||
</o-dropdown-item>
|
||||
<o-dropdown-item
|
||||
aria-role="listitem"
|
||||
v-if="ableToReport"
|
||||
@click="isReportModalActive = true"
|
||||
@keyup.enter="isReportModalActive = true"
|
||||
class="p-1"
|
||||
>
|
||||
<span class="flex gap-1">
|
||||
<Flag />
|
||||
{{ t("Report") }}
|
||||
</span>
|
||||
</o-dropdown-item>
|
||||
</o-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
<o-modal
|
||||
v-model:active="isReportModalActive"
|
||||
has-modal-card
|
||||
ref="reportModal"
|
||||
:close-button-aria-label="t('Close')"
|
||||
:autoFocus="false"
|
||||
:trapFocus="false"
|
||||
>
|
||||
<ReportModal
|
||||
:on-confirm="reportEvent"
|
||||
:title="t('Report this event')"
|
||||
:outside-domain="organizerDomain"
|
||||
/>
|
||||
</o-modal>
|
||||
<o-modal
|
||||
:close-button-aria-label="t('Close')"
|
||||
v-model:active="isShareModalActive"
|
||||
has-modal-card
|
||||
ref="shareModal"
|
||||
>
|
||||
<share-event-modal
|
||||
v-if="event"
|
||||
:event="event"
|
||||
:eventCapacityOK="eventCapacityOK"
|
||||
/>
|
||||
</o-modal>
|
||||
<o-modal
|
||||
v-model:active="isJoinModalActive"
|
||||
has-modal-card
|
||||
ref="participationModal"
|
||||
:close-button-aria-label="t('Close')"
|
||||
>
|
||||
<identity-picker v-if="identity" v-model="identity">
|
||||
<template #footer>
|
||||
<footer class="flex gap-2">
|
||||
<o-button
|
||||
outlined
|
||||
ref="cancelButton"
|
||||
@click="isJoinModalActive = false"
|
||||
@keyup.enter="isJoinModalActive = false"
|
||||
>
|
||||
{{ t("Cancel") }}
|
||||
</o-button>
|
||||
<o-button
|
||||
v-if="identity"
|
||||
variant="primary"
|
||||
ref="confirmButton"
|
||||
@click="
|
||||
event?.joinOptions === EventJoinOptions.RESTRICTED
|
||||
? joinEventWithConfirmation(identity as IPerson)
|
||||
: joinEvent(identity as IPerson)
|
||||
"
|
||||
@keyup.enter="
|
||||
event?.joinOptions === EventJoinOptions.RESTRICTED
|
||||
? joinEventWithConfirmation(identity as IPerson)
|
||||
: joinEvent(identity as IPerson)
|
||||
"
|
||||
>
|
||||
{{ t("Confirm my particpation") }}
|
||||
</o-button>
|
||||
</footer>
|
||||
</template>
|
||||
</identity-picker>
|
||||
</o-modal>
|
||||
<o-modal
|
||||
v-model:active="isJoinConfirmationModalActive"
|
||||
has-modal-card
|
||||
ref="joinConfirmationModal"
|
||||
:close-button-aria-label="t('Close')"
|
||||
>
|
||||
<div class="modal-card">
|
||||
<header class="modal-card-head">
|
||||
<p class="modal-card-title">
|
||||
{{ t("Participation confirmation") }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section class="modal-card-body">
|
||||
<p>
|
||||
{{
|
||||
t(
|
||||
"The event organiser has chosen to validate manually participations. Do you want to add a little note to explain why you want to participate to this event?"
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<form
|
||||
@submit.prevent="
|
||||
joinEvent(actorForConfirmation as IPerson, messageForConfirmation)
|
||||
"
|
||||
>
|
||||
<o-field :label="t('Message')">
|
||||
<o-input
|
||||
type="textarea"
|
||||
size="medium"
|
||||
v-model="messageForConfirmation"
|
||||
minlength="10"
|
||||
></o-input>
|
||||
</o-field>
|
||||
<div class="buttons">
|
||||
<o-button
|
||||
native-type="button"
|
||||
class="button"
|
||||
ref="cancelButton"
|
||||
@click="isJoinConfirmationModalActive = false"
|
||||
@keyup.enter="isJoinConfirmationModalActive = false"
|
||||
>{{ t("Cancel") }}
|
||||
</o-button>
|
||||
<o-button variant="primary" native-type="submit">
|
||||
{{ t("Confirm my participation") }}
|
||||
</o-button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</o-modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { IActor, IPerson } from "@/types/actor";
|
||||
import { IEvent } from "@/types/event.model";
|
||||
import ParticipationSection from "@/components/Participation/ParticipationSection.vue";
|
||||
import ReportModal from "@/components/Report/ReportModal.vue";
|
||||
import IdentityPicker from "@/views/Account/IdentityPicker.vue";
|
||||
import { EventJoinOptions, ParticipantRole, MemberRole } from "@/types/enums";
|
||||
import { GRAPHQL_API_ENDPOINT } from "@/api/_entrypoint";
|
||||
import { computed, defineAsyncComponent, inject, onMounted, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import Flag from "vue-material-design-icons/Flag.vue";
|
||||
import CalendarPlus from "vue-material-design-icons/CalendarPlus.vue";
|
||||
import ContentDuplicate from "vue-material-design-icons/ContentDuplicate.vue";
|
||||
import Delete from "vue-material-design-icons/Delete.vue";
|
||||
import Pencil from "vue-material-design-icons/Pencil.vue";
|
||||
import HelpCircleOutline from "vue-material-design-icons/HelpCircleOutline.vue";
|
||||
import TicketConfirmationOutline from "vue-material-design-icons/TicketConfirmationOutline.vue";
|
||||
import Share from "vue-material-design-icons/Share.vue";
|
||||
import {
|
||||
EVENT_PERSON_PARTICIPATION,
|
||||
FETCH_EVENT,
|
||||
JOIN_EVENT,
|
||||
LEAVE_EVENT,
|
||||
} from "@/graphql/event";
|
||||
import { Notifier } from "@/plugins/notifier";
|
||||
import { Dialog } from "@/plugins/dialog";
|
||||
import { Snackbar } from "@/plugins/snackbar";
|
||||
import RouteName from "@/router/name";
|
||||
import {
|
||||
AnonymousParticipationNotFoundError,
|
||||
getLeaveTokenForParticipation,
|
||||
isParticipatingInThisEvent,
|
||||
removeAnonymousParticipation,
|
||||
} from "@/services/AnonymousParticipationStorage";
|
||||
import {
|
||||
useAnonymousActorId,
|
||||
useAnonymousParticipationConfig,
|
||||
useAnonymousReportsConfig,
|
||||
} from "@/composition/apollo/config";
|
||||
import { useCurrentUserIdentities } from "@/composition/apollo/actor";
|
||||
import { useRouter } from "vue-router";
|
||||
import { IParticipant } from "@/types/participant.model";
|
||||
import { ApolloCache, FetchResult } from "@apollo/client/core";
|
||||
import { useMutation } from "@vue/apollo-composable";
|
||||
import { useCreateReport } from "@/composition/apollo/report";
|
||||
import { useDeleteEvent } from "@/composition/apollo/event";
|
||||
import { useProgrammatic } from "@oruga-ui/oruga-next";
|
||||
import ExternalParticipationButton from "./ExternalParticipationButton.vue";
|
||||
|
||||
const ShareEventModal = defineAsyncComponent(
|
||||
() => import("@/components/Event/ShareEventModal.vue")
|
||||
);
|
||||
|
||||
const props = defineProps<{
|
||||
event: IEvent;
|
||||
currentActor: IPerson | undefined;
|
||||
participations: IParticipant[];
|
||||
person: IPerson | undefined;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
const notifier = inject<Notifier>("notifier");
|
||||
const dialog = inject<Dialog>("dialog");
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const { anonymousReportsConfig } = useAnonymousReportsConfig();
|
||||
const { anonymousActorId } = useAnonymousActorId();
|
||||
const { anonymousParticipationConfig } = useAnonymousParticipationConfig();
|
||||
const { identities } = useCurrentUserIdentities();
|
||||
|
||||
const event = computed(() => props.event);
|
||||
|
||||
const identity = ref<IPerson | undefined | null>(null);
|
||||
|
||||
const ableToReport = computed((): boolean => {
|
||||
return (
|
||||
props.currentActor?.id != null ||
|
||||
anonymousReportsConfig.value?.allowed === true
|
||||
);
|
||||
});
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
const reportModal = ref();
|
||||
const isReportModalActive = ref(false);
|
||||
const isShareModalActive = ref(false);
|
||||
const isJoinModalActive = ref(false);
|
||||
const isJoinConfirmationModalActive = ref(false);
|
||||
|
||||
const actorForConfirmation = ref<IPerson | null>(null);
|
||||
const messageForConfirmation = ref("");
|
||||
|
||||
const anonymousParticipation = ref<boolean | null>(null);
|
||||
|
||||
const downloadIcsEvent = async (): Promise<void> => {
|
||||
const data = await (
|
||||
await fetch(`${GRAPHQL_API_ENDPOINT}/events/${event.value.uuid}/export/ics`)
|
||||
).text();
|
||||
const blob = new Blob([data], { type: "text/calendar" });
|
||||
const link = document.createElement("a");
|
||||
link.href = window.URL.createObjectURL(blob);
|
||||
link.download = `${event.value?.title}.ics`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
const triggerShare = (): void => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore-start
|
||||
if (navigator.share) {
|
||||
navigator
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
.share({
|
||||
title: event.value?.title,
|
||||
url: event.value?.url,
|
||||
})
|
||||
.then(() => console.debug("Successful share"))
|
||||
.catch((error: any) => console.debug("Error sharing", error));
|
||||
} else {
|
||||
isShareModalActive.value = true;
|
||||
// send popup
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore-end
|
||||
};
|
||||
|
||||
const canManageEvent = computed((): boolean => {
|
||||
return actorIsOrganizer.value || hasGroupPrivileges.value;
|
||||
});
|
||||
|
||||
// const actorIsParticipant = computed((): boolean => {
|
||||
// if (actorIsOrganizer.value) return true;
|
||||
|
||||
// return (
|
||||
// participations.value.length > 0 &&
|
||||
// participations.value[0].role === ParticipantRole.PARTICIPANT
|
||||
// );
|
||||
// });
|
||||
|
||||
const actorIsOrganizer = computed((): boolean => {
|
||||
return (
|
||||
props.participations.length > 0 &&
|
||||
props.participations[0].role === ParticipantRole.CREATOR
|
||||
);
|
||||
});
|
||||
|
||||
const hasGroupPrivileges = computed((): boolean => {
|
||||
return (
|
||||
props.person?.memberships !== undefined &&
|
||||
props.person?.memberships?.total > 0 &&
|
||||
[MemberRole.MODERATOR, MemberRole.ADMINISTRATOR].includes(
|
||||
props.person?.memberships?.elements[0].role
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
const joinEventWithConfirmation = (actor: IPerson): void => {
|
||||
isJoinConfirmationModalActive.value = true;
|
||||
actorForConfirmation.value = actor;
|
||||
};
|
||||
|
||||
const {
|
||||
mutate: joinEventMutation,
|
||||
onDone: onJoinEventMutationDone,
|
||||
onError: onJoinEventMutationError,
|
||||
} = useMutation<{
|
||||
joinEvent: IParticipant;
|
||||
}>(JOIN_EVENT, () => ({
|
||||
update: (
|
||||
store: ApolloCache<{
|
||||
joinEvent: IParticipant;
|
||||
}>,
|
||||
{ data }: FetchResult
|
||||
) => {
|
||||
if (data == null) return;
|
||||
|
||||
const participationCachedData = store.readQuery<{ person: IPerson }>({
|
||||
query: EVENT_PERSON_PARTICIPATION,
|
||||
variables: { eventId: event.value?.id, actorId: identity.value?.id },
|
||||
});
|
||||
|
||||
if (participationCachedData?.person == undefined) {
|
||||
console.error(
|
||||
"Cannot update participation cache, because of null value."
|
||||
);
|
||||
return;
|
||||
}
|
||||
store.writeQuery({
|
||||
query: EVENT_PERSON_PARTICIPATION,
|
||||
variables: { eventId: event.value?.id, actorId: identity.value?.id },
|
||||
data: {
|
||||
person: {
|
||||
...participationCachedData?.person,
|
||||
participations: {
|
||||
elements: [data.joinEvent],
|
||||
total: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const cachedData = store.readQuery<{ event: IEvent }>({
|
||||
query: FETCH_EVENT,
|
||||
variables: { uuid: event.value?.uuid },
|
||||
});
|
||||
if (cachedData == null) return;
|
||||
const { event: cachedEvent } = cachedData;
|
||||
if (cachedEvent === null) {
|
||||
console.error(
|
||||
"Cannot update event participant cache, because of null value."
|
||||
);
|
||||
return;
|
||||
}
|
||||
const participantStats = { ...cachedEvent.participantStats };
|
||||
|
||||
if (data.joinEvent.role === ParticipantRole.NOT_APPROVED) {
|
||||
participantStats.notApproved += 1;
|
||||
} else {
|
||||
participantStats.going += 1;
|
||||
participantStats.participant += 1;
|
||||
}
|
||||
|
||||
store.writeQuery({
|
||||
query: FETCH_EVENT,
|
||||
variables: { uuid: props.event.uuid },
|
||||
data: {
|
||||
event: {
|
||||
...cachedEvent,
|
||||
participantStats,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
const joinEvent = (
|
||||
identityForJoin: IPerson,
|
||||
message: string | null = null
|
||||
): void => {
|
||||
isJoinConfirmationModalActive.value = false;
|
||||
isJoinModalActive.value = false;
|
||||
joinEventMutation({
|
||||
eventId: event.value?.id,
|
||||
actorId: identityForJoin?.id,
|
||||
message,
|
||||
});
|
||||
};
|
||||
|
||||
const participationRequestedMessage = () => {
|
||||
notifier?.success(t("Your participation has been requested"));
|
||||
};
|
||||
|
||||
const participationConfirmedMessage = () => {
|
||||
notifier?.success(t("Your participation has been confirmed"));
|
||||
};
|
||||
|
||||
onJoinEventMutationDone(({ data }) => {
|
||||
if (data) {
|
||||
if (data.joinEvent.role === ParticipantRole.NOT_APPROVED) {
|
||||
participationRequestedMessage();
|
||||
} else {
|
||||
participationConfirmedMessage();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const { oruga } = useProgrammatic();
|
||||
|
||||
onJoinEventMutationError((error) => {
|
||||
if (error.message) {
|
||||
oruga.notification.open({
|
||||
message: error.message,
|
||||
variant: "danger",
|
||||
position: "bottom-right",
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
console.error(error);
|
||||
});
|
||||
|
||||
const confirmLeave = (): void => {
|
||||
dialog?.confirm({
|
||||
title: t('Leaving event "{title}"', {
|
||||
title: event.value?.title,
|
||||
}),
|
||||
message: t(
|
||||
'Are you sure you want to cancel your participation at event "{title}"?',
|
||||
{
|
||||
title: event.value?.title,
|
||||
}
|
||||
),
|
||||
confirmText: t("Leave event"),
|
||||
cancelText: t("Cancel"),
|
||||
variant: "danger",
|
||||
hasIcon: true,
|
||||
onConfirm: () => {
|
||||
if (event.value && props.currentActor?.id) {
|
||||
console.debug("calling leave event");
|
||||
leaveEvent(event.value, props.currentActor.id);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const {
|
||||
mutate: createReportMutation,
|
||||
onDone: onCreateReportDone,
|
||||
onError: onCreateReportError,
|
||||
} = useCreateReport();
|
||||
|
||||
onCreateReportDone(() => {
|
||||
notifier?.success(
|
||||
t("Event {eventTitle} reported", { eventTitle: props?.event?.title })
|
||||
);
|
||||
});
|
||||
|
||||
onCreateReportError((error) => {
|
||||
console.error(error);
|
||||
});
|
||||
|
||||
const reportEvent = async (
|
||||
content: string,
|
||||
forward: boolean
|
||||
): Promise<void> => {
|
||||
isReportModalActive.value = false;
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
reportModal.value.close();
|
||||
if (!organizer.value) return;
|
||||
|
||||
createReportMutation({
|
||||
eventsIds: [event.value?.id ?? ""],
|
||||
reportedId: organizer.value?.id ?? "",
|
||||
content,
|
||||
forward,
|
||||
});
|
||||
};
|
||||
|
||||
const maximumAttendeeCapacity = computed((): number | undefined => {
|
||||
return event.value?.options?.maximumAttendeeCapacity;
|
||||
});
|
||||
|
||||
const eventCapacityOK = computed((): boolean => {
|
||||
if (event.value?.draft) return true;
|
||||
if (!maximumAttendeeCapacity.value) return true;
|
||||
return (
|
||||
event.value?.options?.maximumAttendeeCapacity !== undefined &&
|
||||
event.value.participantStats.participant !== undefined &&
|
||||
event.value?.options?.maximumAttendeeCapacity >
|
||||
event.value.participantStats.participant
|
||||
);
|
||||
});
|
||||
|
||||
// const numberOfPlacesStillAvailable = computed((): number | undefined => {
|
||||
// if (event.value?.draft) return maximumAttendeeCapacity.value;
|
||||
// return (
|
||||
// (maximumAttendeeCapacity.value ?? 0) -
|
||||
// (event.value?.participantStats.participant ?? 0)
|
||||
// );
|
||||
// });
|
||||
|
||||
const {
|
||||
mutate: leaveEventMutation,
|
||||
onDone: onLeaveEventMutationDone,
|
||||
onError: onLeaveEventMutationError,
|
||||
} = useMutation<{ leaveEvent: { actor: { id: string } } }>(LEAVE_EVENT, () => ({
|
||||
update: (
|
||||
store: ApolloCache<{
|
||||
leaveEvent: IParticipant;
|
||||
}>,
|
||||
{ data }: FetchResult,
|
||||
{ context, variables }
|
||||
) => {
|
||||
if (data == null) return;
|
||||
let participation;
|
||||
|
||||
const token = context?.token;
|
||||
const actorId = variables?.actorId;
|
||||
const localEventId = variables?.eventId;
|
||||
const eventUUID = context?.eventUUID;
|
||||
const isAnonymousParticipationConfirmed =
|
||||
context?.isAnonymousParticipationConfirmed;
|
||||
|
||||
if (!token) {
|
||||
const participationCachedData = store.readQuery<{
|
||||
person: IPerson;
|
||||
}>({
|
||||
query: EVENT_PERSON_PARTICIPATION,
|
||||
variables: { eventId: localEventId, actorId },
|
||||
});
|
||||
if (participationCachedData == null) return;
|
||||
const { person: cachedPerson } = participationCachedData;
|
||||
[participation] = cachedPerson?.participations?.elements ?? [undefined];
|
||||
|
||||
store.modify({
|
||||
id: `Person:${actorId}`,
|
||||
fields: {
|
||||
participations() {
|
||||
return {
|
||||
elements: [],
|
||||
total: 0,
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const eventCachedData = store.readQuery<{ event: IEvent }>({
|
||||
query: FETCH_EVENT,
|
||||
variables: { uuid: eventUUID },
|
||||
});
|
||||
if (eventCachedData == null) return;
|
||||
const { event: eventCached } = eventCachedData;
|
||||
if (eventCached === null) {
|
||||
console.error("Cannot update event cache, because of null value.");
|
||||
return;
|
||||
}
|
||||
const participantStats = { ...eventCached.participantStats };
|
||||
if (participation && participation?.role === ParticipantRole.NOT_APPROVED) {
|
||||
participantStats.notApproved -= 1;
|
||||
} else if (isAnonymousParticipationConfirmed === false) {
|
||||
participantStats.notConfirmed -= 1;
|
||||
} else {
|
||||
participantStats.going -= 1;
|
||||
participantStats.participant -= 1;
|
||||
}
|
||||
store.writeQuery({
|
||||
query: FETCH_EVENT,
|
||||
variables: { uuid: eventUUID },
|
||||
data: {
|
||||
event: {
|
||||
...eventCached,
|
||||
participantStats,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
const leaveEvent = (
|
||||
eventToLeave: IEvent,
|
||||
actorId: string,
|
||||
token: string | null = null,
|
||||
isAnonymousParticipationConfirmed: boolean | null = null
|
||||
): void => {
|
||||
leaveEventMutation(
|
||||
{
|
||||
eventId: eventToLeave.id,
|
||||
actorId,
|
||||
token,
|
||||
},
|
||||
{
|
||||
context: {
|
||||
token,
|
||||
isAnonymousParticipationConfirmed,
|
||||
eventUUID: eventToLeave.uuid,
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
onLeaveEventMutationDone(({ data }) => {
|
||||
if (data) {
|
||||
notifier?.success(t("You have cancelled your participation"));
|
||||
}
|
||||
});
|
||||
|
||||
const snackbar = inject<Snackbar>("snackbar");
|
||||
|
||||
onLeaveEventMutationError((error) => {
|
||||
snackbar?.open({
|
||||
message: error.message,
|
||||
variant: "danger",
|
||||
position: "bottom",
|
||||
});
|
||||
console.error(error);
|
||||
});
|
||||
|
||||
const anonymousParticipationConfirmed = async (): Promise<boolean> => {
|
||||
return isParticipatingInThisEvent(props.event?.uuid);
|
||||
};
|
||||
|
||||
const cancelAnonymousParticipation = async (): Promise<void> => {
|
||||
if (!event.value || !anonymousActorId.value) return;
|
||||
const token = (await getLeaveTokenForParticipation(
|
||||
props.event?.uuid
|
||||
)) as string;
|
||||
leaveEvent(event.value, anonymousActorId.value, token);
|
||||
await removeAnonymousParticipation(props.event?.uuid);
|
||||
anonymousParticipation.value = null;
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
identity.value = props.currentActor;
|
||||
|
||||
try {
|
||||
if (window.isSecureContext) {
|
||||
anonymousParticipation.value = await anonymousParticipationConfirmed();
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof AnonymousParticipationNotFoundError) {
|
||||
anonymousParticipation.value = null;
|
||||
} else {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const {
|
||||
mutate: deleteEvent,
|
||||
onDone: onDeleteEventDone,
|
||||
onError: onDeleteEventError,
|
||||
} = useDeleteEvent();
|
||||
|
||||
const escapeRegExp = (string: string) => {
|
||||
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string
|
||||
};
|
||||
|
||||
const deleteEventMessage = computed(() => {
|
||||
const participantsLength = event.value?.participantStats.participant;
|
||||
const prefix = participantsLength
|
||||
? t(
|
||||
"There are {participants} participants.",
|
||||
{
|
||||
participants: event.value.participantStats.participant,
|
||||
},
|
||||
event.value.participantStats.participant
|
||||
)
|
||||
: "";
|
||||
return `${prefix}
|
||||
${t(
|
||||
"Are you sure you want to delete this event? This action cannot be reverted."
|
||||
)}
|
||||
<br><br>
|
||||
${t('To confirm, type your event title "{eventTitle}"', {
|
||||
eventTitle: event.value?.title,
|
||||
})}`;
|
||||
});
|
||||
|
||||
const openDeleteEventModal = () => {
|
||||
dialog?.prompt({
|
||||
title: t("Delete event"),
|
||||
message: deleteEventMessage.value,
|
||||
confirmText: t("Delete event"),
|
||||
cancelText: t("Cancel"),
|
||||
variant: "danger",
|
||||
hasIcon: true,
|
||||
hasInput: true,
|
||||
inputAttrs: {
|
||||
placeholder: event.value?.title,
|
||||
pattern: escapeRegExp(event.value?.title ?? ""),
|
||||
},
|
||||
onConfirm: (result: string) => {
|
||||
console.debug("calling delete event", result);
|
||||
if (result.trim() === event.value?.title) {
|
||||
event.value?.id ? deleteEvent({ eventId: event.value?.id }) : null;
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
onDeleteEventDone(() => {
|
||||
router.push({ name: RouteName.MY_EVENTS });
|
||||
});
|
||||
|
||||
onDeleteEventError((error) => {
|
||||
console.error(error);
|
||||
});
|
||||
</script>
|
||||
16
src/components/Event/EventBanner.vue
Normal file
16
src/components/Event/EventBanner.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<div class="flex justify-center max-h-80">
|
||||
<lazy-image-wrapper :picture="picture" />
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { IMedia } from "@/types/media.model";
|
||||
import LazyImageWrapper from "../Image/LazyImageWrapper.vue";
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
picture: IMedia | null;
|
||||
}>(),
|
||||
{ picture: null }
|
||||
);
|
||||
</script>
|
||||
148
src/components/Event/EventCard.story.vue
Normal file
148
src/components/Event/EventCard.story.vue
Normal file
@@ -0,0 +1,148 @@
|
||||
<template>
|
||||
<Story title="EventCard">
|
||||
<Variant title="default">
|
||||
<EventCard :event="event" />
|
||||
</Variant>
|
||||
<Variant title="long">
|
||||
<EventCard :event="longEvent" />
|
||||
</Variant>
|
||||
|
||||
<Variant title="tentative">
|
||||
<EventCard :event="tentativeEvent" />
|
||||
</Variant>
|
||||
|
||||
<Variant title="cancelled">
|
||||
<EventCard :event="cancelledEvent" />
|
||||
</Variant>
|
||||
<Variant title="Row mode">
|
||||
<EventCard :event="longEvent" mode="row" />
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { IActor } from "@/types/actor";
|
||||
import {
|
||||
ActorType,
|
||||
CommentModeration,
|
||||
EventJoinOptions,
|
||||
EventStatus,
|
||||
EventVisibility,
|
||||
} from "@/types/enums";
|
||||
import { IEvent } from "@/types/event.model";
|
||||
import { reactive } from "vue";
|
||||
import EventCard from "./EventCard.vue";
|
||||
|
||||
const baseActorAvatar = {
|
||||
id: "",
|
||||
name: "",
|
||||
alt: "",
|
||||
metadata: {},
|
||||
url: "https://social.tcit.fr/system/accounts/avatars/000/000/001/original/a28c50ce5f2b13fd.jpg",
|
||||
};
|
||||
|
||||
const baseActor: IActor = {
|
||||
name: "Thomas Citharel",
|
||||
preferredUsername: "tcit",
|
||||
avatar: baseActorAvatar,
|
||||
domain: null,
|
||||
url: "",
|
||||
summary: "",
|
||||
suspended: false,
|
||||
type: ActorType.PERSON,
|
||||
};
|
||||
|
||||
const baseEvent: IEvent = {
|
||||
uuid: "",
|
||||
title: "A very interesting event",
|
||||
description: "Things happen",
|
||||
beginsOn: new Date().toISOString(),
|
||||
endsOn: new Date().toISOString(),
|
||||
physicalAddress: {
|
||||
description: "Somewhere",
|
||||
street: "",
|
||||
locality: "",
|
||||
region: "",
|
||||
country: "",
|
||||
type: "",
|
||||
postalCode: "",
|
||||
},
|
||||
picture: {
|
||||
id: "",
|
||||
name: "",
|
||||
alt: "",
|
||||
metadata: {},
|
||||
url: "https://mobilizon.fr/media/81d9c76aaf740f84eefb28cf2b9988bdd2495ab1f3246159fd688e242155cb23.png?name=Screenshot_20220315_171848.png",
|
||||
},
|
||||
url: "",
|
||||
local: true,
|
||||
slug: "",
|
||||
publishAt: new Date().toISOString(),
|
||||
status: EventStatus.CONFIRMED,
|
||||
visibility: EventVisibility.PUBLIC,
|
||||
joinOptions: EventJoinOptions.FREE,
|
||||
draft: false,
|
||||
participantStats: {
|
||||
notApproved: 0,
|
||||
notConfirmed: 0,
|
||||
rejected: 0,
|
||||
participant: 0,
|
||||
creator: 0,
|
||||
moderator: 0,
|
||||
administrator: 0,
|
||||
going: 0,
|
||||
},
|
||||
participants: { total: 0, elements: [] },
|
||||
relatedEvents: [],
|
||||
tags: [{ slug: "something", title: "Something" }],
|
||||
attributedTo: undefined,
|
||||
organizerActor: {
|
||||
...baseActor,
|
||||
name: "Hello",
|
||||
avatar: {
|
||||
...baseActorAvatar,
|
||||
url: "https://mobilizon.fr/media/653c2dcbb830636e0db975798163b85e038dfb7713e866e96d36bd411e105e3c.png?name=festivalsanantes%27s%20avatar.png",
|
||||
},
|
||||
},
|
||||
comments: [],
|
||||
options: {
|
||||
maximumAttendeeCapacity: 0,
|
||||
remainingAttendeeCapacity: 0,
|
||||
showRemainingAttendeeCapacity: false,
|
||||
anonymousParticipation: false,
|
||||
hideOrganizerWhenGroupEvent: false,
|
||||
offers: [],
|
||||
participationConditions: [],
|
||||
attendees: [],
|
||||
program: "",
|
||||
commentModeration: CommentModeration.ALLOW_ALL,
|
||||
showParticipationPrice: false,
|
||||
showStartTime: false,
|
||||
showEndTime: false,
|
||||
timezone: null,
|
||||
isOnline: false,
|
||||
},
|
||||
metadata: [],
|
||||
contacts: [],
|
||||
language: "en",
|
||||
category: "hello",
|
||||
};
|
||||
|
||||
const event = reactive<IEvent>(baseEvent);
|
||||
|
||||
const longEvent = reactive<IEvent>({
|
||||
...baseEvent,
|
||||
title:
|
||||
"A very long title that will have trouble to display because it will take multiple lines but where will it stop ?! Maybe after 3 lines is enough. Let's say so. But if it doesn't work, we really need to truncate it at some point. Definitively.",
|
||||
});
|
||||
|
||||
const tentativeEvent = reactive<IEvent>({
|
||||
...baseEvent,
|
||||
status: EventStatus.TENTATIVE,
|
||||
});
|
||||
|
||||
const cancelledEvent = reactive<IEvent>({
|
||||
...baseEvent,
|
||||
status: EventStatus.CANCELLED,
|
||||
});
|
||||
</script>
|
||||
239
src/components/Event/EventCard.vue
Normal file
239
src/components/Event/EventCard.vue
Normal file
@@ -0,0 +1,239 @@
|
||||
<template>
|
||||
<LinkOrRouterLink
|
||||
class="mbz-card snap-center dark:bg-mbz-purple"
|
||||
:class="{
|
||||
'sm:flex sm:items-start': mode === 'row',
|
||||
'sm:max-w-xs w-[18rem] shrink-0 flex flex-col': mode === 'column',
|
||||
}"
|
||||
:to="to"
|
||||
:isInternal="isInternal"
|
||||
>
|
||||
<div
|
||||
class="rounded-lg"
|
||||
:class="{ 'sm:w-full sm:max-w-[20rem]': mode === 'row' }"
|
||||
>
|
||||
<figure class="block relative pt-40">
|
||||
<lazy-image-wrapper
|
||||
:picture="event.picture"
|
||||
style="height: 100%; position: absolute; top: 0; left: 0; width: 100%"
|
||||
/>
|
||||
<div
|
||||
class="absolute top-3 right-0 ltr:-mr-1 rtl:-ml-1 z-10 max-w-xs no-underline flex flex-col gap-1 items-end"
|
||||
v-show="mode === 'column'"
|
||||
v-if="event.tags || event.status !== EventStatus.CONFIRMED"
|
||||
>
|
||||
<mobilizon-tag
|
||||
variant="info"
|
||||
v-if="event.status === EventStatus.TENTATIVE"
|
||||
>
|
||||
{{ t("Tentative") }}
|
||||
</mobilizon-tag>
|
||||
<mobilizon-tag
|
||||
variant="danger"
|
||||
v-if="event.status === EventStatus.CANCELLED"
|
||||
>
|
||||
{{ t("Cancelled") }}
|
||||
</mobilizon-tag>
|
||||
<router-link
|
||||
:to="{ name: RouteName.TAG, params: { tag: tag.title } }"
|
||||
v-for="tag in (event.tags || []).slice(0, 3)"
|
||||
:key="tag.slug"
|
||||
>
|
||||
<mobilizon-tag dir="auto" :with-hash-tag="true">{{
|
||||
tag.title
|
||||
}}</mobilizon-tag>
|
||||
</router-link>
|
||||
</div>
|
||||
</figure>
|
||||
</div>
|
||||
<div class="p-2 flex-auto" :class="{ 'sm:flex-1': mode === 'row' }">
|
||||
<div class="relative flex flex-col h-full">
|
||||
<div
|
||||
class="-mt-3 h-0 flex mb-3 ltr:ml-0 rtl:mr-0 items-end self-start"
|
||||
:class="{ 'sm:hidden': mode === 'row' }"
|
||||
>
|
||||
<date-calendar-icon
|
||||
:small="true"
|
||||
v-if="!mergedOptions.hideDate"
|
||||
:date="event.beginsOn.toString()"
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
class="text-gray-700 dark:text-white font-semibold hidden"
|
||||
:class="{ 'sm:block': mode === 'row' }"
|
||||
>{{ formatDateTimeWithCurrentLocale }}</span
|
||||
>
|
||||
<div class="w-full flex flex-col justify-between h-full">
|
||||
<h2
|
||||
class="mt-0 mb-2 text-2xl line-clamp-3 font-bold text-violet-3 dark:text-white"
|
||||
dir="auto"
|
||||
:lang="event.language"
|
||||
>
|
||||
{{ event.title }}
|
||||
</h2>
|
||||
<div class="">
|
||||
<div
|
||||
class="flex items-center text-violet-3 dark:text-white"
|
||||
dir="auto"
|
||||
>
|
||||
<figure class="" v-if="actorAvatarURL">
|
||||
<img
|
||||
class="rounded-xl"
|
||||
:src="actorAvatarURL"
|
||||
alt=""
|
||||
width="24"
|
||||
height="24"
|
||||
loading="lazy"
|
||||
/>
|
||||
</figure>
|
||||
<account-circle v-else />
|
||||
<span class="font-semibold ltr:pl-2 rtl:pr-2">
|
||||
{{ organizerDisplayName(event) }}
|
||||
</span>
|
||||
</div>
|
||||
<inline-address
|
||||
v-if="event.physicalAddress"
|
||||
:physical-address="event.physicalAddress"
|
||||
/>
|
||||
<div
|
||||
class="flex items-center text-sm"
|
||||
dir="auto"
|
||||
v-else-if="event.options && event.options.isOnline"
|
||||
>
|
||||
<Video />
|
||||
<span class="ltr:pl-2 rtl:pr-2">{{ t("Online") }}</span>
|
||||
</div>
|
||||
<div
|
||||
class="mt-1 no-underline gap-1 items-center hidden"
|
||||
:class="{ 'sm:flex': mode === 'row' }"
|
||||
v-if="
|
||||
event.tags ||
|
||||
event.status !== EventStatus.CONFIRMED ||
|
||||
event.participantStats?.participant > 0
|
||||
"
|
||||
>
|
||||
<mobilizon-tag
|
||||
variant="info"
|
||||
v-if="event.participantStats?.participant > 0"
|
||||
>
|
||||
{{
|
||||
t(
|
||||
"{count} participants",
|
||||
{ count: event.participantStats?.participant },
|
||||
event.participantStats?.participant
|
||||
)
|
||||
}}
|
||||
</mobilizon-tag>
|
||||
<mobilizon-tag
|
||||
variant="info"
|
||||
v-if="event.status === EventStatus.TENTATIVE"
|
||||
>
|
||||
{{ t("Tentative") }}
|
||||
</mobilizon-tag>
|
||||
<mobilizon-tag
|
||||
variant="danger"
|
||||
v-if="event.status === EventStatus.CANCELLED"
|
||||
>
|
||||
{{ t("Cancelled") }}
|
||||
</mobilizon-tag>
|
||||
<router-link
|
||||
:to="{ name: RouteName.TAG, params: { tag: tag.title } }"
|
||||
v-for="tag in (event.tags || []).slice(0, 3)"
|
||||
:key="tag.slug"
|
||||
>
|
||||
<mobilizon-tag :with-hash-tag="true" dir="auto">{{
|
||||
tag.title
|
||||
}}</mobilizon-tag>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</LinkOrRouterLink>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
IEvent,
|
||||
IEventCardOptions,
|
||||
organizerDisplayName,
|
||||
organizerAvatarUrl,
|
||||
} from "@/types/event.model";
|
||||
import DateCalendarIcon from "@/components/Event/DateCalendarIcon.vue";
|
||||
import LazyImageWrapper from "@/components/Image/LazyImageWrapper.vue";
|
||||
import { EventStatus } from "@/types/enums";
|
||||
import RouteName from "../../router/name";
|
||||
import InlineAddress from "@/components/Address/InlineAddress.vue";
|
||||
|
||||
import { computed, inject } from "vue";
|
||||
import MobilizonTag from "@/components/TagElement.vue";
|
||||
import AccountCircle from "vue-material-design-icons/AccountCircle.vue";
|
||||
import Video from "vue-material-design-icons/Video.vue";
|
||||
import { formatDateTimeForEvent } from "@/utils/datetime";
|
||||
import type { Locale } from "date-fns";
|
||||
import LinkOrRouterLink from "../core/LinkOrRouterLink.vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
event: IEvent;
|
||||
options?: IEventCardOptions;
|
||||
mode?: "row" | "column";
|
||||
}>(),
|
||||
{ mode: "column" }
|
||||
);
|
||||
const defaultOptions: IEventCardOptions = {
|
||||
hideDate: false,
|
||||
loggedPerson: false,
|
||||
hideDetails: false,
|
||||
organizerActor: null,
|
||||
isRemoteEvent: false,
|
||||
isLoggedIn: true,
|
||||
};
|
||||
|
||||
const mergedOptions = computed<IEventCardOptions>(() => ({
|
||||
...defaultOptions,
|
||||
...props.options,
|
||||
}));
|
||||
|
||||
// const actor = computed<Actor>(() => {
|
||||
// return Object.assign(
|
||||
// new Person(),
|
||||
// props.event.organizerActor ?? mergedOptions.value.organizerActor
|
||||
// );
|
||||
// });
|
||||
|
||||
const actorAvatarURL = computed<string | null>(() =>
|
||||
organizerAvatarUrl(props.event)
|
||||
);
|
||||
|
||||
const dateFnsLocale = inject<Locale>("dateFnsLocale");
|
||||
|
||||
const formatDateTimeWithCurrentLocale = computed(() => {
|
||||
if (!dateFnsLocale) return;
|
||||
return formatDateTimeForEvent(new Date(props.event.beginsOn), dateFnsLocale);
|
||||
});
|
||||
|
||||
const isInternal = computed(() => {
|
||||
return (
|
||||
mergedOptions.value.isRemoteEvent &&
|
||||
mergedOptions.value.isLoggedIn === false
|
||||
);
|
||||
});
|
||||
|
||||
const to = computed(() => {
|
||||
if (mergedOptions.value.isRemoteEvent) {
|
||||
if (mergedOptions.value.isLoggedIn === false) {
|
||||
return props.event.url;
|
||||
}
|
||||
return {
|
||||
name: RouteName.INTERACT,
|
||||
query: { uri: encodeURI(props.event.url) },
|
||||
};
|
||||
}
|
||||
return { name: RouteName.EVENT, params: { uuid: props.event.uuid } };
|
||||
});
|
||||
</script>
|
||||
187
src/components/Event/EventFullDate.vue
Normal file
187
src/components/Event/EventFullDate.vue
Normal file
@@ -0,0 +1,187 @@
|
||||
<template>
|
||||
<p v-if="!endsOn">
|
||||
<span>{{
|
||||
formatDateTimeString(beginsOn, timezoneToShow, showStartTime)
|
||||
}}</span>
|
||||
<br />
|
||||
<o-switch
|
||||
size="small"
|
||||
v-model="showLocalTimezone"
|
||||
v-if="differentFromUserTimezone"
|
||||
>
|
||||
{{ singleTimeZone }}
|
||||
</o-switch>
|
||||
</p>
|
||||
<p v-else-if="isSameDay() && showStartTime && showEndTime">
|
||||
<span>{{
|
||||
t("On {date} from {startTime} to {endTime}", {
|
||||
date: formatDate(beginsOn),
|
||||
startTime: formatTime(beginsOn, timezoneToShow),
|
||||
endTime: formatTime(endsOn, timezoneToShow),
|
||||
})
|
||||
}}</span>
|
||||
<br />
|
||||
<o-switch
|
||||
size="small"
|
||||
v-model="showLocalTimezone"
|
||||
v-if="differentFromUserTimezone"
|
||||
>
|
||||
{{ singleTimeZone }}
|
||||
</o-switch>
|
||||
</p>
|
||||
<p v-else-if="isSameDay() && showStartTime && !showEndTime">
|
||||
{{
|
||||
t("On {date} starting at {startTime}", {
|
||||
date: formatDate(beginsOn),
|
||||
startTime: formatTime(beginsOn, timezoneToShow),
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<p v-else-if="isSameDay()">
|
||||
{{ t("On {date}", { date: formatDate(beginsOn) }) }}
|
||||
</p>
|
||||
<p v-else-if="endsOn && showStartTime && showEndTime">
|
||||
<span>
|
||||
{{
|
||||
t("From the {startDate} at {startTime} to the {endDate} at {endTime}", {
|
||||
startDate: formatDate(beginsOn),
|
||||
startTime: formatTime(beginsOn, timezoneToShow),
|
||||
endDate: formatDate(endsOn),
|
||||
endTime: formatTime(endsOn, timezoneToShow),
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<br />
|
||||
<o-switch
|
||||
size="small"
|
||||
v-model="showLocalTimezone"
|
||||
v-if="differentFromUserTimezone"
|
||||
>
|
||||
{{ multipleTimeZones }}
|
||||
</o-switch>
|
||||
</p>
|
||||
<p v-else-if="endsOn && showStartTime">
|
||||
<span>
|
||||
{{
|
||||
t("From the {startDate} at {startTime} to the {endDate}", {
|
||||
startDate: formatDate(beginsOn),
|
||||
startTime: formatTime(beginsOn, timezoneToShow),
|
||||
endDate: formatDate(endsOn),
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<br />
|
||||
<o-switch
|
||||
size="small"
|
||||
v-model="showLocalTimezone"
|
||||
v-if="differentFromUserTimezone"
|
||||
>
|
||||
{{ singleTimeZone }}
|
||||
</o-switch>
|
||||
</p>
|
||||
<p v-else-if="endsOn">
|
||||
{{
|
||||
t("From the {startDate} to the {endDate}", {
|
||||
startDate: formatDate(beginsOn),
|
||||
endDate: formatDate(endsOn),
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
formatDateString,
|
||||
formatDateTimeString,
|
||||
formatTimeString,
|
||||
} from "@/filters/datetime";
|
||||
import { getTimezoneOffset } from "date-fns-tz";
|
||||
import { computed, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
beginsOn: string;
|
||||
endsOn?: string;
|
||||
showStartTime?: boolean;
|
||||
showEndTime?: boolean;
|
||||
timezone?: string;
|
||||
userTimezone?: string;
|
||||
}>(),
|
||||
{
|
||||
showStartTime: true,
|
||||
showEndTime: true,
|
||||
}
|
||||
);
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
const showLocalTimezone = ref(true);
|
||||
|
||||
const timezoneToShow = computed((): string | undefined => {
|
||||
if (showLocalTimezone.value) {
|
||||
return props.timezone;
|
||||
}
|
||||
return userActualTimezone.value;
|
||||
});
|
||||
|
||||
const userActualTimezone = computed((): string => {
|
||||
if (props.userTimezone) {
|
||||
return props.userTimezone;
|
||||
}
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
});
|
||||
|
||||
const formatDate = (value: string): string | undefined => {
|
||||
return formatDateString(value);
|
||||
};
|
||||
|
||||
const formatTime = (
|
||||
value: string,
|
||||
timezone: string | undefined = undefined
|
||||
): string | undefined => {
|
||||
return formatTimeString(value, timezone ?? "Etc/UTC");
|
||||
};
|
||||
|
||||
const isSameDay = (): boolean => {
|
||||
if (!props.endsOn) return false;
|
||||
return (
|
||||
beginsOnDate.value.toDateString() === new Date(props.endsOn).toDateString()
|
||||
);
|
||||
};
|
||||
|
||||
const beginsOnDate = computed((): Date => {
|
||||
return new Date(props.beginsOn);
|
||||
});
|
||||
|
||||
const differentFromUserTimezone = computed((): boolean => {
|
||||
return (
|
||||
!!props.timezone &&
|
||||
!!userActualTimezone.value &&
|
||||
getTimezoneOffset(props.timezone, beginsOnDate.value) !==
|
||||
getTimezoneOffset(userActualTimezone.value, beginsOnDate.value) &&
|
||||
props.timezone !== userActualTimezone.value
|
||||
);
|
||||
});
|
||||
|
||||
const singleTimeZone = computed((): string => {
|
||||
if (showLocalTimezone.value) {
|
||||
return t("Local time ({timezone})", {
|
||||
timezone: timezoneToShow.value,
|
||||
});
|
||||
}
|
||||
return t("Time in your timezone ({timezone})", {
|
||||
timezone: timezoneToShow.value,
|
||||
});
|
||||
});
|
||||
|
||||
const multipleTimeZones = computed((): string => {
|
||||
if (showLocalTimezone.value) {
|
||||
return t("Local times ({timezone})", {
|
||||
timezone: timezoneToShow.value,
|
||||
});
|
||||
}
|
||||
return t("Times in your timezone ({timezone})", {
|
||||
timezone: timezoneToShow.value,
|
||||
});
|
||||
});
|
||||
</script>
|
||||
143
src/components/Event/EventListViewCard.story.vue
Normal file
143
src/components/Event/EventListViewCard.story.vue
Normal file
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<Story title="EventListViewCard">
|
||||
<Variant title="default">
|
||||
<EventListViewCard :event="baseEvent" />
|
||||
</Variant>
|
||||
<Variant title="long">
|
||||
<EventListViewCard :event="longEvent" />
|
||||
</Variant>
|
||||
|
||||
<!-- <Variant title="tentative">
|
||||
<EventListViewCard :event="tentativeEvent" />
|
||||
</Variant>
|
||||
|
||||
<Variant title="cancelled">
|
||||
<EventListViewCard :event="cancelledEvent" />
|
||||
</Variant> -->
|
||||
</Story>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { IActor } from "@/types/actor";
|
||||
import {
|
||||
ActorType,
|
||||
CommentModeration,
|
||||
EventJoinOptions,
|
||||
EventStatus,
|
||||
EventVisibility,
|
||||
} from "@/types/enums";
|
||||
import { IEvent } from "@/types/event.model";
|
||||
import { reactive } from "vue";
|
||||
import EventListViewCard from "./EventListViewCard.vue";
|
||||
|
||||
const baseActorAvatar = {
|
||||
id: "",
|
||||
name: "",
|
||||
alt: "",
|
||||
metadata: {},
|
||||
url: "https://social.tcit.fr/system/accounts/avatars/000/000/001/original/a28c50ce5f2b13fd.jpg",
|
||||
};
|
||||
|
||||
const baseActor: IActor = {
|
||||
name: "Thomas Citharel",
|
||||
preferredUsername: "tcit",
|
||||
avatar: baseActorAvatar,
|
||||
domain: null,
|
||||
url: "",
|
||||
summary: "",
|
||||
suspended: false,
|
||||
type: ActorType.PERSON,
|
||||
};
|
||||
|
||||
const baseEvent: IEvent = {
|
||||
uuid: "",
|
||||
title: "A very interesting event",
|
||||
description: "Things happen",
|
||||
beginsOn: new Date().toISOString(),
|
||||
endsOn: new Date().toISOString(),
|
||||
physicalAddress: {
|
||||
description: "Somewhere",
|
||||
street: "",
|
||||
locality: "",
|
||||
region: "",
|
||||
country: "",
|
||||
type: "",
|
||||
postalCode: "",
|
||||
},
|
||||
picture: {
|
||||
id: "",
|
||||
name: "",
|
||||
alt: "",
|
||||
metadata: {},
|
||||
url: "https://mobilizon.fr/media/81d9c76aaf740f84eefb28cf2b9988bdd2495ab1f3246159fd688e242155cb23.png?name=Screenshot_20220315_171848.png",
|
||||
},
|
||||
url: "",
|
||||
local: true,
|
||||
slug: "",
|
||||
publishAt: new Date().toISOString(),
|
||||
status: EventStatus.CONFIRMED,
|
||||
visibility: EventVisibility.PUBLIC,
|
||||
joinOptions: EventJoinOptions.FREE,
|
||||
draft: false,
|
||||
participantStats: {
|
||||
notApproved: 0,
|
||||
notConfirmed: 0,
|
||||
rejected: 0,
|
||||
participant: 0,
|
||||
creator: 0,
|
||||
moderator: 0,
|
||||
administrator: 0,
|
||||
going: 0,
|
||||
},
|
||||
participants: { total: 0, elements: [] },
|
||||
relatedEvents: [],
|
||||
tags: [{ slug: "something", title: "Something" }],
|
||||
attributedTo: undefined,
|
||||
organizerActor: {
|
||||
...baseActor,
|
||||
name: "Hello",
|
||||
avatar: {
|
||||
...baseActorAvatar,
|
||||
url: "https://mobilizon.fr/media/653c2dcbb830636e0db975798163b85e038dfb7713e866e96d36bd411e105e3c.png?name=festivalsanantes%27s%20avatar.png",
|
||||
},
|
||||
},
|
||||
comments: [],
|
||||
options: {
|
||||
maximumAttendeeCapacity: 0,
|
||||
remainingAttendeeCapacity: 0,
|
||||
showRemainingAttendeeCapacity: false,
|
||||
anonymousParticipation: false,
|
||||
hideOrganizerWhenGroupEvent: false,
|
||||
offers: [],
|
||||
participationConditions: [],
|
||||
attendees: [],
|
||||
program: "",
|
||||
commentModeration: CommentModeration.ALLOW_ALL,
|
||||
showParticipationPrice: false,
|
||||
showStartTime: false,
|
||||
showEndTime: false,
|
||||
timezone: null,
|
||||
isOnline: false,
|
||||
},
|
||||
metadata: [],
|
||||
contacts: [],
|
||||
language: "en",
|
||||
category: "hello",
|
||||
};
|
||||
|
||||
const longEvent = reactive<IEvent>({
|
||||
...baseEvent,
|
||||
title:
|
||||
"A very long title that will have trouble to display because it will take multiple lines but where will it stop ?! Maybe after 3 lines is enough. Let's say so.",
|
||||
});
|
||||
|
||||
// const tentativeEvent = reactive<IEvent>({
|
||||
// ...baseEvent,
|
||||
// status: EventStatus.TENTATIVE,
|
||||
// });
|
||||
|
||||
// const cancelledEvent = reactive<IEvent>({
|
||||
// ...baseEvent,
|
||||
// status: EventStatus.CANCELLED,
|
||||
// });
|
||||
</script>
|
||||
83
src/components/Event/EventListViewCard.vue
Normal file
83
src/components/Event/EventListViewCard.vue
Normal file
@@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<article
|
||||
class="bg-white dark:bg-zinc-700 dark:text-white dark:hover:text-white rounded-lg shadow-md max-w-3xl p-2"
|
||||
>
|
||||
<div class="flex gap-2">
|
||||
<div class="">
|
||||
<date-calendar-icon :date="event.beginsOn.toString()" :small="true" />
|
||||
</div>
|
||||
<router-link
|
||||
:to="{ name: RouteName.EVENT, params: { uuid: event.uuid } }"
|
||||
>
|
||||
<h2 class="mt-0 line-clamp-2">{{ event.title }}</h2>
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="">
|
||||
<span v-if="event.physicalAddress && event.physicalAddress.locality">
|
||||
{{ event.physicalAddress.locality }}
|
||||
</span>
|
||||
<span v-if="event.attributedTo">
|
||||
{{
|
||||
$t("Created by {name}", {
|
||||
name: displayName(event.attributedTo),
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{
|
||||
$t("Organized by {name}", {
|
||||
name: displayName(event.organizerActor),
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
<span>
|
||||
<Earth v-if="event.visibility === EventVisibility.PUBLIC" />
|
||||
<Link v-if="event.visibility === EventVisibility.UNLISTED" />
|
||||
<Lock v-if="event.visibility === EventVisibility.PRIVATE" />
|
||||
</span>
|
||||
<span>
|
||||
<span v-if="event.options.maximumAttendeeCapacity !== 0">
|
||||
{{
|
||||
$t("{approved} / {total} seats", {
|
||||
approved: event.participantStats.participant,
|
||||
total: event.options.maximumAttendeeCapacity,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{
|
||||
$t(
|
||||
"{count} participants",
|
||||
{
|
||||
count: event.participantStats.participant,
|
||||
},
|
||||
event.participantStats.participant
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { IEventCardOptions, IEvent } from "@/types/event.model";
|
||||
import DateCalendarIcon from "@/components/Event/DateCalendarIcon.vue";
|
||||
import { displayName } from "@/types/actor";
|
||||
import { EventVisibility } from "@/types/enums";
|
||||
import RouteName from "../../router/name";
|
||||
import Earth from "vue-material-design-icons/Earth.vue";
|
||||
import Link from "vue-material-design-icons/Link.vue";
|
||||
import Lock from "vue-material-design-icons/Lock.vue";
|
||||
|
||||
withDefaults(defineProps<{ event: IEvent; options?: IEventCardOptions }>(), {
|
||||
options: (): IEventCardOptions => ({
|
||||
hideDate: true,
|
||||
loggedPerson: false,
|
||||
hideDetails: false,
|
||||
organizerActor: null,
|
||||
}),
|
||||
});
|
||||
</script>
|
||||
171
src/components/Event/EventMap.vue
Normal file
171
src/components/Event/EventMap.vue
Normal file
@@ -0,0 +1,171 @@
|
||||
<template>
|
||||
<div class="">
|
||||
<div class="text-end">
|
||||
<button @click="emit('close')">
|
||||
<Close />
|
||||
<span class="sr-only">{{ t("Close map") }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<section class="map">
|
||||
<map-leaflet
|
||||
v-if="physicalAddress?.geom"
|
||||
:coords="physicalAddress.geom"
|
||||
:marker="{
|
||||
text: physicalAddress.fullName,
|
||||
icon: physicalAddress.poiInfos.poiIcon.icon,
|
||||
}"
|
||||
/>
|
||||
</section>
|
||||
<section class="flex flex-col items-center mt-4">
|
||||
<p v-if="physicalAddress?.fullName" class="flex gap-2 text-xl font-bold">
|
||||
<MapMarker />
|
||||
{{ physicalAddress.fullName }}
|
||||
</p>
|
||||
<p class="mt-4">{{ t("Getting there") }}</p>
|
||||
<div
|
||||
class="flex gap-2"
|
||||
v-if="
|
||||
addressLinkToRouteByCar ||
|
||||
addressLinkToRouteByBike ||
|
||||
addressLinkToRouteByFeet
|
||||
"
|
||||
>
|
||||
<o-button
|
||||
tag="a"
|
||||
target="_blank"
|
||||
v-if="addressLinkToRouteByFeet"
|
||||
:href="addressLinkToRouteByFeet"
|
||||
>
|
||||
<Walk />
|
||||
<span class="sr-only">{{ t("On foot") }}</span>
|
||||
</o-button>
|
||||
<o-button
|
||||
tag="a"
|
||||
target="_blank"
|
||||
v-if="addressLinkToRouteByBike"
|
||||
:href="addressLinkToRouteByBike"
|
||||
>
|
||||
<Bike />
|
||||
<span class="sr-only">{{ t("By bike") }}</span>
|
||||
</o-button>
|
||||
<o-button
|
||||
tag="a"
|
||||
target="_blank"
|
||||
v-if="addressLinkToRouteByTransit"
|
||||
:href="addressLinkToRouteByTransit"
|
||||
>
|
||||
<Bus />
|
||||
<span class="sr-only">{{ t("By transit") }}</span>
|
||||
</o-button>
|
||||
<o-button
|
||||
tag="a"
|
||||
target="_blank"
|
||||
v-if="addressLinkToRouteByCar"
|
||||
:href="addressLinkToRouteByCar"
|
||||
>
|
||||
<Car />
|
||||
<span class="sr-only">{{ t("By car") }}</span>
|
||||
</o-button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { Address, IAddress } from "@/types/address.model";
|
||||
import { RoutingTransportationType, RoutingType } from "@/types/enums";
|
||||
import { computed, defineAsyncComponent } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import Car from "vue-material-design-icons/Car.vue";
|
||||
import Bike from "vue-material-design-icons/Bike.vue";
|
||||
import Bus from "vue-material-design-icons/Bus.vue";
|
||||
import Walk from "vue-material-design-icons/Walk.vue";
|
||||
import MapMarker from "vue-material-design-icons/MapMarker.vue";
|
||||
import Close from "vue-material-design-icons/Close.vue";
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
const RoutingParamType = {
|
||||
[RoutingType.OPENSTREETMAP]: {
|
||||
[RoutingTransportationType.FOOT]: "engine=fossgis_osrm_foot",
|
||||
[RoutingTransportationType.BIKE]: "engine=fossgis_osrm_bike",
|
||||
[RoutingTransportationType.TRANSIT]: null,
|
||||
[RoutingTransportationType.CAR]: "engine=fossgis_osrm_car",
|
||||
},
|
||||
[RoutingType.GOOGLE_MAPS]: {
|
||||
[RoutingTransportationType.FOOT]: "dirflg=w",
|
||||
[RoutingTransportationType.BIKE]: "dirflg=b",
|
||||
[RoutingTransportationType.TRANSIT]: "dirflg=r",
|
||||
[RoutingTransportationType.CAR]: "driving",
|
||||
},
|
||||
};
|
||||
|
||||
const MapLeaflet = defineAsyncComponent(
|
||||
() => import("@/components/LeafletMap.vue")
|
||||
);
|
||||
|
||||
const props = defineProps<{
|
||||
address: IAddress;
|
||||
routingType: RoutingType;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(["close"]);
|
||||
|
||||
const physicalAddress = computed((): Address | null => {
|
||||
if (!props.address) return null;
|
||||
|
||||
return new Address(props.address);
|
||||
});
|
||||
|
||||
const makeNavigationPath = (
|
||||
transportationType: RoutingTransportationType
|
||||
): string | undefined => {
|
||||
const geometry = physicalAddress.value?.geom;
|
||||
if (geometry) {
|
||||
/**
|
||||
* build urls to routing map
|
||||
*/
|
||||
if (!RoutingParamType[props.routingType][transportationType]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const urlGeometry = geometry.split(";").reverse().join(",");
|
||||
|
||||
switch (props.routingType) {
|
||||
case RoutingType.GOOGLE_MAPS:
|
||||
return `https://maps.google.com/?saddr=Current+Location&daddr=${urlGeometry}&${
|
||||
RoutingParamType[props.routingType][transportationType]
|
||||
}`;
|
||||
case RoutingType.OPENSTREETMAP:
|
||||
default: {
|
||||
const bboxX = geometry.split(";").reverse()[0];
|
||||
const bboxY = geometry.split(";").reverse()[1];
|
||||
return `https://www.openstreetmap.org/directions?from=&to=${urlGeometry}&${
|
||||
RoutingParamType[props.routingType][transportationType]
|
||||
}#map=14/${bboxX}/${bboxY}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const addressLinkToRouteByCar = computed((): undefined | string => {
|
||||
return makeNavigationPath(RoutingTransportationType.CAR);
|
||||
});
|
||||
|
||||
const addressLinkToRouteByBike = computed((): undefined | string => {
|
||||
return makeNavigationPath(RoutingTransportationType.BIKE);
|
||||
});
|
||||
|
||||
const addressLinkToRouteByFeet = computed((): undefined | string => {
|
||||
return makeNavigationPath(RoutingTransportationType.FOOT);
|
||||
});
|
||||
|
||||
const addressLinkToRouteByTransit = computed((): undefined | string => {
|
||||
return makeNavigationPath(RoutingTransportationType.TRANSIT);
|
||||
});
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
section.map {
|
||||
height: 75vh;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
46
src/components/Event/EventMetadataBlock.vue
Normal file
46
src/components/Event/EventMetadataBlock.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<div>
|
||||
<h2>{{ title }}</h2>
|
||||
<div class="flex items-center mb-3 gap-1 eventMetadataBlock">
|
||||
<slot name="icon"></slot>
|
||||
<!-- Custom icons -->
|
||||
<!-- <span
|
||||
class="icon is-medium"
|
||||
v-if="icon && icon.substring(0, 7) === 'mz:icon'"
|
||||
>
|
||||
<img
|
||||
:src="`/img/${icon.substring(8)}_monochrome.svg`"
|
||||
width="32"
|
||||
height="32"
|
||||
/>
|
||||
</span>
|
||||
<o-icon v-else-if="icon" :icon="icon" size="is-medium" /> -->
|
||||
<div class="content-wrapper overflow-hidden w-full">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
title: string;
|
||||
}>();
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
div.eventMetadataBlock {
|
||||
.content-wrapper {
|
||||
max-width: calc(100vw - 32px - 20px);
|
||||
|
||||
&.padding-left {
|
||||
padding: 0 20px;
|
||||
|
||||
a {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
147
src/components/Event/EventMetadataItem.vue
Normal file
147
src/components/Event/EventMetadataItem.vue
Normal file
@@ -0,0 +1,147 @@
|
||||
<template>
|
||||
<div
|
||||
class="block p-4 bg-white rounded-lg border border-gray-200 shadow-md hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-700 dark:hover:bg-gray-700"
|
||||
>
|
||||
<div class="flex flex-wrap gap-1 w-full items-center">
|
||||
<div class="">
|
||||
<img
|
||||
v-if="
|
||||
modelValue.icon && modelValue.icon.substring(0, 7) === 'mz:icon'
|
||||
"
|
||||
:src="`/img/${modelValue.icon.substring(8)}_monochrome.svg`"
|
||||
width="24"
|
||||
height="24"
|
||||
alt=""
|
||||
/>
|
||||
|
||||
<o-icon
|
||||
v-else-if="modelValue.icon"
|
||||
:icon="modelValue.icon"
|
||||
customSize="24"
|
||||
/>
|
||||
<o-icon v-else icon="help-circle" customSize="24" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<b>{{ modelValue.title || modelValue.label }}</b>
|
||||
<br />
|
||||
<small>
|
||||
{{ modelValue.description }}
|
||||
</small>
|
||||
<div
|
||||
v-if="
|
||||
modelValue.type === EventMetadataType.STRING &&
|
||||
modelValue.keyType === EventMetadataKeyType.CHOICE &&
|
||||
modelValue.choices
|
||||
"
|
||||
>
|
||||
<o-field v-for="(value, key) in modelValue.choices" :key="key">
|
||||
<o-radio v-model="metadataItemValue" :native-value="key">{{
|
||||
value
|
||||
}}</o-radio>
|
||||
</o-field>
|
||||
</div>
|
||||
<o-field
|
||||
v-else-if="
|
||||
modelValue.type === EventMetadataType.STRING &&
|
||||
modelValue.keyType == EventMetadataKeyType.URL
|
||||
"
|
||||
>
|
||||
<o-input
|
||||
@blur="validatePattern"
|
||||
ref="urlInput"
|
||||
type="url"
|
||||
:pattern="
|
||||
modelValue.pattern ? modelValue.pattern.source : undefined
|
||||
"
|
||||
:validation-message="t(`This URL doesn't seem to be valid`)"
|
||||
required
|
||||
v-model="metadataItemValue"
|
||||
:placeholder="modelValue.placeholder"
|
||||
/>
|
||||
</o-field>
|
||||
<o-field v-else-if="modelValue.type === EventMetadataType.STRING">
|
||||
<o-input
|
||||
v-model="metadataItemValue"
|
||||
:placeholder="modelValue.placeholder"
|
||||
/>
|
||||
</o-field>
|
||||
<o-field v-else-if="modelValue.type === EventMetadataType.INTEGER">
|
||||
<o-input type="number" v-model="metadataItemValue" />
|
||||
</o-field>
|
||||
<o-field v-else-if="modelValue.type === EventMetadataType.BOOLEAN">
|
||||
<o-checkbox v-model="metadataItemValue">
|
||||
{{
|
||||
metadataItemValue === "true"
|
||||
? modelValue?.choices?.true
|
||||
: modelValue?.choices?.false
|
||||
}}
|
||||
</o-checkbox>
|
||||
</o-field>
|
||||
</div>
|
||||
<o-button icon-left="close" @click="$emit('removeItem', modelValue.key)">
|
||||
<span class="sr-only">
|
||||
{{ t("Remove") }}
|
||||
</span>
|
||||
</o-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { EventMetadataKeyType, EventMetadataType } from "@/types/enums";
|
||||
import { IEventMetadataDescription } from "@/types/event-metadata";
|
||||
import { computed, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: IEventMetadataDescription;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(["update:modelValue", "removeItem"]);
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
const urlInput = ref<any>(null);
|
||||
|
||||
const metadataItemValue = computed({
|
||||
get(): string {
|
||||
return props.modelValue.value;
|
||||
},
|
||||
set(value: string) {
|
||||
if (validate(value)) {
|
||||
emit("update:modelValue", {
|
||||
...props.modelValue,
|
||||
value: value.toString(),
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const validatePattern = (): void => {
|
||||
urlInput.value?.checkHtml5Validity();
|
||||
};
|
||||
|
||||
const validate = (value: string): boolean => {
|
||||
if (props.modelValue.keyType === EventMetadataKeyType.URL) {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (!["http:", "https:", "mailto:"].includes(url.protocol)) return false;
|
||||
if (props.modelValue.pattern) {
|
||||
return value.match(props.modelValue.pattern) !== null;
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
@use "@/styles/_mixins" as *;
|
||||
.card .media {
|
||||
align-items: center;
|
||||
|
||||
// & > button {
|
||||
// @include margin-left(1rem);
|
||||
// }
|
||||
}
|
||||
</style>
|
||||
222
src/components/Event/EventMetadataList.vue
Normal file
222
src/components/Event/EventMetadataList.vue
Normal file
@@ -0,0 +1,222 @@
|
||||
<template>
|
||||
<section>
|
||||
<div class="mb-4">
|
||||
<div v-for="(item, index) in metadata" :key="item.key" class="my-2">
|
||||
<event-metadata-item
|
||||
:modelValue="metadata[index]"
|
||||
@update:modelValue="updateSingleMetadata"
|
||||
@removeItem="removeItem"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<o-field
|
||||
grouped
|
||||
:label="$t('Find or add an element')"
|
||||
label-for="event-metadata-autocomplete"
|
||||
>
|
||||
<o-autocomplete
|
||||
expanded
|
||||
:clear-on-select="true"
|
||||
v-model="search"
|
||||
ref="autocomplete"
|
||||
:data="filteredDataArray"
|
||||
group-field="category"
|
||||
group-options="items"
|
||||
open-on-focus
|
||||
:placeholder="$t('e.g. Accessibility, Twitch, PeerTube')"
|
||||
id="event-metadata-autocomplete"
|
||||
@select="addElement"
|
||||
dir="auto"
|
||||
>
|
||||
<template v-slot="props">
|
||||
<div class="dark:bg-violet-3 p-1 flex items-center gap-1">
|
||||
<div class="">
|
||||
<img
|
||||
v-if="
|
||||
props.option.icon &&
|
||||
props.option.icon.substring(0, 7) === 'mz:icon'
|
||||
"
|
||||
:src="`/img/${props.option.icon.substring(8)}_monochrome.svg`"
|
||||
width="24"
|
||||
height="24"
|
||||
alt=""
|
||||
/>
|
||||
<o-icon v-else-if="props.option.icon" :icon="props.option.icon" />
|
||||
<o-icon v-else icon="help-circle" />
|
||||
</div>
|
||||
<div class="">
|
||||
<b>{{ props.option.label }}</b>
|
||||
<br />
|
||||
<small>
|
||||
{{ props.option.description }}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #empty>{{
|
||||
$t("No results for {search}", { search })
|
||||
}}</template>
|
||||
</o-autocomplete>
|
||||
<p class="control">
|
||||
<o-button @click="showNewElementModal = true">
|
||||
{{ $t("Add new…") }}
|
||||
</o-button>
|
||||
</p>
|
||||
</o-field>
|
||||
<o-modal
|
||||
has-modal-card
|
||||
v-model:active="showNewElementModal"
|
||||
:close-button-aria-label="$t('Close')"
|
||||
>
|
||||
<div class="">
|
||||
<header class="">
|
||||
<h2>{{ t("Create a new metadata element") }}</h2>
|
||||
<p>
|
||||
{{
|
||||
t(
|
||||
"You can put any arbitrary content in this element. URLs will be clickable."
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</header>
|
||||
<div class="">
|
||||
<form @submit="addNewElement">
|
||||
<o-field :label="$t('Element title')">
|
||||
<o-input v-model="newElement.title" />
|
||||
</o-field>
|
||||
<o-field :label="$t('Element value')">
|
||||
<o-input v-model="newElement.value" />
|
||||
</o-field>
|
||||
<o-button class="mt-2" variant="primary" native-type="submit">{{
|
||||
$t("Add")
|
||||
}}</o-button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</o-modal>
|
||||
</section>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { IEventMetadataDescription } from "@/types/event-metadata";
|
||||
import cloneDeep from "lodash/cloneDeep";
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import EventMetadataItem from "./EventMetadataItem.vue";
|
||||
import { eventMetaDataList } from "../../services/EventMetadata";
|
||||
import { EventMetadataCategories, EventMetadataType } from "@/types/enums";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
type GroupedIEventMetadata = Array<{
|
||||
category: string;
|
||||
items: IEventMetadataDescription[];
|
||||
}>;
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: IEventMetadataDescription[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(["update:modelValue"]);
|
||||
|
||||
const newElement = reactive({
|
||||
title: "",
|
||||
value: "",
|
||||
});
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
const search = ref("");
|
||||
|
||||
const data: IEventMetadataDescription[] = eventMetaDataList;
|
||||
|
||||
const showNewElementModal = ref(false);
|
||||
|
||||
const metadata = computed({
|
||||
get(): IEventMetadataDescription[] {
|
||||
return props.modelValue.map((val) => {
|
||||
const def = data.find((dat) => dat.key === val.key);
|
||||
return {
|
||||
...def,
|
||||
...val,
|
||||
};
|
||||
}) as any[];
|
||||
},
|
||||
set(newMetadata: IEventMetadataDescription[]) {
|
||||
emit(
|
||||
"update:modelValue",
|
||||
newMetadata.filter((elem) => elem)
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const localizedCategories: Record<EventMetadataCategories, string> = {
|
||||
[EventMetadataCategories.ACCESSIBILITY]: t("Accessibility") as string,
|
||||
[EventMetadataCategories.LIVE]: t("Live") as string,
|
||||
[EventMetadataCategories.REPLAY]: t("Replay") as string,
|
||||
[EventMetadataCategories.TOOLS]: t("Tools") as string,
|
||||
[EventMetadataCategories.SOCIAL]: t("Social") as string,
|
||||
[EventMetadataCategories.DETAILS]: t("Details") as string,
|
||||
[EventMetadataCategories.BOOKING]: t("Booking") as string,
|
||||
[EventMetadataCategories.VIDEO_CONFERENCE]: t("Video Conference") as string,
|
||||
};
|
||||
|
||||
const filteredDataArray = computed((): GroupedIEventMetadata => {
|
||||
return data
|
||||
.filter((option) => {
|
||||
return (
|
||||
option.label
|
||||
.toString()
|
||||
.toLowerCase()
|
||||
.indexOf(search.value.toLowerCase()) >= 0
|
||||
);
|
||||
})
|
||||
.filter(({ key }) => {
|
||||
return !metadata.value.map(({ key: key2 }) => key2).includes(key);
|
||||
})
|
||||
.reduce(
|
||||
(acc: GroupedIEventMetadata, current: IEventMetadataDescription) => {
|
||||
const group = acc.find(
|
||||
(elem) => elem.category === localizedCategories[current.category]
|
||||
);
|
||||
if (group) {
|
||||
group.items.push(current);
|
||||
} else {
|
||||
acc.push({
|
||||
category: localizedCategories[current.category],
|
||||
items: [current],
|
||||
});
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[]
|
||||
);
|
||||
});
|
||||
|
||||
const updateSingleMetadata = (element: IEventMetadataDescription): void => {
|
||||
const metadataClone = cloneDeep(metadata.value);
|
||||
const index = metadataClone.findIndex((elem) => elem.key === element.key);
|
||||
metadataClone.splice(index, 1, element);
|
||||
emit("update:modelValue", metadataClone);
|
||||
};
|
||||
|
||||
const removeItem = (itemKey: string): void => {
|
||||
const metadataClone = cloneDeep(metadata.value);
|
||||
const index = metadataClone.findIndex((elem) => elem.key === itemKey);
|
||||
metadataClone.splice(index, 1);
|
||||
emit("update:modelValue", metadataClone);
|
||||
};
|
||||
|
||||
const addElement = (element: IEventMetadataDescription): void => {
|
||||
metadata.value = [...metadata.value, element];
|
||||
};
|
||||
|
||||
const addNewElement = (e: Event): void => {
|
||||
e.preventDefault();
|
||||
addElement({
|
||||
...newElement,
|
||||
type: EventMetadataType.STRING,
|
||||
key: `mz:plain:${(Math.random() + 1).toString(36).substring(7)}`,
|
||||
category: EventMetadataCategories.DETAILS,
|
||||
label: "",
|
||||
});
|
||||
showNewElementModal.value = false;
|
||||
};
|
||||
</script>
|
||||
275
src/components/Event/EventMetadataSidebar.vue
Normal file
275
src/components/Event/EventMetadataSidebar.vue
Normal file
@@ -0,0 +1,275 @@
|
||||
<template>
|
||||
<div>
|
||||
<event-metadata-block
|
||||
v-if="!event.options.isOnline"
|
||||
:title="t('Location')"
|
||||
:icon="addressPOIInfos?.poiIcon?.icon ?? 'earth'"
|
||||
>
|
||||
<div class="address-wrapper">
|
||||
<span v-if="!physicalAddress">{{ t("No address defined") }}</span>
|
||||
<div class="address" v-if="physicalAddress">
|
||||
<address-info :address="physicalAddress" />
|
||||
<o-button
|
||||
variant="text"
|
||||
class="map-show-button"
|
||||
@click="$emit('showMapModal', true)"
|
||||
v-if="physicalAddress.geom"
|
||||
>
|
||||
{{ t("Show map") }}
|
||||
</o-button>
|
||||
</div>
|
||||
</div>
|
||||
<template #icon>
|
||||
<o-icon
|
||||
v-if="addressPOIInfos?.poiIcon?.icon"
|
||||
:icon="addressPOIInfos?.poiIcon?.icon"
|
||||
customSize="36"
|
||||
/>
|
||||
<Earth v-else :size="36" />
|
||||
</template>
|
||||
</event-metadata-block>
|
||||
<event-metadata-block :title="t('Date and time')">
|
||||
<template #icon>
|
||||
<Calendar :size="36" />
|
||||
</template>
|
||||
<event-full-date
|
||||
:beginsOn="event.beginsOn.toString()"
|
||||
:show-start-time="event.options.showStartTime"
|
||||
:show-end-time="event.options.showEndTime"
|
||||
:timezone="event.options.timezone ?? undefined"
|
||||
:userTimezone="userTimezone"
|
||||
:endsOn="event.endsOn?.toString()"
|
||||
/>
|
||||
</event-metadata-block>
|
||||
<event-metadata-block
|
||||
class="metadata-organized-by"
|
||||
:title="t('Organized by')"
|
||||
>
|
||||
<router-link
|
||||
v-if="event.attributedTo"
|
||||
class="hover:underline"
|
||||
:to="{
|
||||
name: RouteName.GROUP,
|
||||
params: {
|
||||
preferredUsername: usernameWithDomain(event.attributedTo),
|
||||
},
|
||||
}"
|
||||
>
|
||||
<actor-card
|
||||
v-if="
|
||||
!event.attributedTo || !event.options.hideOrganizerWhenGroupEvent
|
||||
"
|
||||
:actor="event.attributedTo"
|
||||
:inline="true"
|
||||
/>
|
||||
</router-link>
|
||||
<actor-card
|
||||
v-else-if="event.organizerActor"
|
||||
:actor="event.organizerActor"
|
||||
:inline="true"
|
||||
/>
|
||||
<actor-card
|
||||
:inline="true"
|
||||
:actor="contact"
|
||||
v-for="contact in event.contacts"
|
||||
:key="contact.id"
|
||||
/>
|
||||
</event-metadata-block>
|
||||
<event-metadata-block
|
||||
v-if="event.onlineAddress && urlToHostname(event.onlineAddress)"
|
||||
:title="t('Website')"
|
||||
>
|
||||
<template #icon>
|
||||
<Link :size="36" />
|
||||
</template>
|
||||
<a
|
||||
target="_blank"
|
||||
class="underline"
|
||||
rel="noopener noreferrer ugc"
|
||||
:href="event.onlineAddress"
|
||||
:title="
|
||||
t('View page on {hostname} (in a new window)', {
|
||||
hostname: urlToHostname(event.onlineAddress),
|
||||
})
|
||||
"
|
||||
>{{ simpleURL(event.onlineAddress) }}</a
|
||||
>
|
||||
</event-metadata-block>
|
||||
<event-metadata-block
|
||||
v-for="extra in extraMetadata"
|
||||
:title="extra.title || extra.label"
|
||||
:key="extra.key"
|
||||
>
|
||||
<template #icon>
|
||||
<img
|
||||
v-if="extra.icon && extra.icon.substring(0, 7) === 'mz:icon'"
|
||||
:src="`/img/${extra.icon.substring(8)}_monochrome.svg`"
|
||||
width="36"
|
||||
height="36"
|
||||
alt=""
|
||||
/>
|
||||
<o-icon v-else-if="extra.icon" :icon="extra.icon" customSize="36" />
|
||||
<o-icon v-else customSize="36" icon="help-circle" />
|
||||
</template>
|
||||
<span
|
||||
v-if="
|
||||
((extra.type == EventMetadataType.STRING &&
|
||||
extra.keyType == EventMetadataKeyType.CHOICE) ||
|
||||
extra.type === EventMetadataType.BOOLEAN) &&
|
||||
extra.choices &&
|
||||
extra.choices[extra.value]
|
||||
"
|
||||
>
|
||||
{{ extra.choices[extra.value] }}
|
||||
</span>
|
||||
<a
|
||||
v-else-if="
|
||||
extra.type == EventMetadataType.STRING &&
|
||||
extra.keyType == EventMetadataKeyType.URL
|
||||
"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer ugc"
|
||||
:href="extra.value"
|
||||
:title="
|
||||
t('View page on {hostname} (in a new window)', {
|
||||
hostname: urlToHostname(extra.value),
|
||||
})
|
||||
"
|
||||
>{{ simpleURL(extra.value) }}</a
|
||||
>
|
||||
<a
|
||||
v-else-if="
|
||||
extra.type == EventMetadataType.STRING &&
|
||||
extra.keyType == EventMetadataKeyType.HANDLE
|
||||
"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer ugc"
|
||||
:href="accountURL(extra)"
|
||||
:title="
|
||||
t('View account on {hostname} (in a new window)', {
|
||||
hostname: urlToHostname(accountURL(extra)),
|
||||
})
|
||||
"
|
||||
>{{ extra.value }}</a
|
||||
>
|
||||
<span v-else>{{ extra.value }}</span>
|
||||
</event-metadata-block>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { Address, addressToPoiInfos } from "@/types/address.model";
|
||||
import { EventMetadataKeyType, EventMetadataType } from "@/types/enums";
|
||||
import { IEvent } from "@/types/event.model";
|
||||
import { computed } from "vue";
|
||||
import RouteName from "../../router/name";
|
||||
import { usernameWithDomain } from "../../types/actor";
|
||||
import EventMetadataBlock from "./EventMetadataBlock.vue";
|
||||
import EventFullDate from "./EventFullDate.vue";
|
||||
import ActorCard from "../../components/Account/ActorCard.vue";
|
||||
import AddressInfo from "../../components/Address/AddressInfo.vue";
|
||||
import { IEventMetadataDescription } from "@/types/event-metadata";
|
||||
import { eventMetaDataList } from "../../services/EventMetadata";
|
||||
import { IUser } from "@/types/current-user.model";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import Earth from "vue-material-design-icons/Earth.vue";
|
||||
import Calendar from "vue-material-design-icons/Calendar.vue";
|
||||
import Link from "vue-material-design-icons/Link.vue";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
event: IEvent;
|
||||
user: IUser | undefined;
|
||||
showMap?: boolean;
|
||||
}>(),
|
||||
{ showMap: false }
|
||||
);
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
const physicalAddress = computed((): Address | null => {
|
||||
if (!props.event.physicalAddress) return null;
|
||||
|
||||
return new Address(props.event.physicalAddress);
|
||||
});
|
||||
|
||||
const addressPOIInfos = computed(() => {
|
||||
if (!props.event.physicalAddress) return null;
|
||||
return addressToPoiInfos(props.event.physicalAddress);
|
||||
});
|
||||
|
||||
const extraMetadata = computed((): IEventMetadataDescription[] => {
|
||||
return props.event.metadata.map((val) => {
|
||||
const def = eventMetaDataList.find((dat) => dat.key === val.key);
|
||||
return {
|
||||
...def,
|
||||
...val,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const urlToHostname = (url: string | undefined): string | null => {
|
||||
if (!url) return null;
|
||||
try {
|
||||
return new URL(url).hostname;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const simpleURL = (url: string): string | null => {
|
||||
try {
|
||||
const uri = new URL(url);
|
||||
return `${removeWWW(uri.hostname)}${uri.pathname}${uri.search}${uri.hash}`;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const removeWWW = (string: string): string => {
|
||||
return string.replace(/^www./, "");
|
||||
};
|
||||
|
||||
const accountURL = (extra: IEventMetadataDescription): string | undefined => {
|
||||
switch (extra.key) {
|
||||
case "mz:social:twitter:account": {
|
||||
const handle =
|
||||
extra.value[0] === "@" ? extra.value.slice(1) : extra.value;
|
||||
return `https://twitter.com/${handle}`;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const userTimezone = computed((): string | undefined => {
|
||||
return props.user?.settings?.timezone;
|
||||
});
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
:deep(.metadata-organized-by) {
|
||||
.v-popover.popover .trigger {
|
||||
width: 100%;
|
||||
.media-content {
|
||||
width: calc(100% - 32px - 1rem);
|
||||
max-width: 80vw;
|
||||
|
||||
p.has-text-grey-dark {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div.address-wrapper {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-wrap: wrap;
|
||||
|
||||
div.address {
|
||||
flex: 1;
|
||||
|
||||
.map-show-button {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
181
src/components/Event/EventMinimalistCard.vue
Normal file
181
src/components/Event/EventMinimalistCard.vue
Normal file
@@ -0,0 +1,181 @@
|
||||
<template>
|
||||
<router-link
|
||||
class="block md:flex gap-x-2 gap-y-3 bg-white dark:bg-violet-2 rounded-lg shadow-md w-full"
|
||||
dir="auto"
|
||||
:to="{ name: RouteName.EVENT, params: { uuid: event.uuid } }"
|
||||
>
|
||||
<div class="event-preview mr-0 ml-0">
|
||||
<div class="relative w-full">
|
||||
<div class="flex absolute bottom-2 left-2 z-10 date-component">
|
||||
<date-calendar-icon :date="event.beginsOn.toString()" :small="true" />
|
||||
</div>
|
||||
<lazy-image-wrapper
|
||||
:picture="event.picture"
|
||||
:rounded="true"
|
||||
class="object-cover flex-none h-40 md:w-48 rounded-t-lg md:rounded-none md:rounded-l-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-2">
|
||||
<h3
|
||||
class="pb-2 text-lg leading-6 line-clamp-3 font-bold text-violet-title dark:text-white"
|
||||
:lang="event.language"
|
||||
dir="auto"
|
||||
>
|
||||
<tag
|
||||
variant="info"
|
||||
class="mr-1"
|
||||
v-if="event.status === EventStatus.TENTATIVE"
|
||||
>
|
||||
{{ $t("Tentative") }}
|
||||
</tag>
|
||||
<tag
|
||||
variant="danger"
|
||||
class="mr-1"
|
||||
v-if="event.status === EventStatus.CANCELLED"
|
||||
>
|
||||
{{ $t("Cancelled") }}
|
||||
</tag>
|
||||
<tag
|
||||
class="mr-2 font-normal"
|
||||
variant="warning"
|
||||
size="medium"
|
||||
v-if="event.draft"
|
||||
>{{ $t("Draft") }}</tag
|
||||
>
|
||||
{{ event.title }}
|
||||
</h3>
|
||||
<inline-address
|
||||
v-if="event.physicalAddress"
|
||||
class=""
|
||||
:physical-address="event.physicalAddress"
|
||||
/>
|
||||
<div class="" v-else-if="event.options && event.options.isOnline">
|
||||
<Video />
|
||||
<span>{{ $t("Online") }}</span>
|
||||
</div>
|
||||
<div class="flex gap-1" v-if="showOrganizer">
|
||||
<figure class="" v-if="organizer(event) && organizer(event)?.avatar">
|
||||
<img
|
||||
class="rounded-full"
|
||||
:src="organizer(event)?.avatar?.url"
|
||||
alt=""
|
||||
width="24"
|
||||
height="24"
|
||||
/>
|
||||
</figure>
|
||||
<AccountCircle v-else :size="24" />
|
||||
<span class="">
|
||||
{{ organizerDisplayName(event) }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="flex gap-1">
|
||||
<AccountMultiple />
|
||||
<span v-if="event.options.maximumAttendeeCapacity !== 0">
|
||||
{{
|
||||
$t(
|
||||
"{available}/{capacity} available places",
|
||||
{
|
||||
available:
|
||||
event.options.maximumAttendeeCapacity -
|
||||
event.participantStats.participant,
|
||||
capacity: event.options.maximumAttendeeCapacity,
|
||||
},
|
||||
event.options.maximumAttendeeCapacity -
|
||||
event.participantStats.participant
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{
|
||||
$t(
|
||||
"{count} participants",
|
||||
{
|
||||
count: event.participantStats.participant,
|
||||
},
|
||||
event.participantStats.participant
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
<span v-if="event.participantStats.notApproved > 0">
|
||||
<o-button
|
||||
variant="text"
|
||||
@click="
|
||||
gotToWithCheck(participation, {
|
||||
name: RouteName.PARTICIPATIONS,
|
||||
query: { role: ParticipantRole.NOT_APPROVED },
|
||||
params: { eventId: event.uuid },
|
||||
})
|
||||
"
|
||||
>
|
||||
{{
|
||||
$t(
|
||||
"{count} requests waiting",
|
||||
|
||||
{
|
||||
count: event.participantStats.notApproved,
|
||||
},
|
||||
event.participantStats.notApproved
|
||||
)
|
||||
}}
|
||||
</o-button>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</router-link>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { IEvent, organizer, organizerDisplayName } from "@/types/event.model";
|
||||
import DateCalendarIcon from "@/components/Event/DateCalendarIcon.vue";
|
||||
import { EventStatus, ParticipantRole } from "@/types/enums";
|
||||
import RouteName from "../../router/name";
|
||||
import LazyImageWrapper from "@/components/Image/LazyImageWrapper.vue";
|
||||
import InlineAddress from "@/components/Address/InlineAddress.vue";
|
||||
import Video from "vue-material-design-icons/Video.vue";
|
||||
import AccountCircle from "vue-material-design-icons/AccountCircle.vue";
|
||||
import AccountMultiple from "vue-material-design-icons/AccountMultiple.vue";
|
||||
import Tag from "@/components/TagElement.vue";
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
event: IEvent;
|
||||
showOrganizer?: boolean;
|
||||
}>(),
|
||||
{ showOrganizer: false }
|
||||
);
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
@use "@/styles/_mixins" as *;
|
||||
|
||||
.event-minimalist-card-wrapper {
|
||||
// display: grid;
|
||||
// grid-gap: 5px 10px;
|
||||
grid-template-areas: "preview" "body";
|
||||
// color: initial;
|
||||
|
||||
// @include desktop {
|
||||
grid-template-columns: 200px 3fr;
|
||||
grid-template-areas: "preview body";
|
||||
// }
|
||||
|
||||
// .event-preview {
|
||||
// & > div {
|
||||
// position: relative;
|
||||
// height: 120px;
|
||||
// width: 100%;
|
||||
|
||||
// div.date-component {
|
||||
// display: flex;
|
||||
// position: absolute;
|
||||
// bottom: 5px;
|
||||
// left: 5px;
|
||||
// z-index: 1;
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// .calendar-icon {
|
||||
// @include margin-right(1rem);
|
||||
// }
|
||||
}
|
||||
</style>
|
||||
618
src/components/Event/EventParticipationCard.vue
Normal file
618
src/components/Event/EventParticipationCard.vue
Normal file
@@ -0,0 +1,618 @@
|
||||
<template>
|
||||
<article
|
||||
class="bg-white dark:bg-mbz-purple dark:hover:bg-mbz-purple-400 mb-5 mt-4 pb-2 md:p-0 rounded-t-lg"
|
||||
>
|
||||
<div
|
||||
class="bg-mbz-yellow-alt-100 flex p-2 text-violet-title rounded-t-lg"
|
||||
dir="auto"
|
||||
>
|
||||
<figure
|
||||
class="image is-24x24 ltr:pr-1 rtl:pl-1"
|
||||
v-if="participation.actor.avatar"
|
||||
>
|
||||
<img
|
||||
class="rounded"
|
||||
:src="participation.actor.avatar.url"
|
||||
alt=""
|
||||
height="24"
|
||||
width="24"
|
||||
/>
|
||||
</figure>
|
||||
<AccountCircle class="ltr:pr-1 rtl:pl-1" v-else />
|
||||
{{ displayNameAndUsername(participation.actor) }}
|
||||
</div>
|
||||
<div class="list-card flex flex-col relative">
|
||||
<div
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-6 gap-x-1.5 md:gap-y-3 gapt-x-3"
|
||||
>
|
||||
<div class="mr-0 ml-0">
|
||||
<div class="h-40 relative w-full">
|
||||
<div class="flex absolute bottom-2 left-2 z-10">
|
||||
<date-calendar-icon
|
||||
:date="participation.event.beginsOn.toString()"
|
||||
:small="true"
|
||||
/>
|
||||
</div>
|
||||
<router-link
|
||||
class="h-full"
|
||||
:to="{
|
||||
name: RouteName.EVENT,
|
||||
params: { uuid: participation.event.uuid },
|
||||
}"
|
||||
>
|
||||
<lazy-image-wrapper
|
||||
:rounded="true"
|
||||
:picture="participation.event.picture"
|
||||
style="
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
"
|
||||
/>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-card-content lg:col-span-4 flex-1 p-2">
|
||||
<div class="flex items-center pt-2" dir="auto">
|
||||
<Tag
|
||||
variant="info"
|
||||
class="mr-1 mb-1"
|
||||
size="medium"
|
||||
v-if="participation.event.status === EventStatus.TENTATIVE"
|
||||
>
|
||||
{{ t("Tentative") }}
|
||||
</Tag>
|
||||
<Tag
|
||||
variant="danger"
|
||||
class="mr-1 mb-1"
|
||||
size="medium"
|
||||
v-if="participation.event.status === EventStatus.CANCELLED"
|
||||
>
|
||||
{{ t("Cancelled") }}
|
||||
</Tag>
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.EVENT,
|
||||
params: { uuid: participation.event.uuid },
|
||||
}"
|
||||
>
|
||||
<h3
|
||||
class="line-clamp-3 font-bold mx-auto my-0 text-lg text-violet-title dark:text-white"
|
||||
:lang="participation.event.language"
|
||||
>
|
||||
{{ participation.event.title }}
|
||||
</h3>
|
||||
</router-link>
|
||||
</div>
|
||||
<inline-address
|
||||
v-if="participation.event.physicalAddress"
|
||||
:physical-address="participation.event.physicalAddress"
|
||||
/>
|
||||
<div
|
||||
class="flex gap-1"
|
||||
v-else-if="
|
||||
participation.event.options &&
|
||||
participation.event.options.isOnline
|
||||
"
|
||||
>
|
||||
<Video />
|
||||
<span>{{ t("Online") }}</span>
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
<figure class="" v-if="actorAvatarURL">
|
||||
<img
|
||||
class="rounded"
|
||||
:src="actorAvatarURL"
|
||||
alt=""
|
||||
width="24"
|
||||
height="24"
|
||||
/>
|
||||
</figure>
|
||||
<AccountCircle v-else />
|
||||
<span>
|
||||
{{ organizerDisplayName(participation.event) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<AccountGroup :class="{ 'has-text-danger': lastSeatsLeft }" />
|
||||
<span
|
||||
class="flex items-center py-0 px-2"
|
||||
v-if="participation.role !== ParticipantRole.NOT_APPROVED"
|
||||
>
|
||||
<!-- Less than 10 seats left -->
|
||||
<span class="has-text-danger" v-if="lastSeatsLeft">
|
||||
{{
|
||||
t("{number} seats left", {
|
||||
number: seatsLeft,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="
|
||||
participation.event.options.maximumAttendeeCapacity !== 0
|
||||
"
|
||||
>
|
||||
{{
|
||||
t(
|
||||
"{available}/{capacity} available places",
|
||||
{
|
||||
available:
|
||||
participation.event.options.maximumAttendeeCapacity -
|
||||
participation.event.participantStats.participant,
|
||||
capacity:
|
||||
participation.event.options.maximumAttendeeCapacity,
|
||||
},
|
||||
participation.event.options.maximumAttendeeCapacity -
|
||||
participation.event.participantStats.participant
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{
|
||||
t(
|
||||
"{count} participants",
|
||||
{
|
||||
count: participation.event.participantStats.participant,
|
||||
},
|
||||
participation.event.participantStats.participant
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
<o-button
|
||||
v-if="participation.event.participantStats.notApproved > 0"
|
||||
variant="text"
|
||||
@click="
|
||||
gotToWithCheck(participation, {
|
||||
name: RouteName.PARTICIPATIONS,
|
||||
query: { role: ParticipantRole.NOT_APPROVED },
|
||||
params: { eventId: participation.event.uuid },
|
||||
})
|
||||
"
|
||||
>
|
||||
{{
|
||||
t(
|
||||
"{count} requests waiting",
|
||||
{
|
||||
count: participation.event.participantStats.notApproved,
|
||||
},
|
||||
participation.event.participantStats.notApproved
|
||||
)
|
||||
}}
|
||||
</o-button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<o-dropdown
|
||||
aria-role="list"
|
||||
class="text-center self-center md:col-span-2 lg:col-span-1"
|
||||
>
|
||||
<template #trigger>
|
||||
<o-button icon-right="dots-horizontal">
|
||||
{{ t("Actions") }}
|
||||
</o-button>
|
||||
</template>
|
||||
<o-dropdown-item
|
||||
aria-role="listitem"
|
||||
v-if="
|
||||
![
|
||||
ParticipantRole.PARTICIPANT,
|
||||
ParticipantRole.NOT_APPROVED,
|
||||
].includes(participation.role)
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="flex gap-1"
|
||||
@click="
|
||||
gotToWithCheck(participation, {
|
||||
name: RouteName.EDIT_EVENT,
|
||||
params: { eventId: participation.event.uuid },
|
||||
})
|
||||
"
|
||||
>
|
||||
<Pencil />
|
||||
{{ t("Edit") }}
|
||||
</div>
|
||||
</o-dropdown-item>
|
||||
|
||||
<o-dropdown-item
|
||||
aria-role="listitem"
|
||||
v-if="participation.role === ParticipantRole.CREATOR"
|
||||
>
|
||||
<div
|
||||
class="flex gap-1"
|
||||
@click="
|
||||
gotToWithCheck(participation, {
|
||||
name: RouteName.DUPLICATE_EVENT,
|
||||
params: { eventId: participation.event.uuid },
|
||||
})
|
||||
"
|
||||
>
|
||||
<ContentDuplicate />
|
||||
{{ t("Duplicate") }}
|
||||
</div>
|
||||
</o-dropdown-item>
|
||||
|
||||
<o-dropdown-item
|
||||
aria-role="listitem"
|
||||
v-if="
|
||||
![
|
||||
ParticipantRole.PARTICIPANT,
|
||||
ParticipantRole.NOT_APPROVED,
|
||||
].includes(participation.role)
|
||||
"
|
||||
>
|
||||
<div @click="openDeleteEventModalWrapper" class="flex gap-1">
|
||||
<Delete />
|
||||
{{ t("Delete") }}
|
||||
</div>
|
||||
</o-dropdown-item>
|
||||
|
||||
<o-dropdown-item
|
||||
aria-role="listitem"
|
||||
v-if="
|
||||
![
|
||||
ParticipantRole.PARTICIPANT,
|
||||
ParticipantRole.NOT_APPROVED,
|
||||
].includes(participation.role)
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="flex gap-1"
|
||||
@click="
|
||||
gotToWithCheck(participation, {
|
||||
name: RouteName.PARTICIPATIONS,
|
||||
params: { eventId: participation.event.uuid },
|
||||
})
|
||||
"
|
||||
>
|
||||
<AccountMultiplePlus />
|
||||
{{ t("Manage participations") }}
|
||||
</div>
|
||||
</o-dropdown-item>
|
||||
|
||||
<o-dropdown-item aria-role="listitem">
|
||||
<router-link
|
||||
class="flex gap-1"
|
||||
:to="{
|
||||
name: RouteName.EVENT,
|
||||
params: { uuid: participation.event.uuid },
|
||||
}"
|
||||
>
|
||||
<ViewCompact />
|
||||
{{ t("View event page") }}
|
||||
</router-link>
|
||||
</o-dropdown-item>
|
||||
</o-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import DateCalendarIcon from "@/components/Event/DateCalendarIcon.vue";
|
||||
import { EventStatus, ParticipantRole } from "@/types/enums";
|
||||
import { IParticipant } from "@/types/participant.model";
|
||||
import {
|
||||
IEvent,
|
||||
IEventCardOptions,
|
||||
organizerAvatarUrl,
|
||||
organizerDisplayName,
|
||||
} from "@/types/event.model";
|
||||
import { displayNameAndUsername, IPerson } from "@/types/actor";
|
||||
import { CURRENT_ACTOR_CLIENT } from "@/graphql/actor";
|
||||
import RouteName from "@/router/name";
|
||||
import { changeIdentity } from "@/utils/identity";
|
||||
import LazyImageWrapper from "@/components/Image/LazyImageWrapper.vue";
|
||||
import InlineAddress from "@/components/Address/InlineAddress.vue";
|
||||
import { RouteLocationRaw, useRouter } from "vue-router";
|
||||
import Pencil from "vue-material-design-icons/Pencil.vue";
|
||||
import ContentDuplicate from "vue-material-design-icons/ContentDuplicate.vue";
|
||||
import Delete from "vue-material-design-icons/Delete.vue";
|
||||
import AccountMultiplePlus from "vue-material-design-icons/AccountMultiplePlus.vue";
|
||||
import ViewCompact from "vue-material-design-icons/ViewCompact.vue";
|
||||
import AccountCircle from "vue-material-design-icons/AccountCircle.vue";
|
||||
import AccountGroup from "vue-material-design-icons/AccountGroup.vue";
|
||||
import Video from "vue-material-design-icons/Video.vue";
|
||||
import { useProgrammatic } from "@oruga-ui/oruga-next";
|
||||
import { computed, inject } from "vue";
|
||||
import { useQuery } from "@vue/apollo-composable";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Dialog } from "@/plugins/dialog";
|
||||
import { Snackbar } from "@/plugins/snackbar";
|
||||
import { useDeleteEvent } from "@/composition/apollo/event";
|
||||
import Tag from "@/components/TagElement.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
participation: IParticipant;
|
||||
options?: IEventCardOptions;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(["eventDeleted"]);
|
||||
|
||||
const { result: currentActorResult } = useQuery(CURRENT_ACTOR_CLIENT);
|
||||
const currentActor = computed(() => currentActorResult.value?.currentActor);
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
const dialog = inject<Dialog>("dialog");
|
||||
|
||||
const openDeleteEventModal = (
|
||||
event: IEvent,
|
||||
callback: (anEvent: IEvent) => any
|
||||
): void => {
|
||||
function escapeRegExp(string: string) {
|
||||
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string
|
||||
}
|
||||
const participantsLength = event.participantStats.participant;
|
||||
const prefix = participantsLength
|
||||
? t(
|
||||
"There are {participants} participants.",
|
||||
{
|
||||
participants: participantsLength,
|
||||
},
|
||||
participantsLength
|
||||
)
|
||||
: "";
|
||||
|
||||
dialog?.prompt({
|
||||
variant: "danger",
|
||||
title: t("Delete event"),
|
||||
message: `${prefix}
|
||||
${t(
|
||||
"Are you sure you want to delete this event? This action cannot be reverted."
|
||||
)}
|
||||
<br><br>
|
||||
${t('To confirm, type your event title "{eventTitle}"', {
|
||||
eventTitle: event.title,
|
||||
})}`,
|
||||
confirmText: t("Delete {eventTitle}", {
|
||||
eventTitle: event.title,
|
||||
}),
|
||||
inputAttrs: {
|
||||
placeholder: event.title,
|
||||
pattern: escapeRegExp(event.title),
|
||||
},
|
||||
onConfirm: () => callback(event),
|
||||
});
|
||||
};
|
||||
|
||||
const { oruga } = useProgrammatic();
|
||||
const snackbar = inject<Snackbar>("snackbar");
|
||||
|
||||
const {
|
||||
mutate: deleteEvent,
|
||||
onDone: onDeleteEventDone,
|
||||
onError: onDeleteEventError,
|
||||
} = useDeleteEvent();
|
||||
|
||||
onDeleteEventDone(() => {
|
||||
/**
|
||||
* When the event corresponding has been deleted (by the organizer).
|
||||
* A notification is already triggered.
|
||||
*
|
||||
* @type {string}
|
||||
*/
|
||||
emit("eventDeleted", props.participation.event.id);
|
||||
|
||||
oruga.notification.open({
|
||||
message: t("Event {eventTitle} deleted", {
|
||||
eventTitle: props.participation.event.title,
|
||||
}),
|
||||
variant: "success",
|
||||
position: "bottom-right",
|
||||
duration: 5000,
|
||||
});
|
||||
});
|
||||
|
||||
onDeleteEventError((error) => {
|
||||
snackbar?.open({
|
||||
message: error.message,
|
||||
variant: "danger",
|
||||
position: "bottom",
|
||||
});
|
||||
|
||||
console.error(error);
|
||||
});
|
||||
|
||||
/**
|
||||
* Delete the event
|
||||
*/
|
||||
const openDeleteEventModalWrapper = () => {
|
||||
openDeleteEventModal(props.participation.event, (event: IEvent) =>
|
||||
deleteEvent({ eventId: event.id ?? "" })
|
||||
);
|
||||
};
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const gotToWithCheck = async (
|
||||
participation: IParticipant,
|
||||
route: RouteLocationRaw
|
||||
): Promise<any> => {
|
||||
if (
|
||||
participation.actor.id !== currentActor.value.id &&
|
||||
participation.event.organizerActor
|
||||
) {
|
||||
const organizerActor = participation.event.organizerActor as IPerson;
|
||||
await changeIdentity(organizerActor);
|
||||
oruga.notification.open({
|
||||
message: t(
|
||||
"Current identity has been changed to {identityName} in order to manage this event.",
|
||||
{
|
||||
identityName: organizerActor.preferredUsername,
|
||||
}
|
||||
),
|
||||
variant: "info",
|
||||
position: "bottom-right",
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
return router.push(route);
|
||||
};
|
||||
|
||||
// const organizerActor = computed<IActor | undefined>(() => {
|
||||
// if (
|
||||
// props.participation.event.attributedTo &&
|
||||
// props.participation.event.attributedTo.id
|
||||
// ) {
|
||||
// return props.participation.event.attributedTo;
|
||||
// }
|
||||
// return props.participation.event.organizerActor;
|
||||
// });
|
||||
|
||||
const seatsLeft = computed<number | null>(() => {
|
||||
if (props.participation.event.options.maximumAttendeeCapacity > 0) {
|
||||
return (
|
||||
props.participation.event.options.maximumAttendeeCapacity -
|
||||
props.participation.event.participantStats.participant
|
||||
);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const lastSeatsLeft = computed<boolean>(() => {
|
||||
if (seatsLeft.value) {
|
||||
return seatsLeft.value < 10;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const actorAvatarURL = computed<string | null>(() =>
|
||||
organizerAvatarUrl(props.participation.event)
|
||||
);
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use "@/styles/_mixins" as *;
|
||||
|
||||
article.box {
|
||||
// div.tag-container {
|
||||
// position: absolute;
|
||||
// top: 10px;
|
||||
// right: 0;
|
||||
// @include margin-left(-5px);
|
||||
// z-index: 10;
|
||||
// max-width: 40%;
|
||||
|
||||
// span.tag {
|
||||
// margin: 5px auto;
|
||||
// box-shadow: 0 0 5px 0 rgba(0, 0, 0, 1);
|
||||
// /*word-break: break-all;*/
|
||||
// text-overflow: ellipsis;
|
||||
// overflow: hidden;
|
||||
// display: block;
|
||||
// /*text-align: right;*/
|
||||
// font-size: 1em;
|
||||
// /*padding: 0 1px;*/
|
||||
// line-height: 1.75em;
|
||||
// }
|
||||
// }
|
||||
|
||||
.list-card {
|
||||
// display: flex;
|
||||
// padding: 0 6px 0 0;
|
||||
// position: relative;
|
||||
// flex-direction: column;
|
||||
|
||||
.content-and-actions {
|
||||
// display: grid;
|
||||
// grid-gap: 5px 10px;
|
||||
grid-template-areas: "preview" "body" "actions";
|
||||
|
||||
// @include tablet {
|
||||
// grid-template-columns: 1fr 3fr;
|
||||
// grid-template-areas: "preview body" "actions actions";
|
||||
// }
|
||||
|
||||
// @include desktop {
|
||||
// grid-template-columns: 1fr 3fr 1fr;
|
||||
// grid-template-areas: "preview body actions";
|
||||
// }
|
||||
|
||||
.event-preview {
|
||||
grid-area: preview;
|
||||
|
||||
& > div {
|
||||
height: 128px;
|
||||
// width: 100%;
|
||||
// position: relative;
|
||||
|
||||
// div.date-component {
|
||||
// display: flex;
|
||||
// position: absolute;
|
||||
// bottom: 5px;
|
||||
// left: 5px;
|
||||
// z-index: 1;
|
||||
// }
|
||||
|
||||
// img {
|
||||
// width: 100%;
|
||||
// object-position: center;
|
||||
// object-fit: cover;
|
||||
// height: 100%;
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
// padding: 7px;
|
||||
// cursor: pointer;
|
||||
// align-self: center;
|
||||
// justify-self: center;
|
||||
grid-area: actions;
|
||||
}
|
||||
|
||||
div.list-card-content {
|
||||
// flex: 1;
|
||||
// padding: 5px;
|
||||
grid-area: body;
|
||||
|
||||
// .participant-stats {
|
||||
// display: flex;
|
||||
// align-items: center;
|
||||
// padding: 0 5px;
|
||||
// }
|
||||
|
||||
// div.title-wrapper {
|
||||
// display: flex;
|
||||
// align-items: center;
|
||||
// padding-top: 5px;
|
||||
|
||||
// a {
|
||||
// text-decoration: none;
|
||||
// padding-bottom: 5px;
|
||||
// }
|
||||
|
||||
// .title {
|
||||
// display: -webkit-box;
|
||||
// -webkit-line-clamp: 3;
|
||||
// -webkit-box-orient: vertical;
|
||||
// overflow: hidden;
|
||||
// font-size: 18px;
|
||||
// line-height: 24px;
|
||||
// margin: auto 0;
|
||||
// font-weight: bold;
|
||||
// }
|
||||
// }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// .identity-header {
|
||||
// display: flex;
|
||||
// padding: 5px;
|
||||
|
||||
// figure,
|
||||
// span.icon {
|
||||
// @include padding-right(3px);
|
||||
// }
|
||||
// }
|
||||
|
||||
// & > .columns {
|
||||
// padding: 1.25rem;
|
||||
// }
|
||||
// padding: 0;
|
||||
}
|
||||
</style>
|
||||
30
src/components/Event/ExternalParticipationButton.vue
Normal file
30
src/components/Event/ExternalParticipationButton.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<o-button
|
||||
tag="a"
|
||||
:href="
|
||||
event.externalParticipationUrl
|
||||
? encodeURI(`${event.externalParticipationUrl}?uuid=${event.uuid}`)
|
||||
: '#'
|
||||
"
|
||||
rel="noopener ugc"
|
||||
target="_blank"
|
||||
:disabled="!event.externalParticipationUrl"
|
||||
icon-right="OpenInNew"
|
||||
>
|
||||
{{ t("Go to booking") }}
|
||||
</o-button>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue";
|
||||
import { IEvent } from "../../types/event.model";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
const props = defineProps<{
|
||||
event: IEvent;
|
||||
}>();
|
||||
|
||||
const event = computed(() => props.event);
|
||||
</script>
|
||||
502
src/components/Event/FullAddressAutoComplete.vue
Normal file
502
src/components/Event/FullAddressAutoComplete.vue
Normal file
@@ -0,0 +1,502 @@
|
||||
<template>
|
||||
<div class="address-autocomplete">
|
||||
<div class="">
|
||||
<o-field
|
||||
:label-for="id"
|
||||
:message="fieldErrors"
|
||||
:variant="fieldErrors ? 'danger' : ''"
|
||||
class="!-mt-2"
|
||||
:labelClass="labelClass"
|
||||
>
|
||||
<template #label>
|
||||
{{ actualLabel }}
|
||||
</template>
|
||||
<o-button
|
||||
v-if="canShowLocateMeButton"
|
||||
class="!h-auto"
|
||||
ref="mapMarker"
|
||||
icon-right="map-marker"
|
||||
@click="locateMe"
|
||||
:title="t('Use my location')"
|
||||
/>
|
||||
<o-autocomplete
|
||||
:data="addressData"
|
||||
v-model="queryTextWithDefault"
|
||||
:placeholder="placeholderWithDefault"
|
||||
:customFormatter="(elem: IAddress) => addressFullName(elem)"
|
||||
:debounceTyping="debounceDelay"
|
||||
@typing="asyncData"
|
||||
:icon="canShowLocateMeButton ? null : 'map-marker'"
|
||||
expanded
|
||||
@select="setSelected"
|
||||
:id="id"
|
||||
:disabled="disabled"
|
||||
dir="auto"
|
||||
class="!mt-0"
|
||||
>
|
||||
<template #default="{ option }">
|
||||
<p class="flex gap-1">
|
||||
<o-icon :icon="addressToPoiInfos(option).poiIcon.icon" />
|
||||
<b>{{ addressToPoiInfos(option).name }}</b>
|
||||
</p>
|
||||
<small>{{ addressToPoiInfos(option).alternativeName }}</small>
|
||||
</template>
|
||||
<template #empty>
|
||||
<template v-if="isFetching">{{ t("Searching…") }}</template>
|
||||
<template v-else-if="queryTextWithDefault.length >= 3">
|
||||
<p>
|
||||
{{
|
||||
t('No results for "{queryText}"', {
|
||||
queryText: queryTextWithDefault,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<p>
|
||||
{{
|
||||
t(
|
||||
"You can try another search term or add the address details manually below."
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</template>
|
||||
</template>
|
||||
</o-autocomplete>
|
||||
<o-button
|
||||
:disabled="!queryTextWithDefault"
|
||||
@click="resetAddress"
|
||||
class="reset-area !h-auto"
|
||||
icon-left="close"
|
||||
:title="t('Clear address field')"
|
||||
/>
|
||||
</o-field>
|
||||
<p v-if="gettingLocation" class="flex gap-2">
|
||||
<Loading class="animate-spin" />
|
||||
{{ t("Getting location") }}
|
||||
</p>
|
||||
<div
|
||||
class="mt-2 p-2 rounded-lg shadow-md bg-white dark:bg-violet-3"
|
||||
v-if="!hideSelected && (selected?.originId || selected?.url)"
|
||||
>
|
||||
<div class="">
|
||||
<address-info
|
||||
:address="selected"
|
||||
:show-icon="true"
|
||||
:show-timezone="true"
|
||||
:user-timezone="userTimezone"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<o-collapse
|
||||
v-model:open="detailsAddress"
|
||||
:aria-id="`${id}-address-details`"
|
||||
class="my-3"
|
||||
v-if="allowManualDetails"
|
||||
>
|
||||
<template #trigger>
|
||||
<o-button
|
||||
variant="primary"
|
||||
outlined
|
||||
:aria-controls="`${id}-address-details`"
|
||||
:icon-right="detailsAddress ? 'chevron-up' : 'chevron-down'"
|
||||
>
|
||||
{{ t("Details") }}
|
||||
</o-button>
|
||||
</template>
|
||||
<form @submit.prevent="saveManualAddress">
|
||||
<header>
|
||||
<h2>{{ t("Manually enter address") }}</h2>
|
||||
</header>
|
||||
<section>
|
||||
<o-field :label="t('Name')" labelFor="addressNameInput">
|
||||
<o-input
|
||||
aria-required="true"
|
||||
required
|
||||
v-model="selected.description"
|
||||
id="addressNameInput"
|
||||
/>
|
||||
</o-field>
|
||||
|
||||
<o-field :label="t('Street')" labelFor="streetInput">
|
||||
<o-input v-model="selected.street" id="streetInput" />
|
||||
</o-field>
|
||||
|
||||
<o-field grouped>
|
||||
<o-field :label="t('Postal Code')" labelFor="postalCodeInput">
|
||||
<o-input v-model="selected.postalCode" id="postalCodeInput" />
|
||||
</o-field>
|
||||
|
||||
<o-field :label="t('Locality')" labelFor="localityInput">
|
||||
<o-input v-model="selected.locality" id="localityInput" />
|
||||
</o-field>
|
||||
</o-field>
|
||||
|
||||
<o-field grouped>
|
||||
<o-field :label="t('Region')" labelFor="regionInput">
|
||||
<o-input v-model="selected.region" id="regionInput" />
|
||||
</o-field>
|
||||
|
||||
<o-field :label="t('Country')" labelFor="countryInput">
|
||||
<o-input v-model="selected.country" id="countryInput" />
|
||||
</o-field>
|
||||
</o-field>
|
||||
</section>
|
||||
<footer class="mt-3 flex gap-2 items-center">
|
||||
<o-button native-type="submit">
|
||||
{{ t("Save") }}
|
||||
</o-button>
|
||||
<o-button outlined type="button" @click="resetAddress">
|
||||
{{ t("Clear") }}
|
||||
</o-button>
|
||||
<p>
|
||||
{{
|
||||
t(
|
||||
"You can drag and drop the marker below to the desired location"
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</footer>
|
||||
</form>
|
||||
</o-collapse>
|
||||
<div
|
||||
class="map"
|
||||
v-if="!hideMap && !disabled && (selected.geom || detailsAddress)"
|
||||
>
|
||||
<map-leaflet
|
||||
:coords="selected.geom ?? defaultCoords"
|
||||
:marker="mapMarkerValue"
|
||||
:updateDraggableMarkerCallback="reverseGeoCode"
|
||||
:options="{ zoom: mapDefaultZoom }"
|
||||
:readOnly="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { LatLng } from "leaflet";
|
||||
import {
|
||||
Address,
|
||||
IAddress,
|
||||
addressFullName,
|
||||
addressToPoiInfos,
|
||||
resetAddress as resetAddressAction,
|
||||
} from "../../types/address.model";
|
||||
import AddressInfo from "../../components/Address/AddressInfo.vue";
|
||||
import {
|
||||
computed,
|
||||
ref,
|
||||
watch,
|
||||
defineAsyncComponent,
|
||||
onMounted,
|
||||
reactive,
|
||||
onBeforeMount,
|
||||
} from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useGeocodingAutocomplete } from "@/composition/apollo/config";
|
||||
import { ADDRESS } from "@/graphql/address";
|
||||
import { useReverseGeocode } from "@/composition/apollo/address";
|
||||
import { useLazyQuery } from "@vue/apollo-composable";
|
||||
import { AddressSearchType } from "@/types/enums";
|
||||
import Loading from "vue-material-design-icons/Loading.vue";
|
||||
const MapLeaflet = defineAsyncComponent(
|
||||
() => import("@/components/LeafletMap.vue")
|
||||
);
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: IAddress | null;
|
||||
defaultText?: string | null;
|
||||
label?: string;
|
||||
labelClass?: string;
|
||||
userTimezone?: string;
|
||||
disabled?: boolean;
|
||||
hideMap?: boolean;
|
||||
hideSelected?: boolean;
|
||||
placeholder?: string;
|
||||
resultType?: AddressSearchType;
|
||||
defaultCoords?: string;
|
||||
allowManualDetails?: boolean;
|
||||
}>(),
|
||||
{
|
||||
defaultCoords: "0;0",
|
||||
labelClass: "",
|
||||
disabled: false,
|
||||
hideMap: false,
|
||||
hideSelected: false,
|
||||
allowManualDetails: false,
|
||||
}
|
||||
);
|
||||
|
||||
const componentId = ref(0);
|
||||
|
||||
const emit = defineEmits(["update:modelValue"]);
|
||||
|
||||
const gettingLocationError = ref<string | null>(null);
|
||||
const gettingLocation = ref(false);
|
||||
const mapDefaultZoom = computed(() => {
|
||||
if (selected.description) {
|
||||
return 15;
|
||||
}
|
||||
return 5;
|
||||
});
|
||||
|
||||
const addressData = ref<IAddress[]>([]);
|
||||
|
||||
const defaultAddress = new Address();
|
||||
defaultAddress.geom = undefined;
|
||||
defaultAddress.id = undefined;
|
||||
const selected = reactive<IAddress>(defaultAddress);
|
||||
|
||||
const detailsAddress = ref(false);
|
||||
|
||||
const isFetching = ref(false);
|
||||
|
||||
const mapMarker = ref();
|
||||
|
||||
const placeholderWithDefault = computed(
|
||||
() => props.placeholder ?? t("e.g. 10 Rue Jangot")
|
||||
);
|
||||
|
||||
onBeforeMount(() => {
|
||||
componentId.value += 1;
|
||||
});
|
||||
|
||||
const id = computed((): string => {
|
||||
return `full-address-autocomplete-${componentId.value}`;
|
||||
});
|
||||
|
||||
const modelValue = computed(() => props.modelValue);
|
||||
|
||||
watch(modelValue, () => {
|
||||
console.debug("modelValue changed");
|
||||
setSelected(modelValue.value);
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
setSelected(modelValue.value);
|
||||
});
|
||||
|
||||
const setSelected = (newValue: IAddress | null) => {
|
||||
if (!newValue) return;
|
||||
console.debug("setting selected to model value");
|
||||
Object.assign(selected, newValue);
|
||||
emit("update:modelValue", selected);
|
||||
};
|
||||
|
||||
const saveManualAddress = (): void => {
|
||||
console.debug("saving address");
|
||||
selected.id = undefined;
|
||||
selected.originId = undefined;
|
||||
selected.url = undefined;
|
||||
emit("update:modelValue", selected);
|
||||
detailsAddress.value = false;
|
||||
};
|
||||
|
||||
const checkCurrentPosition = (e: LatLng): boolean => {
|
||||
console.debug("checkCurrentPosition");
|
||||
if (!selected?.geom || !e) return false;
|
||||
const lat = parseFloat(selected?.geom.split(";")[1]);
|
||||
const lon = parseFloat(selected?.geom.split(";")[0]);
|
||||
|
||||
return e.lat === lat && e.lng === lon;
|
||||
};
|
||||
|
||||
const { t, locale } = useI18n({ useScope: "global" });
|
||||
|
||||
const actualLabel = computed((): string => {
|
||||
return props.label ?? t("Find an address");
|
||||
});
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
const canShowLocateMeButton = computed((): boolean => {
|
||||
return window.isSecureContext;
|
||||
});
|
||||
|
||||
const { geocodingAutocomplete } = useGeocodingAutocomplete();
|
||||
|
||||
const debounceDelay = computed(() =>
|
||||
geocodingAutocomplete.value === true ? 200 : 2000
|
||||
);
|
||||
|
||||
const { load: searchAddress } = useLazyQuery<{
|
||||
searchAddress: IAddress[];
|
||||
}>(ADDRESS);
|
||||
|
||||
const asyncData = async (query: string): Promise<void> => {
|
||||
console.debug("Finding addresses");
|
||||
if (!query.length) {
|
||||
addressData.value = [];
|
||||
Object.assign(selected, defaultAddress);
|
||||
return;
|
||||
}
|
||||
|
||||
if (query.length < 3) {
|
||||
addressData.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
isFetching.value = true;
|
||||
|
||||
try {
|
||||
const result = await searchAddress(undefined, {
|
||||
query,
|
||||
locale: locale,
|
||||
type: props.resultType,
|
||||
});
|
||||
|
||||
if (!result) return;
|
||||
console.debug("onAddressSearchResult", result.searchAddress);
|
||||
addressData.value = result.searchAddress;
|
||||
isFetching.value = false;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const selectedAddressText = computed(() => {
|
||||
if (!selected) return undefined;
|
||||
return addressFullName(selected);
|
||||
});
|
||||
|
||||
const queryText = ref();
|
||||
|
||||
const queryTextWithDefault = computed({
|
||||
get() {
|
||||
return (
|
||||
queryText.value ?? selectedAddressText.value ?? props.defaultText ?? ""
|
||||
);
|
||||
},
|
||||
set(newValue: string) {
|
||||
queryText.value = newValue;
|
||||
},
|
||||
});
|
||||
|
||||
const resetAddress = (): void => {
|
||||
console.debug("resetting address");
|
||||
emit("update:modelValue", null);
|
||||
resetAddressAction(selected);
|
||||
queryTextWithDefault.value = "";
|
||||
};
|
||||
|
||||
const locateMe = async (): Promise<void> => {
|
||||
gettingLocation.value = true;
|
||||
gettingLocationError.value = null;
|
||||
try {
|
||||
const location = await getLocation();
|
||||
// mapDefaultZoom.value = 12;
|
||||
reverseGeoCode(
|
||||
new LatLng(location.coords.latitude, location.coords.longitude),
|
||||
12
|
||||
);
|
||||
} catch (e: any) {
|
||||
gettingLocationError.value = e.message;
|
||||
}
|
||||
gettingLocation.value = false;
|
||||
};
|
||||
|
||||
const { load: loadReverseGeocode } = useReverseGeocode();
|
||||
|
||||
const reverseGeoCode = async (e: LatLng, zoom: number) => {
|
||||
console.debug("reverse geocode");
|
||||
|
||||
// If the details is opened, just update coords, don't reverse geocode
|
||||
if (e && detailsAddress.value) {
|
||||
selected.geom = `${e.lng};${e.lat}`;
|
||||
console.debug("no reverse geocode, just setting new coords");
|
||||
return;
|
||||
}
|
||||
|
||||
// If the position has been updated through autocomplete selection, no need to geocode it!
|
||||
if (!e || checkCurrentPosition(e)) return;
|
||||
|
||||
try {
|
||||
const result = await loadReverseGeocode(undefined, {
|
||||
latitude: e.lat,
|
||||
longitude: e.lng,
|
||||
zoom,
|
||||
locale: locale as unknown as string,
|
||||
});
|
||||
if (!result) return;
|
||||
addressData.value = result.reverseGeocode;
|
||||
|
||||
if (addressData.value.length > 0) {
|
||||
const foundAddress = addressData.value[0];
|
||||
Object.assign(selected, foundAddress);
|
||||
console.debug("reverse geocode succeded, setting new address");
|
||||
queryTextWithDefault.value = addressFullName(foundAddress);
|
||||
emit("update:modelValue", selected);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load reverse geocode", err);
|
||||
}
|
||||
};
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
const getLocation = async (): Promise<GeolocationPosition> => {
|
||||
let errorMessage = t("Failed to get location.");
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!("geolocation" in navigator)) {
|
||||
reject(new Error(errorMessage as string));
|
||||
}
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
resolve(pos);
|
||||
},
|
||||
(err) => {
|
||||
switch (err.code) {
|
||||
case GeolocationPositionError.PERMISSION_DENIED:
|
||||
errorMessage = t("The geolocation prompt was denied.");
|
||||
break;
|
||||
case GeolocationPositionError.POSITION_UNAVAILABLE:
|
||||
errorMessage = t("Your position was not available.");
|
||||
break;
|
||||
case GeolocationPositionError.TIMEOUT:
|
||||
errorMessage = t("Geolocation was not determined in time.");
|
||||
break;
|
||||
default:
|
||||
errorMessage = err.message;
|
||||
}
|
||||
reject(new Error(errorMessage as string));
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const mapMarkerValue = computed(() => {
|
||||
if (!selected.description) return undefined;
|
||||
return {
|
||||
text: [
|
||||
addressToPoiInfos(selected).name,
|
||||
addressToPoiInfos(selected).alternativeName,
|
||||
],
|
||||
icon: addressToPoiInfos(selected).poiIcon.icon,
|
||||
};
|
||||
});
|
||||
|
||||
const fieldErrors = computed(() => {
|
||||
return gettingLocationError.value;
|
||||
});
|
||||
</script>
|
||||
<style lang="scss">
|
||||
.autocomplete {
|
||||
.dropdown-menu {
|
||||
z-index: 2000;
|
||||
}
|
||||
|
||||
.dropdown-item.is-disabled {
|
||||
opacity: 1 !important;
|
||||
cursor: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.read-only {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.map {
|
||||
height: 400px;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
76
src/components/Event/GroupedMultiEventMinimalistCard.vue
Normal file
76
src/components/Event/GroupedMultiEventMinimalistCard.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex flex-col gap-4" v-for="key of keys" :key="key">
|
||||
<h2 class="capitalize inline-block relative">
|
||||
{{ monthName(groupEvents(key)[0]) }}
|
||||
</h2>
|
||||
<event-minimalist-card
|
||||
v-for="event in groupEvents(key)"
|
||||
:key="event.id"
|
||||
:event="event"
|
||||
:isCurrentActorMember="isCurrentActorMember"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { IEvent } from "@/types/event.model";
|
||||
import { computed } from "vue";
|
||||
import EventMinimalistCard from "./EventMinimalistCard.vue";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
events: IEvent[];
|
||||
isCurrentActorMember?: boolean;
|
||||
order: "ASC" | "DESC";
|
||||
}>(),
|
||||
{ isCurrentActorMember: false, order: "ASC" }
|
||||
);
|
||||
|
||||
const monthlyGroupedEvents = computed((): Map<string, IEvent[]> => {
|
||||
return props.events.reduce((acc: Map<string, IEvent[]>, event: IEvent) => {
|
||||
const beginsOn = new Date(event.beginsOn);
|
||||
const month = `${beginsOn.getUTCFullYear()}-${beginsOn.getUTCMonth()}`;
|
||||
const monthEvents = acc.get(month) || [];
|
||||
acc.set(month, [...monthEvents, event]);
|
||||
return acc;
|
||||
}, new Map());
|
||||
});
|
||||
|
||||
const keys = computed((): string[] => {
|
||||
return Array.from(monthlyGroupedEvents.value.keys()).sort((a, b) => {
|
||||
const aParams = a.split("-").map((x) => parseInt(x, 10)) as [
|
||||
number,
|
||||
number,
|
||||
];
|
||||
const aDate = new Date(...aParams);
|
||||
const bParams = b.split("-").map((x) => parseInt(x, 10)) as [
|
||||
number,
|
||||
number,
|
||||
];
|
||||
const bDate = new Date(...bParams);
|
||||
return props.order === "DESC"
|
||||
? bDate.getTime() - aDate.getTime()
|
||||
: aDate.getTime() - bDate.getTime();
|
||||
});
|
||||
});
|
||||
|
||||
const groupEvents = (key: string): IEvent[] => {
|
||||
return monthlyGroupedEvents.value.get(key) || [];
|
||||
};
|
||||
|
||||
const monthName = (event: IEvent): string => {
|
||||
const beginsOn = new Date(event.beginsOn);
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
}).format(beginsOn);
|
||||
};
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.events-wrapper {
|
||||
display: grid;
|
||||
grid-gap: 20px;
|
||||
grid-template: 1fr;
|
||||
}
|
||||
</style>
|
||||
32
src/components/Event/Integrations/EtherpadIntegration.vue
Normal file
32
src/components/Event/Integrations/EtherpadIntegration.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<div class="etherpad">
|
||||
<div class="etherpad-container" v-if="metadata">
|
||||
<iframe
|
||||
:src="`${metadata.value}?showChat=false&showLineNumbers=false`"
|
||||
width="600"
|
||||
height="400"
|
||||
></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { IEventMetadataDescription } from "@/types/event-metadata";
|
||||
|
||||
defineProps<{ metadata: IEventMetadataDescription }>();
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.etherpad {
|
||||
.etherpad-container {
|
||||
padding-top: 56.25%;
|
||||
position: relative;
|
||||
height: 0;
|
||||
|
||||
iframe {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
31
src/components/Event/Integrations/JitsiMeetIntegration.vue
Normal file
31
src/components/Event/Integrations/JitsiMeetIntegration.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<div class="jitsi-meet">
|
||||
<div class="jitsi-meet-video" v-if="metadata">
|
||||
<iframe
|
||||
allow="camera; microphone; fullscreen; display-capture; autoplay"
|
||||
:src="metadata.value"
|
||||
style="height: 100%; width: 100%; border: 0px"
|
||||
></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { IEventMetadataDescription } from "@/types/event-metadata";
|
||||
defineProps<{ metadata: IEventMetadataDescription }>();
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.jitsi-meet {
|
||||
.jitsi-meet-video {
|
||||
padding-top: 56.25%;
|
||||
position: relative;
|
||||
height: 0;
|
||||
|
||||
iframe {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
46
src/components/Event/Integrations/PeerTubeIntegration.vue
Normal file
46
src/components/Event/Integrations/PeerTubeIntegration.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<div class="peertube">
|
||||
<div class="peertube-video" v-if="videoDetails">
|
||||
<iframe
|
||||
width="100%"
|
||||
height="100%"
|
||||
sandbox="allow-same-origin allow-scripts allow-popups"
|
||||
:src="`https://${videoDetails.host}/videos/embed/${videoDetails.uuid}`"
|
||||
frameborder="0"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { IEventMetadataDescription } from "@/types/event-metadata";
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps<{ metadata: IEventMetadataDescription }>();
|
||||
|
||||
const videoDetails = computed((): { host: string; uuid: string } | null => {
|
||||
if (props.metadata.pattern) {
|
||||
const matches = props.metadata.pattern.exec(props.metadata.value);
|
||||
if (matches && matches[1] && matches[2]) {
|
||||
return { host: matches[1], uuid: matches[2] };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.peertube {
|
||||
.peertube-video {
|
||||
padding-top: 56.25%;
|
||||
position: relative;
|
||||
height: 0;
|
||||
|
||||
iframe {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
51
src/components/Event/Integrations/TwitchIntegration.vue
Normal file
51
src/components/Event/Integrations/TwitchIntegration.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<div class="twitch">
|
||||
<div class="twitch-video" v-if="channelName">
|
||||
<iframe
|
||||
:src="`https://player.twitch.tv/?channel=${channelName}&parent=${origin}&autoplay=false`"
|
||||
frameborder="0"
|
||||
scrolling="no"
|
||||
allowfullscreen="true"
|
||||
height="100%"
|
||||
width="100%"
|
||||
>
|
||||
</iframe>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { IEventMetadataDescription } from "@/types/event-metadata";
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps<{ metadata: IEventMetadataDescription }>();
|
||||
|
||||
const channelName = computed((): string | null => {
|
||||
if (props.metadata.pattern) {
|
||||
const matches = props.metadata.pattern.exec(props.metadata.value);
|
||||
if (matches && matches[1]) {
|
||||
return matches[1];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const origin = computed((): string => {
|
||||
return window.location.hostname;
|
||||
});
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.twitch {
|
||||
.twitch-video {
|
||||
padding-top: 56.25%;
|
||||
position: relative;
|
||||
height: 0;
|
||||
|
||||
iframe {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
47
src/components/Event/Integrations/YouTubeIntegration.vue
Normal file
47
src/components/Event/Integrations/YouTubeIntegration.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<div class="youtube">
|
||||
<div class="youtube-video" v-if="videoID">
|
||||
<iframe
|
||||
width="100%"
|
||||
height="100%"
|
||||
:src="`https://www.youtube.com/embed/${videoID}`"
|
||||
title="YouTube video player"
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { IEventMetadataDescription } from "@/types/event-metadata";
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps<{ metadata: IEventMetadataDescription }>();
|
||||
|
||||
const videoID = computed((): string | null => {
|
||||
if (props.metadata.pattern) {
|
||||
const matches = props.metadata.pattern.exec(props.metadata.value);
|
||||
if (matches && matches[1]) {
|
||||
return matches[1];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.youtube {
|
||||
.youtube-video {
|
||||
padding-top: 56.25%;
|
||||
position: relative;
|
||||
height: 0;
|
||||
|
||||
iframe {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
21
src/components/Event/MultiCard.vue
Normal file
21
src/components/Event/MultiCard.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<div
|
||||
class="grid auto-rows-[1fr] gap-x-2 gap-y-4 md:gap-x-6 grid-cols-[repeat(auto-fill,_minmax(250px,_1fr))] justify-items-center"
|
||||
>
|
||||
<event-card
|
||||
class="flex flex-col h-full"
|
||||
v-for="event in events"
|
||||
:event="event"
|
||||
:key="event.uuid"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { IEvent } from "@/types/event.model";
|
||||
|
||||
import EventCard from "./EventCard.vue";
|
||||
|
||||
defineProps<{
|
||||
events: IEvent[];
|
||||
}>();
|
||||
</script>
|
||||
34
src/components/Event/MultiEventMinimalistCard.vue
Normal file
34
src/components/Event/MultiEventMinimalistCard.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<div class="events-wrapper">
|
||||
<event-minimalist-card
|
||||
v-for="event in events"
|
||||
:key="event.id"
|
||||
:event="event"
|
||||
:isCurrentActorMember="isCurrentActorMember"
|
||||
:showOrganizer="showOrganizer"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { IEvent } from "@/types/event.model";
|
||||
import EventMinimalistCard from "./EventMinimalistCard.vue";
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
events: IEvent[];
|
||||
isCurrentActorMember?: boolean;
|
||||
showOrganizer?: boolean;
|
||||
}>(),
|
||||
{
|
||||
isCurrentActorMember: false,
|
||||
showOrganizer: false,
|
||||
}
|
||||
);
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.events-wrapper {
|
||||
display: grid;
|
||||
grid-gap: 20px;
|
||||
grid-template: 1fr;
|
||||
}
|
||||
</style>
|
||||
56
src/components/Event/OrganizerPicker.story.vue
Normal file
56
src/components/Event/OrganizerPicker.story.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<Story :setup-app="setupApp">
|
||||
<Variant>
|
||||
<OrganizerPicker
|
||||
v-model="actor"
|
||||
:identities="identities"
|
||||
v-model:actor-filter="actorFilter"
|
||||
:groupMemberships="[]"
|
||||
:current-actor="currentActor"
|
||||
@update:actor-filter="hstEvent('Actor Filter updated', $event)"
|
||||
@update:model-value="hstEvent('Selected actor updated', $event)"
|
||||
/>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import OrganizerPicker from "./OrganizerPicker.vue";
|
||||
import { createMemoryHistory, createRouter } from "vue-router";
|
||||
import { reactive, ref } from "vue";
|
||||
import { ActorType } from "@/types/enums";
|
||||
import { hstEvent } from "histoire/client";
|
||||
|
||||
const currentActor = reactive({
|
||||
id: "59",
|
||||
preferredUsername: "me",
|
||||
name: "Someone",
|
||||
type: ActorType.PERSON,
|
||||
});
|
||||
|
||||
const actor = reactive({
|
||||
id: "5",
|
||||
preferredUsername: "hello",
|
||||
name: "Sigmund",
|
||||
type: ActorType.PERSON,
|
||||
});
|
||||
|
||||
const group = reactive({
|
||||
id: "89",
|
||||
preferredUsername: "congregation",
|
||||
name: "College",
|
||||
type: ActorType.GROUP,
|
||||
});
|
||||
|
||||
const identities = [actor, group];
|
||||
|
||||
const actorFilter = ref("");
|
||||
|
||||
function setupApp({ app }) {
|
||||
app.use(
|
||||
createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ path: "/", name: "home", component: { render: () => null } }],
|
||||
})
|
||||
);
|
||||
}
|
||||
</script>
|
||||
166
src/components/Event/OrganizerPicker.vue
Normal file
166
src/components/Event/OrganizerPicker.vue
Normal file
@@ -0,0 +1,166 @@
|
||||
<template>
|
||||
<div class="max-w-md mx-auto">
|
||||
<o-input
|
||||
dir="auto"
|
||||
:placeholder="t('Filter by profile or group name')"
|
||||
v-model="actorFilterProxy"
|
||||
class=""
|
||||
/>
|
||||
<transition-group
|
||||
tag="ul"
|
||||
class="grid grid-cols-1 gap-y-3 m-5 max-w-md mx-auto"
|
||||
:class="{ hidden: actualFilteredAvailableActors.length === 0 }"
|
||||
enter-active-class="duration-300 ease-out"
|
||||
enter-from-class="transform opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="duration-200 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="transform opacity-0"
|
||||
>
|
||||
<li
|
||||
class="relative focus-within:shadow-lg"
|
||||
v-for="availableActor in actualFilteredAvailableActors"
|
||||
:key="availableActor?.id"
|
||||
>
|
||||
<input
|
||||
class="sr-only peer"
|
||||
type="radio"
|
||||
:value="availableActor"
|
||||
name="availableActors"
|
||||
v-model="selectedActor"
|
||||
:id="`availableActor-${availableActor?.id}`"
|
||||
/>
|
||||
<label
|
||||
class="flex items-center gap-2 p-3 bg-white hover:bg-gray-50 dark:bg-violet-3 dark:hover:bg-violet-3/60 border border-gray-300 rounded-lg cursor-pointer peer-checked:ring-primary peer-checked:ring-2 peer-checked:border-transparent"
|
||||
:for="`availableActor-${availableActor?.id}`"
|
||||
>
|
||||
<figure class="h-12 w-12" v-if="availableActor?.avatar">
|
||||
<img
|
||||
class="rounded-full h-full w-full object-cover"
|
||||
:src="availableActor.avatar.url"
|
||||
alt=""
|
||||
width="48"
|
||||
height="48"
|
||||
/>
|
||||
</figure>
|
||||
<AccountCircle v-else :size="48" />
|
||||
<div class="flex-1 w-px">
|
||||
<h3 class="line-clamp-2">{{ availableActor?.name }}</h3>
|
||||
<small class="flex truncate">{{
|
||||
`@${availableActor?.preferredUsername}`
|
||||
}}</small>
|
||||
</div>
|
||||
</label>
|
||||
</li>
|
||||
</transition-group>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { IActor, IPerson } from "@/types/actor";
|
||||
import { IMember } from "@/types/actor/member.model";
|
||||
import { MemberRole } from "@/types/enums";
|
||||
import { computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import AccountCircle from "vue-material-design-icons/AccountCircle.vue";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
currentActor: IPerson;
|
||||
modelValue: IActor;
|
||||
restrictModeratorLevel?: boolean;
|
||||
identities: IActor[];
|
||||
actorFilter: string;
|
||||
groupMemberships: IMember[];
|
||||
}>(),
|
||||
{ restrictModeratorLevel: false }
|
||||
);
|
||||
|
||||
const emit = defineEmits(["update:modelValue", "update:actorFilter"]);
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
const selectedActor = computed({
|
||||
get(): IActor | undefined {
|
||||
if (props.modelValue?.id) {
|
||||
return props.modelValue;
|
||||
}
|
||||
if (props.currentActor) {
|
||||
return props.identities.find(
|
||||
(identity) => identity.id === props.currentActor?.id
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
|
||||
set(actor: IActor | undefined) {
|
||||
emit("update:modelValue", actor);
|
||||
},
|
||||
});
|
||||
|
||||
const actualMemberships = computed((): IMember[] => {
|
||||
if (props.restrictModeratorLevel) {
|
||||
return props.groupMemberships.filter((membership: IMember) =>
|
||||
[
|
||||
MemberRole.ADMINISTRATOR,
|
||||
MemberRole.MODERATOR,
|
||||
MemberRole.CREATOR,
|
||||
].includes(membership.role)
|
||||
);
|
||||
}
|
||||
return props.groupMemberships;
|
||||
});
|
||||
|
||||
const actualAvailableActors = computed((): (IActor | undefined)[] => {
|
||||
return [
|
||||
props.currentActor,
|
||||
...props.identities.filter(
|
||||
(identity: IActor) => identity.id !== props.currentActor?.id
|
||||
),
|
||||
...actualMemberships.value.map((member) => member.parent),
|
||||
].filter((elem) => elem);
|
||||
});
|
||||
|
||||
const actualFilteredAvailableActors = computed((): (IActor | undefined)[] => {
|
||||
return (actualAvailableActors.value ?? []).filter((actor) => {
|
||||
if (actor === undefined) return false;
|
||||
return [
|
||||
actor.preferredUsername?.toLowerCase(),
|
||||
actor.name?.toLowerCase(),
|
||||
actor.domain?.toLowerCase(),
|
||||
].some((match) => match?.includes(actorFilterProxy.value.toLowerCase()));
|
||||
});
|
||||
});
|
||||
|
||||
const actorFilterProxy = computed({
|
||||
get() {
|
||||
return props.actorFilter;
|
||||
},
|
||||
set(newActorFilter: string) {
|
||||
emit("update:actorFilter", newActorFilter);
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
@use "@/styles/_mixins" as *;
|
||||
:deep(.list-item) {
|
||||
box-sizing: content-box;
|
||||
|
||||
label.b-radio {
|
||||
padding: 0.85rem 0;
|
||||
|
||||
.media {
|
||||
padding: 0.25rem 0;
|
||||
align-items: center;
|
||||
|
||||
// figure.image,
|
||||
// span.icon.media-left {
|
||||
// @include margin-right(0.5rem);
|
||||
// }
|
||||
|
||||
// span.icon.media-left {
|
||||
// @include margin-left(-0.25rem);
|
||||
// }
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
89
src/components/Event/OrganizerPickerWrapper.story.vue
Normal file
89
src/components/Event/OrganizerPickerWrapper.story.vue
Normal file
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<Story :setup-app="setupApp">
|
||||
<Variant>
|
||||
<OrganizerPickerWrapper
|
||||
v-model="actor"
|
||||
@update:model-value="hstEvent('Value', $event)"
|
||||
@update:contacts="hstEvent('Contacts', $event)"
|
||||
/>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import OrganizerPickerWrapper from "./OrganizerPickerWrapper.vue";
|
||||
import { DefaultApolloClient } from "@vue/apollo-composable";
|
||||
import { createMockClient } from "mock-apollo-client";
|
||||
import { cache } from "@/apollo/memory";
|
||||
import { ICurrentUserRole } from "@/types/enums";
|
||||
import { PERSON_GROUP_MEMBERSHIPS } from "@/graphql/actor";
|
||||
import { createMemoryHistory, createRouter } from "vue-router";
|
||||
import { IDENTITIES } from "@/graphql/actor";
|
||||
import { reactive } from "vue";
|
||||
import { hstEvent } from "histoire/client";
|
||||
|
||||
const actor = reactive({
|
||||
id: "5",
|
||||
preferredUsername: "hello",
|
||||
name: "Sigmund",
|
||||
});
|
||||
|
||||
function setupApp({ app }) {
|
||||
const defaultResolvers = {
|
||||
Query: {
|
||||
currentUser: (): Record<string, any> => ({
|
||||
email: "user@mail.com",
|
||||
id: "2",
|
||||
role: ICurrentUserRole.USER,
|
||||
isLoggedIn: true,
|
||||
__typename: "CurrentUser",
|
||||
}),
|
||||
currentActor: (): Record<string, any> => ({
|
||||
id: "67",
|
||||
preferredUsername: "someone",
|
||||
name: "Personne",
|
||||
avatar: null,
|
||||
__typename: "CurrentActor",
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
const mockClient = createMockClient({
|
||||
cache,
|
||||
resolvers: defaultResolvers,
|
||||
});
|
||||
|
||||
mockClient.setRequestHandler(
|
||||
PERSON_GROUP_MEMBERSHIPS,
|
||||
() =>
|
||||
new Promise((resolve) =>
|
||||
resolve({
|
||||
data: {
|
||||
person: { id: "5", memberships: { total: 0, elements: [] } },
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
mockClient.setRequestHandler(
|
||||
IDENTITIES,
|
||||
() =>
|
||||
new Promise((resolve) =>
|
||||
resolve({
|
||||
data: {
|
||||
loggedUser: {
|
||||
actors: [{ id: "9", preferredUsername: "sam", name: "Samuel" }],
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
app.provide(DefaultApolloClient, mockClient);
|
||||
app.use(
|
||||
createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ path: "/", name: "home", component: { render: () => null } }],
|
||||
})
|
||||
);
|
||||
}
|
||||
</script>
|
||||
324
src/components/Event/OrganizerPickerWrapper.vue
Normal file
324
src/components/Event/OrganizerPickerWrapper.vue
Normal file
@@ -0,0 +1,324 @@
|
||||
<template>
|
||||
<div
|
||||
class="bg-white dark:bg-violet-3 border border-gray-300 rounded-lg cursor-pointer"
|
||||
v-if="selectedActor"
|
||||
>
|
||||
<!-- If we have a current actor (inline) -->
|
||||
<div
|
||||
v-if="inline && selectedActor.id"
|
||||
class=""
|
||||
dir="auto"
|
||||
@click="isComponentModalActive = true"
|
||||
>
|
||||
<div class="flex gap-1 p-4">
|
||||
<div class="">
|
||||
<figure class="h-12 w-12" v-if="selectedActor.avatar">
|
||||
<img
|
||||
class="rounded-full h-full w-full object-cover"
|
||||
:src="selectedActor.avatar.url"
|
||||
:alt="selectedActor.avatar.alt ?? ''"
|
||||
height="48"
|
||||
width="48"
|
||||
/>
|
||||
</figure>
|
||||
<AccountCircle v-else :size="48" />
|
||||
</div>
|
||||
<div class="flex-1" v-if="selectedActor.name">
|
||||
<p class="">{{ selectedActor.name }}</p>
|
||||
<p class="">
|
||||
{{ `@${selectedActor.preferredUsername}` }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex-1" v-else>
|
||||
{{ `@${selectedActor.preferredUsername}` }}
|
||||
</div>
|
||||
<o-button type="text" @click="isComponentModalActive = true">
|
||||
{{ $t("Change") }}
|
||||
</o-button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- If we have a current actor -->
|
||||
<span
|
||||
v-else-if="selectedActor.id"
|
||||
class="block"
|
||||
@click="isComponentModalActive = true"
|
||||
>
|
||||
<img
|
||||
class="rounded"
|
||||
v-if="selectedActor.avatar"
|
||||
:src="selectedActor.avatar.url"
|
||||
:alt="selectedActor.avatar.alt"
|
||||
width="48"
|
||||
height="48"
|
||||
/>
|
||||
<AccountCircle v-else :size="48" />
|
||||
</span>
|
||||
<o-modal
|
||||
v-model:active="isComponentModalActive"
|
||||
has-modal-card
|
||||
:close-button-aria-label="$t('Close')"
|
||||
>
|
||||
<div class="p-2 rounded">
|
||||
<header class="">
|
||||
<h2 class="">{{ $t("Pick a profile or a group") }}</h2>
|
||||
</header>
|
||||
<section class="">
|
||||
<div class="flex flex-wrap gap-2 items-center">
|
||||
<div class="max-h-[400px] overflow-y-auto flex-1">
|
||||
<organizer-picker
|
||||
v-if="currentActor"
|
||||
:current-actor="currentActor"
|
||||
:identities="identities ?? []"
|
||||
v-model="selectedActor"
|
||||
@update:model-value="relay"
|
||||
:restrict-moderator-level="true"
|
||||
:group-memberships="groupMemberships"
|
||||
v-model:actorFilter="actorFilter"
|
||||
/>
|
||||
</div>
|
||||
<div class="pl-2 max-h-[400px] overflow-y-auto">
|
||||
<div v-if="isSelectedActorAGroup">
|
||||
<p>{{ $t("Add a contact") }}</p>
|
||||
<o-input
|
||||
:placeholder="$t('Filter by name')"
|
||||
:value="contactFilter"
|
||||
@input="debounceSetFilterByName"
|
||||
dir="auto"
|
||||
/>
|
||||
<div v-if="actorMembers.length > 0">
|
||||
<p
|
||||
class="field"
|
||||
v-for="actor in filteredActorMembers"
|
||||
:key="actor.id"
|
||||
>
|
||||
<o-checkbox
|
||||
v-model="actualContacts"
|
||||
:native-value="actor.id"
|
||||
>
|
||||
<div class="flex gap-1">
|
||||
<div class="">
|
||||
<figure class="" v-if="actor.avatar">
|
||||
<img
|
||||
class="rounded"
|
||||
:src="actor.avatar.url"
|
||||
:alt="actor.avatar.alt"
|
||||
width="48"
|
||||
height="48"
|
||||
/>
|
||||
</figure>
|
||||
<AccountCircle v-else :size="48" />
|
||||
</div>
|
||||
<div class="" v-if="actor.name">
|
||||
<p class="">{{ actor.name }}</p>
|
||||
<p class="">
|
||||
{{ `@${usernameWithDomain(actor)}` }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="" v-else>
|
||||
{{ `@${usernameWithDomain(actor)}` }}
|
||||
</div>
|
||||
</div>
|
||||
</o-checkbox>
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="
|
||||
actorMembers.length === 0 && contactFilter.length > 0
|
||||
"
|
||||
>
|
||||
<empty-content icon="account-multiple" :inline="true">
|
||||
{{ $t("No group member found") }}
|
||||
</empty-content>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="">
|
||||
<p>{{ $t("Your profile will be shown as contact.") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<footer class="my-2">
|
||||
<o-button variant="primary" @click="pickActor">
|
||||
{{ $t("Pick") }}
|
||||
</o-button>
|
||||
</footer>
|
||||
</div>
|
||||
</o-modal>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { IActor, IGroup, usernameWithDomain } from "../../types/actor";
|
||||
import OrganizerPicker from "./OrganizerPicker.vue";
|
||||
import EmptyContent from "../Utils/EmptyContent.vue";
|
||||
import {
|
||||
LOGGED_USER_MEMBERSHIPS,
|
||||
PERSON_GROUP_MEMBERSHIPS,
|
||||
} from "../../graphql/actor";
|
||||
import { GROUP_MEMBERS } from "@/graphql/member";
|
||||
import { ActorType, MemberRole } from "@/types/enums";
|
||||
import { useQuery } from "@vue/apollo-composable";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import {
|
||||
useCurrentActorClient,
|
||||
useCurrentUserIdentities,
|
||||
} from "@/composition/apollo/actor";
|
||||
import { useRoute } from "vue-router";
|
||||
import AccountCircle from "vue-material-design-icons/AccountCircle.vue";
|
||||
import debounce from "lodash/debounce";
|
||||
import { IUser } from "@/types/current-user.model";
|
||||
import { IMember } from "@/types/actor/member.model";
|
||||
import { Paginate } from "@/types/paginate";
|
||||
|
||||
const MEMBER_ROLES = [
|
||||
MemberRole.CREATOR,
|
||||
MemberRole.ADMINISTRATOR,
|
||||
MemberRole.MODERATOR,
|
||||
MemberRole.MEMBER,
|
||||
];
|
||||
|
||||
const { currentActor } = useCurrentActorClient();
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const { result: personMembershipsResult } = useQuery(
|
||||
PERSON_GROUP_MEMBERSHIPS,
|
||||
() => ({
|
||||
id: currentActor.value?.id,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
groupId: route.query?.actorId,
|
||||
}),
|
||||
() => ({
|
||||
enabled: currentActor.value?.id !== undefined,
|
||||
})
|
||||
);
|
||||
|
||||
const personMemberships = computed(
|
||||
() =>
|
||||
personMembershipsResult.value?.person.memberships ?? {
|
||||
elements: [],
|
||||
total: 0,
|
||||
}
|
||||
);
|
||||
|
||||
const { identities } = useCurrentUserIdentities();
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue?: IActor;
|
||||
inline?: boolean;
|
||||
contacts?: IActor[];
|
||||
}>(),
|
||||
{ inline: true, contacts: () => [] }
|
||||
);
|
||||
|
||||
const emit = defineEmits(["update:modelValue", "update:contacts"]);
|
||||
|
||||
const selectedActor = computed({
|
||||
get(): IActor | undefined {
|
||||
if (props.modelValue?.id) {
|
||||
return props.modelValue;
|
||||
}
|
||||
if (currentActor.value) {
|
||||
return (identities.value ?? []).find(
|
||||
(identity) => identity.id === currentActor.value?.id
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
set(newSelectedActor: IActor | undefined) {
|
||||
emit("update:modelValue", newSelectedActor);
|
||||
},
|
||||
});
|
||||
|
||||
const isComponentModalActive = ref(false);
|
||||
const contactFilter = ref("");
|
||||
const membersPage = ref(1);
|
||||
|
||||
const { result: membersResult } = useQuery<{ group: Pick<IGroup, "members"> }>(
|
||||
GROUP_MEMBERS,
|
||||
() => ({
|
||||
groupName: usernameWithDomain(selectedActor.value),
|
||||
page: membersPage.value,
|
||||
limit: 10,
|
||||
roles: MEMBER_ROLES.join(","),
|
||||
name: contactFilter.value,
|
||||
}),
|
||||
() => ({ enabled: selectedActor.value?.type === ActorType.GROUP })
|
||||
);
|
||||
|
||||
const members = computed<Paginate<IMember>>(() =>
|
||||
selectedActor.value?.type === ActorType.GROUP
|
||||
? membersResult.value?.group?.members ?? { elements: [], total: 0 }
|
||||
: { elements: [], total: 0 }
|
||||
);
|
||||
|
||||
const actualContacts = computed({
|
||||
get(): (string | undefined)[] {
|
||||
return props.contacts.map(({ id }) => id);
|
||||
},
|
||||
set(contactsIds: (string | undefined)[]) {
|
||||
emit(
|
||||
"update:contacts",
|
||||
actorMembers.value.filter(({ id }) => contactsIds.includes(id))
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const setContactFilter = (newContactFilter: string) => {
|
||||
contactFilter.value = newContactFilter;
|
||||
};
|
||||
|
||||
const debounceSetFilterByName = debounce(setContactFilter, 1000);
|
||||
|
||||
watch(personMemberships, () => {
|
||||
if (
|
||||
personMemberships.value?.elements[0]?.parent?.id === route.query?.actorId
|
||||
) {
|
||||
selectedActor.value = personMemberships.value?.elements[0]?.parent;
|
||||
}
|
||||
});
|
||||
|
||||
const relay = async (group: IGroup): Promise<void> => {
|
||||
actualContacts.value = [];
|
||||
selectedActor.value = group;
|
||||
};
|
||||
|
||||
const pickActor = (): void => {
|
||||
isComponentModalActive.value = false;
|
||||
};
|
||||
|
||||
const actorMembers = computed((): IActor[] => {
|
||||
if (isSelectedActorAGroup.value) {
|
||||
return members.value.elements.map(({ actor }: { actor: IActor }) => actor);
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const filteredActorMembers = computed((): IActor[] => {
|
||||
return actorMembers.value.filter((actor) => {
|
||||
return [
|
||||
actor.preferredUsername.toLowerCase(),
|
||||
actor.name?.toLowerCase(),
|
||||
actor.domain?.toLowerCase(),
|
||||
];
|
||||
});
|
||||
});
|
||||
|
||||
const isSelectedActorAGroup = computed((): boolean => {
|
||||
return selectedActor.value?.type === ActorType.GROUP;
|
||||
});
|
||||
|
||||
const actorFilter = ref("");
|
||||
|
||||
const { result: groupMembershipsResult } = useQuery<{
|
||||
loggedUser: Pick<IUser, "memberships">;
|
||||
}>(LOGGED_USER_MEMBERSHIPS, () => ({
|
||||
page: 1,
|
||||
limit: 10,
|
||||
membershipName: actorFilter.value,
|
||||
}));
|
||||
const groupMemberships = computed(
|
||||
() => groupMembershipsResult.value?.loggedUser.memberships.elements ?? []
|
||||
);
|
||||
</script>
|
||||
114
src/components/Event/ParticipationButton.story.vue
Normal file
114
src/components/Event/ParticipationButton.story.vue
Normal file
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<Story>
|
||||
<Variant title="Unlogged">
|
||||
<ParticipationButton
|
||||
:event="event"
|
||||
:current-actor="emptyCurrentActor"
|
||||
:participation="undefined"
|
||||
:identities="[]"
|
||||
/>
|
||||
</Variant>
|
||||
<Variant title="Basic">
|
||||
<ParticipationButton
|
||||
:event="event"
|
||||
:current-actor="currentActor"
|
||||
:participation="undefined"
|
||||
:identities="identities"
|
||||
@join-event="hstEvent('Join event', $event)"
|
||||
@join-modal="hstEvent('Join modal', $event)"
|
||||
@confirm-leave="hstEvent('Confirm leave', $event)"
|
||||
/>
|
||||
</Variant>
|
||||
<Variant title="Basic with confirmation">
|
||||
<ParticipationButton
|
||||
:event="{ ...event, joinOptions: EventJoinOptions.RESTRICTED }"
|
||||
:current-actor="currentActor"
|
||||
:participation="undefined"
|
||||
:identities="identities"
|
||||
@join-event-with-confirmation="
|
||||
hstEvent('Join Event with confirmation', $event)
|
||||
"
|
||||
@join-modal="hstEvent('Join modal', $event)"
|
||||
/>
|
||||
</Variant>
|
||||
<Variant title="Participating">
|
||||
<ParticipationButton
|
||||
:event="event"
|
||||
:current-actor="currentActor"
|
||||
:participation="participation"
|
||||
:identities="identities"
|
||||
@confirm-leave="hstEvent('Confirm leave', $event)"
|
||||
/>
|
||||
</Variant>
|
||||
<Variant title="Pending approval">
|
||||
<ParticipationButton
|
||||
:event="event"
|
||||
:current-actor="currentActor"
|
||||
:participation="{
|
||||
...participation,
|
||||
role: ParticipantRole.NOT_APPROVED,
|
||||
}"
|
||||
:identities="identities"
|
||||
@confirm-leave="hstEvent('Confirm leave', $event)"
|
||||
/>
|
||||
</Variant>
|
||||
<Variant title="Rejected">
|
||||
<ParticipationButton
|
||||
:event="event"
|
||||
:current-actor="currentActor"
|
||||
:participation="{
|
||||
...participation,
|
||||
role: ParticipantRole.REJECTED,
|
||||
}"
|
||||
:identities="identities"
|
||||
@confirm-leave="hstEvent('Confirm leave', $event)"
|
||||
/>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { IPerson } from "@/types/actor";
|
||||
import { EventJoinOptions, ParticipantRole } from "@/types/enums";
|
||||
import { IEvent } from "@/types/event.model";
|
||||
import ParticipationButton from "./ParticipationButton.vue";
|
||||
import { hstEvent } from "histoire/client";
|
||||
import { IParticipant } from "@/types/participant.model";
|
||||
|
||||
const emptyCurrentActor: IPerson = {};
|
||||
|
||||
const currentActor: IPerson = {
|
||||
id: "1",
|
||||
preferredUsername: "tcit",
|
||||
name: "Thomas",
|
||||
avatar: {
|
||||
url: "https://mobilizon.fr/media/3a5f18c058a8193b1febfaf561f94ae8b91f85ac64c01ddf5ad7b251fb43baf5.jpg?name=profil.jpg",
|
||||
},
|
||||
};
|
||||
|
||||
const participation: IParticipant = {
|
||||
actor: currentActor,
|
||||
role: ParticipantRole.PARTICIPANT,
|
||||
};
|
||||
|
||||
const identities: IPerson[] = [
|
||||
currentActor,
|
||||
{
|
||||
id: "2",
|
||||
preferredUsername: "another",
|
||||
name: "Another",
|
||||
avatar: {
|
||||
url: "https://mobilizon.fr/media/95ab5ba92287ab4857bb517cadae2a7ab6a553748d1c48cefc27e2b7ab640fea.jpg?name=FB_IMG_16150214351371162.jpg",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const event: IEvent = {
|
||||
title: "hello",
|
||||
url: "https://mobilizon.fr/events/an-uuid",
|
||||
options: {
|
||||
anonymousParticipation: false,
|
||||
},
|
||||
joinOptions: EventJoinOptions.FREE,
|
||||
};
|
||||
</script>
|
||||
200
src/components/Event/ParticipationButton.vue
Normal file
200
src/components/Event/ParticipationButton.vue
Normal file
@@ -0,0 +1,200 @@
|
||||
<template>
|
||||
<div class="ml-auto w-min">
|
||||
<o-dropdown
|
||||
v-if="participation && participation.role === ParticipantRole.PARTICIPANT"
|
||||
>
|
||||
<template #trigger="{ active }">
|
||||
<o-button
|
||||
variant="success"
|
||||
size="large"
|
||||
icon-left="check"
|
||||
:icon-right="active ? 'menu-up' : 'menu-down'"
|
||||
>
|
||||
{{ t("I participate") }}
|
||||
</o-button>
|
||||
</template>
|
||||
|
||||
<o-dropdown-item
|
||||
:value="false"
|
||||
aria-role="listitem"
|
||||
@click="confirmLeave"
|
||||
@keyup.enter="confirmLeave"
|
||||
class=""
|
||||
>{{ t("Cancel my participation…") }}
|
||||
</o-dropdown-item>
|
||||
</o-dropdown>
|
||||
|
||||
<div
|
||||
v-else-if="
|
||||
participation && participation.role === ParticipantRole.NOT_APPROVED
|
||||
"
|
||||
class="flex flex-col"
|
||||
>
|
||||
<o-dropdown>
|
||||
<template #trigger>
|
||||
<o-button variant="success" size="large" type="button">
|
||||
<template class="flex items-center">
|
||||
<TimerSandEmpty />
|
||||
<span>{{ t("I participate") }}</span>
|
||||
<MenuDown />
|
||||
</template>
|
||||
</o-button>
|
||||
</template>
|
||||
|
||||
<o-dropdown-item :value="false" aria-role="listitem">
|
||||
{{ t("Change my identity…") }}
|
||||
</o-dropdown-item>
|
||||
|
||||
<o-dropdown-item
|
||||
:value="false"
|
||||
aria-role="listitem"
|
||||
@click="confirmLeave"
|
||||
@keyup.enter="confirmLeave"
|
||||
class=""
|
||||
>{{ t("Cancel my participation request…") }}</o-dropdown-item
|
||||
>
|
||||
</o-dropdown>
|
||||
<p>{{ t("Participation requested!") }}</p>
|
||||
<p>{{ t("Waiting for organization team approval.") }}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="
|
||||
participation && participation.role === ParticipantRole.REJECTED
|
||||
"
|
||||
>
|
||||
<span>
|
||||
{{
|
||||
t(
|
||||
"Unfortunately, your participation request was rejected by the organizers."
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<o-dropdown v-else-if="!participation && currentActor?.id">
|
||||
<template #trigger="{ active }">
|
||||
<o-button
|
||||
variant="primary"
|
||||
size="large"
|
||||
:icon-right="active ? 'menu-up' : 'menu-down'"
|
||||
>
|
||||
{{ t("Participate") }}
|
||||
</o-button>
|
||||
</template>
|
||||
|
||||
<o-dropdown-item
|
||||
:value="true"
|
||||
aria-role="listitem"
|
||||
@click="joinEvent(currentActor)"
|
||||
@keyup.enter="joinEvent(currentActor)"
|
||||
>
|
||||
<div class="flex gap-2 items-center">
|
||||
<figure class="" v-if="currentActor?.avatar">
|
||||
<img
|
||||
class="rounded-xl"
|
||||
:src="currentActor.avatar.url"
|
||||
alt=""
|
||||
width="24"
|
||||
height="24"
|
||||
/>
|
||||
</figure>
|
||||
<AccountCircle v-else />
|
||||
<div class="">
|
||||
<span>
|
||||
{{
|
||||
t("as {identity}", {
|
||||
identity: displayName(currentActor),
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</o-dropdown-item>
|
||||
|
||||
<o-dropdown-item
|
||||
:value="false"
|
||||
aria-role="listitem"
|
||||
@click="joinModal"
|
||||
@keyup.enter="joinModal"
|
||||
v-if="(identities ?? []).length > 1"
|
||||
>{{ t("with another identity…") }}</o-dropdown-item
|
||||
>
|
||||
</o-dropdown>
|
||||
<o-button
|
||||
rel="nofollow"
|
||||
tag="router-link"
|
||||
:to="{
|
||||
name: RouteName.EVENT_PARTICIPATE_LOGGED_OUT,
|
||||
params: { uuid: event.uuid },
|
||||
}"
|
||||
v-else-if="!participation && hasAnonymousParticipationMethods"
|
||||
variant="primary"
|
||||
size="large"
|
||||
native-type="button"
|
||||
>{{ t("Participate") }}</o-button
|
||||
>
|
||||
<o-button
|
||||
tag="router-link"
|
||||
rel="nofollow"
|
||||
:to="{
|
||||
name: RouteName.EVENT_PARTICIPATE_WITH_ACCOUNT,
|
||||
params: { uuid: event.uuid },
|
||||
}"
|
||||
v-else-if="!currentActor?.id"
|
||||
variant="primary"
|
||||
size="large"
|
||||
native-type="button"
|
||||
>{{ t("Participate") }}</o-button
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { EventJoinOptions, ParticipantRole } from "@/types/enums";
|
||||
import { IParticipant } from "../../types/participant.model";
|
||||
import { IEvent } from "../../types/event.model";
|
||||
import { IPerson, displayName } from "../../types/actor";
|
||||
import RouteName from "../../router/name";
|
||||
import { computed } from "vue";
|
||||
import MenuDown from "vue-material-design-icons/MenuDown.vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import AccountCircle from "vue-material-design-icons/AccountCircle.vue";
|
||||
import TimerSandEmpty from "vue-material-design-icons/TimerSandEmpty.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
participation: IParticipant | undefined;
|
||||
event: IEvent;
|
||||
currentActor: IPerson | undefined;
|
||||
identities: IPerson[] | undefined;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits([
|
||||
"join-event-with-confirmation",
|
||||
"join-event",
|
||||
"join-modal",
|
||||
"confirm-leave",
|
||||
]);
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
const joinEvent = (actor: IPerson | undefined): void => {
|
||||
if (props.event.joinOptions === EventJoinOptions.RESTRICTED) {
|
||||
emit("join-event-with-confirmation", actor);
|
||||
} else {
|
||||
emit("join-event", actor);
|
||||
}
|
||||
};
|
||||
|
||||
const joinModal = (): void => {
|
||||
emit("join-modal");
|
||||
};
|
||||
|
||||
const confirmLeave = (): void => {
|
||||
emit("confirm-leave");
|
||||
};
|
||||
|
||||
const hasAnonymousParticipationMethods = computed((): boolean => {
|
||||
return props.event.options.anonymousParticipation;
|
||||
});
|
||||
</script>
|
||||
25
src/components/Event/RecentEventCardWrapper.vue
Normal file
25
src/components/Event/RecentEventCardWrapper.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<div>
|
||||
<p class="time">
|
||||
{{
|
||||
formatDistanceToNow(new Date(event.publishAt), {
|
||||
locale: dateFnsLocale,
|
||||
addSuffix: true,
|
||||
}) || $t("Right now")
|
||||
}}
|
||||
</p>
|
||||
<EventCard :event="event" />
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { IEvent } from "@/types/event.model";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { inject } from "vue";
|
||||
import EventCard from "./EventCard.vue";
|
||||
import type { Locale } from "date-fns";
|
||||
defineProps<{
|
||||
event: IEvent;
|
||||
}>();
|
||||
|
||||
const dateFnsLocale = inject<Locale>("dateFnsLocale");
|
||||
</script>
|
||||
29
src/components/Event/ShareEventModal.story.vue
Normal file
29
src/components/Event/ShareEventModal.story.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<Story>
|
||||
<Variant title="Public">
|
||||
<ShareEventModal :event="event" />
|
||||
</Variant>
|
||||
<Variant title="Private">
|
||||
<ShareEventModal
|
||||
:event="{ ...event, visibility: EventVisibility.PRIVATE }"
|
||||
/>
|
||||
</Variant>
|
||||
<Variant title="Cancelled">
|
||||
<ShareEventModal :event="{ ...event, status: EventStatus.CANCELLED }" />
|
||||
</Variant>
|
||||
<Variant title="No seats left">
|
||||
<ShareEventModal :event="event" :event-capacity-o-k="false" />
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { EventVisibility, EventStatus } from "@/types/enums";
|
||||
import ShareEventModal from "./ShareEventModal.vue";
|
||||
|
||||
const event = {
|
||||
title: "hello",
|
||||
url: "https://mobilizon.fr/events/an-uuid",
|
||||
visibility: EventVisibility.PUBLIC,
|
||||
};
|
||||
</script>
|
||||
49
src/components/Event/ShareEventModal.vue
Normal file
49
src/components/Event/ShareEventModal.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<div class="dark:text-white">
|
||||
<ShareModal
|
||||
:title="t('Share this event')"
|
||||
:text="event.title"
|
||||
:url="event.url"
|
||||
:input-label="t('Event URL')"
|
||||
>
|
||||
<o-notification
|
||||
variant="warning"
|
||||
v-if="event.visibility !== EventVisibility.PUBLIC"
|
||||
:closable="false"
|
||||
>
|
||||
{{
|
||||
$t(
|
||||
"This event is accessible only through it's link. Be careful where you post this link."
|
||||
)
|
||||
}}
|
||||
</o-notification>
|
||||
<o-notification
|
||||
variant="danger"
|
||||
v-if="event.status === EventStatus.CANCELLED"
|
||||
:closable="false"
|
||||
>
|
||||
{{ $t("This event has been cancelled.") }}
|
||||
</o-notification>
|
||||
<o-notification variant="warning" v-if="!eventCapacityOK">
|
||||
{{ $t("All the places have already been taken") }}
|
||||
</o-notification>
|
||||
</ShareModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { EventStatus, EventVisibility } from "@/types/enums";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { IEvent } from "@/types/event.model";
|
||||
import ShareModal from "@/components/Share/ShareModal.vue";
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
event: IEvent;
|
||||
eventCapacityOK?: boolean;
|
||||
}>(),
|
||||
{ eventCapacityOK: true }
|
||||
);
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
</script>
|
||||
18
src/components/Event/SkeletonDateCalendarIcon.vue
Normal file
18
src/components/Event/SkeletonDateCalendarIcon.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<div
|
||||
class="datetime-container flex flex-col rounded-lg text-center justify-center overflow-hidden items-stretch bg-white dark:bg-gray-700 text-violet-3 dark:text-white"
|
||||
>
|
||||
<div class="datetime-container-content">
|
||||
<div class="ml-2 h-8 bg-slate-200 w-16"></div>
|
||||
<div class="ml-2 mt-2 h-4 bg-slate-200 w-16"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
div.datetime-container {
|
||||
width: calc(80px);
|
||||
box-shadow: 0 0 12px rgba(0, 0, 0, 0.2);
|
||||
height: calc(80px);
|
||||
}
|
||||
</style>
|
||||
17
src/components/Event/SkeletonEventResult.story.vue
Normal file
17
src/components/Event/SkeletonEventResult.story.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<Story>
|
||||
<Variant title="row">
|
||||
<SkeletonEventResult />
|
||||
</Variant>
|
||||
<Variant title="column">
|
||||
<SkeletonEventResult view-mode="column" />
|
||||
</Variant>
|
||||
<Variant title="not minimal">
|
||||
<SkeletonEventResult :minimal="false" />
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import SkeletonEventResult from "./SkeletonEventResult.vue";
|
||||
</script>
|
||||
51
src/components/Event/SkeletonEventResult.vue
Normal file
51
src/components/Event/SkeletonEventResult.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<div
|
||||
:class="`bg-white dark:bg-slate-800 shadow rounded-md ${
|
||||
isRowMode ? 'max-w-4xl' : 'max-w-sm'
|
||||
} w-full mx-auto`"
|
||||
>
|
||||
<div
|
||||
:class="`animate-pulse flex flex-col items-center ${
|
||||
isRowMode ? 'md:flex-row' : 'md:flex-col'
|
||||
}`"
|
||||
>
|
||||
<div class="object-cover h-56 w-full md:max-w-[20rem] bg-slate-700" />
|
||||
|
||||
<div
|
||||
class="flex-1 space-3-4 flex self-start flex-col justify-between p-2 md:p-4 w-full"
|
||||
>
|
||||
<span class="h-2 bg-slate-700"></span>
|
||||
<span class="mb-2 h-4 bg-slate-700"></span>
|
||||
|
||||
<div class="flex space-x-4 flex-row">
|
||||
<div class="rounded-full bg-slate-700 h-10 w-10"></div>
|
||||
<div class="flex flex-col flex-1 space-y-2">
|
||||
<div class="h-3 bg-slate-700"></div>
|
||||
<div class="h-2 bg-slate-700"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-3 bg-slate-700 mt-3 w-60" v-if="!minimal"></div>
|
||||
<div class="flex" v-if="!minimal">
|
||||
<div
|
||||
class="h-3 bg-slate-700 mt-2 w-20 mr-2 rounded"
|
||||
v-for="i in 3"
|
||||
:key="i"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue";
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
viewMode?: string;
|
||||
minimal?: boolean;
|
||||
}>(),
|
||||
{ viewMode: "row", minimal: true }
|
||||
);
|
||||
|
||||
const isRowMode = computed<boolean>(() => props.viewMode == "row");
|
||||
</script>
|
||||
20
src/components/Event/SkeletonEventResultList.vue
Normal file
20
src/components/Event/SkeletonEventResultList.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<div class="bg-white dark:bg-slate-800 shadow rounded-md w-full mx-auto">
|
||||
<div class="animate-pulse flex flex-col sm:flex-row space-3-4 items-center">
|
||||
<div class="object-cover h-40 w-72 bg-slate-700 m-2 md:m-4 shrink-0" />
|
||||
|
||||
<div
|
||||
class="flex gap-3 flex self-start flex-col justify-between m-2 md:m-4 w-full px-2 md:px-4"
|
||||
>
|
||||
<div class="h-3 bg-slate-700 w-52 hidden sm:block"></div>
|
||||
<div class="h-5 bg-slate-700 w-72 lg:w-96"></div>
|
||||
<div class="flex items-center">
|
||||
<div
|
||||
class="rounded-full object-cover h-6 w-6 bg-slate-700 mx-2 shrink-0"
|
||||
/>
|
||||
<div class="h-3 bg-slate-700 w-52"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
23
src/components/Event/TagInput.story.vue
Normal file
23
src/components/Event/TagInput.story.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<Story>
|
||||
<Variant title="new">
|
||||
<TagInput v-model="tags" :fetch-tags="fetchTags" />
|
||||
</Variant>
|
||||
<!-- <Variant title="small">
|
||||
<TagInput v-model="tags" />
|
||||
</Variant> -->
|
||||
</Story>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ITag } from "@/types/tag.model";
|
||||
import { reactive } from "vue";
|
||||
import TagInput from "./TagInput.vue";
|
||||
|
||||
const tags = reactive<ITag[]>([{ title: "Hello", slug: "hello" }]);
|
||||
|
||||
const fetchTags = async () =>
|
||||
new Promise<ITag[]>((resolve) => {
|
||||
resolve([{ title: "Welcome", slug: "welcome" }]);
|
||||
});
|
||||
</script>
|
||||
91
src/components/Event/TagInput.vue
Normal file
91
src/components/Event/TagInput.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<o-field :label-for="id">
|
||||
<template #label>
|
||||
{{ $t("Add some tags") }}
|
||||
<o-tooltip
|
||||
variant="dark"
|
||||
:label="
|
||||
$t('You can add tags by hitting the Enter key or by adding a comma')
|
||||
"
|
||||
>
|
||||
<HelpCircleOutline :size="16" />
|
||||
</o-tooltip>
|
||||
</template>
|
||||
<o-inputitems
|
||||
v-model="tagsStrings"
|
||||
:data="filteredTags"
|
||||
:allow-autocomplete="true"
|
||||
:allow-new="true"
|
||||
:field="'title'"
|
||||
icon="label"
|
||||
:maxlength="20"
|
||||
:maxitems="10"
|
||||
:placeholder="$t('Eg: Stockholm, Dance, Chess…')"
|
||||
@typing="debouncedGetFilteredTags"
|
||||
:id="id"
|
||||
dir="auto"
|
||||
>
|
||||
</o-inputitems>
|
||||
</o-field>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import differenceBy from "lodash/differenceBy";
|
||||
import { ITag } from "../../types/tag.model";
|
||||
import debounce from "lodash/debounce";
|
||||
import { computed, onBeforeMount, ref } from "vue";
|
||||
import HelpCircleOutline from "vue-material-design-icons/HelpCircleOutline.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: ITag[];
|
||||
fetchTags: (text: string) => Promise<ITag[]>;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(["update:modelValue"]);
|
||||
|
||||
const text = ref("");
|
||||
|
||||
const tags = ref<ITag[]>([]);
|
||||
|
||||
let componentId = 0;
|
||||
|
||||
onBeforeMount(() => {
|
||||
componentId += 1;
|
||||
});
|
||||
|
||||
const id = computed((): string => {
|
||||
return `tag-input-${componentId}`;
|
||||
});
|
||||
|
||||
const getFilteredTags = async (newText: string): Promise<void> => {
|
||||
text.value = newText;
|
||||
tags.value = await props.fetchTags(newText);
|
||||
};
|
||||
|
||||
const debouncedGetFilteredTags = debounce(getFilteredTags, 200);
|
||||
|
||||
const filteredTags = computed((): ITag[] => {
|
||||
return differenceBy(tags.value, props.modelValue, "id").filter(
|
||||
(option) =>
|
||||
option.title.toString().toLowerCase().indexOf(text.value.toLowerCase()) >=
|
||||
0 ||
|
||||
option.slug.toString().toLowerCase().indexOf(text.value.toLowerCase()) >=
|
||||
0
|
||||
);
|
||||
});
|
||||
|
||||
const tagsStrings = computed({
|
||||
get(): string[] {
|
||||
return props.modelValue.map((tag: ITag) => tag.title);
|
||||
},
|
||||
set(newTagsStrings: string[]) {
|
||||
console.debug("tagsStrings", newTagsStrings);
|
||||
const tagEntities = newTagsStrings.map((tag: string | ITag) => {
|
||||
if (typeof tag !== "string") {
|
||||
return tag;
|
||||
}
|
||||
return { title: tag, slug: tag } as ITag;
|
||||
});
|
||||
emit("update:modelValue", tagEntities);
|
||||
},
|
||||
});
|
||||
</script>
|
||||
Reference in New Issue
Block a user