Wer etwas sieht, kann es jetzt auch sagen
Wer einen Server für andere betreibt, erfährt von Fehlern zuletzt: dass die deutsche Tonspur nach einer halben Stunde aussetzt, merkt der, der den Film sieht, und nicht der, der ihn eingeräumt hat. Bisher gab es dafür keinen Weg ausser dem Zuruf im Wohnzimmer. Gemeldet wird an einem Film, einer Serie, einer einzelnen Staffel und einer einzelnen Folge: Art (Bild, Ton, Untertitel, Angaben, Sonstiges), eine freiwillige Stelle wie 0:34:12 und der Kommentar. Pflicht ist nur der Kommentar — die beiden anderen machen aus einer Liste von Sätzen etwas, das man sortieren und nachstellen kann. **Eine Staffel gibt es als Zeile nicht**, und das prägt den Entwurf. Sie ist Serie plus Nummer, so wie überall sonst in diesem Server; die Meldung trägt deshalb drei Fremdschlüssel und daneben eine Staffelnummer. Dass die Nummer auf eine Staffel zeigt, die es gibt, prüft der Endpunkt beim Schreiben — die Datenbank kann es nicht, es gibt nichts, worauf sie zeigen könnte. **Die Rechteprüfung steht nirgends.** Wer einen Titel nicht sehen darf, findet ihn beim Melden nicht — das erledigt der Freigabefilter, ohne ein einziges `Where`. Elegant und genau deshalb leicht zu zerstören, darum steht dafür ein Test da. **Die eigene Liste überlebt fremde Entscheidungen.** Titel werden nachgeschlagen, nicht verbunden: zieht ein Administrator einen Titel zurück, nachdem jemand ihn gemeldet hat, bleibt die Meldung beim Melder stehen — nur ohne Namen. Ein Join hätte sie verschwinden lassen, und das wäre jemandes eigene Eingabe, weggenommen wegen einer fremden Entscheidung. `IgnoreQueryFilters` wäre die bequeme Abkürzung und gäbe den Namen eines unveröffentlichten Titels über einen Weg heraus, an den beim nächsten Mal niemand denkt. **Erledigt heisst abgehakt, nicht gelöscht**, als Zeitpunkt und nicht als Haken — wie `PublishAt` und `ChangedSince`. Daneben steht „Wieder öffnen"; ein Fehlgriff wäre sonst endgültig. Das Archiv wird erst auf Zuruf geholt: es wächst nur, und für den seltenen Blick dauernd zu zahlen lohnt nicht. **Der Abschnitt steht neben der Prüfliste, nicht darin.** Die versteckt sich, sobald nichts auf Freigabe wartet — das ist der Normalfall, und die Meldungen verschwänden mit ihr. Umgekehrt bleibt der Meldungsabschnitt stehen, auch wenn nichts drinsteht: ein Kanal, der sich versteckt, solange ihn niemand benutzt hat, bringt niemandem bei, dass es ihn gibt. Die Zahl an der Leiste holt sich nur, wer sie nicht ohnehin hat: steht „Zu prüfen" offen, meldet der Abschnitt sie selbst und nach jedem Abhaken. Nebenbei zwei Dinge am Dialog, die hier auffielen. Es gab **kein mehrzeiliges Feld** — und das Einsammeln der Werte hätte ein `<textarea>` still verschluckt: es ist kein `HTMLInputElement` und wäre ins Auffangnetz für Auswahlfelder gelaufen, also als `undefined` hinausgegangen. Der Server hätte „ohne Beschreibung" geantwortet, auf einem Dialog, in dem sichtbar etwas steht. Und `dialog()` meldete sich als einziger nicht beim Seitenwechsel an: ein Dialog von der Detailseite überlebte den Zurück-Knopf und stand danach über der Mediathek. Neun Testfälle auf dem Server, zwölf Läufe. Für den Web-Client gibt es in diesem Projekt keine Tests; dort ist von Hand geprüft. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
02978e482c
commit
7cd48f5528
@@ -1,10 +1,10 @@
|
||||
import { istAdmin, type Bibliothek } from '../../api';
|
||||
import { istAdmin, meldungen, type Bibliothek } from '../../api';
|
||||
import { icon, type IconName } from '../../icons';
|
||||
import { kontoseite } from './konto';
|
||||
import { geschautseite } from './geschaut';
|
||||
import { dashboard, serverbereich, speicherbereich } from './uebersicht';
|
||||
import { bibliotheksbereich, vermisstenbereich } from './mediatheken';
|
||||
import { pruefbereich } from './pruefung';
|
||||
import { pruefbereich, meldungsbereich } from './pruefung';
|
||||
import { ohneZuordnungBereich, unzugeordnetbereich } from './zuordnung';
|
||||
import { wiedergabebereich } from './wiedergabe';
|
||||
import { sprachbereich } from './metadaten';
|
||||
@@ -77,6 +77,9 @@ export function einstellungenseite(
|
||||
const wurzel = document.createElement('div');
|
||||
wurzel.className = 'verwaltung';
|
||||
|
||||
/** Das Feld für die Zahl offener Meldungen — erst beim Bauen der Leiste da. */
|
||||
let zahl: HTMLElement | null = null;
|
||||
|
||||
const leiste = document.createElement('nav');
|
||||
leiste.className = 'verw-leiste';
|
||||
leiste.setAttribute('aria-label', 'Bereiche der Einstellungen');
|
||||
@@ -88,6 +91,19 @@ export function einstellungenseite(
|
||||
buehne.className = 'verw-buehne';
|
||||
inhalt().forEach(a => buehne.appendChild(a));
|
||||
|
||||
// **Die Zahl holt sich nur, wer sie nicht ohnehin hat.** Steht „Zu prüfen"
|
||||
// offen, meldet der Meldungsabschnitt sie selbst — und zwar nach jedem
|
||||
// Abhaken. Sonst eine Anfrage je Seitenaufbau, und nur für Administratoren.
|
||||
//
|
||||
// Verworfen: die Zahl an `/system/info` oder am Konto mitführen. Beide
|
||||
// werden ohnehin geholt, wären also „umsonst" — und wären ab dem ersten
|
||||
// Abhaken falsch.
|
||||
//
|
||||
// Scheitert sie, bleibt es still: ein Abzeichen, das nicht lädt, fehlt eben.
|
||||
if (admin && welcher !== 'pruefung') {
|
||||
void meldungen(false).then(m => abzeichen(m.length)).catch(() => {});
|
||||
}
|
||||
|
||||
wurzel.append(leiste, buehne);
|
||||
return wurzel;
|
||||
|
||||
@@ -95,6 +111,12 @@ export function einstellungenseite(
|
||||
*
|
||||
* „Verwaltung" über den unteren Einträgen sagt ohne weiteren Satz, dass
|
||||
* diese dem Server gehören und nicht einem selbst. */
|
||||
/** Die Zahl offener Meldungen auf „Zu prüfen". Leer statt „0": eine Null
|
||||
* ist Rauschen. */
|
||||
function abzeichen(offen: number): void {
|
||||
if (zahl) zahl.textContent = offen > 0 ? String(offen) : '';
|
||||
}
|
||||
|
||||
function gruppe(name: string, eintraege: Eintrag[]): void {
|
||||
const kopf = document.createElement('div');
|
||||
kopf.className = 'verw-gruppe';
|
||||
@@ -108,6 +130,11 @@ export function einstellungenseite(
|
||||
k.appendChild(icon(b.bild));
|
||||
k.append(document.createTextNode(b.text));
|
||||
if (b.wert === welcher) k.setAttribute('aria-current', 'page');
|
||||
if (b.wert === 'pruefung') {
|
||||
zahl = document.createElement('span');
|
||||
zahl.className = 'zahl';
|
||||
k.appendChild(zahl);
|
||||
}
|
||||
k.onclick = () => { location.hash = `#/einstellungen/${b.wert}`; };
|
||||
leiste.appendChild(k);
|
||||
});
|
||||
@@ -123,7 +150,9 @@ export function einstellungenseite(
|
||||
// **Die Prüfliste hört auf sich selbst.** Wird etwas freigegeben,
|
||||
// ändert sich der Bestand — und die Liste darüber muss nachziehen.
|
||||
const p = pruefbereich(() => { geaendert(); p.auffrischen(); });
|
||||
return [p.wurzel];
|
||||
// Zwei Sektionen, nicht eine: die Prüfliste versteckt sich, sobald
|
||||
// nichts auf Freigabe wartet, und nähme die Meldungen sonst mit.
|
||||
return [p.wurzel, meldungsbereich(abzeichen).wurzel];
|
||||
}
|
||||
case 'zuordnung':
|
||||
return [ohneZuordnungBereich(), unzugeordnetbereich()];
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import {
|
||||
sitzung, meinKonto, profilbildSetzen, profilbildEntfernen, bildAdresse,
|
||||
spracheSetzen, sprachen, abmelden, type Rolle,
|
||||
spracheSetzen, sprachen, abmelden, meineMeldungen, type Rolle, type Meldung,
|
||||
} from '../../api';
|
||||
import { esc, knopf, fehlerzeile, meldung, hinweisZeigen } from '../../util';
|
||||
import { esc, knopf, fehlerzeile, meldung, hinweisZeigen, dauer } from '../../util';
|
||||
import { dialog, nachfragen } from '../../dialog';
|
||||
import { icon } from '../../icons';
|
||||
import { zuschneiden } from '../../bildzuschnitt';
|
||||
import { ARTWORT } from '../../melden';
|
||||
|
||||
/** Das eigene Konto: Bild, Name, Sprache — und Abmelden.
|
||||
*
|
||||
@@ -40,7 +41,7 @@ export function kontoseite(): HTMLElement {
|
||||
if (sitzung.wer) Object.assign(sitzung.wer, konto);
|
||||
|
||||
wurzel.innerHTML = '';
|
||||
wurzel.append(kopf(konto), einstellungen(konto), abmeldeteil());
|
||||
wurzel.append(kopf(konto), einstellungen(konto), meldeteil(), abmeldeteil());
|
||||
}
|
||||
|
||||
function kopf(konto: Rolle): HTMLElement {
|
||||
@@ -170,6 +171,73 @@ export function kontoseite(): HTMLElement {
|
||||
return bereich;
|
||||
}
|
||||
|
||||
/* ---------- Was man selbst gemeldet hat ---------- */
|
||||
|
||||
/** Die eigenen Meldungen — und was daraus geworden ist.
|
||||
*
|
||||
* **Weg, solange es nichts gibt.** Genau umgekehrt zum Abschnitt in der
|
||||
* Verwaltung: dort ist er eine Ablage, die man finden können muss, auch wenn
|
||||
* sie leer ist. Hier ist er eine Quittung. Wer nie etwas gemeldet hat,
|
||||
* braucht keine Überschrift, die ihm das mitteilt.
|
||||
*
|
||||
* **Ohne Aktionen.** Zurückziehen fehlt mit Absicht: die Verwaltung arbeitet
|
||||
* vielleicht schon daran, und eine Meldung, die mitten darin verschwindet,
|
||||
* ist ein Fehler im Fehlermelder.
|
||||
*
|
||||
* **Scheitert das Laden, bleibt es in diesem Abschnitt.** Die Kontoseite ist
|
||||
* die eine Seite, die jeder hat; sie darf nicht an einer Nebensache
|
||||
* zerbrechen. */
|
||||
function meldeteil(): HTMLElement {
|
||||
const bereich = document.createElement('section');
|
||||
bereich.className = 'hidden';
|
||||
|
||||
const h = document.createElement('h2');
|
||||
h.textContent = 'Meine Meldungen';
|
||||
|
||||
const liste = document.createElement('div');
|
||||
liste.className = 'einstellungen';
|
||||
bereich.append(h, liste);
|
||||
|
||||
void meineMeldungen().then(alle => {
|
||||
if (alle.length === 0) return;
|
||||
bereich.classList.remove('hidden');
|
||||
alle.forEach(m => liste.appendChild(zeileFuer(m)));
|
||||
}).catch(() => { /* Ein Abschnitt, der nicht lädt, fehlt eben. */ });
|
||||
|
||||
return bereich;
|
||||
|
||||
function zeileFuer(m: Meldung): HTMLElement {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'einstellung';
|
||||
|
||||
const zustand = m.resolvedAt
|
||||
? `Erledigt am ${new Date(m.resolvedAt).toLocaleDateString('de-CH', { dateStyle: 'medium' })}`
|
||||
: 'Offen';
|
||||
const nebenbei = [
|
||||
zustand,
|
||||
ARTWORT[m.kind],
|
||||
m.positionSeconds !== null ? `bei ${dauer(m.positionSeconds)}` : null,
|
||||
new Date(m.reportedAt).toLocaleDateString('de-CH', { dateStyle: 'medium' }),
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
const zeile = document.createElement('div');
|
||||
zeile.className = 'zeile';
|
||||
// Hat ein Administrator den Titel inzwischen zurückgezogen, bleibt die
|
||||
// eigene Meldung stehen — nur ohne Namen.
|
||||
const name = m.title || 'Titel nicht mehr sichtbar';
|
||||
zeile.innerHTML = `<strong>${esc(name)}${m.subTitle ? ` · ${esc(m.subTitle)}` : ''}</strong>`
|
||||
+ `<span class="winzig">${nebenbei.map(esc).join(' · ')}</span>`;
|
||||
d.appendChild(zeile);
|
||||
|
||||
const kommentar = document.createElement('div');
|
||||
kommentar.className = 'hinweis';
|
||||
kommentar.textContent = m.comment;
|
||||
d.appendChild(kommentar);
|
||||
|
||||
return d;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Abmelden ---------- */
|
||||
|
||||
function abmeldeteil(): HTMLElement {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import {
|
||||
zuPruefen, veroeffentlichen, zurueckziehen, zurKenntnis, type Pruefeintrag, bildAdresse,
|
||||
meldungen, meldungErledigt, meldungWiederOeffnen, type Meldung,
|
||||
} from '../../api';
|
||||
import { esc, knopf, fehlerzeile, meldung } from '../../util';
|
||||
import { esc, knopf, fehlerzeile, meldung, dauer, leer } from '../../util';
|
||||
import { dialog } from '../../dialog';
|
||||
import { icons } from '../../icons';
|
||||
import { kontextmenue, menueAnZiel, type Menuepunkt } from '../../menue';
|
||||
@@ -10,6 +11,7 @@ import { zuordnungsdialog } from '../../zuordnung';
|
||||
import { titeldialog } from '../../titel';
|
||||
import { bearbeiten } from '../../bearbeiten';
|
||||
|
||||
import { ARTWORT } from '../../melden';
|
||||
import { abschnitt } from './teile';
|
||||
|
||||
/** Zu prüfen: was der Scan gefunden hat und noch nicht freigegeben ist. */
|
||||
@@ -280,6 +282,155 @@ export function pruefbereich(geaendert: () => void): {
|
||||
return { wurzel: bereich, auffrischen: () => void laden() };
|
||||
}
|
||||
|
||||
/* ---------- Meldungen ---------- */
|
||||
|
||||
/** Was Benutzer an Titeln gemeldet haben.
|
||||
*
|
||||
* **Warum neben der Prüfliste und nicht darin.** Beides ist Arbeit, die
|
||||
* hereinkommt, und beides wird abgehakt — der Ort ist derselbe. Eine Sektion
|
||||
* ist es trotzdem nicht: die Prüfliste versteckt sich, sobald nichts auf
|
||||
* Freigabe wartet, und das ist der Normalfall. Die Meldungen verschwänden
|
||||
* dann mit ihr.
|
||||
*
|
||||
* **Und dieser Abschnitt bleibt stehen, auch wenn nichts drinsteht.** Genau
|
||||
* umgekehrt zur Prüfliste: die ist ein Nebenprodukt des Scans, und ein leerer
|
||||
* Abschnitt dafür wäre Rauschen. Meldungen sind ein Kanal, den Menschen
|
||||
* benutzen sollen; einer, der verschwindet, solange ihn niemand benutzt hat,
|
||||
* bringt genau niemandem bei, dass es ihn gibt.
|
||||
*
|
||||
* @param gezaehlt Bekommt die Zahl der offenen — für das Abzeichen an der Leiste.
|
||||
*/
|
||||
export function meldungsbereich(gezaehlt: (offen: number) => void = () => {}): {
|
||||
wurzel: HTMLElement; auffrischen: () => void;
|
||||
} {
|
||||
const bereich = document.createElement('section');
|
||||
|
||||
const liste = document.createElement('div');
|
||||
liste.className = 'einstellungen';
|
||||
|
||||
/** Das Archiv wird erst auf Zuruf geholt — es wächst nur. */
|
||||
const archivKopf = document.createElement('div');
|
||||
archivKopf.className = 'pruef-gruppe hidden';
|
||||
archivKopf.innerHTML = '<strong>Erledigt</strong>'
|
||||
+ '<span class="winzig">Abgehakt, aber nicht gelöscht — wer wissen will, '
|
||||
+ 'was schon einmal gemeldet war, findet es hier.</span>';
|
||||
|
||||
const archiv = document.createElement('div');
|
||||
archiv.className = 'einstellungen hidden';
|
||||
let archivOffen = false;
|
||||
|
||||
const umschalten = knopf('Erledigte zeigen', 'btn');
|
||||
umschalten.onclick = () => {
|
||||
archivOffen = !archivOffen;
|
||||
umschalten.textContent = archivOffen ? 'Erledigte ausblenden' : 'Erledigte zeigen';
|
||||
archivKopf.classList.toggle('hidden', !archivOffen);
|
||||
archiv.classList.toggle('hidden', !archivOffen);
|
||||
if (archivOffen) void archivLaden();
|
||||
};
|
||||
|
||||
bereich.append(abschnitt('Meldungen', [umschalten]), liste, archivKopf, archiv);
|
||||
void laden();
|
||||
|
||||
async function laden(): Promise<void> {
|
||||
if (liste.childElementCount === 0) liste.innerHTML = '<div class="spinner"></div>';
|
||||
try {
|
||||
const offene = await meldungen(false);
|
||||
liste.innerHTML = '';
|
||||
if (offene.length === 0) liste.appendChild(leer('Keine offenen Meldungen.'));
|
||||
else offene.forEach(m => liste.appendChild(meldezeile(m, false)));
|
||||
gezaehlt(offene.length);
|
||||
} catch (fehler) {
|
||||
liste.innerHTML = '';
|
||||
liste.appendChild(fehlerzeile(meldung(fehler)));
|
||||
}
|
||||
}
|
||||
|
||||
async function archivLaden(): Promise<void> {
|
||||
archiv.innerHTML = '<div class="spinner"></div>';
|
||||
try {
|
||||
const erledigte = await meldungen(true);
|
||||
archiv.innerHTML = '';
|
||||
if (erledigte.length === 0) archiv.appendChild(leer('Noch nichts abgehakt.'));
|
||||
else erledigte.forEach(m => archiv.appendChild(meldezeile(m, true)));
|
||||
} catch (fehler) {
|
||||
archiv.innerHTML = '';
|
||||
archiv.appendChild(fehlerzeile(meldung(fehler)));
|
||||
}
|
||||
}
|
||||
|
||||
function meldezeile(m: Meldung, erledigt: boolean): HTMLElement {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'einstellung pruefzeile';
|
||||
|
||||
const bild = document.createElement('div');
|
||||
bild.className = 'pruef-bild';
|
||||
const adresse = bildAdresse(m.posterImage);
|
||||
if (adresse) {
|
||||
const img = document.createElement('img');
|
||||
img.src = adresse; img.alt = ''; img.loading = 'lazy';
|
||||
bild.appendChild(img);
|
||||
}
|
||||
|
||||
const text = document.createElement('div');
|
||||
text.className = 'zeile';
|
||||
text.style.flex = '1';
|
||||
// Ein zurückgezogener Titel hat hier keinen Namen mehr — die Meldung
|
||||
// bleibt trotzdem stehen.
|
||||
const name = m.title || 'Titel nicht mehr sichtbar';
|
||||
const nebenbei = [
|
||||
ARTWORT[m.kind],
|
||||
m.positionSeconds !== null ? `bei ${dauer(m.positionSeconds)}` : null,
|
||||
m.reportedBy ? `von ${m.reportedBy}` : null,
|
||||
new Date(m.reportedAt).toLocaleString('de-CH', { dateStyle: 'medium', timeStyle: 'short' }),
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
text.innerHTML = `<strong>${esc(name)}${m.subTitle ? ` · ${esc(m.subTitle)}` : ''}</strong>`
|
||||
+ `<span class="winzig">${nebenbei.map(esc).join(' · ')}</span>`;
|
||||
|
||||
const oben = document.createElement('div');
|
||||
oben.className = 'knopfreihe';
|
||||
oben.append(bild, text);
|
||||
|
||||
// **Zum Titel, wenn es ihn noch gibt.** Die Kennung zeigt bei Folge und
|
||||
// Staffel auf die Serie — dort steht die Detailseite.
|
||||
if (m.title) {
|
||||
const hin = knopf('Zum Titel', 'btn');
|
||||
hin.onclick = () => {
|
||||
location.hash = `#/${m.target === 'movie' ? 'filme' : 'serien'}/${m.itemId}`;
|
||||
};
|
||||
oben.appendChild(hin);
|
||||
}
|
||||
|
||||
const schalter = knopf(erledigt ? 'Wieder öffnen' : 'Erledigt',
|
||||
erledigt ? 'btn' : 'btn btn-primary');
|
||||
schalter.onclick = async () => {
|
||||
schalter.disabled = true;
|
||||
try {
|
||||
if (erledigt) await meldungWiederOeffnen(m.id); else await meldungErledigt(m.id);
|
||||
await laden();
|
||||
if (archivOffen) await archivLaden();
|
||||
} catch (fehler) {
|
||||
schalter.disabled = false;
|
||||
d.appendChild(fehlerzeile(meldung(fehler)));
|
||||
}
|
||||
};
|
||||
oben.appendChild(schalter);
|
||||
d.appendChild(oben);
|
||||
|
||||
// **Der Kommentar steht ganz da.** Das Einzige an einer Meldung, was ein
|
||||
// Mensch von Hand geschrieben hat, abzuschneiden hiesse, den Sinn der
|
||||
// Übung wegzukürzen.
|
||||
const kommentar = document.createElement('div');
|
||||
kommentar.className = 'hinweis';
|
||||
kommentar.textContent = m.comment;
|
||||
d.appendChild(kommentar);
|
||||
|
||||
return d;
|
||||
}
|
||||
|
||||
return { wurzel: bereich, auffrischen: () => void laden() };
|
||||
}
|
||||
|
||||
/* ---------- Protokolle ---------- */
|
||||
|
||||
/** Eine Datei je Tag, lesbar ohne Verbindung auf das NAS.
|
||||
|
||||
Reference in New Issue
Block a user