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:
@@ -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>
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
})
|
||||
|
||||
161
frontend/src/views/BibliothequeView.vue
Normal file
161
frontend/src/views/BibliothequeView.vue
Normal 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>
|
||||
Reference in New Issue
Block a user