6 Commits

Author SHA1 Message Date
Jules Neny
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 Neny
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 Neny
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 Neny
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 Neny
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
17 changed files with 256 additions and 93 deletions

View File

@@ -1,6 +1,6 @@
{
"version": "1.1",
"generatedAt": "2026-07-22T19:04:46.501Z",
"generatedAt": "2026-05-12T09:28:20.972Z",
"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

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>

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>

196
src/pages/api/aep-perso.ts Normal file
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' },
})
}
}