fdeb747f38
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>
368 lines
14 KiB
TypeScript
368 lines
14 KiB
TypeScript
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 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>
|
||
)
|
||
}
|