diff --git a/server/pruef-teilen.mjs b/server/pruef-teilen.mjs new file mode 100644 index 00000000..87d5c66e --- /dev/null +++ b/server/pruef-teilen.mjs @@ -0,0 +1,283 @@ +/* ===================================================================== + AUS „TEILEN" WIRD EIN TIPP (17.09.2026) — Schritt 4 des Video-Plans + + Filipe postet ein Video auf dem Handy. Bisher: Link kopieren, App + öffnen, Bereich suchen, Feld finden, einfügen. Fünf Schritte, von + denen jeder der Grund sein kann, es „später" zu machen. + + --------------------------------------------------------------------- + DER FALL, DER DIESE PRÜFUNG RECHTFERTIGT + + Das Teilen-Ziel kennt drei Felder: `title`, `text`, `url`. Man baut + es und probiert es mit `?url=https://…` aus — das geht, und man ist + fertig. + + IM ECHTEN LEBEN KOMMT DER LINK ABER IN `text`. Android-Apps, + TikTok eingeschlossen, teilen als reinen Text: „Sieh dir das an! + https://vm.tiktok.com/xyz/" steht dann in `text`, und `url` ist + leer. Wer nur `url` liest, hat eine Seite gebaut, die im Test + funktioniert und auf dem Handy eine leere Maske zeigt. + + Abschnitt 2 prüft genau diesen Fall — und zwar mit einem Satz + drumherum, nicht mit einer nackten Adresse. + + Aufrufen mit: node server/pruef-teilen.mjs + ===================================================================== */ + +import { mkdtempSync, rmSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createServer } from "node:http"; +import { portMussFreiSein } from "./helfer-port.mjs"; + +const PORT = await portMussFreiSein(4494, "pruef-teilen"); +const TIKTOK = await portMussFreiSein(4503, "nachgebautes TikTok"); +const ordner = mkdtempSync(join(tmpdir(), "ws-teilen-")); +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"; +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 wer = ((u.searchParams.get("url") || "").match(/@([^/?]+)/) || [])[1] || ""; + if (!wer) { a.writeHead(404); a.end("{}"); return; } + a.writeHead(200, { "content-type": "application/json" }); + a.end(JSON.stringify({ + version: "1.0", type: "video", + title: "Casper erwischt den Postboten", + author_url: `https://www.tiktok.com/@${wer}`, + thumbnail_url: `http://127.0.0.1:${TIKTOK}/cover.jpg`, + })); + 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 }); }; +import { notbremse } from "./helfer-notbremse.mjs"; +await import("./index.js"); +notbremse(240_000, "pruef-teilen"); +await new Promise((r) => setTimeout(r, 900)); +const BASIS = `http://127.0.0.1:${PORT}`; + +let fehler = 0, geprueft = 0; +const melde = (t) => console.log(t); +const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; + +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes } = await import("node:crypto"); +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("; "); + +const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); +const browser = await pw.chromium.launch(); +/* AUF HANDYGRÖSSE. Diese Seite wird fast ausschliesslich dort + geoeffnet -- eine Messung auf 1400 px misst etwas, das niemand + sieht. */ +const ctx = await browser.newContext({ + viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true, +}); +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))); + +const VIDEO = `http://127.0.0.1:${TIKTOK}/@dogfather0804/video/4711`; +const oeffne = async (frage) => { + await seite.goto(`${BASIS}/workspace/teilen.html?${frage}`, { waitUntil: "networkidle" }); + await seite.waitForTimeout(900); +}; +const gezeigterLink = () => seite.$eval("#url-text", (n) => n.textContent.trim()).catch(() => ""); +const zahlIm = async (bereich) => { + const a = await fetch(`${BASIS}/workspace/api/bereich/${bereich}`, { headers: { cookie: keks } }); + const x = await a.json().catch(() => null); + return (x?.eintraege || []).length; +}; + +/* ======================================================================= + 1. DAS SAUBERE FELD + ======================================================================= */ +melde(""); +melde("=== 1. Wenn der Link in „url“ steht ==="); +{ + await oeffne(`url=${encodeURIComponent(VIDEO)}`); + ok(await gezeigterLink() === VIDEO, "der Link wird erkannt und angezeigt"); + ok(!(await seite.$("#haendisch:not([hidden])")), + "und das Feld zum Einfügen bleibt weg – es gibt ja nichts einzufügen"); + const bretter = await seite.$$eval(".teilen__brett", (ns) => ns.map((n) => ({ + name: n.querySelector(".teilen__brett-name")?.textContent, + gewaehlt: n.getAttribute("aria-pressed"), + }))); + ok(bretter.length === 3, `drei Ziele zur Auswahl (${bretter.length})`); + ok(bretter[0]?.gewaehlt === "true" && /Highlights/.test(bretter[0]?.name || ""), + "Highlights ist vorausgewählt – dort gehören Videos hin"); + await seite.screenshot({ path: "server/bild-teilen.png", fullPage: true }); +} + +/* ======================================================================= + 2. DER ECHTE FALL: ein Satz mit Link darin ← der eigentliche Punkt + ======================================================================= */ +melde(""); +melde("=== 2. Wenn TikTok einen Satz teilt ==="); +{ + /* GENAU SO kommt es von einem Android-Handy an: alles in `text`, + `url` leer, und die Adresse steckt mitten im Satz. */ + const satz = `Sieh dir das an! ${VIDEO} #fyp`; + await oeffne(`text=${encodeURIComponent(satz)}`); + ok(await gezeigterLink() === VIDEO, + `die Adresse wird aus dem Satz herausgeholt (${await gezeigterLink()})`); + + /* UND MIT EINEM PUNKT DAHINTER. „…/4711." ist keine Adresse mit + Punkt, sondern ein Satzende. */ + await oeffne(`text=${encodeURIComponent(`Schau mal: ${VIDEO}.`)}`); + ok(await gezeigterLink() === VIDEO, + `ein Satzzeichen am Ende gehört nicht zur Adresse (${await gezeigterLink()})`); + + /* Und im Titel, falls eine App es dort ablegt. */ + await oeffne(`title=${encodeURIComponent(VIDEO)}&text=`); + ok(await gezeigterLink() === VIDEO, "auch aus dem Titel"); +} + +/* ======================================================================= + 3. GAR KEIN LINK + ======================================================================= */ +melde(""); +melde("=== 3. Wenn nichts dabei ist ==="); +{ + await oeffne("text=nur%20ein%20Text%20ohne%20Adresse"); + ok(!!(await seite.$("#haendisch:not([hidden])")), + "dann erscheint ein Feld zum Einfügen – kein Fehler, ein Angebot"); + ok(!(await seite.$("#fund:not([hidden])")), + "und es wird keine Adresse behauptet, die es nicht gibt"); +} + +/* ======================================================================= + 4. UND ES TRÄGT WIRKLICH EIN + ======================================================================= */ +melde(""); +melde("=== 4. Der Tipp auf „Übernehmen“ ==="); +{ + const vorher = await zahlIm("highlight"); + const satz = `Neues Video! ${VIDEO}`; + await oeffne(`text=${encodeURIComponent(satz)}`); + + /* NICHT VON SELBST. Eine Seite, die beim Öffnen einträgt, kann nicht + wissen, ob der Griff in die Tasche gemeint war. */ + ok(await zahlIm("highlight") === vorher, + `allein vom Öffnen passiert nichts (${vorher})`); + + await seite.click("#uebernehmen"); + await seite.waitForTimeout(2500); + const nachher = await zahlIm("highlight"); + ok(nachher === vorher + 1, `nach dem Tipp liegt es da (${vorher} → ${nachher})`); + + const fertig = await seite.$eval("#fertig-satz", (n) => n.textContent.trim()).catch(() => ""); + ok(/Angekommen/.test(fertig), `und die Seite sagt es (\u201e${fertig.slice(0, 55)}\u201c)`); + ok(/Casper erwischt den Postboten/.test(fertig), + "mitsamt dem Titel, den TikTok geliefert hat"); + + /* DER WEG DORTHIN steht als Knopf da -- wer teilt, will meistens + gleich sehen, wie es aussieht. */ + const hin = await seite.$eval("#hin", (n) => n.getAttribute("href")).catch(() => ""); + ok(/b=highlight/.test(hin), `der Knopf führt zum richtigen Brett (${hin})`); + + /* ---- Und derselbe Link ein zweites Mal ---------------------------- */ + await oeffne(`text=${encodeURIComponent(satz)}`); + await seite.click("#uebernehmen"); + await seite.waitForTimeout(2500); + ok(await zahlIm("highlight") === nachher, + `zweimal geteilt ergibt kein zweites Video (${await zahlIm("highlight")})`); + const zweiterSatz = await seite.$eval("#fertig-satz", (n) => n.textContent.trim()).catch(() => ""); + ok(/lag schon/.test(zweiterSatz), `und es steht auch da (\u201e${zweiterSatz.slice(0, 45)}\u201c)`); +} + +/* ======================================================================= + 5. AUF EIN ANDERES BRETT + ======================================================================= */ +melde(""); +melde("=== 5. Wohin ist eine Frage ==="); +{ + const vorher = await zahlIm("treff"); + await oeffne(`url=${encodeURIComponent(`http://127.0.0.1:${TIKTOK}/@hasidog00804/video/99`)}`); + await seite.click('.teilen__brett[data-brett="treff"]'); + await seite.waitForTimeout(300); + const gewaehlt = await seite.$$eval(".teilen__brett", + (ns) => ns.filter((n) => n.getAttribute("aria-pressed") === "true").map((n) => n.dataset.brett)); + ok(gewaehlt.length === 1 && gewaehlt[0] === "treff", + `genau ein Ziel ist gewählt (${gewaehlt.join(", ")})`); + await seite.click("#uebernehmen"); + await seite.waitForTimeout(2500); + ok(await zahlIm("treff") === vorher + 1, + `und es landet dort und nicht bei den Highlights (${vorher} → ${await zahlIm("treff")})`); +} + +/* ======================================================================= + 6. DAS TEILEN-ZIEL STEHT IM MANIFEST + ======================================================================= */ +melde(""); +melde("=== 6. Damit das Handy es überhaupt anbietet ==="); +{ + /* OHNE DIESEN EINTRAG erscheint der Workspace gar nicht erst im + Teilen-Menü -- die ganze Seite wäre dann unerreichbar, und die + fünf Abschnitte darüber hätten es nicht gemerkt. */ + for (const datei of ["crew.webmanifest", "app.webmanifest"]) { + const m = JSON.parse(readFileSync(new URL(`../workspace/${datei}`, import.meta.url), "utf8")); + const z = m.share_target; + ok(!!z && z.action === "/workspace/teilen.html", + `${datei}: Teilen-Ziel zeigt auf die Seite (${z?.action})`); + ok(z?.method === "GET", + `${datei}: per GET – POST bräuchte einen Service Worker dazwischen (${z?.method})`); + /* ALLE DREI FELDER. Fehlte `text`, wäre genau der Fall aus + Abschnitt 2 nicht abgedeckt -- und das ist der häufigste. */ + ok(z?.params?.text === "text" && z?.params?.url === "url" && z?.params?.title === "title", + `${datei}: title, text UND url werden entgegengenommen`); + } + + /* UND DIE SEITE IST IN DER RECHTETAFEL. Ohne Eintrag ist sie seit + dem 11.09. für jede Rolle zu -- ein fehlender Eintrag heißt nein. */ + const { SEITEN } = await import("./rechte.js"); + const rollen = SEITEN["/workspace/teilen.html"] || []; + ok(rollen.length > 0, `die Seite steht in der Rechtetafel (${rollen.join(", ")})`); + ok(!rollen.includes("gast"), + "und die Community kommt nicht hin – sie darf auch keine Videos eintragen"); +} + +melde(""); +ok(konsolenfehler.length === 0, + konsolenfehler.length ? konsolenfehler.join(" | ") : "keine Fehler in der Browserkonsole"); + +melde(""); +melde(`${geprueft} Prüfungen, ${fehler} Fehler`); +melde(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/server/rechte.js b/server/rechte.js index acab947a..45fc53e7 100644 --- a/server/rechte.js +++ b/server/rechte.js @@ -297,6 +297,16 @@ export const SEITEN = { steht. Wer beobachtet wird, darf auch ueber sich selbst sprechen. Die Agentur hat diese Seite nicht -- sie hat auch keine Entwicklungskarte. */ + /* DAS ZIEL DES TEILEN-MENÜS (17.09.2026). + + DIESELBEN ROLLEN WIE DIE VIDEO-ROUTE, nicht mehr: Wer ein Video + nicht eintragen darf, soll auch nicht auf einer Seite landen, die + ihm einen Knopf dafür zeigt. Ein Knopf, der mit 403 antwortet, ist + schlimmer als kein Knopf. + + Die Seite selbst legt nichts an -- das tut die Route, und die + prüft ihrerseits. Das hier ist der Riegel davor, nicht statt. */ + "/workspace/teilen.html": ["admin", "hand", "modi"], "/workspace/befinden.html": ["admin", "hand", "modi"], "/workspace/entwicklung.html": ["admin", "hand", "modi"], diff --git a/server/workspace-aufbewahrung.js b/server/workspace-aufbewahrung.js index ff5b05c6..7145261a 100644 --- a/server/workspace-aufbewahrung.js +++ b/server/workspace-aufbewahrung.js @@ -246,6 +246,27 @@ export function aufbewahrungStarten() { DIE TAFEL ZUM ANSEHEN ===================================================================== */ +/** Kommt die Anfrage von unserer eigenen Seite? + * + * DERSELBE WORTLAUT WIE IN DEN ANDEREN MODULEN und bewusst keine + * eigene Erfindung: Eine zweite, leicht andere Fassung waere eine + * zweite Wahrheit, und beim naechsten Nachschaerfen wuerde genau die + * eine vergessen. + * + * KEIN `origin` HEISST DURCHLASSEN, und das ist Absicht: Browser + * schicken das Feld bei schreibenden Anfragen immer mit. Fehlt es, + * kommt die Anfrage aus einem Werkzeug (curl, eine Pruefung) -- und + * dort ist die Sitzung ohnehin nur da, wenn jemand sie absichtlich + * mitgibt. Der Angriff, um den es geht, braucht einen Browser. */ +function gleicheHerkunft(req, res, next) { + const herkunft = req.get("origin"); + if (!herkunft) return next(); + let erlaubt; + try { erlaubt = new URL(herkunft).host === req.get("host"); } catch { erlaubt = false; } + if (!erlaubt) return res.status(403).json({ fehler: "fremde_herkunft" }); + next(); +} + function nurDogFather(req, res, next) { const person = sitzungLesen(req); if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" }); @@ -288,7 +309,12 @@ aufbewahrungRouter.get("/workspace/api/aufbewahrung", nurDogFather, (req, res) = * * Damit es kein schwarzer Kasten ist: Wer sehen will, ob es wirkt, * drueckt drauf und sieht die Zahlen fallen. */ -aufbewahrungRouter.post("/workspace/api/aufbewahrung/jetzt", nurDogFather, (req, res) => { +/* DER RIEGEL STEHT VOR DER RECHTEPRUEFUNG. + Reihenfolge wie im ganzen Haus: erst "kommt das von uns?", dann + "darf der das?". Andersherum waere es nicht falsch, aber die + Abweichung faellt beim Lesen auf und kostet jedes Mal einen + Moment. */ +aufbewahrungRouter.post("/workspace/api/aufbewahrung/jetzt", gleicheHerkunft, nurDogFather, (req, res) => { try { const ergebnis = aufbewahrungLauf(); protokolliere("aufbewahrung_von_hand", { diff --git a/workspace/app.webmanifest b/workspace/app.webmanifest index 1f9fd164..3fd65edd 100644 --- a/workspace/app.webmanifest +++ b/workspace/app.webmanifest @@ -11,27 +11,36 @@ "dir": "ltr", "background_color": "#0a121e", "theme_color": "#06090f", + "share_target": { + "action": "/workspace/teilen.html", + "method": "GET", + "params": { + "title": "title", + "text": "text", + "url": "url" + } + }, "icons": [ { - "src": "/assets/img/app-symbole/workspace-192.png?v=202609171407", + "src": "/assets/img/app-symbole/workspace-192.png?v=202609171416", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-512.png?v=202609171407", + "src": "/assets/img/app-symbole/workspace-512.png?v=202609171416", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609171407", + "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609171416", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { - "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609171407", + "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609171416", "sizes": "512x512", "type": "image/png", "purpose": "maskable" diff --git a/workspace/assets/css/bereich.css b/workspace/assets/css/bereich.css index 71dd043d..5d8029f9 100644 --- a/workspace/assets/css/bereich.css +++ b/workspace/assets/css/bereich.css @@ -1217,3 +1217,113 @@ color: color-mix(in srgb, var(--v-ton) 82%, #ffffff); background: color-mix(in srgb, var(--v-ton) 20%, transparent); } + +/* ===================================================================== + DIE TEILEN-SEITE (17.09.2026) — Schritt 4 aus dem Video-Plan + + Ziel des Teilen-Menüs auf dem Handy. Sie wird fast ausschließlich + mit einer Hand bedient, im Stehen, direkt nach dem Posten — deshalb + große Flächen, viel Abstand und nur eine Entscheidung je Bildschirm. + ===================================================================== */ +.teilen { + max-width: 44rem; + margin: 0 auto; + display: flex; flex-direction: column; gap: 22px; +} + +.teilen__label { + margin: 0 0 8px; + font-size: .78rem; font-weight: 650; letter-spacing: .03em; + text-transform: uppercase; + color: var(--text-still); +} + +/* WAS ANGEKOMMEN IST. Steht ganz oben und ist umbrechbar: Eine + TikTok-Adresse ist länger als ein Handybildschirm, und eine Adresse, + die rechts abgeschnitten ist, beantwortet die Frage „hab ich den + richtigen Link erwischt?" nicht. */ +.teilen__fund { + padding: 16px 18px; + border: 1px solid var(--rand); + background: color-mix(in srgb, var(--tinte) 94%, #ffffff 6%); +} +.teilen__url { + margin: 0; + font-size: .86rem; line-height: 1.5; + color: var(--text-leise); + overflow-wrap: anywhere; +} + +/* WOHIN. Untereinander und nicht nebeneinander: Drei Knöpfe in einer + Reihe sind auf 360 px drei schmale Streifen, und der Satz darunter + passt in keinen davon. */ +.teilen__bretter { + display: flex; flex-direction: column; gap: 10px; +} +.teilen__brett { + display: flex; flex-direction: column; gap: 3px; + width: 100%; + padding: 14px 16px; text-align: start; + font: inherit; cursor: pointer; + border: 1px solid var(--rand); + border-radius: 10px; + background: color-mix(in srgb, var(--tinte) 96%, #ffffff 4%); + color: var(--text-leise); + transition: border-color .16s ease, background .16s ease; +} +.teilen__brett:hover { border-color: var(--rand-hell); } +.teilen__brett:focus-visible { outline: 2px solid var(--akzent); outline-offset: 2px; } +/* GEWÄHLT — und das muss man SEHEN. + + Im Bildschirmfoto auf 390 px war der Unterschied ein Hauch heller, + mehr nicht. Bei einer echten Wahl (wohin geht das Video?) ist das zu + wenig — und wer Farben schlecht unterscheidet, sieht gar nichts. + + Deshalb drei Dinge zusammen statt eines allein: ein farbiger Streifen + links, ein deutlicherer Grund — und ein HAKEN. Der Haken ist der + Teil, der ohne jede Farbwahrnehmung funktioniert. */ +.teilen__brett[aria-pressed="true"] { + border-color: color-mix(in srgb, var(--akzent) 45%, var(--rand-hell)); + background: rgba(255, 255, 255, .08); + color: var(--text); + box-shadow: inset 3px 0 0 0 var(--akzent); +} +.teilen__brett[aria-pressed="true"] .teilen__brett-name::after { + content: " ✓"; + color: var(--akzent); + font-weight: 700; +} +.teilen__brett-name { font-size: .95rem; font-weight: 700; } +.teilen__brett-satz { font-size: .8rem; color: var(--text-still); } + +.teilen__knoepfe { + display: flex; flex-wrap: wrap; align-items: center; gap: 12px; +} +.teilen__knoepfe .knopf { min-width: 11rem; } + +/* DAS FELD ZUM EINFÜGEN. Es erscheint nur, wenn gar kein Link dabei + war -- als Angebot, nicht als Strafe. */ +.teilen__haendisch input { + width: 100%; box-sizing: border-box; + padding: 13px 14px; + font: inherit; font-size: 1rem; /* 16 px: darunter zoomt iOS beim Tippen */ + color: var(--text); + background: color-mix(in srgb, var(--tinte) 92%, #ffffff 8%); + border: 1px solid var(--rand-hell); + border-radius: 10px; +} +.teilen__haendisch input:focus-visible { outline: 2px solid var(--akzent); outline-offset: 2px; } + +.teilen__fertig { + padding: 18px; + border: 1px solid var(--rand-hell); + background: color-mix(in srgb, var(--tinte) 92%, #ffffff 8%); + display: flex; flex-direction: column; gap: 16px; +} +.teilen__fertig-satz { + margin: 0; font-size: .95rem; line-height: 1.6; color: var(--text); +} + +@media (prefers-reduced-motion: reduce) { + .teilen__brett { transition: none; } +} diff --git a/workspace/assets/js/teilen.js b/workspace/assets/js/teilen.js new file mode 100644 index 00000000..a30f15a0 --- /dev/null +++ b/workspace/assets/js/teilen.js @@ -0,0 +1,180 @@ +/* ===================================================================== + AUS „TEILEN" WIRD EIN TIPP (17.09.2026) — Schritt 4 des Video-Plans + + Filipe: „damit meine videos auch da auf der app rein kommen … sobald + ich ein video poste erscheint es sofort in der app." + + Diese Seite ist das Ziel des Teilen-Menüs auf dem Handy. Sie liest, + was angekommen ist, zeigt es und trägt es auf Tipp ein. + + --------------------------------------------------------------------- + DER STOLPERSTEIN, DEN JEDE ANLEITUNG VERSCHWEIGT + + Das Teilen-Ziel im Manifest kennt drei Felder: `title`, `text`, + `url`. Man erwartet, dass der Link in `url` steht. + + TUT ER MEISTENS NICHT. Android-Apps – TikTok eingeschlossen – teilen + in aller Regel als reinen Text: `text` enthält dann einen Satz, in + dem die Adresse irgendwo drinsteht („Sieh dir das an! + https://vm.tiktok.com/xyz/"), und `url` ist leer. Wer nur `url` + liest, bekommt eine leere Seite und weiß nicht, warum. + + Deshalb wird in ALLEN drei Feldern gesucht, und zwar nach einem + Muster, nicht nach einem Feldnamen. + + --------------------------------------------------------------------- + UND SIE TRÄGT NICHT VON SELBST EIN + + Sie kann nicht wissen, ob der Griff in die Tasche gemeint war. Ein + Video, das ungefragt in den Highlights landet, muss jemand wieder + löschen — und das ist mehr Arbeit als ein Tipp auf „Übernehmen". + ===================================================================== */ +(() => { + 'use strict'; + const $ = (id) => document.getElementById(id); + const el = (art, klasse, text) => { + const n = document.createElement(art); + if (klasse) n.className = klasse; + if (text !== undefined) n.textContent = text; + return n; + }; + + const melde = (t) => { const f = $('fehler'); if (f) f.textContent = t || ''; }; + + /* WOHIN ES GEHEN KANN. Highlights zuerst und vorausgewählt – dort + gehören Videos hin. Die beiden anderen sind die Fälle, in denen + ein Clip etwas anderes ist: eine Antwort auf einen Wunsch oder + eine Ansage. */ + const BRETTER = [ + { wert: 'highlight', name: 'Highlights', satz: 'Die Videos der Community.' }, + { wert: 'treff', name: 'Der Treff', satz: 'Wenn es eher ein Gesprächsanlass ist.' }, + { wert: 'anschlag', name: 'Anschlagbrett', satz: 'Wenn es eine Ansage ist.' }, + ]; + let brett = 'highlight'; + + /* --------------------------------------------------------------------- + DEN LINK FINDEN + ------------------------------------------------------------------- */ + + /** Die erste Adresse in einem Text. Ohne abschliessende Satzzeichen — + * „… https://vm.tiktok.com/xyz/." endet nicht auf einen Punkt. */ + function adresseAus(text) { + const treffer = String(text || '').match(/https?:\/\/[^\s<>"']+/i); + if (!treffer) return ''; + return treffer[0].replace(/[.,;:!?)\]]+$/, ''); + } + + /** Aus dem, was das Teilen-Menü geschickt hat. */ + function geteilterLink() { + const p = new URLSearchParams(location.search); + /* IN DIESER REIHENFOLGE: `url` ist das saubere Feld, wenn es + gefüllt ist. `text` ist der Normalfall bei Android. `title` + enthält bei manchen Apps den Link, wenn sonst nichts da ist. */ + for (const feld of ['url', 'text', 'title']) { + const a = adresseAus(p.get(feld)); + if (a) return a; + } + return ''; + } + + /* --------------------------------------------------------------------- + DIE SEITE + ------------------------------------------------------------------- */ + function brettwahlBauen() { + const ziel = $('bretter'); + ziel.textContent = ''; + for (const b of BRETTER) { + const k = el('button', 'teilen__brett'); + k.type = 'button'; + k.dataset.brett = b.wert; + k.setAttribute('aria-pressed', String(brett === b.wert)); + k.append(el('span', 'teilen__brett-name', b.name)); + k.append(el('span', 'teilen__brett-satz', b.satz)); + k.addEventListener('click', () => { + brett = b.wert; + for (const x of ziel.querySelectorAll('.teilen__brett')) { + x.setAttribute('aria-pressed', String(x.dataset.brett === brett)); + } + }); + ziel.append(k); + } + } + + async function uebernehmen(url) { + const knopf = $('uebernehmen'); + knopf.disabled = true; + const alterText = knopf.textContent; + knopf.textContent = 'wird geholt …'; + melde(''); + try { + const a = await fetch(`/workspace/api/bereich/${brett}/video`, { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url }), + }); + const daten = await a.json().catch(() => null); + if (!a.ok) { + melde(daten?.fehler || 'Das hat nicht geklappt.'); + knopf.disabled = false; + knopf.textContent = alterText; + return; + } + /* FERTIG. Der Weg dorthin steht als Knopf da – wer teilt, will + meistens gleich sehen, wie es aussieht. */ + $('fund').hidden = true; + $('wahl').hidden = true; + $('knoepfe').hidden = true; + $('haendisch').hidden = true; + const name = BRETTER.find((b) => b.wert === brett)?.name || brett; + $('fertig-satz').textContent = daten?.schon + ? `Das Video lag schon bei „${name}“ – es wurde kein zweites angelegt.` + : `Angekommen bei „${name}“.` + + (daten?.titel ? ` Titel: „${daten.titel}“` : ''); + $('hin').href = `bereich.html?b=${encodeURIComponent(brett)}`; + $('fertig').hidden = false; + } catch { + melde('Keine Verbindung. Versuch es gleich noch einmal.'); + knopf.disabled = false; + knopf.textContent = alterText; + } + } + + function los() { + const teilen = $('teilen'); + const link = geteilterLink(); + $('ladezeile').hidden = true; + teilen.setAttribute('aria-busy', 'false'); + brettwahlBauen(); + $('wahl').hidden = false; + $('knoepfe').hidden = false; + + if (link) { + $('url-text').textContent = link; + $('fund').hidden = false; + } else { + /* KEIN LINK DABEI. Das ist kein Fehler – manche Apps teilen nur + einen Text ohne Adresse, und manchmal kommt jemand einfach so + auf diese Seite. Ein Feld zum Einfügen ist die freundlichere + Antwort als eine Meldung. */ + $('haendisch').hidden = false; + $('eingabe').focus(); + } + + $('uebernehmen').addEventListener('click', () => { + const url = link || $('eingabe')?.value?.trim(); + if (!url) { melde('Da fehlt noch der Link zum Video.'); $('eingabe')?.focus(); return; } + uebernehmen(url); + }); + + /* Enter im Feld tut dasselbe wie der Knopf. Wer auf dem Handy + tippt, drückt die grüne Taste und nicht daneben. */ + $('eingabe')?.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); $('uebernehmen').click(); } + }); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', los); + } else { los(); } +})(); diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html index c3b49c42..5d674b88 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 @@ - - - - - + + + + + diff --git a/workspace/automation.html b/workspace/automation.html index 73538f75..1ac2c788 100644 --- a/workspace/automation.html +++ b/workspace/automation.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -168,10 +168,10 @@ - - - - - + + + + + diff --git a/workspace/befinden.html b/workspace/befinden.html index c940b53e..b5684c9e 100644 --- a/workspace/befinden.html +++ b/workspace/befinden.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -105,9 +105,9 @@ - - - - + + + + diff --git a/workspace/bereich.html b/workspace/bereich.html index beabbec4..dbfd90d6 100644 --- a/workspace/bereich.html +++ b/workspace/bereich.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -224,14 +224,14 @@ - - - - - - - - - + + + + + + + + + diff --git a/workspace/calls.html b/workspace/calls.html index 548e4e32..684f1f74 100644 --- a/workspace/calls.html +++ b/workspace/calls.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -89,11 +89,11 @@ - - - - - - + + + + + + diff --git a/workspace/chat.html b/workspace/chat.html index 64fa6f0a..5c3dcc87 100644 --- a/workspace/chat.html +++ b/workspace/chat.html @@ -12,26 +12,26 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - + + + + - - - + + + - + @@ -341,10 +341,10 @@ - - - - - + + + + + diff --git a/workspace/content.html b/workspace/content.html index 4c8cced5..5bb262d8 100644 --- a/workspace/content.html +++ b/workspace/content.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -179,12 +179,12 @@ - - - - - - - + + + + + + + diff --git a/workspace/crew-index.html b/workspace/crew-index.html index a2e6814d..d09e1b27 100644 --- a/workspace/crew-index.html +++ b/workspace/crew-index.html @@ -36,15 +36,15 @@ - - - + + + - + @@ -339,6 +339,6 @@ - + diff --git a/workspace/crew.webmanifest b/workspace/crew.webmanifest index 470a577a..b516d14e 100644 --- a/workspace/crew.webmanifest +++ b/workspace/crew.webmanifest @@ -11,27 +11,36 @@ "dir": "ltr", "background_color": "#0a121e", "theme_color": "#06090f", + "share_target": { + "action": "/workspace/teilen.html", + "method": "GET", + "params": { + "title": "title", + "text": "text", + "url": "url" + } + }, "icons": [ { - "src": "/assets/img/app-symbole/crew-192.png?v=202609171407", + "src": "/assets/img/app-symbole/crew-192.png?v=202609171416", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/crew-512.png?v=202609171407", + "src": "/assets/img/app-symbole/crew-512.png?v=202609171416", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/crew-192-maskable.png?v=202609171407", + "src": "/assets/img/app-symbole/crew-192-maskable.png?v=202609171416", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { - "src": "/assets/img/app-symbole/crew-512-maskable.png?v=202609171407", + "src": "/assets/img/app-symbole/crew-512-maskable.png?v=202609171416", "sizes": "512x512", "type": "image/png", "purpose": "maskable" diff --git a/workspace/dateien.html b/workspace/dateien.html index 4185b59f..d2244b69 100644 --- a/workspace/dateien.html +++ b/workspace/dateien.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -108,10 +108,10 @@ - - - - - + + + + + diff --git a/workspace/entwicklung.html b/workspace/entwicklung.html index 4a31b172..ea607ab7 100644 --- a/workspace/entwicklung.html +++ b/workspace/entwicklung.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -102,9 +102,9 @@ - - - - + + + + diff --git a/workspace/index.html b/workspace/index.html index af2fb21f..648f3e0a 100644 --- a/workspace/index.html +++ b/workspace/index.html @@ -30,9 +30,9 @@ - - - + + + @@ -401,6 +401,6 @@ - + diff --git a/workspace/kalender.html b/workspace/kalender.html index dac957b8..8c97f13b 100644 --- a/workspace/kalender.html +++ b/workspace/kalender.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -377,11 +377,11 @@ - - - - - - + + + + + + diff --git a/workspace/leistung.html b/workspace/leistung.html index db233f27..90d1db71 100644 --- a/workspace/leistung.html +++ b/workspace/leistung.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -444,10 +444,10 @@ - - - - - + + + + + diff --git a/workspace/personen.html b/workspace/personen.html index 05e7a33b..fc476afd 100644 --- a/workspace/personen.html +++ b/workspace/personen.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -423,10 +423,10 @@ - - - - - + + + + + diff --git a/workspace/profil.html b/workspace/profil.html index fbdd8846..957d7c2c 100644 --- a/workspace/profil.html +++ b/workspace/profil.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -214,11 +214,11 @@ - - - - - - + + + + + + diff --git a/workspace/rechte.html b/workspace/rechte.html index ccaeeb0b..dde10f30 100644 --- a/workspace/rechte.html +++ b/workspace/rechte.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -134,9 +134,9 @@ Beim ersten Anlauf fehlte die Zeile auf genau diesen beiden neuen Seiten. Gefunden hat das nicht das Auge, sondern pruef-css-klassen, die seit dem 07.09. jede Seite danach absucht. --> - - - - + + + + diff --git a/workspace/report.html b/workspace/report.html index ab4d4ed8..19850da4 100644 --- a/workspace/report.html +++ b/workspace/report.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -106,11 +106,11 @@ - - - - - - + + + + + + diff --git a/workspace/scouting.html b/workspace/scouting.html index 4e099b54..9caa0c65 100644 --- a/workspace/scouting.html +++ b/workspace/scouting.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -173,10 +173,10 @@ - - - - - + + + + + diff --git a/workspace/start.html b/workspace/start.html index 2e45186b..2342cb9e 100644 --- a/workspace/start.html +++ b/workspace/start.html @@ -12,21 +12,21 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - + + + + - - + + - + @@ -674,10 +674,10 @@

