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

9
.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
# Le contexte de build ne doit pas embarquer les artefacts ni les données.
# En dev comme en prod, le code est monté en volume (voir docker-compose).
node_modules
.nuxt
.output
data
*.log
.env
.git

10
.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
node_modules
.nuxt
.output
.env
data/content-cache
data/exports
data/cache
data/portfolios
data/frozen
*.log

67
CLAUDE.md Normal file
View File

@@ -0,0 +1,67 @@
# CLAUDE.md — générateur de portfolios
App Nuxt (Nuxt 3 + Nuxt UI v3) qui lit les projets du CMS Grav de Figures Libres
et compose des portfolios **A3 paysage** exportés en PDF, fidèles au gabarit
`book_v3`. Ce dépôt est **autonome et déployable seul** (son `Dockerfile` est ici).
## Tout tourne dans Docker
Node, build Nuxt, Playwright/Chromium, Ghostscript, ffmpeg, rsync — **rien sur
l'hôte**. En dev, l'app est lancée via le dépôt d'orchestration `portfolio-stack`
(`docker compose up -d`), qui monte ce dossier en volume sur le port 8091.
```bash
# depuis le dépôt stack (ce dossier cloné en ./generator) :
docker compose run --rm --no-deps generator npm install # dépendances
docker compose exec generator npm run build # build de prod (.output)
docker logs -f figureslibres-portfolio-generator # logs du dev server
```
Mot de passe dev : `figureslibres` (`PORTFOLIO_PASSWORD`).
## Architecture
- `server/lib/` — le cœur :
- `content-source.ts` : abstrait la source de contenu. Mode `dev` = lecture
directe du FS Grav monté en `:ro` ; mode `prod` = cache alimenté par rsync/SSH.
- `grav-reader.ts` : parse les pages Grav (frontmatter YAML, `media_order`,
taxonomies) en `Project` (voir `types/index.ts`).
- `pdf-pipeline.ts` : rend `/print/<id>` via Playwright/Chromium en A3 paysage
(1190,55 × 841,89 pt), images à 2× (sharp), post-traitement Ghostscript, métadonnées.
- `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/preview/``PageRenderer` (dispatch) + `PageViewport` (aperçu à l'échelle).
- `components/composer/` — UI de composition (curation médias, fonds, pickers).
- `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 bandeau + image), `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.
## Contraintes fermes
- Le contenu Grav n'est jamais modifié par l'app (monté `:ro` côté stack).
- Les images ne sont **jamais tronquées** dans les grilles (calées entières).
- Les vidéos ne sont pas sélectionnables dans les grilles.
- Format PDF : A3 paysage aux dimensions exactes du gabarit.
## Pièges Chromium print (avant de toucher aux templates)
- **`columns-*` (CSS multicol) = page PDF blanche** dans Chromium print. Ne pas
utiliser de colonnes CSS dans les templates ; vérifier chaque template via `pdftoppm`.
- **Webfonts en `font-display: block` = texte absent du PDF**. Le pipeline
réinvalide les styles avant `page.pdf()` — ne pas retirer cette étape.
## Données
Tout est sur disque dans `data/` (sauvegarde = copie du dossier), git-ignoré :
`portfolios/*.json`, `exports/*.pdf`, `frozen/<id>/`, `cache/` (régénérable),
`content-cache/` (rsync prod).

21
Dockerfile Normal file
View File

@@ -0,0 +1,21 @@
# Image du générateur de portfolios PDF (Figures Libres).
# Base officielle Playwright : Node + Chromium + dépendances déjà présents.
# On y ajoute Ghostscript (optimisation PDF), ffmpeg (posters des vidéos)
# et rsync/ssh (synchro prod).
FROM mcr.microsoft.com/playwright:v1.61.1-noble
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ghostscript \
ffmpeg \
rsync \
openssh-client \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
ENV NUXT_TELEMETRY_DISABLED=1 \
PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
# En dev, le code est monté dans /app et la commande est fournie par compose.
CMD ["npm", "run", "dev"]

39
README.md Normal file
View File

@@ -0,0 +1,39 @@
# Générateur de portfolios — Figures Libres
App **Nuxt 3** (+ Nuxt UI v3) qui lit les projets du CMS Grav de Figures Libres et
compose des **portfolios A3 paysage** exportés en PDF, fidèles au gabarit `book_v3`.
Dépôt **autonome et déployable seul** : son `Dockerfile` (base Playwright +
Ghostscript + ffmpeg + rsync) est inclus. En dev, il se lance via le dépôt
d'orchestration `portfolio-stack`, qui le clone en `./generator` et le monte en
volume sur le port **8091** (mot de passe partagé : `figureslibres`).
## Lancer
Tout tourne dans Docker (rien sur l'hôte). Depuis le dépôt stack :
```bash
docker compose up -d # dev (port 8091)
docker compose run --rm --no-deps generator npm install # dépendances
docker compose exec generator npm run build # build de prod (.output)
```
## Repères
- `server/lib/``content-source` (dev FS Grav `:ro` / prod cache rsync),
`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).
- `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).
Voir `CLAUDE.md` pour l'architecture détaillée, les contraintes fermes et les
pièges Chromium print.
## Données
Sur disque dans `data/` (git-ignoré) : `portfolios/*.json`, `exports/*.pdf`,
`frozen/<id>/` (snapshots figés à la génération), `cache/` (régénérable),
`content-cache/` (rsync prod).

7
app.vue Normal file
View File

@@ -0,0 +1,7 @@
<template>
<UApp :toaster="{ position: 'bottom-right' }">
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</UApp>
</template>

8
assets/css/main.css Normal file
View File

@@ -0,0 +1,8 @@
@import "tailwindcss";
@import "@nuxt/ui";
:root {
/* Charte Figures Libres (thème Grav figureslibres-v2) */
--fl-base: #f5f5f5;
--fl-ink: #0e1229;
}

137
assets/css/theme.css Normal file
View File

@@ -0,0 +1,137 @@
/* Charte Figures Libres — fontes du thème Grav figureslibres-v2 et canvas
de page A3 paysage (gabarit_book_v3 : 1190,55 × 841,89 pt), communs à
l'aperçu et au PDF. 1 pt = 4/3 px → page = 1587,4 × 1122,5 px. */
@font-face {
font-family: 'Syne';
src: url('/fonts/Syne-regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: block;
}
@font-face {
font-family: 'Syne';
src: url('/fonts/Syne-bold.woff2') format('woff2');
font-weight: 700;
font-style: normal;
font-display: block;
}
@font-face {
font-family: 'Moche';
src: url('/fonts/Moche-bold.woff2') format('woff2');
font-weight: 700;
font-style: normal;
font-display: block;
}
@font-face {
font-family: 'Lato';
src: url('/fonts/Lato-regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: block;
}
@font-face {
font-family: 'Lato';
src: url('/fonts/Lato-bold.woff2') format('woff2');
font-weight: 700;
font-style: normal;
font-display: block;
}
@font-face {
font-family: 'Lato';
src: url('/fonts/Lato-italic.woff2') format('woff2');
font-weight: 400;
font-style: italic;
font-display: block;
}
@font-face {
font-family: 'Lato';
src: url('/fonts/Lato-bold-italic.woff2') format('woff2');
font-weight: 700;
font-style: italic;
font-display: block;
}
/* Fontes de titre par « utilité » (mixins du thème Grav) */
@font-face {
font-family: 'Avara';
src: url('/fonts/Avara-bold-italic.woff2') format('woff2');
font-weight: 700;
font-style: italic;
font-display: block;
}
@font-face {
font-family: 'PlayfairDisplay';
src: url('/fonts/PlayfairDisplay-italic.woff2') format('woff2');
font-weight: 400;
font-style: italic;
font-display: block;
}
@font-face {
font-family: 'ManifontGrotesk';
src: url('/fonts/ManifontGrotesk-bold-italic.woff2') format('woff2');
font-weight: 700;
font-style: italic;
font-display: block;
}
/* ---- Canvas de page A3 paysage — métriques mesurées dans le gabarit ---- */
:root {
--fl-page-w: 1587.4px; /* 420 mm */
--fl-page-h: 1122.5px; /* 297 mm */
--fl-margin: 30.2px; /* 8 mm — marge extérieure */
--fl-gutter: 14.4px; /* 3,8 mm — gouttière des grilles */
--fl-strip: 18.9px; /* 5 mm — filet haut des pages grille */
--fl-band: 208px; /* 55 mm — bandeau des pages couverture */
}
.fl-page {
position: relative;
width: var(--fl-page-w);
height: var(--fl-page-h);
overflow: hidden;
flex-shrink: 0;
background: #ffffff;
color: #000000;
font-family: 'Syne', sans-serif;
font-size: 16px; /* 12 pt */
line-height: 21.33px; /* 16 pt */
}
.fl-page :is(h1, h2, h3) {
font-family: 'Syne', sans-serif;
font-weight: 700;
line-height: 1.1;
}
.fl-page img {
display: block;
}
/* Fontes de titre par catégorie (utilité) — plus spécifiques que la règle
Syne des h1/h2/h3 ci-dessus */
.fl-page .fl-font-publique {
font-family: 'PlayfairDisplay', serif;
font-weight: 400;
font-style: italic;
}
.fl-page .fl-font-sociale {
font-family: 'ManifontGrotesk', sans-serif;
font-weight: 700;
font-style: italic;
}
.fl-page .fl-font-culturelle {
font-family: 'Avara', serif;
font-weight: 700;
font-style: italic;
}
/* Texte courant issu du Markdown — Syne 12 pt / interligne 16 pt (gabarit) */
.fl-prose p { margin: 0 0 0.75em; }
.fl-prose p:last-child { margin-bottom: 0; }
.fl-prose a { text-decoration: underline; text-underline-offset: 2px; }
.fl-prose ul, .fl-prose ol { margin: 0 0 0.75em; padding-left: 1.2em; }
.fl-prose ul { list-style: disc; }
.fl-prose ol { list-style: decimal; }
.fl-prose strong { font-weight: 700; }
.fl-prose em { font-style: italic; }

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>

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,
}
}

66
layouts/default.vue Normal file
View File

@@ -0,0 +1,66 @@
<script setup lang="ts">
const route = useRoute()
const toast = useToast()
const syncing = ref(false)
async function refreshContent() {
syncing.value = true
try {
const result = await $fetch<{ ok: boolean, summary: string }>('/api/sync', {
method: 'POST',
timeout: 900_000,
})
toast.add({
title: result.ok ? 'Contenu rafraîchi' : 'Synchronisation en échec',
description: result.summary,
color: result.ok ? 'success' : 'error',
})
if (result.ok) await refreshNuxtData()
} catch {
toast.add({ title: 'Échec de la synchronisation', color: 'error' })
} finally {
syncing.value = false
}
}
async function logout() {
await $fetch('/api/auth/logout', { method: 'POST' })
navigateTo('/login')
}
</script>
<template>
<div class="min-h-screen bg-[var(--fl-base)] dark:bg-neutral-950">
<header
v-if="route.path !== '/login'"
class="sticky top-0 z-40 border-b border-neutral-200 bg-white/90 backdrop-blur dark:border-neutral-800 dark:bg-neutral-900/90"
>
<UContainer class="flex h-14 items-center gap-6">
<NuxtLink to="/" class="flex items-center gap-2 font-bold text-[var(--fl-ink)] dark:text-white">
<span class="flex size-7 items-center justify-center rounded-full bg-[var(--fl-ink)] text-xs text-white">FL</span>
Portfolios
</NuxtLink>
<div class="ml-auto flex items-center gap-1">
<UTooltip text="Actualise les données depuis Grav">
<UButton
icon="i-lucide-refresh-cw"
label="Rafraîchir"
variant="ghost"
color="neutral"
:loading="syncing"
@click="refreshContent"
/>
</UTooltip>
<UButton
icon="i-lucide-log-out"
variant="ghost"
color="neutral"
aria-label="Se déconnecter"
@click="logout"
/>
</div>
</UContainer>
</header>
<slot />
</div>
</template>

12
middleware/auth.global.ts Normal file
View File

@@ -0,0 +1,12 @@
// Redirige vers /login toute page visitée sans session valide.
export default defineNuxtRouteMiddleware(async (to) => {
if (to.path === '/login' || to.path.startsWith('/print') || to.path.startsWith('/view')) return
try {
const { authenticated } = await $fetch<{ authenticated: boolean }>('/api/auth/me', {
headers: import.meta.server ? useRequestHeaders(['cookie']) : undefined,
})
if (!authenticated) return navigateTo('/login')
} catch {
return navigateTo('/login')
}
})

42
nuxt.config.ts Normal file
View File

@@ -0,0 +1,42 @@
export default defineNuxtConfig({
compatibilityDate: '2026-07-01',
devtools: { enabled: false },
telemetry: false,
modules: ['@nuxt/ui'],
components: [{ path: '~/components', pathPrefix: false }],
css: ['~/assets/css/main.css', '~/assets/css/theme.css'],
routeRules: {
// L'accueil est la liste des portfolios ; l'ancienne URL reste valide.
'/portfolios': { redirect: '/' },
},
app: {
head: {
title: 'Portfolios — Figures Libres',
htmlAttrs: { lang: 'fr' },
},
},
runtimeConfig: {
// Surchargées par les variables d'env NUXT_* (voir docker-compose.yml)
sessionSecret: 'dev-secret-figures-libres',
portfolioPassword: 'figureslibres',
internalToken: 'dev-internal-token',
contentSource: 'dev', // 'dev' (FS local) | 'prod' (cache rsync)
contentDir: '/content/02.projets',
dataDir: '/app/data',
// Mode prod : synchro rsync (phase 3)
syncRemote: '', // ex. user@host:/chemin/vers/user/pages/02.projets/
syncSshKey: '', // chemin de la clé privée dans le conteneur
public: {
appName: 'Portfolios Figures Libres',
},
},
nitro: {
// Les données (portfolios JSON, exports PDF, cache) vivent hors du bundle.
storage: {},
},
})

13987
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "figureslibres-portfolio-generator",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "nuxt dev --host 0.0.0.0 --port 3000",
"build": "nuxt build",
"preview": "node .output/server/index.mjs",
"postinstall": "nuxt prepare"
},
"dependencies": {
"@nuxt/ui": "^3.3.0",
"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",
"yaml": "^2.8.0"
}
}

