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,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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user