diff --git a/calendar-ios.php b/calendar-ios.php index 2a7a9b9..185e7d8 100644 --- a/calendar-ios.php +++ b/calendar-ios.php @@ -20,6 +20,14 @@ require __DIR__ . '/header.php';

Les événements viennent de HouseHub après synchro iCloud. Synchroniser met à jour tous les calendriers du compte ; les changements locaux partent au prochain envoi.

+ +
+ Calendriers affichés et couleurs +

Décoche un calendrier pour le masquer dans l’agenda. La couleur est libre (repère la teinte iOS sur ton téléphone puis copie-la avec le sélecteur).

+
+

+ +
diff --git a/modules/calendar-ios/api.php b/modules/calendar-ios/api.php index cbd3952..d26c7c1 100644 --- a/modules/calendar-ios/api.php +++ b/modules/calendar-ios/api.php @@ -28,6 +28,12 @@ CREATE TABLE IF NOT EXISTS user_calendar_integrations ( updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uq_user_provider (user_id, provider) )"); +try { + $meta_pdo->exec('ALTER TABLE user_calendar_integrations ADD COLUMN calendar_prefs_json TEXT NULL'); +} catch (Throwable $e) { + // colonne déjà présente +} + $pdo->exec(" CREATE TABLE IF NOT EXISTS pf_calendar_events ( id INT AUTO_INCREMENT PRIMARY KEY, @@ -90,11 +96,113 @@ function ios_normalize_datetime(?string $s): ?string return $s; } +function ios_calendar_url_key(?string $url): string +{ + if ($url === null || $url === '') { + return ''; + } + return rtrim(trim($url), '/'); +} + +/** @return array */ +function ios_calendar_prefs_decode(?string $json): array +{ + if ($json === null || $json === '') { + return []; + } + $d = json_decode($json, true); + if (!is_array($d)) { + return []; + } + $out = []; + foreach ($d as $k => $v) { + if (!is_string($k) || $k === '' || !is_array($v)) { + continue; + } + $key = ios_calendar_url_key($k); + if ($key === '') { + continue; + } + $out[$key] = [ + 'visible' => !array_key_exists('visible', $v) ? true : (bool) $v['visible'], + 'color' => (isset($v['color']) && is_string($v['color']) && preg_match('/^#[0-9A-Fa-f]{6}$/', $v['color'])) ? $v['color'] : null, + ]; + } + return $out; +} + +/** + * @return list + */ +function ios_list_calendar_sources_for_ui(array $integration, string $password, PDO $pdo): array +{ + $prefs = ios_calendar_prefs_decode($integration['calendar_prefs_json'] ?? null); + $urlsMeta = []; + $primary = $integration['calendar_url'] ?? ''; + if (hh_caldav_url_is_icloud($primary)) { + try { + foreach (hh_icloud_discover_calendar_entries($integration['username'], $password) as $e) { + $k = ios_calendar_url_key($e['url']); + if ($k === '') { + continue; + } + $disp = trim((string) ($e['display'] ?? '')); + if ($disp === '') { + $disp = basename(parse_url($e['url'], PHP_URL_PATH) ?: '') ?: $k; + } + $urlsMeta[$k] = ['url' => rtrim($e['url'], '/') . '/', 'display' => $disp]; + } + } catch (Throwable $e) { + } + } else { + $k = ios_calendar_url_key($primary); + if ($k !== '') { + $urlsMeta[$k] = ['url' => rtrim($primary, '/') . '/', 'display' => 'Calendrier']; + } + } + $stmt = $pdo->query(" + SELECT DISTINCT l.calendar_url AS u + FROM pf_calendar_event_links l + INNER JOIN pf_calendar_events e ON e.id = l.calendar_event_id + WHERE e.deleted_at IS NULL AND l.calendar_url IS NOT NULL AND TRIM(l.calendar_url) != '' + "); + foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) as $u) { + $k = ios_calendar_url_key((string) $u); + if ($k === '' || isset($urlsMeta[$k])) { + continue; + } + $urlsMeta[$k] = [ + 'url' => rtrim((string) $u, '/') . '/', + 'display' => basename(parse_url((string) $u, PHP_URL_PATH) ?: '') ?: $k, + ]; + } + $out = []; + foreach ($urlsMeta as $key => $meta) { + $p = $prefs[$key] ?? []; + $visible = !array_key_exists('visible', $p) ? true : (bool) $p['visible']; + $color = (isset($p['color']) && is_string($p['color']) && preg_match('/^#[0-9A-Fa-f]{6}$/', $p['color'])) ? $p['color'] : null; + $out[] = [ + 'url' => $meta['url'], + 'url_key' => $key, + 'display' => $meta['display'], + 'visible' => $visible, + 'color' => $color, + ]; + } + usort($out, static fn ($a, $b) => strcasecmp($a['display'], $b['display'])); + + return $out; +} + $integrationStmt = $meta_pdo->prepare("SELECT * FROM user_calendar_integrations WHERE user_id = ? AND provider='icloud_caldav'"); $integrationStmt->execute([$userId]); $integration = $integrationStmt->fetch(); if ($action === 'events' && $method === 'GET') { + $prefs = []; + if ($integration) { + $prefs = ios_calendar_prefs_decode($integration['calendar_prefs_json'] ?? null); + } $rows = $pdo->query(" SELECT e.*, l.calendar_url AS calendar_source_url FROM pf_calendar_events e @@ -102,7 +210,73 @@ if ($action === 'events' && $method === 'GET') { WHERE e.deleted_at IS NULL ORDER BY e.start_at ASC ")->fetchAll(); - ios_ok($rows); + $out = []; + foreach ($rows as $r) { + $key = ios_calendar_url_key($r['calendar_source_url'] ?? ''); + if ($key !== '' && isset($prefs[$key]['visible']) && $prefs[$key]['visible'] === false) { + continue; + } + $r['display_color'] = null; + if ($key !== '' && !empty($prefs[$key]['color']) && is_string($prefs[$key]['color']) && preg_match('/^#[0-9A-Fa-f]{6}$/', $prefs[$key]['color'])) { + $r['display_color'] = $prefs[$key]['color']; + } + $out[] = $r; + } + ios_ok($out); +} + +if ($action === 'calendar_sources' && $method === 'GET') { + if (!$integration) { + ios_ok(['calendars' => []]); + } + try { + $ctx = ios_caldav_prepare($integration, $meta_pdo); + } catch (Throwable $e) { + ios_err('CalDAV: ' . $e->getMessage(), 400); + } + $integrationStmt->execute([$userId]); + $integrationFresh = $integrationStmt->fetch(); + if (!$integrationFresh) { + ios_ok(['calendars' => []]); + } + ios_ok(['calendars' => ios_list_calendar_sources_for_ui($integrationFresh, $ctx['password'], $pdo)]); +} + +if ($action === 'calendar_prefs' && $method === 'POST') { + ios_require_csrf(); + if (!$integration) { + ios_err('Connexion iCloud non configurée.', 400); + } + $body = ios_body(); + $raw = $body['prefs'] ?? null; + if (!is_array($raw)) { + ios_err('Format prefs invalide', 400); + } + $clean = []; + foreach ($raw as $key => $p) { + if (!is_array($p)) { + continue; + } + $urlKey = ios_calendar_url_key(is_string($key) ? $key : ''); + if ($urlKey === '') { + continue; + } + $visible = array_key_exists('visible', $p) ? (bool) $p['visible'] : true; + $col = $p['color'] ?? null; + if ($col !== null && $col !== '' && is_string($col) && preg_match('/^#[0-9A-Fa-f]{6}$/', $col)) { + $col = '#' . strtolower(substr($col, 1)); + } else { + $col = null; + } + $clean[$urlKey] = ['visible' => $visible, 'color' => $col]; + } + $existing = ios_calendar_prefs_decode($integration['calendar_prefs_json'] ?? null); + foreach ($clean as $urlKey => $v) { + $existing[$urlKey] = $v; + } + $json = json_encode($existing, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + $meta_pdo->prepare('UPDATE user_calendar_integrations SET calendar_prefs_json = ? WHERE id = ?')->execute([$json, $integration['id']]); + ios_ok(['saved' => true]); } if ($action === 'events' && $method === 'POST') { diff --git a/modules/calendar-ios/assets/calendar-ios.css b/modules/calendar-ios/assets/calendar-ios.css index 21c3476..d696130 100644 --- a/modules/calendar-ios/assets/calendar-ios.css +++ b/modules/calendar-ios/assets/calendar-ios.css @@ -24,6 +24,102 @@ line-height: 1.45; } +.ios-cal-prefs-details { + margin-top: 14px; + border: 1px solid var(--pf-border, #e2e8f0); + border-radius: 10px; + padding: 10px 14px; + background: var(--pf-bg-page, #fff); +} + +.ios-cal-prefs-summary { + cursor: pointer; + font-weight: 600; + font-size: 0.95rem; +} + +.ios-cal-prefs-help { + margin: 10px 0 12px; + font-size: 0.85rem; + color: var(--pf-text-muted, #64748b); + line-height: 1.45; +} + +.ios-cal-prefs-rows { + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 12px; +} + +.ios-cal-pref-row { + display: grid; + grid-template-columns: auto 1fr auto auto; + align-items: center; + gap: 10px 12px; + padding: 8px 10px; + border: 1px solid var(--pf-border, #e2e8f0); + border-radius: 8px; + background: var(--pf-bg-lighter, #f8fafc); +} + +.ios-cal-pref-row input[type="checkbox"] { + width: 18px; + height: 18px; +} + +.ios-cal-pref-label { + font-size: 0.88rem; + min-width: 0; + word-break: break-word; +} + +.ios-cal-pref-color { + width: 44px; + height: 36px; + padding: 2px; + border: 1px solid var(--pf-border, #e2e8f0); + border-radius: 8px; + cursor: pointer; + background: var(--pf-bg-page, #fff); +} + +.ios-cal-pref-reset { + border: none; + background: transparent; + color: var(--pf-text-muted, #64748b); + font-size: 0.78rem; + text-decoration: underline; + cursor: pointer; + padding: 4px; + font-family: inherit; + white-space: nowrap; +} + +.ios-cal-pref-reset:hover { + color: var(--pf-text-main, #0f172a); +} + +.ios-cal-prefs-msg { + min-height: 1.25em; + font-size: 0.85rem; + margin: 0 0 10px; +} + +@media (max-width: 640px) { + .ios-cal-pref-row { + grid-template-columns: auto 1fr; + grid-template-rows: auto auto; + } + .ios-cal-pref-color { + grid-column: 2; + } + .ios-cal-pref-reset { + grid-column: 2; + justify-self: start; + } +} + .ios-form-grid { display: grid; grid-template-columns: 1fr 1fr; @@ -349,8 +445,10 @@ box-shadow: inset 3px 0 0 rgba(255, 255, 255, 0.35); } -.ios-event-block:hover { - filter: brightness(1.08); +.ios-event-block--custom { + border: none !important; + color: #fff !important; + box-shadow: inset 3px 0 0 rgba(255, 255, 255, 0.35) !important; } .ios-event-block-title { diff --git a/modules/calendar-ios/assets/calendar-ios.js b/modules/calendar-ios/assets/calendar-ios.js index b95e7aa..c08f7a7 100644 --- a/modules/calendar-ios/assets/calendar-ios.js +++ b/modules/calendar-ios/assets/calendar-ios.js @@ -26,6 +26,8 @@ const PALETTE = [ "ios-cal-c5", ]; +const SUGGEST_HEX = ["#22c55e", "#3b82f6", "#8b5cf6", "#eab308", "#14b8a6", "#f43f5e"]; + function escHtml(s) { const d = document.createElement("div"); d.textContent = s ?? ""; @@ -50,6 +52,21 @@ function parseEvtDate(iso) { return Number.isNaN(d.getTime()) ? null : d; } +function darkenHex(hex, amount) { + const n = parseInt(hex.slice(1), 16); + const r = Math.max(0, (n >> 16) - amount); + const g = Math.max(0, ((n >> 8) & 0xff) - amount); + const b = Math.max(0, (n & 0xff) - amount); + return `#${[r, g, b].map((x) => x.toString(16).padStart(2, "0")).join("")}`; +} + +function suggestedColorForKey(key) { + const k = String(key || ""); + let h = 0; + for (let i = 0; i < k.length; i++) h = (h * 31 + k.charCodeAt(i)) >>> 0; + return SUGGEST_HEX[h % SUGGEST_HEX.length]; +} + function colorClassForEvent(evt) { const key = String(evt.calendar_source_url || evt.external_uid || evt.id || ""); let h = 0; @@ -57,6 +74,22 @@ function colorClassForEvent(evt) { return PALETTE[h % PALETTE.length]; } +/** Applique couleur perso (API) ou palette par défaut */ +function styleEventBlock(el, evt) { + el.className = "ios-event-block"; + el.style.background = ""; + el.style.boxShadow = ""; + const hex = evt.display_color; + if (hex && /^#[0-9A-Fa-f]{6}$/i.test(hex)) { + const c = hex.startsWith("#") ? hex : `#${hex.slice(-6)}`; + el.classList.add("ios-event-block--custom"); + el.style.background = `linear-gradient(135deg, ${darkenHex(c, 40)} 0%, ${c} 100%)`; + el.style.boxShadow = "inset 3px 0 0 rgba(255,255,255,0.35)"; + } else { + el.classList.add(colorClassForEvent(evt)); + } +} + function mondayOf(date) { const d = new Date(date.getFullYear(), date.getMonth(), date.getDate()); const dow = d.getDay(); @@ -243,7 +276,7 @@ function buildDayColumn(dayMidnight) { const left = (100 * seg._lane) / seg._laneCount; const blk = document.createElement("button"); blk.type = "button"; - blk.className = `ios-event-block ${colorClassForEvent(seg.evt)}`; + styleEventBlock(blk, seg.evt); blk.style.top = `${top}px`; blk.style.height = `${h}px`; blk.style.left = `calc(${left}% + 2px)`; @@ -442,6 +475,9 @@ async function loadEvents() { events.forEach((evt) => { const card = document.createElement("div"); card.className = "ios-event-card"; + if (evt.display_color && /^#[0-9A-Fa-f]{6}$/i.test(evt.display_color)) { + card.style.borderLeft = `4px solid ${evt.display_color}`; + } card.innerHTML = `
@@ -476,6 +512,94 @@ async function loadSyncStatus() { iosSyncStatus.textContent = status.message; } +async function loadCalendarPrefs() { + const wrap = document.getElementById("ios-calendar-prefs-rows"); + const msg = document.getElementById("ios-calendar-prefs-msg"); + if (!wrap) return; + if (msg) msg.textContent = ""; + wrap.innerHTML = "Chargement des calendriers…"; + try { + const data = await iosApi("calendar_sources"); + const rows = data.calendars || []; + wrap.innerHTML = ""; + if (!rows.length) { + wrap.innerHTML = + "Aucun calendrier détecté. Configure iCloud dans Paramètres puis synchronise."; + return; + } + rows.forEach((row, idx) => { + const id = `ios-cal-cb-${idx}`; + const rowEl = document.createElement("div"); + rowEl.className = "ios-cal-pref-row"; + rowEl.dataset.urlKey = row.url_key; + + const cb = document.createElement("input"); + cb.type = "checkbox"; + cb.id = id; + cb.checked = row.visible !== false; + + const lab = document.createElement("label"); + lab.className = "ios-cal-pref-label"; + lab.htmlFor = id; + lab.textContent = row.display || row.url_key; + + const colInp = document.createElement("input"); + colInp.type = "color"; + colInp.className = "ios-cal-pref-color"; + colInp.value = row.color || suggestedColorForKey(row.url_key); + colInp.title = "Couleur dans l’agenda"; + + const autoBtn = document.createElement("button"); + autoBtn.type = "button"; + autoBtn.className = "ios-cal-pref-reset"; + autoBtn.textContent = "Palette auto"; + autoBtn.title = "Couleurs HouseHub par défaut (sans teinte iOS)"; + autoBtn.addEventListener("click", () => { + rowEl.dataset.paletteAuto = "1"; + colInp.disabled = true; + colInp.style.opacity = "0.35"; + }); + + rowEl.appendChild(cb); + rowEl.appendChild(lab); + rowEl.appendChild(colInp); + rowEl.appendChild(autoBtn); + wrap.appendChild(rowEl); + }); + } catch (e) { + wrap.innerHTML = `${escHtml(e.message)}`; + } +} + +async function saveCalendarPrefs() { + const msg = document.getElementById("ios-calendar-prefs-msg"); + const wrap = document.getElementById("ios-calendar-prefs-rows"); + if (!wrap) return; + const prefs = {}; + wrap.querySelectorAll(".ios-cal-pref-row").forEach((rowEl) => { + const key = rowEl.dataset.urlKey; + if (!key) return; + const vis = rowEl.querySelector('input[type="checkbox"]')?.checked === true; + const colInp = rowEl.querySelector(".ios-cal-pref-color"); + let color = null; + if (!rowEl.dataset.paletteAuto && colInp && !colInp.disabled) { + const v = colInp.value; + if (v && /^#[0-9A-Fa-f]{6}$/i.test(v)) { + color = v.startsWith("#") ? v.toLowerCase() : `#${v}`.toLowerCase(); + } + } + prefs[key] = { visible: vis, color }; + }); + try { + await iosApi("calendar_prefs", { method: "POST", body: { prefs } }); + if (msg) msg.textContent = "Préférences enregistrées."; + await loadEvents(); + await loadCalendarPrefs(); + } catch (e) { + if (msg) msg.textContent = e.message; + } +} + iosForm.addEventListener("submit", async (e) => { e.preventDefault(); const idStr = document.getElementById("ios-event-id").value.trim(); @@ -505,6 +629,7 @@ document.getElementById("ios-sync-btn")?.addEventListener("click", async () => { const data = await iosApi("sync", { method: "POST", body: {} }); if (iosSyncStatus) iosSyncStatus.textContent = data.message; await loadEvents(); + await loadCalendarPrefs(); } finally { btn.disabled = false; } @@ -558,10 +683,13 @@ document.getElementById("ios-agenda-add")?.addEventListener("click", () => { document.getElementById("ios-title")?.focus(); }); +document.getElementById("ios-calendar-prefs-save")?.addEventListener("click", () => saveCalendarPrefs()); + window.addEventListener("DOMContentLoaded", async () => { try { iosCursor = new Date(); await loadEvents(); + await loadCalendarPrefs(); await loadSyncStatus(); showListSection(false); startNowTimer();