146
pages/index.vue Normal file
View File

@@ -0,0 +1,146 @@
<script setup lang="ts">
import type { PortfolioSummary } from '~~/types'
const { data, refresh } = await useFetch<{ portfolios: PortfolioSummary[] }>('/api/portfolios')
const toast = useToast()
const portfolios = computed(() => data.value?.portfolios ?? [])
const creating = ref(false)
const newName = ref('')
const showCreate = ref(false)
const dateFmt = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium', timeStyle: 'short' })
async function create() {
creating.value = true
try {
const p = await $fetch<{ id: string }>('/api/portfolios', {
method: 'POST',
body: { name: newName.value },
})
navigateTo(`/portfolios/${p.id}`)
} finally {
creating.value = false
}
}
async function duplicate(id: string) {
await $fetch(`/api/portfolios/${id}/duplicate`, { method: 'POST' })
await refresh()
toast.add({ title: 'Portfolio dupliqué', color: 'success' })
}
async function remove(id: string, name: string) {
if (!confirm(`Supprimer le portfolio « ${name} » ? Cette action est définitive.`)) return
await $fetch(`/api/portfolios/${id}`, { method: 'DELETE' })
await refresh()
toast.add({ title: 'Portfolio supprimé', color: 'neutral' })
}
const downloading = ref<string | null>(null)
/**
* Télécharge le PDF : direct s'il est à jour, sinon (jamais généré ou modifié
* depuis) on le génère puis on le télécharge.
*/
async function downloadPdf(p: PortfolioSummary) {
if (p.pdfCurrent && p.exportFileName) {
window.open(`/api/exports/${encodeURIComponent(p.exportFileName)}`, '_blank')
return
}
downloading.value = p.id
try {
const result = await $fetch<{ fileName: string, downloadUrl: string }>(`/api/portfolios/${p.id}/generate`, {
method: 'POST',
body: {},
timeout: 300_000,
})
toast.add({ title: 'PDF généré', description: result.fileName, color: 'success' })
window.open(result.downloadUrl, '_blank')
await refresh() // rafraîchit pdfCurrent / exportFileName sur la carte
} catch {
toast.add({ title: 'Échec de la génération', color: 'error' })
} finally {
downloading.value = null
}
}
async function copyPublicLink(id: string) {
const url = `${window.location.origin}/view/${id}`
try {
await navigator.clipboard.writeText(url)
toast.add({ title: 'Lien public copié', color: 'success' })
} catch {
toast.add({ title: 'Copie impossible', description: url, color: 'error' })
}
}
</script>
<template>
<UContainer class="py-8">
<div class="mb-6 flex items-end justify-between gap-4">
<div>
<h1 class="text-2xl font-bold text-[var(--fl-ink)] dark:text-white">Portfolios</h1>
</div>
<UButton icon="i-lucide-plus" label="Nouveau portfolio" @click="showCreate = true" />
</div>
<div v-if="!portfolios.length" class="rounded-lg border border-dashed border-neutral-300 p-16 text-center text-neutral-500 dark:border-neutral-700">
Aucun portfolio pour l'instant créez-en un pour composer votre premier PDF.
</div>
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
<UCard v-for="p in portfolios" :key="p.id">
<div class="flex items-start justify-between gap-2">
<div>
<NuxtLink :to="`/portfolios/${p.id}`" class="font-semibold text-[var(--fl-ink)] hover:underline dark:text-white">
{{ p.name }}
</NuxtLink>
<p class="mt-1 text-xs text-neutral-500">
{{ p.pageCount }} page{{ p.pageCount > 1 ? 's' : '' }} · modifié le {{ dateFmt.format(new Date(p.updatedAt)) }}
</p>
</div>
<div class="flex shrink-0 items-center gap-1.5">
<UBadge v-if="p.publicShare" color="info" variant="subtle" size="sm">Public</UBadge>
<UBadge v-if="p.hasPdf" color="success" variant="subtle" size="sm">PDF</UBadge>
</div>
</div>
<div class="mt-4 flex items-center gap-2">
<UButton :to="`/portfolios/${p.id}`" size="sm" variant="soft" icon="i-lucide-pencil" label="Ouvrir" />
<UButton size="sm" variant="ghost" color="neutral" icon="i-lucide-copy" label="Dupliquer" @click="duplicate(p.id)" />
<UButton
size="sm"
variant="ghost"
color="neutral"
:icon="p.pdfCurrent ? 'i-lucide-download' : 'i-lucide-file-down'"
:label="p.pdfCurrent ? 'Télécharger' : (p.hasPdf ? 'Régénérer' : 'Générer')"
:loading="downloading === p.id"
@click="downloadPdf(p)"
/>
<UButton
v-if="p.publicShare"
size="sm"
variant="ghost"
color="neutral"
icon="i-lucide-link"
aria-label="Copier le lien public"
@click="copyPublicLink(p.id)"
/>
<UButton size="sm" variant="ghost" color="error" icon="i-lucide-trash-2" class="ml-auto" @click="remove(p.id, p.name)" />
</div>
</UCard>
</div>
<UModal v-model:open="showCreate" title="Nouveau portfolio">
<template #body>
<form class="space-y-4" @submit.prevent="create">
<UInput v-model="newName" placeholder="Nom du portfolio (ex. Candidature Bagneux)" class="w-full" autofocus />
<div class="flex justify-end gap-2">
<UButton variant="ghost" color="neutral" label="Annuler" @click="showCreate = false" />
<UButton type="submit" :loading="creating" label="Créer" />
</div>
</form>
</template>
</UModal>
</UContainer>
</template>

46
pages/login.vue Normal file
View File

@@ -0,0 +1,46 @@
<script setup lang="ts">
const password = ref('')
const error = ref('')
const pending = ref(false)
async function submit() {
if (!password.value) return
pending.value = true
error.value = ''
try {
await $fetch('/api/auth/login', { method: 'POST', body: { password: password.value } })
navigateTo('/')
} catch {
error.value = 'Mot de passe incorrect.'
} finally {
pending.value = false
}
}
</script>
<template>
<div class="flex min-h-screen items-center justify-center p-4">
<UCard class="w-full max-w-sm">
<div class="mb-6 flex flex-col items-center gap-3 text-center">
<span class="flex size-12 items-center justify-center rounded-full bg-[var(--fl-ink)] font-bold text-white">FL</span>
<div>
<h1 class="text-lg font-bold text-[var(--fl-ink)] dark:text-white">Portfolios Figures Libres</h1>
<p class="text-sm text-neutral-500">Générateur de portfolios PDF</p>
</div>
</div>
<form class="space-y-4" @submit.prevent="submit">
<UInput
v-model="password"
type="password"
placeholder="Mot de passe partagé"
icon="i-lucide-lock"
size="lg"
class="w-full"
autofocus
/>
<p v-if="error" class="text-sm text-red-600">{{ error }}</p>
<UButton type="submit" block size="lg" :loading="pending" label="Entrer" />
</form>
</UCard>
</div>
</template>

827
pages/portfolios/[id].vue Normal file
View File

