feat: add in-app audio recording in upload page
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 28s

This commit is contained in:
ordinarthur 2026-04-12 22:24:41 +02:00
parent c76b5e7d9c
commit cc4ced3076

View File

@ -1,6 +1,6 @@
import { useState, useRef, useCallback } from 'react' import { useState, useRef, useCallback, useEffect } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { Upload as UploadIcon, Music, X, Image, Mic, Link2, Loader2, Sparkles } from 'lucide-react' import { Upload as UploadIcon, Music, X, Image, Mic, Link2, Loader2, Sparkles, Circle, Square } from 'lucide-react'
import { supabase } from '@/lib/supabase' import { supabase } from '@/lib/supabase'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
@ -111,11 +111,73 @@ export function Upload() {
const [dragActive, setDragActive] = useState(false) const [dragActive, setDragActive] = useState(false)
const audioInputRef = useRef<HTMLInputElement>(null) const audioInputRef = useRef<HTMLInputElement>(null)
// Recording mode
const [isRecording, setIsRecording] = useState(false)
const [recordingTime, setRecordingTime] = useState(0)
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
const chunksRef = useRef<Blob[]>([])
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
// External mode // External mode
const [externalUrl, setExternalUrl] = useState('') const [externalUrl, setExternalUrl] = useState('')
const [fetching, setFetching] = useState(false) const [fetching, setFetching] = useState(false)
const [platform, setPlatform] = useState('') const [platform, setPlatform] = useState('')
// Cleanup recording on unmount
useEffect(() => {
return () => {
if (timerRef.current) clearInterval(timerRef.current)
if (mediaRecorderRef.current?.state === 'recording') {
mediaRecorderRef.current.stop()
mediaRecorderRef.current.stream.getTracks().forEach(t => t.stop())
}
}
}, [])
async function startRecording() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
const mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm;codecs=opus' })
mediaRecorderRef.current = mediaRecorder
chunksRef.current = []
mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0) chunksRef.current.push(e.data)
}
mediaRecorder.onstop = () => {
stream.getTracks().forEach(t => t.stop())
if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null }
const blob = new Blob(chunksRef.current, { type: 'audio/webm' })
const file = new File([blob], `enregistrement-${Date.now()}.webm`, { type: 'audio/webm' })
setAudioFile(file)
setDuration(recordingTime)
if (!title) setTitle('Mon enregistrement')
setIsRecording(false)
}
mediaRecorder.start(1000)
setIsRecording(true)
setRecordingTime(0)
timerRef.current = setInterval(() => setRecordingTime(t => t + 1), 1000)
} catch {
setError('Impossible d\'acceder au microphone. Verifiez les permissions de votre navigateur.')
}
}
function stopRecording() {
if (mediaRecorderRef.current?.state === 'recording') {
mediaRecorderRef.current.stop()
}
}
function formatRecordTime(s: number) {
const m = Math.floor(s / 60)
const sec = s % 60
return `${m.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`
}
const handleAudioSelect = useCallback((file: File) => { const handleAudioSelect = useCallback((file: File) => {
if (!file.type.startsWith('audio/')) { if (!file.type.startsWith('audio/')) {
setError('Veuillez selectionner un fichier audio.') setError('Veuillez selectionner un fichier audio.')
@ -321,30 +383,77 @@ export function Upload() {
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6">
{mode === 'original' ? ( {mode === 'original' ? (
/* ---- ORIGINAL: Audio file picker ---- */ /* ---- ORIGINAL: Audio file picker + Recorder ---- */
!audioFile ? ( isRecording ? (
<div /* Recording in progress */
className={`border-2 border-dashed rounded-2xl p-12 text-center transition-colors cursor-pointer ${dragActive ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/30'}`} <div className="border-2 border-accent/30 bg-accent/[0.03] rounded-2xl p-8 text-center">
onClick={() => audioInputRef.current?.click()} <div className="relative inline-flex items-center justify-center mb-4">
onDragOver={(e) => { e.preventDefault(); setDragActive(true) }} <div className="absolute w-20 h-20 rounded-full bg-accent/20 animate-ping" />
onDragLeave={() => setDragActive(false)} <div className="relative w-16 h-16 rounded-full bg-gradient-to-br from-accent to-[#D04B3A] flex items-center justify-center">
onDrop={(e) => { <Mic size={28} className="text-white" />
e.preventDefault() </div>
setDragActive(false) </div>
const file = e.dataTransfer.files[0] <p className="text-2xl font-heading font-bold tabular-nums mb-1">{formatRecordTime(recordingTime)}</p>
if (file) handleAudioSelect(file) <p className="text-sm text-text-secondary mb-5">Enregistrement en cours...</p>
}} <button
> type="button"
<UploadIcon size={40} className="mx-auto text-text-secondary mb-3" /> onClick={stopRecording}
<p className="font-medium">Glissez votre fichier audio ici</p> className="inline-flex items-center gap-2 px-6 py-3 rounded-full bg-accent text-white font-semibold hover:bg-accent/90 transition-colors cursor-pointer shadow-[0_4px_20px_rgba(232,96,76,0.3)]"
<p className="text-sm text-text-secondary mt-1">ou cliquez pour selectionner (MP3, WAV, M4A)</p> >
<input <Square size={16} fill="white" />
ref={audioInputRef} Arreter l'enregistrement
type="file" </button>
accept="audio/*" </div>
className="hidden" ) : !audioFile ? (
onChange={(e) => e.target.files?.[0] && handleAudioSelect(e.target.files[0])} <div className="space-y-3">
/> {/* Drag & drop zone */}
<div
className={`border-2 border-dashed rounded-2xl p-8 text-center transition-colors cursor-pointer ${dragActive ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/30'}`}
onClick={() => audioInputRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); setDragActive(true) }}
onDragLeave={() => setDragActive(false)}
onDrop={(e) => {
e.preventDefault()
setDragActive(false)
const file = e.dataTransfer.files[0]
if (file) handleAudioSelect(file)
}}
>
<UploadIcon size={32} className="mx-auto text-text-secondary mb-2" />
<p className="font-medium text-sm">Glissez votre fichier audio ici</p>
<p className="text-xs text-text-secondary mt-1">ou cliquez pour selectionner (MP3, WAV, M4A)</p>
<input
ref={audioInputRef}
type="file"
accept="audio/*"
className="hidden"
onChange={(e) => e.target.files?.[0] && handleAudioSelect(e.target.files[0])}
/>
</div>
<div className="flex items-center gap-3">
<div className="flex-1 h-px bg-border" />
<span className="text-xs text-text-secondary">ou</span>
<div className="flex-1 h-px bg-border" />
</div>
{/* Record button */}
<button
type="button"
onClick={startRecording}
className="w-full group relative overflow-hidden rounded-2xl border-2 border-border hover:border-accent/30 bg-surface p-5 text-left transition-all duration-300 hover:shadow-[0_8px_30px_rgba(232,96,76,0.1)] cursor-pointer"
>
<div className="absolute top-0 right-0 w-24 h-24 bg-gradient-to-bl from-accent/[0.06] to-transparent rounded-bl-full transition-all duration-300 group-hover:w-32 group-hover:h-32" />
<div className="relative z-10 flex items-center gap-4">
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-accent to-[#D04B3A] flex items-center justify-center shadow-[0_4px_16px_rgba(232,96,76,0.25)] transition-transform duration-300 group-hover:scale-110">
<Circle size={20} className="text-white" fill="white" />
</div>
<div>
<p className="font-heading font-bold">Enregistrer directement</p>
<p className="text-xs text-text-secondary">Utilisez votre micro pour enregistrer un podcast</p>
</div>
</div>
</button>
</div> </div>
) : ( ) : (
<div className="flex items-center gap-3 p-4 bg-primary/5 rounded-xl border border-primary/20"> <div className="flex items-center gap-3 p-4 bg-primary/5 rounded-xl border border-primary/20">