From 1c1604ea2f5191bc4010e10ba2a2e39d3a40e2b5 Mon Sep 17 00:00:00 2001 From: Valentin Le Moign Date: Mon, 13 Jul 2026 16:00:59 +0200 Subject: [PATCH] =?UTF-8?q?Pages=20=C3=A9quipe=20+=20conclusion,=20colonne?= =?UTF-8?q?s=20de=20page=20libre,=20r=C3=A9glages=20couverture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Page « équipe » : roster statique du collectif (composables/useTeam), portraits servis depuis public/portraits, grille ≤ 3 colonnes équilibrée (portrait/nom/rôle/présentation), sélection + surcharges par membre (repliables, réordonnables, réinitialisables) dans le composeur - Page « conclusion » : « Merci ! » centré + rappel des contacts - Page libre : 1 à 3 colonnes texte (markdown) ou images avec sous-titres, réordonnables ; migration des champs body/media legacy vers columns - Couverture : texte libre sous l'intro, adresse en bas à gauche (3 lignes), case « afficher les années » au niveau du document - Sommaire : items centrés (self-center), filet en points dégradés stable - Retrait de la dépendance morte pagedjs - Docs à jour (CLAUDE.md, README) ; spec marquée comme archive réalisée --- CLAUDE.md | 35 ++- README.md | 6 +- components/preview/PageRenderer.vue | 97 +++++++- components/templates/CoverTemplate.vue | 25 +++ components/templates/FreeTextPage.vue | 48 +++- components/templates/OutroTemplate.vue | 74 +++++++ components/templates/TeamTemplate.vue | 83 +++++++ components/templates/TocTemplate.vue | 16 +- composables/useFormat.ts | 1 + composables/useTeam.ts | 79 +++++++ package-lock.json | 251 +++++---------------- package.json | 1 - pages/portfolios/[id].vue | 292 ++++++++++++++++++++++--- pages/view/[id].vue | 2 + public/portraits/Lilia.png | Bin 0 -> 280073 bytes public/portraits/arman.png | Bin 0 -> 298658 bytes public/portraits/bachir.png | Bin 0 -> 168460 bytes public/portraits/cathy.png | Bin 0 -> 422429 bytes public/portraits/chloe.png | Bin 0 -> 385798 bytes public/portraits/hugo.png | Bin 0 -> 165661 bytes public/portraits/jean-elie.png | Bin 0 -> 196864 bytes public/portraits/julia.png | Bin 0 -> 207074 bytes public/portraits/marion.png | Bin 0 -> 575293 bytes public/portraits/maud.png | Bin 0 -> 532590 bytes public/portraits/ouidade.png | Bin 0 -> 191718 bytes public/portraits/sabine.png | Bin 0 -> 173007 bytes public/portraits/sandrine.png | Bin 0 -> 305363 bytes public/portraits/val.png | Bin 0 -> 188460 bytes types/index.ts | 57 ++++- 29 files changed, 802 insertions(+), 265 deletions(-) create mode 100644 components/templates/OutroTemplate.vue create mode 100644 components/templates/TeamTemplate.vue create mode 100644 composables/useTeam.ts create mode 100644 public/portraits/Lilia.png create mode 100644 public/portraits/arman.png create mode 100644 public/portraits/bachir.png create mode 100644 public/portraits/cathy.png create mode 100644 public/portraits/chloe.png create mode 100644 public/portraits/hugo.png create mode 100644 public/portraits/jean-elie.png create mode 100644 public/portraits/julia.png create mode 100644 public/portraits/marion.png create mode 100644 public/portraits/maud.png create mode 100644 public/portraits/ouidade.png create mode 100644 public/portraits/sabine.png create mode 100644 public/portraits/sandrine.png create mode 100644 public/portraits/val.png diff --git a/CLAUDE.md b/CLAUDE.md index 0669f03..cc5b211 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,21 +31,36 @@ Mot de passe dev : `figureslibres` (`PORTFOLIO_PASSWORD`). - `portfolio-store.ts` : CRUD des portfolios (JSON sur disque), gel/dégel du contenu. - `media.ts`, `session.ts`. - `server/api/` — endpoints (auth, projects, portfolios, media, sync, exports, public). -- `components/templates/` — pages du gabarit (cover, toc, projet, grille, page libre). +- `components/templates/` — pages du gabarit (cover, toc, projet, grille, page + libre, équipe `TeamTemplate`, conclusion `OutroTemplate`). - `components/preview/` — `PageRenderer` (dispatch) + `PageViewport` (aperçu à l'échelle). - `components/composer/` — UI de composition (curation médias, fonds, pickers). +- `composables/useTeam.ts` — roster statique du collectif (`TEAM_ROSTER` : nom, + portrait, rôle et présentation par défaut de chaque membre) ; les portraits + sont servis en statique depuis `public/portraits/`. - `pages/print/[id].vue` — **rendu print servant à la génération PDF** (ne pas casser). - `types/index.ts` — modèle de données complet et commenté (source de vérité du schéma). ## Modèle de données (résumé) -Un `Portfolio` = `cover` (CoverConfig) + `pages[]`. Types de page : `cover`, -`toc`, `project` (couverture d'un projet, disposition `band` = bandeau haut **ou** -`split` = colonne texte + image pleine hauteur à droite), `project-grid` (grille -d'images entières), `free`. Le contenu Grav (`Project`, `MediaAsset`) est **en -lecture seule**. À la génération, le contenu utilisé est **figé** dans -`data/frozen//` (`FrozenSnapshot`) : régénérer redonne le même PDF ; -« Réactualiser depuis Grav » resynchronise volontairement. +Un `Portfolio` = `cover` (CoverConfig) + `pages[]`. Types de page : +- `cover` — couverture (titre, texte libre, date, site/mail, adresse en bas à + gauche) ; `showYears` (au niveau du portfolio) masque les années des projets. +- `toc` — sommaire, généré depuis les pages projet (hérite du filet de la couverture). +- `project` — couverture d'un projet, disposition `band` (bandeau haut) **ou** + `split` (colonne texte + image pleine hauteur à droite). +- `project-grid` — grille d'images entières. +- `free` — page libre : 1 à 3 **colonnes** (`FreeColumn`) de texte (markdown) ou + d'images, chacune avec sous-titre optionnel (les champs `body`/`media` legacy + sont migrés vers `columns` à l'ouverture, avec repli au rendu disque). +- `team` — page équipe : membres du roster en grille (≤ 3 colonnes, équilibrée), + portrait + nom + rôle + présentation ; sélection et surcharges par membre. +- `outro` — page de conclusion : « Merci ! » centré + rappel des contacts. + +Le contenu Grav (`Project`, `MediaAsset`) est **en lecture seule**. À la +génération, le contenu utilisé est **figé** dans `data/frozen//` +(`FrozenSnapshot`) : régénérer redonne le même PDF ; « Réactualiser depuis Grav » +resynchronise volontairement. Chaque page porte un **fond unifié** `background` à trois modes : `white` | `tint` (aplat beige fixe `#efece3`) | `full` (la page — ou le bandeau / @@ -70,8 +85,8 @@ encre `FL_INK`). Il n'y a **plus** de couleur automatique par catégorie seule exception : le mot « Figures Libres » de la couverture (Bold). - **Liens Grav** : `stripTargetParam` retire le `?target=_blank` que Grav accole à chaque URL (lien projet + liens dans les corps de texte), à l'affichage. -- Filet : `--fl-strip` (5 mm, couverture/sommaire/page libre) ou `--fl-strip-light` - (2 mm, filet fin des grilles et des couvertures projet non « plein »). +- Filet : `--fl-strip` (5 mm, couverture/sommaire/page libre/équipe/conclusion) ou + `--fl-strip-light` (2 mm, filet fin des grilles et des couvertures projet non « plein »). ## Pièges Chromium print (avant de toucher aux templates) diff --git a/README.md b/README.md index 0b12084..69fcb26 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,10 @@ docker compose exec generator npm run build # build de prod (.out `grav-reader` (parse Grav → `Project`), `pdf-pipeline` (Playwright → PDF A3, images 2× via sharp, post-traitement Ghostscript), `portfolio-store` (gel/dégel). - `server/api/` — auth, projects, portfolios, media, sync, exports, public. -- `components/` — `templates/` (cover, toc, projet, grille, page libre), - `preview/` (rendu + aperçu), `composer/` (UI de composition). +- `components/` — `templates/` (cover, toc, projet, grille, page libre, équipe, + conclusion), `preview/` (rendu + aperçu), `composer/` (UI de composition). +- `composables/useTeam.ts` — roster statique du collectif ; portraits servis en + statique depuis `public/portraits/`. - `pages/print/[id].vue` — rendu print utilisé pour la génération PDF. - `types/index.ts` — modèle de données (source de vérité du schéma). diff --git a/components/preview/PageRenderer.vue b/components/preview/PageRenderer.vue index ce364a6..27c87c9 100644 --- a/components/preview/PageRenderer.vue +++ b/components/preview/PageRenderer.vue @@ -76,6 +76,7 @@ const projectWorkType = computed(() => { const projectYear = computed(() => { if (props.page.kind !== 'project') return '' + if (props.portfolio.showYears === false) return '' // réglage document : années masquées if (props.page.year?.trim()) return props.page.year.trim() const iso = project.value?.date const y = iso ? new Date(iso).getFullYear() : NaN @@ -132,6 +133,13 @@ const gridMedia = computed(() => { .filter((m): m is ResolvedMedia => m !== null) }) +// Texte libre optionnel sous la phrase d'intro de la couverture (markdown). +const coverBody = computed(() => + props.portfolio.cover.body?.trim() + ? stripTargetParam(marked.parse(props.portfolio.cover.body) as string) + : '', +) + // Filet de la couverture du dossier — définit aussi celui du sommaire. const coverBandColor = computed(() => { const cover = props.portfolio.pages.find( @@ -151,18 +159,61 @@ const tocEntries = computed(() => }), ) -// Page libre : texte markdown + médias "projectId/filename" -const freeText = computed(() => { - if (props.page.kind !== 'free' || !props.page.body?.trim()) return '' - return stripTargetParam(marked.parse(props.page.body) as string) +// Page libre : colonnes de texte (markdown) ou d'images. Repli sur les champs +// legacy `body`/`media` pour les pages non encore migrées (lues depuis le disque). +interface ResolvedColumn { + id: string + kind: 'text' | 'media' + heading?: string + html?: string + media?: ResolvedMedia[] +} + +function resolveFreeMedia(refs: string[], colCount: number): ResolvedMedia[] { + const width = Math.round(INNER_W / colCount) + return refs.map((ref) => { + const idx = ref.lastIndexOf('/') + const projectId = ref.slice(0, idx) + const media = resolve(projectId, ref.slice(idx + 1), width) + media.caption = props.projects[projectId]?.title // titre du projet en légende + return media + }) +} + +// Page équipe : membres cochés, résolus depuis le roster statique (nom + +// portrait) avec rôle/présentation surchargés le cas échéant. +const teamMembers = computed(() => { + if (props.page.kind !== 'team') return [] + return props.page.members + .filter(m => m.enabled) + .map((m) => { + const r = TEAM_ROSTER.find(x => x.id === m.id) + return { + id: m.id, + name: r?.name ?? m.id, + portraitUrl: portraitUrl(r?.portrait ?? ''), + role: m.role ?? r?.role, + bio: m.bio ?? r?.bio, + } + }) }) -const freeMedia = computed(() => { - if (props.page.kind !== 'free' || !props.page.media?.length) return [] - return props.page.media.map((ref) => { - const idx = ref.lastIndexOf('/') - return resolve(ref.slice(0, idx), ref.slice(idx + 1), 560) - }) +const freeColumns = computed(() => { + if (props.page.kind !== 'free') return [] + const page = props.page + // Colonnes explicites, sinon synthèse depuis les champs legacy. + const cols = page.columns?.length + ? page.columns + : [ + ...(page.body?.trim() ? [{ id: 'legacy-text', kind: 'text' as const, body: page.body }] : []), + ...(page.media?.length ? [{ id: 'legacy-media', kind: 'media' as const, media: page.media }] : []), + ] + const list = cols.length ? cols : [{ id: 'empty', kind: 'text' as const, body: '' }] + const count = list.length + return list.map(col => col.kind === 'media' + ? { id: col.id, kind: 'media' as const, heading: col.heading, media: resolveFreeMedia(col.media ?? [], count) } + : { id: col.id, kind: 'text' as const, heading: col.heading, html: col.body?.trim() ? stripTargetParam(marked.parse(col.body) as string) : '' }, + ) }) @@ -171,14 +222,37 @@ const freeMedia = computed(() => { + + + + (() => { (), { renderMode: 'preview' }, @@ -41,6 +43,11 @@ const siteLabel = computed(() => siteHref.value.replace(/^https?:\/\//, '').repl const email = computed(() => props.contactEmail?.trim() || DEFAULT_CONTACT_EMAIL) // Laissée vide, la date affiche par défaut le mois courant (ex. « Juillet 2026 »). const displayDate = computed(() => props.date?.trim() || formatMonthYear()) +// Adresse coupée aux virgules : une ligne par segment (« Figures Libres » / +// « 55 avenue… » / « 94110 Arcueil »). +const addressLines = computed(() => + (props.address?.trim() || DEFAULT_ADDRESS).split(',').map(s => s.trim()).filter(Boolean), +) @@ -636,6 +730,80 @@ async function copyShareUrl() { + + + + + +