@@ -0,0 +1,827 @@
<script setup lang="ts">
// Composeur de portfolio (spec §8.2, gabarit_book_v3) : pages réordonnables,
// pages projet en deux volets (couverture + grilles), curation des images,
// 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'
const route = useRoute()
const toast = useToast()
const [{ data: portfolioData }, { data: projectsData }] = await Promise.all([
useFetch<Portfolio>(`/api/portfolios/${route.params.id}`),
useFetch<{ projects: Project[] }>('/api/projects'),
])
if (!portfolioData.value) {
throw createError({ statusCode: 404, statusMessage: 'Portfolio introuvable' })
}
const portfolio = ref<Portfolio>(portfolioData.value)
const projects = computed<Record<string, Project>>(() =>
Object.fromEntries((projectsData.value?.projects ?? []).map(p => [p.id, p])),
)
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])
// ---- Sauvegarde automatique (debounce) ----
const saveState = ref<'saved' | 'saving' | 'dirty' | 'error'>('saved')
let timer: ReturnType<typeof setTimeout> | undefined
// ---- Fraîcheur du dernier PDF ----
// Un PDF « à jour » (non modifié depuis) reste téléchargeable directement.
const exportFileName = ref(portfolio.value.lastExport?.fileName)
const pdfStale = ref(!(portfolio.value.lastExport && portfolio.value.updatedAt <= portfolio.value.lastExport.at))
const pdfDownloadUrl = computed(() =>
exportFileName.value ? `/api/exports/${encodeURIComponent(exportFileName.value)}` : undefined,
)
const pdfDownloadable = computed(() => !pdfStale.value && !!exportFileName.value)
async function save() {
saveState.value = 'saving'
try {
await $fetch(`/api/portfolios/${portfolio.value.id}`, { method: 'PUT', body: portfolio.value })
saveState.value = 'saved'
} catch {
saveState.value = 'error'
toast.add({ title: 'Échec de la sauvegarde', color: 'error' })
}
}
watch(
portfolio,
() => {
saveState.value = 'dirty'
pdfStale.value = true // toute modif rend le dernier PDF périmé
clearTimeout(timer)
timer = setTimeout(save, 800)
},
{ deep: true },
)
// ---- Ajout / suppression de pages ----
const showProjectPicker = ref(false)
const previewScroll = ref<HTMLElement>()
// Défilement de l'aperçu → la page active (selectedPageId) suit la page centrée.
const pagesRef = computed(() => portfolio.value.pages)
const { onScroll: onPreviewScroll, scrollToIndex } = usePageScrollSpy({
container: previewScroll,
pages: pagesRef,
active: selectedPageId,
idPrefix: 'preview-page',
})
function selectPage(id: string, scroll = true) {
selectedPageId.value = id
if (!scroll) return
const i = portfolio.value.pages.findIndex(p => p.id === id)
if (i !== -1) scrollToIndex(i)
}
/** Clic sur une entrée du sommaire (aperçu) → défile jusqu'à la page N. */
function goToPage(pageNumber: number) {
const page = portfolio.value.pages[pageNumber - 1]
if (page) selectPage(page.id)
}
function projectImages(projectId: string): string[] {
return (projects.value[projectId]?.media ?? []).filter(m => m.type === 'image').map(m => m.filename)
}
/** Images du projet pas encore posées sur une page grille. */
function unusedImages(projectId: string): string[] {
const used = new Set(
portfolio.value.pages
.filter((p): p is ProjectGridPage => p.kind === 'project-grid' && p.projectId === projectId)
.flatMap(p => p.media),
)
return projectImages(projectId).filter(f => !used.has(f))
}
/** Orientation dominante d'un lot d'images du projet (grille par défaut). */
function orientationOf(projectId: string, filenames: string[]) {
const byName = new Map((projects.value[projectId]?.media ?? []).map(m => [m.filename, m]))
return dominantOrientation(
filenames.map((f) => {
const a = byName.get(f)
return a?.width && a?.height ? a.width / a.height : 0
}),
)
}
/** Insère une page grille après la dernière page du même projet. */
function addGridPage(projectId: string) {
const remaining = unusedImages(projectId)
const images = remaining.length ? remaining : projectImages(projectId)
const familyId = defaultGridFor(images.length, orientationOf(projectId, images))
const page: ProjectGridPage = {
kind: 'project-grid',
id: nanoid(8),
projectId,
grid: familyId,
media: images.slice(0, gridFamily(familyId).max),
}
const lastOfProject = portfolio.value.pages.reduce(
(acc, p, i) => ((p.kind === 'project' || p.kind === 'project-grid') && p.projectId === projectId ? i : acc),
-1,
)
portfolio.value.pages.splice(lastOfProject === -1 ? portfolio.value.pages.length : lastOfProject + 1, 0, page)
selectPage(page.id)
}
/** Un projet = une page couverture + une première page grille (gabarit). */
function addProjectPage(project: Project) {
const page: ProjectPage = {
kind: 'project',
id: nanoid(8),
projectId: project.id,
}
portfolio.value.pages.push(page)
const cover = project.coverImage
const others = projectImages(project.id).filter(f => f !== cover)
if (others.length) {
const familyId = defaultGridFor(others.length, orientationOf(project.id, others))
portfolio.value.pages.push({
kind: 'project-grid',
id: nanoid(8),
projectId: project.id,
grid: familyId,
media: others.slice(0, gridFamily(familyId).max),
})
}
selectPage(page.id)
}
function addFreePage() {
const page: FreePage = {
kind: 'free',
id: nanoid(8),
template: 'free-text',
title: 'Page libre',
body: '',
media: [],
background: { mode: 'white' },
}
portfolio.value.pages.push(page)
selectPage(page.id)
}
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
const at = portfolio.value.pages.findIndex(p => p.kind !== 'cover')
portfolio.value.pages.splice(at === -1 ? portfolio.value.pages.length : at, 0, page)
selectPage(page.id)
}
function addCoverPage() {
const page: Page = { kind: 'cover', id: nanoid(8), background: { mode: 'white' } }
portfolio.value.pages.unshift(page)
selectPage(page.id)
}
function removePage(id: string) {
const i = portfolio.value.pages.findIndex(p => p.id === id)
if (i === -1) return
portfolio.value.pages.splice(i, 1)
if (selectedPageId.value === id) {
selectedPageId.value = portfolio.value.pages[Math.max(0, i - 1)]?.id
}
}
const hasCover = computed(() => portfolio.value.pages.some(p => p.kind === 'cover'))
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 },
...(hasCover.value ? [] : [{ label: 'Couverture', icon: 'i-lucide-bookmark', onSelect: addCoverPage }]),
]])
// ---- Libellés de la liste de pages ----
function pageLabel(page: Page): string {
switch (page.kind) {
case 'cover': return portfolio.value.cover.title || 'Couverture'
case 'toc': return page.title || 'Sommaire'
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
}
}
const KIND_LABELS: Record<Page['kind'], string> = {
'cover': 'Couverture',
'toc': 'Sommaire',
'free': 'Page libre',
'project': 'Projet — couverture',
'project-grid': 'Projet — grille',
}
// ---- Encre du bandeau (pages projet) ----
const inkItems = [
{ value: 'auto', label: 'Auto' },
{ value: 'black', label: 'Noir' },
{ value: 'white', label: 'Blanc' },
] as const
function setInk(value: (typeof inkItems)[number]['value']) {
if (selectedPage.value?.kind === 'project') selectedPage.value.ink = value
}
// ---- Calage de l'image de couverture (pages projet) ----
const coverFitItems = [
{ value: 'width', label: 'Largeur' },
{ value: 'height', label: 'Hauteur' },
] as const
function setCoverFit(value: (typeof coverFitItems)[number]['value']) {
if (selectedPage.value?.kind === 'project') selectedPage.value.coverFit = value
}
// Fit hauteur : calage horizontal de l'image (marge gauche / centre / marge droite)
const coverAlignItems = [
{ value: 'left', icon: 'i-lucide-align-start-vertical', title: 'Calée sur la marge gauche' },
{ value: 'center', icon: 'i-lucide-align-center-vertical', title: 'Centrée' },
{ value: 'right', icon: 'i-lucide-align-end-vertical', title: 'Calée sur la marge droite' },
] as const
function setCoverAlign(value: (typeof coverAlignItems)[number]['value']) {
if (selectedPage.value?.kind === 'project') {
selectedPage.value.coverAlign = value === 'center' ? undefined : value
}
}
// Sliders des pages couverture : taille du titre (%) et hauteur du bandeau
// (mm = calage haut de l'image). Undefined = valeurs du gabarit.
const titleScale = computed({
get: () => (selectedPage.value?.kind === 'project' ? selectedPage.value.titleScale ?? 100 : 100),
set: (v: number) => {
if (selectedPage.value?.kind === 'project') selectedPage.value.titleScale = v === 100 ? undefined : v
},
})
const bandHeight = computed({
get: () => (selectedPage.value?.kind === 'project' ? selectedPage.value.bandHeight ?? 55 : 55),
set: (v: number) => {
if (selectedPage.value?.kind === 'project') selectedPage.value.bandHeight = v === 55 ? undefined : v
},
})
// ---- Image de couverture / médias de page libre ----
const showMediaPicker = ref(false)
const mediaPickerTarget = ref<'cover' | 'free'>('cover')
function openMediaPicker(target: 'cover' | 'free') {
mediaPickerTarget.value = target
showMediaPicker.value = true
}
function onMediaPicked(ref_: string) {
if (mediaPickerTarget.value === 'cover') {
portfolio.value.cover.backgroundImage = ref_
} else if (selectedPage.value?.kind === 'free') {
selectedPage.value.media = [...(selectedPage.value.media ?? []), ref_]
}
}
const saveLabels: Record<typeof saveState.value, string> = {
saved: 'Enregistré',
saving: 'Enregistrement…',
dirty: 'Modifications…',
error: 'Erreur de sauvegarde',
}
// ---- Génération PDF (phase 2) ----
const showGenerate = ref(false)
const generating = ref(false)
const pdfFileName = ref('')
const frozenAt = ref(portfolio.value.frozenContent?.generatedAt)
const lastResult = ref<{ fileName: string; downloadUrl: string; sizeBytes: number; pageCount: number } | null>(null)
function openGenerate() {
pdfFileName.value = portfolio.value.frozenContent?.fileName?.replace(/\.pdf$/, '')
?? portfolio.value.name.toLowerCase().replace(/\s+/g, '-')
showGenerate.value = true
}
async function generate() {
generating.value = true
lastResult.value = null
try {
lastResult.value = await $fetch(`/api/portfolios/${portfolio.value.id}/generate`, {
method: 'POST',
body: { fileName: pdfFileName.value },
timeout: 300_000,
})
frozenAt.value = frozenAt.value ?? new Date().toISOString()
exportFileName.value = lastResult.value!.fileName
pdfStale.value = false // le PDF reflète désormais l'état courant
toast.add({ title: 'PDF généré', description: lastResult.value!.fileName, color: 'success' })
} catch {
toast.add({ title: 'Échec de la génération', color: 'error' })
} finally {
generating.value = false
}
}
async function unfreeze() {
await $fetch(`/api/portfolios/${portfolio.value.id}/unfreeze`, { method: 'POST' })
frozenAt.value = undefined
toast.add({ title: 'Contenu réactualisé', description: 'La prochaine génération utilisera le contenu Grav à jour.', color: 'success' })
}
function formatBytes(n: number): string {
return n > 1024 * 1024 ? `${(n / 1024 / 1024).toFixed(1)} Mo` : `${Math.round(n / 1024)} Ko`
}
const frozenDateLabel = computed(() =>
frozenAt.value
? new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(frozenAt.value))
: '',
)
// ---- Partage d'un lien public ----
const showShare = ref(false)
const sharing = ref(false)
const publicShare = ref(Boolean(portfolio.value.publicShare))
const shareUrl = computed(() =>
import.meta.client ? `${window.location.origin}/view/${portfolio.value.id}` : `/view/${portfolio.value.id}`,
)
function openShare() {
publicShare.value = Boolean(portfolio.value.publicShare)
showShare.value = true
}
/** Active/republie le lien public : fige l'état courant et l'expose. */
async function enableShare() {
sharing.value = true
try {
// La réponse est le portfolio complet resynchronisé (frozenContent frais) :
// on remplace l'état local pour éviter tout écrasement par l'auto-save.
const updated = await $fetch<Portfolio>(`/api/portfolios/${portfolio.value.id}/share`, { method: 'POST', body: {} })
portfolio.value = updated
publicShare.value = true
frozenAt.value = updated.frozenContent?.generatedAt
toast.add({ title: 'Lien public activé', color: 'success' })
} catch {
toast.add({ title: 'Échec de lactivation du lien', color: 'error' })
} finally {
sharing.value = false
}
}
/** Coupe le lien public (le PDF conserve son contenu figé). */
async function disableShare() {
sharing.value = true
try {
const updated = await $fetch<Portfolio>(`/api/portfolios/${portfolio.value.id}/share`, { method: 'POST', body: { enabled: false } })
portfolio.value = updated
publicShare.value = false
toast.add({ title: 'Lien public coupé', color: 'success' })
} catch {
toast.add({ title: 'Échec de la coupure du lien', color: 'error' })
} finally {
sharing.value = false
}
}
async function copyShareUrl() {
try {
await navigator.clipboard.writeText(shareUrl.value)
toast.add({ title: 'Lien copié', color: 'success' })
} catch {
toast.add({ title: 'Copie impossible', description: shareUrl.value, color: 'error' })
}
}
</script>
<template>
<div class="flex h-[calc(100vh-3.5rem)]">
<!-- Colonne pages -->
<aside class="flex w-60 shrink-0 flex-col border-r border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-900">
<div class="flex items-center justify-between border-b border-neutral-200 p-3 dark:border-neutral-800">
<span class="text-sm font-semibold">Pages ({{ portfolio.pages.length }})</span>
<UDropdownMenu :items="addItems">
<UButton icon="i-lucide-plus" size="xs" variant="soft" label="Ajouter" />
</UDropdownMenu>
</div>
<draggable
v-model="portfolio.pages"
item-key="id"
handle=".drag-handle"
:animation="150"
class="flex-1 space-y-1.5 overflow-y-auto p-2"
>
<template #item="{ element, index }">
<div
class="group flex cursor-pointer items-center gap-2 rounded-md border p-2 text-sm transition-colors"
:class="element.id === selectedPageId
? 'border-[var(--fl-ink)] bg-neutral-50 dark:border-white dark:bg-neutral-800'
: 'border-neutral-200 hover:bg-neutral-50 dark:border-neutral-700 dark:hover:bg-neutral-800'"
@click="selectPage(element.id)"
>
<UIcon name="i-lucide-grip-vertical" class="drag-handle size-4 shrink-0 cursor-grab text-neutral-400" />
<div class="min-w-0 flex-1">
<p class="truncate font-medium">{{ index + 1 }}. {{ pageLabel(element) }}</p>
<p class="text-xs text-neutral-500">{{ KIND_LABELS[element.kind as Page['kind']] }}</p>
</div>
<UButton
icon="i-lucide-x"
size="xs"
variant="ghost"
color="neutral"
class="opacity-0 group-hover:opacity-100"
aria-label="Supprimer la page"
@click.stop="removePage(element.id)"
/>
</div>
</template>
</draggable>
</aside>
<!-- Aperçu central -->
<main class="flex min-w-0 flex-1 flex-col overflow-hidden">
<div class="flex items-center gap-3 border-b border-neutral-200 bg-white px-4 py-2 dark:border-neutral-800 dark:bg-neutral-900">
<UInput
v-model="portfolio.name"
variant="none"
class="w-72 font-semibold"
placeholder="Nom du portfolio"
/>
<span class="text-xs text-neutral-400">{{ saveLabels[saveState] }}</span>
<div class="ml-auto flex items-center gap-2">
<UButton
v-if="pdfDownloadable"
icon="i-lucide-download"
label="Télécharger le PDF"
size="sm"
color="neutral"
variant="soft"
:href="pdfDownloadUrl"
target="_blank"
external
/>
<UButton
icon="i-lucide-share-2"
label="Partager un lien public"
size="sm"
color="neutral"
variant="outline"
@click="openShare"
/>
<UButton
icon="i-lucide-file-down"
label="Générer le PDF"
size="sm"
@click="openGenerate"
/>
</div>
</div>
<div ref="previewScroll" class="flex flex-1 flex-col items-center gap-6 overflow-auto scroll-smooth bg-neutral-200/70 p-8 dark:bg-neutral-950" @scroll="onPreviewScroll">
<div
v-for="(page, i) in portfolio.pages"
:id="`preview-page-${i + 1}`"
:key="page.id"
class="w-full max-w-[1180px] scroll-mt-8 cursor-pointer rounded-sm shadow-xl outline-1 outline-offset-4 transition-[outline-color]"
:class="page.id === selectedPageId ? 'outline-neutral-400 dark:outline-neutral-500' : 'outline-transparent'"
@click="selectPage(page.id, false)"
>
<PageViewport>
<PageRenderer
:portfolio="portfolio"
:page="page"
:index="i"
:projects="projects"
render-mode="preview"
@navigate="goToPage"
/>
</PageViewport>
</div>
<div v-if="!portfolio.pages.length" class="p-16 text-center text-neutral-500">Ajoutez une page pour commencer.</div>
</div>
</main>
<!-- Panneau d'édition -->
<aside class="w-[340px] shrink-0 overflow-y-auto border-l border-neutral-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900">
<template v-if="!selectedPage">
<p class="text-sm text-neutral-500">Sélectionnez une page.</p>
</template>
<!-- Couverture -->
<template v-else-if="selectedPage.kind === 'cover'">
<h3 class="mb-4 font-semibold">Couverture</h3>
<div class="space-y-4">
<UFormField label="Titre">
<UInput v-model="portfolio.cover.title" class="w-full" />
</UFormField>
<UFormField label="Lien du site (header public)">
<UInput v-model="portfolio.cover.siteUrl" class="w-full" :placeholder="DEFAULT_SITE_URL" />
</UFormField>
<UFormField label="Mail de contact (header public)">
<UInput v-model="portfolio.cover.contactEmail" class="w-full" :placeholder="DEFAULT_CONTACT_EMAIL" />
</UFormField>
<BandColorPicker v-model="selectedPage.bandColor" label="Couleur du filet" :default-color="FL_INK" />
<BackgroundEditor v-model="selectedPage.background" />
</div>
</template>
<!-- Sommaire -->
<template v-else-if="selectedPage.kind === 'toc'">
<h3 class="mb-4 font-semibold">Sommaire</h3>
<div class="space-y-4">
<UFormField label="Titre">
<UInput v-model="selectedPage.title" class="w-full" />
</UFormField>
<BackgroundEditor v-model="selectedPage.background" />
<p class="text-xs text-neutral-500">Les entrées sont générées automatiquement depuis les pages-projet.</p>
</div>
</template>
<!-- Page libre -->
<template v-else-if="selectedPage.kind === 'free'">
<h3 class="mb-4 font-semibold">Page libre</h3>
<div class="space-y-4">
<UFormField label="Titre">
<UInput v-model="selectedPage.title" class="w-full" />
</UFormField>
<UFormField label="Texte (Markdown)">
<UTextarea v-model="selectedPage.body" :rows="10" class="w-full" />
</UFormField>
<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"
@click="openMediaPicker('free')"
/>
</div>
</div>
<BandColorPicker v-model="selectedPage.bandColor" label="Couleur du filet" :default-color="FL_INK" />
<BackgroundEditor v-model="selectedPage.background" />
</div>
</template>
<!-- Page projet : couverture -->
<template v-else-if="selectedPage.kind === 'project'">
<h3 class="mb-1 font-semibold">{{ projects[selectedPage.projectId]?.title }}</h3>
<p class="mb-4 text-xs text-neutral-500">Page couverture du projet</p>
<div class="space-y-4">
<div v-if="projects[selectedPage.projectId]" class="space-y-2">
<p class="text-xs font-medium uppercase tracking-wide text-neutral-500">Image de couverture</p>
<div class="grid grid-cols-4 gap-1.5">
<button
v-for="f in projectImages(selectedPage.projectId)"
:key="f"
type="button"
class="relative aspect-square overflow-hidden rounded border transition-opacity"
:class="(selectedPage.coverImage || projects[selectedPage.projectId]!.coverImage) === f
? 'border-[var(--fl-ink)] dark:border-white'
: 'border-transparent opacity-45 hover:opacity-80'"
@click="selectedPage.coverImage = f"
>
<img :src="mediaUrl(selectedPage.projectId, f, 160)" class="size-full object-cover" :alt="f">
</button>
</div>
</div>
<div class="space-y-2">
<p class="text-xs font-medium uppercase tracking-wide text-neutral-500">Calage de l'image</p>
<div class="flex gap-1">
<UButton
v-for="opt in coverFitItems"
:key="opt.value"
:label="opt.label"
size="xs"
:variant="(selectedPage.coverFit ?? 'width') === opt.value ? 'solid' : 'outline'"
color="neutral"
@click="setCoverFit(opt.value)"
/>
</div>
<div v-if="(selectedPage.coverFit ?? 'width') === 'height'" class="flex gap-1 pt-1">
<UButton
v-for="opt in coverAlignItems"
:key="opt.value"
:icon="opt.icon"
size="xs"
:variant="(selectedPage.coverAlign ?? 'center') === opt.value ? 'solid' : 'outline'"
color="neutral"
:title="opt.title"
:aria-label="opt.title"
@click="setCoverAlign(opt.value)"
/>
</div>
</div>
<div class="space-y-2">
<p class="text-xs font-medium uppercase tracking-wide text-neutral-500">
Taille du titre — {{ titleScale }} %
</p>
<USlider v-model="titleScale" :min="50" :max="150" :step="5" />
</div>
<div class="space-y-2">
<p class="text-xs font-medium uppercase tracking-wide text-neutral-500">
Hauteur du bandeau — {{ bandHeight }} mm
</p>
<USlider v-model="bandHeight" :min="40" :max="160" :step="5" />
</div>
<BandColorPicker
v-model="selectedPage.bandColor"
label="Couleur du projet"
:default-color="CATEGORY_BAND[projects[selectedPage.projectId]?.category ?? '']"
/>
<div class="space-y-2">
<p class="text-xs font-medium uppercase tracking-wide text-neutral-500">Texte du bandeau</p>
<div class="flex gap-1">
<UButton
v-for="opt in inkItems"
:key="opt.value"
:label="opt.label"
size="xs"
:variant="(selectedPage.ink ?? 'auto') === opt.value ? 'solid' : 'outline'"
color="neutral"
@click="setInk(opt.value)"
/>
</div>
</div>
<UFormField label="Type de travail">
<UInput
v-model="selectedPage.subtitleOverride"
class="w-full"
:placeholder="projects[selectedPage.projectId]?.categories.join(', ')"
/>
</UFormField>
<UFormField label="Présentation">
<UTextarea
v-model="selectedPage.textOverride"
:rows="6"
class="w-full"
placeholder="Vide = texte du projet Grav"
/>
</UFormField>
<USeparator />
<UButton
icon="i-lucide-layout-grid"
variant="soft"
block
:label="`Ajouter une page de grille (${unusedImages(selectedPage.projectId).length} images restantes)`"
@click="addGridPage(selectedPage.projectId)"
/>
</div>
</template>
<!-- Page projet : grille -->
<template v-else-if="selectedPage.kind === 'project-grid'">
<h3 class="mb-1 font-semibold">{{ projects[selectedPage.projectId]?.title }}</h3>
<p class="mb-4 text-xs text-neutral-500">Page grille d'images</p>
<div class="space-y-4">
<div class="space-y-2">
<p class="text-xs font-medium uppercase tracking-wide text-neutral-500">Grille</p>
<div class="grid grid-cols-3 gap-1.5">
<button
v-for="fam in GRID_FAMILIES"
:key="fam.id"
type="button"
class="flex items-center justify-center rounded border p-1.5 transition-colors"
:class="gridFamily(selectedPage.grid).id === fam.id
? 'border-[var(--fl-ink)] bg-neutral-100 text-[var(--fl-ink)] dark:border-white dark:bg-neutral-800 dark:text-white'
: 'border-neutral-200 text-neutral-400 hover:border-neutral-400 hover:text-neutral-600 dark:border-neutral-700'"
:title="fam.label"
@click="selectedPage.grid = fam.id"
>
<GridIcon
:grid="fam.id"
:count="Math.max(selectedPage.media.length, 1)"
:orientation="orientationOf(selectedPage.projectId, selectedPage.media)"
:size="72"
/>
</button>
</div>
<p class="text-xs text-neutral-500">
{{ gridFamily(selectedPage.grid).label }} {{ selectedPage.media.length }} image{{ selectedPage.media.length > 1 ? 's' : '' }}
(max {{ gridFamily(selectedPage.grid).max }})
</p>
</div>
<MediaCuration
v-if="projects[selectedPage.projectId]"
v-model="selectedPage.media"
:project="projects[selectedPage.projectId]!"
:max="gridFamily(selectedPage.grid).max"
/>
<USwitch
:model-value="selectedPage.greyBg ?? false"
label="Fond gris clair"
@update:model-value="(v: boolean) => { if (selectedPage?.kind === 'project-grid') selectedPage.greyBg = v || undefined }"
/>
<USeparator />
<UButton
icon="i-lucide-layout-grid"
variant="soft"
block
:label="`Ajouter une page de grille (${unusedImages(selectedPage.projectId).length} images restantes)`"
@click="addGridPage(selectedPage.projectId)"
/>
</div>
</template>
</aside>
<ProjectPickerModal v-model:open="showProjectPicker" @pick="addProjectPage" />
<MediaPickerModal v-model:open="showMediaPicker" @pick="onMediaPicked" />
<UModal v-model:open="showGenerate" title="Générer le PDF">
<template #body>
<div class="space-y-4">
<UFormField label="Nom du fichier">
<UInput v-model="pdfFileName" class="w-full" placeholder="portfolio-figures-libres">
<template #trailing>
<span class="text-xs text-neutral-400">.pdf</span>
</template>
</UInput>
</UFormField>
<UAlert
v-if="frozenAt"
icon="i-lucide-snowflake"
color="info"
variant="subtle"
:title="`Contenu figé le ${frozenDateLabel}`"
description="La régénération produira le même PDF, même si Grav a changé depuis."
>
<template #actions>
<UButton size="xs" variant="soft" color="info" label="Réactualiser depuis Grav" @click="unfreeze" />
</template>
</UAlert>
<p v-else class="text-xs text-neutral-500">
Le contenu utilisé sera figé à la génération : le portfolio restera régénérable à l'identique.
</p>
<div v-if="lastResult" class="rounded-md border border-green-200 bg-green-50 p-3 text-sm dark:border-green-900 dark:bg-green-950">
<p class="font-medium">{{ lastResult.fileName }} — {{ lastResult.pageCount }} pages, {{ formatBytes(lastResult.sizeBytes) }}</p>
<UButton
:href="lastResult.downloadUrl"
external
icon="i-lucide-download"
label="Télécharger"
size="xs"
class="mt-2"
/>
</div>
<div class="flex justify-end gap-2">
<UButton variant="ghost" color="neutral" label="Fermer" @click="() => { showGenerate = false }" />
<UButton
:loading="generating"
:label="generating ? 'Génération…' : 'Générer'"
icon="i-lucide-file-down"
@click="generate"
/>
</div>
</div>
</template>
</UModal>
<UModal v-model:open="showShare" title="Partager un lien public">
<template #body>
<div class="space-y-4">
<template v-if="publicShare">
<UFormField label="Lien public">
<UInput :model-value="shareUrl" readonly class="w-full">
<template #trailing>
<UButton icon="i-lucide-copy" size="xs" variant="ghost" color="neutral" aria-label="Copier" @click="copyShareUrl" />
</template>
</UInput>
</UFormField>
<div class="flex flex-wrap items-center justify-between gap-2">
<UButton icon="i-lucide-external-link" :href="shareUrl" target="_blank" external variant="soft" color="neutral" size="sm" label="Ouvrir" />
<div class="flex gap-2">
<UButton :loading="sharing" icon="i-lucide-refresh-cw" variant="soft" size="sm" label="Republier" @click="enableShare" />
<UButton :loading="sharing" icon="i-lucide-link-2-off" color="error" variant="soft" size="sm" label="Couper le lien" @click="disableShare" />
</div>
</div>
</template>
<template v-else>
<UButton :loading="sharing" icon="i-lucide-share-2" block label="Activer le lien public" @click="enableShare" />
</template>
</div>
</template>
</UModal>
</div>
</template>

