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:
117
frontend/src/components/PhotoGallery.vue
Normal file
117
frontend/src/components/PhotoGallery.vue
Normal 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>
|
||||||
Reference in New Issue
Block a user