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:
2026-05-25 06:49:46 +02:00
parent c4634b5a27
commit fdeb747f38
6 changed files with 835 additions and 4 deletions
+161
View File
@@ -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>
)
}