Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e22dd6654a |
@@ -4,3 +4,7 @@
|
|||||||
# 3) Recupere les feed IDs et copie ce fichier vers .env.local puis remplis ci-dessous
|
# 3) Recupere les feed IDs et copie ce fichier vers .env.local puis remplis ci-dessous
|
||||||
PUBLIC_BEHOLD_AEP=
|
PUBLIC_BEHOLD_AEP=
|
||||||
PUBLIC_BEHOLD_JULESNENY=
|
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
|
||||||
|
|||||||
147
docs/PC6-JOURNAL-N8N-SETUP.md
Normal file
147
docs/PC6-JOURNAL-N8N-SETUP.md
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
# PC6 — Setup journal unifié + n8n workflow
|
||||||
|
|
||||||
|
Spec : `0 INBOX/PROMPTS/page-cerveau-build/PROMPT-PC6-journal-n8n.md`
|
||||||
|
Pilote : `0 INBOX/PROMPTS/page-cerveau-build/PILOTE-PC.md` (delta 5, delta 15)
|
||||||
|
|
||||||
|
## TL;DR
|
||||||
|
|
||||||
|
Le journal de la colonne G est alimenté par un cron n8n nocturne qui agrège plusieurs sources publiques (Gitéa Atom, Behold @aep, Behold @julesneny) et écrit un JSON statique servi par Caddy sur `data.trans-former.fr/journal.json`.
|
||||||
|
|
||||||
|
```
|
||||||
|
n8n cron (3h00 UTC)
|
||||||
|
-> fetch Gitéa Atom + Behold @aep + Behold @julesneny
|
||||||
|
-> normalisation Code Node
|
||||||
|
-> tri desc + top 100
|
||||||
|
-> écrit /home/node/.n8n/journal/journal.json (volume Docker)
|
||||||
|
-> Caddy data.trans-former.fr file_server expose ce fichier
|
||||||
|
-> JournalList.vue fetch côté client (no rebuild Astro requis)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sources V1 actives
|
||||||
|
|
||||||
|
| Plateforme | Hashtag | URL feed | Statut |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Gitéa | `#stack` | `https://git.trans-former.fr/jules.atom` | ACTIF |
|
||||||
|
| Behold @aep | `#aep-politique` | `https://feeds.behold.so/{PUBLIC_BEHOLD_AEP}` | conditionnel (skip si feed ID absent) |
|
||||||
|
| Behold @julesneny | `#peinture` | `https://feeds.behold.so/{PUBLIC_BEHOLD_JULESNENY}` | conditionnel (skip si feed ID absent) |
|
||||||
|
|
||||||
|
## Sources skipped (V1 -> V1.5/V2)
|
||||||
|
|
||||||
|
| Plateforme | Hashtag | Raison |
|
||||||
|
|---|---|---|
|
||||||
|
| GitHub.com | `#stack` | username `julesneny` n'existe pas (HTTP 404). Pivot Gitéa pour le MVP. À reconfirmer si Jules a un autre handle GitHub public. |
|
||||||
|
| Substack | `#politique` | `transformations.substack.com` est pris par "WoodHorse" (pas Jules). Handle Substack à confirmer avant V1.5. |
|
||||||
|
| LinkedIn | `#building-public` | V2 (RSS via service tiers ou scrape) |
|
||||||
|
| Castopod | `#podcast` | V2 (Castopod RSS prêt mais hors scope MVP) |
|
||||||
|
| Blog `trans-former.fr` | `#manifeste` | V2 (post-PC8 deploy) |
|
||||||
|
|
||||||
|
## Format JSON
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"generatedAt": "2026-05-09T03:00:00Z",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": "gitea-2026-05-09-pc6",
|
||||||
|
"platform": "gitea",
|
||||||
|
"hashtag": "#stack",
|
||||||
|
"date": "2026-05-09T01:01:00Z",
|
||||||
|
"titre": "PC6 journal unifié + n8n agrégateur",
|
||||||
|
"extrait": "...",
|
||||||
|
"url": "https://git.trans-former.fr/jules/astro-site-cerveau/commit/...",
|
||||||
|
"thumbnail": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"counts": { "total": N, "gitea": N, "instagram": N }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Composant Vue
|
||||||
|
|
||||||
|
`src/components/vue/JournalList.vue` :
|
||||||
|
- fetch `import.meta.env.PUBLIC_JOURNAL_URL` (défaut `https://data.trans-former.fr/journal.json`)
|
||||||
|
- écoute `window.addEventListener('hashtag-filter-change', ...)` émis par ColJournal.astro
|
||||||
|
- filtre par hashtag (vide ou tous cochés -> tout afficher ; tous décochés -> rien)
|
||||||
|
- tri desc déjà fait côté n8n, le composant respecte l'ordre
|
||||||
|
|
||||||
|
Cabling : `src/components/astro/ColJournal.astro` importe et rend `<JournalList client:visible />` dans `#journal-list`.
|
||||||
|
|
||||||
|
## Variable d'env
|
||||||
|
|
||||||
|
`PUBLIC_JOURNAL_URL=https://data.trans-former.fr/journal.json` (`.env.example`)
|
||||||
|
|
||||||
|
Override possible en local pointant vers `/data/journal.json` (mock fourni dans `public/data/journal.json`).
|
||||||
|
|
||||||
|
## Setup VPS — étapes (ops, à valider Jules)
|
||||||
|
|
||||||
|
### 1. DNS
|
||||||
|
|
||||||
|
Dans OVH zone DNS `trans-former.fr` :
|
||||||
|
|
||||||
|
```
|
||||||
|
data A 178.104.106.195 TTL 600
|
||||||
|
```
|
||||||
|
|
||||||
|
Attendre propagation (~5min).
|
||||||
|
|
||||||
|
### 2. Volume Docker partagé n8n -> Caddy
|
||||||
|
|
||||||
|
Le container n8n monte `vps-kit_n8n_data:/home/node/.n8n`. On va simplement lire un fichier dans ce volume depuis Caddy.
|
||||||
|
|
||||||
|
Path source : `/var/lib/docker/volumes/vps-kit_n8n_data/_data/journal/journal.json`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh vps-hetzner "mkdir -p /var/lib/docker/volumes/vps-kit_n8n_data/_data/journal && \
|
||||||
|
chown 1000:1000 /var/lib/docker/volumes/vps-kit_n8n_data/_data/journal"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Caddyfile bloc
|
||||||
|
|
||||||
|
Ajouter dans le Caddyfile (probablement `/etc/caddy/Caddyfile` ou `/opt/vps-kit/configs/Caddyfile`) :
|
||||||
|
|
||||||
|
```caddy
|
||||||
|
data.trans-former.fr {
|
||||||
|
root * /var/lib/docker/volumes/vps-kit_n8n_data/_data/journal
|
||||||
|
file_server {
|
||||||
|
index journal.json
|
||||||
|
}
|
||||||
|
encode gzip
|
||||||
|
header {
|
||||||
|
Cache-Control "public, max-age=300"
|
||||||
|
Access-Control-Allow-Origin "https://trans-former.fr"
|
||||||
|
}
|
||||||
|
log {
|
||||||
|
output file /var/log/caddy/data.log
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Backup + reload :
|
||||||
|
```bash
|
||||||
|
ssh vps-hetzner "cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.bak.$(date +%Y%m%d-%H%M%S) && \
|
||||||
|
systemctl reload caddy && \
|
||||||
|
systemctl status caddy --no-pager | head -10"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Workflow n8n
|
||||||
|
|
||||||
|
Importer `docs/n8n-workflow-journal-aggregate.json` dans https://automate.trans-former.fr (UI -> Import from file).
|
||||||
|
|
||||||
|
Activer le toggle, vérifier le cron (`0 3 * * *`).
|
||||||
|
|
||||||
|
Configurer les credentials env n8n si besoin (Behold feed IDs) -> non bloquants si absents (workflow skip).
|
||||||
|
|
||||||
|
### 5. Smoke test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run manuel (UI n8n -> Execute Workflow)
|
||||||
|
ssh vps-hetzner "ls -la /var/lib/docker/volumes/vps-kit_n8n_data/_data/journal/"
|
||||||
|
curl -sf https://data.trans-former.fr/journal.json | jq '.counts'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backlog (hors scope PC6)
|
||||||
|
|
||||||
|
- Trigger rebuild Astro Coolify webhook (PC8)
|
||||||
|
- Sources V2 : LinkedIn, Castopod, Blog, Substack (post handle confirmé)
|
||||||
|
- Storage archivage long-terme (V1 = écrasement quotidien)
|
||||||
|
- Real-time updates (V3)
|
||||||
152
docs/n8n-workflow-journal-aggregate.json
Normal file
152
docs/n8n-workflow-journal-aggregate.json
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
{
|
||||||
|
"name": "journal-aggregate",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"rule": {
|
||||||
|
"interval": [
|
||||||
|
{
|
||||||
|
"field": "cronExpression",
|
||||||
|
"expression": "0 3 * * *"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "schedule-trigger",
|
||||||
|
"name": "Cron-3h",
|
||||||
|
"type": "n8n-nodes-base.scheduleTrigger",
|
||||||
|
"typeVersion": 1.1,
|
||||||
|
"position": [240, 320]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"url": "https://git.trans-former.fr/jules.atom",
|
||||||
|
"options": {
|
||||||
|
"response": {
|
||||||
|
"response": {
|
||||||
|
"responseFormat": "text"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"timeout": 15000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "fetch-gitea",
|
||||||
|
"name": "Fetch-gitea",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.1,
|
||||||
|
"position": [460, 200]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"url": "=https://feeds.behold.so/{{ $env.PUBLIC_BEHOLD_AEP || 'NOT_SET' }}",
|
||||||
|
"options": {
|
||||||
|
"response": {
|
||||||
|
"response": {
|
||||||
|
"neverError": true,
|
||||||
|
"responseFormat": "json"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"timeout": 15000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "fetch-behold-aep",
|
||||||
|
"name": "Fetch-behold-aep",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.1,
|
||||||
|
"position": [460, 320]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"url": "=https://feeds.behold.so/{{ $env.PUBLIC_BEHOLD_JULESNENY || 'NOT_SET' }}",
|
||||||
|
"options": {
|
||||||
|
"response": {
|
||||||
|
"response": {
|
||||||
|
"neverError": true,
|
||||||
|
"responseFormat": "json"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"timeout": 15000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "fetch-behold-julesneny",
|
||||||
|
"name": "Fetch-behold-julesneny",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.1,
|
||||||
|
"position": [460, 440]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "// Normalisation des 3 sources V1 vers le format JSON unifié\n// Sources : Gitéa Atom (XML), Behold @aep (JSON), Behold @julesneny (JSON)\n\nconst items = [];\n\n// ---- Helper parse Atom XML (Gitéa) ----\nfunction parseAtomGitea(xml) {\n const out = [];\n if (!xml || typeof xml !== 'string') return out;\n const entries = xml.split(/<entry>/i).slice(1);\n for (const raw of entries) {\n const block = '<entry>' + raw.split(/<\\/entry>/i)[0] + '</entry>';\n const title = (block.match(/<title>([\\s\\S]*?)<\\/title>/i) || [])[1] || '';\n const updated = (block.match(/<updated>([^<]+)<\\/updated>/i) || [])[1] || '';\n const link = (block.match(/<link[^>]*href=\"([^\"]+)\"/i) || [])[1] || '';\n const summary = (block.match(/<summary[^>]*>([\\s\\S]*?)<\\/summary>/i) || [])[1] || '';\n const id = (block.match(/<id>([^<]+)<\\/id>/i) || [])[1] || link || updated;\n if (!updated) continue;\n // Decode HTML entities basique + strip tags\n const decode = (s) => s\n .replace(/</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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
52
public/data/journal.json
Normal file
52
public/data/journal.json
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
"generatedAt": "2026-05-09T01:00:00Z",
|
||||||
|
"fallback": true,
|
||||||
|
"note": "Mock local — agrégateur n8n live pousse sur data.trans-former.fr/journal.json. Ce fichier sert de fallback dev tant que data.trans-former.fr DNS/Caddy ne sont pas en place.",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": "gitea-mock-pc6-feat",
|
||||||
|
"platform": "gitea",
|
||||||
|
"hashtag": "#stack",
|
||||||
|
"date": "2026-05-09T01:01:00Z",
|
||||||
|
"titre": "PC6 journal unifié + n8n agrégateur (mock)",
|
||||||
|
"extrait": "Composant JournalList Vue + workflow n8n cron 3h00. Sources V1 : Gitéa Atom + Behold @aep + Behold @julesneny.",
|
||||||
|
"url": "https://git.trans-former.fr/jules/astro-site-cerveau",
|
||||||
|
"thumbnail": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "gitea-mock-pc3-mindmap",
|
||||||
|
"platform": "gitea",
|
||||||
|
"hashtag": "#stack",
|
||||||
|
"date": "2026-05-09T00:59:41Z",
|
||||||
|
"titre": "PC3 mindmap Carte O (D3 force-directed)",
|
||||||
|
"extrait": "Scrape AEP/Articles + tabs centre HAUT.",
|
||||||
|
"url": "https://git.trans-former.fr/jules/astro-site-cerveau/commit/32bdc9a",
|
||||||
|
"thumbnail": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "insta-mock-aep-1",
|
||||||
|
"platform": "instagram",
|
||||||
|
"hashtag": "#aep-politique",
|
||||||
|
"date": "2026-05-07T18:30:00Z",
|
||||||
|
"titre": "Mock carrousel @aep.politique",
|
||||||
|
"extrait": "Placeholder carrousel manifeste écologie politique. Cron n8n live remplace ce mock par la vraie API Behold.",
|
||||||
|
"url": "https://instagram.com/aep.politique",
|
||||||
|
"thumbnail": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "insta-mock-julesneny-1",
|
||||||
|
"platform": "instagram",
|
||||||
|
"hashtag": "#peinture",
|
||||||
|
"date": "2026-05-05T14:00:00Z",
|
||||||
|
"titre": "Mock peinture @julesneny",
|
||||||
|
"extrait": "Placeholder art / poésie / Corse. Cron n8n live remplace ce mock par la vraie API Behold.",
|
||||||
|
"url": "https://instagram.com/julesneny",
|
||||||
|
"thumbnail": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"counts": {
|
||||||
|
"total": 4,
|
||||||
|
"gitea": 2,
|
||||||
|
"instagram": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
// 7 hashtags = 7 plateformes (cf delta 15 du PILOTE-PC.md)
|
||||||
|
import JournalList from '../vue/JournalList.vue';
|
||||||
|
|
||||||
const hashtags = [
|
const hashtags = [
|
||||||
{ tag: '#manifeste', plateforme: 'Blog trans-former.fr', canal: 'ecriture longue' },
|
{ tag: '#manifeste', plateforme: 'Blog trans-former.fr', canal: 'ecriture longue' },
|
||||||
{ tag: '#building-public', plateforme: 'LinkedIn', canal: 'journal pro' },
|
{ 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>
|
<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">
|
||||||
<!-- PC6 remplit ce slot via <JournalList client:visible /> -->
|
<JournalList client:visible />
|
||||||
<p class="text-neutral-400 italic text-xs">
|
|
||||||
Chargement du journal...
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,168 @@
|
|||||||
<script setup lang="ts">
|
<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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="h-full w-full flex items-center justify-center text-sm text-neutral-400">
|
<div class="space-y-3">
|
||||||
Journal list placeholder (PC6)
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user