71
pages/print/[id].vue Normal file
View File

@@ -0,0 +1,71 @@
<script setup lang="ts">
// Route de rendu print : toutes les pages du portfolio empilées, imprimées
// par Playwright/Chromium (une page CSS = une page PDF, spec §10).
// Accès par jeton interne uniquement (?internal-token=…).
import type { Portfolio, Project } from '~~/types'
definePageMeta({ layout: false })
const route = useRoute()
const token = String(route.query['internal-token'] ?? '')
const headers = { 'x-internal-token': token }
const { data: portfolio } = await useFetch<Portfolio>(`/api/portfolios/${route.params.id}`, { headers })
if (!portfolio.value) {
throw createError({ statusCode: 404, statusMessage: 'Portfolio introuvable' })
}
// Contenu figé si présent (régénération à l'identique), live sinon
const frozen = portfolio.value.frozenContent
let projects: Record<string, Project>
if (frozen) {
projects = frozen.projects
} else {
const { data } = await useFetch<{ projects: Project[] }>('/api/projects', { headers })
projects = Object.fromEntries((data.value?.projects ?? []).map(p => [p.id, p]))
}
</script>
<template>
<div class="print-root">
<div v-for="(page, i) in portfolio!.pages" :id="`page-${i + 1}`" :key="page.id" class="print-page">
<PageRenderer
:portfolio="portfolio!"
:page="page"
:index="i"
:projects="projects"
render-mode="print"
:frozen="frozen ? portfolio!.id : undefined"
/>
</div>
</div>
</template>
<style>
/* Une page CSS = une page PDF — A3 paysage du gabarit :
1190,55 × 841,89 pt ≡ 1587,4 × 1122,5 px */
@page {
size: 1190.55pt 841.89pt;
margin: 0;
}
html,
body {
margin: 0;
padding: 0;
background: #ffffff;
}
.print-page {
width: 1587.4px;
height: 1122.5px;
overflow: hidden;
page-break-after: always;
break-after: page;
}
.print-page:last-child {
page-break-after: auto;
break-after: auto;
}
</style>

128
pages/view/[id].vue Normal file
View File

