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