Files
portfolio-generator-app/components/preview/PageRenderer.vue
Valentin Le Moign 1c1604ea2f Pages équipe + conclusion, colonnes de page libre, réglages couverture
- Page « équipe » : roster statique du collectif (composables/useTeam),
  portraits servis depuis public/portraits, grille ≤ 3 colonnes équilibrée
  (portrait/nom/rôle/présentation), sélection + surcharges par membre
  (repliables, réordonnables, réinitialisables) dans le composeur
- Page « conclusion » : « Merci ! » centré + rappel des contacts
- Page libre : 1 à 3 colonnes texte (markdown) ou images avec sous-titres,
  réordonnables ; migration des champs body/media legacy vers columns
- Couverture : texte libre sous l'intro, adresse en bas à gauche (3 lignes),
  case « afficher les années » au niveau du document
- Sommaire : items centrés (self-center), filet en points dégradés stable
- Retrait de la dépendance morte pagedjs
- Docs à jour (CLAUDE.md, README) ; spec marquée comme archive réalisée
2026-07-13 16:00:59 +02:00

318 lines
11 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.portfolio.showYears === false) return '' // réglage document : années masquées
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)
})
// Texte libre optionnel sous la phrase d'intro de la couverture (markdown).
const coverBody = computed(() =>
props.portfolio.cover.body?.trim()
? stripTargetParam(marked.parse(props.portfolio.cover.body) as string)
: '',
)
// 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 : colonnes de texte (markdown) ou d'images. Repli sur les champs
// legacy `body`/`media` pour les pages non encore migrées (lues depuis le disque).
interface ResolvedColumn {
id: string
kind: 'text' | 'media'
heading?: string
html?: string
media?: ResolvedMedia[]
}
function resolveFreeMedia(refs: string[], colCount: number): ResolvedMedia[] {
const width = Math.round(INNER_W / colCount)
return refs.map((ref) => {
const idx = ref.lastIndexOf('/')
const projectId = ref.slice(0, idx)
const media = resolve(projectId, ref.slice(idx + 1), width)
media.caption = props.projects[projectId]?.title // titre du projet en légende
return media
})
}
// Page équipe : membres cochés, résolus depuis le roster statique (nom +
// portrait) avec rôle/présentation surchargés le cas échéant.
const teamMembers = computed(() => {
if (props.page.kind !== 'team') return []
return props.page.members
.filter(m => m.enabled)
.map((m) => {
const r = TEAM_ROSTER.find(x => x.id === m.id)
return {
id: m.id,
name: r?.name ?? m.id,
portraitUrl: portraitUrl(r?.portrait ?? ''),
role: m.role ?? r?.role,
bio: m.bio ?? r?.bio,
}
})
})
const freeColumns = computed<ResolvedColumn[]>(() => {
if (props.page.kind !== 'free') return []
const page = props.page
// Colonnes explicites, sinon synthèse depuis les champs legacy.
const cols = page.columns?.length
? page.columns
: [
...(page.body?.trim() ? [{ id: 'legacy-text', kind: 'text' as const, body: page.body }] : []),
...(page.media?.length ? [{ id: 'legacy-media', kind: 'media' as const, media: page.media }] : []),
]
const list = cols.length ? cols : [{ id: 'empty', kind: 'text' as const, body: '' }]
const count = list.length
return list.map(col => col.kind === 'media'
? { id: col.id, kind: 'media' as const, heading: col.heading, media: resolveFreeMedia(col.media ?? [], count) }
: { id: col.id, kind: 'text' as const, heading: col.heading, html: col.body?.trim() ? stripTargetParam(marked.parse(col.body) as string) : '' },
)
})
</script>
<template>
<CoverTemplate
v-if="page.kind === 'cover'"
:title="portfolio.cover.title"
:body="coverBody"
:date="portfolio.cover.date"
:site-url="portfolio.cover.siteUrl"
:contact-email="portfolio.cover.contactEmail"
:address="portfolio.cover.address"
:background="page.background"
:band-color="page.bandColor"
:render-mode="renderMode"
/>
<OutroTemplate
v-else-if="page.kind === 'outro'"
:title="page.title"
:site-url="portfolio.cover.siteUrl"
:contact-email="portfolio.cover.contactEmail"
:address="portfolio.cover.address"
:background="page.background"
:band-color="page.bandColor ?? coverBandColor"
:render-mode="renderMode"
/>
<TeamTemplate
v-else-if="page.kind === 'team'"
:title="page.title"
:members="teamMembers"
:background="page.background"
:band-color="page.bandColor ?? coverBandColor"
:page="pageInfo"
: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"
:columns="freeColumns"
: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>