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
co-authored by Claude Sonnet 4.6
parent 11b5c6c92e
commit 6c9ebcaab7
7 changed files with 152 additions and 34 deletions
+1 -1
View File
@@ -3,7 +3,7 @@ FROM python:3.12-slim
WORKDIR /app WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
libpq-dev gcc postgresql-client \ libpq-dev gcc postgresql-client ffmpeg \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY requirements.txt . COPY requirements.txt .
+9 -9
View File
@@ -9,7 +9,7 @@ from app.core.database import get_session
from app.core.redis import enqueue from app.core.redis import enqueue
from app.models.notes import NoteItem, NoteAttachment from app.models.notes import NoteItem, NoteAttachment
from app.schemas.notes import NoteCreate, NoteUpdate, NoteResponse from app.schemas.notes import NoteCreate, NoteUpdate, NoteResponse
from app.services.media import save_image, save_audio, delete_media, ALLOWED_IMAGE_TYPES, ALLOWED_AUDIO_PREFIXES from app.services.media import save_image, save_audio, save_video, delete_media, ALLOWED_IMAGE_TYPES, ALLOWED_AUDIO_PREFIXES, ALLOWED_VIDEO_TYPES
router = APIRouter() router = APIRouter()
@@ -21,6 +21,7 @@ async def list_notes(
tag: str | None = Query(default=None), tag: str | None = Query(default=None),
has_photo: bool | None = Query(default=None), has_photo: bool | None = Query(default=None),
has_audio: bool | None = Query(default=None), has_audio: bool | None = Query(default=None),
has_video: bool | None = Query(default=None),
has_gps: bool | None = Query(default=None), has_gps: bool | None = Query(default=None),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
): ):
@@ -55,15 +56,11 @@ async def list_notes(
notes = result.scalars().all() notes = result.scalars().all()
if has_photo is not None: if has_photo is not None:
notes = [ notes = [n for n in notes if has_photo == any(a.file_type == "image" for a in n.attachments)]
n for n in notes
if has_photo == any(a.file_type == "image" for a in n.attachments)
]
if has_audio is not None: if has_audio is not None:
notes = [ notes = [n for n in notes if has_audio == any(a.file_type == "audio" for a in n.attachments)]
n for n in notes if has_video is not None:
if has_audio == any(a.file_type == "audio" for a in n.attachments) notes = [n for n in notes if has_video == any(a.file_type == "video" for a in n.attachments)]
]
return notes return notes
@@ -137,6 +134,9 @@ async def add_attachment(
elif ct in ALLOWED_AUDIO_PREFIXES: elif ct in ALLOWED_AUDIO_PREFIXES:
media = await save_audio(file) media = await save_audio(file)
file_type = "audio" file_type = "audio"
elif ct in ALLOWED_VIDEO_TYPES:
media = await save_video(file)
file_type = "video"
else: else:
raise HTTPException(400, f"Type non supporté : {file.content_type}") raise HTTPException(400, f"Type non supporté : {file.content_type}")
+68 -6
View File
@@ -1,3 +1,4 @@
import asyncio
import io import io
import uuid import uuid
from pathlib import Path from pathlib import Path
@@ -13,7 +14,8 @@ ALLOWED_IMAGE_TYPES = {
"image/jpeg", "image/jpg", "image/png", "image/webp", "image/svg+xml", "image/jpeg", "image/jpg", "image/png", "image/webp", "image/svg+xml",
"image/heic", "image/heif", "image/heic", "image/heif",
} }
ALLOWED_AUDIO_PREFIXES = {"audio/webm", "audio/mp4", "audio/ogg", "audio/x-m4a"} ALLOWED_AUDIO_PREFIXES = {"audio/webm", "audio/mp4", "audio/ogg", "audio/x-m4a", "audio/aac"}
ALLOWED_VIDEO_TYPES = {"video/mp4", "video/webm", "video/quicktime", "video/x-m4v", "video/3gpp"}
MAX_ORIG_SIZE = (500, 500) MAX_ORIG_SIZE = (500, 500)
@@ -48,7 +50,6 @@ async def save_image(file: UploadFile, context: str = "note") -> dict:
orig_path = orig_dir / f"{file_id}.webp" orig_path = orig_dir / f"{file_id}.webp"
img = Image.open(io.BytesIO(content)).convert("RGB") img = Image.open(io.BytesIO(content)).convert("RGB")
# Redimensionne l'original à 500×500 max en conservant l'aspect ratio
img.thumbnail(MAX_ORIG_SIZE, Image.LANCZOS) img.thumbnail(MAX_ORIG_SIZE, Image.LANCZOS)
img.save(orig_path, "WEBP", quality=85) img.save(orig_path, "WEBP", quality=85)
@@ -78,18 +79,79 @@ async def save_audio(file: UploadFile) -> dict:
audio_dir = UPLOAD_DIR / "audio" audio_dir = UPLOAD_DIR / "audio"
audio_dir.mkdir(parents=True, exist_ok=True) audio_dir.mkdir(parents=True, exist_ok=True)
ext = ".webm" if "webm" in (file.content_type or "") else ".m4a" raw_ext = ".ogg" if "ogg" in ct else (".webm" if "webm" in ct else ".m4a")
audio_path = audio_dir / f"{file_id}{ext}" raw_path = audio_dir / f"{file_id}_raw{raw_ext}"
audio_path.write_bytes(await file.read()) raw_path.write_bytes(await file.read())
# Transcode vers AAC/mp4 pour lecture universelle (Safari iOS, Chrome, Firefox)
out_path = audio_dir / f"{file_id}.m4a"
proc = await asyncio.create_subprocess_exec(
"ffmpeg", "-i", str(raw_path),
"-c:a", "aac", "-b:a", "128k", "-movflags", "+faststart", "-y", str(out_path),
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
await proc.communicate()
if out_path.exists():
raw_path.unlink(missing_ok=True)
final_path = out_path
else:
# ffmpeg indisponible ou échec — conserver le fichier brut
final_path = audio_dir / f"{file_id}{raw_ext}"
raw_path.rename(final_path)
return { return {
"file_id": file_id, "file_id": file_id,
"file_path": str(audio_path.relative_to(UPLOAD_DIR)), "file_path": str(final_path.relative_to(UPLOAD_DIR)),
"thumbnail_path": None, "thumbnail_path": None,
"file_type": "audio", "file_type": "audio",
} }
async def save_video(file: UploadFile) -> dict:
ct = (file.content_type or "").lower().split(";")[0].strip()
if ct not in ALLOWED_VIDEO_TYPES:
raise HTTPException(status_code=400, detail=f"Format vidéo non supporté : {file.content_type}")
file_id = str(uuid.uuid4())
video_dir = UPLOAD_DIR / "videos"
video_dir.mkdir(parents=True, exist_ok=True)
content = await file.read()
if "webm" in ct:
# Transcode webm → H.264/mp4 pour Safari iOS
raw_path = video_dir / f"{file_id}_raw.webm"
raw_path.write_bytes(content)
out_path = video_dir / f"{file_id}.mp4"
proc = await asyncio.create_subprocess_exec(
"ffmpeg", "-i", str(raw_path),
"-c:v", "libx264", "-crf", "28", "-preset", "fast",
"-c:a", "aac", "-b:a", "128k", "-movflags", "+faststart", "-y", str(out_path),
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
await proc.communicate()
if out_path.exists():
raw_path.unlink(missing_ok=True)
final_path = out_path
else:
final_path = video_dir / f"{file_id}.webm"
raw_path.rename(final_path)
else:
# mp4/quicktime : déjà compatible, stockage direct
final_path = video_dir / f"{file_id}.mp4"
final_path.write_bytes(content)
return {
"file_id": file_id,
"file_path": str(final_path.relative_to(UPLOAD_DIR)),
"thumbnail_path": None,
"file_type": "video",
}
def delete_media(file_id: str, file_path: str, thumbnail_path: str | None = None) -> None: def delete_media(file_id: str, file_path: str, thumbnail_path: str | None = None) -> None:
(UPLOAD_DIR / file_path).unlink(missing_ok=True) (UPLOAD_DIR / file_path).unlink(missing_ok=True)
if thumbnail_path: if thumbnail_path:
+1 -1
View File
@@ -4,7 +4,7 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
client_max_body_size 15m; client_max_body_size 200m;
gzip on; gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml image/svg+xml; gzip_types text/plain text/css application/json application/javascript text/xml application/xml image/svg+xml;
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "homehub-frontend", "name": "homehub-frontend",
"private": true, "private": true,
"version": "0.5.3", "version": "0.5.4",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+3 -1
View File
@@ -2,7 +2,7 @@ export interface NoteAttachment {
id: string id: string
file_path: string | null file_path: string | null
thumbnail_path: string | null thumbnail_path: string | null
file_type: 'image' | 'audio' | null file_type: 'image' | 'audio' | 'video' | null
original_name: string | null original_name: string | null
created_at: string created_at: string
} }
@@ -34,6 +34,7 @@ export interface NoteFilters {
tag?: string tag?: string
has_photo?: boolean has_photo?: boolean
has_audio?: boolean has_audio?: boolean
has_video?: boolean
has_gps?: 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.tag) params.set('tag', filters.tag)
if (filters.has_photo !== undefined) params.set('has_photo', String(filters.has_photo)) 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_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)) if (filters.has_gps !== undefined) params.set('has_gps', String(filters.has_gps))
const res = await fetch(`${BASE}/?${params}`) const res = await fetch(`${BASE}/?${params}`)
if (!res.ok) throw new Error('Erreur chargement notes') 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' }) 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 note: Note
onEdit: () => void onEdit: () => void
onDelete: () => void onDelete: () => void
onAddPhoto: (file: File) => void onAddPhoto: (file: File) => void
onAddAudio: (file: File) => void onAddAudio: (file: File) => void
onAddVideo: (file: File) => void
onDeleteAtt: (attId: string) => void onDeleteAtt: (attId: string) => void
}) { }) {
const photoRef = useRef<HTMLInputElement>(null) const photoRef = useRef<HTMLInputElement>(null)
const audioRef = useRef<HTMLInputElement>(null) const audioRef = useRef<HTMLInputElement>(null)
const videoRef = useRef<HTMLInputElement>(null)
const [recording, setRecording] = useState(false) const [recording, setRecording] = useState(false)
const recorderRef = useRef<MediaRecorder | null>(null) const recorderRef = useRef<MediaRecorder | null>(null)
const chunksRef = useRef<Blob[]>([]) const chunksRef = useRef<Blob[]>([])
const images = note.attachments.filter(a => a.file_type === 'image') const images = note.attachments.filter(a => a.file_type === 'image')
const audios = note.attachments.filter(a => a.file_type === 'audio') const audios = note.attachments.filter(a => a.file_type === 'audio')
const videos = note.attachments.filter(a => a.file_type === 'video')
async function startRecord() { async function startRecord() {
try { try {
@@ -116,6 +119,26 @@ function NoteCard({ note, onEdit, onDelete, onAddPhoto, onAddAudio, onDeleteAtt
</div> </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 */} {/* Méta */}
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center', ...noSelect }}> <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> <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 => ( {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> <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> </div>
{/* Actions */} {/* Actions */}
@@ -135,24 +163,31 @@ function NoteCard({ note, onEdit, onDelete, onAddPhoto, onAddAudio, onDeleteAtt
onClick={() => photoRef.current?.click()} onClick={() => photoRef.current?.click()}
title="Ajouter une photo" 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 }} 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 = '' }} /> <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 <button
onClick={recording ? stopRecord : startRecord} 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 }} 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 }} /> <div style={{ flex: 1 }} />
<button <button
onClick={onEdit} onClick={onEdit}
style={{ padding: '4px 10px', borderRadius: 6, border: 'none', background: 'var(--bg-5)', color: 'var(--ink-2)', cursor: 'pointer', fontSize: 13 }} 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 <button
onClick={onDelete} onClick={onDelete}
style={{ padding: '4px 10px', borderRadius: 6, border: 'none', background: 'transparent', color: 'var(--err)', cursor: 'pointer', fontSize: 14 }} 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>
</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) { async function handleDeleteAtt(noteId: string, attId: string) {
try { try {
await deleteAttachment(noteId, attId) 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 ( return (
<div className="p-4"> <div className="p-4">
@@ -277,28 +321,36 @@ export default function NotesPage() {
onChange={e => handleSearchChange(e.target.value)} onChange={e => handleSearchChange(e.target.value)}
/> />
{/* Filtres rapides */} {/* 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 const active = filters[key] === true
return ( return (
<button <button
key={label} key={key}
onClick={() => setFilters(f => ({ ...f, [key]: active ? undefined : true }))} onClick={() => setFilters(f => ({ ...f, [key]: active ? undefined : true }))}
style={{ style={{
padding: '5px 12px', borderRadius: 999, border: 'none', padding: '5px 10px', borderRadius: 999, border: 'none',
background: active ? 'var(--accent)' : 'var(--bg-3)', background: active ? 'var(--accent)' : 'var(--bg-3)',
color: active ? '#1d2021' : 'var(--ink-3)', color: active ? '#1d2021' : 'var(--ink-3)',
cursor: 'pointer', fontFamily: 'var(--font-ui)', fontSize: 12, cursor: 'pointer', fontFamily: 'var(--font-ui)', fontSize: 12,
display: 'flex', alignItems: 'center', gap: 5,
...noSelect, ...noSelect,
}} }}
>{label}</button> >
<i className={`fa-solid ${icon}`} style={{ fontSize: 11 }} />
{label}
</button>
) )
})} })}
{hasActiveFilters && ( {hasActiveFilters && (
<button <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 }} 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> </div>
@@ -345,6 +397,7 @@ export default function NotesPage() {
onDelete={() => void handleDelete(note.id)} onDelete={() => void handleDelete(note.id)}
onAddPhoto={f => void handleAddPhoto(note.id, f)} onAddPhoto={f => void handleAddPhoto(note.id, f)}
onAddAudio={f => void handleAddAudio(note.id, f)} onAddAudio={f => void handleAddAudio(note.id, f)}
onAddVideo={f => void handleAddVideo(note.id, f)}
onDeleteAtt={attId => void handleDeleteAtt(note.id, attId)} onDeleteAtt={attId => void handleDeleteAtt(note.id, attId)}
/> />
))} ))}
@@ -365,6 +418,7 @@ export default function NotesPage() {
onDelete={() => void handleDelete(note.id)} onDelete={() => void handleDelete(note.id)}
onAddPhoto={f => void handleAddPhoto(note.id, f)} onAddPhoto={f => void handleAddPhoto(note.id, f)}
onAddAudio={f => void handleAddAudio(note.id, f)} onAddAudio={f => void handleAddAudio(note.id, f)}
onAddVideo={f => void handleAddVideo(note.id, f)}
onDeleteAtt={attId => void handleDeleteAtt(note.id, attId)} onDeleteAtt={attId => void handleDeleteAtt(note.id, attId)}
/> />
))} ))}