Deutsch oeffnet mit „ und schliesst mit “. An 199 Stellen, die ein
Mensch liest, stand als Schlusszeichen ein GERADES " -- „Nächstes"
statt „Nächstes“. Auf sechzehn oeffentlichen Seiten, in vierzig
Dateien des Workspace und in zwoelf Servermodulen, die Texte
verschicken.
Auf dem Bildschirm sieht man den Unterschied sofort. Beim Schreiben
nicht: Das gerade " liegt auf der Tastatur, die anderen nicht.
WARUM EIN ERSTER ANLAUF ZURUECKGENOMMEN WURDE
Ein gerades " ist an vielen Stellen SYNTAX und kein Schriftzeichen --
Grenze einer Zeichenkette, Grenze eines HTML-Attributs, Zeichen in
einem regulaeren Ausdruck. Wer stumpf ersetzt, macht aus
„<a href="https://… ein kaputtes Attribut
/^["'„»\s]+|["'“«.\s]+$/ einen kaputten Ausdruck
"… nichts „mal " + "eben …" eine kaputte Zeichenkette
DIE UNTERSCHEIDUNG LAEUFT AN MERKMALEN, NICHT AN EINER LISTE
< > = dazwischen -> HTML-Marke oder Attribut
endet auf Leerzeichen -> die Zeichenkette hoert hier auf, der
Satz geht in der naechsten Zeile weiter.
Ein deutsches Schlusszeichen steht NIE
hinter einem Leerzeichen.
Rueckstrich mittendrin -> regulaerer Ausdruck
${ ohne } -> mitten in einem Ausdruck
Eine Liste erlaubter Ausnahmen waere die naechste, die niemand
pflegt. Zwoelf Stellen bleiben dadurch stehen, alle zwoelf einzeln
angesehen und alle zwoelf zu Recht -- dort steht das richtige
Schlusszeichen ohnehin weiter unten im Satz.
Fuenf davon waren allerdings ECHTE Fehler HINTER dem Link
(`…>HasiDog</a>".`) -- die erste Regel hatte nur das Attribut
gesehen, nicht den Satz danach. Gezielt nachgezogen.
Ein maskiertes `\"` in workspace-vorlagen.js (28 Hooks) wird zu “ --
ohne Rueckstrich, denn “ begrenzt nichts.
KOMMENTARE BLEIBEN, WIE SIE SIND. Dort liest es niemand ausser mir;
eine Wache, die auch Kosmetik anmahnt, wird weggeklickt. Beim ersten
Messen fielen ausserdem acht Stellen aus buehne.html faelschlich an,
weil `/* */` in HTML (in <style> und <script>) nicht ausgeblendet
war -- jetzt schon.
DIE WACHE DAZU
pruef-struktur prueft es ab sofort mit derselben Regel: 322 Dateien
mit sichtbarem Text, 0 Funde, und die zwoelf bewussten Ausnahmen
werden GEZAEHLT und genannt (erlaubt: 12). Eine Ausnahme, die niemand
sieht, waechst -- und irgendwann steht der echte Fall darin.
GEPRUEFT
pruef-struktur 59 -> 68 Pruefungen, 0 Fehler
node --check auf allen geaenderten JS-Dateien
nachgemessen: 199 geaendert, 12 mit Grund stehen geblieben
gruen geblieben: bewerbung-aufgaben 163, nachwuchs 262,
reaktion 421, support 63, content 45, terminregel 35, treff 85
Und nachgesehen, ob eine Pruefung noch die alte Schreibweise
ERWARTET: 13 Fundstellen, alle dreizehn nur Text in ihrer eigenen
Ausgabe, keine einzige ein Vergleich mit dem Seitentext.
GEGENPROBE: In reaktion.html ein Schlusszeichen zurueckgedreht ->
„workspace/reaktion.html:546 „Nächstes"", mit Datei und Zeile. Und
die Erkennung einzeln gegen HTML-Attribut, fortgesetzte
Zeichenkette, regulaeren Ausdruck und eingesetzten Wert geprueft.
Stempel gesetzt: workspace 670 Verweise in 45 Dateien, oeffentlich
554 in 48 Seiten.
Co-Authored-By: Claude Opus 5 <[email protected]>
676 lines
26 KiB
JavaScript
676 lines
26 KiB
JavaScript
/* ===================================================================
|
||
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'));
|
||
|
||
/* NUR JE EINER SICHTBAR (Wunsch vom 01.09.2026).
|
||
|
||
Vorher standen bis zu sechs Karten untereinander, jede mit vier
|
||
Knoepfen -- der Einstieg in die Bibliothek war damit eine halbe
|
||
Bildschirmseite, bevor ueberhaupt eine Kategorie zu sehen war.
|
||
|
||
Jetzt: der wichtigste Eintrag und der neueste. Beide sind
|
||
verschiedene Antworten ("was soll ich unbedingt lesen" und "was
|
||
ist dazugekommen"), deshalb je einer und nicht einfach die
|
||
ersten zwei. Alles Weitere ist einen Klick entfernt.
|
||
|
||
Der Server sortiert bereits Wichtiges nach vorn; hier wird nur
|
||
noch der erste jeder Sorte gesucht. */
|
||
const ersterWichtiger = neuLetzte.find((e) => e.ist_wichtig);
|
||
const ersterNeuer = neuLetzte.find((e) => e.ist_neu && e !== ersterWichtiger);
|
||
const sofort = [ersterWichtiger, ersterNeuer].filter(Boolean);
|
||
const rest = neuLetzte.filter((e) => !sofort.includes(e));
|
||
|
||
if (rest.length) {
|
||
const knopf = el('button', 'knopf-still');
|
||
knopf.type = 'button';
|
||
knopf.setAttribute('aria-expanded', 'false');
|
||
knopf.setAttribute('aria-controls', 'neu-rest');
|
||
const text = el('span', null, `Alle ${neuLetzte.length} zeigen`);
|
||
knopf.append(text);
|
||
const pfeil = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||
pfeil.setAttribute('class', 'klapp-pfeil');
|
||
pfeil.setAttribute('viewBox', '0 0 24 24');
|
||
pfeil.setAttribute('aria-hidden', 'true');
|
||
const pfad = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||
pfad.setAttribute('d', 'm6 9.5 6 6 6-6');
|
||
pfeil.append(pfad);
|
||
knopf.append(pfeil);
|
||
kopf.append(knopf);
|
||
|
||
knopf.addEventListener('click', () => {
|
||
const offen = knopf.getAttribute('aria-expanded') === 'true';
|
||
knopf.setAttribute('aria-expanded', offen ? 'false' : 'true');
|
||
const kasten = document.getElementById('neu-rest');
|
||
if (kasten) kasten.hidden = offen;
|
||
text.textContent = offen
|
||
? `Alle ${neuLetzte.length} zeigen`
|
||
: 'Nur die wichtigsten';
|
||
});
|
||
}
|
||
s.append(kopf);
|
||
|
||
s.append(el('p', 'block__frage',
|
||
'Neu Eingestelltes und als wichtig Markiertes steht 48 Stunden hier. '
|
||
+ 'Danach ist es in seiner Kategorie zu finden.'));
|
||
|
||
const box = el('div', 'karten');
|
||
for (const e of sofort) box.append(karte(e));
|
||
s.append(box);
|
||
|
||
if (rest.length) {
|
||
const mehr = el('div', 'karten');
|
||
mehr.id = 'neu-rest';
|
||
mehr.hidden = true;
|
||
for (const e of rest) mehr.append(karte(e));
|
||
s.append(mehr);
|
||
}
|
||
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]));
|
||
/* "Neu" laeuft ab, "Wichtig" nicht -- deshalb zwei Marken und nicht
|
||
eine. Sonst waere nicht erkennbar, warum etwas oben steht. */
|
||
if (e.ist_neu) marken.append(el('span', 'marke-neu', 'Neu'));
|
||
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 (!await frageNach({
|
||
titel: `„${e.titel}“ löschen?`,
|
||
was: 'Der Eintrag verschwindet aus dem Wissen. Die PDF wird dabei '
|
||
+ 'auch vom Server entfernt.',
|
||
bleibt: 'Wer sie schon heruntergeladen hat, behält seine Kopie.',
|
||
endgueltig: true, ja: 'Löschen',
|
||
})) 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();
|
||
/* Stufe und Geraet als Schalterreihe: zwei bis vier Werte, die sich
|
||
nie aendern -- dafuer ein Auswahlmenue zu oeffnen ist ein Klick zu
|
||
viel, und aufgeklappt liesse es sich ohnehin nicht gestalten. */
|
||
const reihe = (id, eintraege, wert, feldname) => {
|
||
const f = $(id);
|
||
f.textContent = '';
|
||
for (const [w, name] of [['', 'Alle'], ...eintraege]) {
|
||
const b = el('button', 'filterreihe__knopf', name);
|
||
b.type = 'button';
|
||
b.setAttribute('role', 'radio');
|
||
const an = (wert || '') === w;
|
||
b.setAttribute('aria-checked', an ? 'true' : 'false');
|
||
if (an) b.dataset.an = 'ja';
|
||
if (w) b.dataset.wert = w;
|
||
b.addEventListener('click', () => setzeZustand({ [feldname]: w }));
|
||
f.append(b);
|
||
}
|
||
};
|
||
reihe('f-stufe', Object.entries(aufbau.stufen), z.stufe, 'stufe');
|
||
reihe('f-geraet', [['pc', 'PC'], ['handy', 'Handy']], z.geraet, 'geraet');
|
||
|
||
/* Die Tags wachsen mit dem Bestand -- dafuer bleibt ein Auswahlfeld
|
||
richtig, eine Schalterreihe waere irgendwann zwei Zeilen lang. */
|
||
const f = $('f-tag');
|
||
f.textContent = '';
|
||
const alle = document.createElement('option');
|
||
alle.value = ''; alle.textContent = 'Alle';
|
||
f.append(alle);
|
||
for (const tg of aufbau.tags) {
|
||
const o = document.createElement('option');
|
||
o.value = tg; o.textContent = '#' + tg;
|
||
f.append(o);
|
||
}
|
||
f.value = z.tag || '';
|
||
$('q').value = z.q;
|
||
|
||
if (filterGefuellt) return;
|
||
filterGefuellt = true;
|
||
$('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_);
|
||
|
||
/* ---------- Kategoriewahl ------------------------------------------------ */
|
||
|
||
function setzeKategorie(schluessel) {
|
||
for (const b of document.querySelectorAll('.katwahl__knopf')) {
|
||
const an = b.dataset.kategorie === schluessel;
|
||
if (an) b.dataset.an = 'ja'; else delete b.dataset.an;
|
||
b.setAttribute('aria-checked', an ? 'true' : 'false');
|
||
}
|
||
const hinweis = $('n-kat-hinweis');
|
||
if (hinweis) {
|
||
const k = aufbau?.kategorien.find((x) => x.schluessel === schluessel);
|
||
hinweis.textContent = k ? k.beschreibung : 'Noch keine Kategorie gewählt.';
|
||
}
|
||
}
|
||
|
||
const gewaehlteKategorie = () =>
|
||
document.querySelector('.katwahl__knopf[data-an="ja"]')?.dataset.kategorie || '';
|
||
|
||
/* ---------- 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. */
|
||
setzeKategorie(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 : window.heuteLokal();
|
||
$('n-fehler').textContent = '';
|
||
$('neu-block').hidden = false;
|
||
$('neu-auf').hidden = true;
|
||
$('n-titel').focus();
|
||
$('neu-block').scrollIntoView({ block: 'start', behavior: window.sanft?.() || 'auto' });
|
||
}
|
||
|
||
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 und dieselben Farben
|
||
wie die Kacheln auf der Seite. Man waehlt aus demselben Regal, in
|
||
dem man sonst stoebert. */
|
||
const kf = $('n-kategorie');
|
||
kf.textContent = '';
|
||
for (const welt of aufbau.welten) {
|
||
const kats = aufbau.kategorien.filter((x) => x.welt === welt.schluessel);
|
||
if (!kats.length) continue;
|
||
const gruppe = el('div', 'katwahl__welt');
|
||
gruppe.dataset.welt = welt.schluessel;
|
||
gruppe.append(el('p', 'katwahl__weltname', welt.name));
|
||
const reihe = el('div', 'katwahl__reihe');
|
||
for (const k of kats) {
|
||
const b = el('button', 'katwahl__knopf');
|
||
b.type = 'button';
|
||
b.dataset.kategorie = k.schluessel;
|
||
b.setAttribute('role', 'radio');
|
||
b.setAttribute('aria-checked', 'false');
|
||
b.title = k.beschreibung;
|
||
b.append(symbol(k.symbol, 'katwahl__symbol'));
|
||
b.append(el('span', 'katwahl__name', k.name));
|
||
b.addEventListener('click', () => setzeKategorie(k.schluessel));
|
||
reihe.append(b);
|
||
}
|
||
gruppe.append(reihe);
|
||
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: gewaehlteKategorie(),
|
||
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').scrollIntoView({ block: 'center', behavior: window.sanft?.() || 'auto' });
|
||
$('n-kategorie').querySelector('.katwahl__knopf')?.focus();
|
||
return;
|
||
}
|
||
|
||
knopf.disabled = true;
|
||
let anzeige = null;
|
||
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);
|
||
/* MIT FORTSCHRITT UND ABBRUCH (19.09.2026). Eine Schulungs-PDF
|
||
hat gern zwanzig Megabyte -- ohne Balken sieht das Formular
|
||
die ganze Zeit aus, als sei der Knopf nicht angekommen. */
|
||
let lauf = null;
|
||
anzeige = hochladeAnzeige($('n-stand'), datei.name,
|
||
() => lauf && lauf.abbrechen());
|
||
lauf = hochladen('/workspace/api/wissen', datei, {
|
||
kopf, beiFortschritt: anzeige.fortschritt,
|
||
beiVerarbeitung: anzeige.verarbeiten,
|
||
});
|
||
const ergebnis = await lauf.fertig;
|
||
if (ergebnis.abgebrochen) {
|
||
anzeige.abschluss('Abgebrochen – es wurde nichts gespeichert.');
|
||
anzeige = null;
|
||
return;
|
||
}
|
||
if (!ergebnis.ok) {
|
||
$('n-fehler').textContent = ergebnis.netz
|
||
? 'Keine Verbindung – die Datei wurde nicht hochgeladen.'
|
||
: sagWas(ergebnis.daten?.fehler, 'Das hat nicht geklappt.');
|
||
return;
|
||
}
|
||
a = { ok: true };
|
||
}
|
||
if (!a.ok) {
|
||
$('n-fehler').textContent = sagWas((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 {
|
||
if (anzeige) anzeige.weg();
|
||
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 {
|
||
/* NICHT MEHR STUMM (20.09.2026). Hier stand `catch { return; }`
|
||
-- und damit blieb die Seite bei jedem Netzaussetzer fuer
|
||
immer auf "wird geladen …" stehen. Gemessen auf 13 von 21
|
||
Seiten. `ladefehler()` sagt, was los ist, und bietet einen
|
||
Knopf an, der es noch einmal versucht. */
|
||
window.ladefehler?.();
|
||
return;
|
||
}
|
||
window.werZeigen?.(ich);
|
||
|
||
await laden_();
|
||
if (aufbau) {
|
||
fuelleFormular();
|
||
$('neu-auf').hidden = !aufbau.darf_pflegen;
|
||
}
|
||
})();
|
||
})();
|