Pages équipe + conclusion, colonnes de page libre, réglages couverture

- 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
This commit is contained in:
2026-07-13 16:00:59 +02:00
parent ef1f14335d
commit 1c1604ea2f
29 changed files with 802 additions and 265 deletions

View File

@@ -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/<id>/` (`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/<id>/`
(`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)

View File

@@ -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).

View File

@@ -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<ResolvedMedia[]>(() => {
.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<ResolvedMedia[]>(() => {
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<ResolvedColumn[]>(() => {
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) : '' },
)
})
</script>
@@ -171,14 +222,37 @@ const freeMedia = computed<ResolvedMedia[]>(() => {
<CoverTemplate
v-if="page.kind === 'cover'"
:title="portfolio.cover.title"
:body="coverBody"
:date="portfolio.cover.date"
:site-url="portfolio.cover.siteUrl"
:contact-email="portfolio.cover.contactEmail"
:address="portfolio.cover.address"
:background="page.background"
:band-color="page.bandColor"
:render-mode="renderMode"
/>
<OutroTemplate
v-else-if="page.kind === 'outro'"
:title="page.title"
:site-url="portfolio.cover.siteUrl"
:contact-email="portfolio.cover.contactEmail"
:address="portfolio.cover.address"
:background="page.background"
:band-color="page.bandColor ?? coverBandColor"
:render-mode="renderMode"
/>
<TeamTemplate
v-else-if="page.kind === 'team'"
:title="page.title"
:members="teamMembers"
:background="page.background"
:band-color="page.bandColor ?? coverBandColor"
:page="pageInfo"
:render-mode="renderMode"
/>
<TocTemplate
v-else-if="page.kind === 'toc'"
:title="page.title"
@@ -193,8 +267,7 @@ const freeMedia = computed<ResolvedMedia[]>(() => {
<FreeTextPage
v-else-if="page.kind === 'free'"
:title="page.title"
:text="freeText"
:media="freeMedia"
:columns="freeColumns"
:background="page.background"
:band-color="page.bandColor ?? coverBandColor"
:text-scale="page.textScale"

View File

@@ -8,6 +8,7 @@ import type { Background } from '~~/types'
const props = withDefaults(
defineProps<{
title: string // titre du portfolio — en-tête + métadonnées PDF
body?: string // HTML : texte libre sous la phrase d'intro (plus petit, centré avec elle)
subtitle?: string
recipient?: string
background: Background
@@ -15,6 +16,7 @@ const props = withDefaults(
date?: string // texte libre de l'en-tête (ex. « Juillet 2026 »)
siteUrl?: string
contactEmail?: string
address?: string // adresse postale — angle bas-gauche
renderMode?: 'preview' | 'print'
}>(),
{ 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),
)
</script>
<template>
@@ -71,8 +78,26 @@ const displayDate = computed(() => props.date?.trim() || formatMonthYear())
<span class="fl-font-culturelle">culturelle</span>
qui œuvre sur logiciels&nbsp;libres.
</p>
<!-- Texte libre optionnel, plus petit, sous la phrase d'intro -->
<div
v-if="body"
class="fl-prose mt-12 w-[65%]"
:style="{ fontFamily: 'Lato, sans-serif', fontSize: '24px', lineHeight: '1.45' }"
v-html="body"
/>
</div>
<!-- Adresse postale dans l'angle bas-gauche (au-dessus du filet) -->
<p
v-if="addressLines.length"
class="absolute left-0"
:style="{
bottom: background.mode === 'full' ? '0' : 'var(--fl-strip)',
padding: 'var(--fl-margin)',
fontFamily: 'Syne, sans-serif', fontSize: '19px', lineHeight: '1.5',
}"
><span v-for="(line, i) in addressLines" :key="i" class="block">{{ line }}</span></p>
<!-- Filet couleur pleine largeur en bas (masqué en fond plein) -->
<div v-if="background.mode !== 'full'" class="absolute inset-x-0 bottom-0" :style="{ height: 'var(--fl-strip)', backgroundColor: strip }" />
</div>

View File

@@ -1,21 +1,29 @@
<script setup lang="ts">
// Page libre (équipe, méthodologie…) — langage du gabarit : filet couleur
// 5 mm, marges 8 mm, titre Syne 45 pt, texte Syne 12/16 pt, médias entiers
// calés à droite. Pas de multi-colonnes CSS (Chromium les imprime en blanc).
// 5 mm, marges 8 mm, titre Syne 45 pt, texte Syne 12/16 pt. Contenu réparti en
// 1 à 3 colonnes de texte ou d'images, chacune avec un sous-titre optionnel.
// Pas de multi-colonnes CSS (Chromium les imprime en blanc) : flexbox explicite.
import type { Background, ResolvedMedia } from '~~/types'
interface ResolvedColumn {
id: string
kind: 'text' | 'media'
heading?: string
html?: string // colonnes texte
media?: ResolvedMedia[] // colonnes images
}
const props = withDefaults(
defineProps<{
title?: string
text?: string // HTML
media?: ResolvedMedia[]
columns?: ResolvedColumn[]
background: Background
bandColor?: string
textScale?: number // taille du texte de labeur en % (défaut 100 = 12 pt)
page: { index: number; total: number }
renderMode?: 'preview' | 'print'
}>(),
{ renderMode: 'preview', textScale: 100 },
{ renderMode: 'preview', textScale: 100, columns: () => [] },
)
const strip = computed(() => props.bandColor || FL_INK)
@@ -30,13 +38,31 @@ const bodyStyle = computed(() => ({ fontSize: `${(16 * props.textScale) / 100}px
<div class="absolute flex flex-col" :style="{ inset: 'var(--fl-margin)', top: '60px' }">
<h2 v-if="title" class="max-w-[1000px]" :style="{ fontSize: '60px', lineHeight: '1.05' }">{{ title }}</h2>
<div class="mt-12 flex min-h-0 flex-1" :style="{ gap: '48px' }">
<div v-if="text" class="fl-prose max-w-[740px] flex-1 overflow-hidden" :style="bodyStyle" v-html="text" />
<div v-if="media?.length" class="flex w-[560px] shrink-0 flex-col" :style="{ gap: 'var(--fl-gutter)' }">
<div v-for="m in media.slice(0, 3)" :key="m.filename" class="min-h-0 flex-1">
<img :src="m.url" :alt="m.filename" class="size-full object-contain" style="object-position: right top">
<div class="mt-12 flex min-h-0 flex-1" :style="{ gap: 'var(--fl-gutter)' }">
<section v-for="col in columns" :key="col.id" class="flex min-w-0 flex-1 flex-col">
<!-- Sous-titre optionnel en tête de colonne -->
<h3 v-if="col.heading" class="mb-4 shrink-0" :style="{ fontSize: '28px', lineHeight: '1.15' }">{{ col.heading }}</h3>
<!-- Colonne texte -->
<div
v-if="col.kind === 'text'"
class="fl-prose min-h-0 flex-1 overflow-hidden"
:style="bodyStyle"
v-html="col.html"
/>
<!-- Colonne images : entières, calées, titre du projet en légende -->
<div v-else class="flex min-h-0 flex-1 flex-col" :style="{ gap: 'var(--fl-gutter)' }">
<figure v-for="m in col.media" :key="m.filename" class="flex min-h-0 flex-1 flex-col">
<img :src="m.url" :alt="m.caption || m.filename" class="min-h-0 flex-1 object-contain object-left-top">
<figcaption
v-if="m.caption"
class="mt-1 shrink-0 text-left opacity-70"
:style="{ fontFamily: 'Syne, sans-serif', fontSize: '14px', lineHeight: '1.3' }"
>{{ m.caption }}</figcaption>
</figure>
</div>
</div>
</section>
</div>
</div>
</div>

View File

@@ -0,0 +1,74 @@
<script setup lang="ts">
// Page de conclusion — bookend de la couverture : « Merci ! » en grand au
// centre + rappel des contacts (site, mail, adresse tirés de la couverture).
import type { Background } from '~~/types'
const props = withDefaults(
defineProps<{
title?: string // texte central — défaut « Merci ! »
background: Background
bandColor?: string // filet couleur en bas
siteUrl?: string
contactEmail?: string
address?: string
renderMode?: 'preview' | 'print'
}>(),
{ renderMode: 'preview' },
)
const strip = computed(() => props.bandColor || FL_INK)
// Même logique de fond que la couverture (blanc = gris charte, plein = filet).
const pageStyle = computed(() => {
const base
= props.background.mode === 'full'
? strip.value
: props.background.mode === 'tint'
? TINT_COLOR
: FL_BASE
return { backgroundColor: base, color: inkOn(base) }
})
const heading = computed(() => props.title?.trim() || 'Merci !')
const siteHref = computed(() => props.siteUrl?.trim() || DEFAULT_SITE_URL)
const siteLabel = computed(() => siteHref.value.replace(/^https?:\/\//, '').replace(/\/$/, ''))
const email = computed(() => props.contactEmail?.trim() || DEFAULT_CONTACT_EMAIL)
// Adresse coupée aux virgules : une ligne par segment.
const addressLines = computed(() =>
(props.address?.trim() || DEFAULT_ADDRESS).split(',').map(s => s.trim()).filter(Boolean),
)
</script>
<template>
<div class="fl-page" :style="pageStyle">
<div
class="absolute inset-0 flex flex-col items-center justify-center"
:style="{ padding: 'var(--fl-margin)', gap: '64px' }"
>
<p :style="{ fontFamily: 'Syne, sans-serif', fontSize: '150px', lineHeight: '1' }">{{ heading }}</p>
<!-- Rappel des contacts (liens actifs PDF/viewer) -->
<div
class="flex flex-col items-center"
:style="{ fontFamily: 'Syne, sans-serif', fontSize: '22px', lineHeight: '1.6' }"
>
<a :href="siteHref" target="_blank" rel="noopener" class="text-current no-underline">{{ siteLabel }}</a>
<a :href="`mailto:${email}`" class="text-current no-underline">{{ email }}</a>
</div>
</div>
<!-- Adresse postale dans l'angle bas-gauche (comme la couverture) -->
<p
v-if="addressLines.length"
class="absolute left-0"
:style="{
bottom: background.mode === 'full' ? '0' : 'var(--fl-strip)',
padding: 'var(--fl-margin)',
fontFamily: 'Syne, sans-serif', fontSize: '19px', lineHeight: '1.5',
}"
><span v-for="(line, i) in addressLines" :key="i" class="block">{{ line }}</span></p>
<!-- Filet couleur pleine largeur en bas (masqué en fond plein) -->
<div v-if="background.mode !== 'full'" class="absolute inset-x-0 bottom-0" :style="{ height: 'var(--fl-strip)', backgroundColor: strip }" />
</div>
</template>

View File

@@ -0,0 +1,83 @@
<script setup lang="ts">
// Page « équipe » — langage du gabarit : filet couleur 5 mm en haut, marges
// 8 mm, titre Syne 45 pt. Les membres s'affichent en grille de 3 colonnes
// (portrait, nom, rôle, présentation), qui s'adapte à leur nombre. Pas de
// multi-colonnes CSS (Chromium les imprime en blanc) : grille explicite.
import type { Background } from '~~/types'
interface ResolvedMember {
id: string
name: string
portraitUrl: string
role?: string
bio?: string
}
const props = withDefaults(
defineProps<{
title?: string
members?: ResolvedMember[]
background: Background
bandColor?: string
page: { index: number; total: number }
renderMode?: 'preview' | 'print'
}>(),
{ title: 'Léquipe', renderMode: 'preview', members: () => [] },
)
const strip = computed(() => props.bandColor || FL_INK)
const pageStyle = computed(() => backgroundStyle(props.background, strip.value))
// 3 colonnes au plus. On prend le minimum de rangées possible, puis on répartit
// les membres pour équilibrer la grille (4 membres → 2×2, 5 → 3+2, etc.).
const count = computed(() => props.members.length || 1)
const rows = computed(() => Math.ceil(count.value / 3))
const cols = computed(() => Math.ceil(count.value / rows.value))
const layout = computed(() => {
if (rows.value <= 1) return { portrait: 280, name: 30, role: 20, bio: 15, top: 48, gapY: 40 }
if (rows.value === 2) return { portrait: 168, name: 23, role: 16, bio: 12, top: 36, gapY: 28 }
return { portrait: 124, name: 19, role: 14, bio: 10.5, top: 28, gapY: 22 }
})
// Peu de colonnes sur plusieurs rangées (ex. 4 membres en 2×2) = colonnes très
// larges : on rentre la grille des deux côtés pour resserrer les colonnes.
const gridInset = computed(() => (rows.value >= 2 && cols.value <= 2 ? 190 : 0))
const gridStyle = computed(() => ({
gridTemplateColumns: `repeat(${cols.value}, minmax(0, 1fr))`,
columnGap: '48px',
rowGap: `${layout.value.gapY}px`,
gridAutoRows: 'min-content',
marginTop: `${layout.value.top}px`,
paddingInline: `${gridInset.value}px`,
}))
</script>
<template>
<div class="fl-page" :style="pageStyle">
<div v-if="background.mode !== 'full'" class="absolute inset-x-0 top-0" :style="{ height: 'var(--fl-strip)', backgroundColor: strip }" />
<div class="absolute flex flex-col" :style="{ inset: 'var(--fl-margin)', top: '60px' }">
<h2 :style="{ fontSize: '60px', lineHeight: '1.05' }">{{ title }}</h2>
<div class="grid min-h-0 flex-1 content-start overflow-hidden" :style="gridStyle">
<div v-for="m in members" :key="m.id" class="flex min-w-0 flex-col">
<!-- Portrait carré, cadré (les portraits non carrés sont recentrés) -->
<div
class="w-full overflow-hidden"
:style="{ aspectRatio: '1 / 1', maxWidth: `${layout.portrait}px` }"
>
<img :src="m.portraitUrl" :alt="m.name" class="size-full object-cover">
</div>
<p class="mt-4" :style="{ fontSize: `${layout.name}px`, lineHeight: '1.15' }">{{ m.name }}</p>
<p v-if="m.role" class="opacity-70" :style="{ fontSize: `${layout.role}px`, lineHeight: '1.2' }">{{ m.role }}</p>
<p
v-if="m.bio"
class="mt-2"
:style="{ fontSize: `${layout.bio}px`, lineHeight: '1.35', hyphens: 'auto', textAlign: 'justify' }"
>{{ m.bio }}</p>
</div>
</div>
</div>
</div>
</template>

View File

@@ -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)"
>
<span :class="CATEGORY_TITLE_CLASS[entry.category ?? '']" :style="{ fontSize: `${layout.size}px`, lineHeight: '1.2' }">{{ entry.title }}</span>
<span v-if="entry.subtitle" class="whitespace-nowrap opacity-70" :style="{ fontSize: `${layout.sub}px` }">{{ entry.subtitle }}</span>
<span class="flex-1 border-b border-dotted border-current opacity-40" />
<!-- Filet de conduite en points dégradés (une bordure `dotted` saute
au rendu mis à l'échelle : ici les points s'antialiasent). -->
<span
class="flex-1 opacity-40"
:style="{
alignSelf: 'baseline',
height: '4px',
backgroundImage: 'radial-gradient(circle at center, currentColor 1px, transparent 1.5px)',
backgroundSize: '6px 4px',
backgroundRepeat: 'repeat-x',
backgroundPosition: '0 100%',
}"
/>
<span class="tabular-nums opacity-80" :style="{ fontSize: `${layout.sub + 2}px` }">{{ entry.pageNumber }}</span>
</a>
</div>

View File

@@ -36,6 +36,7 @@ export const CATEGORY_LABELS: Record<string, string> = {
// 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.

79
composables/useTeam.ts Normal file
View File

@@ -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 lIUFM 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 dutilité 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. Cest la transition douce vers les logiciels libres, Figures Libres est né. Rigoureuse, patiente et organisée, Maud est linterlocutrice 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 aujourdhui 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 lassociation 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 « linformation en mouvement » produit les sites web les plus complexes dotés dune 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 aujourdhui 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 dIvry-sur-Seine où elle apprend les enjeux de la communication locale. Toujours à la recherche du visuel qui fait sens pour un impact maximum, cest 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 lagence, également en charge des projets de scénographie. Comme une évidence, dans son parcours, elle sengage 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 à limage 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 dengagement 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 lhôtellerie du luxe, Ouidade poursuit son parcours en intégrant lagence Figures Libres en 2017. Formées aux logiciels de traitement dimages Gimp et Darktable, Ouidade est la spécialiste du montage et de la retouche dimage. En 2020, elle entame une formation de développeuse web et assure aujourdhui 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 dart du Havre (quil a quitté sous les félicitations du Jury), est aussi à laise 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 lagence. Naturellement, il sinstalle 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}`
}

251
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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<Record<string, Project>>(() =>
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<string | undefined>(portfolio.value.pages[0]?.id)
const selectedIndex = computed(() => portfolio.value.pages.findIndex(p => p.id === selectedPageId.value))
const selectedPage = computed<Page | undefined>(() => 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<Record<string, boolean>>({})
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<Page['kind'], string> = {
'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<MediaPickerTarget>({ 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() {
<UFormField label="Titre">
<UInput v-model="portfolio.cover.title" class="w-full" />
</UFormField>
<UFormField label="Texte libre">
<UTextarea v-model="portfolio.cover.body" :rows="5" class="w-full" autoresize />
</UFormField>
<UFormField label="Date">
<UInput v-model="portfolio.cover.date" class="w-full" :placeholder="formatMonthYear()" />
</UFormField>
@@ -619,8 +705,16 @@ async function copyShareUrl() {
<UFormField label="Mail">
<UInput v-model="portfolio.cover.contactEmail" class="w-full" :placeholder="DEFAULT_CONTACT_EMAIL" />
</UFormField>
<UFormField label="Adresse">
<UInput v-model="portfolio.cover.address" class="w-full" :placeholder="DEFAULT_ADDRESS" />
</UFormField>
<BandColorPicker v-model="selectedPage.bandColor" label="Couleur du filet" :default-color="FL_INK" />
<BackgroundEditor v-model="selectedPage.background" />
<UCheckbox
:model-value="portfolio.showYears !== false"
label="Afficher les années des projets"
@update:model-value="portfolio.showYears = $event === true"
/>
</div>
</template>
@@ -636,6 +730,80 @@ async function copyShareUrl() {
</div>
</template>
<!-- Conclusion -->
<template v-else-if="selectedPage.kind === 'outro'">
<h3 class="mb-4 font-semibold">Conclusion</h3>
<div class="space-y-4">
<UFormField label="Texte">
<UInput v-model="selectedPage.title" class="w-full" placeholder="Merci !" />
</UFormField>
<BandColorPicker v-model="selectedPage.bandColor" label="Couleur du filet" :default-color="FL_INK" />
<BackgroundEditor v-model="selectedPage.background" />
</div>
</template>
<!-- Équipe -->
<template v-else-if="selectedPage.kind === 'team'">
<h3 class="mb-4 font-semibold">Équipe</h3>
<div class="space-y-4">
<UFormField label="Titre">
<UInput v-model="selectedPage.title" class="w-full" placeholder="Léquipe" />
</UFormField>
<div class="space-y-2">
<p class="text-xs font-medium uppercase tracking-wide text-neutral-500">
Membres ({{ enabledMemberCount }}/{{ selectedPage.members.length }})
</p>
<draggable
v-model="selectedPage.members"
item-key="id"
handle=".member-drag-handle"
:animation="150"
class="space-y-2"
>
<template #item="{ element: m }">
<div class="rounded-lg border border-neutral-200 p-3 dark:border-neutral-800">
<div class="flex items-center gap-2.5">
<UIcon name="i-lucide-grip-vertical" class="member-drag-handle size-4 shrink-0 cursor-grab text-neutral-400" />
<UCheckbox v-model="m.enabled" />
<span class="truncate text-sm font-medium">{{ rosterById(m.id)?.name }}</span>
<UButton
v-if="m.enabled"
:icon="expandedMembers[m.id] ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
size="xs"
variant="ghost"
color="neutral"
class="ml-auto"
:aria-label="expandedMembers[m.id] ? 'Replier' : 'Personnaliser'"
@click="expandedMembers[m.id] = !expandedMembers[m.id]"
/>
</div>
<div v-if="m.enabled && expandedMembers[m.id]" class="mt-3 space-y-2">
<UFormField label="Rôle">
<UInput v-model="m.role" class="w-full" />
</UFormField>
<UFormField label="Présentation">
<UTextarea v-model="m.bio" :rows="5" class="w-full" autoresize />
<div v-if="m.bio !== rosterById(m.id)?.bio" class="mt-1.5 flex justify-end">
<UButton
icon="i-lucide-rotate-ccw"
size="xs"
variant="ghost"
color="neutral"
label="Réinitialiser"
@click="m.bio = rosterById(m.id)?.bio"
/>
</div>
</UFormField>
</div>
</div>
</template>
</draggable>
</div>
<BandColorPicker v-model="selectedPage.bandColor" label="Couleur du filet" :default-color="FL_INK" />
<BackgroundEditor v-model="selectedPage.background" />
</div>
</template>
<!-- Page libre -->
<template v-else-if="selectedPage.kind === 'free'">
<h3 class="mb-4 font-semibold">Page libre</h3>
@@ -643,38 +811,96 @@ async function copyShareUrl() {
<UFormField label="Titre">
<UInput v-model="selectedPage.title" class="w-full" />
</UFormField>
<UFormField label="Texte">
<UTextarea v-model="selectedPage.body" :rows="10" class="w-full" />
</UFormField>
<!-- Colonnes (1 à 3, texte ou images) -->
<div class="space-y-3">
<div class="flex items-center justify-between">
<p class="text-xs font-medium uppercase tracking-wide text-neutral-500">
Colonnes ({{ selectedPage.columns?.length ?? 0 }}/{{ MAX_FREE_COLUMNS }})
</p>
<UButton
v-if="(selectedPage.columns?.length ?? 0) < MAX_FREE_COLUMNS"
icon="i-lucide-plus"
label="Colonne"
size="xs"
variant="soft"
@click="addFreeColumn"
/>
</div>
<draggable
v-if="selectedPage.columns"
v-model="selectedPage.columns"
item-key="id"
handle=".col-drag-handle"
:animation="150"
class="space-y-3"
>
<template #item="{ element: col, index: ci }">
<div class="space-y-3 rounded-lg border border-neutral-200 p-3 dark:border-neutral-800">
<div class="flex items-center gap-2">
<UIcon name="i-lucide-grip-vertical" class="col-drag-handle size-4 shrink-0 cursor-grab text-neutral-400" />
<div class="flex gap-1">
<UButton
v-for="opt in freeColumnKinds"
:key="opt.value"
:icon="opt.icon"
:label="opt.label"
size="xs"
:variant="col.kind === opt.value ? 'solid' : 'outline'"
color="neutral"
@click="setFreeColumnKind(col, opt.value)"
/>
</div>
<UButton
v-if="(selectedPage.columns?.length ?? 1) > 1"
icon="i-lucide-trash-2"
size="xs"
variant="ghost"
color="neutral"
class="ml-auto"
:aria-label="`Supprimer la colonne ${ci + 1}`"
@click="removeFreeColumn(col.id)"
/>
</div>
<UFormField label="Sous-titre">
<UInput v-model="col.heading" class="w-full" />
</UFormField>
<UFormField v-if="col.kind === 'text'" label="Texte">
<UTextarea v-model="col.body" :rows="8" class="w-full" />
</UFormField>
<UFormField v-else :label="`Images (${col.media?.length ?? 0}/4)`">
<div class="flex flex-wrap gap-2">
<div v-for="(ref_, i) in col.media" :key="ref_" class="relative size-16 overflow-hidden rounded border">
<img :src="mediaUrl(ref_.slice(0, ref_.lastIndexOf('/')), ref_.slice(ref_.lastIndexOf('/') + 1), 160)" class="size-full object-cover">
<button
type="button"
class="absolute right-0 top-0 flex size-4 items-center justify-center rounded-bl bg-black/60 text-white"
@click="col.media!.splice(i, 1)"
>
<UIcon name="i-lucide-x" class="size-3" />
</button>
</div>
<UButton
v-if="(col.media?.length ?? 0) < 4"
icon="i-lucide-plus"
size="xs"
variant="soft"
class="size-16 justify-center"
@click="openMediaPicker({ type: 'free-column', columnId: col.id })"
/>
</div>
</UFormField>
</div>
</template>
</draggable>
</div>
<div class="space-y-2">
<p class="text-xs font-medium uppercase tracking-wide text-neutral-500">
Taille du texte {{ freeTextScale }} %
</p>
<USlider v-model="freeTextScale" :min="100" :max="180" :step="10" />
</div>
<div class="space-y-2">
<p class="text-xs font-medium uppercase tracking-wide text-neutral-500">Images ({{ selectedPage.media?.length ?? 0 }}/3)</p>
<div class="flex flex-wrap gap-2">
<div v-for="(ref_, i) in selectedPage.media" :key="ref_" class="relative size-16 overflow-hidden rounded border">
<img :src="mediaUrl(ref_.slice(0, ref_.lastIndexOf('/')), ref_.slice(ref_.lastIndexOf('/') + 1), 160)" class="size-full object-cover">
<button
type="button"
class="absolute right-0 top-0 flex size-4 items-center justify-center rounded-bl bg-black/60 text-white"
@click="selectedPage.media!.splice(i, 1)"
>
<UIcon name="i-lucide-x" class="size-3" />
</button>
</div>
<UButton
v-if="(selectedPage.media?.length ?? 0) < 3"
icon="i-lucide-plus"
size="xs"
variant="soft"
class="size-16 justify-center"
@click="openMediaPicker('free')"
/>
</div>
</div>
<BandColorPicker v-model="selectedPage.bandColor" label="Couleur du filet" :default-color="FL_INK" />
<BackgroundEditor v-model="selectedPage.background" />
</div>

View File

@@ -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'
}
}
</script>

BIN
public/portraits/Lilia.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

BIN
public/portraits/arman.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

BIN
public/portraits/bachir.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

BIN
public/portraits/cathy.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 412 KiB

BIN
public/portraits/chloe.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 377 KiB

BIN
public/portraits/hugo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

BIN
public/portraits/julia.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

BIN
public/portraits/marion.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 562 KiB

BIN
public/portraits/maud.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 520 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

BIN
public/portraits/sabine.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 KiB

BIN
public/portraits/val.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

View File

@@ -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/<id> activé (opt-in, révocable)
showYears?: boolean // affiche l'année sur les couvertures de projet — défaut : true
}
export interface PortfolioSummary {