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:
11
server/api/auth/login.post.ts
Normal file
11
server/api/auth/login.post.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { setSessionCookie } from '../../lib/session'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const { password } = await readBody<{ password?: string }>(event)
|
||||
const config = useRuntimeConfig(event)
|
||||
if (!password || password !== config.portfolioPassword) {
|
||||
throw createError({ statusCode: 401, statusMessage: 'Mot de passe incorrect' })
|
||||
}
|
||||
setSessionCookie(event)
|
||||
return { authenticated: true }
|
||||
})
|
||||
6
server/api/auth/logout.post.ts
Normal file
6
server/api/auth/logout.post.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clearSessionCookie } from '../../lib/session'
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
clearSessionCookie(event)
|
||||
return { authenticated: false }
|
||||
})
|
||||
5
server/api/auth/me.get.ts
Normal file
5
server/api/auth/me.get.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { isAuthenticated } from '../../lib/session'
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
return { authenticated: isAuthenticated(event) }
|
||||
})
|
||||
22
server/api/exports/[file].get.ts
Normal file
22
server/api/exports/[file].get.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import { join, normalize } from 'node:path'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const file = decodeURIComponent(getRouterParam(event, 'file') ?? '')
|
||||
const exportsDir = join(useRuntimeConfig(event).dataDir, 'exports')
|
||||
const abs = normalize(join(exportsDir, file))
|
||||
if (!abs.startsWith(normalize(exportsDir) + '/') || !abs.endsWith('.pdf')) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Fichier invalide' })
|
||||
}
|
||||
let s
|
||||
try {
|
||||
s = await stat(abs)
|
||||
} catch {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Export introuvable' })
|
||||
}
|
||||
setHeader(event, 'Content-Type', 'application/pdf')
|
||||
setHeader(event, 'Content-Length', s.size)
|
||||
setHeader(event, 'Content-Disposition', `attachment; filename="${file.replace(/"/g, '')}"`)
|
||||
return sendStream(event, createReadStream(abs))
|
||||
})
|
||||
28
server/api/media/[...path].get.ts
Normal file
28
server/api/media/[...path].get.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
// Sert les médias du contenu Grav (et des snapshots figés).
|
||||
// - `?w=480` : image redimensionnée (voir server/lib/media.ts).
|
||||
// - `?poster=1` : première frame d'une vidéo.
|
||||
// - `?frozen=<portfolioId>` : résout d'abord dans le snapshot figé (§7.3).
|
||||
import { join } from 'node:path'
|
||||
import { useContentSource } from '../../lib/content-source'
|
||||
import { serveMedia, splitMediaPath } from '../../lib/media'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const { projectId, filename } = splitMediaPath(getRouterParam(event, 'path') ?? '')
|
||||
|
||||
const query = getQuery(event)
|
||||
const width = Number(query.w) || 0
|
||||
const wantPoster = Boolean(query.poster)
|
||||
const frozenId = typeof query.frozen === 'string' ? query.frozen : ''
|
||||
|
||||
const config = useRuntimeConfig(event)
|
||||
const source = useContentSource()
|
||||
|
||||
// Racines de résolution : snapshot figé d'abord, contenu live ensuite
|
||||
const roots: string[] = []
|
||||
if (frozenId && /^[\w-]+$/.test(frozenId)) {
|
||||
roots.push(join(config.dataDir, 'frozen', frozenId))
|
||||
}
|
||||
roots.push(source.rootDir)
|
||||
|
||||
return serveMedia(event, { roots, projectId, filename, width, wantPoster, dataDir: config.dataDir })
|
||||
})
|
||||
7
server/api/portfolios/[id]/duplicate.post.ts
Normal file
7
server/api/portfolios/[id]/duplicate.post.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { duplicatePortfolio } from '../../../lib/portfolio-store'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const copy = await duplicatePortfolio(getRouterParam(event, 'id')!)
|
||||
if (!copy) throw createError({ statusCode: 404, statusMessage: 'Portfolio introuvable' })
|
||||
return copy
|
||||
})
|
||||
23
server/api/portfolios/[id]/generate.post.ts
Normal file
23
server/api/portfolios/[id]/generate.post.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { getPortfolio } from '../../../lib/portfolio-store'
|
||||
import { generatePdf } from '../../../lib/pdf-pipeline'
|
||||
|
||||
// Génération séquentielle : une seule instance Chromium à la fois.
|
||||
let queue: Promise<unknown> = Promise.resolve()
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const portfolio = await getPortfolio(getRouterParam(event, 'id')!)
|
||||
if (!portfolio) throw createError({ statusCode: 404, statusMessage: 'Portfolio introuvable' })
|
||||
|
||||
const { fileName } = await readBody<{ fileName?: string }>(event).catch(() => ({ fileName: undefined }))
|
||||
|
||||
const run = queue.then(() => generatePdf(portfolio, fileName))
|
||||
queue = run.catch(() => {})
|
||||
try {
|
||||
return await run
|
||||
} catch (err) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: `Échec de la génération : ${err instanceof Error ? err.message : String(err)}`,
|
||||
})
|
||||
}
|
||||
})
|
||||
6
server/api/portfolios/[id]/index.delete.ts
Normal file
6
server/api/portfolios/[id]/index.delete.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { deletePortfolio } from '../../../lib/portfolio-store'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await deletePortfolio(getRouterParam(event, 'id')!)
|
||||
return { ok: true }
|
||||
})
|
||||
7
server/api/portfolios/[id]/index.get.ts
Normal file
7
server/api/portfolios/[id]/index.get.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { getPortfolio } from '../../../lib/portfolio-store'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const portfolio = await getPortfolio(getRouterParam(event, 'id')!)
|
||||
if (!portfolio) throw createError({ statusCode: 404, statusMessage: 'Portfolio introuvable' })
|
||||
return portfolio
|
||||
})
|
||||
21
server/api/portfolios/[id]/index.put.ts
Normal file
21
server/api/portfolios/[id]/index.put.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { Portfolio } from '~~/types'
|
||||
import { getPortfolio, savePortfolio } from '../../../lib/portfolio-store'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const id = getRouterParam(event, 'id')!
|
||||
const existing = await getPortfolio(id)
|
||||
if (!existing) throw createError({ statusCode: 404, statusMessage: 'Portfolio introuvable' })
|
||||
|
||||
const body = await readBody<Portfolio>(event)
|
||||
if (!body || !Array.isArray(body.pages) || typeof body.name !== 'string') {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Portfolio invalide' })
|
||||
}
|
||||
// L'id et les dates de création restent ceux du serveur.
|
||||
const portfolio: Portfolio = {
|
||||
...existing,
|
||||
...body,
|
||||
id,
|
||||
createdAt: existing.createdAt,
|
||||
}
|
||||
return await savePortfolio(portfolio)
|
||||
})
|
||||
30
server/api/portfolios/[id]/share.post.ts
Normal file
30
server/api/portfolios/[id]/share.post.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
// Active / coupe le lien public d'un portfolio (bouton « Partager un lien public »).
|
||||
// - body { enabled: false } → coupe le partage (publicShare = false), sans
|
||||
// toucher au snapshot figé (réutilisé par le PDF).
|
||||
// - sinon → (re)fige le contenu courant et active le partage. Re-cliquer
|
||||
// « republie » : l'instantané reflète l'état courant du portfolio.
|
||||
import { getPortfolio, savePortfolio } from '../../../lib/portfolio-store'
|
||||
import { freezeContent, sanitizeFileName } from '../../../lib/pdf-pipeline'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const id = getRouterParam(event, 'id') ?? ''
|
||||
const portfolio = await getPortfolio(id)
|
||||
if (!portfolio) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Portfolio introuvable' })
|
||||
}
|
||||
|
||||
const body = await readBody<{ enabled?: boolean }>(event).catch(() => ({}))
|
||||
|
||||
if (body?.enabled === false) {
|
||||
portfolio.publicShare = false
|
||||
// Renvoie le portfolio complet pour resynchroniser le client (évite qu'une
|
||||
// sauvegarde auto ultérieure ne réécrase publicShare/frozenContent).
|
||||
return await savePortfolio(portfolio)
|
||||
}
|
||||
|
||||
// (Re)geler le contenu courant puis activer le partage
|
||||
const fileName = portfolio.frozenContent?.fileName || sanitizeFileName(portfolio.name)
|
||||
portfolio.frozenContent = await freezeContent(portfolio, fileName)
|
||||
portfolio.publicShare = true
|
||||
return await savePortfolio(portfolio)
|
||||
})
|
||||
16
server/api/portfolios/[id]/unfreeze.post.ts
Normal file
16
server/api/portfolios/[id]/unfreeze.post.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
// « Réactualiser depuis Grav » (spec §7.3) : supprime le snapshot figé ;
|
||||
// la prochaine génération repartira du contenu live.
|
||||
import { rm } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { getPortfolio, savePortfolio } from '../../../lib/portfolio-store'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const id = getRouterParam(event, 'id')!
|
||||
const portfolio = await getPortfolio(id)
|
||||
if (!portfolio) throw createError({ statusCode: 404, statusMessage: 'Portfolio introuvable' })
|
||||
|
||||
portfolio.frozenContent = undefined
|
||||
await rm(join(useRuntimeConfig().dataDir, 'frozen', portfolio.id), { recursive: true, force: true })
|
||||
await savePortfolio(portfolio)
|
||||
return { ok: true }
|
||||
})
|
||||
5
server/api/portfolios/index.get.ts
Normal file
5
server/api/portfolios/index.get.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { listPortfolios } from '../../lib/portfolio-store'
|
||||
|
||||
export default defineEventHandler(async () => {
|
||||
return { portfolios: await listPortfolios() }
|
||||
})
|
||||
6
server/api/portfolios/index.post.ts
Normal file
6
server/api/portfolios/index.post.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createPortfolio } from '../../lib/portfolio-store'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const { name } = await readBody<{ name?: string }>(event)
|
||||
return await createPortfolio(name?.trim() ?? '')
|
||||
})
|
||||
10
server/api/projects/[...id].get.ts
Normal file
10
server/api/projects/[...id].get.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { useContentSource } from '../../lib/content-source'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const id = decodeURIComponent(getRouterParam(event, 'id') ?? '')
|
||||
const project = await useContentSource().getProject(id)
|
||||
if (!project) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Projet introuvable' })
|
||||
}
|
||||
return project
|
||||
})
|
||||
7
server/api/projects/index.get.ts
Normal file
7
server/api/projects/index.get.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { useContentSource } from '../../lib/content-source'
|
||||
|
||||
export default defineEventHandler(async () => {
|
||||
const source = useContentSource()
|
||||
const projects = await source.listProjects()
|
||||
return { projects }
|
||||
})
|
||||
19
server/api/public/[id].get.ts
Normal file
19
server/api/public/[id].get.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
// Données de la vue publique : le portfolio partagé + ses projets figés.
|
||||
// Public (hors mot de passe), mais uniquement si publicShare est actif et
|
||||
// qu'un instantané figé existe. On ne sert jamais le contenu live.
|
||||
import { getPortfolio } from '../../lib/portfolio-store'
|
||||
import type { Portfolio, Project } from '~~/types'
|
||||
|
||||
export default defineEventHandler(async (event): Promise<{ portfolio: Portfolio; projects: Record<string, Project> }> => {
|
||||
const id = getRouterParam(event, 'id') ?? ''
|
||||
if (!/^[\w-]+$/.test(id)) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Identifiant invalide' })
|
||||
}
|
||||
|
||||
const portfolio = await getPortfolio(id)
|
||||
if (!portfolio?.publicShare || !portfolio.frozenContent) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Portfolio introuvable' })
|
||||
}
|
||||
|
||||
return { portfolio, projects: portfolio.frozenContent.projects }
|
||||
})
|
||||
28
server/api/public/[id]/media/[...path].get.ts
Normal file
28
server/api/public/[id]/media/[...path].get.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
// Médias de la vue publique : sert exclusivement le snapshot figé du portfolio
|
||||
// <id>, et seulement s'il est partagé publiquement (publicShare). Aucun repli
|
||||
// sur le contenu live — la surface publique reste limitée à l'instantané.
|
||||
import { join } from 'node:path'
|
||||
import { getPortfolio } from '../../../../lib/portfolio-store'
|
||||
import { serveMedia, splitMediaPath } from '../../../../lib/media'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const id = getRouterParam(event, 'id') ?? ''
|
||||
if (!/^[\w-]+$/.test(id)) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Identifiant invalide' })
|
||||
}
|
||||
|
||||
const portfolio = await getPortfolio(id)
|
||||
if (!portfolio?.publicShare) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Portfolio introuvable' })
|
||||
}
|
||||
|
||||
const { projectId, filename } = splitMediaPath(getRouterParam(event, 'path') ?? '')
|
||||
const query = getQuery(event)
|
||||
const width = Number(query.w) || 0
|
||||
const wantPoster = Boolean(query.poster)
|
||||
|
||||
const config = useRuntimeConfig(event)
|
||||
const roots = [join(config.dataDir, 'frozen', id)]
|
||||
|
||||
return serveMedia(event, { roots, projectId, filename, width, wantPoster, dataDir: config.dataDir })
|
||||
})
|
||||
7
server/api/sync.post.ts
Normal file
7
server/api/sync.post.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// Bouton « Rafraîchir » (spec §6) : re-synchronise le contenu.
|
||||
// En DEV : simple relecture du FS. En PROD (phase 3) : rsync/SSH.
|
||||
import { useContentSource } from '../lib/content-source'
|
||||
|
||||
export default defineEventHandler(async () => {
|
||||
return await useContentSource().refresh()
|
||||
})
|
||||
Reference in New Issue
Block a user