diff --git a/cal.js b/cal.js index 7fa26e8..f8f3672 100644 --- a/cal.js +++ b/cal.js @@ -231,6 +231,8 @@ const DEFAULT_COLOR = '#4f7cff', COLORS = [DEFAULT_COLOR, '#2f9e6e', '#e0742b', const FAM = 'family', FAMCAL = { id: 'local', name: 'Family', color: '#e0742b', shares: [] }; // the calendar the whole household shares const dirFor = id => { if (id !== FAM) return users.dirOf(id); const d = path.join(process.env.DATA_DIR || path.join(__dirname, 'data'), 'family'); fs.mkdirSync(d, { recursive: true }); return d; }; const myId = () => users.current().id; +/** The colour of whoever added an event (the family calendar shows each person's own colour). */ +const byCol = ev => ev && ev.by && users.get(ev.by) ? users.colorOf(users.get(ev.by)) : ''; /** A person's own NE Hub calendars. The first one is the original "NE Hub" calendar (id "local"), so older events stay in it. */ function calsIn(dir) { @@ -326,18 +328,31 @@ async function collectEvents(win) { const mine = dirFor(myId()), evs = eventsIn(mine); const add = (dir, evs, c, id, extra) => { const ics_ = ics.buildCalendar(evs.filter(e => evCal(e) === c.id).map(e => ({ uid: e.uid, fields: e.fields }))); - for (const e of expand(parseEvents(ics_), win)) all.push({ ...e, cal: c.name, calId: id, color: over[id] || c.color || DEFAULT_COLOR, by: (evs.find(x => x.uid === e.uid) || {}).byName || '', ...extra }); + for (const e of expand(parseEvents(ics_), win)) all.push({ ...e, cal: c.name, calId: id, color: (extra.family && byCol(evs.find(x => x.uid === e.uid))) || over[id] || c.color || DEFAULT_COLOR, by: (evs.find(x => x.uid === e.uid) || {}).byName || '', ...extra }); }; for (const c of calsIn(mine)) add(mine, evs, c, c.id, { editable: true }); - for (const sh of sharedWithMe()) add(dirFor(sh.owner), eventsIn(dirFor(sh.owner)), sh.cal, sh.id, { editable: sh.mode === 'edit', shared: true, owner: sh.ownerName }); + for (const sh of sharedWithMe()) add(dirFor(sh.owner), eventsIn(dirFor(sh.owner)), sh.cal, sh.id, { editable: sh.mode === 'edit', shared: true, owner: sh.ownerName, family: sh.owner === FAM }); } catch (e) { errors.push('NE Hub: ' + e.message); } try { all.push(...require('./contacts').birthdayEvents(win)); } catch {} // birthdays from Contacts return { events: all, errors }; } +/** An invitation inside the family: the event is put on each invited person's own calendar and they are told. (They can delete it if they cannot come.) */ +function invite(ids, uid, fields, ok) { + if (!ok || !Array.isArray(ids) || !ids.length) return 0; const me = users.current(); let n = 0; + for (const id of new Set(ids.map(String))) { + const who = users.get(id); if (!who || who.disabled || who.id === me.id || !users.can(who, 'calendar')) continue; + const dir = dirFor(who.id), all = eventsIn(dir), cal = calsIn(dir)[0].id; if (all.some(x => x.uid === uid && evCal(x) === cal)) continue; + all.push({ uid, cal, fields, created: Date.now(), updated: Date.now(), by: me.id, byName: me.name, invitedBy: me.name }); saveEvents(dir, all); n++; + try { require('./inbox').add(who.id, { kind: 'info', title: `${me.name} invited you: ${String(fields.title || '').slice(0, 60)}`, body: String(fields.start || '').replace('T', ' ').slice(0, 16), url: '#/calendar', tag: 'invite:' + uid }); } catch { /* the event is on their calendar anyway */ } + } + return n; +} + async function handle(req, res) { const u = new URL(req.url, 'http://x'); try { + if (u.pathname === '/api/cal/people') return json(res, 200, users.all().filter(x => !x.disabled && x.id !== myId() && users.can(x, 'calendar')).map(x => ({ id: x.id, name: x.name, color: users.colorOf(x) }))); if (u.pathname === '/api/cal/list') { const over = colorOverrides(); return json(res, 200, [ @@ -438,7 +453,8 @@ async function handle(req, res) { const rec = { uid, cal: r.cid, fields: b.fields, updated: Date.now() }; if (i >= 0) all[i] = { ...all[i], ...rec }; else all.push({ ...rec, created: Date.now(), by: users.current().id, byName: users.current().name }); // (who added it) saveEvents(r.dir, all); - return json(res, 200, { ok: true, uid }); + const invited = invite(b.invite, uid, b.fields, r.dir === dirFor(myId()) || r.dir === dirFor(FAM)); + return json(res, 200, { ok: true, uid, invited }); } if (caldav.isCal(b.cal)) { ics.validate(b.fields); @@ -480,6 +496,6 @@ async function handle(req, res) { /** The Family calendar's events in a window, for the family board (no signed-in person needed). */ function familyEvents(win) { const evs = eventsIn(dirFor(FAM)), ics_ = ics.buildCalendar(evs.map(e => ({ uid: e.uid, fields: e.fields }))); - return expand(parseEvents(ics_), win).map(e => ({ ...e, cal: 'Family', color: FAMCAL.color, by: (evs.find(x => x.uid === e.uid) || {}).byName || '' })); + return expand(parseEvents(ics_), win).map(e => ({ ...e, cal: 'Family', color: byCol(evs.find(x => x.uid === e.uid)) || FAMCAL.color, by: (evs.find(x => x.uid === e.uid) || {}).byName || '' })); } module.exports = { familyEvents, collectEvents, unfold, parseLine, parseDate, unesc, handle, feed, parseEvents, expand, zonedToUtc, assertPublic, localIcs }; diff --git a/public/calendar.js b/public/calendar.js index eb42954..ba77de7 100644 --- a/public/calendar.js +++ b/public/calendar.js @@ -126,7 +126,9 @@ function openEvent(e) { } if (e.contactId) { // a birthday from Contacts const el = openSheet(e.title, 'Birthdays', `
`); - el.querySelector('#sx').onclick = closeSheet; const a = el.querySelector('#bco'); if (a) a.onclick = () => { closeSheet(); location.hash = '#/contacts/c/' + e.contactId; }; + const inv = el.querySelector('#finv'), invOk = () => { const c = (o.cals || []).find(x => x.id === el.querySelector('#fc').value); return !!c && !c.icloud && !(c.shared && !String(c.id).startsWith('family~')); }; + if (inv) jfetch('/api/cal/people').then(ps => { if (!ps.length || !el.isConnected) return; inv.innerHTML = `The event is added to their calendar and they are told.
`; const sync = () => { inv.hidden = !invOk(); }; sync(); el.querySelector('#fc').addEventListener('change', sync); }).catch(() => {}); + el.querySelector('#sx').onclick = closeSheet; const a = el.querySelector('#bco'); if (a) a.onclick = () => { closeSheet(); location.hash = '#/contacts/c/' + e.contactId; }; return; } if (e.editable) return editEvent(e); @@ -169,6 +171,7 @@ function eventForm(o) { ${calPick} + ${editing ? '' : ''} ${editing && o.series ? 'This repeats. Changes apply to the whole series.
' : ''} ${editing ? '' : ''} @@ -200,9 +203,10 @@ function eventForm(o) { start: el.querySelector('#fs').value, end: el.querySelector('#fe2').value, tz: f.tz, repeat: el.querySelector('#fr').value }; const cal = editing ? o.calId : el.querySelector('#fc').value; try { - await jfetch('/api/cal/event', { method: 'POST', body: JSON.stringify({ cal, uid: o.uid, href: o.href, fields }) }); + const invite = editing || (inv && inv.hidden) ? [] : [...el.querySelectorAll('[data-inv]:checked')].map(c => c.dataset.inv); + const sent = await jfetch('/api/cal/event', { method: 'POST', body: JSON.stringify({ cal, uid: o.uid, href: o.href, fields, invite }) }); if (!editing) store.set('cal_default', cal); - closeSheet(); toast(editing ? 'Event saved' : 'Event added'); viewCalendar(); + closeSheet(); toast(editing ? 'Event saved' : sent && sent.invited ? `Event added, ${sent.invited} invited` : 'Event added'); viewCalendar(); } catch (err) { btn.disabled = false; el.querySelector('#fe').textContent = err.message; } }; const del = el.querySelector('#fd'); diff --git a/public/style.css b/public/style.css index 1803365..3fc6cb6 100644 --- a/public/style.css +++ b/public/style.css @@ -1101,3 +1101,4 @@ body:has(#sheet) #simplehome, body:has(#sheet) #kidreq, body:has(#lyr) #simpleho .lsrow { display: flex; gap: 16px; } .lsrow span { flex: 1; min-width: 0; } .lsrow b { display: block; font-size: 22px; letter-spacing: -.01em; } .lsrow small { color: var(--muted); } .lsbars { display: flex; align-items: flex-end; gap: 4px; height: 44px; margin: 14px 0 4px; } .lsbars i { flex: 1; background: var(--accent); border-radius: 3px; opacity: .85; } .lsgoal { height: 6px; background: var(--panel2); border-radius: 3px; overflow: hidden; margin-top: 8px; } .lsgoal i { display: block; height: 100%; background: var(--accent); } +.invlist { display: flex; flex-wrap: wrap; gap: 4px 16px; margin: 6px 0; } .invdot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin: 0 6px 0 2px; } diff --git a/public/sw.js b/public/sw.js index a705722..d6af495 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,5 +1,5 @@ // App-shell cache only; Seafile/API traffic always goes to the network. -const CACHE = 'hub-v104'; +const CACHE = 'hub-v105'; const SHARE = 'hub-share'; // files shared into the app from other apps, held until the person picks a folder const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png']; diff --git a/public/themes.js b/public/themes.js index e6d096b..6399202 100644 --- a/public/themes.js +++ b/public/themes.js @@ -1,6 +1,7 @@ 'use strict'; // ---- Themes: each person picks an accent colour and Auto, Light, Dark or AMOLED black (true black, easy on phone batteries). Kept in their synced settings, so it follows them between devices. const THEME_ACCENTS = [['blue', 'Blue', '#3d6bf0', '#6a8cff'], ['teal', 'Teal', '#0f8f8a', '#3cc7c0'], ['green', 'Green', '#2f9e6e', '#4cc98d'], ['orange', 'Orange', '#d9741a', '#f09a45'], ['red', 'Red', '#d4392f', '#ff6b60'], ['pink', 'Pink', '#c9407d', '#f26aa4'], ['purple', 'Purple', '#7b4fe0', '#a688ff']]; +const THEME_PERSON = ['#4f7cff', '#2f9e6e', '#e0742b', '#d9453a', '#8a5cf0', '#e5a00d', '#0f8f8a', '#c9407d']; const THEME_MODES = [['auto', 'Auto'], ['light', 'Light'], ['dark', 'Dark'], ['amoled', 'AMOLED']]; const THEME_VARS = { light: { '--bg': '#f5f6f8', '--panel': '#fff', '--panel2': '#eef0f4', '--text': '#14171c', '--muted': '#6a7280', '--line': '#e0e3e9' }, @@ -24,7 +25,9 @@ function themeSettings(el) { const mode = store.get('theme_mode', 'auto'), acc = store.get('theme_accent', 'blue'); el.innerHTML = `Saved for you and used on every device you sign in on.