diff --git a/CLAUDE.md b/CLAUDE.md
index 670242d..1d7980c 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1908,3 +1908,29 @@ Points à réévaluer en cours de route, sans blocage :
- **Wikidata en 3ᵉ source ISBN** : uniquement si la cascade BnF → OpenLibrary montre ses limites en usage réel.
- **Démarrage hors-ligne par le service worker** : à confirmer dans un navigateur ordinaire, l'environnement d'automatisation ne permettant pas d'enregistrer un service worker (voir la section dédiée). Le reste du hors-ligne, lui, est vérifié.
- **Taille de l'instantané** : 10 livres pèsent ~2,4 Ko de JSON. Rien à optimiser avant plusieurs milliers de fiches ; si le jour vient, découper par pages plutôt que renoncer à l'instantané complet, qui est ce qui rend la recherche hors-ligne possible.
+
+## Lot C — séries, cycles et arborescence (2026-08-20)
+
+Le cycle affiche désormais un arbre de profondeur non bornée par la base : `ArbreSerie.razor`
+descend récursivement les séries à partir de la liste plate de `GET /api/series`. Le chemin déjà
+rendu est transmis au composant afin qu'une boucle résiduelle en base coupe le rendu au lieu de
+provoquer une récursion Razor infinie ; le garde-fou serveur de remontée reste borné par le nombre
+de séries.
+
+Les séries directement contenues dans un cycle se réordonnent par flèches via
+`PUT /api/series/{id}/sous-series/ordre`. Les tomes conservent leur propre ordre et la règle
+hors-ligne reste la même : les boutons d'écriture restent visibles mais désactivés avec
+`EtatReseau.MotifHorsLigne`.
+
+Le pliage/dépliage est un état local de l'écran (`HashSet`), volontairement non persistant :
+il s'agit d'un confort de lecture, pas d'une propriété de la série. Une série repliée montre son
+résumé ; une série dépliée montre ses tomes et ses descendants.
+
+Les compteurs « possédés / total » sont calculés côté client par `TotauxSeries`, en sommant
+récursivement les éléments directs et ceux de tous les descendants. Le calcul est lui aussi
+protégé contre une boucle.
+
+Depuis une place manquante, un ISBN passe par la cascade BnF puis OpenLibrary existante. Le
+candidat choisi peut créer un livre du catalogue puis rattacher la place, ou créer une envie avec
+les métadonnées complètes, notamment l'auteur trouvé par le lookup. Les revues (préfixe `977`) ne
+font pas partie de ce flux : `ElementSerie.LivreId` ne pointe que vers `Livre`.
diff --git a/IDEES.md b/IDEES.md
index 20d850d..78649e7 100644
--- a/IDEES.md
+++ b/IDEES.md
@@ -153,7 +153,7 @@ divergeront comme le filtre a divergé (voir A2).
---
-## Lot C — Séries, cycles et arborescence
+## Lot C — Séries, cycles et arborescence — traité le 2026-08-20
Le lot le plus structurant, et le seul qui touche au modèle.
@@ -181,6 +181,8 @@ Le lot le plus structurant, et le seul qui touche au modèle.
rattacher un numéro de revue à une série demanderait un second lien nullable, ou une parenté
commune. À instruire avant de promettre — ce n'est pas le même travail que le reste du lot.
+✅ Traité : voir `CLAUDE.md` (C1 à C5). Les revues restent explicitement hors périmètre de C5.
+
---
## Lot D — La bibliographie devient un écran de travail
diff --git a/MaBibli.Api/Endpoints/SeriesEndpoints.cs b/MaBibli.Api/Endpoints/SeriesEndpoints.cs
index 0989cea..5ddbe99 100644
--- a/MaBibli.Api/Endpoints/SeriesEndpoints.cs
+++ b/MaBibli.Api/Endpoints/SeriesEndpoints.cs
@@ -83,6 +83,14 @@ public static class SeriesEndpoints
.Produces()
.Produces(StatusCodes.Status404NotFound);
+ groupe.MapPut("/{id:int}/sous-series/ordre", async (
+ int id, OrdreSeries ordre, IServiceSeries service, CancellationToken ct) =>
+ Repondre(await service.ReordonnerSousSeriesAsync(id, ordre.Ids, ct)))
+ .WithName("ReordonnerSousSeries")
+ .WithSummary("Fixe l'ordre de lecture des séries directement contenues dans un cycle.")
+ .Produces()
+ .Produces(StatusCodes.Status404NotFound);
+
groupe.MapPut("/elements/{elementId:int}/livre", async (
int elementId,
RattachementLivre rattachement,
diff --git a/MaBibli.Api/Services/Series/ServiceSeries.cs b/MaBibli.Api/Services/Series/ServiceSeries.cs
index b95aefe..358baa4 100644
--- a/MaBibli.Api/Services/Series/ServiceSeries.cs
+++ b/MaBibli.Api/Services/Series/ServiceSeries.cs
@@ -42,6 +42,9 @@ public interface IServiceSeries
Task ReordonnerAsync(
int serieId, IReadOnlyList idsOrdonnes, CancellationToken ct = default);
+ Task ReordonnerSousSeriesAsync(
+ int serieId, IReadOnlyList idsOrdonnes, CancellationToken ct = default);
+
/// Bascule un tome manquant vers la liste d'envies de l'appelant.
Task MettreEnEnviesAsync(
int elementId, string? utilisateur, CancellationToken ct = default);
@@ -321,6 +324,37 @@ public sealed class ServiceSeries(MaBibliDbContext db, IServiceSouhaits souhaits
return await RelireAsync(serieId, ct);
}
+ public async Task ReordonnerSousSeriesAsync(
+ int serieId, IReadOnlyList idsOrdonnes, CancellationToken ct = default)
+ {
+ if (!await db.Series.AnyAsync(s => s.Id == serieId, ct))
+ {
+ return ResultatSerie.Introuvable;
+ }
+
+ var enfants = await db.Series
+ .Where(s => s.SerieParenteId == serieId)
+ .ToListAsync(ct);
+ var parId = enfants.ToDictionary(s => s.Id);
+ var position = 0;
+
+ foreach (var id in idsOrdonnes.Distinct())
+ {
+ if (parId.Remove(id, out var enfant))
+ {
+ enfant.Position = position++;
+ }
+ }
+
+ foreach (var oublie in parId.Values.OrderBy(s => s.Position).ThenBy(s => s.Id))
+ {
+ oublie.Position = position++;
+ }
+
+ await db.SaveChangesAsync(ct);
+ return await RelireAsync(serieId, ct);
+ }
+
public async Task MettreEnEnviesAsync(
int elementId, string? utilisateur, CancellationToken ct = default)
{
@@ -343,8 +377,6 @@ public sealed class ServiceSeries(MaBibliDbContext db, IServiceSouhaits souhaits
// ⚠️ Ici et seulement ici, une série (commune) écrit dans une liste d'envies
// (personnelle). L'envie est donc celle de l'APPELANT, jamais celle du foyer : deux
// membres peuvent vouloir le même tome manquant, chacun dans sa liste.
- // Aucun auteur n'est transmis : une position dans une saga n'en porte pas, et en
- // inventer un fausserait le rapprochement avec le catalogue.
return await souhaits.AjouterAsync(
new EnregistrementSouhait { Titre = element.Titre }, utilisateur, ct);
}
diff --git a/MaBibli.Client/Composants/ArbreSerie.razor b/MaBibli.Client/Composants/ArbreSerie.razor
new file mode 100644
index 0000000..2499c93
--- /dev/null
+++ b/MaBibli.Client/Composants/ArbreSerie.razor
@@ -0,0 +1,121 @@
+@using MaBibli.Shared.Dtos
+
+
+
+@code {
+ [Parameter, EditorRequired] public SerieDto Serie { get; set; } = default!;
+ [Parameter, EditorRequired] public IReadOnlyList Toutes { get; set; } = [];
+ [Parameter, EditorRequired] public IReadOnlyList Livres { get; set; } = [];
+ [Parameter, EditorRequired] public IReadOnlyDictionary Totaux { get; set; } = new Dictionary();
+ [Parameter] public IReadOnlySet Chemin { get; set; } = new HashSet();
+ [Parameter] public ISet Deplies { get; set; } = new HashSet();
+ [Parameter] public bool ActionsActives { get; set; }
+ [Parameter] public string? MotifBlocage { get; set; }
+ [Parameter] public EventCallback OnDeplacer { get; set; }
+
+ private List Soeurs => Toutes
+ .Where(s => s.SerieParenteId == Serie.SerieParenteId)
+ .OrderBy(s => s.Position)
+ .ThenBy(s => s.Id)
+ .ToList();
+
+ private List Enfants => Toutes
+ .Where(s => s.SerieParenteId == Serie.Id)
+ .OrderBy(s => s.Position)
+ .ThenBy(s => s.Id)
+ .ToList();
+
+ private int _index => Soeurs.FindIndex(s => s.Id == Serie.Id);
+ private bool Deplie => Deplies.Contains(Serie.Id);
+ private IReadOnlySet CheminAvecSerie => new HashSet(Chemin) { Serie.Id };
+
+ private string Avancement()
+ {
+ var total = Totaux.TryGetValue(Serie.Id, out var valeur)
+ ? valeur
+ : (Possedes: Serie.NombrePossedes, Total: Serie.Elements.Count);
+ return total.Total == 0
+ ? "Aucun tome recensé"
+ : $"{total.Possedes} sur {total.Total} tome{(total.Total > 1 ? "s" : "")}";
+ }
+
+ private void Basculer()
+ {
+ if (!Deplies.Add(Serie.Id))
+ {
+ Deplies.Remove(Serie.Id);
+ }
+ }
+
+ private Task Monter() => Deplacer(-1);
+ private Task Descendre() => Deplacer(1);
+
+ private Task Deplacer(int delta) =>
+ Serie.SerieParenteId is { } parent
+ ? OnDeplacer.InvokeAsync(new DeplacementSerie(Serie.Id, parent, delta))
+ : Task.CompletedTask;
+}
diff --git a/MaBibli.Client/Pages/Serie.razor b/MaBibli.Client/Pages/Serie.razor
index 9daaf8d..55f7371 100644
--- a/MaBibli.Client/Pages/Serie.razor
+++ b/MaBibli.Client/Pages/Serie.razor
@@ -34,7 +34,8 @@ else
}
else
{
- @_serie.NombrePossedes sur @_serie.Elements.Count tomes dans la bibliothèque.
+ var total = Total(_serie);
+ @total.Possedes sur @total.Total tomes dans la bibliothèque.
}
@@ -50,19 +51,19 @@ else
@_message
}
- @* Les sous-séries d'un cycle : on les liste, on ne les déplie pas — chacune a son écran. *@
+ @* L'arbre est rendu récursivement ; le chemin protège le rendu si une base ancienne contient
+ malgré tout une boucle. *@
@if (!ModeEdition && _filles.Count > 0)
{
Séries de ce cycle
@foreach (var fille in _filles)
{
-
- @fille.Titre
-
- @fille.NombrePossedes sur @fille.Elements.Count
-
-