add many things 2
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 37s
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 37s
This commit is contained in:
parent
3ac0f4f214
commit
828b3b09e9
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
87
src/components/layout/NotificationBell.tsx
Normal file
87
src/components/layout/NotificationBell.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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' },
|
||||
]
|
||||
|
||||
|
||||
120
src/components/playlist/AddToPlaylistModal.tsx
Normal file
120
src/components/playlist/AddToPlaylistModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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) }}
|
||||
|
||||
27
src/hooks/useNotificationPolling.ts
Normal file
27
src/hooks/useNotificationPolling.ts
Normal 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])
|
||||
}
|
||||
@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
@ -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
125
src/pages/Dashboard.tsx
Normal 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
78
src/pages/Following.tsx
Normal 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
105
src/pages/ForYou.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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) || [],
|
||||
}))
|
||||
}
|
||||
|
||||
@ -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
133
src/pages/MyShows.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
81
src/pages/Notifications.tsx
Normal file
81
src/pages/Notifications.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
151
src/pages/PlaylistDetail.tsx
Normal file
151
src/pages/PlaylistDetail.tsx
Normal 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
129
src/pages/Playlists.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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
128
src/pages/ShowDetail.tsx
Normal 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
55
src/pages/Trending.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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">
|
||||
|
||||
47
src/stores/notifications.ts
Normal file
47
src/stores/notifications.ts
Normal 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,
|
||||
}))
|
||||
},
|
||||
}))
|
||||
13
src/stores/playlist-modal.ts
Normal file
13
src/stores/playlist-modal.ts
Normal 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 }),
|
||||
}))
|
||||
@ -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
|
||||
}
|
||||
|
||||
60
supabase/migrations/006_playlists.sql
Normal file
60
supabase/migrations/006_playlists.sql
Normal 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()
|
||||
)
|
||||
);
|
||||
130
supabase/migrations/007_notifications.sql
Normal file
130
supabase/migrations/007_notifications.sql
Normal 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();
|
||||
25
supabase/migrations/008_shows.sql
Normal file
25
supabase/migrations/008_shows.sql
Normal 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;
|
||||
29
supabase/migrations/009_scheduled_publishing.sql
Normal file
29
supabase/migrations/009_scheduled_publishing.sql
Normal 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()
|
||||
);
|
||||
Loading…
x
Reference in New Issue
Block a user