Files
portfolio-generator-app/components/preview/PageRenderer.vue
Valentin Le Moign 59dfbe4155 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>
2026-07-05 18:37:09 +02:00

215 lines
7.5 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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