237 lines
13 KiB
JavaScript
237 lines
13 KiB
JavaScript
/*
|
|
* 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 =>
|
|
({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}[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.
|
|
|
|
// The folders fold away (a "Ordner" chip), per area (the list's element id), remembered on
|
|
// this device; folded, only the folder chosen stays in sight. Folded until opened.
|
|
function areaKey(el) {
|
|
const owner = el.id ? el : el.parentElement && el.parentElement.closest('[id]');
|
|
return 'diora_folders_folded_' + (owner ? owner.id : 'x');
|
|
}
|
|
|
|
function folded(el) {
|
|
try { return localStorage.getItem(areaKey(el)) !== '0'; } catch (e) { return true; }
|
|
}
|
|
|
|
function chips(el, o) {
|
|
const lead = o.lead || [];
|
|
const leadOn = lead.some(c => c.on);
|
|
const folders = o.folders || [];
|
|
const fold = folders.length > 0 && folded(el);
|
|
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 && folders.length) h += '<span class="dfo-sep" aria-hidden="true"></span>';
|
|
if (folders.length) {
|
|
h += '<button type="button" class="dfo-chip dfo-fold" data-dfo-fold="1" aria-expanded="' + !fold + '" title="' +
|
|
(fold ? 'Ordner zeigen' : 'Ordner einklappen') + '">Ordner ' + (fold ? '▸ <span class="dfo-count">' + folders.length + '</span>' : '▾') + '</button>';
|
|
}
|
|
folders.forEach(f => {
|
|
if (fold && o.active !== f.name) return;
|
|
h += chip('', f.name, f.count, o.active === f.name, 'data-dfo-folder="' + esc(f.name) + '"');
|
|
});
|
|
if (o.none && (!fold || o.active === '')) h += chip('dfo-none', 'Ohne Ordner', o.none, o.active === '', 'data-dfo-folder=""');
|
|
if (o.onManage && !fold) 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.dfoFold) {
|
|
try { localStorage.setItem(areaKey(el), fold ? '0' : '1'); } catch (err) {}
|
|
chips(el, o);
|
|
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-fold { border-style: dashed; }',
|
|
'.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);
|
|
})();
|