- - - - - + + + + + diff --git a/workspace/startcheck.html b/workspace/startcheck.html index a4003c37..88683143 100644 --- a/workspace/startcheck.html +++ b/workspace/startcheck.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -85,11 +85,11 @@ - - - - - - + + + + + + diff --git a/workspace/steckbrief.html b/workspace/steckbrief.html index eeb96908..381875f5 100644 --- a/workspace/steckbrief.html +++ b/workspace/steckbrief.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -146,11 +146,11 @@ - - - - - - + + + + + + diff --git a/workspace/talente.html b/workspace/talente.html index 44750cb6..5ca9024d 100644 --- a/workspace/talente.html +++ b/workspace/talente.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -94,9 +94,9 @@ - - - - + + + + diff --git a/workspace/team.html b/workspace/team.html index 9c2602f6..c3b080f7 100644 --- a/workspace/team.html +++ b/workspace/team.html @@ -7,20 +7,20 @@ - - - - - - - + + + + + + + - + @@ -110,10 +110,10 @@ - - - - - + + + + + diff --git a/workspace/teamlage.html b/workspace/teamlage.html index f559dc48..ddae8aa0 100644 --- a/workspace/teamlage.html +++ b/workspace/teamlage.html @@ -7,20 +7,20 @@ - - - - - - - + + + + + + + - + - - - - + + + + diff --git a/workspace/teilen.html b/workspace/teilen.html new file mode 100644 index 00000000..bb9fbfe5 --- /dev/null +++ b/workspace/teilen.html @@ -0,0 +1,120 @@ + + + + + +Video übernehmen · Spicy & Dogi + + + + + + + + + + + + + + + + +
+ +

