/* ===================================================================== DER NOTIZBLOCK (26.09.2026) ===================================================================== Filipe: „ich will dass du das auch wie ein notizblock erstellst. ich will dass es übelst geil und einzigartig ist." --------------------------------------------------------------------- DREI ENTSCHEIDUNGEN, AUS DENEN DER REST FOLGT 1. ES GIBT KEINEN SPEICHERN-KNOPF. Wer einen Block aufschlägt und lostippt, drückt hinterher nicht auf „sichern"; er klappt ihn zu. Gesichert wird 800 ms nach dem letzten Tastendruck. Geht das nicht (Funkloch, Server weg), bleibt der Text im Browser liegen und wird nachgereicht — und der Fuß sagt ehrlich, dass er noch nicht drüben ist. 2. DAS BLATT IST EIN SCHREIBFELD, KEIN CONTENTEDITABLE. Die Auszeichnung (Haken, Überschriften, Striche) zeichnet eine Deckschicht darüber. Warum, steht im Kopf von notizen.css — kurz: Ein `contenteditable` bricht beim Einfügen aus fremden Programmen, beim Rückgängigmachen und an jeder Handytastatur, die über eine Zwischenzeile schreibt. 3. DIE TASTATUR FÜHRT DIE LISTE FORT, NICHT EINE LEISTE. Eine Werkzeugleiste über einem Notizblock ist eine Leiste zu viel: Man schreibt `- ` und drückt Enter, und die nächste Zeile fängt wieder mit `- ` an. Genauso bei `[ ] `. Das ist alles, was es an „Formatierung" gibt, und es fühlt sich wie Papier an, weil man nie den Stift wechselt. --------------------------------------------------------------------- WAS HIER NICHT PASSIERT Es gibt keine Rolle, die mehr sieht. Kein „Leitung"-Zweig, kein Umschalter, keine fremde Liste. Wer das hier liest und eine Sichtbarkeitsregel sucht: Es gibt keine, und das ist der Sinn der Sache (siehe notiz-tabellen.js). ===================================================================== */ (function () { 'use strict'; const $ = (id) => document.getElementById(id); const el = (was, klasse, text) => { const e = document.createElement(was); if (klasse) e.className = klasse; if (text !== undefined) e.textContent = text; return e; }; /* ---------- Zustand ---------------------------------------------- */ let notizen = []; let farben = []; let aktuell = null; // die offene Notiz (Objekt aus `notizen`) let suchwort = ''; let papierkorbZahl = 0; let textMax = 20000; let korbTage = 30; const feld = $('feld'); const deck = $('deck'); /* ---------- Fetch-Hülle ------------------------------------------- */ async function hole(pfad, einstellungen) { const a = await fetch(pfad, { credentials: 'same-origin', ...einstellungen }); if (a.status === 401) { location.href = 'index.html'; throw new Error('weg'); } return a; } async function fehlerText(a, ersatz) { try { const j = await a.json(); return typeof j?.fehler === 'string' && j.fehler ? j.fehler : ersatz; } catch { return ersatz; } } function melde(text) { const f = $('fehler'); f.textContent = text || ''; f.hidden = !text; } /* „vor 3 Minuten" statt eines Zeitstempels: Bei einer Notiz ist die Frage „wann war ich zuletzt dran?", nicht „welcher Tag war das?". Das genaue Datum bleibt als Titel am Element. */ function herZeit(iso) { if (!iso) return ''; const ms = Date.now() - Date.parse(iso); if (!Number.isFinite(ms)) return ''; const min = Math.floor(ms / 60000); if (min < 1) return 'gerade eben'; if (min < 60) return `vor ${min} Min.`; const std = Math.floor(min / 60); if (std < 24) return `vor ${std} Std.`; const tage = Math.floor(std / 24); if (tage === 1) return 'gestern'; if (tage < 30) return `vor ${tage} Tagen`; return new Date(iso).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }); } const genau = (iso) => (iso ? new Date(iso).toLocaleString('de-DE', { dateStyle: 'full', timeStyle: 'short' }) : ''); /* ===================================================================== DIE DECKSCHICHT Sie zeichnet denselben Text noch einmal — mit Haken, Überschriften und Strichen. Die eiserne Regel dabei steht im Kopf von notizen.css: KEINE Auszeichnung darf die Breite eines Zeichens ändern. Deshalb gibt es hier kein ``, kein `

` und keine Zeichen, die hinzukommen oder wegfallen — jedes Zeichen des Textes landet genau einmal in der Ausgabe. GEBAUT WIRD MIT createElement UND textContent, nicht mit einer Zeichenkette und innerHTML. Ein Notizblock ist der Ort, an dem jemand irgendwann `` hineinschreibt — nicht aus Bosheit, sondern weil er sich etwas notiert. Mit textContent ist das ein Text und bleibt einer. ===================================================================== */ /** Wandelt eine Zeile in Knoten um und hängt sie an `ziel`. */ function zeileZeichnen(ziel, zeile, treffer) { /* Die Zeile wird von links nach rechts abgearbeitet; `rest` ist, was noch nicht gezeichnet wurde. */ let rest = zeile; let erledigt = false; const stueck = (text, klasse, aufbau) => { if (!text) return null; const s = el('span', klasse, text); if (aufbau) aufbau(s); ziel.append(s); return s; }; /* 1. Der Zeilenanfang: Überschrift, Aufzählung, Zitat, Haken. */ let m; if ((m = /^(\s*)(#{1,3}\s)/.exec(rest))) { stueck(m[1], null); stueck(m[2], 'n-marke'); const inhalt = rest.slice(m[0].length); textMitLinks(ziel, inhalt, treffer, 'n-h'); return; } if ((m = /^(\s*)(\[[ xX]\])(\s?)/.exec(rest))) { stueck(m[1], null); const an = m[2][1] !== ' '; erledigt = an; stueck(m[2], 'n-hk', (s) => { s.dataset.an = an ? 'ja' : 'nein'; s.setAttribute('role', 'checkbox'); s.setAttribute('aria-checked', an ? 'true' : 'false'); s.title = an ? 'Erledigt – klicken zum Aufheben' : 'Als erledigt abhaken'; }); stueck(m[3], null); textMitLinks(ziel, rest.slice(m[0].length), treffer, erledigt ? 'n-erledigt' : null); return; } if ((m = /^(\s*)([-*]\s)/.exec(rest))) { stueck(m[1], null); stueck(m[2], 'n-punkt'); textMitLinks(ziel, rest.slice(m[0].length), treffer, null); return; } if ((m = /^(\s*)(>\s?)/.exec(rest))) { stueck(m[1], null); stueck(m[2], 'n-zitat'); textMitLinks(ziel, rest.slice(m[0].length), treffer, 'n-zitat'); return; } textMitLinks(ziel, rest, treffer, null); } /* Adressen werden anklickbar. Das Muster ist absichtlich eng: Es trifft nur, was mit http(s):// anfängt. Ein „www." ohne Schema wäre eine Vermutung, und eine Notiz ist kein Ort für Vermutungen. */ const ADRESSE = /https?:\/\/[^\s<>"')\]]+/g; function textMitLinks(ziel, text, treffer, klasse) { const stuecke = []; let zuletzt = 0; ADRESSE.lastIndex = 0; let m; while ((m = ADRESSE.exec(text))) { if (m.index > zuletzt) stuecke.push({ t: text.slice(zuletzt, m.index), link: false }); stuecke.push({ t: m[0], link: true }); zuletzt = m.index + m[0].length; } if (zuletzt < text.length) stuecke.push({ t: text.slice(zuletzt), link: false }); if (!stuecke.length) stuecke.push({ t: '', link: false }); for (const s of stuecke) { const klassen = [klasse, s.link ? 'n-link' : null].filter(Boolean).join(' '); if (s.link) { const a = el('a', klassen, s.t); a.href = s.t; a.target = '_blank'; /* `noopener` ist Pflicht: Ohne ihn bekommt die geöffnete Seite über `window.opener` Zugriff auf diese hier. */ a.rel = 'noopener noreferrer'; ziel.append(a); } else if (treffer) { trefferZeichnen(ziel, s.t, treffer, klassen); } else if (klassen) { ziel.append(el('span', klassen, s.t)); } else { ziel.append(document.createTextNode(s.t)); } } } /** Hebt die Fundstellen der Suche hervor — mit Fläche, nicht mit Breite. */ function trefferZeichnen(ziel, text, wort, klassen) { const klein = text.toLowerCase(); let von = 0, ab = 0; while ((von = klein.indexOf(wort, ab)) !== -1) { if (von > ab) ziel.append(el('span', klassen, text.slice(ab, von))); const t = el('span', (klassen ? klassen + ' ' : '') + 'n-treffer', text.slice(von, von + wort.length)); ziel.append(t); ab = von + wort.length; } if (ab < text.length) ziel.append(el('span', klassen, text.slice(ab))); } function deckZeichnen() { const text = feld.value; deck.textContent = ''; const treffer = suchwort.length >= 2 ? suchwort : ''; const zeilen = text.split('\n'); zeilen.forEach((z, i) => { zeileZeichnen(deck, z, treffer); /* Der Zeilenumbruch gehört zum Text und muss mitgezeichnet werden -- sonst stünde alles in einer Zeile. Beim LETZTEN Element darf er nicht dazu: Ein Schreibfeld hat nach der letzten Zeile keinen Umbruch, und die Deckschicht wäre eine Zeile zu hoch. */ if (i < zeilen.length - 1) deck.append(document.createTextNode('\n')); }); /* Ein Schreibfeld, das auf einen Umbruch endet, zeigt darunter noch eine leere Zeile. Ein
tut das nicht -- deshalb eine Leerzeile anhängen, damit beide gleich hoch bleiben. */ if (text.endsWith('\n')) deck.append(document.createTextNode(' ')); hoeheAnpassen(); titelSetzen(); hakenStandSetzen(); } /** Das Schreibfeld wächst mit dem Text — gescrollt wird der Rahmen. * * So bleibt die Deckschicht ohne eigenes Scrollen deckungsgleich: * Beide stehen im selben scrollenden Kasten. Ein eigener * Bildlauf im Schreibfeld würde außerdem eine Bildlaufleiste * einblenden, die Zeilen schmaler macht — und ab da bräche der * Text in beiden Schichten an verschiedenen Stellen um. */ function hoeheAnpassen() { feld.style.height = 'auto'; const hoch = Math.max(feld.scrollHeight, 320); feld.style.height = hoch + 'px'; deck.style.minHeight = hoch + 'px'; } function titelSetzen() { const t = (feld.value.split('\n').find((z) => z.trim().length) || '') .replace(/^\s*#{1,3}\s*/, '') .replace(/^\s*[-*]\s+/, '') .replace(/^\s*\[[ xX]\]\s*/, '') .trim(); const k = $('blatt-titel'); k.textContent = t || 'Ohne Titel'; k.dataset.leer = t ? 'nein' : 'ja'; k.title = t || ''; } /** Wie viele Haken gesetzt sind. Erscheint nur, wenn es welche gibt. */ function hakenZaehlen(text) { const alle = String(text || '').match(/^\s*\[[ xX]\]/gm) || []; const an = alle.filter((s) => !/\[ \]/.test(s)).length; return { alle: alle.length, an }; } function hakenStandSetzen() { const s = $('haken-stand'); const z = hakenZaehlen(feld.value); if (!z.alle) { s.hidden = true; return; } s.hidden = false; s.textContent = `${z.an} von ${z.alle} erledigt`; s.dataset.fertig = z.an === z.alle ? 'ja' : 'nein'; } /* ===================================================================== SICHERN Gespeichert wird 800 ms nach dem letzten Tastendruck. Klappt das nicht, bleibt der Text hier liegen und wird beim nächsten Versuch nachgereicht — und der Fuß sagt es. Ein „konnte nicht speichern" ohne Rettung wäre die schlechteste aller Meldungen: Sie erzählt von einem Verlust, den man gerade nicht verhindern kann. DER ZWISCHENSPEICHER IM BROWSER ist die zweite Sicherung, für den Fall, dass jemand den Reiter schließt, bevor es durchgeht. Beim nächsten Öffnen wird er angeboten — nicht heimlich eingespielt: Wer zwischendurch auf einem anderen Gerät geschrieben hat, würde sonst seine neuere Fassung verlieren. ===================================================================== */ const LAGER = 'dogi-notizen-offen'; let sicherUhr = null; let laeuft = false; let nochmal = false; function standSetzen(stand, wort) { const s = $('stand'); s.dataset.stand = stand; s.textContent = wort; } function lagerSchreiben(id, text) { try { localStorage.setItem(LAGER, JSON.stringify({ id, text, am: Date.now() })); } catch { /* Voller Speicher ist kein Grund, das Tippen zu stoppen. */ } } function lagerLeeren() { try { localStorage.removeItem(LAGER); } catch { /* egal */ } } function lagerLesen() { try { return JSON.parse(localStorage.getItem(LAGER) || 'null'); } catch { return null; } } function aendernGeplant() { if (!aktuell) return; lagerSchreiben(aktuell.id, feld.value); standSetzen('sichert', 'sichert …'); clearTimeout(sicherUhr); sicherUhr = setTimeout(sichern, 800); } async function sichern() { if (!aktuell) return; if (laeuft) { nochmal = true; return; } laeuft = true; const id = aktuell.id; const text = feld.value; try { const a = await hole(`/workspace/api/notizblock/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text }), }); if (!a.ok) { standSetzen('offen', await fehlerText(a, 'noch nicht gesichert')); return; } const j = await a.json(); const n = notizen.find((x) => x.id === id); if (n) { n.text = text; n.geaendert = j.geaendert; n.titel = titelAus(text); } lagerLeeren(); standSetzen('gesichert', 'gesichert'); wannSetzen(); stapelZeichnen(); } catch { /* Kein Netz. Der Text liegt im Browser; beim nächsten Tippen oder beim nächsten Öffnen geht es weiter. */ standSetzen('offen', 'noch nicht gesichert – bleibt hier, bis es geht'); } finally { laeuft = false; if (nochmal) { nochmal = false; setTimeout(sichern, 200); } } } const titelAus = (text) => (String(text || '').split('\n').find((z) => z.trim().length) || '') .replace(/^\s*#{1,3}\s*/, '').replace(/^\s*[-*]\s+/, '') .replace(/^\s*\[[ xX]\]\s*/, '').trim().slice(0, 120); function wannSetzen() { const w = $('wann'); if (!aktuell) { w.textContent = ''; return; } w.textContent = herZeit(aktuell.geaendert); w.title = genau(aktuell.geaendert); } /* ===================================================================== DER STAPEL ===================================================================== */ function passt(n) { if (suchwort.length < 2) return true; return String(n.text || '').toLowerCase().includes(suchwort); } function stapelZeichnen() { const liste = $('liste'); liste.textContent = ''; const sichtbar = notizen.filter(passt); $('anzahl').textContent = suchwort.length >= 2 ? `${sichtbar.length}/${notizen.length}` : String(notizen.length); for (const n of sichtbar) { const li = el('li'); const b = el('button', 'n-zettel'); b.type = 'button'; b.style.setProperty('--n-tinte', farbHex(n.farbe)); if (aktuell && n.id === aktuell.id) b.setAttribute('aria-current', 'true'); b.append(el('span', 'n-zettel__titel', n.titel || 'Ohne Titel')); const vor = zweiteZeile(n.text); if (vor) b.append(el('span', 'n-zettel__vor', vor)); const fuss = el('span', 'n-zettel__fuss'); if (n.angeheftet) { const nadel = el('span', 'n-zettel__nadel', '📌'); nadel.title = 'Angeheftet'; fuss.append(nadel); } fuss.append(el('span', null, herZeit(n.geaendert))); const h = hakenZaehlen(n.text); if (h.alle) { const hz = el('span', 'n-zettel__haken', `${h.an}/${h.alle}`); hz.dataset.fertig = h.an === h.alle ? 'ja' : 'nein'; hz.title = `${h.an} von ${h.alle} erledigt`; fuss.append(hz); } b.append(fuss); b.addEventListener('click', () => oeffne(n.id)); li.append(b); liste.append(li); } $('leer').hidden = notizen.length > 0; $('korb-auf').hidden = papierkorbZahl === 0; $('korb-zahl').textContent = String(papierkorbZahl); } function zweiteZeile(text) { const zeilen = String(text || '').split('\n').filter((z) => z.trim().length); return (zeilen[1] || '').replace(/^\s*\[[ xX]\]\s*/, '').replace(/^\s*[-*#>]+\s*/, '') .trim().slice(0, 90); } const farbHex = (name) => (farben.find((f) => f.name === name) || farben[0] || {}).hex || '#9cde90'; /* ===================================================================== EIN BLATT AUFSCHLAGEN ===================================================================== */ function oeffne(id, { fokus = true } = {}) { const n = notizen.find((x) => x.id === id); if (!n) return; /* Was im alten Blatt noch offen ist, geht zuerst raus. Sonst landet der letzte Satz beim nächsten Blatt -- oder nirgends. */ if (aktuell && aktuell.id !== id) { clearTimeout(sicherUhr); sichern(); } aktuell = n; $('korb').hidden = true; $('blatt').hidden = false; $('blatt').style.setProperty('--n-tinte', farbHex(n.farbe)); feld.value = n.text || ''; feld.maxLength = textMax; deckZeichnen(); wannSetzen(); standSetzen('gesichert', 'gesichert'); $('anheften').setAttribute('aria-pressed', n.angeheftet ? 'true' : 'false'); farbenZeichnen(); stapelZeichnen(); if (fokus) { feld.focus(); /* Ans Ende springen, nicht an den Anfang: Wer ein Blatt aufschlägt, schreibt weiter -- er liest es nicht von vorn. */ feld.setSelectionRange(feld.value.length, feld.value.length); } zurueckKnopf(true); } function schliessen() { clearTimeout(sicherUhr); if (aktuell) sichern(); aktuell = null; $('blatt').hidden = true; $('korb').hidden = true; stapelZeichnen(); zurueckKnopf(false); } /* Der Zurück-Knopf der Kopfleiste schließt auf dem Handy das Blatt. Auf dem Rechner wird er nicht gebraucht -- dort stehen Stapel und Blatt nebeneinander, es gibt nichts zu schließen. */ function zurueckKnopf(an) { const k = $('zurueck'); if (!k) return; const schmal = window.matchMedia('(max-width: 860px)').matches; k.hidden = !(an && schmal); } function farbenZeichnen() { const kasten = $('farben'); kasten.textContent = ''; for (const f of farben) { const b = el('button', 'n-farbe'); b.type = 'button'; b.style.setProperty('--f', f.hex); b.title = f.wort; b.setAttribute('aria-label', `Papierfarbe ${f.wort}`); b.setAttribute('aria-pressed', aktuell && aktuell.farbe === f.name ? 'true' : 'false'); b.addEventListener('click', () => farbeSetzen(f.name)); kasten.append(b); } } async function farbeSetzen(name) { if (!aktuell) return; const alt = aktuell.farbe; aktuell.farbe = name; $('blatt').style.setProperty('--n-tinte', farbHex(name)); farbenZeichnen(); stapelZeichnen(); try { const a = await hole(`/workspace/api/notizblock/${aktuell.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ farbe: name }), }); if (!a.ok) throw new Error('nein'); } catch { /* Zurückdrehen. Eine Farbe, die nach dem Neuladen wieder umspringt, ist schlimmer als eine, die gar nicht erst umspringt. */ aktuell.farbe = alt; $('blatt').style.setProperty('--n-tinte', farbHex(alt)); farbenZeichnen(); stapelZeichnen(); melde('Die Farbe ließ sich nicht sichern. Versuch es gleich noch einmal.'); } } /* ===================================================================== DIE TASTATUR — sie führt die Liste fort ===================================================================== */ /** Setzt Text und Cursor in einem Zug, so dass der Browser es * rückgängig machen kann. * * `document.execCommand('insertText')` ist offiziell veraltet und * trotzdem das Einzige, was den Rückgängig-Stapel des Schreibfelds * füllt. Wer stattdessen `value` setzt, nimmt dem Benutzer Strg+Z * weg -- in einem Notizblock die unfreundlichste aller Ersparnisse. * Geht es nicht, wird `value` gesetzt; dann fehlt nur das * Rückgängigmachen für diesen einen Schritt. */ function einfuegen(text) { feld.focus(); let ging = false; try { ging = document.execCommand('insertText', false, text); } catch { ging = false; } if (!ging) { const a = feld.selectionStart, b = feld.selectionEnd; feld.value = feld.value.slice(0, a) + text + feld.value.slice(b); feld.setSelectionRange(a + text.length, a + text.length); } deckZeichnen(); aendernGeplant(); } function zeileUm(pos) { const v = feld.value; const von = v.lastIndexOf('\n', pos - 1) + 1; let bis = v.indexOf('\n', pos); if (bis === -1) bis = v.length; return { von, bis, text: v.slice(von, bis) }; } /** Haken auf der Zeile des Cursors umschalten — oder einen setzen. */ function hakenUmschalten() { const z = zeileUm(feld.selectionStart); const m = /^(\s*)\[([ xX])\]/.exec(z.text); if (m) { const neu = m[2] === ' ' ? 'x' : ' '; feld.setSelectionRange(z.von + m[1].length + 1, z.von + m[1].length + 2); einfuegen(neu); feld.setSelectionRange(z.bis, z.bis); return; } /* Keiner da: einen setzen, und zwar hinter der Einrückung. */ const e = /^(\s*)/.exec(z.text)[1]; feld.setSelectionRange(z.von + e.length, z.von + e.length); einfuegen('[ ] '); } feld.addEventListener('keydown', (e) => { /* Strg/Cmd + Enter: neues Blatt. */ if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); neuesBlatt(); return; } /* Strg/Cmd + Leertaste: Haken auf dieser Zeile. */ if ((e.ctrlKey || e.metaKey) && (e.key === ' ' || e.code === 'Space')) { e.preventDefault(); hakenUmschalten(); return; } /* Tabulator rückt ein, statt aus dem Feld zu springen. MIT UMSCHALT springt er weiter -- sonst käme man mit der Tastatur nicht mehr aus dem Blatt heraus, und das wäre eine Falle für alle, die nicht mit der Maus arbeiten. */ if (e.key === 'Tab' && !e.shiftKey) { e.preventDefault(); einfuegen(' '); return; } if (e.key !== 'Enter' || e.shiftKey || e.ctrlKey || e.metaKey || e.altKey) return; /* Enter setzt die Liste fort. */ const z = zeileUm(feld.selectionStart); const vorn = /^(\s*)(\[[ xX]\]\s|[-*]\s|>\s?)/.exec(z.text); if (!vorn) return; const nurZeichen = z.text.trim() === vorn[2].trim(); e.preventDefault(); if (nurZeichen) { /* Zweimal Enter beendet die Liste -- wie in jedem Programm, das Listen kann. Die angefangene Zeile wird dabei geleert. */ feld.setSelectionRange(z.von, z.bis); einfuegen('\n'); return; } /* Ein gesetzter Haken wird NICHT mitgenommen: Die nächste Aufgabe ist noch nicht erledigt. */ const fortsetzung = vorn[2].replace(/\[[xX]\]/, '[ ]'); einfuegen('\n' + vorn[1] + fortsetzung); }); feld.addEventListener('input', () => { deckZeichnen(); aendernGeplant(); }); feld.addEventListener('scroll', () => { $('papier').scrollTop = feld.scrollTop; }); /* Beim Verlassen sofort sichern statt die 800 ms abzuwarten. */ feld.addEventListener('blur', () => { clearTimeout(sicherUhr); sichern(); }); /* Ein Klick auf ein Kästchen schaltet es um. Die Deckschicht lässt sonst alles durch; nur die Haken fangen den Klick ab. */ deck.addEventListener('click', (e) => { const hk = e.target.closest('.n-hk'); if (!hk) return; e.preventDefault(); /* Wo im Text steht dieses Kästchen? Gezählt wird über die Knoten davor -- das ist genauer als eine Suche nach dem n-ten „[ ]", weil derselbe Text mehrfach vorkommen kann. */ let pos = 0; const gehen = (knoten) => { for (const k of knoten.childNodes) { if (k === hk) return true; if (k.nodeType === 3) pos += k.nodeValue.length; else if (gehen(k)) return true; } return false; }; if (!gehen(deck)) return; const an = hk.dataset.an === 'ja'; feld.focus(); feld.setSelectionRange(pos + 1, pos + 2); einfuegen(an ? ' ' : 'x'); feld.setSelectionRange(pos + 3, pos + 3); }); /* ===================================================================== DIE GRIFFE ===================================================================== */ async function neuesBlatt() { melde(''); try { const a = await hole('/workspace/api/notizblock', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: '' }), }); if (!a.ok) { melde(await fehlerText(a, 'Das Blatt ließ sich nicht anlegen.')); return; } const j = await a.json(); notizen.unshift(j.notiz); oeffne(j.notiz.id); } catch { melde('Keine Verbindung. Sobald sie wieder da ist, geht es weiter.'); } } $('neu').addEventListener('click', neuesBlatt); $('leer-neu').addEventListener('click', neuesBlatt); $('anheften').addEventListener('click', async () => { if (!aktuell) return; const neu = !aktuell.angeheftet; aktuell.angeheftet = neu; $('anheften').setAttribute('aria-pressed', neu ? 'true' : 'false'); /* Umsortieren, damit man sieht, was der Knopf bewirkt hat. */ notizen.sort((a, b) => (Number(b.angeheftet) - Number(a.angeheftet)) || (Date.parse(b.geaendert) - Date.parse(a.geaendert))); stapelZeichnen(); try { const a = await hole(`/workspace/api/notizblock/${aktuell.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ angeheftet: neu }), }); if (!a.ok) throw new Error('nein'); } catch { aktuell.angeheftet = !neu; $('anheften').setAttribute('aria-pressed', !neu ? 'true' : 'false'); stapelZeichnen(); melde('Das Anheften ließ sich nicht sichern.'); } }); $('wegtun').addEventListener('click', async () => { if (!aktuell) return; const id = aktuell.id; clearTimeout(sicherUhr); await sichern(); try { const a = await hole(`/workspace/api/notizblock/${id}`, { method: 'DELETE' }); if (!a.ok) { melde(await fehlerText(a, 'Ging nicht.')); return; } notizen = notizen.filter((x) => x.id !== id); papierkorbZahl += 1; aktuell = null; $('blatt').hidden = true; stapelZeichnen(); zurueckKnopf(false); /* KEINE RÜCKFRAGE VORHER, ABER EIN WEG ZURÜCK NACHHER. Eine Rückfrage bei jedem Wegtun ist eine Warnung, die immer kommt -- die klickt man weg. Der Papierkorb ist die bessere Sicherung: Er kostet nichts und hält dreißig Tage. */ melde(`Weggelegt. Liegt ${korbTage} Tage im Papierkorb.`); setTimeout(() => melde(''), 6000); } catch { melde('Keine Verbindung.'); } }); /* ===================================================================== DER PAPIERKORB ===================================================================== */ $('korb-auf').addEventListener('click', korbZeigen); $('korb-zu').addEventListener('click', () => { $('korb').hidden = true; if (aktuell) $('blatt').hidden = false; else zurueckKnopf(false); }); async function korbZeigen() { melde(''); try { const a = await hole('/workspace/api/notizblock/papierkorb'); if (!a.ok) { melde(await fehlerText(a, 'Der Papierkorb ließ sich nicht öffnen.')); return; } const j = await a.json(); korbTage = j.papierkorb_tage || korbTage; $('blatt').hidden = true; $('korb').hidden = false; zurueckKnopf(true); $('korb-satz').textContent = j.notizen.length ? `Hier bleibt alles ${korbTage} Tage liegen und geht dann von selbst.` : 'Nichts drin.'; const liste = $('korb-liste'); liste.textContent = ''; for (const n of j.notizen) { const li = el('li'); const zeile = el('div', 'n-korb-zeile'); zeile.style.setProperty('--n-tinte', farbHex(n.farbe)); const t = el('div', 'n-korb-zeile__text'); t.append(el('span', 'n-korb-zeile__titel', n.titel || 'Ohne Titel')); const w = el('span', 'n-korb-zeile__wann', `weggelegt ${herZeit(n.weg_am)} · verschwindet ${herTag(n.endgueltig_am)}`); w.title = genau(n.endgueltig_am); t.append(w); zeile.append(t); const zurueck = el('button', 'knopf-still', 'Zurückholen'); zurueck.type = 'button'; zurueck.addEventListener('click', () => zurueckholen(n.id)); zeile.append(zurueck); const weg = el('button', 'n-griff n-griff--weg'); weg.type = 'button'; weg.append(el('span', 'n-griff__text', 'Endgültig')); weg.addEventListener('click', () => endgueltig(n.id, n.titel)); zeile.append(weg); li.append(zeile); liste.append(li); } } catch { melde('Keine Verbindung.'); } } /** „in 12 Tagen" — dieselbe Idee wie herZeit, nur nach vorn. */ function herTag(iso) { const ms = Date.parse(iso) - Date.now(); if (!Number.isFinite(ms)) return ''; const tage = Math.ceil(ms / 86400000); if (tage <= 0) return 'demnächst'; if (tage === 1) return 'morgen'; return `in ${tage} Tagen`; } async function zurueckholen(id) { try { const a = await hole(`/workspace/api/notizblock/${id}/zurueck`, { method: 'POST' }); if (!a.ok) { melde(await fehlerText(a, 'Ging nicht.')); return; } const j = await a.json(); notizen.unshift(j.notiz); notizen.sort((x, y) => (Number(y.angeheftet) - Number(x.angeheftet)) || (Date.parse(y.geaendert) - Date.parse(x.geaendert))); papierkorbZahl = Math.max(0, papierkorbZahl - 1); $('korb').hidden = true; oeffne(j.notiz.id); } catch { melde('Keine Verbindung.'); } } async function endgueltig(id, titel) { /* HIER WIRD GEFRAGT, und nur hier. Der Unterschied zum Wegtun ist genau der, der eine Rückfrage rechtfertigt: Danach gibt es keinen Weg zurück mehr. */ const name = titel ? `„${titel}"` : 'dieses Blatt'; if (!window.confirm(`${name} endgültig löschen?\n\nDanach ist es weg – ` + 'auch für dich.')) return; try { const a = await hole(`/workspace/api/notizblock/${id}/endgueltig`, { method: 'DELETE' }); if (!a.ok) { melde(await fehlerText(a, 'Ging nicht.')); return; } papierkorbZahl = Math.max(0, papierkorbZahl - 1); stapelZeichnen(); korbZeigen(); } catch { melde('Keine Verbindung.'); } } /* ===================================================================== SUCHE ===================================================================== */ $('suche').addEventListener('input', (e) => { suchwort = String(e.target.value || '').trim().toLowerCase(); stapelZeichnen(); if (aktuell) deckZeichnen(); }); $('suche').addEventListener('keydown', (e) => { if (e.key !== 'Escape') return; e.target.value = ''; suchwort = ''; stapelZeichnen(); if (aktuell) deckZeichnen(); }); document.addEventListener('keydown', (e) => { if ((e.ctrlKey || e.metaKey) && (e.key === 'k' || e.key === 'K')) { e.preventDefault(); $('suche').focus(); $('suche').select(); } if (e.key === 'Escape' && document.activeElement === feld) { feld.blur(); if (window.matchMedia('(max-width: 860px)').matches) schliessen(); } }); $('zurueck')?.addEventListener('click', (e) => { if ($('blatt').hidden && $('korb').hidden) return; // nicht unseres e.preventDefault(); e.stopPropagation(); schliessen(); }, true); window.addEventListener('resize', () => { zurueckKnopf(!!aktuell); hoeheAnpassen(); }); /* Beim Verlassen der Seite noch einmal losschicken. `keepalive` sorgt dafür, dass die Anfrage den Seitenwechsel überlebt -- ohne das wird sie vom Browser abgebrochen, und der letzte Satz wäre weg. */ window.addEventListener('pagehide', () => { if (!aktuell) return; const n = notizen.find((x) => x.id === aktuell.id); if (n && n.text === feld.value) return; lagerSchreiben(aktuell.id, feld.value); try { fetch(`/workspace/api/notizblock/${aktuell.id}`, { method: 'PATCH', credentials: 'same-origin', keepalive: true, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: feld.value }), }); } catch { /* Dann hilft der Zwischenspeicher beim nächsten Mal. */ } }); /* ===================================================================== LOS ===================================================================== */ async function laden() { try { const a = await hole('/workspace/api/notizblock'); if (!a.ok) { melde(await fehlerText(a, 'Der Block ließ sich nicht laden.')); return; } const j = await a.json(); notizen = j.notizen || []; farben = j.farben || []; papierkorbZahl = j.papierkorb || 0; textMax = j.text_max || textMax; korbTage = j.papierkorb_tage || korbTage; stapelZeichnen(); /* Lag noch etwas im Browser? Anbieten, nicht einspielen. */ const rest = lagerLesen(); const dazu = rest && notizen.find((x) => x.id === rest.id); if (dazu && rest.text !== dazu.text) { melde('Auf diesem Gerät liegt noch eine ungesicherte Fassung von ' + `„${dazu.titel || 'Ohne Titel'}". Öffne das Blatt – du kannst sie dann übernehmen.`); dazu.rest = rest.text; } else if (rest) { lagerLeeren(); } /* Auf dem Rechner wird das oberste Blatt gleich aufgeschlagen -- ein leerer Arbeitsplatz neben einem vollen Stapel ist eine Aufforderung zum Suchen. Auf dem Handy nicht: Dort legt sich das Blatt über alles, und man käme nie an die Liste. */ if (notizen.length && !window.matchMedia('(max-width: 860px)').matches) { oeffne(notizen[0].id, { fokus: false }); } } catch { melde('Keine Verbindung zum Server.'); } } laden(); })();