feat: vue BibliothequeView + route /bibliotheque + nav + endpoint media/all

- backend: ajoute GET /api/media/all (filtrable par entity_type, trié par date desc) dans media.py ; importe Optional depuis typing
- frontend: crée BibliothequeView.vue (grille photo, filtres par type, lightbox, modal PhotoIdentifyModal)
- frontend: ajoute la route /bibliotheque dans router/index.ts
- frontend: ajoute le lien "📷 Bibliothèque" dans AppDrawer.vue

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-22 12:35:46 +01:00
parent 7349f770e6
commit 94ebe338a0
4 changed files with 271 additions and 20 deletions

View File

@@ -21,11 +21,13 @@ defineEmits(['close'])
const links = [
{ to: '/', label: 'Dashboard' },
{ to: '/jardins', label: 'Jardins' },
{ to: '/varietes', label: 'Variétés' },
{ to: '/plantes', label: 'Plantes' },
{ to: '/bibliotheque', label: '📷 Bibliothèque' },
{ to: '/outils', label: 'Outils' },
{ to: '/plantations', label: 'Plantations' },
{ to: '/taches', label: 'Tâches' },
{ to: '/planning', label: 'Planning' },
{ to: '/lunaire', label: 'Calendrier lunaire' },
{ to: '/calendrier', label: 'Calendrier' },
{ to: '/reglages', label: 'Réglages' },
]
</script>

View File

@@ -6,11 +6,16 @@ export default createRouter({
{ path: '/', component: () => import('@/views/DashboardView.vue') },
{ path: '/jardins', component: () => import('@/views/JardinsView.vue') },
{ path: '/jardins/:id', component: () => import('@/views/JardinDetailView.vue') },
{ path: '/varietes', component: () => import('@/views/VarietesView.vue') },
{ path: '/plantes', component: () => import('@/views/PlantesView.vue') },
{ path: '/bibliotheque', component: () => import('@/views/BibliothequeView.vue') },
{ path: '/outils', component: () => import('@/views/OutilsView.vue') },
{ path: '/plantations', component: () => import('@/views/PlantationsView.vue') },
{ path: '/planning', component: () => import('@/views/PlanningView.vue') },
{ path: '/taches', component: () => import('@/views/TachesView.vue') },
{ path: '/lunaire', component: () => import('@/views/LunaireView.vue') },
{ path: '/calendrier', component: () => import('@/views/CalendrierView.vue') },
{ path: '/reglages', component: () => import('@/views/ReglagesView.vue') },
// Redirect des anciens liens
{ path: '/varietes', redirect: '/plantes' },
{ path: '/lunaire', redirect: '/calendrier' },
],
})

View File

@@ -0,0 +1,161 @@
<template>
<div class="p-4 max-w-4xl mx-auto">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-green">📷 Bibliothèque</h1>
<button
@click="showIdentify = true"
class="bg-green text-bg px-4 py-2 rounded-lg text-sm font-semibold hover:opacity-90"
>
Identifier une plante
</button>
</div>
<!-- Filtres -->
<div class="flex gap-2 mb-4 flex-wrap">
<button
v-for="f in filters"
:key="f.val"
@click="activeFilter = f.val"
:class="[
'px-3 py-1 rounded-full text-xs font-medium transition-colors',
activeFilter === f.val
? 'bg-green text-bg'
: 'bg-bg-soft text-text-muted hover:text-text',
]"
>
{{ f.label }}
</button>
</div>
<!-- Grille -->
<div v-if="loading" class="text-text-muted text-sm">Chargement...</div>
<div v-else-if="!filtered.length" class="text-text-muted text-sm py-4">
Aucune photo.
</div>
<div v-else class="grid grid-cols-3 md:grid-cols-4 gap-2">
<div
v-for="m in filtered"
: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/70 text-xs text-green px-1 py-0.5 truncate"
>
{{ m.identified_common }}
</div>
<div class="absolute top-1 left-1 bg-black/60 text-text-muted text-xs px-1 rounded">
{{ labelFor(m.entity_type) }}
</div>
</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-3 text-text-muted text-sm"
>
<div class="text-green font-semibold text-base">
{{ lightbox.identified_common }}
</div>
<div class="italic">{{ lightbox.identified_species }}</div>
<div class="text-xs mt-1">
Confiance : {{ Math.round((lightbox.identified_confidence || 0) * 100) }}%
via {{ lightbox.identified_source }}
</div>
</div>
<button
class="mt-4 w-full text-text-muted hover:text-text text-sm"
@click="lightbox = null"
>
Fermer
</button>
</div>
</div>
<!-- Modal identification -->
<PhotoIdentifyModal
v-if="showIdentify"
@close="showIdentify = false"
@identified="onIdentified"
/>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import axios from 'axios'
import PhotoIdentifyModal from '@/components/PhotoIdentifyModal.vue'
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)
const showIdentify = ref(false)
const activeFilter = ref('')
const filters = [
{ val: '', label: 'Toutes' },
{ val: 'plante', label: '🌱 Plantes' },
{ val: 'jardin', label: '🏡 Jardins' },
{ val: 'plantation', label: '🥕 Plantations' },
{ val: 'outil', label: '🔧 Outils' },
]
const filtered = computed(() =>
activeFilter.value
? medias.value.filter((m) => m.entity_type === activeFilter.value)
: medias.value,
)
function labelFor(type: string) {
const map: Record<string, string> = {
plante: '🌱',
jardin: '🏡',
plantation: '🥕',
outil: '🔧',
}
return map[type] ?? '📷'
}
async function fetchAll() {
loading.value = true
try {
const { data } = await axios.get<Media[]>('/api/media/all')
medias.value = data
} finally {
loading.value = false
}
}
function onIdentified() {
fetchAll()
}
onMounted(fetchAll)
</script>