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:
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>
|
||||
Reference in New Issue
Block a user