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:
2026-07-06 13:26:06 +02:00
parent 59dfbe4155
commit ef1f14335d
22 changed files with 750 additions and 334 deletions
+25 -5
View File
@@ -40,11 +40,18 @@ Mot de passe dev : `figureslibres` (`PORTFOLIO_PASSWORD`).
## Modèle de données (résumé) ## Modèle de données (résumé)
Un `Portfolio` = `cover` (CoverConfig) + `pages[]`. Types de page : `cover`, Un `Portfolio` = `cover` (CoverConfig) + `pages[]`. Types de page : `cover`,
`toc`, `project` (couverture bandeau + image), `project-grid` (grille d'images `toc`, `project` (couverture d'un projet, disposition `band` = bandeau haut **ou**
entières), `free`. Le contenu Grav (`Project`, `MediaAsset`) est **en lecture `split` = colonne texte + image pleine hauteur à droite), `project-grid` (grille
seule**. À la génération, le contenu utilisé est **figé** dans `data/frozen/<id>/` d'images entières), `free`. Le contenu Grav (`Project`, `MediaAsset`) est **en
(`FrozenSnapshot`) : régénérer redonne le même PDF ; « Réactualiser depuis Grav » lecture seule**. À la génération, le contenu utilisé est **figé** dans
resynchronise volontairement. `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 ## Contraintes fermes
@@ -53,6 +60,19 @@ resynchronise volontairement.
- Les vidéos ne sont pas sélectionnables dans les grilles. - Les vidéos ne sont pas sélectionnables dans les grilles.
- Format PDF : A3 paysage aux dimensions exactes du gabarit. - 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) ## Pièges Chromium print (avant de toucher aux templates)
- **`columns-*` (CSS multicol) = page PDF blanche** dans Chromium print. Ne pas - **`columns-*` (CSS multicol) = page PDF blanche** dans Chromium print. Ne pas
+4 -3
View File
@@ -82,6 +82,7 @@
--fl-margin: 30.2px; /* 8 mm — marge extérieure */ --fl-margin: 30.2px; /* 8 mm — marge extérieure */
--fl-gutter: 14.4px; /* 3,8 mm — gouttière des grilles */ --fl-gutter: 14.4px; /* 3,8 mm — gouttière des grilles */
--fl-strip: 18.9px; /* 5 mm — filet haut des pages grille */ --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 */ --fl-band: 208px; /* 55 mm — bandeau des pages couverture */
} }
@@ -100,8 +101,8 @@
.fl-page :is(h1, h2, h3) { .fl-page :is(h1, h2, h3) {
font-family: 'Syne', sans-serif; font-family: 'Syne', sans-serif;
font-weight: 700; font-weight: 400; /* Syne toujours en Regular (seul « Figures Libres » reste en Bold) */
line-height: 1.1; line-height: 1.05;
} }
.fl-page img { .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, .fl-prose ol { margin: 0 0 0.75em; padding-left: 1.2em; }
.fl-prose ul { list-style: disc; } .fl-prose ul { list-style: disc; }
.fl-prose ol { list-style: decimal; } .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; } .fl-prose em { font-style: italic; }
+26
View 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
View 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>
+9 -28
View File
@@ -1,50 +1,31 @@
<script setup lang="ts"> <script setup lang="ts">
// Fond de page (spec §7.2) : blanc / teinté (aplat clair) / pleine couleur. // Fond de page unifié : Blanc / Teinté (aplat clair fixe) / Plein (couleur du
import type { Background } from '~~/types' // 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 = [ const modes = [
{ value: 'white', label: 'Blanc' }, { value: 'white', label: 'Blanc' },
{ value: 'tint', label: 'Teinté' }, { value: 'tint', label: 'Teinté' },
{ value: 'color', label: 'Couleur' }, { value: 'full', label: 'Plein' },
] as const ] as const
function setMode(mode: Background['mode']) { const current = computed<BgMode>(() => model.value?.mode ?? 'white')
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 : [],
)
</script> </script>
<template> <template>
<div class="space-y-2"> <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"> <div class="flex gap-1">
<UButton <UButton
v-for="m in modes" v-for="m in modes"
:key="m.value" :key="m.value"
:label="m.label" :label="m.label"
size="xs" size="xs"
:variant="model.mode === m.value ? 'solid' : 'outline'" :variant="current === m.value ? 'solid' : 'outline'"
color="neutral" color="neutral"
@click="setMode(m.value)" @click="model = { mode: 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 }"
/> />
</div> </div>
</div> </div>
+19 -1
View File
@@ -11,6 +11,9 @@ const props = withDefaults(
) )
const model = defineModel<string | undefined>() 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> </script>
<template> <template>
@@ -24,7 +27,7 @@ const model = defineModel<string | undefined>()
class="relative size-7 rounded-full border transition-transform hover:scale-110" 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'" :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 }" :style="{ backgroundColor: props.defaultColor }"
title="Couleur de la catégorie (défaut)" title="Couleur par défaut"
@click="model = undefined" @click="model = undefined"
> >
<UIcon name="i-lucide-rotate-ccw" class="absolute inset-0 m-auto size-3.5 text-black/40" /> <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" :aria-label="c"
@click="model = 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>
</div> </div>
</template> </template>
+45 -15
View File
@@ -57,30 +57,52 @@ const projectCoverImage = computed<ResolvedMedia | undefined>(() => {
const projectText = computed(() => { const projectText = computed(() => {
if (props.page.kind !== 'project' || !project.value) return '' if (props.page.kind !== 'project' || !project.value) return ''
if (props.page.textOverride?.trim()) return marked.parse(props.page.textOverride) as string // `textOverride` undefined = suit Grav (bodyHtml) ; toute chaîne définie
return project.value.bodyHtml // (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(() => { // Ligne d'info de la couverture de projet : type de travail + année.
if (props.page.kind !== 'project' || !project.value) return '' // Type par défaut = taxonomy.category de Grav (« Site web », « Édition »…),
return props.page.subtitleOverride?.trim() || project.value.categories.join(', ') // 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 projectBadge = computed(() => {
const c = project.value?.category 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 ---- // ---- Page grille de projet ----
// Le filet hérite de la couleur du bandeau de la page couverture du projet. // Filet et mode clair hérités de la page couverture du même projet.
const gridStripOverride = computed(() => { const gridCover = computed(() => {
if (props.page.kind !== 'project-grid') return undefined if (props.page.kind !== 'project-grid') return undefined
const projectId = props.page.projectId 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, (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 // Orientation dominante des images posées : la grille adaptative en dépend
const gridOrientation = computed(() => { const gridOrientation = computed(() => {
@@ -132,7 +154,7 @@ const tocEntries = computed(() =>
// Page libre : texte markdown + médias "projectId/filename" // Page libre : texte markdown + médias "projectId/filename"
const freeText = computed(() => { const freeText = computed(() => {
if (props.page.kind !== 'free' || !props.page.body?.trim()) return '' 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[]>(() => { const freeMedia = computed<ResolvedMedia[]>(() => {
@@ -149,6 +171,9 @@ const freeMedia = computed<ResolvedMedia[]>(() => {
<CoverTemplate <CoverTemplate
v-if="page.kind === 'cover'" v-if="page.kind === 'cover'"
:title="portfolio.cover.title" :title="portfolio.cover.title"
:date="portfolio.cover.date"
:site-url="portfolio.cover.siteUrl"
:contact-email="portfolio.cover.contactEmail"
:background="page.background" :background="page.background"
:band-color="page.bandColor" :band-color="page.bandColor"
:render-mode="renderMode" :render-mode="renderMode"
@@ -171,7 +196,8 @@ const freeMedia = computed<ResolvedMedia[]>(() => {
:text="freeText" :text="freeText"
:media="freeMedia" :media="freeMedia"
:background="page.background" :background="page.background"
:band-color="page.bandColor" :band-color="page.bandColor ?? coverBandColor"
:text-scale="page.textScale"
:page="pageInfo" :page="pageInfo"
:render-mode="renderMode" :render-mode="renderMode"
/> />
@@ -185,7 +211,10 @@ const freeMedia = computed<ResolvedMedia[]>(() => {
:title="project.title" :title="project.title"
:category="project.category" :category="project.category"
:badge="projectBadge" :badge="projectBadge"
:intro="projectIntro" :work-type="projectWorkType"
:year="projectYear"
:subtitle="page.subtitle"
:link="projectLink"
:text="projectText" :text="projectText"
:image="projectCoverImage" :image="projectCoverImage"
:image-fit="page.coverFit" :image-fit="page.coverFit"
@@ -194,6 +223,8 @@ const freeMedia = computed<ResolvedMedia[]>(() => {
:band-height="page.bandHeight" :band-height="page.bandHeight"
:band-color="page.bandColor" :band-color="page.bandColor"
:ink="page.ink" :ink="page.ink"
:background="page.background"
:layout="page.layout"
/> />
</template> </template>
@@ -206,9 +237,8 @@ const freeMedia = computed<ResolvedMedia[]>(() => {
:media="gridMedia" :media="gridMedia"
:grid="page.grid" :grid="page.grid"
:orientation="gridOrientation" :orientation="gridOrientation"
:category="project.category"
:band-color="gridStripOverride" :band-color="gridStripOverride"
:grey-bg="page.greyBg" :background="page.background"
/> />
</template> </template>
</template> </template>
+39 -19
View File
@@ -7,28 +7,59 @@ import type { Background } from '~~/types'
const props = withDefaults( const props = withDefaults(
defineProps<{ 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 subtitle?: string
recipient?: string recipient?: string
background: Background background: Background
bandColor?: string // filet couleur en bas (définit aussi celui du sommaire) 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' | 'print'
}>(), }>(),
{ renderMode: 'preview' }, { 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 pageStyle = computed(() => {
const s = backgroundStyle(props.background) const base
if (props.background.mode === 'white') s.backgroundColor = FL_BASE = props.background.mode === 'full'
return s ? 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> </script>
<template> <template>
<div class="fl-page" :style="pageStyle"> <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"> <div class="absolute inset-0 flex flex-col items-center justify-center">
<!-- Phrase d'intro du site (pages/01.home) — Lato, keywords dans les <!-- Phrase d'intro du site (pages/01.home) — Lato, keywords dans les
fontes d'« utilité » du thème Grav, non soulignés --> 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> <span class="fl-font-culturelle">culturelle</span>
qui œuvre sur logiciels&nbsp;libres. qui œuvre sur logiciels&nbsp;libres.
</p> </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> </div>
<!-- Filet couleur pleine largeur en bas --> <!-- Filet couleur pleine largeur en bas (masqué en fond plein) -->
<div class="absolute inset-x-0 bottom-0" :style="{ height: 'var(--fl-strip)', backgroundColor: strip }" /> <div v-if="background.mode !== 'full'" class="absolute inset-x-0 bottom-0" :style="{ height: 'var(--fl-strip)', backgroundColor: strip }" />
</div> </div>
</template> </template>
+9 -5
View File
@@ -11,23 +11,27 @@ const props = withDefaults(
media?: ResolvedMedia[] media?: ResolvedMedia[]
background: Background background: Background
bandColor?: string bandColor?: string
textScale?: number // taille du texte de labeur en % (défaut 100 = 12 pt)
page: { index: number; total: number } page: { index: number; total: number }
renderMode?: 'preview' | 'print' renderMode?: 'preview' | 'print'
}>(), }>(),
{ renderMode: 'preview' }, { renderMode: 'preview', textScale: 100 },
) )
const strip = computed(() => props.bandColor || FL_INK) 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> </script>
<template> <template>
<div class="fl-page" :style="backgroundStyle(background)"> <div class="fl-page" :style="pageStyle">
<div class="absolute inset-x-0 top-0" :style="{ height: 'var(--fl-strip)', backgroundColor: strip }" /> <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' }"> <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 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-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"> <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"> <img :src="m.url" :alt="m.filename" class="size-full object-contain" style="object-position: right top">
+108 -29
View File
@@ -4,14 +4,17 @@
// pastille catégorie (10 pt), présentation (Syne 12/16 pt, colonne à 50,5 %). // 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 // Sous le bandeau : image de couverture. Taille du titre et hauteur du
// bandeau (= calage haut de l'image) ajustables pour éviter les débords. // 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( const props = withDefaults(
defineProps<{ defineProps<{
title: string title: string
category: string // "publique" | "sociale" | "culturelle" category: string // "publique" | "sociale" | "culturelle"
badge?: string // texte de la pastille (ex. « Utilité publique ») badge?: string // pastille d'utilité (ex. « Culturel »)
intro?: string // ligne(s) d'intro en gras 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 text?: string // présentation, HTML
image?: ResolvedMedia image?: ResolvedMedia
imageFit?: 'width' | 'height' // calage : pleine largeur ou pleine hauteur imageFit?: 'width' | 'height' // calage : pleine largeur ou pleine hauteur
@@ -20,14 +23,39 @@ const props = withDefaults(
bandHeight?: number // mm (gabarit : 55) bandHeight?: number // mm (gabarit : 55)
bandColor?: string bandColor?: string
ink?: InkMode 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 PX_PER_MM = 3.7796
const band = computed(() => bandColorOf(props.category, props.bandColor)) // Couleur choisie du projet (filet / bandeau). Fond « plein » = bandeau/colonne
const inkColor = computed(() => inkOn(band.value, props.ink)) // à 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] ?? '') 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. // L'Avara est plus grasse : le gabarit la compose en 42 pt au lieu de 45 pt.
const titleSize = computed(() => { const titleSize = computed(() => {
@@ -48,39 +76,90 @@ const alignStyle = computed(() => {
</script> </script>
<template> <template>
<div class="fl-page"> <!-- Disposition « colonne + image » : texte à gauche, image pleine hauteur à droite -->
<!-- Bandeau --> <div v-if="layout === 'split'" class="fl-page" :style="{ backgroundColor: pageBg }">
<div class="absolute inset-x-0 top-0" :style="{ height: bandPx, backgroundColor: band, color: inkColor }"> <div class="absolute inset-0 flex">
<h1 <!-- Colonne gauche : filet (mode clair) ou fond plein coloré, infos, titre, sous-titre, lien, description -->
class="absolute" <div class="flex min-w-0 flex-1 flex-col" :style="{ backgroundColor: splitBg, color: splitInk }">
:class="titleClass" <div v-if="bandColor && !isFull" class="shrink-0" :style="{ height: 'var(--fl-strip-light)', backgroundColor: catColor }" />
:style="{ left: 'var(--fl-margin)', top: '14px', fontSize: titleSize, lineHeight: '1.15', maxWidth: '620px' }" <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' }">
{{ title }} <span
</h1> v-if="badge"
<span class="rounded-full px-[12px] py-[3px]"
v-if="badge" :style="{ backgroundColor: splitPillBg, color: splitPillInk }"
class="absolute whitespace-nowrap rounded-full border px-[12px] py-[3px]" >{{ badge }}</span>
:style="{ left: '42.33%', top: '54px', transform: 'translateY(-50%)', fontSize: '13.3px', lineHeight: '1.2', borderColor: 'currentColor' }" <span v-if="workType">{{ workType }}</span>
> <span v-if="year" class="opacity-70">{{ year }}</span>
{{ badge }} </div>
</span> <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 <div
class="absolute overflow-hidden" class="absolute flex items-center whitespace-nowrap"
:style="{ left: '50.47%', right: 'var(--fl-margin)', top: '33px', bottom: '12px' }" :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> <span
<div v-if="text" class="fl-prose" v-html="text" /> 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>
</div> </div>
<!-- Image de couverture : calée sur la largeur (pleine largeur, fixée en <!-- 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, 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 centrée). Le fond visible sous/à côté de l'image reprend la couleur
du bandeau. --> du bandeau (blanc en mode clair). -->
<div <div
class="absolute inset-x-0 bottom-0 flex overflow-hidden" 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 <img
v-if="image" v-if="image"
+8 -7
View File
@@ -3,7 +3,7 @@
// Filet couleur 5 mm en haut, images ENTIÈRES (object-contain) calées sur // 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 // une grille ADAPTATIVE : la famille choisie se construit selon le nombre
// d'images et leur orientation. Marges 8 mm, gouttières 3,8 mm. // 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' import type { ImageOrientation } from '~~/composables/useGrids'
const props = withDefaults( const props = withDefaults(
@@ -11,15 +11,16 @@ const props = withDefaults(
media: ResolvedMedia[] media: ResolvedMedia[]
grid: string // id de famille (useGrids) grid: string // id de famille (useGrids)
orientation?: ImageOrientation orientation?: ImageOrientation
category?: string
bandColor?: string bandColor?: string
greyBg?: boolean background?: Background // fond : blanc / teinté / plein (couleur du filet)
}>(), }>(),
{ orientation: 'mixed' }, { orientation: 'mixed' },
) )
const def = computed(() => gridDef(props.grid, props.media.length, props.orientation)) 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. // Métriques du canvas A3 paysage (theme.css) — zone utile hors marges 8 mm.
const MARGIN = 30.2 const MARGIN = 30.2
@@ -92,9 +93,9 @@ const items = computed(() =>
</script> </script>
<template> <template>
<div class="fl-page" :style="greyBg ? { backgroundColor: FL_BASE } : undefined"> <div class="fl-page" :style="{ backgroundColor: pageBg }">
<!-- Filet couleur pleine largeur --> <!-- Filet couleur fin en haut (masqué en fond plein) -->
<div class="absolute inset-x-0 top-0" :style="{ height: 'var(--fl-strip)', backgroundColor: strip }" /> <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 --> <!-- 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)' }"> <div v-if="hero" class="absolute flex" :style="{ inset: 'var(--fl-margin)', gap: 'var(--fl-gutter)' }">
+4 -3
View File
@@ -19,6 +19,7 @@ const props = withDefaults(
const emit = defineEmits<{ navigate: [pageNumber: number] }>() const emit = defineEmits<{ navigate: [pageNumber: number] }>()
const strip = computed(() => props.bandColor || FL_INK) const strip = computed(() => props.bandColor || FL_INK)
const pageStyle = computed(() => backgroundStyle(props.background, strip.value))
const isPrint = computed(() => props.renderMode === 'print') const isPrint = computed(() => props.renderMode === 'print')
// En print, chaque entrée est un lien interne #page-N (converti en lien PDF par // En print, chaque entrée est un lien interne #page-N (converti en lien PDF par
@@ -52,11 +53,11 @@ const gridStyle = computed(() => ({
</script> </script>
<template> <template>
<div class="fl-page" :style="backgroundStyle(background)"> <div class="fl-page" :style="pageStyle">
<div class="absolute inset-x-0 top-0" :style="{ height: 'var(--fl-strip)', backgroundColor: strip }" /> <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' }"> <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"> <div class="mt-14 grid min-h-0 flex-1 content-start" :style="gridStyle">
<a <a
v-for="entry in entries" v-for="entry in entries"
+30
View 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
}
}
+19
View File
@@ -8,6 +8,25 @@ export function formatProjectDate(iso?: string): string {
return Number.isNaN(d.getTime()) ? '' : dateFormatter.format(d) 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> = { export const CATEGORY_LABELS: Record<string, string> = {
culturelle: 'Culturelle', culturelle: 'Culturelle',
publique: 'Publique', publique: 'Publique',
+28 -54
View File
@@ -5,39 +5,22 @@ import type { Background, InkMode } from '~~/types'
export const FL_INK = '#0e1229' export const FL_INK = '#0e1229'
export const FL_BASE = '#f5f5f5' export const FL_BASE = '#f5f5f5'
/** Couleurs de bandeau du gabarit PDF (aplats CMJN convertis). */ /** Palette proposée pour la couleur du filet/bandeau des pages projet
export const PDF_COLORS = [ * (magenta et vert du gabarit + roses/jaune/menthe du site). */
'#ec008c', // magenta 100% — utilité publique export const BAND_COLORS = [
'#8bd261', // vert C45 J71 — utilité sociale '#ec008c', // magenta 100% (publique)
'#fff766', // jaune 60% — utilité culturelle (bandeau) '#8bd261', // vert (sociale)
'#fff200', // jaune 100% — utilité culturelle (filet) '#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). */ /** Libellé court de la pastille d'utilité (sans le mot « Utilité »). */
export const SITE_COLORS = [ export const CATEGORY_BADGE: Record<string, string> = {
'#ffaeab', // publique publique: 'Publique',
'#71ff94', // sociale sociale: 'Social',
'#feff74', // culturelle culturelle: 'Culturel',
'#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',
} }
/** Classe CSS de la fonte de titre par catégorie (mixins du thème Grav). */ /** 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', culturelle: 'fl-font-culturelle',
} }
/** Aplats clairs proposés pour le mode « teinté ». */ /** Aplat unique du mode « teinté » (beige de la charte). */
export const TINT_PRESETS = [ export const TINT_COLOR = '#efece3'
'#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]
function luminance(hex: string): number { function luminance(hex: string): number {
const m = hex.replace('#', '') const m = hex.replace('#', '')
@@ -74,22 +47,23 @@ export function inkOn(color: string, ink: InkMode = 'auto'): string {
return luminance(color) < 0.45 ? '#ffffff' : '#000000' return luminance(color) < 0.45 ? '#ffffff' : '#000000'
} }
export function bandColorOf(category: string | undefined, override?: string): string { /** Couleur du filet/bandeau : couleur choisie, sinon l'encre neutre (plus de
return override || CATEGORY_BAND[category ?? ''] || FL_INK * couleur automatique par catégorie). */
export function filetColor(override?: string): string {
return override || FL_INK
} }
export function stripColorOf(category: string | undefined, override?: string): string { /** Couleur de fond d'une page : blanc, teinté (aplat fixe) ou plein (`fullColor`,
return override || CATEGORY_STRIP[category ?? ''] || FL_INK * la couleur du filet/bandeau de la page). */
} export function backgroundColorOf(bg: Background | undefined, fullColor = FL_INK): string {
if (bg?.mode === 'full') return fullColor
export function backgroundColorOf(bg: Background): string { if (bg?.mode === 'tint') return TINT_COLOR
if (bg.mode === 'white') return '#ffffff' return '#ffffff'
return bg.color || (bg.mode === 'tint' ? FL_BASE : FL_INK)
} }
/** Style inline d'une page : fond + couleur d'encre lisible. */ /** Style inline d'une page : fond + couleur d'encre lisible. */
export function backgroundStyle(bg: Background): Record<string, string> { export function backgroundStyle(bg: Background | undefined, fullColor = FL_INK): Record<string, string> {
const color = backgroundColorOf(bg) const color = backgroundColorOf(bg, fullColor)
return { return {
backgroundColor: color, backgroundColor: color,
color: luminance(color) < 0.45 ? '#ffffff' : FL_INK, color: luminance(color) < 0.45 ? '#ffffff' : FL_INK,
+4 -59
View File
@@ -1,66 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
const route = useRoute() // Plus de barre d'en-tête globale : le logo « Portfolios » et la déconnexion
const toast = useToast() // vivent désormais en haut de la colonne gauche de chaque page (BrandBar),
const syncing = ref(false) // laissant les colonnes centrale/droite occuper toute la hauteur.
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')
}
</script> </script>
<template> <template>
<div class="min-h-screen bg-[var(--fl-base)] dark:bg-neutral-950"> <div class="min-h-screen bg-[var(--fl-base)] text-[var(--fl-ink)] dark:bg-neutral-950 dark:text-white">
<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>
<slot /> <slot />
</div> </div>
</template> </template>
+68 -39
View File
@@ -67,30 +67,51 @@ async function downloadPdf(p: PortfolioSummary) {
async function copyPublicLink(id: string) { async function copyPublicLink(id: string) {
const url = `${window.location.origin}/view/${id}` const url = `${window.location.origin}/view/${id}`
try { if (await copyToClipboard(url)) {
await navigator.clipboard.writeText(url)
toast.add({ title: 'Lien public copié', color: 'success' }) toast.add({ title: 'Lien public copié', color: 'success' })
} catch { } else {
toast.add({ title: 'Copie impossible', description: url, color: 'error' }) 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> </script>
<template> <template>
<UContainer class="py-8"> <div class="flex h-screen">
<div class="mb-6 flex items-end justify-between gap-4"> <!-- Colonne gauche : header logo (fond + bordure), colonne transparente -->
<div> <aside class="w-60 shrink-0">
<h1 class="text-2xl font-bold text-[var(--fl-ink)] dark:text-white">Portfolios</h1> <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> </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"> <div class="flex-1 overflow-auto p-6">
Aucun portfolio pour l'instant — créez-en un pour composer votre premier PDF. <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">
</div> 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"> <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"> <UCard v-for="p in portfolios" :key="p.id">
<div class="flex items-start justify-between gap-2"> <div class="flex items-start justify-between gap-2">
<div> <div>
<NuxtLink :to="`/portfolios/${p.id}`" class="font-semibold text-[var(--fl-ink)] hover:underline dark:text-white"> <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> <UBadge v-if="p.hasPdf" color="success" variant="subtle" size="sm">PDF</UBadge>
</div> </div>
</div> </div>
<div class="mt-4 flex items-center gap-2"> <div class="mt-4 space-y-2">
<UButton :to="`/portfolios/${p.id}`" size="sm" variant="soft" icon="i-lucide-pencil" label="Ouvrir" /> <div class="flex gap-2">
<UButton size="sm" variant="ghost" color="neutral" icon="i-lucide-copy" label="Dupliquer" @click="duplicate(p.id)" /> <UButton :to="`/portfolios/${p.id}`" class="flex-1 justify-center" size="sm" variant="soft" icon="i-lucide-pencil" label="Ouvrir" />
<UButton <UButton class="flex-1 justify-center" size="sm" variant="ghost" color="neutral" icon="i-lucide-copy" label="Dupliquer" @click="duplicate(p.id)" />
size="sm" </div>
variant="ghost" <div class="flex gap-2">
color="neutral" <UButton
:icon="p.pdfCurrent ? 'i-lucide-download' : 'i-lucide-file-down'" class="flex-1 justify-center"
:label="p.pdfCurrent ? 'Télécharger' : (p.hasPdf ? 'Régénérer' : 'Générer')" size="sm"
:loading="downloading === p.id" variant="ghost"
@click="downloadPdf(p)" color="neutral"
/> :icon="p.pdfCurrent ? 'i-lucide-download' : 'i-lucide-file-down'"
<UButton :label="p.pdfCurrent ? 'Télécharger' : (p.hasPdf ? 'Régénérer' : 'Générer')"
v-if="p.publicShare" :loading="downloading === p.id"
size="sm" @click="downloadPdf(p)"
variant="ghost" />
color="neutral" <UButton
icon="i-lucide-link" class="flex-1 justify-center"
aria-label="Copier le lien public" size="sm"
@click="copyPublicLink(p.id)" variant="ghost"
/> color="neutral"
<UButton size="sm" variant="ghost" color="error" icon="i-lucide-trash-2" class="ml-auto" @click="remove(p.id, p.name)" /> :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> </div>
</UCard> </UCard>
</div> </div>
</div>
</main>
<UModal v-model:open="showCreate" title="Nouveau portfolio"> <UModal v-model:open="showCreate" title="Nouveau portfolio">
<template #body> <template #body>
@@ -142,5 +171,5 @@ async function copyPublicLink(id: string) {
</form> </form>
</template> </template>
</UModal> </UModal>
</UContainer> </div>
</template> </template>
+204 -53
View File
@@ -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 selectedIndex = computed(() => portfolio.value.pages.findIndex(p => p.id === selectedPageId.value))
const selectedPage = computed<Page | undefined>(() => portfolio.value.pages[selectedIndex.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) ---- // ---- Sauvegarde automatique (debounce) ----
const saveState = ref<'saved' | 'saving' | 'dirty' | 'error'>('saved') const saveState = ref<'saved' | 'saving' | 'dirty' | 'error'>('saved')
let timer: ReturnType<typeof setTimeout> | undefined 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( watch(
portfolio, portfolio,
() => { () => {
if (programmatic) {
programmatic = false
return
}
saveState.value = 'dirty' saveState.value = 'dirty'
pdfStale.value = true // toute modif rend le dernier PDF périmé 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) clearTimeout(timer)
timer = setTimeout(save, 800) timer = setTimeout(save, 800)
}, },
@@ -233,6 +260,16 @@ function setInk(value: (typeof inkItems)[number]['value']) {
if (selectedPage.value?.kind === 'project') selectedPage.value.ink = 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) ---- // ---- Calage de l'image de couverture (pages projet) ----
const coverFitItems = [ const coverFitItems = [
{ value: 'width', label: 'Largeur' }, { 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 ---- // ---- Image de couverture / médias de page libre ----
const showMediaPicker = ref(false) const showMediaPicker = ref(false)
const mediaPickerTarget = ref<'cover' | 'free'>('cover') const mediaPickerTarget = ref<'cover' | 'free'>('cover')
@@ -301,6 +383,7 @@ const showGenerate = ref(false)
const generating = ref(false) const generating = ref(false)
const pdfFileName = ref('') const pdfFileName = ref('')
const frozenAt = ref(portfolio.value.frozenContent?.generatedAt) 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) const lastResult = ref<{ fileName: string; downloadUrl: string; sizeBytes: number; pageCount: number } | null>(null)
function openGenerate() { function openGenerate() {
@@ -319,11 +402,15 @@ async function generate() {
timeout: 300_000, timeout: 300_000,
}) })
frozenAt.value = frozenAt.value ?? new Date().toISOString() frozenAt.value = frozenAt.value ?? new Date().toISOString()
lastGeneratedAt.value = new Date().toISOString()
exportFileName.value = lastResult.value!.fileName exportFileName.value = lastResult.value!.fileName
pdfStale.value = false // le PDF reflète désormais l'état courant 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' }) toast.add({ title: 'PDF généré', description: lastResult.value!.fileName, color: 'success' })
} catch { } catch (err: any) {
toast.add({ title: 'Échec de la génération', color: 'error' }) // 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 { } finally {
generating.value = false 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` return n > 1024 * 1024 ? `${(n / 1024 / 1024).toFixed(1)} Mo` : `${Math.round(n / 1024)} Ko`
} }
const frozenDateLabel = computed(() => const dateTimeFmt = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium', timeStyle: 'short' })
frozenAt.value const frozenDateLabel = computed(() => (frozenAt.value ? dateTimeFmt.format(new Date(frozenAt.value)) : ''))
? new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(frozenAt.value)) const lastGeneratedLabel = computed(() => (lastGeneratedAt.value ? dateTimeFmt.format(new Date(lastGeneratedAt.value)) : ''))
: '',
)
// ---- Partage d'un lien public ---- // ---- Partage d'un lien public ----
const showShare = ref(false) const showShare = ref(false)
@@ -365,9 +450,11 @@ async function enableShare() {
// La réponse est le portfolio complet resynchronisé (frozenContent frais) : // La réponse est le portfolio complet resynchronisé (frozenContent frais) :
// on remplace l'état local pour éviter tout écrasement par l'auto-save. // 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: {} }) const updated = await $fetch<Portfolio>(`/api/portfolios/${portfolio.value.id}/share`, { method: 'POST', body: {} })
programmatic = true
portfolio.value = updated portfolio.value = updated
publicShare.value = true publicShare.value = true
frozenAt.value = updated.frozenContent?.generatedAt frozenAt.value = updated.frozenContent?.generatedAt
sharedStale.value = false // l'instantané reflète l'état courant
toast.add({ title: 'Lien public activé', color: 'success' }) toast.add({ title: 'Lien public activé', color: 'success' })
} catch { } catch {
toast.add({ title: 'Échec de l’activation du lien', color: 'error' }) toast.add({ title: 'Échec de l’activation du lien', color: 'error' })
@@ -381,6 +468,7 @@ async function disableShare() {
sharing.value = true sharing.value = true
try { try {
const updated = await $fetch<Portfolio>(`/api/portfolios/${portfolio.value.id}/share`, { method: 'POST', body: { enabled: false } }) const updated = await $fetch<Portfolio>(`/api/portfolios/${portfolio.value.id}/share`, { method: 'POST', body: { enabled: false } })
programmatic = true
portfolio.value = updated portfolio.value = updated
publicShare.value = false publicShare.value = false
toast.add({ title: 'Lien public coupé', color: 'success' }) toast.add({ title: 'Lien public coupé', color: 'success' })
@@ -392,19 +480,19 @@ async function disableShare() {
} }
async function copyShareUrl() { async function copyShareUrl() {
try { if (await copyToClipboard(shareUrl.value)) {
await navigator.clipboard.writeText(shareUrl.value)
toast.add({ title: 'Lien copié', color: 'success' }) toast.add({ title: 'Lien copié', color: 'success' })
} catch { } else {
toast.add({ title: 'Copie impossible', description: shareUrl.value, color: 'error' }) toast.add({ title: 'Copie impossible', description: shareUrl.value, color: 'error' })
} }
} }
</script> </script>
<template> <template>
<div class="flex h-[calc(100vh-3.5rem)]"> <div class="flex h-screen">
<!-- Colonne pages --> <!-- 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"> <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"> <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> <span class="text-sm font-semibold">Pages ({{ portfolio.pages.length }})</span>
<UDropdownMenu :items="addItems"> <UDropdownMenu :items="addItems">
@@ -420,6 +508,7 @@ async function copyShareUrl() {
> >
<template #item="{ element, index }"> <template #item="{ element, index }">
<div <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="group flex cursor-pointer items-center gap-2 rounded-md border p-2 text-sm transition-colors"
:class="element.id === selectedPageId :class="element.id === selectedPageId
? 'border-[var(--fl-ink)] bg-neutral-50 dark:border-white dark:bg-neutral-800' ? 'border-[var(--fl-ink)] bg-neutral-50 dark:border-white dark:bg-neutral-800'
@@ -475,6 +564,7 @@ async function copyShareUrl() {
variant="outline" variant="outline"
@click="openShare" @click="openShare"
/> />
<RefreshButton size="sm" />
<UButton <UButton
icon="i-lucide-file-down" icon="i-lucide-file-down"
label="Générer le PDF" label="Générer le PDF"
@@ -520,10 +610,13 @@ async function copyShareUrl() {
<UFormField label="Titre"> <UFormField label="Titre">
<UInput v-model="portfolio.cover.title" class="w-full" /> <UInput v-model="portfolio.cover.title" class="w-full" />
</UFormField> </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" /> <UInput v-model="portfolio.cover.siteUrl" class="w-full" :placeholder="DEFAULT_SITE_URL" />
</UFormField> </UFormField>
<UFormField label="Mail de contact (header public)"> <UFormField label="Mail">
<UInput v-model="portfolio.cover.contactEmail" class="w-full" :placeholder="DEFAULT_CONTACT_EMAIL" /> <UInput v-model="portfolio.cover.contactEmail" class="w-full" :placeholder="DEFAULT_CONTACT_EMAIL" />
</UFormField> </UFormField>
<BandColorPicker v-model="selectedPage.bandColor" label="Couleur du filet" :default-color="FL_INK" /> <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" /> <UInput v-model="selectedPage.title" class="w-full" />
</UFormField> </UFormField>
<BackgroundEditor v-model="selectedPage.background" /> <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> </div>
</template> </template>
@@ -550,9 +643,15 @@ async function copyShareUrl() {
<UFormField label="Titre"> <UFormField label="Titre">
<UInput v-model="selectedPage.title" class="w-full" /> <UInput v-model="selectedPage.title" class="w-full" />
</UFormField> </UFormField>
<UFormField label="Texte (Markdown)"> <UFormField label="Texte">
<UTextarea v-model="selectedPage.body" :rows="10" class="w-full" /> <UTextarea v-model="selectedPage.body" :rows="10" class="w-full" />
</UFormField> </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"> <div class="space-y-2">
<p class="text-xs font-medium uppercase tracking-wide text-neutral-500">Images ({{ selectedPage.media?.length ?? 0 }}/3)</p> <p class="text-xs font-medium uppercase tracking-wide text-neutral-500">Images ({{ selectedPage.media?.length ?? 0 }}/3)</p>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
@@ -571,7 +670,7 @@ async function copyShareUrl() {
icon="i-lucide-plus" icon="i-lucide-plus"
size="xs" size="xs"
variant="soft" variant="soft"
class="size-16" class="size-16 justify-center"
@click="openMediaPicker('free')" @click="openMediaPicker('free')"
/> />
</div> </div>
@@ -586,8 +685,22 @@ async function copyShareUrl() {
<h3 class="mb-1 font-semibold">{{ projects[selectedPage.projectId]?.title }}</h3> <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> <p class="mb-4 text-xs text-neutral-500">Page couverture du projet</p>
<div class="space-y-4"> <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"> <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"> <div class="grid grid-cols-4 gap-1.5">
<button <button
v-for="f in projectImages(selectedPage.projectId)" v-for="f in projectImages(selectedPage.projectId)"
@@ -603,8 +716,8 @@ async function copyShareUrl() {
</button> </button>
</div> </div>
</div> </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">Calage de l'image</p> <p class="text-xs font-medium uppercase tracking-wide text-neutral-500">Calage</p>
<div class="flex gap-1"> <div class="flex gap-1">
<UButton <UButton
v-for="opt in coverFitItems" v-for="opt in coverFitItems"
@@ -632,23 +745,24 @@ async function copyShareUrl() {
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<p class="text-xs font-medium uppercase tracking-wide text-neutral-500"> <p class="text-xs font-medium uppercase tracking-wide text-neutral-500">
Taille du titre — {{ titleScale }} % Titre — {{ titleScale }} %
</p> </p>
<USlider v-model="titleScale" :min="50" :max="150" :step="5" /> <USlider v-model="titleScale" :min="50" :max="150" :step="5" />
</div> </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"> <p class="text-xs font-medium uppercase tracking-wide text-neutral-500">
Hauteur du bandeau — {{ bandHeight }} mm Hauteur bandeau — {{ bandHeight }} mm
</p> </p>
<USlider v-model="bandHeight" :min="40" :max="160" :step="5" /> <USlider v-model="bandHeight" :min="40" :max="160" :step="5" />
</div> </div>
<BandColorPicker <BandColorPicker
v-model="selectedPage.bandColor" v-model="selectedPage.bandColor"
label="Couleur du projet" label="Couleur du filet"
:default-color="CATEGORY_BAND[projects[selectedPage.projectId]?.category ?? '']" :default-color="FL_INK"
/> />
<div class="space-y-2"> <BackgroundEditor v-model="selectedPage.background" />
<p class="text-xs font-medium uppercase tracking-wide text-neutral-500">Texte du bandeau</p> <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"> <div class="flex gap-1">
<UButton <UButton
v-for="opt in inkItems" v-for="opt in inkItems"
@@ -661,27 +775,60 @@ async function copyShareUrl() {
/> />
</div> </div>
</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 <UInput
v-model="selectedPage.subtitleOverride" v-model="selectedPage.link"
class="w-full" class="w-full"
:placeholder="projects[selectedPage.projectId]?.categories.join(', ')" :placeholder="projects[selectedPage.projectId]?.externalUrl || 'https://…'"
/> />
</UFormField> </UFormField>
<UFormField label="Présentation"> <UFormField label="Présentation">
<UTextarea <UTextarea
v-model="selectedPage.textOverride" v-model="presentationText"
:rows="6" :rows="6"
class="w-full" 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> </UFormField>
<USeparator /> <USeparator />
<UButton <UButton
icon="i-lucide-layout-grid" icon="i-lucide-layout-grid"
variant="soft" variant="soft"
block 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)" @click="addGridPage(selectedPage.projectId)"
/> />
</div> </div>
@@ -725,17 +872,13 @@ async function copyShareUrl() {
:project="projects[selectedPage.projectId]!" :project="projects[selectedPage.projectId]!"
:max="gridFamily(selectedPage.grid).max" :max="gridFamily(selectedPage.grid).max"
/> />
<USwitch <BackgroundEditor v-model="selectedPage.background" />
:model-value="selectedPage.greyBg ?? false"
label="Fond gris clair"
@update:model-value="(v: boolean) => { if (selectedPage?.kind === 'project-grid') selectedPage.greyBg = v || undefined }"
/>
<USeparator /> <USeparator />
<UButton <UButton
icon="i-lucide-layout-grid" icon="i-lucide-layout-grid"
variant="soft" variant="soft"
block 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)" @click="addGridPage(selectedPage.projectId)"
/> />
</div> </div>
@@ -756,21 +899,21 @@ async function copyShareUrl() {
</UInput> </UInput>
</UFormField> </UFormField>
<UAlert <div class="flex items-center justify-between gap-2">
v-if="frozenAt" <p v-if="!exportFileName" class="flex items-center gap-1.5 text-xs text-neutral-500">
icon="i-lucide-snowflake" <UIcon name="i-lucide-info" class="size-3.5 shrink-0" />
color="info" Aucun PDF généré pour l'instant.
variant="subtle" </p>
:title="`Contenu figé le ${frozenDateLabel}`" <p v-else-if="pdfStale" class="flex items-center gap-1.5 text-xs text-amber-600 dark:text-amber-500">
description="La régénération produira le même PDF, même si Grav a changé depuis." <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.
<template #actions> </p>
<UButton size="xs" variant="soft" color="info" label="Réactualiser depuis Grav" @click="unfreeze" /> <p v-else class="flex items-center gap-1.5 text-xs text-neutral-500">
</template> <UIcon name="i-lucide-check" class="size-3.5 shrink-0" />
</UAlert> À jour · généré le {{ lastGeneratedLabel }}
<p v-else class="text-xs text-neutral-500"> </p>
Le contenu utilisé sera figé à la génération : le portfolio restera régénérable à l'identique. <UButton v-if="frozenAt" size="xs" variant="soft" color="info" label="Réactualiser depuis Grav" @click="unfreeze" />
</p> </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"> <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> <p class="font-medium">{{ lastResult.fileName }} — {{ lastResult.pageCount }} pages, {{ formatBytes(lastResult.sizeBytes) }}</p>
@@ -808,6 +951,14 @@ async function copyShareUrl() {
</template> </template>
</UInput> </UInput>
</UFormField> </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"> <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" /> <UButton icon="i-lucide-external-link" :href="shareUrl" target="_blank" external variant="soft" color="neutral" size="sm" label="Ouvrir" />
<div class="flex gap-2"> <div class="flex gap-2">
+11 -1
View File
@@ -15,5 +15,15 @@ export default defineEventHandler(async (event): Promise<{ portfolio: Portfolio;
throw createError({ statusCode: 404, statusMessage: 'Portfolio introuvable' }) 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 }
}) })
+10 -1
View File
@@ -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 { export interface GenerateResult {
+11 -2
View File
@@ -1,5 +1,5 @@
// Bibliothèque de portfolios : un fichier JSON par portfolio (spec §13). // 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 { join } from 'node:path'
import { nanoid } from 'nanoid' import { nanoid } from 'nanoid'
import type { Portfolio, PortfolioSummary } from '~~/types' 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 »). // aligner updatedAt sur lastExport.at et marquer le PDF « à jour »).
if (touch) portfolio.updatedAt = new Date().toISOString() if (touch) portfolio.updatedAt = new Date().toISOString()
await mkdir(portfoliosDir(), { recursive: true }) 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 return portfolio
} }
+27 -10
View File
@@ -34,10 +34,14 @@ export interface Project {
// --- Composition (créée dans l'outil, sauvegardée) --- // --- 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 = export type Background =
| { mode: 'white' } | { mode: 'white' }
| { mode: 'tint'; color: string } // aplat clair | { mode: 'tint' }
| { mode: 'color'; color: string } // pleine couleur | { mode: 'full' }
export type BgMode = Background['mode']
/** Encre du bandeau d'une page projet : auto = selon luminance du fond. */ /** Encre du bandeau d'une page projet : auto = selon luminance du fond. */
export type InkMode = 'auto' | 'black' | 'white' 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 coverAlign?: 'left' | 'center' | 'right' // calage horizontal (fit hauteur) — défaut : centrée
titleScale?: number // taille du titre en % du gabarit (défaut 100) 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) 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 textOverride?: string // sinon texte Grav
bandColor?: string // défaut : couleur de la catégorie (gabarit) — définit bandColor?: string // défaut : couleur de la catégorie (gabarit) — définit
// aussi la couleur des filets des pages grille du même projet // aussi la couleur des filets des pages grille du même projet
@@ -81,7 +90,7 @@ export interface ProjectGridPage {
projectId: string projectId: string
grid: string // id de GridDef (composables/useGrids) grid: string // id de GridDef (composables/useGrids)
media: string[] // filenames choisis + ordonnés — images uniquement 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 // Pas de couleur de filet propre : il hérite du bandeau de la page
// couverture du même projet. // couverture du même projet.
} }
@@ -93,14 +102,15 @@ export interface FreePage {
title?: string title?: string
body?: string // markdown saisi dans l'UI body?: string // markdown saisi dans l'UI
media?: string[] // "projectId/filename" optionnels media?: string[] // "projectId/filename" optionnels
background: Background background: Background // blanc / teinté / plein (couleur du filet)
bandColor?: string // filet haut optionnel bandColor?: string // filet haut optionnel
textScale?: number // taille du texte de labeur en % (défaut 100)
} }
export interface CoverPage { export interface CoverPage {
kind: 'cover' kind: 'cover'
id: string 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 bandColor?: string // filet couleur en bas — définit aussi le filet du sommaire
} }
@@ -108,7 +118,7 @@ export interface TocPage {
kind: 'toc' kind: 'toc'
id: string id: string
title?: 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. // 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 … » recipient?: string // « préparé pour … »
backgroundImage?: string // "projectId/filename" backgroundImage?: string // "projectId/filename"
background: Background // couleur du bandeau de couverture background: Background // couleur du bandeau de couverture
siteUrl?: string // lien affiché dans le header de la vue publique (défaut DEFAULT_SITE_URL) date?: string // texte libre affiché dans l'en-tête de couverture (ex. « Juillet 2026 »)
contactEmail?: string // mail de contact du header public (défaut DEFAULT_CONTACT_EMAIL) 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 { export interface FrozenSnapshot {
generatedAt: string generatedAt: string
fileName: string fileName: string
projects: Record<string, Project> // projets tels qu'utilisés à la génération 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 { export interface Portfolio {