Merge branch 'fixes' into 'main'

Fixes

See merge request framasoft/mobilizon!1286
This commit is contained in:
Thomas Citharel 2022-10-11 17:15:21 +00:00
commit 68378c1860
13 changed files with 1030 additions and 1007 deletions

View File

@ -0,0 +1,904 @@
<template>
<div class="">
<participation-section
v-if="event && currentActor && identities && 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">
<template v-if="!event?.draft">
<p
v-if="event?.visibility === EventVisibility.PUBLIC"
class="inline-flex gap-1"
>
<Earth />
{{ t("Public event") }}
</p>
<p
v-if="event?.visibility === EventVisibility.UNLISTED"
class="inline-flex gap-1"
>
<Link />
{{ t("Private event") }}
</p>
</template>
<template v-if="!event?.local && organizer?.domain">
<a :href="event?.url">
<tag>{{ organizer?.domain }}</tag>
</a>
</template>
<p class="inline-flex gap-1">
<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>
<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')"
>
<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 {
EventVisibility,
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 Tag from "@/components/TagElement.vue";
import Earth from "vue-material-design-icons/Earth.vue";
import Link from "vue-material-design-icons/Link.vue";
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";
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();
}
}
});
onJoinEventMutationError((error) => {
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({
eventId: 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>

View File

@ -1,5 +1,5 @@
<template> <template>
<div class="flex justify-center h-80"> <div class="flex justify-center max-h-80">
<lazy-image-wrapper :picture="picture" /> <lazy-image-wrapper :picture="picture" />
</div> </div>
</template> </template>

View File

@ -12,13 +12,13 @@
<lazy-image-wrapper <lazy-image-wrapper
:picture="event.picture" :picture="event.picture"
:rounded="true" :rounded="true"
class="object-cover flex-none h-32 md:w-48 rounded-t-lg md:rounded-none md:rounded-l-lg" class="object-cover flex-none h-40 md:w-48 rounded-t-lg md:rounded-none md:rounded-l-lg"
/> />
</div> </div>
</div> </div>
<div class="title-info-wrapper p-2"> <div class="p-2">
<h3 <h3
class="event-minimalist-title pb-2 text-lg leading-6 line-clamp-3 font-bold text-violet-title dark:text-white" class="pb-2 text-lg leading-6 line-clamp-3 font-bold text-violet-title dark:text-white"
:lang="event.language" :lang="event.language"
dir="auto" dir="auto"
> >
@ -36,9 +36,13 @@
> >
{{ $t("Cancelled") }} {{ $t("Cancelled") }}
</tag> </tag>
<tag class="mr-2" variant="warning" size="medium" v-if="event.draft">{{ <tag
$t("Draft") class="mr-2 font-normal"
}}</tag> variant="warning"
size="medium"
v-if="event.draft"
>{{ $t("Draft") }}</tag
>
{{ event.title }} {{ event.title }}
</h3> </h3>
<inline-address <inline-address
@ -173,24 +177,5 @@ withDefaults(
// .calendar-icon { // .calendar-icon {
// @include margin-right(1rem); // @include margin-right(1rem);
// } // }
.title-info-wrapper {
flex: 2;
// .event-minimalist-title {
// padding-bottom: 5px;
// font-size: 18px;
// line-height: 24px;
// display: -webkit-box;
// -webkit-line-clamp: 3;
// -webkit-box-orient: vertical;
// overflow: hidden;
// font-weight: bold;
d // }
:deep(.icon) {
vertical-align: middle;
}
}
} }
</style> </style>

View File

@ -26,7 +26,7 @@
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-6 gap-x-1.5 md:gap-y-3 gapt-x-3" 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="mr-0 ml-0">
<div class="h-36 relative w-full"> <div class="h-40 relative w-full">
<div class="flex absolute bottom-2 left-2 z-10"> <div class="flex absolute bottom-2 left-2 z-10">
<date-calendar-icon <date-calendar-icon
:date="participation.event.beginsOn.toString()" :date="participation.event.beginsOn.toString()"

View File

@ -1,6 +1,6 @@
<template> <template>
<div ref="wrapper" class="" v-bind="$attrs"> <div ref="wrapper" class="flex-1" v-bind="$attrs">
<div class="h-full w-full"> <div class="h-full w-full max-w-100 min-h-[10rem]">
<!-- Show the placeholder as background --> <!-- Show the placeholder as background -->
<blurhash-img <blurhash-img
v-if="blurhash" v-if="blurhash"
@ -13,7 +13,7 @@
<!-- Show the real image on the top and fade in after loading --> <!-- Show the real image on the top and fade in after loading -->
<img <img
ref="image" ref="image"
class="transition-opacity duration-500 rounded-lg object-cover w-full h-full" class="transition-opacity duration-500 rounded-lg object-cover mx-auto h-full"
:class="imageOpacity" :class="imageOpacity"
alt="" alt=""
src="" src=""

View File

@ -55,7 +55,7 @@
import { LocationType } from "../../types/user-location.model"; import { LocationType } from "../../types/user-location.model";
import MoreContent from "./MoreContent.vue"; import MoreContent from "./MoreContent.vue";
import CloseContent from "./CloseContent.vue"; import CloseContent from "./CloseContent.vue";
import { computed, onMounted, ref, useAttrs } from "vue"; import { computed, onMounted, useAttrs } from "vue";
import { SEARCH_EVENTS } from "@/graphql/search"; import { SEARCH_EVENTS } from "@/graphql/search";
import { IEvent } from "@/types/event.model"; import { IEvent } from "@/types/event.model";
import { useLazyQuery } from "@vue/apollo-composable"; import { useLazyQuery } from "@vue/apollo-composable";

View File

@ -13,7 +13,7 @@
> >
<button <button
class="menubar__button" class="menubar__button"
:class="{ 'is-active': editor.isActive('bold') }" :class="{ 'is-active': editor?.isActive('bold') }"
@click="editor?.chain().focus().toggleBold().run()" @click="editor?.chain().focus().toggleBold().run()"
type="button" type="button"
:title="t('Bold')" :title="t('Bold')"
@ -23,7 +23,7 @@
<button <button
class="menubar__button" class="menubar__button"
:class="{ 'is-active': editor.isActive('italic') }" :class="{ 'is-active': editor?.isActive('italic') }"
@click="editor?.chain().focus().toggleItalic().run()" @click="editor?.chain().focus().toggleItalic().run()"
type="button" type="button"
:title="t('Italic')" :title="t('Italic')"
@ -33,7 +33,7 @@
<button <button
class="menubar__button" class="menubar__button"
:class="{ 'is-active': editor.isActive('underline') }" :class="{ 'is-active': editor?.isActive('underline') }"
@click="editor?.chain().focus().toggleUnderline().run()" @click="editor?.chain().focus().toggleUnderline().run()"
type="button" type="button"
:title="t('Underline')" :title="t('Underline')"
@ -44,7 +44,7 @@
<button <button
v-if="!isBasicMode" v-if="!isBasicMode"
class="menubar__button" class="menubar__button"
:class="{ 'is-active': editor.isActive('heading', { level: 1 }) }" :class="{ 'is-active': editor?.isActive('heading', { level: 1 }) }"
@click="editor?.chain().focus().toggleHeading({ level: 1 }).run()" @click="editor?.chain().focus().toggleHeading({ level: 1 }).run()"
type="button" type="button"
:title="t('Heading Level 1')" :title="t('Heading Level 1')"
@ -55,7 +55,7 @@
<button <button
v-if="!isBasicMode" v-if="!isBasicMode"
class="menubar__button" class="menubar__button"
:class="{ 'is-active': editor.isActive('heading', { level: 2 }) }" :class="{ 'is-active': editor?.isActive('heading', { level: 2 }) }"
@click="editor?.chain().focus().toggleHeading({ level: 2 }).run()" @click="editor?.chain().focus().toggleHeading({ level: 2 }).run()"
type="button" type="button"
:title="t('Heading Level 2')" :title="t('Heading Level 2')"
@ -66,7 +66,7 @@
<button <button
v-if="!isBasicMode" v-if="!isBasicMode"
class="menubar__button" class="menubar__button"
:class="{ 'is-active': editor.isActive('heading', { level: 3 }) }" :class="{ 'is-active': editor?.isActive('heading', { level: 3 }) }"
@click="editor?.chain().focus().toggleHeading({ level: 3 }).run()" @click="editor?.chain().focus().toggleHeading({ level: 3 }).run()"
type="button" type="button"
:title="t('Heading Level 3')" :title="t('Heading Level 3')"
@ -77,7 +77,7 @@
<button <button
class="menubar__button" class="menubar__button"
@click="showLinkMenu()" @click="showLinkMenu()"
:class="{ 'is-active': editor.isActive('link') }" :class="{ 'is-active': editor?.isActive('link') }"
type="button" type="button"
:title="t('Add link')" :title="t('Add link')"
> >
@ -85,7 +85,7 @@
</button> </button>
<button <button
v-if="editor.isActive('link')" v-if="editor?.isActive('link')"
class="menubar__button" class="menubar__button"
@click="editor?.chain().focus().unsetLink().run()" @click="editor?.chain().focus().unsetLink().run()"
type="button" type="button"
@ -107,7 +107,7 @@
<button <button
class="menubar__button" class="menubar__button"
v-if="!isBasicMode" v-if="!isBasicMode"
:class="{ 'is-active': editor.isActive('bulletList') }" :class="{ 'is-active': editor?.isActive('bulletList') }"
@click="editor?.chain().focus().toggleBulletList().run()" @click="editor?.chain().focus().toggleBulletList().run()"
type="button" type="button"
:title="t('Bullet list')" :title="t('Bullet list')"
@ -118,7 +118,7 @@
<button <button
v-if="!isBasicMode" v-if="!isBasicMode"
class="menubar__button" class="menubar__button"
:class="{ 'is-active': editor.isActive('orderedList') }" :class="{ 'is-active': editor?.isActive('orderedList') }"
@click="editor?.chain().focus().toggleOrderedList().run()" @click="editor?.chain().focus().toggleOrderedList().run()"
type="button" type="button"
:title="t('Ordered list')" :title="t('Ordered list')"
@ -129,7 +129,7 @@
<button <button
v-if="!isBasicMode" v-if="!isBasicMode"
class="menubar__button" class="menubar__button"
:class="{ 'is-active': editor.isActive('blockquote') }" :class="{ 'is-active': editor?.isActive('blockquote') }"
@click="editor?.chain().focus().toggleBlockquote().run()" @click="editor?.chain().focus().toggleBlockquote().run()"
type="button" type="button"
:title="t('Quote')" :title="t('Quote')"
@ -193,7 +193,7 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { Editor, EditorContent, BubbleMenu } from "@tiptap/vue-3"; import { useEditor, EditorContent, BubbleMenu } from "@tiptap/vue-3";
import Blockquote from "@tiptap/extension-blockquote"; import Blockquote from "@tiptap/extension-blockquote";
import BulletList from "@tiptap/extension-bullet-list"; import BulletList from "@tiptap/extension-bullet-list";
import Heading from "@tiptap/extension-heading"; import Heading from "@tiptap/extension-heading";
@ -218,7 +218,7 @@ import Underline from "@tiptap/extension-underline";
import Link from "@tiptap/extension-link"; import Link from "@tiptap/extension-link";
import { AutoDir } from "./Editor/Autodir"; import { AutoDir } from "./Editor/Autodir";
// import sanitizeHtml from "sanitize-html"; // import sanitizeHtml from "sanitize-html";
import { computed, inject, onBeforeUnmount, onMounted, ref, watch } from "vue"; import { computed, inject, onBeforeUnmount, watch } from "vue";
import { Dialog } from "@/plugins/dialog"; import { Dialog } from "@/plugins/dialog";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useMutation } from "@vue/apollo-composable"; import { useMutation } from "@vue/apollo-composable";
@ -254,8 +254,6 @@ const props = withDefaults(
const emit = defineEmits(["update:modelValue"]); const emit = defineEmits(["update:modelValue"]);
const editor = ref<Editor | null>(null);
const isDescriptionMode = computed((): boolean => { const isDescriptionMode = computed((): boolean => {
return props.mode === "description" || isBasicMode.value; return props.mode === "description" || isBasicMode.value;
}); });
@ -278,13 +276,28 @@ const isBasicMode = computed((): boolean => {
// const observer = ref<MutationObserver | null>(null); // const observer = ref<MutationObserver | null>(null);
onMounted(() => { const transformPastedHTML = (html: string): string => {
editor.value = new Editor({ // When using comment mode, limit to acceptable tags
if (isCommentMode.value) {
// return sanitizeHtml(html, {
// allowedTags: ["b", "i", "em", "strong", "a"],
// allowedAttributes: {
// a: ["href", "rel", "target"],
// },
// });
return html;
}
return html;
};
const editor = useEditor({
editorProps: { editorProps: {
attributes: { attributes: {
"aria-multiline": isShortMode.value.toString(), "aria-multiline": isShortMode.value.toString(),
"aria-label": props.ariaLabel ?? "", "aria-label": props.ariaLabel ?? "",
role: "textbox", role: "textbox",
class:
"prose dark:prose-invert prose-sm sm:prose lg:prose-lg xl:prose-xl m-5 focus:outline-none !max-w-full",
}, },
transformPastedHTML: transformPastedHTML, transformPastedHTML: transformPastedHTML,
}, },
@ -317,21 +330,6 @@ onMounted(() => {
emit("update:modelValue", editor.value?.getHTML()); emit("update:modelValue", editor.value?.getHTML());
}, },
}); });
});
const transformPastedHTML = (html: string): string => {
// When using comment mode, limit to acceptable tags
if (isCommentMode.value) {
// return sanitizeHtml(html, {
// allowedTags: ["b", "i", "em", "strong", "a"],
// allowedAttributes: {
// a: ["href", "rel", "target"],
// },
// });
return html;
}
return html;
};
const value = computed(() => props.modelValue); const value = computed(() => props.modelValue);
@ -351,6 +349,7 @@ const { t } = useI18n({ useScope: "global" });
const showLinkMenu = (): void => { const showLinkMenu = (): void => {
dialog?.prompt({ dialog?.prompt({
message: t("Enter the link URL"), message: t("Enter the link URL"),
hasInput: true,
inputAttrs: { inputAttrs: {
type: "url", type: "url",
}, },

View File

@ -28,7 +28,7 @@ export const VALIDATE_USER = gql`
`; `;
export const LOGGED_USER = gql` export const LOGGED_USER = gql`
query { query LoggedUserQuery {
loggedUser { loggedUser {
id id
email email
@ -300,7 +300,7 @@ export const UPDATE_USER_LOCALE = gql`
`; `;
export const FEED_TOKENS_LOGGED_USER = gql` export const FEED_TOKENS_LOGGED_USER = gql`
query { query FeedTokensLoggedUser {
loggedUser { loggedUser {
id id
feedTokens { feedTokens {

View File

@ -1,7 +1,11 @@
<template> <template>
<div class="container mx-auto py-4 md:py-12 px-2 md:px-60"> <div class="container mx-auto py-4 md:py-12 px-2 md:px-60">
<main> <main>
<div class="flex flex-wrap items-center justify-center gap-3 md:gap-4"> <h1>{{ t("Category list") }}</h1>
<div
class="flex flex-wrap items-center justify-center gap-3 md:gap-4"
v-if="promotedCategories.length > 0"
>
<CategoryCard <CategoryCard
v-for="category in promotedCategories" v-for="category in promotedCategories"
:key="category.key" :key="category.key"
@ -9,40 +13,31 @@
:with-details="true" :with-details="true"
/> />
</div> </div>
<div v-else>
<EmptyContent icon="image" :inline="true">
{{
t(
"No categories with public upcoming events on this instance were found."
)
}}
</EmptyContent>
</div>
<div <div
class="mx-auto w-full max-w-lg rounded-2xl dark:bg-gray-800 p-2 mt-10" class="mx-auto w-full max-w-lg rounded-2xl dark:bg-gray-800 p-2 mt-10"
> >
<div <o-collapse
class="card" v-model:open="isLicencePanelOpen"
animation="slide"
:open="isLicencePanelOpen"
@open="isLicencePanelOpen = !isLicencePanelOpen"
:aria-id="'contentIdForA11y5'" :aria-id="'contentIdForA11y5'"
> >
<div> <template #trigger>
<button <o-button
class="flex w-full justify-between rounded-lg px-4 py-2 text-left text-sm font-medium dark:text-zinc-300" aria-controls="contentIdForA11y1"
:icon-right="isLicencePanelOpen ? 'chevron-up' : 'chevron-down'"
> >
{{ t("Category illustrations credits") }} {{ t("Category illustrations credits") }}
<svg </o-button>
width="24" </template>
height="24"
:class="isLicencePanelOpen ? 'transform rotate-90' : ''"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth="{2}"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 5l7 7-7 7"
/>
</svg>
</button>
</div>
<div class="flex flex-col dark:text-zinc-300 gap-2 py-4 px-1"> <div class="flex flex-col dark:text-zinc-300 gap-2 py-4 px-1">
<p <p
v-for="(categoryLicence, key) in categoriesPicturesLicences" v-for="(categoryLicence, key) in categoriesPicturesLicences"
@ -88,7 +83,7 @@
/> />
</p> </p>
</div> </div>
</div> </o-collapse>
</div> </div>
</main> </main>
</div> </div>
@ -107,6 +102,7 @@ import {
} from "@/components/Categories/constants"; } from "@/components/Categories/constants";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useEventCategories } from "@/composition/apollo/config"; import { useEventCategories } from "@/composition/apollo/config";
import EmptyContent from "@/components/Utils/EmptyContent.vue";
const { t } = useI18n({ useScope: "global" }); const { t } = useI18n({ useScope: "global" });

File diff suppressed because it is too large Load Diff

View File

@ -1674,6 +1674,7 @@ defmodule Mobilizon.Events do
|> Repo.all() |> Repo.all()
end end
@spec category_statistics :: [{String.t(), non_neg_integer()}]
def category_statistics do def category_statistics do
Event Event
|> filter_future_events(true) |> filter_future_events(true)

View File

@ -7,9 +7,11 @@
<link rel="apple-touch-icon" href="/img/icons/apple-touch-icon-152x152.png" sizes="152x152" /> <link rel="apple-touch-icon" href="/img/icons/apple-touch-icon-152x152.png" sizes="152x152" />
<link rel="mask-icon" href="/img/icons/safari-pinned-tab.svg" color={theme_color()} /> <link rel="mask-icon" href="/img/icons/safari-pinned-tab.svg" color={theme_color()} />
<meta name="theme-color" content={theme_color()} /> <meta name="theme-color" content={theme_color()} />
<%= if is_root(assigns) do %>
<link rel="preload" href="/img/shape-1.svg" as="image" /> <link rel="preload" href="/img/shape-1.svg" as="image" />
<link rel="preload" href="/img/shape-2.svg" as="image" /> <link rel="preload" href="/img/shape-2.svg" as="image" />
<link rel="preload" href="/img/shape-3.svg" as="image" /> <link rel="preload" href="/img/shape-3.svg" as="image" />
<% end %>
<%= tags(assigns) || assigns.tags %> <%= tags(assigns) || assigns.tags %>
<%= Vite.inlined_phx_manifest() %> <%= Vite.inlined_phx_manifest() %>
<%= Vite.vite_client() %> <%= Vite.vite_client() %>

View File

@ -86,4 +86,9 @@ defmodule Mobilizon.Web.PageView do
def language_direction(assigns) do def language_direction(assigns) do
assigns |> Map.get(:locale, "en") |> get_language_direction() assigns |> Map.get(:locale, "en") |> get_language_direction()
end end
@spec is_root(map()) :: boolean()
def is_root(assigns) do
assigns |> Map.get(:conn, %{request_path: "/"}) |> Map.get(:request_path, "/") == "/"
end
end end