add many things 2
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 37s

This commit is contained in:
ordinarthur 2026-04-13 15:52:26 +02:00
parent 3ac0f4f214
commit 828b3b09e9
29 changed files with 1782 additions and 22 deletions

View File

@ -3,10 +3,15 @@ import { Navbar } from './Navbar'
import { Sidebar } from './Sidebar'
import { PlayerBar } from './PlayerBar'
import { MobileNav } from './MobileNav'
import { AddToPlaylistModal } from '@/components/playlist/AddToPlaylistModal'
import { usePlayerStore } from '@/stores/player'
import { usePlaylistModal } from '@/stores/playlist-modal'
import { useNotificationPolling } from '@/hooks/useNotificationPolling'
export function Layout() {
const current = usePlayerStore((s) => s.current)
const playlistPodcastId = usePlaylistModal((s) => s.podcastId)
useNotificationPolling()
return (
<div className="min-h-screen flex flex-col">
@ -19,6 +24,7 @@ export function Layout() {
</div>
<PlayerBar />
<MobileNav />
{playlistPodcastId && <AddToPlaylistModal />}
</div>
)
}

View File

@ -3,6 +3,7 @@ import { Search, Upload, LogIn } from 'lucide-react'
import { useAuthStore } from '@/stores/auth'
import { Avatar } from '@/components/ui/Avatar'
import { Button } from '@/components/ui/Button'
import { NotificationBell } from './NotificationBell'
export function Navbar() {
const { user, profile } = useAuthStore()
@ -41,6 +42,7 @@ export function Navbar() {
<Upload size={15} />
<span className="hidden sm:inline">Publier</span>
</Button>
<NotificationBell />
<button onClick={() => navigate(`/profile/${profile?.username || user.id}`)} className="cursor-pointer">
<Avatar src={profile?.avatar_url} name={profile?.username || 'U'} size="sm" />
</button>

View File

@ -0,0 +1,87 @@
import { useState, useRef, useEffect } from 'react'
import { Bell } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { useNotificationsStore } from '@/stores/notifications'
import { useAuthStore } from '@/stores/auth'
import { timeAgo } from '@/lib/utils'
import type { Notification } from '@/types'
function notificationText(n: Notification): string {
const username = n.data?.username || 'Quelqu\'un'
switch (n.type) {
case 'new_podcast': return `${username} a publié un nouveau podcast`
case 'like': return `${username} a aimé votre podcast`
case 'comment': return `${username} a commenté votre podcast`
default: return 'Nouvelle notification'
}
}
export function NotificationBell() {
const navigate = useNavigate()
const { user } = useAuthStore()
const { notifications, unreadCount, markRead, markAllRead } = useNotificationsStore()
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [])
return (
<div ref={ref} className="relative">
<button
onClick={() => setOpen(!open)}
className="relative p-2 rounded-xl hover:bg-surface transition-colors cursor-pointer text-text-secondary hover:text-text"
>
<Bell size={18} />
{unreadCount > 0 && (
<span className="absolute -top-0.5 -right-0.5 w-4.5 h-4.5 rounded-full bg-accent text-[9px] font-bold flex items-center justify-center text-white min-w-[18px] h-[18px]">
{unreadCount > 9 ? '9+' : unreadCount}
</span>
)}
</button>
{open && (
<div className="absolute right-0 top-full mt-2 w-80 max-h-[70vh] bg-surface rounded-2xl border border-border shadow-organic-lg overflow-hidden flex flex-col z-50">
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<span className="text-sm font-heading font-bold">Notifications</span>
<div className="flex items-center gap-2">
{unreadCount > 0 && user && (
<button onClick={() => markAllRead(user.id)} className="text-[11px] text-primary hover:text-primary-hover transition-colors cursor-pointer">
Tout lire
</button>
)}
<button onClick={() => { setOpen(false); navigate('/notifications') }} className="text-[11px] text-text-secondary hover:text-text transition-colors cursor-pointer">
Voir tout
</button>
</div>
</div>
<div className="overflow-y-auto flex-1">
{notifications.length === 0 ? (
<p className="text-sm text-text-secondary text-center py-8">Aucune notification</p>
) : (
notifications.slice(0, 10).map((n) => (
<button
key={n.id}
onClick={() => {
if (!n.read) markRead(n.id)
if (n.data?.podcast_id) { setOpen(false); navigate(`/podcast/${n.data.podcast_id}`) }
}}
className={`w-full text-left px-4 py-3 hover:bg-primary/5 transition-colors cursor-pointer border-b border-border/50 last:border-0 ${!n.read ? 'bg-primary/[0.03]' : ''}`}
>
<p className="text-[13px] leading-snug">{notificationText(n)}</p>
<p className="text-[11px] text-text-secondary mt-0.5">{timeAgo(n.created_at)}</p>
{!n.read && <div className="w-1.5 h-1.5 rounded-full bg-primary absolute right-3 top-1/2 -translate-y-1/2" />}
</button>
))
)}
</div>
</div>
)}
</div>
)
}

View File

@ -1,16 +1,22 @@
import { NavLink } from 'react-router-dom'
import { Home, Compass, Upload, Heart, User, Settings } from 'lucide-react'
import { Home, Compass, Upload, Heart, User, Settings, TrendingUp, Rss, Sparkles, ListMusic, Layers, BarChart3 } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useAuthStore } from '@/stores/auth'
const publicLinks = [
{ to: '/', icon: Home, label: 'Accueil' },
{ to: '/explore', icon: Compass, label: 'Explorer' },
{ to: '/trending', icon: TrendingUp, label: 'Tendances' },
{ to: '/for-you', icon: Sparkles, label: 'Pour toi' },
]
const authLinks = [
{ to: '/following', icon: Rss, label: 'Abonnements' },
{ to: '/upload', icon: Upload, label: 'Publier' },
{ to: '/playlists', icon: ListMusic, label: 'Playlists' },
{ to: '/favorites', icon: Heart, label: 'Favoris' },
{ to: '/my-shows', icon: Layers, label: 'Mes séries' },
{ to: '/dashboard', icon: BarChart3, label: 'Dashboard' },
{ to: '/settings', icon: Settings, label: 'Paramètres' },
]

View File

@ -0,0 +1,120 @@
import { useEffect, useState } from 'react'
import { X, Plus, Check, ListMusic } from 'lucide-react'
import { supabase } from '@/lib/supabase'
import { useAuthStore } from '@/stores/auth'
import { usePlaylistModal } from '@/stores/playlist-modal'
import { Button } from '@/components/ui/Button'
import type { Playlist } from '@/types'
export function AddToPlaylistModal() {
const { user } = useAuthStore()
const { podcastId, close } = usePlaylistModal()
const [playlists, setPlaylists] = useState<Playlist[]>([])
const [inPlaylists, setInPlaylists] = useState<Set<string>>(new Set())
const [loading, setLoading] = useState(true)
const [newName, setNewName] = useState('')
const [creating, setCreating] = useState(false)
useEffect(() => {
if (!podcastId || !user) return
setLoading(true)
async function load() {
const [{ data: pls }, { data: items }] = await Promise.all([
supabase.from('playlists').select('*').eq('user_id', user!.id).order('created_at', { ascending: false }),
supabase.from('playlist_items').select('playlist_id').eq('podcast_id', podcastId!),
])
if (pls) setPlaylists(pls)
if (items) setInPlaylists(new Set(items.map((i) => i.playlist_id)))
setLoading(false)
}
load()
}, [podcastId, user])
if (!podcastId || !user) return null
async function togglePlaylist(playlistId: string) {
if (inPlaylists.has(playlistId)) {
await supabase.from('playlist_items').delete().eq('playlist_id', playlistId).eq('podcast_id', podcastId!)
setInPlaylists((prev) => { const n = new Set(prev); n.delete(playlistId); return n })
} else {
const { data: maxItem } = await supabase
.from('playlist_items')
.select('sort_order')
.eq('playlist_id', playlistId)
.order('sort_order', { ascending: false })
.limit(1)
.maybeSingle()
const nextOrder = (maxItem?.sort_order ?? 0) + 1
await supabase.from('playlist_items').insert({ playlist_id: playlistId, podcast_id: podcastId!, sort_order: nextOrder })
setInPlaylists((prev) => new Set(prev).add(playlistId))
}
}
async function handleCreate(e: React.FormEvent) {
e.preventDefault()
if (!newName.trim()) return
setCreating(true)
const { data } = await supabase
.from('playlists')
.insert({ user_id: user!.id, name: newName.trim() })
.select()
.single()
if (data) {
await supabase.from('playlist_items').insert({ playlist_id: data.id, podcast_id: podcastId!, sort_order: 1 })
setPlaylists([data, ...playlists])
setInPlaylists((prev) => new Set(prev).add(data.id))
setNewName('')
}
setCreating(false)
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={close} />
<div className="relative bg-surface rounded-2xl border border-border shadow-organic-lg w-full max-w-sm overflow-hidden">
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
<h3 className="font-heading font-bold">Ajouter à une playlist</h3>
<button onClick={close} className="text-text-secondary hover:text-text transition-colors cursor-pointer">
<X size={18} />
</button>
</div>
<div className="max-h-[50vh] overflow-y-auto p-2">
{loading ? (
<div className="space-y-2 p-3">
{[1, 2, 3].map((i) => <div key={i} className="h-12 skeleton rounded-xl" />)}
</div>
) : playlists.length === 0 ? (
<p className="text-sm text-text-secondary text-center py-6">Aucune playlist. Créez-en une ci-dessous.</p>
) : (
playlists.map((pl) => (
<button
key={pl.id}
onClick={() => togglePlaylist(pl.id)}
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl hover:bg-primary/5 transition-colors cursor-pointer"
>
<div className="w-9 h-9 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
{inPlaylists.has(pl.id) ? <Check size={16} className="text-primary" /> : <ListMusic size={16} className="text-primary/50" />}
</div>
<span className="text-sm font-medium truncate flex-1 text-left">{pl.name}</span>
</button>
))
)}
</div>
<form onSubmit={handleCreate} className="flex gap-2 p-4 border-t border-border">
<input
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="Nouvelle playlist..."
className="flex-1 rounded-xl border border-border bg-background px-3 py-2 text-sm outline-none focus:border-primary"
/>
<Button size="sm" type="submit" disabled={creating || !newName.trim()}>
<Plus size={14} />
</Button>
</form>
</div>
</div>
)
}

View File

@ -1,4 +1,4 @@
import { Play, Pause, Heart, MessageCircle, Clock, ListPlus, Download, Check, Loader2 } from 'lucide-react'
import { Play, Pause, Heart, MessageCircle, Clock, ListPlus, Download, Check, Loader2, BookmarkPlus } from 'lucide-react'
import { Link } from 'react-router-dom'
import type { Podcast } from '@/types'
import { formatDuration, timeAgo } from '@/lib/utils'
@ -8,6 +8,8 @@ import { Avatar } from '@/components/ui/Avatar'
import { usePlayerStore } from '@/stores/player'
import { useListeningProgress } from '@/hooks/useListeningProgress'
import { useOfflineStore } from '@/stores/offline'
import { usePlaylistModal } from '@/stores/playlist-modal'
import { useAuthStore } from '@/stores/auth'
import { isExternalUrl } from '@/lib/embed'
const coverGradients = [
@ -37,6 +39,7 @@ export function PodcastCard({ podcast, progressPercent: propProgress }: PodcastC
const progressMap = useListeningProgress()
const progressPercent = propProgress ?? progressMap.get(podcast.id)?.percent
const { downloadPodcast, isDownloaded, isDownloading, getDownloadProgress } = useOfflineStore()
const user = useAuthStore((s) => s.user)
const downloaded = isDownloaded(podcast.id)
const downloading = isDownloading(podcast.id)
const isNative = !isExternalUrl(podcast.audio_url)
@ -81,6 +84,15 @@ export function PodcastCard({ podcast, progressPercent: propProgress }: PodcastC
>
<ListPlus size={14} className="text-primary" />
</button>
{user && (
<button
onClick={(e) => { e.stopPropagation(); usePlaylistModal.getState().open(podcast.id) }}
className="w-8 h-8 rounded-full bg-white/90 shadow-lg flex items-center justify-center transition-all duration-300 scale-90 group-hover:scale-100 cursor-pointer hover:bg-white"
title="Ajouter à une playlist"
>
<BookmarkPlus size={14} className="text-primary" />
</button>
)}
{isNative && !downloaded && (
<button
onClick={(e) => { e.stopPropagation(); downloadPodcast(podcast) }}

View File

@ -0,0 +1,27 @@
import { useEffect, useRef } from 'react'
import { useAuthStore } from '@/stores/auth'
import { useNotificationsStore } from '@/stores/notifications'
export function useNotificationPolling() {
const { user } = useAuthStore()
const fetch = useNotificationsStore((s) => s.fetch)
const intervalRef = useRef<ReturnType<typeof setInterval>>()
useEffect(() => {
if (!user) return
fetch(user.id)
intervalRef.current = setInterval(() => fetch(user.id), 30000)
function handleVisibility() {
if (document.visibilityState === 'visible' && user) fetch(user.id)
}
document.addEventListener('visibilitychange', handleVisibility)
return () => {
clearInterval(intervalRef.current)
document.removeEventListener('visibilitychange', handleVisibility)
}
}, [user, fetch])
}

View File

@ -3,4 +3,10 @@ import { createClient } from '@supabase/supabase-js'
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL || 'https://placeholder.supabase.co'
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY || 'placeholder'
export const supabase = createClient(supabaseUrl, supabaseAnonKey)
export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
auth: {
persistSession: true,
autoRefreshToken: true,
detectSessionInUrl: true,
},
})

View File

@ -28,3 +28,31 @@ export function durationLabel(seconds: number): string {
if (seconds < 1800) return 'Moyen'
return 'Long'
}
export function secondsToTimestamp(seconds: number): string {
const s = Math.floor(seconds)
const h = Math.floor(s / 3600)
const m = Math.floor((s % 3600) / 60)
const sec = s % 60
let result = ''
if (h > 0) result += `${h}h`
if (m > 0) result += `${m}m`
if (sec > 0 || result === '') result += `${sec}s`
return result
}
export function timestampToSeconds(ts: string): number {
const match = ts.match(/(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?/)
if (!match) return 0
const h = parseInt(match[1] || '0')
const m = parseInt(match[2] || '0')
const s = parseInt(match[3] || '0')
return h * 3600 + m * 60 + s
}
export function normalizePodcasts(data: any[]): any[] {
return data.map((p) => ({
...p,
tags: p.tags?.map((t: any) => t.tag).filter(Boolean) || [],
}))
}

125
src/pages/Dashboard.tsx Normal file
View File

@ -0,0 +1,125 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { BarChart3, Headphones, Heart, MessageCircle, Clock } from 'lucide-react'
import { supabase } from '@/lib/supabase'
import { useAuthStore } from '@/stores/auth'
import { formatDuration } from '@/lib/utils'
interface PodcastStat {
id: string
title: string
plays_count: number
likes_count: number
comments_count: number
duration_seconds: number
}
export function Dashboard() {
const { user, loading: authLoading } = useAuthStore()
const navigate = useNavigate()
const [stats, setStats] = useState<PodcastStat[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
if (authLoading) return
if (!user) { navigate('/auth'); return }
async function load() {
const { data } = await supabase
.from('podcasts')
.select('id, title, plays_count, likes_count, comments_count, duration_seconds')
.eq('creator_id', user!.id)
.order('plays_count', { ascending: false })
if (data) setStats(data)
setLoading(false)
}
load()
}, [user, authLoading, navigate])
if (!user) return null
const totalPlays = stats.reduce((s, p) => s + (p.plays_count || 0), 0)
const totalLikes = stats.reduce((s, p) => s + (p.likes_count || 0), 0)
const totalComments = stats.reduce((s, p) => s + (p.comments_count || 0), 0)
const avgDuration = stats.length > 0 ? stats.reduce((s, p) => s + (p.duration_seconds || 0), 0) / stats.length : 0
const maxPlays = Math.max(...stats.map((p) => p.plays_count || 0), 1)
return (
<div className="max-w-4xl mx-auto space-y-8">
<div>
<h1 className="text-2xl font-heading font-bold flex items-center gap-2">
<BarChart3 size={22} />
Dashboard
</h1>
<p className="text-sm text-text-secondary mt-1">Statistiques de vos podcasts</p>
</div>
{loading ? (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[1, 2, 3, 4].map((i) => <div key={i} className="h-24 skeleton rounded-2xl" />)}
</div>
) : (
<>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard icon={Headphones} label="Écoutes totales" value={totalPlays.toLocaleString()} color="primary" />
<StatCard icon={Heart} label="Likes totaux" value={totalLikes.toLocaleString()} color="accent" />
<StatCard icon={MessageCircle} label="Commentaires" value={totalComments.toLocaleString()} color="mint" />
<StatCard icon={Clock} label="Durée moyenne" value={formatDuration(Math.round(avgDuration))} color="primary" />
</div>
{stats.length === 0 ? (
<div className="text-center py-16 bg-surface rounded-2xl border border-border">
<BarChart3 size={40} className="mx-auto text-text-secondary/30 mb-3" />
<p className="text-text-secondary">Publiez votre premier podcast pour voir vos statistiques.</p>
</div>
) : (
<div className="bg-surface rounded-2xl border border-border overflow-hidden">
<div className="px-5 py-4 border-b border-border">
<h2 className="font-heading font-bold text-sm">Par podcast</h2>
</div>
<div className="divide-y divide-border">
{stats.map((p) => (
<div key={p.id} className="flex items-center gap-4 px-5 py-3.5">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{p.title}</p>
<div className="flex items-center gap-3 text-[11px] text-text-secondary mt-0.5">
<span>{p.plays_count} écoutes</span>
<span>{p.likes_count || 0} likes</span>
<span>{p.comments_count || 0} commentaires</span>
</div>
</div>
<div className="w-32 shrink-0">
<div className="h-2 bg-border-light rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-primary to-[#7B6AEF] rounded-full transition-all"
style={{ width: `${((p.plays_count || 0) / maxPlays) * 100}%` }}
/>
</div>
</div>
</div>
))}
</div>
</div>
)}
</>
)}
</div>
)
}
function StatCard({ icon: Icon, label, value, color }: { icon: any; label: string; value: string; color: string }) {
const colorMap: Record<string, string> = {
primary: 'from-primary/10 to-primary/5 text-primary',
accent: 'from-accent/10 to-accent/5 text-accent',
mint: 'from-mint/10 to-mint/5 text-mint',
}
return (
<div className="bg-surface rounded-2xl border border-border p-4">
<div className={`w-10 h-10 rounded-xl bg-gradient-to-br ${colorMap[color]} flex items-center justify-center mb-3`}>
<Icon size={18} />
</div>
<p className="text-xl font-heading font-extrabold">{value}</p>
<p className="text-[11px] text-text-secondary font-medium mt-0.5">{label}</p>
</div>
)
}

78
src/pages/Following.tsx Normal file
View File

@ -0,0 +1,78 @@
import { useEffect, useState } from 'react'
import { Rss } from 'lucide-react'
import { supabase } from '@/lib/supabase'
import { useAuthStore } from '@/stores/auth'
import { PodcastCard } from '@/components/podcast/PodcastCard'
import { normalizePodcasts } from '@/lib/utils'
import type { Podcast } from '@/types'
export function Following() {
const { user } = useAuthStore()
const [podcasts, setPodcasts] = useState<Podcast[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
if (!user) { setLoading(false); return }
async function load() {
const { data: follows } = await supabase
.from('follows')
.select('following_id')
.eq('follower_id', user!.id)
if (!follows || follows.length === 0) {
setLoading(false)
return
}
const ids = follows.map((f) => f.following_id)
const { data } = await supabase
.from('podcasts')
.select('*, creator:profiles!podcasts_creator_id_fkey(*), tags:podcast_tags(tag:tags(*))')
.in('creator_id', ids)
.order('created_at', { ascending: false })
.limit(40)
if (data) setPodcasts(normalizePodcasts(data))
setLoading(false)
}
load()
}, [user])
if (!user) {
return (
<div className="text-center py-16">
<Rss size={40} className="mx-auto text-text-secondary/30 mb-3" />
<p className="text-text-secondary">Connectez-vous pour voir le flux de vos abonnements.</p>
</div>
)
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-heading font-bold flex items-center gap-2">
<Rss size={22} />
Abonnements
</h1>
<p className="text-sm text-text-secondary mt-1">Les derniers podcasts de vos créateurs suivis</p>
</div>
{loading ? (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-5">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="rounded-3xl aspect-[4/5] skeleton" />
))}
</div>
) : podcasts.length === 0 ? (
<p className="text-center py-16 text-text-secondary">Aucun podcast. Suivez des créateurs pour voir leurs publications ici.</p>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-5">
{podcasts.map((p) => (
<PodcastCard key={p.id} podcast={p} />
))}
</div>
)}
</div>
)
}

