Files
dogfather-universe/workspace/assets/js/support.js
T
DogFatherGitandClaude Opus 5 e5cd016b60 Aus jedem Fenster kommt man heraus, die Kachel dreht sich, der Eingang sieht aus wie das Haus
VIER DINGE, und das erste ist eine Meldung aus dem Support.

1. MISS KAM AUS "AUFGABE BEARBEITEN" NICHT HERAUS.
   „Ich konnte da wieder nicht zurück gehen, musste die App schließen
   damit ich wieder auf die Hauptseite kam."
   Gemessen (mess-dialog-ausgang.mjs), vier Größen:
     412x915 App      782 px Inhalt in 784 px  -- knapp ja
     412x780 Browser  782 px Inhalt in 742 px  -- SACKGASSE
     360x640 klein    794 px Inhalt in 602 px  -- SACKGASSE
     412x430 Tastatur 794 px Inhalt in 392 px  -- SACKGASSE
   `.dialog` hatte `overflow: hidden`, eine Scroll-Höhe gab es NUR für
   `.dialog--breit`. Alles unterhalb des Rands wurde abgeschnitten --
   samt "Abbrechen". Jetzt rollt JEDES Fenster, und Kopf wie Knopfzeile
   bleiben stehen (`position: sticky`), damit man den Ausgang SIEHT,
   ohne erst durch acht Felder zu scrollen. Alle vier Größen: ja.

2. DIE VORLAGENKACHEL DREHT SICH.
   „wenn ich drauf drücke dreht sich die kachel und dan seh ich wer es
   gemacht hat, und immer noch die option es nochmal zu verteilen falls
   neue leute ins team zustoßen."
   Vorne bleibt die Kurzfassung ("liegt bei 3 von 4"), hinten stehen
   die Namen mit ihrem Stand und zwei Knöpfe: "Nachholen – 1 fehlt"
   (oder "Nochmal an alle", wenn wirklich alle sie haben) und "Zurück".
   Nach dem Verteilen dreht sie sich von selbst; wer nur nachsehen
   will, drückt "Wer hat sie?".

3. DER EINGANG SIEHT AUS WIE DAS HAUS.
   Fase und Leuchtschiene statt flachem Kasten, die Schiene in der
   Farbe des Stands. Die drei Zahlen werden drei Felder -- und die
   "0 neu" leuchtet nicht mehr rot: Eine Warnung, die immer kommt, ist
   keine Warnung. Ab 760 px steht das Bild neben dem Text statt
   darunter; die Karte war dadurch dreimal so hoch wie nötig.

4. DER CREATOR-KATALOG IST AUF DER TEAM-SEITE WEG.
   „es gibt keine creator auf dieser seite" -- dort stand "Wähle oben
   einen Creator", eine Aufforderung zu etwas Unmöglichem. Gefragt wird
   jetzt nach den Daten (gibt es jemanden, dem ich das geben kann?),
   nicht nach der Adresse.

DAZU FERTIG GEMACHT, WAS VON GESTERN OFFEN WAR:
  * Die zwei Serien ohne Haus ("Community-Call", "Schulung-Agentur").
    Ursache war meine eigene Abschrift: Bei den Terminen frage ich die
    Teilnehmerliste, bei den Serien hatte ich sie vergessen. Auf einer
    Kopie der echten Datenbank: 0 offene Zeilen.
  * Sieben Schreibwege setzen jetzt `haus` (Aufgaben, Einträge,
    Dateien, Material, Wissen, Video-Titelbild). Dabei gefunden:
    `material` verwaltet seine Spalten SELBST -- meine Spalte stand in
    der falschen Liste und fehlte auf einer frischen Datenbank
    (78 Fehlschläge in pruef-material, jetzt 159/0).
  * unterstuetzen.html lud meldung.js gar nicht -- dort stand das
    Maschinenwort des Servers statt eines Satzes (pruef-meldungen 8/0).

DREI VERALTETE PRÜFUNGEN NACHGEZOGEN, jede STRENGER als vorher:
  * "der Modi legt eine Aufgabe an (201)" -- seit dem 22.09. ist das
    403 und gewollt. Geprüft wird jetzt auch das WORT.
  * "calls.html ist verboten" -- Filipe hat die Kachel selbst verlangt
    ("jeder der einen kalender hat"). Mit Gegenprobe ersetzt.
  * "Review" heißt seit dem 20.09. "Zur Freigabe". Der Name wird jetzt
    aus STATUS_NAME GELESEN statt abgeschrieben.

