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

View File

@@ -0,0 +1,51 @@
<script setup lang="ts">
// Fond de page (spec §7.2) : blanc / teinté (aplat clair) / pleine couleur.
import type { Background } from '~~/types'
const model = defineModel<Background>({ required: true })
const modes = [
{ value: 'white', label: 'Blanc' },
{ value: 'tint', label: 'Teinté' },
{ value: 'color', label: 'Couleur' },
] as const
function setMode(mode: Background['mode']) {
if (mode === 'white') model.value = { mode: 'white' }
else if (mode === 'tint') model.value = { mode: 'tint', color: model.value.mode === 'tint' ? model.value.color : TINT_PRESETS[0] }
else model.value = { mode: 'color', color: model.value.mode === 'color' ? model.value.color : COLOR_PRESETS[1] }
}
const swatches = computed(() =>
model.value.mode === 'tint' ? TINT_PRESETS : model.value.mode === 'color' ? COLOR_PRESETS : [],
)
</script>
<template>
<div class="space-y-2">
<p class="text-xs font-medium uppercase tracking-wide text-neutral-500">Fond de page</p>
<div class="flex gap-1">
<UButton
v-for="m in modes"
:key="m.value"
:label="m.label"
size="xs"
:variant="model.mode === m.value ? 'solid' : 'outline'"
color="neutral"
@click="setMode(m.value)"
/>
</div>
<div v-if="swatches.length" class="flex flex-wrap gap-1.5 pt-1">
<button
v-for="c in swatches"
:key="c"
type="button"
class="size-7 rounded-full border transition-transform hover:scale-110"
:class="(model as any).color === c ? 'ring-2 ring-[var(--fl-ink)] ring-offset-1 dark:ring-white' : 'border-neutral-300 dark:border-neutral-600'"
:style="{ backgroundColor: c }"
:aria-label="c"
@click="model = { mode: model.mode as 'tint' | 'color', color: c }"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,44 @@
<script setup lang="ts">
// Couleur de bandeau/filet : palette du gabarit PDF + couleurs du site Grav
// (hover des keywords de l'index). undefined = couleur par défaut (catégorie).
const props = withDefaults(
defineProps<{
label?: string
/** Couleur affichée quand aucune n'est choisie (défaut catégorie). */
defaultColor?: string
}>(),
{ label: 'Couleur du bandeau' },
)
const model = defineModel<string | undefined>()
</script>
<template>
<div class="space-y-2">
<p class="text-xs font-medium uppercase tracking-wide text-neutral-500">{{ label }}</p>
<div class="flex flex-wrap gap-1.5">
<!-- Défaut (catégorie) -->
<button
v-if="props.defaultColor"
type="button"
class="relative size-7 rounded-full border transition-transform hover:scale-110"
:class="!model ? 'ring-2 ring-[var(--fl-ink)] ring-offset-1 dark:ring-white' : 'border-neutral-300 dark:border-neutral-600'"
:style="{ backgroundColor: props.defaultColor }"
title="Couleur de la catégorie (défaut)"
@click="model = undefined"
>
<UIcon name="i-lucide-rotate-ccw" class="absolute inset-0 m-auto size-3.5 text-black/40" />
</button>
<button
v-for="c in BAND_COLORS"
:key="c"
type="button"
class="size-7 rounded-full border transition-transform hover:scale-110"
:class="model === c ? 'ring-2 ring-[var(--fl-ink)] ring-offset-1 dark:ring-white' : 'border-neutral-300 dark:border-neutral-600'"
:style="{ backgroundColor: c }"
:aria-label="c"
@click="model = c"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,44 @@
<script setup lang="ts">
// Icône d'une grille : les blocs dessinés dans le format de page A3 paysage,
// générés depuis la même définition adaptative que le rendu (useGrids) —
// l'icône reflète le nombre d'images réellement posées et leur orientation.
import type { ImageOrientation } from '~~/composables/useGrids'
const props = withDefaults(
defineProps<{ grid: string; count?: number; orientation?: ImageOrientation; size?: number }>(),
{ count: 3, orientation: 'mixed', size: 64 },
)
// Page 42 × 29.7 (A3/10), marges/gouttières proportionnelles au gabarit
const W = 42
const H = 29.7
const M = 0.8 // 8 mm
const rects = computed(() => {
const def = gridDef(props.grid, props.count, props.orientation)
const iw = W - 2 * M
const ih = H - 2 * M
// gouttière 3,8 mm → fractions de la largeur et de la hauteur utiles
return gridCellRects(def, 0.38 / iw, 0.38 / ih).map(r => ({
x: M + r.x * iw,
y: M + r.y * ih,
w: r.w * iw,
h: r.h * ih,
}))
})
</script>
<template>
<svg :width="size" :height="(size * H) / W" :viewBox="`0 0 ${W} ${H}`" aria-hidden="true">
<rect :width="W" :height="H" fill="none" stroke="currentColor" stroke-width="0.6" opacity="0.35" />
<rect
v-for="(r, i) in rects"
:key="i"
:x="r.x"
:y="r.y"
:width="r.w"
:height="r.h"
fill="currentColor"
opacity="0.75"
/>
</svg>
</template>

