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
@@ -749,3 +749,46 @@ public sealed record SessionResponse(
|
||||
/// <summary>Was der Server gerade tut.</summary>
|
||||
public sealed record ServerStatusResponse(
|
||||
int Playing, int Packaging, int Scanning, double Uptime);
|
||||
|
||||
/* ---------- Meldungen ---------- */
|
||||
|
||||
/// <summary>Was jemand an einem Titel gemeldet hat.</summary>
|
||||
public sealed record MeldungRequest(
|
||||
/// <summary><c>bild</c>, <c>ton</c>, <c>untertitel</c>, <c>daten</c> oder <c>sonstiges</c>.</summary>
|
||||
string Kind,
|
||||
string Comment,
|
||||
/// <summary>Eine Stelle wie <c>0:34:12</c> — oder nichts.</summary>
|
||||
string? Position = null,
|
||||
/// <summary>Nur bei einer Serie: eine einzelne Staffel statt der ganzen.</summary>
|
||||
int? SeasonNumber = null);
|
||||
|
||||
/// <summary>Eine Meldung, wie sie gelesen wird.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b><c>ItemId</c> ist die Kennung der Detailseite, nicht die des
|
||||
/// Gemeldeten.</b> Bei einer Folge und bei einer Staffel steht hier die Serie —
|
||||
/// die Detailseite gehört ihr, wie bei „Weiterschauen" auch. Was genau gemeint
|
||||
/// ist, steht in <c>Target</c> und <c>SubTitle</c>.
|
||||
///
|
||||
/// <b><c>Title</c> ist leer, wenn der Titel nicht mehr sichtbar ist.</b> Ein
|
||||
/// Administrator kann einen Titel zurückziehen, nachdem jemand ihn gemeldet
|
||||
/// hat; die Meldung bleibt dann in der eigenen Liste des Melders stehen, nur
|
||||
/// ohne Namen. Sie verschwinden zu lassen hiesse, jemandem seine eigene Eingabe
|
||||
/// wegen einer fremden Entscheidung wegzunehmen.
|
||||
/// </remarks>
|
||||
public sealed record Meldungseintrag(
|
||||
Guid Id,
|
||||
/// <summary><c>movie</c>, <c>series</c>, <c>season</c> oder <c>episode</c>.</summary>
|
||||
string Target,
|
||||
Guid ItemId,
|
||||
string Title,
|
||||
/// <summary>„Staffel 2" oder „S2 · F5 — Der Pakt" — sonst <c>null</c>.</summary>
|
||||
string? SubTitle,
|
||||
string? PosterImage,
|
||||
string Kind,
|
||||
double? PositionSeconds,
|
||||
string Comment,
|
||||
DateTimeOffset ReportedAt,
|
||||
string ReportedBy,
|
||||
/// <summary><c>null</c> heisst: offen.</summary>
|
||||
DateTimeOffset? ResolvedAt);
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
using System.Security.Claims;
|
||||
using Flaro.Core.Library;
|
||||
using Flaro.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Flaro.Api.Endpoints;
|
||||
|
||||
/// <summary>Melden, was an einem Titel nicht stimmt — und es abarbeiten.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Warum es das gibt.</b> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Melden darf jeder, lesen nur die Verwaltung.</b> Die eine Ausnahme sind
|
||||
/// die eigenen Meldungen: wer etwas gemeldet hat, soll nachsehen können, ob
|
||||
/// etwas daraus geworden ist, ohne zu fragen. Das ist dieselbe Linie wie beim
|
||||
/// Sehprotokoll — eigene Daten ja, fremde nein, auch als Administrator nicht
|
||||
/// über diesen Weg.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class MeldungEndpoints
|
||||
{
|
||||
private const int HoechstensZeichen = 2000;
|
||||
|
||||
/// <summary>Wie viele Meldungen eine Liste höchstens zurückgibt.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Die offene Liste ist eine Warteschlange. Wer fünfhundert offene
|
||||
/// Meldungen hat, hat ein anderes Problem als das, das dieser Bildschirm
|
||||
/// löst.
|
||||
/// </remarks>
|
||||
private const int HoechstensZeilen = 500;
|
||||
|
||||
public static void MapMeldungen(this IEndpointRouteBuilder app)
|
||||
{
|
||||
// **Am selben Präfix wie alles, was eine undurchsichtige Titelkennung
|
||||
// annimmt.** Gesehen, Favorit, Weglegen und Fortschritt liegen auch
|
||||
// dort; der Client muss dadurch nicht wissen, ob er einen Film, eine
|
||||
// Serie oder eine Folge in der Hand hält.
|
||||
var melden = app.MapGroup("/api/v1/items").RequireAuthorization();
|
||||
|
||||
melden.MapPost("/{id:guid}/reports", async (
|
||||
Guid id, MeldungRequest anfrage, ClaimsPrincipal wer,
|
||||
FlaroContext db, TimeProvider uhr, CancellationToken ct) =>
|
||||
{
|
||||
if (Kennung(wer) is not { } benutzer) return Results.Unauthorized();
|
||||
|
||||
var kommentar = anfrage.Comment?.Trim() ?? "";
|
||||
if (kommentar.Length == 0)
|
||||
return Results.BadRequest(new { fehler = "Ohne Beschreibung lässt sich nichts nachsehen." });
|
||||
if (kommentar.Length > HoechstensZeichen)
|
||||
return Results.BadRequest(new { fehler = $"Höchstens {HoechstensZeichen} Zeichen." });
|
||||
|
||||
if (AlsArt(anfrage.Kind) is not { } art)
|
||||
{
|
||||
return Results.BadRequest(new
|
||||
{
|
||||
fehler = "Unbekannte Art. Möglich sind: bild, ton, untertitel, daten, sonstiges.",
|
||||
});
|
||||
}
|
||||
|
||||
if (!AlsStelle(anfrage.Position, out var stelle))
|
||||
return Results.BadRequest(new { fehler = "Die Stelle muss wie 0:34:12 aussehen." });
|
||||
|
||||
// **Der Freigabefilter erledigt die Rechteprüfung.** Wer einen
|
||||
// Titel nicht sehen darf, findet ihn hier nicht — ohne ein
|
||||
// einziges `Where` und ohne eine Stelle, die man zu pflegen
|
||||
// vergessen kann.
|
||||
Guid? film = null, serie = null, folge = null;
|
||||
if (await db.Movies.AnyAsync(m => m.Id == id, ct)) film = id;
|
||||
else if (await db.Episodes.AnyAsync(f => f.Id == id, ct)) folge = id;
|
||||
else if (await db.Series.AnyAsync(s => s.Id == id, ct)) serie = id;
|
||||
else return Results.BadRequest(new { fehler = "Diesen Titel gibt es nicht." });
|
||||
|
||||
int? staffel = null;
|
||||
if (anfrage.SeasonNumber is { } nummer)
|
||||
{
|
||||
if (serie is null)
|
||||
return Results.BadRequest(new { fehler = "Eine Staffel gibt es nur zu einer Serie." });
|
||||
if (!await db.Episodes.AnyAsync(f => f.SeriesId == serie && f.SeasonNumber == nummer, ct))
|
||||
return Results.BadRequest(new { fehler = "Diese Staffel gibt es in der Serie nicht." });
|
||||
staffel = nummer;
|
||||
}
|
||||
|
||||
var meldung = new Meldung
|
||||
{
|
||||
UserId = benutzer,
|
||||
MovieId = film,
|
||||
SeriesId = serie,
|
||||
EpisodeId = folge,
|
||||
SeasonNumber = staffel,
|
||||
Art = art,
|
||||
Stelle = stelle,
|
||||
Kommentar = kommentar,
|
||||
GemeldetAm = uhr.GetUtcNow(),
|
||||
};
|
||||
|
||||
db.Meldungen.Add(meldung);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return Results.Ok(new { id = meldung.Id });
|
||||
});
|
||||
|
||||
// **Die eigenen — jeder nur sich selbst, auch ein Administrator.**
|
||||
// Steht vor der Verwaltungsgruppe, damit `mine` nicht als Kennung
|
||||
// gelesen wird; die Verwaltungsrouten tragen zusätzlich `:guid`.
|
||||
app.MapGroup("/api/v1/reports/mine").RequireAuthorization()
|
||||
.MapGet("", async (ClaimsPrincipal wer, FlaroContext db, CancellationToken ct) =>
|
||||
{
|
||||
if (Kennung(wer) is not { } benutzer) return Results.Unauthorized();
|
||||
|
||||
var roh = await db.Meldungen.AsNoTracking()
|
||||
.Where(m => m.UserId == benutzer)
|
||||
.OrderByDescending(m => m.GemeldetAm)
|
||||
.Take(HoechstensZeilen)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return Results.Ok(await Anreichern(db, roh, ct));
|
||||
});
|
||||
|
||||
var verwalten = app.MapGroup("/api/v1/reports")
|
||||
.RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
verwalten.MapGet("", async (
|
||||
FlaroContext db, CancellationToken ct, bool erledigt = false) =>
|
||||
{
|
||||
// **Offene aufsteigend, erledigte absteigend.** Das eine ist eine
|
||||
// Warteschlange — dort gehört das Älteste nach oben. Das andere
|
||||
// ist ein Archiv, und darin sucht man das Letzte.
|
||||
var abfrage = db.Meldungen.AsNoTracking();
|
||||
var roh = erledigt
|
||||
? await abfrage.Where(m => m.ErledigtAm != null)
|
||||
.OrderByDescending(m => m.ErledigtAm).Take(HoechstensZeilen).ToListAsync(ct)
|
||||
: await abfrage.Where(m => m.ErledigtAm == null)
|
||||
.OrderBy(m => m.GemeldetAm).Take(HoechstensZeilen).ToListAsync(ct);
|
||||
|
||||
return Results.Ok(await Anreichern(db, roh, ct));
|
||||
});
|
||||
|
||||
// **Abhaken, nicht löschen.** Wiederholt sich ein Problem, ist die
|
||||
// Vorgeschichte die halbe Antwort. Zweimal abhaken ist kein Fehler,
|
||||
// sondern zweimal dasselbe Ergebnis.
|
||||
verwalten.MapPost("/{id:guid}/resolve", (Guid id, ClaimsPrincipal wer,
|
||||
FlaroContext db, TimeProvider uhr, CancellationToken ct) =>
|
||||
Umschalten(id, wer, db, ct, uhr.GetUtcNow()));
|
||||
|
||||
verwalten.MapPost("/{id:guid}/reopen", (Guid id, ClaimsPrincipal wer,
|
||||
FlaroContext db, CancellationToken ct) =>
|
||||
Umschalten(id, wer, db, ct, null));
|
||||
}
|
||||
|
||||
private static async Task<IResult> Umschalten(
|
||||
Guid id, ClaimsPrincipal wer, FlaroContext db, CancellationToken ct, DateTimeOffset? wann)
|
||||
{
|
||||
var meldung = await db.Meldungen.FirstOrDefaultAsync(m => m.Id == id, ct);
|
||||
if (meldung is null)
|
||||
return Results.NotFound(new { fehler = "Diese Meldung gibt es nicht mehr." });
|
||||
|
||||
meldung.ErledigtAm = wann;
|
||||
meldung.ErledigtVon = wann is null ? null : Kennung(wer);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
/// <summary>Hängt Titel, Plakat und Melder an die nackten Zeilen.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Nachgeschlagen, nicht verbunden.</b> Ein Join würde eine Meldung
|
||||
/// verschwinden lassen, sobald ihr Titel für den Fragenden unsichtbar wird
|
||||
/// — und das trifft ausgerechnet die eigene Liste des Melders, wenn ein
|
||||
/// Administrator den Titel zurückzieht. Hier bleibt die Zeile stehen und
|
||||
/// der Name fehlt.
|
||||
///
|
||||
/// <b>Und ohne <c>IgnoreQueryFilters</c>.</b> Das wäre die bequeme Lösung
|
||||
/// und gäbe den Namen eines unveröffentlichten Titels über einen Weg
|
||||
/// heraus, an den beim nächsten Mal niemand denkt.
|
||||
/// </remarks>
|
||||
private static async Task<List<Meldungseintrag>> Anreichern(
|
||||
FlaroContext db, List<Meldung> roh, CancellationToken ct)
|
||||
{
|
||||
var filmIds = roh.Where(m => m.MovieId != null).Select(m => m.MovieId!.Value).Distinct().ToList();
|
||||
var serienIds = roh.Where(m => m.SeriesId != null).Select(m => m.SeriesId!.Value).Distinct().ToList();
|
||||
var folgenIds = roh.Where(m => m.EpisodeId != null).Select(m => m.EpisodeId!.Value).Distinct().ToList();
|
||||
var leuteIds = roh.Select(m => m.UserId).Distinct().ToList();
|
||||
|
||||
var filme = await db.Movies.AsNoTracking().Where(m => filmIds.Contains(m.Id))
|
||||
.Select(m => new { m.Id, m.Title, m.PosterImage }).ToDictionaryAsync(m => m.Id, ct);
|
||||
|
||||
var serien = await db.Series.AsNoTracking().Where(s => serienIds.Contains(s.Id))
|
||||
.Select(s => new { s.Id, s.Title, s.PosterImage }).ToDictionaryAsync(s => s.Id, ct);
|
||||
|
||||
var folgen = await db.Episodes.AsNoTracking().Where(f => folgenIds.Contains(f.Id))
|
||||
.Join(db.Series.AsNoTracking(), f => f.SeriesId, s => s.Id, (f, s) => new
|
||||
{
|
||||
f.Id, Folgentitel = f.Title, f.SeasonNumber, f.EpisodeNumber,
|
||||
Serientitel = s.Title, s.PosterImage, SerienId = s.Id,
|
||||
})
|
||||
.ToDictionaryAsync(f => f.Id, ct);
|
||||
|
||||
var leute = await db.Users.AsNoTracking().Where(u => leuteIds.Contains(u.Id))
|
||||
.Select(u => new { u.Id, u.UserName }).ToDictionaryAsync(u => u.Id, ct);
|
||||
|
||||
var fertig = new List<Meldungseintrag>(roh.Count);
|
||||
foreach (var m in roh)
|
||||
{
|
||||
var melder = leute.TryGetValue(m.UserId, out var u) ? u.UserName : "";
|
||||
var gemeinsam = (
|
||||
Kind: AlsWort(m.Art),
|
||||
Stelle: m.Stelle?.TotalSeconds,
|
||||
m.Kommentar, m.GemeldetAm, m.ErledigtAm);
|
||||
|
||||
if (m.MovieId is { } film)
|
||||
{
|
||||
filme.TryGetValue(film, out var f);
|
||||
fertig.Add(new Meldungseintrag(
|
||||
m.Id, "movie", film, f?.Title ?? "", null, f?.PosterImage,
|
||||
gemeinsam.Kind, gemeinsam.Stelle, gemeinsam.Kommentar,
|
||||
gemeinsam.GemeldetAm, melder, gemeinsam.ErledigtAm));
|
||||
}
|
||||
else if (m.EpisodeId is { } folge)
|
||||
{
|
||||
folgen.TryGetValue(folge, out var f);
|
||||
fertig.Add(new Meldungseintrag(
|
||||
m.Id, "episode", f?.SerienId ?? folge, f?.Serientitel ?? "",
|
||||
f is null ? null
|
||||
: $"S{f.SeasonNumber} · F{f.EpisodeNumber}"
|
||||
+ (f.Folgentitel is { Length: > 0 } t ? $" — {t}" : ""),
|
||||
f?.PosterImage,
|
||||
gemeinsam.Kind, gemeinsam.Stelle, gemeinsam.Kommentar,
|
||||
gemeinsam.GemeldetAm, melder, gemeinsam.ErledigtAm));
|
||||
}
|
||||
else if (m.SeriesId is { } serie)
|
||||
{
|
||||
serien.TryGetValue(serie, out var s);
|
||||
fertig.Add(new Meldungseintrag(
|
||||
m.Id, m.SeasonNumber is null ? "series" : "season", serie,
|
||||
s?.Title ?? "",
|
||||
m.SeasonNumber is { } nummer ? $"Staffel {nummer}" : null,
|
||||
s?.PosterImage,
|
||||
gemeinsam.Kind, gemeinsam.Stelle, gemeinsam.Kommentar,
|
||||
gemeinsam.GemeldetAm, melder, gemeinsam.ErledigtAm));
|
||||
}
|
||||
}
|
||||
return fertig;
|
||||
}
|
||||
|
||||
private static Guid? Kennung(ClaimsPrincipal wer) =>
|
||||
Guid.TryParse(wer.FindFirstValue(ClaimTypes.NameIdentifier), out var id) ? id : null;
|
||||
|
||||
private static Meldungsart? AlsArt(string? wert) => wert?.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"bild" => Meldungsart.Bild,
|
||||
"ton" => Meldungsart.Ton,
|
||||
"untertitel" => Meldungsart.Untertitel,
|
||||
"daten" => Meldungsart.Daten,
|
||||
"sonstiges" => Meldungsart.Sonstiges,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <summary>Das Wort, das über die Leitung geht — nicht der Name im Code.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Von Hand und nicht über <c>ToString()</c>: sonst entschiede ein
|
||||
/// Umbenennen im C# über das Format, das der Client liest.
|
||||
/// </remarks>
|
||||
private static string AlsWort(Meldungsart art) => art switch
|
||||
{
|
||||
Meldungsart.Bild => "bild",
|
||||
Meldungsart.Ton => "ton",
|
||||
Meldungsart.Untertitel => "untertitel",
|
||||
Meldungsart.Daten => "daten",
|
||||
_ => "sonstiges",
|
||||
};
|
||||
|
||||
/// <summary>„1:02:03", „2:03" oder „123" — genau die Formen, die der Client schreibt.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Nicht <c>TimeSpan.Parse</c>: der liest „2:03" als zwei Stunden und drei
|
||||
/// Minuten. Wer in ein Feld neben einem Film „2:03" schreibt, meint zwei
|
||||
/// Minuten — so steht es auch unter jedem Fortschrittsbalken.
|
||||
/// </remarks>
|
||||
private static bool AlsStelle(string? text, out TimeSpan? wert)
|
||||
{
|
||||
wert = null;
|
||||
var roh = text?.Trim() ?? "";
|
||||
if (roh.Length == 0) return true;
|
||||
|
||||
var teile = roh.Split(':');
|
||||
if (teile.Length > 3) return false;
|
||||
|
||||
var zahlen = new List<int>();
|
||||
foreach (var teil in teile)
|
||||
{
|
||||
if (!int.TryParse(teil, out var zahl) || zahl < 0) return false;
|
||||
zahlen.Add(zahl);
|
||||
}
|
||||
|
||||
// Alles ausser dem vordersten Glied zählt in Sechzigern.
|
||||
if (zahlen.Skip(1).Any(z => z > 59)) return false;
|
||||
|
||||
var sekunden = zahlen.Aggregate(0L, (summe, zahl) => summe * 60 + zahl);
|
||||
wert = TimeSpan.FromSeconds(sekunden);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -271,6 +271,7 @@ app.MapMatching();
|
||||
app.MapArtwork();
|
||||
app.MapProfil();
|
||||
app.MapFreigabe();
|
||||
app.MapMeldungen();
|
||||
|
||||
// **Was unter /api nicht beantwortet wurde, ist nicht da.** Ohne diese Zeile
|
||||
// fängt der Rückfall darunter jeden unbekannten Pfad ab und liefert die
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
namespace Flaro.Core.Library;
|
||||
|
||||
/// <summary>Worum es bei einer Meldung geht.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Fünf Fächer, nicht fünfzig.</b> Sie sind nicht dazu da, das Problem zu
|
||||
/// beschreiben — das tut der Kommentar —, sondern dazu, eine Liste sortierbar
|
||||
/// zu machen. „Dreimal Ton an derselben Serie" ist eine Auskunft, die man
|
||||
/// sieht, ohne zu lesen.
|
||||
/// </remarks>
|
||||
public enum Meldungsart
|
||||
{
|
||||
Bild,
|
||||
Ton,
|
||||
Untertitel,
|
||||
|
||||
/// <summary>Titel, Jahr, Plakat, Zuordnung — alles, was nicht im Bild ist.</summary>
|
||||
Daten,
|
||||
|
||||
Sonstiges,
|
||||
}
|
||||
|
||||
/// <summary>Was jemandem an einem Titel aufgefallen ist.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Vier Ziele, drei Spalten.</b> Film, Serie, Staffel, Folge — und eine
|
||||
/// Staffel gibt es als Zeile nicht. Sie ist <c>SeriesId</c> plus
|
||||
/// <c>SeasonNumber</c>, so wie sie überall sonst in diesem Server adressiert
|
||||
/// wird. Eine Tabelle dafür anzulegen, nur damit eine Meldung irgendwohin
|
||||
/// zeigen kann, wäre ein Modellumbau für einen Fremdschlüssel.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Keine Zeile je Benutzer und Titel.</b> Anders als bei
|
||||
/// <see cref="Playback.UserItemState"/> und <see cref="Playback.Sehtag"/> steht
|
||||
/// hier kein eindeutiger Index: zwei Meldungen zu demselben Film sind zwei
|
||||
/// Meldungen. „Der Ton setzt bei 0:30 aus" und „das Plakat gehört zu einem
|
||||
/// anderen Film" haben miteinander nichts zu tun.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Ein Zeitpunkt statt eines Hakens.</b> Wie bei <c>PublishAt</c> und
|
||||
/// <c>ChangedSince</c>: ein <c>bool Erledigt</c> sagt, dass es vorbei ist,
|
||||
/// aber nicht wann — und „seit wann liegt das hier" ist die erste Frage an
|
||||
/// eine Liste von Aufgaben.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class Meldung
|
||||
{
|
||||
public Guid Id { get; init; } = Guid.CreateVersion7();
|
||||
|
||||
/// <summary>Wer gemeldet hat — aus dem Token, nie aus dem Rumpf.</summary>
|
||||
public required Guid UserId { get; init; }
|
||||
|
||||
/// <summary>Entweder ein Film …</summary>
|
||||
public Guid? MovieId { get; init; }
|
||||
|
||||
/// <summary>… oder eine Serie, ganz oder mit <see cref="SeasonNumber"/> …</summary>
|
||||
public Guid? SeriesId { get; init; }
|
||||
|
||||
/// <summary>… oder eine einzelne Folge. Genau eines von dreien.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Ohne die Serie daneben.</b> Welche Serie das ist, weiss die Folge
|
||||
/// selbst; es hier ein zweites Mal abzulegen hiesse, zwei Wahrheiten zu
|
||||
/// pflegen, von denen eine irgendwann die ältere ist.
|
||||
/// </remarks>
|
||||
public Guid? EpisodeId { get; init; }
|
||||
|
||||
/// <summary>Eine einzelne Staffel — nur zusammen mit <see cref="SeriesId"/>.</summary>
|
||||
public int? SeasonNumber { get; init; }
|
||||
|
||||
public required Meldungsart Art { get; init; }
|
||||
|
||||
/// <summary>Wo im Titel. <c>null</c> heisst: nicht an einer Stelle.</summary>
|
||||
public TimeSpan? Stelle { get; init; }
|
||||
|
||||
public required string Kommentar { get; init; }
|
||||
|
||||
public required DateTimeOffset GemeldetAm { get; init; }
|
||||
|
||||
/// <summary>Wann abgehakt — <c>null</c> heisst: offen.</summary>
|
||||
public DateTimeOffset? ErledigtAm { get; set; }
|
||||
|
||||
/// <summary>Wer abgehakt hat.</summary>
|
||||
public Guid? ErledigtVon { get; set; }
|
||||
}
|
||||
@@ -61,6 +61,9 @@ public sealed class FlaroContext(DbContextOptions<FlaroContext> options) : DbCon
|
||||
/// <summary>Wie lange an welchem Tag gesehen wurde — siehe <see cref="Sehtag"/>.</summary>
|
||||
public DbSet<Sehtag> Sehtage => Set<Sehtag>();
|
||||
|
||||
/// <summary>Was Benutzer an Titeln gemeldet haben — siehe <see cref="Meldung"/>.</summary>
|
||||
public DbSet<Meldung> Meldungen => Set<Meldung>();
|
||||
|
||||
public DbSet<MediaFile> MediaFiles => Set<MediaFile>();
|
||||
public DbSet<Movie> Movies => Set<Movie>();
|
||||
public DbSet<Series> Series => Set<Series>();
|
||||
@@ -392,6 +395,39 @@ public sealed class FlaroContext(DbContextOptions<FlaroContext> options) : DbCon
|
||||
e.HasOne<Episode>().WithMany().HasForeignKey(x => x.EpisodeId).OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
b.Entity<Meldung>(e =>
|
||||
{
|
||||
e.HasKey(x => x.Id);
|
||||
|
||||
// **Als Text, nicht als Zahl.** Eine 2 in der Datenbank sagt keinem
|
||||
// Menschen etwas, und eine später eingeschobene Art machte aus
|
||||
// jedem gespeicherten „Ton" stillschweigend „Untertitel".
|
||||
e.Property(x => x.Art).HasConversion<string>().HasMaxLength(32);
|
||||
|
||||
// Zweitausend Zeichen: Platz für eine Schilderung mit Zeiten und
|
||||
// Fassungen, zu wenig für einen Aufsatz. Zum Vergleich: „Der
|
||||
// deutsche Ton setzt nach einer halben Minute aus, Englisch läuft
|
||||
// weiter" sind 72.
|
||||
e.Property(x => x.Kommentar).HasMaxLength(2000);
|
||||
|
||||
// Der Weg der Verwaltung: die offenen, älteste zuerst.
|
||||
e.HasIndex(x => new { x.ErledigtAm, x.GemeldetAm });
|
||||
// Der Weg des Melders: seine eigenen, neueste zuerst.
|
||||
e.HasIndex(x => new { x.UserId, x.GemeldetAm });
|
||||
|
||||
// **Kein eindeutiger Index.** Zwei Meldungen zu demselben Film sind
|
||||
// zwei Meldungen; sie zusammenzulegen hiesse, die zweite zu
|
||||
// verschlucken.
|
||||
|
||||
e.HasOne<User>().WithMany().HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
// Verschwindet der Titel, verschwindet die Meldung dazu. Eine
|
||||
// Aufgabe zu einem Film, den es nicht mehr gibt, ist keine mehr.
|
||||
e.HasOne<Movie>().WithMany().HasForeignKey(x => x.MovieId).OnDelete(DeleteBehavior.Cascade);
|
||||
e.HasOne<Series>().WithMany().HasForeignKey(x => x.SeriesId).OnDelete(DeleteBehavior.Cascade);
|
||||
e.HasOne<Episode>().WithMany().HasForeignKey(x => x.EpisodeId).OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
b.Entity<Episode>(e =>
|
||||
{
|
||||
e.HasKey(x => x.Id);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,93 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Flaro.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Meldungen : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Meldungen",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
MovieId = table.Column<Guid>(type: "TEXT", nullable: true),
|
||||
SeriesId = table.Column<Guid>(type: "TEXT", nullable: true),
|
||||
EpisodeId = table.Column<Guid>(type: "TEXT", nullable: true),
|
||||
SeasonNumber = table.Column<int>(type: "INTEGER", nullable: true),
|
||||
Art = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
|
||||
Stelle = table.Column<long>(type: "INTEGER", nullable: true),
|
||||
Kommentar = table.Column<string>(type: "TEXT", maxLength: 2000, nullable: false),
|
||||
GemeldetAm = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
ErledigtAm = table.Column<long>(type: "INTEGER", nullable: true),
|
||||
ErledigtVon = table.Column<Guid>(type: "TEXT", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Meldungen", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Meldungen_Episodes_EpisodeId",
|
||||
column: x => x.EpisodeId,
|
||||
principalTable: "Episodes",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_Meldungen_Movies_MovieId",
|
||||
column: x => x.MovieId,
|
||||
principalTable: "Movies",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_Meldungen_Series_SeriesId",
|
||||
column: x => x.SeriesId,
|
||||
principalTable: "Series",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_Meldungen_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Meldungen_EpisodeId",
|
||||
table: "Meldungen",
|
||||
column: "EpisodeId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Meldungen_ErledigtAm_GemeldetAm",
|
||||
table: "Meldungen",
|
||||
columns: new[] { "ErledigtAm", "GemeldetAm" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Meldungen_MovieId",
|
||||
table: "Meldungen",
|
||||
column: "MovieId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Meldungen_SeriesId",
|
||||
table: "Meldungen",
|
||||
column: "SeriesId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Meldungen_UserId_GemeldetAm",
|
||||
table: "Meldungen",
|
||||
columns: new[] { "UserId", "GemeldetAm" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "Meldungen");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -440,6 +440,64 @@ namespace Flaro.Data.Migrations
|
||||
b.ToTable("MediaStreams");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Flaro.Core.Library.Meldung", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Art")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("EpisodeId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("ErledigtAm")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid?>("ErledigtVon")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("GemeldetAm")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Kommentar")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("MovieId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("SeasonNumber")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid?>("SeriesId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("Stelle")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("EpisodeId");
|
||||
|
||||
b.HasIndex("MovieId");
|
||||
|
||||
b.HasIndex("SeriesId");
|
||||
|
||||
b.HasIndex("ErledigtAm", "GemeldetAm");
|
||||
|
||||
b.HasIndex("UserId", "GemeldetAm");
|
||||
|
||||
b.ToTable("Meldungen");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Flaro.Core.Library.Movie", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -927,6 +985,30 @@ namespace Flaro.Data.Migrations
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Flaro.Core.Library.Meldung", b =>
|
||||
{
|
||||
b.HasOne("Flaro.Core.Library.Episode", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("EpisodeId")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
b.HasOne("Flaro.Core.Library.Movie", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("MovieId")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
b.HasOne("Flaro.Core.Library.Series", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("SeriesId")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
b.HasOne("Flaro.Core.Users.User", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Flaro.Core.Library.Movie", b =>
|
||||
{
|
||||
b.HasOne("Flaro.Core.Libraries.Library", null)
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using Flaro.Api.Endpoints;
|
||||
|
||||
namespace Flaro.Api.Tests;
|
||||
|
||||
/// <summary>Melden darf jeder, lesen nur die Verwaltung — und die eigenen sieht man selbst.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Der Test, der hier wirklich zählt, ist der mit dem ungeprüften Titel.</b>
|
||||
/// Die Rechteprüfung beim Melden steht nirgends als `Where` — sie fällt aus dem
|
||||
/// Freigabefilter heraus. Das ist elegant und genau deshalb leicht zu
|
||||
/// zerstören: wer in den Endpunkt einmal `IgnoreQueryFilters` schreibt, um
|
||||
/// „auch zurückgezogene Titel zu finden", hat sie weggenommen, ohne dass eine
|
||||
/// Zeile danach aussieht.
|
||||
/// </remarks>
|
||||
public class MeldungTests
|
||||
{
|
||||
private string _bestand = "";
|
||||
|
||||
[SetUp]
|
||||
public void Anlegen() => _bestand = Directory.CreateDirectory(
|
||||
Path.Combine(Path.GetTempPath(), "flaro-meldung-" + Guid.CreateVersion7().ToString("N"))).FullName;
|
||||
|
||||
[TearDown]
|
||||
public void Aufraeumen()
|
||||
{
|
||||
try { Directory.Delete(_bestand, recursive: true); } catch (IOException) { }
|
||||
}
|
||||
|
||||
private void Datei(string relativ)
|
||||
{
|
||||
var voll = Path.Combine(_bestand, relativ);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(voll)!);
|
||||
File.WriteAllBytes(voll, new byte[2048]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Eine_Meldung_zu_einem_Film_landet_beim_Administrator()
|
||||
{
|
||||
Datei("Heat (1995).mkv");
|
||||
|
||||
using var server = new FlaroServer();
|
||||
var alsAdmin = await Aufsetzen(server, "Filme", "Movies");
|
||||
var gast = await server.NeuerBenutzer(alsAdmin, "gast", "gastpasswortlang");
|
||||
|
||||
var film = (await Filme(gast)).Single();
|
||||
|
||||
var gemeldet = await gast.PostAsJsonAsync($"/api/v1/items/{film.Id}/reports",
|
||||
new MeldungRequest("ton", "Der deutsche Ton setzt aus.", "1:02:03"));
|
||||
|
||||
var liste = await alsAdmin.GetFromJsonAsync<List<Meldungseintrag>>("/api/v1/reports");
|
||||
|
||||
Assert.That(gemeldet.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
||||
Assert.That(liste, Has.Count.EqualTo(1));
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(liste![0].Target, Is.EqualTo("movie"));
|
||||
Assert.That(liste[0].ItemId, Is.EqualTo(film.Id));
|
||||
Assert.That(liste[0].Title, Is.EqualTo("Heat"));
|
||||
Assert.That(liste[0].Kind, Is.EqualTo("ton"));
|
||||
Assert.That(liste[0].PositionSeconds, Is.EqualTo(3723));
|
||||
Assert.That(liste[0].ReportedBy, Is.EqualTo("gast"));
|
||||
Assert.That(liste[0].ResolvedAt, Is.Null);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Melden_darf_jeder_die_Verwaltung_bleibt_zu()
|
||||
{
|
||||
Datei("Heat (1995).mkv");
|
||||
|
||||
using var server = new FlaroServer();
|
||||
var alsAdmin = await Aufsetzen(server, "Filme", "Movies");
|
||||
var gast = await server.NeuerBenutzer(alsAdmin, "gast", "gastpasswortlang");
|
||||
var film = (await Filme(gast)).Single();
|
||||
|
||||
var gemeldet = await gast.PostAsJsonAsync($"/api/v1/items/{film.Id}/reports",
|
||||
new MeldungRequest("bild", "Das Plakat gehört zu einem anderen Film."));
|
||||
var meine = await gast.GetAsync("/api/v1/reports/mine");
|
||||
var fremd = await gast.GetAsync("/api/v1/reports");
|
||||
|
||||
var id = (await alsAdmin.GetFromJsonAsync<List<Meldungseintrag>>("/api/v1/reports"))![0].Id;
|
||||
var abhaken = await gast.PostAsync($"/api/v1/reports/{id}/resolve", null);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(gemeldet.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
||||
Assert.That(meine.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
||||
Assert.That(fremd.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden),
|
||||
"Die Liste aller Meldungen ist Verwaltung.");
|
||||
Assert.That(abhaken.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Jeder_sieht_nur_die_eigenen_Meldungen()
|
||||
{
|
||||
Datei("Heat (1995).mkv");
|
||||
|
||||
using var server = new FlaroServer();
|
||||
var alsAdmin = await Aufsetzen(server, "Filme", "Movies");
|
||||
var eine = await server.NeuerBenutzer(alsAdmin, "eine", "einepasswortlang");
|
||||
var andere = await server.NeuerBenutzer(alsAdmin, "andere", "anderepasswortlang");
|
||||
var film = (await Filme(eine)).Single();
|
||||
|
||||
await eine.PostAsJsonAsync($"/api/v1/items/{film.Id}/reports",
|
||||
new MeldungRequest("ton", "Von mir."));
|
||||
await andere.PostAsJsonAsync($"/api/v1/items/{film.Id}/reports",
|
||||
new MeldungRequest("bild", "Von jemand anderem."));
|
||||
|
||||
var meine = await eine.GetFromJsonAsync<List<Meldungseintrag>>("/api/v1/reports/mine");
|
||||
var alle = await alsAdmin.GetFromJsonAsync<List<Meldungseintrag>>("/api/v1/reports");
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(meine, Has.Count.EqualTo(1));
|
||||
Assert.That(meine![0].Comment, Is.EqualTo("Von mir."));
|
||||
Assert.That(alle, Has.Count.EqualTo(2), "Der Administrator sieht beide.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Erledigt_heisst_abgehakt_und_nicht_geloescht()
|
||||
{
|
||||
Datei("Heat (1995).mkv");
|
||||
|
||||
using var server = new FlaroServer();
|
||||
var alsAdmin = await Aufsetzen(server, "Filme", "Movies");
|
||||
var gast = await server.NeuerBenutzer(alsAdmin, "gast", "gastpasswortlang");
|
||||
var film = (await Filme(gast)).Single();
|
||||
|
||||
await gast.PostAsJsonAsync($"/api/v1/items/{film.Id}/reports",
|
||||
new MeldungRequest("untertitel", "Die deutsche Spur ist leer."));
|
||||
var id = (await alsAdmin.GetFromJsonAsync<List<Meldungseintrag>>("/api/v1/reports"))![0].Id;
|
||||
|
||||
(await alsAdmin.PostAsync($"/api/v1/reports/{id}/resolve", null)).EnsureSuccessStatusCode();
|
||||
|
||||
var offen = await alsAdmin.GetFromJsonAsync<List<Meldungseintrag>>("/api/v1/reports");
|
||||
var erledigt = await alsAdmin.GetFromJsonAsync<List<Meldungseintrag>>("/api/v1/reports?erledigt=true");
|
||||
var beimMelder = await gast.GetFromJsonAsync<List<Meldungseintrag>>("/api/v1/reports/mine");
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(offen, Is.Empty);
|
||||
Assert.That(erledigt, Has.Count.EqualTo(1));
|
||||
Assert.That(erledigt![0].ResolvedAt, Is.Not.Null);
|
||||
Assert.That(beimMelder, Has.Count.EqualTo(1),
|
||||
"Der Melder sieht seine Meldung weiterhin — mitsamt dem Stand.");
|
||||
Assert.That(beimMelder![0].ResolvedAt, Is.Not.Null);
|
||||
});
|
||||
|
||||
// Ein Fehlgriff beim Abhaken darf kein Dauerzustand sein.
|
||||
(await alsAdmin.PostAsync($"/api/v1/reports/{id}/reopen", null)).EnsureSuccessStatusCode();
|
||||
var wiederOffen = await alsAdmin.GetFromJsonAsync<List<Meldungseintrag>>("/api/v1/reports");
|
||||
Assert.That(wiederOffen, Has.Count.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Was_man_nicht_sieht_kann_man_nicht_melden()
|
||||
{
|
||||
Datei("Geheimsache (2024).mkv");
|
||||
|
||||
using var server = new FlaroServer();
|
||||
var admin = await server.SetUpAndSignIn();
|
||||
var alsAdmin = server.AlsAngemeldeter(admin.Token);
|
||||
|
||||
var angelegt = await alsAdmin.PostAsJsonAsync("/api/v1/libraries",
|
||||
new CreateLibraryRequest("Prüfbestand", "Movies", _bestand, ReviewNewItems: true));
|
||||
var bibliothek = (await angelegt.Content.ReadFromJsonAsync<LibraryResponse>())!;
|
||||
await Scannen(alsAdmin, bibliothek.Id);
|
||||
|
||||
var wartend = await alsAdmin.GetFromJsonAsync<List<Pruefeintrag>>("/api/v1/review");
|
||||
var id = wartend![0].Id;
|
||||
|
||||
var gast = await server.NeuerBenutzer(alsAdmin, "gast", "gastpasswortlang");
|
||||
var abgelehnt = await gast.PostAsJsonAsync($"/api/v1/items/{id}/reports",
|
||||
new MeldungRequest("bild", "Sieht komisch aus."));
|
||||
var vomAdmin = await alsAdmin.PostAsJsonAsync($"/api/v1/items/{id}/reports",
|
||||
new MeldungRequest("bild", "Sieht komisch aus."));
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(abgelehnt.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
|
||||
Assert.That(vomAdmin.StatusCode, Is.EqualTo(HttpStatusCode.OK),
|
||||
"Der Administrator sieht den Titel und darf ihn deshalb melden.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Eine_Staffel_wird_ueber_Serie_und_Nummer_gemeldet()
|
||||
{
|
||||
Datei("Dark/Season 01/Dark - S01E01.mkv");
|
||||
Datei("Dark/Season 02/Dark - S02E01.mkv");
|
||||
|
||||
using var server = new FlaroServer();
|
||||
var alsAdmin = await Aufsetzen(server, "Serien", "Shows");
|
||||
var gast = await server.NeuerBenutzer(alsAdmin, "gast", "gastpasswortlang");
|
||||
|
||||
var serien = await gast.GetFromJsonAsync<Page<SeriesResponse>>("/api/v1/series");
|
||||
var serie = serien!.Items.Single();
|
||||
|
||||
var gut = await gast.PostAsJsonAsync($"/api/v1/items/{serie.Id}/reports",
|
||||
new MeldungRequest("ton", "Ab Folge zwei kein Ton.", null, SeasonNumber: 2));
|
||||
var falscheStaffel = await gast.PostAsJsonAsync($"/api/v1/items/{serie.Id}/reports",
|
||||
new MeldungRequest("ton", "Gibt es nicht.", null, SeasonNumber: 9));
|
||||
|
||||
var liste = await alsAdmin.GetFromJsonAsync<List<Meldungseintrag>>("/api/v1/reports");
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(gut.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
||||
Assert.That(falscheStaffel.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
|
||||
Assert.That(liste, Has.Count.EqualTo(1));
|
||||
Assert.That(liste![0].Target, Is.EqualTo("season"));
|
||||
Assert.That(liste[0].ItemId, Is.EqualTo(serie.Id));
|
||||
Assert.That(liste[0].SubTitle, Is.EqualTo("Staffel 2"));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Eine_Folge_meldet_sich_unter_ihrer_eigenen_Kennung()
|
||||
{
|
||||
Datei("Dark/Season 01/Dark - S01E01.mkv");
|
||||
|
||||
using var server = new FlaroServer();
|
||||
var alsAdmin = await Aufsetzen(server, "Serien", "Shows");
|
||||
var gast = await server.NeuerBenutzer(alsAdmin, "gast", "gastpasswortlang");
|
||||
|
||||
var serie = (await gast.GetFromJsonAsync<Page<SeriesResponse>>("/api/v1/series"))!.Items.Single();
|
||||
var folgen = await gast.GetFromJsonAsync<Page<EpisodeResponse>>(
|
||||
$"/api/v1/series/{serie.Id}/episodes");
|
||||
var folge = folgen!.Items.Single();
|
||||
|
||||
await gast.PostAsJsonAsync($"/api/v1/items/{folge.Id}/reports",
|
||||
new MeldungRequest("bild", "Bei 0:34 zwei Minuten schwarz.", "0:34:00"));
|
||||
|
||||
var liste = await alsAdmin.GetFromJsonAsync<List<Meldungseintrag>>("/api/v1/reports");
|
||||
|
||||
Assert.That(liste, Has.Count.EqualTo(1));
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(liste![0].Target, Is.EqualTo("episode"));
|
||||
Assert.That(liste[0].ItemId, Is.EqualTo(serie.Id),
|
||||
"Die Detailseite gehört der Serie, nicht der Folge.");
|
||||
Assert.That(liste[0].SubTitle, Does.StartWith("S1 · F1"));
|
||||
Assert.That(liste[0].PositionSeconds, Is.EqualTo(2040));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Ohne_Beschreibung_gibt_es_nichts_nachzusehen()
|
||||
{
|
||||
Datei("Heat (1995).mkv");
|
||||
|
||||
using var server = new FlaroServer();
|
||||
var alsAdmin = await Aufsetzen(server, "Filme", "Movies");
|
||||
var gast = await server.NeuerBenutzer(alsAdmin, "gast", "gastpasswortlang");
|
||||
var film = (await Filme(gast)).Single();
|
||||
|
||||
var leer = await gast.PostAsJsonAsync($"/api/v1/items/{film.Id}/reports",
|
||||
new MeldungRequest("ton", " "));
|
||||
var zuLang = await gast.PostAsJsonAsync($"/api/v1/items/{film.Id}/reports",
|
||||
new MeldungRequest("ton", new string('a', 2001)));
|
||||
var unbekannt = await gast.PostAsJsonAsync($"/api/v1/items/{film.Id}/reports",
|
||||
new MeldungRequest("geruch", "Riecht komisch."));
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(leer.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
|
||||
Assert.That(zuLang.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
|
||||
Assert.That(unbekannt.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>„2:03" sind zwei Minuten, nicht zwei Stunden.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <c>TimeSpan.Parse</c> läse es als zwei Stunden und drei Minuten. Wer
|
||||
/// neben einem Film „2:03" schreibt, meint das, was unter dem
|
||||
/// Fortschrittsbalken steht.
|
||||
/// </remarks>
|
||||
[TestCase("1:02:03", 3723)]
|
||||
[TestCase("2:03", 123)]
|
||||
[TestCase("90", 90)]
|
||||
public async Task Die_Stelle_kommt_als_Zeit_zurueck(string geschrieben, double sekunden)
|
||||
{
|
||||
Datei("Heat (1995).mkv");
|
||||
|
||||
using var server = new FlaroServer();
|
||||
var alsAdmin = await Aufsetzen(server, "Filme", "Movies");
|
||||
var film = (await Filme(alsAdmin)).Single();
|
||||
|
||||
await alsAdmin.PostAsJsonAsync($"/api/v1/items/{film.Id}/reports",
|
||||
new MeldungRequest("bild", "Hier.", geschrieben));
|
||||
var liste = await alsAdmin.GetFromJsonAsync<List<Meldungseintrag>>("/api/v1/reports");
|
||||
|
||||
Assert.That(liste![0].PositionSeconds, Is.EqualTo(sekunden));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Eine_unleserliche_Stelle_wird_abgewiesen()
|
||||
{
|
||||
Datei("Heat (1995).mkv");
|
||||
|
||||
using var server = new FlaroServer();
|
||||
var alsAdmin = await Aufsetzen(server, "Filme", "Movies");
|
||||
var film = (await Filme(alsAdmin)).Single();
|
||||
|
||||
var krumm = await alsAdmin.PostAsJsonAsync($"/api/v1/items/{film.Id}/reports",
|
||||
new MeldungRequest("bild", "Hier.", "Mitte"));
|
||||
var ohne = await alsAdmin.PostAsJsonAsync($"/api/v1/items/{film.Id}/reports",
|
||||
new MeldungRequest("bild", "Betrifft den ganzen Film.", ""));
|
||||
|
||||
var liste = await alsAdmin.GetFromJsonAsync<List<Meldungseintrag>>("/api/v1/reports");
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(krumm.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
|
||||
Assert.That(ohne.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
||||
Assert.That(liste, Has.Count.EqualTo(1));
|
||||
Assert.That(liste![0].PositionSeconds, Is.Null);
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- Gerüst ---------- */
|
||||
|
||||
private async Task<HttpClient> Aufsetzen(FlaroServer server, string name, string art)
|
||||
{
|
||||
var admin = await server.SetUpAndSignIn();
|
||||
var alsAdmin = server.AlsAngemeldeter(admin.Token);
|
||||
|
||||
var angelegt = await alsAdmin.PostAsJsonAsync("/api/v1/libraries",
|
||||
new CreateLibraryRequest(name, art, _bestand, ReviewNewItems: false));
|
||||
angelegt.EnsureSuccessStatusCode();
|
||||
var bibliothek = (await angelegt.Content.ReadFromJsonAsync<LibraryResponse>())!;
|
||||
|
||||
await Scannen(alsAdmin, bibliothek.Id);
|
||||
return alsAdmin;
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<MovieResponse>> Filme(HttpClient client) =>
|
||||
(await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies"))!.Items;
|
||||
|
||||
private static async Task Scannen(HttpClient client, Guid bibliothek)
|
||||
{
|
||||
(await client.PostAsync($"/api/v1/libraries/{bibliothek}/scan", null))
|
||||
.EnsureSuccessStatusCode();
|
||||
|
||||
for (var versuch = 0; versuch < 600; versuch++)
|
||||
{
|
||||
var stand = await client.GetFromJsonAsync<ScanStatus>($"/api/v1/libraries/{bibliothek}/scan");
|
||||
if (stand!.State is "finished") return;
|
||||
if (stand.State is "failed") Assert.Fail($"Der Scan ist gescheitert: {stand.Error}");
|
||||
await Task.Delay(50);
|
||||
}
|
||||
|
||||
Assert.Fail("Der Scan wurde nicht fertig.");
|
||||
}
|
||||
}
|
||||
@@ -810,6 +810,60 @@ export const zurueckziehen = (ids: string[]) =>
|
||||
anfrage<{ withheld: number }>('/review/withhold',
|
||||
{ method: 'POST', body: JSON.stringify({ ids, publishAt: null }) });
|
||||
|
||||
/* ---------- Meldungen ---------- */
|
||||
|
||||
export type Meldungsart = 'bild' | 'ton' | 'untertitel' | 'daten' | 'sonstiges';
|
||||
|
||||
export type Meldung = {
|
||||
id: string;
|
||||
/** Worauf sie zeigt. */
|
||||
target: 'movie' | 'series' | 'season' | 'episode';
|
||||
/** Die Kennung der Detailseite — bei Staffel und Folge die der Serie. */
|
||||
itemId: string;
|
||||
/** Leer, wenn der Titel nicht mehr sichtbar ist. */
|
||||
title: string;
|
||||
subTitle: string | null;
|
||||
posterImage: string | null;
|
||||
kind: Meldungsart;
|
||||
positionSeconds: number | null;
|
||||
comment: string;
|
||||
reportedAt: string;
|
||||
reportedBy: string;
|
||||
/** `null` heisst: offen. */
|
||||
resolvedAt: string | null;
|
||||
};
|
||||
|
||||
/** Meldet ein Problem an einem Titel.
|
||||
*
|
||||
* **Eine Kennung für alles.** Film, Serie und Folge gehen über denselben Weg
|
||||
* hinaus wie Häkchen, Favorit und Fortschritt; welche von dreien es ist,
|
||||
* entscheidet der Server. Nur die Staffel braucht eine Nummer daneben — die
|
||||
* gibt es als eigene Zeile nicht. */
|
||||
export const meldungAnlegen = (
|
||||
id: string, kind: Meldungsart, comment: string, position?: string, staffel?: number,
|
||||
) =>
|
||||
anfrage<{ id: string }>(`/items/${id}/reports`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
kind, comment,
|
||||
position: position ?? null,
|
||||
seasonNumber: staffel ?? null,
|
||||
}),
|
||||
});
|
||||
|
||||
/** Alle Meldungen — offene oder erledigte. Nur für Administratoren. */
|
||||
export const meldungen = (erledigt = false) =>
|
||||
anfrage<Meldung[]>(`/reports${erledigt ? '?erledigt=true' : ''}`);
|
||||
|
||||
/** Die eigenen, offene wie erledigte. */
|
||||
export const meineMeldungen = () => anfrage<Meldung[]>('/reports/mine');
|
||||
|
||||
export const meldungErledigt = (id: string) =>
|
||||
anfrage<void>(`/reports/${id}/resolve`, { method: 'POST' });
|
||||
|
||||
export const meldungWiederOeffnen = (id: string) =>
|
||||
anfrage<void>(`/reports/${id}/reopen`, { method: 'POST' });
|
||||
|
||||
/** Die Reihenfolge der Seitenleiste, von oben nach unten. */
|
||||
export const reihenfolgeSetzen = (ids: string[]) =>
|
||||
anfrage<void>('/libraries/order', { method: 'PUT', body: JSON.stringify({ ids }) });
|
||||
|
||||
+38
-9
@@ -11,7 +11,8 @@ import { auswahl, type Auswahlfeld } from './auswahl';
|
||||
* Vorlesesoftware kündigt ihn als Dialog an. Jede Zeile, die das selbst
|
||||
* baut, ist eine Zeile, die es schlechter kann. */
|
||||
|
||||
export type Feldart = 'text' | 'password' | 'number' | 'ordner' | 'zeitpunkt';
|
||||
export type Feldart =
|
||||
'text' | 'password' | 'number' | 'ordner' | 'zeitpunkt' | 'mehrzeilig';
|
||||
|
||||
export type Dialogfeld = {
|
||||
name: string;
|
||||
@@ -26,6 +27,8 @@ export type Dialogfeld = {
|
||||
schalter?: boolean;
|
||||
/** Frühester erlaubter Zeitpunkt — nur bei `art: 'zeitpunkt'`. */
|
||||
min?: string;
|
||||
/** Wie hoch es aufmacht — nur bei `art: 'mehrzeilig'`. */
|
||||
zeilen?: number;
|
||||
};
|
||||
|
||||
/** Liest einen Feldwert — nie `undefined`, ein unbekannter Name ergibt „".
|
||||
@@ -74,7 +77,7 @@ export function dialog(optionen: {
|
||||
koerper.appendChild(p);
|
||||
}
|
||||
|
||||
const eingaben = new Map<string, HTMLInputElement | Auswahlfeld>();
|
||||
const eingaben = new Map<string, HTMLInputElement | HTMLTextAreaElement | Auswahlfeld>();
|
||||
|
||||
(optionen.felder ?? []).forEach(f => {
|
||||
// **Ein `<label>` nur dort, wo genau ein Bedienelement darin sitzt.**
|
||||
@@ -106,6 +109,18 @@ export function dialog(optionen: {
|
||||
block.prepend(e);
|
||||
titel.remove();
|
||||
block.append(titel);
|
||||
} else if (f.art === 'mehrzeilig') {
|
||||
// **Ein `<textarea>`, kein hohes Textfeld.** „Der Ton setzt bei 0:30
|
||||
// aus" passt in eine Zeile; „… und ab 0:47 ist das Bild schwarz, aber
|
||||
// nur in der 4K-Fassung" nicht. Ein einzeiliges Feld schiebt sich dann
|
||||
// waagerecht weg, und beim Absenden sieht man nicht, was dasteht.
|
||||
const e = document.createElement('textarea');
|
||||
e.className = 'feld';
|
||||
e.rows = f.zeilen ?? 4;
|
||||
e.value = f.wert ?? '';
|
||||
if (f.platzhalter) e.placeholder = f.platzhalter;
|
||||
eingaben.set(f.name, e);
|
||||
block.appendChild(e);
|
||||
} else if (f.art === 'zeitpunkt') {
|
||||
// **Der Wähler des Browsers, nicht ein Textfeld mit Formvorschrift.**
|
||||
// Vorher stand dort „Form: 2026-12-24 20:15" als Hinweis — und jeder
|
||||
@@ -182,6 +197,12 @@ export function dialog(optionen: {
|
||||
los.disabled = true;
|
||||
const gesammelt = new Map<string, string>();
|
||||
eingaben.forEach((e, name) => {
|
||||
// **Vor der Auswahl geprüft.** Die Zeile darunter ist ein Auffangnetz
|
||||
// für alles, was kein `<input>` ist — ein `<textarea>` liefe hinein und
|
||||
// ergäbe `undefined`. Das wäre der stille Fehler: der Dialog schickte
|
||||
// ein leeres Feld los, und der Server antwortete „ohne Beschreibung",
|
||||
// obwohl man sichtbar etwas geschrieben hat.
|
||||
if (e instanceof HTMLTextAreaElement) { gesammelt.set(name, e.value.trim()); return; }
|
||||
if (!(e instanceof HTMLInputElement)) { gesammelt.set(name, e.wert); return; }
|
||||
gesammelt.set(name, e.type === 'checkbox' ? String(e.checked) : e.value.trim());
|
||||
});
|
||||
@@ -210,17 +231,25 @@ export function dialog(optionen: {
|
||||
|
||||
document.body.appendChild(d);
|
||||
d.showModal();
|
||||
// **Ein Seitenwechsel nimmt ihn mit.** Alle Dialoge, die sich selbst bauen,
|
||||
// melden sich hier an; `dialog()` tat es als einziger nicht — ein Dialog von
|
||||
// der Detailseite überlebte damit den Zurück-Knopf und stand danach über der
|
||||
// Mediathek.
|
||||
beimSeitenwechselSchliessen(d);
|
||||
|
||||
const erstes = koerper.querySelector<HTMLElement>('input, select');
|
||||
const erstes = koerper.querySelector<HTMLElement>('input, textarea, select');
|
||||
erstes?.focus();
|
||||
|
||||
// Enter im Feld bestätigt — ausser im mehrzeiligen Fall, den es hier
|
||||
// nicht gibt.
|
||||
// Enter im Feld bestätigt — im mehrzeiligen Feld macht es eine Zeile, und
|
||||
// abgeschickt wird dort mit Strg bzw. ⌘. Diesen Fall gab es bis jetzt
|
||||
// nicht; der Satz, der hier stand, stimmte deshalb.
|
||||
koerper.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter' && !(e.target as HTMLElement).matches('select')) {
|
||||
e.preventDefault();
|
||||
los.click();
|
||||
}
|
||||
if (e.key !== 'Enter') return;
|
||||
const ziel = e.target as HTMLElement;
|
||||
if (ziel.matches('select')) return;
|
||||
if (ziel.matches('textarea') && !(e.ctrlKey || e.metaKey)) return;
|
||||
e.preventDefault();
|
||||
los.click();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -88,6 +88,13 @@ export const icons = {
|
||||
/** Schieberegler — Einstellungen, die eine Stufe haben. */
|
||||
stufen: rahmen('<path d="M5 7h14M5 17h14"/><circle cx="9.5" cy="7" r="2.3"/>'
|
||||
+ '<circle cx="15" cy="17" r="2.3"/>'),
|
||||
|
||||
/** Eine Fahne — hier stimmt etwas nicht.
|
||||
*
|
||||
* **Kein Warndreieck.** Dreieck und Rot sind in dieser Oberfläche für das
|
||||
* reserviert, was etwas wegnimmt. Wer meldet, dass ein Plakat nicht passt,
|
||||
* löst keinen Alarm aus. */
|
||||
fahne: rahmen('<path d="M6 21V4"/><path d="M6 4.8h11.6l-2.5 4 2.5 4H6"/>'),
|
||||
} as const;
|
||||
|
||||
export type IconName = keyof typeof icons;
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { meldungAnlegen, type Meldungsart } from './api';
|
||||
import { dialog } from './dialog';
|
||||
import { hinweisZeigen } from './util';
|
||||
|
||||
/** Die Worte für jede Art.
|
||||
*
|
||||
* **An einer Stelle, nicht an zweien.** Der Dialog bietet sie an, die
|
||||
* Verwaltung zeigt sie wieder an — stünden die Wörter zweimal da, hiesse die
|
||||
* eine Liste irgendwann „Angaben" und die andere noch „Daten". */
|
||||
export const ARTWORT: Record<Meldungsart, string> = {
|
||||
bild: 'Bild',
|
||||
ton: 'Ton',
|
||||
untertitel: 'Untertitel',
|
||||
daten: 'Angaben',
|
||||
sonstiges: 'Sonstiges',
|
||||
};
|
||||
|
||||
/** Meldet ein Problem an einem Titel, einer Staffel oder einer Folge.
|
||||
*
|
||||
* **Warum es das gibt.** 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.
|
||||
*
|
||||
* **Drei Felder, und zwei davon sind freiwillig.** Die Art macht aus einer
|
||||
* Liste von Sätzen etwas, das man sortieren kann; die Stelle macht aus „ab
|
||||
* irgendwann kein Ton" etwas, das man nachstellen kann. Der Kommentar ist das
|
||||
* eigentliche. Pflicht ist nur er.
|
||||
*
|
||||
* @param id Film, Serie oder Folge — welche von dreien, entscheidet der Server.
|
||||
* @param was Was dasteht, damit man im Dialog sieht, worüber man redet.
|
||||
* @param staffel Nur mit einer Serienkennung: eine einzelne Staffel.
|
||||
*/
|
||||
export function meldedialog(id: string, was: string, staffel?: number): void {
|
||||
dialog({
|
||||
titel: 'Problem melden',
|
||||
text: `Betrifft „${was}“. Der Administrator sieht die Meldung mit deinem `
|
||||
+ 'Namen und kann sie abhaken; du siehst unter „Mein Konto“, was daraus '
|
||||
+ 'geworden ist.',
|
||||
felder: [
|
||||
{
|
||||
name: 'art', beschriftung: 'Was stimmt nicht?',
|
||||
auswahl: (Object.keys(ARTWORT) as Meldungsart[])
|
||||
.map(a => ({ wert: a, text: ARTWORT[a] })),
|
||||
wert: 'bild',
|
||||
},
|
||||
{
|
||||
name: 'stelle', beschriftung: 'Stelle',
|
||||
platzhalter: '0:34:12',
|
||||
hinweis: 'Leer lassen, wenn es den ganzen Titel betrifft.',
|
||||
},
|
||||
{
|
||||
name: 'text', beschriftung: 'Was ist passiert?',
|
||||
art: 'mehrzeilig',
|
||||
platzhalter: 'Der deutsche Ton setzt nach einer halben Stunde aus, Englisch läuft weiter.',
|
||||
},
|
||||
],
|
||||
bestaetigen: 'Melden',
|
||||
tun: async wert => {
|
||||
const text = wert('text').trim();
|
||||
// **Hier und nicht erst am Server.** Der Dialog bleibt bei einem Fehler
|
||||
// offen und zeigt den Satz an; einmal hin und zurück über die Leitung
|
||||
// wäre dafür Verschwendung. Der Server prüft es trotzdem.
|
||||
if (text.length === 0) throw new Error('Ohne Beschreibung lässt sich nichts nachsehen.');
|
||||
|
||||
await meldungAnlegen(
|
||||
id, (wert('art') || 'sonstiges') as Meldungsart, text,
|
||||
wert('stelle') || undefined, staffel);
|
||||
hinweisZeigen('Gemeldet. Danke.');
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -663,6 +663,15 @@ h2 { font-size: var(--t-abschnitt); font-weight: 700; letter-spacing: -.015em; m
|
||||
color: var(--text); outline: none; min-width: 0;
|
||||
}
|
||||
.feld:focus { border-color: var(--accent); }
|
||||
|
||||
/* Ein mehrzeiliges Feld erbt alles vom einzeiligen — ausser dem, was es nur
|
||||
dort gibt. Ohne `font: inherit` nähme der Browser eine Schreibmaschine, und
|
||||
der Dialog hätte plötzlich zwei Schriften. Gezogen wird nur senkrecht:
|
||||
waagerecht hiesse, den Dialog aufzusprengen. */
|
||||
textarea.feld {
|
||||
font: inherit; line-height: 1.45;
|
||||
resize: vertical; min-height: 84px;
|
||||
}
|
||||
.knopfreihe { display: flex; gap: var(--s-snug); flex-wrap: wrap; align-items: center; }
|
||||
|
||||
.fehler {
|
||||
@@ -1466,6 +1475,15 @@ body:has(.pl.pl-klein) .auswahlleiste { bottom: 92px; }
|
||||
border-left: 3px solid transparent; border-radius: 0 var(--r-control) var(--r-control) 0;
|
||||
font-size: var(--t-normal);
|
||||
}
|
||||
|
||||
/* Dieselbe Zahl wie in der Hauptleiste, nur in der anderen Leiste: rechts
|
||||
aussen, damit sie nicht am Text klebt, und tabellarisch, damit sie beim
|
||||
Wechsel von 9 auf 10 nicht springt. */
|
||||
.verw-eintrag .zahl {
|
||||
margin-left: auto; flex: none;
|
||||
font-size: 11px; font-variant-numeric: tabular-nums; color: var(--text-faint);
|
||||
}
|
||||
.verw-eintrag.check .zahl { color: var(--accent); }
|
||||
.verw-eintrag .icon { flex: none; color: var(--text-muted); }
|
||||
.verw-eintrag:hover { color: var(--text); background: rgba(255, 255, 255, .04); }
|
||||
.verw-eintrag:hover .icon { color: var(--text-dim); }
|
||||
|
||||
@@ -11,6 +11,7 @@ import { icons, icon } from '../icons';
|
||||
import { kachel, alsKachel, reihe } from '../card';
|
||||
import { vergessen } from '../ansichten';
|
||||
import { spieler } from '../spieler';
|
||||
import { meldedialog } from '../melden';
|
||||
|
||||
type Art = 'filme' | 'serien';
|
||||
|
||||
@@ -186,7 +187,8 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
|
||||
text.appendChild(zeile);
|
||||
}
|
||||
|
||||
text.appendChild(aktionsreihe(eintrag, stand, datei));
|
||||
text.appendChild(aktionsreihe(eintrag, stand, datei,
|
||||
() => meldedialog(eintrag.id, eintrag.title)));
|
||||
|
||||
const beschreibung = document.createElement('p');
|
||||
beschreibung.className = 'beschreibung';
|
||||
@@ -245,7 +247,13 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
|
||||
return 'title' in eintrag && eintrag.title ? eintrag.title : 'Wiedergabe';
|
||||
}
|
||||
|
||||
function aktionsreihe(eintrag: Film | Serie, stand: Stand | null, datei: Datei | null): HTMLElement {
|
||||
function aktionsreihe(
|
||||
eintrag: Film | Serie, stand: Stand | null, datei: Datei | null,
|
||||
/** **Nur vom Kopfbereich gesetzt.** Dieselbe Reihe steht auch über jeder
|
||||
* Fassung und über jeder Folge; unbedingt gesetzt stünde der Melden-Knopf
|
||||
* drei- bis sechsmal auf der Seite und meinte jedes Mal denselben Titel. */
|
||||
melden?: () => void,
|
||||
): HTMLElement {
|
||||
const reihe = document.createElement('div');
|
||||
reihe.className = 'aktionen';
|
||||
const hinweisfeld = document.createElement('div');
|
||||
@@ -321,6 +329,20 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
|
||||
async () => { await weglegen(eintrag.id); await laden(); }));
|
||||
}
|
||||
|
||||
// **Nicht über `rundKnopf`.** Der schaltet sich beim Klick ab und erst im
|
||||
// Fehlerfall wieder an — richtig für etwas, das danach die Seite neu lädt.
|
||||
// Ein Dialog kehrt sofort zurück; der Knopf bliebe für den Rest der Seite
|
||||
// tot.
|
||||
if (melden) {
|
||||
const m = document.createElement('button');
|
||||
m.className = 'rund';
|
||||
m.innerHTML = icons.fahne;
|
||||
m.title = 'Problem melden';
|
||||
m.setAttribute('aria-label', 'Problem melden');
|
||||
m.onclick = melden;
|
||||
reihe.appendChild(m);
|
||||
}
|
||||
|
||||
const huelle = document.createElement('div');
|
||||
huelle.append(reihe, hinweisfeld);
|
||||
return huelle;
|
||||
@@ -555,9 +577,25 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
|
||||
const alle = await folgen(serie.id);
|
||||
|
||||
serie.seasons.forEach(nummer => {
|
||||
// **Die Überschrift bekommt eine Zeile.** Eine Staffel ist das Einzige,
|
||||
// was man melden kann und was es als Eintrag nicht gibt — sie ist Serie
|
||||
// plus Nummer. Also hängt der Knopf an ihrer Überschrift.
|
||||
const kopf = document.createElement('div');
|
||||
kopf.className = 'reihe-kopf';
|
||||
const h = document.createElement('h2');
|
||||
h.textContent = `Staffel ${nummer}`;
|
||||
bereich.appendChild(h);
|
||||
|
||||
const melden = document.createElement('button');
|
||||
melden.className = 'pfeil';
|
||||
melden.style.marginLeft = 'auto';
|
||||
melden.innerHTML = icons.fahne;
|
||||
melden.title = `Problem mit Staffel ${nummer} melden`;
|
||||
melden.setAttribute('aria-label', `Problem mit Staffel ${nummer} melden`);
|
||||
melden.onclick = () => meldedialog(
|
||||
serie.id, `${serie.title} — Staffel ${nummer}`, nummer);
|
||||
|
||||
kopf.append(h, melden);
|
||||
bereich.appendChild(kopf);
|
||||
|
||||
const liste = document.createElement('div');
|
||||
liste.className = 'einstellungen';
|
||||
@@ -582,7 +620,22 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
|
||||
zeile.innerHTML =
|
||||
`<strong>${String(folge.episode).padStart(2, '0')} · ${esc(folge.title ?? 'Ohne Titel')}</strong>`
|
||||
+ (nebenbei ? `<span class="winzig">${esc(nebenbei)}</span>` : '');
|
||||
block.appendChild(zeile);
|
||||
|
||||
const wie = `S${folge.season} · F${folge.episode}`
|
||||
+ (folge.title ? ` — ${folge.title}` : '');
|
||||
const meldenFolge = document.createElement('button');
|
||||
meldenFolge.className = 'rund';
|
||||
meldenFolge.innerHTML = icons.fahne;
|
||||
meldenFolge.title = 'Problem melden';
|
||||
meldenFolge.setAttribute('aria-label', `Problem mit ${wie} melden`);
|
||||
// Die Kennung der Folge allein — welche Serie das ist, weiss sie selbst.
|
||||
meldenFolge.onclick = () => meldedialog(folge.id, wie);
|
||||
|
||||
const oben = document.createElement('div');
|
||||
oben.className = 'knopfreihe';
|
||||
zeile.style.flex = '1';
|
||||
oben.append(zeile, meldenFolge);
|
||||
block.appendChild(oben);
|
||||
|
||||
if (folge.overview) {
|
||||
const text = document.createElement('div');
|
||||
|
||||
@@ -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