diora-web/static/js/projects.js
mrwnslz 66dc7a48fd
Some checks failed
Test / e2e (push) Waiting to run
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Has been cancelled
Projekt-Fenster: alle Leisten der App ausblenden (.nui-tab-bar); sw v143
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 20:23:16 +02:00

1180 lines
60 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* Projekte (docs/projects.md): projects of a preset — Forschung first: sources (papers by
* DOI, books, podcast episodes with a time, articles) with citation data and keys, notes in
* Markdown, export as BibTeX / CSL-JSON. End-to-end encrypted like the books: the project's
* name and every item's content are AES-GCM blobs (32-crypto.js), the server keeps them.
*
* In the navigation (nav.js) above Suche at the foot of the sidebar, a tab on a phone.
* Anything elsewhere joins a project through DioraProjects.link(kind, ref, info) — the book,
* episode and article sheets call it; DioraProjects.of(kind, ref) says where it is.
* On the desktop a project opens in a window of its own (dioraDesktop.openWindow).
* Chrome-61-safe: no ?. / ?? / optional catch binding.
*/
(function () {
'use strict';
const tab = document.getElementById('tab-projekte');
if (!tab) return;
const PRESETS = {
research: {
label: 'Forschung',
hint: 'Papers, Bücher, Folgen und Artikel als Quellen, Zitationen, Notizen.',
tabs: [['sources', 'Quellen'], ['notes', 'Notizen']],
},
};
const KIND = {
paper: {label: 'Paper', many: 'Papers', glyph: '¶'},
book: {label: 'Buch', many: 'Bücher', glyph: '▤'},
episode: {label: 'Folge', many: 'Folgen', glyph: '◎'},
article: {label: 'Artikel', many: 'Artikel', glyph: '✎'},
};
const SOURCE_KINDS = ['paper', 'book', 'episode', 'article'];
const st = {
list: null, // [{id, preset, data, archived, items, updated_at}]
links: [], // [{project, kind, ref}]
error: '',
open: null, // {id, preset, data, iv, items: [{id, kind, ref, data, iv, …}]}
view: 'sources',
filter: 'all',
expanded: null, // item id whose actions are shown
note: null, // the note being edited: item id, or 'new'
preview: false,
adding: null, // {q, loading, error, doi: {csl}, found: [...]}
creating: null, // {preset}
menu: false,
};
// A window of its own (desktop): only this project, no navigation.
const solo = /[?&]project=(\d+)/.exec(location.search);
if (solo) document.documentElement.classList.add('prj-solo');
// ---- helpers -------------------------------------------------------------------------
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, c =>
({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'}[c]));
}
async function api(path, body) {
const opts = {credentials: 'same-origin', cache: 'no-store'};
if (body !== undefined) {
opts.method = 'POST';
opts.headers = {'Content-Type': 'application/json'};
opts.body = JSON.stringify(body);
}
const r = await fetch(path, opts);
const d = await r.json().catch(() => null);
if (r.status === 409 && d) { const e = new Error('stale'); e.stale = d; throw e; }
if (!r.ok || !d) throw new Error((d && d.error) || ('HTTP ' + r.status));
return d;
}
async function seal(obj) {
const key = await getOrCreateEncKey();
const out = await encryptBytes(key, new TextEncoder().encode(JSON.stringify(obj)));
return {data_ct: out.ciphertext, data_iv: out.iv};
}
async function open_(ct, iv) {
try {
const key = await getOrCreateEncKey();
return JSON.parse(new TextDecoder().decode(await decryptBytes(key, iv, ct)));
} catch (e) {
return null; // another key (an old device?): shown as unreadable
}
}
function toast(text) {
let t = document.getElementById('prj-toast');
if (!t) {
t = document.createElement('div');
t.id = 'prj-toast';
document.body.appendChild(t);
}
t.textContent = text;
t.classList.add('prj-show');
clearTimeout(toast.timer);
toast.timer = setTimeout(() => t.classList.remove('prj-show'), 2800);
}
function go(id) {
if (window.DioraNav) window.DioraNav.go(id);
else if (typeof showTab === 'function') showTab(id);
}
function nameOf(p) {
return p && p.data ? (p.data.name || 'Ohne Namen') : 'Nicht lesbar';
}
function download(name, text, type) {
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([text], {type: type}));
a.download = name;
document.body.appendChild(a);
a.click();
setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1000);
}
async function copy(text) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch (e) {
const t = document.createElement('textarea');
t.value = text;
document.body.appendChild(t);
t.select();
let ok = false;
try { ok = document.execCommand('copy'); } catch (e2) {}
t.remove();
return ok;
}
}
// ---- citation data (CSL-JSON) ----------------------------------------------------------
function year(csl) {
const d = csl && (csl.issued || csl.published || csl['published-print'] || csl['published-online']);
const p = d && d['date-parts'] && d['date-parts'][0];
return p && p[0] ? String(p[0]) : '';
}
function dateParts(iso) {
const d = iso ? new Date(iso) : null;
return d && !isNaN(d) ? {'date-parts': [[d.getFullYear(), d.getMonth() + 1, d.getDate()]]} : undefined;
}
function names(text) {
// "Karl Marx, Friedrich Engels" / "Marx, Karl; Engels, Friedrich" → CSL names.
const s = String(text || '').trim();
if (!s) return [];
const parts = s.indexOf(';') >= 0 ? s.split(';') : s.split(/,\s*(?=[^,]+\s)|\s+(?:und|and|&)\s+/);
return parts.map(x => x.trim()).filter(Boolean).map(x => {
if (x.indexOf(',') > 0) {
const p = x.split(',');
return {family: p[0].trim(), given: p.slice(1).join(',').trim()};
}
const w = x.split(/\s+/);
return w.length > 1 ? {family: w[w.length - 1], given: w.slice(0, -1).join(' ')} : {literal: x};
});
}
function authorsText(csl, short) {
const a = (csl && (csl.author || csl.editor)) || [];
const n = a.map(x => x.family || x.literal || '').filter(Boolean);
if (!n.length) return '';
if (short) return n.length > 2 ? n[0] + ' u. a.' : n.join(' & ');
return n.length > 4 ? n.slice(0, 3).join(', ') + ' u. a.' : n.join(', ');
}
function cslTitle(csl) {
const t = csl && csl.title;
return Array.isArray(t) ? (t[0] || '') : (t || '');
}
function container(csl) {
const c = csl && csl['container-title'];
return Array.isArray(c) ? (c[0] || '') : (c || '');
}
// The citation data of a linked thing, from what diora knows of it.
function cslFor(kind, info) {
if (kind === 'book') {
const csl = {type: 'book', title: info.title || '', author: names(info.author)};
if (info.isbn) csl.ISBN = info.isbn;
if (info.series) csl['collection-title'] = info.series;
if (info.seriesIndex) csl['collection-number'] = String(info.seriesIndex);
return csl;
}
if (kind === 'episode') {
// Without hosts named, the podcast stands for them ("Wissenschaft im Brennpunkt (2025)").
const who = names(info.author);
return {type: 'broadcast', genre: 'Podcast', title: info.title || '', 'container-title': info.podcast || '',
author: who.length ? who : (info.podcast ? [{literal: info.podcast}] : []), issued: dateParts(info.date),
URL: info.url || undefined};
}
if (kind === 'article') {
return {type: info.news ? 'article-newspaper' : 'post-weblog', title: info.title || '',
'container-title': info.source || '', author: names(info.author), issued: dateParts(info.date),
URL: info.url || undefined};
}
return {};
}
// A key for [@key]: family name of the first author, year, first word of the title.
function makeKey(csl, taken) {
const fold = s => String(s || '').normalize('NFKD').replace(/[̀-ͯ]/g, '')
.replace(/ß/g, 'ss').replace(/[^A-Za-z0-9]+/g, '');
const a = (csl.author || csl.editor || [])[0];
const who = fold(a ? (a.family || a.literal) : '').toLowerCase() || 'anon';
const skip = /^(the|a|an|der|die|das|ein|eine|on|of|zur|zum|uber|über)$/i;
const word = (cslTitle(csl).split(/\s+/).filter(w => !skip.test(w))[0] || '');
let key = who + (year(csl) || 'oj') + fold(word).toLowerCase().slice(0, 12);
let n = 0;
const base = key;
while (taken[key]) key = base + String.fromCharCode(97 + (n++ % 26)) + (n > 26 ? n : '');
return key;
}
function takenKeys(exceptId) {
const t = {};
((st.open && st.open.items) || []).forEach(i => {
if (i.id !== exceptId && i.data && i.data.key) t[i.data.key] = true;
});
return t;
}
const BIB_TYPE = {'article-journal': 'article', 'paper-conference': 'inproceedings', book: 'book',
chapter: 'incollection', thesis: 'phdthesis', report: 'techreport', 'posted-content': 'misc',
broadcast: 'misc', 'post-weblog': 'online', 'article-newspaper': 'article', webpage: 'online'};
function bibtex(items) {
const field = (k, v) => v ? ' ' + k + ' = {' + String(v).replace(/[{}]/g, '') + '},\n' : '';
return items.map(i => {
const c = i.data.csl || {};
const people = (c.author || []).map(a => a.literal || ((a.family || '') + (a.given ? ', ' + a.given : ''))).join(' and ');
return '@' + (BIB_TYPE[c.type] || 'misc') + '{' + i.data.key + ',\n' +
field('author', people) + field('title', cslTitle(c)) +
field(c.type === 'article-journal' || c.type === 'article-newspaper' ? 'journal' : 'booktitle', container(c)) +
field('year', year(c)) + field('volume', c.volume) + field('number', c.issue) + field('pages', c.page) +
field('publisher', c.publisher) + field('series', c['collection-title']) + field('isbn', c.ISBN) +
field('doi', c.DOI) + field('url', c.URL) +
field('note', i.kind === 'episode' && i.data.position ? 'ab ' + clock(i.data.position) : '') + '}\n';
}).join('\n');
}
function cslJson(items) {
return JSON.stringify(items.map(i => Object.assign({}, i.data.csl || {}, {id: i.data.key})), null, 2);
}
function clock(sec) {
sec = Math.max(0, Math.round(sec || 0));
const h = Math.floor(sec / 3600), m = Math.floor(sec / 60) % 60, s = sec % 60;
const two = n => (n < 10 ? '0' : '') + n;
return (h ? h + ':' + two(m) : m) + ':' + two(s);
}
// ---- Markdown (the notes) ----------------------------------------------------------------
function inline(s, cite) {
s = esc(s);
s = s.replace(/`([^`]+)`/g, '<code>$1</code>');
s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>').replace(/(^|[^*])\*([^*\s][^*]*)\*/g, '$1<em>$2</em>');
s = s.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
s = s.replace(/(^|[\s(])(https?:\/\/[^\s<]+[^\s<.,;)])/g, '$1<a href="$2" target="_blank" rel="noopener">$2</a>');
// [@key] and [@key, S. 12]: the source, by author and year.
s = s.replace(/\[@([\w:.\-]+)(?:,\s*([^\]]+))?\]/g, (m, key, loc) => {
const i = cite ? cite[key] : null;
const label = i ? (authorsText(i.data.csl, true) || cslTitle(i.data.csl)) + ' ' + year(i.data.csl) : '@' + key;
return '<span class="prj-cite' + (i ? '' : ' prj-cite-missing') + '" data-cite="' + key + '">(' + label.trim() +
(loc ? ', ' + loc : '') + ')</span>';
});
return s;
}
function markdown(text, cite) {
const lines = String(text || '').replace(/\r/g, '').split('\n');
let out = '', list = null, para = [], quote = [];
const flush = () => {
if (para.length) { out += '<p>' + inline(para.join(' '), cite) + '</p>'; para = []; }
if (quote.length) { out += '<blockquote>' + inline(quote.join(' '), cite) + '</blockquote>'; quote = []; }
if (list) { out += '</' + list + '>'; list = null; }
};
for (const raw of lines) {
const line = raw.replace(/\s+$/, '');
let m;
if (!line.trim()) { flush(); continue; }
if ((m = /^(#{1,4})\s+(.*)$/.exec(line))) { flush(); out += '<h' + (m[1].length + 2) + '>' + inline(m[2], cite) + '</h' + (m[1].length + 2) + '>'; continue; }
if ((m = /^\s*[-*+]\s+(?:\[( |x)\]\s+)?(.*)$/i.exec(line))) {
if (para.length || quote.length || list !== 'ul') { flush(); out += '<ul>'; list = 'ul'; }
out += '<li>' + (m[1] ? (m[1] === ' ' ? '☐ ' : '☑ ') : '') + inline(m[2], cite) + '</li>';
continue;
}
if ((m = /^\s*\d+[.)]\s+(.*)$/.exec(line))) {
if (para.length || quote.length || list !== 'ol') { flush(); out += '<ol>'; list = 'ol'; }
out += '<li>' + inline(m[1], cite) + '</li>';
continue;
}
if ((m = /^>\s?(.*)$/.exec(line))) { if (para.length || list) flush(); quote.push(m[1]); continue; }
if (/^(-{3,}|\*{3,})$/.test(line.trim())) { flush(); out += '<hr>'; continue; }
if (list) flush();
para.push(line.trim());
}
flush();
return out;
}
// ---- loading -----------------------------------------------------------------------------
async function loadList() {
try {
const d = await api('/projects/');
const list = [];
for (const p of d.projects) {
list.push({id: p.id, preset: p.preset, archived: p.archived, items: p.items, updated_at: p.updated_at,
position: p.position, iv: p.data_iv, data: await open_(p.data_ct, p.data_iv)});
}
st.list = list;
st.links = d.links || [];
st.error = '';
} catch (e) {
if (st.list === null) st.list = [];
st.error = navigator.onLine === false ? 'Offline: Projekte brauchen gerade eine Verbindung.' : 'Projekte nicht erreichbar.';
}
linksChanged();
}
async function openProject(id, keepView) {
const d = await api('/projects/' + id + '/');
const items = [];
for (const i of d.items) items.push(Object.assign({}, i, {iv: i.data_iv, data: await open_(i.data_ct, i.data_iv)}));
st.open = {id: d.project.id, preset: d.project.preset, archived: d.project.archived, iv: d.project.data_iv,
data: await open_(d.project.data_ct, d.project.data_iv), items: items};
if (!keepView) {
st.view = 'sources';
st.filter = 'all';
st.expanded = null;
st.note = null;
st.adding = null;
st.menu = false;
}
setHash();
render();
}
function setHash() {
if (solo || !st.open) return;
const want = '#projekte/' + st.open.id;
if (location.hash !== want && /^#projekte/.test(location.hash || '#projekte')) history.replaceState(null, '', want);
}
// ---- changing ----------------------------------------------------------------------------
async function addItem(kind, ref, data, projectId) {
const pid = projectId || st.open.id;
const body = Object.assign({kind: kind}, ref != null ? {ref: ref} : {}, await seal(data));
const d = await api('/projects/' + pid + '/items/add/', body);
if (!d.existing && ref != null) st.links.push({project: pid, kind: kind, ref: ref});
if (st.open && st.open.id === pid && !d.existing) {
st.open.items.push(Object.assign({}, d.item, {iv: d.item.data_iv, data: data}));
}
const p = (st.list || []).filter(x => x.id === pid)[0];
if (p && !d.existing) p.items++;
linksChanged();
return d;
}
// Save an item's data; changed elsewhere since: theirs is kept, ours merged onto it where
// that's clear (a note's text: a copy beside it).
async function saveItem(item, data) {
try {
const d = await api('/projects/items/' + item.id + '/', Object.assign({base_iv: item.iv}, await seal(data)));
item.iv = d.item.data_iv;
item.data = data;
return true;
} catch (e) {
if (!e.stale) throw e;
const theirs = e.stale.item;
item.iv = theirs.data_iv;
item.data = await open_(theirs.data_ct, theirs.data_iv);
if (item.kind === 'note') {
await addItem('note', null, Object.assign({}, data, {title: (data.title || 'Notiz') + ' (Konflikt)'}));
toast('Die Notiz wurde auch anderswo geändert: deine Fassung liegt als Kopie daneben.');
} else {
toast('Anderswo geändert: die neuere Fassung ist geladen.');
}
return false;
}
}
async function removeItem(item) {
await api('/projects/items/' + item.id + '/delete/', {});
st.open.items = st.open.items.filter(i => i.id !== item.id);
st.links = st.links.filter(l => !(l.project === st.open.id && l.kind === item.kind && l.ref === item.ref));
const p = (st.list || []).filter(x => x.id === st.open.id)[0];
if (p) p.items = Math.max(0, p.items - 1);
linksChanged();
}
async function saveProject(data) {
try {
const d = await api('/projects/' + st.open.id + '/', Object.assign({base_iv: st.open.iv}, await seal(data)));
st.open.iv = d.project.data_iv;
st.open.data = data;
} catch (e) {
if (!e.stale) throw e;
st.open.iv = e.stale.project.data_iv;
st.open.data = await open_(e.stale.project.data_ct, e.stale.project.data_iv);
toast('Anderswo geändert: die neuere Fassung ist geladen.');
}
const p = (st.list || []).filter(x => x.id === st.open.id)[0];
if (p) { p.data = st.open.data; p.iv = st.open.iv; }
}
async function createProject(preset, name, description) {
const d = await api('/projects/create/', Object.assign({preset: preset}, await seal({name: name, description: description || ''})));
await loadList();
await openProject(d.project.id);
}
// ---- linking from elsewhere ----------------------------------------------------------------
const linkListeners = [];
function linksChanged() {
linkListeners.slice().forEach(fn => { try { fn(); } catch (e) {} });
}
// What diora knows of a book (its meta is on this device only).
function bookInfo(id) {
const m = (typeof bookMetaCache !== 'undefined' && bookMetaCache[id]) || {};
return {title: m.title || ('Buch #' + id), author: m.author || '', isbn: m.isbn || '', series: m.series || '',
seriesIndex: m.seriesIndex || '', type: m.type || ''};
}
function snapshot(kind, ref, info) {
info = info || {};
if (kind === 'book') info = Object.assign(bookInfo(ref), info);
const csl = cslFor(kind, info);
const data = {csl: csl, note: ''};
if (kind === 'episode') {
data.podcast = info.podcast || '';
data.feed = info.feed || null;
if (info.position > 5) data.position = Math.round(info.position);
}
if (kind === 'book') data.type = info.type;
return data;
}
let picker = null;
function closePicker() {
if (picker) { picker.remove(); picker = null; }
}
// The sheet "Mit Projekt verknüpfen": the projects, where it is already ticked.
async function link(kind, ref, info) {
if (!SOURCE_KINDS.some(k => k === kind)) return;
if (st.list === null) await loadList();
closePicker();
picker = document.createElement('div');
picker.className = 'prj-picker';
const projects = (st.list || []).filter(p => !p.archived && p.data);
const inIt = id => st.links.some(l => l.project === id && l.kind === kind && l.ref === Number(ref));
picker.innerHTML = '<div class="prj-picker-box" role="dialog" aria-label="Mit Projekt verknüpfen">' +
'<div class="prj-picker-h">Mit Projekt verknüpfen</div>' +
(info && info.title ? '<div class="prj-picker-what">' + esc(info.title) +
(info.position > 5 ? ' · ab ' + clock(info.position) : '') + '</div>' : '') +
projects.map(p => '<button type="button" class="prj-picker-p" data-pick="' + p.id + '"' + (inIt(p.id) ? ' disabled' : '') + '>' +
'<span>' + esc(nameOf(p)) + '</span><span class="prj-muted">' + (inIt(p.id) ? '✓ verknüpft' : esc(PRESETS[p.preset] ? PRESETS[p.preset].label : '')) +
'</span></button>').join('') +
(projects.length ? '' : '<p class="prj-muted">Noch kein Projekt.</p>') +
'<button type="button" class="prj-picker-p prj-picker-new" data-pick="new">+ Neues Projekt …</button>' +
'<button type="button" class="prj-btn prj-picker-close" data-pick="close">Schließen</button></div>';
document.body.appendChild(picker);
picker.addEventListener('click', async e => {
if (e.target === picker) { closePicker(); return; }
const b = e.target.closest('[data-pick]');
if (!b || b.disabled) return;
const what = b.dataset.pick;
if (what === 'close') { closePicker(); return; }
if (what === 'new') {
closePicker();
st.creating = {preset: 'research', then: {kind: kind, ref: ref, info: info}};
st.open = null;
go('projekte');
render();
return;
}
b.disabled = true;
try {
const pid = Number(what);
const data = snapshot(kind, Number(ref), info);
await keyed(data, pid);
const d = await addItem(kind, Number(ref), data, pid);
const p = st.list.filter(x => x.id === pid)[0];
toast(d.existing ? 'Schon in ' + nameOf(p) : 'Mit ' + nameOf(p) + ' verknüpft');
closePicker();
if (st.open && st.open.id === pid) render();
} catch (err) {
b.disabled = false;
toast('Verknüpfen ging nicht: ' + err.message);
}
});
}
// A citation key, unique in the project (its items loaded for that when it isn't open).
async function keyed(data, pid) {
let taken = {};
if (st.open && st.open.id === pid) taken = takenKeys();
else {
try {
const d = await api('/projects/' + pid + '/');
for (const i of d.items) {
const x = await open_(i.data_ct, i.data_iv);
if (x && x.key) taken[x.key] = true;
}
} catch (e) {}
}
data.key = makeKey(data.csl || {}, taken);
return data;
}
// ---- adding sources inside a project ------------------------------------------------------------
const looksLikeDoi = q => /10\.\d{4,9}\/\S+|arxiv\.org\/(abs|pdf)\/|^(arxiv:)?\d{4}\.\d{4,5}(v\d+)?$/i.test(q.trim());
async function findToAdd(q) {
q = q.trim();
st.adding = {q: q, loading: true, error: '', doi: null, found: []};
render();
try {
if (looksLikeDoi(q)) {
const d = await api('/projects/doi/?q=' + encodeURIComponent(q));
st.adding.doi = d;
} else {
const found = [];
const qw = q.toLowerCase().split(/\s+/).filter(Boolean);
const has = t => { t = String(t || '').toLowerCase(); return qw.every(w => t.indexOf(w) >= 0); };
const books = typeof _lastBookListData !== 'undefined' && _lastBookListData && _lastBookListData.length
? _lastBookListData : Object.keys(typeof bookMetaCache !== 'undefined' ? bookMetaCache : {}).map(id =>
Object.assign({id: Number(id)}, bookMetaCache[id]));
books.filter(b => has(b.title + ' ' + b.author + ' ' + (b.series || ''))).slice(0, 8).forEach(b =>
found.push({kind: 'book', ref: b.id, title: b.title, sub: b.author || '', info: {}}));
if (q.length >= 2) {
const s = await api('/search/?q=' + encodeURIComponent(q));
(s.episodes || []).slice(0, 8).forEach(e => found.push({kind: 'episode', ref: e.id, title: e.title,
sub: e.feed__title || '', info: episodeInfo(e)}));
(s.articles || []).slice(0, 8).forEach(a => found.push({kind: 'article', ref: a.id, title: a.title,
sub: a.source || '', info: {title: a.title, source: a.source, author: a.author, date: a.published, news: a.kind === 'news'}}));
}
st.adding.found = found;
}
} catch (e) {
st.adding.error = e.message;
}
st.adding.loading = false;
render();
}
function episodeInfo(e) {
return {title: e.title, podcast: e.feed__title || '', author: e.author || e.feed__author || '', date: e.pub_date,
url: e.link || e.audio_url || '', feed: e.feed__id};
}
// ---- drawing -------------------------------------------------------------------------------
function render() {
if (!tab.querySelector('.prj')) tab.innerHTML = '<div class="prj"></div>';
const root = tab.querySelector('.prj');
const focus = document.activeElement && root.contains(document.activeElement) ? document.activeElement : null;
const keep = focus && focus.dataset && focus.dataset.keep;
const sel = keep && typeof focus.selectionStart === 'number' ? [focus.selectionStart, focus.selectionEnd] : null;
root.innerHTML = st.creating ? createHtml() : st.open ? projectHtml() : listHtml();
if (keep) {
const again = root.querySelector('[data-keep="' + keep + '"]');
if (again) {
again.focus();
if (sel) try { again.setSelectionRange(sel[0], sel[1]); } catch (e) {}
}
}
}
function listHtml() {
let h = '<h2 class="prj-h1">Projekte</h2>';
if (st.list === null) return h + '<p class="prj-muted">Lädt …</p>';
if (st.error) h += '<p class="prj-muted">' + esc(st.error) + '</p>';
const active = st.list.filter(p => !p.archived);
const archived = st.list.filter(p => p.archived);
const card = p => '<button type="button" class="prj-card" data-open-project="' + p.id + '">' +
'<span class="prj-card-preset">' + esc(PRESETS[p.preset] ? PRESETS[p.preset].label : p.preset) + '</span>' +
'<span class="prj-card-name">' + esc(nameOf(p)) + '</span>' +
(p.data && p.data.description ? '<span class="prj-card-desc">' + esc(p.data.description) + '</span>' : '') +
'<span class="prj-muted">' + (p.items === 1 ? '1 Eintrag' : p.items + ' Einträge') + '</span></button>';
h += '<div class="prj-cards">' + active.map(card).join('') +
'<button type="button" class="prj-card prj-card-new" data-act="new"><span class="prj-card-plus">+</span>' +
'<span class="prj-card-name">Neues Projekt</span></button></div>';
if (archived.length) {
h += '<details class="prj-archived"><summary>Archiviert (' + archived.length + ')</summary><div class="prj-cards">' +
archived.map(card).join('') + '</div></details>';
}
return h;
}
function createHtml() {
const c = st.creating;
let h = '<button type="button" class="prj-back" data-act="cancel-new">‹ Projekte</button>';
h += '<h2 class="prj-h1">Neues Projekt</h2><div class="prj-presets">';
Object.keys(PRESETS).forEach(id => {
h += '<button type="button" class="prj-preset' + (c.preset === id ? ' on' : '') + '" data-preset="' + id + '">' +
'<span class="prj-preset-name">' + esc(PRESETS[id].label) + '</span><span class="prj-muted">' + esc(PRESETS[id].hint) + '</span></button>';
});
h += '<div class="prj-preset prj-preset-soon"><span class="prj-preset-name">Weitere Vorlagen</span>' +
'<span class="prj-muted">Lesekreis, Lernplan, Hörbuch … folgen.</span></div></div>';
h += '<form class="prj-form" data-form="create"><label>Name<input name="name" required maxlength="120" data-keep="new-name" autocomplete="off"></label>' +
'<label>Worum es geht (optional)<textarea name="description" rows="2" maxlength="600"></textarea></label>' +
'<button type="submit" class="prj-btn prj-primary">Anlegen</button></form>';
return h;
}
function sources() {
return st.open.items.filter(i => i.kind !== 'note');
}
function notes() {
return st.open.items.filter(i => i.kind === 'note');
}
function projectHtml() {
const p = st.open;
const preset = PRESETS[p.preset] || PRESETS.research;
let h = solo ? '' : '<button type="button" class="prj-back" data-act="back">‹ Projekte</button>';
h += '<div class="prj-head"><div><span class="prj-card-preset">' + esc(preset.label) + '</span>' +
'<h2 class="prj-h1">' + esc(p.data ? p.data.name : 'Nicht lesbar') + '</h2>' +
(p.data && p.data.description ? '<p class="prj-muted prj-desc">' + esc(p.data.description) + '</p>' : '') +
'</div><button type="button" class="prj-icon" data-act="menu" aria-label="Projekt-Menü">⋯</button></div>';
if (st.menu) {
h += '<div class="prj-menu">' +
(window.dioraDesktop && window.dioraDesktop.openWindow && !solo
? '<button type="button" class="prj-btn" data-act="window">In neuem Fenster öffnen</button>' : '') +
'<button type="button" class="prj-btn" data-act="rename">Umbenennen …</button>' +
'<button type="button" class="prj-btn" data-act="archive">' + (p.archived ? 'Aus dem Archiv holen' : 'Archivieren') + '</button>' +
'<button type="button" class="prj-btn prj-danger" data-act="delete">Löschen …</button></div>';
}
h += '<div class="prj-tabs">' + preset.tabs.map(t => '<button type="button" class="prj-tab' + (st.view === t[0] ? ' on' : '') +
'" data-view="' + t[0] + '">' + esc(t[1]) + '<span class="prj-count">' +
(t[0] === 'sources' ? sources().length : notes().length) + '</span></button>').join('') + '</div>';
h += st.view === 'notes' ? notesHtml() : sourcesHtml();
return h;
}
function sourceMeta(i) {
const c = (i.data && i.data.csl) || {};
const bits = [authorsText(c), year(c), container(c) || (i.data && i.data.podcast) || ''];
if (i.kind === 'episode' && i.data.position) bits.push('ab ' + clock(i.data.position));
return bits.filter(Boolean).map(esc).join(' · ');
}
function sourcesHtml() {
const all = sources();
const a = st.adding;
let h = '<form class="prj-add" data-form="add"><input name="q" placeholder="DOI, arXiv-Link oder Titel aus deiner Bibliothek …" ' +
'autocomplete="off" data-keep="add-q" value="' + esc(a ? a.q : '') + '"><button type="submit" class="prj-btn prj-primary">Suchen</button></form>';
if (a) {
h += '<div class="prj-found">';
if (a.loading) h += '<p class="prj-muted">Sucht …</p>';
else if (a.error) h += '<p class="prj-muted">' + esc(a.error) + '</p>';
else if (a.doi) {
const c = a.doi.csl;
h += '<div class="prj-cand"><span class="prj-kind">Paper</span><div class="prj-text"><b>' + esc(cslTitle(c)) + '</b>' +
'<span class="prj-muted">' + esc([authorsText(c), year(c), container(c)].filter(Boolean).join(' · ')) + '</span></div>' +
'<button type="button" class="prj-btn prj-primary" data-add-doi="1">Hinzufügen</button></div>';
} else if (!a.found.length) h += '<p class="prj-muted">Nichts gefunden. Eine DOI oder ein arXiv-Link findet jedes Paper.</p>';
else {
const linked = (kind, ref) => all.some(i => i.kind === kind && i.ref === ref);
h += a.found.map((f, n) => '<div class="prj-cand"><span class="prj-kind">' + esc(KIND[f.kind].label) + '</span>' +
'<div class="prj-text"><b>' + esc(f.title) + '</b><span class="prj-muted">' + esc(f.sub) + '</span></div>' +
(linked(f.kind, f.ref) ? '<span class="prj-muted">✓</span>'
: '<button type="button" class="prj-btn" data-add-found="' + n + '">+</button>') + '</div>').join('');
}
h += '<button type="button" class="prj-link" data-act="close-add">Schließen</button></div>';
}
if (!all.length) {
return h + '<p class="prj-muted prj-empty">Noch keine Quellen. Füge oben ein Paper per DOI hinzu, oder verknüpfe ein Buch, ' +
'eine Folge oder einen Artikel über ihr Menü: „Mit Projekt verknüpfen“.</p>';
}
const counts = {};
all.forEach(i => { counts[i.kind] = (counts[i.kind] || 0) + 1; });
h += '<div class="prj-chips"><button type="button" class="prj-chip' + (st.filter === 'all' ? ' on' : '') + '" data-filter="all">Alle ' + all.length + '</button>' +
SOURCE_KINDS.filter(k => counts[k]).map(k => '<button type="button" class="prj-chip' + (st.filter === k ? ' on' : '') +
'" data-filter="' + k + '">' + KIND[k].many + ' ' + counts[k] + '</button>').join('') +
'<span class="prj-spacer"></span><button type="button" class="prj-chip" data-export="bib">BibTeX</button>' +
'<button type="button" class="prj-chip" data-export="csl">CSL-JSON</button></div>';
const shown = all.filter(i => st.filter === 'all' || i.kind === st.filter).sort((x, y) =>
(authorsText(x.data && x.data.csl) || cslTitle(x.data && x.data.csl)).localeCompare(
authorsText(y.data && y.data.csl) || cslTitle(y.data && y.data.csl), 'de'));
h += shown.map(i => {
if (!i.data) return '<div class="prj-row"><span class="prj-muted">Nicht lesbar (anderer Schlüssel)</span></div>';
const open = st.expanded === i.id;
return '<div class="prj-row' + (open ? ' open' : '') + '"><button type="button" class="prj-main" data-expand="' + i.id + '">' +
'<span class="prj-glyph">' + KIND[i.kind].glyph + '</span><span class="prj-text"><span class="prj-title">' +
esc(cslTitle(i.data.csl) || '?') + '</span><span class="prj-muted">' + sourceMeta(i) + '</span>' +
(i.data.note && !open ? '<span class="prj-note-line">' + esc(i.data.note) + '</span>' : '') + '</span>' +
'<code class="prj-key">' + esc(i.data.key || '') + '</code></button>' +
(open ? sourceActionsHtml(i) : '') + '</div>';
}).join('');
return h;
}
function sourceActionsHtml(i) {
const c = i.data.csl || {};
return '<div class="prj-actions">' +
'<label class="prj-field">Notiz<textarea data-note-of="' + i.id + '" data-keep="note-of-' + i.id + '" rows="3" ' +
'placeholder="Warum diese Quelle, was steht drin …">' + esc(i.data.note || '') + '</textarea></label>' +
'<label class="prj-field">Zitierschlüssel<input data-key-of="' + i.id + '" data-keep="key-of-' + i.id + '" value="' + esc(i.data.key || '') + '"></label>' +
'<div>' + (i.kind !== 'paper' || c.URL || c.DOI ? '<button type="button" class="prj-btn prj-primary" data-go="' + i.id + '">Öffnen</button>' : '') +
'<button type="button" class="prj-btn" data-cite-copy="' + i.id + '">[@' + esc(i.data.key || '') + '] kopieren</button>' +
'<button type="button" class="prj-btn prj-danger" data-remove="' + i.id + '">Entfernen</button></div></div>';
}
function notesHtml() {
if (st.note != null) return noteEditorHtml();
const list = notes().filter(n => n.data).sort((a, b) => String(b.updated_at).localeCompare(String(a.updated_at)));
let h = '<div class="prj-chips"><button type="button" class="prj-btn prj-primary" data-act="new-note">+ Notiz</button></div>';
if (!list.length) return h + '<p class="prj-muted prj-empty">Noch keine Notizen.</p>';
const groups = {};
const order = [];
list.forEach(n => {
const g = n.data.folder || 'Notizen';
if (!groups[g]) { groups[g] = []; order.push(g); }
groups[g].push(n);
});
order.sort((a, b) => (a === 'Notizen' ? -1 : b === 'Notizen' ? 1 : a.localeCompare(b, 'de')));
order.forEach(g => {
h += '<h3 class="prj-h3">' + esc(g) + '<span class="prj-count">' + groups[g].length + '</span></h3>';
h += groups[g].map(n => '<button type="button" class="prj-note" data-open-note="' + n.id + '">' +
'<span class="prj-title">' + esc(n.data.title || 'Ohne Titel') + '</span>' +
'<span class="prj-muted">' + esc(firstLine(n.data.body)) + '</span></button>').join('');
});
return h;
}
function firstLine(body) {
const l = String(body || '').split('\n').map(x => x.replace(/^[#>*\-\s]+/, '').trim()).filter(Boolean);
return (l[0] || '').slice(0, 140);
}
function citeIndex() {
const idx = {};
sources().forEach(i => { if (i.data && i.data.key) idx[i.data.key] = i; });
return idx;
}
function noteEditorHtml() {
const n = st.note === 'new' ? null : st.open.items.filter(i => i.id === st.note)[0];
const d = (n && n.data) || draft;
let h = '<div class="prj-note-head"><button type="button" class="prj-back" data-act="close-note">‹ Notizen</button>' +
'<span class="prj-muted prj-saved">' + esc(saveState) + '</span>' +
'<button type="button" class="prj-chip' + (st.preview ? ' on' : '') + '" data-act="preview">' + (st.preview ? 'Bearbeiten' : 'Ansehen') + '</button></div>';
h += '<input class="prj-note-title" data-note-title data-keep="note-title" placeholder="Titel" value="' + esc(d.title || '') + '">';
if (st.preview) {
h += '<div class="prj-md">' + markdown(d.body, citeIndex()) + '</div>';
const cited = {};
String(d.body || '').replace(/\[@([\w:.\-]+)/g, (m, k) => { cited[k] = true; return m; });
const idx = citeIndex();
const refs = Object.keys(cited).map(k => idx[k]).filter(Boolean);
if (refs.length) {
h += '<h3 class="prj-h3">Literatur</h3><ol class="prj-refs">' + refs.sort((a, b) =>
(authorsText(a.data.csl) || '').localeCompare(authorsText(b.data.csl) || '', 'de')).map(i =>
'<li>' + esc(reference(i)) + '</li>').join('') + '</ol>';
}
} else {
h += '<textarea class="prj-note-body" data-note-body data-keep="note-body" placeholder="Markdown: # Überschrift, - Liste, **fett**, [@schlüssel] zitiert eine Quelle">' +
esc(d.body || '') + '</textarea>';
h += '<div class="prj-muted prj-hint">Zitieren: [@' + esc((sources()[0] && sources()[0].data && sources()[0].data.key) || 'schlüssel') +
'] oder [@schlüssel, S. 12]</div>';
}
if (n) h += '<button type="button" class="prj-link prj-danger" data-act="delete-note">Notiz löschen</button>';
return h;
}
// A reference in the list (author-year, close to APA).
function reference(i) {
const c = i.data.csl || {};
const parts = [authorsText(c) || cslTitle(c), year(c) ? '(' + year(c) + ')' : '', cslTitle(c) && authorsText(c) ? cslTitle(c) + '.' : ''];
const where = [container(c), c.volume ? c.volume + (c.issue ? '(' + c.issue + ')' : '') : '', c.page].filter(Boolean).join(', ');
if (where) parts.push(where + '.');
if (c.publisher && !container(c)) parts.push(c.publisher + '.');
if (c.DOI) parts.push('https://doi.org/' + c.DOI);
else if (c.URL) parts.push(c.URL);
return parts.filter(Boolean).join(' ');
}
// ---- the note editor's saving ------------------------------------------------------------------
let draft = {title: '', body: ''};
let saveState = '';
let saveTimer = 0;
let saving = Promise.resolve();
function noteChanged(field, value) {
const n = st.note === 'new' ? null : st.open.items.filter(i => i.id === st.note)[0];
const d = Object.assign({}, (n && n.data) || draft);
d[field] = value;
if (n) n.data = d; else draft = d;
saveState = 'nicht gespeichert';
const label = tab.querySelector('.prj-saved');
if (label) label.textContent = saveState;
clearTimeout(saveTimer);
saveTimer = setTimeout(saveNote, 900);
}
function saveNote() {
clearTimeout(saveTimer);
saving = saving.then(async () => {
if (!st.open || st.note == null || saveState !== 'nicht gespeichert') return;
const label = () => tab.querySelector('.prj-saved');
try {
if (st.note === 'new') {
if (!draft.title && !draft.body) return;
const data = Object.assign({folder: 'Notizen'}, draft);
const d = await addItem('note', null, data);
st.note = d.item.id;
draft = {title: '', body: ''};
} else {
const n = st.open.items.filter(i => i.id === st.note)[0];
if (n) {
const pending = n.data;
const ok = await saveItem(n, pending);
if (!ok) render();
}
}
saveState = 'gespeichert';
} catch (e) {
saveState = 'nicht gespeichert (' + e.message + ')';
}
if (label()) label().textContent = saveState;
});
return saving;
}
// ---- events --------------------------------------------------------------------------------------
function itemById(id) {
return st.open ? st.open.items.filter(i => i.id === Number(id))[0] : null;
}
function openSource(i) {
const c = (i.data && i.data.csl) || {};
const what = {kind: i.kind, ref: i.ref, feed: i.data ? i.data.feed : 0, title: cslTitle(c),
podcast: i.data ? i.data.podcast : '', url: c.DOI ? 'https://doi.org/' + c.DOI : (c.URL || '')};
// A project's own window (desktop): the main window opens it, with its reader and player.
if (solo && what.kind !== 'paper' && window.dioraDesktop && window.dioraDesktop.openInMain) {
window.dioraDesktop.openInMain(what);
return;
}
openThing(what);
}
// {kind, ref, feed, url}: a book, an episode, an article where it lives; a paper by its link.
function openThing(w) {
if (w.kind === 'book') {
if (typeof openBook === 'function') openBook(w.ref);
} else if (w.kind === 'episode') {
const row = {id: w.ref, feed__id: w.feed, title: w.title || '', feed__title: w.podcast || ''};
if (window.__dioraPodcastOpenEpisode && window.__dioraPodcastOpenEpisode(row)) return;
if (w.feed && window.__dioraPodcastOpenFeed) window.__dioraPodcastOpenFeed(w.feed);
} else if (w.kind === 'article') {
if (window.__dioraFeedsOpenItem) window.__dioraFeedsOpenItem(w.ref);
} else if (w.url) {
window.open(w.url, '_blank', 'noopener');
}
}
tab.addEventListener('click', async e => {
const t = e.target.closest('button, [data-cite]');
if (!t) return;
const ds = t.dataset;
try {
if (ds.openProject) { await openProject(Number(ds.openProject)); return; }
if (ds.preset) { st.creating.preset = ds.preset; render(); return; }
if (ds.view) { await saveNote(); st.view = ds.view; st.note = null; render(); return; }
if (ds.filter) { st.filter = ds.filter; render(); return; }
if (ds.expand) { st.expanded = st.expanded === Number(ds.expand) ? null : Number(ds.expand); render(); return; }
if (ds.go) { const i = itemById(ds.go); if (i) openSource(i); return; }
if (ds.citeCopy) {
const i = itemById(ds.citeCopy);
if (i && await copy('[@' + i.data.key + ']')) toast('Kopiert');
return;
}
if (ds.remove) {
const i = itemById(ds.remove);
if (i && confirm('„' + cslTitle(i.data.csl) + '“ aus dem Projekt entfernen?')) { await removeItem(i); render(); }
return;
}
if (ds.export) {
const items = sources().filter(i => i.data && (st.filter === 'all' || i.kind === st.filter));
const base = (nameOf(st.open) || 'projekt').replace(/[^\w\-]+/g, '_');
if (ds.export === 'bib') download(base + '.bib', bibtex(items), 'application/x-bibtex');
else download(base + '.json', cslJson(items), 'application/json');
return;
}
if (ds.addFound != null) {
const f = st.adding.found[Number(ds.addFound)];
const data = snapshot(f.kind, f.ref, Object.assign({title: f.title}, f.info));
await keyed(data, st.open.id);
await addItem(f.kind, f.ref, data);
toast('Hinzugefügt');
render();
return;
}
if (ds.addDoi) {
const csl = st.adding.doi.csl;
const data = {csl: csl, note: '', doi: st.adding.doi.doi};
await keyed(data, st.open.id);
await addItem('paper', null, data);
st.adding = null;
toast('Paper hinzugefügt');
render();
return;
}
if (ds.openNote) { st.note = Number(ds.openNote); st.preview = true; saveState = ''; render(); return; }
if (ds.cite) {
const i = citeIndex()[ds.cite];
if (i) { st.view = 'sources'; st.note = null; st.expanded = i.id; render(); }
return;
}
switch (ds.act) {
case 'new': st.creating = {preset: 'research'}; render(); return;
case 'cancel-new': st.creating = null; render(); return;
case 'back': await saveNote(); st.open = null; st.menu = false; history.replaceState(null, '', '#projekte'); render(); loadList().then(render); return;
case 'menu': st.menu = !st.menu; render(); return;
case 'window': st.menu = false; render(); window.dioraDesktop.openWindow('project', st.open.id); return;
case 'rename': {
const name = prompt('Name des Projekts', st.open.data ? st.open.data.name : '');
if (name && name.trim()) await saveProject(Object.assign({}, st.open.data || {}, {name: name.trim()}));
st.menu = false;
render();
return;
}
case 'archive':
await api('/projects/' + st.open.id + '/', {archived: !st.open.archived});
st.open.archived = !st.open.archived;
st.menu = false;
await loadList();
render();
return;
case 'delete':
if (!confirm('„' + nameOf(st.open) + '“ mit allen Quellen und Notizen löschen? Das lässt sich nicht rückgängig machen.')) return;
await api('/projects/' + st.open.id + '/delete/', {});
st.open = null;
await loadList();
render();
if (solo) window.close();
return;
case 'close-add': st.adding = null; render(); return;
case 'new-note': draft = {title: '', body: ''}; st.note = 'new'; st.preview = false; saveState = ''; render(); return;
case 'close-note': await saveNote(); st.note = null; render(); return;
case 'preview': await saveNote(); st.preview = !st.preview; render(); return;
case 'delete-note': {
const n = itemById(st.note);
if (n && confirm('Notiz „' + (n.data.title || 'Ohne Titel') + '“ löschen?')) {
await removeItem(n);
st.note = null;
render();
}
return;
}
}
} catch (err) {
toast(err.message === 'stale' ? 'Anderswo geändert, bitte noch einmal.' : 'Ging nicht: ' + err.message);
}
});
tab.addEventListener('submit', async e => {
e.preventDefault();
const f = e.target;
if (f.dataset.form === 'add') {
const q = f.q.value.trim();
if (q) findToAdd(q);
} else if (f.dataset.form === 'create') {
const name = f.name.value.trim();
if (!name) return;
const btn = f.querySelector('button[type="submit"]');
btn.disabled = true;
try {
const then = st.creating.then;
const preset = st.creating.preset;
st.creating = null;
await createProject(preset, name, f.description.value.trim());
if (then) {
const data = snapshot(then.kind, Number(then.ref), then.info);
await keyed(data, st.open.id);
await addItem(then.kind, Number(then.ref), data);
toast('Mit ' + name + ' verknüpft');
render();
}
} catch (err) {
btn.disabled = false;
toast('Anlegen ging nicht: ' + err.message);
}
}
});
tab.addEventListener('input', e => {
const t = e.target;
if (t.matches('[data-note-body]')) noteChanged('body', t.value);
else if (t.matches('[data-note-title]')) noteChanged('title', t.value);
});
// A source's note and key: saved when the field is left.
tab.addEventListener('change', async e => {
const t = e.target;
const id = t.dataset.noteOf || t.dataset.keyOf;
if (!id) return;
const i = itemById(id);
if (!i) return;
const data = Object.assign({}, i.data);
if (t.dataset.noteOf) data.note = t.value;
else {
const key = t.value.trim().replace(/[^\w:.\-]+/g, '');
if (!key || takenKeys(i.id)[key]) { toast('Diesen Schlüssel gibt es schon.'); t.value = i.data.key; return; }
data.key = key;
}
try {
await saveItem(i, data);
toast('Gespeichert');
} catch (err) {
toast('Speichern ging nicht: ' + err.message);
}
});
// ---- showing ---------------------------------------------------------------------------------------
async function show() {
render();
await loadList();
const m = solo ? [0, solo[1]] : /^#projekte\/(\d+)/.exec(location.hash || '');
if (m && (!st.open || st.open.id !== Number(m[1]))) {
try { await openProject(Number(m[1])); return; } catch (e) {}
}
if (st.open) {
try { await openProject(st.open.id, true); return; } catch (e) { st.open = null; }
}
render();
}
const showTab = window.showTab;
if (typeof showTab === 'function') {
window.showTab = function (name) {
const r = showTab.apply(this, arguments);
if (name === 'projekte') show();
else saveNote();
return r;
};
}
window.addEventListener('beforeunload', () => { if (saveState === 'nicht gespeichert') saveNote(); });
// Back at this window (another one, another device may have changed the project): fresh,
// unless a note is being written here.
window.addEventListener('focus', () => {
if (tab.style.display === 'none' || st.note != null || st.creating) return;
if (st.open) openProject(st.open.id, true).catch(() => {});
else loadList().then(render);
});
if (solo) {
document.title = 'Projekt · diora';
if (typeof showTab === 'function') showTab('projekte');
} else if (tab.style.display !== 'none') show();
window.DioraProjects = {
// "Mit Projekt verknüpfen": kind 'book' | 'episode' | 'article', ref its id, info what's
// known of it ({title, author, podcast, date, url, position (s), feed, source, news}).
link: link,
// The projects a thing is in: [{id, name}] (after the first load; onChange tells).
of(kind, ref) {
const ids = st.links.filter(l => l.kind === kind && l.ref === Number(ref)).map(l => l.project);
return (st.list || []).filter(p => ids.indexOf(p.id) >= 0).map(p => ({id: p.id, name: nameOf(p)}));
},
open(id) {
go('projekte');
openProject(Number(id)).catch(() => {});
},
onChange(fn) { if (typeof fn === 'function') linkListeners.push(fn); },
openSource: openThing,
load: loadList,
};
// The links for the sheets ("in Projekt …"), once the page has settled.
if (typeof IS_AUTHENTICATED === 'undefined' || IS_AUTHENTICATED) setTimeout(() => { if (st.list === null) loadList(); }, 4000);
// ---- styles --------------------------------------------------------------------------------------
const style = document.createElement('style');
style.textContent = [
'#tab-projekte .prj { max-width: 820px; margin: 0 auto; padding-bottom: 32px; }',
'#tab-projekte .prj-h1 { font-size: 20px; margin: 4px 0 12px; }',
'#tab-projekte .prj-h3 { display: flex; gap: 8px; align-items: baseline; font-size: 12px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted, #9a9a9a); margin: 18px 0 4px; }',
'#tab-projekte .prj-muted { color: var(--muted, #9a9a9a); font-size: 12px; }',
'#tab-projekte .prj-count { color: var(--muted, #9a9a9a); font-size: 12px; margin-left: 6px; font-weight: 400; letter-spacing: 0; }',
'#tab-projekte .prj-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 10px; }',
'#tab-projekte .prj-card { display: flex; flex-direction: column; gap: 6px; align-items: flex-start; min-height: 120px; padding: 14px; border-radius: 10px; border: 1px solid var(--border, #2a2a2a); background: var(--card, #0b0b0b); color: inherit; font: inherit; text-align: left; cursor: pointer; }',
'#tab-projekte .prj-card:hover { border-color: var(--border-strong, #444); }',
'#tab-projekte .prj-card-preset { font-size: 11px; text-transform: uppercase; letter-spacing: .08em; color: var(--accent, #e63946); }',
'#tab-projekte .prj-card-name { font-size: 17px; font-weight: 700; }',
'#tab-projekte .prj-card-desc { font-size: 13px; color: var(--muted, #9a9a9a); display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }',
'#tab-projekte .prj-card-new { align-items: center; justify-content: center; border-style: dashed; }',
'#tab-projekte .prj-card-plus { font-size: 28px; line-height: 1; }',
'#tab-projekte .prj-archived { margin-top: 20px; }',
'#tab-projekte .prj-archived summary { cursor: pointer; color: var(--muted, #9a9a9a); font-size: 13px; padding: 6px 0; }',
'#tab-projekte .prj-back, #tab-projekte .prj-link { background: none; border: none; color: var(--accent, #e63946); font: inherit; font-size: 14px; cursor: pointer; padding: 6px 0; }',
'#tab-projekte .prj-head { display: flex; justify-content: space-between; gap: 10px; align-items: flex-start; }',
'#tab-projekte .prj-head .prj-h1 { margin: 2px 0 4px; }',
'#tab-projekte .prj-desc { font-size: 13px; margin: 0 0 6px; }',
'#tab-projekte .prj-icon { width: 40px; height: 40px; flex: none; border-radius: 20px; border: 1px solid var(--border-strong, #444); background: none; color: inherit; font-size: 18px; cursor: pointer; }',
'#tab-projekte .prj-menu { display: flex; flex-wrap: wrap; gap: 6px; margin: 4px 0 8px; }',
'#tab-projekte .prj-tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border, #2a2a2a); margin: 10px 0 12px; }',
'#tab-projekte .prj-tab { padding: 10px 14px; background: none; border: none; border-bottom: 2px solid transparent; color: inherit; font: inherit; font-size: 15px; cursor: pointer; }',
'#tab-projekte .prj-tab.on { border-bottom-color: var(--accent, #e63946); font-weight: 700; }',
'#tab-projekte .prj-btn { display: inline-flex; align-items: center; 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; margin: 4px 6px 0 0; }',
'#tab-projekte .prj-primary { background: var(--accent, #e63946); border-color: var(--accent, #e63946); color: var(--on-accent, #fff); }',
'#tab-projekte .prj-danger { color: var(--accent, #e63946); }',
'#tab-projekte .prj-btn[disabled] { opacity: .5; }',
'#tab-projekte .prj-add { display: flex; gap: 8px; }',
'#tab-projekte input, #tab-projekte textarea { box-sizing: border-box; padding: 10px 12px; border-radius: 6px; border: 1px solid var(--border, #2a2a2a); background: var(--bg, #000); color: inherit; font: inherit; font-size: 15px; }',
'#tab-projekte .prj-add input { flex: 1; min-width: 0; }',
'#tab-projekte .prj-found { margin: 10px 0; padding: 4px 12px 8px; border: 1px solid var(--border, #2a2a2a); border-radius: 8px; background: var(--card, #0b0b0b); }',
'#tab-projekte .prj-cand { display: flex; gap: 10px; align-items: center; padding: 8px 0; border-bottom: 1px solid var(--border, #2a2a2a); }',
'#tab-projekte .prj-kind { flex: none; font-size: 11px; padding: 1px 6px; border-radius: 8px; background: var(--raised, rgba(255,255,255,.12)); }',
'#tab-projekte .prj-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }',
'#tab-projekte .prj-chips { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin: 14px 0 6px; }',
'#tab-projekte .prj-spacer { flex: 1; }',
'#tab-projekte .prj-chip { min-height: 32px; padding: 0 12px; border-radius: 16px; border: 1px solid var(--border-strong, #444); background: none; color: inherit; font: inherit; font-size: 13px; cursor: pointer; }',
'#tab-projekte .prj-chip.on { background: var(--accent, #e63946); border-color: var(--accent, #e63946); color: var(--on-accent, #fff); }',
'#tab-projekte .prj-row { border-bottom: 1px solid var(--border, #2a2a2a); }',
'#tab-projekte .prj-main { display: flex; align-items: flex-start; gap: 10px; width: 100%; padding: 10px 0; background: none; border: none; color: inherit; font: inherit; text-align: left; cursor: pointer; }',
'#tab-projekte .prj-glyph { width: 28px; flex: none; text-align: center; color: var(--muted, #9a9a9a); font-size: 16px; padding-top: 1px; }',
'#tab-projekte .prj-title { font-size: 15px; }',
'#tab-projekte .prj-note-line { font-size: 13px; font-style: italic; color: var(--muted, #9a9a9a); }',
'#tab-projekte .prj-key { flex: none; font-size: 11px; color: var(--muted, #9a9a9a); max-width: 34%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }',
'#tab-projekte .prj-actions { padding: 0 0 12px 38px; display: flex; flex-direction: column; gap: 8px; }',
'#tab-projekte .prj-field { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--muted, #9a9a9a); }',
'#tab-projekte .prj-field input, #tab-projekte .prj-field textarea { color: var(--fg, #eee); }',
'#tab-projekte .prj-empty { margin-top: 18px; line-height: 1.5; font-size: 14px; }',
'#tab-projekte .prj-presets { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 10px; margin-bottom: 16px; }',
'#tab-projekte .prj-preset { display: flex; flex-direction: column; gap: 4px; padding: 14px; border-radius: 10px; border: 1px solid var(--border, #2a2a2a); background: none; color: inherit; font: inherit; text-align: left; cursor: pointer; }',
'#tab-projekte .prj-preset.on { border-color: var(--accent, #e63946); box-shadow: inset 0 0 0 1px var(--accent, #e63946); }',
'#tab-projekte .prj-preset-name { font-weight: 700; }',
'#tab-projekte .prj-preset-soon { opacity: .5; cursor: default; border-style: dashed; }',
'#tab-projekte .prj-form { display: flex; flex-direction: column; gap: 12px; max-width: 520px; }',
'#tab-projekte .prj-form label { display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: var(--muted, #9a9a9a); }',
'#tab-projekte .prj-form input, #tab-projekte .prj-form textarea { color: var(--fg, #eee); }',
'#tab-projekte .prj-note { display: flex; flex-direction: column; gap: 2px; width: 100%; padding: 10px 0; background: none; border: none; border-bottom: 1px solid var(--border, #2a2a2a); color: inherit; font: inherit; text-align: left; cursor: pointer; }',
'#tab-projekte .prj-note-head { display: flex; align-items: center; gap: 10px; }',
'#tab-projekte .prj-saved { flex: 1; text-align: right; }',
'#tab-projekte .prj-note-title { width: 100%; font-size: 18px; font-weight: 700; margin: 8px 0; }',
'#tab-projekte .prj-note-body { width: 100%; min-height: 55vh; font-family: inherit; line-height: 1.5; resize: vertical; }',
'#tab-projekte .prj-hint { margin-top: 4px; }',
'#tab-projekte .prj-md { line-height: 1.6; font-size: 15px; overflow-wrap: anywhere; }',
'#tab-projekte .prj-md h3 { font-size: 19px; } #tab-projekte .prj-md h4 { font-size: 16px; } #tab-projekte .prj-md h5, #tab-projekte .prj-md h6 { font-size: 15px; }',
'#tab-projekte .prj-md blockquote { margin: 8px 0; padding-left: 12px; border-left: 3px solid var(--border-strong, #444); color: var(--muted, #9a9a9a); }',
'#tab-projekte .prj-md a { color: var(--accent, #e63946); }',
'#tab-projekte .prj-cite { color: var(--accent, #e63946); cursor: pointer; }',
'#tab-projekte .prj-cite-missing { text-decoration: underline wavy; }',
'#tab-projekte .prj-refs { font-size: 13px; line-height: 1.5; padding-left: 20px; overflow-wrap: anywhere; }',
'.prj-picker { position: fixed; inset: 0; z-index: 3000; background: rgba(0,0,0,.55); display: flex; align-items: flex-end; justify-content: center; }',
'.prj-picker-box { width: 100%; max-width: 480px; max-height: 80vh; overflow: auto; background: var(--card, #111); color: var(--fg, #eee); border-radius: 14px 14px 0 0; padding: 14px 16px calc(14px + env(safe-area-inset-bottom, 0px)); box-sizing: border-box; }',
'@media (min-width: 721px) { .prj-picker { align-items: center; } .prj-picker-box { border-radius: 14px; } }',
'.prj-picker-h { font-weight: 700; font-size: 16px; margin-bottom: 4px; }',
'.prj-picker-what { font-size: 13px; color: var(--muted, #9a9a9a); margin-bottom: 8px; }',
'.prj-picker-p { display: flex; justify-content: space-between; gap: 10px; width: 100%; padding: 12px 4px; background: none; border: none; border-bottom: 1px solid var(--border, #2a2a2a); color: inherit; font: inherit; font-size: 15px; text-align: left; cursor: pointer; }',
'.prj-picker-p[disabled] { opacity: .6; cursor: default; }',
'.prj-picker-new { color: var(--accent, #e63946); }',
'.prj-picker .prj-muted { color: var(--muted, #9a9a9a); font-size: 12px; }',
'.prj-picker-close { display: inline-flex; align-items: center; min-height: 38px; padding: 0 14px; margin-top: 12px; border-radius: 19px; border: 1px solid var(--border-strong, #444); background: none; color: inherit; font: inherit; cursor: pointer; }',
'#prj-toast { position: fixed; left: 50%; bottom: calc(var(--nui-dock-h, 0px) + 90px); transform: translateX(-50%); z-index: 3100; background: var(--raised, #222); color: var(--fg, #eee); padding: 10px 16px; border-radius: 8px; font-size: 14px; opacity: 0; pointer-events: none; transition: opacity .2s; max-width: calc(100vw - 32px); }',
'#prj-toast.prj-show { opacity: 1; }',
// A window of its own (desktop): only the project.
'html.prj-solo #tabs, html.prj-solo #nav-segments, html.prj-solo header, html.prj-solo .site-header, html.prj-solo #nui-dock, html.prj-solo .nui-tab-bar { display: none !important; }',
'html.prj-solo body { padding-left: 0 !important; padding-bottom: 0 !important; }',
'html.prj-solo #tab-projekte { padding-top: 12px; }',
'body.eink #tab-projekte .prj-primary, body.eink #tab-projekte .prj-chip.on { background: #000; color: #fff; }',
].join('\n');
document.head.appendChild(style);
})();