View File

@@ -0,0 +1,69 @@
<script setup lang="ts">
// Curation des médias d'une page grille (spec §8.2) : cocher/décocher +
// réordonner, sans recadrage. Images uniquement — les vidéos ne sont pas
// sélectionnables (un PDF est statique).
import draggable from 'vuedraggable'
import type { Project } from '~~/types'
const props = defineProps<{
project: Project
/** Nombre de cases de la grille choisie. */
max?: number
}>()
const selection = defineModel<string[]>({ required: true })
const images = computed(() => props.project.media.filter(m => m.type === 'image'))
function toggle(filename: string) {
if (selection.value.includes(filename)) {
selection.value = selection.value.filter(f => f !== filename)
} else if (!props.max || selection.value.length < props.max) {
selection.value = [...selection.value, filename]
}
}
</script>
<template>
<div class="space-y-3">
<p class="text-xs font-medium uppercase tracking-wide text-neutral-500">
Images de la grille ({{ selection.length }}{{ max ? ` / ${max} cases` : '' }})
</p>
<!-- Ordre des images sélectionnées (glisser-déposer) -->
<draggable
v-if="selection.length"
v-model="selection"
item-key="."
class="flex flex-wrap gap-2"
:animation="150"
>
<template #item="{ element, index }">
<div class="relative size-16 cursor-grab overflow-hidden rounded border border-neutral-300 active:cursor-grabbing dark:border-neutral-600">
<img :src="mediaUrl(project.id, element, 160)" class="size-full object-cover" :alt="element">
<span class="absolute left-0.5 top-0.5 flex size-4 items-center justify-center rounded-full bg-[var(--fl-ink)] text-[10px] text-white">{{ index + 1 }}</span>
</div>
</template>
</draggable>
<!-- Toutes les images du projet : clic = sélection/désélection -->
<div class="grid grid-cols-4 gap-1.5">
<button
v-for="m in images"
:key="m.filename"
type="button"
class="relative aspect-square overflow-hidden rounded border transition-opacity"
:class="selection.includes(m.filename)
? 'border-[var(--fl-ink)] dark:border-white'
: 'border-transparent opacity-45 hover:opacity-80'"
@click="toggle(m.filename)"
>
<img :src="mediaUrl(project.id, m.filename, 160)" class="size-full object-cover" :alt="m.filename">
<UIcon
v-if="selection.includes(m.filename)"
name="i-lucide-check-circle-2"
class="absolute right-1 top-1 size-4 rounded-full bg-white text-[var(--fl-ink)]"
/>
</button>
</div>
</div>
</template>

View File

@@ -0,0 +1,70 @@
<script setup lang="ts">
// Sélecteur d'un média (image) parmi tous les projets — utilisé pour l'image
// de couverture et les médias des pages libres. Renvoie "projectId/filename".
import type { Project } from '~~/types'
const open = defineModel<boolean>('open', { required: true })
const emit = defineEmits<{ pick: [ref: string] }>()
const { data } = await useFetch<{ projects: Project[] }>('/api/projects')
const search = ref('')
const selectedProject = ref<Project | null>(null)
const filtered = computed(() =>
(data.value?.projects ?? []).filter(
p => p.published && (!search.value || p.title.toLowerCase().includes(search.value.toLowerCase())),
),
)
function pick(project: Project, filename: string) {
emit('pick', `${project.id}/${filename}`)
open.value = false
selectedProject.value = null
}
</script>
<template>
<UModal v-model:open="open" :title="selectedProject ? selectedProject.title : 'Choisir une image'" :ui="{ content: 'max-w-3xl' }">
<template #body>
<template v-if="!selectedProject">
<UInput v-model="search" icon="i-lucide-search" placeholder="Rechercher un projet…" class="mb-4 w-full" autofocus />
<div class="grid max-h-[55vh] grid-cols-4 gap-3 overflow-y-auto pr-1">
<button
v-for="p in filtered"
:key="p.id"
type="button"
class="overflow-hidden rounded-lg border border-neutral-200 text-left hover:shadow-md dark:border-neutral-700"
@click="selectedProject = p"
>
<div class="aspect-video bg-neutral-100 dark:bg-neutral-800">
<img v-if="projectThumb(p, 320)" :src="projectThumb(p, 320)!" class="size-full object-cover" :alt="p.title" loading="lazy">
</div>
<p class="truncate p-2 text-xs font-medium">{{ p.title }}</p>
</button>
</div>
</template>
<template v-else>
<UButton
icon="i-lucide-arrow-left"
label="Retour aux projets"
variant="ghost"
color="neutral"
size="sm"
class="mb-3"
@click="selectedProject = null"
/>
<div class="grid max-h-[55vh] grid-cols-4 gap-3 overflow-y-auto pr-1">
<button
v-for="m in selectedProject.media.filter(m => m.type === 'image')"
:key="m.filename"
type="button"
class="aspect-video overflow-hidden rounded-lg border border-neutral-200 hover:shadow-md dark:border-neutral-700"
@click="pick(selectedProject, m.filename)"
>
<img :src="mediaUrl(selectedProject.id, m.filename, 320)" class="size-full object-cover" :alt="m.filename" loading="lazy">
</button>
</div>
</template>
</template>
</UModal>
</template>

