feat: upload page with original/external modes, YouTube/Dailymotion/SoundCloud auto-fetch
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 19s

This commit is contained in:
ordinarthur 2026-04-12 21:50:50 +02:00
parent 9302841bae
commit 39e1ed70ee

View File

@ -1,28 +1,124 @@
import { useState, useRef, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import { Upload as UploadIcon, Music, X, Image } from 'lucide-react'
import { Upload as UploadIcon, Music, X, Image, Mic, Link2, Loader2, Sparkles } 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
}
function extractVideoId(url: string): { platform: string; id: string } | null {
// YouTube
const ytMatch = url.match(
/(?:youtube\.com\/(?:watch\?v=|embed\/|shorts\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/,
)
if (ytMatch) return { platform: 'youtube', id: ytMatch[1] }
// Dailymotion
const dmMatch = url.match(
/(?:dailymotion\.com\/video\/|dai\.ly\/)([a-zA-Z0-9]+)/,
)
if (dmMatch) return { platform: 'dailymotion', id: dmMatch[1] }
// SoundCloud (handled via oEmbed, no ID extraction needed)
if (url.includes('soundcloud.com/')) return { platform: 'soundcloud', id: url }
return null
}
async function fetchVideoMeta(url: string): Promise<OEmbedData | null> {
const info = extractVideoId(url)
if (!info) return null
try {
if (info.platform === 'youtube') {
const oembedUrl = `https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${info.id}&format=json`
const res = await fetch(oembedUrl)
if (!res.ok) return null
const data = await res.json()
return {
title: data.title || '',
description: '',
thumbnail: `https://img.youtube.com/vi/${info.id}/hqdefault.jpg`,
duration: 0,
audioUrl: url,
platform: 'YouTube',
}
}
if (info.platform === 'dailymotion') {
const oembedUrl = `https://www.dailymotion.com/services/oembed?url=https://www.dailymotion.com/video/${info.id}&format=json`
const res = await fetch(oembedUrl)
if (!res.ok) return null
const data = await res.json()
return {
title: data.title || '',
description: '',
thumbnail: data.thumbnail_url || `https://www.dailymotion.com/thumbnail/video/${info.id}`,
duration: 0,
audioUrl: url,
platform: 'Dailymotion',
}
}
if (info.platform === 'soundcloud') {
const oembedUrl = `https://soundcloud.com/oembed?url=${encodeURIComponent(url)}&format=json`
const res = await fetch(oembedUrl)
if (!res.ok) return null
const data = await res.json()
return {
title: data.title || '',
description: data.description || '',
thumbnail: data.thumbnail_url || '',
duration: 0,
audioUrl: url,
platform: 'SoundCloud',
}
}
} catch {
return null
}
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 [audioFile, setAudioFile] = useState<File | null>(null)
const [coverFile, setCoverFile] = useState<File | null>(null)
const [coverPreview, setCoverPreview] = useState<string | null>(null)
const [duration, setDuration] = useState(0)
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)
// External mode
const [externalUrl, setExternalUrl] = useState('')
const [fetching, setFetching] = useState(false)
const [platform, setPlatform] = useState('')
const handleAudioSelect = useCallback((file: File) => {
if (!file.type.startsWith('audio/')) {
setError('Veuillez sélectionner un fichier audio.')
setError('Veuillez selectionner un fichier audio.')
return
}
setAudioFile(file)
@ -44,28 +140,56 @@ export function Upload() {
setCoverPreview(URL.createObjectURL(file))
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!user || !audioFile) return
setUploading(true)
async function handleFetchUrl() {
if (!externalUrl.trim()) return
setFetching(true)
setError('')
const timestamp = Date.now()
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)
const meta = await fetchVideoMeta(externalUrl.trim())
if (!meta) {
setError('URL non reconnue. Formats supportes : YouTube, Dailymotion, SoundCloud.')
setFetching(false)
return
}
const { data: audioUrl } = supabase.storage.from('podcasts').getPublicUrl(audioPath)
if (meta.title && !title) setTitle(meta.title)
if (meta.description && !description) setDescription(meta.description)
if (meta.thumbnail) setCoverPreview(meta.thumbnail)
if (meta.duration) setDuration(meta.duration)
setPlatform(meta.platform)
setFetching(false)
}
let cover_url: string | null = null
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
@ -83,7 +207,7 @@ export function Upload() {
creator_id: user.id,
title,
description,
audio_url: audioUrl.publicUrl,
audio_url,
duration_seconds: duration,
cover_url,
})
@ -108,69 +232,195 @@ export function Upload() {
)
}
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">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">
<h1 className="text-2xl font-heading font-bold mb-6">Publier un podcast</h1>
<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('')
}}
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">
{!audioFile ? (
<div
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'}`}
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={40} className="mx-auto text-text-secondary mb-3" />
<p className="font-medium">Glissez votre fichier audio ici</p>
<p className="text-sm text-text-secondary mt-1">ou cliquez pour sélectionner (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 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>
{mode === 'original' ? (
/* ---- ORIGINAL: Audio file picker ---- */
!audioFile ? (
<div
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'}`}
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={40} className="mx-auto text-text-secondary mb-3" />
<p className="font-medium">Glissez votre fichier audio ici</p>
<p className="text-sm 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>
<button type="button" onClick={() => { setAudioFile(null); setDuration(0) }} className="text-text-secondary hover:text-text cursor-pointer">
<X size={18} />
</button>
) : (
<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="flex gap-2">
<div className="flex-1">
<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://dai.ly/..."
/>
</div>
<div className="flex items-end">
<Button
type="button"
variant="secondary"
onClick={handleFetchUrl}
disabled={!externalUrl.trim() || fetching}
className="h-[46px]"
>
{fetching ? <Loader2 size={18} className="animate-spin" /> : <Sparkles size={18} />}
</Button>
</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" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{title || 'Sans titre'}</p>
<p className="text-xs text-text-secondary">via {platform}</p>
</div>
<span className="text-[11px] font-medium px-2 py-0.5 rounded-full bg-mint/10 text-mint shrink-0">
Auto-detecte
</span>
</div>
)}
</div>
)}
{/* Shared fields */}
<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-sm font-medium text-text">Description</label>
<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-xl border border-border bg-surface px-4 py-2.5 text-sm text-text placeholder:text-text-secondary/50 outline-none resize-none focus:border-primary focus:ring-2 focus:ring-primary/20 transition-colors"
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-sm font-medium text-text">Couverture</label>
<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={() => {
@ -187,12 +437,22 @@ export function Upload() {
<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>}
<Button type="submit" size="lg" className="w-full" disabled={!audioFile || !title || uploading}>
<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>