Team Dogi · Video übernehmen

+
+ … + +
+
+ +
+ + + +
+
+

Geteilt

+

Video übernehmen

+

+ Titel und Coverbild werden von TikTok geholt – du musst nichts abtippen. +

+
+
+ + + +
+

einen Moment …

+ + + + + + + + + + + + + + +
+ +
+ + + + + + + diff --git a/workspace/treff-moderation.html b/workspace/treff-moderation.html index 547bfeff..9d2e2551 100644 --- a/workspace/treff-moderation.html +++ b/workspace/treff-moderation.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -126,9 +126,9 @@ - - - - + + + + diff --git a/workspace/treff-regeln.html b/workspace/treff-regeln.html index b2c96fca..1f931d0f 100644 --- a/workspace/treff-regeln.html +++ b/workspace/treff-regeln.html @@ -7,18 +7,18 @@ - - - - - - - + + + + + + + - + @@ -211,10 +211,10 @@ Beim ersten Anlauf fehlte die Zeile auf genau diesen beiden neuen Seiten. Gefunden hat das nicht das Auge, sondern pruef-css-klassen, die seit dem 07.09. jede Seite danach absucht. --> - - - - - + + + + + diff --git a/workspace/uebersicht.html b/workspace/uebersicht.html index 8df09d73..6053742c 100644 --- a/workspace/uebersicht.html +++ b/workspace/uebersicht.html @@ -12,22 +12,22 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - + + + + - - - + + + - + @@ -149,11 +149,11 @@ - + - - - - + + + + diff --git a/workspace/wissen.html b/workspace/wissen.html index 6b5afd04..3ff95184 100644 --- a/workspace/wissen.html +++ b/workspace/wissen.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -190,10 +190,10 @@ - - - - - + + + + +