Files
dogfather-universe/workspace/assets/js/kopf.js
T
DogFatherGitandClaude Opus 5 299ef0d506 Eigenes Profil fuer jeden - mit Bild und Kanaelen
SCREEN 1: Jeder im Workspace hat jetzt seinen eigenen Steckbrief --
Creator, Scout, Manager und DogFather gleichermassen. Ein Bild, ein Satz
ueber sich, und die Kanaele, auf denen er unterwegs ist. Er steht ganz
oben auf profil.html, ueber der Creator-Akte; die ist etwas anderes
(die fuehren die Betreuer, den Steckbrief fuehrt man selbst).

Das Bild erscheint sofort ueberall: in der Kopfleiste jeder Seite und in
der Begruessung. /api/ich liefert es deshalb gleich mit -- das spart auf
jeder Seite eine zweite Abfrage und verhindert, dass die Plakette erst
als Buchstabe erscheint und dann umspringt. Faellt das Bild aus, steht
der Buchstabe da: Er wird nicht ersetzt, sondern liegt darunter.

WARUM KEINE ECHTE TIKTOK-ANMELDUNG. "Mit TikTok verbinden" klingt nach
OAuth. Das waere: ein Entwicklerkonto mit App-Freischaltung, eine
Pruefung durch TikTok (Wochen, widerrufbar), je Person ein Zugriffstoken,
das ablaeuft, erneuert werden muss und -- wenn es abhandenkommt --
fremden Zugriff auf ein fremdes Konto bedeutet. Dafuer bekaeme man
Follower-Zahlen.

Gebraucht wird hier aber "Wer ist das, und wo finde ich ihn?". Dafuer
genuegt der oeffentliche Name. Gespeichert wird deshalb NUR das Handle --
kein Token, kein Passwort, nichts, was abhandenkommen kann. Daraus wird
ein Verweis auf den Kanal. Vier Kanaele: TikTok, Instagram, YouTube,
Twitch. Sollen spaeter echte Zahlen dazukommen, ist das ein eigenes
Vorhaben -- der Name hier bleibt dann trotzdem richtig.

Eingefuegt werden darf, was Leute wirklich in der Zwischenablage haben:
"@name", "https://www.tiktok.com/@name" oder der Name allein. Alles wird
auf den Namen zurueckgefuehrt, statt eine Fehlermeldung zu zeigen.

SICHERHEIT beim Bild -- die drei Stellen, an denen Profilbilder
typischerweise scheitern:
  1. Der Typ wird an der SIGNATUR der Datei geprueft, nicht am
     Content-Type, den der Absender behauptet. Ein umbenanntes SVG mit
     Skript darin kaeme sonst durch und liefe im Namen der Domain --
     mit der Sitzung des Betrachters.
  2. Auf der Platte bekommt jede Datei einen Zufallsnamen. Kein Name
     kann Pfade verlassen oder etwas ueberschreiben.
  3. Ausgeliefert mit nosniff und einer eigenen, alles verbietenden
     Inhaltsregel.
Das Bild liegt als Datei neben der Datenbank, nicht darin: Bilder in
SQLite blaehen jede Sicherung auf, und die laeuft jede Nacht.

Niemand schreibt einem anderen ins Profil -- auch DogFather nicht. Ein
Satz ueber sich und der eigene Kanalname gehoeren der Person. Wer wen
SEHEN darf, folgt der bekannten Regel: Leitung alle, Scout seine
Creator, Creator sich und seine Betreuer. Fremde Profile sind 404, nicht
403.

ZWEI FUNDE DURCH DIE PRUEFUNG:
- Der TikTok-Link, den die App beim Teilen kopiert, endet auf
  "?lang=de&is_from_webapp=1". Der wurde abgelehnt: "sieht nicht richtig
  aus" -- obwohl es der offizielle Link ist. Jetzt werden Parameter und
  Anker mit abgeschnitten.
