Neun gemeldete Fehler, und die Frage, welcher Build gerade läuft
Ein grosser Commit, weil die Punkte einander bedingen: ohne lesbare Fehlermeldung war die Serienerkennung nicht zu diagnostizieren, und ohne Build-Kennung nicht einmal festzustellen, ob überhaupt der gebaute Stand läuft. Zwei der neun Meldungen betrafen Funktionen, die seit Stunden im Quelltext stehen. Version und Build. Die Version steht fest; daneben eine Kennung aus sechs Zeichen, die sich bei jedem Docker-Bau ändert. Sie steht in der Verwaltung, unter /health und im Protokoll bei jedem Start. Damit ist "reden wir vom selben Build?" in zwei Sekunden beantwortet statt in einem halben Tag. Serien. Lag eine Datei direkt im Serienordner und trug vor der Folgennummer nichts, wurde der ganze Dateiname zum Serientitel: aus 95 Serien wurden 737, jede mit einer Folge, bei jedem Lauf neu. Dazu lief der Scan in Verweise hinein — derselbe Ordner unter zwei Namen ergab zwei Serien. Und weder neue noch umgezogene Dateien standen in der Pfadliste des Laufs, weshalb ein doppelt gesehener Pfad eine zweite Zeile mit gleichem (LibraryId, Path) anlegte und der Eindeutigkeitsindex den ganzen Speichervorgang scheitern liess. Jede Serienanlage wird jetzt im Tagesprotokoll begründet. Fehlermeldungen. "An error occurred while saving the entity changes" stand englisch in einer deutschen Oberfläche und nannte weder Ursache noch nächsten Schritt. Jetzt geht der vollständige Text ins Protokoll und ein deutscher Satz ohne Fehlercode in die Anzeige — auch für "Failed to fetch", das aus dem Browser kam und nie übersetzt wurde. Protokolle. Die Ansicht holte die ganze Datei und zeigte 800 Zeilen davon; bei zwei Megabyte brach die Verbindung ab. Gelesen wird jetzt serverseitig rückwärts, mit einem Knopf für mehr. Auswahlfelder. Die drei <select> zeichnete das Betriebssystem: ein helles Systemmenü mitten in der dunklen Oberfläche. Ersetzt durch ein eigenes Listenfeld mit Tastaturbedienung. Bildwahl. Drei Raster untereinander in einem scrollenden Dialog hiess zwei Scrollebenen übereinander. Jetzt ein Reiter je Bildtyp. Sprache. Die Serversprache stand in der Serverliste zwischen Name, Metadaten und Fassung — gesucht und nicht gefunden. Sie hat jetzt einen eigenen Abschnitt ganz oben. Dazu: ein Knopf, der den erfassten Inhalt einer Mediathek verwirft, ohne Name und Pfad mitzunehmen; ein Abschnitt Speicher mit den drei Pfaden, ihrer Belegung und dem Leeren des Bildspeichers; und die Korrektur einer Zuordnung frischt nur noch die Kachel auf statt die ganze Seite. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ab8a66d898
commit
ac04901ccc
@@ -0,0 +1,186 @@
|
||||
import { icons } from './icons';
|
||||
|
||||
export type Option = { wert: string; text: string };
|
||||
|
||||
/** Ein Auswahlfeld, das aussieht wie der Rest der Oberfläche.
|
||||
*
|
||||
* **Warum nicht `<select>`.** Dessen Liste zeichnet das Betriebssystem, und
|
||||
* zwar in seinem eigenen Stil: auf einem Mac erscheint mitten in einer
|
||||
* dunklen Oberfläche ein helles Systemmenü mit abgerundeten Ecken und
|
||||
* blauer Markierung. Es lässt sich nicht gestalten — kein Rahmen, keine
|
||||
* Farbe, keine Radien. In einer Oberfläche, die sonst durchgehend eine
|
||||
* Formensprache spricht, ist es der eine Fleck, der verrät, dass hier nichts
|
||||
* aufeinander abgestimmt wurde.
|
||||
*
|
||||
* **Was dafür selbst gebaut werden muss.** Ein `<select>` bringt viel mit,
|
||||
* das man leicht vergisst: Tastaturbedienung, Rollen für Hilfsmittel, das
|
||||
* Schliessen beim Klick daneben. Steht alles unten — ohne das wäre der
|
||||
* Tausch eine Verschlechterung, nur eine hübschere.
|
||||
*/
|
||||
export type Auswahlfeld = {
|
||||
/** Was in die Seite gehängt wird. */
|
||||
element: HTMLButtonElement;
|
||||
/** Der gewählte Wert — wie bei `select.value`. */
|
||||
wert: string;
|
||||
/** Wird bei jeder Änderung durch den Benutzer gerufen, nicht beim Setzen. */
|
||||
beiWechsel: (fn: () => void) => void;
|
||||
/** Tauscht die Liste aus — für Werte, die erst nachgeladen werden. */
|
||||
setzen: (optionen: Option[]) => void;
|
||||
};
|
||||
|
||||
export function auswahl(optionen: Option[], vorgabe = ''): Auswahlfeld {
|
||||
let liste = [...optionen];
|
||||
let wert = vorgabe;
|
||||
let melden: (() => void) | null = null;
|
||||
|
||||
const knopf = document.createElement('button');
|
||||
knopf.type = 'button';
|
||||
knopf.className = 'auswahl';
|
||||
knopf.setAttribute('aria-haspopup', 'listbox');
|
||||
knopf.setAttribute('aria-expanded', 'false');
|
||||
|
||||
const beschriftung = document.createElement('span');
|
||||
const pfeil = document.createElement('span');
|
||||
pfeil.className = 'auswahl-pfeil icon';
|
||||
pfeil.innerHTML = icons.runter;
|
||||
knopf.append(beschriftung, pfeil);
|
||||
|
||||
const beschriften = () => {
|
||||
beschriftung.textContent = liste.find(o => o.wert === wert)?.text ?? liste[0]?.text ?? '';
|
||||
};
|
||||
beschriften();
|
||||
|
||||
let offen: HTMLElement | null = null;
|
||||
|
||||
function schliessen(): void {
|
||||
offen?.remove();
|
||||
offen = null;
|
||||
knopf.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
|
||||
function waehlen(neu: string): void {
|
||||
const geaendert = neu !== wert;
|
||||
wert = neu;
|
||||
beschriften();
|
||||
schliessen();
|
||||
knopf.focus();
|
||||
if (geaendert) melden?.();
|
||||
}
|
||||
|
||||
function oeffnen(): void {
|
||||
if (offen) { schliessen(); return; }
|
||||
|
||||
const box = document.createElement('div');
|
||||
box.className = 'auswahl-liste';
|
||||
box.setAttribute('role', 'listbox');
|
||||
|
||||
liste.forEach(o => {
|
||||
const e = document.createElement('button');
|
||||
e.type = 'button';
|
||||
e.className = 'auswahl-punkt' + (o.wert === wert ? ' gewaehlt' : '');
|
||||
e.setAttribute('role', 'option');
|
||||
e.setAttribute('aria-selected', String(o.wert === wert));
|
||||
e.textContent = o.text;
|
||||
e.onclick = () => waehlen(o.wert);
|
||||
box.appendChild(e);
|
||||
});
|
||||
|
||||
// **Die Tasten müssen auch in der Liste wirken.** Nach dem Öffnen sitzt
|
||||
// der Fokus auf einem Eintrag, nicht mehr auf dem Knopf — Escape lief
|
||||
// dort ins Leere, und die Liste blieb offen stehen.
|
||||
box.onkeydown = e => {
|
||||
if (e.key === 'Escape') { e.preventDefault(); schliessen(); knopf.focus(); return; }
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
bewegen(e.key === 'ArrowDown' ? 1 : -1);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Home' || e.key === 'End') {
|
||||
e.preventDefault();
|
||||
const punkte = [...box.querySelectorAll<HTMLElement>('.auswahl-punkt')];
|
||||
(e.key === 'Home' ? punkte[0] : punkte[punkte.length - 1])?.focus();
|
||||
return;
|
||||
}
|
||||
// Tab verlässt die Liste — dann hat sie dort nichts mehr zu suchen.
|
||||
if (e.key === 'Tab') schliessen();
|
||||
};
|
||||
|
||||
document.body.appendChild(box);
|
||||
offen = box;
|
||||
knopf.setAttribute('aria-expanded', 'true');
|
||||
|
||||
// **Erst anhängen, dann messen.** Vorher hat die Liste keine Grösse, und
|
||||
// die Prüfung auf den Bildschirmrand liefe gegen null. Dasselbe Vorgehen
|
||||
// wie im Kontextmenü, und aus demselben Grund.
|
||||
const ab = knopf.getBoundingClientRect();
|
||||
const eigen = box.getBoundingClientRect();
|
||||
box.style.minWidth = `${ab.width}px`;
|
||||
box.style.left = `${Math.max(8, Math.min(ab.left, window.innerWidth - eigen.width - 8))}px`;
|
||||
box.style.top = ab.bottom + eigen.height + 8 > window.innerHeight
|
||||
? `${Math.max(8, ab.top - eigen.height - 4)}px`
|
||||
: `${ab.bottom + 4}px`;
|
||||
|
||||
box.querySelector<HTMLElement>('.gewaehlt, .auswahl-punkt')?.focus();
|
||||
}
|
||||
|
||||
knopf.onclick = e => { e.stopPropagation(); oeffnen(); };
|
||||
|
||||
// **Die Tastatur, die ein `select` mitbrachte.** Hoch und runter wechseln
|
||||
// auch ohne geöffnete Liste — genau so verhält sich das Original, und wer
|
||||
// es gewohnt ist, merkt den Tausch sonst sofort.
|
||||
knopf.onkeydown = e => {
|
||||
const stelle = liste.findIndex(o => o.wert === wert);
|
||||
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
if (offen) { bewegen(e.key === 'ArrowDown' ? 1 : -1); return; }
|
||||
const ziel = liste[Math.min(liste.length - 1, Math.max(0, stelle + (e.key === 'ArrowDown' ? 1 : -1)))];
|
||||
if (ziel) waehlen(ziel.wert);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Home' || e.key === 'End') {
|
||||
e.preventDefault();
|
||||
const ziel = e.key === 'Home' ? liste[0] : liste[liste.length - 1];
|
||||
if (ziel) waehlen(ziel.wert);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); oeffnen(); return; }
|
||||
if (e.key === 'Escape') { schliessen(); return; }
|
||||
|
||||
// Anfangsbuchstabe — springt zum nächsten passenden Eintrag.
|
||||
if (e.key.length === 1 && /\S/.test(e.key)) {
|
||||
const zeichen = e.key.toLowerCase();
|
||||
const ab = stelle + 1;
|
||||
const treffer = [...liste.slice(ab), ...liste.slice(0, ab)]
|
||||
.find(o => o.text.toLowerCase().startsWith(zeichen));
|
||||
if (treffer) waehlen(treffer.wert);
|
||||
}
|
||||
};
|
||||
|
||||
/** Pfeiltasten in der offenen Liste. */
|
||||
function bewegen(richtung: 1 | -1): void {
|
||||
if (!offen) return;
|
||||
const punkte = [...offen.querySelectorAll<HTMLElement>('.auswahl-punkt')];
|
||||
const jetzt = punkte.indexOf(document.activeElement as HTMLElement);
|
||||
const ziel = punkte[Math.min(punkte.length - 1, Math.max(0, jetzt + richtung))];
|
||||
ziel?.focus();
|
||||
}
|
||||
|
||||
// Ein Klick daneben, Escape oder ein Bildlauf schliessen — wie beim
|
||||
// Kontextmenü. Je Feld registriert und nicht global: es gibt wenige davon,
|
||||
// und beim Entfernen des Feldes gehen sie mit.
|
||||
document.addEventListener('click', e => {
|
||||
if (offen && !offen.contains(e.target as Node) && e.target !== knopf) schliessen();
|
||||
});
|
||||
window.addEventListener('scroll', () => schliessen(), { passive: true, capture: true });
|
||||
|
||||
return {
|
||||
element: knopf,
|
||||
get wert() { return wert; },
|
||||
set wert(neu: string) { wert = neu; beschriften(); },
|
||||
beiWechsel: fn => { melden = fn; },
|
||||
setzen: neue => { liste = [...neue]; beschriften(); },
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user