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
This commit is contained in:
2026-07-06 13:26:06 +02:00
parent 59dfbe4155
commit ef1f14335d
22 changed files with 750 additions and 334 deletions

26
components/BrandBar.vue Normal file
View File

@@ -0,0 +1,26 @@
<script setup lang="ts">
// En-tête de la colonne gauche : logo « Portfolios » + déconnexion (petite icône
// à droite). Header à part entière (fond + filet bas), commun index/éditeur.
async function logout() {
await $fetch('/api/auth/logout', { method: 'POST' })
navigateTo('/login')
}
</script>
<template>
<div class="flex h-14 items-center justify-between gap-2 border-b border-neutral-200 bg-white px-4 dark:border-neutral-800 dark:bg-neutral-900">
<NuxtLink to="/" class="font-bold text-[var(--fl-ink)] hover:opacity-80 dark:text-white">
Portfolios
</NuxtLink>
<UTooltip text="Déconnexion">
<UButton
icon="i-lucide-log-out"
size="xs"
variant="ghost"
color="neutral"
aria-label="Se déconnecter"
@click="logout"
/>
</UTooltip>
</div>
</template>

View File

@@ -0,0 +1,42 @@
<script setup lang="ts">
// Bouton « Rafraîchir » : resynchronise le contenu depuis Grav. Placé à côté de
// « Générer le PDF » (éditeur) et « Nouveau portfolio » (index).
withDefaults(defineProps<{ size?: 'xs' | 'sm' | 'md' }>(), { size: 'sm' })
const toast = useToast()
const syncing = ref(false)
async function refreshContent() {
syncing.value = true
try {
const result = await $fetch<{ ok: boolean, summary: string }>('/api/sync', {
method: 'POST',
timeout: 900_000,
})
toast.add({
title: result.ok ? 'Contenu rafraîchi' : 'Synchronisation en échec',
description: result.summary,
color: result.ok ? 'success' : 'error',
})
if (result.ok) await refreshNuxtData()
} catch {
toast.add({ title: 'Échec de la synchronisation', color: 'error' })
} finally {
syncing.value = false
}
}
</script>
<template>
<UTooltip text="Actualise les données depuis Grav">
<UButton
icon="i-lucide-refresh-cw"
label="Rafraîchir"
:size="size"
variant="ghost"
color="neutral"
:loading="syncing"
@click="refreshContent"
/>
</UTooltip>
</template>

View File

@@ -1,50 +1,31 @@
<script setup lang="ts">
// Fond de page (spec §7.2) : blanc / teinté (aplat clair) / pleine couleur.
import type { Background } from '~~/types'
// Fond de page unifié : Blanc / Teinté (aplat clair fixe) / Plein (couleur du
// filet/bandeau de la page). Undefined = Blanc par défaut.
import type { Background, BgMode } from '~~/types'
const model = defineModel<Background>({ required: true })
const model = defineModel<Background | undefined>()
const modes = [
{ value: 'white', label: 'Blanc' },
{ value: 'tint', label: 'Teinté' },
{ value: 'color', label: 'Couleur' },
{ value: 'full', label: 'Plein' },
] as const
function setMode(mode: Background['mode']) {
if (mode === 'white') model.value = { mode: 'white' }
else if (mode === 'tint') model.value = { mode: 'tint', color: model.value.mode === 'tint' ? model.value.color : TINT_PRESETS[0] }
else model.value = { mode: 'color', color: model.value.mode === 'color' ? model.value.color : COLOR_PRESETS[1] }
}
const swatches = computed(() =>
model.value.mode === 'tint' ? TINT_PRESETS : model.value.mode === 'color' ? COLOR_PRESETS : [],
)
const current = computed<BgMode>(() => model.value?.mode ?? 'white')
</script>
<template>
<div class="space-y-2">
<p class="text-xs font-medium uppercase tracking-wide text-neutral-500">Fond de page</p>
<p class="text-xs font-medium uppercase tracking-wide text-neutral-500">Fond</p>
<div class="flex gap-1">
<UButton
v-for="m in modes"
:key="m.value"
:label="m.label"
size="xs"
:variant="model.mode === m.value ? 'solid' : 'outline'"
:variant="current === m.value ? 'solid' : 'outline'"
color="neutral"
@click="setMode(m.value)"
/>
</div>
<div v-if="swatches.length" class="flex flex-wrap gap-1.5 pt-1">
<button
v-for="c in swatches"
:key="c"
type="button"
class="size-7 rounded-full border transition-transform hover:scale-110"
:class="(model as any).color === c ? 'ring-2 ring-[var(--fl-ink)] ring-offset-1 dark:ring-white' : 'border-neutral-300 dark:border-neutral-600'"
:style="{ backgroundColor: c }"
:aria-label="c"
@click="model = { mode: model.mode as 'tint' | 'color', color: c }"
@click="model = { mode: m.value }"
/>
</div>
</div>

