feat: Phase 4 — module Notes complet
Backend : - schemas/notes.py : NoteCreate/Update/Response + AttachmentResponse - api/notes.py : CRUD + FTS français (plainto_tsquery) + filtres rapides (has_photo/audio/gps/tag/category) + pièces jointes (image/audio) - main.py : enregistrement /api/notes Frontend : - api/notes.ts : fetchNotes/create/update/delete + add/deleteAttachment - NoteForm.tsx : titre, contenu, catégorie, tags CSV, GPS - NotesPage.tsx : liste mobile (chronologique) + grille laptop, FAB +, enregistrement audio inline (MediaRecorder), upload photo, filtres rapides Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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]
|
||||
@@ -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<Note[]> {
|
||||
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<Note[]>
|
||||
}
|
||||
|
||||
export async function createNote(data: NoteCreate): Promise<Note> {
|
||||
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<Note>
|
||||
}
|
||||
|
||||
export async function updateNote(id: string, data: Partial<NoteCreate>): Promise<Note> {
|
||||
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<Note>
|
||||
}
|
||||
|
||||
export async function deleteNote(id: string): Promise<void> {
|
||||
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<Note> {
|
||||
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<Note>
|
||||
}
|
||||
|
||||
export async function deleteAttachment(noteId: string, attId: string): Promise<void> {
|
||||
const res = await fetch(`${BASE}/${noteId}/attachments/${attId}`, { method: 'DELETE' })
|
||||
if (!res.ok) throw new Error('Erreur suppression pièce jointe')
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import type { Note, NoteCreate } from '../../api/notes'
|
||||
|
||||
interface NoteFormProps {
|
||||
initialValues?: Note
|
||||
onSubmit: (data: NoteCreate) => Promise<void>
|
||||
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<number | undefined>(initialValues?.gps_lat ?? undefined)
|
||||
const [gpsLon, setGpsLon] = useState<number | undefined>(initialValues?.gps_lon ?? undefined)
|
||||
const [gpsLoading, setGpsLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const contentRef = useRef<HTMLTextAreaElement>(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 (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{error && (
|
||||
<p style={{ color: 'var(--err)', fontSize: 13, fontFamily: 'var(--font-ui)', margin: 0 }}>{error}</p>
|
||||
)}
|
||||
|
||||
<input
|
||||
style={inputStyle}
|
||||
placeholder="Titre (optionnel)"
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
/>
|
||||
|
||||
<textarea
|
||||
ref={contentRef}
|
||||
style={{ ...inputStyle, minHeight: 120, resize: 'vertical' }}
|
||||
placeholder="Contenu *"
|
||||
value={content}
|
||||
onChange={e => setContent(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
||||
<select
|
||||
style={inputStyle}
|
||||
value={category}
|
||||
onChange={e => setCategory(e.target.value)}
|
||||
>
|
||||
<option value="">Catégorie</option>
|
||||
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
|
||||
<input
|
||||
style={inputStyle}
|
||||
placeholder="Tags (virgule)"
|
||||
value={tagInput}
|
||||
onChange={e => setTagInput(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* GPS */}
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGps}
|
||||
disabled={gpsLoading}
|
||||
style={{
|
||||
padding: '6px 14px',
|
||||
borderRadius: 8,
|
||||
border: '1px solid var(--bg-5)',
|
||||
background: gpsLat ? 'var(--info)' : 'var(--bg-4)',
|
||||
color: gpsLat ? '#fff' : 'var(--ink-2)',
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'var(--font-ui)',
|
||||
fontSize: 13,
|
||||
minHeight: 36,
|
||||
}}
|
||||
>
|
||||
{gpsLoading ? '…' : gpsLat ? '📍 GPS capturé' : '📍 Ajouter GPS'}
|
||||
</button>
|
||||
{gpsLat && (
|
||||
<>
|
||||
<span style={{ color: 'var(--ink-3)', fontSize: 11, fontFamily: 'var(--font-mono)' }}>
|
||||
{gpsLat.toFixed(5)}, {gpsLon?.toFixed(5)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setGpsLat(undefined); setGpsLon(undefined) }}
|
||||
style={{ background: 'transparent', border: 'none', color: 'var(--err)', cursor: 'pointer', fontSize: 14 }}
|
||||
>✕</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 4 }}>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
style={{ padding: '8px 16px', borderRadius: 8, border: '1px solid var(--bg-5)', background: 'transparent', color: 'var(--ink-2)', cursor: 'pointer', fontFamily: 'var(--font-ui)', minHeight: 40 }}
|
||||
>Annuler</button>
|
||||
<button
|
||||
onClick={() => void handleSubmit()}
|
||||
disabled={saving || !content.trim()}
|
||||
style={{ padding: '8px 20px', borderRadius: 8, border: 'none', background: 'var(--accent)', color: '#1d2021', cursor: 'pointer', fontFamily: 'var(--font-ui)', fontWeight: 600, minHeight: 40, opacity: saving ? 0.7 : 1 }}
|
||||
>{saving ? '…' : submitLabel}</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,367 @@
|
||||
export default function NotesPage() {
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import type { Note, NoteFilters } from '../api/notes'
|
||||
import { fetchNotes, createNote, updateNote, deleteNote, addAttachment, deleteAttachment } from '../api/notes'
|
||||
import Modal from '../components/Modal'
|
||||
import NoteForm from '../components/notes/NoteForm'
|
||||
|
||||
const noSelect: React.CSSProperties = { userSelect: 'none' }
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
background: 'var(--bg-3)',
|
||||
border: '1px solid var(--bg-5)',
|
||||
borderRadius: 8,
|
||||
padding: '6px 10px',
|
||||
color: 'var(--ink-1)',
|
||||
fontFamily: 'var(--font-ui)',
|
||||
fontSize: 13,
|
||||
}
|
||||
|
||||
function formatDate(iso: string) {
|
||||
return new Date(iso).toLocaleDateString('fr-FR', { day: '2-digit', month: 'short', year: 'numeric' })
|
||||
}
|
||||
|
||||
function NoteCard({ note, onEdit, onDelete, onAddPhoto, onAddAudio, onDeleteAtt }: {
|
||||
note: Note
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
onAddPhoto: (file: File) => void
|
||||
onAddAudio: (file: File) => void
|
||||
onDeleteAtt: (attId: string) => void
|
||||
}) {
|
||||
const photoRef = useRef<HTMLInputElement>(null)
|
||||
const audioRef = useRef<HTMLInputElement>(null)
|
||||
const [recording, setRecording] = useState(false)
|
||||
const recorderRef = useRef<MediaRecorder | null>(null)
|
||||
const chunksRef = useRef<Blob[]>([])
|
||||
|
||||
const images = note.attachments.filter(a => a.file_type === 'image')
|
||||
const audios = note.attachments.filter(a => a.file_type === 'audio')
|
||||
|
||||
async function startRecord() {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
const recorder = new MediaRecorder(stream)
|
||||
chunksRef.current = []
|
||||
recorder.ondataavailable = e => { if (e.data.size > 0) chunksRef.current.push(e.data) }
|
||||
recorder.onstop = () => {
|
||||
stream.getTracks().forEach(t => t.stop())
|
||||
const blob = new Blob(chunksRef.current, { type: 'audio/webm' })
|
||||
onAddAudio(new File([blob], 'enregistrement.webm', { type: 'audio/webm' }))
|
||||
}
|
||||
recorder.start()
|
||||
recorderRef.current = recorder
|
||||
setRecording(true)
|
||||
} catch {
|
||||
// micro non disponible
|
||||
}
|
||||
}
|
||||
|
||||
function stopRecord() {
|
||||
recorderRef.current?.stop()
|
||||
setRecording(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24, color: 'var(--ink-1)', fontFamily: 'var(--font-ui)' }}>
|
||||
<h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 8 }}>Notes</h1>
|
||||
<p style={{ color: 'var(--ink-3)' }}>Module en cours de développement — Phase 2b</p>
|
||||
<div className="glass" style={{ borderRadius: 10, padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{/* En-tête */}
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 8, ...noSelect }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{note.title && (
|
||||
<div style={{ color: 'var(--ink-1)', fontFamily: 'var(--font-ui)', fontWeight: 600, fontSize: 14, marginBottom: 2 }}>
|
||||
{note.title}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ color: 'var(--ink-2)', fontFamily: 'var(--font-ui)', fontSize: 13, lineHeight: 1.5, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
|
||||
{note.content.length > 200 ? note.content.slice(0, 200) + '…' : note.content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Photos */}
|
||||
{images.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{images.map(img => (
|
||||
<div key={img.id} style={{ position: 'relative' }}>
|
||||
<img
|
||||
src={`/media/${img.thumbnail_path ?? img.file_path}`}
|
||||
alt=""
|
||||
style={{ width: 72, height: 72, objectFit: 'cover', borderRadius: 6 }}
|
||||
/>
|
||||
<button
|
||||
onClick={() => onDeleteAtt(img.id)}
|
||||
style={{ position: 'absolute', top: 2, right: 2, background: 'rgba(0,0,0,0.6)', border: 'none', color: '#fff', borderRadius: '50%', width: 18, height: 18, cursor: 'pointer', fontSize: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0 }}
|
||||
>✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Audios */}
|
||||
{audios.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{audios.map(aud => (
|
||||
<div key={aud.id} style={{ display: 'flex', alignItems: 'center', gap: 6, background: 'var(--bg-4)', borderRadius: 6, padding: '4px 8px' }}>
|
||||
<audio src={`/media/${aud.file_path}`} controls style={{ height: 28, flex: 1 }} />
|
||||
<button
|
||||
onClick={() => onDeleteAtt(aud.id)}
|
||||
style={{ background: 'transparent', border: 'none', color: 'var(--err)', cursor: 'pointer', fontSize: 14, flexShrink: 0 }}
|
||||
>✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Méta */}
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center', ...noSelect }}>
|
||||
<span style={{ color: 'var(--ink-4)', fontSize: 11, fontFamily: 'var(--font-mono)' }}>{formatDate(note.created_at)}</span>
|
||||
{note.category && (
|
||||
<span style={{ background: 'var(--bg-4)', color: 'var(--info)', fontSize: 11, fontFamily: 'var(--font-ui)', borderRadius: 999, padding: '1px 7px' }}>{note.category}</span>
|
||||
)}
|
||||
{note.tags.map(t => (
|
||||
<span key={t} style={{ background: 'var(--bg-5)', color: 'var(--ink-3)', fontSize: 11, fontFamily: 'var(--font-ui)', borderRadius: 999, padding: '1px 7px' }}>{t}</span>
|
||||
))}
|
||||
{note.gps_lat && <span style={{ color: 'var(--ok)', fontSize: 11 }}>📍</span>}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<input ref={photoRef} type="file" accept="image/*" capture="environment" style={{ display: 'none' }} onChange={e => { const f = e.target.files?.[0]; if (f) onAddPhoto(f); e.target.value = '' }} />
|
||||
<button
|
||||
onClick={() => photoRef.current?.click()}
|
||||
title="Ajouter une photo"
|
||||
style={{ padding: '4px 10px', borderRadius: 6, border: '1px solid var(--bg-5)', background: 'var(--bg-4)', color: 'var(--ink-3)', cursor: 'pointer', fontSize: 13 }}
|
||||
>📷</button>
|
||||
|
||||
<input ref={audioRef} type="file" accept="audio/*" style={{ display: 'none' }} onChange={e => { const f = e.target.files?.[0]; if (f) onAddAudio(f); e.target.value = '' }} />
|
||||
<button
|
||||
onClick={recording ? stopRecord : startRecord}
|
||||
title={recording ? 'Arrêter l\'enregistrement' : 'Enregistrer un audio'}
|
||||
style={{ padding: '4px 10px', borderRadius: 6, border: '1px solid var(--bg-5)', background: recording ? 'var(--err)' : 'var(--bg-4)', color: recording ? '#fff' : 'var(--ink-3)', cursor: 'pointer', fontSize: 13 }}
|
||||
>{recording ? '⏹ Stop' : '🎤'}</button>
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
<button
|
||||
onClick={onEdit}
|
||||
style={{ padding: '4px 10px', borderRadius: 6, border: 'none', background: 'var(--bg-5)', color: 'var(--ink-2)', cursor: 'pointer', fontSize: 13 }}
|
||||
>✏️</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
style={{ padding: '4px 10px', borderRadius: 6, border: 'none', background: 'transparent', color: 'var(--err)', cursor: 'pointer', fontSize: 14 }}
|
||||
>✕</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function NotesPage() {
|
||||
const [notes, setNotes] = useState<Note[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editingNote, setEditingNote] = useState<Note | null>(null)
|
||||
const [filters, setFilters] = useState<NoteFilters>({})
|
||||
const [searchInput, setSearchInput] = useState('')
|
||||
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
setNotes(await fetchNotes(filters))
|
||||
} catch {
|
||||
setError('Erreur de chargement')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [filters])
|
||||
|
||||
useEffect(() => { void load() }, [load])
|
||||
|
||||
function handleSearchChange(val: string) {
|
||||
setSearchInput(val)
|
||||
if (searchTimer.current) clearTimeout(searchTimer.current)
|
||||
searchTimer.current = setTimeout(() => {
|
||||
setFilters(f => ({ ...f, q: val || undefined }))
|
||||
}, 300)
|
||||
}
|
||||
|
||||
async function handleCreate(data: Parameters<typeof createNote>[0]) {
|
||||
await createNote(data)
|
||||
setShowForm(false)
|
||||
void load()
|
||||
}
|
||||
|
||||
async function handleUpdate(id: string, data: Parameters<typeof updateNote>[1]) {
|
||||
await updateNote(id, data)
|
||||
setEditingNote(null)
|
||||
void load()
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
if (!confirm('Supprimer cette note ?')) return
|
||||
try {
|
||||
await deleteNote(id)
|
||||
void load()
|
||||
} catch {
|
||||
setError('Erreur lors de la suppression')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddPhoto(noteId: string, file: File) {
|
||||
try {
|
||||
await addAttachment(noteId, file)
|
||||
void load()
|
||||
} catch {
|
||||
setError('Erreur upload photo')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddAudio(noteId: string, file: File) {
|
||||
try {
|
||||
await addAttachment(noteId, file)
|
||||
void load()
|
||||
} catch {
|
||||
setError('Erreur upload audio')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteAtt(noteId: string, attId: string) {
|
||||
try {
|
||||
await deleteAttachment(noteId, attId)
|
||||
void load()
|
||||
} catch {
|
||||
setError('Erreur suppression pièce jointe')
|
||||
}
|
||||
}
|
||||
|
||||
const hasActiveFilters = filters.has_photo || filters.has_audio || filters.has_gps
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
{/* En-tête */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16, flexWrap: 'wrap' }}>
|
||||
<h1 style={{ color: 'var(--accent)', fontFamily: 'var(--font-mono)', margin: 0, flex: 1, minWidth: 100, ...noSelect }}>
|
||||
Notes
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Barre de recherche + filtres */}
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 12, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<input
|
||||
style={{ ...inputStyle, flex: 1, minWidth: 160 }}
|
||||
placeholder="Rechercher…"
|
||||
value={searchInput}
|
||||
onChange={e => handleSearchChange(e.target.value)}
|
||||
/>
|
||||
{/* Filtres rapides */}
|
||||
{(['📷 Photo', '🎤 Audio', '📍 GPS'] as const).map((label, i) => {
|
||||
const key = ['has_photo', 'has_audio', 'has_gps'][i] as keyof NoteFilters
|
||||
const active = filters[key] === true
|
||||
return (
|
||||
<button
|
||||
key={label}
|
||||
onClick={() => setFilters(f => ({ ...f, [key]: active ? undefined : true }))}
|
||||
style={{
|
||||
padding: '5px 12px', borderRadius: 999, border: 'none',
|
||||
background: active ? 'var(--accent)' : 'var(--bg-3)',
|
||||
color: active ? '#1d2021' : 'var(--ink-3)',
|
||||
cursor: 'pointer', fontFamily: 'var(--font-ui)', fontSize: 12,
|
||||
...noSelect,
|
||||
}}
|
||||
>{label}</button>
|
||||
)
|
||||
})}
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
onClick={() => setFilters(f => ({ ...f, has_photo: undefined, has_audio: undefined, has_gps: undefined }))}
|
||||
style={{ padding: '5px 10px', borderRadius: 999, border: 'none', background: 'transparent', color: 'var(--err)', cursor: 'pointer', fontSize: 12, ...noSelect }}
|
||||
>✕ Filtres</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p style={{ color: 'var(--err)', background: 'var(--bg-3)', borderRadius: 8, padding: '8px 12px', marginBottom: 12, fontFamily: 'var(--font-ui)', fontSize: 13 }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Modal création */}
|
||||
{showForm && (
|
||||
<Modal title="Nouvelle note" onClose={() => setShowForm(false)}>
|
||||
<NoteForm onSubmit={handleCreate} onCancel={() => setShowForm(false)} />
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* Modal édition */}
|
||||
{editingNote && (
|
||||
<Modal title="Modifier la note" onClose={() => setEditingNote(null)}>
|
||||
<NoteForm
|
||||
initialValues={editingNote}
|
||||
onSubmit={data => handleUpdate(editingNote.id, data)}
|
||||
onCancel={() => setEditingNote(null)}
|
||||
submitLabel="Enregistrer"
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<p style={{ color: 'var(--ink-3)', textAlign: 'center', padding: 24, ...noSelect }}>Chargement…</p>
|
||||
)}
|
||||
|
||||
{/* Mobile — liste chronologique */}
|
||||
<div className="block lg:hidden">
|
||||
{!loading && notes.length === 0 && (
|
||||
<p style={{ color: 'var(--ink-3)', textAlign: 'center', marginTop: 40, ...noSelect }}>Aucune note</p>
|
||||
)}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{notes.map(note => (
|
||||
<NoteCard
|
||||
key={note.id}
|
||||
note={note}
|
||||
onEdit={() => setEditingNote(note)}
|
||||
onDelete={() => void handleDelete(note.id)}
|
||||
onAddPhoto={f => void handleAddPhoto(note.id, f)}
|
||||
onAddAudio={f => void handleAddAudio(note.id, f)}
|
||||
onDeleteAtt={attId => void handleDeleteAtt(note.id, attId)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Laptop — grille */}
|
||||
<div className="hidden lg:block">
|
||||
{!loading && notes.length === 0 && (
|
||||
<p style={{ color: 'var(--ink-3)', textAlign: 'center', marginTop: 40, ...noSelect }}>Aucune note</p>
|
||||
)}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: 12 }}>
|
||||
{notes.map(note => (
|
||||
<NoteCard
|
||||
key={note.id}
|
||||
note={note}
|
||||
onEdit={() => setEditingNote(note)}
|
||||
onDelete={() => void handleDelete(note.id)}
|
||||
onAddPhoto={f => void handleAddPhoto(note.id, f)}
|
||||
onAddAudio={f => void handleAddAudio(note.id, f)}
|
||||
onDeleteAtt={attId => void handleDeleteAtt(note.id, attId)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* FAB */}
|
||||
<button
|
||||
className="fixed bottom-[72px] right-5 lg:bottom-6 lg:right-6"
|
||||
onClick={() => setShowForm(true)}
|
||||
aria-label="Nouvelle note"
|
||||
style={{
|
||||
width: 56, height: 56, borderRadius: '50%',
|
||||
background: 'var(--accent)', color: '#1d2021',
|
||||
border: 'none', fontSize: 28, cursor: 'pointer',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.5)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>+</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user