feat(frontend): modal PhotoIdentifyModal — upload + identification + association

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-22 12:31:32 +01:00
parent 941bf7aa3e
commit 7349f770e6

View 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>