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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user