Hochladen sieht man jetzt, Leerzustaende sagen was hingehoert

DREI BLOECKE AUS DEM PERFEKTIONSLAUF.

1. HOCHLADEN MIT FORTSCHRITT UND ABBRUCH
   Alle fuenf Wege (Dateien, Chat-Anhang, Wissen-PDF, Aufgaben-Anhang,
   Profilbild) benutzten `fetch`. Das kann beim SENDEN nicht sagen, wie
   weit es ist -- sichtbar war "wird hochgeladen …", von der ersten bis
   zur letzten Sekunde gleich. Bei 40 MB im Mobilfunknetz zwei Minuten.
   Wer das sieht, drueckt noch einmal und laedt dieselbe Datei doppelt.
   Neu: workspace/assets/js/hochladen.js (XMLHttpRequest, das Einzige,
   was `upload.onprogress` kann) samt gemeinsamer Anzeige.
   Drei Ausgaenge: fertig / abgebrochen / schiefgegangen -- und ein
   Abbruch ist KEIN Fehler und bekommt keine rote Meldung.

   DABEI AUFGEFALLEN: KEINE EINZIGE PRUEFUNG im Haus laedt eine Datei
   ueber die Oberflaeche hoch. Der ganze Umbau waere gruen gewesen,
   ohne dass ein Byte je den Weg der Nutzer gegangen waere.
   Neu: server/pruef-hochladen.mjs -- 18/0, mit echter Datei.
   Zwei Irrtuemer beim Bauen, beide gemessen statt vermutet:
     Ohne Drosselung gibt es auf localhost EINEN Fortschritt-Stand.
       Das sah nach Befund aus und war keiner. Jetzt 2 MBit/s ueber
       CDP -- derselbe Verlauf wie bei den Modis im Mobilfunk, 57
       gemessene Zwischenstaende.
     Gewartet wurde auf den Dateinamen "irgendwo im Dokument" -- der
       stand auch im Fortschrittsbalken. Die Bedingung war erfuellt,
       bevor etwas angekommen war.

2. LEERZUSTAENDE
   Elf von 18 Brettern fielen auf "Noch kein Eintrag in diesem
   Bereich" zurueck. Am ersten Tag ist ALLES leer -- wer da achtzehn
   Bretter oeffnet und achtzehnmal denselben Satz liest, lernt nichts
   ueber die Bretter, sondern dass das System kaputt ist. Jeder Satz
   sagt jetzt, was hier hingehoert UND was der naechste Schritt ist.
   Neu: server/pruef-leerzustand.mjs -- 13/0, leitet die Bretter aus
   BEREICHE ab; ein neunzehntes ohne Satz macht sie rot.

3. ABMELDEN UND KONTRASTMODUS
   Abmelden war am Handy ein 44-Pixel-Zeichen neben Glocke und Suche,
   sofort wirksam. Teurer als es aussieht: Zum Wiederanmelden braucht
   man den Zugangscode, und den gibt es EINMAL. Jetzt mit Rueckfrage,
   die genau das sagt -- und dazu, dass Zumachen reicht (12 Stunden).

   Kontrastmodus: 68 Regeln zeigen einen Zustand NUR ueber Farbe
   (35x aria-pressed, 33x data-an). Der Modus ersetzt alle Farben und
   entfernt box-shadow -- gedrueckt sah aus wie nicht gedrueckt.
   14 CSS-Dateien hatten gar keinen Block. Statt 14 Bloecke zu pflegen
   eine Regel in gate.css, die den ZUSTAND trifft statt die Datei.
   Gemessen mit forcedColors: active -- vorher ununterscheidbar,
   jetzt `solid 2px Highlight`.
   Was seinen Zustand als WORT traegt (.marke-status, .t-stufe,
   .spalte), braucht nichts -- nachgesehen, nicht vermutet.

PRUEFUNGEN, DIE AUF confirm() WARTETEN: Fuenf Dateien benutzten
`seite.once("dialog", d => d.accept())`. Playwright faengt confirm()
selbst ab, einen <dialog> nicht -- pruef-chat-anhaenge meldete acht
Fehler, keiner davon im Code. Neu: server/helfer-nachfrage.mjs, der
beide Wege kennt (auch den Notnagel fuer Safari vor 15.4).
pruef-chat-anhaenge, -ausbau, -optik und pruef-code wieder gruen.

