diff --git a/server/pruef-wissen-neu.mjs b/server/pruef-wissen-neu.mjs new file mode 100644 index 00000000..d79a60c3 --- /dev/null +++ b/server/pruef-wissen-neu.mjs @@ -0,0 +1,185 @@ +/* Prueft "Neu & wichtig" in der Bibliothek: + + 1. Es steht nur JE EINER da -- der wichtigste und der neueste. + 2. Der Rest kommt auf Knopfdruck und geht wieder weg. + 3. "Wichtig" laeuft nach 48 Stunden ab, genau wie "neu". + + Punkt 3 ist der, den man leicht falsch prueft: Mit frischen Daten ist + ALLES neu, und ein abgelaufener Eintrag kommt nie vor. Deshalb werden + hier ausdruecklich Eintraege mit einem Zeitstempel von vor drei Tagen + angelegt -- sonst waere die Pruefung gruen, ohne den Fall je gesehen + zu haben. */ +import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const ordner = mkdtempSync(join(tmpdir(), "ws-wissen-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); +process.env.PORT = "4201"; +process.env.SITE_ACCESS_SECRET = "lokaler-test"; +process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1"; +process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; + +const express = (await import("express")).default; +const ec = express.response.cookie; +express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); }; +await import("./index.js"); +await new Promise((r) => setTimeout(r, 700)); +const BASIS = "http://127.0.0.1:4201"; +setTimeout(() => { console.log("ABBRUCH"); process.exit(1); }, 200_000).unref?.(); + +let fehler = 0; +const ok = (b, t) => { console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; + +await fetch(BASIS + "/workspace/api/anmelden", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {}); + +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes } = await import("node:crypto"); +for (let i = 0; i < 60; i++) { + try { + const p = new DatabaseSync(process.env.WORKSPACE_DB); + try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); } + } catch { await new Promise((r) => setTimeout(r, 250)); } +} + +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const jetzt = new Date().toISOString(); +const salt = randomBytes(16).toString("hex"); +const hash = scryptSync("CODE-DOGI-0001", salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); +d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)") + .run("Filipe", "admin", hash, salt, 32768, jetzt); + +/* Die Dateien muessen auf der Platte liegen, sonst zeigt die Seite sie + zwar an, aber der Server koennte sie nicht ausliefern. */ +const wissenOrdner = join(ordner, "wissen"); +mkdirSync(wissenOrdner, { recursive: true }); + +const vorDreiTagen = new Date(Date.now() - 3 * 86400_000).toISOString(); +const vorEinerStunde = new Date(Date.now() - 3600_000).toISOString(); + +/* Vier Faelle, und der dritte ist der eigentliche Punkt. */ +const EINTRAEGE = [ + ["Frisch und wichtig", 1, vorEinerStunde], // muss oben stehen + ["Frisch, nicht wichtig", 0, vorEinerStunde], // muss oben stehen + ["ALT aber wichtig", 1, vorDreiTagen], // darf NICHT mehr oben stehen + ["Alt und unwichtig", 0, vorDreiTagen], // darf nicht oben stehen + ["Noch einer frisch", 0, vorEinerStunde], // landet im Aufklappteil +]; +for (const [titel, wichtig, wann] of EINTRAEGE) { + const datei = randomBytes(8).toString("hex") + ".pdf"; + writeFileSync(join(wissenOrdner, datei), "%PDF-1.4\n%%EOF\n"); + d.prepare(`INSERT INTO wissen + (titel, beschreibung, kategorie, stufe, geraet, name_datei, name_original, groesse, + wichtig, veroeffentlicht, hochgeladen, erstellt_von) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`) + .run(titel, "Beschreibung", "tiktok-grundlagen", "einsteiger", "egal", + datei, titel + ".pdf", 1024, wichtig, wann.slice(0, 10), wann, 1); +} +d.close(); + +/* ---------- Was der Server liefert --------------------------------------- */ +async function anmelden() { + const a = await fetch(BASIS + "/workspace/api/anmelden", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rolle: "admin", code: "CODE-DOGI-0001" }) }); + return (a.headers.getSetCookie?.() || []).map((k) => k.split(";")[0]).join("; "); +} +const keks = await anmelden(); + +console.log("\n=== Was oben stehen darf"); +{ + const a = await fetch(BASIS + "/workspace/api/wissen", { headers: { Cookie: keks } }); + const { neu } = await a.json(); + const namen = neu.map((e) => e.titel); + console.log(" oben:", namen.join(" · ")); + + ok(namen.includes("Frisch und wichtig"), "das frische Wichtige steht oben"); + ok(namen.includes("Frisch, nicht wichtig"), "das frisch Eingestellte auch"); + /* DER EIGENTLICHE PUNKT: Vor dem 01.09.2026 blieb "wichtig" ewig + stehen. Ein drei Tage alter, als wichtig markierter Eintrag muss + jetzt verschwunden sein. */ + ok(!namen.includes("ALT aber wichtig"), + `"wichtig" laeuft nach 48 Stunden ab (${namen.includes("ALT aber wichtig") ? "steht noch da" : "weg"})`); + ok(!namen.includes("Alt und unwichtig"), "und Altes ohne Marke erst recht"); + + /* Wichtiges zuerst -- sonst steht der Scheinwerfer an zweiter Stelle. */ + ok(neu[0]?.titel === "Frisch und wichtig", + `Wichtiges steht an erster Stelle (${neu[0]?.titel})`); + ok(neu.every((e) => typeof e.ist_wichtig === "boolean"), + "jeder Eintrag sagt, ob er wichtig ist"); +} + +/* ---------- Was die Seite zeigt ------------------------------------------ */ +console.log("\n=== Was die Seite zeigt"); +const { chromium } = await import( + "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); +const browser = await chromium.launch(); +{ + const kontext = await browser.newContext({ viewport: { width: 1280, height: 1000 } }); + const konsole = []; + const seite = await kontext.newPage(); + seite.on("console", (m) => { if (m.type() === "error") konsole.push(m.text()); }); + seite.on("pageerror", (e) => konsole.push("pageerror: " + e.message)); + + await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" }); + await seite.click('.rolle[data-rolle="admin"]'); + await seite.fill("#code", "CODE-DOGI-0001"); + await seite.click("button[type=submit]"); + await seite.waitForURL("**/start.html", { timeout: 15000 }); + await seite.goto(BASIS + "/workspace/wissen.html", { waitUntil: "networkidle" }); + await seite.waitForTimeout(900); + + const lesen = () => seite.evaluate(() => { + const block = [...document.querySelectorAll(".block")] + .find((b) => b.querySelector(".block__titel")?.textContent === "Neu & wichtig"); + if (!block) return null; + /* Die Karten heissen .pdf -- der erste Anlauf suchte nach + ".karte" und zaehlte dabei die UMSCHLIESSENDEN Container mit. */ + const sichtbar = [...block.querySelectorAll(".pdf")] + .filter((k) => k.offsetParent !== null); + return { + sichtbar: sichtbar.length, + titel: sichtbar.map((k) => k.querySelector(".pdf__titel, h3, strong")?.textContent?.trim()) + .filter(Boolean), + restVerborgen: document.getElementById("neu-rest")?.hidden, + restAnzahl: document.getElementById("neu-rest")?.children.length ?? 0, + knopf: block.querySelector(".knopf-still")?.textContent?.trim() || null, + }; + }); + + const r = await lesen(); + console.log(" sichtbar:", r?.titel.join(" · "), "| Knopf:", r?.knopf); + ok(r !== null, "der Block \"Neu & wichtig\" ist da"); + /* GENAU ZWEI: einer wichtig, einer neu. */ + ok(r.sichtbar === 2, `es stehen genau zwei da (${r.sichtbar})`); + ok(r.restVerborgen === true, `der Rest ist zugeklappt (${r.restVerborgen})`); + ok(r.restAnzahl > 0, `und es gibt ueberhaupt einen Rest (${r.restAnzahl})`); + ok(/Alle \d+ zeigen/.test(r.knopf || ""), `der Knopf nennt die Zahl ("${r.knopf}")`); + + /* Aufklappen muss WIRKLICH etwas zeigen -- ein Knopf, der nur seine + Beschriftung aendert, ist keiner. */ + await seite.click(".block .knopf-still"); + await seite.waitForTimeout(350); + const auf = await lesen(); + ok(auf.sichtbar > r.sichtbar, + `aufgeklappt stehen mehr da (${r.sichtbar} -> ${auf.sichtbar})`); + ok(auf.restVerborgen === false, "der Rest ist sichtbar"); + ok(!/Alle \d+ zeigen/.test(auf.knopf || ""), + `und der Knopf bietet das Zuklappen an ("${auf.knopf}")`); + + /* Und wieder zu. */ + await seite.click(".block .knopf-still"); + await seite.waitForTimeout(350); + const zu = await lesen(); + ok(zu.sichtbar === 2, `zugeklappt sind es wieder zwei (${zu.sichtbar})`); + + ok(konsole.length === 0, `keine Fehler in der Konsole${konsole.length ? " -> " + konsole.join(" | ") : ""}`); + await kontext.close(); +} + +await browser.close(); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } +console.log(fehler ? `\n${fehler} Prüfung(en) fehlgeschlagen.` : "\nAlles in Ordnung."); +process.exit(fehler ? 1 : 0); diff --git a/server/workspace-wissen.js b/server/workspace-wissen.js index 1d8c7cd0..94fdb716 100644 --- a/server/workspace-wissen.js +++ b/server/workspace-wissen.js @@ -257,10 +257,18 @@ wissenRouter.get("/workspace/api/wissen", (req, res) => { Datum und darf frei gesetzt werden. Bei alten Eintraegen ohne Zeitstempel wird auf Mitternacht des Datums zurueckgefallen. - WICHTIG bleibt stehen, bis es jemand wieder abschaltet. Es ist - eine Entscheidung von Hand ("Erscheint zusaetzlich ganz oben"), - keine Eigenschaft, die von selbst verfaellt -- sonst waere der - Schalter sinnlos. */ + WICHTIG lief bis zum 01.09.2026 NICHT ab -- es blieb stehen, bis + es jemand von Hand abschaltete. Das hat sich als falsch erwiesen: + Niemand raeumt so etwas auf, und nach ein paar Wochen stuende + oben eine Liste von Dingen, die laengst niemanden mehr angehen. + Auf ausdruecklichen Wunsch laeuft es jetzt ebenfalls nach 48 + Stunden ab -- gemessen ab demselben Zeitpunkt wie bei NEU. + + Der Schalter bleibt sinnvoll: Er hebt einen Eintrag fuer zwei + Tage nach oben, auch wenn dieser aelter ist. Er ist damit ein + Scheinwerfer, kein Regal. Wer laenger oben stehen will, setzt + ihn neu -- und trifft damit bewusst eine Entscheidung, statt + dass etwas von allein liegen bleibt. */ const grenze = Date.now() - NEU_STUNDEN * 3600_000; const istNeu = (e) => { const wann = e.hochgeladen @@ -268,11 +276,22 @@ wissenRouter.get("/workspace/api/wissen", (req, res) => { : Date.parse((e.veroeffentlicht || "") + "T00:00:00"); return Number.isFinite(wann) && wann >= grenze; }; + /* Wichtig zaehlt nur, solange es FRISCH ist -- dieselben 48 + Stunden wie bei neu. */ + const istWichtig = (e) => !!e.wichtig && istNeu(e); const neu = !k && !q && !stufe && !geraet && !tag - ? liste.filter((e) => e.wichtig || istNeu(e)).slice(0, 6) + ? liste.filter((e) => istWichtig(e) || istNeu(e)) : []; - /* Damit die Oberflaeche sagen kann, WARUM etwas oben steht. */ - for (const e of neu) e.ist_neu = istNeu(e); + /* Damit die Oberflaeche sagen kann, WARUM etwas oben steht -- und + damit sie Wichtiges zuerst zeigen kann. */ + for (const e of neu) { e.ist_neu = istNeu(e); e.ist_wichtig = istWichtig(e); } + /* Wichtiges zuerst, dann das Neueste. Die Oberflaeche zeigt davon + zunaechst nur je einen Eintrag (siehe wissen.js) -- deshalb wird + hier NICHT mehr auf sechs gekuerzt: Was sie zeigt, entscheidet + sie selbst, und der Aufklappknopf braucht den Rest. */ + neu.sort((a, b) => (Number(b.ist_wichtig) - Number(a.ist_wichtig)) + || String(b.hochgeladen || b.veroeffentlicht || '').localeCompare( + String(a.hochgeladen || a.veroeffentlicht || ''))); res.json({ eintraege: liste, neu, gesamt: liste.length }); } catch (fehler) { diff --git a/workspace/assets/css/start.css b/workspace/assets/css/start.css index d68cd358..d96211d9 100644 --- a/workspace/assets/css/start.css +++ b/workspace/assets/css/start.css @@ -1762,3 +1762,51 @@ body.start #protokoll { padding: 4px 2px; } } + +/* --- Der Aktionsknopf steht IMMER oben rechts ------------------------- + + Beanstandet am 01.09.2026: "einmal rechts, einmal links". Der Knopf + sprang, und zwar abhaengig von der TEXTLAENGE: Die Kopfzeile ist eine + umbrechende Flex-Zeile, und der Textblock daneben durfte wachsen. Auf + der Bibliotheks-Startseite ist die Unterzeile kurz -- der Knopf blieb + rechts. Auf einer Kategorieseite ("Community-Richtlinien, erlaubte + und verbotene Inhalte, Altersprüfung, Sperren, Verwarnungen, + Datenschutz, Jugendschutz und sicheres Verhalten") war sie lang, der + Textblock nahm die ganze Breite, und der Knopf rutschte darunter. + + Es sah aus wie zwei verschiedene Seiten und war doch dieselbe Regel. + + Jetzt: Der Textblock schrumpft (min-width: 0), der Knopf schrumpft + NICHT (flex: none) und wird nach rechts geschoben. Damit steht er auf + jeder Seite an derselben Stelle -- unabhaengig davon, wie lang der + Text daneben ist. Erst auf dem Handy rutscht er bewusst darunter, + dort ist nebeneinander kein Platz. */ +.kopf-zeile > .steuerung, +.kopf-zeile > .kopf-knoepfe, +.kopf-zeile > .knopf, +.kopf-zeile > button, +.kopf-zeile > #auswahl-block { + flex: none; + margin-left: auto; + align-self: flex-start; +} +.kopf-zeile > .kopf-zeile__marke, +.kopf-zeile > div:first-child { + min-width: 0; + flex: 1 1 auto; +} +/* Die Unterzeile darf dabei umbrechen statt den Knopf wegzudruecken. */ +.kopf-zeile .unterzeile { overflow-wrap: anywhere; } + +@media (max-width: 700px) { + /* Auf dem Handy unter den Titel -- und ueber die volle Breite, damit + er ein ordentliches Beruehrziel ist. */ + .kopf-zeile { flex-direction: column; align-items: stretch; } + .kopf-zeile > .steuerung, + .kopf-zeile > .kopf-knoepfe, + .kopf-zeile > .knopf, + .kopf-zeile > button, + .kopf-zeile > #auswahl-block { margin-left: 0; width: 100%; } + .kopf-zeile > .steuerung > .knopf, + .kopf-zeile > .kopf-knoepfe > .knopf { width: 100%; justify-content: center; } +} diff --git a/workspace/assets/js/wissen.js b/workspace/assets/js/wissen.js index 73786c6c..9f0d4bdf 100644 --- a/workspace/assets/js/wissen.js +++ b/workspace/assets/js/wissen.js @@ -86,13 +86,69 @@ 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 eingestellte Anleitungen stehen 48 Stunden hier. Danach sind sie ' - + 'in ihrer Kategorie zu finden – angepinnte bleiben, bis du sie abschaltest.')); + '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 neuLetzte) box.append(karte(e)); + 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); } diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html index d6e2e7f8..8d0c6ff2 100644 --- a/workspace/aufgaben.html +++ b/workspace/aufgaben.html @@ -7,9 +7,9 @@ - - - + + +
@@ -139,9 +139,9 @@ - - - - + + + +