Files
portfolio-generator-app/pages/portfolios/[id].vue
T
valentin_le_moign 1c1604ea2f 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
2026-07-13 16:00:59 +02:00

1205 lines
50 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
// Composeur de portfolio (spec §8.2, gabarit_book_v3) : pages réordonnables,
// pages projet en deux volets (couverture + grilles), curation des images,
// overrides, couleurs de bandeau — avec aperçu WYSIWYG central.
import draggable from 'vuedraggable'
import { nanoid } from 'nanoid'
import type { FreeColumn, FreePage, OutroPage, Page, Portfolio, Project, ProjectGridPage, ProjectPage, TeamPage, TocPage } from '~~/types'
const route = useRoute()
const toast = useToast()
const [{ data: portfolioData }, { data: projectsData }] = await Promise.all([
useFetch<Portfolio>(`/api/portfolios/${route.params.id}`),
useFetch<{ projects: Project[] }>('/api/projects'),
])
if (!portfolioData.value) {
throw createError({ statusCode: 404, statusMessage: 'Portfolio introuvable' })
}
const portfolio = ref<Portfolio>(portfolioData.value)
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])
// La liste des pages (colonne de gauche) suit la page active : quand la
// sélection sort de la vue (beaucoup de pages), on la ramène dans le conteneur.
watch(selectedPageId, (id) => {
if (!id) return
nextTick(() => {
document
.querySelector(`[data-page-nav-id="${CSS.escape(id)}"]`)
?.scrollIntoView({ block: 'nearest' })
})
})
// ---- Sauvegarde automatique (debounce) ----
const saveState = ref<'saved' | 'saving' | 'dirty' | 'error'>('saved')
let timer: ReturnType<typeof setTimeout> | undefined
// ---- Fraîcheur du dernier PDF ----
// Un PDF « à jour » (non modifié depuis) reste téléchargeable directement.
const exportFileName = ref(portfolio.value.lastExport?.fileName)
const pdfStale = ref(!(portfolio.value.lastExport && portfolio.value.updatedAt <= portfolio.value.lastExport.at))
const pdfDownloadUrl = computed(() =>
exportFileName.value ? `/api/exports/${encodeURIComponent(exportFileName.value)}` : undefined,
)
const pdfDownloadable = computed(() => !pdfStale.value && !!exportFileName.value)
async function save() {
saveState.value = 'saving'
try {
await $fetch(`/api/portfolios/${portfolio.value.id}`, { method: 'PUT', body: portfolio.value })
saveState.value = 'saved'
} catch {
saveState.value = 'error'
toast.add({ title: 'Échec de la sauvegarde', color: 'error' })
}
}
// Réassignation programmatique de `portfolio` (publier/couper/dégeler) : ne
// compte pas comme une édition (ni autosave, ni « modifié depuis publication »).
let programmatic = false
// Le lien public a-t-il pris du retard sur les éditions ? (édité après le gel)
const sharedStale = ref(
Boolean(portfolio.value.publicShare)
&& !!portfolio.value.frozenContent
&& portfolio.value.updatedAt > (portfolio.value.frozenContent.generatedAt ?? ''),
)
watch(
portfolio,
() => {
if (programmatic) {
programmatic = false
return
}
saveState.value = 'dirty'
pdfStale.value = true // toute modif rend le dernier PDF périmé
sharedStale.value = true // et décale le lien public par rapport à l'instantané
clearTimeout(timer)
timer = setTimeout(save, 800)
},
{ deep: true },
)
// ---- Ajout / suppression de pages ----
const showProjectPicker = ref(false)
const previewScroll = ref<HTMLElement>()
// Défilement de l'aperçu → la page active (selectedPageId) suit la page centrée.
const pagesRef = computed(() => portfolio.value.pages)
const { onScroll: onPreviewScroll, scrollToIndex } = usePageScrollSpy({
container: previewScroll,
pages: pagesRef,
active: selectedPageId,
idPrefix: 'preview-page',
})
function selectPage(id: string, scroll = true) {
selectedPageId.value = id
if (!scroll) return
const i = portfolio.value.pages.findIndex(p => p.id === id)
if (i !== -1) scrollToIndex(i)
}
/** Clic sur une entrée du sommaire (aperçu) → défile jusqu'à la page N. */
function goToPage(pageNumber: number) {
const page = portfolio.value.pages[pageNumber - 1]
if (page) selectPage(page.id)
}
function projectImages(projectId: string): string[] {
return (projects.value[projectId]?.media ?? []).filter(m => m.type === 'image').map(m => m.filename)
}
/** Images du projet pas encore posées sur une page grille. */
function unusedImages(projectId: string): string[] {
const used = new Set(
portfolio.value.pages
.filter((p): p is ProjectGridPage => p.kind === 'project-grid' && p.projectId === projectId)
.flatMap(p => p.media),
)
return projectImages(projectId).filter(f => !used.has(f))
}
/** Orientation dominante d'un lot d'images du projet (grille par défaut). */
function orientationOf(projectId: string, filenames: string[]) {
const byName = new Map((projects.value[projectId]?.media ?? []).map(m => [m.filename, m]))
return dominantOrientation(
filenames.map((f) => {
const a = byName.get(f)
return a?.width && a?.height ? a.width / a.height : 0
}),
)
}
/** Insère une page grille après la dernière page du même projet. */
function addGridPage(projectId: string) {
const remaining = unusedImages(projectId)
const images = remaining.length ? remaining : projectImages(projectId)
const familyId = defaultGridFor(images.length, orientationOf(projectId, images))
const page: ProjectGridPage = {
kind: 'project-grid',
id: nanoid(8),
projectId,
grid: familyId,
media: images.slice(0, gridFamily(familyId).max),
}
const lastOfProject = portfolio.value.pages.reduce(
(acc, p, i) => ((p.kind === 'project' || p.kind === 'project-grid') && p.projectId === projectId ? i : acc),
-1,
)
portfolio.value.pages.splice(lastOfProject === -1 ? portfolio.value.pages.length : lastOfProject + 1, 0, page)
selectPage(page.id)
}
/** Un projet = une page couverture + une première page grille (gabarit). */
function addProjectPage(project: Project) {
const page: ProjectPage = {
kind: 'project',
id: nanoid(8),
projectId: project.id,
}
portfolio.value.pages.push(page)
const cover = project.coverImage
const others = projectImages(project.id).filter(f => f !== cover)
if (others.length) {
const familyId = defaultGridFor(others.length, orientationOf(project.id, others))
portfolio.value.pages.push({
kind: 'project-grid',
id: nanoid(8),
projectId: project.id,
grid: familyId,
media: others.slice(0, gridFamily(familyId).max),
})
}
selectPage(page.id)
}
function addFreePage() {
const page: FreePage = {
kind: 'free',
id: nanoid(8),
template: 'free-text',
title: 'Page libre',
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
const at = portfolio.value.pages.findIndex(p => p.kind !== 'cover')
portfolio.value.pages.splice(at === -1 ? portfolio.value.pages.length : at, 0, page)
selectPage(page.id)
}
function addCoverPage() {
const page: Page = { kind: 'cover', id: nanoid(8), background: { mode: 'white' } }
portfolio.value.pages.unshift(page)
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
portfolio.value.pages.splice(i, 1)
if (selectedPageId.value === id) {
selectedPageId.value = portfolio.value.pages[Math.max(0, i - 1)]?.id
}
}
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 ----
function pageLabel(page: Page): string {
switch (page.kind) {
case 'cover': return portfolio.value.cover.title || 'Couverture'
case 'toc': return page.title || 'Sommaire'
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'
}
}
const KIND_LABELS: Record<Page['kind'], string> = {
'cover': 'Couverture',
'toc': 'Sommaire',
'free': 'Page libre',
'project': 'Projet — couverture',
'project-grid': 'Projet — grille',
'outro': 'Conclusion',
'team': 'Équipe',
}
// ---- Encre du bandeau (pages projet) ----
const inkItems = [
{ value: 'auto', label: 'Auto' },
{ value: 'black', label: 'Noir' },
{ value: 'white', label: 'Blanc' },
] as const
function setInk(value: (typeof inkItems)[number]['value']) {
if (selectedPage.value?.kind === 'project') selectedPage.value.ink = value
}
// Disposition de la page couverture de projet
const coverLayoutItems = [
{ value: 'band', label: 'Bandeau' },
{ value: 'split', label: 'Colonne' },
] as const
function setCoverLayout(value: (typeof coverLayoutItems)[number]['value']) {
if (selectedPage.value?.kind === 'project') selectedPage.value.layout = value === 'band' ? undefined : value
}
// ---- Calage de l'image de couverture (pages projet) ----
const coverFitItems = [
{ value: 'width', label: 'Largeur' },
{ value: 'height', label: 'Hauteur' },
] as const
function setCoverFit(value: (typeof coverFitItems)[number]['value']) {
if (selectedPage.value?.kind === 'project') selectedPage.value.coverFit = value
}
// Fit hauteur : calage horizontal de l'image (marge gauche / centre / marge droite)
const coverAlignItems = [
{ value: 'left', icon: 'i-lucide-align-start-vertical', title: 'Calée sur la marge gauche' },
{ value: 'center', icon: 'i-lucide-align-center-vertical', title: 'Centrée' },
{ value: 'right', icon: 'i-lucide-align-end-vertical', title: 'Calée sur la marge droite' },
] as const
function setCoverAlign(value: (typeof coverAlignItems)[number]['value']) {
if (selectedPage.value?.kind === 'project') {
selectedPage.value.coverAlign = value === 'center' ? undefined : value
}
}
// Sliders des pages couverture : taille du titre (%) et hauteur du bandeau
// (mm = calage haut de l'image). Undefined = valeurs du gabarit.
const titleScale = computed({
get: () => (selectedPage.value?.kind === 'project' ? selectedPage.value.titleScale ?? 100 : 100),
set: (v: number) => {
if (selectedPage.value?.kind === 'project') selectedPage.value.titleScale = v === 100 ? undefined : v
},
})
const bandHeight = computed({
get: () => (selectedPage.value?.kind === 'project' ? selectedPage.value.bandHeight ?? 55 : 55),
set: (v: number) => {
if (selectedPage.value?.kind === 'project') selectedPage.value.bandHeight = v === 55 ? undefined : v
},
})
// Taille du texte de labeur des pages libres (%). Undefined = 100.
const freeTextScale = computed({
get: () => (selectedPage.value?.kind === 'free' ? selectedPage.value.textScale ?? 100 : 100),
set: (v: number) => {
if (selectedPage.value?.kind === 'free') selectedPage.value.textScale = v === 100 ? undefined : v
},
})
// Année par défaut (placeholder) déduite de la date Grav du projet sélectionné.
const projectYearPlaceholder = computed(() => {
if (selectedPage.value?.kind !== 'project') return 'Année'
const iso = projects.value[selectedPage.value.projectId]?.date
const y = iso ? new Date(iso).getFullYear() : NaN
return Number.isNaN(y) ? 'Année' : String(y)
})
// Texte de présentation du projet : le champ est prérempli avec le texte Grav
// pour édition rapide. `textOverride` reste `undefined` tant que le texte n'a pas
// été modifié → la page suit alors automatiquement Grav (donc se met à jour lors
// d'un rafraîchissement manuel). Dès qu'il diffère de Grav, il est « figé » et
// n'est plus resynchronisé ; le bouton « Réinitialiser » le rattache à Grav.
const presentationText = computed({
get: () => {
if (selectedPage.value?.kind !== 'project') return ''
const grav = projects.value[selectedPage.value.projectId]?.bodyMarkdown ?? ''
return selectedPage.value.textOverride ?? grav
},
set: (v: string) => {
if (selectedPage.value?.kind !== 'project') return
const grav = projects.value[selectedPage.value.projectId]?.bodyMarkdown ?? ''
// Ré-édité à l'identique de Grav → on relâche l'override (re-suit Grav).
selectedPage.value.textOverride = v === grav ? undefined : v
},
})
// Le texte de présentation a-t-il été modifié (ne suit plus Grav) ?
const presentationEdited = computed(() =>
selectedPage.value?.kind === 'project' && selectedPage.value.textOverride !== undefined,
)
function resetPresentation() {
if (selectedPage.value?.kind === 'project') selectedPage.value.textOverride = undefined
}
// ---- Image de couverture / médias de page libre ----
const showMediaPicker = ref(false)
type MediaPickerTarget = { type: 'cover' } | { type: 'free-column'; columnId: string }
const mediaPickerTarget = ref<MediaPickerTarget>({ type: 'cover' })
function openMediaPicker(target: MediaPickerTarget) {
mediaPickerTarget.value = target
showMediaPicker.value = true
}
function onMediaPicked(ref_: string) {
const target = mediaPickerTarget.value
if (target.type === 'cover') {
portfolio.value.cover.backgroundImage = ref_
} else if (selectedPage.value?.kind === 'free') {
const col = selectedPage.value.columns?.find(c => c.id === target.columnId)
if (col?.kind === 'media') col.media = [...(col.media ?? []), ref_]
}
}
const saveLabels: Record<typeof saveState.value, string> = {
saved: 'Enregistré',
saving: 'Enregistrement…',
dirty: 'Modifications…',
error: 'Erreur de sauvegarde',
}
// ---- Génération PDF (phase 2) ----
const showGenerate = ref(false)
const generating = ref(false)
const pdfFileName = ref('')
const frozenAt = ref(portfolio.value.frozenContent?.generatedAt)
const lastGeneratedAt = ref(portfolio.value.lastExport?.at)
const lastResult = ref<{ fileName: string; downloadUrl: string; sizeBytes: number; pageCount: number } | null>(null)
function openGenerate() {
pdfFileName.value = portfolio.value.frozenContent?.fileName?.replace(/\.pdf$/, '')
?? portfolio.value.name.toLowerCase().replace(/\s+/g, '-')
showGenerate.value = true
}
async function generate() {
generating.value = true
lastResult.value = null
try {
lastResult.value = await $fetch(`/api/portfolios/${portfolio.value.id}/generate`, {
method: 'POST',
body: { fileName: pdfFileName.value },
timeout: 300_000,
})
frozenAt.value = frozenAt.value ?? new Date().toISOString()
lastGeneratedAt.value = new Date().toISOString()
exportFileName.value = lastResult.value!.fileName
pdfStale.value = false // le PDF reflète désormais l'état courant
toast.add({ title: 'PDF généré', description: lastResult.value!.fileName, color: 'success' })
} catch (err: any) {
// Détail de l'erreur pour diagnostic (message serveur, timeout, réseau…).
const detail = err?.data?.statusMessage || err?.statusMessage || err?.message || String(err)
toast.add({ title: 'Échec de la génération', description: detail, color: 'error' })
console.error('[generate] échec:', err)
} finally {
generating.value = false
}
}
async function unfreeze() {
await $fetch(`/api/portfolios/${portfolio.value.id}/unfreeze`, { method: 'POST' })
frozenAt.value = undefined
toast.add({ title: 'Contenu réactualisé', description: 'La prochaine génération utilisera le contenu Grav à jour.', color: 'success' })
}
function formatBytes(n: number): string {
return n > 1024 * 1024 ? `${(n / 1024 / 1024).toFixed(1)} Mo` : `${Math.round(n / 1024)} Ko`
}
const dateTimeFmt = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium', timeStyle: 'short' })
const frozenDateLabel = computed(() => (frozenAt.value ? dateTimeFmt.format(new Date(frozenAt.value)) : ''))
const lastGeneratedLabel = computed(() => (lastGeneratedAt.value ? dateTimeFmt.format(new Date(lastGeneratedAt.value)) : ''))
// ---- Partage d'un lien public ----
const showShare = ref(false)
const sharing = ref(false)
const publicShare = ref(Boolean(portfolio.value.publicShare))
const shareUrl = computed(() =>
import.meta.client ? `${window.location.origin}/view/${portfolio.value.id}` : `/view/${portfolio.value.id}`,
)
function openShare() {
publicShare.value = Boolean(portfolio.value.publicShare)
showShare.value = true
}
/** Active/republie le lien public : fige l'état courant et l'expose. */
async function enableShare() {
sharing.value = true
try {
// La réponse est le portfolio complet resynchronisé (frozenContent frais) :
// on remplace l'état local pour éviter tout écrasement par l'auto-save.
const updated = await $fetch<Portfolio>(`/api/portfolios/${portfolio.value.id}/share`, { method: 'POST', body: {} })
programmatic = true
portfolio.value = updated
publicShare.value = true
frozenAt.value = updated.frozenContent?.generatedAt
sharedStale.value = false // l'instantané reflète l'état courant
toast.add({ title: 'Lien public activé', color: 'success' })
} catch {
toast.add({ title: 'Échec de lactivation du lien', color: 'error' })
} finally {
sharing.value = false
}
}
/** Coupe le lien public (le PDF conserve son contenu figé). */
async function disableShare() {
sharing.value = true
try {
const updated = await $fetch<Portfolio>(`/api/portfolios/${portfolio.value.id}/share`, { method: 'POST', body: { enabled: false } })
programmatic = true
portfolio.value = updated
publicShare.value = false
toast.add({ title: 'Lien public coupé', color: 'success' })
} catch {
toast.add({ title: 'Échec de la coupure du lien', color: 'error' })
} finally {
sharing.value = false
}
}
async function copyShareUrl() {
if (await copyToClipboard(shareUrl.value)) {
toast.add({ title: 'Lien copié', color: 'success' })
} else {
toast.add({ title: 'Copie impossible', description: shareUrl.value, color: 'error' })
}
}
</script>
<template>
<div class="flex h-screen">
<!-- Colonne pages -->
<aside class="flex w-60 shrink-0 flex-col border-r border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-900">
<BrandBar />
<div class="flex items-center justify-between border-b border-neutral-200 p-3 dark:border-neutral-800">
<span class="text-sm font-semibold">Pages ({{ portfolio.pages.length }})</span>
<UDropdownMenu :items="addItems">
<UButton icon="i-lucide-plus" size="xs" variant="soft" label="Ajouter" />
</UDropdownMenu>
</div>
<draggable
v-model="portfolio.pages"
item-key="id"
handle=".drag-handle"
:animation="150"
class="flex-1 space-y-1.5 overflow-y-auto p-2"
>
<template #item="{ element, index }">
<div
:data-page-nav-id="element.id"
class="group flex cursor-pointer items-center gap-2 rounded-md border p-2 text-sm transition-colors"
:class="element.id === selectedPageId
? 'border-[var(--fl-ink)] bg-neutral-50 dark:border-white dark:bg-neutral-800'
: 'border-neutral-200 hover:bg-neutral-50 dark:border-neutral-700 dark:hover:bg-neutral-800'"
@click="selectPage(element.id)"
>
<UIcon name="i-lucide-grip-vertical" class="drag-handle size-4 shrink-0 cursor-grab text-neutral-400" />
<div class="min-w-0 flex-1">
<p class="truncate font-medium">{{ index + 1 }}. {{ pageLabel(element) }}</p>
<p class="text-xs text-neutral-500">{{ KIND_LABELS[element.kind as Page['kind']] }}</p>
</div>
<UButton
icon="i-lucide-x"
size="xs"
variant="ghost"
color="neutral"
class="opacity-0 group-hover:opacity-100"
aria-label="Supprimer la page"
@click.stop="removePage(element.id)"
/>
</div>
</template>
</draggable>
</aside>
<!-- Aperçu central -->
<main class="flex min-w-0 flex-1 flex-col overflow-hidden">
<div class="flex items-center gap-3 border-b border-neutral-200 bg-white px-4 py-2 dark:border-neutral-800 dark:bg-neutral-900">
<UInput
v-model="portfolio.name"
variant="none"
class="w-72 font-semibold"
placeholder="Nom du portfolio"
/>
<span class="text-xs text-neutral-400">{{ saveLabels[saveState] }}</span>
<div class="ml-auto flex items-center gap-2">
<UButton
v-if="pdfDownloadable"
icon="i-lucide-download"
label="Télécharger le PDF"
size="sm"
color="neutral"
variant="soft"
:href="pdfDownloadUrl"
target="_blank"
external
/>
<UButton
icon="i-lucide-share-2"
label="Partager un lien public"
size="sm"
color="neutral"
variant="outline"
@click="openShare"
/>
<RefreshButton size="sm" />
<UButton
icon="i-lucide-file-down"
label="Générer le PDF"
size="sm"
@click="openGenerate"
/>
</div>
</div>
<div ref="previewScroll" class="flex flex-1 flex-col items-center gap-6 overflow-auto scroll-smooth bg-neutral-200/70 p-8 dark:bg-neutral-950" @scroll="onPreviewScroll">
<div
v-for="(page, i) in portfolio.pages"
:id="`preview-page-${i + 1}`"
:key="page.id"
class="w-full max-w-[1180px] scroll-mt-8 cursor-pointer rounded-sm shadow-xl outline-1 outline-offset-4 transition-[outline-color]"
:class="page.id === selectedPageId ? 'outline-neutral-400 dark:outline-neutral-500' : 'outline-transparent'"
@click="selectPage(page.id, false)"
>
<PageViewport>
<PageRenderer
:portfolio="portfolio"
:page="page"
:index="i"
:projects="projects"
render-mode="preview"
@navigate="goToPage"
/>
</PageViewport>
</div>
<div v-if="!portfolio.pages.length" class="p-16 text-center text-neutral-500">Ajoutez une page pour commencer.</div>
</div>
</main>
<!-- Panneau d'édition -->
<aside class="w-[340px] shrink-0 overflow-y-auto border-l border-neutral-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900">
<template v-if="!selectedPage">
<p class="text-sm text-neutral-500">Sélectionnez une page.</p>
</template>
<!-- Couverture -->
<template v-else-if="selectedPage.kind === 'cover'">
<h3 class="mb-4 font-semibold">Couverture</h3>
<div class="space-y-4">
<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>
<UFormField label="Site">
<UInput v-model="portfolio.cover.siteUrl" class="w-full" :placeholder="DEFAULT_SITE_URL" />
</UFormField>
<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>
<!-- Sommaire -->
<template v-else-if="selectedPage.kind === 'toc'">
<h3 class="mb-4 font-semibold">Sommaire</h3>
<div class="space-y-4">
<UFormField label="Titre">
<UInput v-model="selectedPage.title" class="w-full" />
</UFormField>
<BackgroundEditor v-model="selectedPage.background" />
<p class="text-xs text-neutral-500">Entrées et fond hérités de la couverture.</p>
</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>
<div class="space-y-4">
<UFormField label="Titre">
<UInput v-model="selectedPage.title" 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>
<BandColorPicker v-model="selectedPage.bandColor" label="Couleur du filet" :default-color="FL_INK" />
<BackgroundEditor v-model="selectedPage.background" />
</div>
</template>
<!-- Page projet : couverture -->
<template v-else-if="selectedPage.kind === 'project'">
<h3 class="mb-1 font-semibold">{{ projects[selectedPage.projectId]?.title }}</h3>
<p class="mb-4 text-xs text-neutral-500">Page couverture du projet</p>
<div class="space-y-4">
<div class="space-y-2">
<p class="text-xs font-medium uppercase tracking-wide text-neutral-500">Disposition</p>
<div class="flex gap-1">
<UButton
v-for="opt in coverLayoutItems"
:key="opt.value"
:label="opt.label"
size="xs"
:variant="(selectedPage.layout ?? 'band') === opt.value ? 'solid' : 'outline'"
color="neutral"
@click="setCoverLayout(opt.value)"
/>
</div>
</div>
<div v-if="projects[selectedPage.projectId]" class="space-y-2">
<p class="text-xs font-medium uppercase tracking-wide text-neutral-500">Image</p>
<div class="grid grid-cols-4 gap-1.5">
<button
v-for="f in projectImages(selectedPage.projectId)"
:key="f"
type="button"
class="relative aspect-square overflow-hidden rounded border transition-opacity"
:class="(selectedPage.coverImage || projects[selectedPage.projectId]!.coverImage) === f
? 'border-[var(--fl-ink)] dark:border-white'
: 'border-transparent opacity-45 hover:opacity-80'"
@click="selectedPage.coverImage = f"
>
<img :src="mediaUrl(selectedPage.projectId, f, 160)" class="size-full object-cover" :alt="f">
</button>
</div>
</div>
<div v-if="(selectedPage.layout ?? 'band') === 'band'" class="space-y-2">
<p class="text-xs font-medium uppercase tracking-wide text-neutral-500">Calage</p>
<div class="flex gap-1">
<UButton
v-for="opt in coverFitItems"
:key="opt.value"
:label="opt.label"
size="xs"
:variant="(selectedPage.coverFit ?? 'width') === opt.value ? 'solid' : 'outline'"
color="neutral"
@click="setCoverFit(opt.value)"
/>
</div>
<div v-if="(selectedPage.coverFit ?? 'width') === 'height'" class="flex gap-1 pt-1">
<UButton
v-for="opt in coverAlignItems"
:key="opt.value"
:icon="opt.icon"
size="xs"
:variant="(selectedPage.coverAlign ?? 'center') === opt.value ? 'solid' : 'outline'"
color="neutral"
:title="opt.title"
:aria-label="opt.title"
@click="setCoverAlign(opt.value)"
/>
</div>
</div>
<div class="space-y-2">
<p class="text-xs font-medium uppercase tracking-wide text-neutral-500">
Titre {{ titleScale }} %
</p>
<USlider v-model="titleScale" :min="50" :max="150" :step="5" />
</div>
<div v-if="(selectedPage.layout ?? 'band') === 'band'" class="space-y-2">
<p class="text-xs font-medium uppercase tracking-wide text-neutral-500">
Hauteur bandeau {{ bandHeight }} mm
</p>
<USlider v-model="bandHeight" :min="40" :max="160" :step="5" />
</div>
<BandColorPicker
v-model="selectedPage.bandColor"
label="Couleur du filet"
:default-color="FL_INK"
/>
<BackgroundEditor v-model="selectedPage.background" />
<div v-if="selectedPage.background?.mode === 'full'" class="space-y-2">
<p class="text-xs font-medium uppercase tracking-wide text-neutral-500">Couleur du texte</p>
<div class="flex gap-1">
<UButton
v-for="opt in inkItems"
:key="opt.value"
:label="opt.label"
size="xs"
:variant="(selectedPage.ink ?? 'auto') === opt.value ? 'solid' : 'outline'"
color="neutral"
@click="setInk(opt.value)"
/>
</div>
</div>
<UFormField label="Sous-titre">
<UInput v-model="selectedPage.subtitle" class="w-full" placeholder="Sous le titre" />
</UFormField>
<div class="grid grid-cols-2 gap-2">
<UFormField label="Type">
<UInput
v-model="selectedPage.subtitleOverride"
class="w-full"
:placeholder="projects[selectedPage.projectId]?.categories.join(', ') || 'ex. édition'"
/>
</UFormField>
<UFormField label="Année">
<UInput v-model="selectedPage.year" class="w-full" :placeholder="projectYearPlaceholder" />
</UFormField>
</div>
<UFormField label="Lien">
<UInput
v-model="selectedPage.link"
class="w-full"
:placeholder="projects[selectedPage.projectId]?.externalUrl || 'https://…'"
/>
</UFormField>
<UFormField label="Présentation">
<UTextarea
v-model="presentationText"
:rows="6"
class="w-full"
placeholder="Aucun texte"
/>
<div class="mt-1.5 flex items-center justify-between gap-2">
<p v-if="presentationEdited" class="flex items-center gap-1.5 text-xs text-amber-600 dark:text-amber-500">
<UIcon name="i-lucide-pencil" class="size-3.5 shrink-0" />
Texte modifié ne suit plus Grav
</p>
<p v-else class="flex items-center gap-1.5 text-xs text-neutral-500">
<UIcon name="i-lucide-check" class="size-3.5 shrink-0" />
Synchronisé avec Grav
</p>
<UButton
v-if="presentationEdited"
size="xs"
variant="ghost"
color="neutral"
label="Réinitialiser"
@click="resetPresentation"
/>
</div>
</UFormField>
<USeparator />
<UButton
icon="i-lucide-layout-grid"
variant="soft"
block
:label="`Ajouter une grille (${unusedImages(selectedPage.projectId).length} images restantes)`"
@click="addGridPage(selectedPage.projectId)"
/>
</div>
</template>
<!-- Page projet : grille -->
<template v-else-if="selectedPage.kind === 'project-grid'">
<h3 class="mb-1 font-semibold">{{ projects[selectedPage.projectId]?.title }}</h3>
<p class="mb-4 text-xs text-neutral-500">Page grille d'images</p>
<div class="space-y-4">
<div class="space-y-2">
<p class="text-xs font-medium uppercase tracking-wide text-neutral-500">Grille</p>
<div class="grid grid-cols-3 gap-1.5">
<button
v-for="fam in GRID_FAMILIES"
:key="fam.id"
type="button"
class="flex items-center justify-center rounded border p-1.5 transition-colors"
:class="gridFamily(selectedPage.grid).id === fam.id
? 'border-[var(--fl-ink)] bg-neutral-100 text-[var(--fl-ink)] dark:border-white dark:bg-neutral-800 dark:text-white'
: 'border-neutral-200 text-neutral-400 hover:border-neutral-400 hover:text-neutral-600 dark:border-neutral-700'"
:title="fam.label"
@click="selectedPage.grid = fam.id"
>
<GridIcon
:grid="fam.id"
:count="Math.max(selectedPage.media.length, 1)"
:orientation="orientationOf(selectedPage.projectId, selectedPage.media)"
:size="72"
/>
</button>
</div>
<p class="text-xs text-neutral-500">
{{ gridFamily(selectedPage.grid).label }} — {{ selectedPage.media.length }} image{{ selectedPage.media.length > 1 ? 's' : '' }}
(max {{ gridFamily(selectedPage.grid).max }})
</p>
</div>
<MediaCuration
v-if="projects[selectedPage.projectId]"
v-model="selectedPage.media"
:project="projects[selectedPage.projectId]!"
:max="gridFamily(selectedPage.grid).max"
/>
<BackgroundEditor v-model="selectedPage.background" />
<USeparator />
<UButton
icon="i-lucide-layout-grid"
variant="soft"
block
:label="`Ajouter une grille (${unusedImages(selectedPage.projectId).length} images restantes)`"
@click="addGridPage(selectedPage.projectId)"
/>
</div>
</template>
</aside>
<ProjectPickerModal v-model:open="showProjectPicker" @pick="addProjectPage" />
<MediaPickerModal v-model:open="showMediaPicker" @pick="onMediaPicked" />
<UModal v-model:open="showGenerate" title="Générer le PDF">
<template #body>
<div class="space-y-4">
<UFormField label="Nom du fichier">
<UInput v-model="pdfFileName" class="w-full" placeholder="portfolio-figures-libres">
<template #trailing>
<span class="text-xs text-neutral-400">.pdf</span>
</template>
</UInput>
</UFormField>
<div class="flex items-center justify-between gap-2">
<p v-if="!exportFileName" class="flex items-center gap-1.5 text-xs text-neutral-500">
<UIcon name="i-lucide-info" class="size-3.5 shrink-0" />
Aucun PDF généré pour l'instant.
</p>
<p v-else-if="pdfStale" class="flex items-center gap-1.5 text-xs text-amber-600 dark:text-amber-500">
<UIcon name="i-lucide-triangle-alert" class="size-3.5 shrink-0" />
Modifié depuis la dernière génération régénérez pour mettre à jour.
</p>
<p v-else class="flex items-center gap-1.5 text-xs text-neutral-500">
<UIcon name="i-lucide-check" class="size-3.5 shrink-0" />
À jour · généré le {{ lastGeneratedLabel }}
</p>
<UButton v-if="frozenAt" size="xs" variant="soft" color="info" label="Réactualiser depuis Grav" @click="unfreeze" />
</div>
<div v-if="lastResult" class="rounded-md border border-green-200 bg-green-50 p-3 text-sm dark:border-green-900 dark:bg-green-950">
<p class="font-medium">{{ lastResult.fileName }} {{ lastResult.pageCount }} pages, {{ formatBytes(lastResult.sizeBytes) }}</p>
<UButton
:href="lastResult.downloadUrl"
external
icon="i-lucide-download"
label="Télécharger"
size="xs"
class="mt-2"
/>
</div>
<div class="flex justify-end gap-2">
<UButton variant="ghost" color="neutral" label="Fermer" @click="() => { showGenerate = false }" />
<UButton
:loading="generating"
:label="generating ? 'Génération…' : 'Générer'"
icon="i-lucide-file-down"
@click="generate"
/>
</div>
</div>
</template>
</UModal>
<UModal v-model:open="showShare" title="Partager un lien public">
<template #body>
<div class="space-y-4">
<template v-if="publicShare">
<UFormField label="Lien public">
<UInput :model-value="shareUrl" readonly class="w-full">
<template #trailing>
<UButton icon="i-lucide-copy" size="xs" variant="ghost" color="neutral" aria-label="Copier" @click="copyShareUrl" />
</template>
</UInput>
</UFormField>
<p v-if="sharedStale" class="flex items-center gap-1.5 text-xs text-amber-600 dark:text-amber-500">
<UIcon name="i-lucide-triangle-alert" class="size-3.5 shrink-0" />
Modifié depuis la dernière publication republiez pour mettre à jour.
</p>
<p v-else class="flex items-center gap-1.5 text-xs text-neutral-500">
<UIcon name="i-lucide-check" class="size-3.5 shrink-0" />
À jour<span v-if="frozenDateLabel"> · publié le {{ frozenDateLabel }}</span>
</p>
<div class="flex flex-wrap items-center justify-between gap-2">
<UButton icon="i-lucide-external-link" :href="shareUrl" target="_blank" external variant="soft" color="neutral" size="sm" label="Ouvrir" />
<div class="flex gap-2">
<UButton :loading="sharing" icon="i-lucide-refresh-cw" variant="soft" size="sm" label="Republier" @click="enableShare" />
<UButton :loading="sharing" icon="i-lucide-link-2-off" color="error" variant="soft" size="sm" label="Couper le lien" @click="disableShare" />
</div>
</div>
</template>
<template v-else>
<UButton :loading="sharing" icon="i-lucide-share-2" block label="Activer le lien public" @click="enableShare" />
</template>
</div>
</template>
</UModal>
</div>
</template>