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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-05 18:37:09 +02:00
co-authored by Claude Opus 4.8
commit 59dfbe4155
79 changed files with 18587 additions and 0 deletions
@@ -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 }
})
+7
View File
@@ -0,0 +1,7 @@
import { getPortfolio } from '../../../lib/portfolio-store'
export default defineEventHandler(async (event) => {
const portfolio = await getPortfolio(getRouterParam(event, 'id')!)
if (!portfolio) throw createError({ statusCode: 404, statusMessage: 'Portfolio introuvable' })
return portfolio
})
+21
View File
@@ -0,0 +1,21 @@
import type { Portfolio } from '~~/types'
import { getPortfolio, savePortfolio } from '../../../lib/portfolio-store'
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id')!
const existing = await getPortfolio(id)
if (!existing) throw createError({ statusCode: 404, statusMessage: 'Portfolio introuvable' })
const body = await readBody<Portfolio>(event)
if (!body || !Array.isArray(body.pages) || typeof body.name !== 'string') {
throw createError({ statusCode: 400, statusMessage: 'Portfolio invalide' })
}
// L'id et les dates de création restent ceux du serveur.
const portfolio: Portfolio = {
...existing,
...body,
id,
createdAt: existing.createdAt,
}
return await savePortfolio(portfolio)
})
+30
View File
@@ -0,0 +1,30 @@
// Active / coupe le lien public d'un portfolio (bouton « Partager un lien public »).
// - body { enabled: false } → coupe le partage (publicShare = false), sans
// toucher au snapshot figé (réutilisé par le PDF).
// - sinon → (re)fige le contenu courant et active le partage. Re-cliquer
// « republie » : l'instantané reflète l'état courant du portfolio.
import { getPortfolio, savePortfolio } from '../../../lib/portfolio-store'
import { freezeContent, sanitizeFileName } from '../../../lib/pdf-pipeline'
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id') ?? ''
const portfolio = await getPortfolio(id)
if (!portfolio) {
throw createError({ statusCode: 404, statusMessage: 'Portfolio introuvable' })
}
const body = await readBody<{ enabled?: boolean }>(event).catch(() => ({}))
if (body?.enabled === false) {
portfolio.publicShare = false
// Renvoie le portfolio complet pour resynchroniser le client (évite qu'une
// sauvegarde auto ultérieure ne réécrase publicShare/frozenContent).
return await savePortfolio(portfolio)
}
// (Re)geler le contenu courant puis activer le partage
const fileName = portfolio.frozenContent?.fileName || sanitizeFileName(portfolio.name)
portfolio.frozenContent = await freezeContent(portfolio, fileName)
portfolio.publicShare = true
return await savePortfolio(portfolio)
})
@@ -0,0 +1,16 @@
// « Réactualiser depuis Grav » (spec §7.3) : supprime le snapshot figé ;
// la prochaine génération repartira du contenu live.
import { rm } from 'node:fs/promises'
import { join } from 'node:path'
import { getPortfolio, savePortfolio } from '../../../lib/portfolio-store'
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id')!
const portfolio = await getPortfolio(id)
if (!portfolio) throw createError({ statusCode: 404, statusMessage: 'Portfolio introuvable' })
portfolio.frozenContent = undefined
await rm(join(useRuntimeConfig().dataDir, 'frozen', portfolio.id), { recursive: true, force: true })
await savePortfolio(portfolio)
return { ok: true }
})
+5
View File
@@ -0,0 +1,5 @@
import { listPortfolios } from '../../lib/portfolio-store'
export default defineEventHandler(async () => {
return { portfolios: await listPortfolios() }
})
+6
View File
@@ -0,0 +1,6 @@
import { createPortfolio } from '../../lib/portfolio-store'
export default defineEventHandler(async (event) => {
const { name } = await readBody<{ name?: string }>(event)
return await createPortfolio(name?.trim() ?? '')
})