Générateur de portfolios PDF Figures Libres (app Nuxt autonome)

App Nuxt 3 + Nuxt UI qui lit les projets du CMS Grav et compose des portfolios
A3 paysage exportés en PDF (gabarit book_v3) : composeur WYSIWYG, templates
cover/toc/projet/grille/page libre, pipeline Playwright + Ghostscript, gel du
contenu à la génération, partage public opt-in.

Dépôt autonome : Dockerfile (base Playwright + Ghostscript + ffmpeg + rsync) inclus.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-05 18:37:09 +02:00
commit 59dfbe4155
79 changed files with 18587 additions and 0 deletions

35
composables/useFormat.ts Normal file
View File

@@ -0,0 +1,35 @@
import type { Project } from '~~/types'
const dateFormatter = new Intl.DateTimeFormat('fr-FR', { month: 'long', year: 'numeric' })
export function formatProjectDate(iso?: string): string {
if (!iso) return ''
const d = new Date(iso)
return Number.isNaN(d.getTime()) ? '' : dateFormatter.format(d)
}
export const CATEGORY_LABELS: Record<string, string> = {
culturelle: 'Culturelle',
publique: 'Publique',
sociale: 'Sociale',
}
// 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'
/**
* URL de miniature basse résolution d'un média de projet.
* `base` permet de router vers la route publique (/api/public/<id>/media) en
* vue publique au lieu de la route authentifiée par défaut.
*/
export function mediaUrl(projectId: string, filename: string, width?: number, base = '/api/media'): string {
const url = `${base}/${projectId.split('/').map(encodeURIComponent).join('/')}/${encodeURIComponent(filename)}`
return width ? `${url}?w=${width}` : url
}
/** Vignette de couverture d'un projet (aura.image ou 1re image). */
export function projectThumb(project: Project, width = 480): string | null {
const cover = project.coverImage ?? project.media.find(m => m.type === 'image')?.filename
return cover ? mediaUrl(project.id, cover, width) : null
}

219
composables/useGrids.ts Normal file
View File

