add listen count
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 39s
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 39s
This commit is contained in:
parent
828b3b09e9
commit
2fbccfac10
@ -1,5 +1,5 @@
|
||||
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 { useAuthStore } from '@/stores/auth'
|
||||
import { usePlayerStore } from '@/stores/player'
|
||||
@ -11,11 +11,11 @@ export function MobileNav() {
|
||||
const links = [
|
||||
{ to: '/', icon: Home, label: 'Accueil' },
|
||||
{ to: '/explore', icon: Compass, label: 'Explorer' },
|
||||
{ to: '/trending', icon: TrendingUp, label: 'Tendances' },
|
||||
...(user
|
||||
? [
|
||||
{ to: '/upload', icon: Upload, label: 'Publier' },
|
||||
{ to: '/favorites', icon: Heart, label: 'Favoris' },
|
||||
{ to: `/profile/${profile?.username || user.id}`, icon: User, label: 'Profil' },
|
||||
{ to: '/for-you', icon: Sparkles, label: 'Pour toi' },
|
||||
]
|
||||
: [{ to: '/auth', icon: User, label: 'Connexion' }]),
|
||||
]
|
||||
|
||||
@ -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 type { Podcast } from '@/types'
|
||||
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">
|
||||
<span className="flex items-center gap-1">
|
||||
<Headphones size={11} />
|
||||
{podcast.plays_count || 0}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Heart size={11} />
|
||||
{podcast.likes_count || 0}
|
||||
|
||||
@ -8,6 +8,12 @@ import { PodcastCard } from '@/components/podcast/PodcastCard'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
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 {
|
||||
progressPercent: number
|
||||
}
|
||||
@ -17,6 +23,7 @@ export function Home() {
|
||||
const [recent, setRecent] = useState<Podcast[]>([])
|
||||
const [inProgress, setInProgress] = useState<InProgressPodcast[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [stats, setStats] = useState({ plays: 0, creators: 0, podcasts: 0 })
|
||||
const { user } = useAuthStore()
|
||||
const navigate = useNavigate()
|
||||
|
||||
@ -66,6 +73,17 @@ export function Home() {
|
||||
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)
|
||||
}
|
||||
load()
|
||||
@ -73,55 +91,54 @@ export function Home() {
|
||||
|
||||
return (
|
||||
<div className="space-y-14">
|
||||
{/* Hero */}
|
||||
<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 -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" />
|
||||
{/* 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">
|
||||
<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 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">
|
||||
{/* 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]">
|
||||
<Mic size={28} className="text-white rotate-[6deg]" />
|
||||
</div>
|
||||
<div className="relative z-10 px-6 sm:px-10 py-14 sm:py-20 text-center">
|
||||
<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]" />
|
||||
</div>
|
||||
|
||||
<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,{' '}
|
||||
<span className="gradient-text">partagez-la.</span>
|
||||
</h1>
|
||||
<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.
|
||||
</p>
|
||||
<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,{' '}
|
||||
<span className="gradient-text">partagez-la.</span>
|
||||
</h1>
|
||||
<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.
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-center gap-3 mt-8">
|
||||
<Button size="lg" onClick={() => navigate('/explore')}>
|
||||
Decouvrir
|
||||
<ArrowRight size={17} />
|
||||
</Button>
|
||||
<Button variant="secondary" size="lg" onClick={() => navigate('/auth')}>
|
||||
Creer un compte
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-3 mt-8">
|
||||
<Button size="lg" onClick={() => navigate('/explore')}>
|
||||
Decouvrir
|
||||
<ArrowRight size={17} />
|
||||
</Button>
|
||||
<Button variant="secondary" size="lg" onClick={() => navigate('/auth')}>
|
||||
Creer un compte
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<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: Users, label: 'Createurs', value: '128' },
|
||||
{ icon: Mic, label: 'Podcasts', value: '340' },
|
||||
].map(({ icon: Icon, label, value }) => (
|
||||
<div key={label} className="text-center">
|
||||
<div className="flex items-center justify-center gap-1.5 text-primary mb-0.5">
|
||||
<Icon size={14} strokeWidth={2.5} />
|
||||
<span className="font-heading font-extrabold text-xl">{value}</span>
|
||||
<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: Users, label: 'Createurs', value: formatStat(stats.creators) },
|
||||
{ icon: Mic, label: 'Podcasts', value: formatStat(stats.podcasts) },
|
||||
].map(({ icon: Icon, label, value }) => (
|
||||
<div key={label} className="text-center">
|
||||
<div className="flex items-center justify-center gap-1.5 text-primary mb-0.5">
|
||||
<Icon size={14} strokeWidth={2.5} />
|
||||
<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>
|
||||
<span className="text-[11px] text-text-secondary font-medium uppercase tracking-wider">{label}</span>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Continue listening */}
|
||||
{inProgress.length > 0 && (
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { Users, Headphones } from 'lucide-react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { Users, Headphones, Settings } from 'lucide-react'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { Profile as ProfileType, Podcast } from '@/types'
|
||||
@ -11,6 +11,7 @@ import { PodcastCard } from '@/components/podcast/PodcastCard'
|
||||
export function Profile() {
|
||||
const { username } = useParams<{ username: string }>()
|
||||
const { user } = useAuthStore()
|
||||
const navigate = useNavigate()
|
||||
const [profile, setProfile] = useState<ProfileType | null>(null)
|
||||
const [podcasts, setPodcasts] = useState<Podcast[]>([])
|
||||
const [isFollowing, setIsFollowing] = useState(false)
|
||||
@ -116,8 +117,8 @@ export function Profile() {
|
||||
<span>{followingCount} abonnements</span>
|
||||
</div>
|
||||
|
||||
{!isOwn && user && (
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center gap-2 mt-4">
|
||||
{!isOwn && user && (
|
||||
<Button
|
||||
variant={isFollowing ? 'secondary' : 'primary'}
|
||||
size="sm"
|
||||
@ -125,8 +126,18 @@ export function Profile() {
|
||||
>
|
||||
{isFollowing ? 'Abonné' : 'S\'abonner'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
{isOwn && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => navigate('/settings')}
|
||||
>
|
||||
<Settings size={15} />
|
||||
Paramètres
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -215,6 +215,7 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
|
||||
// Resume same podcast (already loaded)
|
||||
if (current?.id === podcast.id) {
|
||||
// Don't count resume as a new play
|
||||
if (external && embed?.platform === 'youtube' && ytPlayer) {
|
||||
try { ytPlayer.playVideo() } catch { /* ignore */ }
|
||||
startYtProgressTracking()
|
||||
@ -241,6 +242,17 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
if (audio) { audio.pause(); audio.removeAttribute('src') }
|
||||
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 ──
|
||||
if (external && embed?.platform === 'youtube') {
|
||||
set({ audio: null, current: podcast, isPlaying: false, isExternal: true, progress: 0, duration: podcast.duration_seconds || 0 })
|
||||
|
||||
9
supabase/migrations/010_increment_plays.sql
Normal file
9
supabase/migrations/010_increment_plays.sql
Normal 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;
|
||||
Loading…
x
Reference in New Issue
Block a user