From d08a39307f03ca012d80908c723768895e81ec28 Mon Sep 17 00:00:00 2001 From: Daru13 Date: Mon, 19 Nov 2018 23:57:42 +0100 Subject: [PATCH] Initial addition of the visual calendar (day view only). This is the first step to include a visual calendar to Poulpe, only including a "day view" as of now (i.e. events hour-by-hour from one date to another). More views may be added at a later time. It is **NOT WORKING YET**! The CSS has been broken on this branch, and will have to be fixed before the calendar can work: * the CSS of the calendar needs to be adapted to the environment and design of Poulpe; * add actual links to enroll/un-enroll to an activity (cf. `Event` class in `calendar.js`); * other small tweaks :)? Finally, this view is likely to require the addition of start and end date change, so that an user can browse events over several days (cf. `setStartDate` and `setEndDate` methods of `Calendar` class in `calendar.js`). Note that this code should be better re-written (e.g. in Typescript, split between files, using more/better design patterns) at a later time. It should nonetheless be easy to fix it and use it right now (see above requirements for this). --- event/static/css/calendar.css | 359 +++++++++++ event/static/js/calendar.js | 914 ++++++++++++++++++++++++++++ event/templates/event/calendar.html | 53 +- event/templatetags/event_tags.py | 5 + 4 files changed, 1329 insertions(+), 2 deletions(-) create mode 100644 event/static/css/calendar.css create mode 100644 event/static/js/calendar.js diff --git a/event/static/css/calendar.css b/event/static/css/calendar.css new file mode 100644 index 0000000..fe2443e --- /dev/null +++ b/event/static/css/calendar.css @@ -0,0 +1,359 @@ +/* Calendar */ + +#cal-container { + width: 100%; + height: 100%; + padding: 0; + + font-family: "sans-serif"; +} + +#cal-container, +#cal-container * { + box-sizing: border-box; +} + + + +/* Time slots */ + +#cal-container .cal-time-slot-container { + display: grid; + /* grid-template-columns: repeat(24, 1fr); */ + grid-template-rows: 30px auto; + + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + padding: 0; + z-index: 10; +} + +#cal-container .cal-time-slot { + border-right: 1px solid #EEE; + background-color: #FAFAFA; +} + +/* #cal-container .cal-time-slot:hover { + background-color: red; +} */ + +#cal-container .cal-time-slot:nth-child(even) { + background-color: #F4F4F4; +} + +/* #cal-container .cal-time-slot:nth-child(even):hover { + background-color: #D9D9D9; +} */ + +#cal-container .cal-time-slot:last-child { + border-right: 0; +} + +#cal-container .cal-time-slot-hour { + padding: 0 0 0 calc(100% - 0.9rem + 4px); + background-color: #F9F9F9;/* #3D3D3D; */ + border-bottom: 1px solid #AAA; + color: #333; + font-size: 0.9rem; + line-height: 30px; + vertical-align: middle; +} + +#cal-container .cal-time-slot-hour:nth-child(even) { + background-color: #FCFCFC;/* #464646; */ +} + +#cal-container .cal-time-slot-hour:last-child { + border-right: 0; +} + +#cal-container .cal-time-slot.cal-new-day, +#cal-container .cal-time-slot-hour.cal-new-day { + border-left: 2px solid #AAA; +} + +/* Events */ + +#cal-container .cal-event-container { + display: grid; + /* grid-template-columns: repeat(24, 1fr); */ + grid-template-rows: repeat(12, auto); + + position: absolute; + top: 40px; + left: 0; + width: 100%; + height: 100%; + padding: 0; + z-index: 100; +} + +#cal-container .cal-event { + position: relative; + margin: 2px 0; + /* padding: 5px; */ + /* background-color: #EFEFEF; */ + border-radius: 3px; + /* border: 1px solid #CCC; */ + border-width: 1px; + border-style: solid; + text-align: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + /* z-index: 500; */ + transition: 50ms ease-in; +} + +#cal-container .cal-event:hover { + /* background-color: #FEDDDD; */ +} + +#cal-container .cal-event > * { + display: none; + margin: 5px; +} + +#cal-container .cal-event > .cal-event-name, +#cal-container .cal-event > .cal-event-location, +#cal-container .cal-event > .cal-event-perm-count { + display: block; +} + +#cal-container .cal-event > .cal-event-name { + font-weight: bold; +} + +#cal-container .cal-event > .cal-event-location { + font-style: italic; +} + +#cal-container .cal-event > .cal-event-perm-count { + position: absolute; + bottom: 0; + right: 0; +} + +#cal-container .cal-event:not(.cal-event-subscribed) > .cal-event-perm-count.cal-perms-missing { + width: calc(100% - 10px); + right: auto; + margin: 5px; + padding: 5px; + background-color: #FFF; + border: 2px solid #E44; + color: #E44; + font-weight: bold; + border-radius: 3px; + overflow: hidden; +} + +#cal-container .cal-event.cal-event-subscribed { + border-width: 3px; + border-color: #000; +} + +#cal-container .cal-event.cal-event-subscribed::after { + content: "✔"; + position: absolute; + left: 0; + bottom: 0; + width: 16px; + height: 16px; + padding: 1px; + color: #fff; + background-color: #000; + border-top-right-radius: 3px; +} + + +/* Event details popup */ + +#cal-container .cal-event-details { + position: absolute; + min-height: 100px; + min-width: 25%; + max-width: 60%; + padding: 20px; + background-color: #333; + color: #FFF; + border-radius: 4px; + box-shadow: 0 15px 50px rgba(0, 0, 0, 0.6); + z-index: 1000; +} + +#cal-container .cal-event-details:after { + bottom: 100%; + left: 50%; + border: solid transparent; + content: " "; + height: 0; + width: 0; + position: absolute; + pointer-events: none; + border-bottom-color: #333; + border-width: 20px; + margin-left: -20px; +} + +#cal-container .cal-event-details.above-event:after { + top: 100%; + left: 50%; + border: solid transparent; + content: " "; + height: 0; + width: 0; + position: absolute; + pointer-events: none; + border-top-color: #333; + border-width: 20px; + margin-left: -20px; +} + +#cal-container .cal-event-details * { + z-index: 1000; +} + +#cal-container .cal-event-details .cal-detail-close-button { + width: 35px; + height: 35px; + position: absolute; + top: 10px; + right: 10px; + margin: 0; + padding: 5px; + background: transparent; + border: none; + border-radius: 50%; + font-size: 1.2rem; + color: #BBB; + transition: 100ms ease-out; +} + +#cal-container .cal-event-details .cal-detail-close-button:hover { + background-color: #484848; + color: #EFEFEF; +} + +#cal-container .cal-event-details a, +#cal-container .cal-event-details a:hover { + color: #FFF; + text-decoration: none; +} + +#cal-container .cal-event-details .cal-detail-name > h3 { + margin: 0 0 25px 0; + padding: 10px; + border-radius: 4px; + color: #FFF; + text-transform: uppercase; + text-align: center; +} + +#cal-container .cal-event-details .cal-detail-name > h3::after { + content: ""; + display: inline-block; + width: 16px; + height: 16px; + margin: 0 0 0 8px; + vertical-align: middle; + background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAQAAAC1+jfqAAAA90lEQVQoz2WRsS6DURiGn/yaaNqBySpGicFg6iJcAYuuNBaJpJEg6gLcADFIdWR0BZZKS4SBRTe3YFf/Y/j/c3LUe4Zz3vd78+U734uUJ7Ptg7k68NDpoIdyU9V3TzzwRdXt1HCq9pwR510W616oZ8Gwoe6KWFN1ScStogui3sRJ9uxYLd/n6hTuaCy3XHfNWuR6hM+OojBQdSXyJ0cZizwSsMo3kEc+ZCEjxZgxE8j4oBFZhRww8gaff4fEL3UuGfK4uG5L4VLVfvrNCrDJHfd0gSrXQB2AJvu0+Cm8HbXnbGw9seqwWH2z65Wv/8MKcfdVHaZx/wKtOg5kifzQhwAAAABJRU5ErkJggg==); + opacity: 0.6; +} + +#cal-container .cal-event-details .cal-detail-name:hover > h3 { + margin-bottom: 5px; + background-color: #484848; + color: #FFF; +} + +#cal-container .cal-event-details .cal-detail-name:hover > h3::after { + content: "Cliquez pour afficher les détails."; + display: block; + width: auto; + height: 15px; + padding: 5px 0 0 0; + font-size: 0.6rem; + background-image: none; + color: #DDD; +} + +#cal-container .cal-event-details .cal-detail-name:hover + .cal-detail-close-button { + opacity: 0; +} + +#cal-container .cal-event-details table { + width: 100%; +} + +#cal-container .cal-event-details td.cal-detail-label { + padding: 0 10px 10px 0; + font-weight: bold; + text-align: right; +} + +#cal-container .cal-event-details td.cal-detail-value { + padding: 0 0 10px 10px; + text-align: left; +} + +#cal-container .cal-event-details .cal-detail-perm-area { + margin: 10px 0; + padding: 10px; + background-color: #DFDFDF; + color: #333; + text-align: center; + border-radius: 4px; +} + +#cal-container .cal-event-details .cal-detail-perm-title { + margin: 0 0 10px 0; +} + +#cal-container .cal-event-details .cal-detail-perm-area .cal-detail-perm-count { + margin: 0 10px 0 0; + font-size: 1.7rem; + vertical-align: middle; +} + +#cal-container .cal-event-details .cal-detail-perm-area .cal-detail-perm-count.cal-perms-missing { + color: #E44; +} + +#cal-container .cal-event-details .cal-detail-perm-area .cal-detail-perm-count.cal-perms-full { + color: #393; +} + + +#cal-container .cal-event-details .cal-detail-perm-area .cal-detail-perm-subscription-switch { + margin: 0 0 0 10px; + padding: 10px; + font-size: 1.35rem; + vertical-align: middle; +} + +#cal-container .cal-event-details .cal-detail-perm-area .cal-detail-perm-nb-missing-perms { + margin: 20px 0 0 0; + padding: 5px; + background-color: #FFF; + border-radius: 4px; + text-align: center; + font-size: 1.1rem; + color: #E44; + font-weight: bold; +} + +#cal-container .cal-event-details .cal-detail-description { + color: #DDD; + font-style: italic; + text-align: justify; +} + +#cal-container .cal-event-details .cal-detail-tag { + display: inline-block; + margin: 0 5px; + padding: 5px; + border: 1px solid #DDD; +} diff --git a/event/static/js/calendar.js b/event/static/js/calendar.js new file mode 100644 index 0000000..03c15dc --- /dev/null +++ b/event/static/js/calendar.js @@ -0,0 +1,914 @@ +// Based on https://stackoverflow.com/a/15289883 +function computeDateDifferenceInHours (date1, date2) { + const msPerHour = 60 * 60 * 1000; + return Math.floor(Math.abs(date2.getTime() - date1.getTime()) / msPerHour); +} + + + +class Calendar { + + constructor (calendarParameters = {}) { + this.containerNode = calendarParameters.containerNode !== undefined + ? calendarParameters.containerNode + : $("#cal-container"); + + this.eventContainerNode = null; + this.timeSlotsContainerNode = null; + this.eventDetailsContainerNode = null; + + this.startDate = calendarParameters.startDate !== undefined + ? calendarParameters.startDate + : new Date(); + this.endDate = calendarParameters.endDate !== undefined + ? calendarParameters.endDate + : new Date(Date.now() + (24 * 60 * 60 * 1000)); + + this.nbHoursToDisplay = 0; + this.firstHourToDisplay = 0; + this.endHourToDisplay = 0; + + this.events = []; + + this.subscriptionURLFormat = calendarParameters.subscriptionURLFormat !== undefined + ? calendarParameters.subscriptionURLFormat + : ""; + this.csrfToken = calendarParameters.csrfToken !== undefined + ? calendarParameters.csrfToken + : ""; + + // Map from locations to their CSS styles + this.locationStyles = new Map(); + + this.init(); + } + + init () { + this.updateHoursToDisplay(); + + this.createTimeSlotContainer(); + this.createEventContainer(); + this.createEventDetailsContainer(); + + this.updateEventContainerGridStyle(); + + this.createTimeSlots(); + this.createEvents(); + + this.createLocationStyles(); + this.applyLocationStylesAsCSS(); + this.updateEventLocationStyleID(); + + this.sortEventNodesByEndTimeAndLocation(); + } + + + // Date change + + setStartDate (newStartDate) { + this.startDate = newStartDate; + + this.updateHoursToDisplay(); + this.updateEventContainerGridStyle(); + this.updateTimeSlots(); + this.updateEventVisibilities(); + } + + setEndDate (newEndDate) { + this.endDate = newEndDate; + + this.updateHoursToDisplay(); + this.updateEventContainerGridStyle(); + this.updateTimeSlots(); + this.updateEventVisibilities(); + } + + updateHoursToDisplay () { + this.startHourToDisplay = this.startDate.getHours(); + this.endHourToDisplay = this.endDate.getHours(); + + this.nbHoursToDisplay = computeDateDifferenceInHours(this.startDate, this.endDate); + } + + + // Time slots + + createTimeSlotContainer () { + this.timeSlotsContainerNode = $("
") + .addClass("cal-time-slot-container") + .appendTo(this.containerNode); + } + + createTimeSlots () { + // Populate the container hour by hour + let self = this; + function getHourStringToDisplay (index, hour) { + if (index === self.nbHoursToDisplay - 1 + || hour === 23) { + return ""; + } + + if (hour >= 10) { + return hour + 1; + } + else { + return " " + (hour + 1); + } + } + + for (let i = 0; i < this.nbHoursToDisplay; i++) { + let hour = (this.startHourToDisplay + i) % 24; + + // Time slot hour + let timeSlotHourNode = $("
") + .addClass("cal-time-slot-hour") + .css({ + "grid-column-start": `${i + 1}`, + "grid-column-end" : "span 1", + "grid-row-start" : "1", + "grid-row-end" : "1" + }) + .html(getHourStringToDisplay(i, hour)) + .prependTo(this.timeSlotsContainerNode); + + // Time slot block + let timeSlotBlockNode = $("
") + .addClass("cal-time-slot") + .css({ + "grid-column-start": `${i + 1}`, + "grid-column-end" : "span 1", + "grid-row-start" : "2", + "grid-row-end" : "2" + }) + .appendTo(this.timeSlotsContainerNode); + + if (i > 0 && hour === 0) { + timeSlotHourNode.addClass("cal-new-day"); + timeSlotBlockNode.addClass("cal-new-day"); + } + } + } + + updateTimeSlots () { + this.timeSlotsContainerNode.empty(); + this.createTimeSlots(); + } + + getHourSlotWidth () { + return this.timeSlotsContainerNode.width() / this.nbHoursToDisplay; + } + + + // Events + + createEventContainer () { + this.eventContainerNode = $("
") + .addClass("cal-event-container") + .appendTo(this.containerNode); + } + + createEvents () { + // Move all event nodes into the event container + let eventElements = this.containerNode.find(".cal-event"); + eventElements.appendTo(this.eventContainerNode); + + // Create event objects from them all + for (let element of eventElements) { + let newEvent = new Event($(element), this); + this.events.push(newEvent); + } + } + + updateEventContainerGridStyle () { + this.eventContainerNode.css("grid-template-columns", + `repeat(${this.nbHoursToDisplay}, ${100 / this.nbHoursToDisplay }%)`); + } + + updateEventVisibilities () { + for (let event of this.events) { + event.updateVisibility(); + } + } + + + // Event details + + createEventDetailsContainer () { + this.eventDetailsContainerNode = $("
") + .addClass("cal-details-container") + .appendTo(this.containerNode); + } + + + // Location styles + + createLocationStyles () { + let locationIndices = new Map(); + for (let event of this.events) { + if (! locationIndices.has(event.location)) { + locationIndices.set(event.location, [...locationIndices.keys()].length); + } + } + + let nbUniqueLocations = [...locationIndices.keys()].length; + + let styleID = 0; + for (let [location, index] of locationIndices.entries()) { + let hue = (index / (nbUniqueLocations + 1)) * 255; + styleID += 1; + + this.locationStyles.set(location, { + id: styleID, + + normal: [ + `background-color: hsl(${hue}, 60%, 80%);`, + `border-color: hsl(${hue}, 50%, 50%);`, + `color: #000;` + ], + + hover: [ + `background-color: hsl(${hue}, 65%, 85%);`, + `border-color: hsl(${hue}, 52%, 55%);`, + `color: #000;` + ], + + subscribed: [ + `background-color: hsl(${hue}, 75%, 75%);`, + `border-color: hsl(${hue}, 60%, 50%);`, + `color: #000;` + ], + + selected: [ + `background-color: hsl(${hue}, 45%, 50%);`, + `border-color: hsl(${hue}, 40%, 35%);`, + `color: #FFF;` + ] + }); + } + } + + applyLocationStylesAsCSS () { + let styleNode = $("