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
35
CLAUDE.md
@@ -31,21 +31,36 @@ Mot de passe dev : `figureslibres` (`PORTFOLIO_PASSWORD`).
|
||||
- `portfolio-store.ts` : CRUD des portfolios (JSON sur disque), gel/dégel du contenu.
|
||||
- `media.ts`, `session.ts`.
|
||||
- `server/api/` — endpoints (auth, projects, portfolios, media, sync, exports, public).
|
||||
- `components/templates/` — pages du gabarit (cover, toc, projet, grille, page libre).
|
||||
- `components/templates/` — pages du gabarit (cover, toc, projet, grille, page
|
||||
libre, équipe `TeamTemplate`, conclusion `OutroTemplate`).
|
||||
- `components/preview/` — `PageRenderer` (dispatch) + `PageViewport` (aperçu à l'échelle).
|
||||
- `components/composer/` — UI de composition (curation médias, fonds, pickers).
|
||||
- `composables/useTeam.ts` — roster statique du collectif (`TEAM_ROSTER` : nom,
|
||||
portrait, rôle et présentation par défaut de chaque membre) ; les portraits
|
||||
sont servis en statique depuis `public/portraits/`.
|
||||
- `pages/print/[id].vue` — **rendu print servant à la génération PDF** (ne pas casser).
|
||||
- `types/index.ts` — modèle de données complet et commenté (source de vérité du schéma).
|
||||
|
||||
## Modèle de données (résumé)
|
||||
|
||||
Un `Portfolio` = `cover` (CoverConfig) + `pages[]`. Types de page : `cover`,
|
||||
`toc`, `project` (couverture d'un projet, disposition `band` = bandeau haut **ou**
|
||||
`split` = colonne texte + image pleine hauteur à droite), `project-grid` (grille
|
||||
d'images entières), `free`. Le contenu Grav (`Project`, `MediaAsset`) est **en
|
||||
lecture seule**. À la génération, le contenu utilisé est **figé** dans
|
||||
`data/frozen/<id>/` (`FrozenSnapshot`) : régénérer redonne le même PDF ;
|
||||
« Réactualiser depuis Grav » resynchronise volontairement.
|
||||
Un `Portfolio` = `cover` (CoverConfig) + `pages[]`. Types de page :
|
||||
- `cover` — couverture (titre, texte libre, date, site/mail, adresse en bas à
|
||||
gauche) ; `showYears` (au niveau du portfolio) masque les années des projets.
|
||||
- `toc` — sommaire, généré depuis les pages projet (hérite du filet de la couverture).
|
||||
- `project` — couverture d'un projet, disposition `band` (bandeau haut) **ou**
|
||||
`split` (colonne texte + image pleine hauteur à droite).
|
||||
- `project-grid` — grille d'images entières.
|
||||
- `free` — page libre : 1 à 3 **colonnes** (`FreeColumn`) de texte (markdown) ou
|
||||
d'images, chacune avec sous-titre optionnel (les champs `body`/`media` legacy
|
||||
sont migrés vers `columns` à l'ouverture, avec repli au rendu disque).
|
||||
- `team` — page équipe : membres du roster en grille (≤ 3 colonnes, équilibrée),
|
||||
portrait + nom + rôle + présentation ; sélection et surcharges par membre.
|
||||
- `outro` — page de conclusion : « Merci ! » centré + rappel des contacts.
|
||||
|
||||
Le contenu Grav (`Project`, `MediaAsset`) est **en lecture seule**. À la
|
||||
génération, le contenu utilisé est **figé** dans `data/frozen/<id>/`
|
||||
(`FrozenSnapshot`) : régénérer redonne le même PDF ; « Réactualiser depuis Grav »
|
||||
resynchronise volontairement.
|
||||
|
||||
Chaque page porte un **fond unifié** `background` à trois modes :
|
||||
`white` | `tint` (aplat beige fixe `#efece3`) | `full` (la page — ou le bandeau /
|
||||
@@ -70,8 +85,8 @@ encre `FL_INK`). Il n'y a **plus** de couleur automatique par catégorie
|
||||
seule exception : le mot « Figures Libres » de la couverture (Bold).
|
||||
- **Liens Grav** : `stripTargetParam` retire le `?target=_blank` que Grav accole à
|
||||
chaque URL (lien projet + liens dans les corps de texte), à l'affichage.
|
||||
- Filet : `--fl-strip` (5 mm, couverture/sommaire/page libre) ou `--fl-strip-light`
|
||||
(2 mm, filet fin des grilles et des couvertures projet non « plein »).
|
||||
- Filet : `--fl-strip` (5 mm, couverture/sommaire/page libre/équipe/conclusion) ou
|
||||
`--fl-strip-light` (2 mm, filet fin des grilles et des couvertures projet non « plein »).
|
||||
|
||||
## Pièges Chromium print (avant de toucher aux templates)
|
||||
|
||||
|
||||
@@ -24,8 +24,10 @@ docker compose exec generator npm run build # build de prod (.out
|
||||
`grav-reader` (parse Grav → `Project`), `pdf-pipeline` (Playwright → PDF A3,
|
||||
images 2× via sharp, post-traitement Ghostscript), `portfolio-store` (gel/dégel).
|
||||
- `server/api/` — auth, projects, portfolios, media, sync, exports, public.
|
||||
- `components/` — `templates/` (cover, toc, projet, grille, page libre),
|
||||
`preview/` (rendu + aperçu), `composer/` (UI de composition).
|
||||
- `components/` — `templates/` (cover, toc, projet, grille, page libre, équipe,
|
||||
conclusion), `preview/` (rendu + aperçu), `composer/` (UI de composition).
|
||||
- `composables/useTeam.ts` — roster statique du collectif ; portraits servis en
|
||||
statique depuis `public/portraits/`.
|
||||
- `pages/print/[id].vue` — rendu print utilisé pour la génération PDF.
|
||||
- `types/index.ts` — modèle de données (source de vérité du schéma).
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -36,6 +36,7 @@ export const CATEGORY_LABELS: Record<string, string> = {
|
||||
// Valeurs par défaut du header de la vue publique (surchargées par la couverture).
|
||||
export const DEFAULT_SITE_URL = 'https://figureslibres.cc'
|
||||
export const DEFAULT_CONTACT_EMAIL = 'bonjour@figureslibres.io'
|
||||
export const DEFAULT_ADDRESS = 'Figures Libres, 55 avenue Jean Jaurès, 94110 Arcueil'
|
||||
|
||||
/**
|
||||
* URL de miniature basse résolution d'un média de projet.
|
||||
|
||||
79
composables/useTeam.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
// Roster de l'équipe du collectif Figures Libres — données statiques de l'app
|
||||
// (pas du contenu Grav). Les portraits sont servis en statique depuis
|
||||
// public/portraits/. La page « équipe » (TeamPage) référence ces membres par id
|
||||
// et peut surcharger rôle et présentation ; nom et portrait viennent d'ici.
|
||||
|
||||
export interface TeamMember {
|
||||
id: string // slug stable (= base du portrait)
|
||||
name: string
|
||||
portrait: string // fichier dans public/portraits/
|
||||
role?: string // rôle par défaut
|
||||
bio?: string // présentation par défaut
|
||||
defaultEnabled?: boolean // coché à la création de la page équipe
|
||||
}
|
||||
|
||||
export const TEAM_ROSTER: TeamMember[] = [
|
||||
{
|
||||
id: 'maud',
|
||||
name: 'Maud Boyer',
|
||||
portrait: 'maud.png',
|
||||
role: 'cheffe de projet et directrice artistique',
|
||||
bio: 'Maud travaille depuis plus de 15 ans pour les acteur.trices des services publics et les associations. Après un diplôme des Arts décoratifs de Strasbourg, un passage par l’IUFM et quelques premières expériences dans les grosses agences de communication parisiennes, Maud co-fonde en premier lieu un collectif de design graphique dédié à la communication d’utilité publique. Elle travaillera notamment pour Médecins sans frontières, le CCFD-Terre Solidaire, Oxfam, Le Grand Paris Aménagement… En 2016, Maud poursuit son engagement en mettant ses outils de production en adéquation avec les objectifs de ses client.es. C’est la transition douce vers les logiciels libres, Figures Libres est né. Rigoureuse, patiente et organisée, Maud est l’interlocutrice privilégiée pour tous vos travaux au long cours, notamment la conception des sites web, pour lesquels elle assure la gestion avec son binôme technique, Bachir Soussi-Chiadmi.',
|
||||
defaultEnabled: true,
|
||||
},
|
||||
{
|
||||
id: 'bachir',
|
||||
name: 'Bachir Soussi-Chiadmi',
|
||||
portrait: 'bachir.png',
|
||||
role: 'designer interactif et développeur',
|
||||
bio: 'Après son diplôme des Arts Décoratifs de Strasbourg qui clôture un parcours d’études débuté sous le signe des sciences, Bachir entame sa carrière professionnelle en conjuguant créativité et rigueur du code. En plongeant dans la bulle internet des années 2000 et son interactivité émergeante, Bachir se forme en autodidacte pour devenir aujourd’hui le maître incontesté de plusieurs langages de programmation (html/CSS, Javascript / Nodejs, PHP, Mysql, Python) et de plusieurs types de CMS (Drupal, SPIP, Grav, Wordpress…). Enseignant aux Beaux-Arts du Havre et fondateur de l’association O.L.A. (Outils Libres Alternatifs) pour la promotion des logiciels libres de création, Bachir maitrise également l’écosystème Linux, la gestion et la maintenance de serveurs et maîtrise les machines aussi bien que le code qui les dirige. Sa vision de « l’information en mouvement » produit les sites web les plus complexes dotés d’une interface fluide et intuitive.',
|
||||
defaultEnabled: true,
|
||||
},
|
||||
{
|
||||
id: 'sandrine',
|
||||
name: 'Sandrine Ripoll',
|
||||
portrait: 'sandrine.png',
|
||||
role: 'cheffe de projet et directrice artistique',
|
||||
bio: 'Sandrine, fondatrice du collectif « Entrez-sans-frapper » en 2001 avec Maud Boyer, est aujourd’hui en pilotage de la plupart des projets de Figures Libres. Après un début professionnel dans un grand groupe industriel français, Sandrine rejoint le service communication de la ville d’Ivry-sur-Seine où elle apprend les enjeux de la communication locale. Toujours à la recherche du visuel qui fait sens pour un impact maximum, c’est la spécialiste des campagnes de communication grand public et des visuels de plaidoyer. Forte de ses compétences rédactionnelles, elle est aussi en charge du travail éditorial et du rewriting de contenus. Passionnée de façonnage, de couleur et de texture de papier, Sandrine est la responsable fabrication de l’agence, également en charge des projets de scénographie. Comme une évidence, dans son parcours, elle s’engage en tant que bénévole dans des associations locales en tant que militante féministe.',
|
||||
defaultEnabled: true,
|
||||
},
|
||||
{
|
||||
id: 'chloe',
|
||||
name: 'Chloé Listrat',
|
||||
portrait: 'chloe.png',
|
||||
role: 'graphiste',
|
||||
bio: 'Après six mois de stage intensif chez Figures Libres en 2020, Chloé Listrat rejoint l’équipe à la suite de son Master en Design Graphique et multimédia mené à Rennes. Enthousiaste et généreuse dans ses propositions créatives, elle analyse avant tout les sujets abordés pour concevoir la réponse adaptée qui a du sens. De cette génération élevée à l’image et au rythme des réseaux sociaux, elle met son talent en mouvement pour concevoir motions design et montages vidéos. L’œil toujours ouvert sur le monde, Chloé mène une vie d’engagement associatif intense entre éducation populaire et initiation à la sérigraphie dans les tiers-lieux du Val-de-Marne.',
|
||||
defaultEnabled: true,
|
||||
},
|
||||
{
|
||||
id: 'ouidade',
|
||||
name: 'Ouidade Soussi-Chiadmi',
|
||||
portrait: 'ouidade.png',
|
||||
role: 'photographe et développeuse',
|
||||
bio: 'Ouidade commence son parcours créatif en passant par l’école des Arts décoratifs de Paris. Photographe indépendant pour les centres culturels parisiens et les milieux de l’hôtellerie du luxe, Ouidade poursuit son parcours en intégrant l’agence Figures Libres en 2017. Formées aux logiciels de traitement d’images Gimp et Darktable, Ouidade est la spécialiste du montage et de la retouche d’image. En 2020, elle entame une formation de développeuse web et assure aujourd’hui la gestion et la réalisation des sites.',
|
||||
defaultEnabled: true,
|
||||
},
|
||||
{
|
||||
id: 'val',
|
||||
name: 'Valentin Le Moign',
|
||||
portrait: 'val.png',
|
||||
role: 'graphiste et développeur',
|
||||
bio: 'Valentin diplômé de l’École d’art du Havre (qu’il a quitté sous les félicitations du Jury), est aussi à l’aise dans la conception de supports print que de sites web. Curieux des nouveautés, il affine ses compétences de graphiste et de développeur au gré des projets de l’agence. Naturellement, il s’installe dans sa région de cœur, la Bretagne, pour mener de front une vie de professeur en design et designer web pour le collectif en accord avec ses engagements sociaux et citoyens.',
|
||||
defaultEnabled: true,
|
||||
},
|
||||
// Autres membres du roster — décochés par défaut ; rôle et présentation à
|
||||
// renseigner dans l'UI de composition.
|
||||
{ id: 'arman', name: 'Arman', portrait: 'arman.png' },
|
||||
{ id: 'cathy', name: 'Cathy', portrait: 'cathy.png' },
|
||||
{ id: 'hugo', name: 'Hugo', portrait: 'hugo.png' },
|
||||
{ id: 'jean-elie', name: 'Jean-Élie', portrait: 'jean-elie.png' },
|
||||
{ id: 'julia', name: 'Julia', portrait: 'julia.png' },
|
||||
{ id: 'lilia', name: 'Lilia', portrait: 'Lilia.png' },
|
||||
{ id: 'marion', name: 'Marion', portrait: 'marion.png' },
|
||||
{ id: 'sabine', name: 'Sabine', portrait: 'sabine.png' },
|
||||
]
|
||||
|
||||
/** URL statique du portrait d'un membre (servi depuis public/portraits/). */
|
||||
export function portraitUrl(portrait: string): string {
|
||||
return `/portraits/${portrait}`
|
||||
}
|
||||
251
package-lock.json
generated
@@ -13,7 +13,6 @@
|
||||
"marked": "^15.0.0",
|
||||
"nanoid": "^5.1.0",
|
||||
"nuxt": "^3.17.0",
|
||||
"pagedjs": "^0.4.3",
|
||||
"playwright": "1.61.1",
|
||||
"sharp": "^0.34.0",
|
||||
"vuedraggable": "^4.1.0",
|
||||
@@ -406,26 +405,6 @@
|
||||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/polyfill": {
|
||||
"version": "7.12.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/polyfill/-/polyfill-7.12.1.tgz",
|
||||
"integrity": "sha512-X0pi0V6gxLi6lFZpGmeNa4zxtwEmCs42isWLNjZZDE0Y8yVfgu0T2OAHlzBbdYlqbW/YXVvoBHpATEM+goCj8g==",
|
||||
"deprecated": "🚨 This package has been deprecated in favor of separate inclusion of a polyfill and regenerator-runtime (when needed). See the @babel/polyfill docs (https://babeljs.io/docs/en/babel-polyfill) for more information.",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-js": "^2.6.5",
|
||||
"regenerator-runtime": "^0.13.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
|
||||
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/template": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
|
||||
@@ -5790,6 +5769,66 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
||||
"version": "1.11.1",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.2",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.1",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.2",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.4",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@tybys/wasm-util": "^0.10.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emnapi/core": "^1.7.1",
|
||||
"@emnapi/runtime": "^1.7.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.2",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"inBundle": true,
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz",
|
||||
@@ -7132,12 +7171,6 @@
|
||||
"integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/clear-cut": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/clear-cut/-/clear-cut-2.0.2.tgz",
|
||||
"integrity": "sha512-WVgn/gSejQ+0aoR8ucbKIdo6icduPZW6AbWwyUmAUgxy63rUYjwa5rj/HeoNPhf0/XPrl82X8bO/hwBkSmsFtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
|
||||
@@ -7194,16 +7227,6 @@
|
||||
"integrity": "sha512-Z3UPUKasUVDFCDYAjP2fmlVRf1jFHJv1izAmPjiOa0OCIw1W7iC8PZ2GsoDa8uZv+mKyWopxxStT9q05+27h7w==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "13.1.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz",
|
||||
"integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==",
|
||||
"extraneous": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/commondir": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
|
||||
@@ -7271,14 +7294,6 @@
|
||||
"integrity": "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/core-js": {
|
||||
"version": "2.6.12",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz",
|
||||
"integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==",
|
||||
"deprecated": "core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/core-util-is": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||
@@ -7541,19 +7556,6 @@
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz",
|
||||
"integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"es5-ext": "^0.10.64",
|
||||
"type": "^2.7.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
}
|
||||
},
|
||||
"node_modules/db0": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/db0/-/db0-0.3.4.tgz",
|
||||
@@ -7990,46 +7992,6 @@
|
||||
"integrity": "sha512-3lGxdTXCLfe1MYfTz1y2ksAAUM4NAOP6rPEjxGJVKO7TZ5+tvHCaQWGpC4Y3IXvW3ece0Cz1cIP4FWBxOnGCTQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/es5-ext": {
|
||||
"version": "0.10.64",
|
||||
"resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz",
|
||||
"integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"es6-iterator": "^2.0.3",
|
||||
"es6-symbol": "^3.1.3",
|
||||
"esniff": "^2.0.1",
|
||||
"next-tick": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/es6-iterator": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz",
|
||||
"integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"d": "1",
|
||||
"es5-ext": "^0.10.35",
|
||||
"es6-symbol": "^3.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/es6-symbol": {
|
||||
"version": "3.1.4",
|
||||
"resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.4.tgz",
|
||||
"integrity": "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d": "^1.0.2",
|
||||
"ext": "^1.7.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
||||
@@ -8098,21 +8060,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/esniff": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz",
|
||||
"integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d": "^1.0.1",
|
||||
"es5-ext": "^0.10.62",
|
||||
"event-emitter": "^0.3.5",
|
||||
"type": "^2.7.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/estree-walker": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||
@@ -8131,16 +8078,6 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/event-emitter": {
|
||||
"version": "0.3.5",
|
||||
"resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz",
|
||||
"integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"d": "1",
|
||||
"es5-ext": "~0.10.14"
|
||||
}
|
||||
},
|
||||
"node_modules/event-target-shim": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
|
||||
@@ -8197,15 +8134,6 @@
|
||||
"integrity": "sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ext": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz",
|
||||
"integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"type": "^2.7.2"
|
||||
}
|
||||
},
|
||||
"node_modules/externality": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/externality/-/externality-1.0.2.tgz",
|
||||
@@ -9786,12 +9714,6 @@
|
||||
"integrity": "sha512-Kv2JYYiCzt16Kt5QwAc9BFG89xfPNBx+oQL4GQXD9nLqPkZBiNaqaCWtwnbk/q7UVsTYevvM1b0UF8zmEI4pCg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/next-tick": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz",
|
||||
"integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/nitropack": {
|
||||
"version": "2.13.4",
|
||||
"resolved": "https://registry.npmjs.org/nitropack/-/nitropack-2.13.4.tgz",
|
||||
@@ -10535,47 +10457,6 @@
|
||||
"integrity": "sha512-xg1eHpwYL/D/HEdWw2goFZP6vV0FH7W+PZ5rFkGjdIDLtxq7EkzBUeT3m+lndYCt8wKbmofUu1MUdMCXkCk9ZQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pagedjs": {
|
||||
"version": "0.4.3",
|
||||
"resolved": "https://registry.npmjs.org/pagedjs/-/pagedjs-0.4.3.tgz",
|
||||
"integrity": "sha512-YtAN9JAjsQw1142gxEjEAwXvOF5nYQuDwnQ67RW2HZDkMLI+b4RsBE37lULZa9gAr6kDAOGBOhXI4wGMoY3raw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/polyfill": "^7.10.1",
|
||||
"@babel/runtime": "^7.21.0",
|
||||
"clear-cut": "^2.0.2",
|
||||
"css-tree": "^1.1.3",
|
||||
"event-emitter": "^0.3.5"
|
||||
}
|
||||
},
|
||||
"node_modules/pagedjs/node_modules/css-tree": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz",
|
||||
"integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mdn-data": "2.0.14",
|
||||
"source-map": "^0.6.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pagedjs/node_modules/mdn-data": {
|
||||
"version": "2.0.14",
|
||||
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz",
|
||||
"integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==",
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/pagedjs/node_modules/source-map": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "0.2.9",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
|
||||
@@ -11393,12 +11274,6 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/regenerator-runtime": {
|
||||
"version": "0.13.11",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
|
||||
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/regexp-tree": {
|
||||
"version": "0.1.27",
|
||||
"resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz",
|
||||
@@ -12480,12 +12355,6 @@
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/type": {
|
||||
"version": "2.7.3",
|
||||
"resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz",
|
||||
"integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/type-fest": {
|
||||
"version": "5.7.0",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.7.0.tgz",
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
"marked": "^15.0.0",
|
||||
"nanoid": "^5.1.0",
|
||||
"nuxt": "^3.17.0",
|
||||
"pagedjs": "^0.4.3",
|
||||
"playwright": "1.61.1",
|
||||
"sharp": "^0.34.0",
|
||||
"vuedraggable": "^4.1.0",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// overrides, couleurs de bandeau — avec aperçu WYSIWYG central.
|
||||
import draggable from 'vuedraggable'
|
||||
import { nanoid } from 'nanoid'
|
||||
import type { FreePage, Page, Portfolio, Project, ProjectGridPage, ProjectPage, TocPage } from '~~/types'
|
||||
import type { FreeColumn, FreePage, OutroPage, Page, Portfolio, Project, ProjectGridPage, ProjectPage, TeamPage, TocPage } from '~~/types'
|
||||
|
||||
const route = useRoute()
|
||||
const toast = useToast()
|
||||
@@ -23,6 +23,20 @@ const projects = computed<Record<string, Project>>(() =>
|
||||
Object.fromEntries((projectsData.value?.projects ?? []).map(p => [p.id, p])),
|
||||
)
|
||||
|
||||
// Migration des pages libres legacy (body/media) → colonnes. Faite avant
|
||||
// l'enregistrement du watch d'autosave (donc sans sauvegarde parasite) ; se
|
||||
// persistera à la première vraie édition. Le rendu gère aussi le repli legacy.
|
||||
for (const p of portfolio.value.pages) {
|
||||
if (p.kind === 'free' && !p.columns) {
|
||||
const cols: FreeColumn[] = []
|
||||
if (p.body?.trim()) cols.push({ id: nanoid(6), kind: 'text', body: p.body })
|
||||
if (p.media?.length) cols.push({ id: nanoid(6), kind: 'media', media: [...p.media] })
|
||||
p.columns = cols.length ? cols : [{ id: nanoid(6), kind: 'text', body: '' }]
|
||||
p.body = undefined
|
||||
p.media = undefined
|
||||
}
|
||||
}
|
||||
|
||||
const selectedPageId = ref<string | undefined>(portfolio.value.pages[0]?.id)
|
||||
const selectedIndex = computed(() => portfolio.value.pages.findIndex(p => p.id === selectedPageId.value))
|
||||
const selectedPage = computed<Page | undefined>(() => portfolio.value.pages[selectedIndex.value])
|
||||
@@ -190,14 +204,43 @@ function addFreePage() {
|
||||
id: nanoid(8),
|
||||
template: 'free-text',
|
||||
title: 'Page libre',
|
||||
body: '',
|
||||
media: [],
|
||||
columns: [{ id: nanoid(6), kind: 'text', body: '' }],
|
||||
background: { mode: 'white' },
|
||||
}
|
||||
portfolio.value.pages.push(page)
|
||||
selectPage(page.id)
|
||||
}
|
||||
|
||||
// ---- Colonnes des pages libres (1 à 3, texte ou images) ----
|
||||
const MAX_FREE_COLUMNS = 3
|
||||
|
||||
function addFreeColumn() {
|
||||
if (selectedPage.value?.kind !== 'free') return
|
||||
const cols = selectedPage.value.columns ?? (selectedPage.value.columns = [])
|
||||
if (cols.length >= MAX_FREE_COLUMNS) return
|
||||
cols.push({ id: nanoid(6), kind: 'text', body: '' })
|
||||
}
|
||||
|
||||
function removeFreeColumn(id: string) {
|
||||
if (selectedPage.value?.kind !== 'free' || !selectedPage.value.columns) return
|
||||
if (selectedPage.value.columns.length <= 1) return // au moins une colonne
|
||||
selectedPage.value.columns = selectedPage.value.columns.filter(c => c.id !== id)
|
||||
}
|
||||
|
||||
function setFreeColumnKind(col: FreeColumn, kind: FreeColumn['kind']) {
|
||||
if (col.kind === kind) return
|
||||
col.kind = kind
|
||||
// On conserve les deux contenus : repasser sur l'autre type retrouve ce qui
|
||||
// avait été saisi. Seul le champ actif est initialisé s'il est vide.
|
||||
if (kind === 'text') col.body ??= ''
|
||||
else col.media ??= []
|
||||
}
|
||||
|
||||
const freeColumnKinds = [
|
||||
{ value: 'text', label: 'Texte', icon: 'i-lucide-text' },
|
||||
{ value: 'media', label: 'Images', icon: 'i-lucide-image' },
|
||||
] as const
|
||||
|
||||
function addTocPage() {
|
||||
const page: TocPage = { kind: 'toc', id: nanoid(8), title: 'Sommaire', background: { mode: 'white' } }
|
||||
// Le sommaire s'insère après la couverture par convention
|
||||
@@ -212,6 +255,35 @@ function addCoverPage() {
|
||||
selectPage(page.id)
|
||||
}
|
||||
|
||||
function addOutroPage() {
|
||||
// Page de conclusion : à la fin par convention.
|
||||
const page: OutroPage = { kind: 'outro', id: nanoid(8), title: 'Merci !', background: { mode: 'white' } }
|
||||
portfolio.value.pages.push(page)
|
||||
selectPage(page.id)
|
||||
}
|
||||
|
||||
function addTeamPage() {
|
||||
// Membres seedés depuis le roster : les défauts sont cochés, chaque membre
|
||||
// porte son rôle et sa présentation (éditables ensuite).
|
||||
const page: TeamPage = {
|
||||
kind: 'team',
|
||||
id: nanoid(8),
|
||||
title: 'L’équipe',
|
||||
members: TEAM_ROSTER.map(m => ({ id: m.id, enabled: !!m.defaultEnabled, role: m.role, bio: m.bio })),
|
||||
background: { mode: 'white' },
|
||||
}
|
||||
portfolio.value.pages.push(page)
|
||||
selectPage(page.id)
|
||||
}
|
||||
|
||||
const rosterById = (id: string) => TEAM_ROSTER.find(m => m.id === id)
|
||||
const enabledMemberCount = computed(() =>
|
||||
selectedPage.value?.kind === 'team' ? selectedPage.value.members.filter(m => m.enabled).length : 0,
|
||||
)
|
||||
// Sections de personnalisation (rôle + présentation) repliées par défaut ;
|
||||
// dépliées membre par membre via le chevron.
|
||||
const expandedMembers = ref<Record<string, boolean>>({})
|
||||
|
||||
function removePage(id: string) {
|
||||
const i = portfolio.value.pages.findIndex(p => p.id === id)
|
||||
if (i === -1) return
|
||||
@@ -222,12 +294,16 @@ function removePage(id: string) {
|
||||
}
|
||||
|
||||
const hasCover = computed(() => portfolio.value.pages.some(p => p.kind === 'cover'))
|
||||
const hasOutro = computed(() => portfolio.value.pages.some(p => p.kind === 'outro'))
|
||||
const hasTeam = computed(() => portfolio.value.pages.some(p => p.kind === 'team'))
|
||||
|
||||
const addItems = computed(() => [[
|
||||
{ label: 'Projet (couverture + grille)…', icon: 'i-lucide-image', onSelect: () => (showProjectPicker.value = true) },
|
||||
{ label: 'Page libre', icon: 'i-lucide-text', onSelect: addFreePage },
|
||||
{ label: 'Sommaire', icon: 'i-lucide-list', onSelect: addTocPage },
|
||||
...(hasTeam.value ? [] : [{ label: 'Équipe', icon: 'i-lucide-users', onSelect: addTeamPage }]),
|
||||
...(hasCover.value ? [] : [{ label: 'Couverture', icon: 'i-lucide-bookmark', onSelect: addCoverPage }]),
|
||||
...(hasOutro.value ? [] : [{ label: 'Conclusion', icon: 'i-lucide-heart', onSelect: addOutroPage }]),
|
||||
]])
|
||||
|
||||
// ---- Libellés de la liste de pages ----
|
||||
@@ -238,6 +314,8 @@ function pageLabel(page: Page): string {
|
||||
case 'free': return page.title || 'Page libre'
|
||||
case 'project': return projects.value[page.projectId]?.title ?? page.projectId
|
||||
case 'project-grid': return projects.value[page.projectId]?.title ?? page.projectId
|
||||
case 'outro': return page.title || 'Conclusion'
|
||||
case 'team': return page.title || 'Équipe'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,6 +325,8 @@ const KIND_LABELS: Record<Page['kind'], string> = {
|
||||
'free': 'Page libre',
|
||||
'project': 'Projet — couverture',
|
||||
'project-grid': 'Projet — grille',
|
||||
'outro': 'Conclusion',
|
||||
'team': 'Équipe',
|
||||
}
|
||||
|
||||
// ---- Encre du bandeau (pages projet) ----
|
||||
@@ -356,18 +436,21 @@ function resetPresentation() {
|
||||
|
||||
// ---- Image de couverture / médias de page libre ----
|
||||
const showMediaPicker = ref(false)
|
||||
const mediaPickerTarget = ref<'cover' | 'free'>('cover')
|
||||
type MediaPickerTarget = { type: 'cover' } | { type: 'free-column'; columnId: string }
|
||||
const mediaPickerTarget = ref<MediaPickerTarget>({ type: 'cover' })
|
||||
|
||||
function openMediaPicker(target: 'cover' | 'free') {
|
||||
function openMediaPicker(target: MediaPickerTarget) {
|
||||
mediaPickerTarget.value = target
|
||||
showMediaPicker.value = true
|
||||
}
|
||||
|
||||
function onMediaPicked(ref_: string) {
|
||||
if (mediaPickerTarget.value === 'cover') {
|
||||
const target = mediaPickerTarget.value
|
||||
if (target.type === 'cover') {
|
||||
portfolio.value.cover.backgroundImage = ref_
|
||||
} else if (selectedPage.value?.kind === 'free') {
|
||||
selectedPage.value.media = [...(selectedPage.value.media ?? []), ref_]
|
||||
const col = selectedPage.value.columns?.find(c => c.id === target.columnId)
|
||||
if (col?.kind === 'media') col.media = [...(col.media ?? []), ref_]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -610,6 +693,9 @@ async function copyShareUrl() {
|
||||
<UFormField label="Titre">
|
||||
<UInput v-model="portfolio.cover.title" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="Texte libre">
|
||||
<UTextarea v-model="portfolio.cover.body" :rows="5" class="w-full" autoresize />
|
||||
</UFormField>
|
||||
<UFormField label="Date">
|
||||
<UInput v-model="portfolio.cover.date" class="w-full" :placeholder="formatMonthYear()" />
|
||||
</UFormField>
|
||||
@@ -619,8 +705,16 @@ async function copyShareUrl() {
|
||||
<UFormField label="Mail">
|
||||
<UInput v-model="portfolio.cover.contactEmail" class="w-full" :placeholder="DEFAULT_CONTACT_EMAIL" />
|
||||
</UFormField>
|
||||
<UFormField label="Adresse">
|
||||
<UInput v-model="portfolio.cover.address" class="w-full" :placeholder="DEFAULT_ADDRESS" />
|
||||
</UFormField>
|
||||
<BandColorPicker v-model="selectedPage.bandColor" label="Couleur du filet" :default-color="FL_INK" />
|
||||
<BackgroundEditor v-model="selectedPage.background" />
|
||||
<UCheckbox
|
||||
:model-value="portfolio.showYears !== false"
|
||||
label="Afficher les années des projets"
|
||||
@update:model-value="portfolio.showYears = $event === true"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -636,6 +730,80 @@ async function copyShareUrl() {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Conclusion -->
|
||||
<template v-else-if="selectedPage.kind === 'outro'">
|
||||
<h3 class="mb-4 font-semibold">Conclusion</h3>
|
||||
<div class="space-y-4">
|
||||
<UFormField label="Texte">
|
||||
<UInput v-model="selectedPage.title" class="w-full" placeholder="Merci !" />
|
||||
</UFormField>
|
||||
<BandColorPicker v-model="selectedPage.bandColor" label="Couleur du filet" :default-color="FL_INK" />
|
||||
<BackgroundEditor v-model="selectedPage.background" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Équipe -->
|
||||
<template v-else-if="selectedPage.kind === 'team'">
|
||||
<h3 class="mb-4 font-semibold">Équipe</h3>
|
||||
<div class="space-y-4">
|
||||
<UFormField label="Titre">
|
||||
<UInput v-model="selectedPage.title" class="w-full" placeholder="L’équipe" />
|
||||
</UFormField>
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-neutral-500">
|
||||
Membres ({{ enabledMemberCount }}/{{ selectedPage.members.length }})
|
||||
</p>
|
||||
<draggable
|
||||
v-model="selectedPage.members"
|
||||
item-key="id"
|
||||
handle=".member-drag-handle"
|
||||
:animation="150"
|
||||
class="space-y-2"
|
||||
>
|
||||
<template #item="{ element: m }">
|
||||
<div class="rounded-lg border border-neutral-200 p-3 dark:border-neutral-800">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<UIcon name="i-lucide-grip-vertical" class="member-drag-handle size-4 shrink-0 cursor-grab text-neutral-400" />
|
||||
<UCheckbox v-model="m.enabled" />
|
||||
<span class="truncate text-sm font-medium">{{ rosterById(m.id)?.name }}</span>
|
||||
<UButton
|
||||
v-if="m.enabled"
|
||||
:icon="expandedMembers[m.id] ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
class="ml-auto"
|
||||
:aria-label="expandedMembers[m.id] ? 'Replier' : 'Personnaliser'"
|
||||
@click="expandedMembers[m.id] = !expandedMembers[m.id]"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="m.enabled && expandedMembers[m.id]" class="mt-3 space-y-2">
|
||||
<UFormField label="Rôle">
|
||||
<UInput v-model="m.role" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="Présentation">
|
||||
<UTextarea v-model="m.bio" :rows="5" class="w-full" autoresize />
|
||||
<div v-if="m.bio !== rosterById(m.id)?.bio" class="mt-1.5 flex justify-end">
|
||||
<UButton
|
||||
icon="i-lucide-rotate-ccw"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
label="Réinitialiser"
|
||||
@click="m.bio = rosterById(m.id)?.bio"
|
||||
/>
|
||||
</div>
|
||||
</UFormField>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
</div>
|
||||
<BandColorPicker v-model="selectedPage.bandColor" label="Couleur du filet" :default-color="FL_INK" />
|
||||
<BackgroundEditor v-model="selectedPage.background" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Page libre -->
|
||||
<template v-else-if="selectedPage.kind === 'free'">
|
||||
<h3 class="mb-4 font-semibold">Page libre</h3>
|
||||
@@ -643,38 +811,96 @@ async function copyShareUrl() {
|
||||
<UFormField label="Titre">
|
||||
<UInput v-model="selectedPage.title" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="Texte">
|
||||
<UTextarea v-model="selectedPage.body" :rows="10" class="w-full" />
|
||||
</UFormField>
|
||||
|
||||
<!-- Colonnes (1 à 3, texte ou images) -->
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-neutral-500">
|
||||
Colonnes ({{ selectedPage.columns?.length ?? 0 }}/{{ MAX_FREE_COLUMNS }})
|
||||
</p>
|
||||
<UButton
|
||||
v-if="(selectedPage.columns?.length ?? 0) < MAX_FREE_COLUMNS"
|
||||
icon="i-lucide-plus"
|
||||
label="Colonne"
|
||||
size="xs"
|
||||
variant="soft"
|
||||
@click="addFreeColumn"
|
||||
/>
|
||||
</div>
|
||||
<draggable
|
||||
v-if="selectedPage.columns"
|
||||
v-model="selectedPage.columns"
|
||||
item-key="id"
|
||||
handle=".col-drag-handle"
|
||||
:animation="150"
|
||||
class="space-y-3"
|
||||
>
|
||||
<template #item="{ element: col, index: ci }">
|
||||
<div class="space-y-3 rounded-lg border border-neutral-200 p-3 dark:border-neutral-800">
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon name="i-lucide-grip-vertical" class="col-drag-handle size-4 shrink-0 cursor-grab text-neutral-400" />
|
||||
<div class="flex gap-1">
|
||||
<UButton
|
||||
v-for="opt in freeColumnKinds"
|
||||
:key="opt.value"
|
||||
:icon="opt.icon"
|
||||
:label="opt.label"
|
||||
size="xs"
|
||||
:variant="col.kind === opt.value ? 'solid' : 'outline'"
|
||||
color="neutral"
|
||||
@click="setFreeColumnKind(col, opt.value)"
|
||||
/>
|
||||
</div>
|
||||
<UButton
|
||||
v-if="(selectedPage.columns?.length ?? 1) > 1"
|
||||
icon="i-lucide-trash-2"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
class="ml-auto"
|
||||
:aria-label="`Supprimer la colonne ${ci + 1}`"
|
||||
@click="removeFreeColumn(col.id)"
|
||||
/>
|
||||
</div>
|
||||
<UFormField label="Sous-titre">
|
||||
<UInput v-model="col.heading" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField v-if="col.kind === 'text'" label="Texte">
|
||||
<UTextarea v-model="col.body" :rows="8" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField v-else :label="`Images (${col.media?.length ?? 0}/4)`">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<div v-for="(ref_, i) in col.media" :key="ref_" class="relative size-16 overflow-hidden rounded border">
|
||||
<img :src="mediaUrl(ref_.slice(0, ref_.lastIndexOf('/')), ref_.slice(ref_.lastIndexOf('/') + 1), 160)" class="size-full object-cover">
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-0 top-0 flex size-4 items-center justify-center rounded-bl bg-black/60 text-white"
|
||||
@click="col.media!.splice(i, 1)"
|
||||
>
|
||||
<UIcon name="i-lucide-x" class="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
<UButton
|
||||
v-if="(col.media?.length ?? 0) < 4"
|
||||
icon="i-lucide-plus"
|
||||
size="xs"
|
||||
variant="soft"
|
||||
class="size-16 justify-center"
|
||||
@click="openMediaPicker({ type: 'free-column', columnId: col.id })"
|
||||
/>
|
||||
</div>
|
||||
</UFormField>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-neutral-500">
|
||||
Taille du texte — {{ freeTextScale }} %
|
||||
</p>
|
||||
<USlider v-model="freeTextScale" :min="100" :max="180" :step="10" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-neutral-500">Images ({{ selectedPage.media?.length ?? 0 }}/3)</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<div v-for="(ref_, i) in selectedPage.media" :key="ref_" class="relative size-16 overflow-hidden rounded border">
|
||||
<img :src="mediaUrl(ref_.slice(0, ref_.lastIndexOf('/')), ref_.slice(ref_.lastIndexOf('/') + 1), 160)" class="size-full object-cover">
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-0 top-0 flex size-4 items-center justify-center rounded-bl bg-black/60 text-white"
|
||||
@click="selectedPage.media!.splice(i, 1)"
|
||||
>
|
||||
<UIcon name="i-lucide-x" class="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
<UButton
|
||||
v-if="(selectedPage.media?.length ?? 0) < 3"
|
||||
icon="i-lucide-plus"
|
||||
size="xs"
|
||||
variant="soft"
|
||||
class="size-16 justify-center"
|
||||
@click="openMediaPicker('free')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<BandColorPicker v-model="selectedPage.bandColor" label="Couleur du filet" :default-color="FL_INK" />
|
||||
<BackgroundEditor v-model="selectedPage.background" />
|
||||
</div>
|
||||
|
||||
@@ -51,6 +51,8 @@ function pageLabel(page: Page): string {
|
||||
case 'free': return page.title || 'Page libre'
|
||||
case 'project':
|
||||
case 'project-grid': return projects.value[page.projectId]?.title ?? page.projectId
|
||||
case 'outro': return page.title || 'Conclusion'
|
||||
case 'team': return page.title || 'Équipe'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
BIN
public/portraits/Lilia.png
Normal file
|
After Width: | Height: | Size: 274 KiB |
BIN
public/portraits/arman.png
Normal file
|
After Width: | Height: | Size: 292 KiB |
BIN
public/portraits/bachir.png
Normal file
|
After Width: | Height: | Size: 164 KiB |
BIN
public/portraits/cathy.png
Normal file
|
After Width: | Height: | Size: 412 KiB |
BIN
public/portraits/chloe.png
Normal file
|
After Width: | Height: | Size: 377 KiB |
BIN
public/portraits/hugo.png
Normal file
|
After Width: | Height: | Size: 162 KiB |
BIN
public/portraits/jean-elie.png
Normal file
|
After Width: | Height: | Size: 192 KiB |
BIN
public/portraits/julia.png
Normal file
|
After Width: | Height: | Size: 202 KiB |
BIN
public/portraits/marion.png
Normal file
|
After Width: | Height: | Size: 562 KiB |
BIN
public/portraits/maud.png
Normal file
|
After Width: | Height: | Size: 520 KiB |
BIN
public/portraits/ouidade.png
Normal file
|
After Width: | Height: | Size: 187 KiB |
BIN
public/portraits/sabine.png
Normal file
|
After Width: | Height: | Size: 169 KiB |
BIN
public/portraits/sandrine.png
Normal file
|
After Width: | Height: | Size: 298 KiB |
BIN
public/portraits/val.png
Normal file
|
After Width: | Height: | Size: 184 KiB |
@@ -53,6 +53,7 @@ export interface ResolvedMedia {
|
||||
url: string
|
||||
posterUrl?: string
|
||||
ratio?: number // largeur / hauteur intrinsèque (sert au dimensionnement des bandeaux)
|
||||
caption?: string // légende affichée sous l'image (ex. titre du projet sur les pages libres)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -95,13 +96,25 @@ export interface ProjectGridPage {
|
||||
// couverture du même projet.
|
||||
}
|
||||
|
||||
export type FreeColumnKind = 'text' | 'media'
|
||||
|
||||
/** Colonne d'une page libre : soit du texte (markdown), soit des images. */
|
||||
export interface FreeColumn {
|
||||
id: string
|
||||
kind: FreeColumnKind
|
||||
heading?: string // sous-titre optionnel en tête de colonne
|
||||
body?: string // markdown (colonnes texte)
|
||||
media?: string[] // "projectId/filename" (colonnes images)
|
||||
}
|
||||
|
||||
export interface FreePage {
|
||||
kind: 'free'
|
||||
id: string
|
||||
template: 'free-text'
|
||||
title?: string
|
||||
body?: string // markdown saisi dans l'UI
|
||||
media?: string[] // "projectId/filename" optionnels
|
||||
columns?: FreeColumn[] // 1 à 3 colonnes de texte ou d'images
|
||||
body?: string // legacy (mono-colonne texte) — migré vers columns à l'ouverture
|
||||
media?: string[] // legacy (colonne images) — migré vers columns à l'ouverture
|
||||
background: Background // blanc / teinté / plein (couleur du filet)
|
||||
bandColor?: string // filet haut optionnel
|
||||
textScale?: number // taille du texte de labeur en % (défaut 100)
|
||||
@@ -122,17 +135,54 @@ export interface TocPage {
|
||||
// Pas de couleur de filet propre : il hérite du filet de la couverture.
|
||||
}
|
||||
|
||||
export type Page = ProjectPage | ProjectGridPage | FreePage | CoverPage | TocPage
|
||||
/**
|
||||
* Page de conclusion : « Merci ! » en grand au centre + rappel des contacts
|
||||
* (site, mail, adresse tirés de la couverture). Bookend de la couverture.
|
||||
*/
|
||||
export interface OutroPage {
|
||||
kind: 'outro'
|
||||
id: string
|
||||
title?: string // texte central — défaut « Merci ! »
|
||||
background: Background // blanc / teinté / plein (couleur du filet)
|
||||
bandColor?: string // filet couleur en bas — défaut encre
|
||||
}
|
||||
|
||||
/** Membre affiché sur la page équipe. Nom et portrait viennent du roster
|
||||
* statique (composables/useTeam) ; rôle et présentation sont surchargeables. */
|
||||
export interface TeamMemberConfig {
|
||||
id: string // référence un membre de TEAM_ROSTER
|
||||
enabled: boolean // affiché dans la grille ?
|
||||
role?: string // rôle (défaut : celui du roster)
|
||||
bio?: string // présentation (défaut : celle du roster)
|
||||
}
|
||||
|
||||
/**
|
||||
* Page « équipe » : présente les membres du collectif en grille de 3 colonnes
|
||||
* (portrait, nom, rôle, présentation). La grille s'adapte au nombre de membres
|
||||
* cochés. Titre par défaut « L'équipe ».
|
||||
*/
|
||||
export interface TeamPage {
|
||||
kind: 'team'
|
||||
id: string
|
||||
title?: string // défaut « L'équipe »
|
||||
members: TeamMemberConfig[] // ordre + sélection + surcharges
|
||||
background: Background // blanc / teinté / plein (couleur du filet)
|
||||
bandColor?: string // filet couleur en haut — défaut encre
|
||||
}
|
||||
|
||||
export type Page = ProjectPage | ProjectGridPage | FreePage | CoverPage | TocPage | OutroPage | TeamPage
|
||||
|
||||
export interface CoverConfig {
|
||||
title: string
|
||||
subtitle?: string
|
||||
recipient?: string // « préparé pour … »
|
||||
body?: string // texte libre (markdown) sous la phrase d'intro — plus petit, centré avec elle
|
||||
backgroundImage?: string // "projectId/filename"
|
||||
background: Background // couleur du bandeau de couverture
|
||||
date?: string // texte libre affiché dans l'en-tête de couverture (ex. « Juillet 2026 »)
|
||||
siteUrl?: string // lien du site — en-tête de couverture + header de la vue publique (défaut DEFAULT_SITE_URL)
|
||||
contactEmail?: string // mail de contact — en-tête de couverture + header public (défaut DEFAULT_CONTACT_EMAIL)
|
||||
address?: string // adresse postale — angle bas-gauche de la couverture (défaut DEFAULT_ADDRESS)
|
||||
}
|
||||
|
||||
// Contenu figé à la génération (spec §7.3) — vrai instantané : contenu Grav
|
||||
@@ -158,6 +208,7 @@ export interface Portfolio {
|
||||
frozenContent?: FrozenSnapshot
|
||||
lastExport?: { fileName: string; at: string }
|
||||
publicShare?: boolean // lien public /view/<id> activé (opt-in, révocable)
|
||||
showYears?: boolean // affiche l'année sur les couvertures de projet — défaut : true
|
||||
}
|
||||
|
||||
export interface PortfolioSummary {
|
||||
|
||||