Générateur de portfolios PDF Figures Libres (app Nuxt autonome)
App Nuxt 3 + Nuxt UI qui lit les projets du CMS Grav et compose des portfolios A3 paysage exportés en PDF (gabarit book_v3) : composeur WYSIWYG, templates cover/toc/projet/grille/page libre, pipeline Playwright + Ghostscript, gel du contenu à la génération, partage public opt-in. Dépôt autonome : Dockerfile (base Playwright + Ghostscript + ffmpeg + rsync) inclus. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
51
components/composer/BackgroundEditor.vue
Normal file
51
components/composer/BackgroundEditor.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
// Fond de page (spec §7.2) : blanc / teinté (aplat clair) / pleine couleur.
|
||||
import type { Background } from '~~/types'
|
||||
|
||||
const model = defineModel<Background>({ required: true })
|
||||
|
||||
const modes = [
|
||||
{ value: 'white', label: 'Blanc' },
|
||||
{ value: 'tint', label: 'Teinté' },
|
||||
{ value: 'color', label: 'Couleur' },
|
||||
] 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 : [],
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-neutral-500">Fond de page</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'"
|
||||
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 }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
44
components/composer/BandColorPicker.vue
Normal file
44
components/composer/BandColorPicker.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
// Couleur de bandeau/filet : palette du gabarit PDF + couleurs du site Grav
|
||||
// (hover des keywords de l'index). undefined = couleur par défaut (catégorie).
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
label?: string
|
||||
/** Couleur affichée quand aucune n'est choisie (défaut catégorie). */
|
||||
defaultColor?: string
|
||||
}>(),
|
||||
{ label: 'Couleur du bandeau' },
|
||||
)
|
||||
|
||||
const model = defineModel<string | undefined>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-neutral-500">{{ label }}</p>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<!-- Défaut (catégorie) -->
|
||||
<button
|
||||
v-if="props.defaultColor"
|
||||
type="button"
|
||||
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)"
|
||||
@click="model = undefined"
|
||||
>
|
||||
<UIcon name="i-lucide-rotate-ccw" class="absolute inset-0 m-auto size-3.5 text-black/40" />
|
||||
</button>
|
||||
<button
|
||||
v-for="c in BAND_COLORS"
|
||||
:key="c"
|
||||
type="button"
|
||||
class="size-7 rounded-full border transition-transform hover:scale-110"
|
||||
:class="model === 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 = c"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
44
components/composer/GridIcon.vue
Normal file
44
components/composer/GridIcon.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
// Icône d'une grille : les blocs dessinés dans le format de page A3 paysage,
|
||||
// générés depuis la même définition adaptative que le rendu (useGrids) —
|
||||
// l'icône reflète le nombre d'images réellement posées et leur orientation.
|
||||
import type { ImageOrientation } from '~~/composables/useGrids'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{ grid: string; count?: number; orientation?: ImageOrientation; size?: number }>(),
|
||||
{ count: 3, orientation: 'mixed', size: 64 },
|
||||
)
|
||||
|
||||
// Page 42 × 29.7 (A3/10), marges/gouttières proportionnelles au gabarit
|
||||
const W = 42
|
||||
const H = 29.7
|
||||
const M = 0.8 // 8 mm
|
||||
const rects = computed(() => {
|
||||
const def = gridDef(props.grid, props.count, props.orientation)
|
||||
const iw = W - 2 * M
|
||||
const ih = H - 2 * M
|
||||
// gouttière 3,8 mm → fractions de la largeur et de la hauteur utiles
|
||||
return gridCellRects(def, 0.38 / iw, 0.38 / ih).map(r => ({
|
||||
x: M + r.x * iw,
|
||||
y: M + r.y * ih,
|
||||
w: r.w * iw,
|
||||
h: r.h * ih,
|
||||
}))
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg :width="size" :height="(size * H) / W" :viewBox="`0 0 ${W} ${H}`" aria-hidden="true">
|
||||
<rect :width="W" :height="H" fill="none" stroke="currentColor" stroke-width="0.6" opacity="0.35" />
|
||||
<rect
|
||||
v-for="(r, i) in rects"
|
||||
:key="i"
|
||||
:x="r.x"
|
||||
:y="r.y"
|
||||
:width="r.w"
|
||||
:height="r.h"
|
||||
fill="currentColor"
|
||||
opacity="0.75"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
69
components/composer/MediaCuration.vue
Normal file
69
components/composer/MediaCuration.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
// Curation des médias d'une page grille (spec §8.2) : cocher/décocher +
|
||||
// réordonner, sans recadrage. Images uniquement — les vidéos ne sont pas
|
||||
// sélectionnables (un PDF est statique).
|
||||
import draggable from 'vuedraggable'
|
||||
import type { Project } from '~~/types'
|
||||
|
||||
const props = defineProps<{
|
||||
project: Project
|
||||
/** Nombre de cases de la grille choisie. */
|
||||
max?: number
|
||||
}>()
|
||||
const selection = defineModel<string[]>({ required: true })
|
||||
|
||||
const images = computed(() => props.project.media.filter(m => m.type === 'image'))
|
||||
|
||||
function toggle(filename: string) {
|
||||
if (selection.value.includes(filename)) {
|
||||
selection.value = selection.value.filter(f => f !== filename)
|
||||
} else if (!props.max || selection.value.length < props.max) {
|
||||
selection.value = [...selection.value, filename]
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-neutral-500">
|
||||
Images de la grille ({{ selection.length }}{{ max ? ` / ${max} cases` : '' }})
|
||||
</p>
|
||||
|
||||
<!-- Ordre des images sélectionnées (glisser-déposer) -->
|
||||
<draggable
|
||||
v-if="selection.length"
|
||||
v-model="selection"
|
||||
item-key="."
|
||||
class="flex flex-wrap gap-2"
|
||||
:animation="150"
|
||||
>
|
||||
<template #item="{ element, index }">
|
||||
<div class="relative size-16 cursor-grab overflow-hidden rounded border border-neutral-300 active:cursor-grabbing dark:border-neutral-600">
|
||||
<img :src="mediaUrl(project.id, element, 160)" class="size-full object-cover" :alt="element">
|
||||
<span class="absolute left-0.5 top-0.5 flex size-4 items-center justify-center rounded-full bg-[var(--fl-ink)] text-[10px] text-white">{{ index + 1 }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
|
||||
<!-- Toutes les images du projet : clic = sélection/désélection -->
|
||||
<div class="grid grid-cols-4 gap-1.5">
|
||||
<button
|
||||
v-for="m in images"
|
||||
:key="m.filename"
|
||||
type="button"
|
||||
class="relative aspect-square overflow-hidden rounded border transition-opacity"
|
||||
:class="selection.includes(m.filename)
|
||||
? 'border-[var(--fl-ink)] dark:border-white'
|
||||
: 'border-transparent opacity-45 hover:opacity-80'"
|
||||
@click="toggle(m.filename)"
|
||||
>
|
||||
<img :src="mediaUrl(project.id, m.filename, 160)" class="size-full object-cover" :alt="m.filename">
|
||||
<UIcon
|
||||
v-if="selection.includes(m.filename)"
|
||||
name="i-lucide-check-circle-2"
|
||||
class="absolute right-1 top-1 size-4 rounded-full bg-white text-[var(--fl-ink)]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
70
components/composer/MediaPickerModal.vue
Normal file
70
components/composer/MediaPickerModal.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
// Sélecteur d'un média (image) parmi tous les projets — utilisé pour l'image
|
||||
// de couverture et les médias des pages libres. Renvoie "projectId/filename".
|
||||
import type { Project } from '~~/types'
|
||||
|
||||
const open = defineModel<boolean>('open', { required: true })
|
||||
const emit = defineEmits<{ pick: [ref: string] }>()
|
||||
|
||||
const { data } = await useFetch<{ projects: Project[] }>('/api/projects')
|
||||
const search = ref('')
|
||||
const selectedProject = ref<Project | null>(null)
|
||||
|
||||
const filtered = computed(() =>
|
||||
(data.value?.projects ?? []).filter(
|
||||
p => p.published && (!search.value || p.title.toLowerCase().includes(search.value.toLowerCase())),
|
||||
),
|
||||
)
|
||||
|
||||
function pick(project: Project, filename: string) {
|
||||
emit('pick', `${project.id}/${filename}`)
|
||||
open.value = false
|
||||
selectedProject.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UModal v-model:open="open" :title="selectedProject ? selectedProject.title : 'Choisir une image'" :ui="{ content: 'max-w-3xl' }">
|
||||
<template #body>
|
||||
<template v-if="!selectedProject">
|
||||
<UInput v-model="search" icon="i-lucide-search" placeholder="Rechercher un projet…" class="mb-4 w-full" autofocus />
|
||||
<div class="grid max-h-[55vh] grid-cols-4 gap-3 overflow-y-auto pr-1">
|
||||
<button
|
||||
v-for="p in filtered"
|
||||
:key="p.id"
|
||||
type="button"
|
||||
class="overflow-hidden rounded-lg border border-neutral-200 text-left hover:shadow-md dark:border-neutral-700"
|
||||
@click="selectedProject = p"
|
||||
>
|
||||
<div class="aspect-video bg-neutral-100 dark:bg-neutral-800">
|
||||
<img v-if="projectThumb(p, 320)" :src="projectThumb(p, 320)!" class="size-full object-cover" :alt="p.title" loading="lazy">
|
||||
</div>
|
||||
<p class="truncate p-2 text-xs font-medium">{{ p.title }}</p>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<UButton
|
||||
icon="i-lucide-arrow-left"
|
||||
label="Retour aux projets"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
size="sm"
|
||||
class="mb-3"
|
||||
@click="selectedProject = null"
|
||||
/>
|
||||
<div class="grid max-h-[55vh] grid-cols-4 gap-3 overflow-y-auto pr-1">
|
||||
<button
|
||||
v-for="m in selectedProject.media.filter(m => m.type === 'image')"
|
||||
:key="m.filename"
|
||||
type="button"
|
||||
class="aspect-video overflow-hidden rounded-lg border border-neutral-200 hover:shadow-md dark:border-neutral-700"
|
||||
@click="pick(selectedProject, m.filename)"
|
||||
>
|
||||
<img :src="mediaUrl(selectedProject.id, m.filename, 320)" class="size-full object-cover" :alt="m.filename" loading="lazy">
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
130
components/composer/ProjectPickerModal.vue
Normal file
130
components/composer/ProjectPickerModal.vue
Normal file
@@ -0,0 +1,130 @@
|
||||
<script setup lang="ts">
|
||||
// Sélecteur de projet pour ajouter une page-projet au portfolio.
|
||||
// Reprend toutes les fonctionnalités de la bibliothèque : recherche plein
|
||||
// texte, filtres catégorie et tag. Seuls les projets publiés sont proposés.
|
||||
import type { Project } from '~~/types'
|
||||
|
||||
const open = defineModel<boolean>('open', { required: true })
|
||||
const emit = defineEmits<{ pick: [project: Project] }>()
|
||||
|
||||
const { data } = await useFetch<{ projects: Project[] }>('/api/projects')
|
||||
const search = ref('')
|
||||
const category = ref<string | undefined>(undefined)
|
||||
const tag = ref<string | undefined>(undefined)
|
||||
const workTypes = ref<string[]>([]) // « Site web », « Identité visuelle »…
|
||||
|
||||
const published = computed(() => (data.value?.projects ?? []).filter(p => p.published))
|
||||
|
||||
const categoryItems = computed(() => [
|
||||
{ label: 'Toutes les catégories', value: undefined },
|
||||
...[...new Set(published.value.map(p => p.category))].map(c => ({
|
||||
label: CATEGORY_LABELS[c] ?? c,
|
||||
value: c,
|
||||
})),
|
||||
])
|
||||
|
||||
/** Types de travail = taxonomy.category des projets (Site web, Éditions…). */
|
||||
const workTypeItems = computed(() => {
|
||||
const types = new Set<string>()
|
||||
for (const p of published.value) {
|
||||
for (const t of p.categories) types.add(t)
|
||||
}
|
||||
return [...types].sort((a, b) => a.localeCompare(b, 'fr'))
|
||||
})
|
||||
|
||||
function toggleWorkType(t: string) {
|
||||
workTypes.value = workTypes.value.includes(t)
|
||||
? workTypes.value.filter(x => x !== t)
|
||||
: [...workTypes.value, t]
|
||||
}
|
||||
|
||||
const tagItems = computed(() => {
|
||||
const tags = new Set<string>()
|
||||
for (const p of published.value) {
|
||||
for (const t of p.tags) tags.add(t)
|
||||
}
|
||||
return [
|
||||
{ label: 'Tous les tags', value: undefined },
|
||||
...[...tags].sort((a, b) => a.localeCompare(b, 'fr')).map(t => ({ label: t, value: t })),
|
||||
]
|
||||
})
|
||||
|
||||
const filtered = computed(() =>
|
||||
published.value.filter((p) => {
|
||||
if (category.value && p.category !== category.value) return false
|
||||
if (workTypes.value.length && !workTypes.value.every(t => p.categories.includes(t))) return false
|
||||
if (tag.value && ![...p.categories, ...p.tags].includes(tag.value)) return false
|
||||
if (search.value) {
|
||||
const q = search.value.toLowerCase()
|
||||
const haystack = [p.title, p.bodyMarkdown, ...p.categories, ...p.tags].join(' ').toLowerCase()
|
||||
if (!haystack.includes(q)) return false
|
||||
}
|
||||
return true
|
||||
}),
|
||||
)
|
||||
|
||||
function pick(project: Project) {
|
||||
emit('pick', project)
|
||||
open.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UModal v-model:open="open" title="Ajouter un projet" :ui="{ content: 'max-w-4xl' }">
|
||||
<template #body>
|
||||
<div class="mb-3 flex flex-wrap items-center gap-3">
|
||||
<UInput v-model="search" icon="i-lucide-search" placeholder="Rechercher…" class="min-w-48 flex-1" autofocus />
|
||||
<USelectMenu v-model="category" :items="categoryItems" value-key="value" class="w-48" placeholder="Catégorie" />
|
||||
<USelectMenu v-model="tag" :items="tagItems" value-key="value" class="w-48" placeholder="Tag" />
|
||||
</div>
|
||||
<div class="mb-3 flex flex-wrap gap-1.5">
|
||||
<UButton
|
||||
v-for="t in workTypeItems"
|
||||
:key="t"
|
||||
:label="t"
|
||||
size="xs"
|
||||
:variant="workTypes.includes(t) ? 'solid' : 'outline'"
|
||||
color="neutral"
|
||||
class="rounded-full"
|
||||
@click="toggleWorkType(t)"
|
||||
/>
|
||||
</div>
|
||||
<p class="mb-3 text-xs text-neutral-500">
|
||||
{{ filtered.length }} projet{{ filtered.length > 1 ? 's' : '' }}
|
||||
</p>
|
||||
<div class="grid max-h-[55vh] grid-cols-3 gap-3 overflow-y-auto pr-1">
|
||||
<button
|
||||
v-for="p in filtered"
|
||||
:key="p.id"
|
||||
type="button"
|
||||
class="overflow-hidden rounded-lg border border-neutral-200 text-left transition-shadow hover:shadow-md dark:border-neutral-700"
|
||||
@click="pick(p)"
|
||||
>
|
||||
<div class="aspect-video bg-neutral-100 dark:bg-neutral-800">
|
||||
<img
|
||||
v-if="projectThumb(p, 320)"
|
||||
:src="projectThumb(p, 320)!"
|
||||
class="size-full object-cover"
|
||||
:alt="p.title"
|
||||
loading="lazy"
|
||||
>
|
||||
<div v-else class="flex size-full items-center justify-center text-neutral-400">
|
||||
<UIcon name="i-lucide-image-off" class="size-6" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1.5 p-2.5">
|
||||
<p class="truncate text-sm font-medium">{{ p.title }}</p>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<UBadge color="neutral" variant="outline" size="sm">{{ CATEGORY_LABELS[p.category] ?? p.category }}</UBadge>
|
||||
<UBadge v-for="c in p.categories.slice(0, 2)" :key="c" color="neutral" variant="soft" size="sm">{{ c }}</UBadge>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-xs text-neutral-500">
|
||||
<span>{{ formatProjectDate(p.date) }}</span>
|
||||
<span>{{ p.media.length }} média{{ p.media.length > 1 ? 's' : '' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
214
components/preview/PageRenderer.vue
Normal file
214
components/preview/PageRenderer.vue
Normal file
@@ -0,0 +1,214 @@
|
||||
<script setup lang="ts">
|
||||
// Dispatcher central : transforme une Page du portfolio en props de template
|
||||
// (§9.2) — médias résolus à la bonne résolution, texte effectif, libellés.
|
||||
// Mêmes composants en aperçu (basse rés.) et en print (haute rés.).
|
||||
import { marked } from 'marked'
|
||||
import type { Page, Portfolio, Project, ResolvedMedia } from '~~/types'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
portfolio: Portfolio
|
||||
page: Page
|
||||
index: number // position 0-based dans portfolio.pages
|
||||
projects: Record<string, Project>
|
||||
renderMode?: 'preview' | 'print'
|
||||
/** id du portfolio si le rendu doit lire le snapshot figé (§7.3) */
|
||||
frozen?: string
|
||||
/** base des URLs médias (route publique en vue publique) */
|
||||
mediaBase?: string
|
||||
}>(),
|
||||
{ renderMode: 'preview', mediaBase: '/api/media' },
|
||||
)
|
||||
|
||||
// Clic sur une entrée du sommaire (aperçu) → défilement vers la page cible
|
||||
defineEmits<{ navigate: [pageNumber: number] }>()
|
||||
|
||||
const pageInfo = computed(() => ({ index: props.index + 1, total: props.portfolio.pages.length }))
|
||||
// En print, chaque image est demandée à ~2× sa taille d'affichage (spec §10.1)
|
||||
const scale = computed(() => (props.renderMode === 'print' ? 2 : 1))
|
||||
|
||||
// Largeur utile de la page A3 (1587,4px − 2 × 30,2px de marge)
|
||||
const INNER_W = 1527
|
||||
|
||||
function resolve(projectId: string, filename: string, width: number, type: 'image' | 'video' = 'image'): ResolvedMedia {
|
||||
const w = Math.min(Math.round(width * scale.value), 3200)
|
||||
const base = mediaUrl(projectId, filename, undefined, props.mediaBase)
|
||||
const frozenParam = props.frozen ? `&frozen=${props.frozen}` : ''
|
||||
return {
|
||||
filename,
|
||||
type,
|
||||
url: type === 'image' ? `${base}?w=${w}${frozenParam}` : `${base}${frozenParam ? `?${frozenParam.slice(1)}` : ''}`,
|
||||
posterUrl: type === 'video' ? `${base}?poster=1&w=${w}${frozenParam}` : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
const project = computed(() => {
|
||||
const p = props.page
|
||||
return p.kind === 'project' || p.kind === 'project-grid' ? props.projects[p.projectId] : undefined
|
||||
})
|
||||
|
||||
// ---- Page couverture de projet ----
|
||||
const projectCoverImage = computed<ResolvedMedia | undefined>(() => {
|
||||
if (props.page.kind !== 'project' || !project.value) return undefined
|
||||
const filename = props.page.coverImage || project.value.coverImage
|
||||
if (!filename) return undefined
|
||||
return resolve(project.value.id, filename, 1588)
|
||||
})
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
const projectIntro = computed(() => {
|
||||
if (props.page.kind !== 'project' || !project.value) return ''
|
||||
return props.page.subtitleOverride?.trim() || project.value.categories.join(', ')
|
||||
})
|
||||
|
||||
const projectBadge = computed(() => {
|
||||
const c = project.value?.category
|
||||
return c ? `Utilité ${CATEGORY_LABELS[c]?.toLowerCase() ?? c}` : undefined
|
||||
})
|
||||
|
||||
// ---- Page grille de projet ----
|
||||
// Le filet hérite de la couleur du bandeau de la page couverture du projet.
|
||||
const gridStripOverride = computed(() => {
|
||||
if (props.page.kind !== 'project-grid') return undefined
|
||||
const projectId = props.page.projectId
|
||||
const cover = props.portfolio.pages.find(
|
||||
(p): p is Extract<Page, { kind: 'project' }> => p.kind === 'project' && p.projectId === projectId,
|
||||
)
|
||||
return cover?.bandColor
|
||||
})
|
||||
|
||||
// Orientation dominante des images posées : la grille adaptative en dépend
|
||||
const gridOrientation = computed(() => {
|
||||
if (props.page.kind !== 'project-grid' || !project.value) return 'mixed' as const
|
||||
const byName = new Map(project.value.media.map(m => [m.filename, m]))
|
||||
return dominantOrientation(
|
||||
props.page.media.map((f) => {
|
||||
const a = byName.get(f)
|
||||
return a?.width && a?.height ? a.width / a.height : 0
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const gridMedia = computed<ResolvedMedia[]>(() => {
|
||||
if (props.page.kind !== 'project-grid' || !project.value) return []
|
||||
const byName = new Map(project.value.media.map(m => [m.filename, m]))
|
||||
const rects = gridCellRects(gridDef(props.page.grid, props.page.media.length, gridOrientation.value))
|
||||
return props.page.media
|
||||
.map((filename, i) => {
|
||||
const asset = byName.get(filename)
|
||||
if (!asset || asset.type !== 'image') return null // vidéos exclues des grilles
|
||||
const cellW = Math.round((rects[i]?.w ?? 1) * INNER_W)
|
||||
const media = resolve(project.value!.id, filename, cellW, 'image')
|
||||
if (asset.width && asset.height) media.ratio = asset.width / asset.height
|
||||
return media
|
||||
})
|
||||
.filter((m): m is ResolvedMedia => m !== null)
|
||||
})
|
||||
|
||||
// Filet de la couverture du dossier — définit aussi celui du sommaire.
|
||||
const coverBandColor = computed(() => {
|
||||
const cover = props.portfolio.pages.find(
|
||||
(p): p is Extract<Page, { kind: 'cover' }> => p.kind === 'cover',
|
||||
)
|
||||
return cover?.bandColor
|
||||
})
|
||||
|
||||
// Sommaire : entrées générées depuis les pages couverture de projet
|
||||
const tocEntries = computed(() =>
|
||||
props.portfolio.pages
|
||||
.map((p, i) => ({ p, pageNumber: i + 1 }))
|
||||
.filter(({ p }) => p.kind === 'project')
|
||||
.map(({ p, pageNumber }) => {
|
||||
const proj = props.projects[(p as Extract<Page, { kind: 'project' }>).projectId]
|
||||
return { title: proj?.title ?? '—', subtitle: proj?.categories.join(' · '), category: proj?.category, pageNumber }
|
||||
}),
|
||||
)
|
||||
|
||||
// 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
|
||||
})
|
||||
|
||||
const freeMedia = computed<ResolvedMedia[]>(() => {
|
||||
if (props.page.kind !== 'free' || !props.page.media?.length) return []
|
||||
return props.page.media.map((ref) => {
|
||||
const idx = ref.lastIndexOf('/')
|
||||
return resolve(ref.slice(0, idx), ref.slice(idx + 1), 560)
|
||||
})
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CoverTemplate
|
||||
v-if="page.kind === 'cover'"
|
||||
:title="portfolio.cover.title"
|
||||
:background="page.background"
|
||||
:band-color="page.bandColor"
|
||||
:render-mode="renderMode"
|
||||
/>
|
||||
|
||||
<TocTemplate
|
||||
v-else-if="page.kind === 'toc'"
|
||||
:title="page.title"
|
||||
:entries="tocEntries"
|
||||
:background="page.background"
|
||||
:band-color="coverBandColor"
|
||||
:page="pageInfo"
|
||||
:render-mode="renderMode"
|
||||
@navigate="$emit('navigate', $event)"
|
||||
/>
|
||||
|
||||
<FreeTextPage
|
||||
v-else-if="page.kind === 'free'"
|
||||
:title="page.title"
|
||||
:text="freeText"
|
||||
:media="freeMedia"
|
||||
:background="page.background"
|
||||
:band-color="page.bandColor"
|
||||
:page="pageInfo"
|
||||
:render-mode="renderMode"
|
||||
/>
|
||||
|
||||
<template v-else-if="page.kind === 'project'">
|
||||
<div v-if="!project" class="fl-page flex items-center justify-center bg-white text-neutral-400">
|
||||
Projet introuvable : {{ page.projectId }}
|
||||
</div>
|
||||
<ProjectCoverPage
|
||||
v-else
|
||||
:title="project.title"
|
||||
:category="project.category"
|
||||
:badge="projectBadge"
|
||||
:intro="projectIntro"
|
||||
:text="projectText"
|
||||
:image="projectCoverImage"
|
||||
:image-fit="page.coverFit"
|
||||
:image-align="page.coverAlign"
|
||||
:title-scale="page.titleScale"
|
||||
:band-height="page.bandHeight"
|
||||
:band-color="page.bandColor"
|
||||
:ink="page.ink"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-else-if="page.kind === 'project-grid'">
|
||||
<div v-if="!project" class="fl-page flex items-center justify-center bg-white text-neutral-400">
|
||||
Projet introuvable : {{ page.projectId }}
|
||||
</div>
|
||||
<ProjectGridPage
|
||||
v-else
|
||||
:media="gridMedia"
|
||||
:grid="page.grid"
|
||||
:orientation="gridOrientation"
|
||||
:category="project.category"
|
||||
:band-color="gridStripOverride"
|
||||
:grey-bg="page.greyBg"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
23
components/preview/PageViewport.vue
Normal file
23
components/preview/PageViewport.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
// Met à l'échelle le canvas A3 paysage (1587,4×1122,5) pour l'aperçu (§8.4).
|
||||
const PAGE_W = 1587.4
|
||||
const wrapper = ref<HTMLElement>()
|
||||
const scale = ref(0.5)
|
||||
|
||||
let observer: ResizeObserver | undefined
|
||||
onMounted(() => {
|
||||
observer = new ResizeObserver(([entry]) => {
|
||||
scale.value = entry.contentRect.width / PAGE_W
|
||||
})
|
||||
if (wrapper.value) observer.observe(wrapper.value)
|
||||
})
|
||||
onBeforeUnmount(() => observer?.disconnect())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="wrapper" class="relative w-full overflow-hidden" style="aspect-ratio: 1190.55 / 841.89">
|
||||
<div class="absolute left-0 top-0 origin-top-left" :style="{ transform: `scale(${scale})` }">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
59
components/templates/CoverTemplate.vue
Normal file
59
components/templates/CoverTemplate.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
// Couverture du portfolio — reprend l'accueil du site figureslibres.cc
|
||||
// (docs/portfolios-examples/screenshot-site-figures-libres.png) : la phrase
|
||||
// d'intro seule, bloc centré à 65 % de large, chaque keyword dans sa fonte
|
||||
// (sans soulignement, contrairement au site), flèche vers le bas. Rien d'autre.
|
||||
import type { Background } from '~~/types'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title: string // utilisé pour les métadonnées PDF, pas affiché
|
||||
subtitle?: string
|
||||
recipient?: string
|
||||
background: Background
|
||||
bandColor?: string // filet couleur en bas (définit aussi celui du sommaire)
|
||||
renderMode?: 'preview' | 'print'
|
||||
}>(),
|
||||
{ renderMode: 'preview' },
|
||||
)
|
||||
|
||||
// Le site affiche l'intro sur fond gris clair : « Blanc » rend le gris charte.
|
||||
const pageStyle = computed(() => {
|
||||
const s = backgroundStyle(props.background)
|
||||
if (props.background.mode === 'white') s.backgroundColor = FL_BASE
|
||||
return s
|
||||
})
|
||||
|
||||
const strip = computed(() => props.bandColor || FL_INK)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fl-page" :style="pageStyle">
|
||||
<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 -->
|
||||
<p class="w-[65%]" :style="{ fontFamily: 'Lato, sans-serif', fontSize: '54px', lineHeight: '1.35' }">
|
||||
<span class="font-bold" style="font-family: 'Syne', sans-serif">Figures Libres</span>
|
||||
est un collectif de design graphique et interactif porteur de messages d'utilité
|
||||
<span class="fl-font-publique">publique</span>,
|
||||
<span class="fl-font-sociale">sociale</span> et
|
||||
<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 }" />
|
||||
</div>
|
||||
</template>
|
||||
39
components/templates/FreeTextPage.vue
Normal file
39
components/templates/FreeTextPage.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
// Page libre (équipe, méthodologie…) — langage du gabarit : filet couleur
|
||||
// 5 mm, marges 8 mm, titre Syne 45 pt, texte Syne 12/16 pt, médias entiers
|
||||
// calés à droite. Pas de multi-colonnes CSS (Chromium les imprime en blanc).
|
||||
import type { Background, ResolvedMedia } from '~~/types'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title?: string
|
||||
text?: string // HTML
|
||||
media?: ResolvedMedia[]
|
||||
background: Background
|
||||
bandColor?: string
|
||||
page: { index: number; total: number }
|
||||
renderMode?: 'preview' | 'print'
|
||||
}>(),
|
||||
{ renderMode: 'preview' },
|
||||
)
|
||||
|
||||
const strip = computed(() => props.bandColor || FL_INK)
|
||||
</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="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>
|
||||
<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="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">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
95
components/templates/ProjectCoverPage.vue
Normal file
95
components/templates/ProjectCoverPage.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<script setup lang="ts">
|
||||
// Page « couverture » de projet — gabarit_book_v3 p.1/3/5/7.
|
||||
// Bandeau pleine largeur 55 mm : titre (fonte de la catégorie, 45 pt),
|
||||
// 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'
|
||||
|
||||
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
|
||||
text?: string // présentation, HTML
|
||||
image?: ResolvedMedia
|
||||
imageFit?: 'width' | 'height' // calage : pleine largeur ou pleine hauteur
|
||||
imageAlign?: 'left' | 'center' | 'right' // fit hauteur : marge gauche / centrée / marge droite
|
||||
titleScale?: number // % de la taille gabarit (100 = 45 pt)
|
||||
bandHeight?: number // mm (gabarit : 55)
|
||||
bandColor?: string
|
||||
ink?: InkMode
|
||||
}>(),
|
||||
{ ink: 'auto', imageFit: 'width', imageAlign: 'center', titleScale: 100, bandHeight: 55 },
|
||||
)
|
||||
|
||||
const PX_PER_MM = 3.7796
|
||||
|
||||
const band = computed(() => bandColorOf(props.category, props.bandColor))
|
||||
const inkColor = computed(() => inkOn(band.value, props.ink))
|
||||
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(() => {
|
||||
const base = props.category === 'culturelle' ? 56 : 60
|
||||
return `${(base * props.titleScale) / 100}px`
|
||||
})
|
||||
const bandPx = computed(() => `${props.bandHeight * PX_PER_MM}px`)
|
||||
|
||||
// Fit hauteur : calage horizontal sur la marge gauche, centré, ou marge droite
|
||||
const alignStyle = computed(() => {
|
||||
if (props.imageFit !== 'height') return { justifyContent: 'center' }
|
||||
switch (props.imageAlign) {
|
||||
case 'left': return { justifyContent: 'flex-start', paddingLeft: 'var(--fl-margin)' }
|
||||
case 'right': return { justifyContent: 'flex-end', paddingRight: 'var(--fl-margin)' }
|
||||
default: return { justifyContent: 'center' }
|
||||
}
|
||||
})
|
||||
</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>
|
||||
<div
|
||||
class="absolute overflow-hidden"
|
||||
:style="{ left: '50.47%', right: 'var(--fl-margin)', top: '33px', bottom: '12px' }"
|
||||
>
|
||||
<p v-if="intro" class="font-bold">{{ intro }}</p>
|
||||
<div v-if="text" class="fl-prose" v-html="text" />
|
||||
</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. -->
|
||||
<div
|
||||
class="absolute inset-x-0 bottom-0 flex overflow-hidden"
|
||||
:style="{ top: bandPx, backgroundColor: image ? band : '#ffffff', ...alignStyle }"
|
||||
>
|
||||
<img
|
||||
v-if="image"
|
||||
:src="image.url"
|
||||
:alt="title"
|
||||
:style="imageFit === 'height'
|
||||
? { height: '100%', width: 'auto', maxWidth: 'none' }
|
||||
: { width: '100%', height: 'auto', alignSelf: 'flex-end' }"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
134
components/templates/ProjectGridPage.vue
Normal file
134
components/templates/ProjectGridPage.vue
Normal file
@@ -0,0 +1,134 @@
|
||||
<script setup lang="ts">
|
||||
// Page « grille » de projet — gabarit_book_v3 p.2/4/6/8.
|
||||
// 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 { ImageOrientation } from '~~/composables/useGrids'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
media: ResolvedMedia[]
|
||||
grid: string // id de famille (useGrids)
|
||||
orientation?: ImageOrientation
|
||||
category?: string
|
||||
bandColor?: string
|
||||
greyBg?: boolean
|
||||
}>(),
|
||||
{ orientation: 'mixed' },
|
||||
)
|
||||
|
||||
const def = computed(() => gridDef(props.grid, props.media.length, props.orientation))
|
||||
const strip = computed(() => stripColorOf(props.category, props.bandColor))
|
||||
|
||||
// Métriques du canvas A3 paysage (theme.css) — zone utile hors marges 8 mm.
|
||||
const MARGIN = 30.2
|
||||
const GUTTER = 14.4
|
||||
const AVAIL_W = 1587.4 - 2 * MARGIN // 1527 px
|
||||
const AVAIL_H = 1122.5 - 2 * MARGIN // 1062,1 px
|
||||
|
||||
// Familles « grande image + bandeaux/file » : la colonne étroite empile ses
|
||||
// images à leur HAUTEUR RÉELLE (à la largeur de colonne), si bien que les
|
||||
// gouttières entre bandeaux valent exactement --fl-gutter — comme la colonne
|
||||
// de la grille « pleine largeur empilées ». Si la pile dépasse la hauteur de
|
||||
// page, on réduit toutes les images proportionnellement (aucun letterbox,
|
||||
// images toujours entières).
|
||||
const hero = computed(() => {
|
||||
const id = def.value.id
|
||||
if (id !== 'hero-left' && id !== 'hero-right') return null
|
||||
const left = id === 'hero-left'
|
||||
const b = def.value.cells.length - 1 // nombre de bandeaux/vignettes
|
||||
if (b < 1) return null
|
||||
|
||||
const cols = def.value.cols
|
||||
const bigFrac = left ? cols[0] : cols[1]
|
||||
const smallFrac = left ? cols[1] : cols[0]
|
||||
const total = bigFrac + smallFrac
|
||||
// largeur de la colonne étroite (les deux colonnes se partagent la largeur
|
||||
// utile moins la gouttière qui les sépare)
|
||||
const smallW = (smallFrac / total) * (AVAIL_W - GUTTER)
|
||||
|
||||
const big = left ? props.media[0] : props.media[b]
|
||||
const bandeaux = left ? props.media.slice(1, b + 1) : props.media.slice(0, b)
|
||||
|
||||
const natH = bandeaux.map(m => smallW / (m.ratio && m.ratio > 0 ? m.ratio : 1.5))
|
||||
const gaps = (b - 1) * GUTTER
|
||||
const sum = natH.reduce((a, h) => a + h, 0)
|
||||
const maxH = AVAIL_H - gaps
|
||||
const k = sum > maxH ? maxH / sum : 1
|
||||
const heights = natH.map(h => h * k)
|
||||
|
||||
return { left, big, bandeaux, heights, bigFrac, smallFrac }
|
||||
})
|
||||
|
||||
const gridStyle = computed(() => ({
|
||||
gridTemplateColumns: def.value.cols.map(c => `${c}fr`).join(' '),
|
||||
gridTemplateRows: def.value.rows.map(r => `${r}fr`).join(' '),
|
||||
gap: 'var(--fl-gutter)',
|
||||
}))
|
||||
|
||||
function cellStyle(i: number) {
|
||||
const cell = def.value.cells[i]
|
||||
if (!cell) return {}
|
||||
return {
|
||||
gridColumn: `${cell.c + 1} / span ${cell.cs ?? 1}`,
|
||||
gridRow: `${cell.r + 1} / span ${cell.rs ?? 1}`,
|
||||
}
|
||||
}
|
||||
|
||||
/** Cale l'image sur les lignes extérieures de la grille (jamais tronquée). */
|
||||
function objectPosition(i: number) {
|
||||
const cell = def.value.cells[i]
|
||||
if (!cell) return 'left top'
|
||||
const lastCol = cell.c + (cell.cs ?? 1) === def.value.cols.length
|
||||
const firstCol = cell.c === 0
|
||||
const x = firstCol && lastCol ? 'center' : lastCol ? 'right' : 'left'
|
||||
return `${x} top`
|
||||
}
|
||||
|
||||
const items = computed(() =>
|
||||
def.value.cells.map((_, i) => props.media[i]).filter((m): m is ResolvedMedia => !!m),
|
||||
)
|
||||
</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 }" />
|
||||
|
||||
<!-- 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)' }">
|
||||
<!-- grande image (à gauche pour hero-left, à droite pour hero-right) -->
|
||||
<div v-if="!hero.left" class="min-h-0 min-w-0 flex flex-col" :style="{ flex: `${hero.smallFrac} 0 0`, gap: 'var(--fl-gutter)' }">
|
||||
<div v-for="(m, i) in hero.bandeaux" :key="m.filename + i" class="min-h-0 w-full" :style="{ height: `${hero.heights[i]}px` }">
|
||||
<img :src="m.url" :alt="m.filename" class="size-full object-contain">
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-h-0 min-w-0" :style="{ flex: `${hero.bigFrac} 0 0` }">
|
||||
<img
|
||||
:src="hero.big.url"
|
||||
:alt="hero.big.filename"
|
||||
class="size-full object-contain"
|
||||
:style="{ objectPosition: hero.left ? 'left top' : 'right top' }"
|
||||
>
|
||||
</div>
|
||||
<div v-if="hero.left" class="min-h-0 min-w-0 flex flex-col" :style="{ flex: `${hero.smallFrac} 0 0`, gap: 'var(--fl-gutter)' }">
|
||||
<div v-for="(m, i) in hero.bandeaux" :key="m.filename + i" class="min-h-0 w-full" :style="{ height: `${hero.heights[i]}px` }">
|
||||
<img :src="m.url" :alt="m.filename" class="size-full object-contain">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Grille d'images standard dans les marges de 8 mm -->
|
||||
<div v-else class="absolute grid" :style="{ inset: 'var(--fl-margin)', ...gridStyle }">
|
||||
<div v-for="(m, i) in items" :key="m.filename + i" class="min-h-0 min-w-0" :style="cellStyle(i)">
|
||||
<img
|
||||
:src="m.url"
|
||||
:alt="m.filename"
|
||||
class="size-full object-contain"
|
||||
:style="{ objectPosition: objectPosition(i) }"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
77
components/templates/TocTemplate.vue
Normal file
77
components/templates/TocTemplate.vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<script setup lang="ts">
|
||||
// Sommaire — langage du gabarit : filet couleur 5 mm en haut, marges 8 mm,
|
||||
// titre Syne 45 pt. La taille des entrées et leur disposition (colonne unique
|
||||
// ou grille) s'adaptent au nombre de projets listés.
|
||||
import type { Background } from '~~/types'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title?: string
|
||||
entries: { title: string; subtitle?: string; category?: string; pageNumber: number }[]
|
||||
background: Background
|
||||
bandColor?: string
|
||||
page: { index: number; total: number }
|
||||
renderMode?: 'preview' | 'print'
|
||||
}>(),
|
||||
{ title: 'Sommaire', renderMode: 'preview' },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{ navigate: [pageNumber: number] }>()
|
||||
|
||||
const strip = computed(() => props.bandColor || FL_INK)
|
||||
const isPrint = computed(() => props.renderMode === 'print')
|
||||
|
||||
// En print, chaque entrée est un lien interne #page-N (converti en lien PDF par
|
||||
// Chromium). En aperçu, le même clic défile jusqu'à la page dans la preview.
|
||||
function hrefFor(n: number) {
|
||||
return isPrint.value ? `#page-${n}` : `#preview-page-${n}`
|
||||
}
|
||||
function onEntryClick(n: number, e: MouseEvent) {
|
||||
if (isPrint.value) return // laisse le lien natif au PDF
|
||||
e.preventDefault()
|
||||
e.stopPropagation() // n'active pas la sélection de la page-sommaire elle-même
|
||||
emit('navigate', n)
|
||||
}
|
||||
|
||||
// Peu de projets = gros titres sur une colonne ; beaucoup = grille dense.
|
||||
const layout = computed(() => {
|
||||
const n = props.entries.length
|
||||
if (n <= 4) return { cols: 1, size: 48, sub: 18.7, gapX: 0, gapY: 44 }
|
||||
if (n <= 8) return { cols: 1, size: 32, sub: 14.7, gapX: 0, gapY: 30 }
|
||||
if (n <= 16) return { cols: 2, size: 24, sub: 13.3, gapX: 96, gapY: 24 }
|
||||
if (n <= 26) return { cols: 2, size: 18.7, sub: 12, gapX: 96, gapY: 18 }
|
||||
return { cols: 3, size: 14.7, sub: 10.7, gapX: 60, gapY: 14 }
|
||||
})
|
||||
|
||||
const gridStyle = computed(() => ({
|
||||
gridTemplateColumns: `repeat(${layout.value.cols}, minmax(0, 1fr))`,
|
||||
columnGap: `${layout.value.gapX}px`,
|
||||
rowGap: `${layout.value.gapY}px`,
|
||||
gridAutoRows: 'min-content',
|
||||
}))
|
||||
</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="absolute flex flex-col" :style="{ inset: 'var(--fl-margin)', top: '60px' }">
|
||||
<h2 :style="{ fontSize: '60px', lineHeight: '1.1' }">{{ title }}</h2>
|
||||
<div class="mt-14 grid min-h-0 flex-1 content-start" :style="gridStyle">
|
||||
<a
|
||||
v-for="entry in entries"
|
||||
:key="`${entry.pageNumber}-${entry.title}`"
|
||||
:href="hrefFor(entry.pageNumber)"
|
||||
class="flex items-baseline gap-3 text-current no-underline"
|
||||
:class="{ 'cursor-pointer': !isPrint }"
|
||||
@click="onEntryClick(entry.pageNumber, $event)"
|
||||
>
|
||||
<span :class="CATEGORY_TITLE_CLASS[entry.category ?? '']" :style="{ fontSize: `${layout.size}px`, lineHeight: '1.2' }">{{ entry.title }}</span>
|
||||
<span v-if="entry.subtitle" class="whitespace-nowrap opacity-70" :style="{ fontSize: `${layout.sub}px` }">{{ entry.subtitle }}</span>
|
||||
<span class="flex-1 border-b border-dotted border-current opacity-40" />
|
||||
<span class="tabular-nums opacity-80" :style="{ fontSize: `${layout.sub + 2}px` }">{{ entry.pageNumber }}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user