Files
home_hub/frontend/src/pages/NotesPage.tsx
T
gilles fdeb747f38 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>
2026-05-25 06:49:46 +02:00

368 lines
14 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}