Modale de détail au clic sur une tuile événement (#2)

- calendar.php : ajout DTEND, DESCRIPTION, URL ; dépliage des lignes
  iCal et désescapage (\n, \,, \;)
- index.php : tuiles cliquables avec data-event JSON, modale native
  (overlay + dialog div), JS vanilla pour ouverture/fermeture (clic
  extérieur, touche Escape)
- style.css : styles modale et hover sur tuiles événements

Closes #2

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Cédrix 2026-05-06 00:10:16 +02:00
parent 8ce9c3ad3b
commit fc4d010c66
3 changed files with 151 additions and 12 deletions

View file

@ -331,6 +331,70 @@ td.center { text-align: center; }
.ip-bar { background: var(--primary); height: 10px; border-radius: 4px; min-width: 4px; } .ip-bar { background: var(--primary); height: 10px; border-radius: 4px; min-width: 4px; }
.ip-count { width: 24px; text-align: right; font-weight: 600; } .ip-count { width: 24px; text-align: right; font-weight: 600; }
/* ── Calendar events ──────────────────────────────────────────────── */
.calendar-section { margin-bottom: 2.5rem; }
.calendar-section h2 { font-size: 1.2rem; margin-bottom: 1rem; }
.about-card--event {
cursor: pointer;
transition: border-color .15s, box-shadow .15s;
}
.about-card--event:hover {
border-color: var(--primary);
box-shadow: var(--shadow);
}
/* ── Event modal ──────────────────────────────────────────────────── */
.event-modal-overlay {
display: none;
position: fixed; inset: 0; z-index: 200;
background: rgba(0,0,0,.45);
align-items: center;
justify-content: center;
padding: 1rem;
}
.event-modal-overlay--open { display: flex; }
.event-modal {
background: var(--bg2);
border-radius: var(--radius);
box-shadow: 0 8px 40px rgba(0,0,0,.18);
padding: 2rem;
max-width: 560px;
width: 100%;
position: relative;
max-height: 90vh;
overflow-y: auto;
}
.event-modal-close {
position: absolute; top: 1rem; right: 1rem;
background: none; border: none; cursor: pointer;
font-size: 1.4rem; color: var(--text-muted); line-height: 1;
}
.event-modal-close:hover { color: var(--text); }
.event-modal-title { font-size: 1.2rem; margin-bottom: 1rem; padding-right: 2rem; }
.event-modal-meta {
display: grid;
grid-template-columns: auto 1fr;
gap: .3rem .8rem;
font-size: .88rem;
margin-bottom: 1.2rem;
}
.event-modal-meta dt { color: var(--text-muted); white-space: nowrap; }
.event-modal-meta dd { color: var(--text); }
.event-modal-desc {
font-size: .9rem;
color: var(--text);
line-height: 1.7;
border-top: 1px solid var(--border);
padding-top: 1rem;
white-space: pre-line;
}
/* ── Footer ───────────────────────────────────────────────────────── */ /* ── Footer ───────────────────────────────────────────────────────── */
footer { footer {
text-align: center; text-align: center;

View file

@ -19,26 +19,37 @@ function _ical_upcoming(string $ical, int $n): array {
$events = []; $events = [];
$now = time(); $now = time();
// Unfold continuation lines before parsing
$ical = preg_replace('/\r?\n[ \t]/', '', $ical);
preg_match_all('/BEGIN:VEVENT(.+?)END:VEVENT/s', $ical, $blocks); preg_match_all('/BEGIN:VEVENT(.+?)END:VEVENT/s', $ical, $blocks);
foreach ($blocks[1] as $b) { foreach ($blocks[1] as $b) {
$e = []; $e = [];
if (preg_match('/^SUMMARY[^:]*:(.+)$/m', $b, $m)) if (preg_match('/^SUMMARY[^:]*:(.+)$/m', $b, $m))
$e['title'] = trim($m[1]); $e['title'] = _ical_unescape(trim($m[1]));
if (preg_match('/^LOCATION[^:]*:(.+)$/m', $b, $m)) if (preg_match('/^LOCATION[^:]*:(.+)$/m', $b, $m))
$e['location'] = trim($m[1]); $e['location'] = _ical_unescape(trim($m[1]));
if (preg_match('/^DESCRIPTION[^:]*:(.+)$/m', $b, $m))
$e['description'] = _ical_unescape(trim($m[1]));
if (preg_match('/^URL[^:]*:(.+)$/m', $b, $m))
$e['url'] = trim($m[1]);
// Handles: DTSTART:YYYYMMDD, DTSTART:YYYYMMDDTHHmmss[Z], DTSTART;TZID=...:YYYYMMDDTHHmmss // Handles: DTSTART:YYYYMMDD, DTSTART:YYYYMMDDTHHmmss[Z], DTSTART;TZID=...:YYYYMMDDTHHmmss
if (preg_match('/^DTSTART(?:;[^:]+)?:([\dTZ]+)/m', $b, $m)) { foreach (['start' => 'DTSTART', 'end' => 'DTEND'] as $key => $prop) {
if (preg_match('/^' . $prop . '(?:;[^:]+)?:([\dTZ]+)/m', $b, $m)) {
$raw = $m[1]; $raw = $m[1];
if (strlen($raw) === 8) { if (strlen($raw) === 8) {
$e['start'] = mktime(0, 0, 0, (int)substr($raw, 4, 2), (int)substr($raw, 6, 2), (int)substr($raw, 0, 4)); $e[$key] = mktime(0, 0, 0, (int)substr($raw, 4, 2), (int)substr($raw, 6, 2), (int)substr($raw, 0, 4));
} else { } else {
$dt = DateTime::createFromFormat('Ymd\THis\Z', $raw, new DateTimeZone('UTC')) $dt = DateTime::createFromFormat('Ymd\THis\Z', $raw, new DateTimeZone('UTC'))
?: DateTime::createFromFormat('Ymd\THis', $raw, new DateTimeZone('Europe/Paris')); ?: DateTime::createFromFormat('Ymd\THis', $raw, new DateTimeZone('Europe/Paris'));
if ($dt) $e['start'] = $dt->getTimestamp(); if ($dt) $e[$key] = $dt->getTimestamp();
}
} }
} }
@ -50,3 +61,7 @@ function _ical_upcoming(string $ical, int $n): array {
return array_slice($events, 0, $n); return array_slice($events, 0, $n);
} }
function _ical_unescape(string $s): string {
return str_replace(['\\n', '\\,', '\\;', '\\\\'], ["\n", ',', ';', '\\'], $s);
}

View file

@ -70,13 +70,73 @@ require __DIR__ . '/views/layout.php';
<h2>Prochains événements</h2> <h2>Prochains événements</h2>
<div class="about-grid"> <div class="about-grid">
<?php foreach ($calendar_events as $ev): ?> <?php foreach ($calendar_events as $ev): ?>
<div class="about-card"> <div class="about-card about-card--event"
onclick="openEventModal(this)"
data-event="<?= htmlspecialchars(json_encode([
'title' => $ev['title'],
'start' => $ev['start'],
'end' => $ev['end'] ?? null,
'location' => $ev['location'] ?? null,
'description' => $ev['description'] ?? null,
'url' => $ev['url'] ?? null,
]), ENT_QUOTES) ?>">
<div class="about-title"><?= htmlspecialchars($ev['title']) ?></div> <div class="about-title"><?= htmlspecialchars($ev['title']) ?></div>
<p><?= date('d/m/Y', $ev['start']) ?><?= !empty($ev['location']) ? ' — ' . htmlspecialchars($ev['location']) : '' ?></p> <p>
<?= date('d/m/Y H:i', $ev['start']) ?>
<?= !empty($ev['location']) ? ' — ' . htmlspecialchars($ev['location']) : '' ?>
</p>
</div> </div>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
</section> </section>
<div id="event-modal-overlay" class="event-modal-overlay" onclick="closeEventModal(event)">
<div class="event-modal" role="dialog" aria-modal="true">
<button class="event-modal-close" onclick="closeEventModal()" aria-label="Fermer">&times;</button>
<h2 class="event-modal-title" id="event-modal-title"></h2>
<dl class="event-modal-meta" id="event-modal-meta"></dl>
<div class="event-modal-desc" id="event-modal-desc"></div>
<div id="event-modal-url"></div>
</div>
</div>
<script>
function openEventModal(card) {
const ev = JSON.parse(card.dataset.event);
const fmt = ts => ts ? new Date(ts * 1000).toLocaleString('fr-FR', {weekday:'long', day:'numeric', month:'long', year:'numeric', hour:'2-digit', minute:'2-digit'}) : null;
document.getElementById('event-modal-title').textContent = ev.title;
let meta = '';
const start = fmt(ev.start), end = fmt(ev.end);
if (start) meta += `<dt>Début</dt><dd>${start}</dd>`;
if (end) meta += `<dt>Fin</dt><dd>${end}</dd>`;
if (ev.location) meta += `<dt>Lieu</dt><dd>${ev.location}</dd>`;
document.getElementById('event-modal-meta').innerHTML = meta;
const desc = document.getElementById('event-modal-desc');
if (ev.description) {
desc.innerHTML = ev.description.replace(/\n/g, '<br>');
desc.style.display = '';
} else {
desc.style.display = 'none';
}
const urlEl = document.getElementById('event-modal-url');
urlEl.innerHTML = ev.url ? `<a href="${ev.url}" target="_blank" rel="noopener" class="btn-outline btn-sm" style="margin-top:1rem;display:inline-block">En savoir plus →</a>` : '';
document.getElementById('event-modal-overlay').classList.add('event-modal-overlay--open');
document.body.style.overflow = 'hidden';
}
function closeEventModal(e) {
if (e && e.target !== document.getElementById('event-modal-overlay')) return;
document.getElementById('event-modal-overlay').classList.remove('event-modal-overlay--open');
document.body.style.overflow = '';
}
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeEventModal(); });
</script>
<?php endif; ?> <?php endif; ?>
<section class="services-section"> <section class="services-section">