feat(notes): support vidéo + transcodage audio AAC universel

Audio : ffmpeg transcode toute entrée (webm/ogg/m4a) vers AAC/m4a
au moment de l'upload → lecture Safari iOS garantie.

Vidéo : nouveau save_video(), webm transcodé en H.264/mp4, mp4/quicktime
stocké directement. Lecteur <video> inline dans NoteCard.

Frontend :
- Bouton vidéo (fa-video) dans les actions de chaque note
- Icônes fa-image / fa-microphone / fa-video / fa-location-dot dans la méta
- Filtres rapides : Photo / Audio / Vidéo / GPS (avec icônes fa)
- Boutons actions migrés vers icônes Font Awesome
- client_max_body_size nginx : 15m → 200m pour les vidéos

v0.5.4

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 16:31:05 +02:00
parent 11b5c6c92e
commit 6c9ebcaab7
7 changed files with 152 additions and 34 deletions
+3 -1
View File
@@ -2,7 +2,7 @@ export interface NoteAttachment {
id: string
file_path: string | null
thumbnail_path: string | null
file_type: 'image' | 'audio' | null
file_type: 'image' | 'audio' | 'video' | null
original_name: string | null
created_at: string
}
@@ -34,6 +34,7 @@ export interface NoteFilters {
tag?: string
has_photo?: boolean
has_audio?: boolean
has_video?: boolean
has_gps?: boolean
}
@@ -46,6 +47,7 @@ export async function fetchNotes(filters: NoteFilters = {}): Promise<Note[]> {
if (filters.tag) params.set('tag', filters.tag)
if (filters.has_photo !== undefined) params.set('has_photo', String(filters.has_photo))
if (filters.has_audio !== undefined) params.set('has_audio', String(filters.has_audio))
if (filters.has_video !== undefined) params.set('has_video', String(filters.has_video))
if (filters.has_gps !== undefined) params.set('has_gps', String(filters.has_gps))
const res = await fetch(`${BASE}/?${params}`)
if (!res.ok) throw new Error('Erreur chargement notes')
+69 -15
View File
@@ -21,22 +21,25 @@ 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 }: {
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 photoRef = useRef<HTMLInputElement>(null)
const audioRef = useRef<HTMLInputElement>(null)
const videoRef = 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')
const videos = note.attachments.filter(a => a.file_type === 'video')
async function startRecord() {
try {
@@ -116,6 +119,26 @@ function NoteCard({ note, onEdit, onDelete, onAddPhoto, onAddAudio, onDeleteAtt
</div>
)}
{/* Vidéos */}
{videos.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{videos.map(vid => (
<div key={vid.id} style={{ position: 'relative' }}>
<video
src={`/media/${vid.file_path}`}
controls
playsInline
style={{ width: '100%', maxHeight: 220, borderRadius: 6, background: '#000', display: 'block' }}
/>
<button
onClick={() => onDeleteAtt(vid.id)}
style={{ position: 'absolute', top: 6, right: 6, background: 'rgba(0,0,0,0.65)', border: 'none', color: '#fff', borderRadius: '50%', width: 22, height: 22, cursor: 'pointer', fontSize: 11, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 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>
@@ -125,7 +148,12 @@ function NoteCard({ note, onEdit, onDelete, onAddPhoto, onAddAudio, onDeleteAtt
{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 != null && <i className="fa-solid fa-location-dot" style={{ color: 'var(--ok)', fontSize: 12 }} title={`${note.gps_lat?.toFixed(4)}, ${note.gps_lon?.toFixed(4)}`} />}
{images.length > 0 && <i className="fa-solid fa-image" style={{ color: 'var(--ink-4)', fontSize: 11 }} title={`${images.length} photo(s)`} />}
{audios.length > 0 && <i className="fa-solid fa-microphone" style={{ color: 'var(--ink-4)', fontSize: 11 }} title={`${audios.length} audio(s)`} />}
{videos.length > 0 && <i className="fa-solid fa-video" style={{ color: 'var(--ink-4)', fontSize: 11 }} title={`${videos.length} vidéo(s)`} />}
{note.gps_lat != null && (
<i className="fa-solid fa-location-dot" style={{ color: 'var(--ok)', fontSize: 12 }} title={`${note.gps_lat.toFixed(4)}, ${note.gps_lon?.toFixed(4)}`} />
)}
</div>
{/* Actions */}
@@ -135,24 +163,31 @@ function NoteCard({ note, onEdit, onDelete, onAddPhoto, onAddAudio, onDeleteAtt
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>
><i className="fa-solid fa-camera" /></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'}
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>
><i className={`fa-solid fa-${recording ? 'stop' : 'microphone'}`} /></button>
<input ref={videoRef} type="file" accept="video/*" style={{ display: 'none' }} onChange={e => { const f = e.target.files?.[0]; if (f) onAddVideo(f); e.target.value = '' }} />
<button
onClick={() => videoRef.current?.click()}
title="Ajouter une vidéo"
style={{ padding: '4px 10px', borderRadius: 6, border: '1px solid var(--bg-5)', background: 'var(--bg-4)', color: 'var(--ink-3)', cursor: 'pointer', fontSize: 13 }}
><i className="fa-solid fa-video" /></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>
><i className="fa-solid fa-pen" /></button>
<button
onClick={onDelete}
style={{ padding: '4px 10px', borderRadius: 6, border: 'none', background: 'transparent', color: 'var(--err)', cursor: 'pointer', fontSize: 14 }}
></button>
><i className="fa-solid fa-xmark" /></button>
</div>
</div>
)
@@ -248,6 +283,15 @@ export default function NotesPage() {
}
}
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)
@@ -257,7 +301,7 @@ export default function NotesPage() {
}
}
const hasActiveFilters = filters.has_photo || filters.has_audio || filters.has_gps
const hasActiveFilters = filters.has_photo || filters.has_audio || filters.has_video || filters.has_gps
return (
<div className="p-4">
@@ -277,28 +321,36 @@ export default function NotesPage() {
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
{([
{ 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 (
<button
key={label}
key={key}
onClick={() => setFilters(f => ({ ...f, [key]: active ? undefined : true }))}
style={{
padding: '5px 12px', borderRadius: 999, border: 'none',
padding: '5px 10px', 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,
display: 'flex', alignItems: 'center', gap: 5,
...noSelect,
}}
>{label}</button>
>
<i className={`fa-solid ${icon}`} style={{ fontSize: 11 }} />
{label}
</button>
)
})}
{hasActiveFilters && (
<button
onClick={() => setFilters(f => ({ ...f, has_photo: undefined, has_audio: undefined, has_gps: undefined }))}
onClick={() => setFilters(f => ({ ...f, has_photo: undefined, has_audio: undefined, has_video: undefined, has_gps: undefined }))}
style={{ padding: '5px 10px', borderRadius: 999, border: 'none', background: 'transparent', color: 'var(--err)', cursor: 'pointer', fontSize: 12, ...noSelect }}
> Filtres</button>
><i className="fa-solid fa-xmark" /> Filtres</button>
)}
</div>
@@ -345,6 +397,7 @@ export default function NotesPage() {
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)}
/>
))}
@@ -365,6 +418,7 @@ export default function NotesPage() {
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)}
/>
))}