From e22dd6654ad3b1e7de253e5e660bdc72a3c57cbf Mon Sep 17 00:00:00 2001 From: Jules Neny Date: Sat, 9 May 2026 01:13:51 +0200 Subject: [PATCH] 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 - 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) --- .env.example | 4 + docs/PC6-JOURNAL-N8N-SETUP.md | 147 ++++++++++++++++++++ docs/n8n-workflow-journal-aggregate.json | 152 +++++++++++++++++++++ public/data/journal.json | 52 +++++++ src/components/astro/ColJournal.astro | 9 +- src/components/vue/JournalList.vue | 165 ++++++++++++++++++++++- 6 files changed, 521 insertions(+), 8 deletions(-) create mode 100644 docs/PC6-JOURNAL-N8N-SETUP.md create mode 100644 docs/n8n-workflow-journal-aggregate.json create mode 100644 public/data/journal.json diff --git a/.env.example b/.env.example index 15ce499..031e98f 100644 --- a/.env.example +++ b/.env.example @@ -4,3 +4,7 @@ # 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 diff --git a/docs/PC6-JOURNAL-N8N-SETUP.md b/docs/PC6-JOURNAL-N8N-SETUP.md new file mode 100644 index 0000000..47448a7 --- /dev/null +++ b/docs/PC6-JOURNAL-N8N-SETUP.md @@ -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 `` 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) diff --git a/docs/n8n-workflow-journal-aggregate.json b/docs/n8n-workflow-journal-aggregate.json new file mode 100644 index 0000000..f9451df --- /dev/null +++ b/docs/n8n-workflow-journal-aggregate.json @@ -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(//i).slice(1);\n for (const raw of entries) {\n const block = '' + raw.split(/<\\/entry>/i)[0] + '';\n const title = (block.match(/([\\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(/</g, '<')\n .replace(/>/g, '>')\n .replace(/"/g, '\"')\n .replace(/"/g, '\"')\n .replace(/ /g, ' ')\n .replace(/&/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" } + ] +} diff --git a/public/data/journal.json b/public/data/journal.json new file mode 100644 index 0000000..35e044f --- /dev/null +++ b/public/data/journal.json @@ -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 + } +} diff --git a/src/components/astro/ColJournal.astro b/src/components/astro/ColJournal.astro index b067d8a..ccbc089 100644 --- a/src/components/astro/ColJournal.astro +++ b/src/components/astro/ColJournal.astro @@ -1,6 +1,8 @@ --- -// ColJournal - colonne gauche : CTA Manifeste + Hashtags accordeon + Journal skeleton +// ColJournal - colonne gauche : CTA Manifeste + Hashtags accordeon + Journal (PC6) // 7 hashtags = 7 plateformes (cf delta 15 du PILOTE-PC.md) +import JournalList from '../vue/JournalList.vue'; + const hashtags = [ { tag: '#manifeste', plateforme: 'Blog trans-former.fr', canal: 'ecriture longue' }, { tag: '#building-public', plateforme: 'LinkedIn', canal: 'journal pro' }, @@ -55,10 +57,7 @@ const hashtags = [ <span class="text-xs text-neutral-400 font-normal">chrono</span> </h2> <div id="journal-list" class="space-y-3 text-sm"> - <!-- PC6 remplit ce slot via <JournalList client:visible /> --> - <p class="text-neutral-400 italic text-xs"> - Chargement du journal... - </p> + <JournalList client:visible /> </div> </section> </div> diff --git a/src/components/vue/JournalList.vue b/src/components/vue/JournalList.vue index fe3f4be..2e3a9f5 100644 --- a/src/components/vue/JournalList.vue +++ b/src/components/vue/JournalList.vue @@ -1,9 +1,168 @@ <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 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 } + } +} + +onMounted(async () => { + loadFilters() + window.addEventListener('hashtag-filter-change', onFilterChange 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) +}) + +const visibleItems = computed(() => { + // Tous les filtres faux = aucun affiche ; tous true ou aucune cle = tout afficher + const keys = Object.keys(filters.value) + if (keys.length === 0) return items.value + const activeKeys = keys.filter((k) => filters.value[k]) + // Si l'utilisateur a explicitement décoché tous les hashtags, on n'affiche rien + if (activeKeys.length === 0 && keys.some((k) => filters.value[k] === false)) return [] + if (activeKeys.length === 0) return items.value + return items.value.filter((it) => activeKeys.includes(it.hashtag)) +}) + +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" + > + <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>