- 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
245 lines
8.8 KiB
Vue
245 lines
8.8 KiB
Vue
<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>
|