Material: Bilder und Videos zum Posten, jedes nur einmal

Filipe, 22.09.2026: "ich brauch auch noch eine neue kachel im bereich
community, wo wir als team ... bilder reinschicken koennen, mit text
und wo die leute sei es community oder modis sich die bilder nehmen
koennen zum posten. die community soll keine posten koennen ... sobald
ein bild oder ein video ... runtergeladen wurde, soll direkt blockiert
werden ... damit nie ein bild zwei mal gepostet wird ... und die rechte
hand und dogfather, nur die beiden sollen auch immer sehen koennen wer
das bild oder video runtergeladen hat."

DER KERN IST DIE EINMALIGKEIT, und deshalb sind Nehmen und Laden ZWEI
Schritte. "nehmen" schreibt in EINER Abfrage fest, wer es hat -- mit
`WHERE genommen_von IS NULL` in der Bedingung. Wer zu spaet kommt,
aendert null Zeilen und bekommt 409. Ein einziger Schritt ("laden und
dabei markieren") haette dieselbe Luecke wie ein Pool ohne Sperre:
zwei Anfragen, beide sehen "frei", beide laden.

UND DIE SPERRE GILT AUCH FUER DIE VORSCHAU. Waere sie offen
geblieben, waere sie der Weg, ein vergebenes Bild doch noch zu
bekommen (Rechtsklick, speichern) -- und die ganze Einmaligkeit eine
Behauptung. Ausnahme: wer es selbst genommen hat, sieht es weiter.

WER WEN SIEHT, entscheidet der Server, nicht die Seite. Fuer alle
ausser DogFather und der rechten Hand fehlt das Feld `genommen_von`
ganz -- nicht `null`: Ein Feld, das da ist und leer bleibt, laedt
dazu ein, es spaeter "zu fuellen".

pruef-material.mjs (70 Pruefungen, 0 Fehler) misst den ganzen Weg,
mit Gegenprobe zu jeder Grenze. Die Zahl der vergebenen Stuecke steht
in der BEDINGUNG -- ohne sie waere "keine Namen dabei" trivial wahr.

DREI DINGE HAT ERST DER BLICK MIT ECHTEN AUGEN GEFUNDEN
(tools/material-blick.mjs, drei Rollen, vier Bildschirmbreiten):

  * "hat es genommen am 22.09.." -- eine deutsche Datumsangabe endet
    selbst auf einen Punkt. Kein Pruefprogramm haette danach gefragt.
  * Die Knoepfe standen auf drei verschiedenen Hoehen (1127, 1155,
    1176), weil der eine Text zwei Zeilen hatte und der naechste
    keine. `margin-top: auto` am Fuss statt einer geratenen
    Mindesthoehe.
  * "Schon benutzt" nahm 273 px Hoehe je Stueck fuer ein einziges
    Zeichen -- das Bild ist dort ohnehin nicht mehr abrufbar. Jetzt
    eine Zeile mit 96 px.

Am Handy (412 px) blieb es bei EINER Spalte: 3062 px Seitenhoehe fuer
fuenf Stuecke. Filipe: "es ist alles so lang gezogen, muss ewig
scrollen". Statt einer festen Umbruchschwelle -- die am 06.09. schon
zweimal teuer war -- waechst die Spaltenbreite jetzt mit:
`max(160px, 22%)`. Gemessen 360/412/768/1500 px: 2/2/3/3 Spalten,
nirgends ein Ueberlauf, 412 px jetzt 2014 statt 3062 px.

Dazu neun Saetze in meldung.js. Der wichtigste ist "schon_genommen",
und er ist bewusst kein Fehler: Wer ihn liest, hat nichts falsch
gemacht. pruef-meldungen fand dabei einen Rest aus dem Aufgaben-Block
-- `nur_leitung_legt_an` hatte keinen Satz, ein Modi mit altem Tab
haette rohen Maschinentext gelesen.

pruef-material 70/0 · pruef-meldungen 8/0 · pruef-treff 80/0
pruef-rechtetafel 19/0 · pruef-sackgassen 13/0 · pruef-css-klassen ok
pruef-zwischenspeicher 27/0

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-22 02:27:21 +02:00
co-authored by Claude Opus 5
parent e4c6d69ce8
commit 06f3fecaa7
50 changed files with 2489 additions and 521 deletions
+289
View File
@@ -0,0 +1,289 @@
/* =====================================================================
DIE MATERIAL-SEITE MIT DREI PAAR AUGEN
Aufruf: node tools/material-blick.mjs
BREITE=412 node tools/material-blick.mjs (Handy)
WARUM DREI SICHTEN UND NICHT EINE. Auf dieser Seite sieht jede
Rolle etwas anderes, und die Unterschiede sind der ganze Sinn:
DogFather sieht den Kasten zum Bereitstellen UND die Namen
derer, die sich etwas genommen haben.
ein Modi sieht den Kasten, aber KEINE Namen.
die Community sieht weder noch -- nur das, was sie holen darf.
Wer die Seite nur als DogFather ansieht, beurteilt eine Seite, die
es fuer die Community nicht gibt. Genau dieser Fehler ist am
11.09. beim Community-Blick schon einmal passiert.
UND ES LIEGT ECHTES MATERIAL DARIN. Ein leeres Raster zu
fotografieren beantwortet nur die Frage, ob es leer ist -- nicht,
ob die Vorschau passt, ob die Karten gleich hoch sind oder ob der
Text unter einem Bild lesbar bleibt.
===================================================================== */
import { mkdtempSync, rmSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { spawn } from "node:child_process";
import { DatabaseSync } from "node:sqlite";
import { scryptSync, randomBytes } from "node:crypto";
import { request as anfrage } from "node:http";
const WURZEL = join(dirname(fileURLToPath(import.meta.url)), "..");
const HIER = join(WURZEL, "server");
const PORT = Number(process.env.PORT_BLICK || 4371);
const BASIS = `http://127.0.0.1:${PORT}`;
const CREW = "crew.dogfather-universe.com";
const ordner = mkdtempSync(join(tmpdir(), "ws-mat-blick-"));
const DB = join(ordner, "workspace.db");
let kind = null;
async function start() {
kind = spawn(process.execPath, [join(HIER, "index.js")], {
env: { ...process.env, WORKSPACE_DB: DB, PORT: String(PORT),
SITE_ACCESS_SECRET: "lokaler-test",
SITE_PUBLIC_LAUNCH_AT: "2020-01-01T00:00:00+01:00" },
stdio: ["ignore", "pipe", "pipe"] });
let aus = "";
kind.stdout.on("data", (d) => { aus += d; });
kind.stderr.on("data", (d) => { aus += d; });
for (let i = 0; i < 80; i++) {
await new Promise((r) => setTimeout(r, 250));
if (kind.exitCode !== null) break;
try {
const a = await fetch(BASIS + "/workspace/", { redirect: "manual" });
if (a.status < 500) return;
} catch { /* noch nicht oben */ }
}
throw new Error("Server startete nicht:\n" + aus.slice(-1500));
}
function personAnlegen(name, rolle, code) {
const d = new DatabaseSync(DB);
const salz = randomBytes(16).toString("hex");
const hash = scryptSync(code, salz, 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, salz, 32768, new Date().toISOString());
d.close();
}
await start();
kind.kill();
await new Promise((r) => setTimeout(r, 600));
personAnlegen("Filipe", "admin", "CODE-DOGI-0001");
personAnlegen("Rieke", "hand", "CODE-HAND-0001");
personAnlegen("Frida", "modi", "CODE-MODI-0001");
personAnlegen("Mara", "gast", "CODE-GAST-0001");
personAnlegen("Jonas", "gast", "CODE-GAST-0002");
await start();
function rohAnfrage(pfad, host, koerper, kopf = {}, methode = "POST") {
return new Promise((f, s2) => {
const daten = Buffer.isBuffer(koerper) ? koerper
: (koerper == null ? null : Buffer.from(JSON.stringify(koerper)));
const a = anfrage({
host: "127.0.0.1", port: PORT, path: pfad, method: methode,
headers: {
Host: host,
...(daten ? { "Content-Type": "application/json", "Content-Length": daten.length } : {}),
...kopf,
},
}, (an) => {
let t = "";
an.on("data", (c) => { t += c; });
an.on("end", () => f({ code: an.statusCode, text: t, kopf: an.headers }));
});
a.on("error", s2);
if (daten) a.write(daten);
a.end();
});
}
async function anmelden(rolle, code) {
let a = await rohAnfrage("/workspace/api/anmelden", CREW, { rolle, code });
if (a.code === 400 && a.text.includes("alter_offen")) {
a = await rohAnfrage("/workspace/api/anmelden", CREW, { rolle, code, alter_ok: true });
}
if (a.code !== 200) throw new Error(`Anmeldung ${rolle} -> ${a.code} ${a.text.slice(0, 160)}`);
return [].concat(a.kopf["set-cookie"] || []).map((z) => z.split(";")[0]).join("; ");
}
/* ---- ECHTES MATERIAL HINEINLEGEN -----------------------------------
Die Buehnenbilder des Hauses. Sie sind echte Fotos in echter
Groesse -- ein 1x1-Pixel-Platzhalter wuerde jede Frage nach dem
Seitenverhaeltnis und der Ladezeit unbeantwortet lassen. */
const kDogi = await anmelden("admin", "CODE-DOGI-0001");
const kHand = await anmelden("hand", "CODE-HAND-0001");
const kModi = await anmelden("modi", "CODE-MODI-0001");
const kGast = await anmelden("gast", "CODE-GAST-0001");
const kJonas = await anmelden("gast", "CODE-GAST-0002");
const BILDER = [
["crew-gate-960.webp", "Stream-Ausschnitt gestern Abend.webp",
"Für TikTok – passt zum Clip von gestern, gern mit Musik drunter."],
["crew-gate-hoch-580.webp", "Hochformat für Insta Story.webp",
"Schon im 9:16-Format, geht direkt als Story raus."],
["gate-1280.webp", "Ankündigung Freitag.webp", ""],
["crew-gate-1280.webp", "Dogi im Studio.webp",
"Bitte nur mit dem Text „Freitag 20 Uhr“ posten."],
["gate-960.webp", "Thumbnail Variante B.webp", "Falls A nicht zieht."],
];
console.log("=== Material einstellen ===");
let nr = 0;
for (const [datei, name, text] of BILDER) {
const rohBild = readFileSync(join(WURZEL, "workspace/assets/img/buehne", datei));
const wer = nr % 2 === 0 ? kDogi : kHand;
const a = await rohAnfrage("/workspace/api/material", CREW, rohBild, {
Cookie: wer, "Content-Type": "image/webp",
"Content-Length": rohBild.length,
"X-Datei-Name": encodeURIComponent(name),
"X-Text": encodeURIComponent(text),
Origin: `https://${CREW}`,
});
console.log(` ${String(a.code).padEnd(4)} ${name} (${Math.round(rohBild.length / 1024)} KB)`);
nr++;
}
/* ZWEI STUECKE SIND SCHON WEG. Ohne sie waere „Schon benutzt" leer,
und genau dieser Block ist der, den Filipe sehen will. */
const alle = JSON.parse((await rohAnfrage("/workspace/api/material", CREW, null,
{ Cookie: kDogi }, "GET")).text).material;
for (const [i, keks] of [[3, kGast], [4, kJonas]]) {
const a = await rohAnfrage(`/workspace/api/material/${alle[i].id}/nehmen`, CREW, null,
{ Cookie: keks, Origin: `https://${CREW}` });
console.log(` genommen: ${alle[i].name} -> ${a.code}`);
}
/* ---- Und jetzt hinsehen -------------------------------------------- */
const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
const browser = await pw.chromium.launch();
const BREITE = Number(process.env.BREITE || 1500);
const ANHANG = BREITE < 700 ? "-handy" : "";
async function hinsehen(titel, keks, marke) {
const ctx = await browser.newContext({
viewport: { width: BREITE, height: BREITE < 700 ? 915 : 1100 },
deviceScaleFactor: 1, isMobile: BREITE < 700, hasTouch: BREITE < 700 });
await ctx.addCookies(keks.split("; ").filter(Boolean).map((z) => {
const [name, ...rest] = z.split("=");
return { name, value: rest.join("="), domain: "127.0.0.1", path: "/" };
}));
const seite = await ctx.newPage();
const schlimm = [];
seite.on("console", (m) => { if (m.type() === "error") schlimm.push(m.text().slice(0, 120)); });
seite.on("requestfailed", (r) => schlimm.push(`${r.url().slice(-50)} (${r.failure()?.errorText})`));
seite.on("response", (r) => { if (r.status() >= 400) schlimm.push(`${r.url().slice(-50)} -> ${r.status()}`); });
await seite.goto(`${BASIS}/workspace/material.html`, { waitUntil: "networkidle" });
await seite.waitForTimeout(1800);
const z = await seite.evaluate(() => {
const karten = [...document.querySelectorAll(".m-karte")];
const bilder = [...document.querySelectorAll(".m-karte__bild")];
const kasten = document.getElementById("neu-block");
const wegBlock = document.getElementById("weg-block");
/* Kontrast messen, wo es zaehlt: der Text unter dem Bild. */
const probe = document.querySelector(".m-karte__text, .m-karte__name");
const farbe = probe ? getComputedStyle(probe).color : "";
let hinter = "";
for (let e = probe; e; e = e.parentElement) {
const h = getComputedStyle(e).backgroundColor;
if (h && !/rgba\(0, 0, 0, 0\)/.test(h)) { hinter = h; break; }
}
return {
titel: document.querySelector("h1")?.textContent?.trim() || "",
unterzeile: (document.querySelector(".unterzeile")?.textContent || "")
.replace(/\s+/g, " ").trim(),
karten: karten.length,
frei: document.querySelectorAll("#frei .m-karte").length,
weg: document.querySelectorAll("#weg .m-karte").length,
wegBlockDa: !!wegBlock && !wegBlock.hidden,
kastenDa: !!kasten && !kasten.hidden,
/* Wie viele Karten nennen einen NAMEN dessen, der es hat? */
mitNamen: [...document.querySelectorAll(".m-karte__stand")]
.filter((e) => /hat es genommen/.test(e.textContent)).length,
staende: [...new Set([...document.querySelectorAll(".m-karte__stand")]
.map((e) => e.textContent.replace(/\s+/g, " ").trim()))],
knoepfe: [...new Set([...document.querySelectorAll(".m-karte button")]
.map((b) => b.textContent.trim()))],
/* Sind die Karten gleich hoch? Verschiedene Hoehen in einem
Raster sehen nach Versehen aus. */
hoehen: [...new Set(karten.map((k) => Math.round(k.getBoundingClientRect().height)))],
/* UND STEHEN DIE KNOEPFE AUF EINER LINIE? Gleich hohe Karten
genuegen dafuer nicht -- bei unterschiedlich langem Text
bleibt unten unterschiedlich viel Luft, und die Knoepfe
rutschen auseinander. Genau das war am 22.09. der Fall. */
knopfLinien: (() => {
const erste = [...document.querySelectorAll("#frei .m-karte")]
.map((k) => k.getBoundingClientRect().top);
const oben = erste.length ? erste[0] : 0;
return [...new Set([...document.querySelectorAll("#frei .m-karte")]
.filter((k) => Math.abs(k.getBoundingClientRect().top - oben) < 2)
.map((k) => {
const b = k.querySelector("button, a.knopf");
return b ? Math.round(b.getBoundingClientRect().top) : null;
}).filter((v) => v !== null))];
})(),
wegHoehen: [...new Set([...document.querySelectorAll("#weg .m-karte")]
.map((k) => Math.round(k.getBoundingClientRect().height)))],
bilderGeladen: bilder.filter((b) => b.naturalWidth > 0).length,
bilderGesamt: bilder.length,
/* Laeuft etwas ueber den Rand hinaus? */
zuBreit: karten.filter((k) => k.getBoundingClientRect().right
> document.documentElement.clientWidth + 1).length,
spalten: (() => {
const oben = karten.map((k) => Math.round(k.getBoundingClientRect().top));
return oben.length ? oben.filter((t) => t === oben[0]).length : 0;
})(),
seitenhoehe: document.body.scrollHeight,
farbe, hinter,
kopfSatz: (document.getElementById("frei-satz")?.textContent || "").trim(),
wegSatz: (document.getElementById("weg-satz")?.textContent || "").trim(),
};
});
console.log("");
console.log(`=== ${titel} ===`);
console.log(` „${z.titel}" · ${z.unterzeile.slice(0, 90)}`);
console.log(` Kasten zum Bereitstellen: ${z.kastenDa ? "DA" : "weg"}`);
console.log(` ${z.frei} frei, ${z.weg} vergeben (Block „Schon benutzt": ${z.wegBlockDa ? "da" : "weg"})`);
console.log(` Karten mit Namen: ${z.mitNamen} ${marke}`);
console.log(` Zustaende: ${z.staende.join(" | ") || "(keine)"}`);
console.log(` Knoepfe: ${z.knoepfe.join(" | ") || "(keine)"}`);
console.log(` Vorschau geladen: ${z.bilderGeladen}/${z.bilderGesamt}`);
console.log(` Raster: ${z.spalten} Spalten, ${z.hoehen.length} verschiedene Hoehen (${z.hoehen.join(", ")})`);
console.log(` Knoepfe der ersten Zeile auf ${z.knopfLinien.length} Hoehe(n): ${z.knopfLinien.join(", ")}`
+ (z.knopfLinien.length > 1 ? " << stehen nicht auf einer Linie" : ""));
console.log(` „Schon benutzt": Kartenhoehe ${z.wegHoehen.join(", ") || "(keine)"} px`);
console.log(` ueber den Rand: ${z.zuBreit} · Seitenhoehe ${z.seitenhoehe} px`);
console.log(` Text ${z.farbe} auf ${z.hinter}`);
console.log(` oben: „${z.kopfSatz}"`);
console.log(` unten: „${z.wegSatz}"`);
if (schlimm.length) console.log(` !! ${[...new Set(schlimm)].join(" · ")}`);
const pfad = `C:/Users/qciga/AppData/Local/Temp/material-${titel.toLowerCase()
.replace(/[^a-z]+/g, "-").replace(/^-|-$/g, "")}${ANHANG}.png`;
await seite.screenshot({ path: pfad, fullPage: true });
console.log(` -> ${pfad}`);
await ctx.close();
return z;
}
const a1 = await hinsehen("DogFather", kDogi, "(erwartet: 2)");
const a2 = await hinsehen("Modi", kModi, "(erwartet: 0)");
const a3 = await hinsehen("Community", kGast, "(erwartet: 0)");
console.log("");
console.log("=== Was sich unterscheidet ===");
console.log(` Kasten: DogFather ${a1.kastenDa} · Modi ${a2.kastenDa} · Community ${a3.kastenDa}`);
console.log(` Namen: DogFather ${a1.mitNamen} · Modi ${a2.mitNamen} · Community ${a3.mitNamen}`);
console.log(` frei: ${a1.frei} / ${a2.frei} / ${a3.frei}`);
await browser.close();
kind.kill();
await new Promise((r) => setTimeout(r, 400));
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }