Files
dogfather-universe/workspace/assets/js/wissen.js
T
DogFatherGit 9b2e09be58 Workspace: Wissens-Bibliothek -- 20 Kategorien, Suche, Filter, Pflege
Wichtigster Befund vorab: Es gab noch KEINEN PDF-Bereich. Weder auf der
Website (28 Seiten, keine davon Anleitungen) noch auf dem Server (null
PDFs). Es wurde also nichts umstrukturiert, sondern neu gebaut -- und
damit war die erste Frage nicht "wie sieht es aus", sondern "wie kommen
die PDFs spaeter rein". Eine feste Liste im Code waere nach drei Monaten
unbrauchbar gewesen.

Filipe hat entschieden: eigener Pflegebereich, und alles nur fuer das
Team. Die Bibliothek liegt deshalb komplett im Workspace hinter dem
Login, nicht auf der oeffentlichen Seite.

GLIEDERUNG
Zwanzig Kategorien woertlich nach Vorgabe, gebuendelt in sechs Welten:
TikTok, Streaming einrichten, Bild & Ton, Uebertragung & Aussehen,
Inhalt & Menschen, Hilfe & Schnellstart. Zwanzig gleich aussehende
Kacheln untereinander findet niemand -- gebuendelt sucht man erst die
Welt, und darin stehen nur noch drei bis vier. Jede Welt hat ihre eigene
Farbe, jede Kategorie ihr eigenes Symbol (20 verschiedene, keins
doppelt). Die Kategorietexte sind Filipes Beschreibungen -- sie
beantworten beim Einsortieren die einzige Frage, die man wirklich hat.

Die Gliederung steht im Code, nicht in der Datenbank: Sie ist eine
bewusste Ordnung, keine Nutzdaten.

JEDE PDF HAT GENAU EINE HAUPTKATEGORIE, alles Weitere laeuft ueber Tags
-- so gibt es jede Anleitung nur einmal, sie ist aber ueber mehrere
Begriffe auffindbar. Genau wie gefordert.

SUCHE UND FILTER
Suche ueber Titel, Beschreibung, Tags und Dateiname. Filter nach Stufe
(Einsteiger / Fortgeschritten / Profi), Geraet und Tag. Zwei Details:
- "PC" zeigt auch die Anleitungen, die fuer BEIDE Geraete gelten --
  sonst filtert man sich versehentlich die Haelfte weg.
- Der Tag-Filter sucht mit Kommas drumherum, sonst wuerde "pc" auch bei
  "pc-spiele" anschlagen.
Sobald gesucht oder gefiltert wird, verschwinden die Kacheln und es
erscheinen Treffer. Wer sucht, will nicht erst noch klicken.

Der Zustand steht in der Adresse (?k=obs&tag=...). Damit funktionieren
Zurueck-Taste, Neuladen und Weiterschicken. Eine Kategorie, die man
niemandem verlinken kann, ist keine Seite. Geprueft.

SICHERHEIT
Hochgeladen wird nur, was WIRKLICH eine PDF ist -- geprueft an den
ersten Bytes (%PDF-), nicht an der Dateiendung. Die kann jeder
umbenennen. Geprueft mit einer als .pdf getarnten HTML-Datei mit Skript:
abgewiesen. Nur weil diese Pruefung existiert, darf die Datei ueberhaupt
im Browser angezeigt werden statt bloss heruntergeladen -- und selbst
dann mit strenger CSP und nosniff.

Lesen darf jeder Angemeldete, pflegen nur DogFather. Geprueft: Creator
und Scout bekommen 403 beim Hochladen und sehen keine Bearbeiten-Knoepfe.
Ohne Anmeldung ist auch die Datei selbst nicht erreichbar (401).

Bricht das Anlegen nach dem Schreiben ab, wird die Datei wieder
geloescht -- sonst laege sie fuer immer verwaist auf der Platte.

Beim Bearbeiten wird die Datei bewusst NICHT ersetzt. Wer eine neue
Fassung hat, stellt sie neu ein. So bleibt nachvollziehbar, was wann galt.

Geprueft: 20 Kacheln, 20 verschiedene Symbole, Kategorie oeffnen, Suche,
Tag-Klick, Browser-Zurueck, Handy ohne Ueberlauf, 0 Konsolenfehler.
2026-08-28 12:40:49 +02:00

445 lines
17 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.
/* ===================================================================
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;
};
const melde = (t) => { $('fehler').textContent = t || ''; };
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 || '') : '';
$('n-kategorie').value = eintrag ? eintrag.kategorie : (zustand().k || aufbau.kategorien[0].schluessel);
$('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();
$('n-dateiname').textContent = 'Höchstens 40 MB. Nur echte PDF-Dateien.';
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);
}
};
setz('n-kategorie', aufbau.kategorien.map((k) => [k.schluessel, k.nr + '. ' + k.name]));
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);
$('n-datei').addEventListener('change', () => {
const d = $('n-datei').files[0];
$('n-dateiname').textContent = d
? `${d.name} · ${groesse(d.size)}`
: 'Höchstens 40 MB. Nur echte PDF-Dateien.';
});
$('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; }
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;
}
formularSchliessen();
melde('');
$('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;
}
})();
})();