add listen count
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 39s

This commit is contained in:
ordinarthur 2026-04-13 16:14:50 +02:00
parent 828b3b09e9
commit 2fbccfac10
6 changed files with 106 additions and 53 deletions

View File

@ -1,5 +1,5 @@
import { NavLink } from 'react-router-dom' import { NavLink } from 'react-router-dom'
import { Home, Compass, Upload, Heart, User } from 'lucide-react' import { Home, Compass, Upload, TrendingUp, User, Sparkles } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { usePlayerStore } from '@/stores/player' import { usePlayerStore } from '@/stores/player'
@ -11,11 +11,11 @@ export function MobileNav() {
const links = [ const links = [
{ to: '/', icon: Home, label: 'Accueil' }, { to: '/', icon: Home, label: 'Accueil' },
{ to: '/explore', icon: Compass, label: 'Explorer' }, { to: '/explore', icon: Compass, label: 'Explorer' },
{ to: '/trending', icon: TrendingUp, label: 'Tendances' },
...(user ...(user
? [ ? [
{ to: '/upload', icon: Upload, label: 'Publier' }, { to: '/upload', icon: Upload, label: 'Publier' },
{ to: '/favorites', icon: Heart, label: 'Favoris' }, { to: '/for-you', icon: Sparkles, label: 'Pour toi' },
{ to: `/profile/${profile?.username || user.id}`, icon: User, label: 'Profil' },
] ]
: [{ to: '/auth', icon: User, label: 'Connexion' }]), : [{ to: '/auth', icon: User, label: 'Connexion' }]),
] ]

View File

@ -1,4 +1,4 @@
import { Play, Pause, Heart, MessageCircle, Clock, ListPlus, Download, Check, Loader2, BookmarkPlus } from 'lucide-react' import { Play, Pause, Heart, MessageCircle, Clock, ListPlus, Download, Check, Loader2, BookmarkPlus, Headphones } from 'lucide-react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import type { Podcast } from '@/types' import type { Podcast } from '@/types'
import { formatDuration, timeAgo } from '@/lib/utils' import { formatDuration, timeAgo } from '@/lib/utils'
@ -165,6 +165,10 @@ export function PodcastCard({ podcast, progressPercent: propProgress }: PodcastC
)} )}
<div className="flex items-center gap-3 mt-2.5 pt-2.5 border-t border-border-light text-text-secondary text-[11px] font-medium"> <div className="flex items-center gap-3 mt-2.5 pt-2.5 border-t border-border-light text-text-secondary text-[11px] font-medium">
<span className="flex items-center gap-1">
<Headphones size={11} />
{podcast.plays_count || 0}
</span>
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<Heart size={11} /> <Heart size={11} />
{podcast.likes_count || 0} {podcast.likes_count || 0}

View File