hilfeAufraeumen bleibt ausgeschaltet -- das loescht echte Daten und
ist Filipes Entscheidung.
This commit is contained in:
2026-09-19 20:16:23 +02:00
parent c516aad4ed
commit 1207b79a33
54 changed files with 1665 additions and 544 deletions
+46
View File
@@ -2932,3 +2932,49 @@ body.gate--crew .rollen .feldschild::before {
color: var(--text-leise);
margin-bottom: 6px;
}
/* =====================================================================
KONTRASTMODUS: EIN GEDRÜCKTER SCHALTER MUSS GEDRÜCKT AUSSEHEN
(19.09.2026)
In Windows' Kontrastmodus (`forced-colors: active`) ersetzt das
System alle Farben und entfernt `box-shadow` ganz. Was seinen
Zustand NUR über Hintergrundfarbe oder einen Schatten-Rand zeigt,
sieht danach aus wie jeder andere Schalter.
Gemessen am 19.09.2026: **68 Regeln** im Haus zeigen einen Zustand
so -- 35× `[aria-pressed="true"]`, 33× `[data-an="ja"]`. Betroffen
sind Filterreihen, Empfängerauswahl, Merkmale, Stufen.
VIERZEHN CSS-DATEIEN hatten gar keinen Kontrastmodus-Block. Die
naheliegende Reaktion wäre gewesen, vierzehn Blöcke anzuhängen --
und damit eine Liste zu pflegen, die beim fünfzehnten Bereich
wieder unvollständig ist. Eine Regel, die den ZUSTAND trifft statt
die Datei, kann nicht veralten.
NICHT über die Hintergrundfarbe (die darf das System ersetzen),
sondern über eine Kante aus einer SYSTEMFARBE: `Highlight` ist die
Farbe, die der Nutzer selbst für „ausgewählt" eingestellt hat.
ABSICHTLICH KEIN `forced-color-adjust: none` -- damit bliebe unsere
eigene Farbe stehen, und genau die kann der Nutzer ja nicht lesen.
Das wäre, als würde man die Barrierefreiheit wegoptimieren, um die
Gestaltung zu retten.
Was seinen Zustand ohnehin als WORT trägt, braucht hier nichts:
`.marke-status` schreibt „Entwurf"/„Freigegeben" hin, `.t-stufe`
hat `.t-stufe__name`, jede `.spalte` hat ihre Überschrift. Dort ist
die Farbe Zierde, nicht Aussage -- nachgesehen, nicht vermutet.
===================================================================== */
@media (forced-colors: active) {
[aria-pressed="true"],
[data-an="ja"],
[data-anders="ja"],
[aria-current="page"],
[aria-current="true"] {
outline: 2px solid Highlight;
/* Nach INNEN, damit die Kante nichts überdeckt und das Element
nicht springt, wenn es gedrückt wird. */
outline-offset: -2px;
}
}
+57
View File
@@ -815,3 +815,60 @@
/* Die Knöpfe unten bekommen Luft nach oben -- sonst sitzt "Löschen"
direkt unter dem Satz, der davor warnt. */
.nachfrage .dialog__knoepfe { margin-top: 6px; }
/* ---- Hochladen, das man sehen kann --------------------------------
Gebaut von `hochladen.js`, benutzt an fünf Stellen (Dateien,
Chat-Anhang, Wissen-PDF, Aufgaben-Anhang, Profilbild). Steht hier
und nicht in fünf Seitendateien -- aus demselben Grund wie der
Dialog darüber: Hochladen soll überall gleich aussehen. */
.hochlade { margin: 8px 0; display: grid; gap: 6px; }
.hochlade__kopf {
display: flex; align-items: center; justify-content: space-between;
gap: 10px; flex-wrap: wrap;
}
.hochlade__text { font-size: .9rem; color: var(--text-leise); }
/* 44 Pixel, damit ein Daumen den Abbruch trifft -- gerade hier, denn
wer abbricht, hat es meist eilig. */
.hochlade__abbruch {
min-height: 44px; min-width: 44px; padding: 0 14px;
font: inherit; font-size: .86rem;
color: var(--text-leise);
background: transparent;
border: 1px solid var(--rand);
border-radius: var(--radius, 10px);
cursor: pointer;
}
.hochlade__abbruch:hover:not(:disabled) { color: var(--text); border-color: var(--akzent); }
.hochlade__abbruch:disabled { opacity: .55; cursor: default; }
/* Ein natives <progress>: Vorleseprogramme kennen es, und ohne
`value` zeigt es von allein den unbestimmten Lauf -- genau der Fall,
in dem der Browser die Gesamtgröße nicht kennt. */
.hochlade__balken {
-webkit-appearance: none; appearance: none;
width: 100%; height: 6px; border: none;
background: rgba(150, 186, 220, .14);
border-radius: 999px; overflow: hidden;
}
.hochlade__balken::-webkit-progress-bar { background: rgba(150, 186, 220, .14); }
.hochlade__balken::-webkit-progress-value {
background: var(--akzent); border-radius: 999px;
transition: width .15s linear;
}
.hochlade__balken::-moz-progress-bar { background: var(--akzent); border-radius: 999px; }
/* Der letzte Satz nach einem Abbruch. Ruhig, nicht rot: Wer selbst
abbricht, hat keinen Fehler gemacht. */
.hochlade--schluss .hochlade__text { color: var(--text-still); }
/* Kein Wandern, wenn jemand Bewegung abbestellt hat. Der Balken
springt dann einfach auf den neuen Wert. */
@media (prefers-reduced-motion: reduce) {
.hochlade__balken::-webkit-progress-value { transition: none; }
}
@media (forced-colors: active) {
.hochlade__balken { border: 1px solid CanvasText; }
.hochlade__abbruch { border: 1px solid ButtonText; color: ButtonText; }
}
+22 -11
View File
@@ -1722,30 +1722,41 @@
feld.addEventListener('change', async () => {
const f = feld.files?.[0];
if (!f || !fuerAufgabe) return;
sagen('Wird hochgeladen …');
/* MIT FORTSCHRITT UND ABBRUCH (19.09.2026). `sagen('Wird
hochgeladen …')` stand hier von der ersten bis zur letzten
Sekunde unveraendert. */
let lauf = null, anzeige = null;
try {
const a = await fetch('/workspace/api/dateien', {
method: 'POST',
credentials: 'same-origin',
headers: {
anzeige = hochladeAnzeige(stand, f.name, () => lauf && lauf.abbrechen());
lauf = hochladen('/workspace/api/dateien', f, {
kopf: {
'Content-Type': f.type || 'application/octet-stream',
'x-dateiname': encodeURIComponent(f.name),
'x-aufgabe': String(fuerAufgabe),
},
body: f,
beiFortschritt: anzeige.fortschritt,
beiVerarbeitung: anzeige.verarbeiten,
});
if (!a.ok) {
const j = await a.json().catch(() => ({}));
sagen(j.fehler === 'zu_gross'
? 'Die Datei ist zu groß.'
: (sagWas(j.fehler, 'Das hat nicht geklappt.')));
const ergebnis = await lauf.fertig;
if (ergebnis.abgebrochen) {
anzeige.abschluss('Abgebrochen – nichts wurde angehängt.');
anzeige = null;
return;
}
if (!ergebnis.ok) {
anzeige.weg(); anzeige = null;
sagen(ergebnis.netz ? 'Keine Verbindung.'
: (ergebnis.daten?.fehler === 'zu_gross' ? 'Die Datei ist zu groß.'
: sagWas(ergebnis.daten?.fehler, 'Das hat nicht geklappt.')));
return;
}
anzeige.weg(); anzeige = null;
sagen('Angehängt.');
await window.anhaengeLaden(fuerAufgabe);
} catch {
sagen('Keine Verbindung.');
} finally {
if (anzeige) anzeige.weg();
/* Zuruecksetzen, sonst loest dieselbe Datei kein zweites Mal aus. */
feld.value = '';
}
+28 -10
View File
@@ -2088,29 +2088,44 @@
schicktGerade = true;
$('anhang-auf').disabled = true;
melde(`„${datei.name}“ wird geschickt …`, 'lauf');
/* Die alte Laufmeldung faellt weg -- der Balken sagt dasselbe und
zusaetzlich, WIE WEIT es ist. Zwei Anzeigen fuer dieselbe Sache
waeren Rauschen. */
melde('');
let anzeige = null;
/* Ein angefangener Begleitsatz geht mit -- wer erst tippt und dann
das Bild anhängt, will beides in einer Nachricht. */
const begleittext = $('text').value.trim();
try {
const a = await hole(`/workspace/api/chat/raeume/${offen.id}/anhang`, {
method: 'POST',
headers: {
/* MIT FORTSCHRITT UND ABBRUCH (19.09.2026). Ein Foto vom Handy
ist schnell vier Megabyte gross; ohne Balken sieht der Chat
in der Zeit aus, als sei er eingefroren. */
let lauf = null;
anzeige = hochladeAnzeige($('anhang-fortschritt'), datei.name || 'Anhang',
() => lauf && lauf.abbrechen());
lauf = hochladen(`/workspace/api/chat/raeume/${offen.id}/anhang`, datei, {
kopf: {
'Content-Type': datei.type,
/* Kopfzeilen sind streng genommen nur ASCII -- ohne die
Kodierung käme aus "Größe.pdf" unterwegs Unsinn. */
'X-Dateiname': encodeURIComponent(datei.name || 'anhang'),
'X-Text': encodeURIComponent(begleittext),
},
body: datei,
beiFortschritt: anzeige.fortschritt,
beiVerarbeitung: anzeige.verarbeiten,
});
if (!a.ok) {
let grund = 'Ging nicht.';
try { grund = (await a.json()).fehler || grund; } catch { /* nichts Lesbares */ }
melde(grund);
const ergebnis = await lauf.fertig;
if (ergebnis.abgebrochen) {
anzeige.abschluss('Abgebrochen – nichts wurde geschickt.');
anzeige = null;
return;
}
const { nachricht } = await a.json();
if (!ergebnis.ok) {
melde(ergebnis.netz ? 'Keine Verbindung – der Anhang wurde nicht geschickt.'
: sagWas(ergebnis.daten?.fehler, 'Ging nicht.'));
return;
}
const { nachricht } = e.daten;
/* Nur eintragen, wenn sie nicht schon über den Strom kam --
sonst stünde dasselbe Foto zweimal da. */
if (!nachrichten.some((x) => x.id === nachricht.id)) {
@@ -2127,6 +2142,9 @@
} catch {
melde('Keine Verbindung – der Anhang wurde nicht geschickt.');
} finally {
/* Nur wegnehmen, wenn nicht gerade der Abbruch-Satz steht --
der bekommt seine vier Sekunden. */
if (anzeige) anzeige.weg();
schicktGerade = false;
$('anhang-auf').disabled = false;
}
+24 -7
View File
@@ -101,8 +101,13 @@
melde(`„${datei.name}" ist ${groesse(datei.size)} groß – erlaubt sind ${groesse(maxBytes)}.`);
return;
}
$('fortschritt').hidden = false;
$('fortschritt-text').textContent = `„${datei.name}" wird hochgeladen …`;
/* MIT FORTSCHRITT UND ABBRUCH (19.09.2026). Vorher stand hier von
der ersten bis zur letzten Sekunde derselbe Satz -- bei 40 MB im
Mobilfunknetz zwei Minuten lang. Wer das sieht, drueckt noch
einmal und laedt dieselbe Datei doppelt hoch. */
let lauf = null, abgebrochen = false;
const anzeige = hochladeAnzeige($('fortschritt'), datei.name,
() => lauf && lauf.abbrechen());
try {
const kopf = {
'Content-Type': datei.type || 'application/octet-stream',
@@ -119,10 +124,20 @@
.map((b) => b.dataset.id);
if (gewaehlt.length) kopf['X-Personen'] = gewaehlt.join(',');
const a = await hole('/workspace/api/dateien', { method: 'POST', headers: kopf, body: datei });
if (!a.ok) {
const d = await a.json().catch(() => ({}));
melde(sagWas(d.fehler, 'Hochladen hat nicht geklappt.'));
lauf = hochladen('/workspace/api/dateien', datei, {
kopf, beiFortschritt: anzeige.fortschritt, beiVerarbeitung: anzeige.verarbeiten,
});
const ergebnis = await lauf.fertig;
/* Ein selbst ausgeloester Abbruch ist KEIN Fehler und bekommt
keine rote Meldung -- wer abbricht, weiss, was er getan hat. */
if (ergebnis.abgebrochen) {
anzeige.abschluss('Abgebrochen – es wurde nichts hochgeladen.');
abgebrochen = true;
return;
}
if (!ergebnis.ok) {
melde(ergebnis.netz ? 'Keine Verbindung – die Datei wurde nicht hochgeladen.'
: sagWas(ergebnis.daten?.fehler, 'Hochladen hat nicht geklappt.'));
return;
}
/* Auswahl nach dem Hochladen loeschen -- sonst bekaeme die
@@ -131,7 +146,9 @@
await laden();
wahlHinweis();
} catch { /* umgeleitet */ } finally {
$('fortschritt').hidden = true;
/* Nicht wegnehmen, wenn gerade der Abbruch-Satz steht -- der
soll seine vier Sekunden bekommen. */
if (!abgebrochen) anzeige.weg();
$('feld-datei').value = '';
}
}
+240
View File
@@ -0,0 +1,240 @@
/* =====================================================================
HOCHLADEN, DAS MAN SEHEN KANN — window.hochladen()
Filipe, 19.09.2026: „Ladezustände … Man soll auf die Seite kommen,
sofort verstehen, was man machen kann."
---------------------------------------------------------------------
WARUM `fetch` HIER NICHT REICHT
Alle fünf Hochlade-Wege im Haus (Dateien, Chat-Anhang, Wissen-PDF,
Aufgaben-Anhang, Profilbild) benutzten `fetch`. Das funktioniert --
aber `fetch` kann beim SENDEN nicht sagen, wie weit es ist. Es gibt
`response.body` zum Lesen, nichts zum Schreiben.
Sichtbar war deshalb nur „wird hochgeladen …", und zwar von der
ersten bis zur letzten Sekunde gleich. Bei einer 40-MB-Datei im
Mobilfunknetz sind das zwei Minuten, in denen nichts darauf
hindeutet, dass überhaupt etwas passiert. Wer das sieht, drückt ein
zweites Mal -- und lädt dieselbe Datei doppelt hoch.
`XMLHttpRequest` ist älter und kann genau das eine, was hier fehlt:
`upload.onprogress`. Deshalb steht es hier, und nur hier.
---------------------------------------------------------------------
UND ABBRECHEN GEHÖRT DAZU
Ein Fortschrittsbalken ohne Abbruch ist eine Zumutung: Wer merkt,
dass er die falsche Datei erwischt hat, kann bis heute nur die Seite
neu laden -- und weiß dann nicht, ob die Datei trotzdem angekommen
ist. `xhr.abort()` bricht die Verbindung wirklich ab; was schon
gesendet wurde, verwirft der Server, weil er den Körper nie
vollständig bekommt.
---------------------------------------------------------------------
DREI AUSGÄNGE
fertig -> { ok: true, antwort, daten }
abgebrochen -> { ok: false, abgebrochen: true }
schiefgegangen -> { ok: false, status, daten } (mit Grund)
Der mittlere ist wichtig: Ein Abbruch ist KEIN Fehler und darf keine
rote Meldung erzeugen. Wer selbst abbricht, weiß, was er getan hat.
401 wird behandelt wie überall im Haus -- zurück zur Anmeldung.
---------------------------------------------------------------------
AUFRUF
const lauf = hochladen('/workspace/api/dateien', datei, {
kopf: { 'X-Dateiname': encodeURIComponent(datei.name) },
beiFortschritt: (anteil, gesendet, gesamt) => { ... },
});
abbruchKnopf.onclick = () => lauf.abbrechen();
const ergebnis = await lauf.fertig; // NICHT `e` -- in einem
// Ereignis-Handler wuerde das den Parameter verdecken, und
// `e.target.value` zeigte danach auf das Hochlade-Ergebnis.
===================================================================== */
(function () {
'use strict';
/** Bytes als Text, den ein Mensch liest. Dieselbe Rechnung wie in
* `dateien.js` -- damit nicht an zwei Stellen zwei Zahlen stehen. */
function groesse(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return Math.round(bytes / 1024) + ' KB';
return (bytes / 1024 / 1024).toFixed(1).replace('.', ',') + ' MB';
}
window.groesseInWorten = groesse;
window.hochladen = function hochladen(pfad, daten, o) {
o = o || {};
const xhr = new XMLHttpRequest();
let abgebrochen = false;
const fertig = new Promise((liefere) => {
xhr.open(o.art || 'POST', pfad, true);
xhr.withCredentials = true;
for (const [k, v] of Object.entries(o.kopf || {})) xhr.setRequestHeader(k, v);
/* DER FORTSCHRITT. `lengthComputable` ist nicht selbstverständlich:
Bei einem Strom ohne bekannte Länge kann der Browser nichts
ausrechnen. Dann bleibt der Anteil `null` -- und der Aufrufer
zeigt einen laufenden Balken statt einer falschen Zahl. Eine
erfundene Prozentzahl wäre schlimmer als gar keine. */
xhr.upload.addEventListener('progress', (e) => {
if (!o.beiFortschritt) return;
const anteil = e.lengthComputable && e.total > 0 ? e.loaded / e.total : null;
o.beiFortschritt(anteil, e.loaded, e.total);
});
/* Wenn alles gesendet ist, der Server aber noch rechnet (bei einem
PDF wird geprüft, bei einem Bild verkleinert), steht der Balken
auf 100 % und es passiert scheinbar nichts mehr. Deshalb sagt
der Aufrufer ab hier "wird verarbeitet". */
xhr.upload.addEventListener('load', () => {
if (o.beiVerarbeitung) o.beiVerarbeitung();
});
xhr.addEventListener('load', () => {
if (xhr.status === 401) { location.assign('/workspace/'); return; }
let inhalt = null;
try { inhalt = JSON.parse(xhr.responseText); } catch { /* kein JSON */ }
liefere(xhr.status >= 200 && xhr.status < 300
? { ok: true, status: xhr.status, daten: inhalt }
: { ok: false, status: xhr.status, daten: inhalt });
});
/* ABBRUCH IST KEIN FEHLER. Ohne diese Unterscheidung bekäme der
Nutzer nach seinem eigenen Klick eine rote Meldung. */
xhr.addEventListener('abort', () => {
liefere({ ok: false, abgebrochen: true });
});
xhr.addEventListener('error', () => {
liefere({ ok: false, status: 0, netz: true });
});
xhr.addEventListener('timeout', () => {
liefere({ ok: false, status: 0, zeit: true });
});
/* Keine Frist von Hand: Eine große Datei in einem langsamen Netz
braucht ihre Zeit, und eine feste Zahl wäre wieder "eine
Rechnung von gestern". Der Browser bricht von selbst ab, wenn
die Verbindung wirklich weg ist -- das meldet `error`. */
xhr.send(daten);
});
return {
fertig,
abbrechen() {
if (abgebrochen) return;
abgebrochen = true;
try { xhr.abort(); } catch { /* war schon fertig */ }
},
get laeuft() { return xhr.readyState > 0 && xhr.readyState < 4; },
};
};
})();
/* =====================================================================
UND DIE ANZEIGE DAZU — window.hochladeAnzeige()
Die Mechanik oben nützt nichts, solange jede der fünf Stellen ihren
eigenen Balken baut. Dann sieht Hochladen im Chat anders aus als in
den Dateien, und die Abbruchtaste sitzt woanders -- genau das, was
Filipe mit „weniger Chaos, mehr Struktur" meint.
Deshalb baut diese Funktion die Anzeige EINMAL, in ein vorhandenes
Element hinein, und gibt drei Handgriffe zurück.
DREI ZUSTÄNDE, weil es drei gibt:
senden -> Balken mit Prozent, Abbrechen möglich
verarbeiten -> Balken voll, kein Prozent mehr, kein Abbruch
(der Server prüft/verkleinert -- abbrechen würde
jetzt nur eine halbe Datei hinterlassen)
weg -> alles verschwindet
Der mittlere Zustand ist der, den man am leichtesten vergisst: Der
Balken steht auf 100 %, und für zwei, drei Sekunden passiert nichts.
Ohne ein Wort dafür sieht das aus wie ein Hänger.
===================================================================== */
(function () {
'use strict';
window.hochladeAnzeige = function hochladeAnzeige(ziel, dateiname, beiAbbruch) {
if (!ziel) return { fortschritt() {}, verarbeiten() {}, weg() {} };
ziel.hidden = false;
ziel.textContent = '';
ziel.classList.add('hochlade');
const kopf = document.createElement('div');
kopf.className = 'hochlade__kopf';
const text = document.createElement('span');
text.className = 'hochlade__text';
text.textContent = `„${dateiname}" wird hochgeladen …`;
kopf.append(text);
let abbruch = null;
if (beiAbbruch) {
abbruch = document.createElement('button');
abbruch.type = 'button';
abbruch.className = 'hochlade__abbruch';
abbruch.textContent = 'Abbrechen';
abbruch.addEventListener('click', () => {
abbruch.disabled = true;
text.textContent = 'wird abgebrochen …';
beiAbbruch();
});
kopf.append(abbruch);
}
/* Ein <progress> und kein eigenes <div>: Vorleseprogramme kennen
es, es sagt von selbst „Fortschritt 40 Prozent", und ohne
`value` zeigt es von allein den unbestimmten Lauf -- genau der
Fall, in dem der Browser die Gesamtgröße nicht kennt. */
const balken = document.createElement('progress');
balken.className = 'hochlade__balken';
balken.max = 1;
balken.removeAttribute('value');
ziel.append(kopf, balken);
return {
fortschritt(anteil, gesendet, gesamt) {
if (anteil === null || anteil === undefined) {
balken.removeAttribute('value');
text.textContent = `„${dateiname}" wird hochgeladen …`;
return;
}
balken.value = anteil;
const g = window.groesseInWorten;
text.textContent = `„${dateiname}" – ${Math.round(anteil * 100)} %`
+ (gesamt ? ` von ${g(gesamt)}` : '');
},
verarbeiten() {
balken.value = 1;
text.textContent = 'ist da – wird noch geprüft …';
if (abbruch) abbruch.remove();
},
weg() {
ziel.hidden = true;
ziel.textContent = '';
ziel.classList.remove('hochlade', 'hochlade--schluss');
},
/** Ein letzter, NEUTRALER Satz -- fuer den selbst ausgeloesten
* Abbruch. Er gehoert nicht in die rote Fehlerzeile: Wer selbst
* abbricht, hat keinen Fehler gemacht, und eine rote Meldung
* dafuer lehrt nur, rote Meldungen zu ignorieren.
* Verschwindet von allein -- ein Hinweis, den man wegklicken
* muss, ist ein Hindernis. */
abschluss(satz) {
balken.remove();
if (abbruch) abbruch.remove();
text.textContent = satz;
ziel.classList.add('hochlade--schluss');
setTimeout(() => { if (text.textContent === satz) this.weg(); }, 4000);
},
};
};
})();
+22
View File
@@ -940,6 +940,28 @@
}
if (abmelden) {
abmelden.addEventListener('click', async () => {
/* RUECKFRAGE, UND ZWAR EINE MIT INHALT (19.09.2026).
------------------------------------------------------------
Der Knopf sitzt am Handy als 44-Pixel-Zeichen direkt neben
Glocke und Suche. Ein Fehlgriff war bis heute sofort
wirksam -- und er ist teurer, als er aussieht:
Zum Wiederanmelden braucht man den persoenlichen Zugangscode.
Den bekommt jeder EINMAL. Wer ihn nicht aufgeschrieben hat,
kommt ohne einen neuen Code von DogFather nicht zurueck.
Deshalb steht genau das im Kasten -- nicht "Sicher?". Und
daneben die Auskunft, dass Zuklappen reicht: Die Sitzung
haelt 12 Stunden, abmelden muss man sich zum Weggehen gar
nicht. */
if (!await frageNach({
titel: 'Abmelden?',
was: 'Zum Wiederanmelden brauchst du deinen persönlichen '
+ 'Zugangscode.',
bleibt: 'Wenn du nur kurz weg willst: Einfach zumachen reicht – '
+ 'du bleibst 12 Stunden angemeldet.',
ja: 'Abmelden',
})) return;
abmelden.disabled = true;
try {
await fetch('/workspace/api/abmelden', {
+32 -8
View File
@@ -134,15 +134,36 @@
e.target.value = '';
return;
}
sage('Bild wird hochgeladen …');
/* MIT FORTSCHRITT UND ABBRUCH (19.09.2026). Ein Foto direkt aus
der Handykamera hat schnell vier Megabyte -- genau die Groesse,
bei der man nicht mehr weiss, ob noch etwas passiert. */
let lauf = null;
const anzeige = hochladeAnzeige($('sb-stand'), datei.name || 'Bild',
() => lauf && lauf.abbrechen());
let stehenLassen = false;
try {
const a = await hole('/workspace/api/steckbrief/bild', {
method: 'POST',
headers: { 'Content-Type': 'application/octet-stream' },
body: datei,
lauf = hochladen('/workspace/api/steckbrief/bild', datei, {
kopf: { 'Content-Type': 'application/octet-stream' },
beiFortschritt: anzeige.fortschritt,
/* Beim Bild rechnet der Server danach wirklich -- er
verkleinert es. Ohne diesen Zwischenstand stuende der
Balken auf 100 % und nichts geschaehe. */
beiVerarbeitung: anzeige.verarbeiten,
});
const antwort = await a.json().catch(() => ({}));
if (!a.ok) { melde(sagWas(antwort.fehler, 'Das Hochladen ging nicht.')); sage(''); return; }
const ergebnis = await lauf.fertig;
if (ergebnis.abgebrochen) {
anzeige.abschluss('Abgebrochen – das Bild wurde nicht gespeichert.');
stehenLassen = true;
return;
}
const antwort = ergebnis.daten || {};
if (!ergebnis.ok) {
anzeige.weg();
melde(ergebnis.netz ? 'Keine Verbindung – das Bild wurde nicht gespeichert.'
: sagWas(antwort.fehler, 'Das Hochladen ging nicht.'));
return;
}
anzeige.weg();
bildZeigen(antwort.bild, stand?.name);
sage('Bild gespeichert.');
/* Auch oben rechts in der Kopfleiste soll sofort das neue Bild
@@ -156,7 +177,10 @@
b.alt = '';
oben.append(b);
}
} catch { /* umgeleitet */ } finally { e.target.value = ''; }
} catch { /* umgeleitet */ } finally {
if (!stehenLassen) anzeige.weg();
e.target.value = '';
}
});
$('sb-bild-weg').addEventListener('click', async () => {
+28 -2
View File
@@ -591,6 +591,7 @@
}
knopf.disabled = true;
let anzeige = null;
try {
let a;
if (bearbeite) {
@@ -604,7 +605,29 @@
if (!datei) { $('n-fehler').textContent = 'Wähle eine PDF-Datei aus.'; return; }
const kopf = { 'Content-Type': 'application/pdf', 'x-dateiname': encodeURIComponent(datei.name) };
for (const [n, w] of Object.entries(angaben)) kopf['x-' + n] = encodeURIComponent(w);
a = await hole('/workspace/api/wissen', { method: 'POST', headers: kopf, body: datei });
/* MIT FORTSCHRITT UND ABBRUCH (19.09.2026). Eine Schulungs-PDF
hat gern zwanzig Megabyte -- ohne Balken sieht das Formular
die ganze Zeit aus, als sei der Knopf nicht angekommen. */
let lauf = null;
anzeige = hochladeAnzeige($('n-stand'), datei.name,
() => lauf && lauf.abbrechen());
lauf = hochladen('/workspace/api/wissen', datei, {
kopf, beiFortschritt: anzeige.fortschritt,
beiVerarbeitung: anzeige.verarbeiten,
});
const ergebnis = await lauf.fertig;
if (ergebnis.abgebrochen) {
anzeige.abschluss('Abgebrochen – es wurde nichts gespeichert.');
anzeige = null;
return;
}
if (!ergebnis.ok) {
$('n-fehler').textContent = ergebnis.netz
? 'Keine Verbindung – die Datei wurde nicht hochgeladen.'
: sagWas(ergebnis.daten?.fehler, 'Das hat nicht geklappt.');
return;
}
a = { ok: true };
}
if (!a.ok) {
$('n-fehler').textContent = sagWas((await a.json().catch(() => ({}))).fehler, 'Das hat nicht geklappt.');
@@ -618,7 +641,10 @@
setTimeout(() => melde(''), 7000);
$('n-stand').textContent = '';
await laden_();
} catch { /* umgeleitet */ } finally { knopf.disabled = false; }
} catch { /* umgeleitet */ } finally {
if (anzeige) anzeige.weg();
knopf.disabled = false;
}
});
/* ---------- Start --------------------------------------------------------- */