Compare commits

...
Author SHA1 Message Date
Jules NenyandClaude Opus 5.5 fec977a58f feat(carte-o): brouillon des 17 accroches au survol (à valider par Jules)
Champ problematique ajouté aux 17 nodes de carte-o-source.yaml (source
éditoriale), propagé par build-carte-o.js, lu en priorité par l'infobulle
de CarteO.vue. Page temporaire /apercu-accroches pour la relecture :
À SUPPRIMER avant merge. Branche partie de wip/pont-tailwind4-20260817 :
ne pas merger telle quelle dans main (elle embarque le pont non validé),
cherry-pick de ce commit seul, sans la page d'aperçu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SHuEiSieLhPY2uZbCu6uQb
2026-09-29 14:06:25 +02:00
Jules NenyandClaude Fable 5.1 6a01db1833 wip(pont): pont Tailwind 4 câblé + pont-test.astro + carte-o.json (session défrichage 17/08, mis à l'abri le 22/09)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 14:05:12 +02:00
Jules NenyandClaude Sonnet 5 f642160d4b fix(insta): feed remplit toute la hauteur de colonne, Boutique collée en bas
La zone de scroll avait une max-height fixe (420px) alors que la colonne
fait toute la hauteur de la page -> gros bandeau blanc entre le feed et
le footer. Passe en flex-col (header shrink-0, feed flex-1 scrollable,
Boutique shrink-0 collée en bas), le feed occupe maintenant tout
l'espace disponible.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 17:55:43 +02:00
Jules NenyandClaude Sonnet 5 d58911a7e8 feat(insta): simplifie - 2 comptes en header, Boutique seule en bas
Retire les 3 onglets (trop petits, moches). Header : "Jules Neny"
(compte actif, lien direct) + "AEP" (lien direct vers @aep.politique,
pas encore de feed dédié). Bande du bas : uniquement "Boutique"
(placeholder désactivé).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 17:53:26 +02:00
Jules NenyandClaude Sonnet 5 5a4a8fbc2b feat(insta): 3 onglets (artistique/boutique/politique) + bande réduite
Réduit le padding de la bande d'actions (py-3->py-1, gap serré) pour
coller au feed. Ajoute un 3e onglet "Compte Insta politique" en
placeholder désactivé, à côté de "Boutique", en prévision d'une
navigation à onglets entre les comptes @julesneny (artistique, actif)
et @aep.politique (à venir).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 17:49:44 +02:00
Jules NenyandClaude Sonnet 5 aa819600f5 feat(insta): remplace la grille 3x3 par un feed scrollable (13 captures)
Empile les 13 captures d'écran du feed réel (IMG_5439..5451, plus récent
en haut, plus ancien en bas) dans une colonne overflow-y:auto avec
scrollbar fine, pour permettre de naviguer dans le feed sans quitter le
site. Petit gap 2px entre chaque capture (façon séparation Instagram).

