build-container / build (push) Successful in 3m35s
- A bare #/store now opens at #/store/books so the Book store page draws - Admins choose how long a family device waits before returning to the board (1 to 30 minutes) - Any playing video or audio keeps a family device signed in - Test updated Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
235 lines
29 KiB
JavaScript
235 lines
29 KiB
JavaScript
'use strict';
|
|
// ---- Store: everything you can get, in one place. Books (free sources), free audiobooks, radio stations, and My Store: content the admin adds themselves (books,
|
|
// music, apps to download, and extensions that add features to the hub). The first three are the same screens as before, shown inside this one.
|
|
const stApi = (p, opts) => jfetch('/api/store/my' + p, opts);
|
|
const stOpt = (m, o) => ({ method: m, headers: { 'content-type': 'application/json' }, body: o ? JSON.stringify(o) : undefined });
|
|
const ST_KINDS = [['book', 'Books', 'book'], ['music', 'Music', 'music'], ['app', 'Apps', 'files'], ['extension', 'Extensions', 'cog'], ['link', 'Sites', 'link']];
|
|
const stKind = k => ST_KINDS.find(x => x[0] === k) || ST_KINDS[2];
|
|
const stSize = b => fmtSize(+b || 0);
|
|
const stCover = it => it.cover ? `/api/store/my/items/${it.id}/cover?v=${it.updated}` : '';
|
|
const stHue = s => { let h = 0; for (const ch of String(s)) h = (h * 31 + ch.charCodeAt(0)) % 360; return h; };
|
|
let stItems = [], stInstalled = [], stAdmin = false, stFilter = 'all';
|
|
|
|
/** The sections this person may open (each needs its own tab's permission, except My Store). */
|
|
const stSections = () => [['books', 'Books', allowed('read')], ['audiobooks', 'Audiobooks', allowed('audio')], ['radio', 'Radio', allowed('radio')], ['mine', 'My Store', true]].filter(s => s[2]);
|
|
|
|
async function viewStore(args = []) {
|
|
const secs = stSections(), want = args[0] === 'ext' || args[0] === 'site' ? 'mine' : args[0], sec = (secs.find(s => s[0] === want) || secs[0])[0];
|
|
if (!args[0] || (!secs.some(s => s[0] === args[0]) && args[0] !== 'ext' && args[0] !== 'site')) return location.replace('#/store/' + sec); // (a bare #/store opens the first section by its own address, so its page knows it is the one showing)
|
|
const v = $('#view');
|
|
v.innerHTML = `<div class="wrap" id="stwrap"><div class="ptop"><h1>Store</h1></div><div class="seg">${secs.map(s => `<a href="#/store/${s[0]}" class="${s[0] === sec ? 'on' : ''}">${esc(s[1])}</a>`).join('')}</div><div id="stbody"></div></div>`;
|
|
const el = $('#stbody'), host = { el, hash: '#/store/' + sec };
|
|
if (sec === 'books') return rdStore(host);
|
|
if (sec === 'audiobooks') return viewFreeBooks(host);
|
|
if (sec === 'radio') return viewRadio(host);
|
|
if (args[0] === 'ext' && args[1]) return stRunExt(el, args[1]);
|
|
if (args[0] === 'site' && args[1]) return stRunSite(el, args[1]);
|
|
return stMine(el);
|
|
}
|
|
|
|
// ---------------------------------------------------------------- My Store
|
|
async function stLoad() { const r = await stApi('/items'); stItems = r.items; stInstalled = r.installed; stAdmin = r.admin; return r; }
|
|
async function stMine(el) {
|
|
try { await stLoad(); } catch (e) { el.innerHTML = `<p class="err">${esc(e.message)}</p>`; return; }
|
|
const draw = () => {
|
|
if (!$('#stbody')) return;
|
|
const mine = stItems.filter(i => i.kind === 'extension' && stInstalled.includes(i.id)), shown = stItems.filter(i => stFilter === 'all' || i.kind === stFilter);
|
|
el.innerHTML = `<div class="bar" style="margin:0 0 8px;justify-content:flex-start"><p class="sub" style="margin:0;flex:1">Books, music, apps and extensions added just for this hub.</p>${stAdmin ? `<button class="btn" id="stadd">${icon('plus')}Add</button>` : ''}</div>
|
|
${mine.length ? `<h2>My extensions</h2><div class="vgrid">${mine.map(i => stCard(i, true)).join('')}</div>` : ''}
|
|
<div class="seg" id="stkinds"><a href="#" data-k="all" class="${stFilter === 'all' ? 'on' : ''}">All</a>${ST_KINDS.map(k => `<a href="#" data-k="${k[0]}" class="${stFilter === k[0] ? 'on' : ''}">${k[1]}</a>`).join('')}</div>
|
|
${shown.length ? `<div class="vgrid">${shown.map(i => stCard(i)).join('')}</div>` : `<p class="empty">${stItems.length ? 'Nothing of that kind yet.' : stAdmin ? 'Nothing here yet. Add your first book, album, app or extension.' : 'Nothing has been added yet.'}</p>`}`;
|
|
el.querySelectorAll('#stkinds a').forEach(a => a.onclick = e => { e.preventDefault(); stFilter = a.dataset.k; draw(); });
|
|
el.querySelectorAll('[data-it]').forEach(a => a.onclick = e => { e.preventDefault(); const it = stItems.find(x => x.id === a.dataset.it); if (a.dataset.run) location.hash = '#/store/ext/' + it.id; else stDetail(it, draw); });
|
|
if ($('#stadd')) $('#stadd').onclick = () => stEdit(null, draw);
|
|
};
|
|
draw();
|
|
}
|
|
const stCard = (it, run) => `<a class="vcard" href="#" data-it="${esc(it.id)}"${run ? ' data-run="1"' : ''}><span class="vimg" style="aspect-ratio:${it.kind === 'book' ? '2/3' : '1/1'};background:linear-gradient(135deg,hsl(${stHue(it.title)} 55% 40%),hsl(${stHue(it.title) + 40} 55% 28%));display:grid;place-items:center;color:#fff;font-size:40px">${it.cover ? `<img loading="lazy" alt="" src="${stCover(it)}" onerror="this.remove()">` : esc(((it.kind === 'extension' || it.kind === 'link') && it.icon) || (it.title.match(/\p{L}|\p{N}/u) || ['?'])[0].toUpperCase())}</span>
|
|
<b>${esc(it.title)}</b><small>${esc([stKind(it.kind)[1].replace(/s$/, ''), it.creator].filter(Boolean).join(' · '))}</small></a>`;
|
|
|
|
function stDetail(it, redraw) {
|
|
const k = it.kind, f0 = it.files[0], isMine = k === 'extension' && stInstalled.includes(it.id);
|
|
const url = f => `/api/store/my/items/${it.id}/files/${f.id}`;
|
|
const el = openSheet(it.title, [stKind(k)[1].replace(/s$/, ''), it.creator, it.version && 'v' + it.version].filter(Boolean).join(' · '), `<div style="display:flex;gap:14px;align-items:flex-start">${it.cover ? `<span class="vimg" style="width:96px;flex:none;aspect-ratio:${k === 'book' ? '2/3' : '1/1'}"><img alt="" src="${stCover(it)}"></span>` : ''}
|
|
<div style="min-width:0">${it.description ? `<p style="margin:0;white-space:normal">${esc(it.description).replace(/\n/g, '<br>')}</p>` : '<p class="sub" style="margin:0">No description.</p>'}</div></div>
|
|
${k === 'music' || k === 'app' ? `<div class="list" style="margin-top:12px">${it.files.map((f, i) => `<div class="row"><span class="grow"><b>${esc(f.name)}</b><small>${esc(stSize(f.size))}</small></span>${k === 'music' ? `<button class="ibtn" data-play="${i}" aria-label="Play">${icon('play')}</button>` : ''}<a class="ibtn" href="${url(f)}?dl=1" download aria-label="Download">${icon('download')}</a></div>`).join('') || '<p class="empty">No files.</p>'}</div>` : ''}
|
|
${k === 'link' ? `<p class="sub" style="margin:10px 0 0;word-break:break-all">${esc(it.url)}</p>` : ''}<div class="err" id="sterr"></div><div class="bar" id="stbar" style="flex-wrap:wrap">
|
|
${k === 'book' && f0 ? `${allowed('read') ? '<button class="btn" id="stlib">Add to my library</button>' : ''}<a class="btn ghost" href="${url(f0)}?dl=1" download>Download</a>` : ''}
|
|
${k === 'music' && it.files.length ? `<button class="btn" id="stplay">${icon('play')}Play</button>` : ''}
|
|
${k === 'app' && it.url ? `<a class="btn" href="${esc(it.url)}" target="_blank" rel="noopener noreferrer">Open link</a>` : ''}
|
|
${k === 'link' ? `<button class="btn" id="stopen">Open</button><a class="btn ghost" href="${esc(it.url)}" target="_blank" rel="noopener noreferrer">New tab</a>` : ''}
|
|
${k === 'extension' && f0 ? `<button class="btn" id="stinst">${isMine ? 'Open' : 'Install'}</button>${isMine ? '<button class="btn ghost" id="stun">Remove</button>' : ''}` : ''}
|
|
${stAdmin ? '<button class="btn ghost" id="stedit">Edit</button>' : ''}</div>`);
|
|
const err = t => { el.querySelector('#sterr').textContent = t; };
|
|
const tracks = () => it.files.map(f => ({ key: `my:${it.id}:${f.id}`, title: f.name.replace(/\.[^.]+$/, '').replace(/^\d+[\s._-]+/, ''), artist: it.creator || '', album: it.title, thumb: stCover(it), src: url(f), nocache: true }));
|
|
const play = i => { playQueue(tracks(), i || 0); closeSheet(); };
|
|
if (el.querySelector('#stplay')) el.querySelector('#stplay').onclick = () => play(0);
|
|
el.querySelectorAll('[data-play]').forEach(b => b.onclick = () => play(+b.dataset.play));
|
|
if (el.querySelector('#stlib')) el.querySelector('#stlib').onclick = async e => { const b = e.currentTarget; b.disabled = true; try { const r = await stApi(`/items/${it.id}/to-library`, stOpt('POST')); closeSheet(); toast(r.duplicate ? 'Already in your library' : 'Added to your library', { label: 'Open', run: () => { location.hash = '#/read/open/' + r.id; } }); } catch (x) { err(x.message); b.disabled = false; } };
|
|
if (el.querySelector('#stopen')) el.querySelector('#stopen').onclick = () => { closeSheet(); if (it.embed) location.hash = '#/store/site/' + it.id; else window.open(it.url, '_blank', 'noopener,noreferrer'); };
|
|
if (el.querySelector('#stinst')) el.querySelector('#stinst').onclick = async e => { const b = e.currentTarget; try { if (!isMine) { b.disabled = true; stInstalled = (await stApi(`/ext/${it.id}/install`, stOpt('POST'))).installed; } closeSheet(); location.hash = '#/store/ext/' + it.id; } catch (x) { err(x.message); b.disabled = false; } };
|
|
if (el.querySelector('#stun')) el.querySelector('#stun').onclick = () => { closeSheet(); confirmSheet('Remove this extension?', 'Its saved settings are forgotten too.', 'Remove', async () => { try { stInstalled = (await stApi(`/ext/${it.id}/uninstall`, stOpt('POST'))).installed; toast('Removed'); redraw(); } catch (x) { toast(x.message); } }); };
|
|
if (el.querySelector('#stedit')) el.querySelector('#stedit').onclick = () => { closeSheet(); stEdit(it, redraw); };
|
|
}
|
|
|
|
// ---- adding and changing (admin)
|
|
function stEdit(it, redraw) {
|
|
const x = it || { kind: 'book', title: '', creator: '', description: '', version: '', url: '', icon: '', files: [], cover: false };
|
|
let created = false;
|
|
const el = openSheet(it ? 'Edit item' : 'Add to My Store', '', `<form id="stf">
|
|
${it ? '' : `<label for="stk">What is it?</label><select id="stk">${ST_KINDS.map(k => `<option value="${k[0]}">${k[1].replace(/s$/, '')}</option>`).join('')}</select>`}
|
|
<label for="stt">Title</label><input type="text" id="stt" value="${esc(x.title)}" autocomplete="off" required>
|
|
<label for="stc" id="stcl">Author</label><input type="text" id="stc" value="${esc(x.creator)}" autocomplete="off">
|
|
<label for="std">Description</label><textarea id="std" rows="3">${esc(x.description)}</textarea>
|
|
<div id="stver" hidden><label for="stv">Version</label><input type="text" id="stv" value="${esc(x.version)}" autocomplete="off"></div>
|
|
<div id="stlnk" hidden><label for="stu" id="stul">Link (optional)</label><input type="url" id="stu" value="${esc(x.url)}" placeholder="https://…" autocapitalize="off"></div>
|
|
<label id="stemb" class="row" style="cursor:pointer" hidden><span class="grow"><b>Open inside the hub</b><small>Some sites refuse to be shown this way; they open in a new tab instead</small></span><input type="checkbox" id="ste"${x.embed ? ' checked' : ''}></label>
|
|
<div id="stext" hidden><label for="sti">Icon (one letter or emoji)</label><input type="text" id="sti" value="${esc(x.icon || '')}" maxlength="2" style="width:80px"></div>
|
|
<div id="stfiles"><label>Files</label><div id="stfl" class="list"></div>
|
|
<div class="bar" style="justify-content:flex-start;margin:6px 0"><label class="btn ghost" style="cursor:pointer;margin:0">${icon('plus')}Upload<input type="file" id="stup" hidden multiple></label>${allowed('files') ? `<button type="button" class="btn ghost" id="stsf">${icon('files')}From Files</button>` : ''}</div></div>
|
|
<label>Cover picture</label><div class="bar" style="justify-content:flex-start;margin:4px 0"><label class="btn ghost" style="cursor:pointer;margin:0">${icon('plus')}Choose<input type="file" id="stcv" accept="image/jpeg,image/png,image/gif,image/webp" hidden></label><small id="stcvn" style="color:var(--muted)">${x.cover ? 'A cover is set' : ''}</small></div>
|
|
<div class="err" id="sterr"></div><div class="bar">${it ? '<button type="button" class="btn danger" id="stdel">Delete</button>' : ''}<button type="button" class="btn ghost" id="stx" style="flex:1;justify-content:center">Cancel</button><button class="btn" id="stok" style="flex:1;justify-content:center">${it ? 'Save' : 'Add'}</button></div></form>`);
|
|
const $e = s => el.querySelector(s), err = t => { $e('#sterr').textContent = t; };
|
|
let kind = x.kind; const files = [], existing = [...x.files]; let cover = null;
|
|
const kindUi = () => { kind = it ? x.kind : $e('#stk').value; $e('#stcl').textContent = kind === 'music' ? 'Artist' : kind === 'book' ? 'Author' : 'Made by'; $e('#stver').hidden = kind !== 'app' && kind !== 'extension'; $e('#stlnk').hidden = kind !== 'app' && kind !== 'link'; $e('#stul').textContent = kind === 'link' ? 'Address of the site' : 'Link (optional)'; $e('#stemb').hidden = kind !== 'link'; $e('#stext').hidden = kind !== 'extension' && kind !== 'link'; $e('#stfiles').hidden = kind === 'link';
|
|
$e('#stup').accept = kind === 'book' ? '.epub,.pdf,.mobi,.azw3,.azw,.docx' : kind === 'music' ? 'audio/*,.mp3,.m4a,.flac,.ogg,.opus,.wav' : kind === 'extension' ? '.html' : ''; $e('#stup').multiple = kind === 'music' || kind === 'app'; };
|
|
const drawFiles = () => { $e('#stfl').innerHTML = [...existing.map((f, i) => [f.name, stSize(f.size), 'e', i]), ...files.map((f, i) => [f.name || f.path.split('/').pop(), f.size != null ? stSize(f.size) : 'from Files', 'n', i])].map(([n, s, t, i]) => `<div class="row"><span class="grow"><b>${esc(n)}</b><small>${esc(s)}</small></span><button type="button" class="ibtn" data-t="${t}" data-i="${i}" aria-label="Remove">${icon('trash')}</button></div>`).join('') || '<p class="sub" style="margin:8px 12px">No files yet.</p>';
|
|
el.querySelectorAll('#stfl [data-t]').forEach(b => b.onclick = () => { (b.dataset.t === 'e' ? existing : files).splice(+b.dataset.i, 1); drawFiles(); }); };
|
|
if (!it) $e('#stk').onchange = kindUi; kindUi(); drawFiles();
|
|
$e('#stup').onchange = e => { const single = kind !== 'music' && kind !== 'app'; if (single) { files.length = 0; existing.length = 0; } for (const f of e.target.files) files.push(f); e.target.value = ''; drawFiles(); };
|
|
if ($e('#stsf')) $e('#stsf').onclick = async () => { const p = await stPickFile(); if (!p) return; if (kind !== 'music' && kind !== 'app') { files.length = 0; existing.length = 0; } files.push({ path: p.path, repo: p.repo, name: p.path.split('/').pop() }); drawFiles(); };
|
|
$e('#stcv').onchange = e => { cover = e.target.files[0] || null; $e('#stcvn').textContent = cover ? cover.name : ''; };
|
|
$e('#stx').onclick = closeSheet;
|
|
if ($e('#stdel')) $e('#stdel').onclick = () => { closeSheet(); confirmSheet('Delete this item?', it.title, 'Delete', async () => { try { await stApi('/items/' + it.id, { method: 'DELETE' }); toast('Deleted'); await stLoad(); redraw(); } catch (e) { toast(e.message); } }); };
|
|
$e('#stf').onsubmit = async e => {
|
|
e.preventDefault(); const ok = $e('#stok'); ok.disabled = true; err('');
|
|
try {
|
|
if (!files.length && !existing.length && kind !== 'app' && kind !== 'link') throw new Error('Add the file first');
|
|
let saved = await stApi('/items', stOpt('POST', { id: it && it.id, kind, title: $e('#stt').value, creator: $e('#stc').value, description: $e('#std').value, version: $e('#stv').value, url: $e('#stu').value, embed: !!$e('#ste').checked, icon: $e('#sti') ? $e('#sti').value : '' }));
|
|
if (!it) created = true;
|
|
for (const f of x.files) if (!existing.some(y => y.id === f.id)) saved = await stApi(`/items/${saved.id}/files/${f.id}`, { method: 'DELETE' });
|
|
let n = 0;
|
|
for (const f of files) {
|
|
ok.textContent = `Adding ${++n} of ${files.length}…`;
|
|
saved = f instanceof File ? await stApi(`/items/${saved.id}/files?name=${encodeURIComponent(f.name)}`, { method: 'POST', body: f, headers: { 'content-type': 'application/octet-stream' } }) : await stApi(`/items/${saved.id}/files`, stOpt('POST', { repo: f.repo, path: f.path }));
|
|
}
|
|
if (cover) saved = await stApi(`/items/${saved.id}/cover`, { method: 'POST', body: cover, headers: { 'content-type': cover.type } });
|
|
closeSheet(); toast(it ? 'Saved' : 'Added'); await stLoad(); redraw();
|
|
} catch (x2) { err(x2.message + (created ? ' The item was created without it: open it to finish.' : '')); ok.disabled = false; ok.textContent = it ? 'Save' : 'Add'; }
|
|
};
|
|
}
|
|
|
|
/** Pick one file from your Seafile libraries. Resolves { repo, path } or null. */
|
|
function stPickFile() {
|
|
return new Promise(resolve => {
|
|
if (!sf.token()) { toast('Open Files and sign in first'); return resolve(null); }
|
|
let repo = null, dir = '/', done = false;
|
|
const el = openSheet('Choose a file from Files', '', '<div id="stpk"></div>'); const finish = v => { done = true; closeSheet(); resolve(v); };
|
|
el.querySelector('.scrim').addEventListener('click', () => { if (!done) resolve(null); });
|
|
const draw = async () => {
|
|
const box = el.querySelector('#stpk'); box.innerHTML = '<p class="empty">Loading…</p>';
|
|
try {
|
|
if (!repo) { const repos = (await sf.req('/api2/repos/')).filter(r => !r.encrypted).sort((a, b) => a.name.localeCompare(b.name));
|
|
box.innerHTML = `<div class="list" style="max-height:50dvh;overflow:auto">${repos.map(r => `<button class="row" data-r="${esc(r.id)}">${icon('files')}<span class="grow"><b>${esc(r.name)}</b></span></button>`).join('')}</div><div class="bar"><button class="btn ghost" id="pcx" style="flex:1;justify-content:center">Cancel</button></div>`;
|
|
box.querySelectorAll('[data-r]').forEach(b => b.onclick = () => { repo = b.dataset.r; dir = '/'; draw(); });
|
|
} else {
|
|
const items = (await sf.req(`/api2/repos/${encodeURIComponent(repo)}/dir/?p=${encodeURIComponent(dir)}`)).sort((a, b) => (a.type === b.type ? 0 : a.type === 'dir' ? -1 : 1) || a.name.localeCompare(b.name));
|
|
box.innerHTML = `<p class="sub" style="word-break:break-all">${esc(dir)}</p><div class="list" style="max-height:50dvh;overflow:auto"><button class="row" data-up="1">${icon('back')}<span class="grow"><b>${dir === '/' ? 'All libraries' : 'Up'}</b></span></button>
|
|
${items.map(x => `<button class="row" data-${x.type === 'dir' ? 'd' : 'f'}="${esc(x.name)}">${icon(x.type === 'dir' ? 'files' : 'doc')}<span class="grow"><b>${esc(x.name)}</b>${x.type === 'dir' ? '' : `<small>${esc(stSize(x.size))}</small>`}</span></button>`).join('')}</div><div class="bar"><button class="btn ghost" id="pcx" style="flex:1;justify-content:center">Cancel</button></div>`;
|
|
box.querySelectorAll('[data-d]').forEach(b => b.onclick = () => { dir = (dir === '/' ? '' : dir) + '/' + b.dataset.d; draw(); });
|
|
box.querySelectorAll('[data-f]').forEach(b => b.onclick = () => finish({ repo, path: (dir === '/' ? '' : dir) + '/' + b.dataset.f }));
|
|
box.querySelector('[data-up]').onclick = () => { if (dir === '/') repo = null; else dir = dir.slice(0, dir.lastIndexOf('/')) || '/'; draw(); };
|
|
}
|
|
box.querySelector('#pcx').onclick = () => finish(null);
|
|
} catch (e) { box.innerHTML = `<p class="err">${esc(e.message)}</p>`; }
|
|
};
|
|
draw();
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------- running an extension
|
|
// The extension is one HTML file in a sandboxed frame (no access to your sign-in or the hub's data). It talks to the hub only through the messages below.
|
|
async function stRunExt(el, id) {
|
|
try { await stLoad(); } catch (e) { el.innerHTML = `<p class="err">${esc(e.message)}</p>`; return; }
|
|
const it = stItems.find(x => x.id === id && x.kind === 'extension');
|
|
if (!it) { el.innerHTML = '<p class="empty">That extension is not in the store any more.</p>'; return; }
|
|
if (!stInstalled.includes(id)) { el.innerHTML = `<p class="empty">Install “${esc(it.title)}” from My Store first.</p>`; return; }
|
|
el.innerHTML = `<div class="bar" style="margin:0 0 8px;justify-content:flex-start"><a class="btn ghost" href="#/store/mine">${icon('back')}My Store</a><b style="flex:1">${esc(it.title)}</b></div>
|
|
<iframe id="stframe" title="${esc(it.title)}" sandbox="allow-scripts allow-forms allow-popups allow-modals" referrerpolicy="no-referrer" style="width:100%;height:calc(100dvh - 190px);min-height:360px;border:1px solid var(--line);border-radius:12px;background:#fff"></iframe>`;
|
|
const frame = el.querySelector('#stframe'); let data = null, timer;
|
|
// (the page is fetched by the hub and shown from here, so the frame needs no sign-in of its own; its sandbox keeps it from touching anything of the hub's)
|
|
fetch(`/api/store/my/ext/${id}/page`).then(r => r.ok ? r.text() : Promise.reject(new Error('Could not load it'))).then(h => { frame.srcdoc = h; }).catch(e => { el.querySelector('#stframe').outerHTML = `<p class="err">${esc(e.message)}</p>`; });
|
|
const load = async () => data || (data = await stApi(`/ext/${id}/data`).catch(() => ({})));
|
|
const save = () => { clearTimeout(timer); timer = setTimeout(() => stApi(`/ext/${id}/data`, stOpt('POST', data)).catch(e => toast(e.message)), 400); };
|
|
// What an extension may ask the hub for. Each kind of access is asked of the person the first time and remembered until the extension is removed.
|
|
const WHY = { 'tasks.read': 'see your tasks', 'tasks.write': 'add tasks and mark them done', 'contacts.read': 'see your contacts', 'contacts.write': 'add contacts', 'calendar.read': 'see your calendar', 'calendar.write': 'add events to your calendar' };
|
|
let grants = null; const refused = new Set();
|
|
const allowedTo = async perm => {
|
|
if (!grants) grants = (await stApi(`/ext/${id}/grants`).catch(() => ({ grants: [] }))).grants;
|
|
if (grants.includes(perm)) return true; if (refused.has(perm) || !allowed(perm.split('.')[0] === 'calendar' ? 'calendar' : perm.split('.')[0])) return false;
|
|
const yes = await new Promise(ok => { let done = false; const el2 = openSheet('Allow access?', it.title, `<p>“${esc(it.title)}” would like to <b>${esc(WHY[perm])}</b>.</p><div class="bar"><button class="btn ghost" id="pno" style="flex:1;justify-content:center">Don't allow</button><button class="btn" id="pyes" style="flex:1;justify-content:center">Allow</button></div>`);
|
|
const fin = v => { if (!done) { done = true; closeSheet(); ok(v); } }; el2.querySelector('#pyes').onclick = () => fin(true); el2.querySelector('#pno').onclick = () => fin(false); el2.querySelector('.scrim').addEventListener('click', () => fin(false)); });
|
|
if (!yes) { refused.add(perm); return false; }
|
|
grants = [...grants, perm]; stApi(`/ext/${id}/grants`, stOpt('POST', { grants })).catch(() => {}); return true;
|
|
};
|
|
const jf = (p, o) => jfetch(p, o), pj = (m, b) => ({ method: m, headers: { 'content-type': 'application/json' }, body: JSON.stringify(b) });
|
|
const txt = (v, n) => String(v == null ? '' : v).slice(0, n), ymd = v => /^\d{4}-\d{2}-\d{2}$/.test(String(v)) ? String(v) : '';
|
|
const hubCall = async (method, a = {}) => {
|
|
const need = { 'tasks.lists': 'tasks.read', 'tasks.list': 'tasks.read', 'tasks.add': 'tasks.write', 'tasks.complete': 'tasks.write', 'contacts.list': 'contacts.read', 'contacts.add': 'contacts.write', 'calendar.events': 'calendar.read', 'calendar.add': 'calendar.write' }[method];
|
|
if (!need) throw new Error('Unknown request'); if (!a || typeof a !== 'object') a = {};
|
|
if (!(await allowedTo(need))) throw new Error('The person did not allow that');
|
|
const localList = async name => { const l = (await jf('/api/tasks/lists')).lists.filter(x => x.local), n = txt(name, 60).toLowerCase(); return (n && l.find(x => x.name.toLowerCase() === n)) || l.find(x => x.id === 'local:inbox') || l[0]; };
|
|
if (method === 'tasks.lists') return (await jf('/api/tasks/lists')).lists.map(l => ({ id: l.id, name: l.name }));
|
|
if (method === 'tasks.list') return (await jf('/api/tasks?list=' + encodeURIComponent(txt(a.list, 80)))).map(t => ({ id: t.id, title: t.title, notes: t.notes || '', due: t.due || '', done: !!t.done }));
|
|
if (method === 'tasks.add') { const l = await localList(a.list); if (!l) throw new Error('No task list'); await jf('/api/tasks', pj('POST', { list: l.id, fields: { title: txt(a.title, 200), notes: txt(a.notes, 2000), due: ymd(a.due), done: false } })); return { ok: true, list: l.name }; }
|
|
if (method === 'tasks.complete') { const list = txt(a.list, 80), t = (await jf('/api/tasks?list=' + encodeURIComponent(list))).find(x => x.id === a.id); if (!t) throw new Error('Task not found'); await jf('/api/tasks', pj('POST', { list, id: t.id, fields: { title: t.title, notes: t.notes || '', due: t.due || '', done: true } })); return { ok: true }; }
|
|
if (method === 'contacts.list') return (await ctApi('/')).map(c => ({ id: c.id, name: c.name, org: c.org, emails: c.emails.map(e => e.value), phones: c.phones.map(p => p.value), birthday: c.birthday }));
|
|
if (method === 'contacts.add') { const c = await ctApi('/', pj('POST', { name: txt(a.name, 120), org: txt(a.org, 120), emails: a.email ? [{ label: '', value: txt(a.email, 120) }] : [], phones: a.phone ? [{ label: '', value: txt(a.phone, 60) }] : [], birthday: txt(a.birthday, 12), notes: txt(a.notes, 2000) })); ctList = null; return { id: c.id }; }
|
|
if (method === 'calendar.events') { const f = ymd(a.from), t = ymd(a.to); if (!f || !t) throw new Error('Give from and to as YYYY-MM-DD'); const q = new URLSearchParams({ from: new Date(f + 'T00:00:00').getTime(), to: new Date(t + 'T23:59:59').getTime(), fd: f, td: t });
|
|
return (await jf('/api/cal/events?' + q)).events.slice(0, 500).map(e => ({ title: e.title, start: e.start, end: e.end, allDay: !!e.allDay, calendar: e.cal })); }
|
|
if (method === 'calendar.add') { const cals = await jf('/api/cal/list'), c = cals.find(x => x.local && !x.shared); if (!c) throw new Error('No calendar to add to'); const allDay = !!a.allDay, re = allDay ? /^\d{4}-\d{2}-\d{2}$/ : /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/;
|
|
if (!re.test(a.start || '')) throw new Error('start must be ' + (allDay ? 'YYYY-MM-DD' : 'YYYY-MM-DDTHH:MM')); const end = re.test(a.end || '') ? a.end : a.start;
|
|
await jf('/api/cal/event', pj('POST', { cal: c.id, fields: { title: txt(a.title, 200), location: txt(a.location, 200), notes: txt(a.notes, 2000), allDay, start: a.start, end, tz: Intl.DateTimeFormat().resolvedOptions().timeZone, repeat: 'none' } })); return { ok: true }; }
|
|
};
|
|
const onMsg = async e => {
|
|
if (!document.body.contains(frame)) return removeEventListener('message', onMsg);
|
|
if (e.source !== frame.contentWindow || !e.data || !e.data.hub) return;
|
|
const { id: mid, method, args = [] } = e.data; let result, error;
|
|
try {
|
|
if (method === 'storage.get') result = (await load())[String(args[0])] ?? null;
|
|
else if (method === 'storage.all') result = { ...(await load()) };
|
|
else if (method === 'storage.set') { const d = await load(); d[String(args[0]).slice(0, 100)] = args[1]; if (JSON.stringify(d).length > 256 * 1024) { delete d[String(args[0]).slice(0, 100)]; throw new Error('Out of space (256 KB)'); } save(); result = true; }
|
|
else if (method === 'storage.remove') { delete (await load())[String(args[0])]; save(); result = true; }
|
|
else if (method === 'toast') { toast(String(args[0]).slice(0, 200)); result = true; }
|
|
else if (method === 'user') result = { name: ME ? ME.name : '' };
|
|
else if (method.startsWith('tasks.') || method.startsWith('contacts.') || method.startsWith('calendar.')) result = await hubCall(method, args[0]);
|
|
else if (method === 'open') { if (/^https?:\/\//i.test(String(args[0]))) window.open(String(args[0]), '_blank', 'noopener,noreferrer'); result = true; }
|
|
else throw new Error('Unknown request');
|
|
} catch (x) { error = x.message; }
|
|
frame.contentWindow.postMessage({ hub: 1, id: mid, result, error }, '*');
|
|
};
|
|
addEventListener('message', onMsg);
|
|
}
|
|
|
|
/** A site shown inside the hub. It is another website in a frame of its own, so it cannot see anything of the hub's. */
|
|
async function stRunSite(el, id) {
|
|
try { await stLoad(); } catch (e) { el.innerHTML = `<p class="err">${esc(e.message)}</p>`; return; }
|
|
const it = stItems.find(x => x.id === id && x.kind === 'link');
|
|
if (!it) { el.innerHTML = '<p class="empty">That site is not in the store any more.</p>'; return; }
|
|
el.innerHTML = `<div class="bar" style="margin:0 0 8px;justify-content:flex-start"><a class="btn ghost" href="#/store/mine">${icon('back')}My Store</a><b style="flex:1">${esc(it.title)}</b><a class="btn ghost" href="${esc(it.url)}" target="_blank" rel="noopener noreferrer">New tab</a></div>
|
|
<iframe title="${esc(it.title)}" src="${esc(it.url)}" sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox" referrerpolicy="no-referrer" style="width:100%;height:calc(100dvh - 190px);min-height:360px;border:1px solid var(--line);border-radius:12px;background:#fff"></iframe>
|
|
<p class="sub" style="margin-top:8px">If the page stays blank, that site does not allow being shown here. Use New tab.</p>`;
|
|
}
|
|
|
|
/** On Home, under the app tiles: the extensions this person installed, and the Sites that were added to My Store, one tap away. */
|
|
async function stHomeExt(el) {
|
|
if (!el) return;
|
|
try { await stLoad(); } catch { return; }
|
|
if (!el.isConnected) return;
|
|
const mine = stItems.filter(i => (i.kind === 'extension' && stInstalled.includes(i.id)) || i.kind === 'link');
|
|
if (!mine.length) { el.innerHTML = ''; return; }
|
|
const tileOf = i => { const href = i.kind === 'extension' ? '#/store/ext/' + i.id : i.embed ? '#/store/site/' + i.id : i.url, ext = i.kind === 'link' && !i.embed;
|
|
return `<a class="tile" href="${esc(href)}" ${ext ? 'target="_blank" rel="noopener noreferrer"' : ''}><span class="ic" style="background:hsl(${stHue(i.title)} 55% 42%);font-weight:700;font-size:20px">${esc(i.icon || (i.title.match(/\p{L}|\p{N}/u) || ['?'])[0].toUpperCase())}</span><b>${esc(i.title)}</b><small>${i.kind === 'extension' ? 'Extension' : 'Site'}</small></a>`; };
|
|
el.innerHTML = `<h2>My apps</h2><div class="grid">${mine.map(tileOf).join('')}</div>`;
|
|
}
|