@@ -0,0 +1,219 @@
// Grilles ADAPTATIVES des pages d'images : chaque « famille » de grille se
// construit selon le nombre d'images posées (2 images = 2 cases, jamais de
// case vide) et l'orientation du lot. Le damier vise le format écran 16:9
// (majorité des paysages) ou affiche (~1/√2) pour les lots portrait.
// Les compositions du gabarit (p.2/4/6/8) restent les familles « grande à
// gauche + bandeaux » et « file + grande à droite ». La même définition sert
// au rendu (CSS grid) et aux icônes SVG du composeur.
export interface GridCell {
c: number // colonne de départ (0-based)
r: number // ligne de départ
cs?: number // colspan
rs?: number // rowspan
}
export interface GridDef {
id: string
label: string
cols: number[] // proportions (fr)
rows: number[]
cells: GridCell[]
}
export interface GridFamily {
id: string
label: string
max: number // nombre maximal d'images
}
export const GRID_FAMILIES: GridFamily[] = [
{ id: 'mosaic', label: 'Damier (s\'adapte au format)', max: 8 },
{ id: 'rows', label: 'Pleine largeur, empilées', max: 4 },
{ id: 'cols', label: 'Pleine hauteur, côte à côte', max: 4 },
{ id: 'hero-left', label: 'Grande à gauche + bandeaux', max: 6 },
{ id: 'hero-right', label: 'File + grande à droite', max: 5 },
]
// Anciens ids de grilles fixes → famille équivalente (portfolios existants)
const LEGACY_IDS: Record<string, string> = {
'full': 'mosaic',
'duo': 'cols',
'cols-3': 'cols',
'rows-2': 'rows',
'rows-3': 'rows',
'large-left-2': 'hero-left',
'large-left-3': 'hero-left',
'large-left-4': 'hero-left',
'stack3-large': 'hero-right',
'quad': 'mosaic',
'six': 'mosaic',
'land-2x3': 'mosaic',
'port-4x2': 'mosaic',
'tall-5': 'hero-left',
'tall-banners': 'hero-left',
}
export function gridFamily(id: string): GridFamily {
const fid = LEGACY_IDS[id] ?? id
return GRID_FAMILIES.find(f => f.id === fid) ?? GRID_FAMILIES[0]
}
export type ImageOrientation = 'landscape' | 'portrait' | 'mixed'
/**
* Orientation dominante d'un lot d'images (≥ 70 % dans le même sens),
* d'après leurs ratios largeur/hauteur.
*/
export function dominantOrientation(ratios: number[]): ImageOrientation {
const known = ratios.filter(r => r > 0)
if (!known.length) return 'mixed'
const landscape = known.filter(r => r >= 1.15).length
const portrait = known.filter(r => r <= 0.87).length
if (landscape / known.length >= 0.7) return 'landscape'
if (portrait / known.length >= 0.7) return 'portrait'
return 'mixed'
}
// Zone utile A3 paysage hors marges, en mm (sert aux calculs de ratios)
const INNER_W_MM = 404
const INNER_H_MM = 281
function pgcd(a: number, b: number): number {
return b ? pgcd(b, a % b) : a
}
/**
* Damier : choisit le nombre de colonnes qui maximise la surface d'une image
* au ratio cible (16:9 en paysage/mixte — la majorité des paysages sont des
* écrans —, ~1/√2 en portrait). Les images de la dernière rangée incomplète
* se partagent toute la largeur (pas de case vide).
*/
function mosaicDef(n: number, orientation: ImageOrientation): GridDef {
const target = orientation === 'portrait' ? 0.707 : 16 / 9
let bestC = 1
let bestArea = 0
for (let c = 1; c <= Math.min(n, 4); c++) {
const r = Math.ceil(n / c)
const w = INNER_W_MM / c
const h = INNER_H_MM / r
const iw = Math.min(w, h * target)
const area = iw * (iw / target)
if (area > bestArea) {
bestArea = area
bestC = c
}
}
const C = bestC
const R = Math.ceil(n / C)
const rem = n % C
const cells: GridCell[] = []
if (!rem || R === 1) {
for (let i = 0; i < n; i++) cells.push({ c: i % C, r: Math.floor(i / C) })
return { id: 'mosaic', label: 'Damier', cols: Array(C).fill(1), rows: Array(R).fill(1), cells }
}
// Dernière rangée incomplète : rem images se partagent la largeur.
// Grille exprimée en L = ppcm(C, rem) colonnes pour des spans entiers.
const L = (C * rem) / pgcd(C, rem)
const fullSpan = L / C
const remSpan = L / rem
const full = n - rem
for (let i = 0; i < full; i++) {
cells.push({ c: (i % C) * fullSpan, r: Math.floor(i / C), cs: fullSpan })
}
for (let i = 0; i < rem; i++) {
cells.push({ c: i * remSpan, r: R - 1, cs: remSpan })
}
return { id: 'mosaic', label: 'Damier', cols: Array(L).fill(1), rows: Array(R).fill(1), cells }
}
/**
* Définition effective d'une grille : famille × nombre d'images × orientation.
*/
export function gridDef(id: string, count = 1, orientation: ImageOrientation = 'mixed'): GridDef {
const fam = gridFamily(id)
const n = Math.max(1, Math.min(count, fam.max))
if (n === 1) {
return { id: fam.id, label: fam.label, cols: [1], rows: [1], cells: [{ c: 0, r: 0 }] }
}
switch (fam.id) {
case 'rows':
return {
id: fam.id,
label: fam.label,
cols: [1],
rows: Array(n).fill(1),
cells: Array.from({ length: n }, (_, i) => ({ c: 0, r: i })),
}
case 'cols':
return {
id: fam.id,
label: fam.label,
cols: Array(n).fill(1),
rows: [1],
cells: Array.from({ length: n }, (_, i) => ({ c: i, r: 0 })),
}
case 'hero-left': {
// Grande image pleine hauteur à gauche + bandeaux empilés à droite.
// Image de gauche à largeur maximale (gabarit p.2/p.8 : 60/40 puis 70/30)
// et marges toujours égales entre les bandeaux (rangées 1fr).
const cols = n <= 3 ? [60, 40] : [70, 30]
return {
id: fam.id,
label: fam.label,
cols,
rows: Array(n - 1).fill(1),
cells: [
{ c: 0, r: 0, rs: n - 1 },
...Array.from({ length: n - 1 }, (_, i) => ({ c: 1, r: i })),
],
}
}
case 'hero-right':
// Gabarit p.4 : file à gauche (30 %), grande à droite (70 %)
return {
id: fam.id,
label: fam.label,
cols: [30, 70],
rows: Array(n - 1).fill(1),
cells: [
...Array.from({ length: n - 1 }, (_, i) => ({ c: 0, r: i })),
{ c: 1, r: 0, rs: n - 1 },
],
}
default:
return mosaicDef(n, orientation)
}
}
/**
* Famille par défaut : compositions du gabarit en lot mixte, damier
* auto-optimisé (16:9 ou affiche) sinon.
*/
export function defaultGridFor(count: number, orientation: ImageOrientation = 'mixed'): string {
if (count <= 1) return 'mosaic'
if (orientation === 'mixed') return count <= 6 ? 'hero-left' : 'mosaic'
return 'mosaic'
}
/**
* Rectangles des cellules en fractions [0..1] de la zone utile (pour les
* icônes SVG et le calcul des largeurs d'affichage). Les gouttières sont
* exprimées en fraction de la largeur (gapX) et de la hauteur (gapY) utiles.
*/
export function gridCellRects(def: GridDef, gapX = 0.0095, gapY = gapX): { x: number; y: number; w: number; h: number }[] {
const colTotal = def.cols.reduce((a, b) => a + b, 0)
const rowTotal = def.rows.reduce((a, b) => a + b, 0)
const availW = 1 - gapX * (def.cols.length - 1)
const availH = 1 - gapY * (def.rows.length - 1)
const colW = def.cols.map(c => (c / colTotal) * availW)
const rowH = def.rows.map(r => (r / rowTotal) * availH)
const colX = colW.map((_, i) => colW.slice(0, i).reduce((a, b) => a + b, 0) + gapX * i)
const rowY = rowH.map((_, i) => rowH.slice(0, i).reduce((a, b) => a + b, 0) + gapY * i)
return def.cells.map(cell => ({
x: colX[cell.c],
y: rowY[cell.r],
w: colW.slice(cell.c, cell.c + (cell.cs ?? 1)).reduce((a, b) => a + b, 0) + gapX * ((cell.cs ?? 1) - 1),
h: rowH.slice(cell.r, cell.r + (cell.rs ?? 1)).reduce((a, b) => a + b, 0) + gapY * ((cell.rs ?? 1) - 1),
}))
}