105
src/pages/ForYou.tsx Normal file
View File

@ -0,0 +1,105 @@
import { useEffect, useState } from 'react'
import { Sparkles } from 'lucide-react'
import { supabase } from '@/lib/supabase'
import { useAuthStore } from '@/stores/auth'
import { PodcastCard } from '@/components/podcast/PodcastCard'
import { normalizePodcasts } from '@/lib/utils'
import type { Podcast } from '@/types'
export function ForYou() {
const { user } = useAuthStore()
const [podcasts, setPodcasts] = useState<Podcast[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
async function load() {
if (user) {
// Get user's listened podcast IDs + their tags
const { data: history } = await supabase
.from('listen_history')
.select('podcast_id')
.eq('user_id', user.id)
.order('listened_at', { ascending: false })
.limit(50)
const listenedIds = history?.map((h) => h.podcast_id) || []
if (listenedIds.length > 0) {
// Get tags from listened podcasts
const { data: tagLinks } = await supabase
.from('podcast_tags')
.select('tag_id')
.in('podcast_id', listenedIds)
const tagIds = [...new Set(tagLinks?.map((t) => t.tag_id) || [])]
if (tagIds.length > 0) {
// Find podcasts with same tags, not yet listened
const { data: taggedPodcastLinks } = await supabase
.from('podcast_tags')
.select('podcast_id')
.in('tag_id', tagIds)
const candidateIds = [...new Set(taggedPodcastLinks?.map((t) => t.podcast_id) || [])]
.filter((id) => !listenedIds.includes(id))
if (candidateIds.length > 0) {
const { data } = await supabase
.from('podcasts')
.select('*, creator:profiles!podcasts_creator_id_fkey(*), tags:podcast_tags(tag:tags(*))')
.in('id', candidateIds.slice(0, 40))
.order('plays_count', { ascending: false })
if (data && data.length > 0) {
setPodcasts(normalizePodcasts(data))
setLoading(false)
return
}
}
}
}
}
// Fallback: popular podcasts
const { data } = await supabase
.from('podcasts')
.select('*, creator:profiles!podcasts_creator_id_fkey(*), tags:podcast_tags(tag:tags(*))')
.order('plays_count', { ascending: false })
.limit(20)
if (data) setPodcasts(normalizePodcasts(data))
setLoading(false)
}
load()
}, [user])
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-heading font-bold flex items-center gap-2">
<Sparkles size={22} />
Pour toi
</h1>
<p className="text-sm text-text-secondary mt-1">
{user ? 'Des recommandations basées sur vos écoutes' : 'Les podcasts les plus populaires'}
</p>
</div>
{loading ? (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-5">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="rounded-3xl aspect-[4/5] skeleton" />
))}
</div>
) : podcasts.length === 0 ? (
<p className="text-center py-16 text-text-secondary">Aucune recommandation pour le moment.</p>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-5">
{podcasts.map((p) => (
<PodcastCard key={p.id} podcast={p} />
))}
</div>
)}
</div>
)
}

