wetalk/src/pages/Upload.tsx
ordinarthur 0f9679ca2f
Some checks failed
Build & Deploy / build-and-deploy (push) Has been cancelled
add good author
2026-04-13 11:45:06 +02:00

592 lines
25 KiB
TypeScript

import { useState, useRef, useCallback, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { Upload as UploadIcon, Music, X, Image, Mic, Link2, Loader2, Circle, Square, MicOff, ShieldAlert } from 'lucide-react'
import { supabase } from '@/lib/supabase'
import { useAuthStore } from '@/stores/auth'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
type Mode = 'choose' | 'original' | 'external'
interface OEmbedData {
title: string
description: string
thumbnail: string
duration: number
audioUrl: string
platform: string
author: string
authorAvatar: string
}
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001'
async function fetchVideoMeta(url: string): Promise<OEmbedData | null> {
try {
const res = await fetch(`${API_URL}/api/metadata?url=${encodeURIComponent(url)}`)
if (!res.ok) return null
return await res.json()
} catch {
return null
}
}
export function Upload() {
const { user } = useAuthStore()
const navigate = useNavigate()
const [mode, setMode] = useState<Mode>('choose')
// Shared fields
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [coverPreview, setCoverPreview] = useState<string | null>(null)
const [coverFile, setCoverFile] = useState<File | null>(null)
const [uploading, setUploading] = useState(false)
const [error, setError] = useState('')
// Original mode
const [audioFile, setAudioFile] = useState<File | null>(null)
const [duration, setDuration] = useState(0)
const [dragActive, setDragActive] = useState(false)
const audioInputRef = useRef<HTMLInputElement>(null)
// Recording mode
const [isRecording, setIsRecording] = useState(false)
const [recordingTime, setRecordingTime] = useState(0)
const [micPermission, setMicPermission] = useState<'granted' | 'denied' | 'prompt' | 'checking'>('checking')
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
const chunksRef = useRef<Blob[]>([])
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
// External mode
const [externalUrl, setExternalUrl] = useState('')
const [fetching, setFetching] = useState(false)
const [platform, setPlatform] = useState('')
const [author, setAuthor] = useState('')
const [authorAvatar, setAuthorAvatar] = useState('')
// Check microphone permission when entering original mode
useEffect(() => {
if (mode !== 'original') return
let permStatus: PermissionStatus | null = null
async function checkMicPermission() {
try {
permStatus = await navigator.permissions.query({ name: 'microphone' as PermissionName })
setMicPermission(permStatus.state as 'granted' | 'denied' | 'prompt')
permStatus.onchange = () => setMicPermission(permStatus!.state as 'granted' | 'denied' | 'prompt')
} catch {
// Permissions API not supported or blocked by Permissions Policy — assume prompt
setMicPermission('prompt')
}
}
checkMicPermission()
return () => { if (permStatus) permStatus.onchange = null }
}, [mode])
// 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() {
setError('')
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
setMicPermission('granted')
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 (err) {
const e = err as DOMException
if (e.name === 'NotAllowedError') {
setMicPermission('denied')
setError('Acces au microphone refuse. Autorisez le microphone dans les parametres de votre navigateur puis rechargez la page.')
} else if (e.name === 'NotFoundError') {
setError('Aucun microphone detecte. Verifiez qu\'un micro est branche a votre appareil.')
} else {
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) => {
if (!file.type.startsWith('audio/')) {
setError('Veuillez selectionner un fichier audio.')
return
}
setAudioFile(file)
setError('')
const audio = new Audio()
audio.src = URL.createObjectURL(file)
audio.addEventListener('loadedmetadata', () => {
setDuration(Math.floor(audio.duration))
URL.revokeObjectURL(audio.src)
})
if (!title) setTitle(file.name.replace(/\.[^.]+$/, '').replace(/[-_]/g, ' '))
}, [title])
function handleCoverSelect(file: File) {
if (!file.type.startsWith('image/')) return
setCoverFile(file)
setCoverPreview(URL.createObjectURL(file))
}
// Auto-fetch metadata when URL changes (debounced)
useEffect(() => {
if (mode !== 'external') return
const url = externalUrl.trim()
if (!url) {
setPlatform('')
setCoverPreview(null)
setTitle('')
setDescription('')
setDuration(0)
setAuthor('')
setAuthorAvatar('')
return
}
// Basic URL validation before fetching
if (!url.startsWith('http://') && !url.startsWith('https://')) return
const timeout = setTimeout(async () => {
setFetching(true)
setError('')
const meta = await fetchVideoMeta(url)
if (!meta) {
setError('URL non reconnue. Formats supportes : YouTube, Spotify, Dailymotion, SoundCloud.')
setFetching(false)
return
}
setTitle(meta.title || '')
setDescription(meta.description || '')
if (meta.thumbnail) setCoverPreview(meta.thumbnail)
if (meta.duration) setDuration(meta.duration)
if (meta.author) setAuthor(meta.author)
if (meta.authorAvatar) setAuthorAvatar(meta.authorAvatar)
setPlatform(meta.platform)
setFetching(false)
}, 600)
return () => clearTimeout(timeout)
}, [externalUrl, mode])
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!user) return
setUploading(true)
setError('')
let audio_url = ''
const timestamp = Date.now()
if (mode === 'original') {
if (!audioFile) return
const audioPath = `${user.id}/${timestamp}-${audioFile.name}`
const { error: audioErr } = await supabase.storage
.from('podcasts')
.upload(audioPath, audioFile)
if (audioErr) {
setError("Erreur lors de l'upload audio: " + audioErr.message)
setUploading(false)
return
}
const { data: audioUrlData } = supabase.storage.from('podcasts').getPublicUrl(audioPath)
audio_url = audioUrlData.publicUrl
} else {
audio_url = externalUrl.trim()
}
let cover_url: string | null = coverPreview
if (coverFile) {
const coverPath = `${user.id}/${timestamp}-cover-${coverFile.name}`
const { error: coverErr } = await supabase.storage
.from('covers')
.upload(coverPath, coverFile)
if (!coverErr) {
const { data } = supabase.storage.from('covers').getPublicUrl(coverPath)
cover_url = data.publicUrl
}
}
const { data: podcast, error: insertErr } = await supabase
.from('podcasts')
.insert({
creator_id: user.id,
title,
description,
audio_url,
duration_seconds: duration,
cover_url,
...(mode === 'external' && author ? { external_author: author, external_author_avatar: authorAvatar || null } : {}),
})
.select()
.single()
if (insertErr) {
setError('Erreur: ' + insertErr.message)
setUploading(false)
return
}
navigate(`/podcast/${podcast.id}`)
}
if (!user) {
return (
<div className="text-center py-16">
<p className="text-text-secondary mb-4">Connectez-vous pour publier un podcast.</p>
<Button onClick={() => navigate('/auth')}>Se connecter</Button>
</div>
)
}
if (mode === 'choose') {
return (
<div className="max-w-2xl mx-auto">
<h1 className="text-2xl font-heading font-bold mb-2">Publier un podcast</h1>
<p className="text-text-secondary mb-8">Choisissez comment vous souhaitez partager votre contenu</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Original content card */}
<button
type="button"
onClick={() => setMode('original')}
className="group relative overflow-hidden rounded-2xl border-2 border-border hover:border-primary/40 bg-surface p-6 text-left transition-all duration-300 hover:shadow-[0_8px_30px_rgba(91,76,219,0.12)] cursor-pointer"
>
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-bl from-primary/[0.08] to-transparent rounded-bl-full transition-all duration-300 group-hover:w-40 group-hover:h-40" />
<div className="absolute bottom-0 left-0 w-20 h-20 bg-gradient-to-tr from-accent/[0.05] to-transparent rounded-tr-full" />
<div className="relative z-10">
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-primary to-[#7B6AEF] flex items-center justify-center mb-4 shadow-[0_4px_20px_rgba(91,76,219,0.25)] transition-transform duration-300 group-hover:scale-110 group-hover:rotate-[-4deg]">
<Mic size={24} className="text-white" />
</div>
<h3 className="font-heading font-bold text-lg mb-1.5">Contenu original</h3>
<p className="text-sm text-text-secondary leading-relaxed">
Uploadez votre propre fichier audio. Partagez votre voix, votre histoire, votre podcast.
</p>
<div className="flex gap-1.5 mt-4">
<span className="text-[11px] font-medium px-2 py-0.5 rounded-full bg-primary/10 text-primary">MP3</span>
<span className="text-[11px] font-medium px-2 py-0.5 rounded-full bg-primary/10 text-primary">WAV</span>
<span className="text-[11px] font-medium px-2 py-0.5 rounded-full bg-primary/10 text-primary">M4A</span>
</div>
</div>
</button>
{/* External content card */}
<button
type="button"
onClick={() => setMode('external')}
className="group relative overflow-hidden rounded-2xl border-2 border-border hover:border-mint/40 bg-surface p-6 text-left transition-all duration-300 hover:shadow-[0_8px_30px_rgba(76,199,178,0.12)] cursor-pointer"
>
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-bl from-mint/[0.08] to-transparent rounded-bl-full transition-all duration-300 group-hover:w-40 group-hover:h-40" />
<div className="absolute bottom-0 left-0 w-20 h-20 bg-gradient-to-tr from-sun/[0.05] to-transparent rounded-tr-full" />
<div className="relative z-10">
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-mint to-[#3BB8A0] flex items-center justify-center mb-4 shadow-[0_4px_20px_rgba(76,199,178,0.25)] transition-transform duration-300 group-hover:scale-110 group-hover:rotate-[4deg]">
<Link2 size={24} className="text-white" />
</div>
<h3 className="font-heading font-bold text-lg mb-1.5">Contenu existant</h3>
<p className="text-sm text-text-secondary leading-relaxed">
Partagez une video ou un podcast deja en ligne. On recupere tout automatiquement.
</p>
<div className="flex gap-1.5 mt-4">
<span className="text-[11px] font-medium px-2 py-0.5 rounded-full bg-mint/10 text-mint">YouTube</span>
<span className="text-[11px] font-medium px-2 py-0.5 rounded-full bg-mint/10 text-mint">Spotify</span>
<span className="text-[11px] font-medium px-2 py-0.5 rounded-full bg-mint/10 text-mint">Dailymotion</span>
<span className="text-[11px] font-medium px-2 py-0.5 rounded-full bg-mint/10 text-mint">SoundCloud</span>
</div>
</div>
</button>
</div>
</div>
)
}
return (
<div className="max-w-2xl mx-auto">
<div className="flex items-center gap-3 mb-6">
<button
type="button"
onClick={() => {
setMode('choose')
setAudioFile(null)
setExternalUrl('')
setTitle('')
setDescription('')
setCoverPreview(null)
setCoverFile(null)
setDuration(0)
setError('')
setPlatform('')
setAuthor('')
setAuthorAvatar('')
}}
className="text-sm text-text-secondary hover:text-text transition-colors cursor-pointer"
>
Retour
</button>
<h1 className="text-2xl font-heading font-bold">
{mode === 'original' ? 'Uploader un fichier audio' : 'Partager un contenu existant'}
</h1>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{mode === 'original' ? (
/* ---- ORIGINAL: Audio file picker + Recorder ---- */
isRecording ? (
/* Recording in progress */
<div className="border-2 border-accent/30 bg-accent/[0.03] rounded-2xl p-8 text-center">
<div className="relative inline-flex items-center justify-center mb-4">
<div className="absolute w-20 h-20 rounded-full bg-accent/20 animate-ping" />
<div className="relative w-16 h-16 rounded-full bg-gradient-to-br from-accent to-[#D04B3A] flex items-center justify-center">
<Mic size={28} className="text-white" />
</div>
</div>
<p className="text-2xl font-heading font-bold tabular-nums mb-1">{formatRecordTime(recordingTime)}</p>
<p className="text-sm text-text-secondary mb-5">Enregistrement en cours...</p>
<button
type="button"
onClick={stopRecording}
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)]"
>
<Square size={16} fill="white" />
Arreter l'enregistrement
</button>
</div>
) : !audioFile ? (
<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 */}
{micPermission === 'denied' ? (
<div className="w-full rounded-2xl border-2 border-accent/20 bg-accent/[0.03] p-5">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-2xl bg-gray-200 flex items-center justify-center shrink-0">
<MicOff size={20} className="text-text-secondary" />
</div>
<div>
<p className="font-heading font-bold text-text-secondary">Microphone bloque</p>
<p className="text-xs text-text-secondary mt-0.5">
Cliquez sur l'icone <ShieldAlert size={12} className="inline -mt-0.5" /> dans la barre d'adresse de votre navigateur pour autoriser l'acces au microphone, puis rechargez la page.
</p>
</div>
</div>
</div>
) : (
<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">
{micPermission === 'granted'
? 'Votre micro est pret — cliquez pour commencer'
: 'Votre navigateur demandera l\'acces au microphone'}
</p>
</div>
</div>
</button>
)}
</div>
) : (
<div className="flex items-center gap-3 p-4 bg-primary/5 rounded-xl border border-primary/20">
<Music size={20} className="text-primary shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{audioFile.name}</p>
<p className="text-xs text-text-secondary">
{(audioFile.size / 1024 / 1024).toFixed(1)} Mo
{duration > 0 && `${Math.floor(duration / 60)}min ${duration % 60}s`}
</p>
</div>
<button type="button" onClick={() => { setAudioFile(null); setDuration(0) }} className="text-text-secondary hover:text-text cursor-pointer">
<X size={18} />
</button>
</div>
)
) : (
/* ---- EXTERNAL: URL input ---- */
<div className="space-y-3">
<div className="relative">
<Input
id="url"
label="URL de la video ou du podcast"
value={externalUrl}
onChange={(e) => setExternalUrl(e.target.value)}
placeholder="https://youtube.com/watch?v=... ou https://open.spotify.com/episode/..."
/>
{fetching && (
<div className="absolute right-3 bottom-3">
<Loader2 size={18} className="animate-spin text-text-secondary" />
</div>
)}
</div>
{platform && coverPreview && (
<div className="flex items-center gap-3 p-3 bg-mint/5 rounded-xl border border-mint/20">
<img src={coverPreview} alt="" className="w-16 h-16 rounded-lg object-cover shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{title || 'Sans titre'}</p>
<div className="flex items-center gap-2 mt-0.5 text-xs text-text-secondary">
{author && <span className="truncate">{author}</span>}
{author && duration > 0 && <span>·</span>}
{duration > 0 && <span className="shrink-0">{Math.floor(duration / 60)}:{(duration % 60).toString().padStart(2, '0')}</span>}
</div>
<div className="flex items-center gap-1.5 mt-1">
<span className="text-[11px] font-medium px-2 py-0.5 rounded-full bg-mint/10 text-mint">
{platform}
</span>
<span className="text-[11px] font-medium px-2 py-0.5 rounded-full bg-primary/10 text-primary">
Auto-detecte
</span>
</div>
</div>
</div>
)}
</div>
)}
{/* Shared fields — hidden in external mode until metadata is fetched */}
{(mode === 'original' || platform) && (
<div className="grid grid-cols-1 sm:grid-cols-[1fr_auto] gap-6">
<div className="space-y-4">
<Input id="title" label="Titre" value={title} onChange={(e) => setTitle(e.target.value)} required placeholder="Le titre de votre podcast" />
<div className="flex flex-col gap-1.5">
<label htmlFor="desc" className="text-[13px] font-semibold text-text tracking-wide">Description</label>
<textarea
id="desc"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="De quoi parle votre podcast ?"
rows={4}
className="rounded-2xl border-2 border-border bg-surface-warm/50 px-4 py-3 text-sm text-text placeholder:text-text-secondary/40 outline-none resize-none transition-all duration-200 focus:border-primary focus:bg-surface focus:shadow-[0_0_0_4px_rgba(91,76,219,0.08)]"
/>
</div>
</div>
<div className="flex flex-col items-center gap-2">
<label className="text-[13px] font-semibold text-text tracking-wide">Couverture</label>
<div
className="w-32 h-32 rounded-xl border-2 border-dashed border-border hover:border-primary/30 flex items-center justify-center cursor-pointer overflow-hidden transition-colors"
onClick={() => {
const input = document.createElement('input')
input.type = 'file'
input.accept = 'image/*'
input.onchange = () => input.files?.[0] && handleCoverSelect(input.files[0])
input.click()
}}
>
{coverPreview ? (
<img src={coverPreview} alt="" className="w-full h-full object-cover" />
) : (
<Image size={24} className="text-text-secondary" />
)}
</div>
{coverPreview && mode === 'external' && !coverFile && (
<p className="text-[11px] text-text-secondary">Auto-detectee</p>
)}
</div>
</div>
)}
{error && <p className="text-sm text-accent">{error}</p>}
{(mode === 'original' || platform) && (
<Button
type="submit"
size="lg"
className="w-full"
disabled={
(mode === 'original' ? !audioFile : !externalUrl.trim()) || !title || uploading
}
>
{uploading ? 'Publication en cours...' : 'Publier le podcast'}
</Button>
)}
</form>
</div>
)
}