View File

@@ -0,0 +1,130 @@
<script setup lang="ts">
// Sélecteur de projet pour ajouter une page-projet au portfolio.
// Reprend toutes les fonctionnalités de la bibliothèque : recherche plein
// texte, filtres catégorie et tag. Seuls les projets publiés sont proposés.
import type { Project } from '~~/types'
const open = defineModel<boolean>('open', { required: true })
const emit = defineEmits<{ pick: [project: Project] }>()
const { data } = await useFetch<{ projects: Project[] }>('/api/projects')
const search = ref('')
const category = ref<string | undefined>(undefined)
const tag = ref<string | undefined>(undefined)
const workTypes = ref<string[]>([]) // « Site web », « Identité visuelle »…
const published = computed(() => (data.value?.projects ?? []).filter(p => p.published))
const categoryItems = computed(() => [
{ label: 'Toutes les catégories', value: undefined },
...[...new Set(published.value.map(p => p.category))].map(c => ({
label: CATEGORY_LABELS[c] ?? c,
value: c,
})),
])
/** Types de travail = taxonomy.category des projets (Site web, Éditions…). */
const workTypeItems = computed(() => {
const types = new Set<string>()
for (const p of published.value) {
for (const t of p.categories) types.add(t)
}
return [...types].sort((a, b) => a.localeCompare(b, 'fr'))
})
function toggleWorkType(t: string) {
workTypes.value = workTypes.value.includes(t)
? workTypes.value.filter(x => x !== t)
: [...workTypes.value, t]
}
const tagItems = computed(() => {
const tags = new Set<string>()
for (const p of published.value) {
for (const t of p.tags) tags.add(t)
}
return [
{ label: 'Tous les tags', value: undefined },
...[...tags].sort((a, b) => a.localeCompare(b, 'fr')).map(t => ({ label: t, value: t })),
]
})
const filtered = computed(() =>
published.value.filter((p) => {
if (category.value && p.category !== category.value) return false
if (workTypes.value.length && !workTypes.value.every(t => p.categories.includes(t))) return false
if (tag.value && ![...p.categories, ...p.tags].includes(tag.value)) return false
if (search.value) {
const q = search.value.toLowerCase()
const haystack = [p.title, p.bodyMarkdown, ...p.categories, ...p.tags].join(' ').toLowerCase()
if (!haystack.includes(q)) return false
}
return true
}),
)
function pick(project: Project) {
emit('pick', project)
open.value = false
}
</script>
<template>
<UModal v-model:open="open" title="Ajouter un projet" :ui="{ content: 'max-w-4xl' }">
<template #body>
<div class="mb-3 flex flex-wrap items-center gap-3">
<UInput v-model="search" icon="i-lucide-search" placeholder="Rechercher…" class="min-w-48 flex-1" autofocus />
<USelectMenu v-model="category" :items="categoryItems" value-key="value" class="w-48" placeholder="Catégorie" />
<USelectMenu v-model="tag" :items="tagItems" value-key="value" class="w-48" placeholder="Tag" />
</div>
<div class="mb-3 flex flex-wrap gap-1.5">
<UButton
v-for="t in workTypeItems"
:key="t"
:label="t"
size="xs"
:variant="workTypes.includes(t) ? 'solid' : 'outline'"
color="neutral"
class="rounded-full"
@click="toggleWorkType(t)"
/>
</div>
<p class="mb-3 text-xs text-neutral-500">
{{ filtered.length }} projet{{ filtered.length > 1 ? 's' : '' }}
</p>
<div class="grid max-h-[55vh] grid-cols-3 gap-3 overflow-y-auto pr-1">
<button
v-for="p in filtered"
:key="p.id"
type="button"
class="overflow-hidden rounded-lg border border-neutral-200 text-left transition-shadow hover:shadow-md dark:border-neutral-700"
@click="pick(p)"
>
<div class="aspect-video bg-neutral-100 dark:bg-neutral-800">
<img
v-if="projectThumb(p, 320)"
:src="projectThumb(p, 320)!"
class="size-full object-cover"
:alt="p.title"
loading="lazy"
>
<div v-else class="flex size-full items-center justify-center text-neutral-400">
<UIcon name="i-lucide-image-off" class="size-6" />
</div>
</div>
<div class="space-y-1.5 p-2.5">
<p class="truncate text-sm font-medium">{{ p.title }}</p>
<div class="flex flex-wrap gap-1">
<UBadge color="neutral" variant="outline" size="sm">{{ CATEGORY_LABELS[p.category] ?? p.category }}</UBadge>
<UBadge v-for="c in p.categories.slice(0, 2)" :key="c" color="neutral" variant="soft" size="sm">{{ c }}</UBadge>
</div>
<div class="flex items-center justify-between text-xs text-neutral-500">
<span>{{ formatProjectDate(p.date) }}</span>
<span>{{ p.media.length }} média{{ p.media.length > 1 ? 's' : '' }}</span>
</div>
</div>
</button>
</div>
</template>
</UModal>
</template>

