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:
@@ -8,6 +8,7 @@ import type { Background } from '~~/types'
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title: string // titre du portfolio — en-tête + métadonnées PDF
|
||||
body?: string // HTML : texte libre sous la phrase d'intro (plus petit, centré avec elle)
|
||||
subtitle?: string
|
||||
recipient?: string
|
||||
background: Background
|
||||
@@ -15,6 +16,7 @@ const props = withDefaults(
|
||||
date?: string // texte libre de l'en-tête (ex. « Juillet 2026 »)
|
||||
siteUrl?: string
|
||||
contactEmail?: string
|
||||
address?: string // adresse postale — angle bas-gauche
|
||||
renderMode?: 'preview' | 'print'
|
||||
}>(),
|
||||
{ renderMode: 'preview' },
|
||||
@@ -41,6 +43,11 @@ const siteLabel = computed(() => siteHref.value.replace(/^https?:\/\//, '').repl
|
||||
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())
|
||||
// Adresse coupée aux virgules : une ligne par segment (« Figures Libres » /
|
||||
// « 55 avenue… » / « 94110 Arcueil »).
|
||||
const addressLines = computed(() =>
|
||||
(props.address?.trim() || DEFAULT_ADDRESS).split(',').map(s => s.trim()).filter(Boolean),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -71,8 +78,26 @@ const displayDate = computed(() => props.date?.trim() || formatMonthYear())
|
||||
<span class="fl-font-culturelle">culturelle</span>
|
||||
qui œuvre sur logiciels libres.
|
||||
</p>
|
||||
<!-- Texte libre optionnel, plus petit, sous la phrase d'intro -->
|
||||
<div
|
||||
v-if="body"
|
||||
class="fl-prose mt-12 w-[65%]"
|
||||
:style="{ fontFamily: 'Lato, sans-serif', fontSize: '24px', lineHeight: '1.45' }"
|
||||
v-html="body"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Adresse postale dans l'angle bas-gauche (au-dessus du filet) -->
|
||||
<p
|
||||
v-if="addressLines.length"
|
||||
class="absolute left-0"
|
||||
:style="{
|
||||
bottom: background.mode === 'full' ? '0' : 'var(--fl-strip)',
|
||||
padding: 'var(--fl-margin)',
|
||||
fontFamily: 'Syne, sans-serif', fontSize: '19px', lineHeight: '1.5',
|
||||
}"
|
||||
><span v-for="(line, i) in addressLines" :key="i" class="block">{{ line }}</span></p>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
@@ -1,21 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
// Page libre (équipe, méthodologie…) — langage du gabarit : filet couleur
|
||||
// 5 mm, marges 8 mm, titre Syne 45 pt, texte Syne 12/16 pt, médias entiers
|
||||
// calés à droite. Pas de multi-colonnes CSS (Chromium les imprime en blanc).
|
||||
// 5 mm, marges 8 mm, titre Syne 45 pt, texte Syne 12/16 pt. Contenu réparti en
|
||||
// 1 à 3 colonnes de texte ou d'images, chacune avec un sous-titre optionnel.
|
||||
// Pas de multi-colonnes CSS (Chromium les imprime en blanc) : flexbox explicite.
|
||||
import type { Background, ResolvedMedia } from '~~/types'
|
||||
|
||||
interface ResolvedColumn {
|
||||
id: string
|
||||
kind: 'text' | 'media'
|
||||
heading?: string
|
||||
html?: string // colonnes texte
|
||||
media?: ResolvedMedia[] // colonnes images
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title?: string
|
||||
text?: string // HTML
|
||||
media?: ResolvedMedia[]
|
||||
columns?: ResolvedColumn[]
|
||||
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', textScale: 100 },
|
||||
{ renderMode: 'preview', textScale: 100, columns: () => [] },
|
||||
)
|
||||
|
||||
const strip = computed(() => props.bandColor || FL_INK)
|
||||
@@ -30,13 +38,31 @@ const bodyStyle = computed(() => ({ fontSize: `${(16 * props.textScale) / 100}px
|
||||
|
||||
<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.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" :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">
|
||||
<div class="mt-12 flex min-h-0 flex-1" :style="{ gap: 'var(--fl-gutter)' }">
|
||||
<section v-for="col in columns" :key="col.id" class="flex min-w-0 flex-1 flex-col">
|
||||
<!-- Sous-titre optionnel en tête de colonne -->
|
||||
<h3 v-if="col.heading" class="mb-4 shrink-0" :style="{ fontSize: '28px', lineHeight: '1.15' }">{{ col.heading }}</h3>
|
||||
|
||||
<!-- Colonne texte -->
|
||||
<div
|
||||
v-if="col.kind === 'text'"
|
||||
class="fl-prose min-h-0 flex-1 overflow-hidden"
|
||||
:style="bodyStyle"
|
||||
v-html="col.html"
|
||||
/>
|
||||
|
||||
<!-- Colonne images : entières, calées, titre du projet en légende -->
|
||||
<div v-else class="flex min-h-0 flex-1 flex-col" :style="{ gap: 'var(--fl-gutter)' }">
|
||||
<figure v-for="m in col.media" :key="m.filename" class="flex min-h-0 flex-1 flex-col">
|
||||
<img :src="m.url" :alt="m.caption || m.filename" class="min-h-0 flex-1 object-contain object-left-top">
|
||||
<figcaption
|
||||
v-if="m.caption"
|
||||
class="mt-1 shrink-0 text-left opacity-70"
|
||||
:style="{ fontFamily: 'Syne, sans-serif', fontSize: '14px', lineHeight: '1.3' }"
|
||||
>{{ m.caption }}</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
74
components/templates/OutroTemplate.vue
Normal file
74
components/templates/OutroTemplate.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
// Page de conclusion — bookend de la couverture : « Merci ! » en grand au
|
||||
// centre + rappel des contacts (site, mail, adresse tirés de la couverture).
|
||||
import type { Background } from '~~/types'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title?: string // texte central — défaut « Merci ! »
|
||||
background: Background
|
||||
bandColor?: string // filet couleur en bas
|
||||
siteUrl?: string
|
||||
contactEmail?: string
|
||||
address?: string
|
||||
renderMode?: 'preview' | 'print'
|
||||
}>(),
|
||||
{ renderMode: 'preview' },
|
||||
)
|
||||
|
||||
const strip = computed(() => props.bandColor || FL_INK)
|
||||
|
||||
// Même logique de fond que la couverture (blanc = gris charte, plein = filet).
|
||||
const pageStyle = computed(() => {
|
||||
const base
|
||||
= props.background.mode === 'full'
|
||||
? strip.value
|
||||
: props.background.mode === 'tint'
|
||||
? TINT_COLOR
|
||||
: FL_BASE
|
||||
return { backgroundColor: base, color: inkOn(base) }
|
||||
})
|
||||
|
||||
const heading = computed(() => props.title?.trim() || 'Merci !')
|
||||
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)
|
||||
// Adresse coupée aux virgules : une ligne par segment.
|
||||
const addressLines = computed(() =>
|
||||
(props.address?.trim() || DEFAULT_ADDRESS).split(',').map(s => s.trim()).filter(Boolean),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fl-page" :style="pageStyle">
|
||||
<div
|
||||
class="absolute inset-0 flex flex-col items-center justify-center"
|
||||
:style="{ padding: 'var(--fl-margin)', gap: '64px' }"
|
||||
>
|
||||
<p :style="{ fontFamily: 'Syne, sans-serif', fontSize: '150px', lineHeight: '1' }">{{ heading }}</p>
|
||||
|
||||
<!-- Rappel des contacts (liens actifs PDF/viewer) -->
|
||||
<div
|
||||
class="flex flex-col items-center"
|
||||
:style="{ fontFamily: 'Syne, sans-serif', fontSize: '22px', lineHeight: '1.6' }"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- Adresse postale dans l'angle bas-gauche (comme la couverture) -->
|
||||
<p
|
||||
v-if="addressLines.length"
|
||||
class="absolute left-0"
|
||||
:style="{
|
||||
bottom: background.mode === 'full' ? '0' : 'var(--fl-strip)',
|
||||
padding: 'var(--fl-margin)',
|
||||
fontFamily: 'Syne, sans-serif', fontSize: '19px', lineHeight: '1.5',
|
||||
}"
|
||||
><span v-for="(line, i) in addressLines" :key="i" class="block">{{ line }}</span></p>
|
||||
|
||||
<!-- 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>
|
||||
83
components/templates/TeamTemplate.vue
Normal file
83
components/templates/TeamTemplate.vue
Normal file
@@ -0,0 +1,83 @@
|
||||
<script setup lang="ts">
|
||||
// Page « équipe » — langage du gabarit : filet couleur 5 mm en haut, marges
|
||||
// 8 mm, titre Syne 45 pt. Les membres s'affichent en grille de 3 colonnes
|
||||
// (portrait, nom, rôle, présentation), qui s'adapte à leur nombre. Pas de
|
||||
// multi-colonnes CSS (Chromium les imprime en blanc) : grille explicite.
|
||||
import type { Background } from '~~/types'
|
||||
|
||||
interface ResolvedMember {
|
||||
id: string
|
||||
name: string
|
||||
portraitUrl: string
|
||||
role?: string
|
||||
bio?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title?: string
|
||||
members?: ResolvedMember[]
|
||||
background: Background
|
||||
bandColor?: string
|
||||
page: { index: number; total: number }
|
||||
renderMode?: 'preview' | 'print'
|
||||
}>(),
|
||||
{ title: 'L’équipe', renderMode: 'preview', members: () => [] },
|
||||
)
|
||||
|
||||
const strip = computed(() => props.bandColor || FL_INK)
|
||||
const pageStyle = computed(() => backgroundStyle(props.background, strip.value))
|
||||
|
||||
// 3 colonnes au plus. On prend le minimum de rangées possible, puis on répartit
|
||||
// les membres pour équilibrer la grille (4 membres → 2×2, 5 → 3+2, etc.).
|
||||
const count = computed(() => props.members.length || 1)
|
||||
const rows = computed(() => Math.ceil(count.value / 3))
|
||||
const cols = computed(() => Math.ceil(count.value / rows.value))
|
||||
const layout = computed(() => {
|
||||
if (rows.value <= 1) return { portrait: 280, name: 30, role: 20, bio: 15, top: 48, gapY: 40 }
|
||||
if (rows.value === 2) return { portrait: 168, name: 23, role: 16, bio: 12, top: 36, gapY: 28 }
|
||||
return { portrait: 124, name: 19, role: 14, bio: 10.5, top: 28, gapY: 22 }
|
||||
})
|
||||
|
||||
// Peu de colonnes sur plusieurs rangées (ex. 4 membres en 2×2) = colonnes très
|
||||
// larges : on rentre la grille des deux côtés pour resserrer les colonnes.
|
||||
const gridInset = computed(() => (rows.value >= 2 && cols.value <= 2 ? 190 : 0))
|
||||
|
||||
const gridStyle = computed(() => ({
|
||||
gridTemplateColumns: `repeat(${cols.value}, minmax(0, 1fr))`,
|
||||
columnGap: '48px',
|
||||
rowGap: `${layout.value.gapY}px`,
|
||||
gridAutoRows: 'min-content',
|
||||
marginTop: `${layout.value.top}px`,
|
||||
paddingInline: `${gridInset.value}px`,
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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.05' }">{{ title }}</h2>
|
||||
|
||||
<div class="grid min-h-0 flex-1 content-start overflow-hidden" :style="gridStyle">
|
||||
<div v-for="m in members" :key="m.id" class="flex min-w-0 flex-col">
|
||||
<!-- Portrait carré, cadré (les portraits non carrés sont recentrés) -->
|
||||
<div
|
||||
class="w-full overflow-hidden"
|
||||
:style="{ aspectRatio: '1 / 1', maxWidth: `${layout.portrait}px` }"
|
||||
>
|
||||
<img :src="m.portraitUrl" :alt="m.name" class="size-full object-cover">
|
||||
</div>
|
||||
<p class="mt-4" :style="{ fontSize: `${layout.name}px`, lineHeight: '1.15' }">{{ m.name }}</p>
|
||||
<p v-if="m.role" class="opacity-70" :style="{ fontSize: `${layout.role}px`, lineHeight: '1.2' }">{{ m.role }}</p>
|
||||
<p
|
||||
v-if="m.bio"
|
||||
class="mt-2"
|
||||
:style="{ fontSize: `${layout.bio}px`, lineHeight: '1.35', hyphens: 'auto', textAlign: 'justify' }"
|
||||
>{{ m.bio }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -63,13 +63,25 @@ const gridStyle = computed(() => ({
|
||||
v-for="entry in entries"
|
||||
:key="`${entry.pageNumber}-${entry.title}`"
|
||||
:href="hrefFor(entry.pageNumber)"
|
||||
class="flex items-baseline gap-3 text-current no-underline"
|
||||
class="flex items-baseline gap-3 self-center text-current no-underline"
|
||||
:class="{ 'cursor-pointer': !isPrint }"
|
||||
@click="onEntryClick(entry.pageNumber, $event)"
|
||||
>
|
||||
<span :class="CATEGORY_TITLE_CLASS[entry.category ?? '']" :style="{ fontSize: `${layout.size}px`, lineHeight: '1.2' }">{{ entry.title }}</span>
|
||||
<span v-if="entry.subtitle" class="whitespace-nowrap opacity-70" :style="{ fontSize: `${layout.sub}px` }">{{ entry.subtitle }}</span>
|
||||
<span class="flex-1 border-b border-dotted border-current opacity-40" />
|
||||
<!-- Filet de conduite en points dégradés (une bordure `dotted` saute
|
||||
au rendu mis à l'échelle : ici les points s'antialiasent). -->
|
||||
<span
|
||||
class="flex-1 opacity-40"
|
||||
:style="{
|
||||
alignSelf: 'baseline',
|
||||
height: '4px',
|
||||
backgroundImage: 'radial-gradient(circle at center, currentColor 1px, transparent 1.5px)',
|
||||
backgroundSize: '6px 4px',
|
||||
backgroundRepeat: 'repeat-x',
|
||||
backgroundPosition: '0 100%',
|
||||
}"
|
||||
/>
|
||||
<span class="tabular-nums opacity-80" :style="{ fontSize: `${layout.sub + 2}px` }">{{ entry.pageNumber }}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user