- 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
131 lines
5.2 KiB
Vue
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>
|