@ -8,6 +8,12 @@ import { PodcastCard } from '@/components/podcast/PodcastCard'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import { normalizePodcasts } from '@/lib/utils' import { normalizePodcasts } from '@/lib/utils'
function formatStat(n: number): string {
if (n >= 1000000) return `${(n / 1000000).toFixed(1).replace(/\.0$/, '')}M`
if (n >= 1000) return `${(n / 1000).toFixed(1).replace(/\.0$/, '')}k`
return n.toString()
}
interface InProgressPodcast extends Podcast { interface InProgressPodcast extends Podcast {
progressPercent: number progressPercent: number
} }
@ -17,6 +23,7 @@ export function Home() {
const [recent, setRecent] = useState<Podcast[]>([]) const [recent, setRecent] = useState<Podcast[]>([])
const [inProgress, setInProgress] = useState<InProgressPodcast[]>([]) const [inProgress, setInProgress] = useState<InProgressPodcast[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [stats, setStats] = useState({ plays: 0, creators: 0, podcasts: 0 })
const { user } = useAuthStore() const { user } = useAuthStore()
const navigate = useNavigate() const navigate = useNavigate()
@ -66,6 +73,17 @@ export function Home() {
setInProgress(items) setInProgress(items)
} }
// Fetch real stats for hero
if (!user) {
const [{ count: podcastCount }, { data: playsData }, { count: creatorCount }] = await Promise.all([
supabase.from('podcasts').select('*', { count: 'exact', head: true }),
supabase.from('podcasts').select('plays_count'),
supabase.from('profiles').select('*', { count: 'exact', head: true }),
])
const totalPlays = playsData?.reduce((sum: number, p: any) => sum + (p.plays_count || 0), 0) || 0
setStats({ plays: totalPlays, creators: creatorCount || 0, podcasts: podcastCount || 0 })
}
setLoading(false) setLoading(false)
} }
load() load()
@ -73,15 +91,14 @@ export function Home() {
return ( return (
<div className="space-y-14"> <div className="space-y-14">
{/* Hero */} {/* Hero — only for visitors */}
{!user && (
<section className="relative overflow-hidden rounded-[2rem] bg-gradient-to-br from-primary/[0.06] via-surface to-accent/[0.04] border border-border/50 noise-bg"> <section className="relative overflow-hidden rounded-[2rem] bg-gradient-to-br from-primary/[0.06] via-surface to-accent/[0.04] border border-border/50 noise-bg">
{/* Decorative blobs */}
<div className="absolute -top-20 -right-20 w-64 h-64 bg-primary/[0.07] rounded-full blur-3xl" /> <div className="absolute -top-20 -right-20 w-64 h-64 bg-primary/[0.07] rounded-full blur-3xl" />
<div className="absolute -bottom-16 -left-16 w-48 h-48 bg-accent/[0.06] rounded-full blur-3xl" /> <div className="absolute -bottom-16 -left-16 w-48 h-48 bg-accent/[0.06] rounded-full blur-3xl" />
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-96 h-96 bg-mint/[0.03] rounded-full blur-3xl" /> <div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-96 h-96 bg-mint/[0.03] rounded-full blur-3xl" />
<div className="relative z-10 px-6 sm:px-10 py-14 sm:py-20 text-center"> <div className="relative z-10 px-6 sm:px-10 py-14 sm:py-20 text-center">
{/* Floating mic icon */}
<div className="inline-flex items-center justify-center w-16 h-16 rounded-[1.25rem] bg-gradient-to-br from-primary to-[#7B6AEF] shadow-[0_8px_30px_rgba(91,76,219,0.3)] mb-6 rotate-[-6deg]"> <div className="inline-flex items-center justify-center w-16 h-16 rounded-[1.25rem] bg-gradient-to-br from-primary to-[#7B6AEF] shadow-[0_8px_30px_rgba(91,76,219,0.3)] mb-6 rotate-[-6deg]">
<Mic size={28} className="text-white rotate-[6deg]" /> <Mic size={28} className="text-white rotate-[6deg]" />
</div> </div>
@ -104,12 +121,11 @@ export function Home() {
</Button> </Button>
</div> </div>
{/* Stats */}
<div className="flex items-center justify-center gap-8 sm:gap-12 mt-10 pt-8 border-t border-border/40"> <div className="flex items-center justify-center gap-8 sm:gap-12 mt-10 pt-8 border-t border-border/40">
{[ {[
{ icon: Headphones, label: 'Ecoutes', value: '2.4k' }, { icon: Headphones, label: 'Ecoutes', value: formatStat(stats.plays) },
{ icon: Users, label: 'Createurs', value: '128' }, { icon: Users, label: 'Createurs', value: formatStat(stats.creators) },
{ icon: Mic, label: 'Podcasts', value: '340' }, { icon: Mic, label: 'Podcasts', value: formatStat(stats.podcasts) },
].map(({ icon: Icon, label, value }) => ( ].map(({ icon: Icon, label, value }) => (
<div key={label} className="text-center"> <div key={label} className="text-center">
<div className="flex items-center justify-center gap-1.5 text-primary mb-0.5"> <div className="flex items-center justify-center gap-1.5 text-primary mb-0.5">
@ -122,6 +138,7 @@ export function Home() {
</div> </div>
</div> </div>
</section> </section>
)}
{/* Continue listening */} {/* Continue listening */}
{inProgress.length > 0 && ( {inProgress.length > 0 && (

View File

@ -1,6 +1,6 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useParams } from 'react-router-dom' import { useParams, useNavigate } from 'react-router-dom'
import { Users, Headphones } from 'lucide-react' import { Users, Headphones, Settings } from 'lucide-react'
import { supabase } from '@/lib/supabase' import { supabase } from '@/lib/supabase'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import type { Profile as ProfileType, Podcast } from '@/types' import type { Profile as ProfileType, Podcast } from '@/types'
@ -11,6 +11,7 @@ import { PodcastCard } from '@/components/podcast/PodcastCard'
export function Profile() { export function Profile() {
const { username } = useParams<{ username: string }>() const { username } = useParams<{ username: string }>()
const { user } = useAuthStore() const { user } = useAuthStore()
const navigate = useNavigate()
const [profile, setProfile] = useState<ProfileType | null>(null) const [profile, setProfile] = useState<ProfileType | null>(null)
const [podcasts, setPodcasts] = useState<Podcast[]>([]) const [podcasts, setPodcasts] = useState<Podcast[]>([])
const [isFollowing, setIsFollowing] = useState(false) const [isFollowing, setIsFollowing] = useState(false)
@ -116,8 +117,8 @@ export function Profile() {
<span>{followingCount} abonnements</span> <span>{followingCount} abonnements</span>
</div> </div>
<div className="flex items-center gap-2 mt-4">
{!isOwn && user && ( {!isOwn && user && (
<div className="mt-4">
<Button <Button
variant={isFollowing ? 'secondary' : 'primary'} variant={isFollowing ? 'secondary' : 'primary'}
size="sm" size="sm"
@ -125,8 +126,18 @@ export function Profile() {
> >
{isFollowing ? 'Abonné' : 'S\'abonner'} {isFollowing ? 'Abonné' : 'S\'abonner'}
</Button> </Button>
</div>
)} )}
{isOwn && (
<Button
variant="secondary"
size="sm"
onClick={() => navigate('/settings')}
>
<Settings size={15} />
Paramètres
</Button>
)}
</div>
</div> </div>
</div> </div>

View File

@ -215,6 +215,7 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
// Resume same podcast (already loaded) // Resume same podcast (already loaded)
if (current?.id === podcast.id) { if (current?.id === podcast.id) {
// Don't count resume as a new play
if (external && embed?.platform === 'youtube' && ytPlayer) { if (external && embed?.platform === 'youtube' && ytPlayer) {
try { ytPlayer.playVideo() } catch { /* ignore */ } try { ytPlayer.playVideo() } catch { /* ignore */ }
startYtProgressTracking() startYtProgressTracking()
@ -241,6 +242,17 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
if (audio) { audio.pause(); audio.removeAttribute('src') } if (audio) { audio.pause(); audio.removeAttribute('src') }
destroyAll() destroyAll()
// Count as a play: increment plays_count via RPC (SECURITY DEFINER bypasses RLS)
supabase.rpc('increment_plays', { podcast_id: podcast.id })
.then(({ error }) => { if (error) console.error('[player] increment_plays RPC failed — did you run migration 010?', error) })
const userId = useAuthStore.getState().user?.id
if (userId) {
supabase.from('listen_history').upsert(
{ user_id: userId, podcast_id: podcast.id, progress_seconds: 0, completed: false, listened_at: new Date().toISOString() },
{ onConflict: 'user_id,podcast_id' }
).then(() => {}, () => {})
}
// ── YouTube: use IFrame API for full control ── // ── YouTube: use IFrame API for full control ──
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 })

View File

@ -0,0 +1,9 @@
-- RPC to atomically increment plays_count
CREATE OR REPLACE FUNCTION increment_plays(podcast_id uuid)
RETURNS void AS $$
BEGIN
UPDATE podcasts
SET plays_count = plays_count + 1
WHERE id = podcast_id;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;