My Store: select books and add them to chosen people's shelves
build-container / build (push) Successful in 3m53s
build-container / build (push) Successful in 3m53s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3376499805
commit
4e26bea455
5 files changed
+52
-2
No files matched your search
@@ -164,6 +164,7 @@ One **Store** tab (turn it on per person) with sections for **Books** (the free
|
|||||||
**My Store** is your own catalogue, for everyone who has the Store tab. The admin adds items with **Add**: upload files, or copy a file from their Seafile (**From Files**), with an optional cover picture.
|
**My Store** is your own catalogue, for everyone who has the Store tab. The admin adds items with **Add**: upload files, or copy a file from their Seafile (**From Files**), with an optional cover picture.
|
||||||
- **Books** (EPUB, PDF, MOBI, AZW3, DOCX): *Add to my library* puts a copy on that person's shelf, or download it.
|
- **Books** (EPUB, PDF, MOBI, AZW3, DOCX): *Add to my library* puts a copy on that person's shelf, or download it.
|
||||||
- **Add many books** (admin): pick a whole batch of book files at once. Each one is uploaded on its own and the server reads the **title, author, description and cover picture out of the EPUB itself** (`epubmeta.js`, no libraries); a book already in the store (same file) is skipped. PDF, MOBI, AZW3 and DOCX carry little usable metadata, so they are named after the file, with no author or cover. When it finishes it lists any book with no cover or no author so you can fix those with *Edit*.
|
- **Add many books** (admin): pick a whole batch of book files at once. Each one is uploaded on its own and the server reads the **title, author, description and cover picture out of the EPUB itself** (`epubmeta.js`, no libraries); a book already in the store (same file) is skipped. PDF, MOBI, AZW3 and DOCX carry little usable metadata, so they are named after the file, with no author or cover. When it finishes it lists any book with no cover or no author so you can fix those with *Edit*.
|
||||||
|
- **Select and add to people** (admin): tap *Select* in My Store, tick books (or *Select all shown*), then *Add to people…* and tick the people. The books land on each person's own shelf; someone without the Books tab is skipped and a book they already have is left alone.
|
||||||
- **Music** (MP3, M4A, AAC, FLAC, OGG, OPUS, WAV): an album can have many tracks; they play in the hub's player and can be downloaded.
|
- **Music** (MP3, M4A, AAC, FLAC, OGG, OPUS, WAV): an album can have many tracks; they play in the hub's player and can be downloaded.
|
||||||
- **Apps**: any files to download (an APK, an installer...), with an optional link and version. The hub does not run them.
|
- **Apps**: any files to download (an APK, an installer...), with an optional link and version. The hub does not run them.
|
||||||
- **Sites**: any website as a card. It opens in a new tab, or inside the hub if you tick *Open inside the hub* (some sites refuse to be shown that way).
|
- **Sites**: any website as a card. It opens in a new tab, or inside the hub if you tick *Open inside the hub* (some sites refuse to be shown that way).
|
||||||
|
|||||||
+17
@@ -154,6 +154,23 @@ async function handle(req, res) {
|
|||||||
if (req.method === 'DELETE') { needAdmin(me); fs.rm(coverOn(it), { force: true }, () => {}); it.cover = false; saveItems(l); return json(res, 200, view(it)); }
|
if (req.method === 'DELETE') { needAdmin(me); fs.rm(coverOn(it), { force: true }, () => {}); it.cover = false; saveItems(l); return json(res, 200, view(it)); }
|
||||||
if (req.method === 'GET') { if (!it.cover) throw fail(404, 'No cover'); return send(req, res, coverOn(it), it.cover, 'cover', false); }
|
if (req.method === 'GET') { if (!it.cover) throw fail(404, 'No cover'); return send(req, res, coverOn(it), it.cover, 'cover', false); }
|
||||||
}
|
}
|
||||||
|
if (p === '/people' && req.method === 'GET') { needAdmin(me); return json(res, 200, users.all().filter(u => !u.disabled).map(u => ({ id: u.id, name: u.name, books: users.can(u, 'read') }))); }
|
||||||
|
if (p === '/assign' && req.method === 'POST') { // put the chosen books on the chosen people's shelves (admin). A book they already have is left alone.
|
||||||
|
needAdmin(me); const b = JSON.parse(await readBody(req, 1e5) || '{}'), l = loadItems();
|
||||||
|
const its = [...new Set(Array.isArray(b.items) ? b.items : [])].map(id => l.find(x => x.id === id)).filter(x => x && x.kind === 'book' && x.files[0]);
|
||||||
|
const who = [...new Set(Array.isArray(b.users) ? b.users : [])].map(id => users.get(id)).filter(u => u && !u.disabled);
|
||||||
|
if (!its.length) throw fail(400, 'Choose at least one book'); if (!who.length) throw fail(400, 'Choose at least one person');
|
||||||
|
const out = [];
|
||||||
|
for (const u of who) {
|
||||||
|
const r = { id: u.id, name: u.name, added: 0, had: 0, failed: 0, skipped: !users.can(u, 'read') };
|
||||||
|
if (!r.skipped) await users.runAs(u, async () => { for (const it of its) {
|
||||||
|
try { const f = it.files[0], buf = fs.readFileSync(fileOn(it, f.id)); let cover; if (it.cover) { try { cover = { type: it.cover, data: fs.readFileSync(coverOn(it)) }; } catch {} }
|
||||||
|
books.addBook(buf, f.ext, { title: it.title, author: it.creator }, cover).duplicate ? r.had++ : r.added++; } catch { r.failed++; }
|
||||||
|
} });
|
||||||
|
out.push(r);
|
||||||
|
}
|
||||||
|
return json(res, 200, { books: its.length, people: out });
|
||||||
|
}
|
||||||
if ((m = /^\/items\/([0-9a-f]{12})\/to-library$/.exec(p)) && req.method === 'POST') { // a book goes onto the person's own shelf
|
if ((m = /^\/items\/([0-9a-f]{12})\/to-library$/.exec(p)) && req.method === 'POST') { // a book goes onto the person's own shelf
|
||||||
if (!users.can(me, 'read')) throw fail(403, 'You do not have the Books tab');
|
if (!users.can(me, 'read')) throw fail(403, 'You do not have the Books tab');
|
||||||
const it = loadItems().find(x => x.id === m[1]); if (!it || it.kind !== 'book' || !it.files[0]) throw fail(404, 'That is not a book');
|
const it = loadItems().find(x => x.id === m[1]); if (!it || it.kind !== 'book' || !it.files[0]) throw fail(404, 'That is not a book');
|
||||||
|
|||||||
+24
-2
@@ -29,19 +29,26 @@ async function viewStore(args = []) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- My Store
|
// ---------------------------------------------------------------- My Store
|
||||||
|
let stSelecting = false; const stPicked = new Set();
|
||||||
async function stLoad() { const r = await stApi('/items'); stItems = r.items; stInstalled = r.installed; stSeen = r.seen || {}; stAdmin = r.admin; return r; }
|
async function stLoad() { const r = await stApi('/items'); stItems = r.items; stInstalled = r.installed; stSeen = r.seen || {}; stAdmin = r.admin; return r; }
|
||||||
async function stMine(el) {
|
async function stMine(el) {
|
||||||
try { await stLoad(); } catch (e) { el.innerHTML = `<p class="err">${esc(e.message)}</p>`; return; }
|
try { await stLoad(); } catch (e) { el.innerHTML = `<p class="err">${esc(e.message)}</p>`; return; }
|
||||||
const draw = () => {
|
const draw = () => {
|
||||||
if (!$('#stbody')) return;
|
if (!$('#stbody')) return;
|
||||||
const mine = stItems.filter(i => i.kind === 'extension' && stInstalled.includes(i.id)), shown = stItems.filter(i => stFilter === 'all' || i.kind === stFilter);
|
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 ? `<label class="btn ghost" style="cursor:pointer;margin:0 6px 0 0" title="Pick many EPUB, PDF, MOBI, AZW3 or DOCX files at once. Titles, authors and covers are read from the books.">${icon('plus')}Add many books<input type="file" id="stbulk" hidden multiple accept=".epub,.pdf,.mobi,.azw3,.azw,.docx"></label><button class="btn" id="stadd">${icon('plus')}Add</button>` : ''}</div>
|
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 ? `<label class="btn ghost" style="cursor:pointer;margin:0 6px 0 0" title="Pick many EPUB, PDF, MOBI, AZW3 or DOCX files at once. Titles, authors and covers are read from the books.">${icon('plus')}Add many books<input type="file" id="stbulk" hidden multiple accept=".epub,.pdf,.mobi,.azw3,.azw,.docx"></label><button class="btn ghost" id="stsel" style="margin:0 6px 0 0">${stSelecting ? 'Cancel' : 'Select'}</button><button class="btn" id="stadd">${icon('plus')}Add</button>` : ''}</div>
|
||||||
|
${stSelecting ? `<div class="bar" style="justify-content:flex-start;position:sticky;top:0;z-index:3;background:var(--bg,var(--panel));padding:8px 0;flex-wrap:wrap"><b style="margin-right:8px">${stPicked.size} selected</b><button class="btn ghost" id="stall">Select all shown</button><button class="btn" id="stgive"${stPicked.size ? '' : ' disabled'}>Add to people…</button></div>` : ''}
|
||||||
${mine.length ? `<h2>My extensions</h2><div class="vgrid">${mine.map(i => stCard(i, true)).join('')}</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>
|
<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>`}`;
|
${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('#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); });
|
el.querySelectorAll('[data-it]').forEach(a => a.onclick = e => { e.preventDefault(); const it = stItems.find(x => x.id === a.dataset.it);
|
||||||
|
if (stSelecting) { if (it.kind !== 'book' || !it.files.length) return toast('Only books can be given to people'); stPicked.has(it.id) ? stPicked.delete(it.id) : stPicked.add(it.id); return draw(); } if (a.dataset.run) location.hash = '#/store/ext/' + it.id; else stDetail(it, draw); });
|
||||||
if ($('#stadd')) $('#stadd').onclick = () => stEdit(null, draw);
|
if ($('#stadd')) $('#stadd').onclick = () => stEdit(null, draw);
|
||||||
|
if ($('#stsel')) $('#stsel').onclick = () => { stSelecting = !stSelecting; stPicked.clear(); if (stSelecting) stFilter = 'book'; draw(); };
|
||||||
|
el.querySelectorAll('[data-it]').forEach(a => a.classList.toggle('picked', stPicked.has(a.dataset.it)));
|
||||||
|
if ($('#stgive')) $('#stgive').onclick = () => stGive([...stPicked], () => { stSelecting = false; stPicked.clear(); draw(); });
|
||||||
|
if ($('#stall')) $('#stall').onclick = () => { const bs = shown.filter(i => i.kind === 'book' && i.files.length); bs.every(i => stPicked.has(i.id)) ? stPicked.clear() : bs.forEach(i => stPicked.add(i.id)); draw(); };
|
||||||
if ($('#stbulk')) $('#stbulk').onchange = e => { const fs = [...e.target.files]; e.target.value = ''; if (fs.length) stBulk(fs, draw); };
|
if ($('#stbulk')) $('#stbulk').onchange = e => { const fs = [...e.target.files]; e.target.value = ''; if (fs.length) stBulk(fs, draw); };
|
||||||
};
|
};
|
||||||
draw();
|
draw();
|
||||||
@@ -268,3 +275,18 @@ async function stBulk(files, redraw) {
|
|||||||
q('#sbx').textContent = 'Done'; done = files.length;
|
q('#sbx').textContent = 'Done'; done = files.length;
|
||||||
try { await stLoad(); } catch {} redraw();
|
try { await stLoad(); } catch {} redraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Give the chosen books to people: they land on each person's shelf (a person without the Books tab is skipped, and a book they already have is left alone).
|
||||||
|
async function stGive(ids, after) {
|
||||||
|
let people; try { people = await stApi('/people'); } catch (e) { return toast(e.message); }
|
||||||
|
const el = openSheet('Add to people', ids.length + (ids.length === 1 ? ' book' : ' books'), `<div class="list" style="max-height:50dvh;overflow:auto">${people.map(p => `<label class="row" style="cursor:pointer"><input type="checkbox" data-u="${esc(p.id)}"${p.books ? '' : ' disabled'}><span class="grow"><b>${esc(p.name)}</b>${p.books ? '' : '<small>No Books tab</small>'}</span></label>`).join('')}</div>
|
||||||
|
<div class="err" id="sterr"></div><div class="bar"><button class="btn ghost" id="sgx" style="flex:1;justify-content:center">Cancel</button><button class="btn" id="sgok" style="flex:1;justify-content:center">Add</button></div>`);
|
||||||
|
el.querySelector('#sgx').onclick = closeSheet;
|
||||||
|
el.querySelector('#sgok').onclick = async e => {
|
||||||
|
const us = [...el.querySelectorAll('[data-u]:checked')].map(c => c.dataset.u), err = m => { el.querySelector('#sterr').textContent = m; }; if (!us.length) return err('Choose at least one person');
|
||||||
|
e.currentTarget.disabled = true; e.currentTarget.textContent = 'Adding…';
|
||||||
|
try { const r = await stApi('/assign', stOpt('POST', { items: ids, users: us })); closeSheet(); const n = r.people.reduce((a, p) => a + p.added, 0), had = r.people.reduce((a, p) => a + p.had, 0), bad = r.people.reduce((a, p) => a + p.failed, 0);
|
||||||
|
toast(`Added ${n} ${n === 1 ? 'book' : 'books'} to ${r.people.filter(p => p.added).length} ${r.people.filter(p => p.added).length === 1 ? 'person' : 'people'}` + (had ? `, ${had} already had it` : '') + (bad ? `, ${bad} failed` : '')); after();
|
||||||
|
} catch (x) { err(x.message); e.currentTarget.disabled = false; e.currentTarget.textContent = 'Add'; }
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1197,3 +1197,6 @@ body.qdock #view { padding-right: 340px; }
|
|||||||
img, svg, a, button { -webkit-touch-callout: none; -webkit-user-drag: none; }
|
img, svg, a, button { -webkit-touch-callout: none; -webkit-user-drag: none; }
|
||||||
input, textarea, select { font-size: max(16px, 1em); } /* (iPadOS zooms the page when a smaller field is focused) */
|
input, textarea, select { font-size: max(16px, 1em); } /* (iPadOS zooms the page when a smaller field is focused) */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* My Store: books ticked for "Add to people" */
|
||||||
|
.vcard.picked { outline: 3px solid var(--accent); outline-offset: 2px; border-radius: 12px; }
|
||||||
@@ -34,6 +34,13 @@ const hub = http.createServer((req, res) => users.runAs(req.headers['x-user'] ==
|
|||||||
assert.equal((await (await imp('again.epub', withCover)).json()).duplicate, true, 'the same book is not added twice');
|
assert.equal((await (await imp('again.epub', withCover)).json()).duplicate, true, 'the same book is not added twice');
|
||||||
ir = await (await imp('Plain_Name.epub', epub)).json(); assert.equal(ir.item.title, 'Rabbit Tales'); assert.equal(ir.noCover, true); assert.equal(ir.item.creator, 'A. Writer');
|
ir = await (await imp('Plain_Name.epub', epub)).json(); assert.equal(ir.item.title, 'Rabbit Tales'); assert.equal(ir.noCover, true); assert.equal(ir.item.creator, 'A. Writer');
|
||||||
ir = await (await imp('Some_Pdf_Book.pdf', Buffer.from('%PDF-1.4 hello'))).json(); assert.equal(ir.item.title, 'Some Pdf Book'); assert.equal(ir.noAuthor, true);
|
ir = await (await imp('Some_Pdf_Book.pdf', Buffer.from('%PDF-1.4 hello'))).json(); assert.equal(ir.item.title, 'Some Pdf Book'); assert.equal(ir.noAuthor, true);
|
||||||
|
// the admin gives chosen books to chosen people
|
||||||
|
const impd = (await (await fetch(B + '/items')).json()).items.filter(x => x.id !== book.id && x.kind === 'book').map(x => x.id);
|
||||||
|
assert.equal((await fetch(B + '/people', { headers: bob })).status, 403); const ppl = await (await fetch(B + '/people')).json(); assert.ok(ppl.some(x => x.id === people.b.id && x.books));
|
||||||
|
assert.equal((await post('/assign', { items: impd, users: [people.b.id] }, bob)).status, 403); assert.equal((await post('/assign', { items: [], users: [people.b.id] })).status, 400); assert.equal((await post('/assign', { items: impd, users: [] })).status, 400);
|
||||||
|
let as = await (await post('/assign', { items: impd, users: [people.b.id] })).json(); assert.equal(as.people[0].added + as.people[0].had, impd.length, 'one of them is the book Bob already has'); assert.ok(as.people[0].added >= 2); assert.equal(as.people[0].failed, 0);
|
||||||
|
as = await (await post('/assign', { items: impd, users: [people.b.id] })).json(); assert.equal(as.people[0].added, 0); assert.equal(as.people[0].had, impd.length, 'a repeat is left alone');
|
||||||
|
assert.ok((await (await fetch(base + '/api/read/list', { headers: bob })).json()).some(x => x.title === 'Café & Cream'), 'the book is on Bob\'s shelf');
|
||||||
for (const it of (await (await fetch(B + '/items')).json()).items.filter(x => x.id !== book.id && x.kind === 'book')) await fetch(`${B}/items/${it.id}`, { method: 'DELETE' });
|
for (const it of (await (await fetch(B + '/items')).json()).items.filter(x => x.id !== book.id && x.kind === 'book')) await fetch(`${B}/items/${it.id}`, { method: 'DELETE' });
|
||||||
// music: several files, streamed with ranges
|
// music: several files, streamed with ranges
|
||||||
let alb = await (await post('/items', { kind: 'music', title: 'Album', creator: 'Band' })).json(); const mp3 = Buffer.from('0123456789abcdefghij');
|
let alb = await (await post('/items', { kind: 'music', title: 'Album', creator: 'Band' })).json(); const mp3 = Buffer.from('0123456789abcdefghij');
|
||||||
|
|||||||
Reference in new issue
Block a user