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:
9
.dockerignore
Normal file
9
.dockerignore
Normal 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
10
.gitignore
vendored
Normal 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
67
CLAUDE.md
Normal 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
21
Dockerfile
Normal 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
39
README.md
Normal 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
7
app.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<UApp :toaster="{ position: 'bottom-right' }">
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</UApp>
|
||||
</template>
|
||||
8
assets/css/main.css
Normal file
8
assets/css/main.css
Normal 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
137
assets/css/theme.css
Normal 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; }
|
||||
51
components/composer/BackgroundEditor.vue
Normal file
51
components/composer/BackgroundEditor.vue
Normal 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>
|
||||
44
components/composer/BandColorPicker.vue
Normal file
44
components/composer/BandColorPicker.vue
Normal 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>
|
||||
44
components/composer/GridIcon.vue
Normal file
44
components/composer/GridIcon.vue
Normal 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>
|
||||
69
components/composer/MediaCuration.vue
Normal file
69
components/composer/MediaCuration.vue
Normal 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>
|
||||
70
components/composer/MediaPickerModal.vue
Normal file
70
components/composer/MediaPickerModal.vue
Normal 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>
|
||||
130
components/composer/ProjectPickerModal.vue
Normal file
130
components/composer/ProjectPickerModal.vue
Normal 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>
|
||||
214
components/preview/PageRenderer.vue
Normal file
214
components/preview/PageRenderer.vue
Normal 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>
|
||||
23
components/preview/PageViewport.vue
Normal file
23
components/preview/PageViewport.vue
Normal 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>
|
||||
59
components/templates/CoverTemplate.vue
Normal file
59
components/templates/CoverTemplate.vue
Normal 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 Libres</span>
|
||||
est un collectif de 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 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>
|
||||
39
components/templates/FreeTextPage.vue
Normal file
39
components/templates/FreeTextPage.vue
Normal 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>
|
||||
95
components/templates/ProjectCoverPage.vue
Normal file
95
components/templates/ProjectCoverPage.vue
Normal 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>
|
||||
134
components/templates/ProjectGridPage.vue
Normal file
134
components/templates/ProjectGridPage.vue
Normal 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>
|
||||
77
components/templates/TocTemplate.vue
Normal file
77
components/templates/TocTemplate.vue
Normal 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
35
composables/useFormat.ts
Normal 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
219
composables/useGrids.ts
Normal 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),
|
||||
}))
|
||||
}
|
||||
69
composables/usePageScrollSpy.ts
Normal file
69
composables/usePageScrollSpy.ts
Normal 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
97
composables/useTheme.ts
Normal 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
66
layouts/default.vue
Normal 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
12
middleware/auth.global.ts
Normal 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
42
nuxt.config.ts
Normal 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
13987
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
package.json
Normal file
23
package.json
Normal 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
146
pages/index.vue
Normal 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
46
pages/login.vue
Normal 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
827
pages/portfolios/[id].vue
Normal 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 l’activation 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
71
pages/print/[id].vue
Normal 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
128
pages/view/[id].vue
Normal 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>
|
||||
BIN
public/fonts/Avara-bold-italic.woff2
Normal file
BIN
public/fonts/Avara-bold-italic.woff2
Normal file
Binary file not shown.
BIN
public/fonts/Lato-bold-italic.woff2
Normal file
BIN
public/fonts/Lato-bold-italic.woff2
Normal file
Binary file not shown.
BIN
public/fonts/Lato-bold.woff2
Normal file
BIN
public/fonts/Lato-bold.woff2
Normal file
Binary file not shown.
BIN
public/fonts/Lato-italic.woff2
Normal file
BIN
public/fonts/Lato-italic.woff2
Normal file
Binary file not shown.
BIN
public/fonts/Lato-regular.woff2
Normal file
BIN
public/fonts/Lato-regular.woff2
Normal file
Binary file not shown.
BIN
public/fonts/ManifontGrotesk-bold-italic.woff2
Normal file
BIN
public/fonts/ManifontGrotesk-bold-italic.woff2
Normal file
Binary file not shown.
BIN
public/fonts/Moche-bold.woff2
Normal file
BIN
public/fonts/Moche-bold.woff2
Normal file
Binary file not shown.
BIN
public/fonts/PlayfairDisplay-italic.woff2
Normal file
BIN
public/fonts/PlayfairDisplay-italic.woff2
Normal file
Binary file not shown.
BIN
public/fonts/Syne-bold.woff2
Normal file
BIN
public/fonts/Syne-bold.woff2
Normal file
Binary file not shown.
BIN
public/fonts/Syne-regular.woff2
Normal file
BIN
public/fonts/Syne-regular.woff2
Normal file
Binary file not shown.
23
scripts/dev-composer-shots.mjs
Normal file
23
scripts/dev-composer-shots.mjs
Normal 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()
|
||||
23
scripts/dev-pages-shots.mjs
Normal file
23
scripts/dev-pages-shots.mjs
Normal 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()
|
||||
24
scripts/dev-picker-shot.mjs
Normal file
24
scripts/dev-picker-shot.mjs
Normal 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
50
scripts/dev-raw-pdf.mjs
Normal 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()
|
||||
18
scripts/dev-screenshot.mjs
Normal file
18
scripts/dev-screenshot.mjs
Normal 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}`)
|
||||
20
scripts/dev-test-generate.mjs
Normal file
20
scripts/dev-test-generate.mjs
Normal 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()
|
||||
11
server/api/auth/login.post.ts
Normal file
11
server/api/auth/login.post.ts
Normal 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 }
|
||||
})
|
||||
6
server/api/auth/logout.post.ts
Normal file
6
server/api/auth/logout.post.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clearSessionCookie } from '../../lib/session'
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
clearSessionCookie(event)
|
||||
return { authenticated: false }
|
||||
})
|
||||
5
server/api/auth/me.get.ts
Normal file
5
server/api/auth/me.get.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { isAuthenticated } from '../../lib/session'
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
return { authenticated: isAuthenticated(event) }
|
||||
})
|
||||
22
server/api/exports/[file].get.ts
Normal file
22
server/api/exports/[file].get.ts
Normal 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))
|
||||
})
|
||||
28
server/api/media/[...path].get.ts
Normal file
28
server/api/media/[...path].get.ts
Normal 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 })
|
||||
})
|
||||
7
server/api/portfolios/[id]/duplicate.post.ts
Normal file
7
server/api/portfolios/[id]/duplicate.post.ts
Normal 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
|
||||
})
|
||||
23
server/api/portfolios/[id]/generate.post.ts
Normal file
23
server/api/portfolios/[id]/generate.post.ts
Normal 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)}`,
|
||||
})
|
||||
}
|
||||
})
|
||||
6
server/api/portfolios/[id]/index.delete.ts
Normal file
6
server/api/portfolios/[id]/index.delete.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { deletePortfolio } from '../../../lib/portfolio-store'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await deletePortfolio(getRouterParam(event, 'id')!)
|
||||
return { ok: true }
|
||||
})
|
||||
7
server/api/portfolios/[id]/index.get.ts
Normal file
7
server/api/portfolios/[id]/index.get.ts
Normal 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
|
||||
})
|
||||
21
server/api/portfolios/[id]/index.put.ts
Normal file
21
server/api/portfolios/[id]/index.put.ts
Normal 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)
|
||||
})
|
||||
30
server/api/portfolios/[id]/share.post.ts
Normal file
30
server/api/portfolios/[id]/share.post.ts
Normal 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)
|
||||
})
|
||||
16
server/api/portfolios/[id]/unfreeze.post.ts
Normal file
16
server/api/portfolios/[id]/unfreeze.post.ts
Normal 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 }
|
||||
})
|
||||
5
server/api/portfolios/index.get.ts
Normal file
5
server/api/portfolios/index.get.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { listPortfolios } from '../../lib/portfolio-store'
|
||||
|
||||
export default defineEventHandler(async () => {
|
||||
return { portfolios: await listPortfolios() }
|
||||
})
|
||||
6
server/api/portfolios/index.post.ts
Normal file
6
server/api/portfolios/index.post.ts
Normal 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() ?? '')
|
||||
})
|
||||
10
server/api/projects/[...id].get.ts
Normal file
10
server/api/projects/[...id].get.ts
Normal 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
|
||||
})
|
||||
7
server/api/projects/index.get.ts
Normal file
7
server/api/projects/index.get.ts
Normal 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 }
|
||||
})
|
||||
19
server/api/public/[id].get.ts
Normal file
19
server/api/public/[id].get.ts
Normal 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 }
|
||||
})
|
||||
28
server/api/public/[id]/media/[...path].get.ts
Normal file
28
server/api/public/[id]/media/[...path].get.ts
Normal 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
7
server/api/sync.post.ts
Normal 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()
|
||||
})
|
||||
140
server/lib/content-source.ts
Normal file
140
server/lib/content-source.ts
Normal 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
198
server/lib/grav-reader.ts
Normal 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
186
server/lib/media.ts
Normal 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
198
server/lib/pdf-pipeline.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
104
server/lib/portfolio-store.ts
Normal file
104
server/lib/portfolio-store.ts
Normal 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
48
server/lib/session.ts
Normal 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))
|
||||
}
|
||||
16
server/middleware/01.auth.ts
Normal file
16
server/middleware/01.auth.ts
Normal 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
3
tsconfig.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "./.nuxt/tsconfig.json"
|
||||
}
|
||||
157
types/index.ts
Normal file
157
types/index.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user