- 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
318 lines
11 KiB
Vue
318 lines
11 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.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>
|