Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
4f1cdea
feat: remove calendar buttons and update design
carolinedplm May 9, 2025
8dde98c
fix build
carolinedplm Jan 26, 2026
eaae9c3
typo
carolinedplm Jan 26, 2026
7432fc8
missing
carolinedplm Jan 26, 2026
a5b405a
better alignment for month
carolinedplm Jan 26, 2026
12f24ec
Fix link
fpellet Apr 28, 2026
b28c03f
Truncate body
fpellet Apr 28, 2026
710de3a
Add link
fpellet Apr 28, 2026
73067ef
Add date
fpellet Apr 28, 2026
1b2b3fa
durty fix
fpellet Apr 28, 2026
568420a
mobile view
fpellet Apr 29, 2026
a73329e
fix(calendar): validate URL scheme to prevent XSS in popup and event …
fpellet Apr 29, 2026
623e559
perf(calendar): cache parsed ICS feed across consumers
fpellet Apr 29, 2026
c746413
fix(calendar): prevent stale renders when navigating mobile list
fpellet Apr 29, 2026
9a14073
refactor(calendar): narrow popup-position observer to floating layer
fpellet Apr 29, 2026
d9cae5d
refactor(calendar): drop redundant state-icon hide
fpellet Apr 29, 2026
7d2262b
fix(calendar): include multi-day events in period filter
fpellet Apr 29, 2026
4c0e906
fix(calendar): pin popup date/time formatting to Europe/Paris
fpellet Apr 29, 2026
83a7fca
fix(calendar): show end date in popup for multi-day events
fpellet Apr 29, 2026
453a52e
fix(calendar): make truncate code-point aware and trim trailing punct…
fpellet Apr 29, 2026
adc2411
refactor(calendar): use addEventListener for desktop nav buttons
fpellet Apr 29, 2026
4676da6
fix(calendar): use canonical community pattern for title prefix
fpellet Apr 29, 2026
3248449
fix(calendar): add noreferrer to external event links
fpellet Apr 29, 2026
1d753c4
a11y(calendar): label nav buttons and announce range changes
fpellet Apr 29, 2026
4700015
a11y(calendar): bump popup link contrast to WCAG AA
fpellet Apr 29, 2026
94937a8
style(calendar): shrink mobile header font on small screens
fpellet Apr 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
456 changes: 452 additions & 4 deletions public/css/main.css

Large diffs are not rendered by default.

