diff --git a/server/bild-stufen.mjs b/server/bild-stufen.mjs new file mode 100644 index 00000000..1274f1d3 --- /dev/null +++ b/server/bild-stufen.mjs @@ -0,0 +1,153 @@ +/* Bild der Stufenleiter und der Talente-Seite (17.09.2026) + + Beides wurde heute gebaut und bisher nur an Zahlen geprueft. Die + Hausregel dazu ist eindeutig: Ein gruener Haken sagt nicht, wie es + aussieht. Der Startkatalog hat am selben Tag den Countdown von + 458 px auf 1488 px geschoben, ohne dass eine einzige Pruefung rot + wurde -- gesehen habe ich es im Bildschirmfoto. + + Aufrufen mit: node server/bild-stufen.mjs +*/ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const ordner = mkdtempSync(join(tmpdir(), "ws-bild-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); +const { portMussFreiSein } = await import("./helfer-port.mjs"); +await portMussFreiSein(4331, "das Bildwerkzeug"); +process.env.PORT = "4331"; +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"); +await new Promise((r) => setTimeout(r, 1200)); +const BASIS = "http://127.0.0.1:4331"; + +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 j = new Date().toISOString(); +function anlegen(name, rolle, code) { + const salt = randomBytes(16).toString("hex"); + const hash = scryptSync(code, 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(name, rolle, hash, salt, 32768, j); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; +} +anlegen("Filipe", "admin", "CODE-DOGI-0001"); +anlegen("Diene", "modi", "CODE-MODI-0001"); +d.close(); + +const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); +const browser = await pw.chromium.launch(); +const ctx = await browser.newContext({ viewport: { width: 1400, height: 1000 }, deviceScaleFactor: 2 }); +const seite = await ctx.newPage(); +const fehlerAufSeite = []; +seite.on("console", (m) => { if (m.type() === "error") fehlerAufSeite.push(m.text()); }); +seite.on("pageerror", (f) => fehlerAufSeite.push(String(f))); + +await seite.goto(BASIS + "/workspace/", { waitUntil: "networkidle" }); +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: 25000 }); + +/* ---- 1. Die Entwicklungsseite mit der Stufenleiter ------------------- */ +await seite.goto(BASIS + "/workspace/entwicklung.html", { waitUntil: "networkidle" }); +await seite.waitForTimeout(1800); + +/* Eine Person waehlen, damit die Karte mit den Bloecken erscheint. */ +const person = await seite.$(".e-person, .p-kachel, [data-person-id]"); +if (person) { await person.click(); await seite.waitForTimeout(1500); } + +const leiste = await seite.$(".e-stufen"); +console.log("\n=== 1. Die Stufenleiter ==="); +console.log(" vorhanden:", !!leiste); +if (leiste) { + const knoepfe = await seite.$$eval(".e-stufen__knopf", (ns) => ns.map((n) => ({ + text: n.textContent.trim().replace(/\s+/g, " "), + breite: Math.round(n.getBoundingClientRect().width), + gewaehlt: n.getAttribute("aria-pressed"), + }))); + for (const k of knoepfe) console.log(` ${String(k.breite).padStart(4)} px ${k.gewaehlt === "true" ? "[x]" : "[ ]"} ${k.text}`); + + /* WIE VIELE ZEILEN BRAUCHT DIE LEISTE? Bei sechs Knoepfen in einer + Leiste, die schon einen "Alle aufklappen"-Knopf enthaelt, ist das + die Frage. Gezaehlt ueber die verschiedenen Oberkanten -- nicht + ueber die Hoehe geteilt durch etwas. */ + const zeilen = await seite.$$eval(".e-stufen__knopf", + (ns) => new Set(ns.map((n) => Math.round(n.getBoundingClientRect().top))).size); + console.log(" Zeilen:", zeilen); + + await leiste.scrollIntoViewIfNeeded(); + await seite.waitForTimeout(400); + const kasten = await seite.$(".e-bloecke__leiste"); + await (kasten || leiste).screenshot({ path: "server/bild-stufenleiste.png" }); + + /* ---- Und nach einem Klick auf "Fortgeschritten" ------------------- */ + const fort = await seite.$('.e-stufen__knopf[data-erwartung="fort"]'); + if (fort) { + await fort.click(); + await seite.waitForTimeout(1200); + const koepfe = await seite.$$eval(".e-block", (ns) => ns.map((n) => ({ + name: n.querySelector(".e-block__name")?.textContent?.trim(), + stand: n.querySelector(".e-block__stand")?.textContent?.trim(), + }))); + console.log("\n Nach Klick auf „Fortgeschritten“:"); + for (const k of koepfe) console.log(` ${String(k.name).padEnd(28)} ${k.stand}`); + await seite.screenshot({ path: "server/bild-stufen-gefiltert.png", fullPage: false }); + } +} + +/* ---- 2. Die Talente-Seite, leer -------------------------------------- */ +await seite.goto(BASIS + "/workspace/talente.html", { waitUntil: "networkidle" }); +await seite.waitForTimeout(1800); +console.log("\n=== 2. Die Talente-Seite ==="); +const leer = await seite.$(".t-leer"); +console.log(" leerer Zustand:", !!leer); +if (leer) { + const merkmale = await seite.$$eval(".t-leer__merkmal", + (ns) => ns.map((n) => n.textContent.trim().replace(/\s+/g, " "))); + console.log(" Merkmalsgruppen:", merkmale.length); + for (const m of merkmale) console.log(" " + m); + const knoepfe = await seite.$$eval(".t-leer__knoepfe a", + (ns) => ns.map((n) => n.textContent.trim())); + console.log(" Wege:", knoepfe.join(" | ")); + await leer.scrollIntoViewIfNeeded(); + await seite.waitForTimeout(400); + await leer.screenshot({ path: "server/bild-talente-leer.png" }); +} + +/* ---- 3. Auf dem Handy ------------------------------------------------ */ +const handy = await ctx.newPage(); +await handy.goto(BASIS + "/workspace/entwicklung.html", { waitUntil: "networkidle" }); +await handy.setViewportSize({ width: 412, height: 915 }); +await handy.waitForTimeout(1500); +const p2 = await handy.$(".e-person, .p-kachel, [data-person-id]"); +if (p2) { await p2.click(); await handy.waitForTimeout(1500); } +console.log("\n=== 3. Auf 412 px (haeufigste Android-Breite) ==="); +const l2 = await handy.$(".e-stufen"); +if (l2) { + const zeilen = await handy.$$eval(".e-stufen__knopf", + (ns) => new Set(ns.map((n) => Math.round(n.getBoundingClientRect().top))).size); + const ueber = await handy.$$eval(".e-stufen__knopf", + (ns) => ns.filter((n) => n.getBoundingClientRect().right > window.innerWidth).length); + console.log(" Zeilen:", zeilen, " ueber den Rand hinaus:", ueber); + const kasten2 = await handy.$(".e-bloecke__leiste"); + await (kasten2 || l2).screenshot({ path: "server/bild-stufen-handy.png" }); +} else { console.log(" keine Leiste gefunden"); } + +console.log("\nFehler auf der Seite:", fehlerAufSeite.length ? fehlerAufSeite.join(" | ") : "keine"); +await browser.close(); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Aufraeumrest */ } +process.exit(0); diff --git a/server/pruef-kanalzeile.mjs b/server/pruef-kanalzeile.mjs new file mode 100644 index 00000000..b5c0d01e --- /dev/null +++ b/server/pruef-kanalzeile.mjs @@ -0,0 +1,259 @@ +/* ===================================================================== + DIE KANALZEILE (17.09.2026) -- Schritt 3 aus dem Video-Plan + + Filipe: "fuer meinen Account, dogfather, dann dogfather clips und + hasidog." + + Geprueft wird nicht nur, DASS die Zeile da ist: + + * Bei weniger als zwei Kanaelen darf sie NICHT erscheinen. Ein + Filter, der nichts zu filtern hat, macht misstrauisch -- und + wer einmal einen Knopf gedrueckt hat, bei dem nichts passiert, + drueckt den naechsten nicht mehr. + * Ein Klick muss wirklich weniger Karten uebrig lassen, und zwar + die richtigen. + * Ein zweiter Klick auf denselben Knopf hebt den Filter auf. + * Kanal- und Art-Filter wirken ZUSAMMEN -- das war der Grund, den + Kanal als zweite Dimension zu bauen statt als weiteren Wert in + derselben Leiste. + + Aufrufen mit: node server/pruef-kanalzeile.mjs + ===================================================================== */ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createServer } from "node:http"; + +const ordner = mkdtempSync(join(tmpdir(), "ws-kz-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); +const { portMussFreiSein } = await import("./helfer-port.mjs"); +await portMussFreiSein(4332, "das Bildwerkzeug"); +const TIKTOK = await portMussFreiSein(4502, "nachgebautes TikTok"); +process.env.PORT = "4332"; +process.env.SITE_ACCESS_SECRET = "lokaler-test"; +process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; +process.env.TIKTOK_OEMBED_BASIS = `http://127.0.0.1:${TIKTOK}/oembed`; + +const PNG = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", + "base64"); +const tiktok = createServer((q, a) => { + const u = new URL(q.url, `http://127.0.0.1:${TIKTOK}`); + if (u.pathname === "/oembed") { + const ziel = u.searchParams.get("url") || ""; + const wer = (ziel.match(/@([^/?]+)/) || [])[1] || ""; + if (!wer) { a.writeHead(404); a.end("{}"); return; } + const titel = { "dogfather0804": "Casper klaut die Fernbedienung 😄", + "hasidog00804": "Casper schläft im Wäschekorb", + "dogis.modi.gang": "Der Chat entscheidet: Horror oder Koop?" }[wer] || "Video"; + a.writeHead(200, { "content-type": "application/json" }); + a.end(JSON.stringify({ + version: "1.0", type: "video", title: titel, + author_url: `https://www.tiktok.com/@${wer}`, + thumbnail_url: `http://127.0.0.1:${TIKTOK}/cover.jpg`, + thumbnail_width: 576, thumbnail_height: 1024, + })); + return; + } + if (u.pathname === "/cover.jpg") { + a.writeHead(200, { "content-type": "image/jpeg" }); a.end(PNG); return; + } + a.writeHead(404); a.end("{}"); +}); +await new Promise((r) => tiktok.listen(TIKTOK, "127.0.0.1", r)); + +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, 1200)); +const BASIS = "http://127.0.0.1:4332"; + +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes } = await import("node:crypto"); +for (let i = 0; i < 60; i++) { + try { + const t = new DatabaseSync(process.env.WORKSPACE_DB); + try { t.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { t.close(); } + } catch { await new Promise((r) => setTimeout(r, 250)); } +} +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const j = 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, j); +d.close(); + +const an = await fetch(BASIS + "/workspace/api/anmelden", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rolle: "admin", code: "CODE-DOGI-0001" }), +}); +const keks = (an.headers.getSetCookie?.() || []).map((z) => z.split(";")[0]).join("; "); + +async function video(handle, nr) { + const a = await fetch(`${BASIS}/workspace/api/bereich/highlight/video`, { + method: "POST", + headers: { "Content-Type": "application/json", cookie: keks, origin: BASIS }, + body: JSON.stringify({ url: `http://127.0.0.1:${TIKTOK}/@${handle}/video/${nr}` }), + }); + return a.status; +} + +const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); +const browser = await pw.chromium.launch(); +const ctx = await browser.newContext({ viewport: { width: 1200, height: 900 }, deviceScaleFactor: 2 }); +await ctx.addCookies(keks.split("; ").map((k) => { + const [name, ...rest] = k.split("="); + return { name, value: rest.join("="), domain: "127.0.0.1", path: "/" }; +})); +const seite = await ctx.newPage(); +const konsolenfehler = []; +seite.on("console", (m) => { if (m.type() === "error") konsolenfehler.push(m.text()); }); +seite.on("pageerror", (f) => konsolenfehler.push(String(f))); + +let fehler = 0, geprueft = 0; +const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; + +const zeileDa = async () => !!(await seite.$(".filter__kanaele")); +/* DER SELEKTOR HEISST .eintrag-karte -- nachgesehen in bereich.js + (el("article", "eintrag-karte")), nicht geraten. Mein erster + Versuch traf nichts, und die Messung meldete brav "0 -> 0": Das + sah aus wie ein Ergebnis und war keins. + + BEIM BERICHTIGEN bin ich dann in die naechste Falle gelaufen und + habe daraus "undefined -> undefined" gemacht: Das Patchskript + benutzte String.replace, und dort bedeutet "$$" im ERSATZTEXT ein + einzelnes "$". Aus seite.$$(...) wurde seite.$(...). Der Fehler + steht woertlich in meinen eigenen Regeln -- und hat trotzdem + zugeschlagen. Aufgefallen ist er nur, weil die Ausgabe gelesen + wurde: "undefined" ist wenigstens ehrlich, "0" waere es nicht + gewesen. */ +const karten = async () => (await seite.$$(".eintrag-karte")).length; + +/* ---- 1. EIN Kanal: die Zeile darf NICHT da sein --------------------- */ +console.log("\n=== 1. Mit nur einem Kanal ==="); +console.log(" Video 1:", await video("dogfather0804", "101")); +await seite.goto(BASIS + "/workspace/bereich.html?b=highlight", { waitUntil: "networkidle" }); +await seite.waitForTimeout(1500); +ok(!(await zeileDa()), "bei einem einzigen Kanal steht dort KEINE Filterzeile"); + +/* ---- 2. Drei Kanäle -------------------------------------------------- */ +console.log("\n=== 2. Mit drei Kanaelen ==="); +console.log(" Video 2:", await video("hasidog00804", "102")); +console.log(" Video 3:", await video("dogis.modi.gang", "103")); +await seite.reload({ waitUntil: "networkidle" }); +await seite.waitForTimeout(1800); +ok(await zeileDa(), "sobald drei Kanaele da sind, erscheint sie"); +const knoepfe = await seite.$$eval(".filter__kanal", (ns) => ns.map((n) => ({ + text: n.textContent.trim().replace(/\s+/g, " "), + kanal: n.dataset.kanal || "(alle)", + gewaehlt: n.getAttribute("aria-pressed"), +}))); +for (const k of knoepfe) console.log(` ${k.gewaehlt === "true" ? "[x]" : "[ ]"} ${k.text}`); +ok(knoepfe.length === 4, `vier Knoepfe: Alle Kanaele und die drei (${knoepfe.length})`); +ok(knoepfe[0]?.gewaehlt === "true", "und „Alle Kanaele“ ist die Vorgabe"); + +const zeile = await seite.$(".filter__kanaele"); +if (zeile) { + const leiste = await seite.$("#filter"); + await (leiste || zeile).scrollIntoViewIfNeeded(); + await seite.waitForTimeout(400); + await (leiste || zeile).screenshot({ path: "server/bild-kanalzeile.png" }); +} + +/* ---- 3. Ein Klick filtert wirklich ----------------------------------- */ +console.log("\n=== 3. Ein Klick ==="); +const vorher = await karten(); +ok(vorher === 3, `ungefiltert liegen drei Videos da (${vorher})`); + +const hasi = await seite.$('.filter__kanal[data-kanal="hasidog"]'); +ok(!!hasi, "der HasiDog-Knopf ist da"); +if (hasi) { + await hasi.click(); + await seite.waitForTimeout(1500); + const nachher = await karten(); + ok(nachher === 1, `nach einem Klick auf HasiDog bleibt eines uebrig (${nachher})`); + + /* UND ZWAR DAS RICHTIGE. "Weniger" allein waere auch erfuellt, wenn + der Filter irgendetwas wegwirft -- das ist der Unterschied + zwischen filtern und kaputt sein. */ + const titel = await seite.$$eval(".eintrag-karte", + /* /\s+/ und nicht /s+/ -- beim Erzeugen dieser Datei ist der + Backslash durch eine Escaping-Ebene verloren gegangen, und der + Ausdruck ersetzte danach JEDES "s" durch ein Leerzeichen. In der + Ausgabe stand "Ca per chläft im Wä chekorb". Das war kein Fehler + im Filter, sondern einer in der Pruefung -- und er hat sie rot + gemacht, was genau richtig ist: Eine Pruefung, die ihren eigenen + Text zerstoert, darf nicht gruen sein. */ + (ns) => ns.map((n) => n.textContent.replace(/\s+/g, " ").trim())); + for (const t of titel) console.log(" " + t.slice(0, 60)); + ok(titel.length === 1 && /Wäschekorb/.test(titel[0]), + "und es ist wirklich das HasiDog-Video"); + + const leiste2 = await seite.$("#filter"); + if (leiste2) await leiste2.screenshot({ path: "server/bild-kanalzeile-gefiltert.png" }); + + /* Der gewaehlte Knopf sagt es auch. */ + const gedrueckt = await seite.$$eval(".filter__kanal", + (ns) => ns.filter((n) => n.getAttribute("aria-pressed") === "true") + .map((n) => n.dataset.kanal || "(alle)")); + ok(gedrueckt.length === 1 && gedrueckt[0] === "hasidog", + `genau ein Knopf ist gewaehlt (${gedrueckt.join(", ")})`); + + /* ---- Und ein zweiter Klick hebt ihn auf --------------------------- + Ohne das sucht man den Weg zurueck zu "Alle Kanaele" und findet + ihn nicht, weil man nicht mehr weiss, dass man gefiltert hat. */ + const nochmal = await seite.$('.filter__kanal[data-kanal="hasidog"]'); + await nochmal.click(); + await seite.waitForTimeout(1500); + ok((await karten()) === vorher, + `ein zweiter Klick hebt den Filter auf (wieder ${await karten()})`); +} + +/* ---- 4. Kanal UND Art zusammen -------------------------------------- + Der eigentliche Grund fuer zwei getrennte Reihen. Waere der Kanal + nur ein weiterer Wert in derselben Leiste, koennte man das hier + nicht fragen -- und die Pruefung waere trotzdem gruen geblieben. */ +console.log("\n=== 4. Beide Filter zusammen ==="); +{ + const df = await seite.$('.filter__kanal[data-kanal="dogfather"]'); + await df.click(); + await seite.waitForTimeout(1200); + const nurKanal = await karten(); + ok(nurKanal === 1, `nur DogFather: ${nurKanal}`); + + /* Jetzt zusaetzlich eine Art, die dieses Video NICHT hat. */ + const arten = await seite.$$eval("#filter > .schritt", + (ns) => ns.map((n) => n.textContent.trim())); + console.log(" Arten in der ersten Reihe: " + arten.join(", ")); + const bild = (await seite.$$("#filter > .schritt")) + .find ? null : null; + const alleKnoepfe = await seite.$$("#filter > .schritt"); + let getroffen = false; + for (const k of alleKnoepfe) { + const t = (await k.textContent()).trim(); + if (t !== "Bild") continue; + await k.click(); + await seite.waitForTimeout(1200); + getroffen = true; + ok((await karten()) === 0, + `DogFather + Art "Bild" ergibt nichts (${await karten()}) -- beide Filter wirken`); + break; + } + ok(getroffen, "die Art „Bild“ war zum Gegenprobieren da"); + void bild; +} + +console.log(""); +ok(konsolenfehler.length === 0, + konsolenfehler.length ? konsolenfehler.join(" | ") : "keine Fehler in der Browserkonsole"); + +console.log(""); +console.log(`${geprueft} Pruefungen, ${fehler} Fehler`); +console.log(fehler ? "NICHT IN ORDNUNG" : "ALLES IN ORDNUNG"); +await browser.close(); +await new Promise((r) => tiktok.close(r)); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Aufraeumrest */ } +process.exit(fehler ? 1 : 0); diff --git a/workspace/app.webmanifest b/workspace/app.webmanifest index 6a00991b..1f9fd164 100644 --- a/workspace/app.webmanifest +++ b/workspace/app.webmanifest @@ -13,25 +13,25 @@ "theme_color": "#06090f", "icons": [ { - "src": "/assets/img/app-symbole/workspace-192.png?v=202609171318", + "src": "/assets/img/app-symbole/workspace-192.png?v=202609171407", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-512.png?v=202609171318", + "src": "/assets/img/app-symbole/workspace-512.png?v=202609171407", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609171318", + "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609171407", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { - "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609171318", + "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609171407", "sizes": "512x512", "type": "image/png", "purpose": "maskable" diff --git a/workspace/assets/css/bereich.css b/workspace/assets/css/bereich.css index f0c5906f..71dd043d 100644 --- a/workspace/assets/css/bereich.css +++ b/workspace/assets/css/bereich.css @@ -1164,3 +1164,56 @@ .videozeile__kanal[data-kanal="clips"] { --v-ton: #3f9c8a; } .videozeile__weg { flex: 0 0 auto; text-decoration: none; } + +/* --------------------------------------------------------------------- + DIE KANALZEILE (17.09.2026) -- Schritt 3 aus dem Video-Plan + + Sie steht als eigene Reihe UNTER dem Art-Filter, nicht daneben. Zwei + Fragen in einer Zeile sehen aus wie eine: Man klickt „Highlights" + und dann „HasiDog" und wundert sich, warum das erste wieder aus ist. + Getrennte Reihen zeigen, dass es zwei Dinge sind. + + DIESELBEN DREI TÖNE WIE AUF DER KARTE (.videozeile__kanal). Wer + „DogFather Clips" in Türkis filtert, soll darunter Karten mit + türkisem Zeichen finden -- sonst muss er bei jedem Video neu lesen. + ------------------------------------------------------------------- */ +.filter__kanaele { + display: flex; flex-wrap: wrap; gap: 8px; + /* Volle Breite erzwingt die eigene Reihe, auch wenn oben noch Platz + wäre. Das ist Absicht: Es sind zwei Fragen, keine Fortsetzung. + Und es ist eine REGEL statt einer Schwelle -- keine Pixelzahl, die + beim nächsten hinzugefügten Knopf still falsch wird. */ + flex-basis: 100%; + margin-top: 10px; padding-top: 10px; + border-top: 1px solid var(--rand); +} + +.filter__kanal { + --v-ton: #6f89a8; + display: inline-flex; align-items: center; gap: 7px; +} +.filter__kanal[data-kanal="dogfather"] { --v-ton: #c2544f; } +.filter__kanal[data-kanal="hasidog"] { --v-ton: #7d6bbd; } +.filter__kanal[data-kanal="clips"] { --v-ton: #3f9c8a; } + +/* GEWÄHLT heißt: der Ton wird sichtbar. Ungewählt bleibt er gedeckt -- + drei bunte Knöpfe nebeneinander wären eine Ampel, und eine Ampel + sagt „gut/schlecht", wo nur „welcher Kanal" gemeint ist. */ +.filter__kanal[aria-pressed="true"] { + color: color-mix(in srgb, var(--v-ton) 76%, #ffffff); + border-color: color-mix(in srgb, var(--v-ton) 55%, transparent); + background: color-mix(in srgb, var(--v-ton) 13%, transparent); +} + +.filter__kanal-zahl { + /* .72rem = 11,5 px, die Grenze, die pruef-css-klassen bewacht. */ + font-size: .72rem; font-weight: 700; + min-width: 1.5em; padding: 1px 5px; + border-radius: 999px; text-align: center; + color: var(--text-still); + background: rgba(255, 255, 255, .07); +} +.filter__kanal[aria-pressed="true"] .filter__kanal-zahl { + color: color-mix(in srgb, var(--v-ton) 82%, #ffffff); + background: color-mix(in srgb, var(--v-ton) 20%, transparent); +} diff --git a/workspace/assets/css/entwicklung.css b/workspace/assets/css/entwicklung.css index 04d5978e..4c7ad78e 100644 --- a/workspace/assets/css/entwicklung.css +++ b/workspace/assets/css/entwicklung.css @@ -567,7 +567,30 @@ Zeile den Anfang neu. Zentriert war richtig, solange dort ein einziger Satz stand; jetzt steht dort eine Aufzählung. ------------------------------------------------------------------- */ -.t-leer:has(.t-leer__merkmale) { text-align: start; } +.t-leer:has(.t-leer__merkmale) { + text-align: start; + /* UND ER BRAUCHT EINEN GRUND. Gesehen im Bildschirmfoto, nicht in + einer Zahl: Der Kasten hatte nur einen gestrichelten Rand, und das + Spicy-Media-Motiv -- Chilischoten, Neonlicht, helle Stellen -- + schien voll durch den Text. Lesbar war das nicht, augenschonend + erst recht nicht. + + SOLANGE DORT EIN SATZ STAND, ging es. Jetzt stehen dort drei + Absätze, fünf Merkmale und zwei Knöpfe. + + DIE WARNUNG STAND 70 ZEILEN WEITER UNTEN, bei .t-fuss, und sie + beschreibt exakt diesen Fehler ("Sie stand als einziger Text der + Seite direkt auf der Bühne"). Ich habe sie gelesen und bin + trotzdem hineingelaufen -- ein Kommentar, der vor einem Fehler + warnt, verhindert ihn nicht. Gefunden hat es erst das Bild. + + Derselbe Wert wie bei .t-fuss, damit auf dieser Seite nicht zwei + verschiedene Grautöne nebeneinander liegen. */ + padding: 20px; + background: color-mix(in srgb, var(--tinte) 94%, #ffffff 6%); + border-style: solid; + border-color: var(--rand); +} .t-leer:has(.t-leer__merkmale) p { margin-inline: 0; } .t-leer__titel { diff --git a/workspace/assets/js/bereich.js b/workspace/assets/js/bereich.js index 0947ccbc..f67a9f79 100644 --- a/workspace/assets/js/bereich.js +++ b/workspace/assets/js/bereich.js @@ -107,6 +107,14 @@ let treffBrett = false; let lage = null; let filter = ''; + /* WELCHER KANAL -- eine ZWEITE Dimension neben `filter`, nicht + derselbe Wert. + + Ein gemeinsamer Wert wäre einfacher zu bauen und falsch zu + benutzen: Man könnte dann „nur offene" ODER „nur HasiDog" fragen, + aber nie beides. Und genau das will man, sobald aus drei Kanälen + Videos nebeneinander liegen. */ + let kanalFilter = ''; /* Nur beim ersten Laden springen -- sonst reisst es die Seite nach jeder Bewertung erneut an dieselbe Stelle. */ let gesprungen = false; @@ -908,7 +916,7 @@ } catch { /* dann gilt die alte Regel, siehe darfEintragen */ } } - function filterBauen() { + function filterBauen(eintraege = []) { const ziel = $('filter'); ziel.textContent = ''; const knopf = (wert, text) => { @@ -921,6 +929,47 @@ ziel.append(knopf('', 'Alle')); ziel.append(knopf('offen', 'Nur offene')); for (const [wert, name] of Object.entries(einstellung.arten)) ziel.append(knopf(wert, name)); + + /* ---- UND DIE KANALZEILE, wenn hier Videos liegen ---------------- + + SIE ERSCHEINT NUR, WENN ES ETWAS ZU FILTERN GIBT. Die Kanäle + werden aus den vorhandenen Einträgen abgeleitet, nicht + aufgezählt: Auf einem Brett ohne Videos steht keine leere Leiste, + und auf einem mit nur einem Kanal steht kein Filter, der nichts + tut. Ein Knopf, der immer dasselbe zeigt, macht misstrauisch. + + ERST AB ZWEI KANÄLEN. Bei einem gibt es nichts zu wählen. */ + const kanaele = [...new Set(eintraege.map((e) => e.quelle_kanal).filter(Boolean))]; + if (kanaele.length < 2) { kanalFilter = ''; return; } + + const zweite = el('div', 'filter__kanaele'); + zweite.setAttribute('role', 'group'); + zweite.setAttribute('aria-label', 'Nach Kanal filtern'); + const kanalKnopf = (wert, text, zahl) => { + const b = el('button', 'schritt filter__kanal', text); + b.type = 'button'; + if (wert) b.dataset.kanal = wert; + b.setAttribute('aria-pressed', String(kanalFilter === wert)); + if (zahl !== null) b.append(el('span', 'filter__kanal-zahl', String(zahl))); + b.addEventListener('click', async () => { + /* Ein zweiter Klick auf denselben Kanal hebt ihn auf -- sonst + sucht man den Weg zurück zu „Alle Kanäle" und findet ihn + nicht, weil man nicht mehr weiß, dass man gefiltert hat. */ + kanalFilter = (kanalFilter === wert) ? '' : wert; + await laden(); + }); + return b; + }; + zweite.append(kanalKnopf('', 'Alle Kanäle', null)); + /* IN DER REIHENFOLGE VON KANAL_WORT, nicht in der, in der die + Einträge zufällig kommen. Sonst springen die Knöpfe, sobald + jemand ein Video einfügt. */ + for (const wert of Object.keys(KANAL_WORT)) { + if (!kanaele.includes(wert)) continue; + zweite.append(kanalKnopf(wert, KANAL_WORT[wert], + eintraege.filter((e) => e.quelle_kanal === wert).length)); + } + ziel.append(zweite); } /* WAS AUS DEN EINTRAEGEN GEWORDEN IST -- einmal fuer das ganze Brett. @@ -1061,7 +1110,7 @@ einstellung = daten.einstellung; treffBrett = !!daten.treff; await lageHolen(); - filterBauen(); + filterBauen(daten.eintraege || []); const ziel = $('liste'); ziel.textContent = ''; @@ -1080,6 +1129,10 @@ else delete ziel.dataset.aushang; const sichtbar = daten.eintraege.filter((e) => { + /* DER KANAL ZUERST und als eigene Bedingung: Er schließt den + Art-Filter nicht aus, er kommt dazu. „Nur offene Clips von + HasiDog" ist eine sinnvolle Frage. */ + if (kanalFilter && e.quelle_kanal !== kanalFilter) return false; if (!filter) return true; if (filter === 'offen') return e.status === 'offen'; return e.art === filter; diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html index 711249ba..c3b49c42 100644 --- a/workspace/aufgaben.html +++ b/workspace/aufgaben.html @@ -12,19 +12,19 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - + + + + + + - +
@@ -291,10 +291,10 @@ - - - - - + + + + +