Compare commits

...

2 commits

Author SHA1 Message Date
00165620ca
Ajout du manifest 2026-06-14 18:19:10 +02:00
87005adb44
Ajout de l'appel a l'API pretalx 2026-06-14 18:00:47 +02:00
11 changed files with 441 additions and 2 deletions

94
assets/favicon.svg Normal file
View file

@ -0,0 +1,94 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="32"
height="32"
viewBox="0 0 32 32"
version="1.1"
id="svg1"
xml:space="preserve"
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
sodipodi:docname="favicon.svg"
inkscape:export-filename="favicon@512.png"
inkscape:export-xdpi="1536"
inkscape:export-ydpi="1536"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
id="namedview1"
pagecolor="#505050"
bordercolor="#ffffff"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="1"
inkscape:deskcolor="#505050"
inkscape:document-units="px"
inkscape:zoom="16.788059"
inkscape:cx="4.0504979"
inkscape:cy="12.955637"
inkscape:window-width="2560"
inkscape:window-height="1378"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="g2" /><defs
id="defs1"><clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath2"><use
x="0"
y="0"
xlink:href="#g1"
id="use2" /></clipPath></defs><g
inkscape:label="Calque 1"
inkscape:groupmode="layer"
id="layer1"><rect
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:4.93195;stroke-opacity:1"
id="rect3"
width="24.225895"
height="32"
x="3.8870773"
y="-0.041903432" /><rect
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:5.00771;stroke-opacity:1"
id="rect3-5"
width="31.999977"
height="24.975895"
x="2.2888184e-05"
y="3.470149" /><g
id="g2"
clip-path="url(#clipPath2)"><g
inkscape:label="Clip"
id="g1"><rect
style="display:inline;fill:#ffbf3e;fill-opacity:1;stroke:none;stroke-width:2;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke fill markers"
id="rect2-6"
width="22.600895"
height="22.600895"
x="4.6995773"
y="4.65765" /></g><g
id="g3"
transform="matrix(0.99855145,0.99855145,-0.99855145,0.99855145,15.958132,-15.911828)"><rect
style="fill:#ffbf3e;fill-opacity:1;stroke:none;stroke-width:1.15551;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke fill markers"
id="rect2"
width="22.600895"
height="7.5441823"
x="4.6995773"
y="4.657649" /><rect
style="fill:#ff4e00;fill-opacity:1;stroke:none;stroke-width:1.15551;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke fill markers"
id="rect2-2"
width="22.600895"
height="7.5441823"
x="4.6995773"
y="12.196491" /><rect
style="fill:#8800ff;fill-opacity:1;stroke:none;stroke-width:1.15551;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke fill markers"
id="rect2-2-7"
width="22.600895"
height="7.5441823"
x="4.6995773"
y="19.714361" /></g></g><path
style="fill:none;stroke:#ffffff;stroke-width:1.495;stroke-linejoin:bevel;paint-order:stroke markers fill"
d="M 7.8494299,17.472889 22.913993,7.5223487 18.632486,24.886239 16.237862,16.917879 Z"
id="path1"
sodipodi:nodetypes="ccccc" /></g></svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

BIN
assets/favicon@192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

BIN
assets/favicon@512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View file

@ -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;
}

View file

@ -9,6 +9,10 @@
<link rel="stylesheet" href="./css/widgets.css">
<script type="module" src="./js/index.js"></script>
<script type="module" src="./js/components/bidi-panel.js"></script>
<script type="module" src="./js/components/upcoming-talks.js"></script>
<script type="module" src="./js/components/talk.js"></script>
<link rel="shortcut icon" href="./assets/favicon@192.png" type="image/png">
<link rel="manifest" href="manifest.json" />
</head>
<body>
<header id="main-header">

64
js/components/talk.js Normal file
View file

@ -0,0 +1,64 @@
const TEMPLATE = document.createElement("template")
TEMPLATE.innerHTML = `
<div class="talk-calendar">
<span class="talk-start-date"></span>
<time class="talk-start-time"></time>
&rarr;
<time class="talk-end-time"></time>
</div>
<h3 class="talk-title"></h3>
<p class="talk-abstract"></p>
<p><small class="track-name"></small></p>
`
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)

View file

@ -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);

View file

@ -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,

View file

@ -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
}
}

101
js/pretalx.js Normal file
View file

@ -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)
}
}
}
}

View file

@ -10,6 +10,7 @@ echo "Copie des fichiers source"
cp -v index.html "$OUT"
cp -v logo.png "$OUT"
cp -v manifest.json "$OUT"
cp -v -r ./js "$OUT"
cp -v -r ./css "$OUT"
cp -v -r ./icons "$OUT"