Pages équipe + conclusion, colonnes de page libre, réglages couverture

- Page « équipe » : roster statique du collectif (composables/useTeam),
  portraits servis depuis public/portraits, grille ≤ 3 colonnes équilibrée
  (portrait/nom/rôle/présentation), sélection + surcharges par membre
  (repliables, réordonnables, réinitialisables) dans le composeur
- Page « conclusion » : « Merci ! » centré + rappel des contacts
- Page libre : 1 à 3 colonnes texte (markdown) ou images avec sous-titres,
  réordonnables ; migration des champs body/media legacy vers columns
- Couverture : texte libre sous l'intro, adresse en bas à gauche (3 lignes),
  case « afficher les années » au niveau du document
- Sommaire : items centrés (self-center), filet en points dégradés stable
- Retrait de la dépendance morte pagedjs
- Docs à jour (CLAUDE.md, README) ; spec marquée comme archive réalisée
This commit is contained in:
2026-07-13 16:00:59 +02:00
parent ef1f14335d
commit 1c1604ea2f
29 changed files with 802 additions and 265 deletions

View File

@@ -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>

View File

@@ -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>