diff --git a/public/app.js b/public/app.js index 775707b..252297a 100644 --- a/public/app.js +++ b/public/app.js @@ -235,8 +235,7 @@ function viewSearch() { // ---- Calendar: agenda built from private ICS links (managed here; fetched and parsed by the server) const ymd = d => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; -async function loadAgenda(days) { - const start = new Date(); start.setHours(0, 0, 0, 0); +async function loadRange(start, days) { const end = new Date(start); end.setDate(end.getDate() + days); const last = new Date(end.getTime() - 1); const q = new URLSearchParams({ from: start.getTime(), to: end.getTime() - 1, fd: ymd(start), td: ymd(last) }); @@ -251,6 +250,7 @@ async function loadAgenda(days) { for (const list of byDay.values()) list.sort((a, b) => (b.allDay - a.allDay) || ((a.allDay ? 0 : a.start) - (b.allDay ? 0 : b.start))); return { byDay, errors, start }; } +const loadAgenda = days => { const start = new Date(); start.setHours(0, 0, 0, 0); return loadRange(start, days); }; const fmtTime = ms => new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); const evRow = e => `
${esc(e.title)} ${e.allDay ? 'All day' : fmtTime(e.start) + ' – ' + fmtTime(e.end)}${e.location ? ' · ' + esc(e.location) : ''} · ${esc(e.cal)}
`; @@ -268,22 +268,67 @@ async function renderAgenda(el, mode) { // Home card (today only) } catch {} } +const now0 = new Date(); +const calState = { y: now0.getFullYear(), m: now0.getMonth(), sel: ymd(now0) }; + async function viewCalendar() { - const range = store.get('cal_range', 7); + const mode = store.get('cal_view', 'month'); const v = $('#view'); - v.innerHTML = `

Calendar

${[[1, 'Today'], [7, '7 days'], [30, '30 days']].map(([n, l]) => `${l}`).join('')}
-

Loading…

`; - v.querySelectorAll('.seg a').forEach(a => a.onclick = e => { e.preventDefault(); store.set('cal_range', +a.dataset.n); viewCalendar(); }); + v.innerHTML = `

Calendar

+
${[['month', 'Month'], ['agenda', 'Agenda']].map(([k, l]) => `${l}`).join('')}
+

Loading…

`; + v.querySelectorAll('.seg a[data-v]').forEach(a => a.onclick = e => { e.preventDefault(); store.set('cal_view', a.dataset.v); viewCalendar(); }); $('#mc').onclick = manageCalendars; try { const cals = await jfetch('/api/cal/list'); - if (!cals.length) { $('#ag').innerHTML = '

No calendars yet. Add a private calendar link to see your events here.

'; $('#ac').onclick = addCalendar; return; } - const { byDay, errors } = await loadAgenda(range); - if (!location.hash.startsWith('#/calendar')) return; - const keys = [...byDay.keys()].sort(); - $('#ag').innerHTML = (keys.map(k => `

${esc(dayLabel(k))}

${byDay.get(k).map(evRow).join('')}
`).join('') || '

Nothing coming up

') - + errors.map(e => `

${esc(e)}

`).join(''); - } catch (e) { $('#ag').innerHTML = `

${esc(e.message)}

`; } + if (!cals.length) { $('#cbody').innerHTML = '

No calendars yet. Add a private calendar link to see your events here.

'; $('#ac').onclick = addCalendar; return; } + if (mode === 'month') await renderMonth(); else await renderAgendaList(); + } catch (e) { const b = $('#cbody'); if (b) b.innerHTML = `

${esc(e.message)}

`; } +} + +async function renderAgendaList() { + const range = store.get('cal_range', 7); + const { byDay, errors } = await loadAgenda(range); + if (!location.hash.startsWith('#/calendar') || !$('#cbody')) return; + const keys = [...byDay.keys()].sort(); + $('#cbody').innerHTML = `
${[[1, 'Today'], [7, '7 days'], [30, '30 days']].map(([n, l]) => `${l}`).join('')}
` + + (keys.map(k => `

${esc(dayLabel(k))}

${byDay.get(k).map(evRow).join('')}
`).join('') || '

Nothing coming up

') + + errors.map(e => `

${esc(e)}

`).join(''); + $('#cbody').querySelectorAll('.seg a[data-n]').forEach(a => a.onclick = e => { e.preventDefault(); store.set('cal_range', +a.dataset.n); viewCalendar(); }); +} + +async function renderMonth() { + const { y, m } = calState; + const first = new Date(y, m, 1), gridStart = new Date(y, m, 1 - first.getDay()); // the Sunday on or before the 1st + const { byDay, errors } = await loadRange(gridStart, 42); + if (!location.hash.startsWith('#/calendar') || !$('#cbody')) return; + const today = ymd(new Date()); + if (calState.sel < ymd(gridStart) || calState.sel > ymd(new Date(y, m, 1 - first.getDay() + 41))) calState.sel = ymd(first); + const cells = []; + for (let i = 0; i < 42; i++) { + const d = new Date(gridStart); d.setDate(d.getDate() + i); + const k = ymd(d), evs = byDay.get(k) || [], other = d.getMonth() !== m; + cells.push(``); + } + $('#cbody').innerHTML = `
+

${first.toLocaleDateString([], { month: 'long', year: 'numeric' })}

+
+
${['S', 'M', 'T', 'W', 'T', 'F', 'S'].map(d => `${d}`).join('')}
+
${cells.join('')}
+
${errors.map(e => `

${esc(e)}

`).join('')}`; + const showDay = () => { + const k = calState.sel, evs = byDay.get(k) || []; + $('#dayev').innerHTML = `

${esc(dayLabel(k))}

${evs.length ? evs.map(evRow).join('') : '
No events
'}
`; + $('#cbody').querySelectorAll('.cday').forEach(c => c.classList.toggle('sel', c.dataset.d === k)); + }; + showDay(); + $('#cbody').querySelectorAll('.cday').forEach(c => c.onclick = () => { calState.sel = c.dataset.d; showDay(); }); + const go = (dm) => { const t = new Date(y, m + dm, 1); calState.y = t.getFullYear(); calState.m = t.getMonth(); calState.sel = ymd(t); renderMonth(); }; + $('#pm').onclick = () => go(-1); $('#nm').onclick = () => go(1); + $('#tm').onclick = () => { const t = new Date(); calState.y = t.getFullYear(); calState.m = t.getMonth(); calState.sel = ymd(t); renderMonth(); }; } function addCalendar() { diff --git a/public/style.css b/public/style.css index 2aaab09..ddcd871 100644 --- a/public/style.css +++ b/public/style.css @@ -201,3 +201,25 @@ body.inedit #view { padding: 0; display: flex; flex-direction: column; } #audiowrap iframe { width: 100%; height: 100%; border: 1px solid var(--line); border-radius: 16px; background: var(--panel); } body.onaudio #view { flex: none; overflow: visible; padding-bottom: 8px; } @media (min-width: 768px) { #audiowrap.on { padding-right: 32px; padding-left: 32px; padding-bottom: 24px; } } + +/* Month calendar */ +.mhdr { display: flex; align-items: center; gap: 6px; margin: 4px 0 10px; } +.cgrid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 1px; background: var(--line); border: 1px solid var(--line); border-radius: 14px; overflow: hidden; } +.cgrid.wk { background: none; border: 0; border-radius: 0; margin-bottom: 4px; gap: 0; } +.cgrid.wk span { text-align: center; color: var(--muted); font-size: 12px; padding: 4px 0; } +.cday { background: var(--panel); border: 0; padding: 4px 2px 6px; min-height: 54px; display: flex; flex-direction: column; align-items: center; gap: 3px; text-align: center; min-width: 0; } +.cday.dim { background: var(--bg); color: var(--muted); } +.cday .dn { width: 26px; height: 26px; border-radius: 50%; display: grid; place-items: center; font-size: 14px; } +.cday.today .dn { background: var(--accent); color: var(--accent-t); font-weight: 700; } +.cday.sel { outline: 2px solid var(--accent); outline-offset: -2px; } +.cday .dots { display: flex; gap: 3px; min-height: 6px; } +.cday .dots i { width: 6px; height: 6px; border-radius: 50%; } +.cday .chips { display: none; } +@media (min-width: 768px) { + .cday { min-height: 104px; align-items: stretch; padding: 6px; gap: 4px; } + .cday .dn { align-self: flex-start; } + .cday .dots { display: none; } + .cday .chips { display: flex; flex-direction: column; gap: 2px; min-width: 0; } + .cday .chips em { font-style: normal; font-size: 12px; line-height: 1.25; padding: 1px 6px; border-radius: 5px; border-left: 3px solid var(--c); background: var(--panel2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: left; } + .cday .chips small { color: var(--muted); font-size: 11px; text-align: left; padding-left: 6px; } +}