Files
portfolio-generator-app/components/preview/PageRenderer.vue
Valentin Le Moign ef1f14335d Textes projet éditables suivant Grav + écriture atomique du store
- Champ Présentation prérempli avec le texte Grav, suivi modifié/synchro
  (textOverride undefined = suit Grav, sinon figé ; bouton Réinitialiser)
- Fix génération PDF : savePortfolio écrit en atomique (temp+rename) pour
  éviter la corruption de lecture par l'autosave concurrent du rendu print
- Toast d'échec de génération explicite (message serveur)
- Refonte architecture visuelle, fond unifié white/tint/full, dispositions
  couverture bandeau/colonne, instantané public complet, index 3 lignes
2026-07-06 13:26:06 +02:00

245 lines
8.8 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 ''
// `textOverride` undefined = suit Grav (bodyHtml) ; toute chaîne définie
// (y compris vide) est un texte figé à l'édition et rendue telle quelle.
const override = props.page.textOverride
const html = override === undefined
? project.value.bodyHtml
: (marked.parse(override) as string)
return stripTargetParam(html)
})
// Ligne d'info de la couverture de projet : type de travail + année.
// Type par défaut = taxonomy.category de Grav (« Site web », « Édition »…),
// c.-à-d. Project.categories — surchargée par subtitleOverride si renseignée.
const projectWorkType = computed(() => {
if (props.page.kind !== 'project') return ''
return props.page.subtitleOverride?.trim() || project.value?.categories.join(', ') || ''
})
const projectYear = computed(() => {
if (props.page.kind !== 'project') return ''
if (props.page.year?.trim()) return props.page.year.trim()
const iso = project.value?.date
const y = iso ? new Date(iso).getFullYear() : NaN
return Number.isNaN(y) ? '' : String(y)
})
const projectLink = computed(() => {
if (props.page.kind !== 'project') return ''
return stripTargetParam(props.page.link?.trim() || project.value?.externalUrl || '')
})
const projectBadge = computed(() => {
const c = project.value?.category
return c ? CATEGORY_BADGE[c] ?? c : undefined
})
// ---- Page grille de projet ----
// Filet et mode clair hérités de la page couverture du même projet.
const gridCover = computed(() => {
if (props.page.kind !== 'project-grid') return undefined
const projectId = props.page.projectId
return props.portfolio.pages.find(
(p): p is Extract<Page, { kind: 'project' }> => p.kind === 'project' && p.projectId === projectId,
)
})
// Le filet des grilles hérite de la couleur du bandeau de la couverture du projet.
const gridStripOverride = computed(() => gridCover.value?.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 stripTargetParam(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"
:date="portfolio.cover.date"
:site-url="portfolio.cover.siteUrl"
:contact-email="portfolio.cover.contactEmail"
: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 ?? coverBandColor"
:text-scale="page.textScale"
: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"
:work-type="projectWorkType"
:year="projectYear"
:subtitle="page.subtitle"
:link="projectLink"
: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"
:background="page.background"
:layout="page.layout"
/>
</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"
:band-color="gridStripOverride"
:background="page.background"
/>
</template>
</template>