diff --git a/css/style.css b/css/style.css index ca7547c..efd425b 100644 --- a/css/style.css +++ b/css/style.css @@ -1,6 +1,7 @@ body, html { padding: 0; margin: 0; + font-family: sans-serif; } body { @@ -381,13 +382,13 @@ camp-feature .widget > :last-child { margin-bottom: 0; } -camp-feature .widget h2 { +camp-feature .widget > h2 { font-size: 1em; text-transform: uppercase; margin-bottom: 1ex; } -camp-feature .widget p { +camp-feature .widget > p { margin-top: 1ex; margin-bottom: 1ex; line-height: 1.5; @@ -459,4 +460,69 @@ camp-feature .box-widget h2:first-child, margin-left: -10px; margin-top: -10px; margin-right: 2em; +} + +/* TALKS */ + +camp-upcoming-talks > * { + margin-bottom: 1em; + display: block; + color: inherit; + text-decoration: inherit; +} + +camp-talk { + display: block; + --track-color: white; + border: solid 1px var(--track-color); + border-left-width: 5px; + border-radius: 2.5px; + padding: 5px 10px; + box-sizing: border-box; +} + +camp-talk .track-name { + font-weight: bold; + color: var(--track-color); +} + +camp-talk > * { + margin: 0; +} + +camp-talk > p:not(:last-child) { + margin-top: 1ex; + margin-bottom: 1ex; +} + +camp-talk[data-track-id="36"] { + --track-color: #33d8d8; +} + +camp-talk[data-track-id="35"] { + --track-color: #ffbf3e; +} + +camp-talk[data-track-id="33"] { + --track-color: #ff4e00; +} + +camp-talk[data-track-id="34"] { + --track-color: #8800ff; +} + +@keyframes active-talk { + from { + box-shadow: 0px 0px 0px var(--track-color), 0px 0px 0px var(--track-color); + } + 25% { + box-shadow: 0px 0px 10px var(--track-color), 0px 0px 0px var(--track-color); + } + 100% { + box-shadow: 0px 0px 15px var(--track-color), 0px 0px 7px var(--track-color); + } +} + +camp-talk.active { + animation: active-talk 1.5s alternate-reverse infinite linear; } \ No newline at end of file diff --git a/index.html b/index.html index bde105d..ee42f99 100644 --- a/index.html +++ b/index.html @@ -9,6 +9,8 @@ + +
diff --git a/js/components/talk.js b/js/components/talk.js new file mode 100644 index 0000000..6bbe8a2 --- /dev/null +++ b/js/components/talk.js @@ -0,0 +1,64 @@ +const TEMPLATE = document.createElement("template") +TEMPLATE.innerHTML = ` +
+ + + → + +
+

+

+

+` + +class TalkElement extends HTMLElement { + + #activeChackInterval + + srcObject + + connectedCallback(){ + this.updateContent() + this.#activeChackInterval = setInterval(this.updateActiveState.bind(this), 1000) + } + + disconnectedCallback(){ + clearInterval(this.#activeChackInterval) + } + + updateActiveState(){ + this.classList.toggle("active", this.srcObject?.isActive) + } + + updateContent(){ + if(!this.srcObject){ + this.replaceChildren() + } + + this.dataset.trackId = this.srcObject.track_id + + this.replaceChildren(TEMPLATE.content.cloneNode(true)) + this.querySelector(".talk-title").textContent = this.srcObject.title + this.querySelector(".talk-abstract").textContent = this.srcObject.abstract + this.querySelector(".track-name").textContent = this.srcObject.track.fr + + let timeFormatter = new Intl.DateTimeFormat("fr-FR", { + timeStyle: "short" + }) + + let dateFormatter = new Intl.DateTimeFormat("fr-FR", { + dateStyle: "short" + }) + + this.querySelector(".talk-start-date").textContent = dateFormatter.format(this.srcObject.startTime) + this.querySelector(".talk-start-time").textContent = timeFormatter.format(this.srcObject.startTime) + this.querySelector(".talk-start-time").datetime = this.srcObject.startTime.toJSON() + this.querySelector(".talk-end-time").textContent = timeFormatter.format(this.srcObject.endTime) + this.querySelector(".talk-end-time").datetime = this.srcObject.endTime.toJSON() + + this.updateActiveState() + } + +} + +customElements.define("camp-talk", TalkElement) \ No newline at end of file diff --git a/js/components/upcoming-talks.js b/js/components/upcoming-talks.js new file mode 100644 index 0000000..9dee72f --- /dev/null +++ b/js/components/upcoming-talks.js @@ -0,0 +1,90 @@ +import { getUpcomingTalksForRoom } from "../pretalx.js" + +class UpcomingTalksElement extends HTMLElement { + #roomId + #loadingPromise + + srcObject = undefined + + get roomId(){ + return this.#roomId + } + + set roomId(val){ + this.#roomId = val + } + + connectedCallback(){ + this.updateContent() + } + + updateContent(){ + if(!this.roomId){ + this.replaceChildren() + return + } + + if(!this.srcObject){ + + if(!this.#loadingPromise){ + this.fetchTalks() + } + + let loading = document.createElement("div") + loading.classList.add("loading") + loading.textContent = "Chargement des événements..." + this.replaceChildren(loading) + + } else { + + let childrens = [] + + for(let talk of this.srcObject){ + let item = document.createElement("camp-talk") + item.srcObject = talk + + let a = document.createElement("a") + a.href = talk.displayUrl + a.target = "_blank" + a.append(item) + + childrens.push(a) + } + + this.classList.toggle("empty", childrens.length == 0) + if(childrens.length == 0){ + let empty = document.createElement("p") + empty.classList.add("empty") + empty.textContent = `Aucun événement à venir dans cette salle` + childrens.push(empty) + } + this.replaceChildren(...childrens) + + } + + this.classList.toggle("loading", !this.srcObject && this.#loadingPromise) + } + + async fetchTalks(){ + let prom = (async () => { + this.srcObject = await getUpcomingTalksForRoom(this.roomId) + this.#loadingPromise = null + this.updateContent() + })() + this.#loadingPromise = prom + await prom + } + + static observedAttributes = ["room-id"] + + attributeChangedCallback(name, oldVal, newVal){ + switch(name){ + case "room-id": + this.roomId = newVal + break; + } + } + +} + +customElements.define("camp-upcoming-talks", UpcomingTalksElement); \ No newline at end of file diff --git a/js/feature-widgets/feature-widgets.js b/js/feature-widgets/feature-widgets.js index c809be1..4de408d 100644 --- a/js/feature-widgets/feature-widgets.js +++ b/js/feature-widgets/feature-widgets.js @@ -4,6 +4,7 @@ import { capaciteEspaceWidget } from "./capacite-espace.js" import { capaciteParkingWidget } from "./capacite-parking.js" import { deposeMinuteWidget } from "./depose-minute.js" import { mixiteChoisieWidget } from "./mixite-choisie.js" +import { upcomingTalksWidget } from "./upcoming-talks.js" import { zoneInterditeWidget } from "./zone-interdite.js" export const FEATURE_WIDGETS = [ @@ -13,6 +14,7 @@ export const FEATURE_WIDGETS = [ // Other deposeMinuteWidget, campingCarsWidget, + upcomingTalksWidget, // Fields capaciteParkingWidget, capaciteDortoirWidget, diff --git a/js/feature-widgets/upcoming-talks.js b/js/feature-widgets/upcoming-talks.js new file mode 100644 index 0000000..9999a65 --- /dev/null +++ b/js/feature-widgets/upcoming-talks.js @@ -0,0 +1,17 @@ +export function upcomingTalksWidget(feature){ + if(feature.properties["pretalx-room-id"]){ + let content = document.createElement("div") + content.classList.add("widget") + content.classList.add("upcoming-talks-widget") + + let h2 = document.createElement("h2") + h2.textContent = "Événements à venir" + content.append(h2) + + let list = document.createElement("camp-upcoming-talks") + list.roomId = feature.properties["pretalx-room-id"] + content.append(list) + + return content + } +} \ No newline at end of file diff --git a/js/pretalx.js b/js/pretalx.js new file mode 100644 index 0000000..a008333 --- /dev/null +++ b/js/pretalx.js @@ -0,0 +1,101 @@ +const PRETALX_URL = `https://pretalx.lebib.org/` +const EVENT_ID = `camp-interhack-2026-2025` +const TALKS_EXPIRATION = 60*60*1000; +const API_ROOT = new URL("api/", PRETALX_URL); + +// Database management + +let cached_talks = null; + +export async function getFreshTalks(){ + if(!cached_talks || (Date.now() - cached_talks.updatedAt.getTime()) > TALKS_EXPIRATION){ + let talk_list = [] + + for await(let talk_json of requestAllTalks()){ + if(talk_json.state != "confirmed"){ + continue + } + let talk = new Talk(talk_json); + talk_list.push(talk) + } + + talk_list.sort((a,b) => a.startTime.getTime() - b.startTime.getTime()) + + // { + // let mock_i = 2 + // talk_list[mock_i].slot.start = new Date(Date.now() - 120*1000).toJSON() + // talk_list[mock_i].slot.end = (new Date(Date.now() + 3600000 + ( Math.random()*10000))).toJSON() + // console.log("mock event", talk_list[mock_i]) + // } + + let talks_by_room_id = {} + for(let talk of talk_list) { + if(!talks_by_room_id[talk.slot.room_id]){ + talks_by_room_id[talk.slot.room_id] = [] + } + talks_by_room_id[talk.slot.room_id].push(talk) + } + + cached_talks = { + updatedAt: new Date(), + talks: talk_list, + talks_by_room: talks_by_room_id + } + } + + return cached_talks +} + +export async function getUpcomingTalksForRoom(room_id){ + let db = await getFreshTalks() + return (db.talks_by_room[room_id] || []).filter(it => it.endTime.getTime() > Date.now()) +} + +class Talk { + constructor(srcObj){ + Object.assign(this, srcObj) + } + + get startTime(){ + return new Date(this.slot.start) + } + + get endTime(){ + return new Date(this.slot.end) + } + + get displayUrl(){ + return new URL(`${encodeURIComponent(EVENT_ID)}/talk/${encodeURIComponent(this.code)}`, PRETALX_URL).toString() + } + + get isActive(){ + return this.startTime.getTime() <= Date.now() && this.endTime.getTime() >= Date.now() + } +} + +// Lower level tools + +const requestAllRooms = makePaginatedGenerator(new URL(`events/${encodeURIComponent(EVENT_ID)}/rooms/`, API_ROOT)) +const requestAllTalks = makePaginatedGenerator(new URL(`events/${encodeURIComponent(EVENT_ID)}/talks/`, API_ROOT)) + +function makePaginatedGenerator(baseUrl){ + return async function*(){ + let buffer = [] + let next_url = baseUrl + + while(buffer.length > 0 || next_url != null){ + let item = buffer.shift() + if(item){ + yield item + } else { + let res = await fetch(next_url) + if(!res.ok){ + throw new Error(`Server responded with error ${res.status} ${res.statusText}`) + } + let response = await res.json() + next_url = response.next + buffer.push(...response.results) + } + } + } +} \ No newline at end of file