From 7b1b6521e53258fff957c27dcdaa5399b7c4945a Mon Sep 17 00:00:00 2001 From: Gilles Soulier Date: Mon, 25 May 2026 06:37:33 +0200 Subject: [PATCH] feat(shopping): photo par article dans le catalogue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Upload photo (context=product → thumbnail 150×150) dans CatalogueModal - Miniature affichée dans la liste et dans le formulaire - Schémas ProductCreate/Update/Response exposent image_path + thumbnail_path - Backend sert /media/* via StaticFiles (FastAPI) - Proxy /media → backend dans vite.config et nginx.conf Co-Authored-By: Claude Sonnet 4.6 --- amelioration_todo.md | 10 +-- backend/app/main.py | 3 + backend/app/schemas/shopping.py | 6 ++ frontend/nginx.conf | 6 ++ frontend/src/api/shopping.ts | 6 ++ .../components/shopping/CatalogueModal.tsx | 72 ++++++++++++++++++- frontend/vite.config.ts | 1 + 7 files changed, 99 insertions(+), 5 deletions(-) diff --git a/amelioration_todo.md b/amelioration_todo.md index 668d441..0b36b16 100644 --- a/amelioration_todo.md +++ b/amelioration_todo.md @@ -31,8 +31,10 @@ - **Viewport non zoomable** : `maximum-scale=1.0, user-scalable=no` dans index.html - **Domaines stockés en tableau** (TEXT[]) — migration 002 Alembic -## En attente +## Implémentées (suite) ✅ -- il y a un bouton + et un bouton nouvelle tache, si meme fonction garder bouton+ (supprimer "Nouvelle tâche" sur laptop si même action) -- bien differencier le fonctionnement de l'interface entre le mode laptop et smartphone -- conserve les todo terminé en base (déjà le cas, mais vérifier UI) +- **FAB + unique** : bouton circulaire "+" pour créer une tâche, visible sur mobile et laptop (position adaptée) — bouton "Nouvelle tâche" supprimé +- **Todos terminés conservés** : statut `done` en base, filtre "Terminé" dans le select du header pour les retrouver +- **Différenciation mobile/laptop** : mobile = groupes par domaine + swipe ; laptop = tableau avec filtres date, colonnes domaines/priorité/statut + +## En attente diff --git a/backend/app/main.py b/backend/app/main.py index ffc9207..c9d576f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,6 +1,7 @@ from contextlib import asynccontextmanager from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles from app.api.health import router as health_router from app.api.media import router as media_router from app.api.todos import router as todos_router @@ -29,3 +30,5 @@ app.include_router(health_router, prefix="/api") app.include_router(media_router, prefix="/api/media") app.include_router(todos_router, prefix="/api/todos") app.include_router(shopping_router, prefix="/api/shopping") + +app.mount("/media", StaticFiles(directory=str(settings.upload_path)), name="media") diff --git a/backend/app/schemas/shopping.py b/backend/app/schemas/shopping.py index e71088d..915f13a 100644 --- a/backend/app/schemas/shopping.py +++ b/backend/app/schemas/shopping.py @@ -38,6 +38,8 @@ class ProductCreate(BaseModel): price: Decimal | None = None quantity_per_unit: Decimal | None = None default_store_id: uuid.UUID | None = None + image_path: str | None = None + thumbnail_path: str | None = None class ProductUpdate(BaseModel): @@ -50,6 +52,8 @@ class ProductUpdate(BaseModel): price: Decimal | None = None quantity_per_unit: Decimal | None = None default_store_id: uuid.UUID | None = None + image_path: str | None = None + thumbnail_path: str | None = None class ProductResponse(BaseModel): @@ -65,6 +69,8 @@ class ProductResponse(BaseModel): quantity_per_unit: Decimal | None default_store_id: uuid.UUID | None frequency_score: int + image_path: str | None + thumbnail_path: str | None class ListItemCreate(BaseModel): diff --git a/frontend/nginx.conf b/frontend/nginx.conf index bcef841..a14b38b 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -18,6 +18,12 @@ server { proxy_set_header X-Real-IP $remote_addr; } + location /media/ { + proxy_pass http://backend:8000/media/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + location / { try_files $uri $uri/ /index.html; } diff --git a/frontend/src/api/shopping.ts b/frontend/src/api/shopping.ts index aab470a..b41b7f0 100644 --- a/frontend/src/api/shopping.ts +++ b/frontend/src/api/shopping.ts @@ -34,6 +34,8 @@ export interface Product { quantity_per_unit: string | null default_store_id: string | null frequency_score: number + image_path: string | null + thumbnail_path: string | null } export interface ProductCreate { @@ -46,6 +48,8 @@ export interface ProductCreate { price?: string quantity_per_unit?: string default_store_id?: string + image_path?: string + thumbnail_path?: string } export interface ProductUpdate { @@ -58,6 +62,8 @@ export interface ProductUpdate { price?: string quantity_per_unit?: string default_store_id?: string + image_path?: string + thumbnail_path?: string } export interface ShoppingItem { diff --git a/frontend/src/components/shopping/CatalogueModal.tsx b/frontend/src/components/shopping/CatalogueModal.tsx index 88da7df..e6a21aa 100644 --- a/frontend/src/components/shopping/CatalogueModal.tsx +++ b/frontend/src/components/shopping/CatalogueModal.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useRef } from 'react' import type { Product, ProductCreate, Store } from '../../api/shopping' import { searchProducts, createProduct, updateProduct, deleteProduct } from '../../api/shopping' import Modal from '../Modal' @@ -26,6 +26,8 @@ const emptyForm: ProductCreate = { name: '', brand: '', category: '', description: '', default_unit: '', barcode: '', price: '', quantity_per_unit: '', default_store_id: undefined, + image_path: undefined, + thumbnail_path: undefined, } export default function CatalogueModal({ stores, onClose }: CatalogueModalProps) { @@ -35,7 +37,9 @@ export default function CatalogueModal({ stores, onClose }: CatalogueModalProps) const [creating, setCreating] = useState(false) const [form, setForm] = useState(emptyForm) const [saving, setSaving] = useState(false) + const [uploading, setUploading] = useState(false) const [error, setError] = useState(null) + const photoRef = useRef(null) useEffect(() => { void loadProducts() @@ -67,6 +71,8 @@ export default function CatalogueModal({ stores, onClose }: CatalogueModalProps) price: p.price ?? '', quantity_per_unit: p.quantity_per_unit ?? '', default_store_id: p.default_store_id ?? undefined, + image_path: p.image_path ?? undefined, + thumbnail_path: p.thumbnail_path ?? undefined, }) setEditing(p) setCreating(false) @@ -90,6 +96,28 @@ export default function CatalogueModal({ stores, onClose }: CatalogueModalProps) price: f.price?.trim() || undefined, quantity_per_unit: f.quantity_per_unit?.trim() || undefined, default_store_id: f.default_store_id || undefined, + image_path: f.image_path || undefined, + thumbnail_path: f.thumbnail_path || undefined, + } + } + + async function handlePhotoChange(e: React.ChangeEvent) { + const file = e.target.files?.[0] + if (!file) return + setUploading(true) + setError(null) + try { + const fd = new FormData() + fd.append('file', file) + const res = await fetch('/api/media/upload?context=product', { method: 'POST', body: fd }) + if (!res.ok) throw new Error('Upload échoué') + const data = await res.json() as { file_path: string; thumbnail_path: string | null } + setForm(f => ({ ...f, image_path: data.file_path, thumbnail_path: data.thumbnail_path ?? undefined })) + } catch { + setError('Erreur lors de l\'upload photo') + } finally { + setUploading(false) + if (photoRef.current) photoRef.current.value = '' } } @@ -124,6 +152,7 @@ export default function CatalogueModal({ stores, onClose }: CatalogueModalProps) } const isFormOpen = creating || editing !== null + const previewSrc = form.thumbnail_path ?? form.image_path return ( @@ -134,6 +163,38 @@ export default function CatalogueModal({ stores, onClose }: CatalogueModalProps) {isFormOpen ? ( /* ── Formulaire création / édition ── */
+ {/* Zone photo */} +
+ {previewSrc ? ( + photo + ) : ( +
📷
+ )} + + + {previewSrc && ( + + )} +
+ setForm(f => ({ ...f, name: e.target.value }))} @@ -236,6 +297,15 @@ export default function CatalogueModal({ stores, onClose }: CatalogueModalProps) userSelect: 'none', }} > + {p.thumbnail_path || p.image_path ? ( + + ) : ( +
+ )}
{p.name} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index b3ef9e9..3e2a6ae 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -39,6 +39,7 @@ export default defineConfig({ server: { proxy: { '/api': { target: 'http://localhost:8000', changeOrigin: true }, + '/media': { target: 'http://localhost:8000', changeOrigin: true }, }, }, })