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