feat(front): #1334 add a calendar view

This commit is contained in:
setop 2024-03-06 12:01:27 +01:00
commit e168fb307b
14 changed files with 515 additions and 8 deletions

View File

@ -18,6 +18,8 @@ defmodule Mobilizon.Web.PageController do
defdelegate my_events(conn, params), to: PageController, as: :index
@spec create_event(Plug.Conn.t(), any) :: Plug.Conn.t()
defdelegate create_event(conn, params), to: PageController, as: :index
@spec calendar(Plug.Conn.t(), any) :: Plug.Conn.t()
defdelegate calendar(conn, params), to: PageController, as: :index
@spec list_events(Plug.Conn.t(), any) :: Plug.Conn.t()
defdelegate list_events(conn, params), to: PageController, as: :index
@spec edit_event(Plug.Conn.t(), any) :: Plug.Conn.t()

View File

@ -77,7 +77,7 @@ defmodule Mobilizon.Web.Plugs.HTTPSecurityPlug do
# unsafe-eval is because of JS issues with regenerator-runtime
@script_src "script-src 'self' 'unsafe-eval' "
@style_src "style-src 'self' "
@font_src "font-src 'self' "
@font_src "font-src 'self' data: "
@spec csp_string(Keyword.t()) :: String.t()
defp csp_string(options) do
@ -117,6 +117,8 @@ defmodule Mobilizon.Web.Plugs.HTTPSecurityPlug do
style_src = [style_src] ++ [get_csp_config(:style_src, options)]
style_src = [style_src] ++ ["'sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU='"]
font_src = [@font_src] ++ [get_csp_config(:font_src, options)]
frame_src = build_csp_field(:frame_src, options)

View File

@ -120,6 +120,7 @@ defmodule Mobilizon.Web.Router do
get("/@:name", PageController, :actor)
get("/events/me", PageController, :my_events)
get("/events/create", PageController, :create_event)
get("/events/calendar", PageController, :calendar)
get("/events/:uuid", PageController, :event)
get("/comments/:uuid", PageController, :comment)
get("/resource/:uuid", PageController, :resource)
@ -188,6 +189,7 @@ defmodule Mobilizon.Web.Router do
get("/events/create", PageController, :create_event)
get("/events/list", PageController, :list_events)
get("/events/me", PageController, :my_events)
get("/events/calendar", PageController, :calendar)
get("/events/:uuid/edit", PageController, :edit_event)
# This is a hack to ease link generation into emails

52
package-lock.json generated
View File

