Calendar: add events (NE Hub calendar + iCloud) and a Podcast tab
build-container / build (push) Successful in 1m55s
build-container / build (push) Successful in 1m55s
- Add, edit and delete events; month/agenda views open them on tap - NE Hub calendar stored on the server and published as a private ICS subscribe link - iCloud CalDAV read/write with an app-specific password (encrypted at rest, only *.icloud.com contacted, event addresses checked against the calendar) - Edits patch the existing entry so alarms/attendees survive; writes use If-Match / If-None-Match - Time zone definitions (VTIMEZONE) generated so other apps read times correctly - Podcast tab (PODCAST_URL) alongside Podcast Admin Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7beb06678c
commit
5466506ffb
11 files changed
+550
-22
No files matched your search
+146
-15
@@ -39,6 +39,7 @@ const ICONS = {
|
||||
cal: '<rect x="3" y="5" width="18" height="16" rx="2"/><path d="M3 10h18M8 3v4M16 3v4"/>',
|
||||
search: '<circle cx="11" cy="11" r="7"/><path d="M20 20l-4-4"/>',
|
||||
book: '<path d="M4 5a2 2 0 012-2h13v16H6a2 2 0 00-2 2z"/><path d="M19 19v2H6a2 2 0 01-2-2"/><path d="M9 7h6"/>',
|
||||
mic: '<rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5 11a7 7 0 0014 0M12 18v3M9 21h6"/>',
|
||||
trash: '<path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/>',
|
||||
};
|
||||
const icon = (n, cls = '') => `<svg class="${cls}" viewBox="0 0 24 24">${ICONS[n]}</svg>`;
|
||||
@@ -62,7 +63,7 @@ function mailUrl(m, kind) {
|
||||
return base; // iCloud has no sign-in hint
|
||||
}
|
||||
|
||||
let cfg = { verse: 'https://versebyversewithnate.us/admin', audiobooks: 'https://audiobooks.necloud.us', seafile: 'https://seafile.necloud.us', onlyoffice: 'https://onlyoffice.necloud.us' };
|
||||
let cfg = { podcast: 'http://172.16.16.106:8383/', verse: 'https://versebyversewithnate.us/admin', audiobooks: 'https://audiobooks.necloud.us', seafile: 'https://seafile.necloud.us', onlyoffice: 'https://onlyoffice.necloud.us' };
|
||||
|
||||
// ---- Seafile client (via same-origin proxy)
|
||||
const sf = {
|
||||
@@ -96,8 +97,8 @@ const fmtSize = b => b < 1024 ? b + ' B' : b < 1048576 ? (b / 1024).toFixed(0) +
|
||||
const fmtDate = s => new Date(s * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
|
||||
// ---- Navigation
|
||||
const TABS = [['home', 'Home'], ['files', 'Files'], ['music', 'Music'], ['mail', 'Mail'], ['calendar', 'Calendar'], ['search', 'Search'], ['office', 'Office'], ['audio', 'Audio'], ['verse', 'Podcast Admin'], ['settings', 'Settings']];
|
||||
const TAB_ICON = { home: 'home', mail: 'mail', files: 'files', office: 'doc', audio: 'audio', verse: 'book', music: 'music', settings: 'cog', calendar: 'cal', search: 'search' };
|
||||
const TABS = [['home', 'Home'], ['files', 'Files'], ['music', 'Music'], ['mail', 'Mail'], ['calendar', 'Calendar'], ['search', 'Search'], ['office', 'Office'], ['audio', 'Audio'], ['podcast', 'Podcast'], ['verse', 'Podcast Admin'], ['settings', 'Settings']];
|
||||
const TAB_ICON = { home: 'home', mail: 'mail', files: 'files', office: 'doc', audio: 'audio', verse: 'book', podcast: 'mic', music: 'music', settings: 'cog', calendar: 'cal', search: 'search' };
|
||||
const PRIMARY = ['home', 'files', 'music', 'mail']; // phone tab bar; the rest sit behind "More"
|
||||
const phone = matchMedia('(max-width: 767px)');
|
||||
let activeTab = 'home';
|
||||
@@ -121,7 +122,7 @@ function openMore() {
|
||||
el.querySelectorAll('a.row').forEach(a => a.addEventListener('click', closeSheet));
|
||||
}
|
||||
|
||||
const routes = { home: viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: viewAudio, verse: viewVerse, music: viewMusic, search: viewSearch, calendar: viewCalendar, settings: viewSettings, edit: viewEdit };
|
||||
const routes = { home: viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: viewAudio, verse: viewVerse, podcast: viewPodcast, music: viewMusic, search: viewSearch, calendar: viewCalendar, settings: viewSettings, edit: viewEdit };
|
||||
let editor;
|
||||
function route() {
|
||||
const [, id = 'home', ...rest] = location.hash.split('/');
|
||||
@@ -158,6 +159,7 @@ function viewHome() {
|
||||
${tile('#/calendar', '#d9453a', 'cal', 'Calendar', 'Agenda')}
|
||||
${tile('#/office', '#e0742b', 'doc', 'Office', 'OnlyOffice')}
|
||||
${tile('#/audio', '#8a5cf0', 'audio', 'Audio', 'Audiobooks')}
|
||||
${tile(cfg.podcast, '#c2417a', 'mic', 'Podcast', 'Home network', true)}
|
||||
${tile(cfg.verse, '#0f9d9a', 'book', 'Podcast Admin', 'Opens in a new tab', true)}
|
||||
${tile('#/mail', '#4f7cff', 'mail', 'Mail', '4 accounts')}
|
||||
</div>
|
||||
@@ -254,17 +256,21 @@ async function loadRange(start, days) {
|
||||
}
|
||||
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>`;
|
||||
const evStore = []; // events currently on screen, so a tap can open the right one
|
||||
const evRow = e => { const i = evStore.push(e) - 1;
|
||||
return `<button class="row" data-ei="${i}" style="text-align:left"><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)}${e.series ? ' · repeats' : ''}</small></span>${e.editable ? icon('chev', 'chev') : ''}</button>`; };
|
||||
document.addEventListener('click', e => { const b = e.target.closest('[data-ei]'); if (b) openEvent(evStore[+b.dataset.ei]); });
|
||||
const dayLabel = k => { const d = new Date(k + 'T00:00:00'), t = ymd(new Date()); const tm = new Date(); tm.setDate(tm.getDate() + 1);
|
||||
return (k === t ? 'Today · ' : k === ymd(tm) ? 'Tomorrow · ' : '') + d.toLocaleDateString([], { weekday: 'long', month: 'short', day: 'numeric' }); };
|
||||
|
||||
async function renderAgenda(el, mode) { // Home card (today only)
|
||||
if (!el) return;
|
||||
try {
|
||||
const cals = await jfetch('/api/cal/list'); if (!cals.length) return;
|
||||
const cals = await jfetch('/api/cal/list');
|
||||
const { byDay } = await loadAgenda(1);
|
||||
const list = byDay.get(ymd(new Date())) || [];
|
||||
if (cals.length < 2 && !list.length) return; // only the empty NE Hub calendar: say nothing
|
||||
if (!document.body.contains(el)) return;
|
||||
el.innerHTML = `<h2>Today</h2><div class="list">${list.length ? list.slice(0, 6).map(evRow).join('') : '<div class="row"><small>Nothing on the calendar today</small></div>'}</div>`;
|
||||
} catch {}
|
||||
@@ -278,7 +284,9 @@ async function viewCalendar() {
|
||||
const v = $('#view');
|
||||
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 class="bar" style="margin:0 0 8px"><button class="btn" id="ne">${icon('cal')}Add event</button></div>
|
||||
<div id="cbody"><p class="empty">Loading…</p></div><div class="bar"><button class="btn ghost" id="mc">Manage calendars</button></div></div>`;
|
||||
$('#ne').onclick = () => newEvent(mode === 'month' ? calState.sel : ymd(new Date()));
|
||||
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 {
|
||||
@@ -333,10 +341,98 @@ async function renderMonth() {
|
||||
$('#tm').onclick = () => { const t = new Date(); calState.y = t.getFullYear(); calState.m = t.getMonth(); calState.sel = ymd(t); renderMonth(); };
|
||||
}
|
||||
|
||||
const deviceTz = () => Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
||||
const pad2 = n => String(n).padStart(2, '0');
|
||||
const localStr = d => `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}T${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
||||
|
||||
function openEvent(e) {
|
||||
if (!e) return;
|
||||
if (e.editable) return editEvent(e);
|
||||
const when = e.allDay ? 'All day' : new Date(e.start).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }) + ' – ' + fmtTime(e.end);
|
||||
const el = openSheet(e.title, e.cal, `<div class="list" style="background:var(--panel2);border:0"><div class="row"><span class="grow"><b>When</b><small style="white-space:normal">${esc(when)}</small></span></div>
|
||||
${e.location ? `<div class="row"><span class="grow"><b>Where</b><small style="white-space:normal">${esc(e.location)}</small></span></div>` : ''}</div>
|
||||
<p class="sub">This calendar is read-only here (it is a subscribed link). Change it in the app it comes from.</p><div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Close</button></div>`);
|
||||
el.querySelector('#sx').onclick = closeSheet;
|
||||
}
|
||||
|
||||
async function editEvent(e) {
|
||||
try {
|
||||
const q = new URLSearchParams({ cal: e.calId, tz: deviceTz() }); if (e.uid) q.set('uid', e.uid); if (e.href) q.set('href', e.href);
|
||||
const r = await jfetch('/api/cal/event?' + q);
|
||||
eventForm({ mode: 'edit', calId: e.calId, calName: e.cal, uid: e.uid, href: e.href || r.href, etag: r.etag || e.etag, fields: r.fields, series: e.series });
|
||||
} catch (err) { toast(err.message); }
|
||||
}
|
||||
|
||||
async function newEvent(day) {
|
||||
try {
|
||||
const cals = (await jfetch('/api/cal/list')).filter(c => c.local || c.icloud);
|
||||
const d = new Date(day + 'T00:00:00'), now = new Date();
|
||||
if (ymd(d) === ymd(now)) d.setHours(now.getHours() + 1, 0, 0, 0); else d.setHours(9, 0, 0, 0);
|
||||
const end = new Date(d.getTime() + 36e5);
|
||||
eventForm({ mode: 'new', cals, calId: store.get('cal_default', 'local'), fields: { title: '', location: '', notes: '', allDay: false, start: localStr(d), end: localStr(end), tz: deviceTz(), repeat: 'none' } });
|
||||
} catch (err) { toast(err.message); }
|
||||
}
|
||||
|
||||
function eventForm(o) {
|
||||
const f = { ...o.fields }, editing = o.mode === 'edit';
|
||||
const opts = [['none', 'Does not repeat'], ['daily', 'Every day'], ['weekdays', 'Every weekday'], ['weekly', 'Every week'], ['monthly', 'Every month'], ['yearly', 'Every year']];
|
||||
if (f.repeat === 'keep') opts.push(['keep', 'Custom repeat (unchanged)']);
|
||||
const calPick = editing ? `<div class="row" style="background:var(--panel2);border-radius:12px"><span class="grow"><small>Calendar</small><b>${esc(o.calName)}</b></span></div>`
|
||||
: `<label for="fc">Calendar</label><select id="fc">${o.cals.map(c => `<option value="${esc(c.id)}" ${c.id === o.calId ? 'selected' : ''}>${esc(c.name)}${c.icloud ? ' (iCloud)' : ''}</option>`).join('')}</select>`;
|
||||
const el = openSheet(editing ? 'Edit event' : 'New event', '', `<form id="ef"><input type="text" id="ft" placeholder="Title" value="${esc(f.title)}" autocomplete="off" required style="margin-top:6px">
|
||||
<label class="chk"><input type="checkbox" id="fa" ${f.allDay ? 'checked' : ''}> All day</label>
|
||||
<div id="fdt"></div>
|
||||
<label for="fr">Repeat</label><select id="fr">${opts.map(([v, l]) => `<option value="${v}" ${v === f.repeat ? 'selected' : ''}>${l}</option>`).join('')}</select>
|
||||
<label for="fl">Location</label><input type="text" id="fl" value="${esc(f.location)}" autocomplete="off">
|
||||
<label for="fn">Notes</label><textarea id="fn" rows="3">${esc(f.notes)}</textarea>
|
||||
${calPick}
|
||||
${editing && o.series ? '<p class="sub" style="margin:8px 0 0">This repeats. Changes apply to the whole series.</p>' : ''}
|
||||
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">${editing ? 'Save' : 'Add'}</button></div>
|
||||
${editing ? '<div class="bar" style="margin-top:0"><button class="btn danger" type="button" id="fd" style="flex:1;justify-content:center">Delete event</button></div>' : ''}
|
||||
<div class="err" id="fe"></div></form>`);
|
||||
el.querySelector('.sheet').classList.add('wide');
|
||||
const draw = () => { // date-only inputs for all-day, date+time otherwise
|
||||
const t = f.allDay ? 'date' : 'datetime-local', cut = v => f.allDay ? v.slice(0, 10) : v;
|
||||
el.querySelector('#fdt').innerHTML = `<label for="fs">Starts</label><input type="${t}" id="fs" value="${cut(f.start)}" required><label for="fe2">Ends</label><input type="${t}" id="fe2" value="${cut(f.end)}" required>
|
||||
${!f.allDay && f.tz !== deviceTz() ? `<p class="sub" style="margin:6px 0 0">Times are in ${esc(f.tz)}.</p>` : ''}`;
|
||||
const s = el.querySelector('#fs'), en = el.querySelector('#fe2');
|
||||
s.onchange = () => { // moving the start keeps the length
|
||||
const len = Date.parse(f.allDay ? f.end.slice(0, 10) + 'T00:00' : f.end) - Date.parse(f.allDay ? f.start.slice(0, 10) + 'T00:00' : f.start);
|
||||
f.start = s.value; if (s.value) { const ns = Date.parse(f.allDay ? s.value + 'T00:00' : s.value); if (!isNaN(ns) && len >= 0) { const d = new Date(ns + len); f.end = f.allDay ? ymd(d) : localStr(d); en.value = f.allDay ? ymd(d) : localStr(d); } }
|
||||
};
|
||||
en.onchange = () => { f.end = en.value; };
|
||||
};
|
||||
draw();
|
||||
el.querySelector('#fa').onchange = ev => {
|
||||
f.start = el.querySelector('#fs').value || f.start; f.end = el.querySelector('#fe2').value || f.end;
|
||||
f.allDay = ev.target.checked;
|
||||
if (f.allDay) { f.start = f.start.slice(0, 10); f.end = f.end.slice(0, 10); }
|
||||
else { f.start = f.start.slice(0, 10) + 'T09:00'; f.end = f.end.slice(0, 10) + 'T10:00'; }
|
||||
draw();
|
||||
};
|
||||
el.querySelector('#sx').onclick = closeSheet;
|
||||
el.querySelector('#ef').onsubmit = async ev => {
|
||||
ev.preventDefault(); const btn = el.querySelector('form .btn:not(.ghost):not(.danger)'); btn.disabled = true; el.querySelector('#fe').textContent = '';
|
||||
const fields = { title: el.querySelector('#ft').value.trim(), location: el.querySelector('#fl').value.trim(), notes: el.querySelector('#fn').value.trim(), allDay: f.allDay,
|
||||
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 }) });
|
||||
if (!editing) store.set('cal_default', cal);
|
||||
closeSheet(); toast(editing ? 'Event saved' : 'Event added'); viewCalendar();
|
||||
} catch (err) { btn.disabled = false; el.querySelector('#fe').textContent = err.message; }
|
||||
};
|
||||
const del = el.querySelector('#fd');
|
||||
if (del) del.onclick = () => confirmSheet('Delete event?', o.series ? f.title + ' and every repeat of it' : f.title, 'Delete', async () => {
|
||||
try { await jfetch('/api/cal/event/delete', { method: 'POST', body: JSON.stringify({ cal: o.calId, uid: o.uid, href: o.href, etag: o.etag }) }); toast('Event deleted'); viewCalendar(); }
|
||||
catch (err) { toast(err.message); }
|
||||
});
|
||||
}
|
||||
|
||||
function addCalendar() {
|
||||
const el = openSheet('Add a calendar', '', `<form><label>Name</label><input type="text" id="cn" placeholder="Personal" autocomplete="off">
|
||||
const el = openSheet('Add a calendar link', '', `<form><label>Name</label><input type="text" id="cn" placeholder="Personal" autocomplete="off">
|
||||
<label>Private calendar link (ICS)</label><input type="url" id="cu2" placeholder="https://… or webcal://…" autocomplete="off" autocapitalize="off" required>
|
||||
<p class="sub" style="margin:10px 0 0">Google: Settings → your calendar → "Secret address in iCal format". Outlook.com / M365: Settings → Calendar → Shared calendars → Publish → copy the ICS link. iCloud: calendar's Share → Public Calendar → copy link. Anyone with the link can read that calendar, so keep it private.</p>
|
||||
<p class="sub" style="margin:10px 0 0">Google: Settings → your calendar → "Secret address in iCal format". Outlook.com / M365: Settings → Calendar → Shared calendars → Publish → copy the ICS link. These are read-only: you see the events here but change them in the original app. Anyone with the link can read that calendar, so keep it private.</p>
|
||||
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Add</button></div><div class="err" id="ce"></div></form>`);
|
||||
el.querySelector('#sx').onclick = closeSheet;
|
||||
el.querySelector('form').onsubmit = async e => {
|
||||
@@ -345,13 +441,39 @@ function addCalendar() {
|
||||
catch (err) { btn.disabled = false; el.querySelector('#ce').textContent = err.message; }
|
||||
};
|
||||
}
|
||||
|
||||
async function manageCalendars() {
|
||||
const cals = await jfetch('/api/cal/list');
|
||||
const el = openSheet('Calendars', '', `<div class="list" style="background:var(--panel2);border:0">${cals.map(c => `<div class="row"><span class="dot" style="background:${esc(c.color)}"></span><span class="grow"><b>${esc(c.name)}</b></span>
|
||||
<button class="btn danger" data-id="${esc(c.id)}" style="min-height:36px">Remove</button></div>`).join('') || '<div class="row"><small>No calendars</small></div>'}</div>
|
||||
<div class="bar"><button class="btn" id="ac" style="flex:1;justify-content:center">Add a calendar</button><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Close</button></div>`);
|
||||
el.querySelector('#sx').onclick = closeSheet; el.querySelector('#ac').onclick = addCalendar;
|
||||
el.querySelectorAll('button[data-id]').forEach(b => b.onclick = async () => { await jfetch('/api/cal/remove', { method: 'POST', body: JSON.stringify({ id: b.dataset.id }) }); closeSheet(); viewCalendar(); });
|
||||
const [cals, ic, feed] = await Promise.all([jfetch('/api/cal/list'), jfetch('/api/cal/icloud'), jfetch('/api/cal/feed')]);
|
||||
const links = cals.filter(c => !c.local && !c.icloud);
|
||||
const el = openSheet('Calendars', '', `<div class="list" style="background:var(--panel2);border:0">
|
||||
<div class="row"><span class="dot" style="background:#4f7cff"></span><span class="grow"><b>NE Hub</b><small>Events you add here</small></span></div>
|
||||
${links.map(c => `<div class="row"><span class="dot" style="background:${esc(c.color)}"></span><span class="grow"><b>${esc(c.name)}</b><small>Link (read-only)</small></span><button class="btn danger" data-rm="${esc(c.id)}" style="min-height:36px">Remove</button></div>`).join('')}
|
||||
</div>
|
||||
<div class="bar"><button class="btn ghost" id="al" style="flex:1;justify-content:center">Add a calendar link</button></div>
|
||||
<h2 style="margin-top:14px">iCloud calendars</h2>
|
||||
${ic.connected ? `<div class="list" style="background:var(--panel2);border:0">${ic.calendars.map(c => `<label class="row" style="cursor:pointer"><span class="dot" style="background:${esc(c.color)}"></span><span class="grow"><b>${esc(c.name)}</b></span><input type="checkbox" data-ic="${esc(c.id)}" ${c.enabled ? 'checked' : ''}></label>`).join('')}</div>
|
||||
<p class="sub">Signed in as ${esc(ic.appleId)}. Events you add or change here are saved to iCloud.</p><div class="bar"><button class="btn danger" id="icx" style="flex:1;justify-content:center">Disconnect iCloud</button></div>`
|
||||
: `<p class="sub">Read and add events in your iCloud calendars. Use an <b>app-specific password</b> (appleid.apple.com → Sign-In and Security → App-Specific Passwords), not your normal Apple password.</p>
|
||||
<form id="icf"><input type="text" id="iu" placeholder="Apple ID (email)" autocomplete="off" autocapitalize="off" required><input type="password" id="ip" placeholder="App-specific password" autocomplete="off" style="margin-top:8px" required>
|
||||
<div class="bar"><button class="btn" style="flex:1;justify-content:center">Connect iCloud</button></div><div class="err" id="ice"></div></form>`}
|
||||
<h2 style="margin-top:14px">See NE Hub events in other apps</h2>
|
||||
<p class="sub">Subscribe to this private link from Outlook, Google or Apple Calendar. They show your NE Hub events (read-only there).</p>
|
||||
<input type="text" readonly id="fu" value="${esc(feed.url)}" onfocus="this.select()">
|
||||
<div class="bar"><button class="btn ghost" id="fc2" style="flex:1;justify-content:center">${icon('copy')}Copy link</button><button class="btn ghost" id="fr2" style="flex:1;justify-content:center">Make a new link</button></div>
|
||||
<div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Close</button></div>`);
|
||||
el.querySelector('.sheet').classList.add('wide');
|
||||
el.querySelector('#sx').onclick = closeSheet; el.querySelector('#al').onclick = addCalendar;
|
||||
el.querySelectorAll('[data-rm]').forEach(b => b.onclick = async () => { await jfetch('/api/cal/remove', { method: 'POST', body: JSON.stringify({ id: b.dataset.rm }) }); closeSheet(); viewCalendar(); });
|
||||
el.querySelectorAll('[data-ic]').forEach(c => c.onchange = async () => { try { await jfetch('/api/cal/icloud/toggle', { method: 'POST', body: JSON.stringify({ id: c.dataset.ic, enabled: c.checked }) }); } catch (e) { toast(e.message); } });
|
||||
const icx = el.querySelector('#icx'); if (icx) icx.onclick = async () => { await jfetch('/api/cal/icloud/disconnect', { method: 'POST', body: '{}' }); toast('iCloud disconnected'); closeSheet(); viewCalendar(); };
|
||||
const icf = el.querySelector('#icf');
|
||||
if (icf) icf.onsubmit = async e => {
|
||||
e.preventDefault(); const btn = icf.querySelector('.btn'); btn.disabled = true; btn.textContent = 'Connecting…'; el.querySelector('#ice').textContent = '';
|
||||
try { await jfetch('/api/cal/icloud/connect', { method: 'POST', body: JSON.stringify({ appleId: el.querySelector('#iu').value, password: el.querySelector('#ip').value }) }); toast('iCloud connected'); closeSheet(); viewCalendar(); }
|
||||
catch (err) { btn.disabled = false; btn.textContent = 'Connect iCloud'; el.querySelector('#ice').textContent = err.message; }
|
||||
};
|
||||
el.querySelector('#fc2').onclick = async () => { try { await navigator.clipboard.writeText(feed.url); toast('Link copied'); } catch { el.querySelector('#fu').select(); toast('Press copy on the selected link'); } };
|
||||
el.querySelector('#fr2').onclick = () => confirmSheet('Make a new link?', 'The old link stops working, so anything subscribed to it has to be updated.', 'Make new link', async () => { await jfetch('/api/cal/feed', { method: 'POST', body: '{}' }); manageCalendars(); });
|
||||
}
|
||||
|
||||
function viewMail() {
|
||||
@@ -517,6 +639,15 @@ function viewVerse() {
|
||||
<div class="bar"><a class="btn" href="${esc(url)}" target="_blank" rel="noopener">${icon('book')}Open admin</a></div></div></div>`;
|
||||
}
|
||||
|
||||
// Podcast: a plain-http address on the home network. A secure page cannot frame http content, and the address is
|
||||
// private to the LAN, so this opens it directly in the browser (which works on the home network or over a VPN).
|
||||
function viewPodcast() {
|
||||
const url = cfg.podcast, u = new URL(url);
|
||||
$('#view').innerHTML = `<div class="wrap"><h1>Podcast</h1><p class="sub">${esc(u.host)}</p>
|
||||
<div class="card" style="max-width:none"><p style="margin-top:0">Opens in its own tab. This address is on your home network, so it works when you are at home or connected to your VPN.</p>
|
||||
<div class="bar"><a class="btn" href="${esc(url)}" target="_blank" rel="noopener">${icon('mic')}Open podcast</a></div></div></div>`;
|
||||
}
|
||||
|
||||
function viewAudio() {
|
||||
const url = cfg.audiobooks, c = abCreds();
|
||||
$('#view').innerHTML = `<div class="wrap"><h1>Audio</h1>
|
||||
|
||||
Reference in new issue
Block a user