DOCS/v1-mediatheque-plan.md
2026-09-04 16:48:04 +02:00

20 KiB
Raw Permalink Blame History

V1 — Médiathèque (refonte de l'onglet Ressources)

Statut : implémenté le 2026-09-02. Backend + front livrés, dotnet test et flutter build web verts. Reste à valider à l'œil dans le navigateur (checklist §6). Autonome. Aucun appel à un modèle, aucune clé API nouvelle, aucun coût variable. Ne dépend ni du Studio ni de la migration Postgres. Peut se livrer seul.

Le Studio (V2) se greffe dessus plus tard sans le réécrire — voir v2/studio-plan.md §3.0. Ce document est exécutable tel quel : il ne suppose aucune connaissance de la conversation de conception.


0. Pourquoi ce lot existe

Il est né en cherchant où loger les images générées par le futur Studio. La réponse a été « nulle part de nouveau, l'écran existant suffit » — à condition de le refondre, parce qu'à 312 ressources il est illisible.

Les défauts sont ceux d'aujourd'hui, mesurés dans le code. Le lot les corrige, plus deux bugs.

Défaut
Tuiles carrées de 160 px, affichées d'un bloc sans ordre ni groupement resource_body_grid.dart:78SliverGridDelegateWithMaxCrossAxisExtent(maxCrossAxisExtent: 160, childAspectRatio: 1.0)
Chips de type en Wrapun seul axe de filtre resource_body_grid.dart:133
Ni tri, ni groupement, ni sélection multiple, ni action en lot absent
Popup 520 px, borderRadius: 20, aperçu encadré d'une bordure grise de 3 px à borderRadius: 30 — hors de l'échelle de l'app (5/8/10) show_resource_popup.dart:20, 50-55
Champ Label au-dessus de l'aperçu : on nomme avant d'avoir vu show_resource_popup.dart:36
Quatre RoundedButton de même poids dans un Wrap — destructif collé au primaire, ça se stacke show_resource_popup.dart:67-110
Aucune métadonnée affichée, alors que SizeBytes, FileName, DateCreation, Type sont en base
Impossible de savoir une ressource est utilisée, ni si elle l'est
🐛 Le téléchargement force .json quel que soit le type : un PNG se télécharge en label.json show_resource_popup.dart:91
🐛 Resource.FileName n'est pas dans ToDTO() — le front n'a pas de quoi nommer le fichier Data/Resource.cs

1. Périmètre

Dans le lot

  1. Renommage Ressources → Médiathèque (libellé de menu et titre d'écran).
  2. Rail de facettes cumulables avec compteurs : Type · Usage · Origine · Configuration.
  3. Tri (date, nom, poids, usages) et groupement par mois, bascule grille ↔ liste.
  4. Sélection multiple et actions en lot (télécharger, supprimer).
  5. Compteur d'usages sur la vignette.
  6. Le détail passe de la modale au panneau latéral : aperçu d'abord, métadonnées réelles, liste « Utilisée dans » cliquable, boutons hiérarchisés, Supprimer désactivé tant que la ressource sert.
  7. Backend : index inverse des usages (2 endpoints) + enrichissement de ResourceDTO.
  8. Les deux bugs ci-dessus.

Hors du lot — explicitement

  • Toute génération IA (onglet « Générer », crédits, provenance) → V2.
  • Le filtre « Origine Générées par IA » : la facette existe dans le rail, mais inutile en V1 puisque rien n'est généré. La coder quand même (elle lit AiProvenance, absent en V1 → 0 résultat) ou la masquer tant que studioEnabled == false. Recommandé : la masquer, pour ne pas afficher une facette vide.
  • Upload serveur (IResourceBlobService.UploadAsync) → V2, lot 0. L'upload reste navigateur → Firebase.
  • Thumbnails / variantes / CDN → hors périmètre, aucune des deux versions n'en a besoin.
  • GuidedStep.ImageUrlImageResourceId : V2. Conséquence assumée en V1, voir §3.4.

2. Backend — manager-service

2.1 Enrichir ResourceDTO

Data/Resource.cs, méthode ToDTO(). Champs à ajouter :

fileName   = FileName,      // existe en base, jamais exposé — corrige le bug du .json
width      = Width,         // nouvelle colonne, nullable
height     = Height,        // nouvelle colonne, nullable
usageCount = null,          // rempli seulement par les endpoints qui le calculent (§2.2)

Migration : Resource.Width et Resource.Height, int?. Nullables et non backfillées — l'UI n'affiche les dimensions que si elles sont présentes. Elles sont renseignées à la création par manager-app, qui les connaît déjà (ImageCompressor décode l'image avant de la compresser).

⚠️ FileName peut être nul sur les ressources anciennes. Le front retombe alors sur label + l'extension déduite du Type.

2.2 Index inverse des usages

Le mécanisme existe déjà : GetReferencedResourceIds(string language = null) est implémentée sur les 13 sous-types de Section, plus GuidedPath et GuidedStep. C'est elle qui fait marcher l'export offline (ConfigurationController.Export). L'index inverse consiste à la parcourir dans l'autre sens.

GET /api/Resource/{id}/usages
    → [ { kind, id, label, configurationId, configurationLabel, sectionId, field, path } ]

GET /api/Resource/usage-map?instanceId={id}
    → { usages: { "<resourceId>": { count, configurationIds: [] }, ... },
        configurations: { "<configurationId>": "<label>", ... } }

sectionId (ajouté à l'implémentation) porte la section à ouvrir quand on clique une ligne « Utilisée dans » — nul pour kind = Configuration.

La forme de usage-map s'est enrichie par rapport à la spécification (un simple compteur) : la facette Configuration du rail a besoin de savoir dans quelles configurations une ressource sert, et de leurs libellés. Le tout en une requête, comme prévu.

kindSection | GuidedPath | GuidedStep | Configuration. path est la chaîne lisible affichée au front : "Escape game Parcours image".

Implémentation recommandée : un ResourceUsageService qui construit la carte complète pour une instance en une passe, et la met en cache mémoire courte (30 s) — usage-map est appelée à chaque ouverture de l'écran, et l'écran est ouvert souvent.

⚠️ Quatre pièges, à traiter explicitement

  1. Union sur toutes les langues. Vérifié dans le code à l'implémentation : faux. SectionText.ResourceIds et ResourceIdsFromValues filtrent par language == null || …, et GetReferencedResourceIds(string language = null) est documentée « null rend toutes les langues ». Appeler la méthode sans argument suffit — pas de boucle sur Configuration.Languages. Le danger décrit (une image posée qu'en NL comptée orpheline) est réel si on passe une langue : c'est ce que fige le test ImageUsedOnlyInDutch_IsNotOrphan.

  2. Configuration.ImageId et LoaderImageId sont hors sections. Voir ConfigurationController.cs:407-414, qui les ajoute séparément dans l'export. Les inclure, sans quoi l'image d'accueil d'une configuration s'afficherait « jamais utilisée ».

  3. GuidedStep.ImageUrl est une URL absolue, pas un id — commentaire explicite en GuidedStep.cs:74. Une image posée là sera comptée orpheline en V1. C'est une limite connue : la migration en ImageResourceId est en V2. À écrire dans le tooltip du filtre, sinon un utilisateur supprimera une image utilisée par une étape de parcours.

  4. Un GuidedPath peut pendre d'un SectionEvent (GuidedPath.SectionEventId), et SectionEvent.GetReferencedResourceIds ne descend pas dans les parcours — seul SectionParcours le fait. Piège trouvé à l'implémentation, absent de la spécification initiale. Les GuidedPath sont donc parcourus séparément, et volontairement pas chargés sur SectionParcours (sans quoi chaque usage compterait double). Figé par GuidedPathOnEvent_IsWalked.

AsNoTracking est proscrit dans ce service : les colonnes jsonb passent par un convertisseur sans ValueComparer, et le provider EF InMemory des tests rend alors des collections vides sur une requête détachée — les tests d'usage seraient verts sans rien couvrir.

2.3 Suppression protégée

DELETE /api/Resource/{id} : renvoyer 409 avec la liste des usages si usageCount > 0. Aujourd'hui rien ne protège. Le front désactive déjà le bouton, mais la règle doit vivre côté serveur — l'appel peut venir d'ailleurs.

Ajouter DELETE /api/Resource/bulk (corps : { ids: [] }) pour l'action en lot, avec la même règle par élément et un rapport partiel : { deleted: [], refused: [{id, usageCount}] }.


3. Front — manager-app

3.1 Renommage

Aujourd'hui Après
l10n/app_fr.arb:57 "menuResources": "Ressources" "Médiathèque"
app_en.arb, app_nl.arb idem "Media library" / "Mediatheek"
main_screen.dart:302 case 'resources': return l.menuResources; inchangé (la clé suffit)

⚠️ La route reste /main/resourcesne pas la renommer, les liens et les habitudes existent. Le libellé change, pas l'identifiant.

3.2 Fichiers touchés

Fichier Action
Screens/Resources/resources_screen.dart (469 l. au total du dossier) Garde son rôle de conteneur et son FutureBuilder. Charge en plus la usage-map. Ne pas casser sa double vie : il est aussi embarqué par showSelectResourceModal (§3.3)
Screens/Resources/resource_body_grid.dart Le gros du travail : rail de facettes, tri, groupement, sélection, nouvelles vignettes
Screens/Resources/show_resource_popup.dart Supprimé. À l'implémentation, grep a montré que showResource() n'était appelée que par resources_screen.dart : garder un export mort pour « ne pas casser les appelants » n'avait pas d'objet. Remplacé par resource_detail_panel.dart
Screens/Resources/resource_detail_panel.dart Nouveau — le panneau latéral (aperçu, nom, métadonnées, « Utilisée dans », pied hiérarchisé)
Screens/Resources/resource_formatting.dart Nouveau — extension de téléchargement, poids, mois, dates
Screens/Resources/resource_download.dart NouveaudownloadResource(), la correction du .json en dur
Helpers/ImageCompressor.dart CompressedImage expose width/height : c'est le seul endroit qui décode l'image avant l'upload
Screens/Resources/select_resource_modal.dart Contenu inchangé ; cadre repris par la refonte Configuration/Sections — voir §3.3
constants.dart Aucun token nouveau à créer : kSurface*, kInk*, kLine*, kSpace*, kRadius* couvrent tout

3.3 ⚠️ Le piège central : un écran, deux vies

// select_resource_modal.dart:24
child: ResourcesScreen(
  isAddButton: isAddButton,
  isSelect: isSelect,
  isRemoveButton: isRemoveButton,
  onGetResult: (ResourceDTO? resource) { ... },
  resourceTypes: resourceTypes,
),

showSelectResourceModal embarque ResourcesScreen. L'onglet Médiathèque et le sélecteur de champ de n'importe quel éditeur sont le même widget. Toute régression touche donc les 13 types de section, les POI et les étapes d'un coup.

Règles à respecter :

  • En mode isSelect: true, un clic sur une vignette sélectionne (comportement actuel), il n'ouvre pas le panneau de détail.
  • Le rail de facettes doit être réductible ou masqué dans la modale : elle fait déjà size.width * 0.85, et un rail de 194 px y est acceptable — mais le vérifier à 1280 px de large.
  • ⚠️ Cette règle dépend de la largeur size.width * 0.85. La refonte des écrans Configuration/Sections reprend le cadre de cette modale (voir plus bas) et conserve délibérément cette formule de largeur pour ne pas invalider la règle ci-dessus. Si quelqu'un veut un jour rétrécir la modale, c'est cette ligne-là qu'il faut rouvrir d'abord.
  • Le mode sélection multiple est désactivé quand isSelect: true.
  • resourceTypes filtre déjà en amont : la facette Type doit se limiter aux types reçus, comme aujourd'hui (resource_body_grid.dart:42).

Test de non-régression obligatoire : ouvrir un champ image d'une section, choisir une ressource, enregistrer. C'est le chemin le plus emprunté de toute l'app.

Le cadre de la modale — repris par un autre lot

Le contenu de showSelectResourceModal appartient à ce lot et n'est pas retouché ailleurs. Son cadre, en revanche, est repris par la refonte des écrans Configuration/Sections — maquette : DOCS/claude design/refonte-configuration-sections.html, section 06.

Motif : ce n'est pas qu'une question de style. Un AlertDialog dispose de hauteur 48 (marge verticale de 24 px), le contenu en demande 0,85 × hauteur, plus un titre et une ligne d'actions de 70 px — d'où le SingleChildScrollView qui l'entoure, et un défilement dans un défilement : on fait glisser le dialogue au lieu de la grille.

Ce que l'autre lot change, et rien de plus :

  • coquille standard (Dialog + en-tête + pied) à la place de AlertDialog et de title: Center(Text(...)), kRadiusShell au lieu du rayon 20 ;
  • hauteur figée → corps souple sous un maxHeight, ce qui supprime le double défilement ;
  • le bouton « Annuler » de 180 × 70 px descend dans le pied de dialogue ;
  • showValues (lignes 64-78) supprimé : c'est du code mort. La copie qu'en garde multi_input_modal.dart ligne 186 l'est aussi — son unique appel, ligne 53, est en commentaire. Les deux peuvent partir.

La largeur size.width * 0.85 n'est pas touchée, exprès — voir l'avertissement des règles ci-dessus. Aucune ligne de ResourcesScreen n'est modifiée.

3.4 Le rail de facettes

TYPE            Tout · Images · Audio · Vidéo · PDF        (compteurs)
USAGE           Utilisées · Jamais utilisées
ORIGINE         (masqué en V1 — voir §1)
CONFIGURATION   une ligne par configuration + « Aucune »
  • Cumulables entre groupes (ET), une seule valeur par groupe. Recliquer la valeur active l'efface ; « Tout » efface l'axe Type.
  • Les compteurs portent sur le corpus complet de l'instance, pas sur le résultat filtré.
  • Une barre au-dessus de la grille récapitule les filtres actifs + « tout effacer ».
  • Tooltip sur « Jamais utilisées » : mentionner la limite GuidedStep.ImageUrl (§2.2 piège 3).

3.5 La grille

  • SliverGridDelegateWithMaxCrossAxisExtent(maxCrossAxisExtent: 150) mais ratio 4/3 avec un pied de carte : nom, poids, origine, et le compteur d'usages en pastille sur la vignette (2 usages / libre).
  • Groupement par mois par défaut (en-tête Septembre 2026), replié quand le groupe est vide après filtrage.
  • Tri : plus récentes (défaut), nom A→Z, poids décroissant, usages croissants.
  • État vide explicite : « Aucune ressource ne correspond à ces filtres ».

3.6 Le panneau de détail

Remplace la modale. Ordre imposé, de haut en bas :

  1. Aperçu — d'abord. Pas de bordure grise de 3 px ; kRadiusCard et kLineSoft.
  2. Nom — champ éditable, après l'aperçu.
  3. Métadonnées : type, dimensions (si présentes), poids, date, auteur.
  4. Utilisée dans — liste cliquable, chaque ligne navigue vers le contenu. Si vide : un encart ambre « Jamais utilisée. Aucun visiteur ne la verra ».
  5. Pied d'actions, hiérarchisé : Enregistrer (primaire, à gauche) · Remplacer le fichier · Télécharger · Supprimer (destructif, isolé à droite, désactivé si usageCount > 0).

Le panneau reste ouvert quand on clique une autre vignette — c'est le geste réel quand on trie une médiathèque.

3.7 Correction du téléchargement

// show_resource_popup.dart:91 — aujourd'hui
anchorElement.download = '${resourceDTO.label}.json';

Devient : resource.fileName s'il existe, sinon label + l'extension déduite de Type (Image → .jpg, Audio → .mp3, Video → .mp4, PDF → .pdf, JSON → .json, Word → .docx, PowerPoint → .pptx, Text → .txt).


4. Ce que la V1 laisse volontairement en place

  • L'upload passe toujours par le navigateur → Firebase (resources_screen.dart:247-256).
  • Pas de AiProvenance, pas de badge « généré par IA », pas de crédits.
  • ResourceType inchangé — ne rien ajouter à l'enum, il est persisté en int.
  • La jauge de stockage du menu garde son bug de rafraîchissement (relevé le 2026-08-25, quota_bars_widget.dart:23). Hors périmètre, mais tentant : le corriger ici coûte peu et l'écran touche justement au stockage. À arbitrer au démarrage du lot.

5. Ordre d'exécution suggéré

Suivi à l'implémentation (2026-09-02) — les 8 étapes sont codées. Ce qui reste est de la vérification à l'œil : la checklist §6 n'a pas été passée dans un navigateur. Non fait, délibérément : la jauge de stockage du menu (§4), laissée hors périmètre.

# Étape Vérifiable par
1 Migration Width/Height + fileName dans ToDTO() + correction du .json dotnet test, puis télécharger un PNG et vérifier son extension
2 ResourceUsageService + les 2 endpoints, avec les 3 pièges traités Un test qui pose une image en NL seulement et vérifie qu'elle n'est pas orpheline
3 409 sur DELETE si utilisée, + DELETE /bulk Test d'intégration
4 Renommage + rail de facettes + compteurs flutter build web, puis l'écran
5 Tri, groupement, état vide l'écran
6 Panneau de détail + « Utilisée dans » + suppression protégée l'écran
7 Sélection multiple + actions en lot l'écran
8 Non-régression du sélecteur de ressource Ouvrir un champ image d'une section, choisir, enregistrer

⚠️ flutter analyze ne suffit pas — seul flutter build web dit la vérité (leçon consignée dans STATUS.md §1bis).


6. Checklist de test

À passer dans un navigateur. Aucune de ces cases n'est cochée par le code seul — flutter build web dit que ça compile, pas que ça marche.

  • Le menu affiche « Médiathèque », la route reste /main/resources
  • Deux facettes de groupes différents se cumulent (ex. Audio + Jamais utilisées)
  • Recliquer une facette active l'efface ; « Tout » efface l'axe Type
  • Les compteurs du rail ne bougent pas quand on filtre
  • Un groupe de mois vide après filtrage disparaît
  • Le compteur d'usages sur la vignette correspond au « Utilisée dans » du panneau
  • Une image utilisée uniquement en NL n'apparaît pas dans « Jamais utilisées »
  • L'image d'accueil d'une configuration n'apparaît pas dans « Jamais utilisées »
  • Une image posée sur GuidedStep.ImageUrl apparaît orpheline et le tooltip l'explique
  • Supprimer est désactivé sur une ressource utilisée, et le serveur renvoie 409 si on force
  • Suppression en lot : les utilisées sont refusées, les autres supprimées, le rapport est lisible
  • Un PNG se télécharge en .png, un MP3 en .mp3
  • Une ressource ancienne sans FileName se télécharge quand même avec la bonne extension
  • Le sélecteur de ressource d'un champ de section fonctionne toujours — sélection, annulation, ajout
  • flutter build web passe

7. Ce que la V2 viendra greffer

Pour que l'implémentation V1 ne ferme aucune porte :

V2 ajoutera Point d'ancrage à prévoir en V1
Onglet « Générer » dans le sélecteur ResourceTab / showNewResource gardent une structure à onglets
Facette « Générées par IA » Le rail est déjà générique : une facette de plus, pas une refonte
Badge et bloc provenance Le pied de vignette et le panneau ont la place ; lire AiProvenance nullable
Console audio (narrateurs) Vit dans Configuration Personnages, pas ici

Rien de tout ça ne demande de revenir sur la V1.