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