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

@@ -1,29 +1,112 @@
import os import os
import uuid import uuid
from fastapi import APIRouter, File, HTTPException, UploadFile from typing import List, Optional
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
from sqlmodel import Session, select
from app.config import UPLOAD_DIR from app.config import UPLOAD_DIR
from app.database import get_session
from app.models.media import Attachment, Media
router = APIRouter(tags=["media"]) router = APIRouter(tags=["media"])
ALLOWED_EXT = {".jpg", ".jpeg", ".png", ".webp", ".gif"}
def _save_webp(data: bytes, max_px: int) -> str:
try:
from PIL import Image
import io
img = Image.open(io.BytesIO(data)).convert("RGB")
img.thumbnail((max_px, max_px))
name = f"{uuid.uuid4()}.webp"
path = os.path.join(UPLOAD_DIR, name)
img.save(path, "WEBP", quality=85)
return name
except Exception:
name = f"{uuid.uuid4()}.bin"
path = os.path.join(UPLOAD_DIR, name)
with open(path, "wb") as f:
f.write(data)
return name
@router.post("/upload") @router.post("/upload")
async def upload_file(file: UploadFile = File(...)): async def upload_file(file: UploadFile = File(...)):
ext = os.path.splitext(file.filename or "")[-1].lower()
if ext not in ALLOWED_EXT:
raise HTTPException(status_code=400, detail="Format non supporté")
filename = f"{uuid.uuid4().hex}{ext}"
dest = os.path.join(UPLOAD_DIR, filename)
os.makedirs(UPLOAD_DIR, exist_ok=True) os.makedirs(UPLOAD_DIR, exist_ok=True)
content = await file.read() data = await file.read()
with open(dest, "wb") as f: ct = file.content_type or ""
f.write(content) if ct.startswith("image/"):
return {"filename": filename, "url": f"/uploads/{filename}"} name = _save_webp(data, 1200)
thumb = _save_webp(data, 300)
return {"url": f"/uploads/{name}", "thumbnail_url": f"/uploads/{thumb}"}
else:
name = f"{uuid.uuid4()}_{file.filename}"
path = os.path.join(UPLOAD_DIR, name)
with open(path, "wb") as f:
f.write(data)
return {"url": f"/uploads/{name}", "thumbnail_url": None}
@router.delete("/upload/{filename}", status_code=204) @router.get("/media/all", response_model=List[Media])
def delete_file(filename: str): def list_all_media(
path = os.path.join(UPLOAD_DIR, filename) entity_type: Optional[str] = Query(default=None),
if os.path.exists(path): session: Session = Depends(get_session),
os.remove(path) ):
"""Retourne tous les médias, filtrés optionnellement par entity_type."""
q = select(Media).order_by(Media.created_at.desc())
if entity_type:
q = q.where(Media.entity_type == entity_type)
return session.exec(q).all()
@router.get("/media", response_model=List[Media])
def list_media(
entity_type: str = Query(...),
entity_id: int = Query(...),
session: Session = Depends(get_session),
):
return session.exec(
select(Media).where(
Media.entity_type == entity_type, Media.entity_id == entity_id
)
).all()
@router.post("/media", response_model=Media, status_code=status.HTTP_201_CREATED)
def create_media(m: Media, session: Session = Depends(get_session)):
session.add(m)
session.commit()
session.refresh(m)
return m
@router.delete("/media/{id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_media(id: int, session: Session = Depends(get_session)):
m = session.get(Media, id)
if not m:
raise HTTPException(404, "Media introuvable")
session.delete(m)
session.commit()
@router.get("/attachments", response_model=List[Attachment])
def list_attachments(
entity_type: str = Query(...),
entity_id: int = Query(...),
session: Session = Depends(get_session),
):
return session.exec(
select(Attachment).where(
Attachment.entity_type == entity_type,
Attachment.entity_id == entity_id,
)
).all()
@router.post("/attachments", response_model=Attachment, status_code=status.HTTP_201_CREATED)
def create_attachment(a: Attachment, session: Session = Depends(get_session)):
session.add(a)
session.commit()
session.refresh(a)
return a

View File

@@ -21,11 +21,13 @@ defineEmits(['close'])
const links = [ const links = [
{ to: '/', label: 'Dashboard' }, { to: '/', label: 'Dashboard' },
{ to: '/jardins', label: 'Jardins' }, { 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: '/plantations', label: 'Plantations' },
{ to: '/taches', label: 'Tâches' }, { to: '/taches', label: 'Tâches' },
{ to: '/planning', label: 'Planning' }, { to: '/planning', label: 'Planning' },
{ to: '/lunaire', label: 'Calendrier lunaire' }, { to: '/calendrier', label: 'Calendrier' },
{ to: '/reglages', label: 'Réglages' }, { to: '/reglages', label: 'Réglages' },
] ]
</script> </script>

View File

@@ -6,11 +6,16 @@ export default createRouter({
{ path: '/', component: () => import('@/views/DashboardView.vue') }, { path: '/', component: () => import('@/views/DashboardView.vue') },
{ path: '/jardins', component: () => import('@/views/JardinsView.vue') }, { path: '/jardins', component: () => import('@/views/JardinsView.vue') },
{ path: '/jardins/:id', component: () => import('@/views/JardinDetailView.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: '/plantations', component: () => import('@/views/PlantationsView.vue') },
{ path: '/planning', component: () => import('@/views/PlanningView.vue') }, { path: '/planning', component: () => import('@/views/PlanningView.vue') },
{ path: '/taches', component: () => import('@/views/TachesView.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') }, { 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>