- Ein zu grosses Bild ergab "500 Serverfehler" statt einer Ansage. Der
  PayloadTooLargeError lief bis in die allgemeine Fehlerbehandlung
  durch. Jetzt 413 mit dem Satz, wie viel erlaubt ist. Von Hand haette
  das vermutlich nie jemand probiert.

Vor der Schemaaenderung wurde eine geprueft vollstaendige Sicherung
gezogen (VACUUM INTO, Integritaet ok, 6 Personen).

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-01 11:31:34 +02:00

428 lines
18 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ===================================================================
Gemeinsames Verhalten der Kopfleiste: Zurück-Knopf, Abmelden, Suche.
Liegt bewusst in einer eigenen Datei. Vorher stand das Abmelden in
jeder Seitendatei noch einmal -- fünfmal derselbe Block, fünfmal eine
Stelle zum Vergessen, wenn sich etwas ändert.
=================================================================== */
(() => {
'use strict';
const START = '/workspace/start.html';
/* ---------- Wer angemeldet ist -------------------------------------------
Vorher stand in der Kopfleiste "Dogfather · DogFather". Name und
Rolle lasen sich gleich, und es sah nach einem Fehler aus. Jetzt drei
unterscheidbare Dinge: ein Zeichen mit dem Anfangsbuchstaben, der
Name, und die Rolle als eigene Marke in ihrer Farbe.
Steht hier und nicht in jeder Seitendatei -- jede von ihnen setzte
bisher dieselbe Zeile selbst zusammen, vierzehnmal. */
window.werZeigen = function (ich) {
const ziel = document.getElementById('wer');
if (!ziel || !ich) return;
ziel.textContent = '';
ziel.dataset.rolle = ich.rolle || '';
/* Das eigene Bild, wenn es eines gibt -- sonst der Anfangsbuchstabe.
Der Buchstabe bleibt als Unterlage LIEGEN und wird nicht ersetzt:
Laedt das Bild nicht (Verbindung weg, Datei geloescht), steht dort
weiterhin etwas Sinnvolles statt eines kaputten Bildsymbols. */
const zeichen = document.createElement('span');
zeichen.className = 'wer__zeichen';
zeichen.setAttribute('aria-hidden', 'true');
zeichen.textContent = (ich.name || '?').trim().charAt(0).toUpperCase();
if (ich.bild) {
const b = document.createElement('img');
b.className = 'wer__bild';
b.src = ich.bild;
b.alt = '';
b.addEventListener('error', () => b.remove());
zeichen.append(b);
}
const name = document.createElement('span');
name.className = 'wer__name';
name.textContent = ich.name || '';
const rolle = document.createElement('span');
rolle.className = 'wer__rolle';
rolle.textContent = ich.rolle_name || ich.rolle || '';
ziel.append(zeichen, name, rolle);
/* Vorleseprogramme bekommen einen Satz statt drei Bruchstücke. */
ziel.setAttribute('aria-label',
`Angemeldet als ${ich.name} in der Rolle ${ich.rolle_name || ich.rolle}`);
};
/* ---------- Auf- und Zuklappen langer Listen -------------------------
Lange Protokoll-Listen sind Nachschlagewerk, kein Startbild: Sie
stehen zugeklappt da und zeigen nur die neuesten Zeilen.
Steht hier, weil es an mehreren Stellen gebraucht wird -- und weil
genau so schon dreimal Regeln in einer Seitendatei gelandet sind,
die eine andere Seite gar nicht laedt.
`klappe(liste, knopf, text)` erwartet: die Liste (deren direkte
Kinder die Zeilen sind), den Knopf und das Textfeld darin. Das
Ausblenden macht CSS ueber [data-klapp="zu"] -- ohne Neuaufbau und
ohne zweite Abfrage. */
const KLAPP_ZU = 4; /* dieselbe Zahl steht im CSS als nth-child(n+5) */
window.klappe = function (liste, knopf, textfeld) {
if (!liste || !knopf) return;
const anzahl = liste.children.length;
/* Ein Knopf, der nichts zu verbergen hat, verwirrt nur. */
knopf.hidden = anzahl <= KLAPP_ZU;
if (knopf.hidden) { liste.removeAttribute('data-klapp'); return; }
/* Liest die Anzahl bei jedem Aufruf frisch aus der Liste, statt sie
von oben mitzunehmen. Der Zuhoerer unten wird nur einmal gesetzt
und haelt darum fuer immer die Fassung vom ersten Durchlauf fest
-- mit einer festgehaltenen Zahl stuende spaeter "Alle 3 zeigen"
an einer Liste mit dreissig Zeilen. */
const stand = (offen) => {
if (offen) liste.removeAttribute('data-klapp');
else liste.dataset.klapp = 'zu';
knopf.setAttribute('aria-expanded', offen ? 'true' : 'false');
/* Der Knopf sagt, was er TUT, nicht wo man gerade ist -- und nennt
die Zahl, damit man weiss, was dahintersteckt. */
if (textfeld) {
textfeld.textContent = offen
? `Nur die letzten ${KLAPP_ZU}`
: `Alle ${liste.children.length} zeigen`;
}
};
/* Die Liste wird bei jeder Aktualisierung neu aufgebaut, also laeuft
auch klappe() mehrfach. Der Zuhoerer wird deshalb nur EINMAL
gesetzt -- sonst haengten beim zweiten Mal zwei daran und ein
Klick haette doppelt umgeschaltet, also gar nichts bewirkt. */
if (!knopf.dataset.klappBereit) {
knopf.dataset.klappBereit = 'ja';
knopf.addEventListener('click', () => stand(liste.dataset.klapp === 'zu'));
}
stand(false);
};
/* Gibt es eine sinnvolle vorherige Seite INNERHALB des Workspace?
Blindes history.back() reicht nicht: Wer die Adresse direkt eingibt
oder gerade von der Anmeldung kommt, landet damit außerhalb des
Workspace oder wieder im Anmeldeformular. Deshalb wird zuerst
geprüft, woher der Aufruf kam. */
function hatVorherigeSeite() {
if (!document.referrer) return false;
let u;
try { u = new URL(document.referrer); } catch { return false; }
if (u.origin !== location.origin) return false;
if (!u.pathname.startsWith('/workspace/')) return false;
/* Die Anmeldeseite ist kein sinnvolles Ziel -- wer angemeldet ist,
würde dort sofort wieder weitergeleitet. */
if (u.pathname === '/workspace/' || u.pathname.endsWith('/index.html')) return false;
/* Gleiche Seite heißt: neu geladen, nicht hergekommen. */
if (u.pathname === location.pathname) return false;
return true;
}
/* ---------- Jede Seite trägt Zeichen und Farbe ihrer Kachel -------------
Auf der Startseite hat jeder Bereich sein eigenes Zeichen und seinen
eigenen Farbton. Bisher endete das an der Kachel: Wer sie anklickte,
landete auf einer Seite, der man nicht mehr ansah, woher sie kam --
dreizehn Seiten, alle in demselben Blau, alle ohne Zeichen.
Jetzt wird die Kachel weitergereicht. Der Kopf der Seite bekommt
dasselbe Zeichen groß daneben, denselben Farbton für Strich, Zeichen
und Titel, und dasselbe Zeichen noch einmal riesig und fast
unsichtbar als Wasserzeichen -- genau die Behandlung, die auf der
Startseite aus siebzehn Kästen siebzehn unterscheidbare Bereiche
gemacht hat.
Die Zuordnung kommt aus assets/js/bereiche.js, also aus derselben
Liste wie die Kachel selbst. Wer dort eine Farbe ändert, ändert
Kachel UND Seite -- sie können gar nicht auseinanderlaufen. */
(function seiteEinfaerben() {
const B = window.Bereiche;
/* Der Kalender hat einen eigenen Kopf (.k-kopf), weil dort der
Zeitraum die Überschrift ist. Er soll sein Zeichen trotzdem
bekommen -- sonst wäre ausgerechnet die aufwendigste Seite die
einzige ohne. */
const kopf = document.querySelector('.kopf-zeile, .k-kopf');
if (!B || !kopf) return;
const bereich = B.zuSeite(location.pathname, location.search);
if (!bereich) return;
/* Der Ton hängt am body: Damit erreicht er auch Bauteile, die weit
unten auf der Seite stehen (Karten, Knöpfe, Zähler) -- nicht nur
den Kopf. Die 17 Töne selbst stehen in start.css und hängen nur am
Merkmal data-ton, nicht an einer Klasse. */
document.body.dataset.ton = String(bereich.ton);
/* Und ihre eigene Bühne. Neun Szenen aus Filipes Bildern, jede Seite
bekommt die, die zu ihr passt -- die Werkstatt bei den Aufgaben,
die Nachtstadt beim Kalender, die Sitzecke bei den Calls, der Wald
beim Scouting. Welche wohin gehört, steht in bereiche.js; das Bild
selbst wählt start.css über dieses Merkmal aus. */
if (bereich.szene) document.body.dataset.buehne = bereich.szene;
/* Der Kopf ist bisher ein <div> mit Text. Er bekommt links eine
Plakette mit dem Zeichen -- eingefügt VOR dem vorhandenen Text,
ohne ihn anzufassen. Dadurch funktioniert das auf allen dreizehn
Seiten, ohne dreizehn HTML-Dateien zu ändern. */
const erstes = kopf.firstElementChild;
if (!erstes) return;
const plakette = document.createElement('span');
plakette.className = 'kopf-zeichen';
plakette.setAttribute('aria-hidden', 'true');
plakette.append(B.zeichenBauen(bereich.zeichen, 'kopf-zeichen__svg'));
const reihe = document.createElement('div');
reihe.className = 'kopf-zeile__marke';
reihe.append(plakette);
kopf.insertBefore(reihe, erstes);
reihe.append(erstes);
/* Das Wasserzeichen: dasselbe Zeichen riesig und bei sehr geringer
Deckkraft hinter dem Kopf. Es liegt im Kopf selbst, nicht auf der
Seite -- sonst würde es beim Blättern mitwandern und stören. */
const schatten = document.createElement('span');
schatten.className = 'kopf-wasserzeichen';
schatten.setAttribute('aria-hidden', 'true');
schatten.append(B.zeichenBauen(bereich.zeichen, 'kopf-wasserzeichen__svg'));
kopf.append(schatten);
kopf.dataset.gezeichnet = 'ja';
})();
/* ---------- Der Schriftzug oben links -----------------------------------
Er stand als eine Zeile in einer Farbe da: "DOGFATHER UNIVERSE ·
CREATOR WORKSPACE". Alles gleich laut, und der Mittelpunkt war ein
Satzzeichen wie jedes andere.
Jetzt bekommt er einen Rang: Der vordere Teil ist der Name und steht
kräftig, der hintere sagt, wo man gerade ist, und tritt zurück. Der
Trenner ist kein Zeichen mehr, sondern eine kleine Raute in der
Hausfarbe.
Das passiert hier zentral statt in sechzehn HTML-Dateien -- und ohne
den Text neu zu schreiben: Nur die vorhandenen Textknoten werden
aufgeteilt, Verweise (der Zurück-Link auf den Unterseiten) bleiben
unberührt. Ändert jemand die Beschriftung im HTML, wirkt das hier
weiterhin. */
for (const marke of document.querySelectorAll('.kopfleiste .marke__text')) {
if (marke.dataset.rang) continue;
for (const knoten of [...marke.childNodes]) {
if (knoten.nodeType !== 3 || !knoten.textContent.includes('·')) continue;
const stueck = document.createDocumentFragment();
knoten.textContent.split('·').forEach((teil, i) => {
if (i) {
const raute = document.createElement('span');
raute.className = 'marke__raute';
raute.setAttribute('aria-hidden', 'true');
stueck.append(raute);
}
if (!teil.trim()) return;
const s = document.createElement('span');
s.className = 'marke__teil';
/* Der geschützte Leerraum im Quelltext soll erhalten bleiben --
"Creator Workspace" darf nicht umbrechen. */
s.textContent = teil.replace(/^[\s ]+|[\s ]+$/g, '');
stueck.append(s);
});
knoten.replaceWith(stueck);
}
/* Der erste Teil ist der Name (oder auf Unterseiten der Rückweg),
alles danach sagt nur noch, wo man ist.
Genommen werden ALLE direkten Kinder außer der Raute -- nicht nur
die eben erzeugten. Auf bereich.html steht der Bereichsname in
einem eigenen span, das erst später von bereich.js gefüllt wird;
eine Liste, die nur die selbst gebauten Stücke kennt, hätte ihn
stumm übergangen. */
const teile = [...marke.children].filter((e) => !e.classList.contains('marke__raute'));
teile.forEach((e, i) => e.classList.add(i === 0 ? 'marke__haupt' : 'marke__leise'));
marke.dataset.rang = 'ja';
}
/* Die Startseite hat den Knopf gar nicht erst im Dokument -- dort gibt
es kein "davor". Deshalb genügt hier die Prüfung, ob er existiert. */
const knopf = document.getElementById('zurueck');
if (knopf) {
const beschriftung = knopf.querySelector('.zurueck-knopf__text');
if (hatVorherigeSeite()) {
knopf.hidden = false;
knopf.addEventListener('click', () => {
/* history.back() statt eines festen Ziels: Es führt wirklich
dorthin zurück, wo man war -- samt Bildlaufposition. Nur wenn
es keine Historie gibt, wird ersatzweise umgeleitet. */
if (history.length > 1) history.back();
else location.assign(START);
});
} else {
/* Kein Verlauf, aber wir sind in einem Unterbereich: dann wenigstens
ein Weg zur Übersicht. */
knopf.hidden = false;
if (beschriftung) beschriftung.textContent = 'Übersicht';
knopf.addEventListener('click', () => location.assign(START));
}
}
const abmelden = document.getElementById('abmelden');
if (abmelden) {
abmelden.addEventListener('click', async () => {
abmelden.disabled = true;
try {
await fetch('/workspace/api/abmelden', {
method: 'POST', credentials: 'same-origin',
});
} catch { /* Auch ohne Antwort weiterleiten -- das Cookie läuft ohnehin ab. */ }
location.assign('/workspace/');
});
}
/* ================================================================
Suche über alle Bereiche (Konzept, Phase 4).
Baut sich selbst in die Kopfleiste ein, statt in neun HTML-Dateien
zu stehen. Eine Suche, die man nur auf einer Extraseite findet,
benutzt niemand -- sie muss überall sein, wo man gerade ist.
Mit "/" zu öffnen, mit Esc zu schließen. Das Ergebnis wird erst
nach einer kurzen Pause geholt, damit nicht jeder Tastendruck eine
Abfrage auslöst.
================================================================ */
const kopfRechts = document.querySelector('.kopfleiste__rechts');
if (kopfRechts) aufbauSuche(kopfRechts);
function aufbauSuche(wohin) {
const el = (tag, klasse, text) => {
const k = document.createElement(tag);
if (klasse) k.className = klasse;
if (text !== undefined) k.textContent = text;
return k;
};
/* Auslöser in der Kopfleiste */
const knopf = el('button', 'suche-knopf');
knopf.type = 'button';
knopf.title = 'Suchen (Taste /)';
knopf.setAttribute('aria-label', 'Suchen');
const lupe = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
lupe.setAttribute('viewBox', '0 0 24 24');
lupe.setAttribute('class', 'suche-knopf__lupe');
lupe.setAttribute('aria-hidden', 'true');
lupe.innerHTML = '<circle cx="11" cy="11" r="6.5"/><path d="M15.8 15.8 20 20"/>';
knopf.append(lupe, el('span', 'suche-knopf__text', 'Suchen'));
wohin.prepend(knopf);
/* Overlay */
const schleier = el('div', 'suche-schleier');
schleier.hidden = true;
const fenster = el('div', 'suche-fenster');
fenster.setAttribute('role', 'dialog');
fenster.setAttribute('aria-modal', 'true');
fenster.setAttribute('aria-label', 'Suche über alle Bereiche');
const zeile = el('div', 'suche-zeile');
const feld = el('input', 'suche-feld');
feld.type = 'search';
feld.placeholder = 'Suchen – Aufgaben, Gespräche, Dateien, Profile …';
feld.setAttribute('aria-label', 'Suchbegriff');
feld.maxLength = 100;
const zu = el('button', 'suche-zu', 'Esc');
zu.type = 'button';
zeile.append(feld, zu);
const ergebnis = el('div', 'suche-ergebnis');
ergebnis.setAttribute('aria-live', 'polite');
fenster.append(zeile, ergebnis);
schleier.append(fenster);
document.body.append(schleier);
const zeigeHinweis = (text) => {
ergebnis.textContent = '';
ergebnis.append(el('p', 'suche-hinweis', text));
};
let letzte = 0;
async function suche() {
const q = feld.value.trim();
if (q.length < 2) { zeigeHinweis('Mindestens zwei Zeichen.'); return; }
const meine = ++letzte;
try {
const a = await fetch('/workspace/api/suche?q=' + encodeURIComponent(q),
{ credentials: 'same-origin' });
if (meine !== letzte) return; // eine neuere Abfrage ist schon unterwegs
if (a.status === 401) { location.assign('/workspace/'); return; }
if (!a.ok) { zeigeHinweis('Die Suche ist gerade nicht erreichbar.'); return; }
zeichne(await a.json());
} catch {
if (meine === letzte) zeigeHinweis('Die Suche ist gerade nicht erreichbar.');
}
}
function zeichne(d) {
ergebnis.textContent = '';
if (!d.gesamt) {
zeigeHinweis(`Nichts gefunden zu „${d.frage}".`);
return;
}
for (const g of d.gruppen) {
const s = el('section', 'suche-gruppe');
s.append(el('h3', 'suche-gruppe__titel', g.titel));
for (const tr of g.treffer) {
const a = el('a', 'suche-treffer');
a.href = tr.ziel;
a.append(el('span', 'suche-treffer__titel', tr.titel));
if (tr.zusatz) a.append(el('span', 'suche-treffer__zusatz', tr.zusatz));
if (tr.text) a.append(el('span', 'suche-treffer__text', tr.text));
s.append(a);
}
ergebnis.append(s);
}
}
let warte = null;
feld.addEventListener('input', () => {
clearTimeout(warte);
warte = setTimeout(suche, 220);
});
function auf() {
schleier.hidden = false;
document.body.classList.add('suche-offen');
feld.focus();
feld.select();
if (!feld.value.trim()) zeigeHinweis('Tippen, um alle Bereiche zu durchsuchen.');
}
function zuMachen() {
schleier.hidden = true;
document.body.classList.remove('suche-offen');
knopf.focus();
}
knopf.addEventListener('click', auf);
zu.addEventListener('click', zuMachen);
schleier.addEventListener('mousedown', (e) => { if (e.target === schleier) zuMachen(); });
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !schleier.hidden) { zuMachen(); return; }
/* "/" oeffnet die Suche -- aber nur, wenn man nicht ohnehin
gerade in ein Feld schreibt. */
if (e.key !== '/' || schleier.hidden === false) return;
const z = document.activeElement;
if (z && (z.tagName === 'INPUT' || z.tagName === 'TEXTAREA' || z.tagName === 'SELECT'
|| z.isContentEditable)) return;
e.preventDefault();
auf();
});
}
})();