View File

@@ -11,6 +11,9 @@ const props = withDefaults(
)
const model = defineModel<string | undefined>()
// Une couleur choisie hors palette = couleur personnalisée (input natif).
const isCustom = computed(() => !!model.value && !BAND_COLORS.includes(model.value))
</script>
<template>
@@ -24,7 +27,7 @@ const model = defineModel<string | undefined>()
class="relative size-7 rounded-full border transition-transform hover:scale-110"
:class="!model ? 'ring-2 ring-[var(--fl-ink)] ring-offset-1 dark:ring-white' : 'border-neutral-300 dark:border-neutral-600'"
:style="{ backgroundColor: props.defaultColor }"
title="Couleur de la catégorie (défaut)"
title="Couleur par défaut"
@click="model = undefined"
>
<UIcon name="i-lucide-rotate-ccw" class="absolute inset-0 m-auto size-3.5 text-black/40" />
@@ -39,6 +42,21 @@ const model = defineModel<string | undefined>()
:aria-label="c"
@click="model = c"
/>
<!-- Couleur personnalisée (sélecteur natif) -->
<label
class="relative flex size-7 cursor-pointer items-center justify-center overflow-hidden rounded-full border transition-transform hover:scale-110"
:class="isCustom ? 'ring-2 ring-[var(--fl-ink)] ring-offset-1 dark:ring-white' : 'border-neutral-300 dark:border-neutral-600'"
:style="isCustom ? { backgroundColor: model } : undefined"
title="Couleur personnalisée"
>
<UIcon v-if="!isCustom" name="i-lucide-pipette" class="size-3.5 text-neutral-400" />
<input
type="color"
class="absolute inset-0 cursor-pointer opacity-0"
:value="isCustom ? model : '#000000'"
@input="model = ($event.target as HTMLInputElement).value"
>
</label>
</div>
</div>
</template>

View File

