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
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 19s
This commit is contained in:
parent
9302841bae
commit
39e1ed70ee
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user