Textes projet éditables suivant Grav + écriture atomique du store
- Champ Présentation prérempli avec le texte Grav, suivi modifié/synchro (textOverride undefined = suit Grav, sinon figé ; bouton Réinitialiser) - Fix génération PDF : savePortfolio écrit en atomique (temp+rename) pour éviter la corruption de lecture par l'autosave concurrent du rendu print - Toast d'échec de génération explicite (message serveur) - Refonte architecture visuelle, fond unifié white/tint/full, dispositions couverture bandeau/colonne, instantané public complet, index 3 lignes
This commit is contained in:
30
CLAUDE.md
30
CLAUDE.md
@@ -40,11 +40,18 @@ Mot de passe dev : `figureslibres` (`PORTFOLIO_PASSWORD`).
|
||||
## Modèle de données (résumé)
|
||||
|
||||
Un `Portfolio` = `cover` (CoverConfig) + `pages[]`. Types de page : `cover`,
|
||||
`toc`, `project` (couverture bandeau + image), `project-grid` (grille d'images
|
||||
entières), `free`. Le contenu Grav (`Project`, `MediaAsset`) est **en lecture
|
||||
seule**. À la génération, le contenu utilisé est **figé** dans `data/frozen/<id>/`
|
||||
(`FrozenSnapshot`) : régénérer redonne le même PDF ; « Réactualiser depuis Grav »
|
||||
resynchronise volontairement.
|
||||
`toc`, `project` (couverture d'un projet, disposition `band` = bandeau haut **ou**
|
||||
`split` = colonne texte + image pleine hauteur à droite), `project-grid` (grille
|
||||
d'images entières), `free`. Le contenu Grav (`Project`, `MediaAsset`) est **en
|
||||
lecture seule**. À la génération, le contenu utilisé est **figé** dans
|
||||
`data/frozen/<id>/` (`FrozenSnapshot`) : régénérer redonne le même PDF ;
|
||||
« Réactualiser depuis Grav » resynchronise volontairement.
|
||||
|
||||
Chaque page porte un **fond unifié** `background` à trois modes :
|
||||
`white` | `tint` (aplat beige fixe `#efece3`) | `full` (la page — ou le bandeau /
|
||||
la colonne d'une couverture projet — prend la couleur du filet `bandColor`, défaut
|
||||
encre `FL_INK`). Il n'y a **plus** de couleur automatique par catégorie
|
||||
(`bandColorOf`/`stripColorOf` retombent sur `FL_INK`).
|
||||
|
||||
## Contraintes fermes
|
||||
|
||||
@@ -53,6 +60,19 @@ resynchronise volontairement.
|
||||
- Les vidéos ne sont pas sélectionnables dans les grilles.
|
||||
- Format PDF : A3 paysage aux dimensions exactes du gabarit.
|
||||
|
||||
## Conventions de rendu (templates)
|
||||
|
||||
- **Fond `background`** : `useTheme` → `backgroundColorOf(bg, fullColor)` /
|
||||
`backgroundStyle(bg, fullColor)`. Le mode `full` **dérive** sa couleur du filet
|
||||
au rendu (`fullColor`) — ne rien stocker en dur ; le `BackgroundEditor` (Blanc /
|
||||
Teinté / Plein) est le même sur tous les types de page.
|
||||
- **Syne toujours en Regular** (poids 400), y compris titres et gras de prose ;
|
||||
seule exception : le mot « Figures Libres » de la couverture (Bold).
|
||||
- **Liens Grav** : `stripTargetParam` retire le `?target=_blank` que Grav accole à
|
||||
chaque URL (lien projet + liens dans les corps de texte), à l'affichage.
|
||||
- Filet : `--fl-strip` (5 mm, couverture/sommaire/page libre) ou `--fl-strip-light`
|
||||
(2 mm, filet fin des grilles et des couvertures projet non « plein »).
|
||||
|
||||
## Pièges Chromium print (avant de toucher aux templates)
|
||||
|
||||
- **`columns-*` (CSS multicol) = page PDF blanche** dans Chromium print. Ne pas
|
||||
|
||||
@@ -82,6 +82,7 @@
|
||||
--fl-margin: 30.2px; /* 8 mm — marge extérieure */
|
||||
--fl-gutter: 14.4px; /* 3,8 mm — gouttière des grilles */
|
||||
--fl-strip: 18.9px; /* 5 mm — filet haut des pages grille */
|
||||
--fl-strip-light: 7.6px; /* 2 mm — filet fin du mode clair (couverture + grille) */
|
||||
--fl-band: 208px; /* 55 mm — bandeau des pages couverture */
|
||||
}
|
||||
|
||||
@@ -100,8 +101,8 @@
|
||||
|
||||
.fl-page :is(h1, h2, h3) {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
font-weight: 400; /* Syne toujours en Regular (seul « Figures Libres » reste en Bold) */
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
.fl-page img {
|
||||
@@ -133,5 +134,5 @@
|
||||
.fl-prose ul, .fl-prose ol { margin: 0 0 0.75em; padding-left: 1.2em; }
|
||||
.fl-prose ul { list-style: disc; }
|
||||
.fl-prose ol { list-style: decimal; }
|
||||
.fl-prose strong { font-weight: 700; }
|
||||
.fl-prose strong { font-weight: 400; } /* Syne en Regular : pas de graisse dans le corps de texte */
|
||||
.fl-prose em { font-style: italic; }
|
||||
|
||||
26
components/BrandBar.vue
Normal file
26
components/BrandBar.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
// En-tête de la colonne gauche : logo « Portfolios » + déconnexion (petite icône
|
||||
// à droite). Header à part entière (fond + filet bas), commun index/éditeur.
|
||||
async function logout() {
|
||||
await $fetch('/api/auth/logout', { method: 'POST' })
|
||||
navigateTo('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-14 items-center justify-between gap-2 border-b border-neutral-200 bg-white px-4 dark:border-neutral-800 dark:bg-neutral-900">
|
||||
<NuxtLink to="/" class="font-bold text-[var(--fl-ink)] hover:opacity-80 dark:text-white">
|
||||
Portfolios
|
||||
</NuxtLink>
|
||||
<UTooltip text="Déconnexion">
|
||||
<UButton
|
||||
icon="i-lucide-log-out"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
aria-label="Se déconnecter"
|
||||
@click="logout"
|
||||
/>
|
||||
</UTooltip>
|
||||
</div>
|
||||
</template>
|
||||
42
components/RefreshButton.vue
Normal file
42
components/RefreshButton.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
// Bouton « Rafraîchir » : resynchronise le contenu depuis Grav. Placé à côté de
|
||||
// « Générer le PDF » (éditeur) et « Nouveau portfolio » (index).
|
||||
withDefaults(defineProps<{ size?: 'xs' | 'sm' | 'md' }>(), { size: 'sm' })
|
||||
|
||||
const toast = useToast()
|
||||
const syncing = ref(false)
|
||||
|
||||
async function refreshContent() {
|
||||
syncing.value = true
|
||||
try {
|
||||
const result = await $fetch<{ ok: boolean, summary: string }>('/api/sync', {
|
||||
method: 'POST',
|
||||
timeout: 900_000,
|
||||
})
|
||||
toast.add({
|
||||
title: result.ok ? 'Contenu rafraîchi' : 'Synchronisation en échec',
|
||||
description: result.summary,
|
||||
color: result.ok ? 'success' : 'error',
|
||||
})
|
||||
if (result.ok) await refreshNuxtData()
|
||||
} catch {
|
||||
toast.add({ title: 'Échec de la synchronisation', color: 'error' })
|
||||
} finally {
|
||||
syncing.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UTooltip text="Actualise les données depuis Grav">
|
||||
<UButton
|
||||
icon="i-lucide-refresh-cw"
|
||||
label="Rafraîchir"
|
||||
:size="size"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
:loading="syncing"
|
||||
@click="refreshContent"
|
||||
/>
|
||||
</UTooltip>
|
||||
</template>
|
||||
@@ -1,50 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
// Fond de page (spec §7.2) : blanc / teinté (aplat clair) / pleine couleur.
|
||||
import type { Background } from '~~/types'
|
||||
// Fond de page unifié : Blanc / Teinté (aplat clair fixe) / Plein (couleur du
|
||||
// filet/bandeau de la page). Undefined = Blanc par défaut.
|
||||
import type { Background, BgMode } from '~~/types'
|
||||
|
||||
const model = defineModel<Background>({ required: true })
|
||||
const model = defineModel<Background | undefined>()
|
||||
|
||||
const modes = [
|
||||
{ value: 'white', label: 'Blanc' },
|
||||
{ value: 'tint', label: 'Teinté' },
|
||||
{ value: 'color', label: 'Couleur' },
|
||||
{ value: 'full', label: 'Plein' },
|
||||
] as const
|
||||
|
||||
function setMode(mode: Background['mode']) {
|
||||
if (mode === 'white') model.value = { mode: 'white' }
|
||||
else if (mode === 'tint') model.value = { mode: 'tint', color: model.value.mode === 'tint' ? model.value.color : TINT_PRESETS[0] }
|
||||
else model.value = { mode: 'color', color: model.value.mode === 'color' ? model.value.color : COLOR_PRESETS[1] }
|
||||
}
|
||||
|
||||
const swatches = computed(() =>
|
||||
model.value.mode === 'tint' ? TINT_PRESETS : model.value.mode === 'color' ? COLOR_PRESETS : [],
|
||||
)
|
||||
const current = computed<BgMode>(() => model.value?.mode ?? 'white')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-neutral-500">Fond de page</p>
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-neutral-500">Fond</p>
|
||||
<div class="flex gap-1">
|
||||
<UButton
|
||||
v-for="m in modes"
|
||||
:key="m.value"
|
||||
:label="m.label"
|
||||
size="xs"
|
||||
:variant="model.mode === m.value ? 'solid' : 'outline'"
|
||||
:variant="current === m.value ? 'solid' : 'outline'"
|
||||
color="neutral"
|
||||
@click="setMode(m.value)"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="swatches.length" class="flex flex-wrap gap-1.5 pt-1">
|
||||
<button
|
||||
v-for="c in swatches"
|
||||
:key="c"
|
||||
type="button"
|
||||
class="size-7 rounded-full border transition-transform hover:scale-110"
|
||||
:class="(model as any).color === c ? 'ring-2 ring-[var(--fl-ink)] ring-offset-1 dark:ring-white' : 'border-neutral-300 dark:border-neutral-600'"
|
||||
:style="{ backgroundColor: c }"
|
||||
:aria-label="c"
|
||||
@click="model = { mode: model.mode as 'tint' | 'color', color: c }"
|
||||
@click="model = { mode: m.value }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,9 @@ const props = withDefaults(
|
||||
)
|
||||
|
||||
const model = defineModel<string | undefined>()
|
||||
|
||||
// Une couleur choisie hors palette = couleur personnalisée (input natif).
|
||||
const isCustom = computed(() => !!model.value && !BAND_COLORS.includes(model.value))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -24,7 +27,7 @@ const model = defineModel<string | undefined>()
|
||||
class="relative size-7 rounded-full border transition-transform hover:scale-110"
|
||||
:class="!model ? 'ring-2 ring-[var(--fl-ink)] ring-offset-1 dark:ring-white' : 'border-neutral-300 dark:border-neutral-600'"
|
||||
:style="{ backgroundColor: props.defaultColor }"
|
||||
title="Couleur de la catégorie (défaut)"
|
||||
title="Couleur par défaut"
|
||||
@click="model = undefined"
|
||||
>
|
||||
<UIcon name="i-lucide-rotate-ccw" class="absolute inset-0 m-auto size-3.5 text-black/40" />
|
||||
@@ -39,6 +42,21 @@ const model = defineModel<string | undefined>()
|
||||
:aria-label="c"
|
||||
@click="model = c"
|
||||
/>
|
||||
<!-- Couleur personnalisée (sélecteur natif) -->
|
||||
<label
|
||||
class="relative flex size-7 cursor-pointer items-center justify-center overflow-hidden rounded-full border transition-transform hover:scale-110"
|
||||
:class="isCustom ? 'ring-2 ring-[var(--fl-ink)] ring-offset-1 dark:ring-white' : 'border-neutral-300 dark:border-neutral-600'"
|
||||
:style="isCustom ? { backgroundColor: model } : undefined"
|
||||
title="Couleur personnalisée"
|
||||
>
|
||||
<UIcon v-if="!isCustom" name="i-lucide-pipette" class="size-3.5 text-neutral-400" />
|
||||
<input
|
||||
type="color"
|
||||
class="absolute inset-0 cursor-pointer opacity-0"
|
||||
:value="isCustom ? model : '#000000'"
|
||||
@input="model = ($event.target as HTMLInputElement).value"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -57,30 +57,52 @@ const projectCoverImage = computed<ResolvedMedia | undefined>(() => {
|
||||
|
||||
const projectText = computed(() => {
|
||||
if (props.page.kind !== 'project' || !project.value) return ''
|
||||
if (props.page.textOverride?.trim()) return marked.parse(props.page.textOverride) as string
|
||||
return project.value.bodyHtml
|
||||
// `textOverride` undefined = suit Grav (bodyHtml) ; toute chaîne définie
|
||||
// (y compris vide) est un texte figé à l'édition et rendue telle quelle.
|
||||
const override = props.page.textOverride
|
||||
const html = override === undefined
|
||||
? project.value.bodyHtml
|
||||
: (marked.parse(override) as string)
|
||||
return stripTargetParam(html)
|
||||
})
|
||||
|
||||
const projectIntro = computed(() => {
|
||||
if (props.page.kind !== 'project' || !project.value) return ''
|
||||
return props.page.subtitleOverride?.trim() || project.value.categories.join(', ')
|
||||
// Ligne d'info de la couverture de projet : type de travail + année.
|
||||
// Type par défaut = taxonomy.category de Grav (« Site web », « Édition »…),
|
||||
// c.-à-d. Project.categories — surchargée par subtitleOverride si renseignée.
|
||||
const projectWorkType = computed(() => {
|
||||
if (props.page.kind !== 'project') return ''
|
||||
return props.page.subtitleOverride?.trim() || project.value?.categories.join(', ') || ''
|
||||
})
|
||||
|
||||
const projectYear = computed(() => {
|
||||
if (props.page.kind !== 'project') return ''
|
||||
if (props.page.year?.trim()) return props.page.year.trim()
|
||||
const iso = project.value?.date
|
||||
const y = iso ? new Date(iso).getFullYear() : NaN
|
||||
return Number.isNaN(y) ? '' : String(y)
|
||||
})
|
||||
|
||||
const projectLink = computed(() => {
|
||||
if (props.page.kind !== 'project') return ''
|
||||
return stripTargetParam(props.page.link?.trim() || project.value?.externalUrl || '')
|
||||
})
|
||||
|
||||
const projectBadge = computed(() => {
|
||||
const c = project.value?.category
|
||||
return c ? `Utilité ${CATEGORY_LABELS[c]?.toLowerCase() ?? c}` : undefined
|
||||
return c ? CATEGORY_BADGE[c] ?? c : undefined
|
||||
})
|
||||
|
||||
// ---- Page grille de projet ----
|
||||
// Le filet hérite de la couleur du bandeau de la page couverture du projet.
|
||||
const gridStripOverride = computed(() => {
|
||||
// Filet et mode clair hérités de la page couverture du même projet.
|
||||
const gridCover = computed(() => {
|
||||
if (props.page.kind !== 'project-grid') return undefined
|
||||
const projectId = props.page.projectId
|
||||
const cover = props.portfolio.pages.find(
|
||||
return props.portfolio.pages.find(
|
||||
(p): p is Extract<Page, { kind: 'project' }> => p.kind === 'project' && p.projectId === projectId,
|
||||
)
|
||||
return cover?.bandColor
|
||||
})
|
||||
// Le filet des grilles hérite de la couleur du bandeau de la couverture du projet.
|
||||
const gridStripOverride = computed(() => gridCover.value?.bandColor)
|
||||
|
||||
// Orientation dominante des images posées : la grille adaptative en dépend
|
||||
const gridOrientation = computed(() => {
|
||||
@@ -132,7 +154,7 @@ const tocEntries = computed(() =>
|
||||
// Page libre : texte markdown + médias "projectId/filename"
|
||||
const freeText = computed(() => {
|
||||
if (props.page.kind !== 'free' || !props.page.body?.trim()) return ''
|
||||
return marked.parse(props.page.body) as string
|
||||
return stripTargetParam(marked.parse(props.page.body) as string)
|
||||
})
|
||||
|
||||
const freeMedia = computed<ResolvedMedia[]>(() => {
|
||||
@@ -149,6 +171,9 @@ const freeMedia = computed<ResolvedMedia[]>(() => {
|
||||
<CoverTemplate
|
||||
v-if="page.kind === 'cover'"
|
||||
:title="portfolio.cover.title"
|
||||
:date="portfolio.cover.date"
|
||||
:site-url="portfolio.cover.siteUrl"
|
||||
:contact-email="portfolio.cover.contactEmail"
|
||||
:background="page.background"
|
||||
:band-color="page.bandColor"
|
||||
:render-mode="renderMode"
|
||||
@@ -171,7 +196,8 @@ const freeMedia = computed<ResolvedMedia[]>(() => {
|
||||
:text="freeText"
|
||||
:media="freeMedia"
|
||||
:background="page.background"
|
||||
:band-color="page.bandColor"
|
||||
:band-color="page.bandColor ?? coverBandColor"
|
||||
:text-scale="page.textScale"
|
||||
:page="pageInfo"
|
||||
:render-mode="renderMode"
|
||||
/>
|
||||
@@ -185,7 +211,10 @@ const freeMedia = computed<ResolvedMedia[]>(() => {
|
||||
:title="project.title"
|
||||
:category="project.category"
|
||||
:badge="projectBadge"
|
||||
:intro="projectIntro"
|
||||
:work-type="projectWorkType"
|
||||
:year="projectYear"
|
||||
:subtitle="page.subtitle"
|
||||
:link="projectLink"
|
||||
:text="projectText"
|
||||
:image="projectCoverImage"
|
||||
:image-fit="page.coverFit"
|
||||
@@ -194,6 +223,8 @@ const freeMedia = computed<ResolvedMedia[]>(() => {
|
||||
:band-height="page.bandHeight"
|
||||
:band-color="page.bandColor"
|
||||
:ink="page.ink"
|
||||
:background="page.background"
|
||||
:layout="page.layout"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -206,9 +237,8 @@ const freeMedia = computed<ResolvedMedia[]>(() => {
|
||||
:media="gridMedia"
|
||||
:grid="page.grid"
|
||||
:orientation="gridOrientation"
|
||||
:category="project.category"
|
||||
:band-color="gridStripOverride"
|
||||
:grey-bg="page.greyBg"
|
||||
:background="page.background"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -7,28 +7,59 @@ import type { Background } from '~~/types'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title: string // utilisé pour les métadonnées PDF, pas affiché
|
||||
title: string // titre du portfolio — en-tête + métadonnées PDF
|
||||
subtitle?: string
|
||||
recipient?: string
|
||||
background: Background
|
||||
bandColor?: string // filet couleur en bas (définit aussi celui du sommaire)
|
||||
date?: string // texte libre de l'en-tête (ex. « Juillet 2026 »)
|
||||
siteUrl?: string
|
||||
contactEmail?: string
|
||||
renderMode?: 'preview' | 'print'
|
||||
}>(),
|
||||
{ renderMode: 'preview' },
|
||||
)
|
||||
|
||||
// Le site affiche l'intro sur fond gris clair : « Blanc » rend le gris charte.
|
||||
const strip = computed(() => props.bandColor || FL_INK)
|
||||
|
||||
// Fond : « Plein » = couleur du filet ; « Blanc » rend le gris charte du site
|
||||
// (l'intro y est présentée sur gris clair) ; « Teinté » = aplat beige.
|
||||
const pageStyle = computed(() => {
|
||||
const s = backgroundStyle(props.background)
|
||||
if (props.background.mode === 'white') s.backgroundColor = FL_BASE
|
||||
return s
|
||||
const base
|
||||
= props.background.mode === 'full'
|
||||
? strip.value
|
||||
: props.background.mode === 'tint'
|
||||
? TINT_COLOR
|
||||
: FL_BASE
|
||||
return { backgroundColor: base, color: inkOn(base) }
|
||||
})
|
||||
|
||||
const strip = computed(() => props.bandColor || FL_INK)
|
||||
// En-tête : coordonnées en liens actifs (le PDF et l'aperçu partagent le
|
||||
// template ; Chromium convertit les <a href> en liens cliquables du PDF).
|
||||
const siteHref = computed(() => props.siteUrl?.trim() || DEFAULT_SITE_URL)
|
||||
const siteLabel = computed(() => siteHref.value.replace(/^https?:\/\//, '').replace(/\/$/, ''))
|
||||
const email = computed(() => props.contactEmail?.trim() || DEFAULT_CONTACT_EMAIL)
|
||||
// Laissée vide, la date affiche par défaut le mois courant (ex. « Juillet 2026 »).
|
||||
const displayDate = computed(() => props.date?.trim() || formatMonthYear())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fl-page" :style="pageStyle">
|
||||
<!-- En-tête : titre du portfolio + date, site et mail (liens actifs PDF/viewer) -->
|
||||
<header
|
||||
class="absolute inset-x-0 top-0 flex items-start justify-between gap-8"
|
||||
:style="{ padding: 'var(--fl-margin)', fontFamily: 'Syne, sans-serif' }"
|
||||
>
|
||||
<div class="max-w-[60%]">
|
||||
<h1 :style="{ fontSize: '30px', lineHeight: '1.2' }">{{ title }}</h1>
|
||||
<p class="opacity-70" :style="{ fontSize: '19px', lineHeight: '1.5' }">{{ displayDate }}</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-col items-end" :style="{ fontSize: '19px', lineHeight: '1.5' }">
|
||||
<a :href="siteHref" target="_blank" rel="noopener" class="text-current no-underline">{{ siteLabel }}</a>
|
||||
<a :href="`mailto:${email}`" class="text-current no-underline">{{ email }}</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<!-- Phrase d'intro du site (pages/01.home) — Lato, keywords dans les
|
||||
fontes d'« utilité » du thème Grav, non soulignés -->
|
||||
@@ -40,20 +71,9 @@ const strip = computed(() => props.bandColor || FL_INK)
|
||||
<span class="fl-font-culturelle">culturelle</span>
|
||||
qui œuvre sur logiciels libres.
|
||||
</p>
|
||||
|
||||
<!-- Flèche vers le bas (theme://images/pictos/arrow-down.svg) -->
|
||||
<svg viewBox="0 0 24 30" :style="{ width: '60px', height: '54px', marginTop: '90px' }" aria-hidden="true">
|
||||
<g transform="rotate(90,11,12.999996)">
|
||||
<path
|
||||
d="m 16.681932,5.2684395 0.08629,0.091376 5.042819,6.0548205 v 0 l 0.06362,0.100098 v 0 l 0.06561,0.144066 v 0 l 0.0389,0.137168 v 0 l 0.01717,0.117449 v 0 L 22,12 v 0 l -0.0025,0.07061 v 0 l -0.01594,0.12126 v 0 l -0.02359,0.09573 -0.0317,0.08965 v 0 l -0.06326,0.128377 v 0 l -0.09484,0.134549 -5,6 c -0.353564,0.424277 -0.984128,0.481601 -1.408405,0.128037 C 14.968124,18.441846 14.889154,17.879455 15.15741,17.46116 L 15.231779,17.359816 18.864,12.999975 3,13 C 2.4477153,13 2,12.552285 2,12 2,11.487164 2.3860402,11.064493 2.8833789,11.006728 L 3,11 18.864,10.999975 15.231779,6.6401844 c -0.353564,-0.4242769 -0.29624,-1.0548416 0.128037,-1.4084057 0.39164,-0.3263668 0.959052,-0.3026278 1.322116,0.036661 z"
|
||||
fill="currentColor"
|
||||
fill-rule="nonzero"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Filet couleur pleine largeur en bas -->
|
||||
<div class="absolute inset-x-0 bottom-0" :style="{ height: 'var(--fl-strip)', backgroundColor: strip }" />
|
||||
<!-- Filet couleur pleine largeur en bas (masqué en fond plein) -->
|
||||
<div v-if="background.mode !== 'full'" class="absolute inset-x-0 bottom-0" :style="{ height: 'var(--fl-strip)', backgroundColor: strip }" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -11,23 +11,27 @@ const props = withDefaults(
|
||||
media?: ResolvedMedia[]
|
||||
background: Background
|
||||
bandColor?: string
|
||||
textScale?: number // taille du texte de labeur en % (défaut 100 = 12 pt)
|
||||
page: { index: number; total: number }
|
||||
renderMode?: 'preview' | 'print'
|
||||
}>(),
|
||||
{ renderMode: 'preview' },
|
||||
{ renderMode: 'preview', textScale: 100 },
|
||||
)
|
||||
|
||||
const strip = computed(() => props.bandColor || FL_INK)
|
||||
const pageStyle = computed(() => backgroundStyle(props.background, strip.value))
|
||||
// Texte de labeur : base 16 px (12 pt), agrandissable ; interligne proportionnel.
|
||||
const bodyStyle = computed(() => ({ fontSize: `${(16 * props.textScale) / 100}px`, lineHeight: '1.35' }))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fl-page" :style="backgroundStyle(background)">
|
||||
<div class="absolute inset-x-0 top-0" :style="{ height: 'var(--fl-strip)', backgroundColor: strip }" />
|
||||
<div class="fl-page" :style="pageStyle">
|
||||
<div v-if="background.mode !== 'full'" class="absolute inset-x-0 top-0" :style="{ height: 'var(--fl-strip)', backgroundColor: strip }" />
|
||||
|
||||
<div class="absolute flex flex-col" :style="{ inset: 'var(--fl-margin)', top: '60px' }">
|
||||
<h2 v-if="title" class="max-w-[1000px]" :style="{ fontSize: '60px', lineHeight: '1.1' }">{{ title }}</h2>
|
||||
<h2 v-if="title" class="max-w-[1000px]" :style="{ fontSize: '60px', lineHeight: '1.05' }">{{ title }}</h2>
|
||||
<div class="mt-12 flex min-h-0 flex-1" :style="{ gap: '48px' }">
|
||||
<div v-if="text" class="fl-prose max-w-[740px] flex-1 overflow-hidden" v-html="text" />
|
||||
<div v-if="text" class="fl-prose max-w-[740px] flex-1 overflow-hidden" :style="bodyStyle" v-html="text" />
|
||||
<div v-if="media?.length" class="flex w-[560px] shrink-0 flex-col" :style="{ gap: 'var(--fl-gutter)' }">
|
||||
<div v-for="m in media.slice(0, 3)" :key="m.filename" class="min-h-0 flex-1">
|
||||
<img :src="m.url" :alt="m.filename" class="size-full object-contain" style="object-position: right top">
|
||||
|
||||
@@ -4,14 +4,17 @@
|
||||
// pastille catégorie (10 pt), présentation (Syne 12/16 pt, colonne à 50,5 %).
|
||||
// Sous le bandeau : image de couverture. Taille du titre et hauteur du
|
||||
// bandeau (= calage haut de l'image) ajustables pour éviter les débords.
|
||||
import type { InkMode, ResolvedMedia } from '~~/types'
|
||||
import type { Background, InkMode, ResolvedMedia } from '~~/types'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title: string
|
||||
category: string // "publique" | "sociale" | "culturelle"
|
||||
badge?: string // texte de la pastille (ex. « Utilité publique »)
|
||||
intro?: string // ligne(s) d'intro en gras
|
||||
badge?: string // pastille d'utilité (ex. « Culturel »)
|
||||
workType?: string // type du projet, ligne d'info (ex. « édition »)
|
||||
year?: string // année du projet, ligne d'info
|
||||
subtitle?: string // sous-titre sous le titre (fonte du titre, plus petit)
|
||||
link?: string // lien du projet sous le titre (taille labeur, actif PDF/aperçu)
|
||||
text?: string // présentation, HTML
|
||||
image?: ResolvedMedia
|
||||
imageFit?: 'width' | 'height' // calage : pleine largeur ou pleine hauteur
|
||||
@@ -20,14 +23,39 @@ const props = withDefaults(
|
||||
bandHeight?: number // mm (gabarit : 55)
|
||||
bandColor?: string
|
||||
ink?: InkMode
|
||||
background?: Background // fond : blanc / teinté (filet fin) / plein (bandeau ou colonne coloré)
|
||||
layout?: 'band' | 'split' // 'band' = bandeau haut ; 'split' = colonne texte + image droite
|
||||
}>(),
|
||||
{ ink: 'auto', imageFit: 'width', imageAlign: 'center', titleScale: 100, bandHeight: 55 },
|
||||
{ ink: 'auto', imageFit: 'width', imageAlign: 'center', titleScale: 100, bandHeight: 55, layout: 'band' },
|
||||
)
|
||||
|
||||
const PX_PER_MM = 3.7796
|
||||
|
||||
const band = computed(() => bandColorOf(props.category, props.bandColor))
|
||||
const inkColor = computed(() => inkOn(band.value, props.ink))
|
||||
// Couleur choisie du projet (filet / bandeau). Fond « plein » = bandeau/colonne
|
||||
// à cette couleur ; sinon fond blanc/teinté avec un fin filet.
|
||||
const catColor = computed(() => filetColor(props.bandColor))
|
||||
const isFull = computed(() => props.background?.mode === 'full')
|
||||
// Fond de page (blanc ou teinté) — sous le bandeau clair et autour de l'image.
|
||||
const pageBg = computed(() => (props.background?.mode === 'tint' ? TINT_COLOR : '#ffffff'))
|
||||
|
||||
const bandBg = computed(() => (isFull.value ? catColor.value : pageBg.value))
|
||||
const inkColor = computed(() => (isFull.value ? inkOn(catColor.value, props.ink) : '#000000'))
|
||||
// Pastille pleine, sans contour. Fond plein : fond = couleur du texte, texte
|
||||
// opposé. Sinon : fond = couleur du projet, texte lisible dessus.
|
||||
const pillBg = computed(() => (isFull.value ? inkColor.value : catColor.value))
|
||||
const pillInk = computed(() =>
|
||||
isFull.value ? (inkColor.value === '#ffffff' ? '#000000' : '#ffffff') : inkOn(catColor.value),
|
||||
)
|
||||
// Disposition « colonne ». Clair : texte sur le fond de page, fin filet en haut.
|
||||
// Fond plein : la colonne prend la couleur du projet, texte contrasté (pas de filet).
|
||||
const splitBg = computed(() => (isFull.value ? catColor.value : pageBg.value))
|
||||
const splitInk = computed(() => (isFull.value ? inkOn(catColor.value, props.ink) : inkOn(pageBg.value)))
|
||||
const splitPillBg = computed(() => (isFull.value ? splitInk.value : catColor.value))
|
||||
const splitPillInk = computed(() =>
|
||||
isFull.value ? (splitInk.value === '#ffffff' ? '#000000' : '#ffffff') : inkOn(catColor.value),
|
||||
)
|
||||
// Lien affiché sans le protocole
|
||||
const linkLabel = computed(() => props.link?.replace(/^https?:\/\//, '').replace(/\/$/, ''))
|
||||
const titleClass = computed(() => CATEGORY_TITLE_CLASS[props.category] ?? '')
|
||||
// L'Avara est plus grasse : le gabarit la compose en 42 pt au lieu de 45 pt.
|
||||
const titleSize = computed(() => {
|
||||
@@ -48,39 +76,90 @@ const alignStyle = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fl-page">
|
||||
<!-- Bandeau -->
|
||||
<div class="absolute inset-x-0 top-0" :style="{ height: bandPx, backgroundColor: band, color: inkColor }">
|
||||
<h1
|
||||
class="absolute"
|
||||
:class="titleClass"
|
||||
:style="{ left: 'var(--fl-margin)', top: '14px', fontSize: titleSize, lineHeight: '1.15', maxWidth: '620px' }"
|
||||
>
|
||||
{{ title }}
|
||||
</h1>
|
||||
<span
|
||||
v-if="badge"
|
||||
class="absolute whitespace-nowrap rounded-full border px-[12px] py-[3px]"
|
||||
:style="{ left: '42.33%', top: '54px', transform: 'translateY(-50%)', fontSize: '13.3px', lineHeight: '1.2', borderColor: 'currentColor' }"
|
||||
>
|
||||
{{ badge }}
|
||||
</span>
|
||||
<!-- Disposition « colonne + image » : texte à gauche, image pleine hauteur à droite -->
|
||||
<div v-if="layout === 'split'" class="fl-page" :style="{ backgroundColor: pageBg }">
|
||||
<div class="absolute inset-0 flex">
|
||||
<!-- Colonne gauche : filet (mode clair) ou fond plein coloré, infos, titre, sous-titre, lien, description -->
|
||||
<div class="flex min-w-0 flex-1 flex-col" :style="{ backgroundColor: splitBg, color: splitInk }">
|
||||
<div v-if="bandColor && !isFull" class="shrink-0" :style="{ height: 'var(--fl-strip-light)', backgroundColor: catColor }" />
|
||||
<div class="flex min-h-0 flex-1 flex-col" :style="{ padding: 'var(--fl-margin)' }">
|
||||
<div class="flex items-center whitespace-nowrap" :style="{ gap: '12px', fontSize: '13.3px', lineHeight: '1.2' }">
|
||||
<span
|
||||
v-if="badge"
|
||||
class="rounded-full px-[12px] py-[3px]"
|
||||
:style="{ backgroundColor: splitPillBg, color: splitPillInk }"
|
||||
>{{ badge }}</span>
|
||||
<span v-if="workType">{{ workType }}</span>
|
||||
<span v-if="year" class="opacity-70">{{ year }}</span>
|
||||
</div>
|
||||
<h1 :class="titleClass" :style="{ fontSize: titleSize, lineHeight: '1.05', marginTop: '16px' }">{{ title }}</h1>
|
||||
<p v-if="subtitle" :class="titleClass" :style="{ fontSize: `calc(${titleSize} * 0.5)`, lineHeight: '1.2', marginTop: '4px' }">{{ subtitle }}</p>
|
||||
<a
|
||||
v-if="link"
|
||||
:href="link"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="text-current no-underline"
|
||||
:style="{ marginTop: '8px' }"
|
||||
>{{ linkLabel }}</a>
|
||||
<div v-if="text" class="fl-prose min-h-0 overflow-hidden" :style="{ marginTop: '28px' }" v-html="text" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Image pleine hauteur, rognée au-delà de ~70 % de la largeur, centrée horizontalement -->
|
||||
<div v-if="image" class="flex h-full shrink-0 justify-center overflow-hidden" :style="{ maxWidth: '70%' }">
|
||||
<img :src="image.url" :alt="title" :style="{ height: '100%', width: 'auto', maxWidth: 'none' }">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Disposition « bandeau » (par défaut) -->
|
||||
<div v-else class="fl-page" :style="{ backgroundColor: pageBg }">
|
||||
<!-- Bandeau (pleine couleur) ou fond de page (mode clair) -->
|
||||
<div class="absolute inset-x-0 top-0" :style="{ height: bandPx, backgroundColor: bandBg, color: inkColor }">
|
||||
<!-- Mode clair : la couleur choisie n'est plus qu'un filet fin en haut -->
|
||||
<div v-if="!isFull" class="absolute inset-x-0 top-0" :style="{ height: 'var(--fl-strip-light)', backgroundColor: catColor }" />
|
||||
|
||||
<!-- Ligne d'info au-dessus du titre (pastille · type · année) -->
|
||||
<div
|
||||
class="absolute overflow-hidden"
|
||||
:style="{ left: '50.47%', right: 'var(--fl-margin)', top: '33px', bottom: '12px' }"
|
||||
class="absolute flex items-center whitespace-nowrap"
|
||||
:style="{ left: 'var(--fl-margin)', right: '51%', top: '34px', gap: '12px', fontSize: '13.3px', lineHeight: '1.2' }"
|
||||
>
|
||||
<p v-if="intro" class="font-bold">{{ intro }}</p>
|
||||
<div v-if="text" class="fl-prose" v-html="text" />
|
||||
<span
|
||||
v-if="badge"
|
||||
class="rounded-full px-[12px] py-[3px]"
|
||||
:style="{ backgroundColor: pillBg, color: pillInk }"
|
||||
>{{ badge }}</span>
|
||||
<span v-if="workType">{{ workType }}</span>
|
||||
<span v-if="year" class="opacity-70">{{ year }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Titre (+ sous-titre, lien) et présentation, alignés en haut sous la ligne d'info -->
|
||||
<div class="absolute flex" :style="{ left: 'var(--fl-margin)', right: 'var(--fl-margin)', top: '66px', bottom: '12px', gap: 'var(--fl-gutter)' }">
|
||||
<div class="flex flex-col" :style="{ flex: '0 0 47%' }">
|
||||
<h1 :class="titleClass" :style="{ fontSize: titleSize, lineHeight: '1.05' }">{{ title }}</h1>
|
||||
<p v-if="subtitle" :class="titleClass" :style="{ fontSize: `calc(${titleSize} * 0.5)`, lineHeight: '1.2', marginTop: '4px' }">{{ subtitle }}</p>
|
||||
<a
|
||||
v-if="link"
|
||||
:href="link"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="text-current no-underline"
|
||||
:style="{ marginTop: '8px' }"
|
||||
>{{ linkLabel }}</a>
|
||||
</div>
|
||||
<div class="flex-1 overflow-hidden" :style="{ marginTop: '6px' }">
|
||||
<div v-if="text" class="fl-prose" v-html="text" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Image de couverture : calée sur la largeur (pleine largeur, fixée en
|
||||
bas de page, débord haut coupé) ou sur la hauteur (pleine hauteur,
|
||||
centrée). Le fond visible sous/à côté de l'image reprend la couleur
|
||||
du bandeau. -->
|
||||
du bandeau (blanc en mode clair). -->
|
||||
<div
|
||||
class="absolute inset-x-0 bottom-0 flex overflow-hidden"
|
||||
:style="{ top: bandPx, backgroundColor: image ? band : '#ffffff', ...alignStyle }"
|
||||
:style="{ top: bandPx, backgroundColor: isFull ? catColor : pageBg, ...alignStyle }"
|
||||
>
|
||||
<img
|
||||
v-if="image"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// Filet couleur 5 mm en haut, images ENTIÈRES (object-contain) calées sur
|
||||
// une grille ADAPTATIVE : la famille choisie se construit selon le nombre
|
||||
// d'images et leur orientation. Marges 8 mm, gouttières 3,8 mm.
|
||||
import type { ResolvedMedia } from '~~/types'
|
||||
import type { Background, ResolvedMedia } from '~~/types'
|
||||
import type { ImageOrientation } from '~~/composables/useGrids'
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -11,15 +11,16 @@ const props = withDefaults(
|
||||
media: ResolvedMedia[]
|
||||
grid: string // id de famille (useGrids)
|
||||
orientation?: ImageOrientation
|
||||
category?: string
|
||||
bandColor?: string
|
||||
greyBg?: boolean
|
||||
background?: Background // fond : blanc / teinté / plein (couleur du filet)
|
||||
}>(),
|
||||
{ orientation: 'mixed' },
|
||||
)
|
||||
|
||||
const def = computed(() => gridDef(props.grid, props.media.length, props.orientation))
|
||||
const strip = computed(() => stripColorOf(props.category, props.bandColor))
|
||||
const strip = computed(() => filetColor(props.bandColor))
|
||||
// Fond « plein » = couleur du filet (le filet est alors masqué).
|
||||
const pageBg = computed(() => backgroundColorOf(props.background, strip.value))
|
||||
|
||||
// Métriques du canvas A3 paysage (theme.css) — zone utile hors marges 8 mm.
|
||||
const MARGIN = 30.2
|
||||
@@ -92,9 +93,9 @@ const items = computed(() =>
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fl-page" :style="greyBg ? { backgroundColor: FL_BASE } : undefined">
|
||||
<!-- Filet couleur pleine largeur -->
|
||||
<div class="absolute inset-x-0 top-0" :style="{ height: 'var(--fl-strip)', backgroundColor: strip }" />
|
||||
<div class="fl-page" :style="{ backgroundColor: pageBg }">
|
||||
<!-- Filet couleur fin en haut (masqué en fond plein) -->
|
||||
<div v-if="background?.mode !== 'full'" class="absolute inset-x-0 top-0" :style="{ height: 'var(--fl-strip-light)', backgroundColor: strip }" />
|
||||
|
||||
<!-- Grande image + bandeaux/file : bandeaux à leur hauteur réelle -->
|
||||
<div v-if="hero" class="absolute flex" :style="{ inset: 'var(--fl-margin)', gap: 'var(--fl-gutter)' }">
|
||||
|
||||
@@ -19,6 +19,7 @@ const props = withDefaults(
|
||||
const emit = defineEmits<{ navigate: [pageNumber: number] }>()
|
||||
|
||||
const strip = computed(() => props.bandColor || FL_INK)
|
||||
const pageStyle = computed(() => backgroundStyle(props.background, strip.value))
|
||||
const isPrint = computed(() => props.renderMode === 'print')
|
||||
|
||||
// En print, chaque entrée est un lien interne #page-N (converti en lien PDF par
|
||||
@@ -52,11 +53,11 @@ const gridStyle = computed(() => ({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fl-page" :style="backgroundStyle(background)">
|
||||
<div class="absolute inset-x-0 top-0" :style="{ height: 'var(--fl-strip)', backgroundColor: strip }" />
|
||||
<div class="fl-page" :style="pageStyle">
|
||||
<div v-if="background.mode !== 'full'" class="absolute inset-x-0 top-0" :style="{ height: 'var(--fl-strip)', backgroundColor: strip }" />
|
||||
|
||||
<div class="absolute flex flex-col" :style="{ inset: 'var(--fl-margin)', top: '60px' }">
|
||||
<h2 :style="{ fontSize: '60px', lineHeight: '1.1' }">{{ title }}</h2>
|
||||
<h2 :style="{ fontSize: '60px', lineHeight: '1.05' }">{{ title }}</h2>
|
||||
<div class="mt-14 grid min-h-0 flex-1 content-start" :style="gridStyle">
|
||||
<a
|
||||
v-for="entry in entries"
|
||||
|
||||
30
composables/useClipboard.ts
Normal file
30
composables/useClipboard.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
// Copie presse-papier robuste : l'API `navigator.clipboard` n'existe qu'en
|
||||
// contexte sécurisé (HTTPS ou localhost) ; en HTTP sur IP LAN elle est absente,
|
||||
// d'où un repli via un <textarea> temporaire + `document.execCommand('copy')`.
|
||||
export async function copyToClipboard(text: string): Promise<boolean> {
|
||||
if (import.meta.server) return false
|
||||
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
return true
|
||||
} catch {
|
||||
// contexte sécurisé mais copie refusée → on tente le repli
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = text
|
||||
ta.setAttribute('readonly', '')
|
||||
ta.style.position = 'fixed'
|
||||
ta.style.top = '-9999px'
|
||||
document.body.appendChild(ta)
|
||||
ta.select()
|
||||
const ok = document.execCommand('copy')
|
||||
document.body.removeChild(ta)
|
||||
return ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,25 @@ export function formatProjectDate(iso?: string): string {
|
||||
return Number.isNaN(d.getTime()) ? '' : dateFormatter.format(d)
|
||||
}
|
||||
|
||||
/** Mois + année capitalisés (ex. « Juillet 2026 ») ; sans argument = aujourd'hui.
|
||||
* Sert de date par défaut de l'en-tête de couverture. */
|
||||
export function formatMonthYear(iso?: string): string {
|
||||
const d = iso ? new Date(iso) : new Date()
|
||||
if (Number.isNaN(d.getTime())) return ''
|
||||
const s = dateFormatter.format(d)
|
||||
return s.charAt(0).toUpperCase() + s.slice(1)
|
||||
}
|
||||
|
||||
/** Retire le paramètre `target=_blank` que Grav colle à chaque URL — dans une
|
||||
* URL simple comme dans une chaîne HTML (href des liens du corps de texte). */
|
||||
export function stripTargetParam(s?: string): string {
|
||||
if (!s) return ''
|
||||
return s
|
||||
.replace(/\?target=_blank&/g, '?') // seul paramètre en tête, suivi d'autres
|
||||
.replace(/&target=_blank/g, '') // paramètre en fin de chaîne de requête
|
||||
.replace(/\?target=_blank\b/g, '') // seul paramètre
|
||||
}
|
||||
|
||||
export const CATEGORY_LABELS: Record<string, string> = {
|
||||
culturelle: 'Culturelle',
|
||||
publique: 'Publique',
|
||||
|
||||
@@ -5,39 +5,22 @@ import type { Background, InkMode } from '~~/types'
|
||||
export const FL_INK = '#0e1229'
|
||||
export const FL_BASE = '#f5f5f5'
|
||||
|
||||
/** Couleurs de bandeau du gabarit PDF (aplats CMJN convertis). */
|
||||
export const PDF_COLORS = [
|
||||
'#ec008c', // magenta 100% — utilité publique
|
||||
'#8bd261', // vert C45 J71 — utilité sociale
|
||||
'#fff766', // jaune 60% — utilité culturelle (bandeau)
|
||||
'#fff200', // jaune 100% — utilité culturelle (filet)
|
||||
/** Palette proposée pour la couleur du filet/bandeau des pages projet
|
||||
* (magenta et vert du gabarit + roses/jaune/menthe du site). */
|
||||
export const BAND_COLORS = [
|
||||
'#ec008c', // magenta 100% (publique)
|
||||
'#8bd261', // vert (sociale)
|
||||
'#ffaeab', // rose (publique, site)
|
||||
'#feff74', // jaune pâle (culturelle, site)
|
||||
'#fabbde', // rose (commanditaires, site)
|
||||
'#4bffc9', // menthe (projets, site)
|
||||
]
|
||||
|
||||
/** Couleurs du site Grav (hover des keywords de l'index). */
|
||||
export const SITE_COLORS = [
|
||||
'#ffaeab', // publique
|
||||
'#71ff94', // sociale
|
||||
'#feff74', // culturelle
|
||||
'#fabbde', // commanditaires
|
||||
'#82f8ee', // figures libres
|
||||
'#4bffc9', // projets
|
||||
]
|
||||
|
||||
/** Palette proposée pour les bandeaux de pages projet. */
|
||||
export const BAND_COLORS = [...PDF_COLORS, ...SITE_COLORS]
|
||||
|
||||
/** Couleur de bandeau par catégorie (page couverture de projet, gabarit). */
|
||||
export const CATEGORY_BAND: Record<string, string> = {
|
||||
publique: '#ec008c',
|
||||
sociale: '#8bd261',
|
||||
culturelle: '#fff766',
|
||||
}
|
||||
|
||||
/** Couleur de filet par catégorie (pages grille — jaune à 100 %). */
|
||||
export const CATEGORY_STRIP: Record<string, string> = {
|
||||
publique: '#ec008c',
|
||||
sociale: '#8bd261',
|
||||
culturelle: '#fff200',
|
||||
/** Libellé court de la pastille d'utilité (sans le mot « Utilité »). */
|
||||
export const CATEGORY_BADGE: Record<string, string> = {
|
||||
publique: 'Publique',
|
||||
sociale: 'Social',
|
||||
culturelle: 'Culturel',
|
||||
}
|
||||
|
||||
/** Classe CSS de la fonte de titre par catégorie (mixins du thème Grav). */
|
||||
@@ -47,18 +30,8 @@ export const CATEGORY_TITLE_CLASS: Record<string, string> = {
|
||||
culturelle: 'fl-font-culturelle',
|
||||
}
|
||||
|
||||
/** Aplats clairs proposés pour le mode « teinté ». */
|
||||
export const TINT_PRESETS = [
|
||||
'#f5f5f5', // gris clair (base)
|
||||
'#efece3', // beige
|
||||
'#ffeceb', // rose pâle
|
||||
'#fbfbdc', // jaune pâle
|
||||
'#e9fbee', // vert pâle
|
||||
'#e2fff6', // menthe pâle
|
||||
]
|
||||
|
||||
/** Pleines couleurs de la charte pour le mode « couleur ». */
|
||||
export const COLOR_PRESETS = [FL_INK, ...PDF_COLORS, ...SITE_COLORS]
|
||||
/** Aplat unique du mode « teinté » (beige de la charte). */
|
||||
export const TINT_COLOR = '#efece3'
|
||||
|
||||
function luminance(hex: string): number {
|
||||
const m = hex.replace('#', '')
|
||||
@@ -74,22 +47,23 @@ export function inkOn(color: string, ink: InkMode = 'auto'): string {
|
||||
return luminance(color) < 0.45 ? '#ffffff' : '#000000'
|
||||
}
|
||||
|
||||
export function bandColorOf(category: string | undefined, override?: string): string {
|
||||
return override || CATEGORY_BAND[category ?? ''] || FL_INK
|
||||
/** Couleur du filet/bandeau : couleur choisie, sinon l'encre neutre (plus de
|
||||
* couleur automatique par catégorie). */
|
||||
export function filetColor(override?: string): string {
|
||||
return override || FL_INK
|
||||
}
|
||||
|
||||
export function stripColorOf(category: string | undefined, override?: string): string {
|
||||
return override || CATEGORY_STRIP[category ?? ''] || FL_INK
|
||||
}
|
||||
|
||||
export function backgroundColorOf(bg: Background): string {
|
||||
if (bg.mode === 'white') return '#ffffff'
|
||||
return bg.color || (bg.mode === 'tint' ? FL_BASE : FL_INK)
|
||||
/** Couleur de fond d'une page : blanc, teinté (aplat fixe) ou plein (`fullColor`,
|
||||
* la couleur du filet/bandeau de la page). */
|
||||
export function backgroundColorOf(bg: Background | undefined, fullColor = FL_INK): string {
|
||||
if (bg?.mode === 'full') return fullColor
|
||||
if (bg?.mode === 'tint') return TINT_COLOR
|
||||
return '#ffffff'
|
||||
}
|
||||
|
||||
/** Style inline d'une page : fond + couleur d'encre lisible. */
|
||||
export function backgroundStyle(bg: Background): Record<string, string> {
|
||||
const color = backgroundColorOf(bg)
|
||||
export function backgroundStyle(bg: Background | undefined, fullColor = FL_INK): Record<string, string> {
|
||||
const color = backgroundColorOf(bg, fullColor)
|
||||
return {
|
||||
backgroundColor: color,
|
||||
color: luminance(color) < 0.45 ? '#ffffff' : FL_INK,
|
||||
|
||||
@@ -1,66 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
const route = useRoute()
|
||||
const toast = useToast()
|
||||
const syncing = ref(false)
|
||||
|
||||
async function refreshContent() {
|
||||
syncing.value = true
|
||||
try {
|
||||
const result = await $fetch<{ ok: boolean, summary: string }>('/api/sync', {
|
||||
method: 'POST',
|
||||
timeout: 900_000,
|
||||
})
|
||||
toast.add({
|
||||
title: result.ok ? 'Contenu rafraîchi' : 'Synchronisation en échec',
|
||||
description: result.summary,
|
||||
color: result.ok ? 'success' : 'error',
|
||||
})
|
||||
if (result.ok) await refreshNuxtData()
|
||||
} catch {
|
||||
toast.add({ title: 'Échec de la synchronisation', color: 'error' })
|
||||
} finally {
|
||||
syncing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await $fetch('/api/auth/logout', { method: 'POST' })
|
||||
navigateTo('/login')
|
||||
}
|
||||
// Plus de barre d'en-tête globale : le logo « Portfolios » et la déconnexion
|
||||
// vivent désormais en haut de la colonne gauche de chaque page (BrandBar),
|
||||
// laissant les colonnes centrale/droite occuper toute la hauteur.
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-[var(--fl-base)] dark:bg-neutral-950">
|
||||
<header
|
||||
v-if="route.path !== '/login'"
|
||||
class="sticky top-0 z-40 border-b border-neutral-200 bg-white/90 backdrop-blur dark:border-neutral-800 dark:bg-neutral-900/90"
|
||||
>
|
||||
<UContainer class="flex h-14 items-center gap-6">
|
||||
<NuxtLink to="/" class="flex items-center gap-2 font-bold text-[var(--fl-ink)] dark:text-white">
|
||||
<span class="flex size-7 items-center justify-center rounded-full bg-[var(--fl-ink)] text-xs text-white">FL</span>
|
||||
Portfolios
|
||||
</NuxtLink>
|
||||
<div class="ml-auto flex items-center gap-1">
|
||||
<UTooltip text="Actualise les données depuis Grav">
|
||||
<UButton
|
||||
icon="i-lucide-refresh-cw"
|
||||
label="Rafraîchir"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
:loading="syncing"
|
||||
@click="refreshContent"
|
||||
/>
|
||||
</UTooltip>
|
||||
<UButton
|
||||
icon="i-lucide-log-out"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
aria-label="Se déconnecter"
|
||||
@click="logout"
|
||||
/>
|
||||
</div>
|
||||
</UContainer>
|
||||
</header>
|
||||
<div class="min-h-screen bg-[var(--fl-base)] text-[var(--fl-ink)] dark:bg-neutral-950 dark:text-white">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
107
pages/index.vue
107
pages/index.vue
@@ -67,30 +67,51 @@ async function downloadPdf(p: PortfolioSummary) {
|
||||
|
||||
async function copyPublicLink(id: string) {
|
||||
const url = `${window.location.origin}/view/${id}`
|
||||
try {
|
||||
await navigator.clipboard.writeText(url)
|
||||
if (await copyToClipboard(url)) {
|
||||
toast.add({ title: 'Lien public copié', color: 'success' })
|
||||
} catch {
|
||||
} else {
|
||||
toast.add({ title: 'Copie impossible', description: url, color: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
const sharing = ref<string | null>(null)
|
||||
|
||||
/** Active le lien public (fige le contenu) puis copie l'URL. */
|
||||
async function enableShare(id: string) {
|
||||
sharing.value = id
|
||||
try {
|
||||
await $fetch(`/api/portfolios/${id}/share`, { method: 'POST', body: {} })
|
||||
await refresh()
|
||||
await copyPublicLink(id)
|
||||
} catch {
|
||||
toast.add({ title: 'Échec de la génération du lien', color: 'error' })
|
||||
} finally {
|
||||
sharing.value = null
|
||||
}
|
||||
}
|
||||
</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 class="flex h-screen">
|
||||
<!-- Colonne gauche : header logo (fond + bordure), colonne transparente -->
|
||||
<aside class="w-60 shrink-0">
|
||||
<BrandBar />
|
||||
</aside>
|
||||
|
||||
<!-- Grille des portfolios, pleine hauteur -->
|
||||
<main class="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<div class="flex h-14 items-center justify-end gap-2 border-b border-neutral-200 bg-white px-6 dark:border-neutral-800 dark:bg-neutral-900">
|
||||
<RefreshButton />
|
||||
<UButton icon="i-lucide-plus" size="sm" label="Nouveau portfolio" @click="showCreate = true" />
|
||||
</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="flex-1 overflow-auto p-6">
|
||||
<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 v-else class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
<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">
|
||||
@@ -105,31 +126,39 @@ async function copyPublicLink(id: string) {
|
||||
<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 class="mt-4 space-y-2">
|
||||
<div class="flex gap-2">
|
||||
<UButton :to="`/portfolios/${p.id}`" class="flex-1 justify-center" size="sm" variant="soft" icon="i-lucide-pencil" label="Ouvrir" />
|
||||
<UButton class="flex-1 justify-center" size="sm" variant="ghost" color="neutral" icon="i-lucide-copy" label="Dupliquer" @click="duplicate(p.id)" />
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<UButton
|
||||
class="flex-1 justify-center"
|
||||
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
|
||||
class="flex-1 justify-center"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
:icon="p.publicShare ? 'i-lucide-link' : 'i-lucide-link-2'"
|
||||
:label="p.publicShare ? 'Copier le lien' : 'Générer le lien'"
|
||||
:loading="sharing === p.id"
|
||||
@click="p.publicShare ? copyPublicLink(p.id) : enableShare(p.id)"
|
||||
/>
|
||||
</div>
|
||||
<UButton class="w-full justify-center" size="sm" variant="ghost" color="error" icon="i-lucide-trash-2" label="Supprimer" @click="remove(p.id, p.name)" />
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<UModal v-model:open="showCreate" title="Nouveau portfolio">
|
||||
<template #body>
|
||||
@@ -142,5 +171,5 @@ async function copyPublicLink(id: string) {
|
||||
</form>
|
||||
</template>
|
||||
</UModal>
|
||||
</UContainer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -27,6 +27,17 @@ 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])
|
||||
|
||||
// La liste des pages (colonne de gauche) suit la page active : quand la
|
||||
// sélection sort de la vue (beaucoup de pages), on la ramène dans le conteneur.
|
||||
watch(selectedPageId, (id) => {
|
||||
if (!id) return
|
||||
nextTick(() => {
|
||||
document
|
||||
.querySelector(`[data-page-nav-id="${CSS.escape(id)}"]`)
|
||||
?.scrollIntoView({ block: 'nearest' })
|
||||
})
|
||||
})
|
||||
|
||||
// ---- Sauvegarde automatique (debounce) ----
|
||||
const saveState = ref<'saved' | 'saving' | 'dirty' | 'error'>('saved')
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
@@ -51,11 +62,27 @@ async function save() {
|
||||
}
|
||||
}
|
||||
|
||||
// Réassignation programmatique de `portfolio` (publier/couper/dégeler) : ne
|
||||
// compte pas comme une édition (ni autosave, ni « modifié depuis publication »).
|
||||
let programmatic = false
|
||||
|
||||
// Le lien public a-t-il pris du retard sur les éditions ? (édité après le gel)
|
||||
const sharedStale = ref(
|
||||
Boolean(portfolio.value.publicShare)
|
||||
&& !!portfolio.value.frozenContent
|
||||
&& portfolio.value.updatedAt > (portfolio.value.frozenContent.generatedAt ?? ''),
|
||||
)
|
||||
|
||||
watch(
|
||||
portfolio,
|
||||
() => {
|
||||
if (programmatic) {
|
||||
programmatic = false
|
||||
return
|
||||
}
|
||||
saveState.value = 'dirty'
|
||||
pdfStale.value = true // toute modif rend le dernier PDF périmé
|
||||
sharedStale.value = true // et décale le lien public par rapport à l'instantané
|
||||
clearTimeout(timer)
|
||||
timer = setTimeout(save, 800)
|
||||
},
|
||||
@@ -233,6 +260,16 @@ function setInk(value: (typeof inkItems)[number]['value']) {
|
||||
if (selectedPage.value?.kind === 'project') selectedPage.value.ink = value
|
||||
}
|
||||
|
||||
// Disposition de la page couverture de projet
|
||||
const coverLayoutItems = [
|
||||
{ value: 'band', label: 'Bandeau' },
|
||||
{ value: 'split', label: 'Colonne' },
|
||||
] as const
|
||||
|
||||
function setCoverLayout(value: (typeof coverLayoutItems)[number]['value']) {
|
||||
if (selectedPage.value?.kind === 'project') selectedPage.value.layout = value === 'band' ? undefined : value
|
||||
}
|
||||
|
||||
// ---- Calage de l'image de couverture (pages projet) ----
|
||||
const coverFitItems = [
|
||||
{ value: 'width', label: 'Largeur' },
|
||||
@@ -272,6 +309,51 @@ const bandHeight = computed({
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
// Taille du texte de labeur des pages libres (%). Undefined = 100.
|
||||
const freeTextScale = computed({
|
||||
get: () => (selectedPage.value?.kind === 'free' ? selectedPage.value.textScale ?? 100 : 100),
|
||||
set: (v: number) => {
|
||||
if (selectedPage.value?.kind === 'free') selectedPage.value.textScale = v === 100 ? undefined : v
|
||||
},
|
||||
})
|
||||
|
||||
// Année par défaut (placeholder) déduite de la date Grav du projet sélectionné.
|
||||
const projectYearPlaceholder = computed(() => {
|
||||
if (selectedPage.value?.kind !== 'project') return 'Année'
|
||||
const iso = projects.value[selectedPage.value.projectId]?.date
|
||||
const y = iso ? new Date(iso).getFullYear() : NaN
|
||||
return Number.isNaN(y) ? 'Année' : String(y)
|
||||
})
|
||||
|
||||
// Texte de présentation du projet : le champ est prérempli avec le texte Grav
|
||||
// pour édition rapide. `textOverride` reste `undefined` tant que le texte n'a pas
|
||||
// été modifié → la page suit alors automatiquement Grav (donc se met à jour lors
|
||||
// d'un rafraîchissement manuel). Dès qu'il diffère de Grav, il est « figé » et
|
||||
// n'est plus resynchronisé ; le bouton « Réinitialiser » le rattache à Grav.
|
||||
const presentationText = computed({
|
||||
get: () => {
|
||||
if (selectedPage.value?.kind !== 'project') return ''
|
||||
const grav = projects.value[selectedPage.value.projectId]?.bodyMarkdown ?? ''
|
||||
return selectedPage.value.textOverride ?? grav
|
||||
},
|
||||
set: (v: string) => {
|
||||
if (selectedPage.value?.kind !== 'project') return
|
||||
const grav = projects.value[selectedPage.value.projectId]?.bodyMarkdown ?? ''
|
||||
// Ré-édité à l'identique de Grav → on relâche l'override (re-suit Grav).
|
||||
selectedPage.value.textOverride = v === grav ? undefined : v
|
||||
},
|
||||
})
|
||||
|
||||
// Le texte de présentation a-t-il été modifié (ne suit plus Grav) ?
|
||||
const presentationEdited = computed(() =>
|
||||
selectedPage.value?.kind === 'project' && selectedPage.value.textOverride !== undefined,
|
||||
)
|
||||
|
||||
function resetPresentation() {
|
||||
if (selectedPage.value?.kind === 'project') selectedPage.value.textOverride = undefined
|
||||
}
|
||||
|
||||
// ---- Image de couverture / médias de page libre ----
|
||||
const showMediaPicker = ref(false)
|
||||
const mediaPickerTarget = ref<'cover' | 'free'>('cover')
|
||||
@@ -301,6 +383,7 @@ const showGenerate = ref(false)
|
||||
const generating = ref(false)
|
||||
const pdfFileName = ref('')
|
||||
const frozenAt = ref(portfolio.value.frozenContent?.generatedAt)
|
||||
const lastGeneratedAt = ref(portfolio.value.lastExport?.at)
|
||||
const lastResult = ref<{ fileName: string; downloadUrl: string; sizeBytes: number; pageCount: number } | null>(null)
|
||||
|
||||
function openGenerate() {
|
||||
@@ -319,11 +402,15 @@ async function generate() {
|
||||
timeout: 300_000,
|
||||
})
|
||||
frozenAt.value = frozenAt.value ?? new Date().toISOString()
|
||||
lastGeneratedAt.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' })
|
||||
} catch (err: any) {
|
||||
// Détail de l'erreur pour diagnostic (message serveur, timeout, réseau…).
|
||||
const detail = err?.data?.statusMessage || err?.statusMessage || err?.message || String(err)
|
||||
toast.add({ title: 'Échec de la génération', description: detail, color: 'error' })
|
||||
console.error('[generate] échec:', err)
|
||||
} finally {
|
||||
generating.value = false
|
||||
}
|
||||
@@ -339,11 +426,9 @@ 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))
|
||||
: '',
|
||||
)
|
||||
const dateTimeFmt = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium', timeStyle: 'short' })
|
||||
const frozenDateLabel = computed(() => (frozenAt.value ? dateTimeFmt.format(new Date(frozenAt.value)) : ''))
|
||||
const lastGeneratedLabel = computed(() => (lastGeneratedAt.value ? dateTimeFmt.format(new Date(lastGeneratedAt.value)) : ''))
|
||||
|
||||
// ---- Partage d'un lien public ----
|
||||
const showShare = ref(false)
|
||||
@@ -365,9 +450,11 @@ async function enableShare() {
|
||||
// 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: {} })
|
||||
programmatic = true
|
||||
portfolio.value = updated
|
||||
publicShare.value = true
|
||||
frozenAt.value = updated.frozenContent?.generatedAt
|
||||
sharedStale.value = false // l'instantané reflète l'état courant
|
||||
toast.add({ title: 'Lien public activé', color: 'success' })
|
||||
} catch {
|
||||
toast.add({ title: 'Échec de l’activation du lien', color: 'error' })
|
||||
@@ -381,6 +468,7 @@ async function disableShare() {
|
||||
sharing.value = true
|
||||
try {
|
||||
const updated = await $fetch<Portfolio>(`/api/portfolios/${portfolio.value.id}/share`, { method: 'POST', body: { enabled: false } })
|
||||
programmatic = true
|
||||
portfolio.value = updated
|
||||
publicShare.value = false
|
||||
toast.add({ title: 'Lien public coupé', color: 'success' })
|
||||
@@ -392,19 +480,19 @@ async function disableShare() {
|
||||
}
|
||||
|
||||
async function copyShareUrl() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareUrl.value)
|
||||
if (await copyToClipboard(shareUrl.value)) {
|
||||
toast.add({ title: 'Lien copié', color: 'success' })
|
||||
} catch {
|
||||
} else {
|
||||
toast.add({ title: 'Copie impossible', description: shareUrl.value, color: 'error' })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-[calc(100vh-3.5rem)]">
|
||||
<div class="flex h-screen">
|
||||
<!-- 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">
|
||||
<BrandBar />
|
||||
<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">
|
||||
@@ -420,6 +508,7 @@ async function copyShareUrl() {
|
||||
>
|
||||
<template #item="{ element, index }">
|
||||
<div
|
||||
:data-page-nav-id="element.id"
|
||||
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'
|
||||
@@ -475,6 +564,7 @@ async function copyShareUrl() {
|
||||
variant="outline"
|
||||
@click="openShare"
|
||||
/>
|
||||
<RefreshButton size="sm" />
|
||||
<UButton
|
||||
icon="i-lucide-file-down"
|
||||
label="Générer le PDF"
|
||||
@@ -520,10 +610,13 @@ async function copyShareUrl() {
|
||||
<UFormField label="Titre">
|
||||
<UInput v-model="portfolio.cover.title" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="Lien du site (header public)">
|
||||
<UFormField label="Date">
|
||||
<UInput v-model="portfolio.cover.date" class="w-full" :placeholder="formatMonthYear()" />
|
||||
</UFormField>
|
||||
<UFormField label="Site">
|
||||
<UInput v-model="portfolio.cover.siteUrl" class="w-full" :placeholder="DEFAULT_SITE_URL" />
|
||||
</UFormField>
|
||||
<UFormField label="Mail de contact (header public)">
|
||||
<UFormField label="Mail">
|
||||
<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" />
|
||||
@@ -539,7 +632,7 @@ async function copyShareUrl() {
|
||||
<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>
|
||||
<p class="text-xs text-neutral-500">Entrées et fond hérités de la couverture.</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -550,9 +643,15 @@ async function copyShareUrl() {
|
||||
<UFormField label="Titre">
|
||||
<UInput v-model="selectedPage.title" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="Texte (Markdown)">
|
||||
<UFormField label="Texte">
|
||||
<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">
|
||||
Taille du texte — {{ freeTextScale }} %
|
||||
</p>
|
||||
<USlider v-model="freeTextScale" :min="100" :max="180" :step="10" />
|
||||
</div>
|
||||
<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">
|
||||
@@ -571,7 +670,7 @@ async function copyShareUrl() {
|
||||
icon="i-lucide-plus"
|
||||
size="xs"
|
||||
variant="soft"
|
||||
class="size-16"
|
||||
class="size-16 justify-center"
|
||||
@click="openMediaPicker('free')"
|
||||
/>
|
||||
</div>
|
||||
@@ -586,8 +685,22 @@ async function copyShareUrl() {
|
||||
<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 class="space-y-2">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-neutral-500">Disposition</p>
|
||||
<div class="flex gap-1">
|
||||
<UButton
|
||||
v-for="opt in coverLayoutItems"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
size="xs"
|
||||
:variant="(selectedPage.layout ?? 'band') === opt.value ? 'solid' : 'outline'"
|
||||
color="neutral"
|
||||
@click="setCoverLayout(opt.value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-neutral-500">Image</p>
|
||||
<div class="grid grid-cols-4 gap-1.5">
|
||||
<button
|
||||
v-for="f in projectImages(selectedPage.projectId)"
|
||||
@@ -603,8 +716,8 @@ async function copyShareUrl() {
|
||||
</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 v-if="(selectedPage.layout ?? 'band') === 'band'" class="space-y-2">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-neutral-500">Calage</p>
|
||||
<div class="flex gap-1">
|
||||
<UButton
|
||||
v-for="opt in coverFitItems"
|
||||
@@ -632,23 +745,24 @@ async function copyShareUrl() {
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-neutral-500">
|
||||
Taille du titre — {{ titleScale }} %
|
||||
Titre — {{ titleScale }} %
|
||||
</p>
|
||||
<USlider v-model="titleScale" :min="50" :max="150" :step="5" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div v-if="(selectedPage.layout ?? 'band') === 'band'" class="space-y-2">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-neutral-500">
|
||||
Hauteur du bandeau — {{ bandHeight }} mm
|
||||
Hauteur 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 ?? '']"
|
||||
label="Couleur du filet"
|
||||
:default-color="FL_INK"
|
||||
/>
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-neutral-500">Texte du bandeau</p>
|
||||
<BackgroundEditor v-model="selectedPage.background" />
|
||||
<div v-if="selectedPage.background?.mode === 'full'" class="space-y-2">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-neutral-500">Couleur du texte</p>
|
||||
<div class="flex gap-1">
|
||||
<UButton
|
||||
v-for="opt in inkItems"
|
||||
@@ -661,27 +775,60 @@ async function copyShareUrl() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<UFormField label="Type de travail">
|
||||
<UFormField label="Sous-titre">
|
||||
<UInput v-model="selectedPage.subtitle" class="w-full" placeholder="Sous le titre" />
|
||||
</UFormField>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<UFormField label="Type">
|
||||
<UInput
|
||||
v-model="selectedPage.subtitleOverride"
|
||||
class="w-full"
|
||||
:placeholder="projects[selectedPage.projectId]?.categories.join(', ') || 'ex. édition'"
|
||||
/>
|
||||
</UFormField>
|
||||
<UFormField label="Année">
|
||||
<UInput v-model="selectedPage.year" class="w-full" :placeholder="projectYearPlaceholder" />
|
||||
</UFormField>
|
||||
</div>
|
||||
<UFormField label="Lien">
|
||||
<UInput
|
||||
v-model="selectedPage.subtitleOverride"
|
||||
v-model="selectedPage.link"
|
||||
class="w-full"
|
||||
:placeholder="projects[selectedPage.projectId]?.categories.join(', ')"
|
||||
:placeholder="projects[selectedPage.projectId]?.externalUrl || 'https://…'"
|
||||
/>
|
||||
</UFormField>
|
||||
<UFormField label="Présentation">
|
||||
<UTextarea
|
||||
v-model="selectedPage.textOverride"
|
||||
v-model="presentationText"
|
||||
:rows="6"
|
||||
class="w-full"
|
||||
placeholder="Vide = texte du projet Grav"
|
||||
placeholder="Aucun texte"
|
||||
/>
|
||||
<div class="mt-1.5 flex items-center justify-between gap-2">
|
||||
<p v-if="presentationEdited" class="flex items-center gap-1.5 text-xs text-amber-600 dark:text-amber-500">
|
||||
<UIcon name="i-lucide-pencil" class="size-3.5 shrink-0" />
|
||||
Texte modifié — ne suit plus Grav
|
||||
</p>
|
||||
<p v-else class="flex items-center gap-1.5 text-xs text-neutral-500">
|
||||
<UIcon name="i-lucide-check" class="size-3.5 shrink-0" />
|
||||
Synchronisé avec Grav
|
||||
</p>
|
||||
<UButton
|
||||
v-if="presentationEdited"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
label="Réinitialiser"
|
||||
@click="resetPresentation"
|
||||
/>
|
||||
</div>
|
||||
</UFormField>
|
||||
<USeparator />
|
||||
<UButton
|
||||
icon="i-lucide-layout-grid"
|
||||
variant="soft"
|
||||
block
|
||||
:label="`Ajouter une page de grille (${unusedImages(selectedPage.projectId).length} images restantes)`"
|
||||
:label="`Ajouter une grille (${unusedImages(selectedPage.projectId).length} images restantes)`"
|
||||
@click="addGridPage(selectedPage.projectId)"
|
||||
/>
|
||||
</div>
|
||||
@@ -725,17 +872,13 @@ async function copyShareUrl() {
|
||||
: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 }"
|
||||
/>
|
||||
<BackgroundEditor v-model="selectedPage.background" />
|
||||
<USeparator />
|
||||
<UButton
|
||||
icon="i-lucide-layout-grid"
|
||||
variant="soft"
|
||||
block
|
||||
:label="`Ajouter une page de grille (${unusedImages(selectedPage.projectId).length} images restantes)`"
|
||||
:label="`Ajouter une grille (${unusedImages(selectedPage.projectId).length} images restantes)`"
|
||||
@click="addGridPage(selectedPage.projectId)"
|
||||
/>
|
||||
</div>
|
||||
@@ -756,21 +899,21 @@ async function copyShareUrl() {
|
||||
</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 class="flex items-center justify-between gap-2">
|
||||
<p v-if="!exportFileName" class="flex items-center gap-1.5 text-xs text-neutral-500">
|
||||
<UIcon name="i-lucide-info" class="size-3.5 shrink-0" />
|
||||
Aucun PDF généré pour l'instant.
|
||||
</p>
|
||||
<p v-else-if="pdfStale" class="flex items-center gap-1.5 text-xs text-amber-600 dark:text-amber-500">
|
||||
<UIcon name="i-lucide-triangle-alert" class="size-3.5 shrink-0" />
|
||||
Modifié depuis la dernière génération — régénérez pour mettre à jour.
|
||||
</p>
|
||||
<p v-else class="flex items-center gap-1.5 text-xs text-neutral-500">
|
||||
<UIcon name="i-lucide-check" class="size-3.5 shrink-0" />
|
||||
À jour · généré le {{ lastGeneratedLabel }}
|
||||
</p>
|
||||
<UButton v-if="frozenAt" size="xs" variant="soft" color="info" label="Réactualiser depuis Grav" @click="unfreeze" />
|
||||
</div>
|
||||
|
||||
<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>
|
||||
@@ -808,6 +951,14 @@ async function copyShareUrl() {
|
||||
</template>
|
||||
</UInput>
|
||||
</UFormField>
|
||||
<p v-if="sharedStale" class="flex items-center gap-1.5 text-xs text-amber-600 dark:text-amber-500">
|
||||
<UIcon name="i-lucide-triangle-alert" class="size-3.5 shrink-0" />
|
||||
Modifié depuis la dernière publication — republiez pour mettre à jour.
|
||||
</p>
|
||||
<p v-else class="flex items-center gap-1.5 text-xs text-neutral-500">
|
||||
<UIcon name="i-lucide-check" class="size-3.5 shrink-0" />
|
||||
À jour<span v-if="frozenDateLabel"> · publié le {{ frozenDateLabel }}</span>
|
||||
</p>
|
||||
<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">
|
||||
|
||||
@@ -15,5 +15,15 @@ export default defineEventHandler(async (event): Promise<{ portfolio: Portfolio;
|
||||
throw createError({ statusCode: 404, statusMessage: 'Portfolio introuvable' })
|
||||
}
|
||||
|
||||
return { portfolio, projects: portfolio.frozenContent.projects }
|
||||
// Vrai instantané : on rend la composition figée (pages + mise en forme), pas
|
||||
// la composition live. Repli sur le live pour les instantanés antérieurs.
|
||||
const frozen = portfolio.frozenContent
|
||||
const snapshot: Portfolio = {
|
||||
...portfolio,
|
||||
name: frozen.name ?? portfolio.name,
|
||||
cover: frozen.cover ?? portfolio.cover,
|
||||
pages: frozen.pages ?? portfolio.pages,
|
||||
}
|
||||
|
||||
return { portfolio: snapshot, projects: frozen.projects }
|
||||
})
|
||||
|
||||
@@ -68,7 +68,16 @@ export async function freezeContent(portfolio: Portfolio, fileName: string): Pro
|
||||
}
|
||||
}
|
||||
|
||||
return { generatedAt: new Date().toISOString(), fileName, projects }
|
||||
// Instantané de la composition (mise en forme incluse) : la vue publique
|
||||
// rendra CETTE version figée, indépendante des éditions ultérieures.
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
fileName,
|
||||
projects,
|
||||
name: portfolio.name,
|
||||
cover: structuredClone(portfolio.cover),
|
||||
pages: structuredClone(portfolio.pages),
|
||||
}
|
||||
}
|
||||
|
||||
export interface GenerateResult {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Bibliothèque de portfolios : un fichier JSON par portfolio (spec §13).
|
||||
import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { nanoid } from 'nanoid'
|
||||
import type { Portfolio, PortfolioSummary } from '~~/types'
|
||||
@@ -61,7 +61,16 @@ export async function savePortfolio(portfolio: Portfolio, touch = true): Promise
|
||||
// aligner updatedAt sur lastExport.at et marquer le PDF « à jour »).
|
||||
if (touch) portfolio.updatedAt = new Date().toISOString()
|
||||
await mkdir(portfoliosDir(), { recursive: true })
|
||||
await writeFile(pathFor(portfolio.id), JSON.stringify(portfolio, null, 2))
|
||||
// Écriture ATOMIQUE (fichier temporaire + rename) : l'autosave de l'éditeur
|
||||
// peut écrire pendant que la génération PDF relit le portfolio via /print.
|
||||
// Un writeFile en place tronque le fichier → lecture concurrente = JSON
|
||||
// partiel → getPortfolio renvoie null → rendu print en échec. Le rename est
|
||||
// atomique sur le même volume : les lecteurs voient toujours l'ancien OU le
|
||||
// nouveau fichier complet, jamais un état intermédiaire.
|
||||
const dest = pathFor(portfolio.id)
|
||||
const tmp = `${dest}.tmp-${nanoid(6)}`
|
||||
await writeFile(tmp, JSON.stringify(portfolio, null, 2))
|
||||
await rename(tmp, dest)
|
||||
return portfolio
|
||||
}
|
||||
|
||||
|
||||
@@ -34,10 +34,14 @@ export interface Project {
|
||||
|
||||
// --- Composition (créée dans l'outil, sauvegardée) ---
|
||||
|
||||
/** Fond de page unifié : blanc, teinté (aplat clair fixe) ou plein (couleur du
|
||||
* filet/bandeau de la page, dérivée au rendu). */
|
||||
export type Background =
|
||||
| { mode: 'white' }
|
||||
| { mode: 'tint'; color: string } // aplat clair
|
||||
| { mode: 'color'; color: string } // pleine couleur
|
||||
| { mode: 'tint' }
|
||||
| { mode: 'full' }
|
||||
|
||||
export type BgMode = Background['mode']
|
||||
|
||||
/** Encre du bandeau d'une page projet : auto = selon luminance du fond. */
|
||||
export type InkMode = 'auto' | 'black' | 'white'
|
||||
@@ -64,7 +68,12 @@ export interface ProjectPage {
|
||||
coverAlign?: 'left' | 'center' | 'right' // calage horizontal (fit hauteur) — défaut : centrée
|
||||
titleScale?: number // taille du titre en % du gabarit (défaut 100)
|
||||
bandHeight?: number // hauteur du bandeau en mm = calage haut de l'image (défaut 55)
|
||||
subtitleOverride?: string // ligne d'intro en gras — sinon taxonomy.category
|
||||
subtitleOverride?: string // type du projet affiché dans la ligne d'info (ex. « édition ») — sinon rien
|
||||
subtitle?: string // sous-titre sous le titre (même fonte que le titre, plus petit)
|
||||
link?: string // lien du projet affiché sous le titre (défaut : externalUrl du projet) — actif dans le PDF et l'aperçu
|
||||
year?: string // année affichée dans la ligne d'info (défaut : année de project.date)
|
||||
background?: Background // fond : blanc / teinté (filet fin) / plein (bandeau ou colonne coloré) — défaut blanc
|
||||
layout?: 'band' | 'split' // disposition : bandeau haut (défaut) ou colonne texte + image droite
|
||||
textOverride?: string // sinon texte Grav
|
||||
bandColor?: string // défaut : couleur de la catégorie (gabarit) — définit
|
||||
// aussi la couleur des filets des pages grille du même projet
|
||||
@@ -81,7 +90,7 @@ export interface ProjectGridPage {
|
||||
projectId: string
|
||||
grid: string // id de GridDef (composables/useGrids)
|
||||
media: string[] // filenames choisis + ordonnés — images uniquement
|
||||
greyBg?: boolean // fond gris clair (#f5f5f5) au lieu de blanc
|
||||
background?: Background // fond : blanc / teinté / plein (couleur du filet, hérité) — défaut blanc
|
||||
// Pas de couleur de filet propre : il hérite du bandeau de la page
|
||||
// couverture du même projet.
|
||||
}
|
||||
@@ -93,14 +102,15 @@ export interface FreePage {
|
||||
title?: string
|
||||
body?: string // markdown saisi dans l'UI
|
||||
media?: string[] // "projectId/filename" optionnels
|
||||
background: Background
|
||||
background: Background // blanc / teinté / plein (couleur du filet)
|
||||
bandColor?: string // filet haut optionnel
|
||||
textScale?: number // taille du texte de labeur en % (défaut 100)
|
||||
}
|
||||
|
||||
export interface CoverPage {
|
||||
kind: 'cover'
|
||||
id: string
|
||||
background: Background
|
||||
background: Background // blanc / teinté / plein (couleur du filet)
|
||||
bandColor?: string // filet couleur en bas — définit aussi le filet du sommaire
|
||||
}
|
||||
|
||||
@@ -108,7 +118,7 @@ export interface TocPage {
|
||||
kind: 'toc'
|
||||
id: string
|
||||
title?: string
|
||||
background: Background
|
||||
background: Background // blanc / teinté / plein (couleur du filet, héritée)
|
||||
// Pas de couleur de filet propre : il hérite du filet de la couverture.
|
||||
}
|
||||
|
||||
@@ -120,15 +130,22 @@ export interface CoverConfig {
|
||||
recipient?: string // « préparé pour … »
|
||||
backgroundImage?: string // "projectId/filename"
|
||||
background: Background // couleur du bandeau de couverture
|
||||
siteUrl?: string // lien affiché dans le header de la vue publique (défaut DEFAULT_SITE_URL)
|
||||
contactEmail?: string // mail de contact du header public (défaut DEFAULT_CONTACT_EMAIL)
|
||||
date?: string // texte libre affiché dans l'en-tête de couverture (ex. « Juillet 2026 »)
|
||||
siteUrl?: string // lien du site — en-tête de couverture + header de la vue publique (défaut DEFAULT_SITE_URL)
|
||||
contactEmail?: string // mail de contact — en-tête de couverture + header public (défaut DEFAULT_CONTACT_EMAIL)
|
||||
}
|
||||
|
||||
// Contenu figé à la génération (spec §7.3)
|
||||
// Contenu figé à la génération (spec §7.3) — vrai instantané : contenu Grav
|
||||
// (`projects`) **et** composition (nom, couverture, pages) au moment du gel.
|
||||
// La vue publique n'affiche que cet instantané ; le PDF, lui, rend la
|
||||
// composition courante. (Optionnels : instantanés antérieurs sans composition.)
|
||||
export interface FrozenSnapshot {
|
||||
generatedAt: string
|
||||
fileName: string
|
||||
projects: Record<string, Project> // projets tels qu'utilisés à la génération
|
||||
name?: string // nom du portfolio figé
|
||||
cover?: CoverConfig // couverture figée
|
||||
pages?: Page[] // composition (pages + mise en forme) figée
|
||||
}
|
||||
|
||||
export interface Portfolio {
|
||||
|
||||
Reference in New Issue
Block a user