18 KiB
V1 — Médiathèque (refonte de l'onglet Ressources)
Statut : implémenté le 2026-09-02. Backend + front livrés,
dotnet testetflutter build webverts. 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 | Où |
|---|---|
| Tuiles carrées de 160 px, affichées d'un bloc sans ordre ni groupement | resource_body_grid.dart:78 — SliverGridDelegateWithMaxCrossAxisExtent(maxCrossAxisExtent: 160, childAspectRatio: 1.0) |
Chips de type en Wrap — un 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 où 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
- Renommage Ressources → Médiathèque (libellé de menu et titre d'écran).
- Rail de facettes cumulables avec compteurs : Type · Usage · Origine · Configuration.
- Tri (date, nom, poids, usages) et groupement par mois, bascule grille ↔ liste.
- Sélection multiple et actions en lot (télécharger, supprimer).
- Compteur d'usages sur la vignette.
- 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.
- Backend : index inverse des usages (2 endpoints) + enrichissement de
ResourceDTO. - 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 questudioEnabled == 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.ImageUrl→ImageResourceId: 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.
kind ∈ Section | 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
-
Union sur toutes les langues.Vérifié dans le code à l'implémentation : faux.SectionText.ResourceIdsetResourceIdsFromValuesfiltrent parlanguage == null || …, etGetReferencedResourceIds(string language = null)est documentée « null rend toutes les langues ». Appeler la méthode sans argument suffit — pas de boucle surConfiguration.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 testImageUsedOnlyInDutch_IsNotOrphan. -
Configuration.ImageIdetLoaderImageIdsont hors sections. VoirConfigurationController.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 ». -
GuidedStep.ImageUrlest une URL absolue, pas un id — commentaire explicite enGuidedStep.cs:74. Une image posée là sera comptée orpheline en V1. C'est une limite connue : la migration enImageResourceIdest en V2. À écrire dans le tooltip du filtre, sinon un utilisateur supprimera une image utilisée par une étape de parcours. -
Un
GuidedPathpeut pendre d'unSectionEvent(GuidedPath.SectionEventId), etSectionEvent.GetReferencedResourceIdsne descend pas dans les parcours — seulSectionParcoursle fait. Piège trouvé à l'implémentation, absent de la spécification initiale. LesGuidedPathsont donc parcourus séparément, et volontairement pas chargés surSectionParcours(sans quoi chaque usage compterait double). Figé parGuidedPathOnEvent_IsWalked.
ℹ️
AsNoTrackingest proscrit dans ce service : les colonnesjsonbpassent par un convertisseur sansValueComparer, 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
| Où | 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/resources — ne 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 |
Nouveau — downloadResource(), 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 |
Inchangé si possible — 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. - Le mode sélection multiple est désactivé quand
isSelect: true. resourceTypesfiltre 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.
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 :
- Aperçu — d'abord. Pas de bordure grise de 3 px ;
kRadiusCardetkLineSoft. - Nom — champ éditable, après l'aperçu.
- Métadonnées : type, dimensions (si présentes), poids, date, auteur.
- Utilisée dans — liste cliquable, chaque ligne navigue vers le contenu. Si vide : un encart ambre « Jamais utilisée. Aucun visiteur ne la verra ».
- Pied d'actions, hiérarchisé :
Enregistrer(primaire, à gauche) ·Remplacer le fichier·Télécharger·Supprimer(destructif, isolé à droite, désactivé siusageCount > 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. ResourceTypeinchangé — 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 webdit 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.ImageUrlapparaît orpheline et le tooltip l'explique Supprimerest 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
FileNamese 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 webpasse
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.