// Calendar tab: view/week-start/photo-inlay settings, per-user opt-in, // and the rendered preview. Extracted from frame_config.js when the // Calendar card became its own tab (window.FRAME_API is set by the // template; checkboxes are always sent explicitly as "true"/"false"). // Week-view-only settings (days/layout/start-offset) only matter when // View is actually "Week"; "Week starts on" also matters for Month, so // it gets its own, slightly looser condition. The start-offset row is // further gated on the day count -- it's meaningless at the default 7 // days, where "Week starts on" governs instead (see // calendar_render.py's _build_week). function updateCalendarFieldVisibility() { const view = document.getElementById('calendar_view').value; const days = Number(document.getElementById('calendar_week_days').value); const isWeek = view === 'week'; document.getElementById('calendar-week-start-row').style.display = (view === 'week' || view === 'month') ? '' : 'none'; document.getElementById('calendar-week-days-row').style.display = isWeek ? '' : 'none'; document.getElementById('calendar-week-layout-row').style.display = isWeek ? '' : 'none'; document.getElementById('calendar-week-offset-row').style.display = (isWeek && days !== 7) ? '' : 'none'; } document.getElementById('calendar_view').addEventListener('change', updateCalendarFieldVisibility); document.getElementById('calendar_week_days').addEventListener('input', updateCalendarFieldVisibility); updateCalendarFieldVisibility(); document.getElementById('calendar-config-form').addEventListener('submit', async (e) => { e.preventDefault(); const body = new URLSearchParams({ calendar_view: document.getElementById('calendar_view').value, calendar_week_start: document.getElementById('calendar_week_start').value, calendar_week_days: document.getElementById('calendar_week_days').value, calendar_week_layout: document.getElementById('calendar_week_layout').value, calendar_week_start_offset: document.getElementById('calendar_week_start_offset').value, calendar_photo_inlay: String(document.getElementById('calendar_photo_inlay').checked), }); try { const resp = await fetch(`${window.FRAME_API}/config`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body, }); if (!resp.ok) throw new Error(await apiError(resp)); showStatus(true, 'Saved.'); loadCalendarPreview(); } catch (e) { showStatus(false, e.message); } }); // Each calendar's own include/mute toggle -- auto-saves on change, not // batched into the form above, since it's a data-sharing choice (see // api_frames.py's /calendar-select), not a frame-wide setting. Works the // same element for your own calendars (full add/remove) and other // people's (mute only) -- the server enforces which direction is allowed // and this just reverts the checkbox with an error message if rejected. document.querySelectorAll('.calendar-toggle').forEach((el) => { el.addEventListener('change', async () => { try { const resp = await fetch(`${window.FRAME_API}/calendar-select`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_id: Number(el.dataset.userId), calendar_key: el.dataset.key, calendar_label: el.dataset.label, included: el.checked, }), }); if (!resp.ok) throw new Error(await apiError(resp)); showStatus(true, el.checked ? 'Calendar included on this frame.' : 'Calendar removed from this frame.'); } catch (e) { el.checked = !el.checked; showStatus(false, e.message); } }); }); // Per-calendar color pin -- owner-only (the server enforces it; these // buttons only ever render for the viewer's own calendars anyway, see // frame_calendar.html). Clicking the currently-selected swatch again has // no special "toggle off" behavior -- use the explicit Auto button. document.querySelectorAll('.calendar-color-picker').forEach((picker) => { const key = picker.dataset.key; picker.querySelectorAll('.color-swatch').forEach((btn) => { btn.addEventListener('click', async () => { const colorIndex = btn.dataset.index === '' ? null : Number(btn.dataset.index); try { const resp = await fetch(`${window.FRAME_API}/calendar-color`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ calendar_key: key, color_index: colorIndex }), }); if (!resp.ok) throw new Error(await apiError(resp)); picker.querySelectorAll('.color-swatch').forEach((el) => el.classList.remove('selected')); btn.classList.add('selected'); showStatus(true, 'Color saved.'); loadCalendarPreview(); } catch (e) { showStatus(false, e.message); } }); }); }); document.getElementById('weather-config-form').addEventListener('submit', async (e) => { e.preventDefault(); const body = new URLSearchParams({ calendar_weather_enabled: String(document.getElementById('weather_enabled').checked), calendar_weather_units: document.getElementById('weather_units').value, }); try { const resp = await fetch(`${window.FRAME_API}/config`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body, }); if (!resp.ok) throw new Error(await apiError(resp)); showStatus(true, 'Saved.'); loadCalendarPreview(); } catch (e) { showStatus(false, e.message); } }); function addWeatherCityRow(label) { const list = document.getElementById('weather-city-list'); const empty = document.getElementById('weather-city-empty'); if (empty) empty.remove(); const li = document.createElement('li'); li.className = 'checkbox-row'; li.style.cssText = 'justify-content: space-between; margin-top: 6px;'; const span = document.createElement('span'); span.textContent = label; const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'btn-inline secondary weather-city-remove'; btn.dataset.label = label; btn.textContent = 'Remove'; btn.addEventListener('click', removeWeatherCity); li.appendChild(span); li.appendChild(btn); list.appendChild(li); } async function removeWeatherCity(e) { const label = e.target.dataset.label; try { const resp = await fetch(`${window.FRAME_API}/weather-cities/remove`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ label }), }); if (!resp.ok) throw new Error(await apiError(resp)); e.target.closest('li').remove(); const list = document.getElementById('weather-city-list'); if (!list.querySelector('li')) { list.innerHTML = '
  • No cities added yet.
  • '; } showStatus(true, `${label} removed.`); loadCalendarPreview(); } catch (e) { showStatus(false, e.message); } } document.querySelectorAll('.weather-city-remove').forEach((el) => el.addEventListener('click', removeWeatherCity)); document.getElementById('weather-city-add').addEventListener('click', async () => { const input = document.getElementById('weather-city-input'); const name = input.value.trim(); if (!name) return; try { const resp = await fetch(`${window.FRAME_API}/weather-cities/add`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }), }); if (!resp.ok) throw new Error(await apiError(resp)); const data = await resp.json(); addWeatherCityRow(data.city.label); input.value = ''; showStatus(true, `Added ${data.city.label}.`); loadCalendarPreview(); } catch (e) { showStatus(false, e.message); } }); document.getElementById('tasks_enabled').addEventListener('change', async (e) => { const el = e.target; try { const resp = await fetch(`${window.FRAME_API}/config`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ calendar_tasks_enabled: String(el.checked) }), }); if (!resp.ok) throw new Error(await apiError(resp)); showStatus(true, 'Saved.'); loadCalendarPreview(); } catch (e) { el.checked = !el.checked; showStatus(false, e.message); } }); // Choosing one of your own CalDAV task lists as this frame's source -- // owner-only (see api_frames.py's api_tasks_source), so these radios // only ever render for the viewer's own calendars anyway. document.querySelectorAll('.tasks-source-choice').forEach((el) => { el.addEventListener('change', async () => { try { const resp = await fetch(`${window.FRAME_API}/tasks-source`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ calendar_key: el.dataset.key }), }); if (!resp.ok) throw new Error(await apiError(resp)); showStatus(true, 'Task list saved. Reload to see the updated source.'); loadCalendarPreview(); } catch (e) { showStatus(false, e.message); } }); }); const tasksSourceClear = document.getElementById('tasks-source-clear'); if (tasksSourceClear) { tasksSourceClear.addEventListener('click', async () => { try { const resp = await fetch(`${window.FRAME_API}/tasks-source`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ calendar_key: null }), }); if (!resp.ok) throw new Error(await apiError(resp)); showStatus(true, 'Task list cleared. Reload to see the change.'); loadCalendarPreview(); } catch (e) { showStatus(false, e.message); } }); } function loadCalendarPreview() { document.getElementById('calendar-preview').src = `${window.FRAME_API}/preview/calendar?_=${Date.now()}`; } document.getElementById('calendar-preview-refresh').addEventListener('click', loadCalendarPreview); loadCalendarPreview(); async function takeControl() { try { const resp = await fetch(`${window.FRAME_API}/take-control`, { method: 'POST' }); if (!resp.ok) throw new Error(await apiError(resp)); showStatus(true, 'You have control now.'); loadControl(); } catch (e) { showStatus(false, e.message); } } async function loadControl() { const banner = document.getElementById('control-banner'); try { const resp = await fetch(`${window.FRAME_API}/queue`); if (!resp.ok) return; // unconfigured frame: control still works via 409s const data = await resp.json(); if (data.control && !data.control.you) { banner.style.display = 'flex'; document.getElementById('control-holder').textContent = data.control.controller ? `${data.control.controller} currently has control of this frame.` : 'Nobody has control of this frame yet.'; } else { banner.style.display = 'none'; } } catch (e) { /* banner is best-effort */ } } document.getElementById('take-control').addEventListener('click', takeControl); loadControl();