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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-05 18:37:09 +02:00
commit 59dfbe4155
79 changed files with 18587 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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