diff --git a/server/pruef-highlights.mjs b/server/pruef-highlights.mjs new file mode 100644 index 00000000..5a845fff --- /dev/null +++ b/server/pruef-highlights.mjs @@ -0,0 +1,261 @@ +/* ===================================================================== + pruef-highlights.mjs — Drei Accounts nebeneinander, nach Zeit geteilt + + Filipe, 20.09.2026: „die highlights sollen in 3 kategorien sein + nebeneinander, dogfather, hasidog und dogfather clips. und die liste + soll man auch zu und auf klappen können. die videos will ich auch + kategorisiert haben nach woche, monat, jahr und ganz oben aktuell + also heute. und wenn man was hinzufügt soll man auch den account + auswählen können." + + --------------------------------------------------------------------- + VIER FRAGEN, UND DIE DRITTE IST DIE, DIE MAN VERGISST + + 1. Stehen die drei Spalten da — in DIESER Reihenfolge? + 2. Landet jedes Highlight in seiner Spalte? + 3. Verschwindet beim Zeitfilter wirklich etwas — und kommt es + beim Zurückschalten wieder? + 4. Kann man beim Anlegen einen Account wählen — und lehnt der + Server einen erfundenen ab? + + Frage 3 braucht die Gegenprobe: Ein Filter, der nichts ausblendet, + besteht jede Prüfung, die nur zählt, ob danach noch etwas dasteht. + + AUFRUF: node server/pruef-highlights.mjs + ===================================================================== */ + +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { portMussFreiSein } from "./helfer-port.mjs"; +import { notbremse } from "./helfer-notbremse.mjs"; + +const PORT = await portMussFreiSein(4438, "pruef-highlights"); +const ordner = mkdtempSync(join(tmpdir(), "ws-highlight-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); +process.env.PORT = String(PORT); +process.env.SITE_ACCESS_SECRET = "lokaler-test"; +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"); +notbremse(240_000, "pruef-highlights"); +await new Promise((r) => setTimeout(r, 900)); +const BASIS = `http://127.0.0.1:${PORT}`; + +process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); }); +process.on("unhandledRejection", (f) => { console.error("ABSTURZ (Versprechen):", f); process.exit(7); }); + +let fehler = 0, geprueft = 0; +const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; + +console.log("\n=== Highlights: drei Accounts, vier Zeiträume ===\n"); + +/* ---- Daten --------------------------------------------------------- + Die Zeitpunkte relativ zu HEUTE, nie fest: Ein festes Datum wird vom + Kalender eingeholt, und dann schlägt die Prüfung fehl, ohne dass + etwas kaputt ist (Hausregel 06.09.2026). */ +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes } = await import("node:crypto"); +const { KANAELE } = await import("./workspace.js"); +const d = new DatabaseSync(process.env.WORKSPACE_DB); +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, new Date().toISOString()); + +ok(KANAELE.length === 3, + `der Server kennt drei Accounts (${KANAELE.map((k) => k.name).join(", ")})`); + +const vorTagen = (n) => { + const x = new Date(); + x.setHours(9, 0, 0, 0); + x.setDate(x.getDate() - n); + return x; +}; +/* Eines von heute, eines von vor 40 Tagen (also sicher weder heute + noch diese Woche noch dieser Monat, aber dieses Jahr — sofern es + nicht Januar ist; deshalb wird unten NICHT auf "jahr" gebaut). */ +const einf = (titel, kanal, wannTage) => d.prepare( + "INSERT INTO eintraege (bereich, art, titel, text, status, dringlichkeit," + + " datum, quelle_kanal, erstellt_von, erstellt)" + + " VALUES (?,?,?,?,?,?,?,?,?,?)") + .run("highlight", "clip", titel, "", "offen", "mittel", + vorTagen(wannTage).toISOString().slice(0, 10), kanal, 1, + vorTagen(wannTage).toISOString()); + +einf("Heute DogFather", "dogfather", 0); +einf("Heute HasiDog", "hasidog", 0); +einf("Heute Clips", "clips", 0); +einf("Alt DogFather", "dogfather", 40); +einf("Ohne Account dabei", null, 0); +d.close(); + +const an = await fetch(BASIS + "/workspace/api/anmelden", { + method: "POST", headers: { "Content-Type": "application/json", Origin: BASIS }, + body: JSON.stringify({ rolle: "admin", code: "CODE-DOGI-0001" }), +}); +const keks = (an.headers.getSetCookie?.() || []).map((z) => z.split(";")[0]).join("; "); +ok(an.ok && !!keks, "angemeldet"); + +/* ---- 1. Der Server schickt die Kanäle mit ------------------------- */ +const brett = await (await fetch(BASIS + "/workspace/api/bereich/highlight", + { headers: { Cookie: keks } })).json(); +ok(Array.isArray(brett.kanaele) && brett.kanaele.length === 3, + `die Brett-Auskunft bringt die Accounts mit (${brett.kanaele?.length})`); +ok(brett.kanaele?.[0]?.wert === "dogfather" && brett.kanaele?.[2]?.wert === "clips", + "und zwar in der Reihenfolge des Servers — nicht in der der Einträge"); +ok(brett.kanaele?.every((k) => !!k.handle), + "mit Handle, damit die Spalte sagt, welcher Account gemeint ist"); + +/* GEGENPROBE: Wer nicht zum Team gehört, hat mit den Accounts nichts + zu tun und darf die Liste nicht bekommen. Ohne diese Probe bestünde + auch eine Fassung, die sie an jeden schickt. */ +{ + const dd = new DatabaseSync(process.env.WORKSPACE_DB); + const s2 = randomBytes(16).toString("hex"); + const h2 = scryptSync("CODE-GAST-0002", s2, 64, + { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); + dd.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt)" + + " VALUES (?,?,?,?,?,1,?)").run("Gast", "gast", h2, s2, 32768, new Date().toISOString()); + dd.close(); + const ag = await fetch(BASIS + "/workspace/api/anmelden", { + method: "POST", headers: { "Content-Type": "application/json", Origin: BASIS }, + body: JSON.stringify({ rolle: "gast", code: "CODE-GAST-0002" }), + }); + const gk = (ag.headers.getSetCookie?.() || []).map((z) => z.split(";")[0]).join("; "); + const gb = await fetch(BASIS + "/workspace/api/bereich/highlight", { headers: { Cookie: gk } }); + const gj = gb.ok ? await gb.json() : null; + ok(!gj || (gj.kanaele || []).length === 0, + `Gegenprobe: ein Gast bekommt die Accountliste nicht (${(gj?.kanaele || []).length})`); +} + +/* ---- 2. Ein erfundener Account wird abgelehnt --------------------- */ +const anlegen = async (titel, kanal) => fetch(BASIS + "/workspace/api/bereich/highlight", { + method: "POST", + headers: { "Content-Type": "application/json", Cookie: keks, Origin: BASIS }, + body: JSON.stringify({ titel, art: "bild", text: "", quelle_kanal: kanal }), +}); +const gut = await anlegen("Ein Bild für HasiDog", "hasidog"); +ok(gut.ok, `ein Bild lässt sich mit gewähltem Account anlegen (${gut.status})`); +const boese = await anlegen("Erfundener Account", "netflix"); +ok(boese.status === 400, + `Gegenprobe: ein erfundener Account wird abgelehnt (${boese.status}) — eine Regel, die nur im Formular steht, ist keine Regel`); +const leer = await anlegen("Noch offen", ""); +ok(leer.ok, `„Noch offen" ist erlaubt — ein falscher Eintrag wäre schlechter als ein leerer (${leer.status})`); + +/* ---- 3. Am Bildschirm -------------------------------------------- */ +const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); +const browser = await pw.chromium.launch(); +try { + const ctx = await browser.newContext({ viewport: { width: 1400, height: 1000 } }); + const s = await ctx.newPage(); + await s.goto(BASIS + "/workspace/", { waitUntil: "networkidle" }); + await s.click('.rolle[data-rolle="admin"]'); + await s.fill("#code", "CODE-DOGI-0001"); + await s.click("button[type=submit]"); + await s.waitForURL("**/start.html", { timeout: 25000 }); + await s.goto(BASIS + "/workspace/bereich.html?b=highlight", { waitUntil: "networkidle" }); + await s.waitForTimeout(1600); + + const lies = () => s.evaluate(() => { + const sp = [...document.querySelectorAll(".kanalspalte")]; + return { + spalten: sp.map((x) => ({ + kanal: x.dataset.kanal, + wort: x.querySelector(".kanalspalte__wort")?.firstChild?.textContent?.trim(), + handle: x.querySelector(".kanalspalte__handle")?.textContent?.trim() || null, + zahl: x.querySelector(".kanalspalte__zahl")?.textContent, + karten: x.querySelectorAll(".kanalspalte__inhalt .karte, .kanalspalte__inhalt > *").length, + titel: [...x.querySelectorAll(".kanalspalte__inhalt")] + .map((i) => i.textContent.replace(/\s+/g, " ").trim()).join(" "), + oben: Math.round(x.getBoundingClientRect().top), + links: Math.round(x.getBoundingClientRect().left), + })), + zeitknoepfe: [...document.querySelectorAll(".filter__zeit")] + .map((b) => ({ zeit: b.dataset.zeit || "", text: b.textContent.trim(), + aus: b.disabled, an: b.getAttribute("aria-pressed") === "true" })), + }; + }); + + const a = await lies(); + ok(a.spalten.length >= 3, `die Spalten stehen (${a.spalten.length})`); + ok(a.spalten[0]?.kanal === "dogfather" && a.spalten[1]?.kanal === "hasidog" + && a.spalten[2]?.kanal === "clips", + `in der Reihenfolge DogFather, HasiDog, Clips (${a.spalten.map((x) => x.kanal).join(", ")})`); + /* NEBENEINANDER, nicht untereinander — bei 1400 px ist dafür Platz. + Ohne diese Messung bestünde auch eine Fassung, die drei Blöcke + stapelt, und genau das war die Bitte. */ + ok(a.spalten[0].oben === a.spalten[1].oben && a.spalten[1].oben === a.spalten[2].oben, + `sie stehen NEBENeinander (alle bei y=${a.spalten[0].oben})`); + ok(a.spalten[0].links < a.spalten[1].links && a.spalten[1].links < a.spalten[2].links, + "und in der richtigen Richtung von links nach rechts"); + /* NUR DIE ECHTEN ACCOUNTS. Die Spalte „Ohne Account" hat + berechtigterweise kein Handle -- der erste Anlauf verlangte es von + allen vieren und schlug an einer Stelle an, an der nichts kaputt + war. Genau die Sorte Fehlalarm, die einen Prüfsatz entwertet. */ + const echte = a.spalten.filter((x) => x.kanal !== "ohne"); + ok(echte.length === 3 && echte.every((x) => /^@/.test(x.handle || "")), + `jeder echte Account nennt sein Handle (${echte.map((x) => x.handle).join(" ")})`); + ok(a.spalten.find((x) => x.kanal === "ohne")?.handle === null, + 'und die Spalte ohne Account erfindet sich keines'); + + /* Landet jedes Highlight in SEINER Spalte? */ + ok(/Heute DogFather/.test(a.spalten[0].titel) && !/Heute HasiDog/.test(a.spalten[0].titel), + "ein DogFather-Clip steht in der DogFather-Spalte und nirgends sonst"); + ok(/Ohne Account/.test(a.spalten.at(-1).titel) && a.spalten.at(-1).kanal === "ohne", + "was zu keinem Account gehört, bekommt eine eigene Spalte statt zu verschwinden"); + + /* ---- 4. Der Zeitfilter ----------------------------------------- */ + ok(a.zeitknoepfe.length === 5, + `die Zeitleiste hat Aktuell, Woche, Monat, Jahr und Alles (${a.zeitknoepfe.length})`); + ok(a.zeitknoepfe[0]?.zeit === "heute" && /Aktuell/.test(a.zeitknoepfe[0]?.text || ""), + `„Aktuell" steht ganz oben (${a.zeitknoepfe[0]?.text})`); + ok(a.zeitknoepfe.every((b) => /\d/.test(b.text)), + "jeder Knopf trägt seine Zahl — sonst klickt man ins Leere und weiß nicht, ob es am Filter lag"); + + const vorher = a.spalten.reduce((n, x) => n + Number(x.zahl || 0), 0); + await s.click('.filter__zeit[data-zeit="heute"]'); + await s.waitForTimeout(900); + const b = await lies(); + const nachher = b.spalten.reduce((n, x) => n + Number(x.zahl || 0), 0); + ok(nachher < vorher, + `„Aktuell" blendet wirklich etwas aus (${vorher} → ${nachher})`); + ok(!/Alt DogFather/.test(b.spalten.map((x) => x.titel).join(" ")), + " der 40 Tage alte Eintrag ist weg"); + ok(/Heute DogFather/.test(b.spalten[0].titel), + " der von heute steht noch da"); + + /* GEGENPROBE: Zurückschalten muss ihn zurückbringen. Ohne sie + bestünde auch eine Fassung, die den Eintrag dauerhaft verliert. */ + await s.click('.filter__zeit[data-zeit="heute"]'); + await s.waitForTimeout(900); + const c = await lies(); + ok(c.spalten.reduce((n, x) => n + Number(x.zahl || 0), 0) === vorher, + `Gegenprobe: ein zweiter Klick hebt den Filter auf (${vorher} wieder da)`); + + /* ---- 5. Zuklappen ---------------------------------------------- */ + const zuVorher = await s.evaluate(() => + document.querySelector(".kanalspalte__inhalt")?.hidden === true); + await s.click(".kanalspalte__kopf"); + await s.waitForTimeout(500); + const zuNachher = await s.evaluate(() => { + const i = document.querySelector(".kanalspalte__inhalt"); + return { zu: i?.hidden === true || i?.offsetHeight === 0, + sagt: document.querySelector(".kanalspalte__kopf")?.getAttribute("aria-expanded") }; + }); + ok(zuVorher === false && zuNachher.zu === true, + "eine Spalte lässt sich zuklappen"); + ok(zuNachher.sagt === "false", + ` und sagt es auch Vorleseprogrammen (aria-expanded=${zuNachher.sagt})`); + + await ctx.close(); +} finally { + await browser.close(); +} + +console.log(`\n${fehler ? fehler + " FEHLER" : "Alles in Ordnung"} — ${geprueft} Prüfungen\n`); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } +process.exit(fehler ? 1 : 0); diff --git a/server/workspace-bereiche.js b/server/workspace-bereiche.js index bc02e629..e7a20497 100644 --- a/server/workspace-bereiche.js +++ b/server/workspace-bereiche.js @@ -64,7 +64,7 @@ import { TREFF_BRETTER, TREFF_ROLLEN, AUSSEN_ROLLEN, heuteLokal, gehoertAufDieseAdresse, - bereicheFuer, + bereicheFuer, kanaeleFuer, KANAELE, } from "./workspace.js"; import { nachrichtSchicken } from "./workspace-chat.js"; import { @@ -1500,6 +1500,22 @@ bereicheRouter.get("/workspace/api/bereich/:bereich", (req, res) => { weg. */ kalender_offen: darfSeite(req.person, "/workspace/kalender.html") && gehoertAufDieseAdresse(req.person, "/workspace/kalender.html"), + /* DIE KANAELE KOMMEN VOM SERVER (20.09.2026). + + Filipe: "die highlights sollen in 3 kategorien sein + nebeneinander, dogfather, hasidog und dogfather clips." + + Der Browser hatte dafuer bisher eine eigene kleine Liste + (`KANAL_WORT` in bereich.js) -- in anderer Reihenfolge und + ohne die Handles. Eine zweite Fassung derselben Tatsache ist + die, die beim vierten Account vergessen wird; genau davor + warnt der Kommentar an KANAELE selbst. Deshalb kommt sie von + hier, samt Reihenfolge: DogFather, HasiDog, Clips. + + `kanaeleFuer` und nicht `KANAELE`: Wer nicht zum Team gehoert, + hat mit den Accounts nichts zu tun und bekommt die Liste gar + nicht erst. */ + kanaele: kanaeleFuer(req.person), eintraege: treffBrett ? eintraege.map(mitRollenname) : eintraege, }); } catch (fehler) { @@ -1760,6 +1776,32 @@ function pruefe(bereich, körper, { neu }) { else aus.einsatz = t || null; } + /* ---- ZU WELCHEM ACCOUNT? (20.09.2026) --------------------------- + + Filipe: "und wenn man was hinzufuegt soll man auch den account + auswaehlen koennen." + + NUR AUF DEN HIGHLIGHTS und nur gegen die echte Kanalliste geprueft. + Ein Aufruf an der Oberflaeche vorbei soll keinen erfundenen Kanal + in die Spalte schreiben koennen -- danach stuende in der Galerie + eine vierte Spalte, die es nicht gibt, und niemand faende heraus, + woher sie kommt. Eine Regel, die nur im Formular steht, ist keine + Regel (siehe der Absatz gleich darunter). + + KEIN PFLICHTFELD: Leer bedeutet "noch offen" -- ein falscher + Eintrag ist schlechter als ein leerer, genau wie an KANAELE + vermerkt. Und wer einen TikTok-Link einfuegt, bekommt den Kanal + ohnehin aus dem Handle abgeleitet; DAS ist genauer als jede + Auswahl, weil niemand sich vertippen kann. Diese Zeilen + ueberschreiben das nicht: Die Video-Route setzt `quelle_kanal` + selbst und geht hier nicht durch. */ + if (bereich === "highlight" && körper.quelle_kanal !== undefined) { + const w = String(körper.quelle_kanal ?? "").trim(); + if (!w) aus.quelle_kanal = null; + else if (KANAELE.some((k) => k.wert === w)) aus.quelle_kanal = w; + else fehler.push("Diesen Account gibt es nicht."); + } + /* Ein Agentur-Eintrag gehoert NIEMANDEM einzeln (07.09.2026). Er gilt fuer alle, und genau deshalb sieht ihn auch jeder (siehe diff --git a/workspace/assets/css/bereich.css b/workspace/assets/css/bereich.css index edc7d804..6e9f1403 100644 --- a/workspace/assets/css/bereich.css +++ b/workspace/assets/css/bereich.css @@ -1799,3 +1799,118 @@ body[data-ton] .kopf-zeile::before { transparent 62%); pointer-events: none; } + + +/* ===================================================================== + DIE HIGHLIGHTS: DREI ACCOUNTS NEBENEINANDER (20.09.2026) + + Filipe: „die highlights sollen in 3 kategorien sein nebeneinander, + dogfather, hasidog und dogfather clips. und die liste soll man auch + zu und auf klappen können." + + --------------------------------------------------------------------- + `auto-fit` UND NICHT `repeat(3, …)` + + Drei feste Spalten wären auf einem Handy drei Streifen von 100 px, + in denen kein Titel steht. `minmax(230px, 1fr)` misst statt zu + rechnen: Nebeneinander, solange der Platz für 230 px je Spalte + reicht, sonst untereinander. Kommt ein vierter Account dazu, bricht + die Regel von selbst richtig um — bei einer festen 3 wäre er die + Zeile, die jemand vergisst. + + Das ist dieselbe Lehre wie bei der Kopfleiste am 06.09.2026: Wo es + geht, eine Regel statt einer Zahl. + ===================================================================== */ +.kanalspalten { + display: grid; + gap: 14px; + grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); + align-items: start; +} + +.kanalspalte { + border: 1px solid var(--rand); + border-radius: var(--radius); + background: color-mix(in srgb, var(--flaeche) 70%, transparent); + overflow: hidden; + /* Der farbige Strich oben ist das, was die drei auf einen Blick + unterscheidet -- vor jedem gelesenen Wort. */ + border-top: 3px solid var(--kfarbe, var(--rand-hell)); +} +.kanalspalte[data-kanal="dogfather"] { --kfarbe: #7c6cf0; } +.kanalspalte[data-kanal="hasidog"] { --kfarbe: #e58bbd; } +.kanalspalte[data-kanal="clips"] { --kfarbe: #4fb9c8; } +.kanalspalte[data-kanal="ohne"] { --kfarbe: var(--rand-hell); } + +.kanalspalte__kopf { + display: flex; align-items: center; gap: 10px; + width: 100%; + padding: 11px 13px; + border: 0; background: color-mix(in srgb, var(--kfarbe) 9%, transparent); + color: var(--text); + font: inherit; font-weight: 650; + text-align: left; cursor: pointer; +} +.kanalspalte__kopf:hover { background: color-mix(in srgb, var(--kfarbe) 16%, transparent); } +.kanalspalte__kopf:focus-visible { outline: 2px solid var(--akzent); outline-offset: -2px; } + +.kanalspalte__wort { flex: 1 1 auto; min-width: 0; display: block; } +/* Das Handle DARUNTER, nicht daneben: In einer Spalte von 230 px + schöbe „@dogis.modi.gang" neben dem Namen die Zahl aus dem Bild. */ +.kanalspalte__handle { + display: block; + font-size: .72rem; font-weight: 500; color: var(--text-leise); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.kanalspalte__zahl { + flex: 0 0 auto; + min-width: 22px; padding: 1px 7px; + border-radius: 999px; + background: color-mix(in srgb, var(--kfarbe) 26%, transparent); + font-size: .74rem; font-variant-numeric: tabular-nums; +} + +.kanalspalte__inhalt { + display: flex; flex-direction: column; gap: 10px; + padding: 12px; +} +/* Die Galerie-Form IN der Spalte. `minmax(180px, 1fr)` statt der 280 px + von `#liste`: Eine Spalte ist schmaler als die ganze Seite, und mit + 280 px bliebe in einer 376-px-Spalte eine Kachel je Reihe, auch wenn + zwei hineinpassen. */ +.kanalspalte__inhalt[data-galerie="ja"] { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + align-items: stretch; +} +.kanalspalte__leer { margin: 0; font-size: .82rem; } + +/* ===================================================================== + DIE ZEITLEISTE + + Sie steht ÜBER den Kanalknöpfen und sieht anders aus als sie: zwei + Reihen, die gleich aussehen und Verschiedenes tun, sind die Sorte + Doppeldeutigkeit, die am 18.09. auf „Regeln & Hilfe" schon einmal + aufgefallen ist. + ===================================================================== */ +.filter__zeiten { + display: flex; flex-wrap: wrap; gap: 6px; + margin-bottom: 8px; +} +.filter__zeit { display: inline-flex; align-items: center; gap: 6px; } +.filter__zeit[aria-pressed="true"] { + border-color: var(--akzent); + background: color-mix(in srgb, var(--akzent) 16%, transparent); + color: var(--text); +} +.filter__zeit:disabled { opacity: .42; cursor: default; } +.filter__zeit-zahl { + font-size: .72rem; font-variant-numeric: tabular-nums; + color: var(--text-leise); +} +.filter__zeit[aria-pressed="true"] .filter__zeit-zahl { color: inherit; } + +@media (forced-colors: active) { + .kanalspalte { border: 1px solid CanvasText; border-top-width: 4px; } + .filter__zeit[aria-pressed="true"] { border: 2px solid Highlight; } +} diff --git a/workspace/assets/js/bereich.js b/workspace/assets/js/bereich.js index e82f12fb..66565fea 100644 --- a/workspace/assets/js/bereich.js +++ b/workspace/assets/js/bereich.js @@ -132,6 +132,12 @@ let ich = null; let bereich = null; let einstellung = null; + /* DIE ACCOUNTS, so wie der Server sie schickt (20.09.2026). + Auf Modulebene, weil zwei Stellen sie brauchen: die Galerie (drei + Spalten in DIESER Reihenfolge) und das Formular (Auswahlliste). + Beide aus derselben Auskunft -- eine zweite Liste im Browser waere + die, die beim vierten Account vergessen wird. */ + let kanaeleDa = []; /* Ist dies ein Brett des Treffs, und was darf ich dort? Beides sagt der Server (daten.treff und /api/treff/lage) -- im Browser steht keine Liste der Bretter und keine Rechteregel, sondern nur die @@ -165,6 +171,62 @@ aber nie beides. Und genau das will man, sobald aus drei Kanälen Videos nebeneinander liegen. */ let kanalFilter = ''; + + /* ===================================================================== + DIE HIGHLIGHTS NACH ZEIT (20.09.2026) + + Filipe: "die videos will ich auch kategorisiert haben nach woche, + monat, jahr und ganz oben aktuell also heute." + + WARUM DAS NOETIG WURDE: Highlights sammeln sich. Nach einem halben + Jahr steht das Beste vom Mai zwischen dem von gestern, und die + Galerie beantwortet die Frage, die man wirklich hat ("was ist + GERADE gut gelaufen"), gar nicht mehr. + + DIE ZEITRAEUME SIND KALENDERZEITRAEUME, KEINE RUECKBLICKE. "Diese + Woche" heisst seit Montag, nicht "die letzten sieben Tage". Wer am + Dienstag "diese Woche" fragt, meint Montag und Dienstag -- nicht + den Mittwoch davor. Ein rollendes Fenster waere leichter zu rechnen + und beantwortet eine andere Frage. + + UND SIE STAPELN SICH NICHT. Man waehlt EINEN -- "heute" ist auch + in "diese Woche" enthalten, und zwei sich enthaltende Filter + gleichzeitig anzubieten erzeugt nur die Frage, was ihre + Kombination bedeutet. */ + let zeitFilter = ''; + + const ZEIT_WORT = { + heute: 'Aktuell', woche: 'Diese Woche', + monat: 'Dieser Monat', jahr: 'Dieses Jahr', + }; + + /** Ab wann zaehlt dieser Zeitraum? Gibt einen Zeitstempel in + * Millisekunden -- oder 0 fuer "alles". */ + function zeitAb(was) { + const d = new Date(); + d.setHours(0, 0, 0, 0); + if (was === 'heute') return d.getTime(); + if (was === 'woche') { + /* Montag als Wochenanfang. `getDay()` liefert Sonntag = 0 -- + ohne die Korrektur faenge die Woche am Sonntag an, und in + Deutschland steht der am Ende. */ + const wt = (d.getDay() + 6) % 7; + d.setDate(d.getDate() - wt); + return d.getTime(); + } + if (was === 'monat') { d.setDate(1); return d.getTime(); } + if (was === 'jahr') { d.setMonth(0, 1); return d.getTime(); } + return 0; + } + + /** Wann ist dieser Eintrag entstanden? `erstellt` ist die Wahrheit -- + * `datum` ist ein Feld, das jemand von Hand setzen kann, und ein + * Highlight vom Mai mit dem Datum von heute gehoert nicht unter + * "Aktuell". */ + const entstandAm = (e) => { + const t = Date.parse(e.erstellt || e.datum || ''); + return Number.isNaN(t) ? 0 : t; + }; /* Nur beim ersten Laden springen -- sonst reisst es die Seite nach jeder Bewertung erneut an dieselbe Stelle. */ let gesprungen = false; @@ -1487,6 +1549,59 @@ tut. Ein Knopf, der immer dasselbe zeigt, macht misstrauisch. ERST AB ZWEI KANÄLEN. Bei einem gibt es nichts zu wählen. */ + /* ---- DIE ZEITLEISTE, NUR AUF DEN HIGHLIGHTS (20.09.2026) ------- + + Filipe: "die videos will ich auch kategorisiert haben nach + woche, monat, jahr und ganz oben aktuell also heute." + + SIE STEHT VOR der Kanal-Abbruchstelle darunter. Dort steigt die + Funktion aus, wenn es weniger als zwei Kanaele gibt -- stuende + die Zeitleiste dahinter, waere sie auf einem Brett mit nur + einem Account nicht da. Und genau dann braucht man sie: + Ein Kanal, viele Monate. + + JEDER KNOPF TRAEGT SEINE ZAHL. Ohne sie klickt man auf + "Aktuell" und landet auf einer leeren Seite, ohne zu wissen, ob + der Filter greift oder nichts passiert ist. Mit ihr sieht man + vorher, dass heute nichts dazugekommen ist -- und klickt gar + nicht erst. */ + if (bereich === 'highlight' && eintraege.length) { + const zeile = el('div', 'filter__zeiten'); + zeile.setAttribute('role', 'group'); + zeile.setAttribute('aria-label', 'Nach Zeitraum filtern'); + const zknopf = (wert, text) => { + const b = el('button', 'schritt filter__zeit', text); + b.type = 'button'; + if (wert) b.dataset.zeit = wert; + b.setAttribute('aria-pressed', String(zeitFilter === wert)); + const ab = zeitAb(wert); + const zahl = wert + ? eintraege.filter((e) => entstandAm(e) >= ab).length + : eintraege.length; + b.append(el('span', 'filter__zeit-zahl', String(zahl))); + /* Ein Zeitraum ohne einen einzigen Eintrag laesst sich nicht + waehlen. Er bleibt aber STEHEN, mit seiner Null -- ihn + auszublenden hiesse, die Leiste wechselt ihre Form, sobald + jemand etwas einstellt, und man sucht den Knopf, den man + gestern noch hatte. */ + if (!zahl && wert) { b.disabled = true; b.title = 'Dafür gibt es nichts'; } + b.addEventListener('click', async () => { + /* Zweiter Klick hebt auf -- wie beim Kanalfilter daneben. + Zwei Filter nebeneinander, die sich verschieden bedienen + lassen, sind einer zu viel. */ + zeitFilter = (zeitFilter === wert) ? '' : wert; + await laden(); + }); + return b; + }; + zeile.append(zknopf('heute', ZEIT_WORT.heute)); + zeile.append(zknopf('woche', ZEIT_WORT.woche)); + zeile.append(zknopf('monat', ZEIT_WORT.monat)); + zeile.append(zknopf('jahr', ZEIT_WORT.jahr)); + zeile.append(zknopf('', 'Alles')); + ziel.append(zeile); + } + const kanaele = [...new Set(eintraege.map((e) => e.quelle_kanal).filter(Boolean))]; if (kanaele.length < 2) { kanalFilter = ''; return; } @@ -1786,6 +1901,7 @@ if (!a.ok) { melde('Einträge konnten nicht geladen werden.'); return; } const daten = await a.json(); einstellung = daten.einstellung; + kanaeleDa = Array.isArray(daten.kanaele) ? daten.kanaele : []; treffBrett = !!daten.treff; kalenderOffen = daten.kalender_offen !== false; weiterZeigen(daten.weiter); @@ -1799,8 +1915,14 @@ Raster steht hier und nicht fest im HTML, weil dieselbe Seite acht Bretter bedient -- ein Raster in der Datei waere fuer sieben davon falsch. */ - if (bereich === 'highlight') ziel.dataset.galerie = 'ja'; - else delete ziel.dataset.galerie; + /* SEIT DEN KANALSPALTEN LIEGT DAS RASTER EINE EBENE TIEFER + (20.09.2026). `#liste[data-galerie]` ist selbst ein Raster -- + der Spaltenkasten wurde darin zu EINER Zelle von 376 px, und + die drei Accounts standen untereinander statt nebeneinander. + Gemessen, nicht vermutet: Elternbreite 1160, Kind 376. + Die Galerie gehoert jetzt in die einzelne Spalte (siehe + `.kanalspalte__inhalt[data-galerie]`). */ + delete ziel.dataset.galerie; /* DAS ANSCHLAGBRETT IST EIN SCHWARZES BRETT: wenige, GROSSE Aushaenge. Ein Aushang zwischen dreissig anderen ist kein Aushang -- die Wirkung entsteht durch die Groesse und die @@ -1813,6 +1935,11 @@ Art-Filter nicht aus, er kommt dazu. „Nur offene Clips von HasiDog" ist eine sinnvolle Frage. */ if (kanalFilter && e.quelle_kanal !== kanalFilter) return false; + /* DER ZEITFILTER GILT NUR FUER HIGHLIGHTS. Auf den anderen + Brettern steht er gar nicht erst da -- eine Regel, die + unsichtbar mitfiltert, ist ein Brett, das Dinge verschluckt. */ + if (bereich === 'highlight' && zeitFilter + && entstandAm(e) < zeitAb(zeitFilter)) return false; if (!filter) return true; if (filter === 'offen') return e.status === 'offen'; return e.art === filter; @@ -1850,6 +1977,94 @@ } const karten = new Map(); + /* ===================================================================== + DIE HIGHLIGHTS: DREI KANAELE NEBENEINANDER (20.09.2026) + + Filipe: "die highlights sollen in 3 kategorien sein + nebeneinander, dogfather, hasidog und dogfather clips. und die + liste soll man auch zu und auf klappen koennen." + + WARUM DAS MEHR IST ALS EINE SORTIERUNG: Die drei Accounts haben + eigene Zuschauer und einen eigenen Ton -- das steht so an + KANAELE im Server. Eine gemeinsame Galerie behauptet, sie + waeren ein Topf. Wer fuer Clips schneidet, sucht dann in jeder + Reihe nach den Kacheln, die ihn angehen. + + DIE REIHENFOLGE KOMMT VOM SERVER, nicht aus einer Liste hier. + Der Browser hatte dafuer bis heute `KANAL_WORT` -- dieselben + drei Namen in anderer Reihenfolge, ohne Handles. Beim vierten + Account waere genau diese Kopie die vergessene. + + EINE VIERTE SPALTE GIBT ES NUR, WENN SIE GEBRAUCHT WIRD: + Bilder und Fanart ohne TikTok-Link gehoeren zu keinem Kanal. + Sie verschwinden zu lassen waere der schlimmere Fehler -- ein + Eintrag, den man angelegt hat und nirgends wiederfindet. + + ZUKLAPPEN ueber dieselben zwei Hilfen wie ueberall + (`abschnittKlappbar`, `klappPfeil`), mit einem Merker je Kanal. + Ein zweiter Klappmechanismus waere eine zweite Sache zum + Lernen und eine zweite zum Kaputtgehen. + ===================================================================== */ + if (bereich === 'highlight') { + const kanaele = kanaeleDa.length + ? kanaeleDa + /* Faellt die Auskunft aus (alte Auslieferung, fremde Rolle), + wird aus den Eintraegen selbst abgeleitet -- lieber die + Namen ohne Reihenfolge als eine leere Seite. */ + : [...new Set(sichtbar.map((e) => e.quelle_kanal).filter(Boolean))] + .map((w) => ({ wert: w, name: KANAL_WORT[w] || w })); + + const faecher = new Map(kanaele.map((k) => [k.wert, []])); + const ohne = []; + for (const e of sichtbar) { + if (e.quelle_kanal && faecher.has(e.quelle_kanal)) faecher.get(e.quelle_kanal).push(e); + else ohne.push(e); + } + + const spalten = el('div', 'kanalspalten'); + const bauen = (schluessel, name, handle, fach) => { + const sp = el('section', 'kanalspalte'); + sp.dataset.kanal = schluessel || 'ohne'; + const kopf = el('button', 'kanalspalte__kopf'); + kopf.type = 'button'; + const wort = el('span', 'kanalspalte__wort', name); + if (handle) wort.append(el('span', 'kanalspalte__handle', handle)); + kopf.append(wort); + kopf.append(el('span', 'kanalspalte__zahl', String(fach.length))); + if (window.klappPfeil) kopf.append(window.klappPfeil()); + sp.append(kopf); + + const kasten = el('div', 'kanalspalte__inhalt'); + /* Die Galerie-Form kommt hierher -- in der Spalte, nicht um + sie herum. In einer schmalen Spalte ergibt das eine Kachel + je Reihe, bei viel Platz zwei. Gerechnet wird nichts. */ + kasten.dataset.galerie = 'ja'; + if (!fach.length) { + /* Eine leere Spalte sagt, dass sie leer IST -- sonst sieht + sie aus, als haette die Seite nicht fertig geladen. */ + kasten.append(el('p', 'leise kanalspalte__leer', + zeitFilter + ? `Für ${ZEIT_WORT[zeitFilter].toLowerCase()} steht hier nichts.` + : 'Hier steht noch nichts.')); + } + for (const e of fach) { + const k = karte(e); + karten.set(e.id, k); + kasten.append(k); + } + sp.append(kasten); + window.abschnittKlappbar?.(kopf, kasten, `highlight:kanal:${schluessel || 'ohne'}`); + spalten.append(sp); + }; + + for (const k of kanaele) bauen(k.wert, k.name, k.handle, faecher.get(k.wert) || []); + if (ohne.length) bauen('', 'Ohne Account', null, ohne); + + ziel.append(spalten); + sprungZeigen(ziel, karten); + return; + } + /* ---- STUFE 7: JEDES BRETT BEKOMMT SEINE FORM ------------------ "Regeln & Hilfe" ist ein NACHSCHLAGEWERK, kein Feed: Man schlaegt es im Streitfall auf und will finden, nicht scrollen. @@ -2169,6 +2384,13 @@ unsichtbaren Feld stehenbleibt, wandert sonst beim naechsten Speichern mit -- und niemand sieht, woher er kommt. */ if (einstellung.einsatzFeld && $('f-einsatz')) daten.einsatz = $('f-einsatz').value.trim(); + /* Nur, wenn das Feld auch sichtbar ist -- ein Wert aus einem + verborgenen Feld wandert sonst beim naechsten Speichern mit, und + niemand sieht, woher er kommt. Dieselbe Regel wie eine Zeile + darueber. */ + if ($('feld-kanal') && !$('feld-kanal').hidden) { + daten.quelle_kanal = $('f-kanal').value || null; + } if (einstellung.bewertung) daten.bewertung = $('f-bewertung').value || null; if (event) { daten.event_ende = $('f-ende').value || null; @@ -2235,6 +2457,7 @@ } catch { return; } einstellung = daten.einstellung; + kanaeleDa = Array.isArray(daten.kanaele) ? daten.kanaele : []; document.title = einstellung.name + ' · Creator Workspace'; $('marke-bereich').textContent = einstellung.name; @@ -2368,6 +2591,44 @@ if (einstellung.bewertungName && $('bewertung-schild')) { $('bewertung-schild').textContent = `${einstellung.bewertungName} (1–5)`; } + /* ---- ZU WELCHEM ACCOUNT? (20.09.2026) ------------------------- + + Filipe: "und wenn man was hinzufuegt soll man auch den account + auswaehlen koennen." + + Nur auf den Highlights und nur, wenn der Server die Kanaele + ueberhaupt mitgeschickt hat. Eine leere Auswahlliste waere eine + Frage ohne moegliche Antwort. + + DIE LISTE KOMMT VOM SERVER, nicht aus KANAL_WORT hier. Beim + vierten Account ist die Kopie hier die vergessene -- das steht + so schon am Kanal-Begriff selbst. */ + const kanalFeld = $('feld-kanal'), kanalWahl = $('f-kanal'); + if (kanalFeld && kanalWahl) { + const liste = kanaeleDa; + kanalFeld.hidden = !(bereich === 'highlight' && liste.length); + if (!kanalFeld.hidden && kanalWahl.dataset.stand !== String(liste.length)) { + kanalWahl.dataset.stand = String(liste.length); + kanalWahl.textContent = ''; + /* LEER ZUERST UND ALS VORGABE. Ein Pflichtfeld erzwaenge hier + einen falschen Kanal, und ein falscher Eintrag ist + schlechter als ein leerer. */ + const leer = document.createElement('option'); + leer.value = ''; leer.textContent = 'Noch offen'; + kanalWahl.append(leer); + for (const k of liste) { + const o = document.createElement('option'); + o.value = k.wert; + o.textContent = k.handle ? `${k.name} (${k.handle})` : k.name; + kanalWahl.append(o); + } + } + if ($('hinweis-kanal')) { + $('hinweis-kanal').textContent = 'Bei einem TikTok-Link erkennt die ' + + 'Seite den Account selbst – das hier ist für Bilder und Momente ohne Link.'; + } + } + /* Das Einsatz-Feld nur, wo der Bereich es kennt. */ if ($('feld-einsatz')) $('feld-einsatz').hidden = !einstellung.einsatzFeld; if (einstellung.einsatzName && $('einsatz-schild')) { diff --git a/workspace/bereich.html b/workspace/bereich.html index 92980e62..b4a4dc4f 100644 --- a/workspace/bereich.html +++ b/workspace/bereich.html @@ -114,6 +114,27 @@ + +