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