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>
131 lines
4.9 KiB
Vue
131 lines
4.9 KiB
Vue
<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>
|