@@ -0,0 +1,128 @@
<script setup lang="ts">
// Vue publique d'un portfolio partagé (bouton « Partager un lien public »).
// Hors mot de passe (middleware auth exempte /view). Rendu identique au viewer
// (pages A3 mises à l'échelle) mais sans édition : header + sommaire repliable
// (masqué par défaut, suit la page active au scroll via usePageScrollSpy), et
// rien d'autre.
import type { Page, Portfolio, Project } from '~~/types'
definePageMeta({ layout: false })
const route = useRoute()
const id = String(route.params.id)
const { data } = await useFetch(`/api/public/${id}`)
if (!data.value) {
throw createError({ statusCode: 404, statusMessage: 'Portfolio introuvable' })
}
const portfolio = computed<Portfolio>(() => data.value!.portfolio)
const projects = computed<Record<string, Project>>(() => data.value!.projects)
// Les médias passent par la route publique dédiée (snapshot figé).
const mediaBase = `/api/public/${id}/media`
// ---- Header ----
const siteUrl = computed(() => portfolio.value.cover.siteUrl?.trim() || DEFAULT_SITE_URL)
const siteLabel = computed(() => siteUrl.value.replace(/^https?:\/\//, '').replace(/\/$/, ''))
const contactEmail = computed(() => portfolio.value.cover.contactEmail?.trim() || DEFAULT_CONTACT_EMAIL)
const dateLabel = computed(() => {
const iso = portfolio.value.frozenContent?.generatedAt ?? portfolio.value.lastExport?.at
return iso ? new Intl.DateTimeFormat('fr-FR', { dateStyle: 'long' }).format(new Date(iso)) : ''
})
useHead({ title: () => portfolio.value.cover.title || portfolio.value.name })
// ---- Sommaire repliable + page active (scroll-spy) ----
const showSommaire = ref(false)
const activeId = ref<string | undefined>(portfolio.value.pages[0]?.id)
const previewScroll = ref<HTMLElement>()
const { onScroll, scrollToId } = usePageScrollSpy({
container: previewScroll,
pages: computed(() => portfolio.value.pages),
active: activeId,
idPrefix: 'view-page',
})
function pageLabel(page: Page): string {
switch (page.kind) {
case 'cover': return portfolio.value.cover.title || 'Couverture'
case 'toc': return page.title || 'Sommaire'
case 'free': return page.title || 'Page libre'
case 'project':
case 'project-grid': return projects.value[page.projectId]?.title ?? page.projectId
}
}
</script>
<template>
<div class="flex h-screen flex-col bg-neutral-200/70 dark:bg-neutral-950">
<!-- Header public -->
<header class="flex items-center gap-3 border-b border-neutral-200 bg-white px-4 py-2.5 dark:border-neutral-800 dark:bg-neutral-900">
<UButton
:icon="showSommaire ? 'i-lucide-panel-left-close' : 'i-lucide-panel-left-open'"
color="neutral"
variant="ghost"
size="sm"
:aria-label="showSommaire ? 'Masquer le sommaire' : 'Afficher le sommaire'"
@click="showSommaire = !showSommaire"
/>
<div class="min-w-0">
<h1 class="truncate font-semibold leading-tight">{{ portfolio.cover.title || portfolio.name }}</h1>
<p v-if="dateLabel" class="text-xs text-neutral-500">{{ dateLabel }}</p>
</div>
<div class="ml-auto flex items-center gap-4 text-sm">
<a :href="siteUrl" target="_blank" rel="noopener" class="text-neutral-600 hover:underline dark:text-neutral-300">{{ siteLabel }}</a>
<a :href="`mailto:${contactEmail}`" class="text-neutral-600 hover:underline dark:text-neutral-300">{{ contactEmail }}</a>
</div>
</header>
<div class="flex min-h-0 flex-1">
<!-- Sommaire repliable (masqué par défaut) -->
<aside
v-if="showSommaire"
class="w-60 shrink-0 space-y-0.5 overflow-y-auto border-r border-neutral-200 bg-white p-2 dark:border-neutral-800 dark:bg-neutral-900"
>
<button
v-for="(page, i) in portfolio.pages"
:key="page.id"
type="button"
class="flex w-full items-center gap-2 rounded-md p-2 text-left text-sm transition-colors"
:class="page.id === activeId
? 'bg-neutral-100 font-medium dark:bg-neutral-800'
: 'hover:bg-neutral-50 dark:hover:bg-neutral-800/60'"
@click="scrollToId(page.id)"
>
<span class="w-5 shrink-0 text-right text-neutral-400">{{ i + 1 }}</span>
<span class="truncate">{{ pageLabel(page) }}</span>
</button>
</aside>
<!-- Pages empilées (sans liseré autour de la page active) -->
<main
ref="previewScroll"
class="flex min-w-0 flex-1 flex-col items-center gap-6 overflow-auto scroll-smooth p-8"
@scroll="onScroll"
>
<div
v-for="(page, i) in portfolio.pages"
:id="`view-page-${i + 1}`"
:key="page.id"
class="w-full max-w-[1180px] scroll-mt-8 rounded-sm shadow-xl"
>
<PageViewport>
<PageRenderer
:portfolio="portfolio"
:page="page"
:index="i"
:projects="projects"
render-mode="preview"
:media-base="mediaBase"
@navigate="(n: number) => scrollToId(portfolio.pages[n - 1]?.id ?? '')"
/>
</PageViewport>
</div>
</main>
</div>
</div>
</template>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,23 @@
// Captures du composeur : panneau page couverture projet et panneau grille.
import { chromium } from 'playwright'
const [, , id] = process.argv
const base = process.env.BASE_URL ?? 'http://localhost:3000'
const browser = await chromium.launch()
const page = await browser.newPage({ viewportSize: { width: 1600, height: 950 } })
await page.request.post(`${base}/api/auth/login`, {
data: { password: process.env.NUXT_PORTFOLIO_PASSWORD ?? 'figureslibres' },
})
await page.goto(`${base}/portfolios/${id}`, { waitUntil: 'networkidle' })
await page.getByText('Projet — couverture').first().click()
await page.waitForTimeout(1200)
await page.screenshot({ path: '/app/data/cache/composer-cover.png' })
await page.getByText('Projet — grille').first().click()
await page.waitForTimeout(1200)
await page.screenshot({ path: '/app/data/cache/composer-grid.png' })
console.log('ok')
await browser.close()

View File

@@ -0,0 +1,23 @@
// Capture chaque page d'un portfolio dans le composeur (auto-contrôle dev).
// Usage : node scripts/dev-pages-shots.mjs <portfolioId>
import { chromium } from 'playwright'
const [, , id] = process.argv
const base = process.env.BASE_URL ?? 'http://localhost:3000'
const browser = await chromium.launch()
const page = await browser.newPage({ viewportSize: { width: 1600, height: 900 } })
await page.request.post(`${base}/api/auth/login`, {
data: { password: process.env.NUXT_PORTFOLIO_PASSWORD ?? 'figureslibres' },
})
await page.goto(`${base}/portfolios/${id}`, { waitUntil: 'networkidle' })
const items = page.locator('aside .group')
const count = await items.count()
for (let i = 0; i < count; i++) {
await items.nth(i).click()
await page.waitForTimeout(1200)
await page.screenshot({ path: `/app/data/cache/page-${i + 1}.png` })
console.log(`page ${i + 1}/${count}`)
}
await browser.close()

View File

@@ -0,0 +1,24 @@
// Capture de la modale « Ajouter un projet » du composeur (auto-contrôle dev).
import { chromium } from 'playwright'
const [, , id, out = '/app/data/cache/picker.png'] = process.argv
const base = process.env.BASE_URL ?? 'http://localhost:3000'
const browser = await chromium.launch()
const page = await browser.newPage({ viewportSize: { width: 1440, height: 900 } })
await page.request.post(`${base}/api/auth/login`, {
data: { password: process.env.NUXT_PORTFOLIO_PASSWORD ?? 'figureslibres' },
})
await page.goto(`${base}/portfolios/${id}`, { waitUntil: 'networkidle' })
await page.getByRole('button', { name: 'Ajouter', exact: true }).click()
await page.getByRole('menuitem', { name: /Projet/ }).click()
await page.waitForTimeout(1500)
// Filtre « type de travail » si fourni en 3e argument
const filterLabel = process.argv[4]
if (filterLabel) {
await page.getByRole('button', { name: filterLabel, exact: true }).click()
await page.waitForTimeout(800)
}
await page.screenshot({ path: out })
await browser.close()
console.log(`screenshot: ${out}`)

50
scripts/dev-raw-pdf.mjs Normal file
View File

@@ -0,0 +1,50 @@
// Capture le PDF brut Chromium de /print/<id> (avant Ghostscript) pour diagnostic.
import { chromium } from 'playwright'
const [, , id, out = '/app/data/cache/raw-debug.pdf'] = process.argv
const token = process.env.NUXT_INTERNAL_TOKEN ?? 'dev-internal-token'
const browser = await chromium.launch()
const page = await browser.newPage({
viewportSize: { width: 1280, height: 720 },
extraHTTPHeaders: { 'x-internal-token': token },
})
page.on('console', m => console.log('[console]', m.type(), m.text()))
page.on('requestfailed', r => console.log('[failed]', r.url(), r.failure()?.errorText))
page.on('response', (r) => { if (!r.ok()) console.log('[http]', r.status(), r.url()) })
const url = `http://localhost:3000/print/${id}?internal-token=${encodeURIComponent(token)}`
const response = await page.goto(url, { waitUntil: 'networkidle', timeout: 120_000 })
console.log('goto:', response?.status())
await page.evaluate(async () => {
await Promise.all(Array.from(document.fonts).map(f => f.load().catch(() => {})))
await document.fonts.ready
const images = Array.from(document.querySelectorAll('img'))
await Promise.all(images.map(img => img.complete
? Promise.resolve()
: new Promise((resolve) => { img.onload = img.onerror = resolve })))
})
// État des fontes + présence de texte dans le DOM
const info = await page.evaluate(() => ({
fonts: Array.from(document.fonts).map(f => `${f.family} ${f.weight} ${f.style}: ${f.status}`),
h2: Array.from(document.querySelectorAll('h2')).map(el => el.textContent?.slice(0, 40)),
bodyTextLen: document.body.innerText.length,
}))
console.log(JSON.stringify(info, null, 2))
await page.emulateMedia({ media: 'print' })
// Bug Chromium : texte en webfonts absent du PDF sans réinvalidation des
// styles (cf. pdf-pipeline.ts)
await page.evaluate(async () => {
for (const sheet of document.styleSheets) sheet.disabled = true
void document.body.offsetHeight
for (const sheet of document.styleSheets) sheet.disabled = false
void document.body.offsetHeight
await document.fonts.ready
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))
})
await page.pdf({ path: out, printBackground: true, preferCSSPageSize: true, timeout: 120_000 })
console.log('raw pdf →', out)
await browser.close()

View File

@@ -0,0 +1,18 @@
// Capture d'écran de contrôle (dev) : node scripts/dev-screenshot.mjs <path> <out.png>
import { chromium } from 'playwright'
const [, , path = '/', out = '/app/data/cache/screenshot.png'] = process.argv
const base = process.env.BASE_URL ?? 'http://localhost:3000'
const browser = await chromium.launch()
const page = await browser.newPage({ viewportSize: { width: 1440, height: 900 } })
// Session : login via l'API puis navigation
await page.request.post(`${base}/api/auth/login`, {
data: { password: process.env.NUXT_PORTFOLIO_PASSWORD ?? 'figureslibres' },
})
await page.goto(`${base}${path}`, { waitUntil: 'networkidle' })
await page.waitForTimeout(1000)
await page.screenshot({ path: out, fullPage: false })
await browser.close()
console.log(`screenshot: ${out}`)

View File

@@ -0,0 +1,20 @@
// Test E2E du flux de génération PDF depuis le composeur (auto-contrôle dev).
import { chromium } from 'playwright'
const [, , id] = process.argv
const base = process.env.BASE_URL ?? 'http://localhost:3000'
const browser = await chromium.launch()
const page = await browser.newPage({ viewportSize: { width: 1600, height: 900 } })
await page.request.post(`${base}/api/auth/login`, {
data: { password: process.env.NUXT_PORTFOLIO_PASSWORD ?? 'figureslibres' },
})
await page.goto(`${base}/portfolios/${id}`, { waitUntil: 'networkidle' })
await page.getByRole('button', { name: 'Générer le PDF' }).click()
await page.waitForTimeout(500)
await page.getByRole('button', { name: 'Générer', exact: true }).click()
await page.getByRole('link', { name: 'Télécharger' }).waitFor({ timeout: 180_000 })
await page.screenshot({ path: '/app/data/cache/generate-modal.png' })
console.log('génération OK, lien de téléchargement affiché')
await browser.close()

View File

@@ -0,0 +1,11 @@
import { setSessionCookie } from '../../lib/session'
export default defineEventHandler(async (event) => {
const { password } = await readBody<{ password?: string }>(event)
const config = useRuntimeConfig(event)
if (!password || password !== config.portfolioPassword) {
throw createError({ statusCode: 401, statusMessage: 'Mot de passe incorrect' })
}
setSessionCookie(event)
return { authenticated: true }
})

View File

@@ -0,0 +1,6 @@
import { clearSessionCookie } from '../../lib/session'
export default defineEventHandler((event) => {
clearSessionCookie(event)
return { authenticated: false }
})

View File

@@ -0,0 +1,5 @@
import { isAuthenticated } from '../../lib/session'
export default defineEventHandler((event) => {
return { authenticated: isAuthenticated(event) }
})

View File

@@ -0,0 +1,22 @@
import { createReadStream } from 'node:fs'
import { stat } from 'node:fs/promises'
import { join, normalize } from 'node:path'
export default defineEventHandler(async (event) => {
const file = decodeURIComponent(getRouterParam(event, 'file') ?? '')
const exportsDir = join(useRuntimeConfig(event).dataDir, 'exports')
const abs = normalize(join(exportsDir, file))
if (!abs.startsWith(normalize(exportsDir) + '/') || !abs.endsWith('.pdf')) {
throw createError({ statusCode: 400, statusMessage: 'Fichier invalide' })
}
let s
try {
s = await stat(abs)
} catch {
throw createError({ statusCode: 404, statusMessage: 'Export introuvable' })
}
setHeader(event, 'Content-Type', 'application/pdf')
setHeader(event, 'Content-Length', s.size)
setHeader(event, 'Content-Disposition', `attachment; filename="${file.replace(/"/g, '')}"`)
return sendStream(event, createReadStream(abs))
})

View File

@@ -0,0 +1,28 @@
// Sert les médias du contenu Grav (et des snapshots figés).
// - `?w=480` : image redimensionnée (voir server/lib/media.ts).
// - `?poster=1` : première frame d'une vidéo.
// - `?frozen=<portfolioId>` : résout d'abord dans le snapshot figé (§7.3).
import { join } from 'node:path'
import { useContentSource } from '../../lib/content-source'
import { serveMedia, splitMediaPath } from '../../lib/media'
export default defineEventHandler(async (event) => {
const { projectId, filename } = splitMediaPath(getRouterParam(event, 'path') ?? '')
const query = getQuery(event)
const width = Number(query.w) || 0
const wantPoster = Boolean(query.poster)
const frozenId = typeof query.frozen === 'string' ? query.frozen : ''
const config = useRuntimeConfig(event)
const source = useContentSource()
// Racines de résolution : snapshot figé d'abord, contenu live ensuite
const roots: string[] = []
if (frozenId && /^[\w-]+$/.test(frozenId)) {
roots.push(join(config.dataDir, 'frozen', frozenId))
}
roots.push(source.rootDir)
return serveMedia(event, { roots, projectId, filename, width, wantPoster, dataDir: config.dataDir })
})

View File

@@ -0,0 +1,7 @@
import { duplicatePortfolio } from '../../../lib/portfolio-store'
export default defineEventHandler(async (event) => {
const copy = await duplicatePortfolio(getRouterParam(event, 'id')!)
if (!copy) throw createError({ statusCode: 404, statusMessage: 'Portfolio introuvable' })
return copy
})

View File

@@ -0,0 +1,23 @@
import { getPortfolio } from '../../../lib/portfolio-store'
import { generatePdf } from '../../../lib/pdf-pipeline'
// Génération séquentielle : une seule instance Chromium à la fois.
let queue: Promise<unknown> = Promise.resolve()
export default defineEventHandler(async (event) => {
const portfolio = await getPortfolio(getRouterParam(event, 'id')!)
if (!portfolio) throw createError({ statusCode: 404, statusMessage: 'Portfolio introuvable' })
const { fileName } = await readBody<{ fileName?: string }>(event).catch(() => ({ fileName: undefined }))
const run = queue.then(() => generatePdf(portfolio, fileName))
queue = run.catch(() => {})
try {
return await run
} catch (err) {
throw createError({
statusCode: 500,
statusMessage: `Échec de la génération : ${err instanceof Error ? err.message : String(err)}`,
})
}
})

View File

@@ -0,0 +1,6 @@
import { deletePortfolio } from '../../../lib/portfolio-store'
export default defineEventHandler(async (event) => {
await deletePortfolio(getRouterParam(event, 'id')!)
return { ok: true }
})

View File

@@ -0,0 +1,7 @@
import { getPortfolio } from '../../../lib/portfolio-store'
export default defineEventHandler(async (event) => {
const portfolio = await getPortfolio(getRouterParam(event, 'id')!)
if (!portfolio) throw createError({ statusCode: 404, statusMessage: 'Portfolio introuvable' })
return portfolio
})

View File

@@ -0,0 +1,21 @@
import type { Portfolio } from '~~/types'
import { getPortfolio, savePortfolio } from '../../../lib/portfolio-store'
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id')!
const existing = await getPortfolio(id)
if (!existing) throw createError({ statusCode: 404, statusMessage: 'Portfolio introuvable' })
const body = await readBody<Portfolio>(event)
if (!body || !Array.isArray(body.pages) || typeof body.name !== 'string') {
throw createError({ statusCode: 400, statusMessage: 'Portfolio invalide' })
}
// L'id et les dates de création restent ceux du serveur.
const portfolio: Portfolio = {
...existing,
...body,
id,
createdAt: existing.createdAt,
}
return await savePortfolio(portfolio)
})

View File

