Files
portfolio-generator-app/pages/portfolios/[id].vue
T
valentin_le_moignandClaude Opus 4.8 59dfbe4155 Générateur de portfolios PDF Figures Libres (app Nuxt autonome)
App Nuxt 3 + Nuxt UI qui lit les projets du CMS Grav et compose des portfolios
A3 paysage exportés en PDF (gabarit book_v3) : composeur WYSIWYG, templates
cover/toc/projet/grille/page libre, pipeline Playwright + Ghostscript, gel du
contenu à la génération, partage public opt-in.

Dépôt autonome : Dockerfile (base Playwright + Ghostscript + ffmpeg + rsync) inclus.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 18:37:09 +02:00

828 lines
32 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 { FreePage, Page, Portfolio, Project, ProjectGridPage, ProjectPage, 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])),
)
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])
// ---- 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' })
}
}
watch(
portfolio,
() => {
saveState.value = 'dirty'
pdfStale.value = true // toute modif rend le dernier PDF périmé
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',
body: '',
media: [],
background: { mode: 'white' },
}
portfolio.value.pages.push(page)
selectPage(page.id)
}
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 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 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 },
...(hasCover.value ? [] : [{ label: 'Couverture', icon: 'i-lucide-bookmark', onSelect: addCoverPage }]),
]])
// ---- 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
}
}
const KIND_LABELS: Record<Page['kind'], string> = {
'cover': 'Couverture',
'toc': 'Sommaire',
'free': 'Page libre',
'project': 'Projet — couverture',
'project-grid': 'Projet — grille',
}
// ---- 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
}
// ---- 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
},
})
// ---- Image de couverture / médias de page libre ----
const showMediaPicker = ref(false)
const mediaPickerTarget = ref<'cover' | 'free'>('cover')
function openMediaPicker(target: 'cover' | 'free') {
mediaPickerTarget.value = target
showMediaPicker.value = true
}
function onMediaPicked(ref_: string) {
if (mediaPickerTarget.value === 'cover') {
portfolio.value.cover.backgroundImage = ref_
} else if (selectedPage.value?.kind === 'free') {
selectedPage.value.media = [...(selectedPage.value.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 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()
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 {
toast.add({ title: 'Échec de la génération', color: 'error' })
} 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 frozenDateLabel = computed(() =>
frozenAt.value
? new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(frozenAt.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: {} })
portfolio.value = updated
publicShare.value = true
frozenAt.value = updated.frozenContent?.generatedAt
toast.add({ title: 'Lien public activé', color: 'success' })
} catch {
toast.add({ title: 'Échec de l’activation 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 } })
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() {
try {
await navigator.clipboard.writeText(shareUrl.value)
toast.add({ title: 'Lien copié', color: 'success' })
} catch {
toast.add({ title: 'Copie impossible', description: shareUrl.value, color: 'error' })
}
}
</script>
<template>
<div class="flex h-[calc(100vh-3.5rem)]">
<!-- 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">
<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
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"
/>
<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="Lien du site (header public)">
<UInput v-model="portfolio.cover.siteUrl" class="w-full" :placeholder="DEFAULT_SITE_URL" />
</UFormField>
<UFormField label="Mail de contact (header public)">
<UInput v-model="portfolio.cover.contactEmail" class="w-full" :placeholder="DEFAULT_CONTACT_EMAIL" />
</UFormField>
<BandColorPicker v-model="selectedPage.bandColor" label="Couleur du filet" :default-color="FL_INK" />
<BackgroundEditor v-model="selectedPage.background" />
</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">Les entrées sont générées automatiquement depuis les pages-projet.</p>
</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>
<UFormField label="Texte (Markdown)">
<UTextarea v-model="selectedPage.body" :rows="10" class="w-full" />
</UFormField>
<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"
@click="openMediaPicker('free')"
/>
</div>
</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 v-if="projects[selectedPage.projectId]" class="space-y-2">
<p class="text-xs font-medium uppercase tracking-wide text-neutral-500">Image de couverture</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 class="space-y-2">
<p class="text-xs font-medium uppercase tracking-wide text-neutral-500">Calage de l'image</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">
Taille du titre — {{ titleScale }} %
</p>
<USlider v-model="titleScale" :min="50" :max="150" :step="5" />
</div>
<div class="space-y-2">
<p class="text-xs font-medium uppercase tracking-wide text-neutral-500">
Hauteur du bandeau — {{ bandHeight }} mm
</p>
<USlider v-model="bandHeight" :min="40" :max="160" :step="5" />
</div>
<BandColorPicker
v-model="selectedPage.bandColor"
label="Couleur du projet"
:default-color="CATEGORY_BAND[projects[selectedPage.projectId]?.category ?? '']"
/>
<div class="space-y-2">
<p class="text-xs font-medium uppercase tracking-wide text-neutral-500">Texte du bandeau</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="Type de travail">
<UInput
v-model="selectedPage.subtitleOverride"
class="w-full"
:placeholder="projects[selectedPage.projectId]?.categories.join(', ')"
/>
</UFormField>
<UFormField label="Présentation">
<UTextarea
v-model="selectedPage.textOverride"
:rows="6"
class="w-full"
placeholder="Vide = texte du projet Grav"
/>
</UFormField>
<USeparator />
<UButton
icon="i-lucide-layout-grid"
variant="soft"
block
:label="`Ajouter une page de 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"
/>
<USwitch
:model-value="selectedPage.greyBg ?? false"
label="Fond gris clair"
@update:model-value="(v: boolean) => { if (selectedPage?.kind === 'project-grid') selectedPage.greyBg = v || undefined }"
/>
<USeparator />
<UButton
icon="i-lucide-layout-grid"
variant="soft"
block
:label="`Ajouter une page de 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>
<UAlert
v-if="frozenAt"
icon="i-lucide-snowflake"
color="info"
variant="subtle"
:title="`Contenu figé le ${frozenDateLabel}`"
description="La régénération produira le même PDF, même si Grav a changé depuis."
>
<template #actions>
<UButton size="xs" variant="soft" color="info" label="Réactualiser depuis Grav" @click="unfreeze" />
</template>
</UAlert>
<p v-else class="text-xs text-neutral-500">
Le contenu utilisé sera figé à la génération : le portfolio restera régénérable à l'identique.
</p>
<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>
<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>