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:
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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' },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
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