@@ -0,0 +1,30 @@
// Active / coupe le lien public d'un portfolio (bouton « Partager un lien public »).
// - body { enabled: false } → coupe le partage (publicShare = false), sans
// toucher au snapshot figé (réutilisé par le PDF).
// - sinon → (re)fige le contenu courant et active le partage. Re-cliquer
// « republie » : l'instantané reflète l'état courant du portfolio.
import { getPortfolio, savePortfolio } from '../../../lib/portfolio-store'
import { freezeContent, sanitizeFileName } from '../../../lib/pdf-pipeline'
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id') ?? ''
const portfolio = await getPortfolio(id)
if (!portfolio) {
throw createError({ statusCode: 404, statusMessage: 'Portfolio introuvable' })
}
const body = await readBody<{ enabled?: boolean }>(event).catch(() => ({}))
if (body?.enabled === false) {
portfolio.publicShare = false
// Renvoie le portfolio complet pour resynchroniser le client (évite qu'une
// sauvegarde auto ultérieure ne réécrase publicShare/frozenContent).
return await savePortfolio(portfolio)
}
// (Re)geler le contenu courant puis activer le partage
const fileName = portfolio.frozenContent?.fileName || sanitizeFileName(portfolio.name)
portfolio.frozenContent = await freezeContent(portfolio, fileName)
portfolio.publicShare = true
return await savePortfolio(portfolio)
})

View File

@@ -0,0 +1,16 @@
// « Réactualiser depuis Grav » (spec §7.3) : supprime le snapshot figé ;
// la prochaine génération repartira du contenu live.
import { rm } from 'node:fs/promises'
import { join } from 'node:path'
import { getPortfolio, savePortfolio } from '../../../lib/portfolio-store'
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id')!
const portfolio = await getPortfolio(id)
if (!portfolio) throw createError({ statusCode: 404, statusMessage: 'Portfolio introuvable' })
portfolio.frozenContent = undefined
await rm(join(useRuntimeConfig().dataDir, 'frozen', portfolio.id), { recursive: true, force: true })
await savePortfolio(portfolio)
return { ok: true }
})

View File

@@ -0,0 +1,5 @@
import { listPortfolios } from '../../lib/portfolio-store'
export default defineEventHandler(async () => {
return { portfolios: await listPortfolios() }
})

View File

@@ -0,0 +1,6 @@
import { createPortfolio } from '../../lib/portfolio-store'
export default defineEventHandler(async (event) => {
const { name } = await readBody<{ name?: string }>(event)
return await createPortfolio(name?.trim() ?? '')
})

View File

@@ -0,0 +1,10 @@
import { useContentSource } from '../../lib/content-source'
export default defineEventHandler(async (event) => {
const id = decodeURIComponent(getRouterParam(event, 'id') ?? '')
const project = await useContentSource().getProject(id)
if (!project) {
throw createError({ statusCode: 404, statusMessage: 'Projet introuvable' })
}
return project
})

View File

@@ -0,0 +1,7 @@
import { useContentSource } from '../../lib/content-source'
export default defineEventHandler(async () => {
const source = useContentSource()
const projects = await source.listProjects()
return { projects }
})

View File

@@ -0,0 +1,19 @@
// Données de la vue publique : le portfolio partagé + ses projets figés.
// Public (hors mot de passe), mais uniquement si publicShare est actif et
// qu'un instantané figé existe. On ne sert jamais le contenu live.
import { getPortfolio } from '../../lib/portfolio-store'
import type { Portfolio, Project } from '~~/types'
export default defineEventHandler(async (event): Promise<{ portfolio: Portfolio; projects: Record<string, Project> }> => {
const id = getRouterParam(event, 'id') ?? ''
if (!/^[\w-]+$/.test(id)) {
throw createError({ statusCode: 400, statusMessage: 'Identifiant invalide' })
}
const portfolio = await getPortfolio(id)
if (!portfolio?.publicShare || !portfolio.frozenContent) {
throw createError({ statusCode: 404, statusMessage: 'Portfolio introuvable' })
}
return { portfolio, projects: portfolio.frozenContent.projects }
})

View File

@@ -0,0 +1,28 @@
// Médias de la vue publique : sert exclusivement le snapshot figé du portfolio
// <id>, et seulement s'il est partagé publiquement (publicShare). Aucun repli
// sur le contenu live — la surface publique reste limitée à l'instantané.
import { join } from 'node:path'
import { getPortfolio } from '../../../../lib/portfolio-store'
import { serveMedia, splitMediaPath } from '../../../../lib/media'
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id') ?? ''
if (!/^[\w-]+$/.test(id)) {
throw createError({ statusCode: 400, statusMessage: 'Identifiant invalide' })
}
const portfolio = await getPortfolio(id)
if (!portfolio?.publicShare) {
throw createError({ statusCode: 404, statusMessage: 'Portfolio introuvable' })
}
const { projectId, filename } = splitMediaPath(getRouterParam(event, 'path') ?? '')
const query = getQuery(event)
const width = Number(query.w) || 0
const wantPoster = Boolean(query.poster)
const config = useRuntimeConfig(event)
const roots = [join(config.dataDir, 'frozen', id)]
return serveMedia(event, { roots, projectId, filename, width, wantPoster, dataDir: config.dataDir })
})

7
server/api/sync.post.ts Normal file
View File

@@ -0,0 +1,7 @@
// Bouton « Rafraîchir » (spec §6) : re-synchronise le contenu.
// En DEV : simple relecture du FS. En PROD (phase 3) : rsync/SSH.
import { useContentSource } from '../lib/content-source'
export default defineEventHandler(async () => {
return await useContentSource().refresh()
})

View File

@@ -0,0 +1,140 @@
// Abstraction ContentSource (spec §5) : DEV = FS local (grav-reference monté RO),
// PROD = cache local alimenté par rsync/SSH. Le reste de l'app ne voit que cette interface.
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import { mkdir } from 'node:fs/promises'
import { join, normalize } from 'node:path'
import type { Project } from '~~/types'
import { readAllProjects } from './grav-reader'
const execFileAsync = promisify(execFile)
export interface SyncResult {
ok: boolean
summary: string
syncedAt: string
}
export interface ContentSource {
/** Racine FS des projets (catégories/<slug>/…) */
readonly rootDir: string
listProjects(): Promise<Project[]>
getProject(id: string): Promise<Project | null>
/** Chemin absolu d'un média, borné à rootDir (anti path-traversal). */
resolveMediaPath(projectId: string, filename: string): string | null
/** Re-synchronise le contenu (no-op en DEV). */
refresh(): Promise<SyncResult>
/** Invalide le cache mémoire. */
invalidate(): void
}
const CACHE_TTL_MS = 30_000
abstract class FsContentSource implements ContentSource {
private cache: { at: number; projects: Project[] } | null = null
constructor(public readonly rootDir: string) {}
async listProjects(): Promise<Project[]> {
if (this.cache && Date.now() - this.cache.at < CACHE_TTL_MS) {
return this.cache.projects
}
const projects = await readAllProjects(this.rootDir)
this.cache = { at: Date.now(), projects }
return projects
}
async getProject(id: string): Promise<Project | null> {
const projects = await this.listProjects()
return projects.find(p => p.id === id) ?? null
}
resolveMediaPath(projectId: string, filename: string): string | null {
const abs = normalize(join(this.rootDir, projectId, filename))
if (!abs.startsWith(normalize(this.rootDir) + '/')) return null
return abs
}
invalidate(): void {
this.cache = null
}
abstract refresh(): Promise<SyncResult>
}
/** Mode DEV : lecture directe du Grav local monté en lecture seule. */
export class DevContentSource extends FsContentSource {
async refresh(): Promise<SyncResult> {
this.invalidate()
const projects = await this.listProjects()
return {
ok: true,
summary: `Mode dev : contenu local relu (${projects.length} projets).`,
syncedAt: new Date().toISOString(),
}
}
}
/**
* Mode PROD : cache local `data/content-cache/` alimenté par rsync/SSH
* depuis la prod (spec §6). Lecture seule côté prod, incrémental.
*/
export class ProdContentSource extends FsContentSource {
constructor(
rootDir: string,
private readonly remote: string, // ex. user@host:/chemin/user/pages/02.projets/
private readonly sshKey?: string,
) {
super(rootDir)
}
async refresh(): Promise<SyncResult> {
if (!this.remote) {
return {
ok: false,
summary: 'NUXT_SYNC_REMOTE non configuré : impossible de synchroniser.',
syncedAt: new Date().toISOString(),
}
}
await mkdir(this.rootDir, { recursive: true })
const ssh = ['ssh', '-o', 'StrictHostKeyChecking=accept-new', '-o', 'BatchMode=yes']
if (this.sshKey) ssh.push('-i', this.sshKey)
const started = Date.now()
try {
const { stdout } = await execFileAsync('rsync', [
'-az',
'--delete',
'--itemize-changes',
'-e', ssh.join(' '),
this.remote.endsWith('/') ? this.remote : `${this.remote}/`,
`${this.rootDir}/`,
], { timeout: 15 * 60_000 })
this.invalidate()
const changed = stdout.split('\n').filter(l => /^[<>ch.]\S+\s/.test(l)).length
const projects = await this.listProjects()
return {
ok: true,
summary: `${changed} fichier(s) mis à jour en ${Math.round((Date.now() - started) / 1000)} s — ${projects.length} projets.`,
syncedAt: new Date().toISOString(),
}
} catch (err) {
return {
ok: false,
summary: `Échec rsync : ${err instanceof Error ? err.message : String(err)}`,
syncedAt: new Date().toISOString(),
}
}
}
}
let source: ContentSource | null = null
export function useContentSource(): ContentSource {
if (source) return source
const config = useRuntimeConfig()
source = config.contentSource === 'prod'
? new ProdContentSource(join(config.dataDir, 'content-cache'), config.syncRemote, config.syncSshKey || undefined)
: new DevContentSource(config.contentDir)
return source
}

198
server/lib/grav-reader.ts Normal file
View File

@@ -0,0 +1,198 @@
// Parseur du contenu Grav (lecture seule) — spec §7.1
// Lit user/pages/02.projets/<categorie>/<projet>/reader.md + médias.
import { readdir, readFile, stat } from 'node:fs/promises'
import { join } from 'node:path'
import { parse as parseYaml } from 'yaml'
import { marked } from 'marked'
import sharp from 'sharp'
import type { MediaAsset, MediaVariant, Project } from '~~/types'
const IMAGE_EXT = /\.(png|jpe?g|webp|gif)$/i
const VIDEO_EXT = /\.(mp4|webm)$/i
const VARIANT_RE = /^(.*)@(\d+)x(\.[^.]+)$/i
/** "01.culturelle" -> "culturelle" */
function stripOrderPrefix(dirname: string): string {
return dirname.replace(/^\d+\./, '')
}
/** Date Grav "09-12-2024 22:19" (DD-MM-YYYY HH:mm) -> ISO */
function parseGravDate(raw: unknown): string | undefined {
if (!raw) return undefined
const s = String(raw).trim()
const m = s.match(/^(\d{2})-(\d{2})-(\d{4})(?:\s+(\d{2}):(\d{2}))?$/)
if (m) {
const [, dd, mm, yyyy, hh = '00', min = '00'] = m
return `${yyyy}-${mm}-${dd}T${hh}:${min}:00`
}
const d = new Date(s)
return Number.isNaN(d.getTime()) ? undefined : d.toISOString()
}
function toArray(v: unknown): string[] {
if (Array.isArray(v)) return v.map(String).filter(Boolean)
if (typeof v === 'string' && v.trim()) return [v.trim()]
return []
}
/** Détecte l'URL externe du projet (souvent en fin de corps). */
function detectExternalUrl(body: string): string | undefined {
const matches = body.match(/https?:\/\/[^\s)>\]"']+/g)
if (!matches?.length) return undefined
return matches[matches.length - 1].replace(/[.,;]$/, '')
}
interface ParsedMarkdown {
frontmatter: Record<string, unknown>
body: string
}
function splitFrontmatter(raw: string): ParsedMarkdown {
const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/)
if (!m) return { frontmatter: {}, body: raw }
let frontmatter: Record<string, unknown> = {}
try {
frontmatter = (parseYaml(m[1]) as Record<string, unknown>) ?? {}
} catch {
frontmatter = {}
}
return { frontmatter, body: m[2].trim() }
}
/**
* Regroupe les fichiers d'un dossier projet en MediaAsset :
* base.png + base@2x.png/@3x/@4x -> un asset avec variantes,
* ordonnés selon media_order puis alphabétique pour le reste.
*/
function collectMedia(files: string[], mediaOrder: string[]): MediaAsset[] {
const assets = new Map<string, MediaAsset>()
// Le nom canonique est celui sans suffixe @Nx (c'est lui que référencent
// media_order et aura.image). Certains projets n'ont qu'un @1x sans base.
for (const f of files) {
if (!IMAGE_EXT.test(f) && !VIDEO_EXT.test(f)) continue
const m = f.match(VARIANT_RE)
const canonical = m ? m[1] + m[3] : f
const label = (m ? `${m[2]}x` : '1x') as MediaVariant['label']
let asset = assets.get(canonical)
if (!asset) {
asset = {
filename: canonical,
type: VIDEO_EXT.test(canonical) ? 'video' : 'image',
variants: [],
}
assets.set(canonical, asset)
}
const existing = asset.variants.find(v => v.label === label)
if (!existing) {
asset.variants.push({ label, path: f })
} else if (!m) {
existing.path = f // le fichier de base l'emporte sur un doublon @1x
}
}
for (const a of assets.values()) {
a.variants.sort((x, y) => parseInt(x.label) - parseInt(y.label))
}
// Tri : media_order d'abord, puis le reste en alphabétique
const orderIndex = new Map(mediaOrder.map((name, i) => [name, i]))
return [...assets.values()].sort((a, b) => {
const ia = orderIndex.has(a.filename) ? orderIndex.get(a.filename)! : Infinity
const ib = orderIndex.has(b.filename) ? orderIndex.get(b.filename)! : Infinity
if (ia !== ib) return ia - ib
return a.filename.localeCompare(b.filename)
})
}
async function readProject(
contentDir: string,
categoryDir: string,
projectDir: string,
): Promise<Project | null> {
const dir = join(contentDir, categoryDir, projectDir)
let raw: string
try {
raw = await readFile(join(dir, 'reader.md'), 'utf8')
} catch {
return null // pas un dossier projet (pas de reader.md)
}
const { frontmatter, body } = splitFrontmatter(raw)
const files = (await readdir(dir)).filter(f => f !== 'reader.md')
const taxonomy = (frontmatter.taxonomy ?? {}) as Record<string, unknown>
const mediaOrder = String(frontmatter.media_order ?? '')
.split(',')
.map(s => s.trim())
.filter(Boolean)
const media = collectMedia(files, mediaOrder)
// Dimensions des images (en-tête seulement, rapide) : le ratio est le même
// pour toutes les variantes @Nx, la 1re suffit.
for (const asset of media) {
if (asset.type !== 'image') continue
try {
const meta = await sharp(join(dir, asset.variants[0].path)).metadata()
asset.width = meta.width
asset.height = meta.height
} catch {
// dimensions indisponibles : le choix de grille retombera sur « mixte »
}
}
// Image de couverture = celle de la grille du CMS : la PREMIÈRE image de
// media_order (cf. projet_card.html.twig : p.media.images|first), pas
// aura.image qui sert aux partages sociaux.
const coverImage = media.find(m => m.type === 'image')?.filename
return {
id: `${categoryDir}/${projectDir}`,
category: stripOrderPrefix(categoryDir),
title: String(frontmatter.title ?? stripOrderPrefix(projectDir)),
bodyMarkdown: body,
bodyHtml: await marked.parse(body),
coverImage,
media,
categories: toArray(taxonomy.category),
tags: toArray(taxonomy.tag),
date: parseGravDate(frontmatter.date),
externalUrl: detectExternalUrl(body),
published: frontmatter.published !== false,
}
}
/** Lit tous les projets du dossier 02.projets (3 catégories). */
export async function readAllProjects(contentDir: string): Promise<Project[]> {
const projects: Project[] = []
let categoryDirs: string[] = []
try {
categoryDirs = (await readdir(contentDir, { withFileTypes: true }))
.filter(e => e.isDirectory())
.map(e => e.name)
.sort()
} catch {
return []
}
for (const categoryDir of categoryDirs) {
const entries = await readdir(join(contentDir, categoryDir), { withFileTypes: true })
for (const entry of entries.filter(e => e.isDirectory()).sort((a, b) => a.name.localeCompare(b.name))) {
const project = await readProject(contentDir, categoryDir, entry.name)
if (project) projects.push(project)
}
}
// Tri global : plus récent d'abord (comme les listings Grav)
projects.sort((a, b) => (b.date ?? '').localeCompare(a.date ?? ''))
return projects
}
/** mtime le plus récent des reader.md — sert à invalider le cache mémoire. */
export async function contentFingerprint(contentDir: string): Promise<string> {
try {
const s = await stat(contentDir)
return String(s.mtimeMs)
} catch {
return '0'
}
}

