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
This commit is contained in:
2026-07-13 16:00:59 +02:00
parent ef1f14335d
commit 1c1604ea2f
29 changed files with 802 additions and 265 deletions

View File

@@ -76,6 +76,7 @@ const projectWorkType = computed(() => {
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
@@ -132,6 +133,13 @@ const gridMedia = computed<ResolvedMedia[]>(() => {
.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(
@@ -151,18 +159,61 @@ const tocEntries = computed(() =>
}),
)
// 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)
// 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 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)
})
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>
@@ -171,14 +222,37 @@ const freeMedia = computed<ResolvedMedia[]>(() => {
<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"
@@ -193,8 +267,7 @@ const freeMedia = computed<ResolvedMedia[]>(() => {
<FreeTextPage
v-else-if="page.kind === 'free'"
:title="page.title"
:text="freeText"
:media="freeMedia"
:columns="freeColumns"
:background="page.background"
:band-color="page.bandColor ?? coverBandColor"
:text-scale="page.textScale"