View File

@@ -0,0 +1,214 @@
<script setup lang="ts">
// Dispatcher central : transforme une Page du portfolio en props de template
// (§9.2) — médias résolus à la bonne résolution, texte effectif, libellés.
// Mêmes composants en aperçu (basse rés.) et en print (haute rés.).
import { marked } from 'marked'
import type { Page, Portfolio, Project, ResolvedMedia } from '~~/types'
const props = withDefaults(
defineProps<{
portfolio: Portfolio
page: Page
index: number // position 0-based dans portfolio.pages
projects: Record<string, Project>
renderMode?: 'preview' | 'print'
/** id du portfolio si le rendu doit lire le snapshot figé (§7.3) */
frozen?: string
/** base des URLs médias (route publique en vue publique) */
mediaBase?: string
}>(),
{ renderMode: 'preview', mediaBase: '/api/media' },
)
// Clic sur une entrée du sommaire (aperçu) → défilement vers la page cible
defineEmits<{ navigate: [pageNumber: number] }>()
const pageInfo = computed(() => ({ index: props.index + 1, total: props.portfolio.pages.length }))
// En print, chaque image est demandée à ~2× sa taille d'affichage (spec §10.1)
const scale = computed(() => (props.renderMode === 'print' ? 2 : 1))
// Largeur utile de la page A3 (1587,4px 2 × 30,2px de marge)
const INNER_W = 1527
function resolve(projectId: string, filename: string, width: number, type: 'image' | 'video' = 'image'): ResolvedMedia {
const w = Math.min(Math.round(width * scale.value), 3200)
const base = mediaUrl(projectId, filename, undefined, props.mediaBase)
const frozenParam = props.frozen ? `&frozen=${props.frozen}` : ''
return {
filename,
type,
url: type === 'image' ? `${base}?w=${w}${frozenParam}` : `${base}${frozenParam ? `?${frozenParam.slice(1)}` : ''}`,
posterUrl: type === 'video' ? `${base}?poster=1&w=${w}${frozenParam}` : undefined,
}
}
const project = computed(() => {
const p = props.page
return p.kind === 'project' || p.kind === 'project-grid' ? props.projects[p.projectId] : undefined
})
// ---- Page couverture de projet ----
const projectCoverImage = computed<ResolvedMedia | undefined>(() => {
if (props.page.kind !== 'project' || !project.value) return undefined
const filename = props.page.coverImage || project.value.coverImage
if (!filename) return undefined
return resolve(project.value.id, filename, 1588)
})
const projectText = computed(() => {
if (props.page.kind !== 'project' || !project.value) return ''
if (props.page.textOverride?.trim()) return marked.parse(props.page.textOverride) as string
return project.value.bodyHtml
})
const projectIntro = computed(() => {
if (props.page.kind !== 'project' || !project.value) return ''
return props.page.subtitleOverride?.trim() || project.value.categories.join(', ')
})
const projectBadge = computed(() => {
const c = project.value?.category
return c ? `Utilité ${CATEGORY_LABELS[c]?.toLowerCase() ?? c}` : undefined
})
// ---- Page grille de projet ----
// Le filet hérite de la couleur du bandeau de la page couverture du projet.
const gridStripOverride = computed(() => {
if (props.page.kind !== 'project-grid') return undefined
const projectId = props.page.projectId
const cover = props.portfolio.pages.find(
(p): p is Extract<Page, { kind: 'project' }> => p.kind === 'project' && p.projectId === projectId,
)
return cover?.bandColor
})
// Orientation dominante des images posées : la grille adaptative en dépend
const gridOrientation = computed(() => {
if (props.page.kind !== 'project-grid' || !project.value) return 'mixed' as const
const byName = new Map(project.value.media.map(m => [m.filename, m]))
return dominantOrientation(
props.page.media.map((f) => {
const a = byName.get(f)
return a?.width && a?.height ? a.width / a.height : 0
}),
)
})
const gridMedia = computed<ResolvedMedia[]>(() => {
if (props.page.kind !== 'project-grid' || !project.value) return []
const byName = new Map(project.value.media.map(m => [m.filename, m]))
const rects = gridCellRects(gridDef(props.page.grid, props.page.media.length, gridOrientation.value))
return props.page.media
.map((filename, i) => {
const asset = byName.get(filename)
if (!asset || asset.type !== 'image') return null // vidéos exclues des grilles
const cellW = Math.round((rects[i]?.w ?? 1) * INNER_W)
const media = resolve(project.value!.id, filename, cellW, 'image')
if (asset.width && asset.height) media.ratio = asset.width / asset.height
return media
})
.filter((m): m is ResolvedMedia => m !== null)
})
// Filet de la couverture du dossier — définit aussi celui du sommaire.
const coverBandColor = computed(() => {
const cover = props.portfolio.pages.find(
(p): p is Extract<Page, { kind: 'cover' }> => p.kind === 'cover',
)
return cover?.bandColor
})
// Sommaire : entrées générées depuis les pages couverture de projet
const tocEntries = computed(() =>
props.portfolio.pages
.map((p, i) => ({ p, pageNumber: i + 1 }))
.filter(({ p }) => p.kind === 'project')
.map(({ p, pageNumber }) => {
const proj = props.projects[(p as Extract<Page, { kind: 'project' }>).projectId]
return { title: proj?.title ?? '—', subtitle: proj?.categories.join(' · '), category: proj?.category, pageNumber }
}),
)
// Page libre : texte markdown + médias "projectId/filename"
const freeText = computed(() => {
if (props.page.kind !== 'free' || !props.page.body?.trim()) return ''
return marked.parse(props.page.body) as string
})
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)
})
})
</script>
<template>
<CoverTemplate
v-if="page.kind === 'cover'"
:title="portfolio.cover.title"
:background="page.background"
:band-color="page.bandColor"
:render-mode="renderMode"
/>
<TocTemplate
v-else-if="page.kind === 'toc'"
:title="page.title"
:entries="tocEntries"
:background="page.background"
:band-color="coverBandColor"
:page="pageInfo"
:render-mode="renderMode"
@navigate="$emit('navigate', $event)"
/>
<FreeTextPage
v-else-if="page.kind === 'free'"
:title="page.title"
:text="freeText"
:media="freeMedia"
:background="page.background"
:band-color="page.bandColor"
:page="pageInfo"
:render-mode="renderMode"
/>
<template v-else-if="page.kind === 'project'">
<div v-if="!project" class="fl-page flex items-center justify-center bg-white text-neutral-400">
Projet introuvable : {{ page.projectId }}
</div>
<ProjectCoverPage
v-else
:title="project.title"
:category="project.category"
:badge="projectBadge"
:intro="projectIntro"
:text="projectText"
:image="projectCoverImage"
:image-fit="page.coverFit"
:image-align="page.coverAlign"
:title-scale="page.titleScale"
:band-height="page.bandHeight"
:band-color="page.bandColor"
:ink="page.ink"
/>
</template>
<template v-else-if="page.kind === 'project-grid'">
<div v-if="!project" class="fl-page flex items-center justify-center bg-white text-neutral-400">
Projet introuvable : {{ page.projectId }}
</div>
<ProjectGridPage
v-else
:media="gridMedia"
:grid="page.grid"
:orientation="gridOrientation"
:category="project.category"
:band-color="gridStripOverride"
:grey-bg="page.greyBg"
/>
</template>
</template>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
// Met à l'échelle le canvas A3 paysage (1587,4×1122,5) pour l'aperçu (§8.4).
const PAGE_W = 1587.4
const wrapper = ref<HTMLElement>()
const scale = ref(0.5)
let observer: ResizeObserver | undefined
onMounted(() => {
observer = new ResizeObserver(([entry]) => {
scale.value = entry.contentRect.width / PAGE_W
})
if (wrapper.value) observer.observe(wrapper.value)
})
onBeforeUnmount(() => observer?.disconnect())
</script>
<template>
<div ref="wrapper" class="relative w-full overflow-hidden" style="aspect-ratio: 1190.55 / 841.89">
<div class="absolute left-0 top-0 origin-top-left" :style="{ transform: `scale(${scale})` }">
<slot />
</div>
</div>
</template>

