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 { 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' }]),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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 && (
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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 })
|
||||||
|
|||||||
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