/* ===================================================================== UNSERE SEITEN (19.09.2026) Zwei Dinge macht diese Datei, und beide gehen auf denselben Satz von Filipe zurück: „egal wo man drauf drückt ... sobald man drauf drückt weiter geleitet". 1. Die GANZE Karte ist ein Weg nach draußen, nicht nur der Knopf. 2. Der Rabattcode wandert mit einem Tippen in die Zwischenablage. WARUM DIE KARTE KEIN IST, DAS ALLES UMSCHLIESST: Darin steckt ein Knopf (der Code) und ein weiterer Link (der Knopf „Shop öffnen"). Ein Link in einem Link ist kein gültiges HTML — Browser reparieren das still, indem sie den äußeren beenden, und dann ist die halbe Karte tot. Deshalb: ein Klick-Zuhörer auf der Karte, der Kinder mit eigener Bedeutung in Ruhe lässt. UND DER LINK BLEIBT TROTZDEM EIN LINK. Wer mit der Tastatur unterwegs ist, bekommt weiterhin den Knopf „Seite öffnen" als Haltepunkt. Die Karte selbst wird NICHT fokussierbar gemacht: Das gäbe einen zweiten Tab-Halt mit demselben Ziel — für jemanden, der sich durchtabbt, sieht das aus wie ein Fehler. ===================================================================== */ (() => { 'use strict'; /* ---- 1. DIE GANZE KARTE IST DER WEG ------------------------------ */ /** Hat der Klick schon eine eigene Bedeutung getroffen? * * Ein Klick auf „Shop öffnen" soll den Shop öffnen (und nicht * zusätzlich die Karte auslösen), ein Klick auf den Code soll * kopieren. Alles andere — Bild, Überschrift, Text, leere Fläche — * ist Karte. */ function hatEigeneBedeutung(ziel) { return !!ziel.closest('a, button, input, textarea, select, label, [role="button"]'); } /** Markiert gerade jemand Text? Dann will er lesen, nicht weg. * * Ohne diese Abfrage reißt es einen beim Loslassen der Maus aus der * Seite — der häufigste Ärger an klickbaren Karten überhaupt. */ function markiertGerade() { const s = window.getSelection?.(); return !!s && !s.isCollapsed && String(s).trim().length > 0; } for (const karte of document.querySelectorAll('.aussen[data-weg]')) { const weg = karte.dataset.weg; if (!weg) continue; karte.addEventListener('click', (e) => { if (e.button !== 0) return; /* nur die linke Taste */ if (hatEigeneBedeutung(e.target)) return; if (markiertGerade()) return; /* `noopener` ist Pflicht: Ohne das könnte die geöffnete Seite über `window.opener` auf unsere zugreifen. */ window.open(weg, '_blank', 'noopener,noreferrer'); }); /* Mittlere Maustaste = „in neuem Tab" — dieselbe Erwartung wie bei einem echten Link, also dieselbe Antwort. */ karte.addEventListener('auxclick', (e) => { if (e.button !== 1) return; if (hatEigeneBedeutung(e.target)) return; e.preventDefault(); window.open(weg, '_blank', 'noopener,noreferrer'); }); } /* ---- 2. DER RABATTCODE ------------------------------------------- */ const knopf = document.getElementById('code-knopf'); const hinweis = document.getElementById('code-hinweis'); if (knopf && hinweis) { const urtext = hinweis.textContent; let zurueck = 0; /** Kopieren mit Auffanglösung. * * `navigator.clipboard` gibt es nur auf gesicherten Verbindungen, * und auf einigen älteren Android-Browsern gar nicht. Scheitert es, * wird der Code wenigstens MARKIERT — dann reicht ein langes * Antippen. Ein Knopf, der nichts tut und nichts sagt, ist * schlimmer als gar keiner. */ async function kopieren(text) { try { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text); return 'kopiert'; } } catch { /* weiter zur Auffanglösung */ } try { const feld = document.createElement('textarea'); feld.value = text; feld.setAttribute('readonly', ''); feld.style.position = 'fixed'; feld.style.opacity = '0'; document.body.append(feld); feld.select(); const ging = document.execCommand?.('copy'); feld.remove(); if (ging) return 'kopiert'; } catch { /* auch das ging nicht */ } /* Letzter Ausweg: den Code im Knopf markieren. */ try { const wort = knopf.querySelector('.gutschein__wort'); if (wort) { const bereich = document.createRange(); bereich.selectNodeContents(wort); const auswahl = window.getSelection(); auswahl.removeAllRanges(); auswahl.addRange(bereich); return 'markiert'; } } catch { /* dann eben gar nicht */ } return 'nein'; } knopf.addEventListener('click', async () => { const code = knopf.dataset.code || ''; if (!code) return; const wie = await kopieren(code); hinweis.textContent = wie === 'kopiert' ? 'kopiert ✓' : wie === 'markiert' ? 'markiert – jetzt kopieren' : 'bitte von Hand abschreiben'; knopf.dataset.kopiert = wie === 'kopiert' ? 'ja' : 'nein'; /* Nach ein paar Sekunden steht wieder da, was zu tun ist — sonst sieht die Karte beim nächsten Besuch so aus, als sei gerade eben etwas passiert. */ clearTimeout(zurueck); zurueck = setTimeout(() => { hinweis.textContent = urtext; delete knopf.dataset.kopiert; }, 4000); }); } /* ================================================================== 3. DER PULS -- laeuft er gerade? (21.09.2026) ================================================================== Die Auskunft kommt von postfach.dogfather-universe.com/live-status, demselben Dienst, von dem der oeffentliche Streamplan lebt. Er wird direkt vom Browser gefragt; nachgemessen vor dem Bauen: "access-control-allow-origin: *", und `connect-src` der Treff-Seiten fuehrt postfach bereits -- sonst haette die Inhaltsrichtlinie den Abruf still abgewiesen, und das haette ausgesehen wie ein toter Dienst. ------------------------------------------------------------------ DREI AUSGAENGE, NICHT ZWEI -- UND DAS IST HIER KEINE FEINHEIT `streamplan.js` macht an derselben Stelle etwas anderes: Sein `.catch()` setzt `istLive = false` und zeigt den Countdown. Aus "wir konnten nicht fragen" wird damit "er sendet nicht". Das ist genau der Fehler, den das Haus seit dem 03.09. kennt -- eine Pruefung ohne dritten Ausgang -- nur hier mit einer Folge, die jemand merkt: Wer die Seite waehrend einer Stoerung aufmacht, verpasst den Stream, weil eine Anzeige so getan hat, als haette sie nachgesehen. Der Dienst SAGT sogar, ob er nachsehen konnte: `autoHealthy` steht in derselben Antwort und wird draussen nicht gelesen. Hier schon. In der Lage "unklar" laeuft der Countdown trotzdem weiter -- er braucht kein Netz. Eine Stoerung nimmt der Seite nur die eine Auskunft, die wirklich an ihr haengt. */ const POSTFACH = 'https://postfach.dogfather-universe.com'; const puls = document.getElementById('puls'); if (puls) { const eLage = document.getElementById('puls-lage'); const eZahl = document.getElementById('puls-zahl'); const eKlein = document.getElementById('puls-klein'); const eKnopf = document.getElementById('puls-knopf'); const eBogen = document.getElementById('puls-bogen'); /* DIE SENDEZEIT KOMMT VOM SERVER, DAMIT ES SIE NUR EINMAL GIBT. Sie steht auch in assets/js/streamplan.js der oeffentlichen Seite; zwei Zahlen, die dasselbe bedeuten, laufen irgendwann auseinander. Bis die Antwort da ist, gilt derselbe Wert -- sonst stuende beim Laden gar nichts. Eine Pruefung haelt beide Orte gegeneinander. */ let sendeStunde = 20; let lage = 'laedt'; let uhrLaeuft = 0; /* Der Ring: Umfang aus dem Radius gerechnet, nicht abgemessen. Wird r im HTML geaendert, stimmt er weiter. */ const UMFANG = 2 * Math.PI * Number(eBogen.getAttribute('r') || 52); eBogen.style.strokeDasharray = String(UMFANG); const zwei = (n) => String(n).padStart(2, '0'); function naechsteSendezeit() { const jetzt = new Date(); const ziel = new Date(jetzt); ziel.setHours(sendeStunde, 0, 0, 0); if (ziel <= jetzt) ziel.setDate(ziel.getDate() + 1); return ziel; } function countdownZeichnen() { const restMs = Math.max(0, naechsteSendezeit() - new Date()); const sek = Math.floor(restMs / 1000); eZahl.textContent = zwei(Math.floor(sek / 3600)) + ':' + zwei(Math.floor((sek % 3600) / 60)) + ':' + zwei(sek % 60); /* Voll = gleich. Der Ring sagt dasselbe wie die Zahl, nur ohne Lesen -- fuer den Blick im Vorbeigehen. */ const anteil = 1 - restMs / 86400000; eBogen.style.strokeDashoffset = String(UMFANG * (1 - anteil)); } /* Die Sekundenuhr laeuft NUR, solange jemand hinsieht. Ein Zeitgeber, der im Hintergrund weiterlaeuft, kostet auf dem Handy Akku, ohne etwas anzuzeigen. */ function uhrAn() { if (uhrLaeuft || lage === 'live') return; countdownZeichnen(); uhrLaeuft = setInterval(countdownZeichnen, 1000); } function uhrAus() { if (!uhrLaeuft) return; clearInterval(uhrLaeuft); uhrLaeuft = 0; } function setzeLage(neueLage, titel) { lage = neueLage; puls.dataset.lage = neueLage; /* DIE GANZE SEITE WEISS BESCHEID (22.09.2026). ========================================================== Filipe: "dan auf screen 7 soll die kachel da auch fuer die gleiche zeit richtig geil aufblinken, mit einer uebertrieben geilen farbe damit die leute wissen dass sie darueber sofort drauf kommen. und das auf screen8 auch." Ein Merkmal am , nicht drei Stellen, die einzeln umgeschaltet werden: Die Kanalkarten stehen in einer Liste, die sich neu aufbauen kann; die Pulskarte steht woanders. Wer beide von Hand umschaltet, vergisst beim naechsten Bauteil das dritte. Das CSS entscheidet dann, was blinkt. */ document.body.dataset.live = neueLage === 'live' ? 'ja' : 'nein'; if (neueLage === 'live') { uhrAus(); eLage.textContent = 'Er ist JETZT live'; eZahl.textContent = titel || 'Der Stream läuft'; eKlein.textContent = 'Läuft gerade auf TikTok.'; eKnopf.hidden = false; eKnopf.textContent = 'Jetzt zuschauen →'; eBogen.style.strokeDashoffset = '0'; return; } eKnopf.hidden = true; if (neueLage === 'wartet') { eLage.textContent = 'Gerade nicht live'; eKlein.textContent = 'Der nächste feste Termin ist ' + sendeStunde + ':00 Uhr — in deiner Ortszeit.'; } else if (neueLage === 'unklar') { /* DER SATZ SAGT, WAS ER WEISS, UND WAS NICHT. "Nicht live" waere hier eine Behauptung, die niemand geprueft hat. */ eLage.textContent = 'Konnten wir gerade nicht nachsehen'; eKlein.textContent = 'Der Live-Dienst antwortet nicht. Das heißt NICHT, ' + 'dass kein Stream läuft — schau im Zweifel direkt auf TikTok.'; eKnopf.hidden = false; eKnopf.textContent = 'Auf TikTok nachsehen →'; } else { eLage.textContent = 'Wir sehen gerade nach …'; eKlein.textContent = 'Alle Zeiten in deiner Ortszeit.'; } uhrAn(); } async function lageHolen() { /* EINE AUSLEGUNG, NICHT ZWEI (22.09.2026). ========================================================== Seit die Kachel auf der Startseite denselben Zustand zeigt, darf es die Frage "ist er live" nur EINMAL im Haus geben. Zwei Abfragen waeren zwei Auslegungen -- und spaetestens beim naechsten Umbau steht auf der Kachel LIVE und hier "wartet". `live.js` faellt mit derselben Regel: live / wartet / weiss-nicht, mit `autoHealthy` als drittem Ausgang. Die Zeilen darunter sind stehengeblieben fuer den Fall, dass live.js nicht geladen wurde -- dann verhaelt sich die Seite wie vorher, statt gar nichts zu zeigen. */ if (window.LiveStatus) { const l = await window.LiveStatus.holen(); if (l.zustand === 'live') { setzeLage('live', l.titel); return; } setzeLage(l.zustand === 'weissnicht' ? 'unklar' : 'wartet'); return; } try { const antwort = await fetch(POSTFACH + '/live-status', { cache: 'no-store' }); if (!antwort.ok) throw new Error('HTTP ' + antwort.status); const d = await antwort.json(); if (!d || d.ok !== true) throw new Error('keine Auskunft'); if (d.live) { setzeLage('live', d.title); return; } /* HIER STEHT DER UNTERSCHIED ZUR OEFFENTLICHEN SEITE. Der Dienst schaut automatisch nach; klappt das gerade nicht, sagt er es selbst. Ein "live: false" aus einem gestoerten Pruefer ist keine Aussage ueber den Stream, sondern eine ueber den Pruefer. Nur wenn die Quelle "automatisch" ist -- hat jemand den Status von Hand gesetzt, gilt der, und die Gesundheit des automatischen Pruefers spielt keine Rolle. */ if (d.source === 'automatisch' && d.autoHealthy === false) { setzeLage('unklar'); return; } setzeLage('wartet'); } catch { setzeLage('unklar'); } } setzeLage('laedt'); lageHolen(); setInterval(lageHolen, 30000); document.addEventListener('visibilitychange', () => { if (document.hidden) { uhrAus(); return; } uhrAn(); lageHolen(); }); /* ---- DAS BESONDERE EVENT -------------------------------------- */ const eventKasten = document.getElementById('event'); if (eventKasten) { const evTitel = document.getElementById('event-titel'); const evZahl = document.getElementById('event-zahl'); const evKnopf = document.getElementById('event-knopf'); let evZiel = null; let evUhr = 0; const evZeichnen = () => { if (!evZiel) return; const restMs = evZiel - new Date(); if (restMs <= 0) { evZahl.textContent = 'Es geht los!'; eventKasten.dataset.jetzt = 'ja'; clearInterval(evUhr); evUhr = 0; return; } const sek = Math.floor(restMs / 1000); const tage = Math.floor(sek / 86400); const rest = zwei(Math.floor((sek % 86400) / 3600)) + ':' + zwei(Math.floor((sek % 3600) / 60)) + ':' + zwei(sek % 60); evZahl.textContent = tage > 0 ? 'noch ' + tage + ' Tage · ' + rest : 'noch ' + rest; }; const eventHolen = async () => { try { const antwort = await fetch(POSTFACH + '/special-event', { cache: 'no-store' }); if (!antwort.ok) throw new Error('HTTP ' + antwort.status); const d = await antwort.json(); const e = d && d.ok ? d.event : null; if (evUhr) { clearInterval(evUhr); evUhr = 0; } if (!e || !e.zieldatum) { eventKasten.hidden = true; evZiel = null; return; } evZiel = new Date(e.zieldatum); if (Number.isNaN(evZiel.getTime())) { eventKasten.hidden = true; evZiel = null; return; } /* Der Titel kommt mehrsprachig; auf dieser Seite wird deutsch gesprochen. `textContent`, nicht `innerHTML` -- der Text stammt aus der Verwaltung und ist damit Eingabe, auch wenn sie aus dem eigenen Haus kommt. */ evTitel.textContent = (e.titel && (e.titel.de || e.titel)) || 'Besonderes Event'; eventKasten.hidden = false; delete eventKasten.dataset.jetzt; evZeichnen(); evUhr = setInterval(evZeichnen, 1000); if (e.link) { evKnopf.href = e.link; evKnopf.hidden = false; } else evKnopf.hidden = true; } catch { /* HIER IST VERSTECKEN RICHTIG -- anders als beim Puls oben. Der Puls beantwortet die Frage, wegen der man die Seite oeffnet; schweigt er, muss das dastehen. Ein besonderes Event gibt es an den meisten Tagen gar nicht. "Wir konnten nicht nachsehen, ob es ein Event gibt, das es vermutlich nicht gibt" ist keine Auskunft, sondern Rauschen -- und Rauschen bringt Menschen dazu, auch die echten Hinweise zu ueberlesen. */ eventKasten.hidden = true; } }; eventHolen(); setInterval(eventHolen, 60000); } /* ---- DIE KANAELE ---------------------------------------------- */ const kanaeleKasten = document.getElementById('kanaele'); if (kanaeleKasten) { const reihe = document.getElementById('kanaele-reihe'); const fehlerText = document.getElementById('kanaele-fehler'); (async () => { try { const antwort = await fetch('/workspace/api/draussen', { credentials: 'same-origin' }); if (!antwort.ok) throw new Error('HTTP ' + antwort.status); const d = await antwort.json(); const liste = Array.isArray(d.kanaele) ? d.kanaele : []; /* DIE ZAHL GEHOERT IN DIE BEDINGUNG. Eine leere Antwort wuerde sonst eine leere Reihe ergeben, die aussieht, als gaebe es keine Kanaele -- statt zu sagen, dass etwas schiefging. */ if (!liste.length) throw new Error('leere Liste'); if (Number.isFinite(d.sendeStunde)) { sendeStunde = d.sendeStunde; if (lage !== 'live') setzeLage(lage); } for (const k of liste) { const a = document.createElement('a'); a.className = 'kanal'; a.href = k.adresse; a.target = '_blank'; a.rel = 'noopener noreferrer'; const name = document.createElement('span'); name.className = 'kanal__name'; name.textContent = k.name; const handle = document.createElement('span'); handle.className = 'kanal__handle'; handle.textContent = k.handle; const satz = document.createElement('span'); satz.className = 'kanal__satz'; satz.textContent = k.satz || ''; a.append(name, handle, satz); reihe.append(a); } kanaeleKasten.hidden = false; } catch { fehlerText.hidden = false; kanaeleKasten.hidden = false; } })(); } } })();