diff --git a/public/css/main.css b/public/css/main.css index e68d894..816a338 100644 --- a/public/css/main.css +++ b/public/css/main.css @@ -1,12 +1,460 @@ +main { + font-family: "Segoe UI", Arial, sans-serif; +} + +/* New Header Styles */ +.lth-navbar { + background-color: white; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + padding: 0.5rem 1rem; +} + +.lth-brand { + display: flex; + align-items: center; + gap: 12px; +} + +.lth-logo { + height: 40px; + width: auto; +} + +.lth-brand-text { + display: flex; + flex-direction: column; +} + +.lth-brand-title { + font-size: 18px; + font-weight: 600; + color: #2e333d; + line-height: 1.2; +} + +.lth-brand-subtitle { + font-size: 12px; + color: #6b7280; + line-height: 1.2; +} + +.lth-nav-item { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 16px !important; + border-radius: 6px; + margin: 0 4px; + color: #2e333d !important; + transition: all 0.2s ease; +} + +.lth-nav-item:hover { + background-color: #f3f4f6; +} + +.lth-nav-item.is-active { + background-color: #eb0f0f; + color: white !important; +} + +.lth-nav-item.is-active .icon { + color: white; +} + +.lth-nav-item .icon { + color: #6b7280; +} + +.lth-nav-item:hover .icon { + color: #eb0f0f; +} + +.lth-nav-item.is-active:hover { + background-color: #d10d0d; +} + +.lth-nav-item.is-active:hover .icon { + color: white; +} + +.navbar-burger span { + background-color: #2e333d; +} + +/* Footer Styles */ +.lth-footer { + position: fixed; + bottom: 0; + left: 0; + right: 0; + background-color: white; + padding: 16px 24px; + border-top: 1px solid #e5e7eb; + box-shadow: 0 -2px 4px rgba(0, 0, 0, 0.05); + z-index: 30; +} + +.lth-footer-content { + display: flex; + justify-content: space-between; + align-items: center; + max-width: 1200px; + margin: 0 auto; +} + +.lth-footer-left { + display: flex; + align-items: center; + gap: 24px; +} + +.lth-footer-brand { + color: #2e333d; + font-weight: 600; + font-size: 16px; + text-decoration: none; +} + +.lth-footer-brand:hover { + color: #eb0f0f; +} + +.lth-footer-socials { + display: flex; + gap: 16px; +} + +.lth-footer-socials a { + color: #6b7280; + font-size: 18px; + transition: color 0.2s ease; +} + +.lth-footer-socials a:hover { + color: #eb0f0f; +} + +.lth-footer-link { + color: #6b7280; + text-decoration: none; + font-size: 14px; + transition: color 0.2s ease; +} + +.lth-footer-link:hover { + color: #eb0f0f; +} + +@media (max-width: 600px) { + .lth-footer-content { + flex-direction: column; + gap: 16px; + text-align: center; + } + + .lth-footer-left { + flex-direction: column; + gap: 12px; + } +} + .container { - padding: 20px; + padding: 20px; } .cell { - display: grid; - height: 100%; + display: grid; + height: 100%; +} + +.title { + font-weight: 650; + --bulma-block-spacing: 2rem; +} + +.icon { + color: #eb0f0f; +} + +.grid { + gap: 2rem; } #calendar { - height: 800px; + height: 800px; +} + +.community-image { + justify-content: center; + display: flex; + flex-direction: column; + align-items: center; +} + +.toastui-calendar-detail-item-indent { + display: none !important; +} + +.calendar-header { + display: flex; + flex-direction: row; + gap: 10px; + align-items: center; + font-size: 20px; +} + +#calendarMobileList, +.calendar-mobile-header { + display: none; +} + +@media (max-width: 768px) { + #calendar, + .calendar-header:not(.calendar-mobile-header) { + display: none; + } + #calendarMobileList { + display: block; + } + .calendar-mobile-header { + display: flex; + font-size: 16px; + } + #calendarMobileList .lth-card { + padding: 16px 18px; + } +} + +.calendar-popup-text { + color: #2e333d; +} + +.calendar-popup-link-wrap { + margin-top: 8px; +} + +.calendar-popup-link { + color: #c00d0d; + font-weight: 600; + text-decoration: none; + display: inline-flex; + align-items: center; + gap: 6px; +} + +.calendar-popup-link:hover { + text-decoration: underline; +} + +.lth-card { + display: flex; + flex-direction: column; + gap: 18px; + padding: 24px 30px; + border-radius: 6px; + border: 1px solid #d3d3d3; + color: #2e333d; + white-space: wrap; +} + +.lth-card-icon { + display: flex; + font-size: 48px; + margin-bottom: 24px; + color: #eb0f0f; +} + +.lth-card-title { + font-size: 24px; + font-weight: 600; + line-height: 28px; +} + +.lth-card-text { + font-size: 18px; + line-height: 26px; +} + +.lth-card-list { + padding-left: 20px; + margin: 0; +} + +.lth-card-list li { + margin-bottom: 8px; +} + +.lth-card-list li:last-child { + margin-bottom: 0; +} + +.lth-card h4 { + font-size: 18px; + font-weight: 500; + margin-top: 8px; + color: #2e333d; +} + +.lth-card h4 i { + color: #eb0f0f; + margin-right: 6px; +} + +.lth-card-reference { + max-width: 300px; + justify-content: space-between; +} + +.learn-more:after { + content: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='7' height='11' fill='none' viewBox='0 0 7 11'%3E%3Cpath stroke='%23eb0f0f' stroke-width='1.2' d='M1 1.301 5.199 5.5 1 9.699'/%3E%3C/svg%3E"); + margin-left: 8px; + position: relative; + vertical-align: 1px; +} + +.learn-more { + width: fit-content; + background-image: linear-gradient(#000, #000); + background-position-x: 0; + background-position-y: 100%; + background-repeat: no-repeat; + background-size: 0 .1em; + color: #000 !important; + transition: background-size .2s ease-in-out; +} + +.learn-more:hover { + background-size: 100% .1em; +} + + +.search-header { + max-width: 100%; + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + margin-bottom: 32px; +} + +@media (max-width: 600px) { + .search-header { + flex-direction: column; + align-items: flex-start; + gap: 16px; + } +} + +.search-box { + position: relative; + display: flex; + align-items: center; +} + +.search-icon { + position: absolute; + left: 16px; + color: #9ca3af; + font-size: 14px; + pointer-events: none; +} + +.search-input { + padding: 12px 16px 12px 44px; + border-radius: 50px; + border: 1px solid #e5e7eb; + width: 400px; + font-size: 16px; + background-color: #fff; + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +.search-input:focus { + outline: none; + border-color: #eb0f0f; + box-shadow: 0 0 0 3px rgba(235, 15, 15, 0.1); +} + +.search-input::placeholder { + color: #9ca3af; +} + +@media (max-width: 600px) { + .search-input { + width: 100%; + } +} + +.search-header-link { + color: #6b7280; + text-decoration: none; + font-size: 14px; + transition: color 0.2s ease; +} + +.search-header-link:hover { + color: #eb0f0f; +} + +/* About Page */ + +.about-contact { + background-color: #eb0f0f; + padding: 40px 24px; + text-align: center; + border-radius: 16px; + max-width: 900px; + margin: 0 auto 80px; +} + +.about-contact-content { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; +} + +.about-contact-icon { + display: flex; + align-items: center; + justify-content: center; + width: 64px; + height: 64px; + border-radius: 50%; + background-color: rgba(255, 255, 255, 0.2); + font-size: 28px; + color: white; + margin-bottom: 8px; +} + +.about-contact-title { + font-size: 32px; + font-weight: 600; + color: white; + margin: 0; +} + +.about-contact-subtitle { + font-size: 18px; + color: rgba(255, 255, 255, 0.9); + margin: 0; +} + +.about-contact-links { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 16px; +} + +.about-contact-links a { + color: white; + text-decoration: underline; + font-size: 16px; +} + + +.about-contact-links i { + margin-right: 8px; } diff --git a/public/js/communityEvents.html b/public/js/communityEvents.html index 714169e..dbd4dbb 100644 --- a/public/js/communityEvents.html +++ b/public/js/communityEvents.html @@ -1,8 +1,7 @@ -
{{ paneTitle }}
{{#if noEvent}} -+
{{ noEventCaption }}
{{/if}} @@ -16,12 +15,12 @@{{#if hasUrl}} - {{ title }} + {{ title }} {{else}} {{ title }} {{/if}}
-De {{ startDateHour }} à {{ endDateHour }} - {{ location }}
+De {{ startDateHour }} à {{ endDateHour }}{{#if location}} - {{ location }}{{/if}}
{{#if hasDescription}}{{ description }}
{{/if}} diff --git a/public/js/main.js b/public/js/main.js index c9e9b0d..227e3b3 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -23,14 +23,6 @@ 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(); @@ -38,7 +30,7 @@ const toEvent = (component, index) => { 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'), @@ -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) => + ({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' }[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))); @@ -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 = @@ -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', @@ -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', @@ -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) => { @@ -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; } @@ -153,25 +237,128 @@ const loadCalendar = async () => { } } + function formatWithLink(text, url) { + const safeText = escapeHtml(text); + const href = safeUrl(url); + return href + ? `${safeText}` + : safeText; + } + + const safeItemUrl = safeUrl(item.url); + const truncated = truncate(item.description, 200); + const truncatedHtml = escapeHtml(truncated || ''); + const linkHtml = safeItemUrl + ? `` + : ''; + 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 = () => { @@ -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) ); }); }); @@ -213,4 +404,5 @@ window.onload = () => { if (calendarElement) { loadCalendar(); } + loadCalendarMobileList(); } diff --git a/src/layouts/Layout.astro b/src/layouts/Layout.astro index 7f86430..d764b0d 100644 --- a/src/layouts/Layout.astro +++ b/src/layouts/Layout.astro @@ -2,7 +2,14 @@ interface Props { } -// const { title } = Astro.props; +const currentPath = Astro.url.pathname; + +function isActive(href: string): boolean { + if (href === '/') { + return currentPath === '/' || currentPath === ''; + } + return currentPath.startsWith(href); +} --- @@ -12,111 +19,106 @@ interface Props {