View File

@@ -0,0 +1,69 @@
import type { Ref } from 'vue'
/**
* Scroll-spy des pages empilées : la page active suit en temps réel la page au
* centre du conteneur défilable, et permet de défiler vers une page donnée.
* Partagé par le composeur (pages/portfolios/[id].vue) et la vue publique
* (pages/view/[id].vue) — chacun câble sa propre ref `active` et son `idPrefix`.
*
* - `container` : élément défilable (l'aperçu).
* - `pages` : liste courante des pages (on lit `.id` et l'ordre).
* - `active` : ref d'id écrite à chaque défilement (jamais de re-scroll).
* - `idPrefix` : préfixe des id DOM des pages (`preview-page` / `view-page`),
* suffixés par la position 1-based.
*/
export function usePageScrollSpy(options: {
container: Ref<HTMLElement | undefined>
pages: Ref<{ id: string }[]>
active: Ref<string | undefined>
idPrefix: string
}) {
const { container, pages, active, idPrefix } = options
function elementFor(index: number): HTMLElement | null {
return document.getElementById(`${idPrefix}-${index + 1}`)
}
function scrollToIndex(i: number) {
nextTick(() => {
elementFor(i)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
})
}
function scrollToId(id: string) {
const i = pages.value.findIndex(p => p.id === id)
if (i !== -1) scrollToIndex(i)
}
// Une mise à jour par frame (rAF) pour rester fluide sans recalcul redondant.
let rafPending = false
function onScroll() {
if (rafPending) return
rafPending = true
requestAnimationFrame(() => {
rafPending = false
updateActiveFromScroll()
})
}
function updateActiveFromScroll() {
const cont = container.value
if (!cont) return
const mid = cont.getBoundingClientRect().top + cont.clientHeight / 2
let bestId: string | undefined
let bestDist = Infinity
pages.value.forEach((page, i) => {
const el = elementFor(i)
if (!el) return
const r = el.getBoundingClientRect()
const dist = Math.abs(r.top + r.height / 2 - mid)
if (dist < bestDist) {
bestDist = dist
bestId = page.id
}
})
if (bestId && bestId !== active.value) active.value = bestId
}
return { onScroll, scrollToIndex, scrollToId }
}