Dédoublonnage pixel des zones de chevauchement tenté (corrélation de
luminance par ligne) mais abandonné : signal pas assez discriminant sur
ce contenu (fonds crème uniformes). Chevauchement naturel accepté.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 17:45:13 +02:00
Jules NenyandClaude Sonnet 5 11499a147c feat(insta): remplace Behold (jamais configuré) par galerie statique @julesneny
ColInsta n'affichait que le fallback bio depuis PC5 (feed IDs Behold jamais
renseignés, login Facebook Business trop lourd). Retire le bloc @aep.politique
(jugé insatisfaisant) et remplace InstaFeed par une galerie 3x3 statique de
9 vignettes réelles (croppées depuis le dernier screenshot du feed), avec
boutons Compte Insta + Boutique (placeholder désactivé, en attente d'URL).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 17:05:41 +02:00
Jules Neny 7e7ddfbfbf feat(api): ajoute endpoint /api/aep-perso (desinscription/reabonnement) 2026-07-04 11:55:15 +02:00
Jules Neny 36feace21f fix(deploy): node 20-alpine -> 22-alpine (astro 6 requires node 22) 2026-05-12 12:03:08 +02:00
Jules Neny 0e65156fa0 feat(v1): page-cerveau LIVE - PC0-PC8 + V1.1-V1.4-bis 2026-05-12 11:31:23 +02:00
Jules Neny 020cf0a162 chore: rebuild carte-o.json (17 nodes/20 edges, prebuild auto) 2026-05-12 11:29:38 +02:00
Jules Neny 1c4dce2fa4 feat(deploy): Dockerfile multi-stage Node 20-alpine pour Coolify
Build stage : npm ci + npm run build (inclut prebuild carte-o.js).
Runtime : node:20-alpine, dist/ + node_modules copiees, EXPOSE 3000, HOST/PORT env.
Start command Coolify : node ./dist/server/entry.mjs
2026-05-12 11:29:30 +02:00
Jules Neny cde95cdf61 feat(gio): llms.txt + Schema.org WebSite+Person JSON-LD + meta ai-content-declaration
- public/llms.txt : description structuree ecosysteme Jules NENY pour LLMs (sections/reseaux/politique d'usage)
- BaseLayout.astro : Schema.org WebSite + Person JSON-LD (sameAs Insta + Substack + Gitea) ; Schema.org Article conditionnel via isArticle prop
- Meta ai-content-declaration : human-authored-with-ai-coordination
2026-05-12 11:29:20 +02:00
Jules Neny f099463636 feat(seo): sitemap + robots.txt + meta OG/Twitter/canonical + site URL
- Installe @astrojs/sitemap ; integre dans astro.config.mjs avec filtre /api/
- Ajoute site: 'https://trans-former.fr' pour canonical absolues
- BaseLayout : props ogImage + canonical + isArticle + articleDate ; meta description/canonical/robots/OG/Twitter Card complets ; suppression doublons
- manifeste.astro : passe isArticle=true pour Schema.org Article
- public/robots.txt : open index + GPTBot/ClaudeBot/Google-Extended/Applebot-Extended/PerplexityBot explicites
2026-05-12 11:29:02 +02:00
Jules Neny d01301e40b merge(v14-bis): drag overlay + toggle minmax + manifeste preview slot + scrollbar + couleurs muted + Insta x2 2026-05-12 00:49:43 +02:00
Jules Neny dbba7dee3a fix(v14-bis): drag handle overlay + toggle gauche + manifeste preview + scrollbar visible + couleurs attenuees + 2e logo insta
FIX 1 drag handle : overlay full-screen z-9998 capture mouseup garanti
  (iframe AEP capturait les events — overlay au-dessus de tout au mousedown)
FIX 2 toggle gauche : minmax(0,1fr) + visibility hidden au lieu de display:none
  (conflit display:none + width:0 effondrait la col centre quand 2 cols togglees)
FIX 3 manifeste preview : slot flex-col + astro-island display:block
  (display:contents masquait astro-island au flex layout, preview ne s'inserait pas)
FIX 4 scrollbar visible : overflow-y:auto + scrollbar-color custom #94A3B8
  (style WebKit + Firefox uniquement quand data-preview-open=true)
FIX 5 couleurs attenuees : data-muted #C8867E/#8F9A78/#7B848E/#566375 + texte encre
FIX 6 Insta x2 : @aep.politique + @julesneny avec aria-labels et titles distincts
FIX 7 SVG Insta : path simple (camera carree + lentille) au lieu du path complexe
2026-05-12 00:49:01 +02:00
Jules Neny 3baeb0378c merge(v14-cde): Carte O cadre + bouton toggle desktop + drag mouseup fix + manifeste preview client:load + transitions 2026-05-12 00:31:21 +02:00
Jules Neny 6db93d9c5a merge(v14-abf): toggle cols fix + pastels revert + footer compact aligned 2026-05-12 00:31:20 +02:00
Jules Neny 5a628fd85b fix(v14-cde): Carte O cadre + bouton replier desktop + drag mouseup fix + manifeste preview event + transitions
- C1 : cadre 1px #CBD5E1 + radius 6px autour section centre-haut (Carte O zone).
  Force tuning CarteO.vue : forceX/Y strength 0.05 -> 0.08, collide radius +12 -> +14
  pour mieux contenir les nodes dans le cadre visible.
- C2 : bouton toggle Carte O desktop (icone triangle dans le bandeau, a cote de la legende).
  Replie a flex 0 0 36px (header reste visible, body masque avec opacity 0).
  Persistance sessionStorage 'tf-carte-o-collapsed'.
- D  : fix drag handle qui ne se decliquait pas au mouseup. Listeners poses sur window
  (vs document) + ajout pointerup/mouseleave/blur/mouseenter-buttons-0 pour couvrir
  tous les flux utilisateur (sortie iframe, perte focus, relache hors-page).
  Suspension transitions CSS pendant le drag (pas de lag).
- E1 : PreviewArticle hydration client:load (vs client:visible) - le v-if rendait
  l'IntersectionObserver aveugle, donc les listeners 'preview-open' n'etaient jamais
  installes. Resultat : clic manifeste -> preview ne s'ouvrait pas.
- E2 : Transition Vue preview-fade (opacity + translateY 8px, 250ms ease) +
  transitions CSS flex-basis 0.3s ease sur sections haut/bas pour smooth UX.
2026-05-12 00:30:42 +02:00
Jules Neny bc7e394913 fix(v14-abf): toggle cols cacher seulement bidirectionnel + revert pastels transparent + footer align 2026-05-12 00:28:52 +02:00
Jules Neny b48b66bd76 merge(v13-f): palette desopacifiee + toggle cols + footer gap + manifeste CTA bord rouge 2026-05-11 20:15:17 +02:00
Jules Neny e012d11bee feat(v13-f): palette desopacifiee pills + toggle cols laterales + footer gap-6 + manifeste CTA bord rouge 2026-05-11 20:14:28 +02:00
Jules Neny 8703d056d9 merge(v13-e): manifeste UX preview centrale + page /manifeste preserve 2026-05-11 20:09:07 +02:00
Jules Neny b4f6d63f33 feat(v13-e): manifeste UX preview centrale (racine) + page /manifeste preserve standalone 2026-05-11 20:08:20 +02:00
Jules Neny 8e7125b426 merge(v13-d): Carte O Option B rectangle central + bandeau sommaire + legende + TMIP 2026-05-11 20:01:34 +02:00
Jules Neny 6b9f6bf751 merge(v13-c): header 1-line cliquables + phrase intention Roboto Condensed 2026-05-11 20:01:33 +02:00
Jules Neny 372cb62229 merge(v13-bg): layout 1 ecran fixe + Pro/LinkedIn + hamburger desktop hide 2026-05-11 20:01:33 +02:00
Jules Neny 8f8b0c5f4c feat(v13-d): Carte O Option B rectangle central + bandeau sommaire + legende + TMIP relie
- YAML carte-o-source : label central -> 'Une medecine du corps social pour
  ecrire un nouveau contrat social' (phrase pleine 3 lignes)
- YAML : projet TMIP gagne lien_central:true (edge explicite centre <-> projet)
- build-carte-o.js : addEdge accepte opts.central=true pour tagger les edges
  rattachees au noeud central (permet tuning force-link cote Vue)
- carte-o.json regenere : 17 nodes, 20 edges (vs 19 V1.2-O), tous les edges
  central->thematiques + central->tmip portent flag central:true
- CarteO.vue : noeud central rendu en RECT 300x64 fill encre (vs cercle r30),
  label blanc multi-tspan 3 lignes 13px font-weight 500 line-height 1.35
- CarteO.vue : splitCentralLabel reecrit pour wrap intelligent (3 lignes
  ~30 chars), preserve compat ' + ' (V1.2)
- CarteO.vue : force tuning V1.3 -> alphaDecay 0.025, velocityDecay 0.4,
  forceCollide +12 (CENTRAL_COLLIDE_RADIUS=160 pour le rect), forceX/Y
  strength 0.05 rappel cadre, link distance/strength differencies
  (central->projet = 90/0.6, central->essai = 200/0.3)
- CarteO.vue : hover handler selector etendu rect|circle
- CarteOWrapper.vue : CarteEdge gagne champ central?:boolean
- ColCentre.astro : tabs Chatbot retires (ChatbotV2 import retire aussi),
  remplaces par header bandeau 'Sommaire editorial d'architecture
  d'ecologie politique' (gauche, monospace 12px) + legende 3 symboles
  (publie ● / a venir ○ / projet 🟠) en droite

Build SSR : 5 pages prerender, 0 warning, 4.35s.
2026-05-11 20:00:30 +02:00
Jules Neny aa410ce7aa feat(v13-bg): layout 1 ecran fixe + hamburger desktop hide + categorie Pro 2026-05-11 20:00:16 +02:00
Jules Neny e90a7e12ef feat(v13-c): header 1-line cliquables + phrase intention Roboto Condensed 2026-05-11 19:58:07 +02:00
Jules Neny e43ec60ecf merge(v13-p0): iframe AEP skeleton timeout fallback fix loading stuck 2026-05-11 19:53:54 +02:00
Jules Neny 1033099663 fix(v13-p0): iframe AEP skeleton timeout fallback (resolves loading stuck)
Le @load event ne fire pas (ou tardivement) sur l'iframe AEP enfermee
dans un wrapper avec transform scale(0.42) + viewport simulee 1440px.
Resultat : skeleton 'Chargement de la carte AEP...' reste affiche
indefiniment, masquant l'iframe meme si elle se charge.

Fix :
- setTimeout 2.5s dans onMounted qui force revealIframe() inconditionnellement
- onIframeLoad clear le timer si l'event fire dans les temps (cas nominal)
- retrait du z-10 sur le skeleton (defense en profondeur : si bug residuel,
  l'iframe sera quand meme visible derriere)
- factorisation revealIframe() partagee entre @load et fallback
- cleanup du timer dans onUnmounted

Build SSR : 5 pages, 0 warning, ~4s.
Tests browser manuels a faire par Jules pour confirmer disparition skeleton.
2026-05-11 19:52:57 +02:00
Jules Neny 9bb55bc311 merge(v12-p): preview article 3 zones + colonnes scrollables indep 2026-05-11 18:52:59 +02:00
Jules Neny 7ec0efdeb5 merge(v12-o): Carte O logos Brandfetch overlay (zoom>1.5x) 2026-05-11 18:52:58 +02:00
Jules Neny 12a2d40371 feat(v12-p): preview article 3 zones + colonnes scrollables indep
- PreviewArticle.vue : nouveau composant qui ecoute journal-item-click et s'insere entre Carte O et iframe AEP
- EmbedDynamique.vue : retire le swap article (iframe AEP toujours visible en bas)
- ColCentre.astro : passe en flex-col, preview ouverte = Carte O 33vh + Preview auto + iframe 67vh, overflow-y-auto sur le container
- Bouton 'Retour a la carte' emet preview-close -> grid revient 1/3 + 2/3
- Scroll independant : Journal (gauche), Centre (preview), Insta (droite)
- Drag-resize desactive quand preview ouverte (anti-collision)
2026-05-11 18:52:11 +02:00
Jules Neny a1d6271b65 feat(v12-o): Carte O logos plateforme via Brandfetch CDN (visible zoom>1.5x)
Champ optionnel domain dans YAML carte-o-source : propage vers JSON et
permet d'afficher un logo plateforme en bas-droite de chaque node (cercle
blanc 18px + image clippee circulaire 14px) quand le zoom depasse 1.5x.

V1.2-O par defaut : substack.com sur les 15 thematiques essais. Centre +
projet TMIP gardent leur fill brut (encre / ocre). Toggle visibilite via
callback zoom (opacity 0/1 sur .logo-overlay).

A flagger : CDN Brandfetch retourne 403 en curl server-side avec le client
ID fourni. A revalider en browser (origin trans-former.fr) — le CDN peut
exiger un Origin header autorise. Si bloque, fallback prevu en V1.3
(proxy local ou logos packages dans /public/logos/).

Files:
- public/data/carte-o-source.yaml : +15 champs domain
- scripts/build-carte-o.js : propagation domain -> JSON
- src/components/vue/CarteO.vue : CarteNode.domain + logoUrl helper
  + logo-overlay (circle + image clip-path) + toggle visibilite zoom
- public/data/carte-o.json : regenere (15/17 nodes ont domain)
2026-05-11 18:48:15 +02:00
Jules Neny e613fc1ca3 merge(v12-q): iframe AEP forcé desktop transform scale 2026-05-11 18:43:06 +02:00
Jules Neny d12f77554f merge(v12-n): Carte O fusion noeud central + palette minimaliste 2026-05-11 18:43:06 +02:00
Jules Neny 397484a35e merge(v12-m): header bandeau SiteHeader + titre Trans-Former/Jules Neny 2026-05-11 18:42:50 +02:00
Jules Neny 3f2783e3fc feat(v12-n): Carte O fusion noeud central + palette minimaliste encre/papier/ocre
- YAML: fusion 3 noeuds confus (centre + ncs-politique + medecine-corps-social) en 1 seul noeud central 'Contrat social + Medecine du corps social'
- Build script: toutes les thematiques rattachees directement au centre (suppression mapping NCS/MDCS), radius central 30px, projets 18px
- CarteO.vue palette V1.2: central #0F172A (encre), essais #FFFFFF stroke encre, projets #B45309 (ocre conserve)
- Labels: inscrit dans le cercle (blanc) pour central+projets, a droite (encre douce) pour essais
- Label central long split sur 2-3 lignes via splitCentralLabel()
- Background: #FAFAF7 (papier, raccord colonnes laterales)
- Liens: #94A3B8 opacity 0.4 1px

17 nodes / 19 edges. Build SSR 5 pages prerender + server, 0 warning.
2026-05-11 18:42:06 +02:00
Jules Neny 3ba4628818 feat(v12-m): header bandeau Trans-Former / Jules Neny / baseline palette terre 2026-05-11 18:41:51 +02:00
Jules Neny 7791054ca0 feat(v12-q): iframe AEP forcé desktop via transform scale + viewport 1440px 2026-05-11 18:40:50 +02:00
Jules Neny 78dde6a2a3 merge(v12-r): col-centre default 1/3 + 2/3 (vs 50/50) 2026-05-11 18:34:39 +02:00
Jules Neny 3360daa61f merge(v12-l): footer 1-line 3-zones logos RS palette terre 2026-05-11 18:34:39 +02:00
Jules Neny d2d3629965 merge(v12-k): nav gauche palette terre + sub-pills Court/Article 2026-05-11 18:34:39 +02:00
Jules Neny 272fb5c181 feat(v12-r): col-centre default 1/3 Carte O + 2/3 iframe AEP (vs 50/50) 2026-05-11 18:34:01 +02:00
Jules Neny 6ea256f8a4 feat(v12-l): footer 1-line 3-zones (nav + subscribe + logos RS terre) 2026-05-11 18:33:46 +02:00
Jules Neny 95b75d4866 feat(v12-k): nav gauche palette terre + sub-pills Court/Article 2026-05-11 18:32:37 +02:00
Jules Neny 046f34ec8b feat(v11-f): drag-resize desktop ColCentre HAUT/BAS + persist session 2026-05-11 15:22:44 +02:00
Jules NenyandClaude Sonnet 4.6 61e53a04d5 feat(v11-e): centre BAS embed dynamique click-journal + suppression ScrollArticles
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 15:19:24 +02:00
Jules Neny 4a29a9592a Merge branch 'feat/v11-dg' into feat/page-cerveau-v1 2026-05-11 15:15:36 +02:00
Jules NenyandClaude Sonnet 4.6 79004573f1 feat(v11-dg): mobile header page active + hamburger top-right + poignee carte-o + polish css
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 15:14:48 +02:00
Jules NenyandClaude Sonnet 4.6 d8d3af28a0 feat(v11-c): carte-o rendering refonte niveau/nature/statut + contextmenu positionne
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 15:13:46 +02:00
Jules Neny 1e1c56db2f Merge branch 'feat/v11-i' into feat/page-cerveau-v1 2026-05-11 15:08:08 +02:00
Jules Neny 44ffe84d5b Merge branch 'feat/v11-b' into feat/page-cerveau-v1 2026-05-11 15:08:08 +02:00
Jules Neny c18328517a feat(v11-a): ajout Substack julesneny.substack.com dans selecteur Politique 2026-05-11 15:07:58 +02:00
Jules Neny beb8e9a0bd feat(v11-i): footer CTA infolettre + endpoint /api/subscribe Kit V4 2026-05-11 15:04:58 +02:00
Jules NenyandClaude Sonnet 4.6 5642690829 feat(v11-b): carte-o YAML source editoriale + build script niveau/nature/statut
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 15:04:53 +02:00
Jules NenyandClaude Sonnet 4.6 62794459ac feat(v11-a): hashtags 4 categories capsules monospace + selecteur Politique
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 15:03:45 +02:00
Jules Neny 5589678abc merge: PC7 chatbot v2 AEP + Astro server SSR endpoint (round 3) 2026-05-09 01:23:06 +02:00
Jules Neny be7fc09085 feat: PC7 chatbot V1 onglet centre HAUT + endpoint Astro proxy SSR
- ChatbotV2.vue : Vue island, thread chat (input + messages bot/user),
  persistance sessionStorage, bandeau beta '120 fiches AEP, RAG-PE bientot',
  gestion erreurs 429/502/504 ; pas de streaming ni markdown V1
- /api/chatbot.ts : endpoint Astro server proxy POST vers CHATBOT_UPSTREAM
  (default https://aep.trans-former.fr/api/chatbot), timeout 25s,
  body { question, history } -> upstream classique chatbot AEP Mistral Small
- astro.config.mjs : output 'server' + adapter @astrojs/node standalone
  (Astro 6 a supprime mode hybrid ; on opt-in prerender sur les pages)
- Toutes les pages publiques (index, manifeste, manifeste/commander,
  a-propos, mentions-legales) ont 'export const prerender = true'
- ColCentre.astro : remplace ChatbotPlaceholder par ChatbotV2 dans le tab
- .env.example : ajoute CHATBOT_UPSTREAM (V1.5 = switch LightRAG-PE 1 ligne)

Decision V1 : endpoint AEP /api/chatbot (classique, repond bien) au lieu
de /api/chatbot-v2 qui retourne v2_ready=false ('base vectorielle en cours').
Bandeau beta reste valide ; switch v2 quand ready cote AEP via env var.

Note PC8 deploy : Coolify doit booter avec 'node ./dist/server/entry.mjs'
(SSR Node standalone) au lieu de servir dist/client/ static.

Test end-to-end OK : SSR boot port 4399 + curl POST /api/chatbot ->
reponse_texte 800+ chars de l'AEP backend.
2026-05-09 01:22:01 +02:00
Jules Neny fccbc6d19c merge: PC6 journal n8n + JournalList (round 2) 2026-05-09 01:15:10 +02:00
Jules Neny 98f1257ece merge: PC4 iframe AEP centre BAS + scroll articles (round 2) 2026-05-09 01:15:05 +02:00
Jules NenyandClaude Opus 4.7 e22dd6654a feat: PC6 journal unifie + n8n workflow agregateur (V1 MVP)
Composant Vue JournalList :
- fetch PUBLIC_JOURNAL_URL (defaut data.trans-former.fr/journal.json)
- ecoute event 'hashtag-filter-change' emis par ColJournal (PC2)
- filtre par hashtag actif, tri desc respecte (n8n cote serveur)
- fallback gracieux : loading / errored / empty / no-match

Cabling :
- ColJournal.astro importe et rend <JournalList client:visible />
- placeholder remplace par le composant Vue

Workflow n8n (docs/n8n-workflow-journal-aggregate.json) :
- Schedule trigger cron 0 3 * * *
- Fetch Gitea Atom (jules.atom) + Behold AEP + Behold julesneny (skip si feed IDs absents)
- Code Node normalisation 3 sources -> format JSON commun
- Tri desc + cap top 100
- Write Binary File vers /home/node/.n8n/journal/journal.json (volume Docker partage)

Sources V1 actives :
- Gitea Atom (#stack) - active, 200 OK confirme
- Behold @aep (#aep-politique) - conditionnel feed ID
- Behold @julesneny (#peinture) - conditionnel feed ID

Sources skipped (V1.5/V2) :
- GitHub.com : username 'julesneny' n'existe pas (HTTP 404), pivot Gitea
- Substack 'transformations' : pris par 'WoodHorse' (pas Jules), handle a confirmer
- LinkedIn, Castopod, Blog : V2

Mock journal.json en public/data/ pour dev local (fallback si data.trans-former.fr indisponible).

Setup VPS prepare (cf docs/PC6-JOURNAL-N8N-SETUP.md) :
- Caddyfile bloc data.trans-former.fr ajoute en commentaire (active apres DNS)
- Dossier /var/lib/docker/volumes/vps-kit_n8n_data/_data/journal/ cree
- journal.json initial deploye
- Caddy reload OK valide (config valide)
- Workflow JSON copie sur VPS /tmp/n8n-workflow-journal-aggregate.json (import manuel UI)

Checkpoint Jules requis :
- Ajout DNS A 'data' -> 178.104.106.195 (OVH)
- Decommenter bloc Caddy + reload
- Import workflow n8n via UI (creds basic auth deprecies, login email user)
- Run manuel + activation cron

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 01:13:51 +02:00
Jules Neny 6aa5a7143a feat: PC4 iframe carte AEP centre BAS + scroll articles Substack placeholder
- IframeCarteAEP.astro : iframe https://aep.trans-former.fr/agences avec
  skeleton loader + timeout 8s + fallback lien externe
- ScrollArticles.astro : 7 articles placeholder (V1, PC6 remplacera par
  fetch journal.json)
- ColCentre.astro : zone BAS cablee (iframe min-h 60vh mobile / 400px desktop
  + scroll articles dessous)
- Preflight headers : pas de X-Frame-Options ni frame-ancestors restrictifs
  sur /agences (200 OK), iframe cross-origin permise sans modif Caddy
2026-05-09 01:06:02 +02:00
Jules Neny 68e511be7a merge: PC5 embeds Insta col D Behold (round 1) 2026-05-09 01:01:11 +02:00
Jules Neny 0c53f450c3 merge: PC3 mindmap Carte O D3 + scrape AEP (round 1) 2026-05-09 01:01:07 +02:00
Jules Neny 64a3cc7147 merge: PC2 nav latérale + manifeste + popup (round 1) 2026-05-09 01:01:02 +02:00
Jules NenyandClaude Opus 4.7 32bdc9a2e5 feat: PC3 mindmap Carte O (D3 force-directed) + scrape AEP/Articles + tabs centre HAUT
- scripts/build-carte-o.js : scan recursif AEP/Articles/, parse YAML + legacy header, extract wikilinks, infer 5 famille
- src/components/vue/CarteO.vue : D3 v7 force-directed avec drag, zoom + pan, click handler, tooltips, ResizeObserver
- src/components/vue/CarteOModal.vue : modal recap intention avec Teleport, Esc + backdrop close, transitions
- src/components/vue/CarteOWrapper.vue : fetch /data/carte-o.json, etat selectionne, fallback mobile (msg + miniature SVG)
- src/components/astro/ColCentre.astro : tabs Carte O / Chatbot, panneaux ARIA
- package.json : prebuild + predev hooks, build:carte-o script
- public/data/carte-o.json : 84 nodes / 94 edges sur 21 themes, distribution familles equilibree

Drill-down V1 = zoom + pan seul (V2 recursif backlog).
Pattern adapte de nav-carte/components/codev/CodevGraph.vue (sans coupling Nuxt).
Build Astro 6.3.1 OK, bundle CarteOWrapper 69KB.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 00:59:23 +02:00
Jules Neny 712ed0eefa feat(pc2): hamburger nav + manifeste V1 + popup onboarding
- HamburgerMenu drawer slide-in left avec liens A propos, Manifeste, Mentions legales (ESC + clic overlay pour fermer)
- ColJournal enrichi : CTA Manifeste, accordeon Hashtags (7 plateformes, ferme mobile / ouvert desktop, persistence localStorage), skeleton Journal pour PC6
- Page /manifeste : V1 redige integre (em-dashes remplaces par tirets/points-virgules), pivot stylise blockquote, diagramme mouvements en 3 sections boites
- Page /manifeste/commander : stub form pre-inscription (V1 localStorage, V1.1 cable Listmonk)
- Page /a-propos : extrait de Contexte Global, 3-4 paragraphes Jules
- Page /mentions-legales : placeholder court (editeur, hebergeur Hetzner, pas de cookies)
- PopupOnboarding : micro-resume 3 lignes proposees, dismiss X / CTA / scroll 200px / ESC, flag tf-onboarded
2026-05-09 00:58:19 +02:00
57 changed files with 6641 additions and 153 deletions
+12
View File
@@ -1,6 +1,18 @@
# Kit (ex-ConvertKit) - newsletter infolettre
KIT_API_SECRET_V4=kit_xxx
# Behold.so feed IDs (voir docs/BEHOLD-SETUP.md)
# 1) Inscris-toi sur https://behold.so/dashboard
# 2) Connecte les 2 comptes Insta (@aep.politique + @julesneny)
# 3) Recupere les feed IDs et copie ce fichier vers .env.local puis remplis ci-dessous
PUBLIC_BEHOLD_AEP=
PUBLIC_BEHOLD_JULESNENY=
# Journal unifie (PC6) - URL JSON agrege par n8n cron nocturne
# Override en local : pointer vers un mock /public/data/journal.json par exemple
PUBLIC_JOURNAL_URL=https://data.trans-former.fr/journal.json
# Chatbot upstream (PC7) - URL backend chatbot AEP
# V1 : chatbot AEP classique (Mistral Small + 120 fiches)
# V1.5 : switch vers LightRAG-PE (1 ligne)
CHATBOT_UPSTREAM=https://aep.trans-former.fr/api/chatbot
+16
View File
@@ -0,0 +1,16 @@
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package*.json ./
EXPOSE 3000
ENV HOST=0.0.0.0
ENV PORT=3000
CMD ["node", "./dist/server/entry.mjs"]
+15 -2
View File
@@ -1,11 +1,24 @@
// @ts-check
import { defineConfig } from 'astro/config';
import vue from '@astrojs/vue';
import node from '@astrojs/node';
import tailwindcss from '@tailwindcss/vite';
import sitemap from '@astrojs/sitemap';
// https://astro.build/config
// PC7 — bascule SSR (mode 'server' Astro 6) pour endpoint /api/chatbot proxy.
// Toutes les pages publiques restent statiques via `export const prerender = true`.
// Coolify deploy (PC8) : `node ./dist/server/entry.mjs` (Node adapter standalone).
// PC8 — sitemap auto-genere + site URL pour canonical + redirects SEO.
export default defineConfig({
integrations: [vue()],
site: 'https://trans-former.fr',
output: 'server',
adapter: node({ mode: 'standalone' }),
integrations: [
vue(),
sitemap({
filter: (page) => !page.includes('/api/'),
}),
],
vite: {
plugins: [tailwindcss()],
},
+147
View File
@@ -0,0 +1,147 @@
# PC6 — Setup journal unifié + n8n workflow
Spec : `0 INBOX/PROMPTS/page-cerveau-build/PROMPT-PC6-journal-n8n.md`
Pilote : `0 INBOX/PROMPTS/page-cerveau-build/PILOTE-PC.md` (delta 5, delta 15)
## TL;DR
Le journal de la colonne G est alimenté par un cron n8n nocturne qui agrège plusieurs sources publiques (Gitéa Atom, Behold @aep, Behold @julesneny) et écrit un JSON statique servi par Caddy sur `data.trans-former.fr/journal.json`.
```
n8n cron (3h00 UTC)
-> fetch Gitéa Atom + Behold @aep + Behold @julesneny
-> normalisation Code Node
-> tri desc + top 100
-> écrit /home/node/.n8n/journal/journal.json (volume Docker)
-> Caddy data.trans-former.fr file_server expose ce fichier
-> JournalList.vue fetch côté client (no rebuild Astro requis)
```
## Sources V1 actives
| Plateforme | Hashtag | URL feed | Statut |
|---|---|---|---|
| Gitéa | `#stack` | `https://git.trans-former.fr/jules.atom` | ACTIF |
| Behold @aep | `#aep-politique` | `https://feeds.behold.so/{PUBLIC_BEHOLD_AEP}` | conditionnel (skip si feed ID absent) |
| Behold @julesneny | `#peinture` | `https://feeds.behold.so/{PUBLIC_BEHOLD_JULESNENY}` | conditionnel (skip si feed ID absent) |
## Sources skipped (V1 -> V1.5/V2)
| Plateforme | Hashtag | Raison |
|---|---|---|
| GitHub.com | `#stack` | username `julesneny` n'existe pas (HTTP 404). Pivot Gitéa pour le MVP. À reconfirmer si Jules a un autre handle GitHub public. |
| Substack | `#politique` | `transformations.substack.com` est pris par "WoodHorse" (pas Jules). Handle Substack à confirmer avant V1.5. |
| LinkedIn | `#building-public` | V2 (RSS via service tiers ou scrape) |
| Castopod | `#podcast` | V2 (Castopod RSS prêt mais hors scope MVP) |
| Blog `trans-former.fr` | `#manifeste` | V2 (post-PC8 deploy) |
## Format JSON
```json
{
"generatedAt": "2026-05-09T03:00:00Z",
"items": [
{
"id": "gitea-2026-05-09-pc6",
"platform": "gitea",
"hashtag": "#stack",
"date": "2026-05-09T01:01:00Z",
"titre": "PC6 journal unifié + n8n agrégateur",
"extrait": "...",
"url": "https://git.trans-former.fr/jules/astro-site-cerveau/commit/...",
"thumbnail": null
}
],
"counts": { "total": N, "gitea": N, "instagram": N }
}
```
## Composant Vue
`src/components/vue/JournalList.vue` :
- fetch `import.meta.env.PUBLIC_JOURNAL_URL` (défaut `https://data.trans-former.fr/journal.json`)
- écoute `window.addEventListener('hashtag-filter-change', ...)` émis par ColJournal.astro
- filtre par hashtag (vide ou tous cochés -> tout afficher ; tous décochés -> rien)
- tri desc déjà fait côté n8n, le composant respecte l'ordre
Cabling : `src/components/astro/ColJournal.astro` importe et rend `<JournalList client:visible />` dans `#journal-list`.
## Variable d'env
`PUBLIC_JOURNAL_URL=https://data.trans-former.fr/journal.json` (`.env.example`)
Override possible en local pointant vers `/data/journal.json` (mock fourni dans `public/data/journal.json`).
## Setup VPS — étapes (ops, à valider Jules)
### 1. DNS
Dans OVH zone DNS `trans-former.fr` :
```
data A 178.104.106.195 TTL 600
```
Attendre propagation (~5min).
### 2. Volume Docker partagé n8n -> Caddy
Le container n8n monte `vps-kit_n8n_data:/home/node/.n8n`. On va simplement lire un fichier dans ce volume depuis Caddy.
Path source : `/var/lib/docker/volumes/vps-kit_n8n_data/_data/journal/journal.json`
```bash
ssh vps-hetzner "mkdir -p /var/lib/docker/volumes/vps-kit_n8n_data/_data/journal && \
chown 1000:1000 /var/lib/docker/volumes/vps-kit_n8n_data/_data/journal"
```
### 3. Caddyfile bloc
Ajouter dans le Caddyfile (probablement `/etc/caddy/Caddyfile` ou `/opt/vps-kit/configs/Caddyfile`) :
```caddy
data.trans-former.fr {
root * /var/lib/docker/volumes/vps-kit_n8n_data/_data/journal
file_server {
index journal.json
}
encode gzip
header {
Cache-Control "public, max-age=300"
Access-Control-Allow-Origin "https://trans-former.fr"
}
log {
output file /var/log/caddy/data.log
}
}
```
Backup + reload :
```bash
ssh vps-hetzner "cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.bak.$(date +%Y%m%d-%H%M%S) && \
systemctl reload caddy && \
systemctl status caddy --no-pager | head -10"
```
### 4. Workflow n8n
Importer `docs/n8n-workflow-journal-aggregate.json` dans https://automate.trans-former.fr (UI -> Import from file).
Activer le toggle, vérifier le cron (`0 3 * * *`).
Configurer les credentials env n8n si besoin (Behold feed IDs) -> non bloquants si absents (workflow skip).
### 5. Smoke test
```bash
# Run manuel (UI n8n -> Execute Workflow)
ssh vps-hetzner "ls -la /var/lib/docker/volumes/vps-kit_n8n_data/_data/journal/"
curl -sf https://data.trans-former.fr/journal.json | jq '.counts'
```
## Backlog (hors scope PC6)
- Trigger rebuild Astro Coolify webhook (PC8)
- Sources V2 : LinkedIn, Castopod, Blog, Substack (post handle confirmé)
- Storage archivage long-terme (V1 = écrasement quotidien)
- Real-time updates (V3)
+152
View File
@@ -0,0 +1,152 @@
{
"name": "journal-aggregate",
"nodes": [
{
"parameters": {
"rule": {
"interval": [
{
"field": "cronExpression",
"expression": "0 3 * * *"
}
]
}
},
"id": "schedule-trigger",
"name": "Cron-3h",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.1,
"position": [240, 320]
},
{
"parameters": {
"url": "https://git.trans-former.fr/jules.atom",
"options": {
"response": {
"response": {
"responseFormat": "text"
}
},
"timeout": 15000
}
},
"id": "fetch-gitea",
"name": "Fetch-gitea",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.1,
"position": [460, 200]
},
{
"parameters": {
"url": "=https://feeds.behold.so/{{ $env.PUBLIC_BEHOLD_AEP || 'NOT_SET' }}",
"options": {
"response": {
"response": {
"neverError": true,
"responseFormat": "json"
}
},
"timeout": 15000
}
},
"id": "fetch-behold-aep",
"name": "Fetch-behold-aep",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.1,
"position": [460, 320]
},
{
"parameters": {
"url": "=https://feeds.behold.so/{{ $env.PUBLIC_BEHOLD_JULESNENY || 'NOT_SET' }}",
"options": {
"response": {
"response": {
"neverError": true,
"responseFormat": "json"
}
},
"timeout": 15000
}
},
"id": "fetch-behold-julesneny",
"name": "Fetch-behold-julesneny",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.1,
"position": [460, 440]
},
{
"parameters": {
"jsCode": "// Normalisation des 3 sources V1 vers le format JSON unifié\n// Sources : Gitéa Atom (XML), Behold @aep (JSON), Behold @julesneny (JSON)\n\nconst items = [];\n\n// ---- Helper parse Atom XML (Gitéa) ----\nfunction parseAtomGitea(xml) {\n const out = [];\n if (!xml || typeof xml !== 'string') return out;\n const entries = xml.split(/<entry>/i).slice(1);\n for (const raw of entries) {\n const block = '<entry>' + raw.split(/<\\/entry>/i)[0] + '</entry>';\n const title = (block.match(/<title>([\\s\\S]*?)<\\/title>/i) || [])[1] || '';\n const updated = (block.match(/<updated>([^<]+)<\\/updated>/i) || [])[1] || '';\n const link = (block.match(/<link[^>]*href=\"([^\"]+)\"/i) || [])[1] || '';\n const summary = (block.match(/<summary[^>]*>([\\s\\S]*?)<\\/summary>/i) || [])[1] || '';\n const id = (block.match(/<id>([^<]+)<\\/id>/i) || [])[1] || link || updated;\n if (!updated) continue;\n // Decode HTML entities basique + strip tags\n const decode = (s) => s\n .replace(/&lt;/g, '<')\n .replace(/&gt;/g, '>')\n .replace(/&quot;/g, '\"')\n .replace(/&#34;/g, '\"')\n .replace(/&#xA;/g, ' ')\n .replace(/&amp;/g, '&')\n .replace(/<[^>]+>/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n const titreClean = decode(title);\n const extrait = decode(summary).slice(0, 280);\n out.push({\n id: 'gitea-' + (id.slice(0, 80) || updated),\n platform: 'gitea',\n hashtag: '#stack',\n date: new Date(updated).toISOString(),\n titre: titreClean.slice(0, 140),\n extrait,\n url: link,\n thumbnail: null,\n });\n }\n return out;\n}\n\n// ---- Récupère payloads depuis les 3 nodes amont ----\nlet giteaXml = '';\ntry {\n const giteaItems = $('Fetch-gitea').all();\n if (giteaItems.length && giteaItems[0].json) {\n // HTTP node typeVersion 4.1 met le body brut dans .data si responseFormat=text\n giteaXml = giteaItems[0].json.data || giteaItems[0].json.body || '';\n if (typeof giteaXml !== 'string') giteaXml = String(giteaXml);\n }\n} catch (e) {\n console.log('Gitéa fetch missing:', e.message);\n}\n\nlet beholdAep = [];\ntry {\n const aep = $('Fetch-behold-aep').all();\n if (aep.length && aep[0].json && Array.isArray(aep[0].json.posts)) {\n beholdAep = aep[0].json.posts;\n } else if (aep.length && Array.isArray(aep[0].json)) {\n beholdAep = aep[0].json;\n }\n} catch (e) {\n console.log('Behold AEP fetch missing:', e.message);\n}\n\nlet beholdJulesneny = [];\ntry {\n const j = $('Fetch-behold-julesneny').all();\n if (j.length && j[0].json && Array.isArray(j[0].json.posts)) {\n beholdJulesneny = j[0].json.posts;\n } else if (j.length && Array.isArray(j[0].json)) {\n beholdJulesneny = j[0].json;\n }\n} catch (e) {\n console.log('Behold Julesneny fetch missing:', e.message);\n}\n\n// ---- Normalisation ----\nfor (const it of parseAtomGitea(giteaXml)) items.push(it);\n\nfor (const post of beholdAep) {\n if (!post || !post.id) continue;\n const ts = post.timestamp || post.taken_at || post.date;\n const caption = (post.caption || post.captionWithEmojis || '').toString();\n items.push({\n id: 'insta-aep-' + post.id,\n platform: 'instagram',\n hashtag: '#aep-politique',\n date: ts ? new Date(ts).toISOString() : new Date().toISOString(),\n titre: caption.slice(0, 100) || '@aep.politique',\n extrait: caption.slice(0, 280),\n url: post.permalink || post.url || 'https://instagram.com/aep.politique',\n thumbnail: post.thumbnailUrl || post.mediaUrl || (post.sizes && post.sizes.medium && post.sizes.medium.mediaUrl) || null,\n });\n}\n\nfor (const post of beholdJulesneny) {\n if (!post || !post.id) continue;\n const ts = post.timestamp || post.taken_at || post.date;\n const caption = (post.caption || post.captionWithEmojis || '').toString();\n items.push({\n id: 'insta-julesneny-' + post.id,\n platform: 'instagram',\n hashtag: '#peinture',\n date: ts ? new Date(ts).toISOString() : new Date().toISOString(),\n titre: caption.slice(0, 100) || '@julesneny',\n extrait: caption.slice(0, 280),\n url: post.permalink || post.url || 'https://instagram.com/julesneny',\n thumbnail: post.thumbnailUrl || post.mediaUrl || (post.sizes && post.sizes.medium && post.sizes.medium.mediaUrl) || null,\n });\n}\n\n// ---- Tri desc + cap top 100 ----\nitems.sort((a, b) => b.date.localeCompare(a.date));\nconst top = items.slice(0, 100);\n\nconst counts = {\n total: top.length,\n gitea: top.filter((i) => i.platform === 'gitea').length,\n instagram: top.filter((i) => i.platform === 'instagram').length,\n};\n\nreturn [\n {\n json: {\n generatedAt: new Date().toISOString(),\n items: top,\n counts,\n },\n },\n];"
},
"id": "normalise",
"name": "Normalise",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [720, 320]
},
{
"parameters": {
"operation": "toJson",
"fieldName": "data",
"options": {
"format": true
}
},
"id": "to-json",
"name": "To-json-string",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [940, 320],
"notesInFlow": false,
"notes": "Transforme l'objet JS en string JSON pour Write Binary File"
},
{
"parameters": {
"operation": "write",
"fileName": "/home/node/.n8n/journal/journal.json",
"dataPropertyName": "data",
"options": {}
},
"id": "write-file",
"name": "Write-journal-json",
"type": "n8n-nodes-base.readWriteFile",
"typeVersion": 1,
"position": [1160, 320]
}
],
"connections": {
"Cron-3h": {
"main": [
[
{ "node": "Fetch-gitea", "type": "main", "index": 0 },
{ "node": "Fetch-behold-aep", "type": "main", "index": 0 },
{ "node": "Fetch-behold-julesneny", "type": "main", "index": 0 }
]
]
},
"Fetch-gitea": {
"main": [[{ "node": "Normalise", "type": "main", "index": 0 }]]
},
"Fetch-behold-aep": {
"main": [[{ "node": "Normalise", "type": "main", "index": 0 }]]
},
"Fetch-behold-julesneny": {
"main": [[{ "node": "Normalise", "type": "main", "index": 0 }]]
},
"Normalise": {
"main": [[{ "node": "To-json-string", "type": "main", "index": 0 }]]
},
"To-json-string": {
"main": [[{ "node": "Write-journal-json", "type": "main", "index": 0 }]]
}
},
"settings": {
"executionOrder": "v1",
"saveExecutionProgress": true,
"saveManualExecutions": true
},
"tags": [
{ "name": "page-cerveau" },
{ "name": "PC6" }
]
}
+1408
View File
File diff suppressed because it is too large Load Diff
+15 -1
View File
@@ -6,17 +6,31 @@
"node": ">=22.12.0"
},
"scripts": {
"predev": "node scripts/build-carte-o.js",
"dev": "astro dev",
"prebuild": "node scripts/build-carte-o.js",
"build": "astro build",
"preview": "astro preview",
"astro": "astro"
"astro": "astro",
"build:carte-o": "node scripts/build-carte-o.js"
},
"dependencies": {
"@astrojs/node": "^10.1.0",
"@astrojs/sitemap": "^3.7.2",
"@astrojs/vue": "^6.0.1",
"@fontsource-variable/roboto-condensed": "^5.2.8",
"@tailwindcss/vite": "^4.2.4",
"@transformer/ui": "git+https://git.trans-former.fr/jules/design-system.git",
"@types/d3": "^7.4.3",
"astro": "^6.3.1",
"d3": "^7.9.0",
"embla-carousel-vue": "^8.6.0",
"globby": "^16.2.0",
"gray-matter": "^4.0.3",
"tailwindcss": "^4.2.4",
"vue": "^3.5.34"
},
"devDependencies": {
"js-yaml": "^4.1.1"
}
}
+147
View File
@@ -0,0 +1,147 @@
# Carte O AEP - source editoriale manuelle
# Edite manuellement apres chaque publication
# Build: npm run carte-o -> public/data/carte-o.json
# statut: gestation (draft/en cours) | edite (publie)
# nature: essai (texte politique) | projet (projet archi)
# niveau: 0 (centre) | 1 (concepts force) | 2 (thematiques/projets)
# problematique (optionnel) : accroche affichee au survol du node (une tension, pas une definition)
# BROUILLON MOE 29/09 : les 17 textes sont a valider par Jules
# domain (optionnel) : domaine plateforme source pour logo Brandfetch CDN
# - affiche logo en bas-droite du node si zoom > 1.5x
# - V1.2 par defaut : substack.com pour tous les essais AEP
version: "1.1"
centre:
id: "contrat-social-medecine-corps-social"
label: "Une medecine du corps social pour ecrire un nouveau contrat social"
niveau: 0
nature: essai
statut: gestation
problematique: "Diagnostiquer avant de prescrire : où le corps social bloque-t-il, et quelle aiguille le débloque ?"
resume: "Manifeste central AEP : inventer un nouveau contrat social et diagnostiquer/soigner les pathologies du corps social."
concepts_force: []
thematiques:
- id: "systemique"
label: "Systemique & complexite"
niveau: 2
nature: essai
statut: gestation
problematique: "Tout se tient : c'est pour ça qu'on ne répare rien en traitant les problèmes un par un."
domain: "substack.com"
- id: "pratiques-collectives"
label: "Pratiques collectives"
niveau: 2
nature: essai
statut: gestation
problematique: "Seul, chacun a raison et reste impuissant. Qu'est-ce qui fait passer du « je » au « nous » ?"
domain: "substack.com"
- id: "art-narration"
label: "Art & narration"
niveau: 2
nature: essai
statut: gestation
problematique: "On ne quitte pas un monde qu'on n'arrive pas à raconter autrement."
domain: "substack.com"
- id: "pouvoir-domination"
label: "Rapport au pouvoir"
niveau: 2
nature: essai
statut: gestation
problematique: "Le pouvoir des pires n'est pas un accident : c'est une mécanique. Laquelle démonter ?"
domain: "substack.com"
- id: "medias-critique"
label: "Medias & pensee critique"
niveau: 2
nature: essai
statut: gestation
problematique: "S'informer tous les jours et comprendre de moins en moins : à qui profite le bruit ?"
domain: "substack.com"
- id: "justice-securite"
label: "Justice & securite"
niveau: 2
nature: essai
statut: gestation
problematique: "Dure pour les faibles, douce pour les puissants : punir plus, ou réparer mieux ?"
domain: "substack.com"
- id: "sante-globale"
label: "Sante globale"
niveau: 2
nature: essai
statut: gestation
problematique: "Le burn-out est-il une maladie à soigner, ou le symptôme d'un travail à changer ?"
domain: "substack.com"
- id: "agriculture"
label: "Agriculture"
niveau: 2
nature: essai
statut: gestation
problematique: "Le sol vivant est une infrastructure à entretenir, pas un stock à vider."
domain: "substack.com"
- id: "post-croissance"
label: "Post-croissance"
niveau: 2
nature: essai
statut: gestation
problematique: "Performants jusqu'à casser : et si la robustesse valait mieux que la croissance ?"
domain: "substack.com"
- id: "anthropocene"
label: "Anthropocene & effondrement"
niveau: 2
nature: essai
statut: gestation
problematique: "Quand tout se dérègle en même temps, que protège-t-on en premier ?"
domain: "substack.com"
- id: "education"
label: "Education a la transformation"
niveau: 2
nature: essai
statut: gestation
problematique: "Former des esprits qui doutent, ou des rouages qui tiennent le rang ?"
domain: "substack.com"
- id: "urbanisme"
label: "Urbanisme"
niveau: 2
nature: essai
statut: gestation
problematique: "Couler du béton neuf, ou transformer ce qui est déjà là ?"
domain: "substack.com"
- id: "geopolitique"
label: "Geopolitique & decolonisation"
niveau: 2
nature: essai
statut: gestation
problematique: "Notre confort repose sur un empire qui n'a jamais vraiment pris fin. Qui paie l'addition ?"
domain: "substack.com"
- id: "ia-technologie"
label: "IA & technologie"
niveau: 2
nature: essai
statut: gestation
problematique: "Un outil qui pense à notre place nous rend-il plus libres, ou plus dépendants ?"
domain: "substack.com"
- id: "spiritualite"
label: "Spiritualite"
niveau: 2
nature: essai
statut: gestation
problematique: "Retrouver du sens sans retomber dans le dogme, ni dans le développement personnel."
domain: "substack.com"
projets:
- id: "tmip"
label: "TMIP"
niveau: 2
nature: projet
statut: gestation
problematique: "7,6 millions de maisons périurbaines : une impasse, ou le plus grand chantier de transformation du pays ?"
resume: "Transport, mobilite, industrie, politique - projet archi. Exemple de projet archi relie aux thematiques AEP."
# V1.3-D : lien explicite au noeud central (pont vision <-> pratique)
lien_central: true
liens_thematiques:
- "urbanisme"
- "justice-securite"
- "post-croissance"
- "agriculture"
+306
View File
@@ -0,0 +1,306 @@
{
"version": "1.1",
"generatedAt": "2026-09-29T12:05:12.880Z",
"nodes": [
{
"id": "contrat-social-medecine-corps-social",
"label": "Une medecine du corps social pour ecrire un nouveau contrat social",
"niveau": 0,
"nature": "essai",
"statut": "gestation",
"resume": "Manifeste central AEP : inventer un nouveau contrat social et diagnostiquer/soigner les pathologies du corps social.",
"radius": 30,
"family": "concept",
"problematique": "Diagnostiquer avant de prescrire : où le corps social bloque-t-il, et quelle aiguille le débloque ?"
},
{
"id": "systemique",
"label": "Systemique & complexite",
"niveau": 2,
"nature": "essai",
"statut": "gestation",
"resume": null,
"radius": 12,
"family": "concept",
"domain": "substack.com",
"problematique": "Tout se tient : c'est pour ça qu'on ne répare rien en traitant les problèmes un par un."
},
{
"id": "pratiques-collectives",
"label": "Pratiques collectives",
"niveau": 2,
"nature": "essai",
"statut": "gestation",
"resume": null,
"radius": 12,
"family": "concept",
"domain": "substack.com",
"problematique": "Seul, chacun a raison et reste impuissant. Qu'est-ce qui fait passer du « je » au « nous » ?"
},
{
"id": "art-narration",
"label": "Art & narration",
"niveau": 2,
"nature": "essai",
"statut": "gestation",
"resume": null,
"radius": 12,
"family": "concept",
"domain": "substack.com",
"problematique": "On ne quitte pas un monde qu'on n'arrive pas à raconter autrement."
},
{
"id": "pouvoir-domination",
"label": "Rapport au pouvoir",
"niveau": 2,
"nature": "essai",
"statut": "gestation",
"resume": null,
"radius": 12,
"family": "concept",
"domain": "substack.com",
"problematique": "Le pouvoir des pires n'est pas un accident : c'est une mécanique. Laquelle démonter ?"
},
{
"id": "medias-critique",
"label": "Medias & pensee critique",
"niveau": 2,
"nature": "essai",
"statut": "gestation",
"resume": null,
"radius": 12,
"family": "concept",
"domain": "substack.com",
"problematique": "S'informer tous les jours et comprendre de moins en moins : à qui profite le bruit ?"
},
{
"id": "justice-securite",
"label": "Justice & securite",
"niveau": 2,
"nature": "essai",
"statut": "gestation",
"resume": null,
"radius": 12,
"family": "concept",
"domain": "substack.com",
"problematique": "Dure pour les faibles, douce pour les puissants : punir plus, ou réparer mieux ?"
},
{
"id": "sante-globale",
"label": "Sante globale",
"niveau": 2,
"nature": "essai",
"statut": "gestation",
"resume": null,
"radius": 12,
"family": "concept",
"domain": "substack.com",
"problematique": "Le burn-out est-il une maladie à soigner, ou le symptôme d'un travail à changer ?"
},
{
"id": "agriculture",
"label": "Agriculture",
"niveau": 2,
"nature": "essai",
"statut": "gestation",
"resume": null,
"radius": 12,
"family": "concept",
"domain": "substack.com",
"problematique": "Le sol vivant est une infrastructure à entretenir, pas un stock à vider."
},
{
"id": "post-croissance",
"label": "Post-croissance",
"niveau": 2,
"nature": "essai",
"statut": "gestation",
"resume": null,
"radius": 12,
"family": "concept",
"domain": "substack.com",
"problematique": "Performants jusqu'à casser : et si la robustesse valait mieux que la croissance ?"
},
{
"id": "anthropocene",
"label": "Anthropocene & effondrement",
"niveau": 2,
"nature": "essai",
"statut": "gestation",
"resume": null,
"radius": 12,
"family": "concept",
"domain": "substack.com",
"problematique": "Quand tout se dérègle en même temps, que protège-t-on en premier ?"
},
{
"id": "education",
"label": "Education a la transformation",
"niveau": 2,
"nature": "essai",
"statut": "gestation",
"resume": null,
"radius": 12,
"family": "concept",
"domain": "substack.com",
"problematique": "Former des esprits qui doutent, ou des rouages qui tiennent le rang ?"
},
{
"id": "urbanisme",
"label": "Urbanisme",
"niveau": 2,
"nature": "essai",
"statut": "gestation",
"resume": null,
"radius": 12,
"family": "concept",
"domain": "substack.com",
"problematique": "Couler du béton neuf, ou transformer ce qui est déjà là ?"
},
{
"id": "geopolitique",
"label": "Geopolitique & decolonisation",
"niveau": 2,
"nature": "essai",
"statut": "gestation",
"resume": null,
"radius": 12,
"family": "concept",
"domain": "substack.com",
"problematique": "Notre confort repose sur un empire qui n'a jamais vraiment pris fin. Qui paie l'addition ?"
},
{
"id": "ia-technologie",
"label": "IA & technologie",
"niveau": 2,
"nature": "essai",
"statut": "gestation",
"resume": null,
"radius": 12,
"family": "concept",
"domain": "substack.com",
"problematique": "Un outil qui pense à notre place nous rend-il plus libres, ou plus dépendants ?"
},
{
"id": "spiritualite",
"label": "Spiritualite",
"niveau": 2,
"nature": "essai",
"statut": "gestation",
"resume": null,
"radius": 12,
"family": "concept",
"domain": "substack.com",
"problematique": "Retrouver du sens sans retomber dans le dogme, ni dans le développement personnel."
},
{
"id": "tmip",
"label": "TMIP",
"niveau": 2,
"nature": "projet",
"statut": "gestation",
"resume": "Transport, mobilite, industrie, politique - projet archi. Exemple de projet archi relie aux thematiques AEP.",
"radius": 18,
"family": "ressource",
"problematique": "7,6 millions de maisons périurbaines : une impasse, ou le plus grand chantier de transformation du pays ?"
}
],
"edges": [
{
"source": "contrat-social-medecine-corps-social",
"target": "systemique",
"central": true
},
{
"source": "contrat-social-medecine-corps-social",
"target": "pratiques-collectives",
"central": true
},
{
"source": "contrat-social-medecine-corps-social",
"target": "art-narration",
"central": true
},
{
"source": "contrat-social-medecine-corps-social",
"target": "pouvoir-domination",
"central": true
},
{
"source": "contrat-social-medecine-corps-social",
"target": "medias-critique",
"central": true
},
{
"source": "contrat-social-medecine-corps-social",
"target": "justice-securite",
"central": true
},
{
"source": "contrat-social-medecine-corps-social",
"target": "sante-globale",
"central": true
},
{
"source": "contrat-social-medecine-corps-social",
"target": "agriculture",
"central": true
},
{
"source": "contrat-social-medecine-corps-social",
"target": "post-croissance",
"central": true
},
{
"source": "contrat-social-medecine-corps-social",
"target": "anthropocene",
"central": true
},
{
"source": "contrat-social-medecine-corps-social",
"target": "education",
"central": true
},
{
"source": "contrat-social-medecine-corps-social",
"target": "urbanisme",
"central": true
},
{
"source": "contrat-social-medecine-corps-social",
"target": "geopolitique",
"central": true
},
{
"source": "contrat-social-medecine-corps-social",
"target": "ia-technologie",
"central": true
},
{
"source": "contrat-social-medecine-corps-social",
"target": "spiritualite",
"central": true
},
{
"source": "contrat-social-medecine-corps-social",
"target": "tmip",
"central": true
},
{
"source": "tmip",
"target": "urbanisme"
},
{
"source": "tmip",
"target": "justice-securite"
},
{
"source": "tmip",
"target": "post-croissance"
},
{
"source": "tmip",
"target": "agriculture"
}
]
}
+52
View File
@@ -0,0 +1,52 @@
{
"generatedAt": "2026-05-09T01:00:00Z",
"fallback": true,
"note": "Mock local — agrégateur n8n live pousse sur data.trans-former.fr/journal.json. Ce fichier sert de fallback dev tant que data.trans-former.fr DNS/Caddy ne sont pas en place.",
"items": [
{
"id": "gitea-mock-pc6-feat",
"platform": "gitea",
"hashtag": "#stack",
"date": "2026-05-09T01:01:00Z",
"titre": "PC6 journal unifié + n8n agrégateur (mock)",
"extrait": "Composant JournalList Vue + workflow n8n cron 3h00. Sources V1 : Gitéa Atom + Behold @aep + Behold @julesneny.",
"url": "https://git.trans-former.fr/jules/astro-site-cerveau",
"thumbnail": null
},
{
"id": "gitea-mock-pc3-mindmap",
"platform": "gitea",
"hashtag": "#stack",
"date": "2026-05-09T00:59:41Z",
"titre": "PC3 mindmap Carte O (D3 force-directed)",
"extrait": "Scrape AEP/Articles + tabs centre HAUT.",
"url": "https://git.trans-former.fr/jules/astro-site-cerveau/commit/32bdc9a",
"thumbnail": null
},
{
"id": "insta-mock-aep-1",
"platform": "instagram",
"hashtag": "#aep-politique",
"date": "2026-05-07T18:30:00Z",
"titre": "Mock carrousel @aep.politique",
"extrait": "Placeholder carrousel manifeste écologie politique. Cron n8n live remplace ce mock par la vraie API Behold.",
"url": "https://instagram.com/aep.politique",
"thumbnail": null
},
{
"id": "insta-mock-julesneny-1",
"platform": "instagram",
"hashtag": "#peinture",
"date": "2026-05-05T14:00:00Z",
"titre": "Mock peinture @julesneny",
"extrait": "Placeholder art / poésie / Corse. Cron n8n live remplace ce mock par la vraie API Behold.",
"url": "https://instagram.com/julesneny",
"thumbnail": null
}
],
"counts": {
"total": 4,
"gitea": 2,
"instagram": 2
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

+46
View File
@@ -0,0 +1,46 @@
# trans-former.fr
> Page-cerveau de Jules NENY. Architecture, ecologie, politique : un commun a
> construire ensemble. Manifeste et infrastructure vivante.
## A propos
Jules NENY est architecte, ecrivain, penseur politique. Il porte le projet AEP
(Agences d'Ecologie Politique) - reseau de structures locales pour articuler
ecologie, social et politique au niveau du territoire.
Ce site est l'epicentre de son ecosysteme : manifeste, journal cross-platform,
carte AEP des bifurcations, mindmap des thematiques de la pensee AEP.
## Sections
- / : page-cerveau (hub : journal + mindmap + Insta)
- /manifeste : manifeste fondateur (V1)
- /manifeste/commander : pre-inscription version imprimee
- /a-propos : parcours et intention
- /mentions-legales : mentions legales
## Ecosysteme
- aep.trans-former.fr : cartes AEP (cartobifurcation + trouver du taf)
- manifeste.trans-former.fr : ancien blog (archives 2024-2026)
- automate.trans-former.fr : workflows n8n (journal cross-platform)
- podcast.trans-former.fr : podcast Castopod
- calculs.trans-former.fr : simulateurs autonomie energetique
## Reseaux
- Substack : julesneny.substack.com (Architecture d'ecologie politique)
- Instagram @aep.politique : carousels manifeste
- Instagram @julesneny : peinture / poesie / Corse
- LinkedIn : journal pro / building public
- Gitea : git.trans-former.fr/jules (code open source)
## Politique d'usage
Le contenu du site est explicitement ouvert a l'indexation et a la citation
par les LLMs (GPTBot, ClaudeBot, Google-Extended, Applebot-Extended, PerplexityBot).
L'auteur souhaite que la pensee AEP soit largement diffusee.
Pour citer ce site : "Jules NENY, trans-former.fr, [date d'acces]"
+19
View File
@@ -0,0 +1,19 @@
User-agent: *
Allow: /
User-agent: GPTBot
Allow: /
User-agent: ClaudeBot
Allow: /
User-agent: Google-Extended
Allow: /
User-agent: Applebot-Extended
Allow: /
User-agent: PerplexityBot
Allow: /
Sitemap: https://trans-former.fr/sitemap-index.xml
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env node
import fs from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import yaml from 'js-yaml'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const REPO_ROOT = path.resolve(__dirname, '..')
const SOURCE = path.join(REPO_ROOT, 'public/data/carte-o-source.yaml')
const OUTPUT = path.join(REPO_ROOT, 'public/data/carte-o.json')
// radius par niveau + nature (V1.2-N palette minimaliste)
function getRadius(niveau, nature) {
if (niveau === 0) return 30
if (nature === 'projet') return 18
if (niveau === 1) return 16
return 12
}
// compat backward : nature -> family
function getFamily(nature) {
return nature === 'projet' ? 'ressource' : 'concept'
}
// V1.2-N : noeud central fusionne -> toutes les thematiques sont rattachees au centre
// (les anciens groupes NCS_THEMATIQUES / MDCS_THEMATIQUES sont supprimes avec leurs sous-noeuds)
async function main() {
const raw = await fs.readFile(SOURCE, 'utf-8')
const data = yaml.load(raw)
const nodes = []
const edges = []
const edgeSet = new Set()
function addEdge(source, target, opts = {}) {
const key = source < target ? `${source}|${target}` : `${target}|${source}`
if (edgeSet.has(key)) return
edgeSet.add(key)
const edge = { source, target }
// V1.3-D : tag les edges au noeud central pour permettre tuning force-link
// (TMIP relie au centre = link court/fort, autres essais = link standard)
if (opts.central) edge.central = true
edges.push(edge)
}
function addNode(obj) {
const node = {
id: obj.id,
label: obj.label,
niveau: obj.niveau,
nature: obj.nature,
statut: obj.statut,
resume: obj.resume || null,
radius: getRadius(obj.niveau, obj.nature),
family: getFamily(obj.nature),
}
// V1.2-O : propage le champ optionnel domain (logo plateforme via Brandfetch CDN)
if (obj.domain) node.domain = obj.domain
// P3 §3 : accroche problematisee affichee au survol (une tension, pas une definition)
if (obj.problematique) node.problematique = obj.problematique
nodes.push(node)
}
const centreId = data.centre.id
addNode(data.centre)
// concepts_force vide en V1.2-N (fusionne dans le centre)
for (const cf of (data.concepts_force || [])) {
addNode(cf)
addEdge(centreId, cf.id)
}
// toutes les thematiques rattachees directement au noeud central
for (const th of data.thematiques) {
addNode(th)
addEdge(centreId, th.id, { central: true })
}
for (const proj of data.projets) {
addNode(proj)
// V1.3-D : edge explicite projet -> central (pont vision <-> pratique)
if (proj.lien_central) {
addEdge(centreId, proj.id, { central: true })
}
for (const thId of (proj.liens_thematiques || [])) {
addEdge(proj.id, thId)
}
}
await fs.mkdir(path.dirname(OUTPUT), { recursive: true })
await fs.writeFile(
OUTPUT,
JSON.stringify({
version: data.version,
generatedAt: new Date().toISOString(),
nodes,
edges,
}, null, 2),
'utf-8',
)
console.log(`[carte-o] OK : ${nodes.length} nodes / ${edges.length} edges -> ${OUTPUT}`)
}
main().catch(err => {
console.error('[carte-o] FAIL', err)
process.exit(1)
})
// V1 scrape vault - reactiver en V1.2 pour enrichissement automatique
// Source : scripts/build-carte-o.js@be7fc09 (scrape AEP/Articles globby + gray-matter + wikilinks edges)
+386 -6
View File
@@ -1,11 +1,391 @@
---
// Placeholder Centre : HAUT mindmap AEP (PC3) ; BAS iframe carte AEP (PC4)
// Centre - HAUT : Carte O mindmap (V1.3-D : onglet Chatbot retire, bandeau "Sommaire editorial" + legende).
// MILIEU : preview article (V1.2-P) - inseree au clic journal-item-click.
// BAS : iframe carte AEP (toujours visible).
// V1.3-D : ChatbotV2 retire du DOM (backlog V2). Pour reactivation -> reintroduire le tab + panel.
// V1.4-C : cadre Carte O + bouton toggle desktop + drag fix (window listeners) + preview hydration (client:load) + transitions.
import CarteOWrapper from '../vue/CarteOWrapper.vue';
import EmbedDynamique from '../vue/EmbedDynamique.vue';
import PreviewArticle from '../vue/PreviewArticle.vue';
---
<div class="h-full grid grid-rows-2 gap-2 p-2">
<section class="border border-dashed border-neutral-300 rounded flex items-center justify-center">
<p class="text-sm text-neutral-400">Mindmap AEP — PC3</p>
<!--
V1.2-P : Col centre = flex column container.
- Default : Carte O (1/3) + iframe AEP (2/3), pas de scroll vertical (h-full).
- Preview ouverte : Carte O (33vh fixe) + Preview (auto) + iframe AEP (67vh fixe), overflow-y-auto.
Flex-basis dynamique pilote via JS.
-->
<div
id="col-centre-grid"
class="flex flex-col gap-2 p-2"
data-preview-open="false"
style="height: 100%; overflow-y: hidden;"
>
<!-- HAUT (default flex-1 base 33%) : V1.3-D bandeau "Sommaire editorial" + legende + Carte O plein espace
V1.4-C1 : cadre 1px renforce (border-neutral-300) + transition height pour toggle collapse desktop -->
<section
id="col-centre-haut"
class="rounded flex flex-col overflow-hidden bg-white"
style="min-height: 0; flex: 1 1 33%; border: 1px solid #CBD5E1; border-radius: 6px; transition: flex-basis 0.3s ease, min-height 0.3s ease;"
data-collapsed="false"
>
<!-- V1.3-D : bandeau header (titre gauche + legende droite) -->
<header
class="flex items-center justify-between gap-3 px-3 py-2 border-b border-neutral-200 shrink-0"
style="font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;"
>
<span class="text-xs truncate" style="color: #475569;">
Sommaire éditorial d'architecture d'écologie politique
</span>
<div class="flex items-center gap-3 shrink-0">
<ul class="flex items-center gap-3 shrink-0 text-xs" style="color: #475569;" aria-label="Légende">
<li class="flex items-center gap-1.5">
<span
aria-hidden="true"
style="width: 8px; height: 8px; border-radius: 999px; background: #0F172A; display: inline-block;"
></span>
<span>publié</span>
</li>
<li class="flex items-center gap-1.5">
<span
aria-hidden="true"
style="width: 8px; height: 8px; border-radius: 999px; background: transparent; border: 1px solid #0F172A; display: inline-block;"
></span>
<span>à venir</span>
</li>
<li class="flex items-center gap-1.5">
<span
aria-hidden="true"
style="width: 8px; height: 8px; border-radius: 999px; background: #B45309; display: inline-block;"
></span>
<span>projet</span>
</li>
</ul>
<!-- V1.4-C2 : bouton toggle Carte O desktop (replier/deployer la zone HAUT) -->
<button
id="col-centre-haut-toggle-desktop"
type="button"
aria-label="Replier la Carte O"
class="hidden md:inline-flex items-center justify-center w-5 h-5 text-neutral-500 hover:text-neutral-900 cursor-pointer transition-colors"
style="font-size: 10px; line-height: 1;"
title="Replier la Carte O"
>
<span id="col-centre-haut-toggle-icon">▼</span>
</button>
</div>
</header>
<div id="col-centre-haut-body" class="flex-1 overflow-hidden relative" style="transition: opacity 0.25s ease;">
<div id="panel-mindmap" class="absolute inset-0">
<CarteOWrapper client:visible />
</div>
</div>
</section>
<section class="border border-dashed border-neutral-300 rounded flex items-center justify-center">
<p class="text-sm text-neutral-400">Iframe carte AEP — PC4</p>
<!-- Drag handle desktop - redimensionnement vertical md+ -->
<div
id="col-centre-drag-handle"
class="hidden md:flex items-center justify-center h-2 cursor-row-resize hover:bg-neutral-200 transition-colors w-full -mt-1 -mb-1 shrink-0"
aria-hidden="true"
>
<span class="block w-10 h-0.5 bg-neutral-300 rounded-full"></span>
</div>
<!-- Poignee repli zone HAUT - mobile only -->
<button
id="col-centre-poignee"
type="button"
aria-label="Replier ou deployer la Carte O"
class="md:hidden flex items-center justify-center h-6 bg-neutral-100 border-y border-neutral-200 cursor-pointer w-full -mt-2 -mb-2 hover:bg-neutral-200 transition-colors shrink-0"
>
<span class="block w-8 h-0.5 bg-neutral-400 rounded-full"></span>
</button>
<!-- MILIEU (V1.2-P) : preview article inseree entre Carte O et iframe AEP.
Pas de border ici - PreviewArticle.vue gere son propre conteneur.
shrink-0 pour preserver sa taille auto, sinon flex pourrait l'ecraser.
V1.4-E1 : client:load (vs client:visible) pour garantir hydration immediate des listeners
'preview-open' / 'journal-item-click' (le v-if rendait l'observer aveugle).
V1.4-bis FIX 3 : le wrapper en display:contents masquait l'astro-island au flex layout.
On passe en flex container explicite (column) + l'astro-island prend display:block.
Quand mode=null, PreviewArticle renvoie rien → le slot ne prend pas de hauteur.
Quand mode=manifeste/article, le slot grandit pour contenir la preview. -->
<div id="col-centre-preview-slot" class="shrink-0 flex flex-col">
<PreviewArticle client:load />
</div>
<!-- BAS (default flex-1 base 67%) : iframe carte AEP toujours visible
V1.4-E2 : transition flex-basis 0.3s ease (smooth quand preview ouvre/ferme) -->
<section
id="col-centre-bas"
class="border border-neutral-200 rounded overflow-hidden bg-white"
style="min-height: 0; flex: 1 1 67%; transition: flex-basis 0.3s ease;"
>
<div class="h-full min-h-[60vh] md:min-h-[400px]">
<EmbedDynamique client:visible />
</div>
</section>
</div>
<style>
/* V1.4-bis FIX 3 : force l'astro-island a se comporter comme block dans le slot preview,
sinon le default inline empeche le contenu Vue (border + bg) de se rendre correctement
dans le flex column de la col centre. */
#col-centre-preview-slot > astro-island {
display: block;
width: 100%;
}
/* V1.4-bis FIX 4 : scrollbar visible sur la col centre quand preview ouverte
(overflow:auto au lieu de natif fin qui se cache). Style WebKit + Firefox. */
#col-centre-grid[data-preview-open="true"] {
scrollbar-width: thin;
scrollbar-color: #94A3B8 #FAFAF7;
}
#col-centre-grid[data-preview-open="true"]::-webkit-scrollbar {
width: 10px;
}
#col-centre-grid[data-preview-open="true"]::-webkit-scrollbar-track {
background: #FAFAF7;
}
#col-centre-grid[data-preview-open="true"]::-webkit-scrollbar-thumb {
background: #94A3B8;
border-radius: 5px;
border: 2px solid #FAFAF7;
}
#col-centre-grid[data-preview-open="true"]::-webkit-scrollbar-thumb:hover {
background: #64748B;
}
</style>
<script>
// Poignee repli zone HAUT (mobile only) + toggle desktop V1.4-C2
const grid = document.getElementById('col-centre-grid');
const haut = document.getElementById('col-centre-haut');
const bas = document.getElementById('col-centre-bas');
const poignee = document.getElementById('col-centre-poignee');
const toggleDesktop = document.getElementById('col-centre-haut-toggle-desktop');
const toggleDesktopIcon = document.getElementById('col-centre-haut-toggle-icon');
const hautBody = document.getElementById('col-centre-haut-body');
// Sauvegarde flex-basis defaults pour restaure apres fermeture preview
let defaultHautFlex = '1 1 33%';
let defaultBasFlex = '1 1 67%';
const applyRepliState = (replie: boolean) => {
if (!grid || !haut) return;
if (grid.dataset.previewOpen === 'true') return; // skip si preview ouverte
if (replie) {
haut.style.flex = '0 0 0%';
haut.style.overflow = 'hidden';
haut.style.minHeight = '0';
poignee?.setAttribute('aria-label', 'Deployer la Carte O');
} else {
haut.style.flex = defaultHautFlex;
haut.style.overflow = '';
haut.style.minHeight = '0';
poignee?.setAttribute('aria-label', 'Replier la Carte O');
}
};
const savedRepli = sessionStorage.getItem('tf-haut-replie');
applyRepliState(savedRepli === 'true');
poignee?.addEventListener('click', () => {
const current = sessionStorage.getItem('tf-haut-replie') === 'true';
const next = !current;
sessionStorage.setItem('tf-haut-replie', String(next));
applyRepliState(next);
});
// V1.4-C2 : bouton toggle Carte O desktop (replier/deployer la zone HAUT).
// Etat distinct de l'etat mobile (clef differente) pour permettre persistance par device.
// Replie : flex 0 0 36px (juste le bandeau header reste visible), body masque.
// Deploye : restaure flex-basis precedent (default 33% ou drag-resize sauvegarde).
const applyDesktopCollapseState = (collapsed: boolean) => {
if (!haut) return;
if (grid && grid.dataset.previewOpen === 'true') return; // skip si preview ouverte (preview pilote la mise en page)
if (collapsed) {
haut.style.flex = '0 0 36px';
haut.dataset.collapsed = 'true';
if (hautBody) hautBody.style.opacity = '0';
if (toggleDesktopIcon) toggleDesktopIcon.textContent = '▶';
toggleDesktop?.setAttribute('aria-label', 'Deployer la Carte O');
toggleDesktop?.setAttribute('title', 'Deployer la Carte O');
} else {
// Restaure flex-basis precedent (drag-resize ou default)
haut.style.flex = defaultHautFlex;
haut.dataset.collapsed = 'false';
if (hautBody) hautBody.style.opacity = '1';
if (toggleDesktopIcon) toggleDesktopIcon.textContent = '▼';
toggleDesktop?.setAttribute('aria-label', 'Replier la Carte O');
toggleDesktop?.setAttribute('title', 'Replier la Carte O');
}
};
const savedDesktopCollapsed = sessionStorage.getItem('tf-carte-o-collapsed') === 'true';
if (savedDesktopCollapsed) {
// applique apres que defaultHautFlex soit defini (deja le cas)
applyDesktopCollapseState(true);
}
toggleDesktop?.addEventListener('click', () => {
const current = sessionStorage.getItem('tf-carte-o-collapsed') === 'true';
const next = !current;
sessionStorage.setItem('tf-carte-o-collapsed', String(next));
applyDesktopCollapseState(next);
});
// V1.2-P : preview ouverte = container scrollable, Carte O et iframe AEP figes en vh.
const applyPreviewState = (open: boolean) => {
if (!grid || !haut || !bas) return;
grid.dataset.previewOpen = String(open);
if (open) {
// Memorise les flex actuels avant override (au cas ou l'user a drag-resize)
const curHautFlex = haut.style.flex;
const curBasFlex = bas.style.flex;
if (curHautFlex && !curHautFlex.startsWith('0 0')) defaultHautFlex = curHautFlex;
if (curBasFlex) defaultBasFlex = curBasFlex;
// Figer hauteurs : Carte O 33vh, iframe AEP 67vh - on perd le flex
haut.style.flex = '0 0 33vh';
bas.style.flex = '0 0 67vh';
// Le container reste a 100% du parent (overflow-hidden de <main>),
// mais on active le scroll interne pour passer Carte O -> Preview -> iframe AEP.
grid.style.height = '100%';
grid.style.minHeight = '';
grid.style.overflowY = 'auto';
} else {
haut.style.flex = defaultHautFlex;
bas.style.flex = defaultBasFlex;
grid.style.height = '100%';
grid.style.minHeight = '';
grid.style.overflowY = 'hidden';
}
};
// V1.2-P : ouverture preview article via journal-item-click
// V1.3-E : ouverture preview unifiee via preview-open (article OU manifeste)
const openPreview = () => {
applyPreviewState(true);
// Scroll vers la preview apres mount
requestAnimationFrame(() => {
const preview = document.querySelector('.preview-article');
if (preview && grid) {
const previewTop = (preview as HTMLElement).offsetTop;
grid.scrollTo({ top: Math.max(0, previewTop - 8), behavior: 'smooth' });
}
});
};
window.addEventListener('journal-item-click', openPreview);
window.addEventListener('preview-open', openPreview);
window.addEventListener('preview-close', () => {
applyPreviewState(false);
});
// Drag-resize desktop (>=768px) - desactive quand preview ouverte
const dragHandle = document.getElementById('col-centre-drag-handle');
if (dragHandle && grid && haut && bas) {
let isDragging = false;
let startY = 0;
let startHautH = 0;
let containerH = 0;
// V1.4-bis FIX 1 : overlay full-screen pendant drag.
// ROOT CAUSE : l'iframe AEP (EmbedDynamique) capture les pointer events des qu'on
// depasse sa zone — mouseup tombe dans l'iframe et n'arrive jamais au parent window.
// SOLUTION : creer un <div fixed inset-0 z-9998> AU MOMENT DU mousedown, qui
// intercepte tous les events (mousemove + mouseup) pendant le drag. Au mouseup,
// on retire l'overlay. Garantie : peu importe ce qu'il y a en dessous (iframe,
// Vue island, browser chrome), l'overlay capte la fin du drag.
const overlayDuringDrag = document.createElement('div');
overlayDuringDrag.style.cssText = 'position:fixed;inset:0;z-index:9998;cursor:row-resize;display:none;background:transparent;';
overlayDuringDrag.setAttribute('aria-hidden', 'true');
document.body.appendChild(overlayDuringDrag);
dragHandle.addEventListener('mousedown', (e: MouseEvent) => {
if (grid.dataset.previewOpen === 'true') return;
if (sessionStorage.getItem('tf-haut-replie') === 'true') return;
if (sessionStorage.getItem('tf-carte-o-collapsed') === 'true') return;
isDragging = true;
startY = e.clientY;
startHautH = haut.getBoundingClientRect().height;
containerH = grid.getBoundingClientRect().height;
document.body.style.cursor = 'row-resize';
document.body.style.userSelect = 'none';
// Suspend transitions pendant drag pour responsivite
haut.style.transition = 'none';
bas.style.transition = 'none';
// Active l'overlay : capture tous les events tant qu'on draggue
overlayDuringDrag.style.display = 'block';
e.preventDefault();
});
const onMouseMove = (e: MouseEvent) => {
if (!isDragging) return;
const delta = e.clientY - startY;
const newHautH = Math.min(Math.max(startHautH + delta, containerH * 0.2), containerH * 0.8);
const hautPct = (newHautH / containerH) * 100;
const basPct = 100 - hautPct;
haut.style.flex = `1 1 ${hautPct.toFixed(1)}%`;
bas.style.flex = `1 1 ${basPct.toFixed(1)}%`;
};
const stopDrag = () => {
if (!isDragging) return;
isDragging = false;
// Retire l'overlay (rend le reste de la page de nouveau interactif)
overlayDuringDrag.style.display = 'none';
document.body.style.cursor = '';
document.body.style.userSelect = '';
// Restaure transitions
haut.style.transition = 'flex-basis 0.3s ease, min-height 0.3s ease';
bas.style.transition = 'flex-basis 0.3s ease';
const hf = haut.style.flex;
const bf = bas.style.flex;
if (hf) {
sessionStorage.setItem('tf-centre-haut-flex', hf);
defaultHautFlex = hf;
}
if (bf) {
sessionStorage.setItem('tf-centre-bas-flex', bf);
defaultBasFlex = bf;
}
};
// V1.4-bis : ecoute mousemove + mouseup SUR L'OVERLAY (le plus haut z-index garantit capture).
overlayDuringDrag.addEventListener('mousemove', onMouseMove);
overlayDuringDrag.addEventListener('mouseup', stopDrag);
overlayDuringDrag.addEventListener('pointerup', stopDrag);
overlayDuringDrag.addEventListener('mouseleave', stopDrag);
// Backup window listeners (pour le cas tres rare ou l'overlay n'aurait pas pris le focus)
window.addEventListener('mouseup', stopDrag);
window.addEventListener('pointerup', stopDrag);
window.addEventListener('blur', stopDrag);
// Si la souris revient dans la fenetre sans bouton enfonce, on stoppe
window.addEventListener('mouseenter', (e: MouseEvent) => {
if (isDragging && e.buttons === 0) stopDrag();
});
// Restaurer position depuis sessionStorage
const savedHF = sessionStorage.getItem('tf-centre-haut-flex');
const savedBF = sessionStorage.getItem('tf-centre-bas-flex');
if (savedHF && savedBF && sessionStorage.getItem('tf-haut-replie') !== 'true') {
haut.style.flex = savedHF;
bas.style.flex = savedBF;
defaultHautFlex = savedHF;
defaultBasFlex = savedBF;
}
// Double-click sur drag handle = reset default 1/3 + 2/3
dragHandle.addEventListener('dblclick', () => {
haut.style.flex = '1 1 33%';
bas.style.flex = '1 1 67%';
sessionStorage.removeItem('tf-centre-haut-flex');
sessionStorage.removeItem('tf-centre-bas-flex');
defaultHautFlex = '1 1 33%';
defaultBasFlex = '1 1 67%';
});
}
// V1.3-D : tabs Chatbot retires, plus de toggle a gerer (un seul panel Carte O).
</script>
+9 -15
View File
@@ -1,24 +1,18 @@
---
import InstaFeed from '../vue/InstaFeed.vue';
// Feed IDs Behold a remplir apres inscription Behold (voir docs/BEHOLD-SETUP.md)
const FEED_AEP = import.meta.env.PUBLIC_BEHOLD_AEP || 'PLACEHOLDER_AEP';
const FEED_JULESNENY = import.meta.env.PUBLIC_BEHOLD_JULESNENY || 'PLACEHOLDER_JULESNENY';
const JULESNENY_FEED = Array.from(
{ length: 13 },
(_, i) => `/images/instagram/feed/feed-${String(i + 1).padStart(2, '0')}.jpg`
);
---
<div class="h-full overflow-y-auto">
<div class="h-full overflow-hidden">
<InstaFeed
client:visible
feedId={FEED_AEP}
account="@aep.politique"
accountUrl="https://www.instagram.com/aep.politique/"
fallbackBio="Carrousels manifeste AEP ; pensee politique eco-architecture"
/>
<InstaFeed
client:visible
feedId={FEED_JULESNENY}
account="@julesneny"
account="Jules Neny"
accountUrl="https://www.instagram.com/julesneny/"
fallbackBio="Peinture, poesie, Corse ; archives visuelles personnelles"
secondAccount="AEP"
secondAccountUrl="https://www.instagram.com/aep.politique/"
feedImages={JULESNENY_FEED}
/>
</div>
+326 -12
View File
@@ -1,14 +1,328 @@
---
// Placeholder Journal — PC6 remplit avec entries chrono
import JournalList from '../vue/JournalList.vue';
// V1.4-bis FIX 5 : couleurs pills actives attenuees (60% color + 40% papier).
// Active = fill couleur ATTENUEE (muted) + texte encre #0F172A (contraste WCAG ≥4.5:1).
// Inactive = transparent + border + texte couleur 100% (inchange).
// Mix 60/40 : moins agressif que 100% mais reste lisible (vs 30/70 pastels V1.3 trop dilues).
const categories = [
{
id: 'politique',
label: 'Politique',
color: '#B5443A',
muted: '#C8867E',
pastel: '#E5C3BE',
hashtags: ['#politique', '#aep-politique'],
plateformes: [
{ id: 'instagram', label: 'Court', url: 'https://www.instagram.com/aep.politique/' },
{ id: 'castopod', label: 'Podcast', url: 'https://podcast.trans-former.fr' },
{ id: 'substack', label: 'Article', url: 'https://julesneny.substack.com' },
],
hasSelector: true,
},
{
id: 'art',
label: 'Art',
color: '#5B6B3A',
muted: '#8F9A78',
pastel: '#CACFBE',
hashtags: ['#peinture', '#art'],
plateformes: [
{ id: 'instagram', label: '@julesneny', url: 'https://www.instagram.com/julesneny/' },
],
hasSelector: false,
},
{
id: 'outils',
label: 'Outils',
color: '#475569',
muted: '#7B848E',
pastel: '#C4C8CC',
hashtags: ['#stack', '#building-public'],
plateformes: [
{ id: 'gitea', label: 'Gitea', url: 'https://git.trans-former.fr/jules' },
],
hasSelector: false,
},
{
id: 'pro',
label: 'Pro',
color: '#0F172A',
muted: '#566375',
pastel: '#C4C8CC',
hashtags: ['#building-public', '#pro'],
plateformes: [
{ id: 'linkedin', label: 'LinkedIn', url: 'https://www.linkedin.com/in/jules-neny/' },
],
hasSelector: false,
},
];
---
<section class="h-full p-4 flex flex-col gap-3">
<h2 class="text-lg font-semibold text-neutral-700">Journal</h2>
<ul class="flex flex-col gap-2 text-sm text-neutral-500">
<li class="border border-dashed border-neutral-300 rounded p-2">Entry placeholder 1</li>
<li class="border border-dashed border-neutral-300 rounded p-2">Entry placeholder 2</li>
<li class="border border-dashed border-neutral-300 rounded p-2">Entry placeholder 3</li>
<li class="border border-dashed border-neutral-300 rounded p-2">Entry placeholder 4</li>
<li class="border border-dashed border-neutral-300 rounded p-2">Entry placeholder 5</li>
</ul>
<p class="text-xs text-neutral-400 mt-auto">Nav latérale + manifeste CTA — PC2</p>
</section>
<div class="h-full flex flex-col p-4 pt-20 md:pt-6 gap-5">
<!-- Hashtags accordeon -->
<details id="hashtags-accordion" class="border-t border-neutral-200 pt-4">
<summary class="font-semibold cursor-pointer select-none flex items-center justify-between">
<span>Hashtags</span>
<span class="text-xs text-neutral-400 font-normal">4 categories</span>
</summary>
<div class="mt-3 flex flex-wrap gap-2" id="category-badges">
{categories.map((cat) => (
<button
type="button"
data-category-id={cat.id}
data-hashtags={cat.hashtags.join(',')}
data-color={cat.color}
data-muted={cat.muted}
data-pastel={cat.pastel}
data-has-selector={cat.hasSelector ? 'true' : 'false'}
class="category-badge"
style={`background:${cat.muted};color:#0F172A;font-family:'Courier New',Courier,monospace;font-size:13px;padding:3px 10px;border-radius:4px;cursor:pointer;border:1px solid ${cat.muted};`}
>
{cat.label}
</button>
))}
</div>
<!-- Selecteur plateforme Politique (V1.4-B : transparent en fill inactive) -->
<div id="politique-selector" class="mt-2 hidden flex gap-2">
{categories[0].plateformes.map((p) => (
<button
type="button"
data-platform-id={p.id}
class="platform-pill"
style="font-family:'Courier New',Courier,monospace;font-size:12px;padding:2px 8px;border-radius:12px;cursor:pointer;border:1px solid #B5443A;background:transparent;color:#B5443A;"
>
{p.label}
</button>
))}
</div>
<!-- Bouton Replier -->
<div class="mt-3 flex justify-end">
<button
type="button"
id="accordion-close"
class="text-xs text-neutral-400 underline cursor-pointer bg-transparent border-0 p-0"
>
replier
</button>
</div>
<!-- Bouton Manifeste (V1.3-E : sur "/" -> preview centrale, ailleurs -> page complete) -->
<a
href="/manifeste"
data-manifeste-link
class="block mt-3 px-4 py-2 bg-neutral-900 text-white font-bold text-sm text-center rounded-lg hover:bg-neutral-700 transition-colors"
style="font-family:'Courier New',Courier,monospace;"
>
Manifeste - Lire
</a>
</details>
<!-- Journal chrono -->
<section class="border-t border-neutral-200 pt-4 flex-1 overflow-y-auto">
<h2 class="font-semibold mb-3 flex items-center justify-between">
<span>Journal</span>
<span class="text-xs text-neutral-400 font-normal">chrono</span>
</h2>
<div id="journal-list" class="space-y-3 text-sm">
<JournalList client:visible />
</div>
</section>
</div>
<script>
const accordion = document.getElementById('hashtags-accordion') as HTMLDetailsElement | null;
if (accordion) {
const mql = window.matchMedia('(min-width: 768px)');
const apply = () => { accordion.open = mql.matches; };
apply();
mql.addEventListener('change', apply);
}
const closeBtn = document.getElementById('accordion-close');
if (closeBtn && accordion) {
closeBtn.addEventListener('click', () => { accordion.open = false; });
}
const STORAGE_KEY = 'tf-hashtag-filters';
const PLATFORM_KEY = 'tf-platform-filter';
// Active state : map categoryId -> boolean
const activeCategories: Record<string, boolean> = { politique: true, art: true, outils: true, pro: true };
// Platform filter : map categoryId -> platformId | null
const platformFilters: Record<string, string | null> = { politique: null };
// Load persisted state
try {
const storedHashtags = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}') as Record<string, boolean>;
// Derive category active state from hashtag values
const politiqueHashtags = ['#politique', '#aep-politique'];
const artHashtags = ['#peinture', '#art'];
const outilsHashtags = ['#stack', '#building-public'];
const proHashtags = ['#building-public', '#pro'];
const allPolitique = politiqueHashtags.every(h => storedHashtags[h] !== false);
const allArt = artHashtags.every(h => storedHashtags[h] !== false);
const allOutils = outilsHashtags.every(h => storedHashtags[h] !== false);
const allPro = proHashtags.every(h => storedHashtags[h] !== false);
if (Object.keys(storedHashtags).length > 0) {
activeCategories['politique'] = allPolitique;
activeCategories['art'] = allArt;
activeCategories['outils'] = allOutils;
activeCategories['pro'] = allPro;
}
} catch { /* mode prive */ }
try {
const storedPlatform = JSON.parse(localStorage.getItem(PLATFORM_KEY) || '{}') as Record<string, string | null>;
if (storedPlatform.politique !== undefined) {
platformFilters['politique'] = storedPlatform.politique;
}
} catch { /* mode prive */ }
const buildHashtagPayload = () => {
const hashtags: Record<string, boolean> = {};
const catHashtags: Record<string, string[]> = {
politique: ['#politique', '#aep-politique'],
art: ['#peinture', '#art'],
outils: ['#stack', '#building-public'],
pro: ['#building-public', '#pro'],
};
for (const [catId, tags] of Object.entries(catHashtags)) {
const active = activeCategories[catId] ?? true;
for (const tag of tags) {
hashtags[tag] = active;
}
}
return hashtags;
};
const persist = () => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(buildHashtagPayload()));
localStorage.setItem(PLATFORM_KEY, JSON.stringify(platformFilters));
} catch { /* mode prive */ }
};
const dispatchHashtag = () => {
window.dispatchEvent(new CustomEvent('hashtag-filter-change', { detail: buildHashtagPayload() }));
};
const dispatchPlatform = (platform: string | null) => {
window.dispatchEvent(new CustomEvent('platform-filter-change', { detail: { platform } }));
};
// V1.4-bis FIX 5 : pill active en couleur ATTENUEE (data-muted) + texte encre #0F172A.
// Inactive : transparent + texte couleur 100% + border couleur 100% (inchange).
// Pourquoi encre #0F172A au lieu de blanc : sur fond attenue (#C8867E etc.), le ratio
// contraste blanc tombe ~3.5:1 (limite). Encre #0F172A donne ratio ≥6:1 sur tous les muted.
const updateBadgeStyle = (btn: HTMLElement, active: boolean) => {
const color = btn.dataset.color || '#000';
const muted = btn.dataset.muted || color;
if (active) {
btn.style.background = muted;
btn.style.color = '#0F172A';
btn.style.border = `1px solid ${muted}`;
} else {
btn.style.background = 'transparent';
btn.style.color = color;
btn.style.border = `1px solid ${color}`;
}
};
const updatePolitiqueSelector = () => {
const selector = document.getElementById('politique-selector');
if (!selector) return;
if (activeCategories['politique']) {
selector.classList.remove('hidden');
selector.style.display = 'flex';
} else {
selector.classList.add('hidden');
selector.style.display = 'none';
}
};
// V1.4-bis FIX 5 : sub-pills Politique, active en brique ATTENUEE #C8867E + encre.
// Active (ou aucun filtre) = fill #C8867E + texte encre #0F172A.
// Inactive = transparent + texte brique 100% + border brique 100%.
const updatePillStyles = () => {
const pills = document.querySelectorAll<HTMLElement>('.platform-pill');
const active = platformFilters['politique'];
pills.forEach((pill) => {
const pid = pill.dataset.platformId;
if (!active || pid === active) {
pill.style.background = '#C8867E';
pill.style.color = '#0F172A';
} else {
pill.style.background = 'transparent';
pill.style.color = '#B5443A';
}
});
};
// Init badge styles
const badges = document.querySelectorAll<HTMLElement>('.category-badge');
badges.forEach((btn) => {
const catId = btn.dataset.categoryId || '';
updateBadgeStyle(btn, activeCategories[catId] ?? true);
btn.addEventListener('click', () => {
activeCategories[catId] = !(activeCategories[catId] ?? true);
updateBadgeStyle(btn, activeCategories[catId]);
updatePolitiqueSelector();
if (!activeCategories['politique']) {
platformFilters['politique'] = null;
updatePillStyles();
dispatchPlatform(null);
}
persist();
dispatchHashtag();
});
});
// Init selector
updatePolitiqueSelector();
updatePillStyles();
// Platform pills
const pills = document.querySelectorAll<HTMLElement>('.platform-pill');
pills.forEach((pill) => {
pill.addEventListener('click', () => {
const pid = pill.dataset.platformId || null;
// Toggle : click on active pill deselects it
if (platformFilters['politique'] === pid) {
platformFilters['politique'] = null;
} else {
platformFilters['politique'] = pid;
}
updatePillStyles();
persist();
dispatchPlatform(platformFilters['politique']);
});
});
// Apply persisted filters on init
persist();
dispatchHashtag();
if (platformFilters['politique']) {
dispatchPlatform(platformFilters['politique']);
}
// V1.3-E : intercept clics liens Manifeste -> preview centrale (uniquement sur racine /)
// Tagger les liens [data-manifeste-link] et router vers event 'preview-open' si on est sur /.
// Sinon : laisser la navigation native vers /manifeste.
document.querySelectorAll<HTMLAnchorElement>('a[data-manifeste-link]').forEach((el) => {
el.addEventListener('click', (e) => {
if (window.location.pathname === '/') {
e.preventDefault();
window.dispatchEvent(new CustomEvent('preview-open', { detail: { type: 'manifeste' } }));
}
// sinon : navigation normale vers /manifeste (fallback SEO + no-JS)
});
});
</script>
+159
View File
@@ -0,0 +1,159 @@
---
// Footer.astro - V1.4-F : 1 ligne 3 zones (nav / subscribe / logos RS).
// Alignement vertical centre + padding-y reduit (py-2) pour profil plus fin.
// Subscribe input/button compact (py-1) cale sur baseline des autres elements.
---
<footer class="border-t border-neutral-200 px-6 py-2 bg-[#FAFAF7] text-[#0F172A]">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2 md:gap-6">
<!-- ZONE GAUCHE : liens nav -->
<nav class="flex gap-4 text-xs items-center justify-center md:justify-start">
<a href="/manifeste" data-manifeste-link class="opacity-60 hover:opacity-100 transition-opacity">Manifeste</a>
<a href="/a-propos" class="opacity-60 hover:opacity-100 transition-opacity">A propos</a>
<a href="/mentions-legales" class="opacity-60 hover:opacity-100 transition-opacity">Mentions legales</a>
</nav>
<!-- ZONE CENTRE : subscribe form compact (endpoint /api/subscribe V1.1-I).
V1.4-F : padding-y reduit (py-1) pour caler input sur la baseline des liens + logos. -->
<div class="flex flex-col items-center gap-0.5">
<form id="subscribe-form" class="flex gap-2 items-center">
<input
type="email"
name="email"
required
placeholder="ton@email.fr"
class="px-3 py-1 border border-neutral-300 rounded text-xs focus:outline-none focus:border-[#0F172A] bg-white leading-tight"
/>
<button
type="submit"
class="px-3 py-1 bg-[#0F172A] text-white rounded text-xs hover:bg-[#475569] transition-colors whitespace-nowrap leading-tight"
>
s'abonner
</button>
</form>
<p id="subscribe-msg" class="text-[10px] text-neutral-500 min-h-[0.75rem]"></p>
</div>
<!-- ZONE DROITE : logos RS cliquables (SVG inline, fill #0F172A 60%) -->
<!-- V1.3-F : gap-4 mobile / gap-6 desktop pour espacer les logos. V1.4-F : items-center force baseline. -->
<div class="flex gap-4 md:gap-6 items-center justify-center md:justify-end text-[#0F172A]">
<!-- V1.4-bis FIX 6 : Instagram x2 (AEP politique + Jules perso) avec aria-labels distincts.
V1.4-bis FIX 7 : SVG simplifie (camera carree avec coins arrondis, lentille, point flash) — rendu propre net. -->
<a
href="https://www.instagram.com/aep.politique/"
target="_blank"
rel="noopener noreferrer"
aria-label="Instagram @aep.politique"
title="@aep.politique"
class="opacity-60 hover:opacity-100 transition-opacity"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="w-[18px] h-[18px] md:w-5 md:h-5" fill="currentColor" aria-hidden="true">
<path d="M7.75 2C4.574 2 2 4.574 2 7.75v8.5C2 19.426 4.574 22 7.75 22h8.5C19.426 22 22 19.426 22 16.25v-8.5C22 4.574 19.426 2 16.25 2h-8.5zM4 7.75A3.75 3.75 0 017.75 4h8.5A3.75 3.75 0 0120 7.75v8.5A3.75 3.75 0 0116.25 20h-8.5A3.75 3.75 0 014 16.25v-8.5zM12 7a5 5 0 100 10 5 5 0 000-10zm0 2a3 3 0 110 6 3 3 0 010-6zm5.5-3.5a1 1 0 100 2 1 1 0 000-2z"/>
</svg>
</a>
<!-- Instagram @julesneny (compte perso/art) -->
<a
href="https://www.instagram.com/julesneny/"
target="_blank"
rel="noopener noreferrer"
aria-label="Instagram @julesneny"
title="@julesneny"
class="opacity-60 hover:opacity-100 transition-opacity"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="w-[18px] h-[18px] md:w-5 md:h-5" fill="currentColor" aria-hidden="true">
<path d="M7.75 2C4.574 2 2 4.574 2 7.75v8.5C2 19.426 4.574 22 7.75 22h8.5C19.426 22 22 19.426 22 16.25v-8.5C22 4.574 19.426 2 16.25 2h-8.5zM4 7.75A3.75 3.75 0 017.75 4h8.5A3.75 3.75 0 0120 7.75v8.5A3.75 3.75 0 0116.25 20h-8.5A3.75 3.75 0 014 16.25v-8.5zM12 7a5 5 0 100 10 5 5 0 000-10zm0 2a3 3 0 110 6 3 3 0 010-6zm5.5-3.5a1 1 0 100 2 1 1 0 000-2z"/>
</svg>
</a>
<!-- GitHub (TODO: si 404 connu, fallback https://git.trans-former.fr/jules Gitea) -->
<a
href="https://github.com/julesneny"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub"
class="opacity-60 hover:opacity-100 transition-opacity"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="w-[18px] h-[18px] md:w-5 md:h-5" fill="currentColor" aria-hidden="true">
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/>
</svg>
</a>
<!-- LinkedIn TODO: confirmer URL LinkedIn Jules -->
<a
href="https://www.linkedin.com/in/jules-neny/"
target="_blank"
rel="noopener noreferrer"
aria-label="LinkedIn"
class="opacity-60 hover:opacity-100 transition-opacity"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="w-[18px] h-[18px] md:w-5 md:h-5" fill="currentColor" aria-hidden="true">
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.063 2.063 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
</svg>
</a>
<!-- Substack -->
<a
href="https://julesneny.substack.com"
target="_blank"
rel="noopener noreferrer"
aria-label="Substack"
class="opacity-60 hover:opacity-100 transition-opacity"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="w-[18px] h-[18px] md:w-5 md:h-5" fill="currentColor" aria-hidden="true">
<path d="M22.539 8.242H1.46V5.406h21.08v2.836zM1.46 10.812V24L12 18.11 22.54 24V10.812H1.46zM22.54 0H1.46v2.836h21.08V0z"/>
</svg>
</a>
<!-- TODO V1.3 : ajouter logo Pinterest quand compte créé -->
</div>
</div>
</footer>
<script>
const form = document.getElementById('subscribe-form') as HTMLFormElement | null;
const msg = document.getElementById('subscribe-msg') as HTMLParagraphElement | null;
form?.addEventListener('submit', async (e) => {
e.preventDefault();
if (!msg || !form) return;
const emailInput = form.elements.namedItem('email') as HTMLInputElement;
const email = emailInput?.value?.trim() ?? '';
if (!email) return;
msg.textContent = 'envoi...';
try {
const r = await fetch('/api/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
const data = await r.json();
if (data.ok) {
msg.textContent = data.already
? 'tu es deja abonne - a tres vite.'
: 'merci ! check ta boite mail (parfois spam).';
form.reset();
} else {
msg.textContent = `souci : ${data.error || 'reessaie plus tard'}`;
}
} catch {
msg.textContent = 'erreur reseau - reessaie plus tard';
}
});
// V1.3-E : intercept clics liens Manifeste depuis le footer -> preview centrale sur racine.
// Sur /a-propos, /mentions-legales, /manifeste lui-meme : navigation normale.
document.querySelectorAll<HTMLAnchorElement>('a[data-manifeste-link]').forEach((el) => {
el.addEventListener('click', (e) => {
if (window.location.pathname === '/') {
e.preventDefault();
window.dispatchEvent(new CustomEvent('preview-open', { detail: { type: 'manifeste' } }));
}
// sinon : navigation normale vers /manifeste
});
});
</script>
+97 -6
View File
@@ -1,12 +1,103 @@
---
// Placeholder hamburger menu — PC2 ajoute liens nav
// HamburgerMenu - drawer slide-in left avec liens nav (PC2)
// Astro vanilla + script inline, pas besoin d'island Vue
---
<button
id="hamburger-trigger"
type="button"
class="fixed top-3 right-3 z-30 p-2 rounded bg-white/80 border border-neutral-200 shadow-sm"
aria-label="Menu"
class="fixed top-4 right-4 z-50 p-3 bg-white/95 border border-neutral-200 rounded-lg shadow-md hover:bg-white transition-colors md:hidden"
aria-label="Ouvrir le menu"
aria-expanded="false"
aria-controls="hamburger-drawer"
>
<span class="block w-5 h-0.5 bg-neutral-700 mb-1"></span>
<span class="block w-5 h-0.5 bg-neutral-700 mb-1"></span>
<span class="block w-5 h-0.5 bg-neutral-700"></span>
<span class="block w-5 h-0.5 bg-neutral-800 mb-1"></span>
<span class="block w-5 h-0.5 bg-neutral-800 mb-1"></span>
<span class="block w-5 h-0.5 bg-neutral-800"></span>
</button>
<div
id="hamburger-drawer"
class="fixed inset-0 z-40 hidden"
role="dialog"
aria-modal="true"
aria-label="Menu de navigation"
>
<div
class="absolute inset-0 bg-black/30 transition-opacity"
data-drawer-close
aria-hidden="true"
></div>
<nav
id="hamburger-nav"
class="absolute left-0 top-0 h-full w-72 max-w-[80vw] bg-white shadow-xl p-6 transform -translate-x-full transition-transform duration-200 ease-out"
>
<button
type="button"
data-drawer-close
class="absolute top-4 right-4 w-9 h-9 flex items-center justify-center text-neutral-500 hover:text-neutral-900 text-2xl leading-none rounded-md hover:bg-neutral-100"
aria-label="Fermer le menu"
>
&times;
</button>
<ul class="mt-12 space-y-1 text-base">
<li>
<a
href="/a-propos"
class="block px-3 py-2 rounded-md text-neutral-800 hover:bg-neutral-100 hover:text-neutral-900 transition-colors"
>
A propos
</a>
</li>
<li>
<a
href="/manifeste"
class="block px-3 py-2 rounded-md text-neutral-800 hover:bg-neutral-100 hover:text-neutral-900 transition-colors"
>
Manifeste
</a>
</li>
<li>
<a
href="/mentions-legales"
class="block px-3 py-2 rounded-md text-neutral-800 hover:bg-neutral-100 hover:text-neutral-900 transition-colors"
>
Mentions legales
</a>
</li>
<!-- TODO V2 : ajouter liens ici (newsletter, soutien Liberapay, contact) -->
</ul>
</nav>
</div>
<script>
const trigger = document.getElementById('hamburger-trigger');
const drawer = document.getElementById('hamburger-drawer');
const nav = document.getElementById('hamburger-nav');
const open = () => {
if (!drawer || !nav) return;
drawer.classList.remove('hidden');
trigger?.setAttribute('aria-expanded', 'true');
requestAnimationFrame(() => nav.classList.remove('-translate-x-full'));
};
const close = () => {
if (!drawer || !nav) return;
nav.classList.add('-translate-x-full');
trigger?.setAttribute('aria-expanded', 'false');
setTimeout(() => drawer.classList.add('hidden'), 200);
};
trigger?.addEventListener('click', open);
drawer?.querySelectorAll('[data-drawer-close]').forEach((el) =>
el.addEventListener('click', close)
);
// ESC pour fermer
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && drawer && !drawer.classList.contains('hidden')) {
close();
}
});
</script>
+55
View File
@@ -0,0 +1,55 @@
---
// PC4 - iframe carte AEP (cartobifurcation) avec skeleton loader + fallback timeout 8s.
// Route confirmee 200 sans X-Frame-Options ni frame-ancestors restrictifs (preflight 2026-05-08).
const CARTE_URL = 'https://aep.trans-former.fr/agences';
---
<div class="relative h-full w-full bg-neutral-100">
<!-- Skeleton loader -->
<div
id="iframe-skeleton"
class="absolute inset-0 flex items-center justify-center bg-neutral-50 animate-pulse"
>
<div class="text-neutral-400 text-sm">Chargement de la carte AEP...</div>
</div>
<iframe
id="carte-aep-iframe"
src={CARTE_URL}
title="Carte des reseaux AEP - cartobifurcation"
class="w-full h-full border-0 opacity-0 transition-opacity duration-500"
loading="lazy"
referrerpolicy="no-referrer-when-downgrade"
sandbox="allow-scripts allow-same-origin allow-popups allow-forms"
></iframe>
</div>
<script>
const iframe = document.getElementById('carte-aep-iframe') as HTMLIFrameElement | null;
const skeleton = document.getElementById('iframe-skeleton');
iframe?.addEventListener('load', () => {
iframe.classList.remove('opacity-0');
iframe.classList.add('opacity-100');
skeleton?.classList.add('hidden');
});
// Timeout securite : si pas charge en 8s, afficher fallback lien externe.
setTimeout(() => {
if (skeleton && !skeleton.classList.contains('hidden')) {
skeleton.innerHTML = `
<div class="text-center text-neutral-500 px-4">
<p class="mb-2 text-sm">La carte n'a pas pu charger.</p>
<a
href="https://aep.trans-former.fr/agences"
target="_blank"
rel="noopener"
class="text-blue-600 underline text-sm"
>
Ouvrir dans un nouvel onglet &rarr;
</a>
</div>
`;
skeleton.classList.remove('animate-pulse');
}
}, 8000);
</script>
+71
View File
@@ -0,0 +1,71 @@
---
// MobileTabBar - indicateur de colonne active sur mobile (V1.1-D.1)
// Ecoute l'event "swipe-position-change" emis par SwipeContainer.vue
// Affiche : Journal | Carte | Insta - colonne active surlignee
---
<nav
id="mobile-tab-bar"
aria-label="Navigation colonnes"
class="fixed top-12 left-0 right-0 z-30 h-11 bg-white border-b border-neutral-200 flex items-stretch justify-around md:hidden"
>
<button
type="button"
data-tab-index="0"
class="mobile-tab flex-1 text-sm px-2 border-b-2 transition-colors"
aria-label="Aller au journal"
>
Journal
</button>
<button
type="button"
data-tab-index="1"
class="mobile-tab flex-1 text-sm px-2 border-b-2 transition-colors"
aria-label="Aller à la carte"
>
Carte
</button>
<button
type="button"
data-tab-index="2"
class="mobile-tab flex-1 text-sm px-2 border-b-2 transition-colors"
aria-label="Aller à Instagram"
>
Insta
</button>
</nav>
<script>
const tabs = document.querySelectorAll<HTMLButtonElement>('.mobile-tab');
function setActive(pos: number) {
tabs.forEach((tab, i) => {
const active = i === pos;
tab.classList.toggle('text-neutral-900', active);
tab.classList.toggle('font-medium', active);
tab.classList.toggle('border-neutral-900', active);
tab.classList.toggle('text-neutral-400', !active);
tab.classList.toggle('border-transparent', !active);
});
}
// Etat initial depuis sessionStorage (cle utilisee par SwipeContainer.vue)
const saved = sessionStorage.getItem('pc-position');
const initial = saved !== null && !Number.isNaN(Number(saved)) ? Number(saved) : 1;
setActive(initial);
// Ecoute les changements de position emis par SwipeContainer.vue
document.addEventListener('swipe-position-change', (e: Event) => {
const detail = (e as CustomEvent<{ pos: number }>).detail;
if (detail && typeof detail.pos === 'number') {
setActive(detail.pos);
}
});
// Les boutons de la tab bar declenchent un scroll via un event custom
tabs.forEach((tab) => {
tab.addEventListener('click', () => {
const idx = Number(tab.dataset.tabIndex);
document.dispatchEvent(new CustomEvent('mobile-tab-scroll', { detail: { pos: idx } }));
});
});
</script>
+95 -5
View File
@@ -1,11 +1,101 @@
---
// Placeholder popup onboarding — PC2 fait l'animation et le contenu
// PopupOnboarding - micro-resume premier visit, localStorage flag tf-onboarded
// Le micro-resume est une PROPOSITION Opus a iterer avec Jules.
// Stocke en constante en tete pour iteration facile.
const microResume = `Architecture, ecologie, politique : un commun a construire ensemble.
Reapproprions-nous nos infrastructures vitales en repartant de l'existant.
Ce site, c'est le journal vivant de cette bifurcation.`;
---
<div
id="pc-onboarding"
class="hidden fixed inset-0 z-40 bg-black/30 items-center justify-center"
id="onboarding-popup"
class="fixed inset-0 z-[60] hidden bg-black/50 backdrop-blur-sm items-center justify-center p-4"
role="dialog"
aria-modal="true"
aria-labelledby="onboarding-text"
>
<div class="bg-white p-6 rounded shadow-lg max-w-sm">
<p class="text-sm text-neutral-600">Pop-up onboarding — PC2</p>
<div class="bg-white rounded-xl max-w-md w-full p-6 md:p-7 shadow-2xl relative animate-fade-in">
<button
id="onboarding-close"
type="button"
class="absolute top-3 right-3 w-8 h-8 flex items-center justify-center text-neutral-400 hover:text-neutral-800 text-2xl leading-none rounded-md hover:bg-neutral-100 transition-colors"
aria-label="Fermer"
>
&times;
</button>
<p
id="onboarding-text"
class="text-base md:text-lg leading-relaxed text-neutral-800 whitespace-pre-line pr-6"
>{microResume}</p>
<button
id="onboarding-cta"
type="button"
class="mt-5 px-4 py-2.5 bg-neutral-900 text-white rounded-lg w-full font-medium hover:bg-neutral-700 transition-colors"
>
Entrer
</button>
</div>
</div>
<style>
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-fade-in {
animation: fadeIn 0.25s ease-out;
}
</style>
<script>
const STORAGE_KEY = 'tf-onboarded';
const popup = document.getElementById('onboarding-popup');
const closeBtn = document.getElementById('onboarding-close');
const ctaBtn = document.getElementById('onboarding-cta');
let alreadyOnboarded = false;
try {
alreadyOnboarded = !!localStorage.getItem(STORAGE_KEY);
} catch {
// mode prive : on affiche quand meme, sans persistence
alreadyOnboarded = false;
}
if (!alreadyOnboarded && popup) {
requestAnimationFrame(() => {
popup.classList.remove('hidden');
popup.classList.add('flex');
});
}
const dismiss = () => {
if (!popup) return;
popup.classList.remove('flex');
popup.classList.add('hidden');
try {
localStorage.setItem(STORAGE_KEY, '1');
} catch {
// mode prive : pas de persistence
}
window.removeEventListener('scroll', onScroll);
};
closeBtn?.addEventListener('click', dismiss);
ctaBtn?.addEventListener('click', dismiss);
// Dismiss implicite au scroll 200px (engagement implicite)
const onScroll = () => {
if (window.scrollY > 200) {
dismiss();
}
};
if (!alreadyOnboarded) {
window.addEventListener('scroll', onScroll, { passive: true });
}
// ESC pour fermer
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && popup && !popup.classList.contains('hidden')) {
dismiss();
}
});
</script>
+80
View File
@@ -0,0 +1,80 @@
---
// SiteHeader.astro - V1.3-C : header 1 ligne fine, liens cliquables, phrase intention Roboto Condensed
// Palette V1.3 figee : papier #FAFAF7, encre #0F172A, encre douce #475569, border #E5E7EB
// Composition :
// Desktop (>= md) : 1 ligne ~44px - Trans-Former | Jules Neny | architecture d'ecologie politique [phrase intention right-aligned, Roboto Condensed]
// Mobile (< md) : 2 lignes compactes - ligne 1 Trans-Former / ligne 2 Jules Neny . AEP (cliquables) - phrase intention masquee
// Liens :
// Trans-Former -> /
// Jules Neny -> /a-propos
// architecture d'ecologie politique -> https://aep.trans-former.fr (same-tab, site frere coherent)
// Typo phrase intention : Roboto Condensed Variable @fontsource (weight 400, font-stretch 75%)
import '@fontsource-variable/roboto-condensed/wght.css';
---
<header
class="site-header w-full border-b border-[#E5E7EB] bg-[#FAFAF7] text-[#0F172A] px-4 md:px-6 flex items-center"
role="banner"
>
<!-- Bloc identite (gauche) -->
<nav
class="flex flex-col md:flex-row md:items-baseline gap-x-2 gap-y-0.5 flex-shrink-0"
aria-label="identite site"
>
<!-- Ligne 1 desktop = wordmark inline ; mobile = ligne dediee -->
<a
href="/"
class="font-semibold tracking-tight text-[#0F172A] hover:text-[#0F172A] text-[15px] md:text-[16px] leading-none no-underline hover:underline underline-offset-2 decoration-1"
aria-label="trans-former.fr - accueil"
>
Trans-Former
</a>
<!-- Bloc secondaire : Jules Neny . AEP (mobile : ligne 2 ; desktop : inline) -->
<span class="flex items-baseline gap-1.5 text-[#475569] leading-none">
<span class="text-[#94A3B8] hidden md:inline" aria-hidden="true">·</span>
<a
href="/a-propos"
class="text-[12px] md:text-[13px] text-[#475569] hover:text-[#0F172A] no-underline hover:underline underline-offset-2 decoration-1"
>
Jules Neny
</a>
<span class="text-[#94A3B8]" aria-hidden="true">·</span>
<a
href="https://aep.trans-former.fr"
class="text-[12px] md:text-[13px] text-[#475569] hover:text-[#0F172A] no-underline hover:underline underline-offset-2 decoration-1"
>
architecture d'écologie politique
</a>
</span>
</nav>
<!-- Phrase intention (droite, desktop only) - Roboto Condensed allongee -->
<p
class="intention hidden md:block ml-auto pl-6 text-right text-[#475569] text-[11px] leading-tight max-w-[55%]"
>
Comment créer une pratique systémique, créative et collective de transformation sociale pour répondre à l'effondrement et restaurer notre capacité à habiter la Terre dans l'Anthropocène&nbsp;?
</p>
</header>
<style>
.site-header {
height: 44px;
}
@media (max-width: 767px) {
.site-header {
height: auto;
min-height: 48px;
padding-top: 6px;
padding-bottom: 6px;
}
}
/* Phrase intention : Roboto Condensed Variable, font-stretch 75% (allongement vertical condense)
Cantonnee a .intention pour eviter contagion stack Inter */
.intention {
font-family: 'Roboto Condensed Variable', 'Roboto Condensed', sans-serif;
font-weight: 400;
font-stretch: 75%;
font-style: italic;
letter-spacing: 0.01em;
}
</style>
+556 -3
View File
@@ -1,9 +1,562 @@
<script setup lang="ts">
// Placeholder Carte O — PC3 implémente avec D3 + GraphView porté de nav-carte
// Carte O - mindmap force-directed D3 (V1 zoom + pan + drag + click).
// Pattern adapté de nav-carte/components/codev/CodevGraph.vue (sans coupling Nuxt).
// Drill-down V1 = zoom + pan seul. V2 récursif backlog (cf MP-PAGE-CERVEAU.md).
import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
import * as d3 from 'd3'
interface CarteNode {
id: string
label: string
family: string
niveau?: number
nature?: 'essai' | 'projet'
statut?: 'gestation' | 'edite'
resume?: string | null
radius?: number
intention?: string
slug?: string
theme?: string
path?: string
domain?: string // V1.2-O : domaine plateforme source (logo Brandfetch CDN)
problematique?: string // P3 §3 : accroche au survol (brouillon MOE 29/09, a valider)
}
// V1.2-O : logos plateforme via Brandfetch CDN (visible zoom > 1.5x seulement)
const BRANDFETCH_CLIENT_ID = '4ae58bd85c8140eab0cee72f40656120'
const LOGO_ZOOM_THRESHOLD = 1.5
// V1.3-D : dimensions rectangle central (phrase manifeste 3 lignes)
const CENTRAL_W = 300
const CENTRAL_H = 64
const CENTRAL_COLLIDE_RADIUS = 160 // demi-largeur + marge
const logoUrl = (domain: string) =>
`https://cdn.brandfetch.io/${domain}/w/64/h/64?c=${BRANDFETCH_CLIENT_ID}`
interface CarteEdge {
source: string | CarteNode
target: string | CarteNode
central?: boolean // V1.3-D : link au noeud central (court/fort)
}
const props = withDefaults(defineProps<{
nodes: CarteNode[]
edges: CarteEdge[]
familyColors?: Record<string, string>
}>(), {
familyColors: () => ({
penseur: '#3b82f6',
concept: '#10b981',
methode: '#f59e0b',
collectif: '#ef4444',
ressource: '#8b5cf6',
}),
})
const emit = defineEmits<{
'node-click': [payload: { node: CarteNode; x: number; y: number }]
}>()
// Refs
const container = ref<HTMLDivElement | null>(null)
const svgEl = ref<SVGSVGElement | null>(null)
const width = ref(800)
const height = ref(600)
// State interne
type SimNode = d3.SimulationNodeDatum & CarteNode
type SimLink = d3.SimulationLinkDatum<SimNode>
let simulation: d3.Simulation<SimNode, SimLink> | null = null
let svgRoot: d3.Selection<SVGSVGElement, unknown, null, undefined> | null = null
let gZoom: d3.Selection<SVGGElement, unknown, null, undefined> | null = null
let gLinks: d3.Selection<SVGGElement, unknown, null, undefined> | null = null
let gNodes: d3.Selection<SVGGElement, unknown, null, undefined> | null = null
let zoomBehavior: d3.ZoomBehavior<SVGSVGElement, unknown> | null = null
const isMobile = computed(() => width.value < 600)
const nodeRadius = computed(() => isMobile.value ? 10 : 14)
// V1.2-N palette : encre / papier / ocre
// - central niveau 0 : fill encre #0F172A, label blanc inscrit dans le cercle
// - essais (niveaux 1-2) : fill papier #FFFFFF, stroke encre, label encre a droite
// - projets : fill ocre #B45309, label blanc inscrit dans le cercle
// - statut "edite" : stroke epaissi (l'epaisseur conserve l'info, pas la couleur)
function colorFor(d: SimNode): string {
if (d.nature === 'projet') return '#B45309'
if (d.niveau === 0) return '#0F172A'
return '#FFFFFF'
}
function getRadius(d: SimNode): number {
if (d.radius != null) return d.radius
if (d.niveau === 0) return 30
if (d.nature === 'projet') return 18
return nodeRadius.value
}
function strokeFor(d: SimNode): string {
if (d.nature === 'projet') return '#B45309'
if (d.niveau === 0) return '#0F172A'
return '#0F172A'
}
function strokeWidthFor(d: SimNode): number {
return d.statut === 'edite' ? 2.5 : 1.5
}
function labelWeightFor(d: SimNode): string {
return d.statut === 'edite' ? 'bold' : 'normal'
}
// Label inscrit dans le cercle (fond fonce) pour central + projets
function labelInsideFor(d: SimNode): boolean {
return d.niveau === 0 || d.nature === 'projet'
}
function labelColorFor(d: SimNode): string {
if (labelInsideFor(d)) return '#FFFFFF'
return d.statut === 'edite' ? '#0F172A' : '#475569'
}
function truncate(str: string, max: number): string {
if (!str) return ''
return str.length > max ? str.slice(0, max - 1) + '…' : str
}
// V1.3-D : Wrap intelligent du label central sur 3 lignes (rectangle 300x64).
// Phrase cible : "Une medecine du corps social pour ecrire un nouveau contrat social"
// Heuristique : decoupe en mots, repartit sur ~3 lignes de longueur equilibree.
function splitCentralLabel(label: string, maxLines = 3, maxCharsPerLine = 30): string[] {
if (!label) return ['']
// Fallback : pivot legacy " + " (V1.2 backward compat)
if (label.includes(' + ')) {
const parts = label.split(' + ')
return [parts[0].trim(), '+', parts.slice(1).join(' + ').trim()]
}
const words = label.split(/\s+/).filter(Boolean)
if (words.length <= 1) return [label]
// Repartit en lignes en respectant maxCharsPerLine, max maxLines lignes
const lines: string[] = []
let current = ''
for (const w of words) {
const next = current ? `${current} ${w}` : w
if (next.length > maxCharsPerLine && current && lines.length < maxLines - 1) {
lines.push(current)
current = w
} else {
current = next
}
}
if (current) lines.push(current)
// Si plus de lignes que prevu (mots tres longs), concatene les dernieres
while (lines.length > maxLines) {
const last = lines.pop()!
lines[lines.length - 1] = `${lines[lines.length - 1]} ${last}`
}
return lines
}
function buildSimNodes(): SimNode[] {
return props.nodes.map(n => ({ ...n }))
}
function buildSimLinks(simNodes: SimNode[]): SimLink[] {
const byId = new Map(simNodes.map(n => [n.id, n]))
return props.edges
.map(e => {
const s = typeof e.source === 'string' ? byId.get(e.source) : byId.get(e.source.id)
const t = typeof e.target === 'string' ? byId.get(e.target) : byId.get(e.target.id)
if (!s || !t) return null
return { source: s, target: t } as SimLink
})
.filter((x): x is SimLink => x !== null)
}
function makeDrag(sim: d3.Simulation<SimNode, SimLink>) {
return d3.drag<SVGGElement, SimNode>()
.on('start', (event, d) => {
if (!event.active) sim.alphaTarget(0.3).restart()
d.fx = d.x
d.fy = d.y
})
.on('drag', (event, d) => {
d.fx = event.x
d.fy = event.y
})
.on('end', (event, d) => {
if (!event.active) sim.alphaTarget(0)
d.fx = null
d.fy = null
})
}
function initSvg() {
if (!svgEl.value) return
svgRoot = d3.select(svgEl.value)
.attr('width', width.value)
.attr('height', height.value)
.attr('role', 'img')
.attr('aria-label', 'Carte O — mindmap de la pensée AEP')
svgRoot.selectAll('*').remove()
// Wrapper group for zoom/pan transform.
gZoom = svgRoot.append('g').attr('class', 'zoom-layer')
gLinks = gZoom.append('g').attr('class', 'links')
gNodes = gZoom.append('g').attr('class', 'nodes')
// Zoom + pan behavior.
zoomBehavior = d3.zoom<SVGSVGElement, unknown>()
.scaleExtent([0.3, 4])
.on('zoom', (event) => {
gZoom!.attr('transform', event.transform.toString())
// V1.2-O : toggle visibilite logos plateforme selon le niveau de zoom
// (evite la surcharge visuelle au niveau d'ensemble, montre detail au drill-down)
const scale = event.transform.k
gZoom!.selectAll('.logo-overlay')
.style('opacity', scale > LOGO_ZOOM_THRESHOLD ? 1 : 0)
})
svgRoot.call(zoomBehavior as any)
}
function render() {
if (!svgEl.value || props.nodes.length === 0) return
initSvg()
const simNodes = buildSimNodes()
const simLinks = buildSimLinks(simNodes)
const fontSize = isMobile.value ? 9 : 11
// Liens — gris doux papier
gLinks!
.selectAll<SVGLineElement, SimLink>('line')
.data(simLinks)
.join('line')
.attr('stroke', '#94A3B8')
.attr('stroke-opacity', 0.4)
.attr('stroke-width', 1)
// Noeuds = groupe <g> par node
const nodeGroups = gNodes!
.selectAll<SVGGElement, SimNode>('g.node')
.data(simNodes, d => d.id)
.join('g')
.attr('class', 'node')
.style('cursor', 'pointer')
.on('click', (_event, d) => emit('node-click', { node: d as CarteNode, x: (d as SimNode).x || 0, y: (d as SimNode).y || 0 }))
.on('mouseover', function (_event, d) {
// V1.3-D : selecteur etendu rect|circle (rect pour central, circle pour autres)
d3.select(this).select('rect, circle')
.transition().duration(120)
.attr('stroke-width', strokeWidthFor(d) + 1.5)
})
.on('mouseout', function (_event, d) {
d3.select(this).select('rect, circle')
.transition().duration(120)
.attr('stroke-width', strokeWidthFor(d))
})
// V1.3-D : nœud central = rectangle 300x64 (phrase 3 lignes), autres = cercle
const isCentral = (d: SimNode) => d.niveau === 0
nodeGroups.filter(isCentral)
.append('rect')
.attr('x', -CENTRAL_W / 2)
.attr('y', -CENTRAL_H / 2)
.attr('width', CENTRAL_W)
.attr('height', CENTRAL_H)
.attr('rx', 6)
.attr('fill', d => colorFor(d))
.attr('stroke', d => strokeFor(d))
.attr('stroke-width', d => strokeWidthFor(d))
nodeGroups.filter(d => !isCentral(d))
.append('circle')
.attr('r', d => getRadius(d))
.attr('fill', d => colorFor(d))
.attr('stroke', d => strokeFor(d))
.attr('stroke-width', d => strokeWidthFor(d))
// Label : inscrit dans le cercle pour central + projets, a droite sinon
// Pour le noeud central (label long), on passe en multi-lignes
nodeGroups.each(function (d) {
const g = d3.select(this)
const inside = labelInsideFor(d)
if (inside && d.niveau === 0) {
// V1.3-D : noeud central = rectangle, label 3 lignes 13px line-height 1.35
const fs = isMobile.value ? 11 : 13
const lineHeight = Math.round(fs * 1.35)
const parts = splitCentralLabel(d.label, 3, isMobile.value ? 22 : 30)
const startY = -((parts.length - 1) * lineHeight) / 2
parts.forEach((line, i) => {
g.append('text')
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'central')
.attr('x', 0)
.attr('y', startY + i * lineHeight)
.attr('font-size', fs)
.attr('font-family', 'system-ui, sans-serif')
.attr('font-weight', '500')
.attr('fill', '#FFFFFF')
.attr('pointer-events', 'none')
.text(line)
})
} else if (inside) {
// Projets : label centre dans le cercle (court)
g.append('text')
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'central')
.attr('x', 0)
.attr('y', 0)
.attr('font-size', fontSize)
.attr('font-family', 'system-ui, sans-serif')
.attr('font-weight', labelWeightFor(d))
.attr('fill', '#FFFFFF')
.attr('pointer-events', 'none')
.text(truncate(d.label, 6))
} else {
// Essais : label a droite du cercle
g.append('text')
.attr('text-anchor', 'start')
.attr('dominant-baseline', 'central')
.attr('dx', getRadius(d) + 4)
.attr('font-size', fontSize)
.attr('font-family', 'system-ui, sans-serif')
.attr('font-weight', labelWeightFor(d))
.attr('fill', labelColorFor(d))
.attr('pointer-events', 'none')
.text(truncate(d.label, isMobile.value ? 18 : 30))
}
})
// V1.2-O : Logo plateforme (visible zoom > 1.5x seulement)
// Cercle blanc 14px en bas-droite du node (offset +60% x/+60% y du radius)
// Image clip-path circle pour avatar style. Hidden par defaut (opacity 0).
const nodeGroupsWithLogo = nodeGroups.filter(d => !!d.domain)
const logoOverlay = nodeGroupsWithLogo.append('g')
.attr('class', 'logo-overlay')
.style('opacity', 0)
.attr('pointer-events', 'none')
logoOverlay.append('circle')
.attr('cx', d => getRadius(d) * 0.6)
.attr('cy', d => getRadius(d) * 0.6)
.attr('r', 9)
.attr('fill', '#FFFFFF')
.attr('stroke', '#0F172A')
.attr('stroke-width', 0.5)
logoOverlay.append('image')
.attr('href', d => logoUrl(d.domain!))
.attr('x', d => getRadius(d) * 0.6 - 7)
.attr('y', d => getRadius(d) * 0.6 - 7)
.attr('width', 14)
.attr('height', 14)
.attr('clip-path', d => `circle(7px at ${getRadius(d) * 0.6}px ${getRadius(d) * 0.6}px)`)
// Tooltip <title>
nodeGroups.append('title')
.text(d => `${d.label}\n[${d.family}]\n${truncate(d.problematique || d.resume || d.intention || '', 200)}`)
// V1.3-D : Simulation force-directed fit-cadre + animation continue subtile
// - link distance/strength differencies : TMIP (central) court/fort, autres essais souples
// - collide +12 zero overlap labels
// - forceX/Y faibles rappel cadre
// - alphaDecay 0.025 (anime sans vertige)
simulation = d3.forceSimulation<SimNode, SimLink>(simNodes)
.force('link', d3.forceLink<SimNode, SimLink>(simLinks)
.id(d => d.id)
.distance(l => {
const link = l as SimLink & { central?: boolean }
// Lien central -> TMIP (projet) = court (90), autres centraux (essais) = standard (200)
if (link.central) {
const tgt = link.target as SimNode
const src = link.source as SimNode
const other = (tgt.niveau === 0 ? src : tgt) as SimNode
if (other.nature === 'projet') return 90
return 200
}
return 180
})
.strength(l => {
const link = l as SimLink & { central?: boolean }
if (link.central) {
const tgt = link.target as SimNode
const src = link.source as SimNode
const other = (tgt.niveau === 0 ? src : tgt) as SimNode
if (other.nature === 'projet') return 0.6
return 0.3
}
return 0.3
}))
.force('charge', d3.forceManyBody<SimNode>().strength(d => {
if (d.niveau === 0) return -1200
if (d.niveau === 1) return -400
if (d.niveau === 2) return -180
return -220
}))
.force('center', d3.forceCenter(width.value / 2, height.value / 2))
// V1.4-C1 : rappel horizontal/vertical renforce (0.05 -> 0.08) + collide +14 (vs +12)
// pour mieux contenir les nodes dans le cadre visible de la zone Carte O.
.force('x', d3.forceX<SimNode>(width.value / 2).strength(0.08))
.force('y', d3.forceY<SimNode>(height.value / 2).strength(0.08))
.force('collide', d3.forceCollide<SimNode>().radius(d => {
// V1.3-D : central traite comme zone large (rect 300x64 -> rayon equivalent 160)
if (d.niveau === 0) return CENTRAL_COLLIDE_RADIUS
return getRadius(d) + 14
}))
.alphaDecay(0.025)
.velocityDecay(0.4)
.alphaMin(0.001)
.on('tick', tick)
// Bind drag once simulation exists.
nodeGroups.call(makeDrag(simulation) as any)
}
function tick() {
if (!gLinks || !gNodes) return
gLinks.selectAll<SVGLineElement, SimLink>('line')
.attr('x1', d => (d.source as SimNode).x ?? 0)
.attr('y1', d => (d.source as SimNode).y ?? 0)
.attr('x2', d => (d.target as SimNode).x ?? 0)
.attr('y2', d => (d.target as SimNode).y ?? 0)
gNodes.selectAll<SVGGElement, SimNode>('g.node')
.attr('transform', d => `translate(${d.x ?? 0},${d.y ?? 0})`)
}
// Public methods exposed (zoom controls).
function zoomIn() {
if (!svgRoot || !zoomBehavior) return
svgRoot.transition().duration(250).call(zoomBehavior.scaleBy as any, 1.3)
}
function zoomOut() {
if (!svgRoot || !zoomBehavior) return
svgRoot.transition().duration(250).call(zoomBehavior.scaleBy as any, 0.77)
}
function zoomReset() {
if (!svgRoot || !zoomBehavior) return
svgRoot.transition().duration(350).call(zoomBehavior.transform as any, d3.zoomIdentity)
}
defineExpose({ zoomIn, zoomOut, zoomReset })
// Re-render on data changes.
watch(() => [props.nodes, props.edges], () => {
if (simulation) {
simulation.stop()
simulation = null
}
render()
}, { deep: true })
// ResizeObserver
let ro: ResizeObserver | null = null
onMounted(() => {
if (!container.value) return
width.value = container.value.clientWidth || 800
height.value = container.value.clientHeight || 600
render()
ro = new ResizeObserver(() => {
if (!container.value) return
width.value = container.value.clientWidth || 800
height.value = container.value.clientHeight || 600
if (svgRoot) {
svgRoot.attr('width', width.value).attr('height', height.value)
}
if (simulation) {
simulation.force('center', d3.forceCenter(width.value / 2, height.value / 2))
// V1.4-C1 : aligne avec la simulation initiale (strength 0.08)
simulation.force('x', d3.forceX<SimNode>(width.value / 2).strength(0.08))
simulation.force('y', d3.forceY<SimNode>(height.value / 2).strength(0.08))
simulation.alpha(0.3).restart()
}
})
ro.observe(container.value)
})
onUnmounted(() => {
if (simulation) simulation.stop()
if (ro) ro.disconnect()
})
</script>
<template>
<div class="h-full w-full flex items-center justify-center text-sm text-neutral-400">
Carte O placeholder (PC3)
<div ref="container" class="carte-o-wrap">
<svg ref="svgEl" class="carte-o-svg" />
<!-- Zoom controls overlay -->
<div class="zoom-controls">
<button type="button" @click="zoomIn" aria-label="Zoom avant">+</button>
<button type="button" @click="zoomReset" aria-label="Réinitialiser le zoom">⟲</button>
<button type="button" @click="zoomOut" aria-label="Zoom arrière">−</button>
</div>
</div>
</template>
<style scoped>
.carte-o-wrap {
width: 100%;
height: 100%;
position: relative;
background: #FAFAF7;
overflow: hidden;
}
.carte-o-svg {
width: 100%;
height: 100%;
display: block;
cursor: grab;
}
.carte-o-svg:active {
cursor: grabbing;
}
.zoom-controls {
position: absolute;
top: 0.5rem;
right: 0.5rem;
display: flex;
flex-direction: column;
gap: 4px;
background: rgba(255, 255, 255, 0.85);
border: 1px solid rgba(0, 0, 0, 0.06);
border-radius: 8px;
padding: 4px;
backdrop-filter: blur(4px);
}
.zoom-controls button {
width: 28px;
height: 28px;
border: none;
background: transparent;
cursor: pointer;
font-size: 16px;
font-weight: 600;
color: #374151;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
}
.zoom-controls button:hover {
background: rgba(0, 0, 0, 0.05);
}
</style>
+119
View File
@@ -0,0 +1,119 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted } from 'vue'
interface CarteNode {
id: string
label: string
family?: string
nature?: 'essai' | 'projet'
statut?: 'gestation' | 'edite'
resume?: string | null
intention?: string
}
const props = defineProps<{
node: CarteNode | null
x: number
y: number
}>()
const emit = defineEmits<{ close: [] }>()
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && props.node) emit('close')
}
function onClickOutside(e: MouseEvent) {
const el = document.getElementById('carte-o-context-menu')
if (el && !el.contains(e.target as Node)) emit('close')
}
onMounted(() => {
window.addEventListener('keydown', onKeydown)
setTimeout(() => window.addEventListener('click', onClickOutside), 50)
})
onUnmounted(() => {
window.removeEventListener('keydown', onKeydown)
window.removeEventListener('click', onClickOutside)
})
const naturLabel = computed(() => props.node?.nature === 'projet' ? 'Projet archi' : 'Essai politique')
const naturColor = computed(() => props.node?.nature === 'projet' ? '#b45309' : '#1d4ed8')
const texte = computed(() => props.node?.resume || props.node?.intention || 'En cours d\'ecriture.')
</script>
<template>
<div
v-if="node"
id="carte-o-context-menu"
class="context-menu"
:style="{ left: x + 'px', top: y + 'px' }"
role="dialog"
:aria-label="node.label"
>
<button class="close-btn" type="button" @click="emit('close')" aria-label="Fermer">x</button>
<span class="nature-badge" :style="{ backgroundColor: naturColor }">{{ naturLabel }}</span>
<h3 class="title" :style="{ fontWeight: node.statut === 'edite' ? 'bold' : 'normal' }">
{{ node.label }}
</h3>
<p class="resume">{{ texte }}</p>
<p v-if="node.statut === 'edite'" class="edite-badge">publie</p>
</div>
</template>
<style scoped>
.context-menu {
position: absolute;
z-index: 1000;
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 12px 14px;
width: 220px;
box-shadow: 0 4px 16px rgba(0,0,0,0.12);
font-size: 13px;
}
.close-btn {
position: absolute;
top: 6px;
right: 8px;
background: none;
border: none;
cursor: pointer;
color: #9ca3af;
font-size: 14px;
line-height: 1;
padding: 2px 4px;
}
.close-btn:hover { color: #374151; }
.nature-badge {
display: inline-block;
padding: 2px 7px;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
color: #fff;
letter-spacing: 0.04em;
text-transform: uppercase;
margin-bottom: 6px;
font-family: 'Courier New', Courier, monospace;
}
.title {
font-size: 14px;
color: #1f2937;
margin: 4px 0 6px 0;
line-height: 1.3;
}
.resume {
font-size: 12px;
color: #4b5563;
line-height: 1.5;
margin: 0;
}
.edite-badge {
margin-top: 8px;
font-size: 10px;
color: #059669;
font-family: 'Courier New', Courier, monospace;
font-weight: 600;
}
</style>
+181
View File
@@ -0,0 +1,181 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import CarteO from './CarteO.vue'
import CarteOContextMenu from './CarteOContextMenu.vue'
interface CarteNode {
id: string
label: string
family: string
niveau?: number
nature?: 'essai' | 'projet'
statut?: 'gestation' | 'edite'
resume?: string | null
intention?: string
slug?: string
theme?: string
}
interface CarteEdge {
source: string
target: string
central?: boolean // V1.3-D : edges au noeud central (tuning force-link)
}
interface CarteData {
meta?: {
nodeCount?: number
edgeCount?: number
familyDistribution?: Record<string, number>
familyColors?: Record<string, string>
}
nodes: CarteNode[]
edges: CarteEdge[]
}
const props = withDefaults(defineProps<{
src?: string
}>(), {
src: '/data/carte-o.json',
})
const data = ref<CarteData | null>(null)
const error = ref<string | null>(null)
const selectedNode = ref<CarteNode | null>(null)
const contextX = ref(0)
const contextY = ref(0)
const isMobileScreen = ref(false)
const familyColors = computed(() =>
data.value?.meta?.familyColors || {
penseur: '#3b82f6',
concept: '#10b981',
methode: '#f59e0b',
collectif: '#ef4444',
ressource: '#8b5cf6',
}
)
function onNodeClick(payload: { node: CarteNode; x: number; y: number }) {
selectedNode.value = payload.node
contextX.value = payload.x
contextY.value = payload.y
}
onMounted(async () => {
isMobileScreen.value = window.innerWidth < 768
try {
const res = await fetch(props.src)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
data.value = await res.json()
} catch (e: any) {
console.error('[CarteO] failed to load', e)
error.value = e?.message || 'Erreur de chargement'
}
window.addEventListener('resize', () => {
isMobileScreen.value = window.innerWidth < 768
})
})
</script>
<template>
<div class="wrapper">
<!-- Mobile fallback (V1) -->
<div v-if="isMobileScreen" class="mobile-fallback">
<div class="mini-map">
<svg viewBox="0 0 200 120" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<circle cx="100" cy="60" r="8" fill="#3b82f6" />
<circle cx="50" cy="35" r="5" fill="#10b981" />
<circle cx="150" cy="35" r="5" fill="#f59e0b" />
<circle cx="55" cy="90" r="5" fill="#ef4444" />
<circle cx="145" cy="90" r="5" fill="#8b5cf6" />
<line x1="100" y1="60" x2="50" y2="35" stroke="#94a3b8" stroke-width="0.8" opacity="0.5" />
<line x1="100" y1="60" x2="150" y2="35" stroke="#94a3b8" stroke-width="0.8" opacity="0.5" />
<line x1="100" y1="60" x2="55" y2="90" stroke="#94a3b8" stroke-width="0.8" opacity="0.5" />
<line x1="100" y1="60" x2="145" y2="90" stroke="#94a3b8" stroke-width="0.8" opacity="0.5" />
</svg>
</div>
<p class="msg">
Carte O optimisee desktop. Retournez sur grand ecran pour explorer la mindmap interactive.
</p>
</div>
<!-- Loading state -->
<div v-else-if="!data && !error" class="state">
<span>Chargement de la Carte O...</span>
</div>
<!-- Error state -->
<div v-else-if="error" class="state error">
<span>Impossible de charger la Carte O ({{ error }})</span>
</div>
<!-- Carte O -->
<template v-else-if="data">
<CarteO
:nodes="data.nodes"
:edges="data.edges"
:family-colors="familyColors"
@node-click="onNodeClick"
/>
<CarteOContextMenu
:node="selectedNode"
:x="contextX"
:y="contextY"
@close="selectedNode = null"
/>
</template>
</div>
</template>
<style scoped>
.wrapper {
height: 100%;
width: 100%;
position: relative;
}
.state {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #9ca3af;
font-size: 0.9rem;
}
.state.error {
color: #dc2626;
}
.mobile-fallback {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 1.5rem;
text-align: center;
gap: 1rem;
}
.mini-map {
width: 70%;
max-width: 240px;
}
.mini-map svg {
width: 100%;
height: auto;
}
.msg {
color: #6b7280;
font-size: 0.85rem;
line-height: 1.45;
font-style: italic;
max-width: 24rem;
margin: 0;
}
</style>
+187
View File
@@ -0,0 +1,187 @@
<script setup lang="ts">
// PC7 — Chatbot V1 brancha sur endpoint AEP (proxy Astro server-side).
// Persistance thread sessionStorage. Bandeau beta info ; pas de streaming, pas de markdown V1.
import { ref, onMounted, nextTick, useTemplateRef } from 'vue'
interface Msg {
role: 'user' | 'bot'
content: string
ts: number
}
const props = defineProps<{ apiUrl?: string }>()
const messages = ref<Msg[]>([])
const input = ref('')
const sending = ref(false)
const errorBanner = ref('')
const threadEl = useTemplateRef<HTMLElement>('threadEl')
const STORAGE_KEY = 'tf-chatbot-v2-thread'
const ENDPOINT = props.apiUrl || '/api/chatbot'
onMounted(() => {
try {
const saved = sessionStorage.getItem(STORAGE_KEY)
if (saved) messages.value = JSON.parse(saved)
} catch {
// sessionStorage indisponible ; on ignore.
}
})
const persist = () => {
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(messages.value))
} catch {
// ignore
}
}
const scrollToBottom = async () => {
await nextTick()
const el = threadEl.value
if (el) el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' })
}
const send = async () => {
const q = input.value.trim()
if (!q || sending.value) return
messages.value.push({ role: 'user', content: q, ts: Date.now() })
input.value = ''
sending.value = true
errorBanner.value = ''
persist()
scrollToBottom()
try {
const res = await fetch(ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
question: q,
history: messages.value.slice(-10).map((m) => ({ role: m.role, content: m.content })),
}),
})
if (!res.ok) {
if (res.status === 429) {
errorBanner.value = 'Limite de questions atteinte ; ressaie plus tard.'
} else if (res.status === 502 || res.status === 504) {
errorBanner.value = "Le service de chat n'est pas joignable ; ressaie dans un instant."
} else {
errorBanner.value = `Erreur ${res.status} ; ressaie ou recharge la page.`
}
sending.value = false
return
}
const data = await res.json()
const answer =
data.reponse_texte ||
data.answer ||
data.text ||
data.message ||
'Pas de reponse.'
messages.value.push({ role: 'bot', content: answer, ts: Date.now() })
} catch (e) {
errorBanner.value = "Probleme reseau ; verifie ta connexion."
messages.value.push({
role: 'bot',
content: `Erreur : ${(e as Error).message || 'inconnue'}`,
ts: Date.now(),
})
} finally {
sending.value = false
persist()
scrollToBottom()
}
}
const reset = () => {
messages.value = []
errorBanner.value = ''
persist()
}
</script>
<template>
<div class="h-full flex flex-col bg-white">
<div class="bg-amber-50 border-b border-amber-200 px-3 py-2 text-xs text-amber-900 leading-snug">
<span class="font-medium">Beta :</span> 120 fiches AEP indexees (Mistral Small) ; bientot RAG-PE multi-corpus.
</div>
<div
v-if="errorBanner"
class="bg-rose-50 border-b border-rose-200 px-3 py-2 text-xs text-rose-800"
role="alert"
>
{{ errorBanner }}
</div>
<div
ref="threadEl"
class="flex-1 overflow-y-auto px-3 py-3 space-y-3"
role="log"
aria-live="polite"
>
<p
v-if="!messages.length"
class="text-neutral-400 text-sm italic text-center mt-8 px-4"
>
Pose une question sur l'AEP ; le manifeste ; les agences locales...
</p>
<div
v-for="m in messages"
:key="m.ts"
:class="[
'max-w-[85%] rounded-lg px-3 py-2 text-sm leading-relaxed whitespace-pre-wrap break-words',
m.role === 'user'
? 'bg-neutral-900 text-white ml-auto'
: 'bg-neutral-100 text-neutral-800 mr-auto',
]"
>
{{ m.content }}
</div>
<div
v-if="sending"
class="bg-neutral-100 max-w-[60%] rounded-lg px-3 py-2 text-sm text-neutral-500 italic mr-auto"
>
Le bot reflechit...
</div>
</div>
<form
class="border-t border-neutral-200 p-2 flex gap-2 items-stretch"
@submit.prevent="send"
>
<input
v-model="input"
type="text"
placeholder="Ta question..."
:disabled="sending"
class="flex-1 px-3 py-2 border border-neutral-300 rounded-lg text-sm focus:outline-none focus:border-neutral-900 disabled:opacity-60"
aria-label="Saisis ta question"
/>
<button
type="submit"
:disabled="sending || !input.trim()"
class="px-3 py-2 bg-neutral-900 text-white rounded-lg text-sm font-medium disabled:opacity-50"
>
Envoyer
</button>
<button
v-if="messages.length"
type="button"
class="px-2 py-2 text-neutral-500 text-xs hover:text-neutral-900 underline"
@click="reset"
>
Reset
</button>
</form>
</div>
</template>
+89
View File
@@ -0,0 +1,89 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
const iframeRef = ref<HTMLIFrameElement | null>(null)
const wrapperRef = ref<HTMLDivElement | null>(null)
const skeletonHidden = ref(false)
// Force rendu desktop de l'iframe AEP : viewport simulee 1440px + scale dynamique
const VIEWPORT_W = 1440
const iframeScale = ref(0.42)
let resizeObs: ResizeObserver | null = null
const updateScale = () => {
if (!wrapperRef.value) return
const w = wrapperRef.value.clientWidth
if (w > 0) iframeScale.value = w / VIEWPORT_W
}
const iframeStyle = computed(() => ({
width: VIEWPORT_W + 'px',
height: (100 / iframeScale.value) + '%',
transform: `scale(${iframeScale.value})`,
transformOrigin: '0 0',
}))
let fallbackTimer: ReturnType<typeof setTimeout> | null = null
const revealIframe = () => {
if (iframeRef.value) {
iframeRef.value.classList.remove('opacity-0')
iframeRef.value.classList.add('opacity-100')
}
skeletonHidden.value = true
}
onMounted(() => {
if (wrapperRef.value && typeof ResizeObserver !== 'undefined') {
updateScale()
resizeObs = new ResizeObserver(updateScale)
resizeObs.observe(wrapperRef.value)
}
// Fallback : si @load ne fire pas dans 2.5s (transform/scale peut bloquer l'event),
// on revele quand meme l'iframe pour ne pas laisser le skeleton infini.
fallbackTimer = setTimeout(() => {
revealIframe()
}, 2500)
})
onUnmounted(() => {
resizeObs?.disconnect()
resizeObs = null
if (fallbackTimer) {
clearTimeout(fallbackTimer)
fallbackTimer = null
}
})
const onIframeLoad = () => {
if (fallbackTimer) {
clearTimeout(fallbackTimer)
fallbackTimer = null
}
revealIframe()
}
</script>
<template>
<div class="embed-dynamique h-full flex flex-col relative">
<div class="h-full">
<div ref="wrapperRef" class="relative h-full bg-neutral-100 overflow-hidden">
<div
v-if="!skeletonHidden"
id="embed-skeleton"
class="absolute inset-0 flex items-center justify-center bg-neutral-50 animate-pulse"
>
<span class="text-neutral-400 text-sm">Chargement de la carte AEP...</span>
</div>
<iframe
src="https://aep.trans-former.fr/agences"
title="Carte AEP"
class="absolute top-0 left-0 border-0 opacity-0 transition-opacity duration-500"
:style="iframeStyle"
sandbox="allow-scripts allow-same-origin allow-popups allow-forms"
@load="onIframeLoad"
ref="iframeRef"
></iframe>
</div>
</div>
</div>
</template>
+50 -77
View File
@@ -1,57 +1,16 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
interface BeholdPost {
id: string;
permalink: string;
mediaUrl: string;
thumbnailUrl?: string;
caption?: string;
mediaType: 'IMAGE' | 'VIDEO' | 'CAROUSEL_ALBUM';
timestamp: string;
}
const props = defineProps<{
feedId: string;
defineProps<{
account: string;
accountUrl: string;
fallbackBio?: string;
secondAccount: string;
secondAccountUrl: string;
feedImages: string[]; // ordonné du plus récent (haut) au plus ancien (bas)
}>();
const posts = ref<BeholdPost[]>([]);
const loading = ref(true);
const error = ref<string | null>(null);
const isPlaceholder = (id: string) => !id || id.startsWith('PLACEHOLDER_');
onMounted(async () => {
if (isPlaceholder(props.feedId)) {
loading.value = false;
error.value = 'no-feed-id';
return;
}
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
const res = await fetch(`https://feeds.behold.so/${props.feedId}`, {
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!res.ok) throw new Error(`Behold returned ${res.status}`);
const data = await res.json();
const items: BeholdPost[] = Array.isArray(data) ? data : (data.posts ?? []);
posts.value = items.slice(0, 6);
} catch (e) {
error.value = (e as Error).message || 'fetch-error';
} finally {
loading.value = false;
}
});
</script>
<template>
<section class="border-b border-neutral-200 last:border-b-0">
<header class="px-4 py-3 flex items-center justify-between">
<section class="h-full flex flex-col border-b border-neutral-200 last:border-b-0">
<header class="shrink-0 px-4 py-2 flex items-center justify-between gap-3">
<a
:href="accountUrl"
target="_blank"
@@ -60,48 +19,62 @@ onMounted(async () => {
>
{{ account }}
</a>
<span v-if="loading" class="text-xs text-neutral-400">...</span>
</header>
<div v-if="loading" class="grid grid-cols-2 gap-1 p-1">
<div
v-for="i in 4"
:key="i"
class="aspect-square bg-neutral-100 animate-pulse"
/>
</div>
<div
v-else-if="posts.length"
class="grid grid-cols-2 gap-1 p-1"
>
<a
v-for="post in posts"
:key="post.id"
:href="post.permalink"
:href="secondAccountUrl"
target="_blank"
rel="noopener"
class="block aspect-square overflow-hidden group"
class="font-medium text-sm text-neutral-500 hover:underline hover:text-neutral-800"
>
<img
:src="post.thumbnailUrl || post.mediaUrl"
:alt="post.caption?.slice(0, 80) || account"
loading="lazy"
class="w-full h-full object-cover group-hover:scale-105 transition-transform"
/>
{{ secondAccount }}
</a>
</div>
</header>
<div v-else class="p-4 text-sm text-neutral-600">
<p v-if="fallbackBio" class="mb-3">{{ fallbackBio }}</p>
<div class="insta-feed-scroll flex-1 min-h-0">
<a
:href="accountUrl"
target="_blank"
rel="noopener"
class="inline-block px-3 py-2 bg-neutral-900 text-white rounded-lg text-sm"
:aria-label="`Voir le compte Instagram ${account}`"
>
Voir sur Instagram &rarr;
<img
v-for="(src, i) in feedImages"
:key="i"
:src="src"
alt=""
loading="lazy"
class="block w-full feed-img"
/>
</a>
</div>
<div class="shrink-0 px-2 py-1">
<span
class="block text-center px-3 py-1 bg-neutral-200 text-neutral-400 rounded text-xs cursor-not-allowed"
aria-disabled="true"
title="Boutique — bientôt disponible"
>
Boutique
</span>
</div>
</section>
</template>
<style scoped>
.insta-feed-scroll {
overflow-y: auto;
scrollbar-width: thin;
}
.insta-feed-scroll::-webkit-scrollbar {
width: 6px;
}
.insta-feed-scroll::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.25);
border-radius: 3px;
}
.insta-feed-scroll::-webkit-scrollbar-track {
background: transparent;
}
.feed-img + .feed-img {
margin-top: 2px;
}
</style>
+190 -3
View File
@@ -1,9 +1,196 @@
<script setup lang="ts">
// Placeholder journal list — PC6 lit public/data/journal.json
import { ref, computed, onMounted, onUnmounted } from 'vue'
interface JournalItem {
id: string
platform: 'substack' | 'gitea' | 'github' | 'instagram' | 'castopod' | 'blog' | 'linkedin'
hashtag: string
date: string
titre: string
extrait: string
url: string
thumbnail: string | null
}
interface JournalPayload {
generatedAt: string
items: JournalItem[]
counts?: Record<string, number>
}
const items = ref<JournalItem[]>([])
const filters = ref<Record<string, boolean>>({})
const platformFilter = ref<string | null>(null)
const loading = ref(true)
const errored = ref(false)
const empty = ref(false)
const JOURNAL_URL =
(import.meta as unknown as { env: Record<string, string | undefined> }).env
.PUBLIC_JOURNAL_URL || 'https://data.trans-former.fr/journal.json'
const STORAGE_KEY = 'tf-hashtag-filters'
const loadFilters = () => {
try {
filters.value = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}')
} catch {
filters.value = {}
}
}
const onFilterChange = (e: Event) => {
const ce = e as CustomEvent
if (ce.detail && typeof ce.detail === 'object') {
filters.value = { ...ce.detail }
}
}
const onPlatformChange = (e: Event) => {
const ce = e as CustomEvent
platformFilter.value = ce.detail?.platform ?? null
}
onMounted(async () => {
loadFilters()
window.addEventListener('hashtag-filter-change', onFilterChange as EventListener)
window.addEventListener('platform-filter-change', onPlatformChange as EventListener)
try {
const res = await fetch(JOURNAL_URL, { cache: 'no-store' })
if (!res.ok) {
errored.value = true
return
}
const data = (await res.json()) as JournalPayload
items.value = Array.isArray(data.items) ? data.items : []
if (items.value.length === 0) empty.value = true
} catch (e) {
errored.value = true
console.error('JournalList fetch failed', e)
} finally {
loading.value = false
}
})
onUnmounted(() => {
window.removeEventListener('hashtag-filter-change', onFilterChange as EventListener)
window.removeEventListener('platform-filter-change', onPlatformChange as EventListener)
})
const onItemClick = (item: JournalItem, e: MouseEvent) => {
if (e.metaKey || e.ctrlKey) {
window.open(item.url, '_blank', 'noopener')
return
}
e.preventDefault()
window.dispatchEvent(new CustomEvent('journal-item-click', { detail: { item } }))
}
const visibleItems = computed(() => {
const keys = Object.keys(filters.value)
let filtered: JournalItem[]
if (keys.length === 0) {
filtered = items.value
} else {
const activeKeys = keys.filter((k) => filters.value[k])
if (activeKeys.length === 0 && keys.some((k) => filters.value[k] === false)) {
filtered = []
} else if (activeKeys.length === 0) {
filtered = items.value
} else {
filtered = items.value.filter((it) => activeKeys.includes(it.hashtag))
}
}
if (platformFilter.value) {
filtered = filtered.filter((it) => it.platform === platformFilter.value)
}
return filtered
})
const formatDate = (iso: string) => {
try {
const d = new Date(iso)
if (isNaN(d.getTime())) return ''
return `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}`
} catch {
return ''
}
}
const platformLabel = (p: string) => {
switch (p) {
case 'gitea':
case 'github':
return 'code'
case 'substack':
return 'substack'
case 'instagram':
return 'insta'
case 'castopod':
return 'pod'
case 'blog':
return 'blog'
case 'linkedin':
return 'linkedin'
default:
return p
}
}
</script>
<template>
<div class="h-full w-full flex items-center justify-center text-sm text-neutral-400">
Journal list placeholder (PC6)
<div class="space-y-3">
<p v-if="loading" class="text-neutral-400 italic text-xs">
Chargement du journal...
</p>
<p v-else-if="errored" class="text-neutral-400 italic text-xs">
Journal en cours d'agregation. Reviens dans quelques heures.
</p>
<p v-else-if="empty" class="text-neutral-400 italic text-xs">
Aucun item agrege. Le cron nocturne tourne a 3h.
</p>
<p
v-else-if="!visibleItems.length"
class="text-neutral-400 italic text-xs"
>
Aucun item ; ajuste les filtres.
</p>
<article
v-for="item in visibleItems"
:key="item.id"
class="border-b border-neutral-100 pb-3 last:border-b-0"
>
<a
:href="item.url"
target="_blank"
rel="noopener noreferrer"
class="block group"
@click="onItemClick(item, $event)"
>
<div class="flex items-baseline gap-2 text-[11px] text-neutral-500 mb-1">
<time>{{ formatDate(item.date) }}</time>
<span class="font-mono text-neutral-700">{{ item.hashtag }}</span>
<span class="text-neutral-400">{{ platformLabel(item.platform) }}</span>
</div>
<h4
class="text-sm text-neutral-900 group-hover:text-neutral-600 leading-snug"
>
{{ item.titre }}
</h4>
<p
v-if="item.extrait"
class="text-xs text-neutral-500 mt-1 line-clamp-2"
>
{{ item.extrait }}
</p>
<img
v-if="item.thumbnail"
:src="item.thumbnail"
:alt="item.titre"
loading="lazy"
class="mt-2 w-full max-h-32 object-cover rounded"
/>
</a>
</article>
</div>
</template>
+165
View File
@@ -0,0 +1,165 @@
<script setup lang="ts">
// V1.3-E : contenu manifeste pour preview centrale (mode racine /).
// TODO V1.4 : extraire contenu manifeste dans src/content/manifeste.md (content collection)
// pour avoir une source unique partagee avec src/pages/manifeste.astro.
// Actuellement : duplication consciente du texte (~50 lignes) entre les 2 fichiers.
// Si modif texte -> repercuter aux 2 endroits (this file + manifeste.astro).
</script>
<template>
<article class="manifeste-content px-4 md:px-6 py-6 md:py-8 bg-white">
<!-- En-tete -->
<header class="mb-8 md:mb-10">
<p class="text-[10px] uppercase tracking-widest text-neutral-500 mb-2">
Manifeste
</p>
<h1 class="text-xl md:text-2xl font-semibold text-neutral-900 leading-tight">
Architecture d'Écologie Politique
</h1>
<p class="mt-3 text-neutral-600 text-sm md:text-base leading-relaxed">
Un commun vivant pour bifurquer ; ensemble, lentement, par accumulation de petits gestes situés.
</p>
<!-- V1.3-F : CTA "commander" deplace tout en bas (apres lecture). Le seul lien d'entete = page complete. -->
<div class="mt-5 flex flex-wrap gap-2">
<a
href="/manifeste"
class="inline-block px-4 py-2 border border-neutral-300 text-neutral-700 rounded-lg text-sm hover:border-neutral-900 hover:text-neutral-900 transition-colors"
title="Ouvrir la page complete dans un nouvel onglet"
target="_blank"
rel="noopener"
>
Page complète &rarr;
</a>
</div>
</header>
<!-- Corps du manifeste -->
<div class="text-neutral-800 text-[14px] md:text-[15px] leading-[1.7] space-y-4">
<p class="italic text-neutral-700">
Un quart des architectes vivent sous le seuil de pauvreté. La moitié de nos heures, non facturées. Nos cotisations, parmi les plus lourdes des professions réglementées. Et le secteur du bâtiment, à lui seul, pèse 34% des émissions mondiales de gaz à effet de serre.
</p>
<p>
Quelque chose s'est rompu ; pas dans nos vies, dans les cadres qui les contiennent.
</p>
<p>
Notre profession ne traverse pas une simple crise. Elle reflète l'effondrement d'un monde qui confond performance et destruction, signature et silence, expertise et soumission.
</p>
<hr class="border-neutral-200 my-6" />
<h2 class="text-base md:text-lg font-semibold text-neutral-900 mt-8 mb-3">
Ce que nous voyons.
</h2>
<p>
À l'échelle du métier, une profession structurellement sous l'eau, qui absorbe les tensions d'un système extractiviste ; et porte la responsabilité quand d'autres captent la valeur.
</p>
<p>
À l'échelle des corps, une culture qui rend l'exploitation désirable : métier-passion, modèle starchitecte, isolement libéral, moteur critique délégitimant. Nous tenons. Nous payons.
</p>
<p>
À l'échelle du monde, l'effondrement écologique et social qui avance, pendant que notre voix s'efface du débat public. Notre silence le sert.
</p>
<hr class="border-neutral-200 my-6" />
<h2 class="text-base md:text-lg font-semibold text-neutral-900 mt-8 mb-3">
Ce que nous refusons.
</h2>
<p>
Nous ne signerons plus pour des projets qui détruisent.<br />
Nous n'isolerons plus celles et ceux qui doutent.<br />
Nous ne porterons plus seul-es ce qui doit se penser, se faire ; et se soigner ; ensemble.
</p>
<hr class="border-neutral-200 my-6" />
<!-- Pivot : centre emotionnel du texte (italique + retrait) -->
<blockquote class="my-8 md:my-10 px-4 md:px-6 py-4 border-l-4 border-neutral-900 italic text-neutral-800 text-sm md:text-base leading-relaxed">
<p class="font-medium not-italic mb-2 text-neutral-900">
Et pourtant, quelque chose tient.
</p>
<p>
Pas l'espoir naïf, ni la promesse héroïque. Quelque chose de plus humble : la fatigue commune reconnue, et l'envie qui revient de ne plus économiser sa vie.
</p>
</blockquote>
<hr class="border-neutral-200 my-6" />
<h2 class="text-base md:text-lg font-semibold text-neutral-900 mt-8 mb-3">
Ce que nous tentons.
</h2>
<p>
<em class="font-semibold not-italic">Partager.</em> Nos parcours, nos doutes, nos bifurcations. Se former les un-es les autres. Se tendre la main. Documenter ce qui marche, ce qui rate. Le personnel devient politique quand il se met en commun.
</p>
<p>
<em class="font-semibold not-italic">Construire.</em> L'infrastructure collective qui nous a manqué. Cartes d'entraide, communs documentés, gouvernance horizontale, financement transparent, infra souveraine. <strong>Architecture d'Écologie Politique</strong> : un commun vivant, ouvert, biorégional, ancré.
</p>
<p>
<em class="font-semibold not-italic">Pratiquer une médecine du corps social.</em> Diagnostiquer les infrastructures qui défaillent ; l'éducation, la justice, la sécurité, l'énergie, la santé, le logement, l'agriculture. Proposer des reconfigurations situées, territoire par territoire. Reprendre le pouvoir par la base. Écrire, lentement, un nouveau contrat social.
</p>
<p>
<em class="font-semibold not-italic">Commencer par les marges.</em> Là où le corps social souffre le plus, là où il est le plus prêt à changer. Ne pas décider à la place ; faire émerger. Transparence totale, sur le process et sur l'argent. Tendresse militante : la lucidité sans le mépris, l'engagement sans la dureté.
</p>
<hr class="border-neutral-200 my-6" />
<h2 class="text-base md:text-lg font-semibold text-neutral-900 mt-8 mb-3">
Architectes, allié-es, habitant-es.
</h2>
<p>
Nous avons un travail à faire ensemble. Lentement, patiemment, par accumulation de petits gestes situés. Pas pour fuir ; pour bifurquer.
</p>
<!-- Chute : italique, separee -->
<p class="mt-6 md:mt-8 italic text-neutral-900 text-sm md:text-base leading-relaxed">
Nos métiers sont des médecines. Reprenons-en le pouls ; à mains nues, ensemble.
</p>
</div>
<!-- V1.3-F : CTA commander version papier — bord rouge brique #B5443A, signal politique fort.
Place en bas, apres lecture : j'achete, tranquille. -->
<section class="mt-10 pt-6 border-t border-neutral-200 text-center">
<p class="text-xs italic text-neutral-500 mb-3">
Manifeste lu. J'achete la version papier, tranquille.
</p>
<a
href="/manifeste/commander"
class="inline-block px-5 py-2.5 border-2 border-[#B5443A] text-[#0F172A] bg-[#FAFAF7] font-medium text-sm hover:bg-[#E5C3BE] transition-colors"
>
Commander la version papier
</a>
</section>
<!-- Footer simple : lien vers page complete pour les detail (mouvements, etc.) -->
<footer class="mt-6 pt-4 border-t border-neutral-200 text-center">
<a
href="/manifeste"
target="_blank"
rel="noopener"
class="text-xs text-neutral-500 hover:text-neutral-900 underline"
>
Voir la page complète (mouvements M/S/XL, liens articles) &rarr;
</a>
</footer>
</article>
</template>
<style scoped>
.manifeste-content {
font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
}
</style>
+220
View File
@@ -0,0 +1,220 @@
<script setup lang="ts">
// V1.3-E : PreviewArticle gere 2 modes :
// - 'article' (V1.2-P existant) : teaser + embed Substack / Instagram / CTA propagation
// - 'manifeste' (V1.3-E) : affiche <ManifesteContent /> dans la preview centrale
// Events ecoutes :
// - 'journal-item-click' -> mode 'article' (legacy V1.2-P, conserve tel quel)
// - 'preview-open' -> nouvel event unifie, payload { type: 'article' | 'manifeste', data?: ... }
// - 'preview-close-request'-> close externe
// Event emis : 'preview-close' (utilise par ColCentre pour restaurer la mise en page).
import { ref, computed, onMounted, onUnmounted } from 'vue'
import ManifesteContent from './ManifesteContent.vue'
interface JournalItem {
id: string
platform: 'substack' | 'gitea' | 'github' | 'instagram' | 'castopod' | 'blog' | 'linkedin'
hashtag: string
date: string
titre: string
extrait: string
url: string
thumbnail: string | null
}
type PreviewMode = 'article' | 'manifeste' | null
const mode = ref<PreviewMode>(null)
const selectedItem = ref<JournalItem | null>(null)
const onJournalItemClick = (e: Event) => {
const ce = e as CustomEvent
if (ce.detail?.item) {
selectedItem.value = ce.detail.item
mode.value = 'article'
}
}
const onPreviewOpen = (e: Event) => {
const ce = e as CustomEvent
const type = ce.detail?.type
if (type === 'manifeste') {
mode.value = 'manifeste'
selectedItem.value = null
} else if (type === 'article' && ce.detail?.item) {
selectedItem.value = ce.detail.item
mode.value = 'article'
}
}
const onPreviewCloseExternal = () => {
selectedItem.value = null
mode.value = null
}
onMounted(() => {
window.addEventListener('journal-item-click', onJournalItemClick as EventListener)
window.addEventListener('preview-open', onPreviewOpen as EventListener)
window.addEventListener('preview-close-request', onPreviewCloseExternal as EventListener)
})
onUnmounted(() => {
window.removeEventListener('journal-item-click', onJournalItemClick as EventListener)
window.removeEventListener('preview-open', onPreviewOpen as EventListener)
window.removeEventListener('preview-close-request', onPreviewCloseExternal as EventListener)
})
const close = () => {
selectedItem.value = null
mode.value = null
window.dispatchEvent(new CustomEvent('preview-close'))
}
const extractInstaShortcode = (url: string): string | null => {
const m = url.match(/\/(p|reel)\/([^\/\?#]+)/)
return m ? m[2] : null
}
const embedUrl = computed(() => {
if (!selectedItem.value) return null
const item = selectedItem.value
if (item.platform === 'substack') {
return item.url.includes('?') ? item.url + '&embed=1' : item.url + '?embed=1'
}
if (item.platform === 'instagram') {
const sc = extractInstaShortcode(item.url)
return sc ? `https://www.instagram.com/p/${sc}/embed/` : null
}
return null
})
const platformLabel = (p: string) => {
const labels: Record<string, string> = {
substack: 'Substack',
instagram: 'Instagram',
gitea: 'Gitea',
github: 'GitHub',
castopod: 'Podcast',
blog: 'Blog',
linkedin: 'LinkedIn',
}
return labels[p] || p
}
const formatDate = (iso: string) => {
try {
const d = new Date(iso)
return isNaN(d.getTime())
? ''
: `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}`
} catch {
return ''
}
}
</script>
<template>
<Transition name="preview-fade">
<div v-if="mode" class="preview-article border border-neutral-200 rounded overflow-hidden bg-white flex flex-col">
<!-- Header : reset + label (hashtag article OU "Manifeste") -->
<div class="flex items-center justify-between px-4 py-2 border-b border-neutral-200 bg-white">
<button
class="text-xs text-neutral-500 hover:text-neutral-900 flex items-center gap-1"
@click="close"
type="button"
>
- Retour a la carte
</button>
<span
v-if="mode === 'article' && selectedItem"
class="text-xs text-neutral-400"
style="font-family: 'Courier New', Courier, monospace;"
>
{{ selectedItem.hashtag }}
</span>
<span
v-else-if="mode === 'manifeste'"
class="text-xs text-neutral-400"
style="font-family: 'Courier New', Courier, monospace;"
>
#manifeste
</span>
</div>
<!-- Mode 'manifeste' : contenu manifeste -->
<div v-if="mode === 'manifeste'" class="bg-white">
<ManifesteContent />
</div>
<!-- Mode 'article' : V1.2-P legacy -->
<template v-else-if="mode === 'article' && selectedItem">
<!-- Teaser -->
<div class="px-4 py-3 border-b border-neutral-100 bg-neutral-50">
<p class="text-[11px] text-neutral-500 mb-1">{{ formatDate(selectedItem.date) }} - {{ selectedItem.platform }}</p>
<h3 class="text-sm font-semibold text-neutral-900 leading-snug mb-1">{{ selectedItem.titre }}</h3>
<p v-if="selectedItem.extrait" class="text-xs text-neutral-600 leading-relaxed line-clamp-3">
{{ selectedItem.extrait }}
</p>
<img
v-if="selectedItem.thumbnail"
:src="selectedItem.thumbnail"
:alt="selectedItem.titre"
class="mt-2 w-full max-h-28 object-cover rounded"
loading="lazy"
/>
</div>
<!-- Embed live (Substack ou Instagram) -->
<div v-if="embedUrl" class="bg-white" style="height: 60vh; min-height: 400px;">
<iframe
:src="embedUrl"
class="w-full h-full border-0"
:title="selectedItem.titre"
loading="lazy"
sandbox="allow-scripts allow-same-origin allow-popups allow-forms allow-top-navigation"
></iframe>
</div>
<!-- Carte incitative (autres plateformes) -->
<div v-else class="flex flex-col items-start justify-center px-4 py-6">
<p class="text-xs text-neutral-500 mb-3 italic">Embed non disponible pour cette plateforme.</p>
<a
:href="selectedItem.url"
target="_blank"
rel="noopener noreferrer"
class="inline-block px-4 py-2 bg-neutral-900 text-white text-sm rounded-lg hover:bg-neutral-700 transition-colors"
>
Voir sur {{ platformLabel(selectedItem.platform) }} ->
</a>
</div>
<!-- CTA propagation universel -->
<div class="px-4 py-3 border-t border-neutral-100">
<a
:href="selectedItem.url"
target="_blank"
rel="noopener noreferrer"
class="text-xs text-neutral-500 hover:text-neutral-900 underline"
>
Continuer sur {{ platformLabel(selectedItem.platform) }} - commenter, partager
</a>
</div>
</template>
</div>
</Transition>
</template>
<style scoped>
/* V1.4-E2 : animation legere ouverture/fermeture preview (fade + slide-up subtil) */
.preview-fade-enter-active,
.preview-fade-leave-active {
transition: opacity 0.25s ease, transform 0.25s ease;
}
.preview-fade-enter-from {
opacity: 0;
transform: translateY(8px);
}
.preview-fade-leave-to {
opacity: 0;
transform: translateY(4px);
}
</style>
+12
View File
@@ -22,12 +22,17 @@ const resetFade = () => {
}, 3000);
};
const emitPositionChange = (pos: number) => {
document.dispatchEvent(new CustomEvent('swipe-position-change', { detail: { pos } }));
};
onMounted(() => {
if (!emblaApi.value) return;
emblaApi.value.on('select', () => {
if (!emblaApi.value) return;
selectedIndex.value = emblaApi.value.selectedScrollSnap();
sessionStorage.setItem('pc-position', String(selectedIndex.value));
emitPositionChange(selectedIndex.value);
resetFade();
});
const saved = sessionStorage.getItem('pc-position');
@@ -35,6 +40,13 @@ onMounted(() => {
const idx = Number(saved);
if (!Number.isNaN(idx)) emblaApi.value.scrollTo(idx, false);
}
// Ecoute les clics de la MobileTabBar
document.addEventListener('mobile-tab-scroll', (e: Event) => {
const detail = (e as CustomEvent<{ pos: number }>).detail;
if (detail && typeof detail.pos === 'number') {
emblaApi.value?.scrollTo(detail.pos);
}
});
resetFade();
});
+97 -12
View File
@@ -1,33 +1,118 @@
---
import '../styles/global.css';
import Footer from '../components/astro/Footer.astro';
import SiteHeader from '../components/astro/SiteHeader.astro';
interface Props {
title?: string;
description?: string;
ogImage?: string;
canonical?: string;
/** Article JSON-LD : passer true sur les pages articles (manifeste, etc.) */
isArticle?: boolean;
articleDate?: string;
articleDescription?: string;
}
const {
title = 'trans-former.fr',
description = 'Page-cerveau : journal, mindmap AEP, Insta',
title,
description,
ogImage,
canonical,
isArticle = false,
articleDate,
articleDescription,
} = Astro.props;
const SITE_NAME = 'trans-former.fr';
const SITE_URL = 'https://trans-former.fr';
const DEFAULT_DESC = "Architecture, ecologie, politique : un commun a construire ensemble. Manifeste et infrastructure vivante de Jules NENY.";
const DEFAULT_OG = '/og-default.png';
const fullTitle = title ? `${title} - ${SITE_NAME}` : SITE_NAME;
const finalDesc = description || DEFAULT_DESC;
const finalCanonical = canonical || new URL(Astro.url.pathname, SITE_URL).href;
const finalOg = ogImage || DEFAULT_OG;
const finalOgAbsolute = finalOg.startsWith('http') ? finalOg : `${SITE_URL}${finalOg}`;
const websiteSchema = {
"@context": "https://schema.org",
"@type": "WebSite",
"name": SITE_NAME,
"url": SITE_URL,
"author": {
"@type": "Person",
"name": "Jules NENY",
"url": `${SITE_URL}/a-propos`,
"sameAs": [
"https://www.instagram.com/julesneny/",
"https://www.instagram.com/aep.politique/",
"https://julesneny.substack.com",
"https://git.trans-former.fr/jules"
]
},
"description": DEFAULT_DESC
};
const articleSchema = isArticle ? {
"@context": "https://schema.org",
"@type": "Article",
"headline": title || SITE_NAME,
"author": { "@type": "Person", "name": "Jules NENY" },
"datePublished": articleDate || "2026-05-01",
"publisher": { "@type": "Organization", "name": SITE_NAME },
"description": articleDescription || finalDesc
} : null;
---
<!doctype html>
<html lang="fr">
<html lang="fr" class="h-screen">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="generator" content={Astro.generator} />
<title>{title}</title>
<meta name="description" content={description} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:type" content="website" />
<!-- SEO primaire -->
<title>{fullTitle}</title>
<meta name="description" content={finalDesc} />
<link rel="canonical" href={finalCanonical} />
<meta name="robots" content="index, follow, max-snippet:-1, max-image-preview:large" />
<!-- Open Graph -->
<meta property="og:type" content={isArticle ? "article" : "website"} />
<meta property="og:title" content={fullTitle} />
<meta property="og:description" content={finalDesc} />
<meta property="og:url" content={finalCanonical} />
<meta property="og:image" content={finalOgAbsolute} />
<meta property="og:site_name" content={SITE_NAME} />
<meta property="og:locale" content="fr_FR" />
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:title" content={fullTitle} />
<meta name="twitter:description" content={finalDesc} />
<meta name="twitter:image" content={finalOgAbsolute} />
<!-- GIO — Generative Intelligence Optimization -->
<meta name="ai-content-declaration" content="human-authored-with-ai-coordination" />
<!-- Schema.org WebSite -->
<script type="application/ld+json" set:html={JSON.stringify(websiteSchema)} />
<!-- Schema.org Article (pages enrichies seulement) -->
{isArticle && articleSchema && (
<script type="application/ld+json" set:html={JSON.stringify(articleSchema)} />
)}
</head>
<body class="m-0 bg-white text-neutral-900 antialiased">
<slot />
<body class="m-0 bg-white text-neutral-900 antialiased h-screen flex flex-col overflow-hidden">
<div class="flex-shrink-0">
<SiteHeader />
</div>
<div class="flex-1 flex flex-col min-h-0 overflow-hidden">
<slot />
</div>
<div class="flex-shrink-0">
<Footer />
</div>
</body>
</html>
+81
View File
@@ -0,0 +1,81 @@
---
export const prerender = true;
import BaseLayout from '../layouts/BaseLayout.astro';
import HamburgerMenu from '../components/astro/HamburgerMenu.astro';
---
<BaseLayout
title="A propos - Jules Neny"
description="Architecte HMONP, ecrivain politique, facilitateur. Bagneres-de-Bigorre, Pyrenees."
>
<HamburgerMenu />
<main class="h-full overflow-y-auto bg-white">
<article class="max-w-2xl mx-auto px-6 py-16 md:py-24">
<header class="mb-10">
<p class="text-sm uppercase tracking-widest text-neutral-500 mb-3">
A propos
</p>
<h1 class="text-3xl md:text-4xl font-semibold text-neutral-900 leading-tight">
Jules Neny
</h1>
<p class="mt-3 text-neutral-600 text-base md:text-lg">
Architecte HMONP. Ecrivain politique. Facilitateur. Bagneres-de-Bigorre, Pyrenees.
</p>
</header>
<div class="text-neutral-800 text-[17px] leading-[1.75] space-y-6">
<p>
Je suis architecte, forme a l'ENSAPB (Paris-Belleville), avec un passage par Oxford Brookes et un bootcamp Perspectives. Je travaille en independant sous le nom <em>Transformations resilientes</em>, basee dans les Pyrenees. Mon metier de base : audit energetique (MAR), maitrise d'oeuvre en renovation et construction ecologique, accompagnement a l'autonomie (eau, energie, jardin).
</p>
<p>
Mais l'architecture, pour moi, deborde du bati. C'est aussi une lecture des systemes ; un outil de diagnostic du corps social. La ou l'eau, l'energie, la justice, l'education, la sante, le logement et l'agriculture defaillent ; il y a des reconfigurations a inventer, situees, par territoire. Reprendre le pouvoir par la base. Ecrire, lentement, un nouveau contrat social.
</p>
<p>
Trois fils tissent mon travail :
</p>
<ul class="list-disc list-outside pl-6 space-y-2">
<li>
<strong>Architecte</strong> ; reveler les problematiques complexes des systemes (diagnostic systemique, pratique de terrain).
</li>
<li>
<strong>Ecrivain politique</strong> ; transmettre et problematiser via le manifeste <em>Architecture d'Ecologie Politique</em>, le blog trans-former.fr, et bientot Substack.
</li>
<li>
<strong>Facilitateur</strong> ; accompagner la transformation democratique (intelligence collective, formations).
</li>
</ul>
<p>
Ma boussole : <em>me transformer pour contribuer a la transformation du monde</em>. Mes valeurs : emancipation, liens, amour. Mes pratiques quotidiennes : ecriture du matin, yoga Isha, marche en montagne, peinture a l'huile, cuisine.
</p>
<p>
Ce site est un journal vivant. Il agrege ce que je publie sur plusieurs plateformes (blog, Substack, LinkedIn, Instagram, Castopod, GitHub) ; et tente de rendre lisible une demarche qui se fait dans le temps long.
</p>
<p>
Pour me joindre :
<a
href="mailto:julesneny8@gmail.com"
class="underline underline-offset-2 hover:text-neutral-900"
>julesneny8@gmail.com</a>.
</p>
</div>
<footer class="mt-12 pt-8 border-t border-neutral-200 text-sm text-neutral-500">
<p>
<a href="/" class="underline underline-offset-2 hover:text-neutral-900">
&larr; Retour au journal
</a>
</p>
</footer>
</article>
</main>
</BaseLayout>
+44
View File
@@ -0,0 +1,44 @@
---
// Page TEMPORAIRE d'apercu des 17 accroches de la Carte O (P3 §3, brouillon MOE 29/09).
// Sert a la relecture de Jules : chaque node avec l'accroche qui s'affichera au survol.
// A SUPPRIMER avant tout merge dans main.
import carte from '../../public/data/carte-o.json';
export const prerender = true;
const nodes = carte.nodes as Array<{ id: string; label: string; problematique?: string; niveau?: number }>;
---
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8" />
<meta name="robots" content="noindex" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>apercu-accroches</title>
<style>
body { font-family: system-ui, sans-serif; background: #f7f4ee; color: #1d1d1b; margin: 0; padding: 32px 16px; }
main { max-width: 760px; margin: 0 auto; }
h1 { font-size: 1.1rem; font-weight: 600; margin: 0 0 4px; }
p.note { margin: 0 0 24px; color: #5b5a55; font-size: .9rem; }
ol { list-style: none; padding: 0; margin: 0; display: grid; gap: 10px; }
li { background: #fff; border: 1px solid #e3ded3; border-radius: 8px; padding: 12px 14px; }
li.centre { border-color: #1d1d1b; }
.label { font-size: .75rem; text-transform: uppercase; letter-spacing: .04em; color: #6b6a64; }
.id { font-family: ui-monospace, monospace; font-size: .7rem; color: #9a978f; margin-left: 6px; text-transform: none; }
.acc { font-size: 1.05rem; line-height: 1.4; margin-top: 4px; }
.manque { color: #b3261e; }
</style>
</head>
<body>
<main>
<h1>Carte O : accroches au survol (brouillon, à valider)</h1>
<p class="note">{nodes.filter((n) => n.problematique).length} accroches sur {nodes.length} nodes, lues dans <code>public/data/carte-o.json</code>.</p>
<ol>
{nodes.map((n, i) => (
<li class={n.niveau === 0 ? 'centre' : ''}>
<div class="label">{i + 1}. {n.label}<span class="id">{n.id}</span></div>
<div class={n.problematique ? 'acc' : 'acc manque'}>{n.problematique ?? 'aucune accroche'}</div>
</li>
))}
</ol>
</main>
</body>
</html>
+196
View File
@@ -0,0 +1,196 @@
import type { APIRoute } from 'astro'
export const prerender = false
const KIT_API_BASE = 'https://api.kit.com/v4'
const TAG_NAME = 'AEP - sans perso'
function htmlPage(title: string, heading: string, message: string): string {
return `<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${title} — trans-former.fr</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-neutral-50 text-neutral-900 min-h-screen flex items-center justify-center p-6">
<main class="max-w-md w-full bg-white rounded-xl shadow-sm border border-neutral-200 p-8 text-center">
<h1 class="text-2xl font-semibold mb-4">${heading}</h1>
<p class="text-neutral-600 leading-relaxed">${message}</p>
<a href="https://www.trans-former.fr"
class="mt-6 inline-block text-sm text-neutral-500 underline underline-offset-2 hover:text-neutral-900">
&larr; Retour sur trans-former.fr
</a>
</main>
</body>
</html>`
}
async function getSubscriberByEmail(email: string, apiKey: string): Promise<string | null> {
const url = `${KIT_API_BASE}/subscribers?email_address=${encodeURIComponent(email)}`
const res = await fetch(url, {
headers: { 'X-Kit-Api-Key': apiKey },
signal: AbortSignal.timeout(10000),
})
if (!res.ok) return null
const data = await res.json()
if (!data?.subscribers?.length) return null
return String(data.subscribers[0].id)
}
async function getTagId(apiKey: string): Promise<number | null> {
const url = `${KIT_API_BASE}/tags`
const res = await fetch(url, {
headers: { 'X-Kit-Api-Key': apiKey },
signal: AbortSignal.timeout(10000),
})
if (!res.ok) return null
const data = await res.json()
const tag = data?.tags?.find((t: any) => t.name === TAG_NAME)
return tag?.id ?? null
}
async function tagSubscriber(subscriberId: string, tagId: number, apiKey: string): Promise<boolean> {
const url = `${KIT_API_BASE}/tags/${tagId}/subscribers`
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Kit-Api-Key': apiKey,
},
body: JSON.stringify({ id: subscriberId }),
signal: AbortSignal.timeout(10000),
})
return res.ok || res.status === 409
}
async function untagSubscriber(subscriberId: string, tagId: number, apiKey: string): Promise<boolean> {
const url = `${KIT_API_BASE}/tags/${tagId}/subscribers/${subscriberId}`
const res = await fetch(url, {
method: 'DELETE',
headers: { 'X-Kit-Api-Key': apiKey },
signal: AbortSignal.timeout(10000),
})
return res.ok || res.status === 404
}
export const GET: APIRoute = async ({ url }) => {
const KIT_API_KEY = import.meta.env.KIT_API_SECRET_V4
if (!KIT_API_KEY) {
return new Response(htmlPage(
'Erreur',
'Configuration manquante',
'Le service de désinscription n\'est pas configuré. Contacte-moi à <a href="mailto:julesneny8@gmail.com" class="underline">julesneny8@gmail.com</a>.'
), {
status: 500,
headers: { 'Content-Type': 'text/html; charset=utf-8' },
})
}
const email = (url.searchParams.get('email') || '').trim().toLowerCase()
const action = url.searchParams.get('action')
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return new Response(htmlPage(
'Lien invalide',
'Lien invalide',
'Le lien que tu as suivi est incomplet ou mal formé. Essaye de copier-coller l\'URL complète depuis l\'email.'
), {
status: 400,
headers: { 'Content-Type': 'text/html; charset=utf-8' },
})
}
if (action !== 'unsubscribe' && action !== 'resubscribe') {
return new Response(htmlPage(
'Action inconnue',
'Action inconnue',
'Le lien que tu as suivi ne correspond à aucune action connue.'
), {
status: 400,
headers: { 'Content-Type': 'text/html; charset=utf-8' },
})
}
try {
const subscriberId = await getSubscriberByEmail(email, KIT_API_KEY)
if (!subscriberId) {
return new Response(htmlPage(
'Email inconnu',
'Email inconnu',
'Cet email n\'est pas abonné à la newsletter. Si tu penses qu\'il s\'agit d\'une erreur, écris-moi à <a href="mailto:julesneny8@gmail.com" class="underline">julesneny8@gmail.com</a>.'
), {
status: 404,
headers: { 'Content-Type': 'text/html; charset=utf-8' },
})
}
const tagId = await getTagId(KIT_API_KEY)
if (!tagId) {
return new Response(htmlPage(
'Erreur',
'Configuration manquante',
'Le tag de désinscription est introuvable. Contacte-moi à <a href="mailto:julesneny8@gmail.com" class="underline">julesneny8@gmail.com</a>.'
), {
status: 500,
headers: { 'Content-Type': 'text/html; charset=utf-8' },
})
}
if (action === 'unsubscribe') {
const result = await tagSubscriber(subscriberId, tagId, KIT_API_KEY)
if (!result) {
return new Response(htmlPage(
'Erreur',
'Erreur technique',
'Une erreur est survenue lors de la désinscription. Réessaie ou écris-moi à <a href="mailto:julesneny8@gmail.com" class="underline">julesneny8@gmail.com</a>.'
), {
status: 500,
headers: { 'Content-Type': 'text/html; charset=utf-8' },
})
}
return new Response(htmlPage(
'Désinscription réussie',
'Tu es désinscrit·e',
'Tu ne recevras plus les lettres «&nbsp;perso&nbsp;» de l\'infolettre <em>Architecture d\'Écologie Politique</em>. Tu continues à recevoir les textes de fond.<br><br>Si tu changes d\'avis, un lien de réabonnement est disponible dans chaque email de fond.'
), {
status: 200,
headers: { 'Content-Type': 'text/html; charset=utf-8' },
})
}
if (action === 'resubscribe') {
const result = await untagSubscriber(subscriberId, tagId, KIT_API_KEY)
if (!result) {
return new Response(htmlPage(
'Erreur',
'Erreur technique',
'Une erreur est survenue lors du réabonnement. Réessaie ou écris-moi à <a href="mailto:julesneny8@gmail.com" class="underline">julesneny8@gmail.com</a>.'
), {
status: 500,
headers: { 'Content-Type': 'text/html; charset=utf-8' },
})
}
return new Response(htmlPage(
'Réabonnement réussi',
'Tu es de nouveau abonné·e',
'Tu recevras à nouveau les lettres «&nbsp;perso&nbsp;» de l\'infolettre <em>Architecture d\'Écologie Politique</em>. Merci de ton intérêt !'
), {
status: 200,
headers: { 'Content-Type': 'text/html; charset=utf-8' },
})
}
return new Response(null, { status: 400 })
} catch (e) {
return new Response(htmlPage(
'Erreur',
'Erreur technique',
'Une erreur inattendue est survenue. Réessaie ou écris-moi à <a href="mailto:julesneny8@gmail.com" class="underline">julesneny8@gmail.com</a>.'
), {
status: 500,
headers: { 'Content-Type': 'text/html; charset=utf-8' },
})
}
}
+65
View File
@@ -0,0 +1,65 @@
// PC7 — Endpoint proxy POST /api/chatbot
// Forward la question vers CHATBOT_UPSTREAM (default : aep.trans-former.fr/api/chatbot).
// Tunnel CORS (l'upstream ne sert pas de header CORS pour trans-former.fr) + timeout 25s.
import type { APIRoute } from 'astro'
export const prerender = false
const UPSTREAM =
import.meta.env.CHATBOT_UPSTREAM || 'https://aep.trans-former.fr/api/chatbot'
const TIMEOUT_MS = 25_000
const jsonResponse = (status: number, payload: unknown) =>
new Response(JSON.stringify(payload), {
status,
headers: { 'Content-Type': 'application/json; charset=utf-8' },
})
export const POST: APIRoute = async ({ request }) => {
let body: { question?: string; q?: string; history?: unknown }
try {
body = await request.json()
} catch {
return jsonResponse(400, { error: 'invalid_json' })
}
const question = (body.question || body.q || '').toString().trim()
if (!question) {
return jsonResponse(400, { error: 'missing_question' })
}
const ctrl = new AbortController()
const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS)
try {
const upstream = await fetch(UPSTREAM, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
question,
history: Array.isArray(body.history) ? body.history : [],
}),
signal: ctrl.signal,
})
clearTimeout(timer)
const text = await upstream.text()
const contentType =
upstream.headers.get('content-type') || 'application/json; charset=utf-8'
return new Response(text, {
status: upstream.status,
headers: { 'Content-Type': contentType },
})
} catch (e) {
clearTimeout(timer)
const err = e as Error
const aborted = err.name === 'AbortError'
return jsonResponse(aborted ? 504 : 502, {
error: aborted ? 'upstream_timeout' : 'upstream_failed',
detail: err.message || 'unknown',
})
}
}
+71
View File
@@ -0,0 +1,71 @@
import type { APIRoute } from 'astro'
export const prerender = false
const KIT_API_BASE = 'https://api.kit.com/v4'
export const POST: APIRoute = async ({ request }) => {
const KIT_API_KEY = import.meta.env.KIT_API_SECRET_V4
if (!KIT_API_KEY) {
return new Response(JSON.stringify({ error: 'config_missing' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
})
}
let body: { email: string; first_name?: string }
try {
body = await request.json()
} catch {
return new Response(JSON.stringify({ error: 'invalid_json' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
})
}
const email = (body.email || '').trim().toLowerCase()
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return new Response(JSON.stringify({ error: 'invalid_email' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
})
}
try {
const upstream = await fetch(`${KIT_API_BASE}/subscribers`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Kit-Api-Key': KIT_API_KEY,
},
body: JSON.stringify({
email_address: email,
first_name: body.first_name || undefined,
state: 'active',
}),
signal: AbortSignal.timeout(10000),
})
if (upstream.ok) {
return new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}
if (upstream.status === 422) {
return new Response(JSON.stringify({ ok: true, already: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}
return new Response(
JSON.stringify({ error: 'kit_failed', status: upstream.status }),
{ status: 502, headers: { 'Content-Type': 'application/json' } }
)
} catch (e) {
return new Response(
JSON.stringify({ error: 'upstream_failed', detail: (e as Error).message }),
{ status: 502, headers: { 'Content-Type': 'application/json' } }
)
}
}
+109 -7
View File
@@ -1,29 +1,131 @@
---
export const prerender = true;
import BaseLayout from '../layouts/BaseLayout.astro';
import ColJournal from '../components/astro/ColJournal.astro';
import ColCentre from '../components/astro/ColCentre.astro';
import ColInsta from '../components/astro/ColInsta.astro';
import SwipeContainer from '../components/vue/SwipeContainer.vue';
import HamburgerMenu from '../components/astro/HamburgerMenu.astro';
import MobileTabBar from '../components/astro/MobileTabBar.astro';
import PopupOnboarding from '../components/astro/PopupOnboarding.astro';
---
<BaseLayout title="trans-former.fr">
<HamburgerMenu />
<MobileTabBar />
<PopupOnboarding />
<!-- Desktop : grid 3 colonnes -->
<div class="hidden md:grid md:grid-cols-[320px_1fr_320px] h-screen overflow-hidden">
<aside class="border-r border-neutral-200 overflow-y-auto"><ColJournal /></aside>
<main class="overflow-hidden"><ColCentre /></main>
<aside class="border-l border-neutral-200 overflow-y-auto"><ColInsta /></aside>
<!-- Desktop : grid 3 colonnes V1.4-A avec toggle CACHER latéraux (bidirectionnel).
Layout dynamique pilote par CSS vars --col-left / --col-right (320px visible, 0 cache).
Re-clic = bidirectionnel : col reapparait, centre se retracte. sessionStorage persist.
V1.4-bis FIX 2 : suppression du couplage display:none + width:0 (conflit grid).
On utilise EXCLUSIVEMENT --col-left/--col-right (0px = aside disparait visuellement).
Le contenu de l'aside est cache via visibility hidden (preserve la place 0 sans bug grid).
Grid template utilise minmax(0,1fr) pour que la col centre ne s'effondre pas quand les
voisins sont a 0 (cas critique 2 togglees). -->
<div
id="desktop-grid"
class="hidden md:grid h-full overflow-hidden relative"
style="grid-template-columns: var(--col-left, 320px) minmax(0, 1fr) var(--col-right, 320px);"
>
<aside id="col-left-aside" class="border-r border-neutral-200 overflow-hidden h-full"><div class="h-full overflow-y-auto"><ColJournal /></div></aside>
<main class="overflow-hidden h-full relative" style="min-width: 0;">
<ColCentre />
<!-- Toggle gauche : pose sur le bord gauche de la colonne centrale.
Icone < quand col visible (clic = cacher) ; > quand col cachee (clic = rouvrir). -->
<button
type="button"
id="toggle-col-left"
aria-label="Cacher/afficher colonne gauche"
class="hidden md:flex absolute left-0 top-1/2 -translate-y-1/2 z-20 w-5 h-12 items-center justify-center bg-white border border-neutral-200 border-l-0 rounded-r text-neutral-500 hover:bg-neutral-50 hover:text-neutral-900 transition-colors cursor-pointer"
style="font-family:'Courier New',Courier,monospace;font-size:14px;line-height:1;"
>
<span id="toggle-col-left-icon">&lsaquo;</span>
</button>
<!-- Toggle droite : pose sur le bord droit de la colonne centrale.
Icone > quand col visible (clic = cacher) ; < quand col cachee (clic = rouvrir). -->
<button
type="button"
id="toggle-col-right"
aria-label="Cacher/afficher colonne droite"
class="hidden md:flex absolute right-0 top-1/2 -translate-y-1/2 z-20 w-5 h-12 items-center justify-center bg-white border border-neutral-200 border-r-0 rounded-l text-neutral-500 hover:bg-neutral-50 hover:text-neutral-900 transition-colors cursor-pointer"
style="font-family:'Courier New',Courier,monospace;font-size:14px;line-height:1;"
>
<span id="toggle-col-right-icon">&rsaquo;</span>
</button>
</main>
<aside id="col-right-aside" class="border-l border-neutral-200 overflow-hidden h-full"><div class="h-full overflow-y-auto"><ColInsta /></div></aside>
</div>
<!-- Mobile : SwipeContainer Vue island -->
<div class="md:hidden h-screen overflow-hidden">
<!-- Mobile : SwipeContainer Vue island - tabbar 44px reserve dans la zone flex-1 -->
<div class="md:hidden h-full overflow-hidden" style="padding-top: 44px;">
<SwipeContainer client:load>
<ColJournal slot="left" />
<ColCentre slot="center" />
<ColInsta slot="right" />
</SwipeContainer>
</div>
<script>
// V1.4-A : toggle CACHER colonnes laterales (bidirectionnel, focus simple).
// - Clic bouton gauche / droite -> cache la col concernee (width 0 + display:none).
// - Re-clic -> re-affiche. ColCentre s'elargit ou se retracte selon le grid recalcul.
// - Etat persiste en sessionStorage (survit aux refresh dans la meme tab).
const KEY_LEFT = 'tf-col-left-collapsed';
const KEY_RIGHT = 'tf-col-right-collapsed';
const grid = document.getElementById('desktop-grid') as HTMLElement | null;
const asideLeft = document.getElementById('col-left-aside') as HTMLElement | null;
const asideRight = document.getElementById('col-right-aside') as HTMLElement | null;
const btnLeft = document.getElementById('toggle-col-left') as HTMLButtonElement | null;
const btnRight = document.getElementById('toggle-col-right') as HTMLButtonElement | null;
const iconLeft = document.getElementById('toggle-col-left-icon');
const iconRight = document.getElementById('toggle-col-right-icon');
let collapsedLeft = false;
let collapsedRight = false;
try {
collapsedLeft = sessionStorage.getItem(KEY_LEFT) === '1';
collapsedRight = sessionStorage.getItem(KEY_RIGHT) === '1';
} catch { /* mode prive */ }
// V1.4-bis FIX 2 : UNE SEULE technique (CSS var width 0px) — pas de display:none
// qui creait un conflit grid : quand display:none + grid-column 0 cohabitent,
// certains navigateurs ne recalculent pas la col centre correctement → centre disparu.
// Maintenant : aside reste dans le flux avec width 0 (overflow hidden cache le contenu)
// + le contenu interne avec visibility hidden pour ne pas etre focusable au tab.
const apply = () => {
if (!grid) return;
grid.style.setProperty('--col-left', collapsedLeft ? '0px' : '320px');
grid.style.setProperty('--col-right', collapsedRight ? '0px' : '320px');
// Aside reste en flux (display: '' par defaut) pour ne pas casser le grid template.
// Visibility hidden + tabindex -1 sur le contenu pour empecher interaction inutile.
if (asideLeft) {
asideLeft.style.visibility = collapsedLeft ? 'hidden' : '';
asideLeft.setAttribute('aria-hidden', collapsedLeft ? 'true' : 'false');
}
if (asideRight) {
asideRight.style.visibility = collapsedRight ? 'hidden' : '';
asideRight.setAttribute('aria-hidden', collapsedRight ? 'true' : 'false');
}
// Icones : pointe vers la direction d'ouverture (sens depliage).
if (iconLeft) iconLeft.innerHTML = collapsedLeft ? '&rsaquo;' : '&lsaquo;';
if (iconRight) iconRight.innerHTML = collapsedRight ? '&lsaquo;' : '&rsaquo;';
};
apply();
btnLeft?.addEventListener('click', () => {
collapsedLeft = !collapsedLeft;
try { sessionStorage.setItem(KEY_LEFT, collapsedLeft ? '1' : '0'); } catch { /* mode prive */ }
apply();
});
btnRight?.addEventListener('click', () => {
collapsedRight = !collapsedRight;
try { sessionStorage.setItem(KEY_RIGHT, collapsedRight ? '1' : '0'); } catch { /* mode prive */ }
apply();
});
</script>
</BaseLayout>
+227 -4
View File
@@ -1,9 +1,232 @@
---
export const prerender = true;
import BaseLayout from '../layouts/BaseLayout.astro';
import HamburgerMenu from '../components/astro/HamburgerMenu.astro';
---
<BaseLayout title="Manifeste — trans-former.fr">
<main class="max-w-2xl mx-auto p-6">
<h1 class="text-2xl font-semibold mb-4">Manifeste</h1>
<p class="text-sm text-neutral-500">Placeholder — PC2 importe le contenu manifeste depuis astro-site existant.</p>
<BaseLayout
title="Manifeste - Architecture d'Ecologie Politique"
description="Manifeste AEP : un commun vivant pour une architecture d'ecologie politique. Architectes, allie-es, habitant-es."
isArticle={true}
articleDate="2026-05-01"
articleDescription="Manifeste fondateur des Agences d'Ecologie Politique - un commun vivant pour bifurquer ensemble."
>
<HamburgerMenu />
<main class="h-full overflow-y-auto bg-white">
<article class="max-w-2xl mx-auto px-6 py-16 md:py-24">
<!-- En-tete -->
<header class="mb-12 md:mb-16">
<p class="text-sm uppercase tracking-widest text-neutral-500 mb-3">
Manifeste
</p>
<h1 class="text-3xl md:text-4xl font-semibold text-neutral-900 leading-tight">
Architecture d'Ecologie Politique
</h1>
<p class="mt-4 text-neutral-600 text-base md:text-lg leading-relaxed">
Un commun vivant pour bifurquer ; ensemble, lentement, par accumulation de petits gestes situes.
</p>
<!-- V1.3-F : CTA "commander" deplace tout en bas de page (apres avoir lu). -->
</header>
<!-- Corps du manifeste -->
<div class="text-neutral-800 text-[17px] md:text-[18px] leading-[1.75] space-y-6">
<p class="italic text-neutral-700">
Un quart des architectes vivent sous le seuil de pauvrete. La moitie de nos heures, non facturees. Nos cotisations, parmi les plus lourdes des professions reglementees. Et le secteur du batiment, a lui seul, pese 34% des emissions mondiales de gaz a effet de serre.
</p>
<p>
Quelque chose s'est rompu ; pas dans nos vies, dans les cadres qui les contiennent.
</p>
<p>
Notre profession ne traverse pas une simple crise. Elle reflete l'effondrement d'un monde qui confond performance et destruction, signature et silence, expertise et soumission.
</p>
<hr class="border-neutral-200 my-8" />
<h2 class="text-xl md:text-2xl font-semibold text-neutral-900 mt-12 mb-4">
Ce que nous voyons.
</h2>
<p>
A l'echelle du metier, une profession structurellement sous l'eau, qui absorbe les tensions d'un systeme extractiviste ; et porte la responsabilite quand d'autres captent la valeur.
</p>
<p>
A l'echelle des corps, une culture qui rend l'exploitation desirable : metier-passion, modele starchitecte, isolement liberal, moteur critique delegitimant. Nous tenons. Nous payons.
</p>
<p>
A l'echelle du monde, l'effondrement ecologique et social qui avance, pendant que notre voix s'efface du debat public. Notre silence le sert.
</p>
<hr class="border-neutral-200 my-8" />
<h2 class="text-xl md:text-2xl font-semibold text-neutral-900 mt-12 mb-4">
Ce que nous refusons.
</h2>
<p>
Nous ne signerons plus pour des projets qui detruisent.<br />
Nous n'isolerons plus celles et ceux qui doutent.<br />
Nous ne porterons plus seul-es ce qui doit se penser, se faire ; et se soigner ; ensemble.
</p>
<hr class="border-neutral-200 my-8" />
<!-- Pivot : centre emotionnel du texte (italique + retrait) -->
<blockquote class="my-12 md:my-16 px-6 md:px-10 py-6 border-l-4 border-neutral-900 italic text-neutral-800 text-lg md:text-xl leading-relaxed">
<p class="font-medium not-italic mb-3 text-neutral-900">
Et pourtant, quelque chose tient.
</p>
<p>
Pas l'espoir naif, ni la promesse heroique. Quelque chose de plus humble : la fatigue commune reconnue, et l'envie qui revient de ne plus economiser sa vie.
</p>
</blockquote>
<hr class="border-neutral-200 my-8" />
<h2 class="text-xl md:text-2xl font-semibold text-neutral-900 mt-12 mb-4">
Ce que nous tentons.
</h2>
<p>
<em class="font-semibold not-italic">Partager.</em> Nos parcours, nos doutes, nos bifurcations. Se former les un-es les autres. Se tendre la main. Documenter ce qui marche, ce qui rate. Le personnel devient politique quand il se met en commun.
</p>
<p>
<em class="font-semibold not-italic">Construire.</em> L'infrastructure collective qui nous a manque. Cartes d'entraide, communs documentes, gouvernance horizontale, financement transparent, infra souveraine. <strong>Architecture d'Ecologie Politique</strong> : un commun vivant, ouvert, bioregional, ancre.
</p>
<p>
<em class="font-semibold not-italic">Pratiquer une medecine du corps social.</em> Diagnostiquer les infrastructures qui defaillent ; l'education, la justice, la securite, l'energie, la sante, le logement, l'agriculture. Proposer des reconfigurations situees, territoire par territoire. Reprendre le pouvoir par la base. Ecrire, lentement, un nouveau contrat social.
</p>
<p>
<em class="font-semibold not-italic">Commencer par les marges.</em> La ou le corps social souffre le plus, la ou il est le plus pret a changer. Ne pas decider a la place ; faire emerger. Transparence totale, sur le process et sur l'argent. Tendresse militante : la lucidite sans le mepris, l'engagement sans la durete.
</p>
<hr class="border-neutral-200 my-8" />
<h2 class="text-xl md:text-2xl font-semibold text-neutral-900 mt-12 mb-4">
Architectes, allie-es, habitant-es.
</h2>
<p>
Nous avons un travail a faire ensemble. Lentement, patiemment, par accumulation de petits gestes situes. Pas pour fuir ; pour bifurquer.
</p>
<!-- Chute : italique, separee -->
<p class="mt-10 md:mt-12 italic text-neutral-900 text-lg md:text-xl leading-relaxed">
Nos metiers sont des medecines. Reprenons-en le pouls ; a mains nues, ensemble.
</p>
</div>
<!-- Diagramme ASCII : recap des mouvements -->
<section class="mt-16 md:mt-20 pt-10 border-t border-neutral-200">
<h2 class="text-xl md:text-2xl font-semibold text-neutral-900 mb-6">
Les mouvements
</h2>
<div class="space-y-6 text-sm">
<div class="border border-neutral-200 rounded-lg p-5 bg-neutral-50">
<h3 class="font-mono text-xs uppercase tracking-wider text-neutral-500 mb-3">
M / Profession
</h3>
<ul class="space-y-2">
<li>
<a href="https://trans-former.fr/aep-m1-echm/" class="text-neutral-800 hover:text-neutral-900 underline underline-offset-2 decoration-neutral-300 hover:decoration-neutral-900 transition-colors">
Anatomie d'une profession sous l'eau
</a>
</li>
<li>
<a href="https://trans-former.fr/aep-m4-echxl/" class="text-neutral-800 hover:text-neutral-900 underline underline-offset-2 decoration-neutral-300 hover:decoration-neutral-900 transition-colors">
La filiere beton, dispositif d'extraction
</a>
</li>
<li>
<a href="https://aep.trans-former.fr" class="text-neutral-800 hover:text-neutral-900 underline underline-offset-2 decoration-neutral-300 hover:decoration-neutral-900 transition-colors">
Pratiques regeneratives (carto vivante)
</a>
</li>
</ul>
</div>
<div class="border border-neutral-200 rounded-lg p-5 bg-neutral-50">
<h3 class="font-mono text-xs uppercase tracking-wider text-neutral-500 mb-3">
S / Individu
</h3>
<ul class="space-y-2">
<li>
<a href="https://trans-former.fr/aep-m2-echs/" class="text-neutral-800 hover:text-neutral-900 underline underline-offset-2 decoration-neutral-300 hover:decoration-neutral-900 transition-colors">
Ce qui nous maintient sous l'eau
</a>
</li>
<li>
<a href="https://trans-former.fr/aep-m6-pivot/" class="text-neutral-800 hover:text-neutral-900 underline underline-offset-2 decoration-neutral-300 hover:decoration-neutral-900 transition-colors">
Resistance et regeneration (M6)
</a>
</li>
<li>
<a href="https://trans-former.fr/aep-m8-action/" class="text-neutral-800 hover:text-neutral-900 underline underline-offset-2 decoration-neutral-300 hover:decoration-neutral-900 transition-colors">
Action et poeme (M8)
</a>
</li>
</ul>
</div>
<div class="border border-neutral-200 rounded-lg p-5 bg-neutral-50">
<h3 class="font-mono text-xs uppercase tracking-wider text-neutral-500 mb-3">
XL / Corps social
</h3>
<ul class="space-y-2">
<li>
<a href="https://trans-former.fr/aep-m4-echxl/" class="text-neutral-800 hover:text-neutral-900 underline underline-offset-2 decoration-neutral-300 hover:decoration-neutral-900 transition-colors">
Polycrise et logiciel patriarcal-imperial
</a>
</li>
<li class="text-neutral-400 italic">
Medecine du corps social ; esquisse [a venir, ete 2026]
</li>
<li class="text-neutral-400 italic">
Le nouveau contrat social ; Le Livre [horizon 10 ans]
</li>
</ul>
</div>
</div>
</section>
<!-- V1.3-F : CTA commander version papier — bord rouge brique #B5443A, signal politique fort.
Place en bas : le lecteur a lu, il sait ce qu'il achete. Tranquille. -->
<section class="mt-16 pt-10 border-t border-neutral-200 text-center">
<p class="text-sm italic text-neutral-500 mb-4">
Manifeste lu. J'achete la version papier, tranquille.
</p>
<a
href="/manifeste/commander"
class="inline-block px-6 py-3 border-2 border-[#B5443A] text-[#0F172A] bg-[#FAFAF7] font-medium text-base hover:bg-[#E5C3BE] transition-colors"
>
Commander la version papier
</a>
</section>
<!-- En lire plus -->
<footer class="mt-16 pt-10 border-t border-neutral-200 text-center">
<p class="text-neutral-600 mb-4">En lire plus</p>
<a
href="https://www.trans-former.fr/"
class="inline-block px-5 py-2.5 bg-neutral-900 text-white rounded-lg font-medium hover:bg-neutral-700 transition-colors"
>
Blog trans-former.fr &rarr;
</a>
<p class="mt-4 text-xs text-neutral-400">
bientot sur Substack
</p>
</footer>
</article>
</main>
</BaseLayout>
+114
View File
@@ -0,0 +1,114 @@
---
export const prerender = true;
import BaseLayout from '../../layouts/BaseLayout.astro';
import HamburgerMenu from '../../components/astro/HamburgerMenu.astro';
---
<BaseLayout
title="Commander la version imprimee - Manifeste AEP"
description="Pre-inscription pour la version imprimee du manifeste Architecture d'Ecologie Politique."
>
<HamburgerMenu />
<main class="h-full overflow-y-auto bg-white">
<article class="max-w-xl mx-auto px-6 py-16 md:py-24">
<header class="mb-10">
<p class="text-sm uppercase tracking-widest text-neutral-500 mb-3">
Manifeste imprime
</p>
<h1 class="text-3xl md:text-4xl font-semibold text-neutral-900 leading-tight">
Commander la version imprimee
</h1>
</header>
<div class="text-neutral-700 text-base md:text-lg leading-relaxed space-y-5">
<p>
La version imprimee du manifeste sera disponible prochainement ; tirage limite, papier recycle, format A5.
</p>
<p>
Inscrivez-vous pour etre averti-e de la mise en vente. Vous serez prevenu-e en priorite, sans engagement.
</p>
</div>
<!-- Form pre-inscription (V1 stub, V1.1 cable Listmonk) -->
<form
id="manifeste-preinscription"
class="mt-10 flex flex-col gap-3"
novalidate
>
<label for="email" class="text-sm font-medium text-neutral-700">
Votre email
</label>
<input
id="email"
name="email"
type="email"
required
autocomplete="email"
placeholder="prenom@exemple.fr"
class="px-4 py-3 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:border-neutral-900"
/>
<button
type="submit"
class="mt-2 px-5 py-3 bg-neutral-900 text-white rounded-lg font-medium hover:bg-neutral-700 transition-colors"
>
Etre averti-e
</button>
<p
id="form-feedback"
class="text-sm text-neutral-600 hidden"
aria-live="polite"
></p>
</form>
<p class="mt-8 text-sm text-neutral-500">
<a
href="/manifeste"
class="underline underline-offset-2 hover:text-neutral-900 transition-colors"
>
&larr; Retour au manifeste
</a>
</p>
</article>
</main>
</BaseLayout>
<script>
// V1 stub : capture email en localStorage, V1.1 = POST Listmonk
const form = document.getElementById('manifeste-preinscription') as HTMLFormElement | null;
const feedback = document.getElementById('form-feedback');
form?.addEventListener('submit', (e) => {
e.preventDefault();
const data = new FormData(form);
const email = String(data.get('email') || '').trim();
if (!email || !email.includes('@')) {
if (feedback) {
feedback.textContent = 'Merci de saisir un email valide.';
feedback.classList.remove('hidden');
feedback.classList.add('text-red-600');
}
return;
}
// Stockage local en attendant le cable Listmonk (V1.1)
try {
const existing = JSON.parse(
localStorage.getItem('tf-manifeste-preinscriptions') || '[]'
);
existing.push({ email, ts: Date.now() });
localStorage.setItem('tf-manifeste-preinscriptions', JSON.stringify(existing));
} catch {
// mode prive : on ignore
}
if (feedback) {
feedback.textContent = 'Merci ; vous serez prevenu-e des sa disponibilite.';
feedback.classList.remove('hidden', 'text-red-600');
feedback.classList.add('text-green-700');
}
form.reset();
});
</script>
+94
View File
@@ -0,0 +1,94 @@
---
export const prerender = true;
import BaseLayout from '../layouts/BaseLayout.astro';
import HamburgerMenu from '../components/astro/HamburgerMenu.astro';
---
<BaseLayout
title="Mentions legales - trans-former.fr"
description="Mentions legales du site trans-former.fr."
>
<HamburgerMenu />
<main class="h-full overflow-y-auto bg-white">
<article class="max-w-2xl mx-auto px-6 py-16 md:py-24">
<header class="mb-10">
<p class="text-sm uppercase tracking-widest text-neutral-500 mb-3">
Mentions
</p>
<h1 class="text-3xl md:text-4xl font-semibold text-neutral-900 leading-tight">
Mentions legales
</h1>
</header>
<div class="text-neutral-800 text-[16px] leading-[1.7] space-y-8">
<section>
<h2 class="text-lg font-semibold text-neutral-900 mb-2">Editeur</h2>
<p>
Jules Neny, architecte HMONP.<br />
<em>Transformations resilientes</em><br />
Bagneres-de-Bigorre, Pyrenees, France.<br />
SIRET : 888 668 860 00016.<br />
TVA : non applicable, art. 293B du CGI.<br />
Inscription : Ordre des Architectes, CROA Occitanie.<br />
RC Pro : MAF n 71183.
</p>
</section>
<section>
<h2 class="text-lg font-semibold text-neutral-900 mb-2">Contact</h2>
<p>
<a
href="mailto:julesneny8@gmail.com"
class="underline underline-offset-2 hover:text-neutral-900"
>julesneny8@gmail.com</a>
</p>
</section>
<section>
<h2 class="text-lg font-semibold text-neutral-900 mb-2">Hebergement</h2>
<p>
Hetzner Online GmbH<br />
Industriestr. 25, 91710 Gunzenhausen, Allemagne.<br />
<a
href="https://www.hetzner.com/"
class="underline underline-offset-2 hover:text-neutral-900"
>hetzner.com</a>
</p>
</section>
<section>
<h2 class="text-lg font-semibold text-neutral-900 mb-2">Donnees personnelles</h2>
<p>
Ce site n'utilise pas de cookies analytiques ni de traceurs publicitaires. Les donnees techniques de connexion (logs serveur) sont conservees a des fins de securite et de diagnostic, conformement aux obligations legales.
</p>
<p class="mt-3">
Les emails collectes via les formulaires de pre-inscription (manifeste imprime, newsletter) ne sont pas cedes a des tiers et servent uniquement aux finalites annoncees.
</p>
</section>
<section>
<h2 class="text-lg font-semibold text-neutral-900 mb-2">Propriete intellectuelle</h2>
<p>
Les contenus publies (textes, images, code) sont, sauf mention contraire, la propriete de Jules Neny. Reutilisation autorisee a des fins non commerciales avec attribution. Pour tout autre usage, contact prealable.
</p>
</section>
<p class="text-xs text-neutral-400 italic pt-4">
Page V1 ; les details (statut juridique a confirmer, politique cookies si ajoute, mediateur consommation) seront completes en V1.1.
</p>
</div>
<footer class="mt-12 pt-8 border-t border-neutral-200 text-sm text-neutral-500">
<p>
<a href="/" class="underline underline-offset-2 hover:text-neutral-900">
&larr; Retour au journal
</a>
</p>
</footer>
</article>
</main>
</BaseLayout>
+20
View File
@@ -0,0 +1,20 @@
---
// Page TEMPORAIRE de test du pont Tailwind 4 (@transformer/ui) — P3 §1.
// Sert a verifier que le pont compile les utilitaires et que les tokens
// se resolvent en vraies valeurs. A supprimer apres validation.
export const prerender = true;
---
<!doctype html>
<html lang="fr">
<head><meta charset="utf-8" /><title>pont-test</title></head>
<body class="bg-bg text-text">
<div class="bg-surface text-text-muted border-border">surface / muted / border</div>
<div class="bg-primary text-on-primary">primary / on-primary</div>
<div class="bg-accent text-on-accent">accent / on-accent</div>
<div class="text-link hover:text-link-hover">lien</div>
<div class="bg-success bg-warning bg-danger">etats</div>
<div class="font-(family-name:--font-heading) rounded-(--radius-md) shadow-(--shadow-md) ease-(--ease-out)">
syntaxe var arbitraire (namespaces non mappes)
</div>
</body>
</html>
+27
View File
@@ -1 +1,28 @@
@import "tailwindcss";
/* Pont design system (P3 §1) : tokens + mappings @theme Tailwind 4.
Pas de reset.css ni theme/*.css tant que la palette racine n'est pas tranchee —
le bridge seul ne change aucun rendu (il ne definit que des variables). */
@import "@transformer/ui/tokens.css";
@import "@transformer/ui/tailwind.css";
/* Typographie monospace - labels editoriaux (V1.1-G.1) */
.font-mono-editorial,
.hashtag-label,
.nature-badge,
.carte-o-label {
font-family: 'Courier New', Courier, monospace;
}
/* Corps de texte pages statiques (V1.1-G.2) */
.prose-page {
font-size: 1.0625rem;
line-height: 1.75;
color: #374151;
max-width: 65ch;
}
.prose-page h1, .prose-page h2, .prose-page h3 {
color: #111827;
font-weight: 600;
line-height: 1.3;
}