diff --git a/backend/app/api/notes.py b/backend/app/api/notes.py new file mode 100644 index 0000000..79c45af --- /dev/null +++ b/backend/app/api/notes.py @@ -0,0 +1,173 @@ +import uuid +from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File +from fastapi.responses import Response +from sqlalchemy import select, text, and_ +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.core.database import get_session +from app.models.notes import NoteItem, NoteAttachment +from app.schemas.notes import NoteCreate, NoteUpdate, NoteResponse +from app.services.media import save_image, save_audio, delete_media, ALLOWED_IMAGE_TYPES, ALLOWED_AUDIO_TYPES + +router = APIRouter() + + +@router.get("/", response_model=list[NoteResponse]) +async def list_notes( + q: str | None = Query(default=None), + category: str | None = Query(default=None), + tag: str | None = Query(default=None), + has_photo: bool | None = Query(default=None), + has_audio: bool | None = Query(default=None), + has_gps: bool | None = Query(default=None), + session: AsyncSession = Depends(get_session), +): + stmt = ( + select(NoteItem) + .options(selectinload(NoteItem.attachments)) + .order_by(NoteItem.created_at.desc()) + ) + + conditions = [] + if category: + conditions.append(NoteItem.category == category) + if tag: + conditions.append(NoteItem.tags.contains([tag])) + if has_gps is True: + conditions.append(NoteItem.gps_lat.isnot(None)) + if has_gps is False: + conditions.append(NoteItem.gps_lat.is_(None)) + + if q: + conditions.append( + text( + "to_tsvector('french', coalesce(notes.items.title,'') || ' ' || notes.items.content) " + "@@ plainto_tsquery('french', :q)" + ).bindparams(q=q) + ) + + if conditions: + stmt = stmt.where(and_(*conditions)) + + result = await session.execute(stmt) + notes = result.scalars().all() + + if has_photo is not None: + notes = [ + n for n in notes + if has_photo == any(a.file_type == "image" for a in n.attachments) + ] + if has_audio is not None: + notes = [ + n for n in notes + if has_audio == any(a.file_type == "audio" for a in n.attachments) + ] + + return notes + + +@router.post("/", response_model=NoteResponse, status_code=201) +async def create_note(payload: NoteCreate, session: AsyncSession = Depends(get_session)): + note = NoteItem(**payload.model_dump()) + session.add(note) + await session.flush() + await session.refresh(note, ["attachments"]) + await session.commit() + await session.refresh(note, ["attachments"]) + return note + + +@router.patch("/{note_id}", response_model=NoteResponse) +async def update_note( + note_id: uuid.UUID, + payload: NoteUpdate, + session: AsyncSession = Depends(get_session), +): + stmt = ( + select(NoteItem) + .where(NoteItem.id == note_id) + .options(selectinload(NoteItem.attachments)) + ) + result = await session.execute(stmt) + note = result.scalar_one_or_none() + if not note: + raise HTTPException(404, "Note introuvable") + for field, value in payload.model_dump(exclude_none=True).items(): + setattr(note, field, value) + await session.commit() + await session.refresh(note, ["attachments"]) + return note + + +@router.delete("/{note_id}", status_code=204) +async def delete_note(note_id: uuid.UUID, session: AsyncSession = Depends(get_session)): + note = await session.get(NoteItem, note_id) + if not note: + raise HTTPException(404, "Note introuvable") + await session.delete(note) + await session.commit() + return Response(status_code=204) + + +@router.post("/{note_id}/attachments", response_model=NoteResponse, status_code=201) +async def add_attachment( + note_id: uuid.UUID, + file: UploadFile = File(...), + session: AsyncSession = Depends(get_session), +): + stmt = ( + select(NoteItem) + .where(NoteItem.id == note_id) + .options(selectinload(NoteItem.attachments)) + ) + result = await session.execute(stmt) + note = result.scalar_one_or_none() + if not note: + raise HTTPException(404, "Note introuvable") + + if file.content_type in ALLOWED_IMAGE_TYPES: + media = await save_image(file, context="note") + file_type = "image" + elif file.content_type in ALLOWED_AUDIO_TYPES: + media = await save_audio(file) + file_type = "audio" + else: + raise HTTPException(400, f"Type non supporté : {file.content_type}") + + att = NoteAttachment( + note_id=note_id, + file_path=media["file_path"], + thumbnail_path=media.get("thumbnail_path"), + file_type=file_type, + original_name=file.filename, + ) + session.add(att) + await session.commit() + await session.refresh(note, ["attachments"]) + return note + + +@router.delete("/{note_id}/attachments/{att_id}", status_code=204) +async def delete_attachment( + note_id: uuid.UUID, + att_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +): + stmt = select(NoteAttachment).where( + NoteAttachment.id == att_id, + NoteAttachment.note_id == note_id, + ) + result = await session.execute(stmt) + att = result.scalar_one_or_none() + if not att: + raise HTTPException(404, "Pièce jointe introuvable") + + delete_media( + file_id=str(att_id), + file_path=att.file_path or "", + thumbnail_path=att.thumbnail_path, + ) + await session.delete(att) + await session.commit() + return Response(status_code=204) diff --git a/backend/app/main.py b/backend/app/main.py index c9d576f..15635d1 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -4,6 +4,7 @@ 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.notes import router as notes_router from app.api.todos import router as todos_router from app.api.shopping import router as shopping_router from app.core.config import settings @@ -28,6 +29,7 @@ app.add_middleware( app.include_router(health_router, prefix="/api") app.include_router(media_router, prefix="/api/media") +app.include_router(notes_router, prefix="/api/notes") app.include_router(todos_router, prefix="/api/todos") app.include_router(shopping_router, prefix="/api/shopping") diff --git a/backend/app/schemas/notes.py b/backend/app/schemas/notes.py new file mode 100644 index 0000000..f34450b --- /dev/null +++ b/backend/app/schemas/notes.py @@ -0,0 +1,45 @@ +import uuid +from datetime import datetime +from decimal import Decimal +from pydantic import BaseModel, ConfigDict + + +class AttachmentResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: uuid.UUID + file_path: str | None + thumbnail_path: str | None + file_type: str | None + original_name: str | None + created_at: datetime + + +class NoteCreate(BaseModel): + title: str | None = None + content: str + category: str | None = None + tags: list[str] = [] + gps_lat: Decimal | None = None + gps_lon: Decimal | None = None + + +class NoteUpdate(BaseModel): + title: str | None = None + content: str | None = None + category: str | None = None + tags: list[str] | None = None + gps_lat: Decimal | None = None + gps_lon: Decimal | None = None + + +class NoteResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: uuid.UUID + title: str | None + content: str + category: str | None + tags: list[str] + gps_lat: Decimal | None + gps_lon: Decimal | None + created_at: datetime + attachments: list[AttachmentResponse] diff --git a/frontend/src/api/notes.ts b/frontend/src/api/notes.ts new file mode 100644 index 0000000..8b0a667 --- /dev/null +++ b/frontend/src/api/notes.ts @@ -0,0 +1,91 @@ +export interface NoteAttachment { + id: string + file_path: string | null + thumbnail_path: string | null + file_type: 'image' | 'audio' | null + original_name: string | null + created_at: string +} + +export interface Note { + id: string + title: string | null + content: string + category: string | null + tags: string[] + gps_lat: number | null + gps_lon: number | null + created_at: string + attachments: NoteAttachment[] +} + +export interface NoteCreate { + title?: string + content: string + category?: string + tags?: string[] + gps_lat?: number + gps_lon?: number +} + +export interface NoteFilters { + q?: string + category?: string + tag?: string + has_photo?: boolean + has_audio?: boolean + has_gps?: boolean +} + +const BASE = '/api/notes' + +export async function fetchNotes(filters: NoteFilters = {}): Promise { + const params = new URLSearchParams() + if (filters.q) params.set('q', filters.q) + if (filters.category) params.set('category', filters.category) + if (filters.tag) params.set('tag', filters.tag) + if (filters.has_photo !== undefined) params.set('has_photo', String(filters.has_photo)) + if (filters.has_audio !== undefined) params.set('has_audio', String(filters.has_audio)) + if (filters.has_gps !== undefined) params.set('has_gps', String(filters.has_gps)) + const res = await fetch(`${BASE}/?${params}`) + if (!res.ok) throw new Error('Erreur chargement notes') + return res.json() as Promise +} + +export async function createNote(data: NoteCreate): Promise { + const res = await fetch(`${BASE}/`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }) + if (!res.ok) throw new Error('Erreur création note') + return res.json() as Promise +} + +export async function updateNote(id: string, data: Partial): Promise { + const res = await fetch(`${BASE}/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }) + if (!res.ok) throw new Error('Erreur mise à jour note') + return res.json() as Promise +} + +export async function deleteNote(id: string): Promise { + const res = await fetch(`${BASE}/${id}`, { method: 'DELETE' }) + if (!res.ok) throw new Error('Erreur suppression note') +} + +export async function addAttachment(noteId: string, file: File): Promise { + const fd = new FormData() + fd.append('file', file) + const res = await fetch(`${BASE}/${noteId}/attachments`, { method: 'POST', body: fd }) + if (!res.ok) throw new Error('Erreur upload pièce jointe') + return res.json() as Promise +} + +export async function deleteAttachment(noteId: string, attId: string): Promise { + const res = await fetch(`${BASE}/${noteId}/attachments/${attId}`, { method: 'DELETE' }) + if (!res.ok) throw new Error('Erreur suppression pièce jointe') +} diff --git a/frontend/src/components/notes/NoteForm.tsx b/frontend/src/components/notes/NoteForm.tsx new file mode 100644 index 0000000..b599e0f --- /dev/null +++ b/frontend/src/components/notes/NoteForm.tsx @@ -0,0 +1,161 @@ +import { useState, useRef } from 'react' +import type { Note, NoteCreate } from '../../api/notes' + +interface NoteFormProps { + initialValues?: Note + onSubmit: (data: NoteCreate) => Promise + onCancel: () => void + submitLabel?: string +} + +const CATEGORIES = ['personnel', 'travail', 'idée', 'recette', 'voyage', 'santé', 'autre'] + +const inputStyle: React.CSSProperties = { + width: '100%', + background: 'var(--bg-4)', + border: '1px solid var(--bg-5)', + borderRadius: 8, + padding: '8px 12px', + color: 'var(--ink-1)', + fontFamily: 'var(--font-ui)', + fontSize: 14, + boxSizing: 'border-box', +} + +export default function NoteForm({ initialValues, onSubmit, onCancel, submitLabel = 'Créer' }: NoteFormProps) { + const [title, setTitle] = useState(initialValues?.title ?? '') + const [content, setContent] = useState(initialValues?.content ?? '') + const [category, setCategory] = useState(initialValues?.category ?? '') + const [tagInput, setTagInput] = useState(initialValues?.tags.join(', ') ?? '') + const [gpsLat, setGpsLat] = useState(initialValues?.gps_lat ?? undefined) + const [gpsLon, setGpsLon] = useState(initialValues?.gps_lon ?? undefined) + const [gpsLoading, setGpsLoading] = useState(false) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + const contentRef = useRef(null) + + function parseTags(raw: string): string[] { + return raw.split(',').map(t => t.trim()).filter(Boolean) + } + + function handleGps() { + if (!navigator.geolocation) return + setGpsLoading(true) + navigator.geolocation.getCurrentPosition( + pos => { + setGpsLat(pos.coords.latitude) + setGpsLon(pos.coords.longitude) + setGpsLoading(false) + }, + () => setGpsLoading(false), + { timeout: 8000 }, + ) + } + + async function handleSubmit() { + if (!content.trim()) return + setSaving(true) + setError(null) + try { + await onSubmit({ + title: title.trim() || undefined, + content: content.trim(), + category: category || undefined, + tags: parseTags(tagInput), + gps_lat: gpsLat, + gps_lon: gpsLon, + }) + } catch { + setError('Erreur lors de la sauvegarde') + setSaving(false) + } + } + + return ( +
+ {error && ( +

{error}

+ )} + + setTitle(e.target.value)} + /> + +