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>
This commit is contained in:
+146
@@ -0,0 +1,146 @@
|
||||
<script setup lang="ts">
|
||||
import type { PortfolioSummary } from '~~/types'
|
||||
|
||||
const { data, refresh } = await useFetch<{ portfolios: PortfolioSummary[] }>('/api/portfolios')
|
||||
const toast = useToast()
|
||||
|
||||
const portfolios = computed(() => data.value?.portfolios ?? [])
|
||||
const creating = ref(false)
|
||||
const newName = ref('')
|
||||
const showCreate = ref(false)
|
||||
|
||||
const dateFmt = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium', timeStyle: 'short' })
|
||||
|
||||
async function create() {
|
||||
creating.value = true
|
||||
try {
|
||||
const p = await $fetch<{ id: string }>('/api/portfolios', {
|
||||
method: 'POST',
|
||||
body: { name: newName.value },
|
||||
})
|
||||
navigateTo(`/portfolios/${p.id}`)
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function duplicate(id: string) {
|
||||
await $fetch(`/api/portfolios/${id}/duplicate`, { method: 'POST' })
|
||||
await refresh()
|
||||
toast.add({ title: 'Portfolio dupliqué', color: 'success' })
|
||||
}
|
||||
|
||||
async function remove(id: string, name: string) {
|
||||
if (!confirm(`Supprimer le portfolio « ${name} » ? Cette action est définitive.`)) return
|
||||
await $fetch(`/api/portfolios/${id}`, { method: 'DELETE' })
|
||||
await refresh()
|
||||
toast.add({ title: 'Portfolio supprimé', color: 'neutral' })
|
||||
}
|
||||
|
||||
const downloading = ref<string | null>(null)
|
||||
|
||||
/**
|
||||
* Télécharge le PDF : direct s'il est à jour, sinon (jamais généré ou modifié
|
||||
* depuis) on le génère puis on le télécharge.
|
||||
*/
|
||||
async function downloadPdf(p: PortfolioSummary) {
|
||||
if (p.pdfCurrent && p.exportFileName) {
|
||||
window.open(`/api/exports/${encodeURIComponent(p.exportFileName)}`, '_blank')
|
||||
return
|
||||
}
|
||||
downloading.value = p.id
|
||||
try {
|
||||
const result = await $fetch<{ fileName: string, downloadUrl: string }>(`/api/portfolios/${p.id}/generate`, {
|
||||
method: 'POST',
|
||||
body: {},
|
||||
timeout: 300_000,
|
||||
})
|
||||
toast.add({ title: 'PDF généré', description: result.fileName, color: 'success' })
|
||||
window.open(result.downloadUrl, '_blank')
|
||||
await refresh() // rafraîchit pdfCurrent / exportFileName sur la carte
|
||||
} catch {
|
||||
toast.add({ title: 'Échec de la génération', color: 'error' })
|
||||
} finally {
|
||||
downloading.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function copyPublicLink(id: string) {
|
||||
const url = `${window.location.origin}/view/${id}`
|
||||
try {
|
||||
await navigator.clipboard.writeText(url)
|
||||
toast.add({ title: 'Lien public copié', color: 'success' })
|
||||
} catch {
|
||||
toast.add({ title: 'Copie impossible', description: url, color: 'error' })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UContainer class="py-8">
|
||||
<div class="mb-6 flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-[var(--fl-ink)] dark:text-white">Portfolios</h1>
|
||||
</div>
|
||||
<UButton icon="i-lucide-plus" label="Nouveau portfolio" @click="showCreate = true" />
|
||||
</div>
|
||||
|
||||
<div v-if="!portfolios.length" class="rounded-lg border border-dashed border-neutral-300 p-16 text-center text-neutral-500 dark:border-neutral-700">
|
||||
Aucun portfolio pour l'instant — créez-en un pour composer votre premier PDF.
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<UCard v-for="p in portfolios" :key="p.id">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<NuxtLink :to="`/portfolios/${p.id}`" class="font-semibold text-[var(--fl-ink)] hover:underline dark:text-white">
|
||||
{{ p.name }}
|
||||
</NuxtLink>
|
||||
<p class="mt-1 text-xs text-neutral-500">
|
||||
{{ p.pageCount }} page{{ p.pageCount > 1 ? 's' : '' }} · modifié le {{ dateFmt.format(new Date(p.updatedAt)) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-1.5">
|
||||
<UBadge v-if="p.publicShare" color="info" variant="subtle" size="sm">Public</UBadge>
|
||||
<UBadge v-if="p.hasPdf" color="success" variant="subtle" size="sm">PDF</UBadge>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex items-center gap-2">
|
||||
<UButton :to="`/portfolios/${p.id}`" size="sm" variant="soft" icon="i-lucide-pencil" label="Ouvrir" />
|
||||
<UButton size="sm" variant="ghost" color="neutral" icon="i-lucide-copy" label="Dupliquer" @click="duplicate(p.id)" />
|
||||
<UButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
:icon="p.pdfCurrent ? 'i-lucide-download' : 'i-lucide-file-down'"
|
||||
:label="p.pdfCurrent ? 'Télécharger' : (p.hasPdf ? 'Régénérer' : 'Générer')"
|
||||
:loading="downloading === p.id"
|
||||
@click="downloadPdf(p)"
|
||||
/>
|
||||
<UButton
|
||||
v-if="p.publicShare"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
icon="i-lucide-link"
|
||||
aria-label="Copier le lien public"
|
||||
@click="copyPublicLink(p.id)"
|
||||
/>
|
||||
<UButton size="sm" variant="ghost" color="error" icon="i-lucide-trash-2" class="ml-auto" @click="remove(p.id, p.name)" />
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
<UModal v-model:open="showCreate" title="Nouveau portfolio">
|
||||
<template #body>
|
||||
<form class="space-y-4" @submit.prevent="create">
|
||||
<UInput v-model="newName" placeholder="Nom du portfolio (ex. Candidature Bagneux)" class="w-full" autofocus />
|
||||
<div class="flex justify-end gap-2">
|
||||
<UButton variant="ghost" color="neutral" label="Annuler" @click="showCreate = false" />
|
||||
<UButton type="submit" :loading="creating" label="Créer" />
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
</UModal>
|
||||
</UContainer>
|
||||
</template>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
const password = ref('')
|
||||
const error = ref('')
|
||||
const pending = ref(false)
|
||||
|
||||
async function submit() {
|
||||
if (!password.value) return
|
||||
pending.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
await $fetch('/api/auth/login', { method: 'POST', body: { password: password.value } })
|
||||
navigateTo('/')
|
||||
} catch {
|
||||
error.value = 'Mot de passe incorrect.'
|
||||
} finally {
|
||||
pending.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-h-screen items-center justify-center p-4">
|
||||
<UCard class="w-full max-w-sm">
|
||||
<div class="mb-6 flex flex-col items-center gap-3 text-center">
|
||||
<span class="flex size-12 items-center justify-center rounded-full bg-[var(--fl-ink)] font-bold text-white">FL</span>
|
||||
<div>
|
||||
<h1 class="text-lg font-bold text-[var(--fl-ink)] dark:text-white">Portfolios Figures Libres</h1>
|
||||
<p class="text-sm text-neutral-500">Générateur de portfolios PDF</p>
|
||||
</div>
|
||||
</div>
|
||||
<form class="space-y-4" @submit.prevent="submit">
|
||||
<UInput
|
||||
v-model="password"
|
||||
type="password"
|
||||
placeholder="Mot de passe partagé"
|
||||
icon="i-lucide-lock"
|
||||
size="lg"
|
||||
class="w-full"
|
||||
autofocus
|
||||
/>
|
||||
<p v-if="error" class="text-sm text-red-600">{{ error }}</p>
|
||||
<UButton type="submit" block size="lg" :loading="pending" label="Entrer" />
|
||||
</form>
|
||||
</UCard>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,827 @@
|
||||
<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>
|
||||
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
// Route de rendu print : toutes les pages du portfolio empilées, imprimées
|
||||
// par Playwright/Chromium (une page CSS = une page PDF, spec §10).
|
||||
// Accès par jeton interne uniquement (?internal-token=…).
|
||||
import type { Portfolio, Project } from '~~/types'
|
||||
|
||||
definePageMeta({ layout: false })
|
||||
|
||||
const route = useRoute()
|
||||
const token = String(route.query['internal-token'] ?? '')
|
||||
const headers = { 'x-internal-token': token }
|
||||
|
||||
const { data: portfolio } = await useFetch<Portfolio>(`/api/portfolios/${route.params.id}`, { headers })
|
||||
if (!portfolio.value) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Portfolio introuvable' })
|
||||
}
|
||||
|
||||
// Contenu figé si présent (régénération à l'identique), live sinon
|
||||
const frozen = portfolio.value.frozenContent
|
||||
let projects: Record<string, Project>
|
||||
if (frozen) {
|
||||
projects = frozen.projects
|
||||
} else {
|
||||
const { data } = await useFetch<{ projects: Project[] }>('/api/projects', { headers })
|
||||
projects = Object.fromEntries((data.value?.projects ?? []).map(p => [p.id, p]))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="print-root">
|
||||
<div v-for="(page, i) in portfolio!.pages" :id="`page-${i + 1}`" :key="page.id" class="print-page">
|
||||
<PageRenderer
|
||||
:portfolio="portfolio!"
|
||||
:page="page"
|
||||
:index="i"
|
||||
:projects="projects"
|
||||
render-mode="print"
|
||||
:frozen="frozen ? portfolio!.id : undefined"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* Une page CSS = une page PDF — A3 paysage du gabarit :
|
||||
1190,55 × 841,89 pt ≡ 1587,4 × 1122,5 px */
|
||||
@page {
|
||||
size: 1190.55pt 841.89pt;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.print-page {
|
||||
width: 1587.4px;
|
||||
height: 1122.5px;
|
||||
overflow: hidden;
|
||||
page-break-after: always;
|
||||
break-after: page;
|
||||
}
|
||||
|
||||
.print-page:last-child {
|
||||
page-break-after: auto;
|
||||
break-after: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,128 @@
|
||||
<script setup lang="ts">
|
||||
// Vue publique d'un portfolio partagé (bouton « Partager un lien public »).
|
||||
// Hors mot de passe (middleware auth exempte /view). Rendu identique au viewer
|
||||
// (pages A3 mises à l'échelle) mais sans édition : header + sommaire repliable
|
||||
// (masqué par défaut, suit la page active au scroll via usePageScrollSpy), et
|
||||
// rien d'autre.
|
||||
import type { Page, Portfolio, Project } from '~~/types'
|
||||
|
||||
definePageMeta({ layout: false })
|
||||
|
||||
const route = useRoute()
|
||||
const id = String(route.params.id)
|
||||
|
||||
const { data } = await useFetch(`/api/public/${id}`)
|
||||
if (!data.value) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Portfolio introuvable' })
|
||||
}
|
||||
const portfolio = computed<Portfolio>(() => data.value!.portfolio)
|
||||
const projects = computed<Record<string, Project>>(() => data.value!.projects)
|
||||
|
||||
// Les médias passent par la route publique dédiée (snapshot figé).
|
||||
const mediaBase = `/api/public/${id}/media`
|
||||
|
||||
// ---- Header ----
|
||||
const siteUrl = computed(() => portfolio.value.cover.siteUrl?.trim() || DEFAULT_SITE_URL)
|
||||
const siteLabel = computed(() => siteUrl.value.replace(/^https?:\/\//, '').replace(/\/$/, ''))
|
||||
const contactEmail = computed(() => portfolio.value.cover.contactEmail?.trim() || DEFAULT_CONTACT_EMAIL)
|
||||
const dateLabel = computed(() => {
|
||||
const iso = portfolio.value.frozenContent?.generatedAt ?? portfolio.value.lastExport?.at
|
||||
return iso ? new Intl.DateTimeFormat('fr-FR', { dateStyle: 'long' }).format(new Date(iso)) : ''
|
||||
})
|
||||
|
||||
useHead({ title: () => portfolio.value.cover.title || portfolio.value.name })
|
||||
|
||||
// ---- Sommaire repliable + page active (scroll-spy) ----
|
||||
const showSommaire = ref(false)
|
||||
const activeId = ref<string | undefined>(portfolio.value.pages[0]?.id)
|
||||
const previewScroll = ref<HTMLElement>()
|
||||
|
||||
const { onScroll, scrollToId } = usePageScrollSpy({
|
||||
container: previewScroll,
|
||||
pages: computed(() => portfolio.value.pages),
|
||||
active: activeId,
|
||||
idPrefix: 'view-page',
|
||||
})
|
||||
|
||||
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':
|
||||
case 'project-grid': return projects.value[page.projectId]?.title ?? page.projectId
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-screen flex-col bg-neutral-200/70 dark:bg-neutral-950">
|
||||
<!-- Header public -->
|
||||
<header class="flex items-center gap-3 border-b border-neutral-200 bg-white px-4 py-2.5 dark:border-neutral-800 dark:bg-neutral-900">
|
||||
<UButton
|
||||
:icon="showSommaire ? 'i-lucide-panel-left-close' : 'i-lucide-panel-left-open'"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:aria-label="showSommaire ? 'Masquer le sommaire' : 'Afficher le sommaire'"
|
||||
@click="showSommaire = !showSommaire"
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<h1 class="truncate font-semibold leading-tight">{{ portfolio.cover.title || portfolio.name }}</h1>
|
||||
<p v-if="dateLabel" class="text-xs text-neutral-500">{{ dateLabel }}</p>
|
||||
</div>
|
||||
<div class="ml-auto flex items-center gap-4 text-sm">
|
||||
<a :href="siteUrl" target="_blank" rel="noopener" class="text-neutral-600 hover:underline dark:text-neutral-300">{{ siteLabel }}</a>
|
||||
<a :href="`mailto:${contactEmail}`" class="text-neutral-600 hover:underline dark:text-neutral-300">{{ contactEmail }}</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="flex min-h-0 flex-1">
|
||||
<!-- Sommaire repliable (masqué par défaut) -->
|
||||
<aside
|
||||
v-if="showSommaire"
|
||||
class="w-60 shrink-0 space-y-0.5 overflow-y-auto border-r border-neutral-200 bg-white p-2 dark:border-neutral-800 dark:bg-neutral-900"
|
||||
>
|
||||
<button
|
||||
v-for="(page, i) in portfolio.pages"
|
||||
:key="page.id"
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 rounded-md p-2 text-left text-sm transition-colors"
|
||||
:class="page.id === activeId
|
||||
? 'bg-neutral-100 font-medium dark:bg-neutral-800'
|
||||
: 'hover:bg-neutral-50 dark:hover:bg-neutral-800/60'"
|
||||
@click="scrollToId(page.id)"
|
||||
>
|
||||
<span class="w-5 shrink-0 text-right text-neutral-400">{{ i + 1 }}</span>
|
||||
<span class="truncate">{{ pageLabel(page) }}</span>
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<!-- Pages empilées (sans liseré autour de la page active) -->
|
||||
<main
|
||||
ref="previewScroll"
|
||||
class="flex min-w-0 flex-1 flex-col items-center gap-6 overflow-auto scroll-smooth p-8"
|
||||
@scroll="onScroll"
|
||||
>
|
||||
<div
|
||||
v-for="(page, i) in portfolio.pages"
|
||||
:id="`view-page-${i + 1}`"
|
||||
:key="page.id"
|
||||
class="w-full max-w-[1180px] scroll-mt-8 rounded-sm shadow-xl"
|
||||
>
|
||||
<PageViewport>
|
||||
<PageRenderer
|
||||
:portfolio="portfolio"
|
||||
:page="page"
|
||||
:index="i"
|
||||
:projects="projects"
|
||||
render-mode="preview"
|
||||
:media-base="mediaBase"
|
||||
@navigate="(n: number) => scrollToId(portfolio.pages[n - 1]?.id ?? '')"
|
||||
/>
|
||||
</PageViewport>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user