@ -12,6 +12,10 @@
"@apollo/client": "^3.3.16",
"@framasoft/socket": "^1.0.0",
"@framasoft/socket-apollo-link": "^1.0.0",
"@fullcalendar/core": "^6.1.10",
"@fullcalendar/daygrid": "^6.1.10",
"@fullcalendar/interaction": "^6.1.10",
"@fullcalendar/vue3": "^6.1.10",
"@oruga-ui/oruga-next": "^0.8.2",
"@oruga-ui/theme-oruga": "^0.2.0",
"@sentry/tracing": "^7.1",
@ -57,6 +61,7 @@
"graphql": "^16.8.1",
"graphql-tag": "^2.10.3",
"hammerjs": "^2.0.8",
"ical.js": "^1.5.0",
"intersection-observer": "^0.12.0",
"jwt-decode": "^4.0.0",
"leaflet": "^1.4.0",
@ -2667,6 +2672,39 @@
"zen-observable": "^0.10.0"
}
},
"node_modules/@fullcalendar/core": {
"version": "6.1.10",
"resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.10.tgz",
"integrity": "sha512-oTXGJSAGpCf1oY+CKp5qYjMHkJCPBkJ3SHitl63n8Q6xKeiwQ4EF6Au451euUovREwJpLmD1AyZrCnWmtB9AVg==",
"dependencies": {
"preact": "~10.12.1"
}
},
"node_modules/@fullcalendar/daygrid": {
"version": "6.1.10",
"resolved": "https://registry.npmjs.org/@fullcalendar/daygrid/-/daygrid-6.1.10.tgz",
"integrity": "sha512-Z4GRm1IyHKgxXFTWGcEI0nTsvYOIkpE0aMt3/o3ER2SZkF+hfwcDFhtj0c9+WhMjXFIWYeoTnA9rUOY7Zl/nxA==",
"peerDependencies": {
"@fullcalendar/core": "~6.1.10"
}
},
"node_modules/@fullcalendar/interaction": {
"version": "6.1.10",
"resolved": "https://registry.npmjs.org/@fullcalendar/interaction/-/interaction-6.1.10.tgz",
"integrity": "sha512-aZRlwCpmDasq2RNeWV0ub20Uevare9Cb6iMlxCacx0fhOC14H28G9d1FsduJIecInL84SPGwt5ItqAYMsWv7zw==",
"peerDependencies": {
"@fullcalendar/core": "~6.1.10"
}
},
"node_modules/@fullcalendar/vue3": {
"version": "6.1.10",
"resolved": "https://registry.npmjs.org/@fullcalendar/vue3/-/vue3-6.1.10.tgz",
"integrity": "sha512-YMYBQx0TlWNuN4G6ra2dkf5cCF5aVi/2zDLGLvLqe2Nk2o7uNbTkrCSG40061OepWQlJv+hYqm1JukLRmyqi4Q==",
"peerDependencies": {
"@fullcalendar/core": "~6.1.10",
"vue": "^3.0.11"
}
},
"node_modules/@graphql-typed-document-node/core": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz",
@ -8215,6 +8253,11 @@
"url": "https://github.com/sponsors/typicode"
}
},
"node_modules/ical.js": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/ical.js/-/ical.js-1.5.0.tgz",
"integrity": "sha512-7ZxMkogUkkaCx810yp0ZGKvq1ZpRgJeornPttpoxe6nYZ3NLesZe1wWMXDdwTkj/b5NtXT+Y16Aakph/ao98ZQ=="
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
@ -10734,6 +10777,15 @@
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="
},
"node_modules/preact": {
"version": "10.12.1",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz",
"integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",

View File

@ -36,6 +36,10 @@
"@framasoft/socket-apollo-link": "^1.0.0",
"@oruga-ui/oruga-next": "^0.8.2",
"@oruga-ui/theme-oruga": "^0.2.0",
"@fullcalendar/core": "^6.1.10",
"@fullcalendar/daygrid": "^6.1.10",
"@fullcalendar/interaction": "^6.1.10",
"@fullcalendar/vue3": "^6.1.10",
"@sentry/tracing": "^7.1",
"@sentry/vue": "^7.1",
"@tiptap/core": "^2.0.0-beta.41",
@ -79,6 +83,7 @@
"graphql": "^16.8.1",
"graphql-tag": "^2.10.3",
"hammerjs": "^2.0.8",
"ical.js": "^1.5.0",
"intersection-observer": "^0.12.0",
"jwt-decode": "^4.0.0",
"leaflet": "^1.4.0",

View File

@ -0,0 +1,228 @@
<template>
<FullCalendar
ref="calendarRef"
:options="calendarOptions"
class="agenda-view"
/>
<div v-if="listOfEventsByDate.date" class="my-4">
<b v-text="formatDateString(listOfEventsByDate.date)" />
<div v-if="listOfEventsByDate.events.length > 0">
<div
v-for="(event, index) in listOfEventsByDate.events"
v-bind:key="index"
>
<div class="scroll-ml-6 snap-center shrink-0 my-4">
<EventCard :event="event.event.extendedProps.event" />
</div>
</div>
</div>
<EmptyContent v-else icon="calendar" :inline="true">
<span>
{{ t("No events found") }}
</span>
</EmptyContent>
</div>
</template>
<script lang="ts" setup>
import { useI18n } from "vue-i18n";
import { locale } from "@/utils/i18n";
import { computed, ref } from "vue";
import { useLazyQuery } from "@vue/apollo-composable";
import { IEvent } from "@/types/event.model";
import { Paginate } from "@/types/paginate";
import { SEARCH_CALENDAR_EVENTS } from "@/graphql/search";
import FullCalendar from "@fullcalendar/vue3";
import { EventSegment } from "@fullcalendar/core";
import dayGridPlugin from "@fullcalendar/daygrid";
import interactionPlugin from "@fullcalendar/interaction";
import {
formatDateISOStringWithoutTime,
formatDateString,
} from "@/filters/datetime";
import EventCard from "../Event/EventCard.vue";
import EmptyContent from "../Utils/EmptyContent.vue";
const { t } = useI18n({ useScope: "global" });
const calendarRef = ref();
const lastSelectedDate = ref<string | undefined>(new Date().toISOString());
const listOfEventsByDate = ref<{ events: EventSegment[]; date?: string }>({
events: [],
date: undefined,
});
const showEventsByDate = (dateStr: string) => {
dateStr = formatDateISOStringWithoutTime(dateStr);
const moreLinkElement = document.querySelectorAll(
`td[data-date='${dateStr}'] a.fc-more-link`
)[0] as undefined | HTMLElement;
if (moreLinkElement) {
moreLinkElement.click();
} else {
listOfEventsByDate.value = {
events: [],
date: dateStr,
};
}
calendarRef.value.getApi().select(dateStr);
};
if (window.location.hash.length) {
lastSelectedDate.value = formatDateISOStringWithoutTime(
window.location.hash.replace("#_", "")
);
} else {
lastSelectedDate.value = formatDateISOStringWithoutTime(
new Date().toISOString()
);
}
const { load: searchEventsLoad, refetch: searchEventsRefetch } = useLazyQuery<{
searchEvents: Paginate<IEvent>;
}>(SEARCH_CALENDAR_EVENTS);
const calendarOptions = computed((): object => {
return {
plugins: [dayGridPlugin, interactionPlugin],
initialView: "dayGridMonth",
initialDate: lastSelectedDate.value,
events: async (
info: { start: Date; end: Date; startStr: string; endStr: string },
successCallback: (arg: object[]) => unknown,
failureCallback: (err: string) => unknown
) => {
const queryVars = {
limit: 999,
beginsOn: info.start,
endsOn: info.end,
};
const result =
(await searchEventsLoad(undefined, queryVars)) ||
(await searchEventsRefetch(queryVars))?.data;
if (!result) {
failureCallback("failed to fetch calendar events");
return;
}
successCallback(
(result.searchEvents.elements ?? []).map((event: IEvent) => {
return {
id: event.id,
title: event.title,
start: event.beginsOn,
end: event.endsOn,
startStr: event.beginsOn,
endStr: event.endsOn,
url: event.url,
extendedProps: {
event: event,
},
};
})
);
},
nextDayThreshold: "09:00:00",
dayMaxEventRows: 0,
moreLinkClassNames: "bg-mbz-yellow dark:bg-mbz-purple dark:text-white",
moreLinkContent: (arg: { num: number; text: string }) => {
return "+" + arg.num.toString();
},
contentHeight: "auto",
eventClassNames: "line-clamp-3 bg-mbz-yellow dark:bg-mbz-purple",
headerToolbar: {
left: "prev,next,customTodayButton",
center: "",
right: "title",
},
locale: locale,
firstDay: 1,
buttonText: {
today: t("Today"),
month: t("Month"),
week: t("Week"),
day: t("Day"),
list: t("List"),
},
customButtons: {
customTodayButton: {
text: t("Today"),
click: () => {
calendarRef.value.getApi().today();
lastSelectedDate.value = formatDateISOStringWithoutTime(
new Date().toISOString()
);
},
},
},
dateClick: (info: { dateStr: string }) => {
showEventsByDate(info.dateStr);
},
moreLinkClick: (info: {
date: Date;
allSegs: EventSegment[];
hiddenSegs: EventSegment[];
jsEvent: object;
}) => {
listOfEventsByDate.value = {
events: info.allSegs,
date: info.date.toISOString(),
};
if (info.allSegs.length) {
window.location.hash =
"_" + formatDateISOStringWithoutTime(info.date.toISOString());
}
return "none";
},
moreLinkDidMount: (arg: { el: Element }) => {
if (
lastSelectedDate.value &&
arg.el.closest(`td[data-date='${lastSelectedDate.value}']`)
) {
showEventsByDate(lastSelectedDate.value);
lastSelectedDate.value = undefined;
}
},
};
});
</script>
<style>
.agenda-view .fc-button {
font-size: 0.8rem !important;
}
.agenda-view .fc-toolbar-title {
font-size: 1rem !important;
}
.agenda-view .fc-daygrid-day-events {
min-height: 1.1rem !important;
margin-bottom: 0.2rem !important;
margin-left: 0.1rem !important;
}
.agenda-view .fc-more-link {
pointer-events: none !important;
}
.clock-icon {
display: inline-block;
vertical-align: middle;
}
.time {
font-size: 0.95rem !important;
}
</style>

View File

@ -0,0 +1,104 @@
<template>
<FullCalendar ref="calendarRef" :options="calendarOptions">
<template v-slot:eventContent="arg">
<span
class="text-violet-3 dark:text-white font-bold m-2"
:title="arg.event.title"
>
{{ arg.event.title }}
</span>
</template>
</FullCalendar>
</template>
<script lang="ts" setup>
import { useI18n } from "vue-i18n";
import { locale } from "@/utils/i18n";
import { computed, ref } from "vue";
import { useLazyQuery } from "@vue/apollo-composable";
import { IEvent } from "@/types/event.model";
import { Paginate } from "@/types/paginate";
import { SEARCH_CALENDAR_EVENTS } from "@/graphql/search";
import FullCalendar from "@fullcalendar/vue3";
import dayGridPlugin from "@fullcalendar/daygrid";
import interactionPlugin from "@fullcalendar/interaction";
const calendarRef = ref();
const { t } = useI18n({ useScope: "global" });
const { load: searchEventsLoad, refetch: searchEventsRefetch } = useLazyQuery<{
searchEvents: Paginate<IEvent>;
}>(SEARCH_CALENDAR_EVENTS);
const calendarOptions = computed((): object => {
return {
plugins: [dayGridPlugin, interactionPlugin],
initialView: "dayGridMonth",
events: async (
info: { start: Date; end: Date; startStr: string; endStr: string },
successCallback: (arg: object[]) => unknown,
failureCallback: (err: string) => unknown
) => {
const queryVars = {
limit: 999,
beginsOn: info.start,
endsOn: info.end,
};
const result =
(await searchEventsLoad(undefined, queryVars)) ||
(await searchEventsRefetch(queryVars))?.data;
if (!result) {
failureCallback("failed to fetch calendar events");
return;
}
successCallback(
(result.searchEvents.elements ?? []).map((event: IEvent) => {
return {
id: event.id,
title: event.title,
start: event.beginsOn,
end: event.endsOn,
startStr: event.beginsOn,
endStr: event.endsOn,
url: `/events/${event.uuid}`,
extendedProps: {
event: event,
},
};
})
);
},
nextDayThreshold: "09:00:00",
dayMaxEventRows: 5,
moreLinkClassNames: "bg-mbz-yellow dark:bg-mbz-purple dark:text-white p-2",
moreLinkContent: (arg: { num: number; text: string }) => {
return "+" + arg.num.toString();
},
eventClassNames: "line-clamp-3 bg-mbz-yellow dark:bg-mbz-purple",
headerToolbar: {
left: "prev,next,today",
center: "title",
right: "dayGridWeek,dayGridMonth", // user can switch between the two
},
locale: locale,
firstDay: 1,
buttonText: {
today: t("Today"),
month: t("Month"),
week: t("Week"),
day: t("Day"),
list: t("List"),
},
};
});
</script>
<style>
.fc-popover-header {
color: black !important;
}
</style>

View File

@ -181,7 +181,21 @@
<ul
class="flex flex-col md:flex-row md:space-x-8 mt-2 md:mt-0 md:font-lightbold"
>
<li v-if="currentActor?.id">
<search-fields
v-if="showMobileMenu"
class="m-auto w-auto"
v-model:search="search"
v-model:location="location"
/>
<li class="m-auto">
<router-link
:to="{ name: RouteName.EVENT_CALENDAR }"
class="block py-2 pr-4 pl-3 text-zinc-700 border-b border-gray-100 hover:bg-zinc-50 md:hover:bg-transparent md:border-0 md:hover:text-mbz-purple-700 md:p-0 dark:text-zinc-400 md:dark:hover:text-white dark:hover:bg-zinc-700 dark:hover:text-white md:dark:hover:bg-transparent dark:border-gray-700"
>{{ t("Calendar") }}</router-link
>
</li>
<li class="m-auto" v-if="currentActor?.id">
<router-link
:to="{ name: RouteName.MY_EVENTS }"
class="block py-2 pr-4 pl-3 text-zinc-700 border-b border-gray-100 hover:bg-zinc-50 md:hover:bg-transparent md:border-0 md:hover:text-mbz-purple-700 md:p-0 dark:text-zinc-400 md:dark:hover:text-white dark:hover:bg-zinc-700 dark:hover:text-white md:dark:hover:bg-transparent dark:border-gray-700"
@ -209,6 +223,13 @@
>{{ t("Register") }}</router-link
>
</li>
<search-fields
v-if="!showMobileMenu"
class="m-auto w-auto"
v-model:search="search"
v-model:location="location"
/>
</ul>
</div>
</div>
@ -219,19 +240,18 @@
import MobilizonLogo from "@/components/MobilizonLogo.vue";
import { ICurrentUserRole } from "@/types/enums";
import { logout } from "../utils/auth";
import { IPerson, displayName } from "../types/actor";
import { displayName } from "../types/actor";
import RouteName from "../router/name";
import { computed, onMounted, ref, watch } from "vue";
import { computed, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useI18n } from "vue-i18n";
import AccountCircle from "vue-material-design-icons/AccountCircle.vue";
import Inbox from "vue-material-design-icons/Inbox.vue";
import { useCurrentUserClient } from "@/composition/apollo/user";
import {
useCurrentActorClient,
useCurrentUserIdentities,
} from "@/composition/apollo/actor";
import { useLazyQuery, useMutation } from "@vue/apollo-composable";
import { useMutation } from "@vue/apollo-composable";
import { UPDATE_DEFAULT_ACTOR } from "@/graphql/actor";
import { changeIdentity } from "@/utils/identity";
import { useRegistrationConfig } from "@/composition/apollo/config";

View File

@ -4,6 +4,10 @@ function parseDateTime(value: string): Date {
return new Date(value);
}
function formatDateISOStringWithoutTime(value: string): string {
return parseDateTime(value).toISOString().split("T")[0];
}
function formatDateString(value: string): string {
return parseDateTime(value).toLocaleString(locale(), {
weekday: "long",
@ -76,4 +80,9 @@ function formatDateTimeString(
const locale = () => i18n.global.locale.replace("_", "-");
export { formatDateString, formatTimeString, formatDateTimeString };
export {
formatDateISOStringWithoutTime,
formatDateString,
formatTimeString,
formatDateTimeString,
};

View File

@ -206,6 +206,56 @@ export const SEARCH_EVENTS = gql`
${ACTOR_FRAGMENT}
`;
export const SEARCH_CALENDAR_EVENTS = gql`
query SearchEvents(
$beginsOn: DateTime
$endsOn: DateTime
$eventPage: Int
$limit: Int
) {
searchEvents(
beginsOn: $beginsOn
endsOn: $endsOn
page: $eventPage
limit: $limit
) {
total
elements {
id
title
uuid
beginsOn
endsOn
picture {
id
url
}
status
tags {
...TagFragment
}
physicalAddress {
...AdressFragment
}
organizerActor {
...ActorFragment
}
attributedTo {
...ActorFragment
}
options {
...EventOptions
}
__typename
}
}
}
${EVENT_OPTIONS_FRAGMENT}
${TAG_FRAGMENT}
${ADDRESS_FRAGMENT}
${ACTOR_FRAGMENT}
`;
export const SEARCH_GROUPS = gql`
query SearchGroups(
$location: String

View File

@ -168,6 +168,7 @@
"By transit": "Mit öffentlichen Verkehrsmitteln",
"By {group}": "Von {group}",
"By {username}": "Von {username}",
"Calendar": "Kalender",
"Can be an email or a link, or just plain text.": "Dies kann eine E-Mail-Adresse oder ein Link sein. Oder einfach ein Freitext.",
"Cancel": "Abbrechen",
"Cancel anonymous participation": "Anonyme Teilnahme stornieren",

View File

@ -35,6 +35,7 @@
"Back to previous page": "Back to previous page",
"Before you can login, you need to click on the link inside it to validate your account.": "Before you can login, you need to click on the link inside it to validate your account.",
"By {username}": "By {username}",
"Calendar": "Calendar",
"Cancel anonymous participation": "Cancel anonymous participation",
"Cancel creation": "Cancel creation",
"Cancel edition": "Cancel edition",
@ -1648,4 +1649,4 @@
"You need to enter a text": "You need to enter a text",
"Error while adding tag: {error}": "Error while adding tag: {error}",
"From this instance only": "From this instance only"
}
}

View File

@ -7,9 +7,11 @@ const participations = () => import("@/views/Event/ParticipantsView.vue");
const editEvent = () => import("@/views/Event/EditView.vue");
const event = () => import("@/views/Event/EventView.vue");
const myEvents = () => import("@/views/Event/MyEventsView.vue");
const eventCalendar = () => import("@/views/Event/CalendarView.vue");
export enum EventRouteName {
EVENT_LIST = "EventList",
EVENT_CALENDAR = "EventCalendar",
CREATE_EVENT = "CreateEvent",
MY_EVENTS = "MyEvents",
EDIT_EVENT = "EditEvent",
@ -26,6 +28,14 @@ export enum EventRouteName {
}
export const eventRoutes: RouteRecordRaw[] = [
{
path: "/events/calendar",
name: EventRouteName.EVENT_CALENDAR,
component: eventCalendar,
meta: {
requiredAuth: false,
},
},
{
path: "/events/create",
name: EventRouteName.CREATE_EVENT,

View File

@ -0,0 +1,21 @@
<template>
<div class="container mx-auto px-1 mb-6">
<h1 v-if="!isMobile">
{{ t("Calendar") }}
</h1>
<div class="p-2">
<EventsCalendar v-if="!isMobile" />
<EventsAgenda v-else />
</div>
</div>
</template>
<script lang="ts" setup>
import { useI18n } from "vue-i18n";
import EventsAgenda from "@/components/FullCalendar/EventsAgenda.vue";
import EventsCalendar from "@/components/FullCalendar/EventsCalendar.vue";
const { t } = useI18n({ useScope: "global" });
const isMobile = window.innerWidth < 760;
</script>