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