@@ -57,30 +57,52 @@ const projectCoverImage = computed<ResolvedMedia | undefined>(() => {
const projectText = computed(() => {
if (props.page.kind !== 'project' || !project.value) return ''
if (props.page.textOverride?.trim()) return marked.parse(props.page.textOverride) as string
return project.value.bodyHtml
// `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)
})
const projectIntro = computed(() => {
if (props.page.kind !== 'project' || !project.value) return ''
return props.page.subtitleOverride?.trim() || project.value.categories.join(', ')
// 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 ? `Utilité ${CATEGORY_LABELS[c]?.toLowerCase() ?? c}` : undefined
return c ? CATEGORY_BADGE[c] ?? c : undefined
})
// ---- Page grille de projet ----
// Le filet hérite de la couleur du bandeau de la page couverture du projet.
const gridStripOverride = computed(() => {
// 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
const cover = props.portfolio.pages.find(
return props.portfolio.pages.find(
(p): p is Extract<Page, { kind: 'project' }> => p.kind === 'project' && p.projectId === projectId,
)
return cover?.bandColor
})
// 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(() => {
@@ -132,7 +154,7 @@ 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 marked.parse(props.page.body) as string
return stripTargetParam(marked.parse(props.page.body) as string)
})
const freeMedia = computed<ResolvedMedia[]>(() => {
@@ -149,6 +171,9 @@ const freeMedia = computed<ResolvedMedia[]>(() => {
<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"
@@ -171,7 +196,8 @@ const freeMedia = computed<ResolvedMedia[]>(() => {
:text="freeText"
:media="freeMedia"
:background="page.background"
:band-color="page.bandColor"
:band-color="page.bandColor ?? coverBandColor"
:text-scale="page.textScale"
:page="pageInfo"
:render-mode="renderMode"
/>
@@ -185,7 +211,10 @@ const freeMedia = computed<ResolvedMedia[]>(() => {
:title="project.title"
:category="project.category"
:badge="projectBadge"
:intro="projectIntro"
:work-type="projectWorkType"
:year="projectYear"
:subtitle="page.subtitle"
:link="projectLink"
:text="projectText"
:image="projectCoverImage"
:image-fit="page.coverFit"
@@ -194,6 +223,8 @@ const freeMedia = computed<ResolvedMedia[]>(() => {
:band-height="page.bandHeight"
:band-color="page.bandColor"
:ink="page.ink"
:background="page.background"
:layout="page.layout"
/>
</template>
@@ -206,9 +237,8 @@ const freeMedia = computed<ResolvedMedia[]>(() => {
:media="gridMedia"
:grid="page.grid"
:orientation="gridOrientation"
:category="project.category"
:band-color="gridStripOverride"
:grey-bg="page.greyBg"
:background="page.background"
/>
</template>
</template>

View File

@@ -7,28 +7,59 @@ import type { Background } from '~~/types'
const props = withDefaults(
defineProps<{
title: string // utilisé pour les métadonnées PDF, pas affiché
title: string // titre du portfolio — en-tête + métadonnées PDF
subtitle?: string
recipient?: string
background: Background
bandColor?: string // filet couleur en bas (définit aussi celui du sommaire)
date?: string // texte libre de l'en-tête (ex. « Juillet 2026 »)
siteUrl?: string
contactEmail?: string
renderMode?: 'preview' | 'print'
}>(),
{ renderMode: 'preview' },
)
// Le site affiche l'intro sur fond gris clair : « Blanc » rend le gris charte.
const strip = computed(() => props.bandColor || FL_INK)
// Fond : « Plein » = couleur du filet ; « Blanc » rend le gris charte du site
// (l'intro y est présentée sur gris clair) ; « Teinté » = aplat beige.
const pageStyle = computed(() => {
const s = backgroundStyle(props.background)
if (props.background.mode === 'white') s.backgroundColor = FL_BASE
return s
const base
= props.background.mode === 'full'
? strip.value
: props.background.mode === 'tint'
? TINT_COLOR
: FL_BASE
return { backgroundColor: base, color: inkOn(base) }
})
const strip = computed(() => props.bandColor || FL_INK)
// En-tête : coordonnées en liens actifs (le PDF et l'aperçu partagent le
// template ; Chromium convertit les <a href> en liens cliquables du PDF).
const siteHref = computed(() => props.siteUrl?.trim() || DEFAULT_SITE_URL)
const siteLabel = computed(() => siteHref.value.replace(/^https?:\/\//, '').replace(/\/$/, ''))
const email = computed(() => props.contactEmail?.trim() || DEFAULT_CONTACT_EMAIL)
// Laissée vide, la date affiche par défaut le mois courant (ex. « Juillet 2026 »).
const displayDate = computed(() => props.date?.trim() || formatMonthYear())
</script>
<template>
<div class="fl-page" :style="pageStyle">
<!-- En-tête : titre du portfolio + date, site et mail (liens actifs PDF/viewer) -->
<header
class="absolute inset-x-0 top-0 flex items-start justify-between gap-8"
:style="{ padding: 'var(--fl-margin)', fontFamily: 'Syne, sans-serif' }"
>
<div class="max-w-[60%]">
<h1 :style="{ fontSize: '30px', lineHeight: '1.2' }">{{ title }}</h1>
<p class="opacity-70" :style="{ fontSize: '19px', lineHeight: '1.5' }">{{ displayDate }}</p>
</div>
<div class="flex shrink-0 flex-col items-end" :style="{ fontSize: '19px', lineHeight: '1.5' }">
<a :href="siteHref" target="_blank" rel="noopener" class="text-current no-underline">{{ siteLabel }}</a>
<a :href="`mailto:${email}`" class="text-current no-underline">{{ email }}</a>
</div>
</header>
<div class="absolute inset-0 flex flex-col items-center justify-center">
<!-- Phrase d'intro du site (pages/01.home) — Lato, keywords dans les
fontes d'« utilité » du thème Grav, non soulignés -->
@@ -40,20 +71,9 @@ const strip = computed(() => props.bandColor || FL_INK)
<span class="fl-font-culturelle">culturelle</span>
qui œuvre sur logiciels&nbsp;libres.
</p>
<!-- Flèche vers le bas (theme://images/pictos/arrow-down.svg) -->
<svg viewBox="0 0 24 30" :style="{ width: '60px', height: '54px', marginTop: '90px' }" aria-hidden="true">
<g transform="rotate(90,11,12.999996)">
<path
d="m 16.681932,5.2684395 0.08629,0.091376 5.042819,6.0548205 v 0 l 0.06362,0.100098 v 0 l 0.06561,0.144066 v 0 l 0.0389,0.137168 v 0 l 0.01717,0.117449 v 0 L 22,12 v 0 l -0.0025,0.07061 v 0 l -0.01594,0.12126 v 0 l -0.02359,0.09573 -0.0317,0.08965 v 0 l -0.06326,0.128377 v 0 l -0.09484,0.134549 -5,6 c -0.353564,0.424277 -0.984128,0.481601 -1.408405,0.128037 C 14.968124,18.441846 14.889154,17.879455 15.15741,17.46116 L 15.231779,17.359816 18.864,12.999975 3,13 C 2.4477153,13 2,12.552285 2,12 2,11.487164 2.3860402,11.064493 2.8833789,11.006728 L 3,11 18.864,10.999975 15.231779,6.6401844 c -0.353564,-0.4242769 -0.29624,-1.0548416 0.128037,-1.4084057 0.39164,-0.3263668 0.959052,-0.3026278 1.322116,0.036661 z"
fill="currentColor"
fill-rule="nonzero"
/>
</g>
</svg>
</div>
<!-- Filet couleur pleine largeur en bas -->
<div class="absolute inset-x-0 bottom-0" :style="{ height: 'var(--fl-strip)', backgroundColor: strip }" />
<!-- Filet couleur pleine largeur en bas (masqué en fond plein) -->
<div v-if="background.mode !== 'full'" class="absolute inset-x-0 bottom-0" :style="{ height: 'var(--fl-strip)', backgroundColor: strip }" />
</div>
</template>

View File

@@ -11,23 +11,27 @@ const props = withDefaults(
media?: ResolvedMedia[]
background: Background
bandColor?: string
textScale?: number // taille du texte de labeur en % (défaut 100 = 12 pt)
page: { index: number; total: number }
renderMode?: 'preview' | 'print'
}>(),
{ renderMode: 'preview' },
{ renderMode: 'preview', textScale: 100 },
)
const strip = computed(() => props.bandColor || FL_INK)
const pageStyle = computed(() => backgroundStyle(props.background, strip.value))
// Texte de labeur : base 16 px (12 pt), agrandissable ; interligne proportionnel.
const bodyStyle = computed(() => ({ fontSize: `${(16 * props.textScale) / 100}px`, lineHeight: '1.35' }))
</script>
<template>
<div class="fl-page" :style="backgroundStyle(background)">
<div class="absolute inset-x-0 top-0" :style="{ height: 'var(--fl-strip)', backgroundColor: strip }" />
<div class="fl-page" :style="pageStyle">
<div v-if="background.mode !== 'full'" class="absolute inset-x-0 top-0" :style="{ height: 'var(--fl-strip)', backgroundColor: strip }" />
<div class="absolute flex flex-col" :style="{ inset: 'var(--fl-margin)', top: '60px' }">
<h2 v-if="title" class="max-w-[1000px]" :style="{ fontSize: '60px', lineHeight: '1.1' }">{{ title }}</h2>
<h2 v-if="title" class="max-w-[1000px]" :style="{ fontSize: '60px', lineHeight: '1.05' }">{{ title }}</h2>
<div class="mt-12 flex min-h-0 flex-1" :style="{ gap: '48px' }">
<div v-if="text" class="fl-prose max-w-[740px] flex-1 overflow-hidden" v-html="text" />
<div v-if="text" class="fl-prose max-w-[740px] flex-1 overflow-hidden" :style="bodyStyle" v-html="text" />
<div v-if="media?.length" class="flex w-[560px] shrink-0 flex-col" :style="{ gap: 'var(--fl-gutter)' }">
<div v-for="m in media.slice(0, 3)" :key="m.filename" class="min-h-0 flex-1">
<img :src="m.url" :alt="m.filename" class="size-full object-contain" style="object-position: right top">

View File

@@ -4,14 +4,17 @@
// pastille catégorie (10 pt), présentation (Syne 12/16 pt, colonne à 50,5 %).
// Sous le bandeau : image de couverture. Taille du titre et hauteur du
// bandeau (= calage haut de l'image) ajustables pour éviter les débords.
import type { InkMode, ResolvedMedia } from '~~/types'
import type { Background, InkMode, ResolvedMedia } from '~~/types'
const props = withDefaults(
defineProps<{
title: string
category: string // "publique" | "sociale" | "culturelle"
badge?: string // texte de la pastille (ex. « Utilité publique »)
intro?: string // ligne(s) d'intro en gras
badge?: string // pastille d'utilité (ex. « Culturel »)
workType?: string // type du projet, ligne d'info (ex. « édition »)
year?: string // année du projet, ligne d'info
subtitle?: string // sous-titre sous le titre (fonte du titre, plus petit)
link?: string // lien du projet sous le titre (taille labeur, actif PDF/aperçu)
text?: string // présentation, HTML
image?: ResolvedMedia
imageFit?: 'width' | 'height' // calage : pleine largeur ou pleine hauteur
@@ -20,14 +23,39 @@ const props = withDefaults(
bandHeight?: number // mm (gabarit : 55)
bandColor?: string
ink?: InkMode
background?: Background // fond : blanc / teinté (filet fin) / plein (bandeau ou colonne coloré)
layout?: 'band' | 'split' // 'band' = bandeau haut ; 'split' = colonne texte + image droite
}>(),
{ ink: 'auto', imageFit: 'width', imageAlign: 'center', titleScale: 100, bandHeight: 55 },
{ ink: 'auto', imageFit: 'width', imageAlign: 'center', titleScale: 100, bandHeight: 55, layout: 'band' },
)
const PX_PER_MM = 3.7796
const band = computed(() => bandColorOf(props.category, props.bandColor))
const inkColor = computed(() => inkOn(band.value, props.ink))
// Couleur choisie du projet (filet / bandeau). Fond « plein » = bandeau/colonne
// à cette couleur ; sinon fond blanc/teinté avec un fin filet.
const catColor = computed(() => filetColor(props.bandColor))
const isFull = computed(() => props.background?.mode === 'full')
// Fond de page (blanc ou teinté) — sous le bandeau clair et autour de l'image.
const pageBg = computed(() => (props.background?.mode === 'tint' ? TINT_COLOR : '#ffffff'))
const bandBg = computed(() => (isFull.value ? catColor.value : pageBg.value))
const inkColor = computed(() => (isFull.value ? inkOn(catColor.value, props.ink) : '#000000'))
// Pastille pleine, sans contour. Fond plein : fond = couleur du texte, texte
// opposé. Sinon : fond = couleur du projet, texte lisible dessus.
const pillBg = computed(() => (isFull.value ? inkColor.value : catColor.value))
const pillInk = computed(() =>
isFull.value ? (inkColor.value === '#ffffff' ? '#000000' : '#ffffff') : inkOn(catColor.value),
)
// Disposition « colonne ». Clair : texte sur le fond de page, fin filet en haut.
// Fond plein : la colonne prend la couleur du projet, texte contrasté (pas de filet).
const splitBg = computed(() => (isFull.value ? catColor.value : pageBg.value))
const splitInk = computed(() => (isFull.value ? inkOn(catColor.value, props.ink) : inkOn(pageBg.value)))
const splitPillBg = computed(() => (isFull.value ? splitInk.value : catColor.value))
const splitPillInk = computed(() =>
isFull.value ? (splitInk.value === '#ffffff' ? '#000000' : '#ffffff') : inkOn(catColor.value),
)
// Lien affiché sans le protocole
const linkLabel = computed(() => props.link?.replace(/^https?:\/\//, '').replace(/\/$/, ''))
const titleClass = computed(() => CATEGORY_TITLE_CLASS[props.category] ?? '')
// L'Avara est plus grasse : le gabarit la compose en 42 pt au lieu de 45 pt.
const titleSize = computed(() => {
@@ -48,39 +76,90 @@ const alignStyle = computed(() => {
</script>
<template>
<div class="fl-page">
<!-- Bandeau -->
<div class="absolute inset-x-0 top-0" :style="{ height: bandPx, backgroundColor: band, color: inkColor }">
<h1
class="absolute"
:class="titleClass"
:style="{ left: 'var(--fl-margin)', top: '14px', fontSize: titleSize, lineHeight: '1.15', maxWidth: '620px' }"
>
{{ title }}
</h1>
<span
v-if="badge"
class="absolute whitespace-nowrap rounded-full border px-[12px] py-[3px]"
:style="{ left: '42.33%', top: '54px', transform: 'translateY(-50%)', fontSize: '13.3px', lineHeight: '1.2', borderColor: 'currentColor' }"
>
{{ badge }}
</span>
<!-- Disposition « colonne + image » : texte à gauche, image pleine hauteur à droite -->
<div v-if="layout === 'split'" class="fl-page" :style="{ backgroundColor: pageBg }">
<div class="absolute inset-0 flex">
<!-- Colonne gauche : filet (mode clair) ou fond plein coloré, infos, titre, sous-titre, lien, description -->
<div class="flex min-w-0 flex-1 flex-col" :style="{ backgroundColor: splitBg, color: splitInk }">
<div v-if="bandColor && !isFull" class="shrink-0" :style="{ height: 'var(--fl-strip-light)', backgroundColor: catColor }" />
<div class="flex min-h-0 flex-1 flex-col" :style="{ padding: 'var(--fl-margin)' }">
<div class="flex items-center whitespace-nowrap" :style="{ gap: '12px', fontSize: '13.3px', lineHeight: '1.2' }">
<span
v-if="badge"
class="rounded-full px-[12px] py-[3px]"
:style="{ backgroundColor: splitPillBg, color: splitPillInk }"
>{{ badge }}</span>
<span v-if="workType">{{ workType }}</span>
<span v-if="year" class="opacity-70">{{ year }}</span>
</div>
<h1 :class="titleClass" :style="{ fontSize: titleSize, lineHeight: '1.05', marginTop: '16px' }">{{ title }}</h1>
<p v-if="subtitle" :class="titleClass" :style="{ fontSize: `calc(${titleSize} * 0.5)`, lineHeight: '1.2', marginTop: '4px' }">{{ subtitle }}</p>
<a
v-if="link"
:href="link"
target="_blank"
rel="noopener"
class="text-current no-underline"
:style="{ marginTop: '8px' }"
>{{ linkLabel }}</a>
<div v-if="text" class="fl-prose min-h-0 overflow-hidden" :style="{ marginTop: '28px' }" v-html="text" />
</div>
</div>
<!-- Image pleine hauteur, rognée au-delà de ~70 % de la largeur, centrée horizontalement -->
<div v-if="image" class="flex h-full shrink-0 justify-center overflow-hidden" :style="{ maxWidth: '70%' }">
<img :src="image.url" :alt="title" :style="{ height: '100%', width: 'auto', maxWidth: 'none' }">
</div>
</div>
</div>
<!-- Disposition « bandeau » (par défaut) -->
<div v-else class="fl-page" :style="{ backgroundColor: pageBg }">
<!-- Bandeau (pleine couleur) ou fond de page (mode clair) -->
<div class="absolute inset-x-0 top-0" :style="{ height: bandPx, backgroundColor: bandBg, color: inkColor }">
<!-- Mode clair : la couleur choisie n'est plus qu'un filet fin en haut -->
<div v-if="!isFull" class="absolute inset-x-0 top-0" :style="{ height: 'var(--fl-strip-light)', backgroundColor: catColor }" />
<!-- Ligne d'info au-dessus du titre (pastille · type · année) -->
<div
class="absolute overflow-hidden"
:style="{ left: '50.47%', right: 'var(--fl-margin)', top: '33px', bottom: '12px' }"
class="absolute flex items-center whitespace-nowrap"
:style="{ left: 'var(--fl-margin)', right: '51%', top: '34px', gap: '12px', fontSize: '13.3px', lineHeight: '1.2' }"
>
<p v-if="intro" class="font-bold">{{ intro }}</p>
<div v-if="text" class="fl-prose" v-html="text" />
<span
v-if="badge"
class="rounded-full px-[12px] py-[3px]"
:style="{ backgroundColor: pillBg, color: pillInk }"
>{{ badge }}</span>
<span v-if="workType">{{ workType }}</span>
<span v-if="year" class="opacity-70">{{ year }}</span>
</div>
<!-- Titre (+ sous-titre, lien) et présentation, alignés en haut sous la ligne d'info -->
<div class="absolute flex" :style="{ left: 'var(--fl-margin)', right: 'var(--fl-margin)', top: '66px', bottom: '12px', gap: 'var(--fl-gutter)' }">
<div class="flex flex-col" :style="{ flex: '0 0 47%' }">
<h1 :class="titleClass" :style="{ fontSize: titleSize, lineHeight: '1.05' }">{{ title }}</h1>
<p v-if="subtitle" :class="titleClass" :style="{ fontSize: `calc(${titleSize} * 0.5)`, lineHeight: '1.2', marginTop: '4px' }">{{ subtitle }}</p>
<a
v-if="link"
:href="link"
target="_blank"
rel="noopener"
class="text-current no-underline"
:style="{ marginTop: '8px' }"
>{{ linkLabel }}</a>
</div>
<div class="flex-1 overflow-hidden" :style="{ marginTop: '6px' }">
<div v-if="text" class="fl-prose" v-html="text" />
</div>
</div>
</div>
<!-- Image de couverture : calée sur la largeur (pleine largeur, fixée en
bas de page, débord haut coupé) ou sur la hauteur (pleine hauteur,
centrée). Le fond visible sous/à côté de l'image reprend la couleur
du bandeau. -->
du bandeau (blanc en mode clair). -->
<div
class="absolute inset-x-0 bottom-0 flex overflow-hidden"
:style="{ top: bandPx, backgroundColor: image ? band : '#ffffff', ...alignStyle }"
:style="{ top: bandPx, backgroundColor: isFull ? catColor : pageBg, ...alignStyle }"
>
<img
v-if="image"

View File

@@ -3,7 +3,7 @@
// Filet couleur 5 mm en haut, images ENTIÈRES (object-contain) calées sur
// une grille ADAPTATIVE : la famille choisie se construit selon le nombre
// d'images et leur orientation. Marges 8 mm, gouttières 3,8 mm.
import type { ResolvedMedia } from '~~/types'
import type { Background, ResolvedMedia } from '~~/types'
import type { ImageOrientation } from '~~/composables/useGrids'
const props = withDefaults(
@@ -11,15 +11,16 @@ const props = withDefaults(
media: ResolvedMedia[]
grid: string // id de famille (useGrids)
orientation?: ImageOrientation
category?: string
bandColor?: string
greyBg?: boolean
background?: Background // fond : blanc / teinté / plein (couleur du filet)
}>(),
{ orientation: 'mixed' },
)
const def = computed(() => gridDef(props.grid, props.media.length, props.orientation))
const strip = computed(() => stripColorOf(props.category, props.bandColor))
const strip = computed(() => filetColor(props.bandColor))
// Fond « plein » = couleur du filet (le filet est alors masqué).
const pageBg = computed(() => backgroundColorOf(props.background, strip.value))
// Métriques du canvas A3 paysage (theme.css) — zone utile hors marges 8 mm.
const MARGIN = 30.2
@@ -92,9 +93,9 @@ const items = computed(() =>
</script>
<template>
<div class="fl-page" :style="greyBg ? { backgroundColor: FL_BASE } : undefined">
<!-- Filet couleur pleine largeur -->
<div class="absolute inset-x-0 top-0" :style="{ height: 'var(--fl-strip)', backgroundColor: strip }" />
<div class="fl-page" :style="{ backgroundColor: pageBg }">
<!-- Filet couleur fin en haut (masqué en fond plein) -->
<div v-if="background?.mode !== 'full'" class="absolute inset-x-0 top-0" :style="{ height: 'var(--fl-strip-light)', backgroundColor: strip }" />
<!-- Grande image + bandeaux/file : bandeaux à leur hauteur réelle -->
<div v-if="hero" class="absolute flex" :style="{ inset: 'var(--fl-margin)', gap: 'var(--fl-gutter)' }">

View File

@@ -19,6 +19,7 @@ const props = withDefaults(
const emit = defineEmits<{ navigate: [pageNumber: number] }>()
const strip = computed(() => props.bandColor || FL_INK)
const pageStyle = computed(() => backgroundStyle(props.background, strip.value))
const isPrint = computed(() => props.renderMode === 'print')
// En print, chaque entrée est un lien interne #page-N (converti en lien PDF par
@@ -52,11 +53,11 @@ const gridStyle = computed(() => ({
</script>
<template>
<div class="fl-page" :style="backgroundStyle(background)">
<div class="absolute inset-x-0 top-0" :style="{ height: 'var(--fl-strip)', backgroundColor: strip }" />
<div class="fl-page" :style="pageStyle">
<div v-if="background.mode !== 'full'" class="absolute inset-x-0 top-0" :style="{ height: 'var(--fl-strip)', backgroundColor: strip }" />
<div class="absolute flex flex-col" :style="{ inset: 'var(--fl-margin)', top: '60px' }">
<h2 :style="{ fontSize: '60px', lineHeight: '1.1' }">{{ title }}</h2>
<h2 :style="{ fontSize: '60px', lineHeight: '1.05' }">{{ title }}</h2>
<div class="mt-14 grid min-h-0 flex-1 content-start" :style="gridStyle">
<a
v-for="entry in entries"