feat(frontend): modal PhotoIdentifyModal — upload + identification + association
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
212
frontend/src/components/PhotoIdentifyModal.vue
Normal file
212
frontend/src/components/PhotoIdentifyModal.vue
Normal file
@@ -0,0 +1,212 @@
|
||||
<template>
|
||||
<div
|
||||
class="fixed inset-0 bg-black/70 z-50 flex items-center justify-center p-4"
|
||||
@click.self="$emit('close')"
|
||||
>
|
||||
<div class="bg-bg-hard rounded-xl p-6 w-full max-w-md border border-bg-soft">
|
||||
<h2 class="text-text font-bold text-lg mb-4">Identifier une plante</h2>
|
||||
|
||||
<!-- Zone upload -->
|
||||
<div
|
||||
v-if="!previewUrl"
|
||||
class="border-2 border-dashed border-bg-soft rounded-xl p-8 text-center mb-4"
|
||||
>
|
||||
<p class="text-text-muted text-sm mb-3">Glisser une photo ou</p>
|
||||
<label class="cursor-pointer bg-green text-bg px-4 py-2 rounded-lg text-sm font-semibold hover:opacity-90">
|
||||
Choisir / Photographier
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
class="hidden"
|
||||
@change="onFileSelect"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Preview + résultats -->
|
||||
<div v-else>
|
||||
<img :src="previewUrl" class="w-full rounded-lg mb-4 max-h-48 object-cover" />
|
||||
|
||||
<div v-if="loading" class="text-text-muted text-sm text-center py-4">
|
||||
Identification en cours...
|
||||
</div>
|
||||
|
||||
<div v-else-if="results.length">
|
||||
<p class="text-text-muted text-xs mb-2">
|
||||
Source : <span class="text-yellow font-mono">{{ source }}</span>
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-for="(r, i) in results"
|
||||
:key="i"
|
||||
class="mb-2 p-3 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="
|
||||
selected === i
|
||||
? 'border-green bg-green/10'
|
||||
: 'border-bg-soft bg-bg hover:border-green/50'
|
||||
"
|
||||
@click="selected = i"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-text font-medium text-sm">
|
||||
{{ r.common_name || r.species }}
|
||||
</div>
|
||||
<div class="text-text-muted text-xs italic">{{ r.species }}</div>
|
||||
</div>
|
||||
<div class="text-green text-sm font-bold">
|
||||
{{ Math.round(r.confidence * 100) }}%
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-1 h-1 bg-bg-soft rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-green rounded-full transition-all"
|
||||
:style="{ width: `${r.confidence * 100}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Association à une plante -->
|
||||
<div class="mt-4 flex flex-col gap-2">
|
||||
<select
|
||||
v-model="linkPlantId"
|
||||
class="w-full bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm outline-none focus:border-green"
|
||||
>
|
||||
<option :value="null">— Associer à une plante existante (optionnel)</option>
|
||||
<option v-for="p in plants" :key="p.id" :value="p.id">
|
||||
{{ p.nom_commun }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@click="saveAndLink"
|
||||
:disabled="selected === null || saving"
|
||||
class="flex-1 bg-green text-bg py-2 rounded-lg text-sm font-semibold hover:opacity-90 disabled:opacity-40"
|
||||
>
|
||||
{{ saving ? 'Enregistrement...' : 'Enregistrer' }}
|
||||
</button>
|
||||
<button
|
||||
@click="$emit('close')"
|
||||
class="px-4 py-2 text-text-muted hover:text-text text-sm"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-text-muted text-sm text-center py-4">
|
||||
Aucune plante identifiée. Essayez avec une photo plus nette.
|
||||
<button @click="reset" class="block mt-2 mx-auto text-green hover:underline text-xs">
|
||||
Réessayer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import axios from 'axios'
|
||||
|
||||
interface IdentifyResult {
|
||||
species: string
|
||||
common_name: string
|
||||
confidence: number
|
||||
image_url: string
|
||||
}
|
||||
|
||||
interface Plant {
|
||||
id: number
|
||||
nom_commun: string
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
identified: [result: IdentifyResult & { imageUrl: string; plantId: number | null }]
|
||||
}>()
|
||||
|
||||
const previewUrl = ref<string | null>(null)
|
||||
const imageFile = ref<File | null>(null)
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const results = ref<IdentifyResult[]>([])
|
||||
const source = ref('')
|
||||
const selected = ref<number | null>(null)
|
||||
const plants = ref<Plant[]>([])
|
||||
const linkPlantId = ref<number | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
const { data } = await axios.get<Plant[]>('/api/plants')
|
||||
plants.value = data
|
||||
})
|
||||
|
||||
async function onFileSelect(e: Event) {
|
||||
const file = (e.target as HTMLInputElement).files?.[0]
|
||||
if (!file) return
|
||||
imageFile.value = file
|
||||
previewUrl.value = URL.createObjectURL(file)
|
||||
await identify()
|
||||
}
|
||||
|
||||
async function identify() {
|
||||
loading.value = true
|
||||
results.value = []
|
||||
selected.value = null
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('file', imageFile.value!)
|
||||
const { data } = await axios.post<{ source: string; results: IdentifyResult[] }>(
|
||||
'/api/identify',
|
||||
fd,
|
||||
)
|
||||
results.value = data.results
|
||||
source.value = data.source
|
||||
if (results.value.length) selected.value = 0
|
||||
} catch {
|
||||
results.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAndLink() {
|
||||
if (imageFile.value === null || selected.value === null) return
|
||||
const r = results.value[selected.value]
|
||||
saving.value = true
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('file', imageFile.value)
|
||||
const { data: uploaded } = await axios.post<{ url: string; thumbnail_url: string | null }>(
|
||||
'/api/upload',
|
||||
fd,
|
||||
)
|
||||
if (linkPlantId.value !== null) {
|
||||
await axios.post('/api/media', {
|
||||
entity_type: 'plante',
|
||||
entity_id: linkPlantId.value,
|
||||
url: uploaded.url,
|
||||
thumbnail_url: uploaded.thumbnail_url,
|
||||
identified_species: r.species,
|
||||
identified_common: r.common_name,
|
||||
identified_confidence: r.confidence,
|
||||
identified_source: source.value,
|
||||
})
|
||||
}
|
||||
emit('identified', { ...r, imageUrl: uploaded.url, plantId: linkPlantId.value })
|
||||
emit('close')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
previewUrl.value = null
|
||||
imageFile.value = null
|
||||
results.value = []
|
||||
selected.value = null
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user