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),
+)
@@ -71,8 +78,26 @@ const displayDate = computed(() => props.date?.trim() || formatMonthYear())
culturelle
qui œuvre sur logiciels libres.
+
+
+
+ {{ line }}
+
diff --git a/components/templates/FreeTextPage.vue b/components/templates/FreeTextPage.vue
index b04acf2..f9ce08b 100644
--- a/components/templates/FreeTextPage.vue
+++ b/components/templates/FreeTextPage.vue
@@ -1,21 +1,29 @@
+
+
+
+
+
+
+
{{ line }}
+
+
+
+
+
diff --git a/components/templates/TeamTemplate.vue b/components/templates/TeamTemplate.vue
new file mode 100644
index 0000000..8d4414f
--- /dev/null
+++ b/components/templates/TeamTemplate.vue
@@ -0,0 +1,83 @@
+
+
+
+
+
+
+
+
{{ title }}
+
+
+
+
+
+
![]()
+
+
{{ m.name }}
+
{{ m.role }}
+
{{ m.bio }}
+
+
+
+
+
diff --git a/components/templates/TocTemplate.vue b/components/templates/TocTemplate.vue
index 688c516..dbcd157 100644
--- a/components/templates/TocTemplate.vue
+++ b/components/templates/TocTemplate.vue
@@ -63,13 +63,25 @@ const gridStyle = computed(() => ({
v-for="entry in entries"
:key="`${entry.pageNumber}-${entry.title}`"
:href="hrefFor(entry.pageNumber)"
- class="flex items-baseline gap-3 text-current no-underline"
+ class="flex items-baseline gap-3 self-center text-current no-underline"
:class="{ 'cursor-pointer': !isPrint }"
@click="onEntryClick(entry.pageNumber, $event)"
>
{{ entry.title }}
{{ entry.subtitle }}
-
+
+
{{ entry.pageNumber }}
diff --git a/composables/useFormat.ts b/composables/useFormat.ts
index 9e8e430..46115cd 100644
--- a/composables/useFormat.ts
+++ b/composables/useFormat.ts
@@ -36,6 +36,7 @@ export const CATEGORY_LABELS: Record = {
// Valeurs par défaut du header de la vue publique (surchargées par la couverture).
export const DEFAULT_SITE_URL = 'https://figureslibres.cc'
export const DEFAULT_CONTACT_EMAIL = 'bonjour@figureslibres.io'
+export const DEFAULT_ADDRESS = 'Figures Libres, 55 avenue Jean Jaurès, 94110 Arcueil'
/**
* URL de miniature basse résolution d'un média de projet.
diff --git a/composables/useTeam.ts b/composables/useTeam.ts
new file mode 100644
index 0000000..4699893
--- /dev/null
+++ b/composables/useTeam.ts
@@ -0,0 +1,79 @@
+// Roster de l'équipe du collectif Figures Libres — données statiques de l'app
+// (pas du contenu Grav). Les portraits sont servis en statique depuis
+// public/portraits/. La page « équipe » (TeamPage) référence ces membres par id
+// et peut surcharger rôle et présentation ; nom et portrait viennent d'ici.
+
+export interface TeamMember {
+ id: string // slug stable (= base du portrait)
+ name: string
+ portrait: string // fichier dans public/portraits/
+ role?: string // rôle par défaut
+ bio?: string // présentation par défaut
+ defaultEnabled?: boolean // coché à la création de la page équipe
+}
+
+export const TEAM_ROSTER: TeamMember[] = [
+ {
+ id: 'maud',
+ name: 'Maud Boyer',
+ portrait: 'maud.png',
+ role: 'cheffe de projet et directrice artistique',
+ bio: 'Maud travaille depuis plus de 15 ans pour les acteur.trices des services publics et les associations. Après un diplôme des Arts décoratifs de Strasbourg, un passage par l’IUFM et quelques premières expériences dans les grosses agences de communication parisiennes, Maud co-fonde en premier lieu un collectif de design graphique dédié à la communication d’utilité publique. Elle travaillera notamment pour Médecins sans frontières, le CCFD-Terre Solidaire, Oxfam, Le Grand Paris Aménagement… En 2016, Maud poursuit son engagement en mettant ses outils de production en adéquation avec les objectifs de ses client.es. C’est la transition douce vers les logiciels libres, Figures Libres est né. Rigoureuse, patiente et organisée, Maud est l’interlocutrice privilégiée pour tous vos travaux au long cours, notamment la conception des sites web, pour lesquels elle assure la gestion avec son binôme technique, Bachir Soussi-Chiadmi.',
+ defaultEnabled: true,
+ },
+ {
+ id: 'bachir',
+ name: 'Bachir Soussi-Chiadmi',
+ portrait: 'bachir.png',
+ role: 'designer interactif et développeur',
+ bio: 'Après son diplôme des Arts Décoratifs de Strasbourg qui clôture un parcours d’études débuté sous le signe des sciences, Bachir entame sa carrière professionnelle en conjuguant créativité et rigueur du code. En plongeant dans la bulle internet des années 2000 et son interactivité émergeante, Bachir se forme en autodidacte pour devenir aujourd’hui le maître incontesté de plusieurs langages de programmation (html/CSS, Javascript / Nodejs, PHP, Mysql, Python) et de plusieurs types de CMS (Drupal, SPIP, Grav, Wordpress…). Enseignant aux Beaux-Arts du Havre et fondateur de l’association O.L.A. (Outils Libres Alternatifs) pour la promotion des logiciels libres de création, Bachir maitrise également l’écosystème Linux, la gestion et la maintenance de serveurs et maîtrise les machines aussi bien que le code qui les dirige. Sa vision de « l’information en mouvement » produit les sites web les plus complexes dotés d’une interface fluide et intuitive.',
+ defaultEnabled: true,
+ },
+ {
+ id: 'sandrine',
+ name: 'Sandrine Ripoll',
+ portrait: 'sandrine.png',
+ role: 'cheffe de projet et directrice artistique',
+ bio: 'Sandrine, fondatrice du collectif « Entrez-sans-frapper » en 2001 avec Maud Boyer, est aujourd’hui en pilotage de la plupart des projets de Figures Libres. Après un début professionnel dans un grand groupe industriel français, Sandrine rejoint le service communication de la ville d’Ivry-sur-Seine où elle apprend les enjeux de la communication locale. Toujours à la recherche du visuel qui fait sens pour un impact maximum, c’est la spécialiste des campagnes de communication grand public et des visuels de plaidoyer. Forte de ses compétences rédactionnelles, elle est aussi en charge du travail éditorial et du rewriting de contenus. Passionnée de façonnage, de couleur et de texture de papier, Sandrine est la responsable fabrication de l’agence, également en charge des projets de scénographie. Comme une évidence, dans son parcours, elle s’engage en tant que bénévole dans des associations locales en tant que militante féministe.',
+ defaultEnabled: true,
+ },
+ {
+ id: 'chloe',
+ name: 'Chloé Listrat',
+ portrait: 'chloe.png',
+ role: 'graphiste',
+ bio: 'Après six mois de stage intensif chez Figures Libres en 2020, Chloé Listrat rejoint l’équipe à la suite de son Master en Design Graphique et multimédia mené à Rennes. Enthousiaste et généreuse dans ses propositions créatives, elle analyse avant tout les sujets abordés pour concevoir la réponse adaptée qui a du sens. De cette génération élevée à l’image et au rythme des réseaux sociaux, elle met son talent en mouvement pour concevoir motions design et montages vidéos. L’œil toujours ouvert sur le monde, Chloé mène une vie d’engagement associatif intense entre éducation populaire et initiation à la sérigraphie dans les tiers-lieux du Val-de-Marne.',
+ defaultEnabled: true,
+ },
+ {
+ id: 'ouidade',
+ name: 'Ouidade Soussi-Chiadmi',
+ portrait: 'ouidade.png',
+ role: 'photographe et développeuse',
+ bio: 'Ouidade commence son parcours créatif en passant par l’école des Arts décoratifs de Paris. Photographe indépendant pour les centres culturels parisiens et les milieux de l’hôtellerie du luxe, Ouidade poursuit son parcours en intégrant l’agence Figures Libres en 2017. Formées aux logiciels de traitement d’images Gimp et Darktable, Ouidade est la spécialiste du montage et de la retouche d’image. En 2020, elle entame une formation de développeuse web et assure aujourd’hui la gestion et la réalisation des sites.',
+ defaultEnabled: true,
+ },
+ {
+ id: 'val',
+ name: 'Valentin Le Moign',
+ portrait: 'val.png',
+ role: 'graphiste et développeur',
+ bio: 'Valentin diplômé de l’École d’art du Havre (qu’il a quitté sous les félicitations du Jury), est aussi à l’aise dans la conception de supports print que de sites web. Curieux des nouveautés, il affine ses compétences de graphiste et de développeur au gré des projets de l’agence. Naturellement, il s’installe dans sa région de cœur, la Bretagne, pour mener de front une vie de professeur en design et designer web pour le collectif en accord avec ses engagements sociaux et citoyens.',
+ defaultEnabled: true,
+ },
+ // Autres membres du roster — décochés par défaut ; rôle et présentation à
+ // renseigner dans l'UI de composition.
+ { id: 'arman', name: 'Arman', portrait: 'arman.png' },
+ { id: 'cathy', name: 'Cathy', portrait: 'cathy.png' },
+ { id: 'hugo', name: 'Hugo', portrait: 'hugo.png' },
+ { id: 'jean-elie', name: 'Jean-Élie', portrait: 'jean-elie.png' },
+ { id: 'julia', name: 'Julia', portrait: 'julia.png' },
+ { id: 'lilia', name: 'Lilia', portrait: 'Lilia.png' },
+ { id: 'marion', name: 'Marion', portrait: 'marion.png' },
+ { id: 'sabine', name: 'Sabine', portrait: 'sabine.png' },
+]
+
+/** URL statique du portrait d'un membre (servi depuis public/portraits/). */
+export function portraitUrl(portrait: string): string {
+ return `/portraits/${portrait}`
+}
diff --git a/package-lock.json b/package-lock.json
index fdb3db0..9ae457d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13,7 +13,6 @@
"marked": "^15.0.0",
"nanoid": "^5.1.0",
"nuxt": "^3.17.0",
- "pagedjs": "^0.4.3",
"playwright": "1.61.1",
"sharp": "^0.34.0",
"vuedraggable": "^4.1.0",
@@ -406,26 +405,6 @@
"@babel/core": "^7.0.0-0"
}
},
- "node_modules/@babel/polyfill": {
- "version": "7.12.1",
- "resolved": "https://registry.npmjs.org/@babel/polyfill/-/polyfill-7.12.1.tgz",
- "integrity": "sha512-X0pi0V6gxLi6lFZpGmeNa4zxtwEmCs42isWLNjZZDE0Y8yVfgu0T2OAHlzBbdYlqbW/YXVvoBHpATEM+goCj8g==",
- "deprecated": "🚨 This package has been deprecated in favor of separate inclusion of a polyfill and regenerator-runtime (when needed). See the @babel/polyfill docs (https://babeljs.io/docs/en/babel-polyfill) for more information.",
- "license": "MIT",
- "dependencies": {
- "core-js": "^2.6.5",
- "regenerator-runtime": "^0.13.4"
- }
- },
- "node_modules/@babel/runtime": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
- "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
- "license": "MIT",
- "engines": {
- "node": ">=6.9.0"
- }
- },
"node_modules/@babel/template": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
@@ -5790,6 +5769,66 @@
"node": ">=14.0.0"
}
},
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
+ "version": "1.11.1",
+ "inBundle": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.2.2",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
+ "version": "1.11.1",
+ "inBundle": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
+ "version": "1.2.2",
+ "inBundle": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
+ "version": "1.1.4",
+ "inBundle": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@tybys/wasm-util": "^0.10.1"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ },
+ "peerDependencies": {
+ "@emnapi/core": "^1.7.1",
+ "@emnapi/runtime": "^1.7.1"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
+ "version": "0.10.2",
+ "inBundle": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
+ "version": "2.8.1",
+ "inBundle": true,
+ "license": "0BSD",
+ "optional": true
+ },
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz",
@@ -7132,12 +7171,6 @@
"integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==",
"license": "MIT"
},
- "node_modules/clear-cut": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/clear-cut/-/clear-cut-2.0.2.tgz",
- "integrity": "sha512-WVgn/gSejQ+0aoR8ucbKIdo6icduPZW6AbWwyUmAUgxy63rUYjwa5rj/HeoNPhf0/XPrl82X8bO/hwBkSmsFtg==",
- "license": "MIT"
- },
"node_modules/cliui": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
@@ -7194,16 +7227,6 @@
"integrity": "sha512-Z3UPUKasUVDFCDYAjP2fmlVRf1jFHJv1izAmPjiOa0OCIw1W7iC8PZ2GsoDa8uZv+mKyWopxxStT9q05+27h7w==",
"license": "Apache-2.0"
},
- "node_modules/commander": {
- "version": "13.1.0",
- "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz",
- "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==",
- "extraneous": true,
- "license": "MIT",
- "engines": {
- "node": ">=18"
- }
- },
"node_modules/commondir": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
@@ -7271,14 +7294,6 @@
"integrity": "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==",
"license": "MIT"
},
- "node_modules/core-js": {
- "version": "2.6.12",
- "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz",
- "integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==",
- "deprecated": "core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.",
- "hasInstallScript": true,
- "license": "MIT"
- },
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
@@ -7541,19 +7556,6 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
- "node_modules/d": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz",
- "integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==",
- "license": "ISC",
- "dependencies": {
- "es5-ext": "^0.10.64",
- "type": "^2.7.2"
- },
- "engines": {
- "node": ">=0.12"
- }
- },
"node_modules/db0": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/db0/-/db0-0.3.4.tgz",
@@ -7990,46 +7992,6 @@
"integrity": "sha512-3lGxdTXCLfe1MYfTz1y2ksAAUM4NAOP6rPEjxGJVKO7TZ5+tvHCaQWGpC4Y3IXvW3ece0Cz1cIP4FWBxOnGCTQ==",
"license": "MIT"
},
- "node_modules/es5-ext": {
- "version": "0.10.64",
- "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz",
- "integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==",
- "hasInstallScript": true,
- "license": "ISC",
- "dependencies": {
- "es6-iterator": "^2.0.3",
- "es6-symbol": "^3.1.3",
- "esniff": "^2.0.1",
- "next-tick": "^1.1.0"
- },
- "engines": {
- "node": ">=0.10"
- }
- },
- "node_modules/es6-iterator": {
- "version": "2.0.3",
- "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz",
- "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==",
- "license": "MIT",
- "dependencies": {
- "d": "1",
- "es5-ext": "^0.10.35",
- "es6-symbol": "^3.1.1"
- }
- },
- "node_modules/es6-symbol": {
- "version": "3.1.4",
- "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.4.tgz",
- "integrity": "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==",
- "license": "ISC",
- "dependencies": {
- "d": "^1.0.2",
- "ext": "^1.7.0"
- },
- "engines": {
- "node": ">=0.12"
- }
- },
"node_modules/esbuild": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
@@ -8098,21 +8060,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/esniff": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz",
- "integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==",
- "license": "ISC",
- "dependencies": {
- "d": "^1.0.1",
- "es5-ext": "^0.10.62",
- "event-emitter": "^0.3.5",
- "type": "^2.7.2"
- },
- "engines": {
- "node": ">=0.10"
- }
- },
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
@@ -8131,16 +8078,6 @@
"node": ">= 0.6"
}
},
- "node_modules/event-emitter": {
- "version": "0.3.5",
- "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz",
- "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==",
- "license": "MIT",
- "dependencies": {
- "d": "1",
- "es5-ext": "~0.10.14"
- }
- },
"node_modules/event-target-shim": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
@@ -8197,15 +8134,6 @@
"integrity": "sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==",
"license": "MIT"
},
- "node_modules/ext": {
- "version": "1.7.0",
- "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz",
- "integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==",
- "license": "ISC",
- "dependencies": {
- "type": "^2.7.2"
- }
- },
"node_modules/externality": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/externality/-/externality-1.0.2.tgz",
@@ -9786,12 +9714,6 @@
"integrity": "sha512-Kv2JYYiCzt16Kt5QwAc9BFG89xfPNBx+oQL4GQXD9nLqPkZBiNaqaCWtwnbk/q7UVsTYevvM1b0UF8zmEI4pCg==",
"license": "MIT"
},
- "node_modules/next-tick": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz",
- "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==",
- "license": "ISC"
- },
"node_modules/nitropack": {
"version": "2.13.4",
"resolved": "https://registry.npmjs.org/nitropack/-/nitropack-2.13.4.tgz",
@@ -10535,47 +10457,6 @@
"integrity": "sha512-xg1eHpwYL/D/HEdWw2goFZP6vV0FH7W+PZ5rFkGjdIDLtxq7EkzBUeT3m+lndYCt8wKbmofUu1MUdMCXkCk9ZQ==",
"license": "MIT"
},
- "node_modules/pagedjs": {
- "version": "0.4.3",
- "resolved": "https://registry.npmjs.org/pagedjs/-/pagedjs-0.4.3.tgz",
- "integrity": "sha512-YtAN9JAjsQw1142gxEjEAwXvOF5nYQuDwnQ67RW2HZDkMLI+b4RsBE37lULZa9gAr6kDAOGBOhXI4wGMoY3raw==",
- "license": "MIT",
- "dependencies": {
- "@babel/polyfill": "^7.10.1",
- "@babel/runtime": "^7.21.0",
- "clear-cut": "^2.0.2",
- "css-tree": "^1.1.3",
- "event-emitter": "^0.3.5"
- }
- },
- "node_modules/pagedjs/node_modules/css-tree": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz",
- "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==",
- "license": "MIT",
- "dependencies": {
- "mdn-data": "2.0.14",
- "source-map": "^0.6.1"
- },
- "engines": {
- "node": ">=8.0.0"
- }
- },
- "node_modules/pagedjs/node_modules/mdn-data": {
- "version": "2.0.14",
- "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz",
- "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==",
- "license": "CC0-1.0"
- },
- "node_modules/pagedjs/node_modules/source-map": {
- "version": "0.6.1",
- "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
- "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
- "license": "BSD-3-Clause",
- "engines": {
- "node": ">=0.10.0"
- }
- },
"node_modules/pako": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
@@ -11393,12 +11274,6 @@
"node": ">=4"
}
},
- "node_modules/regenerator-runtime": {
- "version": "0.13.11",
- "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
- "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
- "license": "MIT"
- },
"node_modules/regexp-tree": {
"version": "0.1.27",
"resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz",
@@ -12480,12 +12355,6 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
- "node_modules/type": {
- "version": "2.7.3",
- "resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz",
- "integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==",
- "license": "ISC"
- },
"node_modules/type-fest": {
"version": "5.7.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.7.0.tgz",
diff --git a/package.json b/package.json
index 6f2152c..14b828c 100644
--- a/package.json
+++ b/package.json
@@ -14,7 +14,6 @@
"marked": "^15.0.0",
"nanoid": "^5.1.0",
"nuxt": "^3.17.0",
- "pagedjs": "^0.4.3",
"playwright": "1.61.1",
"sharp": "^0.34.0",
"vuedraggable": "^4.1.0",
diff --git a/pages/portfolios/[id].vue b/pages/portfolios/[id].vue
index c35e710..68d6ba0 100644
--- a/pages/portfolios/[id].vue
+++ b/pages/portfolios/[id].vue
@@ -4,7 +4,7 @@
// overrides, couleurs de bandeau — avec aperçu WYSIWYG central.
import draggable from 'vuedraggable'
import { nanoid } from 'nanoid'
-import type { FreePage, Page, Portfolio, Project, ProjectGridPage, ProjectPage, TocPage } from '~~/types'
+import type { FreeColumn, FreePage, OutroPage, Page, Portfolio, Project, ProjectGridPage, ProjectPage, TeamPage, TocPage } from '~~/types'
const route = useRoute()
const toast = useToast()
@@ -23,6 +23,20 @@ const projects = computed>(() =>
Object.fromEntries((projectsData.value?.projects ?? []).map(p => [p.id, p])),
)
+// Migration des pages libres legacy (body/media) → colonnes. Faite avant
+// l'enregistrement du watch d'autosave (donc sans sauvegarde parasite) ; se
+// persistera à la première vraie édition. Le rendu gère aussi le repli legacy.
+for (const p of portfolio.value.pages) {
+ if (p.kind === 'free' && !p.columns) {
+ const cols: FreeColumn[] = []
+ if (p.body?.trim()) cols.push({ id: nanoid(6), kind: 'text', body: p.body })
+ if (p.media?.length) cols.push({ id: nanoid(6), kind: 'media', media: [...p.media] })
+ p.columns = cols.length ? cols : [{ id: nanoid(6), kind: 'text', body: '' }]
+ p.body = undefined
+ p.media = undefined
+ }
+}
+
const selectedPageId = ref(portfolio.value.pages[0]?.id)
const selectedIndex = computed(() => portfolio.value.pages.findIndex(p => p.id === selectedPageId.value))
const selectedPage = computed(() => portfolio.value.pages[selectedIndex.value])
@@ -190,14 +204,43 @@ function addFreePage() {
id: nanoid(8),
template: 'free-text',
title: 'Page libre',
- body: '',
- media: [],
+ columns: [{ id: nanoid(6), kind: 'text', body: '' }],
background: { mode: 'white' },
}
portfolio.value.pages.push(page)
selectPage(page.id)
}
+// ---- Colonnes des pages libres (1 à 3, texte ou images) ----
+const MAX_FREE_COLUMNS = 3
+
+function addFreeColumn() {
+ if (selectedPage.value?.kind !== 'free') return
+ const cols = selectedPage.value.columns ?? (selectedPage.value.columns = [])
+ if (cols.length >= MAX_FREE_COLUMNS) return
+ cols.push({ id: nanoid(6), kind: 'text', body: '' })
+}
+
+function removeFreeColumn(id: string) {
+ if (selectedPage.value?.kind !== 'free' || !selectedPage.value.columns) return
+ if (selectedPage.value.columns.length <= 1) return // au moins une colonne
+ selectedPage.value.columns = selectedPage.value.columns.filter(c => c.id !== id)
+}
+
+function setFreeColumnKind(col: FreeColumn, kind: FreeColumn['kind']) {
+ if (col.kind === kind) return
+ col.kind = kind
+ // On conserve les deux contenus : repasser sur l'autre type retrouve ce qui
+ // avait été saisi. Seul le champ actif est initialisé s'il est vide.
+ if (kind === 'text') col.body ??= ''
+ else col.media ??= []
+}
+
+const freeColumnKinds = [
+ { value: 'text', label: 'Texte', icon: 'i-lucide-text' },
+ { value: 'media', label: 'Images', icon: 'i-lucide-image' },
+] as const
+
function addTocPage() {
const page: TocPage = { kind: 'toc', id: nanoid(8), title: 'Sommaire', background: { mode: 'white' } }
// Le sommaire s'insère après la couverture par convention
@@ -212,6 +255,35 @@ function addCoverPage() {
selectPage(page.id)
}
+function addOutroPage() {
+ // Page de conclusion : à la fin par convention.
+ const page: OutroPage = { kind: 'outro', id: nanoid(8), title: 'Merci !', background: { mode: 'white' } }
+ portfolio.value.pages.push(page)
+ selectPage(page.id)
+}
+
+function addTeamPage() {
+ // Membres seedés depuis le roster : les défauts sont cochés, chaque membre
+ // porte son rôle et sa présentation (éditables ensuite).
+ const page: TeamPage = {
+ kind: 'team',
+ id: nanoid(8),
+ title: 'L’équipe',
+ members: TEAM_ROSTER.map(m => ({ id: m.id, enabled: !!m.defaultEnabled, role: m.role, bio: m.bio })),
+ background: { mode: 'white' },
+ }
+ portfolio.value.pages.push(page)
+ selectPage(page.id)
+}
+
+const rosterById = (id: string) => TEAM_ROSTER.find(m => m.id === id)
+const enabledMemberCount = computed(() =>
+ selectedPage.value?.kind === 'team' ? selectedPage.value.members.filter(m => m.enabled).length : 0,
+)
+// Sections de personnalisation (rôle + présentation) repliées par défaut ;
+// dépliées membre par membre via le chevron.
+const expandedMembers = ref>({})
+
function removePage(id: string) {
const i = portfolio.value.pages.findIndex(p => p.id === id)
if (i === -1) return
@@ -222,12 +294,16 @@ function removePage(id: string) {
}
const hasCover = computed(() => portfolio.value.pages.some(p => p.kind === 'cover'))
+const hasOutro = computed(() => portfolio.value.pages.some(p => p.kind === 'outro'))
+const hasTeam = computed(() => portfolio.value.pages.some(p => p.kind === 'team'))
const addItems = computed(() => [[
{ label: 'Projet (couverture + grille)…', icon: 'i-lucide-image', onSelect: () => (showProjectPicker.value = true) },
{ label: 'Page libre', icon: 'i-lucide-text', onSelect: addFreePage },
{ label: 'Sommaire', icon: 'i-lucide-list', onSelect: addTocPage },
+ ...(hasTeam.value ? [] : [{ label: 'Équipe', icon: 'i-lucide-users', onSelect: addTeamPage }]),
...(hasCover.value ? [] : [{ label: 'Couverture', icon: 'i-lucide-bookmark', onSelect: addCoverPage }]),
+ ...(hasOutro.value ? [] : [{ label: 'Conclusion', icon: 'i-lucide-heart', onSelect: addOutroPage }]),
]])
// ---- Libellés de la liste de pages ----
@@ -238,6 +314,8 @@ function pageLabel(page: Page): string {
case 'free': return page.title || 'Page libre'
case 'project': return projects.value[page.projectId]?.title ?? page.projectId
case 'project-grid': return projects.value[page.projectId]?.title ?? page.projectId
+ case 'outro': return page.title || 'Conclusion'
+ case 'team': return page.title || 'Équipe'
}
}
@@ -247,6 +325,8 @@ const KIND_LABELS: Record = {
'free': 'Page libre',
'project': 'Projet — couverture',
'project-grid': 'Projet — grille',
+ 'outro': 'Conclusion',
+ 'team': 'Équipe',
}
// ---- Encre du bandeau (pages projet) ----
@@ -356,18 +436,21 @@ function resetPresentation() {
// ---- Image de couverture / médias de page libre ----
const showMediaPicker = ref(false)
-const mediaPickerTarget = ref<'cover' | 'free'>('cover')
+type MediaPickerTarget = { type: 'cover' } | { type: 'free-column'; columnId: string }
+const mediaPickerTarget = ref({ type: 'cover' })
-function openMediaPicker(target: 'cover' | 'free') {
+function openMediaPicker(target: MediaPickerTarget) {
mediaPickerTarget.value = target
showMediaPicker.value = true
}
function onMediaPicked(ref_: string) {
- if (mediaPickerTarget.value === 'cover') {
+ const target = mediaPickerTarget.value
+ if (target.type === 'cover') {
portfolio.value.cover.backgroundImage = ref_
} else if (selectedPage.value?.kind === 'free') {
- selectedPage.value.media = [...(selectedPage.value.media ?? []), ref_]
+ const col = selectedPage.value.columns?.find(c => c.id === target.columnId)
+ if (col?.kind === 'media') col.media = [...(col.media ?? []), ref_]
}
}
@@ -610,6 +693,9 @@ async function copyShareUrl() {
+
+
+
@@ -619,8 +705,16 @@ async function copyShareUrl() {
+
+
+
+
@@ -636,6 +730,80 @@ async function copyShareUrl() {
+
+
+ Conclusion
+
+
+
+
+
+
+
+
+
+
+
+ Équipe
+
+
+
+
+
+
+ Membres ({{ enabledMemberCount }}/{{ selectedPage.members.length }})
+
+
+
+
+
+
+
+ {{ rosterById(m.id)?.name }}
+
+
+
+
+
+
+
+
+
+
+
+
Page libre
@@ -643,38 +811,96 @@ async function copyShareUrl() {
-
-
-
+
+
+
+
+
+ Colonnes ({{ selectedPage.columns?.length ?? 0 }}/{{ MAX_FREE_COLUMNS }})
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
![]()
+
+
+
+
+
+
+
+
+
+
Taille du texte — {{ freeTextScale }} %
-
-
Images ({{ selectedPage.media?.length ?? 0 }}/3)
-
-
-
![]()
-
-
-
-
-
diff --git a/pages/view/[id].vue b/pages/view/[id].vue
index 2669742..4b158a7 100644
--- a/pages/view/[id].vue
+++ b/pages/view/[id].vue
@@ -51,6 +51,8 @@ function pageLabel(page: Page): string {
case 'free': return page.title || 'Page libre'
case 'project':
case 'project-grid': return projects.value[page.projectId]?.title ?? page.projectId
+ case 'outro': return page.title || 'Conclusion'
+ case 'team': return page.title || 'Équipe'
}
}
diff --git a/public/portraits/Lilia.png b/public/portraits/Lilia.png
new file mode 100644
index 0000000..a2f292d
Binary files /dev/null and b/public/portraits/Lilia.png differ
diff --git a/public/portraits/arman.png b/public/portraits/arman.png
new file mode 100644
index 0000000..9584217
Binary files /dev/null and b/public/portraits/arman.png differ
diff --git a/public/portraits/bachir.png b/public/portraits/bachir.png
new file mode 100644
index 0000000..49ba929
Binary files /dev/null and b/public/portraits/bachir.png differ
diff --git a/public/portraits/cathy.png b/public/portraits/cathy.png
new file mode 100644
index 0000000..ad8100b
Binary files /dev/null and b/public/portraits/cathy.png differ
diff --git a/public/portraits/chloe.png b/public/portraits/chloe.png
new file mode 100644
index 0000000..e3c9820
Binary files /dev/null and b/public/portraits/chloe.png differ
diff --git a/public/portraits/hugo.png b/public/portraits/hugo.png
new file mode 100644
index 0000000..68e681f
Binary files /dev/null and b/public/portraits/hugo.png differ
diff --git a/public/portraits/jean-elie.png b/public/portraits/jean-elie.png
new file mode 100644
index 0000000..d673cd1
Binary files /dev/null and b/public/portraits/jean-elie.png differ
diff --git a/public/portraits/julia.png b/public/portraits/julia.png
new file mode 100644
index 0000000..9e1c64a
Binary files /dev/null and b/public/portraits/julia.png differ
diff --git a/public/portraits/marion.png b/public/portraits/marion.png
new file mode 100644
index 0000000..495ad92
Binary files /dev/null and b/public/portraits/marion.png differ
diff --git a/public/portraits/maud.png b/public/portraits/maud.png
new file mode 100644
index 0000000..cec908f
Binary files /dev/null and b/public/portraits/maud.png differ
diff --git a/public/portraits/ouidade.png b/public/portraits/ouidade.png
new file mode 100644
index 0000000..888552b
Binary files /dev/null and b/public/portraits/ouidade.png differ
diff --git a/public/portraits/sabine.png b/public/portraits/sabine.png
new file mode 100644
index 0000000..6e4fea7
Binary files /dev/null and b/public/portraits/sabine.png differ
diff --git a/public/portraits/sandrine.png b/public/portraits/sandrine.png
new file mode 100644
index 0000000..b240828
Binary files /dev/null and b/public/portraits/sandrine.png differ
diff --git a/public/portraits/val.png b/public/portraits/val.png
new file mode 100644
index 0000000..8e24f26
Binary files /dev/null and b/public/portraits/val.png differ
diff --git a/types/index.ts b/types/index.ts
index 0e59eff..4b61ff9 100644
--- a/types/index.ts
+++ b/types/index.ts
@@ -53,6 +53,7 @@ export interface ResolvedMedia {
url: string
posterUrl?: string
ratio?: number // largeur / hauteur intrinsèque (sert au dimensionnement des bandeaux)
+ caption?: string // légende affichée sous l'image (ex. titre du projet sur les pages libres)
}
/**
@@ -95,13 +96,25 @@ export interface ProjectGridPage {
// couverture du même projet.
}
+export type FreeColumnKind = 'text' | 'media'
+
+/** Colonne d'une page libre : soit du texte (markdown), soit des images. */
+export interface FreeColumn {
+ id: string
+ kind: FreeColumnKind
+ heading?: string // sous-titre optionnel en tête de colonne
+ body?: string // markdown (colonnes texte)
+ media?: string[] // "projectId/filename" (colonnes images)
+}
+
export interface FreePage {
kind: 'free'
id: string
template: 'free-text'
title?: string
- body?: string // markdown saisi dans l'UI
- media?: string[] // "projectId/filename" optionnels
+ columns?: FreeColumn[] // 1 à 3 colonnes de texte ou d'images
+ body?: string // legacy (mono-colonne texte) — migré vers columns à l'ouverture
+ media?: string[] // legacy (colonne images) — migré vers columns à l'ouverture
background: Background // blanc / teinté / plein (couleur du filet)
bandColor?: string // filet haut optionnel
textScale?: number // taille du texte de labeur en % (défaut 100)
@@ -122,17 +135,54 @@ export interface TocPage {
// Pas de couleur de filet propre : il hérite du filet de la couverture.
}
-export type Page = ProjectPage | ProjectGridPage | FreePage | CoverPage | TocPage
+/**
+ * Page de conclusion : « Merci ! » en grand au centre + rappel des contacts
+ * (site, mail, adresse tirés de la couverture). Bookend de la couverture.
+ */
+export interface OutroPage {
+ kind: 'outro'
+ id: string
+ title?: string // texte central — défaut « Merci ! »
+ background: Background // blanc / teinté / plein (couleur du filet)
+ bandColor?: string // filet couleur en bas — défaut encre
+}
+
+/** Membre affiché sur la page équipe. Nom et portrait viennent du roster
+ * statique (composables/useTeam) ; rôle et présentation sont surchargeables. */
+export interface TeamMemberConfig {
+ id: string // référence un membre de TEAM_ROSTER
+ enabled: boolean // affiché dans la grille ?
+ role?: string // rôle (défaut : celui du roster)
+ bio?: string // présentation (défaut : celle du roster)
+}
+
+/**
+ * Page « équipe » : présente les membres du collectif en grille de 3 colonnes
+ * (portrait, nom, rôle, présentation). La grille s'adapte au nombre de membres
+ * cochés. Titre par défaut « L'équipe ».
+ */
+export interface TeamPage {
+ kind: 'team'
+ id: string
+ title?: string // défaut « L'équipe »
+ members: TeamMemberConfig[] // ordre + sélection + surcharges
+ background: Background // blanc / teinté / plein (couleur du filet)
+ bandColor?: string // filet couleur en haut — défaut encre
+}
+
+export type Page = ProjectPage | ProjectGridPage | FreePage | CoverPage | TocPage | OutroPage | TeamPage
export interface CoverConfig {
title: string
subtitle?: string
recipient?: string // « préparé pour … »
+ body?: string // texte libre (markdown) sous la phrase d'intro — plus petit, centré avec elle
backgroundImage?: string // "projectId/filename"
background: Background // couleur du bandeau de couverture
date?: string // texte libre affiché dans l'en-tête de couverture (ex. « Juillet 2026 »)
siteUrl?: string // lien du site — en-tête de couverture + header de la vue publique (défaut DEFAULT_SITE_URL)
contactEmail?: string // mail de contact — en-tête de couverture + header public (défaut DEFAULT_CONTACT_EMAIL)
+ address?: string // adresse postale — angle bas-gauche de la couverture (défaut DEFAULT_ADDRESS)
}
// Contenu figé à la génération (spec §7.3) — vrai instantané : contenu Grav
@@ -158,6 +208,7 @@ export interface Portfolio {
frozenContent?: FrozenSnapshot
lastExport?: { fileName: string; at: string }
publicShare?: boolean // lien public /view/ activé (opt-in, révocable)
+ showYears?: boolean // affiche l'année sur les couvertures de projet — défaut : true
}
export interface PortfolioSummary {