Cohérence avec /admin/members et /admin/projects : le nom dans la
1re colonne est maintenant un RouterLink vers `admin-category-edit`.
Le bouton "Éditer" en bout de ligne reste pour la découvrabilité.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Le lien sur display_name dans la liste /admin/members pointait sur
`project-detail` avec le slug du MEMBRE comme :slug -> aboutissait sur
une 404 ou la mauvaise fiche projet selon les collisions de slug.
Maintenant : pointe sur `admin-member-edit`. Plus cohérent avec le pattern
utilisé pour les projets (clic sur titre -> fiche) et avec ce que l'admin
attend dans un tableau d'admin (clic = édition).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Nouvelle page /admin/members/new, jumeau de l'écran d'édition :
- Form complet : display_name, slug (auto), initials (auto), kind, bio,
email, website, avatar URL
- Avatar preview live (initiales / URL d'avatar mis à jour à la saisie)
- Slug auto-généré depuis display_name (NFD + lowercase + tirets)
tant que l'utilisateur n'a pas édité manuellement (flag slugDirty)
- Initiales auto-suggérées (1re lettre de chaque mot, max 3, upper),
flag initialsDirty distinct -> tu peux laisser le slug auto mais
forcer des initiales manuelles ou vice-versa
- Body construit en omettant les champs vides -> l'API génère ses propres
initiales si vides, slug auto-uniqueifie sur collision (-2/-3...)
- 201 -> redirige vers /admin/members/:slug/edit pour voir le résultat
- 422/409/401 gérés comme les autres écrans admin
UI
- AdminMembersPage : CTA "Nouveau membre" en haut à droite (à côté de
la recherche, justify-between sur le wrap parent)
- Router : route nommée admin-member-new avec guard admin
Backend
- Aucune nouvelle route : POST /api/members existait déjà
- Le contrôleur valide display_name (required), kind enum, email format,
URLs ; auto-génère initiales si non fournies (1res lettres de chaque mot)
- Slug auto avec suffixe en cas de collision (cf. Slugger::unique)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Nouvelle page /admin/categories/new, jumeau de l'écran d'édition :
- Form complet : name, slug (auto), description, color_hex, icon,
dimension, sort_order
- Preview live identique à l'éditeur (top border + folder tinté + badge)
- Slug auto-généré depuis le nom (normalisation NFD + lowercase + dashes)
tant que l'utilisateur n'a pas touché manuellement au champ. Flag
slugDirty mis à true sur premier @input du slug -> on arrête l'auto.
- Validation color_hex /^#RRGGBB$/ côté client + normalisation upper-case
- Body construit en omettant les champs vides -> l'API applique ses
défauts (color_hex='#888888', dimension='transversal', sort_order=0)
- Sur 201 : redirige vers /admin/categories/:slug/edit pour que l'admin
voie tout de suite sa nouvelle catégorie et puisse ajuster
- 422/409/401 gérés comme sur les autres écrans admin
UI
- AdminCategoriesPage : CTA "Nouvelle catégorie" en haut à droite
- Router : route nommée admin-category-new avec guard admin
Backend
- Aucune nouvelle route : POST /api/categories existait déjà
- Le contrôleur valide name (required, max 128), dimension enum, color
hex format, slug auto-unique
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Nouvelle page /admin/categories/:slug/edit, même pattern que members :
- Preview live de la carte catégorie (top border colorée + folder icon
tinté + badge "X projets" + label dimension)
- Form complet : name, slug, description, color_hex, icon, dimension, sort_order
- Color picker natif <input type="color"> + champ HEX texte synchronisés,
validation /^#[0-9A-Fa-f]{6}$/ côté client
- Diff PATCH (champs vides -> null), upper-case normalisé pour color_hex
- Si le slug change -> router.replace vers la nouvelle URL
- Bouton Supprimer en header désactivé si projects_count > 0 (FK RESTRICT)
- 422 -> fieldErrors, 409 -> slug collision, 401 -> session expirée
- projects_count récupéré direct depuis GET /api/categories/:slug
(la réponse contient ce champ pour la route détail, cf. controller PHP)
UI
- AdminCategoriesPage : nouveau lien "Éditer" par ligne du tableau
- Router : route nommée admin-category-edit avec guard admin
Backend
- Aucune nouvelle route : PATCH /api/categories/:slug existait déjà
- Validation côté serveur : in dimension enum, colorHex format,
integer sort_order >= 0, slug auto-unique avec suffixe -2/-3...
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Nouvelle page /admin/members/:slug/edit avec :
- Preview avatar live (initiales/URL mis à jour à la saisie)
- Form complet : display_name, slug, initials, kind, bio, email, website, avatar
- Envoie un diff au PATCH (champs vides -> null, pas chaîne vide)
- Si le slug change, redirige automatiquement vers la nouvelle URL
- Encart "Projets attachés" en lecture (avec lien vers chaque fiche)
pour rappeler l'impact d'une suppression ou d'un changement de slug
- Bouton Supprimer en header avec confirm qui mentionne le nb de projets
- Banner vert "Modifications enregistrées" sur succès
- Gestion erreurs : 422 -> fieldErrors, 401, 409 (slug collision), réseau
Backend
- Aucune nouvelle route : PATCH /api/members/:slug existait déjà
- Le contrôleur gère déjà slug auto-unique, initials, kind enum, etc.
UI
- AdminMembersPage : nouveau lien "Éditer" par ligne, à côté de Supprimer
- Router : route nommée admin-member-edit avec guard admin
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Backend
- ProjectMembersController::update : nouveau PATCH /api/projects/:slug/members/:member_slug
pour changer le rôle (admin/contributor/observer). 404 si pivot absent,
no-op idempotent si déjà même rôle, 422 si rôle invalide. Route câblée
dans public/index.php.
Frontend
- JoinProjectModal : 2 onglets (Membre existant / Nouveau membre)
* "existant" : <select> de l'annuaire filtré sur excludeSlugs (membres
déjà sur le projet), preview avatar+bio du membre sélectionné
* "nouveau" : flow d'origine (créer le membre puis l'attacher)
* sélecteur de rôle visible dans les 2 modes
* onJoined remonte un Member complet (au lieu de {id,slug,name})
-> ProjectDetailPage peut pousser un ProjectMemberEntry avec ses
vraies initials/avatar/kind dans la liste optimiste
- ProjectDetailPage panneau Équipe (mode admin uniquement) :
* Rôle inline éditable via <select> stylé minimal -> PATCH optimiste
avec rollback en cas d'échec
* Bouton retirer-membre par ligne (DELETE) avec confirm
* Le badge "Vous" / "Admin connecté" ne change pas
Code-review (high effort, --fix appliqué) :
- loadDirectory affiche maintenant une vraie erreur réseau au lieu de
silencieusement présenter "tous les membres déjà sur ce projet"
- loadDirectory est lazy : on ne fetche l'annuaire que si l'onglet
"existant" est actif (économise 1 requête si admin saute direct à "nouveau")
- Aperçu du membre sélectionné : computed `selectedMember` au lieu de
<template v-for><template v-if>
- Bouton retirer-membre : opacity-60 sur mobile (sm: et plus garde le
behavior hover-to-reveal) -> reste atteignable sur touch
- + label `aria-label` pour l'accessibilité
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
==== Galerie d'images ====
Backend
- Migration 003 : table project_images (FK CASCADE sur projects)
- ImagesController : CRUD complet, deux modes de création
* URL externe via JSON body
* Upload disque via multipart/form-data avec :
- validation MIME RÉELLE via finfo (jpeg/png/webp/gif)
- taille max 5 Mo, vérifiée explicitement (au-delà du php.ini)
- vérification UPLOAD_ERR_* avec messages clairs
- stockage dans public/uploads/projects/:slug/<hash>.<ext>
- dimensions image extraites via getimagesize (best-effort)
- row DB avec mime_type / size_bytes / width / height
- Request : ajout isMultipart(), formField(), file()
- public/index.php : 3 routes (index public, store/update/destroy admin)
Proxy
- caddy.conf : /uploads/* ajouté au reverse_proxy comme /api/*
- vite.config.ts : pareil pour le dev local
Frontend
- Type ProjectImage (api.ts) avec url + storage_path + dimensions
- uploadFile() helper dans client.ts (FormData + Bearer)
- ImageGallery.vue : grille thumbnails + lightbox plein-écran avec
nav clavier (← → Esc), bouton supprimer par image en mode admin
- ImageUploader.vue : drag-drop + file picker, multi-fichiers en série,
validation taille côté client, légende commune optionnelle
- ProjectDetailPage : nouvel onglet Images entre Roadmap et Discussions
==== Dashboard /admin ====
5 nouvelles pages sous /admin :
- /admin : KPI + répartition catégorie + flux activité
- /admin/projects : table CRUD (recherche + filtres status/categorie),
actions Éditer / Archiver / Supprimer par ligne
- /admin/projects/:slug/edit : form complet d'édition (envoie un diff au PATCH)
- /admin/members : annuaire avec recherche + Supprimer
- /admin/categories : table avec compte de projets ;
Supprimer désactivé si la catégorie est utilisée
- AdminSubNav.vue : 4 onglets en haut de chaque page admin
Garde d'auth :
- beforeEnter sur les routes /admin/* (sauf /admin/login)
- Redirige vers /admin/login?next=<url> si pas de token
- Après login, retour automatique vers next= (sinon /admin)
Sidebar :
- Nouvelle section "Administration" visible UNIQUEMENT quand le token
est en localStorage, avec un lien vers le dashboard
Vérifié localement
- Build vue-tsc : 91 modules, OK
- Upload disque : fichier PNG stocké dans public/uploads/, accessible via /uploads/
- GET /api/projects/nos-ecoles/images : 2 entrées renvoyées (1 URL + 1 disque)
- Toutes les routes /admin/* répondent 200 via le proxy Vite
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Les noms étaient tout à l'extérieur (R=275), au-delà des limites planétaires.
L'utilisateur veut "sur le cercle intermédiaire entre les camemberts des
catégories et les limites planétaires" — c'est-à-dire SUR l'anneau gris du
plafond écologique qui sépare la zone sûre (où vivent les wedges colorés)
des limites planétaires.
- R_CAT_LABEL : 275 → 212 (milieu de l'anneau gris 200..225)
- viewBox : retour à 560×560 (plus besoin d'étendre à R=275)
- Cream background : 295 → 270 (idem)
- Font-size : 11 → 10 (plus discret sur ring)
- paint-order + stroke #FAFAF6 sur le texte : "halo" cream autour des
lettres pour rester lisibles malgré le pattern strié du ring gris.
Plus simple qu'un mask et fonctionne dans tous les browsers modernes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Les libellés des 6 catégories thématiques (Data, Démocratie, Habitat,
Territoire, Écologie, Éducation) étaient posés HORIZONTAUX au milieu
de leur wedge dans la zone sûre. Ils chevauchaient les points-projets
quand on en avait beaucoup et compliquaient la lecture.
Déplacement à R=275, ARC autour du donut, dans la couleur de la catégorie :
- Nouvelle constante R_CAT_LABEL (la plus extérieure : > planétaires à 252)
- viewBox 600x600 (vs 560x560) pour caser la nouvelle frontière
- Fond cream étendu à R=295 pour soutenir les labels jusqu'au bord
- Font-size 11 + weight 700 + lettre-espacement 0.5px pour lisibilité
- Réutilisation du helper arcLabelPaths (halfSpan 28° = quasi-pleine
largeur du secteur de 60°)
- Couleur récupérée par index depuis la liste categories réactive
Suppression du rendu in-wedge des labels catégorie : redondance avec les
arcs extérieurs + meilleure lisibilité des points-projets dans la zone sûre.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Beaucoup de dimensions Raworth dépassent l'arc disponible à R=88 :
« Revenu & travail », « Liens sociaux », « Égalité de genre »,
« Équité sociale », « Voix politique », « Paix & justice ». Posées en
une seule ligne, le texte débordait ou était illisible.
Solution : permettre 2 lignes par libellé, chaque ligne ayant son propre
arc à un rayon décalé. Du coup ces 6 dimensions sont séparées en 2 mots
empilés radialement. Les 6 autres (Alimentation, Santé, Éducation, Eau,
Énergie, Logement) restent sur une seule ligne.
Refactor :
- Type `Label = readonly string[]` (1..N lignes)
- PLANETARY_BOUNDARIES et SOCIAL_FOUNDATIONS unifiés au format tableau
de tableaux (planétaires restent toutes à 1 ligne, sociales mixtes)
- arcLabelPaths() gère le multi-lignes : une ArcLine par ligne, avec
décalage radial de ±lineSpacing/2
- lineSpacing = 9u (légèrement > font-size 8 pour éviter le chevauchement)
- Inversion du décalage radial selon la moitié du donut pour que la
ligne "primaire" reste toujours du côté lecteur (haut de l'écran) :
top : ligne 0 = R+offset (extérieur)
bot : ligne 0 = R-offset (intérieur)
- Aplatissement via flatMap pour rendre comme une flat-list dans le
SVG (pas de `<template>` imbriqué)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Le plancher social et ses 12 dimensions étaient à l'étroit autour de
R=67 (anneau intérieur 78..100). Maintenant :
- R_SOCIAL_LABEL passe de 67 à 88
-> circonférence du cercle où sont posés les labels = 2π·88 ≈ 553u
-> chaque label dispose de ~46u d'arc (contre ~35 avant)
- R_FLOOR_INNER passe de 78 à 102
- R_FLOOR_OUTER passe de 100 à 125
-> l'anneau orange du plancher social est visuellement plus présent
- R_CENTER passe de 50 à 55 (pour ne pas écraser le "LE DONUT")
- Font-size des labels sociaux : 6 → 8 (plus lisible avec l'arc élargi)
L'espace sûr (R_FLOOR_OUTER..R_CEILING_INNER) passe de 100u à 75u : les
trois bandes de positionnement des projets (social / transversal /
écologique) sont recalibrées en conséquence sans chevauchement.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Limites planétaires + dimensions sociales : labels qui épousent vraiment la
courbure de l'anneau au lieu d'être posés en ligne droite tangente. Implémenté
avec SVG <textPath> + un <path> caché par label.
Petit piège : pour qu'un label en bas du donut reste lisible (pas écrit à
l'envers ou de droite à gauche), il faut INVERSER la direction du path selon
la moitié.
Helper arcLabelPaths() :
- Moitié haute (angles ~0°) : path va de θ-δ vers θ+δ, sweep=1
- Moitié basse (angles ~180°) : path va de θ+δ vers θ-δ, sweep=0
→ la direction du chemin au point central du label est toujours "horizontale
à gauche-droite" du point de vue d'un lecteur fixe.
Ajouts :
- 12 dimensions du plancher social Raworth (Alimentation, Santé, Éducation,
Revenu & travail, Eau, Énergie, Liens sociaux, Logement, Égalité de genre,
Équité sociale, Voix politique, Paix & justice) placées en arc entre le
centre et l'anneau intérieur (rayon 67)
- R_CENTER passé de 56 à 50 pour libérer 6 unités au profit des labels
sociaux sans toucher au "LE DONUT / X projets" au centre
- Suppression des textes centrés "PLAFOND ÉCOLOGIQUE" et "PLANCHER SOCIAL"
sur les anneaux (redondants avec les labels qui les détaillent)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Le pattern <RouterLink v-slot="..."><a @click.prevent>...</a></RouterLink>
sans le prop `custom` rend DEUX <a> imbriqués : celui de RouterLink (qui gère
la navigation) + celui du slot. Notre @click.prevent sur le <a> interne
bloquait le clic avant qu'il remonte au <a> externe. Résultat : clic mort.
Fix : ajout du prop `custom` (RouterLink ne rend plus son <a>) +
@click="navigate" (fonction fournie par le v-slot qui fait le router.push
en gérant les modifier keys, le ctrl-clic, etc.).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1. Jitter au survol (bug visible) :
`transform: scale(1.5)` en SVG prend pour origine le (0,0) du viewBox,
pas le centre du <circle>. Le scale "déplaçait" donc le cercle vers le centre
de la viewport → la souris n'était plus dessus → mouseleave → retour →
mouseenter → re-déplacé. Boucle infinie de tremblement.
Fix : classe `.dot-circle` avec
transform-box: fill-box;
transform-origin: center;
Le scale prend désormais pour pivot le centre de la forme. Stable.
2. Couleurs des secteurs thématiques (par la maquette d'origine) :
Réintroduction des wedges colorés dans la zone sûre, opacité 10 % sur
le fill + 20 % sur le stroke. Reste sobre, garde la lisibilité.
3. Limites planétaires (de Kate Raworth) :
Les 9 limites planétaires (Changement climatique, Acidification des
océans, Biodiversité, Eau douce, Cycles N&P, Pollution chimique,
Pollution de l'air, Couche d'ozone, Usage des sols) sont placées en
étiquettes tangentes autour de l'anneau extérieur, avec flip 180° en
moitié basse pour rester lisibles.
Cosmétique :
- viewBox 560×560 (au lieu de 520) pour caser les labels extérieurs
- Labels catégorie déplacés AU MILIEU de leur wedge (sur la zone sûre)
plutôt qu'à l'extérieur (où ils chevauchaient les limites planétaires)
- Séparateurs radiaux passés en blanc pour mieux contraster avec les
wedges colorés
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Donut (DonutPage.vue) — alignement avec la maquette "Capture 2026-05-28 205310" :
- Suppression des secteurs en couleur vive : 6 secteurs séparés par des
lignes radiales en pointillé gris léger
- 2 anneaux gris texturés (pattern diagonal) : "Plafond écologique" (extérieur)
et "Plancher social" (intérieur), avec leurs labels
- Espace sûr cream entre les deux où vivent les projets
- Positionnement des projets :
angle : secteur de la catégorie + jitter stable (hash slug)
rayon : bande définie par la dimension de la catégorie
(social -> 108..140, ecologique -> 160..192, transversal -> 142..158)
puis modulé par progress_percent (plus avancé = plus éloigné du centre)
- Projets archivés rendus en POINTS CREUX (stroke only, sans fill)
- Tooltip survol -> RouterLink cliquable vers la fiche projet
- Légende droite réécrite avec les phrases exactes de la maquette
("L'anneau intérieur représente le plancher social, ..." etc.)
- Liste des catégories par dimension avec compte de projets cliquables
Jeu d'essai (tests/seed-demo.sh) :
- 10 actifs / 6 réalisés / 4 archivés (= 20 projets), réalistes Marseille
- 10 membres (4 collectives/orgs, 4 personnes, 2 collectives + AMU)
- 26 liens project_members, 27 étapes de roadmap (mix done/à faire)
- 6 discussions, 8 ressources, 8 events d'activité antérieurs
- Lit DB_* depuis .env, force --default-character-set=utf8mb4
- TRUNCATE+INSERT idempotent (la table categories est préservée)
- Hors /migrations/ pour ne PAS être appliqué automatiquement par apply.sh
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Le client mysql sous Windows + Git Bash utilise cp850 comme
`character_set_client` par défaut. Quand on applique 002_seed_categories.sql
avec un simple `mysql < ...`, le client réinterprète les bytes UTF-8 du fichier
via cp850 AVANT de les insérer : `é` (0xC3 0xA9) devient `├®` (E2949C C2AE)
stocké en clair dans la base, et apparaît tel quel dans l'API + le SPA.
Le `SET NAMES utf8mb4` côté serveur ne corrige pas ça parce que la conversion
est déjà faite côté client. Il faut le flag `--default-character-set=utf8mb4`
sur la ligne de commande mysql.
- README.md : doc de la commande correcte + commande de vérif HEX
- 002_seed_categories.sql : commentaire d'alerte + SET NAMES utf8mb4 (no-op
côté connexion mais documente l'intention)
Côté prod (apply.sh + docker exec mariadb) le client interne utilise utf8mb4
par défaut, donc pas de souci là-bas.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>