View File

@@ -0,0 +1,59 @@
<script setup lang="ts">
// Couverture du portfolio — reprend l'accueil du site figureslibres.cc
// (docs/portfolios-examples/screenshot-site-figures-libres.png) : la phrase
// d'intro seule, bloc centré à 65 % de large, chaque keyword dans sa fonte
// (sans soulignement, contrairement au site), flèche vers le bas. Rien d'autre.
import type { Background } from '~~/types'
const props = withDefaults(
defineProps<{
title: string // utilisé pour les métadonnées PDF, pas affiché
subtitle?: string
recipient?: string
background: Background
bandColor?: string // filet couleur en bas (définit aussi celui du sommaire)
renderMode?: 'preview' | 'print'
}>(),
{ renderMode: 'preview' },
)
// Le site affiche l'intro sur fond gris clair : « Blanc » rend le gris charte.
const pageStyle = computed(() => {
const s = backgroundStyle(props.background)
if (props.background.mode === 'white') s.backgroundColor = FL_BASE
return s
})
const strip = computed(() => props.bandColor || FL_INK)
</script>
<template>
<div class="fl-page" :style="pageStyle">
<div class="absolute inset-0 flex flex-col items-center justify-center">
<!-- Phrase d'intro du site (pages/01.home) — Lato, keywords dans les
fontes d'« utilité » du thème Grav, non soulignés -->
<p class="w-[65%]" :style="{ fontFamily: 'Lato, sans-serif', fontSize: '54px', lineHeight: '1.35' }">
<span class="font-bold" style="font-family: 'Syne', sans-serif">Figures&nbsp;Libres</span>
est un&nbsp;collectif de&nbsp;design graphique et interactif porteur de messages d'utilité
<span class="fl-font-publique">publique</span>,
<span class="fl-font-sociale">sociale</span> et
<span class="fl-font-culturelle">culturelle</span>
qui œuvre sur logiciels&nbsp;libres.
</p>
<!-- Flèche vers le bas (theme://images/pictos/arrow-down.svg) -->
<svg viewBox="0 0 24 30" :style="{ width: '60px', height: '54px', marginTop: '90px' }" aria-hidden="true">
<g transform="rotate(90,11,12.999996)">
<path
d="m 16.681932,5.2684395 0.08629,0.091376 5.042819,6.0548205 v 0 l 0.06362,0.100098 v 0 l 0.06561,0.144066 v 0 l 0.0389,0.137168 v 0 l 0.01717,0.117449 v 0 L 22,12 v 0 l -0.0025,0.07061 v 0 l -0.01594,0.12126 v 0 l -0.02359,0.09573 -0.0317,0.08965 v 0 l -0.06326,0.128377 v 0 l -0.09484,0.134549 -5,6 c -0.353564,0.424277 -0.984128,0.481601 -1.408405,0.128037 C 14.968124,18.441846 14.889154,17.879455 15.15741,17.46116 L 15.231779,17.359816 18.864,12.999975 3,13 C 2.4477153,13 2,12.552285 2,12 2,11.487164 2.3860402,11.064493 2.8833789,11.006728 L 3,11 18.864,10.999975 15.231779,6.6401844 c -0.353564,-0.4242769 -0.29624,-1.0548416 0.128037,-1.4084057 0.39164,-0.3263668 0.959052,-0.3026278 1.322116,0.036661 z"
fill="currentColor"
fill-rule="nonzero"
/>
</g>
</svg>
</div>
<!-- Filet couleur pleine largeur en bas -->
<div class="absolute inset-x-0 bottom-0" :style="{ height: 'var(--fl-strip)', backgroundColor: strip }" />
</div>
</template>

View File

@@ -0,0 +1,39 @@
<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).
import type { Background, ResolvedMedia } from '~~/types'
const props = withDefaults(
defineProps<{
title?: string
text?: string // HTML
media?: ResolvedMedia[]
background: Background
bandColor?: string
page: { index: number; total: number }
renderMode?: 'preview' | 'print'
}>(),
{ renderMode: 'preview' },
)
const strip = computed(() => props.bandColor || FL_INK)
</script>
<template>
<div class="fl-page" :style="backgroundStyle(background)">
<div 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 v-if="title" class="max-w-[1000px]" :style="{ fontSize: '60px', lineHeight: '1.1' }">{{ 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" 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>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,95 @@
<script setup lang="ts">
// Page « couverture » de projet — gabarit_book_v3 p.1/3/5/7.
// Bandeau pleine largeur 55 mm : titre (fonte de la catégorie, 45 pt),
// pastille catégorie (10 pt), présentation (Syne 12/16 pt, colonne à 50,5 %).
// Sous le bandeau : image de couverture. Taille du titre et hauteur du
// bandeau (= calage haut de l'image) ajustables pour éviter les débords.
import type { InkMode, ResolvedMedia } from '~~/types'
const props = withDefaults(
defineProps<{
title: string
category: string // "publique" | "sociale" | "culturelle"
badge?: string // texte de la pastille (ex. « Utilité publique »)
intro?: string // ligne(s) d'intro en gras
text?: string // présentation, HTML
image?: ResolvedMedia
imageFit?: 'width' | 'height' // calage : pleine largeur ou pleine hauteur
imageAlign?: 'left' | 'center' | 'right' // fit hauteur : marge gauche / centrée / marge droite
titleScale?: number // % de la taille gabarit (100 = 45 pt)
bandHeight?: number // mm (gabarit : 55)
bandColor?: string
ink?: InkMode
}>(),
{ ink: 'auto', imageFit: 'width', imageAlign: 'center', titleScale: 100, bandHeight: 55 },
)
const PX_PER_MM = 3.7796
const band = computed(() => bandColorOf(props.category, props.bandColor))
const inkColor = computed(() => inkOn(band.value, props.ink))
const titleClass = computed(() => CATEGORY_TITLE_CLASS[props.category] ?? '')
// L'Avara est plus grasse : le gabarit la compose en 42 pt au lieu de 45 pt.
const titleSize = computed(() => {
const base = props.category === 'culturelle' ? 56 : 60
return `${(base * props.titleScale) / 100}px`
})
const bandPx = computed(() => `${props.bandHeight * PX_PER_MM}px`)
// Fit hauteur : calage horizontal sur la marge gauche, centré, ou marge droite
const alignStyle = computed(() => {
if (props.imageFit !== 'height') return { justifyContent: 'center' }
switch (props.imageAlign) {
case 'left': return { justifyContent: 'flex-start', paddingLeft: 'var(--fl-margin)' }
case 'right': return { justifyContent: 'flex-end', paddingRight: 'var(--fl-margin)' }
default: return { justifyContent: 'center' }
}
})
</script>
<template>
<div class="fl-page">
<!-- Bandeau -->
<div class="absolute inset-x-0 top-0" :style="{ height: bandPx, backgroundColor: band, color: inkColor }">
<h1
class="absolute"
:class="titleClass"
:style="{ left: 'var(--fl-margin)', top: '14px', fontSize: titleSize, lineHeight: '1.15', maxWidth: '620px' }"
>
{{ title }}
</h1>
<span
v-if="badge"
class="absolute whitespace-nowrap rounded-full border px-[12px] py-[3px]"
:style="{ left: '42.33%', top: '54px', transform: 'translateY(-50%)', fontSize: '13.3px', lineHeight: '1.2', borderColor: 'currentColor' }"
>
{{ badge }}
</span>
<div
class="absolute overflow-hidden"
:style="{ left: '50.47%', right: 'var(--fl-margin)', top: '33px', bottom: '12px' }"
>
<p v-if="intro" class="font-bold">{{ intro }}</p>
<div v-if="text" class="fl-prose" v-html="text" />
</div>
</div>
<!-- Image de couverture : calée sur la largeur (pleine largeur, fixée en
bas de page, débord haut coupé) ou sur la hauteur (pleine hauteur,
centrée). Le fond visible sous/à côté de l'image reprend la couleur
du bandeau. -->
<div
class="absolute inset-x-0 bottom-0 flex overflow-hidden"
:style="{ top: bandPx, backgroundColor: image ? band : '#ffffff', ...alignStyle }"
>
<img
v-if="image"
:src="image.url"
:alt="title"
:style="imageFit === 'height'
? { height: '100%', width: 'auto', maxWidth: 'none' }
: { width: '100%', height: 'auto', alignSelf: 'flex-end' }"
>
</div>
</div>
</template>

View File

@@ -0,0 +1,134 @@
<script setup lang="ts">
// Page « grille » de projet — gabarit_book_v3 p.2/4/6/8.
// Filet couleur 5 mm en haut, images ENTIÈRES (object-contain) calées sur
// une grille ADAPTATIVE : la famille choisie se construit selon le nombre
// d'images et leur orientation. Marges 8 mm, gouttières 3,8 mm.
import type { ResolvedMedia } from '~~/types'
import type { ImageOrientation } from '~~/composables/useGrids'
const props = withDefaults(
defineProps<{
media: ResolvedMedia[]
grid: string // id de famille (useGrids)
orientation?: ImageOrientation
category?: string
bandColor?: string
greyBg?: boolean
}>(),
{ orientation: 'mixed' },
)
const def = computed(() => gridDef(props.grid, props.media.length, props.orientation))
const strip = computed(() => stripColorOf(props.category, props.bandColor))
// Métriques du canvas A3 paysage (theme.css) — zone utile hors marges 8 mm.
const MARGIN = 30.2
const GUTTER = 14.4
const AVAIL_W = 1587.4 - 2 * MARGIN // 1527 px
const AVAIL_H = 1122.5 - 2 * MARGIN // 1062,1 px
// Familles « grande image + bandeaux/file » : la colonne étroite empile ses
// images à leur HAUTEUR RÉELLE (à la largeur de colonne), si bien que les
// gouttières entre bandeaux valent exactement --fl-gutter — comme la colonne
// de la grille « pleine largeur empilées ». Si la pile dépasse la hauteur de
// page, on réduit toutes les images proportionnellement (aucun letterbox,
// images toujours entières).
const hero = computed(() => {
const id = def.value.id
if (id !== 'hero-left' && id !== 'hero-right') return null
const left = id === 'hero-left'
const b = def.value.cells.length - 1 // nombre de bandeaux/vignettes
if (b < 1) return null
const cols = def.value.cols
const bigFrac = left ? cols[0] : cols[1]
const smallFrac = left ? cols[1] : cols[0]
const total = bigFrac + smallFrac
// largeur de la colonne étroite (les deux colonnes se partagent la largeur
// utile moins la gouttière qui les sépare)
const smallW = (smallFrac / total) * (AVAIL_W - GUTTER)
const big = left ? props.media[0] : props.media[b]
const bandeaux = left ? props.media.slice(1, b + 1) : props.media.slice(0, b)
const natH = bandeaux.map(m => smallW / (m.ratio && m.ratio > 0 ? m.ratio : 1.5))
const gaps = (b - 1) * GUTTER
const sum = natH.reduce((a, h) => a + h, 0)
const maxH = AVAIL_H - gaps
const k = sum > maxH ? maxH / sum : 1
const heights = natH.map(h => h * k)
return { left, big, bandeaux, heights, bigFrac, smallFrac }
})
const gridStyle = computed(() => ({
gridTemplateColumns: def.value.cols.map(c => `${c}fr`).join(' '),
gridTemplateRows: def.value.rows.map(r => `${r}fr`).join(' '),
gap: 'var(--fl-gutter)',
}))
function cellStyle(i: number) {
const cell = def.value.cells[i]
if (!cell) return {}
return {
gridColumn: `${cell.c + 1} / span ${cell.cs ?? 1}`,
gridRow: `${cell.r + 1} / span ${cell.rs ?? 1}`,
}
}
/** Cale l'image sur les lignes extérieures de la grille (jamais tronquée). */
function objectPosition(i: number) {
const cell = def.value.cells[i]
if (!cell) return 'left top'
const lastCol = cell.c + (cell.cs ?? 1) === def.value.cols.length
const firstCol = cell.c === 0
const x = firstCol && lastCol ? 'center' : lastCol ? 'right' : 'left'
return `${x} top`
}
const items = computed(() =>
def.value.cells.map((_, i) => props.media[i]).filter((m): m is ResolvedMedia => !!m),
)
</script>
<template>
<div class="fl-page" :style="greyBg ? { backgroundColor: FL_BASE } : undefined">
<!-- Filet couleur pleine largeur -->
<div class="absolute inset-x-0 top-0" :style="{ height: 'var(--fl-strip)', backgroundColor: strip }" />
<!-- Grande image + bandeaux/file : bandeaux à leur hauteur réelle -->
<div v-if="hero" class="absolute flex" :style="{ inset: 'var(--fl-margin)', gap: 'var(--fl-gutter)' }">
<!-- grande image (à gauche pour hero-left, à droite pour hero-right) -->
<div v-if="!hero.left" class="min-h-0 min-w-0 flex flex-col" :style="{ flex: `${hero.smallFrac} 0 0`, gap: 'var(--fl-gutter)' }">
<div v-for="(m, i) in hero.bandeaux" :key="m.filename + i" class="min-h-0 w-full" :style="{ height: `${hero.heights[i]}px` }">
<img :src="m.url" :alt="m.filename" class="size-full object-contain">
</div>
</div>
<div class="min-h-0 min-w-0" :style="{ flex: `${hero.bigFrac} 0 0` }">
<img
:src="hero.big.url"
:alt="hero.big.filename"
class="size-full object-contain"
:style="{ objectPosition: hero.left ? 'left top' : 'right top' }"
>
</div>
<div v-if="hero.left" class="min-h-0 min-w-0 flex flex-col" :style="{ flex: `${hero.smallFrac} 0 0`, gap: 'var(--fl-gutter)' }">
<div v-for="(m, i) in hero.bandeaux" :key="m.filename + i" class="min-h-0 w-full" :style="{ height: `${hero.heights[i]}px` }">
<img :src="m.url" :alt="m.filename" class="size-full object-contain">
</div>
</div>
</div>
<!-- Grille d'images standard dans les marges de 8 mm -->
<div v-else class="absolute grid" :style="{ inset: 'var(--fl-margin)', ...gridStyle }">
<div v-for="(m, i) in items" :key="m.filename + i" class="min-h-0 min-w-0" :style="cellStyle(i)">
<img
:src="m.url"
:alt="m.filename"
class="size-full object-contain"
:style="{ objectPosition: objectPosition(i) }"
>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,77 @@
<script setup lang="ts">
// Sommaire — langage du gabarit : filet couleur 5 mm en haut, marges 8 mm,
// titre Syne 45 pt. La taille des entrées et leur disposition (colonne unique
// ou grille) s'adaptent au nombre de projets listés.
import type { Background } from '~~/types'
const props = withDefaults(
defineProps<{
title?: string
entries: { title: string; subtitle?: string; category?: string; pageNumber: number }[]
background: Background
bandColor?: string
page: { index: number; total: number }
renderMode?: 'preview' | 'print'
}>(),
{ title: 'Sommaire', renderMode: 'preview' },
)
const emit = defineEmits<{ navigate: [pageNumber: number] }>()
const strip = computed(() => props.bandColor || FL_INK)
const isPrint = computed(() => props.renderMode === 'print')
// En print, chaque entrée est un lien interne #page-N (converti en lien PDF par
// Chromium). En aperçu, le même clic défile jusqu'à la page dans la preview.
function hrefFor(n: number) {
return isPrint.value ? `#page-${n}` : `#preview-page-${n}`
}
function onEntryClick(n: number, e: MouseEvent) {
if (isPrint.value) return // laisse le lien natif au PDF
e.preventDefault()
e.stopPropagation() // n'active pas la sélection de la page-sommaire elle-même
emit('navigate', n)
}
// Peu de projets = gros titres sur une colonne ; beaucoup = grille dense.
const layout = computed(() => {
const n = props.entries.length
if (n <= 4) return { cols: 1, size: 48, sub: 18.7, gapX: 0, gapY: 44 }
if (n <= 8) return { cols: 1, size: 32, sub: 14.7, gapX: 0, gapY: 30 }
if (n <= 16) return { cols: 2, size: 24, sub: 13.3, gapX: 96, gapY: 24 }
if (n <= 26) return { cols: 2, size: 18.7, sub: 12, gapX: 96, gapY: 18 }
return { cols: 3, size: 14.7, sub: 10.7, gapX: 60, gapY: 14 }
})
const gridStyle = computed(() => ({
gridTemplateColumns: `repeat(${layout.value.cols}, minmax(0, 1fr))`,
columnGap: `${layout.value.gapX}px`,
rowGap: `${layout.value.gapY}px`,
gridAutoRows: 'min-content',
}))
</script>
<template>
<div class="fl-page" :style="backgroundStyle(background)">
<div 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.1' }">{{ title }}</h2>
<div class="mt-14 grid min-h-0 flex-1 content-start" :style="gridStyle">
<a
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="{ '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" />
<span class="tabular-nums opacity-80" :style="{ fontSize: `${layout.sub + 2}px` }">{{ entry.pageNumber }}</span>
</a>
</div>
</div>
</div>
</template>