Month view (default) with event dots on phones and event labels on tablet/desktop, tap a day to see its events, previous/next/today. The agenda list stays available. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
96fb6d807c
commit
e9397f1f21
2 files changed
+80
-13
No files matched your search
+58
-13
@@ -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 => `<div class="row"><span class="dot" style="background:${esc(e.color)}"></span><span class="grow"><b>${esc(e.title)}</b>
|
||||
<small>${e.allDay ? 'All day' : fmtTime(e.start) + ' – ' + fmtTime(e.end)}${e.location ? ' · ' + esc(e.location) : ''} · ${esc(e.cal)}</small></span></div>`;
|
||||
@@ -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 = `<div class="wrap"><h1>Calendar</h1><div class="seg">${[[1, 'Today'], [7, '7 days'], [30, '30 days']].map(([n, l]) => `<a href="#" data-n="${n}" class="${n === range ? 'on' : ''}">${l}</a>`).join('')}</div>
|
||||
<div id="ag"><p class="empty">Loading…</p></div><div class="bar"><button class="btn ghost" id="mc">Manage calendars</button></div></div>`;
|
||||
v.querySelectorAll('.seg a').forEach(a => a.onclick = e => { e.preventDefault(); store.set('cal_range', +a.dataset.n); viewCalendar(); });
|
||||
v.innerHTML = `<div class="wrap"><h1>Calendar</h1>
|
||||
<div class="seg">${[['month', 'Month'], ['agenda', 'Agenda']].map(([k, l]) => `<a href="#" data-v="${k}" class="${k === mode ? 'on' : ''}">${l}</a>`).join('')}</div>
|
||||
<div id="cbody"><p class="empty">Loading…</p></div><div class="bar"><button class="btn ghost" id="mc">Manage calendars</button></div></div>`;
|
||||
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 = '<p class="empty">No calendars yet. Add a private calendar link to see your events here.</p><div class="bar" style="justify-content:center"><button class="btn" id="ac">Add a calendar</button></div>'; $('#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 => `<h2>${esc(dayLabel(k))}</h2><div class="list">${byDay.get(k).map(evRow).join('')}</div>`).join('') || '<p class="empty">Nothing coming up</p>')
|
||||
+ errors.map(e => `<p class="err">${esc(e)}</p>`).join('');
|
||||
} catch (e) { $('#ag').innerHTML = `<p class="err">${esc(e.message)}</p>`; }
|
||||
if (!cals.length) { $('#cbody').innerHTML = '<p class="empty">No calendars yet. Add a private calendar link to see your events here.</p><div class="bar" style="justify-content:center"><button class="btn" id="ac">Add a calendar</button></div>'; $('#ac').onclick = addCalendar; return; }
|
||||
if (mode === 'month') await renderMonth(); else await renderAgendaList();
|
||||
} catch (e) { const b = $('#cbody'); if (b) b.innerHTML = `<p class="err">${esc(e.message)}</p>`; }
|
||||
}
|
||||
|
||||
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 = `<div class="seg">${[[1, 'Today'], [7, '7 days'], [30, '30 days']].map(([n, l]) => `<a href="#" data-n="${n}" class="${n === range ? 'on' : ''}">${l}</a>`).join('')}</div>`
|
||||
+ (keys.map(k => `<h2>${esc(dayLabel(k))}</h2><div class="list">${byDay.get(k).map(evRow).join('')}</div>`).join('') || '<p class="empty">Nothing coming up</p>')
|
||||
+ errors.map(e => `<p class="err">${esc(e)}</p>`).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(`<button class="cday${other ? ' dim' : ''}${k === today ? ' today' : ''}${k === calState.sel ? ' sel' : ''}" data-d="${k}" aria-label="${esc(d.toDateString())}, ${evs.length} events">
|
||||
<span class="dn">${d.getDate()}</span>
|
||||
<span class="dots">${evs.slice(0, 4).map(e => `<i style="background:${esc(e.color)}"></i>`).join('')}</span>
|
||||
<span class="chips">${evs.slice(0, 3).map(e => `<em style="--c:${esc(e.color)}">${esc(e.title)}</em>`).join('')}${evs.length > 3 ? `<small>+${evs.length - 3} more</small>` : ''}</span></button>`);
|
||||
}
|
||||
$('#cbody').innerHTML = `<div class="mhdr"><button class="ibtn" id="pm" aria-label="Previous month">${icon('back')}</button>
|
||||
<h2 style="margin:0;flex:1;text-align:center;font-size:17px;text-transform:none;letter-spacing:0;color:var(--text)">${first.toLocaleDateString([], { month: 'long', year: 'numeric' })}</h2>
|
||||
<button class="ibtn" id="nm" aria-label="Next month" style="transform:scaleX(-1)">${icon('back')}</button><button class="btn ghost" id="tm" style="min-height:36px">Today</button></div>
|
||||
<div class="cgrid wk">${['S', 'M', 'T', 'W', 'T', 'F', 'S'].map(d => `<span>${d}</span>`).join('')}</div>
|
||||
<div class="cgrid">${cells.join('')}</div>
|
||||
<div id="dayev"></div>${errors.map(e => `<p class="err">${esc(e)}</p>`).join('')}`;
|
||||
const showDay = () => {
|
||||
const k = calState.sel, evs = byDay.get(k) || [];
|
||||
$('#dayev').innerHTML = `<h2>${esc(dayLabel(k))}</h2><div class="list">${evs.length ? evs.map(evRow).join('') : '<div class="row"><small>No events</small></div>'}</div>`;
|
||||
$('#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() {
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
Reference in new issue
Block a user