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,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
}