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:
@@ -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
@@ -0,0 +1,10 @@
|
||||
node_modules
|
||||
.nuxt
|
||||
.output
|
||||
.env
|
||||
data/content-cache
|
||||
data/exports
|
||||
data/cache
|
||||
data/portfolios
|
||||
data/frozen
|
||||
*.log
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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).
|
||||
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<UApp :toaster="{ position: 'bottom-right' }">
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</UApp>
|
||||
</template>
|
||||
@@ -0,0 +1,8 @@
|
||||
@import "tailwindcss";
|
||||
@import "@nuxt/ui";
|
||||
|
||||
:root {
|
||||
/* Charte Figures Libres (thème Grav figureslibres-v2) */
|
||||
--fl-base: #f5f5f5;
|
||||
--fl-ink: #0e1229;
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
}))
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
@@ -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: {},
|
||||
},
|
||||
})
|
||||
Generated
+13987
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,128 @@
|
||||
<script setup lang="ts">
|
||||
// Vue publique d'un portfolio partagé (bouton « Partager un lien public »).
|
||||
// Hors mot de passe (middleware auth exempte /view). Rendu identique au viewer
|
||||
// (pages A3 mises à l'échelle) mais sans édition : header + sommaire repliable
|
||||
// (masqué par défaut, suit la page active au scroll via usePageScrollSpy), et
|
||||
// rien d'autre.
|
||||
import type { Page, Portfolio, Project } from '~~/types'
|
||||
|
||||
definePageMeta({ layout: false })
|
||||
|
||||
const route = useRoute()
|
||||
const id = String(route.params.id)
|
||||
|
||||
const { data } = await useFetch(`/api/public/${id}`)
|
||||
if (!data.value) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Portfolio introuvable' })
|
||||
}
|
||||
const portfolio = computed<Portfolio>(() => data.value!.portfolio)
|
||||
const projects = computed<Record<string, Project>>(() => data.value!.projects)
|
||||
|
||||
// Les médias passent par la route publique dédiée (snapshot figé).
|
||||
const mediaBase = `/api/public/${id}/media`
|
||||
|
||||
// ---- Header ----
|
||||
const siteUrl = computed(() => portfolio.value.cover.siteUrl?.trim() || DEFAULT_SITE_URL)
|
||||
const siteLabel = computed(() => siteUrl.value.replace(/^https?:\/\//, '').replace(/\/$/, ''))
|
||||
const contactEmail = computed(() => portfolio.value.cover.contactEmail?.trim() || DEFAULT_CONTACT_EMAIL)
|
||||
const dateLabel = computed(() => {
|
||||
const iso = portfolio.value.frozenContent?.generatedAt ?? portfolio.value.lastExport?.at
|
||||
return iso ? new Intl.DateTimeFormat('fr-FR', { dateStyle: 'long' }).format(new Date(iso)) : ''
|
||||
})
|
||||
|
||||
useHead({ title: () => portfolio.value.cover.title || portfolio.value.name })
|
||||
|
||||
// ---- Sommaire repliable + page active (scroll-spy) ----
|
||||
const showSommaire = ref(false)
|
||||
const activeId = ref<string | undefined>(portfolio.value.pages[0]?.id)
|
||||
const previewScroll = ref<HTMLElement>()
|
||||
|
||||
const { onScroll, scrollToId } = usePageScrollSpy({
|
||||
container: previewScroll,
|
||||
pages: computed(() => portfolio.value.pages),
|
||||
active: activeId,
|
||||
idPrefix: 'view-page',
|
||||
})
|
||||
|
||||
function pageLabel(page: Page): string {
|
||||
switch (page.kind) {
|
||||
case 'cover': return portfolio.value.cover.title || 'Couverture'
|
||||
case 'toc': return page.title || 'Sommaire'
|
||||
case 'free': return page.title || 'Page libre'
|
||||
case 'project':
|
||||
case 'project-grid': return projects.value[page.projectId]?.title ?? page.projectId
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-screen flex-col bg-neutral-200/70 dark:bg-neutral-950">
|
||||
<!-- Header public -->
|
||||
<header class="flex items-center gap-3 border-b border-neutral-200 bg-white px-4 py-2.5 dark:border-neutral-800 dark:bg-neutral-900">
|
||||
<UButton
|
||||
:icon="showSommaire ? 'i-lucide-panel-left-close' : 'i-lucide-panel-left-open'"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:aria-label="showSommaire ? 'Masquer le sommaire' : 'Afficher le sommaire'"
|
||||
@click="showSommaire = !showSommaire"
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<h1 class="truncate font-semibold leading-tight">{{ portfolio.cover.title || portfolio.name }}</h1>
|
||||
<p v-if="dateLabel" class="text-xs text-neutral-500">{{ dateLabel }}</p>
|
||||
</div>
|
||||
<div class="ml-auto flex items-center gap-4 text-sm">
|
||||
<a :href="siteUrl" target="_blank" rel="noopener" class="text-neutral-600 hover:underline dark:text-neutral-300">{{ siteLabel }}</a>
|
||||
<a :href="`mailto:${contactEmail}`" class="text-neutral-600 hover:underline dark:text-neutral-300">{{ contactEmail }}</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="flex min-h-0 flex-1">
|
||||
<!-- Sommaire repliable (masqué par défaut) -->
|
||||
<aside
|
||||
v-if="showSommaire"
|
||||
class="w-60 shrink-0 space-y-0.5 overflow-y-auto border-r border-neutral-200 bg-white p-2 dark:border-neutral-800 dark:bg-neutral-900"
|
||||
>
|
||||
<button
|
||||
v-for="(page, i) in portfolio.pages"
|
||||
:key="page.id"
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 rounded-md p-2 text-left text-sm transition-colors"
|
||||
:class="page.id === activeId
|
||||
? 'bg-neutral-100 font-medium dark:bg-neutral-800'
|
||||
: 'hover:bg-neutral-50 dark:hover:bg-neutral-800/60'"
|
||||
@click="scrollToId(page.id)"
|
||||
>
|
||||
<span class="w-5 shrink-0 text-right text-neutral-400">{{ i + 1 }}</span>
|
||||
<span class="truncate">{{ pageLabel(page) }}</span>
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<!-- Pages empilées (sans liseré autour de la page active) -->
|
||||
<main
|
||||
ref="previewScroll"
|
||||
class="flex min-w-0 flex-1 flex-col items-center gap-6 overflow-auto scroll-smooth p-8"
|
||||
@scroll="onScroll"
|
||||
>
|
||||
<div
|
||||
v-for="(page, i) in portfolio.pages"
|
||||
:id="`view-page-${i + 1}`"
|
||||
:key="page.id"
|
||||
class="w-full max-w-[1180px] scroll-mt-8 rounded-sm shadow-xl"
|
||||
>
|
||||
<PageViewport>
|
||||
<PageRenderer
|
||||
:portfolio="portfolio"
|
||||
:page="page"
|
||||
:index="i"
|
||||
:projects="projects"
|
||||
render-mode="preview"
|
||||
:media-base="mediaBase"
|
||||
@navigate="(n: number) => scrollToId(portfolio.pages[n - 1]?.id ?? '')"
|
||||
/>
|
||||
</PageViewport>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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}`)
|
||||
@@ -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()
|
||||
@@ -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}`)
|
||||
@@ -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()
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clearSessionCookie } from '../../lib/session'
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
clearSessionCookie(event)
|
||||
return { authenticated: false }
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
import { isAuthenticated } from '../../lib/session'
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
return { authenticated: isAuthenticated(event) }
|
||||
})
|
||||
@@ -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))
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
@@ -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)}`,
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,6 @@
|
||||
import { deletePortfolio } from '../../../lib/portfolio-store'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await deletePortfolio(getRouterParam(event, 'id')!)
|
||||
return { ok: true }
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
import { listPortfolios } from '../../lib/portfolio-store'
|
||||
|
||||
export default defineEventHandler(async () => {
|
||||
return { portfolios: await listPortfolios() }
|
||||
})
|
||||
@@ -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() ?? '')
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
import { useContentSource } from '../../lib/content-source'
|
||||
|
||||
export default defineEventHandler(async () => {
|
||||
const source = useContentSource()
|
||||
const projects = await source.listProjects()
|
||||
return { projects }
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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' })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "./.nuxt/tsconfig.json"
|
||||
}
|
||||
+157
@@ -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