diff --git a/public/app.js b/public/app.js index f1b1d72..62ffe71 100644 --- a/public/app.js +++ b/public/app.js @@ -448,16 +448,16 @@ async function viewCalendar() { const mode = store.get('cal_view', 'month'); const v = $('#view'); v.innerHTML = `

Calendar

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

Loading…

`; - $('#ne').onclick = () => newEvent(mode === 'month' ? calState.sel : ymd(new Date())); + $('#ne').onclick = () => newEvent(mode === 'agenda' ? ymd(new Date()) : calState.sel); 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) { $('#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(); + if (mode === 'month') await renderMonth(); else if (mode === 'week') await renderTimeGrid(7); else if (mode === 'day') await renderTimeGrid(1); else await renderAgendaList(); } catch (e) { const b = $('#cbody'); if (b) b.innerHTML = `

${esc(e.message)}

`; } } diff --git a/public/calendar-views.js b/public/calendar-views.js new file mode 100644 index 0000000..3689cbb --- /dev/null +++ b/public/calendar-views.js @@ -0,0 +1,50 @@ +// Calendar week and day views: a time grid with all-day strip, overlapping events side by side and a "now" line. +// Uses the calendar globals from app.js (calState, loadRange, evStore, ymd, fmtTime, esc, icon, newEvent, viewCalendar). +const HOUR_PX = 48; + +/** Lay out one day's timed events in columns so overlapping ones sit side by side. */ +function layoutDay(events) { + const evs = events.map(e => ({ e, s: e.sm, en: Math.max(e.em, e.sm + 20) })).sort((a, b) => a.s - b.s || b.en - a.en); + const out = []; let cluster = [], clusterEnd = -1; + const flush = () => { const cols = []; for (const it of cluster) { let c = cols.findIndex(end => end <= it.s); if (c < 0) { c = cols.length; cols.push(0); } cols[c] = it.en; it.col = c; } cluster.forEach(it => { it.cols = cols.length; out.push(it); }); cluster = []; }; + for (const it of evs) { if (cluster.length && it.s >= clusterEnd) { flush(); clusterEnd = -1; } cluster.push(it); clusterEnd = Math.max(clusterEnd, it.en); } + if (cluster.length) flush(); + return out; +} + +async function renderTimeGrid(nDays) { + const sel = new Date(calState.sel + 'T00:00:00'), start = new Date(sel); + if (nDays === 7) start.setDate(start.getDate() - start.getDay()); + const { byDay, errors } = await loadRange(start, nDays); + if (!location.hash.startsWith('#/calendar') || !$('#cbody')) return; + evStore.length = 0; + const today = ymd(new Date()), days = [...Array(nDays)].map((_, i) => { const d = new Date(start); d.setDate(d.getDate() + i); return d; }); + const title = nDays === 1 ? sel.toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' }) + : days[0].toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' – ' + days[6].toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' }); + const allDay = days.map(d => (byDay.get(ymd(d)) || []).filter(e => e.allDay)); + const cols = days.map(d => { + const k = ymd(d), t0 = new Date(k + 'T00:00:00').getTime(), t1 = t0 + 864e5; + const timed = (byDay.get(k) || []).filter(e => !e.allDay).map(e => ({ ...e, sm: (Math.max(e.start, t0) - t0) / 6e4, em: (Math.min(e.end, t1) - t0) / 6e4, orig: e })); + return layoutDay(timed).map(({ e, s, en, col, cols: n }) => { + const i = evStore.push(e.orig) - 1, h = Math.max(18, (en - s) / 60 * HOUR_PX - 2); + return ``; + }).join(''); + }); + const hours = [...Array(24)].map((_, h) => `
${h === 0 ? '' : new Date(2000, 0, 1, h).toLocaleTimeString([], { hour: 'numeric' })}
`).join(''); + const now = new Date(), nowTop = (now.getHours() * 60 + now.getMinutes()) / 60 * HOUR_PX; + $('#cbody').innerHTML = `
+

${esc(title)}

+
+
+
${days.map(d => ``).join('')}
+ ${allDay.some(a => a.length) ? `
all day${allDay.map(a => `
${a.map(e => { const i = evStore.push(e) - 1; return ``; }).join('')}
`).join('')}
` : ''}
+
${hours}
${days.map((d, i) => `
${cols[i]}${ymd(d) === today ? `` : ''}
`).join('')}
+
${errors.map(e => `

${esc(e)}

`).join('')}`; + const body = $('#tgbody'); if (body) { const wrap = body.parentElement.parentElement; wrap.scrollTop = Math.max(0, ((days.some(d => ymd(d) === today) ? now.getHours() - 1 : 7) * HOUR_PX)); } + const shift = n => { const d = new Date(sel); d.setDate(d.getDate() + n * (nDays === 1 ? 1 : 7)); calState.sel = ymd(d); calState.y = d.getFullYear(); calState.m = d.getMonth(); renderTimeGrid(nDays); }; + $('#tp').onclick = () => shift(-1); $('#tn').onclick = () => shift(1); + $('#tt').onclick = () => { const d = new Date(); calState.sel = ymd(d); calState.y = d.getFullYear(); calState.m = d.getMonth(); renderTimeGrid(nDays); }; + $('#cbody').querySelectorAll('.tgd').forEach(b => b.onclick = () => { if (nDays === 7) { calState.sel = b.dataset.day; store.set('cal_view', 'day'); viewCalendar(); } }); + $('#cbody').querySelectorAll('.tgcol').forEach(c => c.onclick = e => { if (e.target === c) newEvent(c.dataset.day); }); +} diff --git a/public/index.html b/public/index.html index 68dca8d..09d2ea2 100644 --- a/public/index.html +++ b/public/index.html @@ -55,6 +55,7 @@ + diff --git a/public/style.css b/public/style.css index dd1a54f..963d479 100644 --- a/public/style.css +++ b/public/style.css @@ -504,3 +504,25 @@ body.lyropen { overflow: hidden; } .row.subtask { padding-left: 34px; } .row.subtask .tick { transform: scale(.88); } + +/* Calendar week and day views */ +.tgwrap { overflow: auto; max-height: 68vh; border: 1px solid var(--line); border-radius: 14px; background: var(--panel); } +.tg { position: relative; } +.tgg { width: 44px; flex: none; } +.tghead, .tgad, .tgbody { display: flex; } +.tgtop { position: sticky; top: 0; z-index: 3; background: var(--panel); border-bottom: 1px solid var(--line); } +.tgd { flex: 1; border: 0; background: none; padding: 6px 0; display: flex; flex-direction: column; align-items: center; gap: 0; color: var(--muted); min-width: 0; } +.tgd b { font-size: 17px; color: var(--text); width: 30px; height: 30px; line-height: 30px; border-radius: 50%; } +.tgd.today b { background: var(--accent); color: var(--accent-t); } +.tgad { border-bottom: 1px solid var(--line); } +.tgad .tgg { font-size: 10px; color: var(--muted); padding: 6px 4px 0; } +.tgad > div { flex: 1; min-width: 0; padding: 3px 2px; display: flex; flex-direction: column; gap: 2px; border-left: 1px solid var(--line); } +.tgchip { border: 0; text-align: left; font-size: 12px; padding: 2px 6px; border-radius: 6px; background: color-mix(in srgb, var(--c) 28%, transparent); color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.tgh { position: relative; } +.tgh span { position: absolute; top: -8px; right: 6px; font-size: 10px; color: var(--muted); } +.tgcol { flex: 1; position: relative; min-width: 0; border-left: 1px solid var(--line); cursor: cell; background-image: linear-gradient(var(--line) 1px, transparent 1px); background-size: 100% 48px; } +.tgcol.today { background-color: color-mix(in srgb, var(--accent) 5%, transparent); } +.tgev { position: absolute; border: 0; border-left: 3px solid var(--c); border-radius: 6px; padding: 2px 5px; text-align: left; overflow: hidden; font-size: 12px; line-height: 1.25; background: color-mix(in srgb, var(--c) 24%, var(--panel)); color: var(--text); cursor: pointer; } +.tgev b { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.tgev small { color: var(--muted); font-size: 11px; } +.tgnow { position: absolute; left: 0; right: 0; height: 2px; background: var(--danger); z-index: 2; pointer-events: none; } diff --git a/server.js b/server.js index 5362f88..c4f8dd5 100644 --- a/server.js +++ b/server.js @@ -99,7 +99,7 @@ function serveStatic(req, res) { if (err) { res.writeHead(404); return res.end('Not found'); } // The pages link to /app.js?v= etc. A new release changes the address, so no proxy cache ("Cache Assets" in // Nginx Proxy Manager, a CDN, the browser) can hand out an old copy. - if (p === '/index.html' || p === '/login.html') data = Buffer.from(String(data).replace(/(src|href)="(\/(?:app|webauthn|login|links|palette|shell)\.js|\/style\.css)"/g, `$1="$2?v=${BUILD}"`)); + if (p === '/index.html' || p === '/login.html') data = Buffer.from(String(data).replace(/(src|href)="(\/(?:app|webauthn|login|links|palette|shell|calendar-views)\.js|\/style\.css)"/g, `$1="$2?v=${BUILD}"`)); const headers = { 'content-type': TYPES[path.extname(file)] || 'application/octet-stream' }; if (p.startsWith('/icons/')) headers['cache-control'] = 'public, max-age=604800'; // icons and splash screens rarely change else { // everything else: the browser may keep a copy but must check with us first, so a new version is never missed @@ -113,7 +113,7 @@ function serveStatic(req, res) { } // Changes whenever the app's files change, so an open page can tell that a newer version is available. -const BUILD = (() => { try { return crypto.createHash('sha1').update(['app.js', 'links.js', 'palette.js', 'shell.js', 'index.html', 'style.css', 'webauthn.js', 'login.js', 'login.html', 'sw.js'].map(f => fs.readFileSync(path.join(PUBLIC, f))).join('')).digest('hex').slice(0, 12); } catch { return String(Date.now()); } })(); +const BUILD = (() => { try { return crypto.createHash('sha1').update(['app.js', 'links.js', 'palette.js', 'shell.js', 'calendar-views.js', 'index.html', 'style.css', 'webauthn.js', 'login.js', 'login.html', 'sw.js'].map(f => fs.readFileSync(path.join(PUBLIC, f))).join('')).digest('hex').slice(0, 12); } catch { return String(Date.now()); } })(); // ---- OnlyOffice in-app editing (JWT-signed editor config + save-back callback to Seafile) const JWT_SECRET = process.env.ONLYOFFICE_JWT_SECRET || '';