View File

@ -6,6 +6,7 @@ import { useAuthStore } from '@/stores/auth'
import type { Podcast } from '@/types'
import { PodcastCard } from '@/components/podcast/PodcastCard'
import { Button } from '@/components/ui/Button'
import { normalizePodcasts } from '@/lib/utils'
interface InProgressPodcast extends Podcast {
progressPercent: number
@ -146,7 +147,7 @@ export function Home() {
subtitle="Les plus ecoutees cette semaine"
podcasts={trending}
loading={loading}
link="/explore?sort=trending"
link="/trending"
/>
<PodcastSection
@ -211,9 +212,3 @@ function PodcastSection({ icon, title, subtitle, podcasts, loading, link }: {
)
}
function normalizePodcasts(data: any[]): Podcast[] {
return data.map((p) => ({
...p,
tags: p.tags?.map((t: any) => t.tag).filter(Boolean) || [],
}))
}

View File

@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { Trash2, Play, Pause, Mic, Clock, Eye, ExternalLink } from 'lucide-react'
import { Trash2, Play, Pause, Mic, Clock, Eye, ExternalLink, Calendar, Send } from 'lucide-react'
import { supabase } from '@/lib/supabase'
import { useAuthStore } from '@/stores/auth'
import { usePlayerStore } from '@/stores/player'
@ -10,7 +10,7 @@ import { Button } from '@/components/ui/Button'
import type { Podcast } from '@/types'
export function MyPodcasts() {
const { user } = useAuthStore()
const { user, loading: authLoading } = useAuthStore()
const navigate = useNavigate()
const play = usePlayerStore((s) => s.play)
const currentId = usePlayerStore((s) => s.current?.id)
@ -18,11 +18,13 @@ export function MyPodcasts() {
const toggle = usePlayerStore((s) => s.toggle)
const [podcasts, setPodcasts] = useState<Podcast[]>([])
const [scheduled, setScheduled] = useState<Podcast[]>([])
const [loading, setLoading] = useState(true)
const [deleteId, setDeleteId] = useState<string | null>(null)
const [deleting, setDeleting] = useState(false)
useEffect(() => {
if (authLoading) return
if (!user) { navigate('/auth'); return }
async function load() {
@ -33,15 +35,18 @@ export function MyPodcasts() {
.order('created_at', { ascending: false })
if (data) {
setPodcasts(data.map((p: any) => ({
const all = data.map((p: any) => ({
...p,
tags: p.tags?.map((t: any) => t.tag).filter(Boolean) || [],
})))
}))
const now = new Date()
setScheduled(all.filter((p: Podcast) => p.published_at && new Date(p.published_at) > now))
setPodcasts(all.filter((p: Podcast) => !p.published_at || new Date(p.published_at) <= now))
}
setLoading(false)
}
load()
}, [user, navigate])
}, [user, authLoading, navigate])
async function handleDelete(id: string) {
setDeleting(true)
@ -82,6 +87,52 @@ export function MyPodcasts() {
</Button>
</div>
{/* Scheduled podcasts */}
{!loading && scheduled.length > 0 && (
<div className="space-y-3 mb-8">
<h2 className="text-sm font-heading font-bold text-text-secondary flex items-center gap-1.5">
<Calendar size={14} />
Programmés ({scheduled.length})
</h2>
{scheduled.map((podcast) => (
<div
key={podcast.id}
className="bg-surface rounded-2xl border border-sun/20 p-3 sm:p-4"
>
<div className="flex items-center gap-3 sm:gap-4">
<div className="w-14 h-14 sm:w-16 sm:h-16 rounded-xl overflow-hidden shrink-0">
{podcast.cover_url ? (
<img src={publicUrl(podcast.cover_url)} alt="" className="w-full h-full object-cover" />
) : (
<div className="w-full h-full bg-gradient-to-br from-sun/20 to-sun/5 flex items-center justify-center">
<Calendar size={20} className="text-sun/50" />
</div>
)}
</div>
<div className="flex-1 min-w-0">
<p className="font-heading font-bold text-sm truncate">{podcast.title}</p>
<p className="text-[11px] text-text-secondary mt-0.5">
Prévu le {new Date(podcast.published_at!).toLocaleString('fr-FR')}
</p>
</div>
<Button
size="sm"
variant="secondary"
onClick={async () => {
await supabase.from('podcasts').update({ published_at: null }).eq('id', podcast.id)
setScheduled((prev) => prev.filter((p) => p.id !== podcast.id))
setPodcasts((prev) => [podcast, ...prev].map((p) => p.id === podcast.id ? { ...p, published_at: null } : p))
}}
>
<Send size={13} />
Publier maintenant
</Button>
</div>
</div>
))}
</div>
)}
{loading ? (
<div className="space-y-3">
{[1, 2, 3].map(i => (

133
src/pages/MyShows.tsx Normal file
View File

@ -0,0 +1,133 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Layers, Plus, X } from 'lucide-react'
import { supabase } from '@/lib/supabase'
import { useAuthStore } from '@/stores/auth'
import { publicUrl } from '@/lib/storage'
import { Button } from '@/components/ui/Button'
import type { Show } from '@/types'
export function MyShows() {
const { user, loading: authLoading } = useAuthStore()
const navigate = useNavigate()
const [shows, setShows] = useState<Show[]>([])
const [loading, setLoading] = useState(true)
const [showCreate, setShowCreate] = useState(false)
const [newTitle, setNewTitle] = useState('')
const [newDesc, setNewDesc] = useState('')
const [creating, setCreating] = useState(false)
useEffect(() => {
if (authLoading) return
if (!user) { navigate('/auth'); return }
async function load() {
const { data } = await supabase
.from('shows')
.select('*')
.eq('creator_id', user!.id)
.order('created_at', { ascending: false })
if (data) setShows(data)
setLoading(false)
}
load()
}, [user, authLoading, navigate])
async function handleCreate(e: React.FormEvent) {
e.preventDefault()
if (!user || !newTitle.trim()) return
setCreating(true)
const { data } = await supabase
.from('shows')
.insert({ creator_id: user.id, title: newTitle.trim(), description: newDesc.trim() || null })
.select()
.single()
if (data) {
setShows([data, ...shows])
setNewTitle('')
setNewDesc('')
setShowCreate(false)
}
setCreating(false)
}
if (!user) return null
return (
<div className="max-w-3xl mx-auto space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-heading font-bold flex items-center gap-2">
<Layers size={22} />
Mes séries
</h1>
<p className="text-sm text-text-secondary mt-1">{shows.length} série{shows.length !== 1 ? 's' : ''}</p>
</div>
<Button size="sm" onClick={() => setShowCreate(true)}>
<Plus size={15} />
Créer une série
</Button>
</div>
{showCreate && (
<form onSubmit={handleCreate} className="bg-surface rounded-2xl border border-border p-4 space-y-3">
<div className="flex items-center justify-between">
<h3 className="font-heading font-bold text-sm">Nouvelle série</h3>
<button type="button" onClick={() => setShowCreate(false)} className="text-text-secondary hover:text-text cursor-pointer"><X size={16} /></button>
</div>
<input
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
placeholder="Nom de la série..."
className="w-full rounded-xl border border-border bg-background px-3 py-2 text-sm outline-none focus:border-primary"
autoFocus
/>
<textarea
value={newDesc}
onChange={(e) => setNewDesc(e.target.value)}
placeholder="Description (optionnelle)..."
rows={2}
className="w-full rounded-xl border border-border bg-background px-3 py-2 text-sm outline-none resize-none focus:border-primary"
/>
<div className="flex justify-end gap-2">
<Button variant="ghost" size="sm" type="button" onClick={() => setShowCreate(false)}>Annuler</Button>
<Button size="sm" type="submit" disabled={creating || !newTitle.trim()}>
{creating ? 'Création...' : 'Créer'}
</Button>
</div>
</form>
)}
{loading ? (
<div className="space-y-3">{[1, 2].map((i) => <div key={i} className="h-24 skeleton rounded-2xl" />)}</div>
) : shows.length === 0 ? (
<div className="text-center py-16 bg-surface rounded-2xl border border-border">
<Layers size={40} className="mx-auto text-text-secondary/30 mb-3" />
<p className="text-text-secondary mb-4">Aucune série pour le moment.</p>
<Button onClick={() => setShowCreate(true)}>Créer ma première série</Button>
</div>
) : (
<div className="space-y-3">
{shows.map((show) => (
<button
key={show.id}
onClick={() => navigate(`/show/${show.id}`)}
className="w-full flex items-center gap-4 p-4 bg-surface rounded-2xl border border-border hover:border-primary/20 transition-all cursor-pointer text-left"
>
{show.cover_url ? (
<img src={publicUrl(show.cover_url)} alt="" className="w-16 h-16 rounded-xl object-cover shrink-0" />
) : (
<div className="w-16 h-16 rounded-xl bg-gradient-to-br from-primary/15 to-primary/5 flex items-center justify-center shrink-0">
<Layers size={24} className="text-primary/40" />
</div>
)}
<div className="flex-1 min-w-0">
<p className="font-heading font-bold text-sm truncate">{show.title}</p>
{show.description && <p className="text-xs text-text-secondary truncate mt-0.5">{show.description}</p>}
</div>
</button>
))}
</div>
)}
</div>
)
}

View File

@ -0,0 +1,81 @@
import { Bell, Check } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { useAuthStore } from '@/stores/auth'
import { useNotificationsStore } from '@/stores/notifications'
import { timeAgo } from '@/lib/utils'
import { Button } from '@/components/ui/Button'
import type { Notification } from '@/types'
function notificationText(n: Notification): string {
const username = n.data?.username || 'Quelqu\'un'
switch (n.type) {
case 'new_podcast': return `${username} a publié un nouveau podcast`
case 'like': return `${username} a aimé votre podcast`
case 'comment': return `${username} a commenté votre podcast`
default: return 'Nouvelle notification'
}
}
export function Notifications() {
const { user } = useAuthStore()
const navigate = useNavigate()
const { notifications, unreadCount, markRead, markAllRead } = useNotificationsStore()
if (!user) {
return (
<div className="text-center py-16">
<Bell size={40} className="mx-auto text-text-secondary/30 mb-3" />
<p className="text-text-secondary">Connectez-vous pour voir vos notifications.</p>
</div>
)
}
return (
<div className="max-w-2xl mx-auto space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-heading font-bold flex items-center gap-2">
<Bell size={22} />
Notifications
</h1>
{unreadCount > 0 && <p className="text-sm text-text-secondary mt-1">{unreadCount} non lue{unreadCount !== 1 ? 's' : ''}</p>}
</div>
{unreadCount > 0 && (
<Button variant="ghost" size="sm" onClick={() => markAllRead(user.id)}>
<Check size={14} />
Tout marquer comme lu
</Button>
)}
</div>
{notifications.length === 0 ? (
<div className="text-center py-16 bg-surface rounded-2xl border border-border">
<Bell size={40} className="mx-auto text-text-secondary/30 mb-3" />
<p className="text-text-secondary">Aucune notification pour le moment.</p>
</div>
) : (
<div className="space-y-2">
{notifications.map((n) => (
<button
key={n.id}
onClick={() => {
if (!n.read) markRead(n.id)
if (n.data?.podcast_id) navigate(`/podcast/${n.data.podcast_id}`)
}}
className={`w-full text-left flex items-start gap-3 p-4 rounded-2xl border transition-all cursor-pointer ${
n.read ? 'bg-surface border-border hover:border-primary/20' : 'bg-primary/[0.03] border-primary/20 hover:border-primary/40'
}`}
>
<div className={`w-2 h-2 rounded-full mt-1.5 shrink-0 ${n.read ? 'bg-transparent' : 'bg-primary'}`} />
<div className="flex-1 min-w-0">
<p className="text-sm">{notificationText(n)}</p>
{n.data?.podcast_title && <p className="text-xs text-text-secondary mt-0.5 truncate">{n.data.podcast_title}</p>}
<p className="text-[11px] text-text-secondary mt-1">{timeAgo(n.created_at)}</p>
</div>
</button>
))}
</div>
)}
</div>
)
}

View File

@ -0,0 +1,151 @@
import { useEffect, useState } from 'react'
import { useParams, Link } from 'react-router-dom'
import { ListMusic, Play, Pause, Trash2, Clock } from 'lucide-react'
import { supabase } from '@/lib/supabase'
import { useAuthStore } from '@/stores/auth'
import { usePlayerStore } from '@/stores/player'
import { publicUrl } from '@/lib/storage'
import { formatDuration, normalizePodcasts } from '@/lib/utils'
import { Button } from '@/components/ui/Button'
import type { Playlist, Podcast } from '@/types'
export function PlaylistDetail() {
const { id } = useParams<{ id: string }>()
const { user } = useAuthStore()
const { play, toggle, current, isPlaying, addToQueue } = usePlayerStore()
const [playlist, setPlaylist] = useState<Playlist | null>(null)
const [podcasts, setPodcasts] = useState<Podcast[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
if (!id) return
async function load() {
const { data: pl } = await supabase
.from('playlists')
.select('*')
.eq('id', id)
.single()
if (pl) setPlaylist(pl)
const { data: items } = await supabase
.from('playlist_items')
.select('podcast_id, sort_order')
.eq('playlist_id', id)
.order('sort_order', { ascending: true })
if (items && items.length > 0) {
const podcastIds = items.map((i) => i.podcast_id)
const { data } = await supabase
.from('podcasts')
.select('*, creator:profiles!podcasts_creator_id_fkey(*), tags:podcast_tags(tag:tags(*))')
.in('id', podcastIds)
if (data) {
const normalized = normalizePodcasts(data)
// Restore sort order
const sorted = podcastIds.map((pid) => normalized.find((p) => p.id === pid)).filter(Boolean) as Podcast[]
setPodcasts(sorted)
}
}
setLoading(false)
}
load()
}, [id])
async function handleRemove(podcastId: string) {
if (!id) return
await supabase.from('playlist_items').delete().eq('playlist_id', id).eq('podcast_id', podcastId)
setPodcasts((prev) => prev.filter((p) => p.id !== podcastId))
}
function handlePlayAll() {
if (podcasts.length === 0) return
play(podcasts[0])
podcasts.slice(1).forEach((p) => addToQueue(p))
}
if (loading) {
return (
<div className="max-w-3xl mx-auto space-y-4">
<div className="h-8 w-48 skeleton rounded-lg" />
<div className="space-y-3">
{[1, 2, 3].map((i) => <div key={i} className="h-20 skeleton rounded-2xl" />)}
</div>
</div>
)
}
if (!playlist) {
return <div className="text-center py-16 text-text-secondary">Playlist introuvable.</div>
}
const isOwner = user?.id === playlist.user_id
return (
<div className="max-w-3xl mx-auto space-y-6">
<div className="flex items-center gap-4">
<div className="w-20 h-20 rounded-2xl bg-gradient-to-br from-primary/15 to-primary/5 flex items-center justify-center shrink-0">
<ListMusic size={32} className="text-primary/50" />
</div>
<div className="flex-1 min-w-0">
<h1 className="text-2xl font-heading font-bold truncate">{playlist.name}</h1>
{playlist.description && <p className="text-sm text-text-secondary mt-0.5">{playlist.description}</p>}
<p className="text-xs text-text-secondary mt-1">{podcasts.length} épisode{podcasts.length !== 1 ? 's' : ''}</p>
</div>
{podcasts.length > 0 && (
<Button onClick={handlePlayAll}>
<Play size={16} className="ml-0.5" />
Tout écouter
</Button>
)}
</div>
{podcasts.length === 0 ? (
<p className="text-center py-12 text-text-secondary">Cette playlist est vide.</p>
) : (
<div className="space-y-2">
{podcasts.map((podcast, i) => {
const isActive = current?.id === podcast.id
return (
<div key={podcast.id} className="flex items-center gap-3 p-3 rounded-2xl hover:bg-surface transition-colors group">
<span className="text-xs text-text-secondary w-5 text-center tabular-nums">{i + 1}</span>
<button
onClick={() => isActive ? toggle() : play(podcast)}
className="relative shrink-0 w-12 h-12 rounded-xl overflow-hidden cursor-pointer group/cover"
>
{podcast.cover_url ? (
<img src={publicUrl(podcast.cover_url)} alt="" className="w-full h-full object-cover" />
) : (
<div className="w-full h-full bg-gradient-to-br from-primary/20 to-accent/10 flex items-center justify-center">
<span className="text-sm font-heading font-bold text-primary/30">{podcast.title[0]?.toUpperCase()}</span>
</div>
)}
<div className="absolute inset-0 bg-black/30 flex items-center justify-center opacity-0 group-hover/cover:opacity-100 transition-opacity">
{isActive && isPlaying ? <Pause size={14} className="text-white" /> : <Play size={14} className="text-white ml-0.5" />}
</div>
</button>
<div className="flex-1 min-w-0">
<Link to={`/podcast/${podcast.id}`} className="text-sm font-medium hover:text-primary transition-colors truncate block">
{podcast.title}
</Link>
<div className="flex items-center gap-2 text-[11px] text-text-secondary mt-0.5">
<span>{podcast.external_author || podcast.creator?.username}</span>
<span className="flex items-center gap-0.5"><Clock size={10} />{formatDuration(podcast.duration_seconds)}</span>
</div>
</div>
{isOwner && (
<button
onClick={() => handleRemove(podcast.id)}
className="opacity-0 group-hover:opacity-100 p-2 rounded-xl hover:bg-accent/10 text-text-secondary hover:text-accent transition-all cursor-pointer"
>
<Trash2 size={14} />
</button>
)}
</div>
)
})}
</div>
)}
</div>
)
}

129
src/pages/Playlists.tsx Normal file
View File

@ -0,0 +1,129 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { ListMusic, Plus, Globe, Lock } from 'lucide-react'
import { supabase } from '@/lib/supabase'
import { useAuthStore } from '@/stores/auth'
import { Button } from '@/components/ui/Button'
import type { Playlist } from '@/types'
export function Playlists() {
const { user } = useAuthStore()
const navigate = useNavigate()
const [playlists, setPlaylists] = useState<Playlist[]>([])
const [loading, setLoading] = useState(true)
const [showCreate, setShowCreate] = useState(false)
const [newName, setNewName] = useState('')
const [creating, setCreating] = useState(false)
useEffect(() => {
if (!user) { setLoading(false); return }
async function load() {
const { data } = await supabase
.from('playlists')
.select('*')
.eq('user_id', user!.id)
.order('created_at', { ascending: false })
if (data) setPlaylists(data)
setLoading(false)
}
load()
}, [user])
async function handleCreate(e: React.FormEvent) {
e.preventDefault()
if (!user || !newName.trim()) return
setCreating(true)
const { data } = await supabase
.from('playlists')
.insert({ user_id: user.id, name: newName.trim() })
.select()
.single()
if (data) {
setPlaylists([data, ...playlists])
setNewName('')
setShowCreate(false)
}
setCreating(false)
}
if (!user) {
return (
<div className="text-center py-16">
<ListMusic size={40} className="mx-auto text-text-secondary/30 mb-3" />
<p className="text-text-secondary">Connectez-vous pour créer des playlists.</p>
</div>
)
}
return (
<div className="max-w-3xl mx-auto space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-heading font-bold flex items-center gap-2">
<ListMusic size={22} />
Mes playlists
</h1>
<p className="text-sm text-text-secondary mt-1">{playlists.length} playlist{playlists.length !== 1 ? 's' : ''}</p>
</div>
<Button size="sm" onClick={() => setShowCreate(true)}>
<Plus size={15} />
Créer
</Button>
</div>
{showCreate && (
<form onSubmit={handleCreate} className="flex gap-3 items-end bg-surface rounded-2xl border border-border p-4">
<div className="flex-1">
<label className="text-xs font-medium text-text-secondary mb-1 block">Nom de la playlist</label>
<input
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="Ma playlist..."
className="w-full rounded-xl border border-border bg-background px-3 py-2 text-sm outline-none focus:border-primary"
autoFocus
/>
</div>
<Button size="sm" type="submit" disabled={creating || !newName.trim()}>
{creating ? 'Création...' : 'Créer'}
</Button>
<Button size="sm" variant="ghost" onClick={() => setShowCreate(false)} type="button">
Annuler
</Button>
</form>
)}
{loading ? (
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="h-20 rounded-2xl skeleton" />
))}
</div>
) : playlists.length === 0 ? (
<div className="text-center py-16 bg-surface rounded-2xl border border-border">
<ListMusic size={40} className="mx-auto text-text-secondary/30 mb-3" />
<p className="text-text-secondary mb-4">Aucune playlist pour le moment.</p>
<Button onClick={() => setShowCreate(true)}>Créer ma première playlist</Button>
</div>
) : (
<div className="space-y-3">
{playlists.map((pl) => (
<button
key={pl.id}
onClick={() => navigate(`/playlist/${pl.id}`)}
className="w-full flex items-center gap-4 p-4 bg-surface rounded-2xl border border-border hover:border-primary/20 transition-all cursor-pointer text-left"
>
<div className="w-14 h-14 rounded-xl bg-gradient-to-br from-primary/15 to-primary/5 flex items-center justify-center shrink-0">
<ListMusic size={22} className="text-primary/50" />
</div>
<div className="flex-1 min-w-0">
<p className="font-heading font-bold text-sm truncate">{pl.name}</p>
{pl.description && <p className="text-xs text-text-secondary truncate mt-0.5">{pl.description}</p>}
</div>
{pl.is_public ? <Globe size={14} className="text-text-secondary shrink-0" /> : <Lock size={14} className="text-text-secondary shrink-0" />}
</button>
))}
</div>
)}
</div>
)
}

View File

@ -1,23 +1,27 @@
import { useEffect, useState } from 'react'
import { useParams, Link } from 'react-router-dom'
import { Play, Pause, Heart, MessageCircle, Clock, Share2, ListPlus, Download, Check, Loader2 } from 'lucide-react'
import { useEffect, useState, useRef } from 'react'
import { useParams, Link, useSearchParams } from 'react-router-dom'
import { Play, Pause, Heart, MessageCircle, Clock, Share2, ListPlus, Download, Check, Loader2, BookmarkPlus } from 'lucide-react'
import { supabase } from '@/lib/supabase'
import { useAuthStore } from '@/stores/auth'
import { usePlayerStore } from '@/stores/player'
import type { Podcast, Comment, Chapter } from '@/types'
import { formatDuration, timeAgo } from '@/lib/utils'
import { formatDuration, timeAgo, secondsToTimestamp, timestampToSeconds } from '@/lib/utils'
import { Avatar } from '@/components/ui/Avatar'
import { Badge } from '@/components/ui/Badge'
import { publicUrl } from '@/lib/storage'
import { Button } from '@/components/ui/Button'
import { useListeningProgress } from '@/hooks/useListeningProgress'
import { useOfflineStore } from '@/stores/offline'
import { usePlaylistModal } from '@/stores/playlist-modal'
import { isExternalUrl } from '@/lib/embed'
export function PodcastDetail() {
const { id } = useParams<{ id: string }>()
const [searchParams] = useSearchParams()
const { user } = useAuthStore()
const { play, toggle, current, isPlaying, addToQueue, seek: playerSeek } = usePlayerStore()
const { play, toggle, current, isPlaying, addToQueue, seek: playerSeek, progress } = usePlayerStore()
const initialSeekRef = useRef(timestampToSeconds(searchParams.get('t') || ''))
const [shareToast, setShareToast] = useState(false)
const [podcast, setPodcast] = useState<Podcast | null>(null)
const [chapters, setChapters] = useState<Chapter[]>([])
const [comments, setComments] = useState<Comment[]>([])
@ -72,10 +76,29 @@ export function PodcastDetail() {
}
setLoading(false)
// Auto-seek if ?t= param is present
if (initialSeekRef.current > 0 && p) {
const seekTime = initialSeekRef.current
initialSeekRef.current = 0
const podcast = { ...p, tags: p.tags?.map((t: any) => t.tag).filter(Boolean) || [] }
play(podcast)
setTimeout(() => playerSeek(seekTime), 800)
}
}
load()
}, [id, user])
function handleShare() {
const time = Math.floor(progress)
const url = time > 0 && current?.id === id
? `${window.location.origin}/podcast/${id}?t=${secondsToTimestamp(time)}`
: `${window.location.origin}/podcast/${id}`
navigator.clipboard.writeText(url)
setShareToast(true)
setTimeout(() => setShareToast(false), 2000)
}
async function handleLike() {
if (!user || !id) return
if (isLiked) {
@ -190,6 +213,11 @@ export function PodcastDetail() {
<Button variant="ghost" onClick={() => podcast && addToQueue(podcast)} title="Écouter ensuite">
<ListPlus size={16} />
</Button>
{user && (
<Button variant="ghost" onClick={() => usePlaylistModal.getState().open(podcast.id)} title="Ajouter à une playlist">
<BookmarkPlus size={16} />
</Button>
)}
{podcast && !isExternalUrl(podcast.audio_url) && (
isDownloaded(podcast.id) ? (
<Button variant="ghost" onClick={() => removePodcast(podcast)} title="Supprimer le téléchargement">
@ -206,8 +234,13 @@ export function PodcastDetail() {
</Button>
)
)}
<Button variant="ghost">
<Button variant="ghost" onClick={handleShare} className="relative">
<Share2 size={16} />
{shareToast && (
<span className="absolute -top-8 left-1/2 -translate-x-1/2 bg-surface text-text text-xs px-2 py-1 rounded-lg shadow-md whitespace-nowrap border border-border">
Lien copié !
</span>
)}
</Button>
</div>
</div>

128
src/pages/ShowDetail.tsx Normal file
View File

@ -0,0 +1,128 @@
import { useEffect, useState } from 'react'
import { useParams, Link } from 'react-router-dom'
import { Layers, Play, Pause, Clock } from 'lucide-react'
import { supabase } from '@/lib/supabase'
import { usePlayerStore } from '@/stores/player'
import { publicUrl } from '@/lib/storage'
import { formatDuration, timeAgo, normalizePodcasts } from '@/lib/utils'
import { Avatar } from '@/components/ui/Avatar'
import { Button } from '@/components/ui/Button'
import type { Show, Podcast } from '@/types'
export function ShowDetail() {
const { id } = useParams<{ id: string }>()
const { play, toggle, current, isPlaying, addToQueue } = usePlayerStore()
const [show, setShow] = useState<Show | null>(null)
const [episodes, setEpisodes] = useState<Podcast[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
if (!id) return
async function load() {
const { data: s } = await supabase
.from('shows')
.select('*, creator:profiles!shows_creator_id_fkey(*)')
.eq('id', id)
.single()
if (s) setShow(s)
const { data: eps } = await supabase
.from('podcasts')
.select('*, creator:profiles!podcasts_creator_id_fkey(*), tags:podcast_tags(tag:tags(*))')
.eq('show_id', id)
.order('created_at', { ascending: true })
if (eps) setEpisodes(normalizePodcasts(eps))
setLoading(false)
}
load()
}, [id])
function handlePlayAll() {
if (episodes.length === 0) return
play(episodes[0])
episodes.slice(1).forEach((p) => addToQueue(p))
}
if (loading) {
return (
<div className="max-w-3xl mx-auto space-y-4">
<div className="h-32 skeleton rounded-2xl" />
<div className="space-y-3">{[1, 2, 3].map((i) => <div key={i} className="h-20 skeleton rounded-2xl" />)}</div>
</div>
)
}
if (!show) {
return <div className="text-center py-16 text-text-secondary">Série introuvable.</div>
}
return (
<div className="max-w-3xl mx-auto space-y-6">
<div className="flex flex-col sm:flex-row gap-5">
<div className="w-full sm:w-36 shrink-0">
{show.cover_url ? (
<img src={publicUrl(show.cover_url)} alt="" className="w-full aspect-square rounded-2xl object-cover shadow-md" />
) : (
<div className="w-full aspect-square rounded-2xl bg-gradient-to-br from-primary/15 to-primary/5 flex items-center justify-center">
<Layers size={40} className="text-primary/30" />
</div>
)}
</div>
<div className="flex-1 min-w-0">
<h1 className="text-2xl font-heading font-bold">{show.title}</h1>
{show.description && <p className="text-sm text-text-secondary mt-1">{show.description}</p>}
{show.creator && (
<Link to={`/profile/${show.creator.username}`} className="flex items-center gap-2 mt-3">
<Avatar src={show.creator.avatar_url} name={show.creator.username} size="sm" />
<span className="text-sm font-medium hover:text-primary transition-colors">{show.creator.username}</span>
</Link>
)}
<p className="text-xs text-text-secondary mt-2">{episodes.length} épisode{episodes.length !== 1 ? 's' : ''}</p>
{episodes.length > 0 && (
<Button className="mt-3" onClick={handlePlayAll}>
<Play size={16} className="ml-0.5" />
Tout écouter
</Button>
)}
</div>
</div>
{episodes.length === 0 ? (
<p className="text-center py-12 text-text-secondary">Aucun épisode dans cette série.</p>
) : (
<div className="space-y-2">
{episodes.map((ep, i) => {
const isActive = current?.id === ep.id
return (
<div key={ep.id} className="flex items-center gap-3 p-3 rounded-2xl hover:bg-surface transition-colors group">
<span className="text-xs text-text-secondary w-5 text-center tabular-nums">{i + 1}</span>
<button
onClick={() => isActive ? toggle() : play(ep)}
className="relative shrink-0 w-12 h-12 rounded-xl overflow-hidden cursor-pointer group/cover"
>
{ep.cover_url ? (
<img src={publicUrl(ep.cover_url)} alt="" className="w-full h-full object-cover" />
) : (
<div className="w-full h-full bg-gradient-to-br from-primary/20 to-accent/10 flex items-center justify-center">
<span className="text-sm font-heading font-bold text-primary/30">{ep.title[0]?.toUpperCase()}</span>
</div>
)}
<div className="absolute inset-0 bg-black/30 flex items-center justify-center opacity-0 group-hover/cover:opacity-100 transition-opacity">
{isActive && isPlaying ? <Pause size={14} className="text-white" /> : <Play size={14} className="text-white ml-0.5" />}
</div>
</button>
<div className="flex-1 min-w-0">
<Link to={`/podcast/${ep.id}`} className="text-sm font-medium hover:text-primary transition-colors truncate block">{ep.title}</Link>
<div className="flex items-center gap-2 text-[11px] text-text-secondary mt-0.5">
<span className="flex items-center gap-0.5"><Clock size={10} />{formatDuration(ep.duration_seconds)}</span>
<span>{timeAgo(ep.created_at)}</span>
</div>
</div>
</div>
)
})}
</div>
)}
</div>
)
}

55
src/pages/Trending.tsx Normal file
View File

@ -0,0 +1,55 @@
import { useEffect, useState } from 'react'
import { TrendingUp } from 'lucide-react'
import { supabase } from '@/lib/supabase'
import { PodcastCard } from '@/components/podcast/PodcastCard'
import { normalizePodcasts } from '@/lib/utils'
import type { Podcast } from '@/types'
export function Trending() {
const [podcasts, setPodcasts] = useState<Podcast[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
async function load() {
const sevenDaysAgo = new Date(Date.now() - 7 * 86400000).toISOString()
const { data } = await supabase
.from('podcasts')
.select('*, creator:profiles!podcasts_creator_id_fkey(*), tags:podcast_tags(tag:tags(*))')
.gte('created_at', sevenDaysAgo)
.order('plays_count', { ascending: false })
.limit(40)
if (data) setPodcasts(normalizePodcasts(data))
setLoading(false)
}
load()
}, [])
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-heading font-bold flex items-center gap-2">
<TrendingUp size={22} />
Tendances
</h1>
<p className="text-sm text-text-secondary mt-1">Les podcasts les plus écoutés cette semaine</p>
</div>
{loading ? (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-5">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="rounded-3xl aspect-[4/5] skeleton" />
))}
</div>
) : podcasts.length === 0 ? (
<p className="text-center py-16 text-text-secondary">Aucun podcast tendance cette semaine.</p>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-5">
{podcasts.map((p) => (
<PodcastCard key={p.id} podcast={p} />
))}
</div>
)}
</div>
)
}

View File

@ -1,10 +1,11 @@
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, Plus, Trash2 } from 'lucide-react'
import { Upload as UploadIcon, Music, X, Image, Mic, Link2, Loader2, Circle, Square, MicOff, ShieldAlert, Plus, Trash2, Calendar } 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'
import type { Show } from '@/types'
type Mode = 'choose' | 'original' | 'external'
@ -62,6 +63,11 @@ export function Upload() {
// Chapters
const [chapters, setChapters] = useState<{ title: string; minutes: string; seconds: string }[]>([])
// Show & scheduling
const [shows, setShows] = useState<Show[]>([])
const [selectedShowId, setSelectedShowId] = useState('')
const [publishedAt, setPublishedAt] = useState('')
// External mode
const [externalUrl, setExternalUrl] = useState('')
const [fetching, setFetching] = useState(false)
@ -69,6 +75,17 @@ export function Upload() {
const [author, setAuthor] = useState('')
const [authorAvatar, setAuthorAvatar] = useState('')
// Fetch user's shows for the show selector
useEffect(() => {
if (!user || mode !== 'original') return
supabase
.from('shows')
.select('*')
.eq('creator_id', user.id)
.order('created_at', { ascending: false })
.then(({ data }) => { if (data) setShows(data) })
}, [user, mode])
// Check microphone permission when entering original mode
useEffect(() => {
if (mode !== 'original') return
@ -276,6 +293,8 @@ export function Upload() {
duration_seconds: duration,
cover_url,
...(mode === 'external' && author ? { external_author: author, external_author_avatar: authorAvatar || null } : {}),
...(mode === 'original' && selectedShowId ? { show_id: selectedShowId } : {}),
...(mode === 'original' && publishedAt ? { published_at: new Date(publishedAt).toISOString() } : {}),
})
.select()
.single()
@ -597,6 +616,45 @@ export function Upload() {
</div>
)}
{/* Show selector + scheduling — original mode only */}
{mode === 'original' && audioFile && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{shows.length > 0 && (
<div className="flex flex-col gap-1.5">
<label className="text-[13px] font-semibold text-text tracking-wide">Série (optionnel)</label>
<select
value={selectedShowId}
onChange={(e) => setSelectedShowId(e.target.value)}
className="rounded-2xl border-2 border-border bg-surface-warm/50 px-4 py-3 text-sm text-text outline-none transition-all duration-200 focus:border-primary focus:bg-surface focus:shadow-[0_0_0_4px_rgba(91,76,219,0.08)]"
>
<option value="">Aucune série</option>
{shows.map((s) => (
<option key={s.id} value={s.id}>{s.title}</option>
))}
</select>
</div>
)}
<div className="flex flex-col gap-1.5">
<label className="text-[13px] font-semibold text-text tracking-wide flex items-center gap-1.5">
<Calendar size={13} />
Programmer (optionnel)
</label>
<input
type="datetime-local"
value={publishedAt}
onChange={(e) => setPublishedAt(e.target.value)}
min={new Date().toISOString().slice(0, 16)}
className="rounded-2xl border-2 border-border bg-surface-warm/50 px-4 py-3 text-sm text-text outline-none transition-all duration-200 focus:border-primary focus:bg-surface focus:shadow-[0_0_0_4px_rgba(91,76,219,0.08)]"
/>
{publishedAt && (
<p className="text-[11px] text-text-secondary">
Sera publié le {new Date(publishedAt).toLocaleString('fr-FR')}
</p>
)}
</div>
</div>
)}
{/* Chapter editor — original mode only */}
{mode === 'original' && audioFile && (
<div className="space-y-3">

View File

@ -0,0 +1,47 @@
import { create } from 'zustand'
import { supabase } from '@/lib/supabase'
import type { Notification } from '@/types'
interface NotificationsStore {
notifications: Notification[]
unreadCount: number
fetch: (userId: string) => Promise<void>
markRead: (id: string) => Promise<void>
markAllRead: (userId: string) => Promise<void>
}
export const useNotificationsStore = create<NotificationsStore>((set) => ({
notifications: [],
unreadCount: 0,
fetch: async (userId) => {
const { data } = await supabase
.from('notifications')
.select('*')
.eq('user_id', userId)
.order('created_at', { ascending: false })
.limit(50)
if (data) {
set({
notifications: data,
unreadCount: data.filter((n) => !n.read).length,
})
}
},
markRead: async (id) => {
await supabase.from('notifications').update({ read: true }).eq('id', id)
set((s) => ({
notifications: s.notifications.map((n) => n.id === id ? { ...n, read: true } : n),
unreadCount: Math.max(0, s.unreadCount - 1),
}))
},
markAllRead: async (userId) => {
await supabase.from('notifications').update({ read: true }).eq('user_id', userId).eq('read', false)
set((s) => ({
notifications: s.notifications.map((n) => ({ ...n, read: true })),
unreadCount: 0,
}))
},
}))

View File

@ -0,0 +1,13 @@
import { create } from 'zustand'
interface PlaylistModalStore {
podcastId: string | null
open: (id: string) => void
close: () => void
}
export const usePlaylistModal = create<PlaylistModalStore>((set) => ({
podcastId: null,
open: (id) => set({ podcastId: id }),
close: () => set({ podcastId: null }),
}))

View File

@ -27,6 +27,9 @@ export interface Podcast {
comments_count?: number
is_liked?: boolean
chapters?: Chapter[]
show_id?: string | null
show?: Show | null
published_at?: string | null
}
export interface Tag {
@ -61,3 +64,40 @@ export interface Follow {
follower_id: string
following_id: string
}
export interface Playlist {
id: string
user_id: string
name: string
description: string | null
cover_url: string | null
is_public: boolean
created_at: string
}
export interface PlaylistItem {
id: string
playlist_id: string
podcast_id: string
sort_order: number
added_at: string
}
export interface Notification {
id: string
user_id: string
type: 'new_podcast' | 'like' | 'comment'
data: Record<string, any>
read: boolean
created_at: string
}
export interface Show {
id: string
creator_id: string
title: string
description: string | null
cover_url: string | null
created_at: string
creator?: Profile
}

View File

@ -0,0 +1,60 @@
-- Playlists
CREATE TABLE playlists (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
name text NOT NULL,
description text,
cover_url text,
is_public boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE playlists ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Anyone can view public playlists"
ON playlists FOR SELECT
USING (is_public = true OR user_id = auth.uid());
CREATE POLICY "Users can manage their own playlists"
ON playlists FOR ALL
USING (user_id = auth.uid())
WITH CHECK (user_id = auth.uid());
-- Playlist items
CREATE TABLE playlist_items (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
playlist_id uuid NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
podcast_id uuid NOT NULL REFERENCES podcasts(id) ON DELETE CASCADE,
sort_order integer NOT NULL DEFAULT 0,
added_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (playlist_id, podcast_id)
);
ALTER TABLE playlist_items ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Anyone can view items of visible playlists"
ON playlist_items FOR SELECT
USING (
EXISTS (
SELECT 1 FROM playlists
WHERE playlists.id = playlist_items.playlist_id
AND (playlists.is_public = true OR playlists.user_id = auth.uid())
)
);
CREATE POLICY "Playlist owners can manage items"
ON playlist_items FOR ALL
USING (
EXISTS (
SELECT 1 FROM playlists
WHERE playlists.id = playlist_items.playlist_id
AND playlists.user_id = auth.uid()
)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM playlists
WHERE playlists.id = playlist_items.playlist_id
AND playlists.user_id = auth.uid()
)
);

View File

@ -0,0 +1,130 @@
-- Notifications table
CREATE TABLE notifications (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
type text NOT NULL CHECK (type IN ('new_podcast', 'like', 'comment')),
data jsonb NOT NULL DEFAULT '{}',
read boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_notifications_user ON notifications(user_id, created_at DESC);
ALTER TABLE notifications ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can view their own notifications"
ON notifications FOR SELECT
USING (user_id = auth.uid());
CREATE POLICY "Users can update their own notifications"
ON notifications FOR UPDATE
USING (user_id = auth.uid())
WITH CHECK (user_id = auth.uid());
-- Trigger: notify followers when a new podcast is created
CREATE OR REPLACE FUNCTION notify_new_podcast()
RETURNS TRIGGER AS $$
DECLARE
creator_username text;
follower_record RECORD;
BEGIN
-- Skip scheduled podcasts
IF NEW.published_at IS NOT NULL AND NEW.published_at > now() THEN
RETURN NEW;
END IF;
SELECT username INTO creator_username FROM profiles WHERE id = NEW.creator_id;
FOR follower_record IN
SELECT follower_id FROM follows WHERE following_id = NEW.creator_id
LOOP
INSERT INTO notifications (user_id, type, data)
VALUES (
follower_record.follower_id,
'new_podcast',
jsonb_build_object(
'podcast_id', NEW.id,
'podcast_title', NEW.title,
'username', COALESCE(creator_username, 'Quelqu''un')
)
);
END LOOP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE TRIGGER on_podcast_created
AFTER INSERT ON podcasts
FOR EACH ROW
EXECUTE FUNCTION notify_new_podcast();
-- Trigger: notify podcast creator when someone likes their podcast
CREATE OR REPLACE FUNCTION notify_like()
RETURNS TRIGGER AS $$
DECLARE
liker_username text;
podcast_record RECORD;
BEGIN
SELECT username INTO liker_username FROM profiles WHERE id = NEW.user_id;
SELECT id, title, creator_id INTO podcast_record FROM podcasts WHERE id = NEW.podcast_id;
-- Don't notify if user likes their own podcast
IF podcast_record.creator_id = NEW.user_id THEN
RETURN NEW;
END IF;
INSERT INTO notifications (user_id, type, data)
VALUES (
podcast_record.creator_id,
'like',
jsonb_build_object(
'podcast_id', podcast_record.id,
'podcast_title', podcast_record.title,
'username', COALESCE(liker_username, 'Quelqu''un')
)
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE TRIGGER on_like_created
AFTER INSERT ON likes
FOR EACH ROW
EXECUTE FUNCTION notify_like();
-- Trigger: notify podcast creator when someone comments
CREATE OR REPLACE FUNCTION notify_comment()
RETURNS TRIGGER AS $$
DECLARE
commenter_username text;
podcast_record RECORD;
BEGIN
SELECT username INTO commenter_username FROM profiles WHERE id = NEW.user_id;
SELECT id, title, creator_id INTO podcast_record FROM podcasts WHERE id = NEW.podcast_id;
-- Don't notify if user comments on their own podcast
IF podcast_record.creator_id = NEW.user_id THEN
RETURN NEW;
END IF;
INSERT INTO notifications (user_id, type, data)
VALUES (
podcast_record.creator_id,
'comment',
jsonb_build_object(
'podcast_id', podcast_record.id,
'podcast_title', podcast_record.title,
'username', COALESCE(commenter_username, 'Quelqu''un')
)
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE TRIGGER on_comment_created
AFTER INSERT ON comments
FOR EACH ROW
EXECUTE FUNCTION notify_comment();

View File

@ -0,0 +1,25 @@
-- Shows / Series
CREATE TABLE shows (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
creator_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
title text NOT NULL,
description text,
cover_url text,
created_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE shows ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Anyone can view shows"
ON shows FOR SELECT
USING (true);
CREATE POLICY "Creators can manage their own shows"
ON shows FOR ALL
USING (creator_id = auth.uid())
WITH CHECK (creator_id = auth.uid());
-- Add show_id to podcasts
ALTER TABLE podcasts ADD COLUMN show_id uuid REFERENCES shows(id) ON DELETE SET NULL;
CREATE INDEX idx_podcasts_show ON podcasts(show_id) WHERE show_id IS NOT NULL;

View File

@ -0,0 +1,29 @@
-- Scheduled publishing
ALTER TABLE podcasts ADD COLUMN published_at timestamptz;
-- Update the SELECT policy: podcasts are visible if:
-- 1. Not scheduled (published_at IS NULL)
-- 2. Scheduled time has passed (published_at <= now())
-- 3. Current user is the creator
-- Note: drop the existing select policy and recreate it
-- First find and drop existing select policies on podcasts
DO $$
DECLARE
pol RECORD;
BEGIN
FOR pol IN
SELECT policyname FROM pg_policies
WHERE tablename = 'podcasts' AND cmd = 'SELECT'
LOOP
EXECUTE format('DROP POLICY %I ON podcasts', pol.policyname);
END LOOP;
END $$;
CREATE POLICY "Podcasts visible when published or owned"
ON podcasts FOR SELECT
USING (
published_at IS NULL
OR published_at <= now()
OR creator_id = auth.uid()
);