feat(frontend): composant PhotoGallery réutilisable avec lightbox et upload

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-22 12:25:11 +01:00
parent 2065ff7c8a
commit 941bf7aa3e

View File

@@ -0,0 +1,117 @@
<template>
<div>
<div class="flex items-center justify-between mb-3">
<span class="text-text-muted text-sm">{{ medias.length }} photo(s)</span>
<label class="cursor-pointer bg-bg-soft text-text-muted hover:text-text px-3 py-1 rounded-lg text-xs border border-bg-hard transition-colors">
+ Photo
<input type="file" accept="image/*" capture="environment" class="hidden" @change="onUpload" />
</label>
</div>
<div v-if="loading" class="text-text-muted text-xs">Chargement...</div>
<div v-else-if="!medias.length" class="text-text-muted text-xs italic py-2">Aucune photo.</div>
<div class="grid grid-cols-3 gap-2">
<div
v-for="m in medias" :key="m.id"
class="aspect-square rounded-lg overflow-hidden bg-bg-hard relative group cursor-pointer"
@click="lightbox = m"
>
<img :src="m.thumbnail_url || m.url" :alt="m.titre || ''" class="w-full h-full object-cover" />
<div
v-if="m.identified_common"
class="absolute bottom-0 left-0 right-0 bg-black/60 text-xs text-green px-1 py-0.5 truncate"
>
{{ m.identified_common }}
</div>
<button
@click.stop="deleteMedia(m.id)"
class="absolute top-1 right-1 bg-black/60 text-red text-xs px-1 rounded opacity-0 group-hover:opacity-100 transition-opacity"
>
</button>
</div>
</div>
<!-- Lightbox -->
<div
v-if="lightbox"
class="fixed inset-0 bg-black/80 z-50 flex items-center justify-center p-4"
@click.self="lightbox = null"
>
<div class="max-w-lg w-full">
<img :src="lightbox.url" class="w-full rounded-xl" />
<div v-if="lightbox.identified_species" class="text-center mt-2 text-text-muted text-sm">
<span class="text-green font-medium">{{ lightbox.identified_common }}</span>
<em>{{ lightbox.identified_species }}</em>
({{ Math.round((lightbox.identified_confidence || 0) * 100) }}%)
</div>
<button class="mt-3 w-full text-text-muted text-sm hover:text-text" @click="lightbox = null">
Fermer
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import axios from 'axios'
const props = defineProps<{ entityType: string; entityId: number }>()
interface Media {
id: number
entity_type: string
entity_id: number
url: string
thumbnail_url?: string
titre?: string
identified_species?: string
identified_common?: string
identified_confidence?: number
identified_source?: string
}
const medias = ref<Media[]>([])
const loading = ref(false)
const lightbox = ref<Media | null>(null)
async function fetchMedias() {
loading.value = true
try {
const r = await axios.get<Media[]>('/api/media', {
params: { entity_type: props.entityType, entity_id: props.entityId },
})
medias.value = r.data
} finally {
loading.value = false
}
}
async function onUpload(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
const fd = new FormData()
fd.append('file', file)
const { data: uploaded } = await axios.post<{ url: string; thumbnail_url: string | null }>(
'/api/upload',
fd,
)
await axios.post('/api/media', {
entity_type: props.entityType,
entity_id: props.entityId,
url: uploaded.url,
thumbnail_url: uploaded.thumbnail_url,
})
await fetchMedias()
}
async function deleteMedia(id: number) {
if (!confirm('Supprimer cette photo ?')) return
await axios.delete(`/api/media/${id}`)
medias.value = medias.value.filter((m) => m.id !== id)
}
onMounted(fetchMedias)
</script>