/* =================================================================== Wissens-Bibliothek. Zwei Ansichten, eine Seite: die Übersicht mit den Kacheln und die Liste innerhalb einer Kategorie. Der Zustand steht in der Adresse (?k=...), damit Zurück, Neuladen und Weiterschicken funktionieren -- eine Kategorie, die man niemandem verlinken kann, ist keine Seite. Sobald gesucht oder gefiltert wird, verschwinden die Kacheln und es erscheinen Treffer. Wer sucht, will nicht erst noch klicken. =================================================================== */ (() => { 'use strict'; const $ = (id) => document.getElementById(id); let aufbau = null; let bearbeite = null; // id, wenn ein vorhandener Eintrag geändert wird const el = (tag, klasse, text) => { const k = document.createElement(tag); if (klasse) k.className = klasse; if (text !== undefined) k.textContent = text; return k; }; /* Dieselbe Zeile fuer Fehler und Bestaetigungen -- aber nicht dieselbe Farbe. Eine Erfolgsmeldung in Warnrot liest sich wie ein Problem. */ const melde = (text, gut = false) => { const e = $('fehler'); e.textContent = text || ''; if (gut) e.dataset.gut = 'ja'; else delete e.dataset.gut; }; const datum = (iso) => (iso ? iso.slice(0, 10).split('-').reverse().join('.') : ''); const groesse = (b) => (b >= 1048576 ? (b / 1048576).toFixed(1) + ' MB' : Math.round(b / 1024) + ' KB'); function symbol(name, klasse) { const s = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); s.setAttribute('viewBox', '0 0 24 24'); s.setAttribute('aria-hidden', 'true'); if (klasse) s.setAttribute('class', klasse); const u = document.createElementNS('http://www.w3.org/2000/svg', 'use'); u.setAttribute('href', '#s-' + name); s.append(u); return s; } async function hole(pfad, optionen = {}) { const a = await fetch(pfad, { credentials: 'same-origin', ...optionen }); if (a.status === 401) { location.assign('/workspace/'); throw new Error('abgemeldet'); } return a; } /* ---------- Zustand in der Adresse ------------------------------------- */ const zustand = () => { const p = new URLSearchParams(location.search); return { k: p.get('k') || '', q: p.get('q') || '', stufe: p.get('stufe') || '', geraet: p.get('geraet') || '', tag: p.get('tag') || '', }; }; function setzeZustand(neu, ersetzen = false) { const z = { ...zustand(), ...neu }; const p = new URLSearchParams(); for (const [n, w] of Object.entries(z)) if (w) p.set(n, w); const ziel = location.pathname + (p.toString() ? '?' + p : ''); if (ersetzen) history.replaceState(null, '', ziel); else history.pushState(null, '', ziel); /* Gefiltert wird auf dem Server, nicht im Browser. Sonst muesste die ganze Bibliothek beim ersten Aufruf mitgeladen werden, auch wenn man nur eine Kategorie sehen will. */ laden_(); } const filtertAktiv = () => { const z = zustand(); return !!(z.q || z.stufe || z.geraet || z.tag); }; /* ---------- Kachelübersicht -------------------------------------------- */ function uebersicht(ziel, neuLetzte) { /* Neu & wichtig zuerst -- der Einstieg für alle, die nicht suchen, sondern sehen wollen, was dazugekommen ist. */ if (neuLetzte.length) { const s = el('section', 'block'); const kopf = el('div', 'block__kopf'); kopf.append(el('h2', 'block__titel', 'Neu & wichtig')); s.append(kopf); const box = el('div', 'karten'); for (const e of neuLetzte) box.append(karte(e)); s.append(box); ziel.append(s); } for (const welt of aufbau.welten) { const kats = aufbau.kategorien.filter((k) => k.welt === welt.schluessel); if (!kats.length) continue; const s = el('section', 'block'); /* Traegt die Farbe der Welt an alle Kacheln darin weiter. */ s.dataset.welt = welt.schluessel; const kopf = el('div', 'block__kopf'); kopf.append(el('h2', 'block__titel', welt.name)); const summe = kats.reduce((n, k) => n + k.anzahl, 0); kopf.append(el('span', 'block__zahl', String(summe))); s.append(kopf); const raster = el('div', 'kacheln'); for (const k of kats) { const a = el('a', 'kachel'); a.href = '?k=' + encodeURIComponent(k.schluessel); a.addEventListener('click', (e) => { e.preventDefault(); setzeZustand({ k: k.schluessel, q: '' }); }); if (!k.anzahl) a.dataset.leer = 'ja'; const ring = el('span', 'kachel__ring'); ring.append(symbol(k.symbol, 'kachel__symbol')); a.append(ring); const text = el('span', 'kachel__text'); text.append(el('span', 'kachel__name', k.name)); text.append(el('span', 'kachel__beschreibung', k.beschreibung)); a.append(text); const zahl = el('span', 'kachel__zahl', k.anzahl ? String(k.anzahl) : '–'); zahl.title = k.anzahl === 1 ? '1 Anleitung' : k.anzahl + ' Anleitungen'; a.append(zahl); raster.append(a); } s.append(raster); ziel.append(s); } } /* ---------- Karte einer Anleitung -------------------------------------- */ function karte(e) { const kat = aufbau.kategorien.find((k) => k.schluessel === e.kategorie); const k = el('article', 'pdf'); if (e.wichtig) k.dataset.wichtig = 'ja'; const kopf = el('div', 'pdf__kopf'); const ring = el('span', 'pdf__ring'); ring.append(symbol(kat ? kat.symbol : 'haken', 'pdf__symbol')); kopf.append(ring); const mitte = el('div', 'pdf__mitte'); mitte.append(el('h3', 'pdf__titel', e.titel)); if (e.beschreibung) mitte.append(el('p', 'pdf__text', e.beschreibung)); const marken = el('div', 'pdf__marken'); const stufe = el('span', 'marke-stufe', aufbau.stufen[e.stufe] || e.stufe); stufe.dataset.stufe = e.stufe; marken.append(stufe); if (e.geraet && e.geraet !== 'egal') marken.append(el('span', 'marke-geraet', aufbau.geraete[e.geraet])); if (e.wichtig) marken.append(el('span', 'marke-wichtig', 'Wichtig')); for (const t of e.tags) { const b = el('button', 'marke-tag', '#' + t); b.type = 'button'; b.title = 'Nach diesem Tag filtern'; b.addEventListener('click', () => setzeZustand({ tag: t })); marken.append(b); } mitte.append(marken); const fuss = el('p', 'pdf__fuss'); fuss.append(document.createTextNode( (kat ? kat.name + ' · ' : '') + 'veröffentlicht ' + datum(e.veroeffentlicht) + (e.aktualisiert && e.aktualisiert !== e.veroeffentlicht ? ' · aktualisiert ' + datum(e.aktualisiert) : '') + ' · ' + groesse(e.groesse))); mitte.append(fuss); kopf.append(mitte); k.append(kopf); const knoepfe = el('div', 'pdf__knoepfe'); const ansehen = el('a', 'knopf knopf--klein', 'Öffnen'); ansehen.href = `/workspace/api/wissen/${e.id}/datei?modus=ansehen`; ansehen.target = '_blank'; ansehen.rel = 'noopener'; const laden = el('a', 'knopf-still', 'Herunterladen'); laden.href = `/workspace/api/wissen/${e.id}/datei`; knoepfe.append(ansehen, laden); if (aufbau.darf_pflegen) { const aendern = el('button', 'knopf-still', 'Bearbeiten'); aendern.type = 'button'; aendern.addEventListener('click', () => formularOeffnen(e)); const weg = el('button', 'knopf-still knopf-still--warn', 'Löschen'); weg.type = 'button'; weg.addEventListener('click', async () => { if (!confirm(`„${e.titel}" wirklich löschen?\n\nDie PDF wird dabei auch vom Server entfernt.`)) return; const a = await hole('/workspace/api/wissen/' + e.id, { method: 'DELETE' }); if (!a.ok) { melde('Löschen hat nicht geklappt.'); return; } await laden_(); }); knoepfe.append(aendern, weg); } k.append(knoepfe); return k; } /* ---------- Zeichnen ----------------------------------------------------- */ let daten = { eintraege: [], neu: [] }; function zeichne() { const z = zustand(); const ziel = $('inhalt'); ziel.textContent = ''; ziel.setAttribute('aria-busy', 'false'); /* Kopf an die Ansicht anpassen */ const kat = aufbau.kategorien.find((k) => k.schluessel === z.k); if (kat) { $('titel').textContent = kat.name; $('unterzeile').textContent = kat.beschreibung; } else { $('titel').textContent = 'Wissens-Bibliothek'; $('unterzeile').textContent = aufbau.gesamt ? `${aufbau.gesamt} Anleitung${aufbau.gesamt === 1 ? '' : 'en'} zu TikTok, Livestreaming, Technik und Content.` : 'Noch keine Anleitung eingestellt.'; } $('f-weg').hidden = !(filtertAktiv() || z.k); /* Suchen oder filtern schlägt die Kachelansicht -- wer sucht, will Treffer sehen, nicht erst noch eine Kachel anklicken. */ if (!z.k && !filtertAktiv()) { if (!aufbau.gesamt) { const leer = el('div', 'leer-hinweis'); leer.append(el('p', null, 'Die Bibliothek ist noch leer.')); leer.append(el('p', 'leise', aufbau.darf_pflegen ? 'Mit „PDF hinzufügen“ oben rechts geht es los. Die zwanzig Kategorien stehen schon bereit.' : 'Sobald DogFather Anleitungen einstellt, erscheinen sie hier.')); ziel.append(leer); } uebersicht(ziel, daten.neu); return; } const liste = daten.eintraege; if (!liste.length) { const leer = el('div', 'leer-hinweis'); leer.append(el('p', null, kat && !filtertAktiv() ? 'In dieser Kategorie liegt noch keine Anleitung.' : 'Dazu wurde nichts gefunden.')); const b = el('button', 'knopf-still', 'Filter zurücksetzen'); b.type = 'button'; b.addEventListener('click', () => setzeZustand({ k: '', q: '', stufe: '', geraet: '', tag: '' })); leer.append(b); ziel.append(leer); return; } const kopf = el('div', 'block__kopf'); kopf.append(el('h2', 'block__titel', filtertAktiv() ? 'Treffer' : 'Anleitungen')); kopf.append(el('span', 'block__zahl', String(liste.length))); ziel.append(kopf); const box = el('div', 'karten'); for (const e of liste) box.append(karte(e)); ziel.append(box); } async function laden_() { const z = zustand(); const p = new URLSearchParams(); for (const [n, w] of Object.entries(z)) if (w) p.set(n, w); try { const [a1, a2] = await Promise.all([ hole('/workspace/api/wissen/aufbau'), hole('/workspace/api/wissen?' + p), ]); if (!a1.ok || !a2.ok) { melde('Die Bibliothek konnte nicht geladen werden.'); return; } aufbau = await a1.json(); daten = await a2.json(); fuelleFilter(); zeichne(); } catch { /* umgeleitet */ } } /* ---------- Filterfelder ------------------------------------------------- */ let filterGefuellt = false; function fuelleFilter() { const z = zustand(); const setz = (id, eintraege, wert) => { const f = $(id); f.textContent = ''; const alle = document.createElement('option'); alle.value = ''; alle.textContent = 'Alle'; f.append(alle); for (const [w, name] of eintraege) { const o = document.createElement('option'); o.value = w; o.textContent = name; f.append(o); } f.value = wert || ''; }; setz('f-stufe', Object.entries(aufbau.stufen), z.stufe); setz('f-geraet', [['pc', 'PC'], ['handy', 'Handy']], z.geraet); setz('f-tag', aufbau.tags.map((t) => [t, '#' + t]), z.tag); $('q').value = z.q; if (filterGefuellt) return; filterGefuellt = true; $('f-stufe').addEventListener('change', () => setzeZustand({ stufe: $('f-stufe').value })); $('f-geraet').addEventListener('change', () => setzeZustand({ geraet: $('f-geraet').value })); $('f-tag').addEventListener('change', () => setzeZustand({ tag: $('f-tag').value })); $('f-weg').addEventListener('click', () => setzeZustand({ k: '', q: '', stufe: '', geraet: '', tag: '' })); let warte = null; $('q').addEventListener('input', () => { clearTimeout(warte); warte = setTimeout(() => setzeZustand({ q: $('q').value.trim() }, true), 260); }); } window.addEventListener('popstate', laden_); /* ---------- Formular ------------------------------------------------------ */ function formularOeffnen(eintrag) { bearbeite = eintrag ? eintrag.id : null; $('neu-titel').textContent = eintrag ? 'Anleitung bearbeiten' : 'Neue Anleitung'; $('n-speichern').textContent = eintrag ? 'Änderungen speichern' : 'Veröffentlichen'; /* Beim Bearbeiten wird die Datei nicht ersetzt -- wer eine neue Fassung hat, stellt sie neu ein und löscht die alte. So bleibt nachvollziehbar, was wann galt. */ $('n-datei-block').hidden = !!eintrag; $('n-titel').value = eintrag ? eintrag.titel : ''; $('n-beschreibung').value = eintrag ? (eintrag.beschreibung || '') : ''; /* NICHT die erste Kategorie vorbelegen. Genau daran ist eine PDF ueber TikTok LIVE Studio unter "TikTok - Grundlagen" gelandet: Das Feld stand still auf dem ersten Eintrag der Liste, und wer es nicht bewusst aendert, merkt es nicht. Vorbelegt wird nur, wenn man aus EINER Kategorie heraus hochlaedt -- dann ist es eine informierte Annahme, keine geratene. */ $('n-kategorie').value = eintrag ? eintrag.kategorie : (zustand().k || ''); $('n-stufe').value = eintrag ? eintrag.stufe : 'einsteiger'; $('n-geraet').value = eintrag ? eintrag.geraet : 'egal'; $('n-tags').value = eintrag ? eintrag.tags.join(', ') : ''; $('n-wichtig').checked = !!(eintrag && eintrag.wichtig); $('n-datum').value = eintrag ? eintrag.veroeffentlicht : new Date().toISOString().slice(0, 10); $('n-fehler').textContent = ''; $('neu-block').hidden = false; $('neu-auf').hidden = true; $('n-titel').focus(); $('neu-block').scrollIntoView({ block: 'start', behavior: 'smooth' }); } function formularSchliessen() { $('neu-block').hidden = true; $('neu-auf').hidden = !aufbau.darf_pflegen; $('neu-form').reset(); zeigeDatei(); bearbeite = null; } function fuelleFormular() { const setz = (id, eintraege) => { const f = $(id); f.textContent = ''; for (const [w, name] of eintraege) { const o = document.createElement('option'); o.value = w; o.textContent = name; f.append(o); } }; /* Nach Welten gruppiert -- dieselbe Gliederung wie auf der Seite. Zwanzig Eintraege in einer flachen Liste findet man nicht. */ const kf = $('n-kategorie'); kf.textContent = ''; const platzhalter = document.createElement('option'); platzhalter.value = ''; platzhalter.textContent = '— Kategorie wählen —'; platzhalter.disabled = true; kf.append(platzhalter); for (const welt of aufbau.welten) { const gruppe = document.createElement('optgroup'); gruppe.label = welt.name; for (const k of aufbau.kategorien.filter((x) => x.welt === welt.schluessel)) { const o = document.createElement('option'); o.value = k.schluessel; o.textContent = k.name; gruppe.append(o); } if (gruppe.children.length) kf.append(gruppe); } setz('n-stufe', Object.entries(aufbau.stufen)); setz('n-geraet', Object.entries(aufbau.geraete)); const v = $('n-vorschlaege'); v.textContent = ''; for (const t of aufbau.tag_vorschlaege) { const b = el('button', 'tag-knopf', '#' + t); b.type = 'button'; b.addEventListener('click', () => { const jetzt = $('n-tags').value.split(',').map((x) => x.trim()).filter(Boolean); if (jetzt.includes(t)) return; jetzt.push(t); $('n-tags').value = jetzt.join(', '); }); v.append(b); } } $('neu-auf').addEventListener('click', () => formularOeffnen(null)); $('neu-zu').addEventListener('click', formularSchliessen); /* ---------- Ablagefläche ---------------------------------------------- */ const ablage = $('n-ablage'); function zeigeDatei() { const d = $('n-datei').files[0]; const text = $('n-dateiname'); text.textContent = ''; if (!d) { text.append(document.createTextNode('PDF hierher ziehen oder ')); text.append(el('strong', null, 'auswählen')); delete ablage.dataset.fertig; return; } text.append(el('strong', null, d.name)); text.append(document.createTextNode(' · ' + groesse(d.size))); ablage.dataset.fertig = 'ja'; /* Ist der Titel noch leer, den Dateinamen als Vorschlag nehmen -- er ist fast immer schon die halbe Miete. */ if (!$('n-titel').value.trim()) { $('n-titel').value = d.name.replace(/\.pdf$/i, '').replace(/[_-]+/g, ' ').trim().slice(0, 140); } } function nimmDatei(d) { if (!d) return; if (!/\.pdf$/i.test(d.name) && d.type !== 'application/pdf') { $('n-fehler').textContent = 'Das ist keine PDF-Datei.'; return; } const dt = new DataTransfer(); dt.items.add(d); $('n-datei').files = dt.files; $('n-fehler').textContent = ''; zeigeDatei(); } ablage.addEventListener('click', () => $('n-datei').click()); ablage.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); $('n-datei').click(); } }); $('n-datei').addEventListener('change', zeigeDatei); for (const art of ['dragenter', 'dragover']) { ablage.addEventListener(art, (e) => { e.preventDefault(); ablage.dataset.drueber = 'ja'; }); } for (const art of ['dragleave', 'drop']) { ablage.addEventListener(art, () => { delete ablage.dataset.drueber; }); } ablage.addEventListener('drop', (e) => { e.preventDefault(); nimmDatei(e.dataTransfer?.files?.[0]); }); $('neu-form').addEventListener('submit', async (e) => { e.preventDefault(); $('n-fehler').textContent = ''; const knopf = $('n-speichern'); const angaben = { titel: $('n-titel').value.trim(), beschreibung: $('n-beschreibung').value.trim(), kategorie: $('n-kategorie').value, stufe: $('n-stufe').value, geraet: $('n-geraet').value, tags: $('n-tags').value, wichtig: $('n-wichtig').checked ? '1' : '0', veroeffentlicht: $('n-datum').value, }; if (angaben.titel.length < 3) { $('n-fehler').textContent = 'Der Titel fehlt.'; return; } if (!angaben.kategorie) { $('n-fehler').textContent = 'Wähle die Kategorie aus, in der die Anleitung stehen soll.'; $('n-kategorie').focus(); return; } knopf.disabled = true; try { let a; if (bearbeite) { a = await hole('/workspace/api/wissen/' + bearbeite, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(angaben), }); } else { const datei = $('n-datei').files[0]; if (!datei) { $('n-fehler').textContent = 'Wähle eine PDF-Datei aus.'; return; } const kopf = { 'Content-Type': 'application/pdf', 'x-dateiname': encodeURIComponent(datei.name) }; for (const [n, w] of Object.entries(angaben)) kopf['x-' + n] = encodeURIComponent(w); a = await hole('/workspace/api/wissen', { method: 'POST', headers: kopf, body: datei }); } if (!a.ok) { $('n-fehler').textContent = (await a.json().catch(() => ({}))).fehler || 'Das hat nicht geklappt.'; return; } const kat = aufbau.kategorien.find((k) => k.schluessel === angaben.kategorie); formularSchliessen(); /* Sagen, WO sie gelandet ist -- sonst merkt man einen Irrtum erst, wenn man sie irgendwann sucht. */ melde(`„${angaben.titel}“ liegt jetzt unter „${kat ? kat.name : angaben.kategorie}“.`, true); setTimeout(() => melde(''), 7000); $('n-stand').textContent = ''; await laden_(); } catch { /* umgeleitet */ } finally { knopf.disabled = false; } }); /* ---------- Start --------------------------------------------------------- */ (async () => { let ich; try { const a = await hole('/workspace/api/ich'); if (!a.ok) { location.assign('/workspace/'); return; } ich = await a.json(); } catch { return; } $('wer').textContent = ich.name + ' · ' + (ich.rolle_name || ich.rolle); await laden_(); if (aufbau) { fuelleFormular(); $('neu-auf').hidden = !aufbau.darf_pflegen; } })(); })();