186
server/lib/media.ts Normal file
View File

@@ -0,0 +1,186 @@
// Service de fichiers médias, partagé par la route authentifiée
// (/api/media/…) et la route publique (/api/public/<id>/media/…).
// - `width` > 0 : image redimensionnée via sharp (cache disque). La source est
// choisie parmi les variantes @1x…@4x : la plus petite qui couvre la largeur
// demandée (spec §10.1 : on descend, on n'agrandit pas).
// - `wantPoster` : première frame d'une vidéo (ffmpeg), servie comme image.
// - `roots` : racines de résolution testées dans l'ordre (snapshot figé d'abord,
// contenu live ensuite ; la route publique ne passe que le snapshot figé).
import { createHash } from 'node:crypto'
import { createReadStream } from 'node:fs'
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import { extname, join, normalize } from 'node:path'
import type { H3Event } from 'h3'
import sharp from 'sharp'
const execFileAsync = promisify(execFile)
const MIME: Record<string, string> = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.webp': 'image/webp',
'.gif': 'image/gif',
'.mp4': 'video/mp4',
'.webm': 'video/webm',
}
// Cache mémoire des largeurs d'images (clé : chemin + mtime)
const widthCache = new Map<string, number>()
async function imageWidth(abs: string, mtimeMs: number): Promise<number> {
const key = `${abs}:${mtimeMs}`
const cached = widthCache.get(key)
if (cached) return cached
const meta = await sharp(abs).metadata()
const w = meta.width ?? 0
widthCache.set(key, w)
return w
}
interface FoundFile {
abs: string
size: number
mtimeMs: number
}
async function findExisting(paths: string[]): Promise<FoundFile[]> {
const found: FoundFile[] = []
for (const abs of paths) {
try {
const s = await stat(abs)
found.push({ abs, size: s.size, mtimeMs: s.mtimeMs })
} catch {
// absent
}
}
return found
}
/** Découpe le chemin d'URL `projectId/.../filename` en (projectId, filename). */
export function splitMediaPath(rawPath: string): { projectId: string; filename: string } {
const segments = rawPath.split('/').map(decodeURIComponent).filter(Boolean)
if (segments.length < 2 || segments.some(s => s === '..')) {
throw createError({ statusCode: 400, statusMessage: 'Chemin invalide' })
}
return {
filename: segments[segments.length - 1],
projectId: segments.slice(0, -1).join('/'),
}
}
export interface ServeMediaOptions {
roots: string[] // racines de résolution, dans l'ordre de priorité
projectId: string
filename: string
width: number
wantPoster: boolean
dataDir: string
}
/** Résout, redimensionne/extrait puis renvoie un média. Logique commune. */
export async function serveMedia(event: H3Event, opts: ServeMediaOptions) {
const { roots, projectId, filename, width, wantPoster, dataDir } = opts
// Le nom canonique peut ne pas exister sur disque : variantes @Nx en repli
const ext = extname(filename).toLowerCase()
const variantNames = [
filename,
...['1', '2', '3', '4', '5', '6'].map(n => `${filename.slice(0, -ext.length)}@${n}x${ext}`),
]
let candidates: FoundFile[] = []
for (const root of roots) {
const paths = variantNames.map((name) => {
const abs = normalize(join(root, projectId, name))
if (!abs.startsWith(normalize(root) + '/')) {
throw createError({ statusCode: 400, statusMessage: 'Chemin invalide' })
}
return abs
})
candidates = await findExisting(paths)
if (candidates.length) break
}
if (!candidates.length) {
throw createError({ statusCode: 404, statusMessage: 'Média introuvable' })
}
const primary = candidates[0]
setHeader(event, 'Cache-Control', 'public, max-age=3600')
// ---- Poster de vidéo ----
if (wantPoster && MIME[ext]?.startsWith('video/')) {
const cacheDir = join(dataDir, 'cache', 'posters')
const key = createHash('sha1').update(`${primary.abs}:${primary.mtimeMs}`).digest('hex')
const posterPath = join(cacheDir, `${key}.jpg`)
try {
await stat(posterPath)
} catch {
await mkdir(cacheDir, { recursive: true })
try {
await execFileAsync('ffmpeg', [
'-y', '-ss', '1', '-i', primary.abs, '-frames:v', '1', '-q:v', '3', posterPath,
])
} catch {
// vidéos plus courtes qu'une seconde : première frame
await execFileAsync('ffmpeg', ['-y', '-i', primary.abs, '-frames:v', '1', '-q:v', '3', posterPath])
}
}
setHeader(event, 'Content-Type', 'image/jpeg')
if (width > 0) {
return await sharp(posterPath)
.resize({ width: Math.min(width, 3200), withoutEnlargement: true })
.jpeg({ quality: 82 })
.toBuffer()
}
return sendStream(event, createReadStream(posterPath))
}
// ---- Image redimensionnée ----
if (width > 0 && MIME[ext]?.startsWith('image/') && ext !== '.gif') {
const target = Math.min(width, 3200)
// Choix de la source : la plus petite variante couvrant la cible
let best = candidates[0]
let bestWidth = await imageWidth(best.abs, best.mtimeMs)
for (const c of candidates.slice(1)) {
const w = await imageWidth(c.abs, c.mtimeMs)
const covers = w >= target
const bestCovers = bestWidth >= target
if ((covers && (!bestCovers || w < bestWidth)) || (!covers && !bestCovers && w > bestWidth)) {
best = c
bestWidth = w
}
}
const cacheDir = join(dataDir, 'cache', 'thumbs')
const key = createHash('sha1').update(`${best.abs}:${target}:${best.mtimeMs}`).digest('hex')
const cachePath = join(cacheDir, `${key}.webp`)
try {
const cached = await readFile(cachePath)
setHeader(event, 'Content-Type', 'image/webp')
return cached
} catch {
// pas en cache : on génère
}
await mkdir(cacheDir, { recursive: true })
const resized = await sharp(best.abs)
.resize({ width: target, withoutEnlargement: true })
.webp({ quality: 80 })
.toBuffer()
await writeFile(cachePath, resized)
setHeader(event, 'Content-Type', 'image/webp')
return resized
}
// ---- Fichier original ----
setHeader(event, 'Content-Type', MIME[ext] ?? 'application/octet-stream')
setHeader(event, 'Content-Length', primary.size)
return sendStream(event, createReadStream(primary.abs))
}

198
server/lib/pdf-pipeline.ts Normal file
View File

@@ -0,0 +1,198 @@
// Pipeline de génération PDF (spec §10) :
// 1. gel du contenu (§7.3) : textes + médias utilisés copiés dans data/frozen/<id>/
// 2. rendu de la route /print/<id> par Playwright/Chromium (mêmes composants Vue)
// 3. page.pdf() au format 16:9 (960×540 pt ≡ 1280×720 px)
// 4. post-traitement Ghostscript : recompression + métadonnées
import { copyFile, mkdir, rename, rm, writeFile } from 'node:fs/promises'
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import { dirname, join } from 'node:path'
import { chromium } from 'playwright'
import type { FrozenSnapshot, Portfolio, Project } from '~~/types'
import { useContentSource } from './content-source'
import { savePortfolio } from './portfolio-store'
const execFileAsync = promisify(execFile)
export function sanitizeFileName(name: string): string {
const base = name
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.replace(/[^\w\s-]/g, '')
.trim()
.replace(/\s+/g, '-')
.toLowerCase()
return (base || 'portfolio') + '.pdf'
}
/** Fige le contenu utilisé : projets référencés + copie des fichiers médias. */
export async function freezeContent(portfolio: Portfolio, fileName: string): Promise<FrozenSnapshot> {
const config = useRuntimeConfig()
const source = useContentSource()
const frozenDir = join(config.dataDir, 'frozen', portfolio.id)
// Re-création à neuf du snapshot
await rm(frozenDir, { recursive: true, force: true })
await mkdir(frozenDir, { recursive: true })
const projectIds = new Set<string>()
for (const page of portfolio.pages) {
if (page.kind === 'project' || page.kind === 'project-grid') projectIds.add(page.projectId)
if (page.kind === 'free') {
for (const ref of page.media ?? []) projectIds.add(ref.slice(0, ref.lastIndexOf('/')))
}
}
if (portfolio.cover.backgroundImage) {
const ref = portfolio.cover.backgroundImage
projectIds.add(ref.slice(0, ref.lastIndexOf('/')))
}
const projects: Record<string, Project> = {}
for (const id of projectIds) {
const project = await source.getProject(id)
if (!project) continue
projects[id] = project
// Copie de toutes les variantes des médias du projet utilisés
for (const asset of project.media) {
for (const variant of asset.variants) {
const abs = source.resolveMediaPath(id, variant.path)
if (!abs) continue
const dest = join(frozenDir, id, variant.path)
await mkdir(dirname(dest), { recursive: true })
try {
await copyFile(abs, dest)
} catch {
// média manquant : le rendu retombera sur le contenu live
}
}
}
}
return { generatedAt: new Date().toISOString(), fileName, projects }
}
export interface GenerateResult {
fileName: string
downloadUrl: string
sizeBytes: number
pageCount: number
}
export async function generatePdf(portfolio: Portfolio, rawFileName?: string): Promise<GenerateResult> {
const config = useRuntimeConfig()
const fileName = sanitizeFileName(rawFileName || portfolio.frozenContent?.fileName?.replace(/\.pdf$/, '') || portfolio.name)
// Gel du contenu : réutilisé tel quel s'il existe (régénération à l'identique),
// sinon créé depuis le contenu live. « Réactualiser depuis Grav » le supprime.
if (!portfolio.frozenContent) {
portfolio.frozenContent = await freezeContent(portfolio, fileName)
} else {
portfolio.frozenContent.fileName = fileName
}
await savePortfolio(portfolio)
const exportsDir = join(config.dataDir, 'exports')
const workDir = join(config.dataDir, 'cache', 'pdf-work')
await mkdir(exportsDir, { recursive: true })
await mkdir(workDir, { recursive: true })
const rawPdf = join(workDir, `${portfolio.id}-raw.pdf`)
const finalPdf = join(workDir, `${portfolio.id}-final.pdf`)
// ---- Rendu Chromium ----
const browser = await chromium.launch()
try {
const page = await browser.newPage({
viewportSize: { width: 1588, height: 1123 }, // A3 paysage en px CSS
// Le jeton doit accompagner chaque sous-requête (médias, API), pas
// seulement la navigation initiale.
extraHTTPHeaders: { 'x-internal-token': config.internalToken },
})
const url = `http://localhost:3000/print/${portfolio.id}?internal-token=${encodeURIComponent(config.internalToken)}`
const response = await page.goto(url, { waitUntil: 'networkidle', timeout: 120_000 })
if (!response?.ok()) {
throw new Error(`Rendu print en échec (HTTP ${response?.status()})`)
}
// Attendre fonts + images (les médias pleine résolution peuvent être longs)
await page.evaluate(async () => {
// Chargement forcé de TOUTES les faces déclarées (fonts.ready ne couvre
// que celles déjà en cours de chargement)
await Promise.all(Array.from((document as any).fonts).map((f: any) => f.load().catch(() => {})))
await (document as any).fonts.ready
const images = Array.from(document.querySelectorAll('img'))
await Promise.all(images.map(img => img.complete
? Promise.resolve()
: new Promise((resolve) => { img.onload = img.onerror = resolve })))
})
await page.emulateMedia({ media: 'print' })
// Bug Chromium : le texte peint pendant la période « invisible » de
// font-display: block reste absent du PDF même une fois les fontes
// chargées. Invalider tous les styles + reflow force un repaint complet
// avec les glyphes réels.
await page.evaluate(async () => {
for (const sheet of document.styleSheets) sheet.disabled = true
void document.body.offsetHeight
for (const sheet of document.styleSheets) sheet.disabled = false
void document.body.offsetHeight
await (document as any).fonts.ready
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))
})
await page.pdf({
path: rawPdf,
printBackground: true,
preferCSSPageSize: true,
timeout: 120_000,
})
} finally {
await browser.close()
}
// ---- Post-traitement Ghostscript : recompression + métadonnées (§10.4) ----
const pdfmarks = join(workDir, `${portfolio.id}-marks.ps`)
const psEscape = (s: string) => s.replace(/[\\()]/g, c => `\\${c}`)
await writeFile(
pdfmarks,
`[ /Title (${psEscape(portfolio.cover.title || portfolio.name)})\n`
+ ` /Author (Figures Libres)\n`
+ ` /Creator (Générateur de portfolios Figures Libres)\n`
+ ` /DOCINFO pdfmark\n`,
'latin1',
)
await execFileAsync('gs', [
'-dBATCH', '-dNOPAUSE', '-dQUIET', '-dSAFER',
'-sDEVICE=pdfwrite',
'-dCompatibilityLevel=1.6',
'-dAutoRotatePages=/None',
'-sColorConversionStrategy=RGB',
'-dDownsampleColorImages=true',
'-dColorImageDownsampleType=/Bicubic',
'-dColorImageResolution=200',
'-dDownsampleGrayImages=true',
'-dGrayImageResolution=200',
'-dMonoImageResolution=300',
`-sOutputFile=${finalPdf}`,
rawPdf,
pdfmarks,
])
const exportPath = join(exportsDir, fileName)
await rename(finalPdf, exportPath)
await rm(rawPdf, { force: true })
await rm(pdfmarks, { force: true })
const { size } = await import('node:fs/promises').then(fs => fs.stat(exportPath))
// On aligne updatedAt sur lastExport.at (touch=false) : le PDF est ainsi
// marqué « à jour » tant que le portfolio n'est pas réédité (toute sauvegarde
// ultérieure retouche updatedAt et le rend périmé).
const exportedAt = new Date().toISOString()
portfolio.lastExport = { fileName, at: exportedAt }
portfolio.updatedAt = exportedAt
await savePortfolio(portfolio, false)
return {
fileName,
downloadUrl: `/api/exports/${encodeURIComponent(fileName)}`,
sizeBytes: size,
pageCount: portfolio.pages.length,
}
}

