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,55 +91,54 @@ export function Home() {
return ( return (
<div className="space-y-14"> <div className="space-y-14">
{/* Hero */} {/* Hero — only for visitors */}
<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"> {!user && (
{/* Decorative blobs */} <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">
<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>
<h1 className="text-[2.5rem] sm:text-[3.5rem] font-heading font-extrabold tracking-tight leading-[1.1] max-w-2xl mx-auto"> <h1 className="text-[2.5rem] sm:text-[3.5rem] font-heading font-extrabold tracking-tight leading-[1.1] max-w-2xl mx-auto">
Votre voix compte,{' '} Votre voix compte,{' '}
<span className="gradient-text">partagez-la.</span> <span className="gradient-text">partagez-la.</span>
</h1> </h1>
<p className="text-text-secondary mt-4 text-base sm:text-lg max-w-md mx-auto leading-relaxed"> <p className="text-text-secondary mt-4 text-base sm:text-lg max-w-md mx-auto leading-relaxed">
La plateforme de podcast ou tout le monde peut creer, ecouter et decouvrir des voix authentiques. La plateforme de podcast ou tout le monde peut creer, ecouter et decouvrir des voix authentiques.
</p> </p>
<div className="flex items-center justify-center gap-3 mt-8"> <div className="flex items-center justify-center gap-3 mt-8">
<Button size="lg" onClick={() => navigate('/explore')}> <Button size="lg" onClick={() => navigate('/explore')}>
Decouvrir Decouvrir
<ArrowRight size={17} /> <ArrowRight size={17} />
</Button> </Button>
<Button variant="secondary" size="lg" onClick={() => navigate('/auth')}> <Button variant="secondary" size="lg" onClick={() => navigate('/auth')}>
Creer un compte Creer un compte
</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: formatStat(stats.plays) },
{ icon: Headphones, label: 'Ecoutes', value: '2.4k' }, { icon: Users, label: 'Createurs', value: formatStat(stats.creators) },
{ icon: Users, label: 'Createurs', value: '128' }, { icon: Mic, label: 'Podcasts', value: formatStat(stats.podcasts) },
{ icon: Mic, label: 'Podcasts', value: '340' }, ].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"> <Icon size={14} strokeWidth={2.5} />
<Icon size={14} strokeWidth={2.5} /> <span className="font-heading font-extrabold text-xl">{value}</span>
<span className="font-heading font-extrabold text-xl">{value}</span> </div>
<span className="text-[11px] text-text-secondary font-medium uppercase tracking-wider">{label}</span>
</div> </div>
<span className="text-[11px] text-text-secondary font-medium uppercase tracking-wider">{label}</span> ))}
</div> </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>
{!isOwn && user && ( <div className="flex items-center gap-2 mt-4">
<div className="mt-4"> {!isOwn && user && (
<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;