From 828b3b09e92618e6a190795daf1fe8ccadbaaa13 Mon Sep 17 00:00:00 2001 From: ordinarthur <@arthurbarre.js@gmail.com> Date: Mon, 13 Apr 2026 15:52:26 +0200 Subject: [PATCH] add many things 2 --- src/components/layout/Layout.tsx | 6 + src/components/layout/Navbar.tsx | 2 + src/components/layout/NotificationBell.tsx | 87 ++++++++++ src/components/layout/Sidebar.tsx | 8 +- .../playlist/AddToPlaylistModal.tsx | 120 ++++++++++++++ src/components/podcast/PodcastCard.tsx | 14 +- src/hooks/useNotificationPolling.ts | 27 ++++ src/lib/supabase.ts | 8 +- src/lib/utils.ts | 28 ++++ src/pages/Dashboard.tsx | 125 +++++++++++++++ src/pages/Following.tsx | 78 +++++++++ src/pages/ForYou.tsx | 105 ++++++++++++ src/pages/Home.tsx | 9 +- src/pages/MyPodcasts.tsx | 61 ++++++- src/pages/MyShows.tsx | 133 +++++++++++++++ src/pages/Notifications.tsx | 81 ++++++++++ src/pages/PlaylistDetail.tsx | 151 ++++++++++++++++++ src/pages/Playlists.tsx | 129 +++++++++++++++ src/pages/PodcastDetail.tsx | 45 +++++- src/pages/ShowDetail.tsx | 128 +++++++++++++++ src/pages/Trending.tsx | 55 +++++++ src/pages/Upload.tsx | 60 ++++++- src/stores/notifications.ts | 47 ++++++ src/stores/playlist-modal.ts | 13 ++ src/types/index.ts | 40 +++++ supabase/migrations/006_playlists.sql | 60 +++++++ supabase/migrations/007_notifications.sql | 130 +++++++++++++++ supabase/migrations/008_shows.sql | 25 +++ .../migrations/009_scheduled_publishing.sql | 29 ++++ 29 files changed, 1782 insertions(+), 22 deletions(-) create mode 100644 src/components/layout/NotificationBell.tsx create mode 100644 src/components/playlist/AddToPlaylistModal.tsx create mode 100644 src/hooks/useNotificationPolling.ts create mode 100644 src/pages/Dashboard.tsx create mode 100644 src/pages/Following.tsx create mode 100644 src/pages/ForYou.tsx create mode 100644 src/pages/MyShows.tsx create mode 100644 src/pages/Notifications.tsx create mode 100644 src/pages/PlaylistDetail.tsx create mode 100644 src/pages/Playlists.tsx create mode 100644 src/pages/ShowDetail.tsx create mode 100644 src/pages/Trending.tsx create mode 100644 src/stores/notifications.ts create mode 100644 src/stores/playlist-modal.ts create mode 100644 supabase/migrations/006_playlists.sql create mode 100644 supabase/migrations/007_notifications.sql create mode 100644 supabase/migrations/008_shows.sql create mode 100644 supabase/migrations/009_scheduled_publishing.sql diff --git a/src/components/layout/Layout.tsx b/src/components/layout/Layout.tsx index b016403..d37c83c 100644 --- a/src/components/layout/Layout.tsx +++ b/src/components/layout/Layout.tsx @@ -3,10 +3,15 @@ import { Navbar } from './Navbar' import { Sidebar } from './Sidebar' import { PlayerBar } from './PlayerBar' import { MobileNav } from './MobileNav' +import { AddToPlaylistModal } from '@/components/playlist/AddToPlaylistModal' import { usePlayerStore } from '@/stores/player' +import { usePlaylistModal } from '@/stores/playlist-modal' +import { useNotificationPolling } from '@/hooks/useNotificationPolling' export function Layout() { const current = usePlayerStore((s) => s.current) + const playlistPodcastId = usePlaylistModal((s) => s.podcastId) + useNotificationPolling() return (
@@ -19,6 +24,7 @@ export function Layout() {
+ {playlistPodcastId && } ) } diff --git a/src/components/layout/Navbar.tsx b/src/components/layout/Navbar.tsx index 7b32954..880b476 100644 --- a/src/components/layout/Navbar.tsx +++ b/src/components/layout/Navbar.tsx @@ -3,6 +3,7 @@ import { Search, Upload, LogIn } from 'lucide-react' import { useAuthStore } from '@/stores/auth' import { Avatar } from '@/components/ui/Avatar' import { Button } from '@/components/ui/Button' +import { NotificationBell } from './NotificationBell' export function Navbar() { const { user, profile } = useAuthStore() @@ -41,6 +42,7 @@ export function Navbar() { Publier + diff --git a/src/components/layout/NotificationBell.tsx b/src/components/layout/NotificationBell.tsx new file mode 100644 index 0000000..2123e1f --- /dev/null +++ b/src/components/layout/NotificationBell.tsx @@ -0,0 +1,87 @@ +import { useState, useRef, useEffect } from 'react' +import { Bell } from 'lucide-react' +import { useNavigate } from 'react-router-dom' +import { useNotificationsStore } from '@/stores/notifications' +import { useAuthStore } from '@/stores/auth' +import { timeAgo } from '@/lib/utils' +import type { Notification } from '@/types' + +function notificationText(n: Notification): string { + const username = n.data?.username || 'Quelqu\'un' + switch (n.type) { + case 'new_podcast': return `${username} a publié un nouveau podcast` + case 'like': return `${username} a aimé votre podcast` + case 'comment': return `${username} a commenté votre podcast` + default: return 'Nouvelle notification' + } +} + +export function NotificationBell() { + const navigate = useNavigate() + const { user } = useAuthStore() + const { notifications, unreadCount, markRead, markAllRead } = useNotificationsStore() + const [open, setOpen] = useState(false) + const ref = useRef(null) + + useEffect(() => { + function handleClick(e: MouseEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) + } + document.addEventListener('mousedown', handleClick) + return () => document.removeEventListener('mousedown', handleClick) + }, []) + + return ( +
+ + + {open && ( +
+
+ Notifications +
+ {unreadCount > 0 && user && ( + + )} + +
+
+
+ {notifications.length === 0 ? ( +

Aucune notification

+ ) : ( + notifications.slice(0, 10).map((n) => ( + + )) + )} +
+
+ )} +
+ ) +} diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 039fb5f..1715874 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -1,16 +1,22 @@ import { NavLink } from 'react-router-dom' -import { Home, Compass, Upload, Heart, User, Settings } from 'lucide-react' +import { Home, Compass, Upload, Heart, User, Settings, TrendingUp, Rss, Sparkles, ListMusic, Layers, BarChart3 } from 'lucide-react' import { cn } from '@/lib/utils' import { useAuthStore } from '@/stores/auth' const publicLinks = [ { to: '/', icon: Home, label: 'Accueil' }, { to: '/explore', icon: Compass, label: 'Explorer' }, + { to: '/trending', icon: TrendingUp, label: 'Tendances' }, + { to: '/for-you', icon: Sparkles, label: 'Pour toi' }, ] const authLinks = [ + { to: '/following', icon: Rss, label: 'Abonnements' }, { to: '/upload', icon: Upload, label: 'Publier' }, + { to: '/playlists', icon: ListMusic, label: 'Playlists' }, { to: '/favorites', icon: Heart, label: 'Favoris' }, + { to: '/my-shows', icon: Layers, label: 'Mes séries' }, + { to: '/dashboard', icon: BarChart3, label: 'Dashboard' }, { to: '/settings', icon: Settings, label: 'Paramètres' }, ] diff --git a/src/components/playlist/AddToPlaylistModal.tsx b/src/components/playlist/AddToPlaylistModal.tsx new file mode 100644 index 0000000..7c66afd --- /dev/null +++ b/src/components/playlist/AddToPlaylistModal.tsx @@ -0,0 +1,120 @@ +import { useEffect, useState } from 'react' +import { X, Plus, Check, ListMusic } from 'lucide-react' +import { supabase } from '@/lib/supabase' +import { useAuthStore } from '@/stores/auth' +import { usePlaylistModal } from '@/stores/playlist-modal' +import { Button } from '@/components/ui/Button' +import type { Playlist } from '@/types' + +export function AddToPlaylistModal() { + const { user } = useAuthStore() + const { podcastId, close } = usePlaylistModal() + const [playlists, setPlaylists] = useState([]) + const [inPlaylists, setInPlaylists] = useState>(new Set()) + const [loading, setLoading] = useState(true) + const [newName, setNewName] = useState('') + const [creating, setCreating] = useState(false) + + useEffect(() => { + if (!podcastId || !user) return + setLoading(true) + + async function load() { + const [{ data: pls }, { data: items }] = await Promise.all([ + supabase.from('playlists').select('*').eq('user_id', user!.id).order('created_at', { ascending: false }), + supabase.from('playlist_items').select('playlist_id').eq('podcast_id', podcastId!), + ]) + if (pls) setPlaylists(pls) + if (items) setInPlaylists(new Set(items.map((i) => i.playlist_id))) + setLoading(false) + } + load() + }, [podcastId, user]) + + if (!podcastId || !user) return null + + async function togglePlaylist(playlistId: string) { + if (inPlaylists.has(playlistId)) { + await supabase.from('playlist_items').delete().eq('playlist_id', playlistId).eq('podcast_id', podcastId!) + setInPlaylists((prev) => { const n = new Set(prev); n.delete(playlistId); return n }) + } else { + const { data: maxItem } = await supabase + .from('playlist_items') + .select('sort_order') + .eq('playlist_id', playlistId) + .order('sort_order', { ascending: false }) + .limit(1) + .maybeSingle() + const nextOrder = (maxItem?.sort_order ?? 0) + 1 + await supabase.from('playlist_items').insert({ playlist_id: playlistId, podcast_id: podcastId!, sort_order: nextOrder }) + setInPlaylists((prev) => new Set(prev).add(playlistId)) + } + } + + async function handleCreate(e: React.FormEvent) { + e.preventDefault() + if (!newName.trim()) return + setCreating(true) + const { data } = await supabase + .from('playlists') + .insert({ user_id: user!.id, name: newName.trim() }) + .select() + .single() + if (data) { + await supabase.from('playlist_items').insert({ playlist_id: data.id, podcast_id: podcastId!, sort_order: 1 }) + setPlaylists([data, ...playlists]) + setInPlaylists((prev) => new Set(prev).add(data.id)) + setNewName('') + } + setCreating(false) + } + + return ( +
+
+
+
+

Ajouter à une playlist

+ +
+ +
+ {loading ? ( +
+ {[1, 2, 3].map((i) =>
)} +
+ ) : playlists.length === 0 ? ( +

Aucune playlist. Créez-en une ci-dessous.

+ ) : ( + playlists.map((pl) => ( + + )) + )} +
+ +
+ setNewName(e.target.value)} + placeholder="Nouvelle playlist..." + className="flex-1 rounded-xl border border-border bg-background px-3 py-2 text-sm outline-none focus:border-primary" + /> + +
+
+
+ ) +} diff --git a/src/components/podcast/PodcastCard.tsx b/src/components/podcast/PodcastCard.tsx index 87a9306..f9d7f25 100644 --- a/src/components/podcast/PodcastCard.tsx +++ b/src/components/podcast/PodcastCard.tsx @@ -1,4 +1,4 @@ -import { Play, Pause, Heart, MessageCircle, Clock, ListPlus, Download, Check, Loader2 } from 'lucide-react' +import { Play, Pause, Heart, MessageCircle, Clock, ListPlus, Download, Check, Loader2, BookmarkPlus } from 'lucide-react' import { Link } from 'react-router-dom' import type { Podcast } from '@/types' import { formatDuration, timeAgo } from '@/lib/utils' @@ -8,6 +8,8 @@ import { Avatar } from '@/components/ui/Avatar' import { usePlayerStore } from '@/stores/player' import { useListeningProgress } from '@/hooks/useListeningProgress' import { useOfflineStore } from '@/stores/offline' +import { usePlaylistModal } from '@/stores/playlist-modal' +import { useAuthStore } from '@/stores/auth' import { isExternalUrl } from '@/lib/embed' const coverGradients = [ @@ -37,6 +39,7 @@ export function PodcastCard({ podcast, progressPercent: propProgress }: PodcastC const progressMap = useListeningProgress() const progressPercent = propProgress ?? progressMap.get(podcast.id)?.percent const { downloadPodcast, isDownloaded, isDownloading, getDownloadProgress } = useOfflineStore() + const user = useAuthStore((s) => s.user) const downloaded = isDownloaded(podcast.id) const downloading = isDownloading(podcast.id) const isNative = !isExternalUrl(podcast.audio_url) @@ -81,6 +84,15 @@ export function PodcastCard({ podcast, progressPercent: propProgress }: PodcastC > + {user && ( + + )} {isNative && !downloaded && (
+ {/* Scheduled podcasts */} + {!loading && scheduled.length > 0 && ( +
+

+ + Programmés ({scheduled.length}) +

+ {scheduled.map((podcast) => ( +
+
+
+ {podcast.cover_url ? ( + + ) : ( +
+ +
+ )} +
+
+

{podcast.title}

+

+ Prévu le {new Date(podcast.published_at!).toLocaleString('fr-FR')} +

+
+ +
+
+ ))} +
+ )} + {loading ? (
{[1, 2, 3].map(i => ( diff --git a/src/pages/MyShows.tsx b/src/pages/MyShows.tsx new file mode 100644 index 0000000..e4879f6 --- /dev/null +++ b/src/pages/MyShows.tsx @@ -0,0 +1,133 @@ +import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { Layers, Plus, X } from 'lucide-react' +import { supabase } from '@/lib/supabase' +import { useAuthStore } from '@/stores/auth' +import { publicUrl } from '@/lib/storage' +import { Button } from '@/components/ui/Button' +import type { Show } from '@/types' + +export function MyShows() { + const { user, loading: authLoading } = useAuthStore() + const navigate = useNavigate() + const [shows, setShows] = useState([]) + const [loading, setLoading] = useState(true) + const [showCreate, setShowCreate] = useState(false) + const [newTitle, setNewTitle] = useState('') + const [newDesc, setNewDesc] = useState('') + const [creating, setCreating] = useState(false) + + useEffect(() => { + if (authLoading) return + if (!user) { navigate('/auth'); return } + async function load() { + const { data } = await supabase + .from('shows') + .select('*') + .eq('creator_id', user!.id) + .order('created_at', { ascending: false }) + if (data) setShows(data) + setLoading(false) + } + load() + }, [user, authLoading, navigate]) + + async function handleCreate(e: React.FormEvent) { + e.preventDefault() + if (!user || !newTitle.trim()) return + setCreating(true) + const { data } = await supabase + .from('shows') + .insert({ creator_id: user.id, title: newTitle.trim(), description: newDesc.trim() || null }) + .select() + .single() + if (data) { + setShows([data, ...shows]) + setNewTitle('') + setNewDesc('') + setShowCreate(false) + } + setCreating(false) + } + + if (!user) return null + + return ( +
+
+
+

+ + Mes séries +

+

{shows.length} série{shows.length !== 1 ? 's' : ''}

+
+ +
+ + {showCreate && ( +
+
+

Nouvelle série

+ +
+ setNewTitle(e.target.value)} + placeholder="Nom de la série..." + className="w-full rounded-xl border border-border bg-background px-3 py-2 text-sm outline-none focus:border-primary" + autoFocus + /> +