mirror of
https://github.com/ae-utbm/sith.git
synced 2025-03-10 15:27:13 +00:00
343 lines
9.8 KiB
TypeScript
343 lines
9.8 KiB
TypeScript
import { makeUrl } from "#core:utils/api";
|
|
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components";
|
|
import { Calendar, type EventClickArg } from "@fullcalendar/core";
|
|
import type { EventImpl } from "@fullcalendar/core/internal";
|
|
import enLocale from "@fullcalendar/core/locales/en-gb";
|
|
import frLocale from "@fullcalendar/core/locales/fr";
|
|
import dayGridPlugin from "@fullcalendar/daygrid";
|
|
import iCalendarPlugin from "@fullcalendar/icalendar";
|
|
import listPlugin from "@fullcalendar/list";
|
|
import {
|
|
calendarCalendarExternal,
|
|
calendarCalendarInternal,
|
|
calendarCalendarUnpublished,
|
|
newsDeleteNews,
|
|
newsPublishNews,
|
|
newsUnpublishNews,
|
|
} from "#openapi";
|
|
|
|
@registerComponent("ics-calendar")
|
|
export class IcsCalendar extends inheritHtmlElement("div") {
|
|
static observedAttributes = ["locale", "can_moderate", "can_delete"];
|
|
private calendar: Calendar;
|
|
private locale = "en";
|
|
private canModerate = false;
|
|
private canDelete = false;
|
|
|
|
attributeChangedCallback(name: string, _oldValue?: string, newValue?: string) {
|
|
if (name === "locale") {
|
|
this.locale = newValue;
|
|
}
|
|
if (name === "can_moderate") {
|
|
this.canModerate = newValue.toLowerCase() === "true";
|
|
}
|
|
if (name === "can_delete") {
|
|
this.canDelete = newValue.toLowerCase() === "true";
|
|
}
|
|
}
|
|
|
|
isMobile() {
|
|
return window.innerWidth < 765;
|
|
}
|
|
|
|
currentView() {
|
|
// Get view type based on viewport
|
|
return this.isMobile() ? "listMonth" : "dayGridMonth";
|
|
}
|
|
|
|
currentToolbar() {
|
|
if (this.isMobile()) {
|
|
return {
|
|
left: "prev,next",
|
|
center: "title",
|
|
right: "",
|
|
};
|
|
}
|
|
return {
|
|
left: "prev,next today",
|
|
center: "title",
|
|
right: "dayGridMonth,dayGridWeek,dayGridDay",
|
|
};
|
|
}
|
|
|
|
formatDate(date: Date) {
|
|
return new Intl.DateTimeFormat(this.locale, {
|
|
dateStyle: "medium",
|
|
timeStyle: "short",
|
|
}).format(date);
|
|
}
|
|
|
|
getNewsId(event: EventImpl) {
|
|
return Number.parseInt(
|
|
event.url
|
|
.toString()
|
|
.split("/")
|
|
.filter((s) => s) // Remove blank characters
|
|
.pop(),
|
|
);
|
|
}
|
|
|
|
async refreshEvents() {
|
|
this.click(); // Remove focus from popup
|
|
// We can't just refresh events because some ics files are in
|
|
// local browser cache (especially internal.ics)
|
|
// To invalidate the cache, we need to remove the source and add it again
|
|
this.calendar.removeAllEventSources();
|
|
for (const source of await this.getEventSources()) {
|
|
this.calendar.addEventSource(source);
|
|
}
|
|
this.calendar.refetchEvents();
|
|
}
|
|
|
|
async publishNews(id: number) {
|
|
await newsPublishNews({
|
|
path: {
|
|
// biome-ignore lint/style/useNamingConvention: python API
|
|
news_id: id,
|
|
},
|
|
});
|
|
this.dispatchEvent(
|
|
new CustomEvent("calendar-publish", {
|
|
bubbles: true,
|
|
detail: {
|
|
id: id,
|
|
},
|
|
}),
|
|
);
|
|
await this.refreshEvents();
|
|
}
|
|
|
|
async unpublishNews(id: number) {
|
|
await newsUnpublishNews({
|
|
path: {
|
|
// biome-ignore lint/style/useNamingConvention: python API
|
|
news_id: id,
|
|
},
|
|
});
|
|
this.dispatchEvent(
|
|
new CustomEvent("calendar-unpublish", {
|
|
bubbles: true,
|
|
detail: {
|
|
id: id,
|
|
},
|
|
}),
|
|
);
|
|
await this.refreshEvents();
|
|
}
|
|
|
|
async deleteNews(id: number) {
|
|
await newsDeleteNews({
|
|
path: {
|
|
// biome-ignore lint/style/useNamingConvention: python API
|
|
news_id: id,
|
|
},
|
|
});
|
|
this.dispatchEvent(
|
|
new CustomEvent("calendar-delete", {
|
|
bubbles: true,
|
|
detail: {
|
|
id: id,
|
|
},
|
|
}),
|
|
);
|
|
await this.refreshEvents();
|
|
}
|
|
|
|
async getEventSources() {
|
|
const cacheInvalidate = `?invalidate=${Date.now()}`;
|
|
return [
|
|
{
|
|
url: `${await makeUrl(calendarCalendarInternal)}${cacheInvalidate}`,
|
|
format: "ics",
|
|
className: "internal",
|
|
},
|
|
{
|
|
url: `${await makeUrl(calendarCalendarExternal)}${cacheInvalidate}`,
|
|
format: "ics",
|
|
className: "external",
|
|
},
|
|
{
|
|
url: `${await makeUrl(calendarCalendarUnpublished)}${cacheInvalidate}`,
|
|
format: "ics",
|
|
color: "red",
|
|
className: "unpublished",
|
|
},
|
|
];
|
|
}
|
|
|
|
createEventDetailPopup(event: EventClickArg) {
|
|
// Delete previous popup
|
|
const oldPopup = document.getElementById("event-details");
|
|
if (oldPopup !== null) {
|
|
oldPopup.remove();
|
|
}
|
|
|
|
const makePopupInfo = (info: HTMLElement, iconClass: string) => {
|
|
const row = document.createElement("div");
|
|
const icon = document.createElement("i");
|
|
|
|
row.setAttribute("class", "event-details-row");
|
|
|
|
icon.setAttribute("class", `event-detail-row-icon fa-xl ${iconClass}`);
|
|
|
|
row.appendChild(icon);
|
|
row.appendChild(info);
|
|
|
|
return row;
|
|
};
|
|
|
|
const makePopupTitle = (event: EventImpl) => {
|
|
const row = document.createElement("div");
|
|
row.innerHTML = `
|
|
<h4 class="event-details-row-content">
|
|
${event.title}
|
|
</h4>
|
|
<span class="event-details-row-content">
|
|
${this.formatDate(event.start)} - ${this.formatDate(event.end)}
|
|
</span>
|
|
`;
|
|
return makePopupInfo(
|
|
row,
|
|
"fa-solid fa-calendar-days fa-xl event-detail-row-icon",
|
|
);
|
|
};
|
|
|
|
const makePopupLocation = (event: EventImpl) => {
|
|
if (event.extendedProps.location === null) {
|
|
return null;
|
|
}
|
|
const info = document.createElement("div");
|
|
info.innerText = event.extendedProps.location;
|
|
|
|
return makePopupInfo(info, "fa-solid fa-location-dot");
|
|
};
|
|
|
|
const makePopupUrl = (event: EventImpl) => {
|
|
if (event.url === "") {
|
|
return null;
|
|
}
|
|
const url = document.createElement("a");
|
|
url.href = event.url;
|
|
url.textContent = gettext("More info");
|
|
|
|
return makePopupInfo(url, "fa-solid fa-link");
|
|
};
|
|
|
|
const makePopupTools = (event: EventImpl) => {
|
|
if (event.source.internalEventSource.ui.classNames.includes("external")) {
|
|
return null;
|
|
}
|
|
if (!(this.canDelete || this.canModerate)) {
|
|
return null;
|
|
}
|
|
const newsId = this.getNewsId(event);
|
|
const div = document.createElement("div");
|
|
if (this.canModerate) {
|
|
if (event.source.internalEventSource.ui.classNames.includes("unpublished")) {
|
|
const button = document.createElement("button");
|
|
button.innerHTML = `<i class="fa fa-check"></i>${gettext("Publish")}`;
|
|
button.setAttribute("class", "btn btn-green");
|
|
button.onclick = () => {
|
|
this.publishNews(newsId);
|
|
};
|
|
div.appendChild(button);
|
|
} else {
|
|
const button = document.createElement("button");
|
|
button.innerHTML = `<i class="fa fa-times"></i>${gettext("Unpublish")}`;
|
|
button.setAttribute("class", "btn btn-orange");
|
|
button.onclick = () => {
|
|
this.unpublishNews(newsId);
|
|
};
|
|
div.appendChild(button);
|
|
}
|
|
}
|
|
if (this.canDelete) {
|
|
const button = document.createElement("button");
|
|
button.innerHTML = `<i class="fa fa-trash-can"></i>${gettext("Delete")}`;
|
|
button.setAttribute("class", "btn btn-red");
|
|
button.onclick = () => {
|
|
this.deleteNews(newsId);
|
|
};
|
|
div.appendChild(button);
|
|
}
|
|
|
|
return makePopupInfo(div, "fa-solid fa-toolbox");
|
|
};
|
|
|
|
// Create new popup
|
|
const popup = document.createElement("div");
|
|
const popupContainer = document.createElement("div");
|
|
|
|
popup.setAttribute("id", "event-details");
|
|
popupContainer.setAttribute("class", "event-details-container");
|
|
|
|
popupContainer.appendChild(makePopupTitle(event.event));
|
|
|
|
const location = makePopupLocation(event.event);
|
|
if (location !== null) {
|
|
popupContainer.appendChild(location);
|
|
}
|
|
|
|
const url = makePopupUrl(event.event);
|
|
if (url !== null) {
|
|
popupContainer.appendChild(url);
|
|
}
|
|
|
|
const tools = makePopupTools(event.event);
|
|
if (tools !== null) {
|
|
popupContainer.appendChild(tools);
|
|
}
|
|
|
|
popup.appendChild(popupContainer);
|
|
|
|
// We can't just add the element relative to the one we want to appear under
|
|
// Otherwise, it either gets clipped by the boundaries of the calendar or resize cells
|
|
// Here, we create a popup outside the calendar that follows the clicked element
|
|
this.node.appendChild(popup);
|
|
const follow = (node: HTMLElement) => {
|
|
const rect = node.getBoundingClientRect();
|
|
popup.setAttribute(
|
|
"style",
|
|
`top: calc(${rect.top + window.scrollY}px + ${rect.height}px); left: ${rect.left + window.scrollX}px;`,
|
|
);
|
|
};
|
|
follow(event.el);
|
|
window.addEventListener("resize", () => {
|
|
follow(event.el);
|
|
});
|
|
}
|
|
|
|
async connectedCallback() {
|
|
super.connectedCallback();
|
|
this.calendar = new Calendar(this.node, {
|
|
plugins: [dayGridPlugin, iCalendarPlugin, listPlugin],
|
|
locales: [frLocale, enLocale],
|
|
height: "auto",
|
|
locale: this.locale,
|
|
initialView: this.currentView(),
|
|
headerToolbar: this.currentToolbar(),
|
|
eventSources: await this.getEventSources(),
|
|
windowResize: () => {
|
|
this.calendar.changeView(this.currentView());
|
|
this.calendar.setOption("headerToolbar", this.currentToolbar());
|
|
},
|
|
eventClick: (event) => {
|
|
// Avoid our popup to be deleted because we clicked outside of it
|
|
event.jsEvent.stopPropagation();
|
|
// Don't auto-follow events URLs
|
|
event.jsEvent.preventDefault();
|
|
this.createEventDetailPopup(event);
|
|
},
|
|
});
|
|
this.calendar.render();
|
|
|
|
window.addEventListener("click", (event: MouseEvent) => {
|
|
// Auto close popups when clicking outside of it
|
|
const popup = document.getElementById("event-details");
|
|
if (popup !== null && !popup.contains(event.target as Node)) {
|
|
popup.remove();
|
|
}
|
|
});
|
|
}
|
|
}
|