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
23 changed files with 293 additions and 274 deletions
+5 -6
View File
@@ -1,15 +1,14 @@
# Kit (ex-ConvertKit) - newsletter infolettre
KIT_API_SECRET_V4=kit_xxx
# Behold.so : DEPRECATED V1.5-E — remplace par RSSHub self-host (rss.trans-former.fr).
# InstaFeed.vue consomme desormais PUBLIC_JOURNAL_URL (filtre platform=instagram).
# Les 2 vars ci-dessous ne sont plus lues ; conservees pour compat.env.local existant.
# 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 + V1.5-E) - URL JSON agrege par n8n cron 4h UTC
# Sources : RSSHub self-host (Insta @aep.politique + @julesneny + Substack @julesneny)
# + Atom Gitea natif (git.trans-former.fr/jules.atom)
# 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
@@ -1,139 +0,0 @@
{
"name": "journal-aggregate-v2-rsshub",
"_notes": "V1.5-E (2026-05-13) — remplace Behold par RSSHub self-host (rss.trans-former.fr) + ajoute Substack natif. Cron decale a 4h UTC (anti-rate-limit Insta). Si /instagram/user/* renvoie 503 (config Insta absente), le node tombe gracefully et le JSON final aura 0 item insta — ColInsta affichera fallbackBio. A importer dans n8n via UI (Workflows -> Import from File) ou API.",
"nodes": [
{
"parameters": {
"rule": {
"interval": [
{
"field": "cronExpression",
"expression": "0 4 * * *"
}
]
}
},
"id": "schedule-trigger",
"name": "Cron-4h-UTC",
"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, 160]
},
{
"parameters": {
"url": "https://julesneny.substack.com/feed",
"options": {
"response": { "response": { "responseFormat": "text" } },
"timeout": 15000
}
},
"id": "fetch-substack",
"name": "Fetch-substack-natif",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.1,
"position": [460, 280]
},
{
"parameters": {
"url": "https://rss.trans-former.fr/instagram/user/aep.politique",
"options": {
"response": { "response": { "neverError": true, "responseFormat": "text" } },
"timeout": 20000
}
},
"id": "fetch-rsshub-aep",
"name": "Fetch-rsshub-insta-aep",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.1,
"position": [460, 400]
},
{
"parameters": {
"url": "https://rss.trans-former.fr/instagram/user/julesneny",
"options": {
"response": { "response": { "neverError": true, "responseFormat": "text" } },
"timeout": 20000
}
},
"id": "fetch-rsshub-julesneny",
"name": "Fetch-rsshub-insta-julesneny",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.1,
"position": [460, 520]
},
{
"parameters": {
"jsCode": "// V1.5-E : Normalisation 4 sources -> JSON unifie\n// Sources : Gitea Atom (XML), Substack natif (RSS XML), RSSHub Insta x2 (Atom/RSS XML)\n\nconst items = [];\n\n// ---- Helper parse Atom (Gitea) ----\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 const decode = (s) => s\n .replace(/&lt;/g, '<').replace(/&gt;/g, '>')\n .replace(/&quot;/g, '\"').replace(/&#34;/g, '\"')\n .replace(/&#xA;/g, ' ').replace(/&amp;/g, '&')\n .replace(/<[^>]+>/g, ' ').replace(/\\s+/g, ' ').trim();\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: decode(title).slice(0, 140),\n extrait: decode(summary).slice(0, 280),\n url: link,\n thumbnail: null,\n });\n }\n return out;\n}\n\n// ---- Helper parse RSS 2.0 (Substack, RSSHub) ----\nfunction parseRss(xml, platform, hashtag, fallbackUrl) {\n const out = [];\n if (!xml || typeof xml !== 'string') return out;\n const items = xml.split(/<item[\\s>]/i).slice(1);\n for (const raw of items) {\n const block = '<item ' + raw.split(/<\\/item>/i)[0] + '</item>';\n const cdata = (re) => {\n const m = block.match(re);\n if (!m) return '';\n let s = m[1] || '';\n s = s.replace(/<!\\[CDATA\\[([\\s\\S]*?)\\]\\]>/g, '$1');\n return s;\n };\n const title = cdata(/<title[^>]*>([\\s\\S]*?)<\\/title>/i);\n const link = cdata(/<link[^>]*>([\\s\\S]*?)<\\/link>/i).trim();\n const guid = cdata(/<guid[^>]*>([\\s\\S]*?)<\\/guid>/i).trim();\n const desc = cdata(/<description[^>]*>([\\s\\S]*?)<\\/description>/i);\n const pub = cdata(/<pubDate[^>]*>([\\s\\S]*?)<\\/pubDate>/i).trim();\n if (!pub && !title) continue;\n const decode = (s) => s\n .replace(/&lt;/g, '<').replace(/&gt;/g, '>')\n .replace(/&quot;/g, '\"').replace(/&#34;/g, '\"')\n .replace(/&amp;/g, '&')\n .replace(/<[^>]+>/g, ' ').replace(/\\s+/g, ' ').trim();\n const enclosure = (block.match(/<enclosure[^>]+url=\"([^\"]+)\"/i) || [])[1] || null;\n const mediaThumb = (block.match(/<media:thumbnail[^>]+url=\"([^\"]+)\"/i) || [])[1] || null;\n out.push({\n id: platform + '-' + (guid || link || pub).slice(0, 80),\n platform,\n hashtag,\n date: pub ? new Date(pub).toISOString() : new Date().toISOString(),\n titre: decode(title).slice(0, 140) || platform,\n extrait: decode(desc).slice(0, 280),\n url: link || fallbackUrl,\n thumbnail: mediaThumb || enclosure,\n });\n }\n return out;\n}\n\n// ---- Recupere payloads ----\nfunction safeText(nodeName) {\n try {\n const its = $(nodeName).all();\n if (!its.length || !its[0].json) return '';\n const j = its[0].json;\n return String(j.data || j.body || '');\n } catch (e) {\n console.log(nodeName + ' missing:', e.message);\n return '';\n }\n}\n\nconst giteaXml = safeText('Fetch-gitea');\nconst subXml = safeText('Fetch-substack-natif');\nconst rsshubAepXml = safeText('Fetch-rsshub-insta-aep');\nconst rsshubJnXml = safeText('Fetch-rsshub-insta-julesneny');\n\nfor (const it of parseAtomGitea(giteaXml)) items.push(it);\nfor (const it of parseRss(subXml, 'substack', '#substack', 'https://julesneny.substack.com')) items.push(it);\nfor (const it of parseRss(rsshubAepXml, 'instagram', '#aep-politique', 'https://instagram.com/aep.politique')) items.push(it);\nfor (const it of parseRss(rsshubJnXml, 'instagram', '#peinture', 'https://instagram.com/julesneny')) items.push(it);\n\n// ---- Dedup par id, tri desc, cap top 100 ----\nconst seen = new Set();\nconst uniq = items.filter((it) => {\n if (seen.has(it.id)) return false;\n seen.add(it.id);\n return true;\n});\nuniq.sort((a, b) => b.date.localeCompare(a.date));\nconst top = uniq.slice(0, 100);\n\nconst counts = {\n total: top.length,\n gitea: top.filter((i) => i.platform === 'gitea').length,\n substack: top.filter((i) => i.platform === 'substack').length,\n instagram: top.filter((i) => i.platform === 'instagram').length,\n};\n\nreturn [{ json: { generatedAt: new Date().toISOString(), items: top, counts } }];"
},
"id": "normalise",
"name": "Normalise",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [720, 340]
},
{
"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, 340]
},
{
"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, 340]
}
],
"connections": {
"Cron-4h-UTC": {
"main": [[
{ "node": "Fetch-gitea", "type": "main", "index": 0 },
{ "node": "Fetch-substack-natif", "type": "main", "index": 0 },
{ "node": "Fetch-rsshub-insta-aep", "type": "main", "index": 0 },
{ "node": "Fetch-rsshub-insta-julesneny", "type": "main", "index": 0 }
]]
},
"Fetch-gitea": { "main": [[{ "node": "Normalise", "type": "main", "index": 0 }]] },
"Fetch-substack-natif": { "main": [[{ "node": "Normalise", "type": "main", "index": 0 }]] },
"Fetch-rsshub-insta-aep": { "main": [[{ "node": "Normalise", "type": "main", "index": 0 }]] },
"Fetch-rsshub-insta-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": "V1.5-E" }
]
}
+6
View File
@@ -13,6 +13,7 @@
"@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",
@@ -2366,6 +2367,11 @@
"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": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
+1
View File
@@ -20,6 +20,7 @@
"@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",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"version": "1.1",
"generatedAt": "2026-05-12T22:53:33.094Z",
"generatedAt": "2026-08-17T11:48:54.594Z",
"nodes": [
{
"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 -13
View File
@@ -1,22 +1,18 @@
---
import InstaFeed from '../vue/InstaFeed.vue';
// V1.5-E : Behold remplacé par RSSHub self-host. InstaFeed lit désormais
// le journal unifié (PUBLIC_JOURNAL_URL → data.trans-former.fr/journal.json)
// agrégé par n8n depuis rss.trans-former.fr. Plus de feedId Behold à passer.
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
account="aep.politique"
accountUrl="https://www.instagram.com/aep.politique/"
fallbackBio="Carrousels manifeste AEP ; pensee politique eco-architecture"
/>
<InstaFeed
client:visible
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>
+50 -115
View File
@@ -1,91 +1,16 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
// V1.5-E : on consomme désormais le journal unifié agrégé par n8n
// (sources RSSHub self-host → data.trans-former.fr/journal.json),
// au lieu de l'API Behold directe (rate-limit, plafond 6 posts gratuit).
interface JournalItem {
id: string;
platform: string;
date: string;
titre: string;
extrait?: string;
url: string;
thumbnail: string | null;
}
interface JournalPayload {
generatedAt: string;
items: JournalItem[];
}
interface InstaPost {
id: string;
permalink: string;
thumbnailUrl: string | null;
caption: string;
timestamp: string;
}
const props = defineProps<{
/** handle Instagram sans @ (ex: 'aep.politique', 'julesneny') — sert à filtrer le journal */
defineProps<{
account: string;
accountUrl: string;
fallbackBio?: string;
/** nombre max de posts affichés (défaut 6, parité avec ancien Behold) */
max?: number;
secondAccount: string;
secondAccountUrl: string;
feedImages: string[]; // ordonné du plus récent (haut) au plus ancien (bas)
}>();
const posts = ref<InstaPost[]>([]);
const loading = ref(true);
const error = ref<string | null>(null);
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 accountHandle = (props.account || '').replace(/^@/, '').toLowerCase();
const limit = props.max ?? 6;
onMounted(async () => {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 8000);
const res = await fetch(JOURNAL_URL, {
signal: controller.signal,
cache: 'no-store',
});
clearTimeout(timeoutId);
if (!res.ok) throw new Error(`Journal returned ${res.status}`);
const data = (await res.json()) as JournalPayload;
const all = Array.isArray(data?.items) ? data.items : [];
posts.value = all
.filter(
(it) =>
it.platform === 'instagram' &&
typeof it.url === 'string' &&
it.url.toLowerCase().includes(`/${accountHandle}`),
)
.slice(0, limit)
.map((it) => ({
id: it.id,
permalink: it.url,
thumbnailUrl: it.thumbnail ?? null,
caption: it.titre || it.extrait || '',
timestamp: it.date,
}));
if (!posts.value.length) error.value = 'no-posts';
} 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"
@@ -94,52 +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 bg-neutral-100"
class="font-medium text-sm text-neutral-500 hover:underline hover:text-neutral-800"
>
<img
v-if="post.thumbnailUrl"
:src="post.thumbnailUrl"
:alt="post.caption?.slice(0, 80) || account"
loading="lazy"
class="w-full h-full object-cover group-hover:scale-105 transition-transform"
/>
<span
v-else
class="w-full h-full flex items-center justify-center text-[10px] text-neutral-400 p-2 text-center"
>
{{ post.caption?.slice(0, 60) || account }}
</span>
{{ 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>
+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' },
})
}
}
+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";
/* 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,