9 changes: 4 additions & 5 deletions public/js/communityEvents.html
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
<div class="card">
<div class="lth-card">
<div class="card-content">
<p class="title">{{ paneTitle }}</p>
{{#if noEvent}}
<p class="title is-6">
<p class="lth-card-title is-6">
{{ noEventCaption }}
</p>
{{/if}}
Expand All @@ -16,12 +15,12 @@
</p>
<p class="title is-5">
{{#if hasUrl}}
<a href="{{url}}" target="_blank" title="Lien vers l'événement dans Google Calendar">{{ title }} <span class="icon is-medium"><i class="fa fa-exsternal-link fa-lg"</i></span></a>
<a href="{{url}}" target="_blank" rel="noopener noreferrer">{{ title }}</a>
{{else}}
{{ title }}
{{/if}}
</p>
<p class="subtitle is-5">De {{ startDateHour }} à {{ endDateHour }} - {{ location }}</p>
<p class="subtitle is-5">De {{ startDateHour }} à {{ endDateHour }}{{#if location}} - {{ location }}{{/if}}</p>
{{#if hasDescription}}
<p class="description">{{ description }}</p>
{{/if}}
Expand Down
242 changes: 217 additions & 25 deletions public/js/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,22 +23,14 @@ const toDescription = component => {
return description;
};

const meetupUrlFor = description => {
const matching = description.match(/(https:\/\/www.meetup.com\/[a-zA-Z0-9-]+\/events\/[0-9]+)/g);
if (matching && matching.length > 0) {
return matching[matching.length - 1];
}
return undefined;
};

const toEvent = (component, index) => {
const description = toDescription(component);
const startDate = component.getFirstPropertyValue('dtstart').toJSDate();
const endDate = component.getFirstPropertyValue('dtend').toJSDate();
const format = (d) => d.toString().padStart(2, '0');
const formatHour = (d) => format(d.getHours()) + 'H' + format(d.getMinutes());
const months = ['Jan', 'Fev', 'Mars', 'Avr', 'Mai', 'Juin', 'Juil', 'Aout', 'Sept', 'Oct', 'Nov', 'Dec'];
const url = meetupUrlFor(description);
const url = component.getFirstPropertyValue('url');
return {
id: component.getFirstPropertyValue('uid'),
title: component.getFirstPropertyValue('summary'),
Expand All @@ -61,15 +53,35 @@ const matchPatternForEvent = event => pattern => event.title.toLowerCase().inclu

const matchForPatterns = patterns => event => patterns.some(matchPatternForEvent(event));

const filterForPeriod = (minDate, maxDate) => event => event.startDate >= minDate && event.endDate <= maxDate;
const filterForPeriod = (minDate, maxDate) => event => event.startDate < maxDate && event.endDate > minDate;

const listVEventComponents = raw => new ICAL.Component(ICAL.parse(raw)).getAllSubcomponents('vevent');

const escapeHtml = (s) => String(s ?? '').replace(/[&<>"']/g, (c) =>
({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;' }[c]));

const safeUrl = (url) => {
if (!url) return '';
let parsed;
try { parsed = new URL(url, document.baseURI); } catch { return ''; }
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return '';
return parsed.href;
};

const calendarICSUrl = 'https://www.lyontechhub.org/Lyon-Tech-Hub-Calendar/calendar.ics';

const fetchEvents = (patterns, minDate, maxDate) => fetch(calendarICSUrl).then((response) => response.text()).then((raw) =>
listVEventComponents(raw)
.map(toEvent)
let _icsEventsP;
const fetchAllRawEvents = () => {
if (!_icsEventsP) {
_icsEventsP = fetch(calendarICSUrl)
.then((response) => response.text())
.then((raw) => listVEventComponents(raw).map(toEvent));
}
return _icsEventsP;
};

const fetchEvents = (patterns, minDate, maxDate) => fetchAllRawEvents().then((events) =>
events
.filter(filterForPeriod(minDate, maxDate))
.filter(matchForPatterns(patterns)));

Expand All @@ -90,6 +102,12 @@ const loadCommunities = () =>
.then((response) => response.text())
.then((body) => JSON.parse(body));

const refreshCurrentMonth = (calendar) =>{
let dateRangeStart = calendar.getDate();
const monthNames = ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin', 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre'];
document.querySelector('#calendarDate').textContent = monthNames[dateRangeStart.getMonth()] + ' ' + dateRangeStart.getFullYear();
}

const loadCalendar = async () => {
const communities = await loadCommunities();
const communitiesCalendars =
Expand All @@ -103,6 +121,18 @@ const loadCalendar = async () => {
});

const Calendar = tui.Calendar;
const capitalize = (s) => s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
const dateFmt = new Intl.DateTimeFormat('fr-FR', {
weekday: 'long', day: 'numeric', month: 'long', year: 'numeric',
timeZone: 'Europe/Paris',
});
const timeFmt = new Intl.DateTimeFormat('fr-FR', {
hour: '2-digit', minute: '2-digit', hour12: false,
timeZone: 'Europe/Paris',
});
const formatFrTime = (d) => timeFmt.format(d).replace(':', 'h');
const toJsDate = (d) => (d && typeof d.toDate === 'function') ? d.toDate() : new Date(d);

const calendar = new Calendar('#calendar', {
usageStatistics: false,
defaultView: 'month',
Expand All @@ -119,6 +149,20 @@ const loadCalendar = async () => {
},
],
},
template: {
popupDetailDate({ start, end, isAllday }) {
const startDate = toJsDate(start);
const endDate = toJsDate(end);
const startStr = capitalize(dateFmt.format(startDate));
if (isAllday) return startStr;
const sameDay = dateFmt.format(startDate) === dateFmt.format(endDate);
if (sameDay) {
return `${startStr}, ${formatFrTime(startDate)} - ${formatFrTime(endDate)}`;
}
const endStr = capitalize(dateFmt.format(endDate));
return `${startStr}, ${formatFrTime(startDate)} → ${endStr}, ${formatFrTime(endDate)}`;
},
},
calendars: [
{
id: 'default',
Expand All @@ -129,9 +173,49 @@ const loadCalendar = async () => {
],
});

fetch(calendarICSUrl)
.then((response) => response.text())
.then((raw) => listVEventComponents(raw).map(toEvent))
// Workaround Toast UI Calendar 2.1.3 popup offset bug: the lib writes
// document-relative top/left on a popup whose CSS containing block is
// whatever the closest positioned ancestor happens to be (here Bulma's
// .container, since the popup is portalled into a floating-layer that is
// a sibling of the calendar layout, not a descendant). We re-anchor by
// subtracting the popup's actual offsetParent's document offset.
const calendarRoot = document.querySelector('#calendar');
if (calendarRoot) {
const fixPopupPosition = () => {
const popup = calendarRoot.querySelector('.toastui-calendar-popup-container');
if (!popup || !popup.style.top || !popup.style.left) return;
const op = popup.offsetParent;
if (!op) return;
const r = op.getBoundingClientRect();
const dy = r.top + window.scrollY;
const dx = r.left + window.scrollX;
const key = popup.style.top + '|' + popup.style.left;
if (popup.dataset.lthPosKey === key) return;
const t = parseFloat(popup.style.top);
const l = parseFloat(popup.style.left);
if (Number.isNaN(t) || Number.isNaN(l)) return;
popup.style.top = (t - dy) + 'px';
popup.style.left = (l - dx) + 'px';
popup.dataset.lthPosKey = popup.style.top + '|' + popup.style.left;
};
const attachObserver = () => {
const layer = calendarRoot.querySelector('.toastui-calendar-floating-layer');
if (!layer) {
setTimeout(attachObserver, 50);
return;
}
new MutationObserver(fixPopupPosition).observe(layer, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['style'],
});
};
attachObserver();
}

refreshCurrentMonth(calendar);
fetchAllRawEvents()
.then((items) => {
calendar.createEvents(
items.map((item) => {
Expand All @@ -144,7 +228,7 @@ const loadCalendar = async () => {
if (patterns) {
for (var j = 0; j < patterns.length; j++) {
if (match[1].localeCompare(patterns[j], 'en', { sensitivity: 'base' }) === 0) {
title = '[' + match[1] + '] ' + match[2];
title = '[' + patterns[j] + '] ' + match[2];
calendarId = communities[i].key;
break;
}
Expand All @@ -153,25 +237,128 @@ const loadCalendar = async () => {
}
}

function formatWithLink(text, url) {
const safeText = escapeHtml(text);
const href = safeUrl(url);
return href
? `<a class="calendar-popup-text" href="${escapeHtml(href)}">${safeText}</a>`
: safeText;
}

const safeItemUrl = safeUrl(item.url);
const truncated = truncate(item.description, 200);
const truncatedHtml = escapeHtml(truncated || '');
const linkHtml = safeItemUrl
? `<div class="calendar-popup-link-wrap"><a class="calendar-popup-link" href="${escapeHtml(safeItemUrl)}" target="_blank" rel="noopener noreferrer">En savoir plus <i class="fa fa-external-link-alt"></i></a></div>`
: '';
const body = truncatedHtml && linkHtml
? `${truncatedHtml}${linkHtml}`
: (truncatedHtml || linkHtml);

return {
calendarId: calendarId,
id: item.id,
title: title,
body: item.description,
title: formatWithLink(title, safeItemUrl),
body,
start: item.startDate,
end: item.endDate,
location: item.location,
raw: { url: item.url },
state: '',
raw: { url: safeItemUrl },
isReadOnly: true,
}
})
);
})
;

document.querySelector('#calendarToday').onclick = () => { calendar.today(); };
document.querySelector('#calendarNext').onclick = () => { calendar.next(); };
document.querySelector('#calendarPrevious').onclick = () => { calendar.prev(); };
document.querySelector('#calendarToday').addEventListener('click', () => {
calendar.today();
refreshCurrentMonth(calendar);
});
document.querySelector('#calendarNext').addEventListener('click', () => {
calendar.next();
refreshCurrentMonth(calendar);
});
document.querySelector('#calendarPrevious').addEventListener('click', () => {
calendar.prev();
refreshCurrentMonth(calendar);
});

};

const startOfDay = (d) => new Date(d.getFullYear(), d.getMonth(), d.getDate());

const truncate = (text, max) => {
if (!text) return text;
const chars = [...text];
if (chars.length <= max) return text;
return chars.slice(0, max).join('').replace(/[.\s…]+$/, '') + '…';
};

const fetchAllEvents = (minDate, maxDate) =>
fetchAllRawEvents().then((events) => events.filter(filterForPeriod(minDate, maxDate)));

const loadCalendarMobileList = async () => {
const el = document.getElementById('calendarMobileList');
if (!el) return;
const rangeEl = document.getElementById('calendarMobileRange');
const prevEl = document.getElementById('calendarMobilePrevious');
const nextEl = document.getElementById('calendarMobileNext');
const todayEl = document.getElementById('calendarMobileToday');

const template = Handlebars.compile(
await fetch('/js/communityEvents.html').then((r) => r.text())
);

const WINDOW_DAYS = 14;
const monthLabels = ['janvier', 'février', 'mars', 'avril', 'mai', 'juin',
'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre'];
const formatRange = (start, endExclusive) => {
const last = new Date(endExclusive);
last.setDate(last.getDate() - 1);
return `${start.getDate()} ${monthLabels[start.getMonth()]}`;
};

let windowStart = startOfDay(new Date());
let renderToken = 0;

const render = async (start) => {
const myToken = ++renderToken;
const windowEnd = new Date(start);
windowEnd.setDate(windowEnd.getDate() + WINDOW_DAYS);
if (rangeEl) rangeEl.textContent = formatRange(start, windowEnd);
const events = (await fetchAllEvents(start, windowEnd))
.toSorted((a, b) => a.startDate - b.startDate)
.map((ev, i) => {
const url = safeUrl(ev.url);
return {
...ev,
url,
hasUrl: Boolean(url),
description: truncate(ev.description, 200),
isNotFirst: i > 0,
};
});
if (myToken !== renderToken) return;
displayEvents(template, el, events);
};

const shiftBy = (days) => {
const next = new Date(windowStart);
next.setDate(next.getDate() + days);
windowStart = next;
render(windowStart);
};

prevEl?.addEventListener('click', () => shiftBy(-1));
nextEl?.addEventListener('click', () => shiftBy(1));
todayEl?.addEventListener('click', () => {
windowStart = startOfDay(new Date());
render(windowStart);
});

render(windowStart);
};

window.onload = () => {
Expand All @@ -195,15 +382,19 @@ window.onload = () => {
fourMonthAgo,
fourMonthLater
).then((items) => {
const sanitized = items.map((item) => {
const url = safeUrl(item.url);
return { ...item, url, hasUrl: Boolean(url) };
});
displayEvents(
compiledTemplate,
pastEventsElement,
items.filter((item) => item.startDate < now).toSorted((a, b) => b.startDate - a.startDate)
sanitized.filter((item) => item.startDate < now).toSorted((a, b) => b.startDate - a.startDate)
);
displayEvents(
compiledTemplate,
upcomingEventsElement,
items.filter((item) => item.startDate >= now).toSorted((a, b) => a.startDate - b.startDate)
sanitized.filter((item) => item.startDate >= now).toSorted((a, b) => a.startDate - b.startDate)
);
});
});
Expand All @@ -213,4 +404,5 @@ window.onload = () => {
if (calendarElement) {
loadCalendar();
}
loadCalendarMobileList();
}
Loading