add progression
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 11s
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 11s
This commit is contained in:
parent
95a7e744ff
commit
143e453f78
@ -22,9 +22,10 @@ function titleToGradient(title: string) {
|
|||||||
|
|
||||||
interface PodcastCardProps {
|
interface PodcastCardProps {
|
||||||
podcast: Podcast
|
podcast: Podcast
|
||||||
|
progressPercent?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PodcastCard({ podcast }: PodcastCardProps) {
|
export function PodcastCard({ podcast, progressPercent }: PodcastCardProps) {
|
||||||
const play = usePlayerStore((s) => s.play)
|
const play = usePlayerStore((s) => s.play)
|
||||||
const currentId = usePlayerStore((s) => s.current?.id)
|
const currentId = usePlayerStore((s) => s.current?.id)
|
||||||
const isPlaying = usePlayerStore((s) => s.isPlaying)
|
const isPlaying = usePlayerStore((s) => s.isPlaying)
|
||||||
@ -76,6 +77,13 @@ export function PodcastCard({ podcast }: PodcastCardProps) {
|
|||||||
<div className="wave-bar" /><div className="wave-bar" /><div className="wave-bar" /><div className="wave-bar" />
|
<div className="wave-bar" /><div className="wave-bar" /><div className="wave-bar" /><div className="wave-bar" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Listening progress bar */}
|
||||||
|
{progressPercent != null && progressPercent > 0 && (
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 h-1 bg-black/20">
|
||||||
|
<div className="h-full bg-primary rounded-r-full" style={{ width: `${Math.min(progressPercent, 100)}%` }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Info */}
|
{/* Info */}
|
||||||
|
|||||||
@ -1,20 +1,27 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Link, useNavigate } from 'react-router-dom'
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
import { TrendingUp, Clock, ArrowRight, Headphones, Users, Mic } from 'lucide-react'
|
import { TrendingUp, Clock, ArrowRight, Headphones, Users, Mic, PlayCircle } from 'lucide-react'
|
||||||
import { supabase } from '@/lib/supabase'
|
import { supabase } from '@/lib/supabase'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import type { Podcast } from '@/types'
|
import type { Podcast } from '@/types'
|
||||||
import { PodcastCard } from '@/components/podcast/PodcastCard'
|
import { PodcastCard } from '@/components/podcast/PodcastCard'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
|
|
||||||
|
interface InProgressPodcast extends Podcast {
|
||||||
|
progressPercent: number
|
||||||
|
}
|
||||||
|
|
||||||
export function Home() {
|
export function Home() {
|
||||||
const [trending, setTrending] = useState<Podcast[]>([])
|
const [trending, setTrending] = useState<Podcast[]>([])
|
||||||
const [recent, setRecent] = useState<Podcast[]>([])
|
const [recent, setRecent] = useState<Podcast[]>([])
|
||||||
|
const [inProgress, setInProgress] = useState<InProgressPodcast[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const { user } = useAuthStore()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function load() {
|
async function load() {
|
||||||
const [trendingRes, recentRes] = await Promise.all([
|
const queries: Promise<any>[] = [
|
||||||
supabase
|
supabase
|
||||||
.from('podcasts')
|
.from('podcasts')
|
||||||
.select('*, creator:profiles!podcasts_creator_id_fkey(*), tags:podcast_tags(tag:tags(*))')
|
.select('*, creator:profiles!podcasts_creator_id_fkey(*), tags:podcast_tags(tag:tags(*))')
|
||||||
@ -25,14 +32,43 @@ export function Home() {
|
|||||||
.select('*, creator:profiles!podcasts_creator_id_fkey(*), tags:podcast_tags(tag:tags(*))')
|
.select('*, creator:profiles!podcasts_creator_id_fkey(*), tags:podcast_tags(tag:tags(*))')
|
||||||
.order('created_at', { ascending: false })
|
.order('created_at', { ascending: false })
|
||||||
.limit(8),
|
.limit(8),
|
||||||
])
|
]
|
||||||
|
|
||||||
|
// Fetch in-progress podcasts if logged in
|
||||||
|
if (user) {
|
||||||
|
queries.push(
|
||||||
|
supabase
|
||||||
|
.from('listen_history')
|
||||||
|
.select('progress_seconds, podcast:podcasts(*, creator:profiles!podcasts_creator_id_fkey(*), tags:podcast_tags(tag:tags(*)))')
|
||||||
|
.eq('user_id', user.id)
|
||||||
|
.eq('completed', false)
|
||||||
|
.gt('progress_seconds', 5)
|
||||||
|
.order('listened_at', { ascending: false })
|
||||||
|
.limit(6)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await Promise.all(queries)
|
||||||
|
|
||||||
|
if (results[0].data) setTrending(normalizePodcasts(results[0].data))
|
||||||
|
if (results[1].data) setRecent(normalizePodcasts(results[1].data))
|
||||||
|
|
||||||
|
if (user && results[2]?.data) {
|
||||||
|
const items = results[2].data
|
||||||
|
.filter((r: any) => r.podcast)
|
||||||
|
.map((r: any) => {
|
||||||
|
const p = normalizePodcasts([r.podcast])[0]
|
||||||
|
const pct = p.duration_seconds > 0 ? (r.progress_seconds / p.duration_seconds) * 100 : 0
|
||||||
|
return { ...p, progressPercent: pct }
|
||||||
|
})
|
||||||
|
.filter((p: InProgressPodcast) => p.progressPercent > 0 && p.progressPercent < 95)
|
||||||
|
setInProgress(items)
|
||||||
|
}
|
||||||
|
|
||||||
if (trendingRes.data) setTrending(normalizePodcasts(trendingRes.data))
|
|
||||||
if (recentRes.data) setRecent(normalizePodcasts(recentRes.data))
|
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
load()
|
load()
|
||||||
}, [])
|
}, [user])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-14">
|
<div className="space-y-14">
|
||||||
@ -86,6 +122,24 @@ export function Home() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Continue listening */}
|
||||||
|
{inProgress.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<div className="mb-5">
|
||||||
|
<h2 className="flex items-center gap-2 text-xl font-heading font-extrabold">
|
||||||
|
<PlayCircle size={20} strokeWidth={2.5} />
|
||||||
|
Reprendre l'ecoute
|
||||||
|
</h2>
|
||||||
|
<p className="text-[13px] text-text-secondary mt-0.5">La ou vous vous etes arrete</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-5">
|
||||||
|
{inProgress.map((p) => (
|
||||||
|
<PodcastCard key={p.id} podcast={p} progressPercent={p.progressPercent} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
<PodcastSection
|
<PodcastSection
|
||||||
icon={<TrendingUp size={20} strokeWidth={2.5} />}
|
icon={<TrendingUp size={20} strokeWidth={2.5} />}
|
||||||
title="Tendances"
|
title="Tendances"
|
||||||
|
|||||||
@ -2,6 +2,8 @@ import { create } from 'zustand'
|
|||||||
import type { Podcast } from '@/types'
|
import type { Podcast } from '@/types'
|
||||||
import { isExternalUrl, getEmbedInfo } from '@/lib/embed'
|
import { isExternalUrl, getEmbedInfo } from '@/lib/embed'
|
||||||
import { publicUrl } from '@/lib/storage'
|
import { publicUrl } from '@/lib/storage'
|
||||||
|
import { supabase } from '@/lib/supabase'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
// ──── YouTube IFrame API ────
|
// ──── YouTube IFrame API ────
|
||||||
declare global {
|
declare global {
|
||||||
@ -69,6 +71,41 @@ function createHiddenIframe(embedUrl: string) {
|
|||||||
hiddenIframe = iframe
|
hiddenIframe = iframe
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ──── Progress persistence ────
|
||||||
|
let saveTimeout: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
async function saveProgressNow(podcastId: string, seconds: number, duration: number, markCompleted = false): Promise<void> {
|
||||||
|
if (saveTimeout) { clearTimeout(saveTimeout); saveTimeout = null }
|
||||||
|
const userId = useAuthStore.getState().user?.id
|
||||||
|
if (!userId || seconds < 1) return
|
||||||
|
const completed = markCompleted || (duration > 0 && seconds / duration > 0.95)
|
||||||
|
const { error } = await supabase.from('listen_history').upsert(
|
||||||
|
{ user_id: userId, podcast_id: podcastId, progress_seconds: Math.floor(seconds), completed, listened_at: new Date().toISOString() },
|
||||||
|
{ onConflict: 'user_id,podcast_id' }
|
||||||
|
)
|
||||||
|
if (error) console.error('[player] save progress failed:', error)
|
||||||
|
else console.log('[player] saved progress:', podcastId, Math.floor(seconds), 'seconds')
|
||||||
|
}
|
||||||
|
|
||||||
|
function debouncedSaveProgress(podcastId: string, seconds: number, duration: number) {
|
||||||
|
if (saveTimeout) clearTimeout(saveTimeout)
|
||||||
|
saveTimeout = setTimeout(() => saveProgressNow(podcastId, seconds, duration), 5000)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchSavedProgress(podcastId: string): Promise<number> {
|
||||||
|
const userId = useAuthStore.getState().user?.id
|
||||||
|
if (!userId) return 0
|
||||||
|
const { data } = await supabase
|
||||||
|
.from('listen_history')
|
||||||
|
.select('progress_seconds')
|
||||||
|
.eq('user_id', userId)
|
||||||
|
.eq('podcast_id', podcastId)
|
||||||
|
.maybeSingle()
|
||||||
|
const saved = data?.progress_seconds || 0
|
||||||
|
console.log('[player] fetched progress:', podcastId, saved, 'seconds, data:', data)
|
||||||
|
return saved > 5 ? saved : 0
|
||||||
|
}
|
||||||
|
|
||||||
// ──── Store ────
|
// ──── Store ────
|
||||||
interface PlayerState {
|
interface PlayerState {
|
||||||
current: Podcast | null
|
current: Podcast | null
|
||||||
@ -102,7 +139,7 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|||||||
const external = isExternalUrl(podcast.audio_url)
|
const external = isExternalUrl(podcast.audio_url)
|
||||||
const embed = external ? getEmbedInfo(podcast.audio_url) : null
|
const embed = external ? getEmbedInfo(podcast.audio_url) : null
|
||||||
|
|
||||||
// Resume same podcast
|
// Resume same podcast (already loaded)
|
||||||
if (current?.id === podcast.id) {
|
if (current?.id === podcast.id) {
|
||||||
if (external && embed?.platform === 'youtube' && ytPlayer) {
|
if (external && embed?.platform === 'youtube' && ytPlayer) {
|
||||||
try { ytPlayer.playVideo() } catch { /* ignore */ }
|
try { ytPlayer.playVideo() } catch { /* ignore */ }
|
||||||
@ -122,7 +159,11 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop previous
|
// Stop & save previous
|
||||||
|
const prev = get().current
|
||||||
|
const savePrevPromise = prev
|
||||||
|
? saveProgressNow(prev.id, get().progress, get().duration)
|
||||||
|
: Promise.resolve()
|
||||||
if (audio) { audio.pause(); audio.removeAttribute('src') }
|
if (audio) { audio.pause(); audio.removeAttribute('src') }
|
||||||
destroyAll()
|
destroyAll()
|
||||||
|
|
||||||
@ -130,8 +171,9 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|||||||
if (external && embed?.platform === 'youtube') {
|
if (external && embed?.platform === 'youtube') {
|
||||||
set({ audio: null, current: podcast, isPlaying: false, isExternal: true, progress: 0, duration: podcast.duration_seconds || 0 })
|
set({ audio: null, current: podcast, isPlaying: false, isExternal: true, progress: 0, duration: podcast.duration_seconds || 0 })
|
||||||
|
|
||||||
|
// Wait for previous save to complete, then fetch saved progress
|
||||||
|
savePrevPromise.then(() => fetchSavedProgress(podcast.id)).then((savedTime) => {
|
||||||
loadYouTubeAPI().then(() => {
|
loadYouTubeAPI().then(() => {
|
||||||
// Create hidden container
|
|
||||||
ytContainer = document.createElement('div')
|
ytContainer = document.createElement('div')
|
||||||
ytContainer.style.cssText = 'position:fixed;width:1px;height:1px;left:-10px;top:-10px;opacity:0;pointer-events:none;'
|
ytContainer.style.cssText = 'position:fixed;width:1px;height:1px;left:-10px;top:-10px;opacity:0;pointer-events:none;'
|
||||||
const playerDiv = document.createElement('div')
|
const playerDiv = document.createElement('div')
|
||||||
@ -148,6 +190,7 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|||||||
fs: 0,
|
fs: 0,
|
||||||
modestbranding: 1,
|
modestbranding: 1,
|
||||||
rel: 0,
|
rel: 0,
|
||||||
|
start: savedTime > 0 ? Math.floor(savedTime) : undefined,
|
||||||
},
|
},
|
||||||
events: {
|
events: {
|
||||||
onReady: (e: any) => {
|
onReady: (e: any) => {
|
||||||
@ -155,13 +198,19 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|||||||
e.target.playVideo()
|
e.target.playVideo()
|
||||||
const dur = e.target.getDuration()
|
const dur = e.target.getDuration()
|
||||||
if (dur > 0) set({ duration: dur })
|
if (dur > 0) set({ duration: dur })
|
||||||
|
if (savedTime > 0) {
|
||||||
|
e.target.seekTo(savedTime, true)
|
||||||
|
set({ progress: savedTime })
|
||||||
|
}
|
||||||
set({ isPlaying: true })
|
set({ isPlaying: true })
|
||||||
startYtProgressTracking()
|
startYtProgressTracking()
|
||||||
},
|
},
|
||||||
onStateChange: (e: any) => {
|
onStateChange: (e: any) => {
|
||||||
// YT.PlayerState: 0=ENDED, 1=PLAYING, 2=PAUSED
|
|
||||||
if (e.data === 0) {
|
if (e.data === 0) {
|
||||||
|
// Ended
|
||||||
clearProgressInterval()
|
clearProgressInterval()
|
||||||
|
const podId = usePlayerStore.getState().current?.id
|
||||||
|
if (podId) saveProgressNow(podId, e.target.getDuration(), e.target.getDuration(), true)
|
||||||
set({ isPlaying: false, progress: 0 })
|
set({ isPlaying: false, progress: 0 })
|
||||||
} else if (e.data === 1) {
|
} else if (e.data === 1) {
|
||||||
const dur = e.target.getDuration()
|
const dur = e.target.getDuration()
|
||||||
@ -170,12 +219,16 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|||||||
startYtProgressTracking()
|
startYtProgressTracking()
|
||||||
} else if (e.data === 2) {
|
} else if (e.data === 2) {
|
||||||
clearProgressInterval()
|
clearProgressInterval()
|
||||||
|
// Save on pause
|
||||||
|
const state = usePlayerStore.getState()
|
||||||
|
if (state.current) saveProgressNow(state.current.id, state.progress, state.duration)
|
||||||
set({ isPlaying: false })
|
set({ isPlaying: false })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -192,11 +245,20 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|||||||
|
|
||||||
newAudio.addEventListener('timeupdate', () => {
|
newAudio.addEventListener('timeupdate', () => {
|
||||||
set({ progress: newAudio.currentTime })
|
set({ progress: newAudio.currentTime })
|
||||||
|
debouncedSaveProgress(podcast.id, newAudio.currentTime, newAudio.duration || 0)
|
||||||
})
|
})
|
||||||
newAudio.addEventListener('loadedmetadata', () => {
|
newAudio.addEventListener('loadedmetadata', () => {
|
||||||
set({ duration: newAudio.duration })
|
set({ duration: newAudio.duration })
|
||||||
|
// Wait for previous save, then restore saved position
|
||||||
|
savePrevPromise.then(() => fetchSavedProgress(podcast.id)).then((savedTime) => {
|
||||||
|
if (savedTime > 0 && savedTime < newAudio.duration - 5) {
|
||||||
|
newAudio.currentTime = savedTime
|
||||||
|
set({ progress: savedTime })
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
newAudio.addEventListener('ended', () => {
|
newAudio.addEventListener('ended', () => {
|
||||||
|
saveProgressNow(podcast.id, newAudio.duration, newAudio.duration, true)
|
||||||
set({ isPlaying: false, progress: 0 })
|
set({ isPlaying: false, progress: 0 })
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -205,17 +267,17 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
toggle: () => {
|
toggle: () => {
|
||||||
const { audio, isPlaying, isExternal, current } = get()
|
const { audio, isPlaying, isExternal, current, progress, duration } = get()
|
||||||
|
|
||||||
if (isExternal) {
|
if (isExternal) {
|
||||||
const embed = current ? getEmbedInfo(current.audio_url) : null
|
const embed = current ? getEmbedInfo(current.audio_url) : null
|
||||||
|
|
||||||
// YouTube: proper pause/play
|
|
||||||
if (embed?.platform === 'youtube' && ytPlayer) {
|
if (embed?.platform === 'youtube' && ytPlayer) {
|
||||||
try {
|
try {
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
ytPlayer.pauseVideo()
|
ytPlayer.pauseVideo()
|
||||||
clearProgressInterval()
|
clearProgressInterval()
|
||||||
|
if (current) saveProgressNow(current.id, progress, duration)
|
||||||
} else {
|
} else {
|
||||||
ytPlayer.playVideo()
|
ytPlayer.playVideo()
|
||||||
startYtProgressTracking()
|
startYtProgressTracking()
|
||||||
@ -225,7 +287,6 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Other external: destroy/recreate iframe
|
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
destroyIframe()
|
destroyIframe()
|
||||||
} else if (embed) {
|
} else if (embed) {
|
||||||
@ -238,6 +299,7 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|||||||
if (!audio) return
|
if (!audio) return
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
audio.pause()
|
audio.pause()
|
||||||
|
if (current) saveProgressNow(current.id, progress, duration)
|
||||||
} else {
|
} else {
|
||||||
audio.play()
|
audio.play()
|
||||||
}
|
}
|
||||||
@ -245,7 +307,9 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
pause: () => {
|
pause: () => {
|
||||||
const { isExternal, current } = get()
|
const { isExternal, current, progress, duration } = get()
|
||||||
|
if (current) saveProgressNow(current.id, progress, duration)
|
||||||
|
|
||||||
if (isExternal) {
|
if (isExternal) {
|
||||||
const embed = current ? getEmbedInfo(current.audio_url) : null
|
const embed = current ? getEmbedInfo(current.audio_url) : null
|
||||||
if (embed?.platform === 'youtube' && ytPlayer) {
|
if (embed?.platform === 'youtube' && ytPlayer) {
|
||||||
@ -262,7 +326,6 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|||||||
seek: (time) => {
|
seek: (time) => {
|
||||||
const { audio, isExternal, current } = get()
|
const { audio, isExternal, current } = get()
|
||||||
|
|
||||||
// YouTube seek
|
|
||||||
if (isExternal) {
|
if (isExternal) {
|
||||||
const embed = current ? getEmbedInfo(current.audio_url) : null
|
const embed = current ? getEmbedInfo(current.audio_url) : null
|
||||||
if (embed?.platform === 'youtube' && ytPlayer) {
|
if (embed?.platform === 'youtube' && ytPlayer) {
|
||||||
@ -270,7 +333,6 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|||||||
set({ progress: time })
|
set({ progress: time })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Can't seek other external types
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -300,6 +362,8 @@ function startYtProgressTracking() {
|
|||||||
const dur = ytPlayer.getDuration()
|
const dur = ytPlayer.getDuration()
|
||||||
usePlayerStore.setState({ progress: time })
|
usePlayerStore.setState({ progress: time })
|
||||||
if (dur > 0) usePlayerStore.setState({ duration: dur })
|
if (dur > 0) usePlayerStore.setState({ duration: dur })
|
||||||
|
const podId = usePlayerStore.getState().current?.id
|
||||||
|
if (podId) debouncedSaveProgress(podId, time, dur)
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
}, 500)
|
}, 500)
|
||||||
|
|||||||
21
supabase/migrations/003_listening_progress.sql
Normal file
21
supabase/migrations/003_listening_progress.sql
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
-- Add progress tracking to listen_history
|
||||||
|
ALTER TABLE public.listen_history
|
||||||
|
ADD COLUMN IF NOT EXISTS progress_seconds integer NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS completed boolean NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
-- Unique constraint for upsert (one row per user+podcast)
|
||||||
|
ALTER TABLE public.listen_history
|
||||||
|
ADD CONSTRAINT listen_history_user_podcast_unique UNIQUE (user_id, podcast_id);
|
||||||
|
|
||||||
|
-- Allow users to update their own progress
|
||||||
|
CREATE POLICY "Users can update own history" ON public.listen_history
|
||||||
|
FOR UPDATE USING (auth.uid() = user_id);
|
||||||
|
|
||||||
|
-- Allow users to delete own history
|
||||||
|
CREATE POLICY "Users can delete own history" ON public.listen_history
|
||||||
|
FOR DELETE USING (auth.uid() = user_id);
|
||||||
|
|
||||||
|
-- Index for "continue listening" query
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_listen_history_resume
|
||||||
|
ON public.listen_history(user_id, listened_at DESC)
|
||||||
|
WHERE NOT completed;
|
||||||
Loading…
x
Reference in New Issue
Block a user