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

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