This commit is contained in:
parent
2fbccfac10
commit
4c8dd1cc52
186
api/src/index.ts
186
api/src/index.ts
@ -257,6 +257,192 @@ app.get('/api/metadata', async (c) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── Playlist endpoint ──
|
||||||
|
function extractPlaylistInfo(url: string): { platform: string; id: string; type?: string } | null {
|
||||||
|
// YouTube playlist
|
||||||
|
const ytPlaylist = url.match(/[?&]list=([a-zA-Z0-9_-]+)/)
|
||||||
|
if (ytPlaylist && url.includes('youtube.com')) return { platform: 'youtube', id: ytPlaylist[1] }
|
||||||
|
const ytPlaylistDirect = url.match(/youtube\.com\/playlist\?list=([a-zA-Z0-9_-]+)/)
|
||||||
|
if (ytPlaylistDirect) return { platform: 'youtube', id: ytPlaylistDirect[1] }
|
||||||
|
|
||||||
|
// Spotify playlist or album
|
||||||
|
const spotifyPlaylist = url.match(/open\.spotify\.com\/(playlist|album)\/([a-zA-Z0-9]+)/)
|
||||||
|
if (spotifyPlaylist) return { platform: 'spotify', id: spotifyPlaylist[2], type: spotifyPlaylist[1] }
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/api/playlist', async (c) => {
|
||||||
|
const url = c.req.query('url')
|
||||||
|
if (!url) return c.json({ error: 'Missing url parameter' }, 400)
|
||||||
|
|
||||||
|
const info = extractPlaylistInfo(url)
|
||||||
|
if (!info) return c.json({ error: 'Not a playlist URL' }, 400)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// ── YouTube playlist ──
|
||||||
|
if (info.platform === 'youtube') {
|
||||||
|
if (!YOUTUBE_API_KEY) return c.json({ error: 'YouTube API key not configured' }, 500)
|
||||||
|
|
||||||
|
// Fetch playlist metadata
|
||||||
|
const plRes = await fetch(`https://www.googleapis.com/youtube/v3/playlists?part=snippet&id=${info.id}&key=${YOUTUBE_API_KEY}`)
|
||||||
|
const plData = plRes.ok ? await plRes.json() : null
|
||||||
|
const plSnippet = plData?.items?.[0]?.snippet || {}
|
||||||
|
|
||||||
|
// Fetch channel avatar
|
||||||
|
let channelAvatar = ''
|
||||||
|
if (plSnippet.channelId) {
|
||||||
|
const chRes = await fetch(`https://www.googleapis.com/youtube/v3/channels?part=snippet&id=${plSnippet.channelId}&key=${YOUTUBE_API_KEY}`)
|
||||||
|
if (chRes.ok) {
|
||||||
|
const chData = await chRes.json()
|
||||||
|
const chThumbs = chData.items?.[0]?.snippet?.thumbnails || {}
|
||||||
|
channelAvatar = chThumbs.medium?.url || chThumbs.default?.url || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch all playlist items (paginated, max 200)
|
||||||
|
const items: any[] = []
|
||||||
|
let pageToken = ''
|
||||||
|
for (let i = 0; i < 4; i++) { // max 4 pages = 200 items
|
||||||
|
const piUrl = `https://www.googleapis.com/youtube/v3/playlistItems?part=snippet,contentDetails&playlistId=${info.id}&maxResults=50&key=${YOUTUBE_API_KEY}${pageToken ? `&pageToken=${pageToken}` : ''}`
|
||||||
|
const piRes = await fetch(piUrl)
|
||||||
|
if (!piRes.ok) break
|
||||||
|
const piData = await piRes.json()
|
||||||
|
items.push(...(piData.items || []))
|
||||||
|
if (!piData.nextPageToken) break
|
||||||
|
pageToken = piData.nextPageToken
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch video durations in batches of 50
|
||||||
|
const videoIds = items.map((i: any) => i.contentDetails?.videoId).filter(Boolean)
|
||||||
|
const durations: Record<string, number> = {}
|
||||||
|
for (let i = 0; i < videoIds.length; i += 50) {
|
||||||
|
const batch = videoIds.slice(i, i + 50).join(',')
|
||||||
|
const vRes = await fetch(`https://www.googleapis.com/youtube/v3/videos?part=contentDetails&id=${batch}&key=${YOUTUBE_API_KEY}`)
|
||||||
|
if (vRes.ok) {
|
||||||
|
const vData = await vRes.json()
|
||||||
|
for (const v of vData.items || []) {
|
||||||
|
durations[v.id] = parseDuration(v.contentDetails?.duration || '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tracks = items
|
||||||
|
.filter((i: any) => i.snippet?.title !== 'Private video' && i.snippet?.title !== 'Deleted video')
|
||||||
|
.map((i: any) => {
|
||||||
|
const s = i.snippet || {}
|
||||||
|
const videoId = i.contentDetails?.videoId || ''
|
||||||
|
const thumbs = s.thumbnails || {}
|
||||||
|
return {
|
||||||
|
title: s.title || '',
|
||||||
|
description: (s.description || '').slice(0, 300),
|
||||||
|
thumbnail: thumbs.maxres?.url || thumbs.standard?.url || thumbs.high?.url || thumbs.medium?.url || thumbs.default?.url || '',
|
||||||
|
duration: durations[videoId] || 0,
|
||||||
|
audioUrl: `https://www.youtube.com/watch?v=${videoId}`,
|
||||||
|
platform: 'YouTube',
|
||||||
|
author: plSnippet.channelTitle || s.channelTitle || '',
|
||||||
|
authorAvatar: channelAvatar,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
name: plSnippet.title || 'Playlist YouTube',
|
||||||
|
platform: 'YouTube',
|
||||||
|
thumbnail: (plSnippet.thumbnails?.maxres || plSnippet.thumbnails?.high || plSnippet.thumbnails?.medium || plSnippet.thumbnails?.default)?.url || '',
|
||||||
|
author: plSnippet.channelTitle || '',
|
||||||
|
authorAvatar: channelAvatar,
|
||||||
|
tracks,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Spotify playlist/album ──
|
||||||
|
if (info.platform === 'spotify') {
|
||||||
|
const token = await getSpotifyToken()
|
||||||
|
if (!token) return c.json({ error: 'Spotify auth failed' }, 500)
|
||||||
|
|
||||||
|
const isAlbum = info.type === 'album'
|
||||||
|
const endpoint = isAlbum
|
||||||
|
? `https://api.spotify.com/v1/albums/${info.id}?market=FR`
|
||||||
|
: `https://api.spotify.com/v1/playlists/${info.id}?market=FR`
|
||||||
|
|
||||||
|
const res = await fetch(endpoint, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
if (!res.ok) return c.json({ error: 'Could not fetch playlist' }, 404)
|
||||||
|
const data = await res.json()
|
||||||
|
|
||||||
|
const playlistImages = data.images || []
|
||||||
|
let tracks: any[]
|
||||||
|
|
||||||
|
if (isAlbum) {
|
||||||
|
const artist = data.artists?.map((a: any) => a.name).join(', ') || ''
|
||||||
|
let artistAvatar = ''
|
||||||
|
if (data.artists?.[0]?.id) {
|
||||||
|
const artRes = await fetch(`https://api.spotify.com/v1/artists/${data.artists[0].id}`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
if (artRes.ok) {
|
||||||
|
const artData = await artRes.json()
|
||||||
|
artistAvatar = artData.images?.[artData.images.length - 1]?.url || artData.images?.[0]?.url || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tracks = (data.tracks?.items || []).map((t: any) => ({
|
||||||
|
title: t.name || '',
|
||||||
|
description: `${artist} — ${data.name || ''}`,
|
||||||
|
thumbnail: playlistImages[0]?.url || '',
|
||||||
|
duration: Math.floor((t.duration_ms || 0) / 1000),
|
||||||
|
audioUrl: `https://open.spotify.com/track/${t.id}`,
|
||||||
|
platform: 'Spotify',
|
||||||
|
author: t.artists?.map((a: any) => a.name).join(', ') || artist,
|
||||||
|
authorAvatar: artistAvatar,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
name: data.name || 'Album Spotify',
|
||||||
|
platform: 'Spotify',
|
||||||
|
thumbnail: playlistImages[0]?.url || '',
|
||||||
|
author: artist,
|
||||||
|
authorAvatar: artistAvatar,
|
||||||
|
tracks,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Playlist
|
||||||
|
tracks = (data.tracks?.items || [])
|
||||||
|
.filter((i: any) => i.track)
|
||||||
|
.map((i: any) => {
|
||||||
|
const t = i.track
|
||||||
|
const artist = t.artists?.map((a: any) => a.name).join(', ') || ''
|
||||||
|
const images = t.album?.images || []
|
||||||
|
return {
|
||||||
|
title: t.name || '',
|
||||||
|
description: artist ? `Par ${artist}` : '',
|
||||||
|
thumbnail: images[0]?.url || '',
|
||||||
|
duration: Math.floor((t.duration_ms || 0) / 1000),
|
||||||
|
audioUrl: `https://open.spotify.com/track/${t.id}`,
|
||||||
|
platform: 'Spotify',
|
||||||
|
author: artist,
|
||||||
|
authorAvatar: '',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
name: data.name || 'Playlist Spotify',
|
||||||
|
platform: 'Spotify',
|
||||||
|
thumbnail: playlistImages[0]?.url || '',
|
||||||
|
author: data.owner?.display_name || '',
|
||||||
|
authorAvatar: '',
|
||||||
|
tracks,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ error: 'Unsupported playlist platform' }, 400)
|
||||||
|
} catch (e) {
|
||||||
|
return c.json({ error: 'Internal error' }, 500)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
app.get('/health', (c) => c.json({ ok: true }))
|
app.get('/health', (c) => c.json({ ok: true }))
|
||||||
|
|
||||||
const port = parseInt(process.env.PORT || '3001')
|
const port = parseInt(process.env.PORT || '3001')
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useState, useRef, useCallback, useEffect } 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, Circle, Square, MicOff, ShieldAlert, Plus, Trash2, Calendar } from 'lucide-react'
|
import { Upload as UploadIcon, Music, X, Image, Mic, Link2, Loader2, Circle, Square, MicOff, ShieldAlert, Plus, Trash2, Calendar, ListMusic, Check } 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'
|
||||||
@ -20,8 +20,25 @@ interface OEmbedData {
|
|||||||
authorAvatar: string
|
authorAvatar: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PlaylistData {
|
||||||
|
name: string
|
||||||
|
platform: string
|
||||||
|
thumbnail: string
|
||||||
|
author: string
|
||||||
|
authorAvatar: string
|
||||||
|
tracks: OEmbedData[]
|
||||||
|
}
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001'
|
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001'
|
||||||
|
|
||||||
|
function isPlaylistUrl(url: string): boolean {
|
||||||
|
// YouTube playlist
|
||||||
|
if (url.includes('youtube.com') && url.includes('list=')) return true
|
||||||
|
// Spotify playlist or album
|
||||||
|
if (/open\.spotify\.com\/(playlist|album)\//.test(url)) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchVideoMeta(url: string): Promise<OEmbedData | null> {
|
async function fetchVideoMeta(url: string): Promise<OEmbedData | null> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_URL}/api/metadata?url=${encodeURIComponent(url)}`)
|
const res = await fetch(`${API_URL}/api/metadata?url=${encodeURIComponent(url)}`)
|
||||||
@ -32,6 +49,16 @@ async function fetchVideoMeta(url: string): Promise<OEmbedData | null> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchPlaylistMeta(url: string): Promise<PlaylistData | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/api/playlist?url=${encodeURIComponent(url)}`)
|
||||||
|
if (!res.ok) return null
|
||||||
|
return await res.json()
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function Upload() {
|
export function Upload() {
|
||||||
const { user } = useAuthStore()
|
const { user } = useAuthStore()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@ -75,6 +102,12 @@ export function Upload() {
|
|||||||
const [author, setAuthor] = useState('')
|
const [author, setAuthor] = useState('')
|
||||||
const [authorAvatar, setAuthorAvatar] = useState('')
|
const [authorAvatar, setAuthorAvatar] = useState('')
|
||||||
|
|
||||||
|
// Playlist mode
|
||||||
|
const [playlist, setPlaylist] = useState<PlaylistData | null>(null)
|
||||||
|
const [selectedTracks, setSelectedTracks] = useState<Set<number>>(new Set())
|
||||||
|
const [importingPlaylist, setImportingPlaylist] = useState(false)
|
||||||
|
const [importProgress, setImportProgress] = useState(0)
|
||||||
|
|
||||||
// Fetch user's shows for the show selector
|
// Fetch user's shows for the show selector
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user || mode !== 'original') return
|
if (!user || mode !== 'original') return
|
||||||
@ -209,6 +242,8 @@ export function Upload() {
|
|||||||
setDuration(0)
|
setDuration(0)
|
||||||
setAuthor('')
|
setAuthor('')
|
||||||
setAuthorAvatar('')
|
setAuthorAvatar('')
|
||||||
|
setPlaylist(null)
|
||||||
|
setSelectedTracks(new Set())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -218,7 +253,22 @@ export function Upload() {
|
|||||||
const timeout = setTimeout(async () => {
|
const timeout = setTimeout(async () => {
|
||||||
setFetching(true)
|
setFetching(true)
|
||||||
setError('')
|
setError('')
|
||||||
|
setPlaylist(null)
|
||||||
|
setSelectedTracks(new Set())
|
||||||
|
|
||||||
|
// Check if it's a playlist URL
|
||||||
|
if (isPlaylistUrl(url)) {
|
||||||
|
const pl = await fetchPlaylistMeta(url)
|
||||||
|
if (pl && pl.tracks.length > 0) {
|
||||||
|
setPlaylist(pl)
|
||||||
|
setPlatform(pl.platform)
|
||||||
|
setSelectedTracks(new Set(pl.tracks.map((_, i) => i)))
|
||||||
|
setFetching(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single video/track
|
||||||
const meta = await fetchVideoMeta(url)
|
const meta = await fetchVideoMeta(url)
|
||||||
if (!meta) {
|
if (!meta) {
|
||||||
setError('URL non reconnue. Formats supportes : YouTube, Spotify, Dailymotion, SoundCloud.')
|
setError('URL non reconnue. Formats supportes : YouTube, Spotify, Dailymotion, SoundCloud.')
|
||||||
@ -239,6 +289,42 @@ export function Upload() {
|
|||||||
return () => clearTimeout(timeout)
|
return () => clearTimeout(timeout)
|
||||||
}, [externalUrl, mode])
|
}, [externalUrl, mode])
|
||||||
|
|
||||||
|
async function handlePlaylistImport() {
|
||||||
|
if (!user || !playlist) return
|
||||||
|
const WETALK_SYSTEM_ID = 'a1000000-0000-0000-0000-000000000005'
|
||||||
|
const tracks = playlist.tracks.filter((_, i) => selectedTracks.has(i))
|
||||||
|
if (tracks.length === 0) return
|
||||||
|
|
||||||
|
setImportingPlaylist(true)
|
||||||
|
setImportProgress(0)
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
let imported = 0
|
||||||
|
for (const track of tracks) {
|
||||||
|
const { error: insertErr } = await supabase
|
||||||
|
.from('podcasts')
|
||||||
|
.insert({
|
||||||
|
creator_id: WETALK_SYSTEM_ID,
|
||||||
|
title: track.title,
|
||||||
|
description: track.description || '',
|
||||||
|
audio_url: track.audioUrl,
|
||||||
|
duration_seconds: track.duration || 0,
|
||||||
|
cover_url: track.thumbnail || null,
|
||||||
|
external_author: track.author || playlist.author || null,
|
||||||
|
external_author_avatar: track.authorAvatar || playlist.authorAvatar || null,
|
||||||
|
})
|
||||||
|
if (insertErr) {
|
||||||
|
console.error('[upload] playlist item failed:', track.title, insertErr)
|
||||||
|
} else {
|
||||||
|
imported++
|
||||||
|
}
|
||||||
|
setImportProgress(Math.round(((imported) / tracks.length) * 100))
|
||||||
|
}
|
||||||
|
|
||||||
|
setImportingPlaylist(false)
|
||||||
|
navigate('/')
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!user) return
|
if (!user) return
|
||||||
@ -379,7 +465,7 @@ export function Upload() {
|
|||||||
</div>
|
</div>
|
||||||
<h3 className="font-heading font-bold text-lg mb-1.5">Contenu existant</h3>
|
<h3 className="font-heading font-bold text-lg mb-1.5">Contenu existant</h3>
|
||||||
<p className="text-sm text-text-secondary leading-relaxed">
|
<p className="text-sm text-text-secondary leading-relaxed">
|
||||||
Partagez une video ou un podcast deja en ligne. On recupere tout automatiquement.
|
Partagez une video, un podcast ou une playlist entiere. On recupere tout automatiquement.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-1.5 mt-4">
|
<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">YouTube</span>
|
||||||
@ -412,6 +498,8 @@ export function Upload() {
|
|||||||
setPlatform('')
|
setPlatform('')
|
||||||
setAuthor('')
|
setAuthor('')
|
||||||
setAuthorAvatar('')
|
setAuthorAvatar('')
|
||||||
|
setPlaylist(null)
|
||||||
|
setSelectedTracks(new Set())
|
||||||
}}
|
}}
|
||||||
className="text-sm text-text-secondary hover:text-text transition-colors cursor-pointer"
|
className="text-sm text-text-secondary hover:text-text transition-colors cursor-pointer"
|
||||||
>
|
>
|
||||||
@ -549,7 +637,107 @@ export function Upload() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{platform && coverPreview && (
|
{/* Playlist preview */}
|
||||||
|
{playlist && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-3 p-3 bg-primary/5 rounded-xl border border-primary/20">
|
||||||
|
{playlist.thumbnail && (
|
||||||
|
<img src={playlist.thumbnail} 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">{playlist.name}</p>
|
||||||
|
<div className="flex items-center gap-2 mt-0.5 text-xs text-text-secondary">
|
||||||
|
{playlist.author && <span className="truncate">{playlist.author}</span>}
|
||||||
|
<span>·</span>
|
||||||
|
<span>{playlist.tracks.length} titres</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-primary/10 text-primary">
|
||||||
|
<ListMusic size={10} className="inline -mt-0.5 mr-0.5" />{playlist.platform} Playlist
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-text-secondary font-medium">{selectedTracks.size}/{playlist.tracks.length} selectionnes</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedTracks(new Set(playlist.tracks.map((_, i) => i)))}
|
||||||
|
className="text-xs text-primary hover:text-primary-hover font-medium cursor-pointer"
|
||||||
|
>
|
||||||
|
Tout selectionner
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedTracks(new Set())}
|
||||||
|
className="text-xs text-text-secondary hover:text-text font-medium cursor-pointer"
|
||||||
|
>
|
||||||
|
Tout deselectionner
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-80 overflow-y-auto rounded-xl border border-border divide-y divide-border">
|
||||||
|
{playlist.tracks.map((track, i) => (
|
||||||
|
<label
|
||||||
|
key={i}
|
||||||
|
className={`flex items-center gap-3 p-2.5 cursor-pointer transition-colors ${selectedTracks.has(i) ? 'bg-primary/[0.03]' : 'bg-surface opacity-50'}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedTracks.has(i)}
|
||||||
|
onChange={() => {
|
||||||
|
const next = new Set(selectedTracks)
|
||||||
|
if (next.has(i)) next.delete(i)
|
||||||
|
else next.add(i)
|
||||||
|
setSelectedTracks(next)
|
||||||
|
}}
|
||||||
|
className="accent-primary shrink-0"
|
||||||
|
/>
|
||||||
|
{track.thumbnail && (
|
||||||
|
<img src={track.thumbnail} alt="" className="w-10 h-10 rounded object-cover shrink-0" />
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-[13px] font-medium truncate">{track.title}</p>
|
||||||
|
<div className="flex items-center gap-2 text-[11px] text-text-secondary">
|
||||||
|
{track.author && <span className="truncate">{track.author}</span>}
|
||||||
|
{track.duration > 0 && (
|
||||||
|
<span className="shrink-0">{Math.floor(track.duration / 60)}:{(track.duration % 60).toString().padStart(2, '0')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{importingPlaylist && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="h-2 rounded-full bg-border overflow-hidden">
|
||||||
|
<div className="h-full bg-primary rounded-full transition-all duration-300" style={{ width: `${importProgress}%` }} />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-text-secondary text-center">Import en cours... {importProgress}%</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
disabled={selectedTracks.size === 0 || importingPlaylist}
|
||||||
|
onClick={handlePlaylistImport}
|
||||||
|
>
|
||||||
|
{importingPlaylist
|
||||||
|
? `Import en cours... ${importProgress}%`
|
||||||
|
: `Importer ${selectedTracks.size} podcast${selectedTracks.size > 1 ? 's' : ''}`
|
||||||
|
}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Single track preview */}
|
||||||
|
{!playlist && platform && coverPreview && (
|
||||||
<div className="flex items-center gap-3 p-3 bg-mint/5 rounded-xl border border-mint/20">
|
<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" />
|
<img src={coverPreview} alt="" className="w-16 h-16 rounded-lg object-cover shrink-0" />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
@ -573,8 +761,8 @@ export function Upload() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Shared fields — hidden in external mode until metadata is fetched */}
|
{/* Shared fields — hidden in external mode until metadata is fetched, hidden for playlists */}
|
||||||
{(mode === 'original' || platform) && (
|
{!playlist && (mode === 'original' || platform) && (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-[1fr_auto] gap-6">
|
<div className="grid grid-cols-1 sm:grid-cols-[1fr_auto] gap-6">
|
||||||
<div className="space-y-4">
|
<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" />
|
<Input id="title" label="Titre" value={title} onChange={(e) => setTitle(e.target.value)} required placeholder="Le titre de votre podcast" />
|
||||||
@ -723,7 +911,7 @@ export function Upload() {
|
|||||||
|
|
||||||
{error && <p className="text-sm text-accent">{error}</p>}
|
{error && <p className="text-sm text-accent">{error}</p>}
|
||||||
|
|
||||||
{(mode === 'original' || platform) && (
|
{!playlist && (mode === 'original' || platform) && (
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
size="lg"
|
size="lg"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user