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