Ajouter les sagas et cycles, avec leurs tomes manquants

Première relation entre livres du projet. Une série contient des séries
(un cycle EST une série de séries) et des PLACES dans l'ordre de lecture —
pas des livres : une place sans livre est le trou qu'on vient voir.

L'ordre est stocké, jamais déduit d'une année : une préquelle se lit avant
le livre paru dix ans plus tôt. Les tomes non possédés se saisissent à la
main, aucune source ne donnant l'ordre de lecture d'une saga.

Le titre de la place survit à la suppression du livre (SetNull, pas de
cascade), sans quoi perdre un exemplaire effacerait le tome 3 de la série.

Les séries sont communes au foyer. Seule exception : la mise en envies d'un
tome manquant, qui écrit dans une liste personnelle — c'est le seul point
où les deux portées se rencontrent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mathieu
2026-08-19 22:03:08 +02:00
co-authored by Claude Opus 5
parent 49eb8e1c98
commit 4e372ab694
20 changed files with 2768 additions and 36 deletions
+3 -2
View File
@@ -44,7 +44,7 @@
</main>
@*
Navigation permanente, en bas de l'écran : quatre onglets, toujours au même endroit.
Navigation permanente, en bas de l'écran : cinq onglets, toujours au même endroit.
Elle remplace les listes de liens que chaque page portait dans sa barre d'actions — cinq
boutons y passaient sur deux ou trois lignes sur un téléphone, et l'entrée « Envies » ne
figurait pas partout. Les barres d'actions restantes ne portent plus que des ACTIONS.
@@ -53,12 +53,13 @@
qu'un rang d'onglets sous le bandeau. Les actions de page se placent juste au-dessus
(variable --mb-onglets-hauteur, partagée avec .actions-flottantes).
Les onglets restent actifs hors-ligne : les quatre écrans se consultent depuis leurs
Les onglets restent actifs hors-ligne : les cinq écrans se consultent depuis leurs
instantanés. Ce sont les écritures qui se désactivent, pas la navigation.
*@
<nav class="onglets" aria-label="Navigation principale">
<NavLink class="onglet" href="" Match="NavLinkMatch.All">Catalogue</NavLink>
<NavLink class="onglet" href="auteurs">Auteurs</NavLink>
<NavLink class="onglet" href="series">Séries</NavLink>
<NavLink class="onglet" href="prets">Prêts</NavLink>
<NavLink class="onglet" href="souhaits">Envies</NavLink>
</nav>
+3 -2
View File
@@ -86,13 +86,14 @@
line-height: 1.4;
text-align: center;
text-decoration: none;
font-size: 0.85rem;
font-size: 0.8rem;
color: var(--mb-texte-doux);
/* Le trait actif est posé en haut de l'onglet, du côté du contenu qu'il désigne. */
border-top: 3px solid transparent;
/* Le libellé ne doit jamais passer sur deux lignes : quatre onglets tiennent sur 320 px. */
/* Le libellé ne doit jamais passer sur deux lignes. Cinq onglets tiennent sur 320 px à
cette taille ; c'est la raison du 0.8rem plutôt que du 0.85rem d'avant « Séries ». */
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
+43
View File
@@ -109,6 +109,19 @@ else
</p>
}
@*
Les séries auxquelles ce livre appartient. Il peut y en avoir plusieurs — une
trilogie, une intégrale, un cycle — d'où une liste et non un champ.
⚠️ Lu depuis l'instantané des séries, donc disponible hors-ligne comme le reste.
*@
@foreach (var appartenance in _appartenances)
{
<p class="fiche-serie">
Tome @appartenance.Rang de
<a href="@($"series/{appartenance.Serie.Id}")">@appartenance.Serie.Titre</a>
</p>
}
<dl class="fiche-champs">
@if (!string.IsNullOrWhiteSpace(_livre.Editeur))
{
@@ -180,6 +193,35 @@ else
[Parameter] public int Id { get; set; }
private LivreDto? _livre;
/// <summary>Places de ce livre dans des séries : la série, et son rang de lecture.</summary>
private IReadOnlyList<AppartenanceSerie> _appartenances = [];
private sealed record AppartenanceSerie(SerieDto Serie, int Rang);
/// <summary>
/// Cherche ce livre dans les séries.
/// </summary>
/// <remarks>
/// Une seule lecture rend tout l'arbre, et elle est déjà en cache : chercher côté client
/// évite un point d'entrée « séries d'un livre » qui n'aurait servi qu'ici, et qui aurait
/// eu son propre instantané à tenir.
/// <para>
/// Le rang affiché est la <b>position dans l'ordre de lecture</b>, à partir de 1 — pas un
/// numéro de tome imprimé sur la couverture, que le modèle ne connaît pas.
/// </para>
/// </remarks>
private async Task ChargerAppartenancesAsync()
{
var series = await Api.ListerSeriesAsync();
_appartenances = series
.SelectMany(serie => serie.Elements
.Select((element, index) => (serie, element, index))
.Where(x => x.element.LivreId == Id)
.Select(x => new AppartenanceSerie(x.serie, x.index + 1)))
.ToList();
}
private EnregistrementLivre? _saisie;
private bool _introuvable;
private bool _enregistrement;
@@ -252,6 +294,7 @@ else
try
{
_livre = await Api.ObtenirAsync(Id);
await ChargerAppartenancesAsync();
}
catch (Exception ex)
{
+534
View File
@@ -0,0 +1,534 @@
@page "/series/{Id:int}"
@inject ServiceLivresApi Api
@inject NavigationManager Navigation
@inject EtatReseau Reseau
@implements IDisposable
<PageTitle>MaBibli — @(_serie?.Titre ?? "série")</PageTitle>
@if (_serie is null)
{
<p class="message-discret" role="status">
@(_chargement ? "Chargement…" : "Cette série n'est pas dans les données enregistrées sur cet appareil.")
</p>
}
else
{
@if (_parente is { } parente)
{
<p class="message-discret">
Fait partie de <a href="@($"series/{parente.Id}")">@parente.Titre</a>.
</p>
}
<h1 class="titre-page">@_serie.Titre</h1>
<p class="message-discret">
@if (_serie.Elements.Count == 0)
{
<text>Aucun tome recensé. Ajoutez ceux que vous possédez, et ceux qui vous manquent.</text>
}
else
{
<text>@_serie.NombrePossedes sur @_serie.Elements.Count tomes dans la bibliothèque.</text>
}
</p>
@if (_erreur is not null)
{
<p class="message-erreur" role="alert">@_erreur</p>
}
@* La bascule vers les envies ne change rien à l'écran : sans un mot, on ne saurait pas
qu'elle a eu lieu, et on cliquerait deux fois. *@
@if (_message is not null)
{
<p class="message-avertissement" role="status">@_message</p>
}
@* Les sous-séries d'un cycle : on les liste, on ne les déplie pas — chacune a son écran. *@
@if (_filles.Count > 0)
{
<h2 class="titre-section">Séries de ce cycle</h2>
<ul class="liste-sous-series">
@foreach (var fille in _filles)
{
<li @key="fille.Id">
<a href="@($"series/{fille.Id}")">@fille.Titre</a>
<span class="carte-details">
@fille.NombrePossedes sur @fille.Elements.Count
</span>
</li>
}
</ul>
}
@if (_serie.Elements.Count > 0)
{
<h2 class="titre-section">Ordre de lecture</h2>
@*
⚠️ L'ordre est STOCKÉ, jamais déduit d'une année de publication : une préquelle se
lit avant le livre paru dix ans plus tôt. Les flèches sont le seul moyen de le
changer — le glisser-déposer HTML5 ne fonctionne pas au doigt, et c'est sur
téléphone qu'on consulte une saga en librairie.
*@
<ul class="liste-tomes">
@foreach (var (element, index) in _serie.Elements.Select((e, i) => (e, i)))
{
<li class="carte-tome @(element.Possede ? null : "tome-manquant")" @key="element.Id">
<div class="rang-actions">
<button type="button" class="bouton bouton-compact"
disabled="@(_enCours || !Reseau.EnLigne || index == 0)"
title="@MotifBlocage"
aria-label="@($"Monter « {element.Titre} »")"
@onclick="() => DeplacerAsync(index, index - 1)">▲</button>
<span class="rang-numero" aria-hidden="true">@(index + 1)</span>
<button type="button" class="bouton bouton-compact"
disabled="@(_enCours || !Reseau.EnLigne || index == _serie.Elements.Count - 1)"
title="@MotifBlocage"
aria-label="@($"Descendre « {element.Titre} »")"
@onclick="() => DeplacerAsync(index, index + 1)">▼</button>
</div>
<div class="carte-corps">
@if (element.LivreId is { } livreId)
{
<a class="carte-titre" href="@($"livres/{livreId}")">@element.Titre</a>
}
else
{
<p class="carte-titre">@element.Titre</p>
}
@if (!string.IsNullOrWhiteSpace(element.Auteurs))
{
<p class="carte-auteur">@element.Auteurs</p>
}
<p class="carte-details">
@if (!element.Possede)
{
@* Dire ce qui manque, et non laisser deviner par l'absence de lien. *@
<span class="etiquette etiquette-manquant">Pas dans la bibliothèque</span>
}
@if (element.PreteA is { } emprunteur)
{
<span class="etiquette etiquette-prete">Prêté à @emprunteur</span>
}
</p>
<div class="actions-tome">
@if (!element.Possede)
{
@* Le tome manquant est COMMUN au foyer, l'envie qu'il devient est
PERSONNELLE : deux membres peuvent vouloir le même tome. *@
<button type="button" class="bouton bouton-compact"
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
@onclick="() => SouhaiterAsync(element)">
Ajouter à mes envies
</button>
<button type="button" class="bouton bouton-compact"
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
@onclick="() => OuvrirRattachement(element.Id)">
Je l'ai
</button>
}
else
{
<button type="button" class="bouton bouton-compact"
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
@onclick="() => RattacherAsync(element.Id, null)">
Détacher
</button>
}
<button type="button" class="bouton bouton-compact bouton-danger"
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
@onclick="() => RetirerAsync(element)">
Retirer
</button>
</div>
@if (_rattachement == element.Id)
{
<div class="recherche-livre">
<input class="champ-saisie" type="search" placeholder="Chercher dans le catalogue…"
value="@_recherche" @oninput="ChercherAsync"
aria-label="Chercher un livre à rattacher" />
@foreach (var livre in _resultats)
{
<button type="button" class="bouton bouton-discret"
@key="livre.Id"
@onclick="() => RattacherAsync(element.Id, livre.Id)">
@livre.Titre@(livre.AuteursAffiches is { } a ? $" — {a}" : "")
</button>
}
</div>
}
</div>
</li>
}
</ul>
}
<h2 class="titre-section">Ajouter un tome</h2>
@*
Deux voies, parce qu'une saga se recense d'un coup — souvent avant d'en posséder la
moitié — puis se remplit au fil des achats. Un tome saisi sans livre N'EST PAS un
brouillon : c'est ce qui rend le trou visible.
*@
<div class="barre-recherche">
<input class="champ-saisie" type="text" placeholder="Titre d'un tome…"
@bind="_nouveauTome" @bind:event="oninput" @onkeydown="SurToucheTome"
aria-label="Titre du tome à ajouter" autocomplete="off" />
</div>
<div class="actions-formulaire">
<button type="button" class="bouton bouton-principal"
disabled="@(_enCours || !Reseau.EnLigne || string.IsNullOrWhiteSpace(_nouveauTome))"
title="@MotifBlocage"
@onclick="() => AjouterTomeAsync(null)">
Ajouter ce tome
</button>
<button type="button" class="bouton bouton-discret"
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
@onclick="() => OuvrirRattachement(AjoutDepuisCatalogue)">
Depuis le catalogue
</button>
</div>
@if (_rattachement == AjoutDepuisCatalogue)
{
<div class="recherche-livre">
<input class="champ-saisie" type="search" placeholder="Chercher dans le catalogue…"
value="@_recherche" @oninput="ChercherAsync"
aria-label="Chercher un livre à ajouter" />
@foreach (var livre in _resultats)
{
<button type="button" class="bouton bouton-discret" @key="livre.Id"
@onclick="() => AjouterTomeAsync(livre.Id)">
@livre.Titre@(livre.AuteursAffiches is { } a ? $" — {a}" : "")
</button>
}
</div>
}
<h2 class="titre-section">La série</h2>
<label class="champ">
<span class="champ-libelle">Nom</span>
<input class="champ-saisie" type="text" @bind="_titre" @bind:event="oninput" />
</label>
<label class="champ">
<span class="champ-libelle">
Rangée dans<span class="champ-aide"> (un cycle contient des séries)</span>
</span>
<select class="champ-saisie" @bind="_parenteId">
<option value="0">Aucune — série de premier niveau</option>
@foreach (var candidate in Candidates)
{
<option value="@candidate.Id">@candidate.Titre</option>
}
</select>
</label>
<div class="actions-formulaire">
<button type="button" class="bouton bouton-principal"
disabled="@(_enCours || !Reseau.EnLigne || string.IsNullOrWhiteSpace(_titre))"
title="@MotifBlocage"
@onclick="EnregistrerAsync">
Enregistrer
</button>
<button type="button" class="bouton bouton-discret bouton-danger"
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
@onclick="SupprimerAsync">
@(_confirmeSuppression ? "Confirmer la suppression" : "Supprimer la série")
</button>
</div>
@if (_confirmeSuppression)
{
<p class="message-avertissement" role="status">
Les livres restent au catalogue ; seules les places disparaissent.
@if (_filles.Count > 0)
{
<text> Les @_filles.Count série(s) de ce cycle deviendront des séries de premier niveau.</text>
}
</p>
}
}
@code {
/// <summary>Marqueur de la recherche « ajouter depuis le catalogue », qui ne vise aucun élément.</summary>
private const int AjoutDepuisCatalogue = -1;
[Parameter] public int Id { get; set; }
private IReadOnlyList<SerieDto> _toutes = [];
private SerieDto? _serie;
private SerieDto? _parente;
private List<SerieDto> _filles = [];
private bool _chargement = true;
private bool _enCours;
private string? _erreur;
private string _titre = string.Empty;
private int _parenteId;
private bool _confirmeSuppression;
private string _nouveauTome = string.Empty;
private int? _rattachement;
private string _recherche = string.Empty;
private IReadOnlyList<LivreDto> _resultats = [];
private string? MotifBlocage => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne;
/// <summary>
/// Séries dans lesquelles celle-ci peut être rangée.
/// </summary>
/// <remarks>
/// Ni elle-même, ni ses descendantes : l'arbre se refermerait sur lui-même. Le serveur le
/// refuse aussi — cette liste évite seulement de proposer un choix qui sera rejeté.
/// </remarks>
private IEnumerable<SerieDto> Candidates =>
_toutes.Where(s => s.Id != Id && !EstDescendante(s.Id));
private bool EstDescendante(int candidate)
{
var courante = _toutes.FirstOrDefault(s => s.Id == candidate);
var garde = _toutes.Count + 1;
while (courante is not null && garde-- > 0)
{
if (courante.Id == Id)
{
return true;
}
courante = courante.SerieParenteId is { } parenteId
? _toutes.FirstOrDefault(s => s.Id == parenteId)
: null;
}
return false;
}
protected override void OnInitialized() => Reseau.Change += SurChangementReseau;
private void SurChangementReseau() => _ = InvokeAsync(async () =>
{
await ChargerAsync();
StateHasChanged();
});
public void Dispose() => Reseau.Change -= SurChangementReseau;
protected override Task OnParametersSetAsync() => ChargerAsync();
private async Task ChargerAsync()
{
_chargement = true;
_toutes = await Api.ListerSeriesAsync();
_chargement = false;
Rattacher();
}
/// <summary>Recale les vues dérivées après un chargement ou une écriture.</summary>
private void Rattacher()
{
_serie = _toutes.FirstOrDefault(s => s.Id == Id);
if (_serie is null)
{
return;
}
_parente = _serie.SerieParenteId is { } parenteId
? _toutes.FirstOrDefault(s => s.Id == parenteId)
: null;
_filles = _toutes.Where(s => s.SerieParenteId == Id).OrderBy(s => s.Position).ToList();
_titre = _serie.Titre;
_parenteId = _serie.SerieParenteId ?? 0;
}
/// <summary>Remplace la série courante par celle que vient de rendre le serveur.</summary>
private void Appliquer(SerieDto serie)
{
_toutes = _toutes.Select(s => s.Id == serie.Id ? serie : s).ToList();
Rattacher();
}
private async Task SurToucheTome(KeyboardEventArgs e)
{
if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(_nouveauTome) && Reseau.EnLigne)
{
await AjouterTomeAsync(null);
}
}
private async Task AjouterTomeAsync(int? livreId)
{
await EcrireAsync(() => Api.AjouterElementSerieAsync(
Id, new AjoutElementSerie { LivreId = livreId, Titre = livreId is null ? _nouveauTome : null }));
if (_erreur is null)
{
_nouveauTome = string.Empty;
FermerRecherche();
}
}
private Task RattacherAsync(int elementId, int? livreId) =>
EcrireAsync(async () =>
{
var resultat = await Api.RattacherLivreSerieAsync(elementId, livreId);
FermerRecherche();
return resultat;
});
private async Task RetirerAsync(ElementSerieDto element)
{
_enCours = true;
_erreur = await Api.RetirerElementSerieAsync(element.Id);
_enCours = false;
if (_erreur is null)
{
await ChargerAsync();
}
}
private async Task SouhaiterAsync(ElementSerieDto element)
{
_enCours = true;
_erreur = null;
var resultat = await Api.SouhaiterTomeAsync(element.Id);
_enCours = false;
_erreur = resultat.Erreur;
if (resultat.EstOk)
{
_message = $"« {element.Titre} » est dans votre liste d'envies.";
}
}
private string? _message;
private async Task DeplacerAsync(int depuis, int vers)
{
if (_serie is null || vers < 0 || vers >= _serie.Elements.Count)
{
return;
}
// Réordonné localement d'abord : la flèche doit répondre tout de suite, l'aller-retour
// réseau ne doit pas se voir. Un échec recharge l'ordre du serveur.
var ids = _serie.Elements.Select(e => e.Id).ToList();
(ids[depuis], ids[vers]) = (ids[vers], ids[depuis]);
await EcrireAsync(() => Api.ReordonnerSerieAsync(Id, ids));
}
private async Task EnregistrerAsync()
{
await EcrireAsync(() => Api.ModifierSerieAsync(
Id, new EnregistrementSerie { Titre = _titre, SerieParenteId = _parenteId == 0 ? null : _parenteId }));
// Le rangement dans un cycle change la parenté des AUTRES séries affichées : on relit.
if (_erreur is null)
{
await ChargerAsync();
}
}
private async Task SupprimerAsync()
{
if (!_confirmeSuppression)
{
_confirmeSuppression = true;
return;
}
_enCours = true;
_erreur = await Api.SupprimerSerieAsync(Id);
_enCours = false;
if (_erreur is null)
{
Navigation.NavigateTo("series");
}
}
private void OuvrirRattachement(int cible)
{
_rattachement = cible;
_recherche = string.Empty;
_resultats = [];
}
private void FermerRecherche()
{
_rattachement = null;
_recherche = string.Empty;
_resultats = [];
}
private async Task ChercherAsync(ChangeEventArgs e)
{
_recherche = e.Value?.ToString() ?? string.Empty;
if (string.IsNullOrWhiteSpace(_recherche))
{
_resultats = [];
return;
}
// La recherche passe par le service habituel : hors-ligne elle interroge l'instantané,
// en ligne le serveur. Rien de particulier à prévoir ici.
_resultats = (await Api.ListerAsync(new CritereLivres { Recherche = _recherche }))
.Take(8)
.ToList();
}
/// <summary>Exécute une écriture qui rend la série, en tenant l'état d'attente et l'erreur.</summary>
private async Task EcrireAsync(Func<Task<ResultatApi<SerieDto>>> appel)
{
_enCours = true;
_erreur = null;
_message = null;
var resultat = await appel();
_enCours = false;
if (!resultat.EstOk)
{
_erreur = resultat.Erreur;
// L'ordre affiché a pu être modifié localement avant l'appel : on reprend celui du
// serveur plutôt que de laisser croire à un enregistrement.
await ChargerAsync();
return;
}
Appliquer(resultat.Valeur!);
}
}
+142
View File
@@ -0,0 +1,142 @@
@page "/series"
@inject ServiceLivresApi Api
@inject EtatReseau Reseau
@implements IDisposable
<PageTitle>MaBibli — séries</PageTitle>
<h1 class="titre-page">Séries</h1>
<p class="message-discret">
Sagas, cycles et trilogies, dans leur ordre de <strong>lecture</strong> — qui n'est pas
toujours celui de publication. Les séries sont communes au foyer, comme le catalogue.
</p>
@if (_erreur is not null)
{
<p class="message-erreur" role="alert">@_erreur</p>
}
@* Créer une série depuis la liste : c'est le seul endroit d'où l'on part, et un écran de plus
pour un unique champ ne se justifierait pas. *@
<div class="barre-recherche">
<input class="champ-saisie" type="text" placeholder="Nom d'une nouvelle série…"
@bind="_nouveau" @bind:event="oninput" @onkeydown="SurTouche"
aria-label="Nom de la nouvelle série" autocomplete="off" />
</div>
<div class="actions-formulaire">
<button type="button" class="bouton bouton-principal"
disabled="@(_enCours || !Reseau.EnLigne || string.IsNullOrWhiteSpace(_nouveau))"
title="@MotifBlocage"
@onclick="CreerAsync">
Créer la série
</button>
</div>
@if (_series is null)
{
<p class="message-discret" role="status">Chargement…</p>
}
else if (_series.Count == 0)
{
<p class="message-discret">
Aucune série pour l'instant. Créez-en une, puis ajoutez-y vos livres et les tomes
qui vous manquent : c'est ce qui permet de voir les trous.
</p>
}
else
{
<ul class="liste-series">
@foreach (var serie in Racines)
{
<li class="carte-serie" @key="serie.Id">
<a class="carte-titre" href="@($"series/{serie.Id}")">@serie.Titre</a>
<p class="carte-details">@Avancement(serie)</p>
@* Un cycle contient des séries : elles se voient d'ici, sans ouvrir le cycle. *@
@if (Filles(serie.Id) is { Count: > 0 } filles)
{
<ul class="liste-sous-series">
@foreach (var fille in filles)
{
<li @key="fille.Id">
<a href="@($"series/{fille.Id}")">@fille.Titre</a>
<span class="carte-details">@Avancement(fille)</span>
</li>
}
</ul>
}
</li>
}
</ul>
}
@code {
private IReadOnlyList<SerieDto>? _series;
private string _nouveau = string.Empty;
private bool _enCours;
private string? _erreur;
private string? MotifBlocage => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne;
/// <summary>Séries de premier niveau. Celles rangées dans un cycle s'affichent sous lui.</summary>
private IEnumerable<SerieDto> Racines =>
_series?.Where(s => s.SerieParenteId is null) ?? [];
private List<SerieDto> Filles(int parenteId) =>
_series?.Where(s => s.SerieParenteId == parenteId).OrderBy(s => s.Position).ToList() ?? [];
/// <summary>
/// « 4 tomes sur 7 » : le compteur est l'information principale de l'écran.
/// </summary>
/// <remarks>
/// Une série sans aucun tome ne se décrit pas par « 0 sur 0 », qui ressemble à une erreur :
/// elle dit ce qu'il faut faire.
/// </remarks>
private static string Avancement(SerieDto serie) =>
serie.Elements.Count == 0
? "Aucun tome recensé"
: $"{serie.NombrePossedes} tome{(serie.NombrePossedes > 1 ? "s" : "")} sur {serie.Elements.Count}";
protected override void OnInitialized() => Reseau.Change += SurChangementReseau;
private void SurChangementReseau() => _ = InvokeAsync(async () =>
{
await ChargerAsync();
StateHasChanged();
});
public void Dispose() => Reseau.Change -= SurChangementReseau;
protected override Task OnInitializedAsync() => ChargerAsync();
private async Task ChargerAsync() => _series = await Api.ListerSeriesAsync();
private async Task SurTouche(KeyboardEventArgs e)
{
if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(_nouveau) && Reseau.EnLigne)
{
await CreerAsync();
}
}
private async Task CreerAsync()
{
_enCours = true;
_erreur = null;
var resultat = await Api.CreerSerieAsync(new EnregistrementSerie { Titre = _nouveau });
_enCours = false;
if (!resultat.EstOk)
{
_erreur = resultat.Erreur;
return;
}
_nouveau = string.Empty;
await ChargerAsync();
}
}
+14
View File
@@ -34,6 +34,20 @@ public static class ClesCache
/// </para>
/// </remarks>
public const string Souhaits = "souhaits";
/// <summary>
/// Toutes les séries, à plat, avec leurs tomes.
/// </summary>
/// <remarks>
/// Un seul instantané sert la liste <b>et</b> le détail de chaque série, l'API rendant
/// l'arbre entier en une lecture. C'est ce qui évite un instantané par série consultée —
/// et surtout ce qui rend consultable hors-ligne une série qu'on n'avait pas ouverte avant
/// la coupure, exactement pour la même raison que le catalogue complet.
/// <para>
/// Communes au foyer, contrairement à <see cref="Souhaits"/>.
/// </para>
/// </remarks>
public const string Series = "series";
}
/// <summary>Un instantané relu du cache, avec la date de la synchronisation qui l'a produit.</summary>
@@ -47,6 +47,7 @@ public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, Etat
await ListerAuteursAsync(ct);
await ListerPretsEnCoursAsync(ct);
await ListerSouhaitsAsync(ct);
await ListerSeriesAsync(ct);
await ObtenirUtilisateurAsync(ct);
return reseau.EnLigne;
}
@@ -516,6 +517,7 @@ public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, Etat
await ListerPretsEnCoursAsync();
await ListerAuteursAsync();
await ListerSouhaitsAsync();
await ListerSeriesAsync();
}
catch (Exception)
{
@@ -737,6 +739,102 @@ public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, Etat
: await LireAsync<BibliographieDto>(reponse, ct);
}
// ─────────────────────────────────────────────────────────────────────────
// Sagas, cycles et séries. COMMUNES au foyer, comme le catalogue : aucune identité ne
// transite, sauf pour la mise en envies, qui écrit dans une liste personnelle.
// ─────────────────────────────────────────────────────────────────────────
/// <summary>
/// Toutes les séries, avec leurs tomes. Une seule lecture sert la liste et chaque détail.
/// </summary>
public async Task<IReadOnlyList<SerieDto>> ListerSeriesAsync(CancellationToken ct = default)
{
var (ok, series) = await EssayerAsync(
() => http.GetFromJsonAsync<List<SerieDto>>("api/series", Json, ct));
if (ok)
{
await MemoriserAsync(ClesCache.Series, series ?? []);
return series ?? [];
}
var instantane = await LireCacheAsync<List<SerieDto>>(ClesCache.Series);
return instantane?.Donnees ?? [];
}
public async Task<ResultatApi<SerieDto>> CreerSerieAsync(
EnregistrementSerie saisie, CancellationToken ct = default) =>
await EcrireAsync<SerieDto>(() => http.PostAsJsonAsync("api/series", saisie, Json, ct), ct);
public async Task<ResultatApi<SerieDto>> ModifierSerieAsync(
int id, EnregistrementSerie saisie, CancellationToken ct = default) =>
await EcrireAsync<SerieDto>(() => http.PutAsJsonAsync($"api/series/{id}", saisie, Json, ct), ct);
public async Task<ResultatApi<SerieDto>> AjouterElementSerieAsync(
int serieId, AjoutElementSerie saisie, CancellationToken ct = default) =>
await EcrireAsync<SerieDto>(
() => http.PostAsJsonAsync($"api/series/{serieId}/elements", saisie, Json, ct), ct);
/// <summary>Rattache un livre possédé à une place, ou l'en détache avec <c>null</c>.</summary>
public async Task<ResultatApi<SerieDto>> RattacherLivreSerieAsync(
int elementId, int? livreId, CancellationToken ct = default) =>
await EcrireAsync<SerieDto>(
() => http.PutAsJsonAsync(
$"api/series/elements/{elementId}/livre", new { livreId }, Json, ct), ct);
public async Task<ResultatApi<SerieDto>> ReordonnerSerieAsync(
int serieId, IReadOnlyList<int> ids, CancellationToken ct = default) =>
await EcrireAsync<SerieDto>(
() => http.PutAsJsonAsync(
$"api/series/{serieId}/ordre", new OrdreElementsSerie { Ids = [.. ids] }, Json, ct), ct);
/// <summary>Met un tome manquant dans <b>ma</b> liste d'envies — la série, elle, est commune.</summary>
public async Task<ResultatApi<SouhaitDto>> SouhaiterTomeAsync(
int elementId, CancellationToken ct = default) =>
await EcrireAsync<SouhaitDto>(
() => http.PostAsync($"api/series/elements/{elementId}/souhait", null, ct), ct);
/// <summary>
/// Retire une place de la série, ou la série entière. <c>null</c> si c'est fait.
/// </summary>
/// <remarks>
/// Comme les autres suppressions : réponse <c>204 No Content</c>, donc pas de
/// désérialisation, donc pas <see cref="EcrireAsync{T}"/>.
/// </remarks>
public Task<string?> RetirerElementSerieAsync(int elementId, CancellationToken ct = default) =>
SupprimerAsync($"api/series/elements/{elementId}", ct);
public Task<string?> SupprimerSerieAsync(int id, CancellationToken ct = default) =>
SupprimerAsync($"api/series/{id}", ct);
/// <summary>Suppression générique : refus hors-ligne, 204 attendu, instantanés rafraîchis.</summary>
private async Task<string?> SupprimerAsync(string url, CancellationToken ct)
{
if (!reseau.EnLigne)
{
return EtatReseau.MotifHorsLigne;
}
try
{
var reponse = await http.DeleteAsync(url, ct);
reseau.SignalerSuccesReseau();
if (!reponse.IsSuccessStatusCode)
{
return await MessageErreurAsync(reponse, ct);
}
_ = RafraichirEnFondAsync();
return null;
}
catch (HttpRequestException)
{
reseau.SignalerEchecReseau();
return EtatReseau.MotifHorsLigne;
}
}
private sealed record ErreurApi(string? Erreur);
}
+78
View File
@@ -937,3 +937,81 @@ body {
.onglets-secondaires .segment {
flex: 1 1 0;
}
/* --- Sagas, cycles et séries --- */
.liste-series,
.liste-tomes,
.liste-sous-series {
list-style: none;
margin: 0.5rem 0;
padding: 0;
}
.carte-serie {
padding: 0.7rem 0;
border-bottom: 1px solid var(--mb-bord);
}
.liste-sous-series {
margin: 0.35rem 0 0 1rem;
font-size: 0.9rem;
}
.liste-sous-series li {
padding: 0.2rem 0;
display: flex;
gap: 0.5rem;
align-items: baseline;
flex-wrap: wrap;
}
.carte-tome {
display: flex;
gap: 0.6rem;
align-items: flex-start;
padding: 0.6rem 0;
border-bottom: 1px solid var(--mb-bord);
}
/* Le tome absent est estompé, comme une œuvre déjà possédée l'est dans la bibliographie —
mais l'opacité ne dit rien à un lecteur d'écran, d'où l'étiquette qui l'accompagne. */
.tome-manquant .carte-titre {
font-weight: 400;
color: var(--mb-texte-doux);
}
.etiquette-manquant {
background: #f3e2e2;
color: #7a2f2f;
font-weight: 600;
}
.actions-tome {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-top: 0.35rem;
}
/* Rattacher un livre à une place : la recherche s'ouvre SOUS le tome concerné, pour qu'on voie
ce qu'on est en train de compléter. */
.recherche-livre {
display: flex;
flex-direction: column;
gap: 0.3rem;
margin-top: 0.5rem;
padding: 0.5rem;
background: var(--mb-fond-doux);
border-radius: 6px;
}
.recherche-livre .bouton {
text-align: left;
}
.fiche-serie {
margin: 0.2rem 0;
font-size: 0.9rem;
color: var(--mb-texte-doux);
}