Files
portfolio-generator-app/pages/view/[id].vue
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

131 lines
5.2 KiB
Vue

<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
case 'outro': return page.title || 'Conclusion'
case 'team': return page.title || 'Équipe'
}
}
</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>