97
composables/useTheme.ts Normal file
View File

@@ -0,0 +1,97 @@
import type { Background, InkMode } from '~~/types'
// Charte Figures Libres — valeurs mesurées dans gabarit_book_v3.pdf et
// reprises du thème Grav figureslibres-v2 (scss/configuration/_variable.scss).
export const FL_INK = '#0e1229'
export const FL_BASE = '#f5f5f5'
/** Couleurs de bandeau du gabarit PDF (aplats CMJN convertis). */
export const PDF_COLORS = [
'#ec008c', // magenta 100% — utilité publique
'#8bd261', // vert C45 J71 — utilité sociale
'#fff766', // jaune 60% — utilité culturelle (bandeau)
'#fff200', // jaune 100% — utilité culturelle (filet)
]
/** Couleurs du site Grav (hover des keywords de l'index). */
export const SITE_COLORS = [
'#ffaeab', // publique
'#71ff94', // sociale
'#feff74', // culturelle
'#fabbde', // commanditaires
'#82f8ee', // figures libres
'#4bffc9', // projets
]
/** Palette proposée pour les bandeaux de pages projet. */
export const BAND_COLORS = [...PDF_COLORS, ...SITE_COLORS]
/** Couleur de bandeau par catégorie (page couverture de projet, gabarit). */
export const CATEGORY_BAND: Record<string, string> = {
publique: '#ec008c',
sociale: '#8bd261',
culturelle: '#fff766',
}
/** Couleur de filet par catégorie (pages grille — jaune à 100 %). */
export const CATEGORY_STRIP: Record<string, string> = {
publique: '#ec008c',
sociale: '#8bd261',
culturelle: '#fff200',
}
/** Classe CSS de la fonte de titre par catégorie (mixins du thème Grav). */
export const CATEGORY_TITLE_CLASS: Record<string, string> = {
publique: 'fl-font-publique',
sociale: 'fl-font-sociale',
culturelle: 'fl-font-culturelle',
}
/** Aplats clairs proposés pour le mode « teinté ». */
export const TINT_PRESETS = [
'#f5f5f5', // gris clair (base)
'#efece3', // beige
'#ffeceb', // rose pâle
'#fbfbdc', // jaune pâle
'#e9fbee', // vert pâle
'#e2fff6', // menthe pâle
]
/** Pleines couleurs de la charte pour le mode « couleur ». */
export const COLOR_PRESETS = [FL_INK, ...PDF_COLORS, ...SITE_COLORS]
function luminance(hex: string): number {
const m = hex.replace('#', '')
if (m.length < 6) return 1
const [r, g, b] = [0, 2, 4].map(i => parseInt(m.slice(i, i + 2), 16) / 255)
return 0.2126 * r + 0.7152 * g + 0.0722 * b
}
/** Encre lisible sur un fond donné — le gabarit imprime en noir/blanc purs. */
export function inkOn(color: string, ink: InkMode = 'auto'): string {
if (ink === 'black') return '#000000'
if (ink === 'white') return '#ffffff'
return luminance(color) < 0.45 ? '#ffffff' : '#000000'
}
export function bandColorOf(category: string | undefined, override?: string): string {
return override || CATEGORY_BAND[category ?? ''] || FL_INK
}
export function stripColorOf(category: string | undefined, override?: string): string {
return override || CATEGORY_STRIP[category ?? ''] || FL_INK
}
export function backgroundColorOf(bg: Background): string {
if (bg.mode === 'white') return '#ffffff'
return bg.color || (bg.mode === 'tint' ? FL_BASE : FL_INK)
}
/** Style inline d'une page : fond + couleur d'encre lisible. */
export function backgroundStyle(bg: Background): Record<string, string> {
const color = backgroundColorOf(bg)
return {
backgroundColor: color,
color: luminance(color) < 0.45 ? '#ffffff' : FL_INK,
}
}