Compare commits

..
Author SHA1 Message Date
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
32 changed files with 490 additions and 163 deletions
+6
View File
@@ -13,6 +13,7 @@
"@astrojs/vue": "^6.0.1", "@astrojs/vue": "^6.0.1",
"@fontsource-variable/roboto-condensed": "^5.2.8", "@fontsource-variable/roboto-condensed": "^5.2.8",
"@tailwindcss/vite": "^4.2.4", "@tailwindcss/vite": "^4.2.4",
"@transformer/ui": "git+https://git.trans-former.fr/jules/design-system.git",
"@types/d3": "^7.4.3", "@types/d3": "^7.4.3",
"astro": "^6.3.1", "astro": "^6.3.1",
"d3": "^7.9.0", "d3": "^7.9.0",
@@ -2366,6 +2367,11 @@
"vite": "^5.2.0 || ^6 || ^7 || ^8" "vite": "^5.2.0 || ^6 || ^7 || ^8"
} }
}, },
"node_modules/@transformer/ui": {
"version": "0.1.0",
"resolved": "git+https://git.trans-former.fr/jules/design-system.git#b7f4f21b7c41258d976a878a8a3c2d4ce45a2ede",
"license": "UNLICENSED"
},
"node_modules/@types/d3": { "node_modules/@types/d3": {
"version": "7.4.3", "version": "7.4.3",
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
+1
View File
@@ -20,6 +20,7 @@
"@astrojs/vue": "^6.0.1", "@astrojs/vue": "^6.0.1",
"@fontsource-variable/roboto-condensed": "^5.2.8", "@fontsource-variable/roboto-condensed": "^5.2.8",
"@tailwindcss/vite": "^4.2.4", "@tailwindcss/vite": "^4.2.4",
"@transformer/ui": "git+https://git.trans-former.fr/jules/design-system.git",
"@types/d3": "^7.4.3", "@types/d3": "^7.4.3",
"astro": "^6.3.1", "astro": "^6.3.1",
"d3": "^7.9.0", "d3": "^7.9.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"version": "1.1", "version": "1.1",
"generatedAt": "2026-05-12T09:28:20.972Z", "generatedAt": "2026-08-17T11:48:54.594Z",
"nodes": [ "nodes": [
{ {
"id": "contrat-social-medecine-corps-social", "id": "contrat-social-medecine-corps-social",
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

+9 -15
View File
@@ -1,24 +1,18 @@
--- ---
import InstaFeed from '../vue/InstaFeed.vue'; import InstaFeed from '../vue/InstaFeed.vue';
// Feed IDs Behold a remplir apres inscription Behold (voir docs/BEHOLD-SETUP.md) const JULESNENY_FEED = Array.from(
const FEED_AEP = import.meta.env.PUBLIC_BEHOLD_AEP || 'PLACEHOLDER_AEP'; { length: 13 },
const FEED_JULESNENY = import.meta.env.PUBLIC_BEHOLD_JULESNENY || 'PLACEHOLDER_JULESNENY'; (_, 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 <InstaFeed
client:visible client:visible
feedId={FEED_AEP} account="Jules Neny"
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"
accountUrl="https://www.instagram.com/julesneny/" 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> </div>
+16 -26
View File
@@ -58,14 +58,13 @@ const categories = [
}, },
]; ];
--- ---
<div class="h-full flex flex-col px-4 pb-4 pt-1 md:pt-6 gap-3 md:gap-5"> <div class="h-full flex flex-col p-4 pt-20 md:pt-6 gap-5">
<!-- Catégories accordeon (V1.5-A.4 : "Hashtags" -> "Catégories") <!-- Hashtags accordeon -->
V1.5-A.5 : ouvert par defaut sur mobile + desktop (collé sous tab actif). --> <details id="hashtags-accordion" class="border-t border-neutral-200 pt-4">
<details id="hashtags-accordion" class="md:border-t md:border-neutral-200 md:pt-4" open>
<summary class="font-semibold cursor-pointer select-none flex items-center justify-between"> <summary class="font-semibold cursor-pointer select-none flex items-center justify-between">
<span>Catégories</span> <span>Hashtags</span>
<span class="text-xs text-neutral-400 font-normal">4 catégories</span> <span class="text-xs text-neutral-400 font-normal">4 categories</span>
</summary> </summary>
<div class="mt-3 flex flex-wrap gap-2" id="category-badges"> <div class="mt-3 flex flex-wrap gap-2" id="category-badges">
@@ -122,10 +121,10 @@ const categories = [
</a> </a>
</details> </details>
<!-- Publication chrono (V1.5-A.2 : "Journal" -> "Publication") --> <!-- Journal chrono -->
<section class="border-t border-neutral-200 pt-4 flex-1 overflow-y-auto"> <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"> <h2 class="font-semibold mb-3 flex items-center justify-between">
<span>Publication</span> <span>Journal</span>
<span class="text-xs text-neutral-400 font-normal">chrono</span> <span class="text-xs text-neutral-400 font-normal">chrono</span>
</h2> </h2>
<div id="journal-list" class="space-y-3 text-sm"> <div id="journal-list" class="space-y-3 text-sm">
@@ -135,11 +134,13 @@ const categories = [
</div> </div>
<script> <script>
// V1.5-A.5 : Catégories ouvert par défaut sur mobile ET desktop.
// (Auparavant : forcé ouvert desktop seulement, replié sur mobile.)
// L'attribut `open` SSR garantit l'état initial — script JS retiré pour ne pas
// refermer l'accordéon au resize cross-breakpoint.
const accordion = document.getElementById('hashtags-accordion') as HTMLDetailsElement | null; 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'); const closeBtn = document.getElementById('accordion-close');
if (closeBtn && accordion) { if (closeBtn && accordion) {
@@ -312,25 +313,14 @@ const categories = [
dispatchPlatform(platformFilters['politique']); dispatchPlatform(platformFilters['politique']);
} }
// V1.3-E + V1.5-A.6 : intercept clics liens Manifeste -> preview centrale (uniquement sur racine /). // V1.3-E : intercept clics liens Manifeste -> preview centrale (uniquement sur racine /)
// Mobile : on dispatche aussi `mobile-tab-scroll` pos:1 pour swiper vers la col centre // Tagger les liens [data-manifeste-link] et router vers event 'preview-open' si on est sur /.
// (sinon la preview ouvre en background sur la col journal — invisible utilisateur). // Sinon : laisser la navigation native vers /manifeste.
// SwipeContainer écoute `mobile-tab-scroll` et fait scrollTo(1).
document.querySelectorAll<HTMLAnchorElement>('a[data-manifeste-link]').forEach((el) => { document.querySelectorAll<HTMLAnchorElement>('a[data-manifeste-link]').forEach((el) => {
el.addEventListener('click', (e) => { el.addEventListener('click', (e) => {
if (window.location.pathname === '/') { if (window.location.pathname === '/') {
e.preventDefault(); e.preventDefault();
// Mobile : naviguer d'abord vers tab Carte (pos:1) puis ouvrir preview après le swipe.
const isMobile = window.matchMedia('(max-width: 767px)').matches;
if (isMobile) {
document.dispatchEvent(new CustomEvent('mobile-tab-scroll', { detail: { pos: 1 } }));
// Laisse le temps au swipe Embla de se positionner avant d'ouvrir la preview
setTimeout(() => {
window.dispatchEvent(new CustomEvent('preview-open', { detail: { type: 'manifeste' } })); window.dispatchEvent(new CustomEvent('preview-open', { detail: { type: 'manifeste' } }));
}, 350);
} else {
window.dispatchEvent(new CustomEvent('preview-open', { detail: { type: 'manifeste' } }));
}
} }
// sinon : navigation normale vers /manifeste (fallback SEO + no-JS) // sinon : navigation normale vers /manifeste (fallback SEO + no-JS)
}); });
+103
View File
@@ -0,0 +1,103 @@
---
// 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-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-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>
+4 -6
View File
@@ -1,22 +1,20 @@
--- ---
// MobileTabBar - indicateur de colonne active sur mobile (V1.1-D.1) // MobileTabBar - indicateur de colonne active sur mobile (V1.1-D.1)
// V1.5-A.3 : suppression border-b (ligne séparatrice sous header mobile supprimée).
// V1.5-A.2 : "Journal" -> "Publication" (vocabulaire visiteur).
// Ecoute l'event "swipe-position-change" emis par SwipeContainer.vue // Ecoute l'event "swipe-position-change" emis par SwipeContainer.vue
// Affiche : Publication | Carte | Insta - colonne active surlignee // Affiche : Journal | Carte | Insta - colonne active surlignee
--- ---
<nav <nav
id="mobile-tab-bar" id="mobile-tab-bar"
aria-label="Navigation colonnes" aria-label="Navigation colonnes"
class="fixed top-12 left-0 right-0 z-30 h-11 bg-white flex items-stretch justify-around md:hidden" 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 <button
type="button" type="button"
data-tab-index="0" data-tab-index="0"
class="mobile-tab flex-1 text-sm px-2 border-b-2 transition-colors" class="mobile-tab flex-1 text-sm px-2 border-b-2 transition-colors"
aria-label="Aller à la publication" aria-label="Aller au journal"
> >
Publication Journal
</button> </button>
<button <button
type="button" type="button"
+1 -1
View File
@@ -12,7 +12,7 @@
import '@fontsource-variable/roboto-condensed/wght.css'; import '@fontsource-variable/roboto-condensed/wght.css';
--- ---
<header <header
class="site-header w-full md:border-b md:border-[#E5E7EB] bg-[#FAFAF7] text-[#0F172A] px-4 md:px-6 flex items-center" class="site-header w-full border-b border-[#E5E7EB] bg-[#FAFAF7] text-[#0F172A] px-4 md:px-6 flex items-center"
role="banner" role="banner"
> >
<!-- Bloc identite (gauche) --> <!-- Bloc identite (gauche) -->
+57 -4
View File
@@ -1,7 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
// V1.5-A.7 : fallback mobile retiré — la Carte O D3-force fonctionne sur viewport <768px
// (testé tel ≥6"). Auparavant : message "optimisée desktop" + miniature SVG statique.
// Pourquoi : décision τ.15 PILOTE trop agressive — la mindmap tourne, juste plus dense.
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, computed } from 'vue'
import CarteO from './CarteO.vue' import CarteO from './CarteO.vue'
import CarteOContextMenu from './CarteOContextMenu.vue' import CarteOContextMenu from './CarteOContextMenu.vue'
@@ -47,6 +44,7 @@ const error = ref<string | null>(null)
const selectedNode = ref<CarteNode | null>(null) const selectedNode = ref<CarteNode | null>(null)
const contextX = ref(0) const contextX = ref(0)
const contextY = ref(0) const contextY = ref(0)
const isMobileScreen = ref(false)
const familyColors = computed(() => const familyColors = computed(() =>
data.value?.meta?.familyColors || { data.value?.meta?.familyColors || {
@@ -65,6 +63,7 @@ function onNodeClick(payload: { node: CarteNode; x: number; y: number }) {
} }
onMounted(async () => { onMounted(async () => {
isMobileScreen.value = window.innerWidth < 768
try { try {
const res = await fetch(props.src) const res = await fetch(props.src)
if (!res.ok) throw new Error(`HTTP ${res.status}`) if (!res.ok) throw new Error(`HTTP ${res.status}`)
@@ -73,13 +72,37 @@ onMounted(async () => {
console.error('[CarteO] failed to load', e) console.error('[CarteO] failed to load', e)
error.value = e?.message || 'Erreur de chargement' error.value = e?.message || 'Erreur de chargement'
} }
window.addEventListener('resize', () => {
isMobileScreen.value = window.innerWidth < 768
})
}) })
</script> </script>
<template> <template>
<div class="wrapper"> <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 --> <!-- Loading state -->
<div v-if="!data && !error" class="state"> <div v-else-if="!data && !error" class="state">
<span>Chargement de la Carte O...</span> <span>Chargement de la Carte O...</span>
</div> </div>
@@ -125,4 +148,34 @@ onMounted(async () => {
.state.error { .state.error {
color: #dc2626; 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> </style>
+2 -28
View File
@@ -1,16 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
// V1.5-A.9 : iframe AEP responsive sur mobile.
// AEP est confirmé responsive (viewport meta + media queries Tailwind md:).
// Mobile (<768px) : on retire le hack scale 0.42 + viewport simulé 1440px,
// l'iframe rend à width:100% / height:100% nativement.
// Desktop (≥768px) : on conserve le rendu desktop forcé (scale dynamique) pour garder
// la mise en page riche de la carte AEP dans la col centre étroite (~480px).
import { ref, computed, onMounted, onUnmounted } from 'vue' import { ref, computed, onMounted, onUnmounted } from 'vue'
const iframeRef = ref<HTMLIFrameElement | null>(null) const iframeRef = ref<HTMLIFrameElement | null>(null)
const wrapperRef = ref<HTMLDivElement | null>(null) const wrapperRef = ref<HTMLDivElement | null>(null)
const skeletonHidden = ref(false) const skeletonHidden = ref(false)
const isMobile = ref(false)
// Force rendu desktop de l'iframe AEP : viewport simulee 1440px + scale dynamique // Force rendu desktop de l'iframe AEP : viewport simulee 1440px + scale dynamique
const VIEWPORT_W = 1440 const VIEWPORT_W = 1440
@@ -23,24 +16,12 @@ const updateScale = () => {
if (w > 0) iframeScale.value = w / VIEWPORT_W if (w > 0) iframeScale.value = w / VIEWPORT_W
} }
const iframeStyle = computed(() => { const iframeStyle = computed(() => ({
if (isMobile.value) {
// Mobile : rendu natif responsive, pas de scale
return {
width: '100%',
height: '100%',
transform: 'none',
transformOrigin: '0 0',
}
}
// Desktop : viewport simulé 1440px + scale dynamique (workaround col étroite)
return {
width: VIEWPORT_W + 'px', width: VIEWPORT_W + 'px',
height: (100 / iframeScale.value) + '%', height: (100 / iframeScale.value) + '%',
transform: `scale(${iframeScale.value})`, transform: `scale(${iframeScale.value})`,
transformOrigin: '0 0', transformOrigin: '0 0',
} }))
})
let fallbackTimer: ReturnType<typeof setTimeout> | null = null let fallbackTimer: ReturnType<typeof setTimeout> | null = null
@@ -52,13 +33,7 @@ const revealIframe = () => {
skeletonHidden.value = true skeletonHidden.value = true
} }
const detectMobile = () => {
isMobile.value = window.matchMedia('(max-width: 767px)').matches
}
onMounted(() => { onMounted(() => {
detectMobile()
window.addEventListener('resize', detectMobile)
if (wrapperRef.value && typeof ResizeObserver !== 'undefined') { if (wrapperRef.value && typeof ResizeObserver !== 'undefined') {
updateScale() updateScale()
resizeObs = new ResizeObserver(updateScale) resizeObs = new ResizeObserver(updateScale)
@@ -73,7 +48,6 @@ onMounted(() => {
onUnmounted(() => { onUnmounted(() => {
resizeObs?.disconnect() resizeObs?.disconnect()
resizeObs = null resizeObs = null
window.removeEventListener('resize', detectMobile)
if (fallbackTimer) { if (fallbackTimer) {
clearTimeout(fallbackTimer) clearTimeout(fallbackTimer)
fallbackTimer = null fallbackTimer = null
+50 -77
View File
@@ -1,57 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue'; defineProps<{
interface BeholdPost {
id: string;
permalink: string;
mediaUrl: string;
thumbnailUrl?: string;
caption?: string;
mediaType: 'IMAGE' | 'VIDEO' | 'CAROUSEL_ALBUM';
timestamp: string;
}
const props = defineProps<{
feedId: string;
account: string; account: string;
accountUrl: 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> </script>
<template> <template>
<section class="border-b border-neutral-200 last:border-b-0"> <section class="h-full flex flex-col border-b border-neutral-200 last:border-b-0">
<header class="px-4 py-3 flex items-center justify-between"> <header class="shrink-0 px-4 py-2 flex items-center justify-between gap-3">
<a <a
:href="accountUrl" :href="accountUrl"
target="_blank" target="_blank"
@@ -60,48 +19,62 @@ onMounted(async () => {
> >
{{ account }} {{ account }}
</a> </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 <a
v-for="post in posts" :href="secondAccountUrl"
:key="post.id"
:href="post.permalink"
target="_blank" target="_blank"
rel="noopener" rel="noopener"
class="block aspect-square overflow-hidden group" class="font-medium text-sm text-neutral-500 hover:underline hover:text-neutral-800"
> >
<img {{ secondAccount }}
: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"
/>
</a> </a>
</div> </header>
<div v-else class="p-4 text-sm text-neutral-600"> <div class="insta-feed-scroll flex-1 min-h-0">
<p v-if="fallbackBio" class="mb-3">{{ fallbackBio }}</p>
<a <a
:href="accountUrl" :href="accountUrl"
target="_blank" target="_blank"
rel="noopener" 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> </a>
</div> </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> </section>
</template> </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>
+3
View File
@@ -2,11 +2,14 @@
export const prerender = true; export const prerender = true;
import BaseLayout from '../layouts/BaseLayout.astro'; import BaseLayout from '../layouts/BaseLayout.astro';
import HamburgerMenu from '../components/astro/HamburgerMenu.astro';
--- ---
<BaseLayout <BaseLayout
title="A propos - Jules Neny" title="A propos - Jules Neny"
description="Architecte HMONP, ecrivain politique, facilitateur. Bagneres-de-Bigorre, Pyrenees." description="Architecte HMONP, ecrivain politique, facilitateur. Bagneres-de-Bigorre, Pyrenees."
> >
<HamburgerMenu />
<main class="h-full overflow-y-auto bg-white"> <main class="h-full overflow-y-auto bg-white">
<article class="max-w-2xl mx-auto px-6 py-16 md:py-24"> <article class="max-w-2xl mx-auto px-6 py-16 md:py-24">
+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' },
})
}
}
+2
View File
@@ -6,10 +6,12 @@ import ColJournal from '../components/astro/ColJournal.astro';
import ColCentre from '../components/astro/ColCentre.astro'; import ColCentre from '../components/astro/ColCentre.astro';
import ColInsta from '../components/astro/ColInsta.astro'; import ColInsta from '../components/astro/ColInsta.astro';
import SwipeContainer from '../components/vue/SwipeContainer.vue'; import SwipeContainer from '../components/vue/SwipeContainer.vue';
import HamburgerMenu from '../components/astro/HamburgerMenu.astro';
import MobileTabBar from '../components/astro/MobileTabBar.astro'; import MobileTabBar from '../components/astro/MobileTabBar.astro';
import PopupOnboarding from '../components/astro/PopupOnboarding.astro'; import PopupOnboarding from '../components/astro/PopupOnboarding.astro';
--- ---
<BaseLayout title="trans-former.fr"> <BaseLayout title="trans-former.fr">
<HamburgerMenu />
<MobileTabBar /> <MobileTabBar />
<PopupOnboarding /> <PopupOnboarding />
+3
View File
@@ -2,6 +2,7 @@
export const prerender = true; export const prerender = true;
import BaseLayout from '../layouts/BaseLayout.astro'; import BaseLayout from '../layouts/BaseLayout.astro';
import HamburgerMenu from '../components/astro/HamburgerMenu.astro';
--- ---
<BaseLayout <BaseLayout
title="Manifeste - Architecture d'Ecologie Politique" title="Manifeste - Architecture d'Ecologie Politique"
@@ -10,6 +11,8 @@ import BaseLayout from '../layouts/BaseLayout.astro';
articleDate="2026-05-01" articleDate="2026-05-01"
articleDescription="Manifeste fondateur des Agences d'Ecologie Politique - un commun vivant pour bifurquer ensemble." articleDescription="Manifeste fondateur des Agences d'Ecologie Politique - un commun vivant pour bifurquer ensemble."
> >
<HamburgerMenu />
<main class="h-full overflow-y-auto bg-white"> <main class="h-full overflow-y-auto bg-white">
<article class="max-w-2xl mx-auto px-6 py-16 md:py-24"> <article class="max-w-2xl mx-auto px-6 py-16 md:py-24">
+3
View File
@@ -2,11 +2,14 @@
export const prerender = true; export const prerender = true;
import BaseLayout from '../../layouts/BaseLayout.astro'; import BaseLayout from '../../layouts/BaseLayout.astro';
import HamburgerMenu from '../../components/astro/HamburgerMenu.astro';
--- ---
<BaseLayout <BaseLayout
title="Commander la version imprimee - Manifeste AEP" title="Commander la version imprimee - Manifeste AEP"
description="Pre-inscription pour la version imprimee du manifeste Architecture d'Ecologie Politique." description="Pre-inscription pour la version imprimee du manifeste Architecture d'Ecologie Politique."
> >
<HamburgerMenu />
<main class="h-full overflow-y-auto bg-white"> <main class="h-full overflow-y-auto bg-white">
<article class="max-w-xl mx-auto px-6 py-16 md:py-24"> <article class="max-w-xl mx-auto px-6 py-16 md:py-24">
+3
View File
@@ -2,11 +2,14 @@
export const prerender = true; export const prerender = true;
import BaseLayout from '../layouts/BaseLayout.astro'; import BaseLayout from '../layouts/BaseLayout.astro';
import HamburgerMenu from '../components/astro/HamburgerMenu.astro';
--- ---
<BaseLayout <BaseLayout
title="Mentions legales - trans-former.fr" title="Mentions legales - trans-former.fr"
description="Mentions legales du site trans-former.fr." description="Mentions legales du site trans-former.fr."
> >
<HamburgerMenu />
<main class="h-full overflow-y-auto bg-white"> <main class="h-full overflow-y-auto bg-white">
<article class="max-w-2xl mx-auto px-6 py-16 md:py-24"> <article class="max-w-2xl mx-auto px-6 py-16 md:py-24">
+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>
+5
View File
@@ -1,4 +1,9 @@
@import "tailwindcss"; @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) */ /* Typographie monospace - labels editoriaux (V1.1-G.1) */
.font-mono-editorial, .font-mono-editorial,