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:
2026-07-05 18:37:09 +02:00
commit 59dfbe4155
79 changed files with 18587 additions and 0 deletions

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>