import { useState, useEffect, useCallback, useRef } from 'react' import { useServerEvents } from '../hooks/useServerEvents' 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' import { useActionButton } from '../contexts/ActionButtonContext' 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, } const actionBtnStyle: React.CSSProperties = { padding: '5px 10px', borderRadius: 6, border: '1px solid var(--bg-5)', background: 'var(--bg-4)', color: 'var(--ink-3)', cursor: 'pointer', fontSize: 13, minHeight: 32, display: 'flex', alignItems: 'center', justifyContent: 'center', } function formatDate(iso: string) { return new Date(iso).toLocaleDateString('fr-FR', { day: '2-digit', month: 'short', year: 'numeric' }) } // Formate le texte inline : **gras**, *italique*, `code` function inlineFmt(text: string): React.ReactNode { const parts = text.split(/(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/) return ( <> {parts.map((p, i) => { if (p.startsWith('**') && p.endsWith('**')) return {p.slice(2, -2)} if (p.startsWith('*') && p.endsWith('*')) return {p.slice(1, -1)} if (p.startsWith('`') && p.endsWith('`')) return {p.slice(1, -1)} return p || null })} ) } // Renderer pseudo-markdown ligne par ligne function renderMarkdown(text: string): React.ReactNode { const lines = text.split('\n') const nodes: React.ReactNode[] = [] let i = 0 while (i < lines.length) { const line = lines[i] if (line.startsWith('```')) { const lang = line.slice(3).trim() const code: string[] = [] i++ while (i < lines.length && !lines[i].startsWith('```')) { code.push(lines[i]); i++ } nodes.push(
          {lang && 
{lang}
} {code.join('\n')}
) } else if (line.startsWith('# ')) { nodes.push(
{inlineFmt(line.slice(2))}
) } else if (line.startsWith('## ')) { nodes.push(
{inlineFmt(line.slice(3))}
) } else if (line.startsWith('### ')) { nodes.push(
{inlineFmt(line.slice(4))}
) } else if (line.startsWith('- ') || line.startsWith('* ')) { nodes.push(
{inlineFmt(line.slice(2))}
) } else if (/^\d+\.\s/.test(line)) { const m = line.match(/^(\d+)\.\s(.*)/) nodes.push(
{m?.[1]}. {inlineFmt(m?.[2] ?? '')}
) } else if (line.startsWith('> ')) { nodes.push(
{inlineFmt(line.slice(2))}
) } else if (line === '---' || line === '***') { nodes.push(
) } else if (line.trim() === '') { nodes.push(
) } else { nodes.push(
{inlineFmt(line)}
) } i++ } return <>{nodes} } type NoteState = 'semi' | 'expanded' | 'collapsed' function NoteCard({ note, onEdit, onDelete, onAddPhoto, onAddAudio, onAddVideo, onDeleteAtt }: { note: Note onEdit: () => void onDelete: () => void onAddPhoto: (file: File) => void onAddAudio: (file: File) => void onAddVideo: (file: File) => void onDeleteAtt: (attId: string) => void }) { const [state, setState] = useState('semi') const photoRef = useRef(null) const audioRef = useRef(null) const videoRef = useRef(null) const [recording, setRecording] = useState(false) const recorderRef = useRef(null) const chunksRef = useRef([]) const images = note.attachments.filter(a => a.file_type === 'image') const audios = note.attachments.filter(a => a.file_type === 'audio') const videos = note.attachments.filter(a => a.file_type === 'video') function cycleState() { setState(s => s === 'semi' ? 'expanded' : s === 'expanded' ? 'collapsed' : 'semi') } const stateIcon = state === 'semi' ? 'fa-chevron-down' : state === 'expanded' ? 'fa-minus' : 'fa-chevron-right' const stateTitle = state === 'semi' ? 'Tout afficher' : state === 'expanded' ? 'Réduire' : 'Développer' async function startRecord() { try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }) const mimeType = ['audio/webm', 'audio/mp4', 'audio/ogg'].find(t => MediaRecorder.isTypeSupported(t)) ?? '' const recorder = mimeType ? new MediaRecorder(stream, { mimeType }) : 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 type = recorder.mimeType || mimeType || 'audio/webm' const ext = type.includes('mp4') ? 'm4a' : 'webm' const blob = new Blob(chunksRef.current, { type }) onAddAudio(new File([blob], `enregistrement.${ext}`, { type })) } recorder.start() recorderRef.current = recorder setRecording(true) } catch { /* micro non disponible */ } } function stopRecord() { recorderRef.current?.stop() setRecording(false) } const toggleBtn = ( ) const metaLine = (
{formatDate(note.created_at)} {note.category && {note.category}} {note.tags.map(t => {t})} {images.length > 0 && } {audios.length > 0 && } {videos.length > 0 && } {note.gps_lat != null && ( )}
) const mediaSection = ( <> {images.length > 0 && (
{images.map(img => (
))}
)} {audios.length > 0 && (
{audios.map(aud => (
))}
)} {videos.length > 0 && (
{videos.map(vid => (
))}
)} ) const actionButtons = (
{ const f = e.target.files?.[0]; if (f) onAddPhoto(f); e.target.value = '' }} /> { const f = e.target.files?.[0]; if (f) onAddAudio(f); e.target.value = '' }} /> { const f = e.target.files?.[0]; if (f) onAddVideo(f); e.target.value = '' }} />
) // ─── COLLAPSED ─────────────────────────────────────────────────────────────── if (state === 'collapsed') { return (
{note.title || note.content.slice(0, 60).replace(/\n/g, ' ')} {formatDate(note.created_at)}
{toggleBtn}
) } // ─── SEMI (défaut) ─────────────────────────────────────────────────────────── if (state === 'semi') { return (
{note.title && (
{note.title}
)}
{note.content}
{toggleBtn}
{metaLine} {actionButtons}
) } // ─── EXPANDED ──────────────────────────────────────────────────────────────── return (
{note.title && (
{note.title}
)}
{toggleBtn}
{renderMarkdown(note.content)}
{mediaSection} {metaLine} {actionButtons}
) } // ─── PAGE ───────────────────────────────────────────────────────────────────── export default function NotesPage() { const [notes, setNotes] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [showForm, setShowForm] = useState(false) const [editingNote, setEditingNote] = useState(null) const [filters, setFilters] = useState({}) const [searchInput, setSearchInput] = useState('') const searchTimer = useRef | null>(null) const { setActionButton } = useActionButton() useEffect(() => { setActionButton( ) return () => setActionButton(null) }, [setActionButton]) 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]) useServerEvents({ notes_changed: () => void 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[0]) { await createNote(data) setShowForm(false) void load() } async function handleUpdate(id: string, data: Parameters[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 handleAddVideo(noteId: string, file: File) { try { await addAttachment(noteId, file); void load() } catch { setError('Erreur upload vidéo') } } 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_video || filters.has_gps const noteGrid = (cols: string) => (
{notes.map(note => ( setEditingNote(note)} onDelete={() => void handleDelete(note.id)} onAddPhoto={f => void handleAddPhoto(note.id, f)} onAddAudio={f => void handleAddAudio(note.id, f)} onAddVideo={f => void handleAddVideo(note.id, f)} onDeleteAtt={attId => void handleDeleteAtt(note.id, attId)} /> ))}
) return (

Notes

{/* Bouton ajout visible sur laptop uniquement */}
{/* Barre recherche + filtres */}
handleSearchChange(e.target.value)} /> {([ { key: 'has_photo', icon: 'fa-image', label: 'Photo' }, { key: 'has_audio', icon: 'fa-microphone', label: 'Audio' }, { key: 'has_video', icon: 'fa-video', label: 'Vidéo' }, { key: 'has_gps', icon: 'fa-location-dot', label: 'GPS' }, ] as const).map(({ key, icon, label }) => { const active = filters[key] === true return ( ) })} {hasActiveFilters && ( )}
{error && (

{error}

)} {showForm && ( setShowForm(false)}> setShowForm(false)} /> )} {editingNote && ( setEditingNote(null)}> handleUpdate(editingNote.id, data)} onCancel={() => setEditingNote(null)} submitLabel="Enregistrer" /> )} {loading &&

Chargement…

} {!loading && notes.length === 0 && (

Aucune note

)}
{noteGrid('1fr')}
{noteGrid('repeat(3, 1fr)')}
) }