View File

@@ -0,0 +1,104 @@
// Bibliothèque de portfolios : un fichier JSON par portfolio (spec §13).
import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { nanoid } from 'nanoid'
import type { Portfolio, PortfolioSummary } from '~~/types'
function portfoliosDir(): string {
return join(useRuntimeConfig().dataDir, 'portfolios')
}
function pathFor(id: string): string {
if (!/^[\w-]+$/.test(id)) {
throw createError({ statusCode: 400, statusMessage: 'Identifiant invalide' })
}
return join(portfoliosDir(), `${id}.json`)
}
export async function listPortfolios(): Promise<PortfolioSummary[]> {
let files: string[] = []
try {
files = (await readdir(portfoliosDir())).filter(f => f.endsWith('.json'))
} catch {
return []
}
const summaries: PortfolioSummary[] = []
for (const f of files) {
try {
const p = JSON.parse(await readFile(join(portfoliosDir(), f), 'utf8')) as Portfolio
summaries.push({
id: p.id,
name: p.name,
pageCount: p.pages.length,
updatedAt: p.updatedAt,
createdAt: p.createdAt,
coverTitle: p.cover?.title ?? p.name,
hasPdf: Boolean(p.lastExport),
// « À jour » : la génération aligne lastExport.at sur updatedAt ; toute
// édition ultérieure retouche updatedAt (> lastExport.at) → périmé.
pdfCurrent: Boolean(p.lastExport && p.updatedAt <= p.lastExport.at),
exportFileName: p.lastExport?.fileName,
publicShare: Boolean(p.publicShare),
})
} catch {
// fichier corrompu : ignoré de la liste
}
}
summaries.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
return summaries
}
export async function getPortfolio(id: string): Promise<Portfolio | null> {
try {
return JSON.parse(await readFile(pathFor(id), 'utf8')) as Portfolio
} catch {
return null
}
}
export async function savePortfolio(portfolio: Portfolio, touch = true): Promise<Portfolio> {
// touch=false : conserve updatedAt tel quel (utilisé après génération pour
// aligner updatedAt sur lastExport.at et marquer le PDF « à jour »).
if (touch) portfolio.updatedAt = new Date().toISOString()
await mkdir(portfoliosDir(), { recursive: true })
await writeFile(pathFor(portfolio.id), JSON.stringify(portfolio, null, 2))
return portfolio
}
export async function createPortfolio(name: string): Promise<Portfolio> {
const now = new Date().toISOString()
const portfolio: Portfolio = {
id: nanoid(10),
name: name || 'Portfolio sans titre',
cover: {
title: name || 'Portfolio',
subtitle: '',
recipient: '',
background: { mode: 'white' },
},
pages: [{ kind: 'cover', id: nanoid(8), background: { mode: 'white' } }],
createdAt: now,
updatedAt: now,
}
return await savePortfolio(portfolio)
}
export async function duplicatePortfolio(id: string): Promise<Portfolio | null> {
const source = await getPortfolio(id)
if (!source) return null
const now = new Date().toISOString()
const copy: Portfolio = {
...source,
id: nanoid(10),
name: `${source.name} (copie)`,
createdAt: now,
updatedAt: now,
frozenContent: undefined,
lastExport: undefined,
}
return await savePortfolio(copy)
}
export async function deletePortfolio(id: string): Promise<void> {
await rm(pathFor(id), { force: true })
}

48
server/lib/session.ts Normal file
View File

@@ -0,0 +1,48 @@
// Session « mot de passe partagé » (spec §12) : cookie HMAC signé, sans stockage serveur.
import { createHmac, timingSafeEqual } from 'node:crypto'
import type { H3Event } from 'h3'
const COOKIE_NAME = 'fl_session'
const SESSION_TTL_MS = 1000 * 60 * 60 * 24 * 30 // 30 jours
function sign(secret: string, payload: string): string {
return createHmac('sha256', secret).update(payload).digest('base64url')
}
export function createSessionToken(secret: string): string {
const exp = String(Date.now() + SESSION_TTL_MS)
return `${exp}.${sign(secret, exp)}`
}
export function isValidSessionToken(secret: string, token: string | undefined): boolean {
if (!token) return false
const [exp, sig] = token.split('.')
if (!exp || !sig) return false
if (Number(exp) < Date.now()) return false
const expected = sign(secret, exp)
const a = Buffer.from(sig)
const b = Buffer.from(expected)
return a.length === b.length && timingSafeEqual(a, b)
}
export function setSessionCookie(event: H3Event): void {
const { sessionSecret } = useRuntimeConfig(event)
setCookie(event, COOKIE_NAME, createSessionToken(sessionSecret), {
httpOnly: true,
sameSite: 'lax',
path: '/',
maxAge: SESSION_TTL_MS / 1000,
})
}
export function clearSessionCookie(event: H3Event): void {
deleteCookie(event, COOKIE_NAME, { path: '/' })
}
export function isAuthenticated(event: H3Event): boolean {
const config = useRuntimeConfig(event)
// Jeton interne : utilisé par le rendu print (Playwright) en phase 2.
const headerToken = getHeader(event, 'x-internal-token') ?? getQuery(event)['internal-token']
if (headerToken && headerToken === config.internalToken) return true
return isValidSessionToken(config.sessionSecret, getCookie(event, COOKIE_NAME))
}

View File

@@ -0,0 +1,16 @@
// Protège toute l'API (sauf login/me) derrière la session mot de passe partagé.
import { isAuthenticated } from '../lib/session'
const PUBLIC_API = new Set(['/api/auth/login', '/api/auth/me'])
export default defineEventHandler((event) => {
const path = event.path.split('?')[0]
if (!path.startsWith('/api/')) return
if (PUBLIC_API.has(path)) return
// Vue publique : /api/public/* est ouvert (chaque endpoint vérifie lui-même
// que le portfolio ciblé a publicShare actif).
if (path.startsWith('/api/public/')) return
if (!isAuthenticated(event)) {
throw createError({ statusCode: 401, statusMessage: 'Authentification requise' })
}
})

3
tsconfig.json Normal file
View File

@@ -0,0 +1,3 @@
{
"extends": "./.nuxt/tsconfig.json"
}

157
types/index.ts Normal file
View File

@@ -0,0 +1,157 @@
// Modèle de données du générateur de portfolios (spec §7.2, gabarit_book_v3)
// --- Contenu importé de Grav (lecture seule) ---
export interface MediaVariant {
label: `${number}x`
path: string // chemin relatif au dossier du projet
width?: number
}
export interface MediaAsset {
filename: string
type: 'image' | 'video'
variants: MediaVariant[]
posterPath?: string // pour les vidéos : image d'aperçu
width?: number // dimensions de la 1re variante (images) — sert au choix
height?: number // automatique des grilles selon l'orientation
}
export interface Project {
id: string // ex: "01.culturelle/atlas-atterrissage"
category: string // "culturelle" | "publique" | "sociale"
title: string
bodyMarkdown: string
bodyHtml: string
coverImage?: string // 1re image de media_order (= grille du CMS)
media: MediaAsset[] // dans l'ordre de media_order
categories: string[] // taxonomy.category
tags: string[] // taxonomy.tag
date?: string // ISO
externalUrl?: string
published: boolean
}
// --- Composition (créée dans l'outil, sauvegardée) ---
export type Background =
| { mode: 'white' }
| { mode: 'tint'; color: string } // aplat clair
| { mode: 'color'; color: string } // pleine couleur
/** Encre du bandeau d'une page projet : auto = selon luminance du fond. */
export type InkMode = 'auto' | 'black' | 'white'
// Média résolu pour un template : URL prête à afficher (spec §9.2)
export interface ResolvedMedia {
filename: string
type: 'image' | 'video'
url: string
posterUrl?: string
ratio?: number // largeur / hauteur intrinsèque (sert au dimensionnement des bandeaux)
}
/**
* Page « couverture » d'un projet (gabarit p.1/3/5/7) : bandeau pleine
* largeur (titre, pastille catégorie, présentation) + image de couverture.
*/
export interface ProjectPage {
kind: 'project'
id: string // id de page (unique dans le portfolio)
projectId: string
coverImage?: string // défaut : 1re image du projet (grille CMS)
coverFit?: 'width' | 'height' // calage de l'image — défaut : largeur
coverAlign?: 'left' | 'center' | 'right' // calage horizontal (fit hauteur) — défaut : centrée
titleScale?: number // taille du titre en % du gabarit (défaut 100)
bandHeight?: number // hauteur du bandeau en mm = calage haut de l'image (défaut 55)
subtitleOverride?: string // ligne d'intro en gras — sinon taxonomy.category
textOverride?: string // sinon texte Grav
bandColor?: string // défaut : couleur de la catégorie (gabarit) — définit
// aussi la couleur des filets des pages grille du même projet
ink?: InkMode // défaut : auto
}
/**
* Page « grille » d'un projet (gabarit p.2/4/6/8) : filet couleur en haut,
* images entières calées sur une grille, fond blanc.
*/
export interface ProjectGridPage {
kind: 'project-grid'
id: string
projectId: string
grid: string // id de GridDef (composables/useGrids)
media: string[] // filenames choisis + ordonnés — images uniquement
greyBg?: boolean // fond gris clair (#f5f5f5) au lieu de blanc
// Pas de couleur de filet propre : il hérite du bandeau de la page
// couverture du même projet.
}
export interface FreePage {
kind: 'free'
id: string
template: 'free-text'
title?: string
body?: string // markdown saisi dans l'UI
media?: string[] // "projectId/filename" optionnels
background: Background
bandColor?: string // filet haut optionnel
}
export interface CoverPage {
kind: 'cover'
id: string
background: Background
bandColor?: string // filet couleur en bas — définit aussi le filet du sommaire
}
export interface TocPage {
kind: 'toc'
id: string
title?: string
background: Background
// Pas de couleur de filet propre : il hérite du filet de la couverture.
}
export type Page = ProjectPage | ProjectGridPage | FreePage | CoverPage | TocPage
export interface CoverConfig {
title: string
subtitle?: string
recipient?: string // « préparé pour … »
backgroundImage?: string // "projectId/filename"
background: Background // couleur du bandeau de couverture
siteUrl?: string // lien affiché dans le header de la vue publique (défaut DEFAULT_SITE_URL)
contactEmail?: string // mail de contact du header public (défaut DEFAULT_CONTACT_EMAIL)
}
// Contenu figé à la génération (spec §7.3)
export interface FrozenSnapshot {
generatedAt: string
fileName: string
projects: Record<string, Project> // projets tels qu'utilisés à la génération
}
export interface Portfolio {
id: string
name: string
cover: CoverConfig
pages: Page[]
createdAt: string
updatedAt: string
frozenContent?: FrozenSnapshot
lastExport?: { fileName: string; at: string }
publicShare?: boolean // lien public /view/<id> activé (opt-in, révocable)
}
export interface PortfolioSummary {
id: string
name: string
pageCount: number
updatedAt: string
createdAt: string
coverTitle: string
hasPdf: boolean
pdfCurrent: boolean // un PDF existe et reflète l'état courant (non modifié depuis)
exportFileName?: string // nom du dernier PDF généré (pour téléchargement direct)
publicShare: boolean // lien public /view/<id> actif
}