GEPRÜFT: modi-katalog 150/0 (war 144), modi-verborgen 85/0 (war 80/2),
haus-trennung 97/0, material 159/0, meldungen 8/0, abbrechen-optik 0
Fehler. Dazu grün: an-alle, vorlagen, support, css-klassen,
aufgabenbrett, aufgaben-vorlagen, unterstuetzung, formulare, loeschen,
nachfrage, kalender, chat, leerzustand.

OFFEN UND NICHT ANGEFASST: pruef-breiten meldet auf report.html ein
Berührziel von 27x18 px. Der Link (`class="zurueck"`) ist auf 30
Seiten derselbe und hat gar keinen eigenen Stil; beanstandet wird nur
diese eine Seite, weil dort hinter ihm nur "· Review" steht und die
Prüfung Fließtext-Links erst ab 12 Zeichen Umgebung ausnimmt. Eine
Klasse auf 30 Seiten ohne Prüflauf zu ändern wäre geraten.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-25 00:50:15 +02:00

440 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
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.
/* =====================================================================
SUPPORT (24.09.2026)
Filipe: „jeder kann da probleme von der seite melden und hinweisen.
fotos mit schicken mit einem text. … so einfach wie möglich, will
nichts kompliziertes oder zu viel. kurz und knapp."
EINE SEITE, ZWEI ANSICHTEN. Wer meldet, sieht ein Feld und seine
eigenen Meldungen. DogFather und die rechte Hand sehen den Eingang:
alles, nach Stand sortiert, mit Bild und zwei Knöpfen.
WELCHE ANSICHT GILT, ENTSCHEIDET DER SERVER. Er schickt `leitung`
in der Antwort — diese Datei fragt nicht nach der Rolle. Eine
Rollenprüfung im Browser wäre eine zweite Stelle mit derselben
Regel, und es wäre die, an der man sie umgehen kann.
KEIN innerHTML MIT FREMDEM TEXT. Alles, was jemand geschrieben hat,
geht durch `textContent`. Hier landet Text aus einem Fehlerbericht —
und genau dort steht irgendwann ein `<script>`, weil jemand es
ausprobiert.
===================================================================== */
(() => {
'use strict';
const $ = (id) => document.getElementById(id);
const el = (was, klasse, text) => {
const e = document.createElement(was);
if (klasse) e.className = klasse;
if (text !== undefined) e.textContent = text;
return e;
};
let leitung = false;
let meldungen = [];
let filter = 'offen';
let gewaehltesBild = null;
/* Anzeige-Wörter, keine Regel. Der Server schickt den Schlüssel; was
daraus auf dem Bildschirm wird, ist eine Frage der Sprache. */
const STAND_WORT = {
neu: 'neu',
in_arbeit: 'wird bearbeitet',
erledigt: 'erledigt',
};
function melde(id, text) {
const f = $(id);
if (!f) return;
f.textContent = text || '';
f.hidden = !text;
}
async function hole(pfad, einstellungen) {
const a = await fetch(pfad, { credentials: 'same-origin', ...einstellungen });
if (a.status === 401) { location.href = 'index.html'; throw new Error('weg'); }
return a;
}
/** Was der Server als Fehler geschickt hat — oder ein Ersatz.
*
* Die Antwort wegzuwerfen und „Ging nicht" zu schreiben schickt
* einen auf die Suche nach etwas, das der Server längst gesagt hat. */
async function fehlerText(a, ersatz) {
try {
const j = await a.json();
return typeof j?.fehler === 'string' && j.fehler ? j.fehler : ersatz;
} catch { return ersatz; }
}
/** „vor 3 Tagen" statt eines Zeitstempels.
*
* Bei einer Meldung ist die Frage „wie lange liegt das schon?",
* nicht „wann genau war das?". Das genaue Datum bleibt als Titel. */
function herZeit(iso) {
if (!iso) return '';
const ms = Date.now() - Date.parse(iso);
if (!Number.isFinite(ms)) return '';
const min = Math.floor(ms / 60000);
if (min < 2) return 'gerade eben';
if (min < 60) return `vor ${min} Min.`;
const std = Math.floor(min / 60);
if (std < 24) return `vor ${std} Std.`;
const tage = Math.floor(std / 24);
if (tage === 1) return 'gestern';
if (tage < 31) return `vor ${tage} Tagen`;
const monate = Math.floor(tage / 30.44);
return monate < 12 ? `vor ${monate} Mon.` : 'vor über einem Jahr';
}
function genau(iso) {
if (!iso) return '';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
const z = (n) => String(n).padStart(2, '0');
return `${z(d.getDate())}.${z(d.getMonth() + 1)}.${d.getFullYear()} `
+ `${z(d.getHours())}:${z(d.getMinutes())}`;
}
/* ===================================================================
MELDEN
=================================================================== */
function bildZeigen(datei) {
gewaehltesBild = datei || null;
const name = $('bild-name');
const weg = $('bild-weg');
const vorschau = $('vorschau');
if (!datei) {
name.textContent = '';
weg.hidden = true;
vorschau.hidden = true;
$('vorschau-bild').removeAttribute('src');
return;
}
name.textContent = `${datei.name} · ${Math.round(datei.size / 1024)} KB`;
weg.hidden = false;
/* DIE VORSCHAU KOMMT AUS DER DATEI SELBST, nicht vom Server: Sie
ist noch gar nicht hochgeladen. `URL.createObjectURL` zeigt das
Bild aus dem Arbeitsspeicher — wer sieht, was er anhängt, hängt
nicht versehentlich das falsche an. */
const url = URL.createObjectURL(datei);
const img = $('vorschau-bild');
img.onload = () => URL.revokeObjectURL(url);
img.src = url;
vorschau.hidden = false;
}
function meldenEinrichten() {
$('bild-waehlen').addEventListener('click', () => $('bild').click());
$('bild-weg').addEventListener('click', () => {
$('bild').value = '';
bildZeigen(null);
});
$('bild').addEventListener('change', (e) => {
const datei = e.target.files?.[0] || null;
if (!datei) { bildZeigen(null); return; }
/* DIE GRENZE STEHT HIER NUR ALS FREUNDLICHKEIT. Abgelehnt wird
am Server; dieser Hinweis erspart es, zwölf Megabyte erst
hochzuladen, um danach eine Absage zu lesen. */
if (datei.size > 12 * 1024 * 1024) {
melde('melden-fehler', 'Das Bild ist größer als 12 MB – bitte ein kleineres.');
$('bild').value = '';
bildZeigen(null);
return;
}
melde('melden-fehler', '');
bildZeigen(datei);
});
$('melden').addEventListener('submit', async (e) => {
e.preventDefault();
const text = $('text').value.trim();
if (text.length < 5) {
melde('melden-fehler', 'Schreib bitte kurz, was los ist – ein Satz reicht.');
$('text').focus();
return;
}
const knopf = $('senden');
knopf.disabled = true;
melde('melden-fehler', '');
$('melden-hinweis').textContent = 'wird geschickt …';
try {
/* DER TEXT REIST IM KOPF, das Bild im Rumpf — derselbe Weg wie
bei Chat-Anhängen. Ein Weg für beides, damit es keinen
Zustand „Meldung da, Bild fehlt noch" gibt. */
const kopf = { 'x-text': encodeURIComponent(text), 'x-seite': location.pathname };
const a = await hole('/workspace/api/support', {
method: 'POST',
headers: kopf,
body: gewaehltesBild || new Blob([]),
});
if (!a.ok) {
melde('melden-fehler', await fehlerText(a, 'Das hat nicht geklappt.'));
return;
}
$('text').value = '';
$('bild').value = '';
bildZeigen(null);
$('melden-hinweis').textContent = 'Danke – ist angekommen.';
setTimeout(() => { $('melden-hinweis').textContent = ''; }, 6000);
await laden();
} catch { /* umgeleitet */ } finally {
knopf.disabled = false;
if ($('melden-hinweis').textContent === 'wird geschickt …') {
$('melden-hinweis').textContent = '';
}
}
});
}
/* ===================================================================
EINE MELDUNG ALS KARTE
=================================================================== */
function bildAnhaengen(karte, m) {
if (!m.bild) return;
const kasten = el('div', 's-karte__bild');
const a = el('a');
a.href = m.bild;
a.target = '_blank';
a.rel = 'noopener';
a.title = 'In voller Größe ansehen';
const img = document.createElement('img');
img.src = m.bild;
img.alt = 'Bild zur Meldung';
img.loading = 'lazy';
/* BREITE UND HÖHE STEHEN DRAN, wenn der Server sie kennt: Dann
reserviert der Browser den Platz, bevor das Bild da ist, und die
Liste springt beim Laden nicht. */
if (m.bild_breite > 0 && m.bild_hoehe > 0) {
img.width = m.bild_breite;
img.height = m.bild_hoehe;
}
a.append(img);
kasten.append(a);
karte.append(kasten);
}
function karteBauen(m) {
const k = el('article', 's-karte');
k.dataset.stand = m.stand;
k.id = `meldung-${m.id}`;
const kopf = el('div', 's-karte__kopf');
const marke = el('span', 's-marke', STAND_WORT[m.stand] || m.stand);
marke.dataset.stand = m.stand;
kopf.append(marke);
if (leitung && m.von) {
kopf.append(el('span', 's-karte__von', m.von));
}
const zeit = el('span', 's-karte__zeit', herZeit(m.erstellt));
zeit.title = genau(m.erstellt);
kopf.append(zeit);
k.append(kopf);
/* `textContent` mit erhaltenen Zeilenumbrüchen (das CSS setzt
`white-space: pre-wrap`). Kein innerHTML, siehe der Kopf. */
k.append(el('p', 's-karte__text', m.text));
bildAnhaengen(k, m);
if (leitung && m.seite) {
const wo = el('p', 's-karte__wo', 'gemeldet auf ' + m.seite);
if (m.geraet) wo.title = m.geraet;
k.append(wo);
}
if (m.antwort) {
const ant = el('div', 's-antwort');
ant.append(el('span', 's-antwort__schild', 'Antwort'));
ant.append(el('p', 's-antwort__text', m.antwort));
k.append(ant);
}
if (leitung && m.stand !== 'erledigt') k.append(knoepfeFuerLeitung(m));
return k;
}
/** Die zwei Handgriffe der Leitung.
*
* ZWEI, NICHT FÜNF. „Ich kümmere mich" und „erledigt". Eine
* Zuweisung an eine bestimmte Person, Prioritäten, Kategorien — das
* wäre ein Ticketsystem für zwei Menschen, die nebeneinander
* sitzen. */
function knoepfeFuerLeitung(m) {
const reihe = el('div', 's-karte__tun');
if (m.stand === 'neu') {
const dran = el('button', 'schritt', 'Ich kümmere mich');
dran.type = 'button';
dran.addEventListener('click', () => aendern(m, 'in_arbeit', ''));
reihe.append(dran);
}
const fertig = el('button', 'schritt', 'Erledigt …');
fertig.type = 'button';
fertig.addEventListener('click', async () => {
/* WER ZUMACHT, SAGT WARUM. Der Melder sieht nur diesen Satz —
ohne ihn weiß er nicht, ob etwas behoben wurde oder ob
niemand Zeit hatte. Der Server verlangt ihn ohnehin; hier
wird er gefragt, statt eine Absage zu kassieren. */
const antwort = await window.frageNach?.({
titel: 'Meldung erledigen?',
was: 'Sie verschwindet aus dem offenen Eingang.',
bleibt: 'Der Melder bekommt eine Nachricht mit deinem Satz – '
+ 'das ist alles, was er davon sieht.',
ja: 'Erledigt',
grund: 'Was ist daraus geworden?',
grundPflicht: true,
grundMin: 3,
platzhalter: 'z. B. Behoben, der Knopf geht wieder.',
});
if (!antwort) return;
await aendern(m, 'erledigt', antwort.grund || '');
});
reihe.append(fertig);
return reihe;
}
async function aendern(m, stand, antwort) {
melde('eingang-fehler', '');
try {
const a = await hole(`/workspace/api/support/${m.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ stand, antwort }),
});
if (!a.ok) {
melde('eingang-fehler', await fehlerText(a, 'Das hat nicht geklappt.'));
return;
}
await laden();
} catch { /* umgeleitet */ }
}
/* ===================================================================
ZEICHNEN
=================================================================== */
function bilanzZeichnen() {
const ziel = $('bilanz');
ziel.textContent = '';
const zahl = (stand) => meldungen.filter((m) => m.stand === stand).length;
for (const [stand, wort] of [
['neu', 'neu'], ['in_arbeit', 'in Arbeit'], ['erledigt', 'erledigt'],
]) {
const kasten = el('div', 's-bilanz__feld');
kasten.dataset.stand = stand;
/* OB DAS FELD LEER IST, steht als Merkmal daran -- das Stilblatt
faerbt die "neu"-Null damit nicht mehr rot. Gerechnet wird es
hier und nicht im CSS: Dort gibt es keine Zahlen, nur Text,
und ":empty" traefe eine "0" nicht. */
const n = zahl(stand);
if (!n) kasten.dataset.leer = 'ja';
kasten.append(el('span', 's-bilanz__zahl', String(n)));
kasten.append(el('span', 's-bilanz__wort', wort));
ziel.append(kasten);
}
}
function filterZeichnen() {
const ziel = $('filter');
ziel.textContent = '';
const zaehl = (welche) => welche === 'alle'
? meldungen.length
: welche === 'offen'
? meldungen.filter((m) => m.stand !== 'erledigt').length
: meldungen.filter((m) => m.stand === welche).length;
for (const [wert, wort] of [
['offen', 'Offen'], ['erledigt', 'Erledigt'], ['alle', 'Alle'],
]) {
const k = el('button', 's-filter__knopf');
k.type = 'button';
k.dataset.wert = wert;
k.setAttribute('aria-pressed', String(filter === wert));
k.append(el('span', null, wort));
k.append(el('span', 's-filter__zahl', String(zaehl(wert))));
k.addEventListener('click', () => { filter = wert; listeZeichnen(); });
ziel.append(k);
}
}
function listeZeichnen() {
if (leitung) {
filterZeichnen();
const ziel = $('eingang-liste');
ziel.textContent = '';
const drin = meldungen.filter((m) => filter === 'alle' ? true
: filter === 'offen' ? m.stand !== 'erledigt' : m.stand === filter);
if (!drin.length) {
/* EIN LEERER EINGANG IST EINE GUTE NACHRICHT und soll auch so
aussehen — nicht wie eine Seite, die nicht fertig geladen
hat. */
ziel.append(el('p', 's-leer', filter === 'offen'
? 'Nichts offen. Alles abgearbeitet.'
: 'Hier steht nichts.'));
return;
}
for (const m of drin) ziel.append(karteBauen(m));
return;
}
const block = $('meine-block');
const ziel = $('meine-liste');
ziel.textContent = '';
$('meine-zahl').textContent = String(meldungen.length);
block.hidden = meldungen.length === 0;
for (const m of meldungen) ziel.append(karteBauen(m));
}
async function laden() {
try {
const a = await hole('/workspace/api/support');
if (!a.ok) { melde('eingang-fehler', 'Die Meldungen ließen sich nicht laden.'); return; }
const r = await a.json();
leitung = r.leitung === true;
meldungen = Array.isArray(r.meldungen) ? r.meldungen : [];
/* DIE SEITE STELLT SICH AUF DEN UM, DER SIE ÖFFNET. */
$('eingang-block').hidden = !leitung;
$('meine-block').hidden = leitung || meldungen.length === 0;
if (leitung) {
$('seiten-titel').textContent = 'Support';
$('seiten-unter').textContent =
'Was aus dem Team und der Community gemeldet wurde. '
+ 'Das Älteste steht oben – was am längsten wartet, gehört nach vorn.';
/* MELDEN KANN DIE LEITUNG AUCH. Filipe findet selbst Fehler,
und ein Weg, der für ihn nicht gilt, ist ein zweiter Weg,
den er dann anders benutzt. Das Feld rutscht nur nach
unten: Wer den Eingang aufmacht, will zuerst sehen, was
liegt. */
/* WIRKLICH VERSCHIEBEN, nicht nur eine Klasse setzen.
`order` wirkt nur in einem Flex- oder Rasterbehaelter, und
`.inhalt` ist keiner -- nachgemessen in start.css. Ein
`append` an denselben Elternteil bewegt den Kasten ans
Ende, ohne ihn neu zu bauen: Der Text im Feld bleibt
stehen, falls gerade jemand tippt. */
const kasten = $('melden-block');
kasten.classList.add('s-melden--unten');
kasten.parentElement?.append(kasten);
bilanzZeichnen();
}
listeZeichnen();
/* AUS EINER BENACHRICHTIGUNG KOMMT MAN MIT ?meldung=… — dann
gehört der Blick genau dorthin und nicht an den Seitenanfang. */
const wohin = new URLSearchParams(location.search).get('meldung');
if (wohin) {
const karte = document.getElementById(`meldung-${Number(wohin)}`);
if (karte) {
karte.scrollIntoView({ behavior: window.sanft?.() || 'auto', block: 'center' });
karte.dataset.hervor = 'ja';
}
}
} catch { /* umgeleitet */ }
}
meldenEinrichten();
laden();
})();