diora-web/static/js/folders.js

214 lines
12 KiB
JavaScript
Raw Permalink Normal View History

Bibliothek neu: Ordner als Filter-Chips überall, Cover-Raster, Cover verschlüsselt und generiert (noch nicht veröffentlicht) - Ordner wie Tags (docs/library.md): folders.js zeichnet die Chip-Reihe, das Blatt „In Ordner …“ (Häkchen, Neuer Ordner) und „Ordner verwalten“ (umbenennen = zusammenlegen, löschen, Reihenfolge). E-Books: `folders` im verschlüsselten Meta, Reihenfolge in verschlüsselten Bibliotheks-Einstellungen (/books/prefs/). Podcasts/Hörbücher: PodcastFeed.folders (Migration 0021, übernimmt `folder`), /podcasts/folders/ (+rename, +delete). Blogs: Reihenfolge (/feeds/folders/order/). - Bibliothek (53-library.js): Kopf mit Suche, Sortierung, Raster/Liste und +; Chips Angefangen · Ungelesen · Gelesen · Ordner; eine „Weiterlesen“-Karte; Mehrfachauswahl (lange drücken). E-Ink und diora Reader beginnen als Liste. - Cover Ende-zu-Ende verschlüsselt (covers.js, /books/<id>/cover/): aus dem E-Book (EPUB-Coverbild, PDF Seite 1), 300 px, mit dem Nutzerschlüssel verschlüsselt, einmal hochgeladen; sparsam im Leerlauf, nie beim Lesen, nie auf E-Ink. - Bücher ohne Cover bekommen eins (gencover.js): Stichwörter aus Titel, Autor, Regal und Ordnern wählen ein Gestaltungskonzept (Konstruktivismus, Geometrie, Psyche, Wellen, Karte, Kosmos, Ornament, Botanik, Berge, Zellen, Raster, Schatten, Typo, sonst Bauhaus); ein Hash aus Titel und Autor Palette, Aufbau und Details – einzigartig, überall gleich, nichts gespeichert. - Hörbücher: Ordner-Chips statt Kacheln, „In Ordner …“ mit mehreren Ordnern. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:44:23 +02:00
/*
* Folders, the same everywhere (docs/library.md): like tags (a thing may be in several),
* shown as a row of filter chips; assigned with a sheet of checkboxes; managed in another
* (rename = merge onto an existing name, delete = the things stay, order by ↑ ↓). Each area
* keeps its folders where it keeps them and calls these for the look and the dialogs:
*
* DioraFolders.chips(el, {folders: [{name, count}], active, none, lead, onPick, onManage})
* DioraFolders.assign({title, folders: [names], selected: [names], onDone(names)})
* DioraFolders.manage({title, folders: [names], onRename(old, new), onDelete(name), onOrder(names)})
*
* Runs everywhere (web, desktop, both Android apps). Chrome-61-safe: no ?. / ??.
*/
(function () {
'use strict';
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, c =>
({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'}[c]));
}
// ---- the chip row ----------------------------------------------------------------------
// lead: chips before the folders (the library's Angefangen/Ungelesen …): [{id, label, count, on}],
// picked with onPick({lead: id}). active: null = all, '' = without a folder, else a name.
function chips(el, o) {
const lead = o.lead || [];
const leadOn = lead.some(c => c.on);
let h = '<div class="dfo-row" role="toolbar">';
if (!lead.length) h += chip('dfo-all', 'Alle', null, o.active == null && !leadOn, 'data-dfo-all="1"');
lead.forEach(c => { h += chip('', c.label, c.count, !!c.on, 'data-dfo-lead="' + esc(c.id) + '"'); });
if (lead.length && (o.folders || []).length) h += '<span class="dfo-sep" aria-hidden="true"></span>';
(o.folders || []).forEach(f => {
h += chip('', f.name, f.count, o.active === f.name, 'data-dfo-folder="' + esc(f.name) + '"');
});
if (o.none) h += chip('dfo-none', 'Ohne Ordner', o.none, o.active === '', 'data-dfo-folder=""');
if (o.onManage) h += '<button type="button" class="dfo-chip dfo-manage" data-dfo-manage="1" title="Ordner verwalten" aria-label="Ordner verwalten">⋯</button>';
el.innerHTML = h + '</div>';
el.onclick = e => {
const b = e.target.closest('button');
if (!b) return;
if (b.dataset.dfoManage) { o.onManage(); return; }
if (b.dataset.dfoAll) { o.onPick(null); return; }
if (b.dataset.dfoLead != null) { o.onPick({lead: b.dataset.dfoLead}); return; }
if (b.dataset.dfoFolder != null) {
const name = b.dataset.dfoFolder;
o.onPick(o.active === name ? null : name); // a second tap shows everything again
}
};
}
function chip(cls, label, count, on, attrs) {
return '<button type="button" class="dfo-chip ' + cls + (on ? ' on' : '') + '" ' + attrs + '>' + esc(label) +
(count != null ? ' <span class="dfo-count">' + count + '</span>' : '') + '</button>';
}
// ---- a sheet (above the page and the book views, below the page's dialogs) -------------------
let open = null;
function sheet(title, body, onClose) {
close();
const wrap = document.createElement('div');
wrap.className = 'dfo-scrim';
wrap.innerHTML = '<div class="dfo-sheet" role="dialog" aria-label="' + esc(title) + '">' +
'<div class="dfo-head"><strong>' + esc(title) + '</strong>' +
'<button type="button" class="dfo-x" data-dfo-close="1" aria-label="Schließen">✕</button></div>' +
'<div class="dfo-body"></div></div>';
wrap.querySelector('.dfo-body').appendChild(body);
wrap.addEventListener('click', e => {
if (e.target === wrap || e.target.closest('[data-dfo-close]')) close();
});
document.body.appendChild(wrap);
open = {wrap, onClose};
return wrap;
}
function close() {
if (!open) return;
const o = open;
open = null;
o.wrap.remove();
if (o.onClose) o.onClose();
}
const back = window.__dioraBack;
window.__dioraBack = function () {
if (open) { close(); return true; }
return back ? back() : false;
};
document.addEventListener('keydown', e => { if (e.key === 'Escape' && open) close(); });
// ---- "In Ordner …" ---------------------------------------------------------------------------
function assign(o) {
const chosen = new Set(o.selected || []);
const names = (o.folders || []).slice();
const body = document.createElement('div');
const draw = () => {
body.innerHTML = '<div class="dfo-list">' + (names.length ? names.map(n =>
'<label class="dfo-check"><input type="checkbox" value="' + esc(n) + '"' + (chosen.has(n) ? ' checked' : '') + '> ' +
esc(n) + '</label>').join('') : '<p class="dfo-note">Noch keine Ordner.</p>') + '</div>' +
'<form class="dfo-new"><input class="dfo-input" name="n" maxlength="100" placeholder="Neuer Ordner" autocomplete="off">' +
'<button type="submit" class="dfo-btn">Anlegen</button></form>' +
'<div class="dfo-actions"><button type="button" class="dfo-btn" data-dfo-close="1">Abbrechen</button>' +
'<button type="button" class="dfo-btn dfo-primary" data-dfo-ok="1">Übernehmen</button></div>';
};
draw();
body.addEventListener('change', e => {
if (e.target.type !== 'checkbox') return;
if (e.target.checked) chosen.add(e.target.value); else chosen.delete(e.target.value);
});
body.addEventListener('submit', e => {
e.preventDefault();
const n = ' '.concat(e.target.n.value).split(/\s+/).filter(Boolean).join(' ').slice(0, 100);
if (!n) return;
const same = names.filter(x => x.toLowerCase() === n.toLowerCase())[0];
if (!same) names.push(n);
chosen.add(same || n);
draw();
});
body.addEventListener('click', e => {
if (!e.target.closest('[data-dfo-ok]')) return;
const picked = names.filter(n => chosen.has(n));
close();
o.onDone(picked);
});
sheet(o.title || 'In Ordner', body);
}
// ---- "Ordner verwalten" ---------------------------------------------------------------------------
function manage(o) {
let names = (o.folders || []).slice();
const body = document.createElement('div');
const draw = () => {
body.innerHTML = names.length ? '<div class="dfo-list">' + names.map((n, i) =>
'<div class="dfo-mrow"><span class="dfo-mname">' + esc(n) + '</span>' +
'<button type="button" class="dfo-icon" data-up="' + i + '" aria-label="Nach oben"' + (i ? '' : ' disabled') + '>↑</button>' +
'<button type="button" class="dfo-icon" data-down="' + i + '" aria-label="Nach unten"' + (i < names.length - 1 ? '' : ' disabled') + '>↓</button>' +
'<button type="button" class="dfo-icon" data-rename="' + i + '" aria-label="Umbenennen">✎</button>' +
'<button type="button" class="dfo-icon" data-delete="' + i + '" aria-label="Löschen">✕</button></div>').join('') + '</div>' +
'<p class="dfo-note">Umbenennen auf einen vorhandenen Namen legt beide zusammen. Löschen entfernt nur den Ordner, nicht seinen Inhalt.</p>'
: '<p class="dfo-note">Noch keine Ordner. Sie entstehen beim Einordnen („In Ordner …“).</p>';
};
draw();
body.addEventListener('click', async e => {
const b = e.target.closest('button');
if (!b) return;
const ask = (msg, value) => typeof customPrompt === 'function' ? customPrompt(msg, value) : Promise.resolve(window.prompt(msg, value));
const sure = msg => typeof customConfirm === 'function' ? customConfirm(msg, {danger: true}) : Promise.resolve(window.confirm(msg));
if (b.dataset.up != null || b.dataset.down != null) {
const i = Number(b.dataset.up != null ? b.dataset.up : b.dataset.down);
const j = b.dataset.up != null ? i - 1 : i + 1;
if (j < 0 || j >= names.length) return;
const t = names[i]; names[i] = names[j]; names[j] = t;
draw();
o.onOrder(names.slice());
} else if (b.dataset.rename != null) {
const old = names[Number(b.dataset.rename)];
const value = await ask('Neuer Name für „' + old + '“:', old);
const n = value == null ? '' : String(value).split(/\s+/).filter(Boolean).join(' ').slice(0, 100);
if (!n || n === old) return;
await o.onRename(old, n);
const merged = names.filter(x => x.toLowerCase() === n.toLowerCase())[0];
names = merged ? names.filter(x => x !== old) : names.map(x => (x === old ? n : x));
draw();
} else if (b.dataset.delete != null) {
const n = names[Number(b.dataset.delete)];
if (!(await sure('Ordner „' + n + '“ löschen? Was darin liegt, bleibt.'))) return;
await o.onDelete(n);
names = names.filter(x => x !== n);
draw();
}
});
sheet(o.title || 'Ordner verwalten', body);
}
window.DioraFolders = {chips, assign, manage, close};
// ---- styles ----------------------------------------------------------------------------------------
const style = document.createElement('style');
style.textContent = [
'.dfo-row { display: flex; gap: 6px; align-items: center; overflow-x: auto; padding: 2px 0 6px; scrollbar-width: none; }',
'.dfo-row::-webkit-scrollbar { display: none; }',
'@media (min-width: 721px) { .dfo-row { flex-wrap: wrap; overflow: visible; } }',
'.dfo-chip { flex: none; min-height: 34px; padding: 0 12px; border-radius: 17px; border: 1px solid var(--border-strong, #444); background: none; color: inherit; font: inherit; font-size: 13px; cursor: pointer; white-space: nowrap; }',
'.dfo-chip.on { background: var(--accent, #e63946); border-color: var(--accent, #e63946); color: var(--on-accent, #fff); }',
'.dfo-count { opacity: .7; font-size: 12px; }',
'.dfo-none { font-style: italic; }',
'.dfo-manage { min-width: 34px; padding: 0 8px; }',
'.dfo-sep { flex: none; width: 1px; height: 20px; background: var(--border-strong, #444); margin: 0 2px; }',
// Above the book view (900), below the page's dialogs (990): a name is asked for on top.
'.dfo-scrim { position: fixed; top: 0; right: 0; bottom: 0; left: 0; z-index: 950; background: var(--scrim, rgba(0,0,0,.5)); display: flex; align-items: flex-end; justify-content: center; }',
'@media (min-width: 721px) { .dfo-scrim { align-items: center; } }',
'.dfo-sheet { width: 100%; max-width: 480px; max-height: 80vh; overflow-y: auto; background: var(--panel, #161616); color: var(--fg, #eee); border-radius: 14px 14px 0 0; padding: 12px 16px 18px; box-sizing: border-box; }',
'@media (min-width: 721px) { .dfo-sheet { border-radius: 14px; } }',
'.dfo-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }',
'.dfo-x, .dfo-icon { background: none; border: none; color: inherit; font: inherit; font-size: 17px; min-width: 38px; min-height: 38px; cursor: pointer; }',
'.dfo-icon[disabled] { opacity: .3; cursor: default; }',
'.dfo-check { display: flex; align-items: center; gap: 10px; padding: 9px 2px; border-bottom: 1px solid var(--border, #2a2a2a); cursor: pointer; }',
'.dfo-check input { width: 18px; height: 18px; }',
'.dfo-mrow { display: flex; align-items: center; border-bottom: 1px solid var(--border, #2a2a2a); }',
'.dfo-mname { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }',
'.dfo-new { display: flex; gap: 8px; margin: 12px 0 4px; }',
'.dfo-input { flex: 1; min-width: 0; padding: 9px 10px; border-radius: 6px; border: 1px solid var(--border, #2a2a2a); background: var(--bg, #000); color: inherit; font: inherit; }',
'.dfo-btn { min-height: 38px; padding: 0 14px; border-radius: 19px; border: 1px solid var(--border-strong, #444); background: none; color: inherit; font: inherit; font-size: 14px; cursor: pointer; }',
'.dfo-primary { background: var(--accent, #e63946); border-color: var(--accent, #e63946); color: var(--on-accent, #fff); }',
'.dfo-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; }',
'.dfo-note { color: var(--muted, #9a9a9a); font-size: 13px; line-height: 1.45; }',
'body.eink .dfo-chip.on, body.eink .dfo-primary { background: #000; color: #fff; }',
].join('\n');
document.head.appendChild(style);
})();