Cours Vibe coding avec l'IA · Module 11: Clone de Spotify
Playlists et gestion de la bibliothèque
Dans cette leçon4
Spotify, c'est avant tout l'organisation de la musique - playlists, titres likés, albums dans la bibliothèque.
Structure d'une playlist
1interface Playlist {
2 id: string;
3 name: string;
4 description: string;
5 coverImage: string;
6
7 owner: {
8 id: string;
9 username: string;
10 avatar: string;
11 };
12
13 tracks: Track[];
14 followers: number;
15
16 isPublic: boolean;
17 isCollaborative: boolean; // plusieurs personnes peuvent ajouter des morceaux
18
19 totalDuration: number;
20 createdAt: Date;
21 updatedAt: Date;
22}
23
24interface Library {
25 likedTracks: Track[];
26 savedAlbums: Album[];
27 followedArtists: Artist[];
28 playlists: Playlist[];
29 recentlyPlayed: Track[];
30}Page de la playlist
1'use client';
2
3import { useState } from 'react';
4
5interface PlaylistPageProps {
6 playlist: Playlist;
7 onPlay: (trackIndex?: number) => void;
8 onLikePlaylist: () => void;
9 onAddTrack: () => void;
10 onRemoveTrack: (trackId: string) => void;
11}
12
13export default function PlaylistPage({
14 playlist,
15 onPlay,
16 onLikePlaylist,
17 onAddTrack,
18 onRemoveTrack
19}: PlaylistPageProps) {
20 const [sortBy, setSortBy] = useState<'added' | 'title' | 'artist' | 'album' | 'duration'>('added');
21
22 const totalDuration = playlist.tracks.reduce((acc, track) => acc + track.duration, 0);
23 const totalHours = Math.floor(totalDuration / 3600);
24 const totalMinutes = Math.floor((totalDuration % 3600) / 60);
25
26 const formatDuration = (seconds: number) => {
27 const mins = Math.floor(seconds / 60);
28 const secs = Math.floor(seconds % 60);
29 return `${mins}:${secs.toString().padStart(2, '0')}`;
30 };
31
32 return (
33 <div className="min-h-screen bg-gradient-to-b from-blue-900 to-black text-white">
34 {/* En-tête */}
35 <div className="bg-gradient-to-b from-blue-900 to-transparent p-8">
36 <div className="flex items-end gap-6">
37 {/* Pochette */}
38 <img
39 src={playlist.coverImage}
40 alt={playlist.name}
41 className="w-60 h-60 rounded shadow-2xl"
42 />
43
44 {/* Infos */}
45 <div className="flex-1">
46 <p className="text-sm font-semibold uppercase">Playlist</p>
47 <h1 className="text-7xl font-bold my-4">{playlist.name}</h1>
48 <p className="text-gray-300 mb-4">{playlist.description}</p>
49
50 <div className="flex items-center gap-2 text-sm">
51 <img
52 src={playlist.owner.avatar}
53 alt={playlist.owner.username}
54 className="w-6 h-6 rounded-full"
55 />
56 <span className="font-semibold">{playlist.owner.username}</span>
57 <span>•</span>
58 <span>{playlist.followers.toLocaleString()} abonnés</span>
59 <span>•</span>
60 <span>{playlist.tracks.length} titres</span>
61 {totalHours > 0 && (
62 <>
63 <span>•</span>
64 <span>{totalHours} h {totalMinutes} min</span>
65 </>
66 )}
67 </div>
68 </div>
69 </div>
70 </div>
71
72 {/* Actions */}
73 <div className="px-8 py-6 flex items-center gap-4">
74 <button
75 onClick={() => onPlay()}
76 className="w-14 h-14 bg-green-500 rounded-full flex items-center justify-center hover:scale-105 transition"
77 >
78 <span className="text-black text-2xl ml-1">▶</span>
79 </button>
80
81 <button
82 onClick={onLikePlaylist}
83 className="text-sm font-semibold text-gray-400 hover:text-white"
84 >
85 J’aime
86 </button>
87
88 <button className="text-3xl text-gray-400 hover:text-white">
89 ⋯
90 </button>
91 </div>
92
93 {/* Liste des morceaux */}
94 <div className="px-8 pb-8">
95 {/* En-têtes */}
96 <div className="grid grid-cols-[16px_6fr_4fr_3fr_minmax(120px,1fr)] gap-4 px-4 py-2 border-b border-gray-700 text-sm text-gray-400 mb-2">
97 <div>#</div>
98 <div>Titre</div>
99 <div>Album</div>
100 <div>Date d'ajout</div>
101 <div className="text-right">⏱</div>
102 </div>
103
104 {/* Morceaux */}
105 <div>
106 {playlist.tracks.map((track, index) => (
107 <div
108 key={track.id}
109 onDoubleClick={() => onPlay(index)}
110 className="grid grid-cols-[16px_6fr_4fr_3fr_minmax(120px,1fr)] gap-4 px-4 py-2 rounded hover:bg-white hover:bg-white/10 group"
111 >
112 <div className="flex items-center text-gray-400">
113 <span className="group-hover:hidden">{index + 1}</span>
114 <button className="hidden group-hover:block">▶</button>
115 </div>
116
117 <div className="flex items-center gap-3">
118 <img
119 src={track.album.coverArt}
120 alt={track.title}
121 className="w-10 h-10 rounded"
122 />
123 <div>
124 <div className="font-semibold">{track.title}</div>
125 <div className="text-sm text-gray-400">
126 {track.artist.name}
127 </div>
128 </div>
129 </div>
130
131 <div className="flex items-center text-sm text-gray-400">
132 {track.album.title}
133 </div>
134
135 <div className="flex items-center text-sm text-gray-400">
136 {new Date().toLocaleDateString()}
137 </div>
138
139 <div className="flex items-center justify-end gap-4">
140 <button className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-white">
141 {track.isLiked ? 'Aimé' : 'J’aime'}
142 </button>
143 <span className="text-sm text-gray-400">
144 {formatDuration(track.duration)}
145 </span>
146 <button
147 onClick={() => onRemoveTrack(track.id)}
148 className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-white"
149 >
150 ⋯
151 </button>
152 </div>
153 </div>
154 ))}
155 </div>
156 </div>
157 </div>
158 );
159}Fenêtre de création de playlist
1'use client';
2
3import { useState } from 'react';
4
5interface CreatePlaylistModalProps {
6 onClose: () => void;
7 onCreate: (name: string, description: string, isPublic: boolean) => void;
8}
9
10export default function CreatePlaylistModal({
11 onClose,
12 onCreate
13}: CreatePlaylistModalProps) {
14 const [name, setName] = useState('');
15 const [description, setDescription] = useState('');
16 const [isPublic, setIsPublic] = useState(true);
17
18 const handleSubmit = (e: React.FormEvent) => {
19 e.preventDefault();
20 if (name.trim()) {
21 onCreate(name, description, isPublic);
22 onClose();
23 }
24 };
25
26 return (
27 <div className="fixed inset-0 bg-black/75 z-50 flex items-center justify-center p-4">
28 <div className="bg-gray-900 rounded-lg max-w-md w-full p-6">
29 <h2 className="text-2xl font-bold text-white mb-6">Créer une playlist</h2>
30
31 <form onSubmit={handleSubmit}>
32 <div className="mb-4">
33 <label className="block text-sm font-semibold text-white mb-2">
34 Nom
35 </label>
36 <input
37 type="text"
38 value={name}
39 onChange={(e) => setName(e.target.value)}
40 placeholder="Ma playlist n°1"
41 className="w-full bg-gray-800 text-white px-4 py-3 rounded outline-none focus:ring-2 focus:ring-green-500"
42 autoFocus
43 />
44 </div>
45
46 <div className="mb-4">
47 <label className="block text-sm font-semibold text-white mb-2">
48 Description (facultatif)
49 </label>
50 <textarea
51 value={description}
52 onChange={(e) => setDescription(e.target.value)}
53 placeholder="Ajoute une description facultative"
54 className="w-full bg-gray-800 text-white px-4 py-3 rounded outline-none resize-none focus:ring-2 focus:ring-green-500"
55 rows={3}
56 />
57 </div>
58
59 <div className="mb-6">
60 <label className="flex items-center gap-3 cursor-pointer">
61 <input
62 type="checkbox"
63 checked={isPublic}
64 onChange={(e) => setIsPublic(e.target.checked)}
65 className="w-5 h-5"
66 />
67 <span className="text-white">Rendre la playlist publique</span>
68 </label>
69 </div>
70
71 <div className="flex gap-3">
72 <button
73 type="button"
74 onClick={onClose}
75 className="flex-1 py-3 border border-gray-700 rounded-full text-white font-semibold hover:border-white"
76 >
77 Annuler
78 </button>
79 <button
80 type="submit"
81 disabled={!name.trim()}
82 className="flex-1 py-3 bg-green-500 rounded-full text-black font-semibold hover:bg-green-400 disabled:bg-gray-700 disabled:text-gray-500"
83 >
84 Créer
85 </button>
86 </div>
87 </form>
88 </div>
89 </div>
90 );
91}Récapitulatif
Structure d'une playlist - morceaux, abonnés, durée Page de la playlist - en-tête avec pochette, liste des morceaux Grille des morceaux - #, titre, album, date, durée Actions au survol - lire, liker, retirer un morceau Double-clic pour lire - lecture rapide Créer une playlist - fenêtre avec nom, description, confidentialité Public/privé - contrôle de la visibilité Statistiques de la playlist - nombre de morceaux, durée totale, abonnés
Dans le prochain exercice, nous ajouterons la recherche et les recommandations !
À très bientôt !
Code de cette leçon : App.tsx
1'use client';
2
3import { useState } from 'react';
4
5interface Track {
6 id: string;
7 title: string;
8 artist: string;
9 coverArt: string;
10 duration: number;
11}
12
13const mockQueue: Track[] = [
14 {
15 id: '1',
16 title: "The Pirate's Anthem",
17 artist: 'Captain Redbeard',
18 coverArt: 'https://images.unsplash.com/photo-1470225620780-dba8ba36b745?w=200&h=200&fit=crop',
19 duration: 240
20 },
21 {
22 id: '2',
23 title: 'Sailing the Seven Seas',
24 artist: 'Crew Council',
25 coverArt: 'https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?w=200&h=200&fit=crop',
26 duration: 195
27 },
28 {
29 id: '3',
30 title: 'Treasure Hunt',
31 artist: 'Blackbeard Band',
32 coverArt: 'https://images.unsplash.com/photo-1514320291840-2e0a9bf2a9ae?w=200&h=200&fit=crop',
33 duration: 210
34 }
35];
36
37export default function Queue() {
38 const [queue, setQueue] = useState(mockQueue);
39 const [currentIndex, setCurrentIndex] = useState(0);
40
41 const formatDuration = (seconds: number) => {
42 const mins = Math.floor(seconds / 60);
43 const secs = Math.floor(seconds % 60);
44 return `${mins}:${secs.toString().padStart(2, '0')}`;
45 };
46
47 const handleRemove = (index: number) => {
48 setQueue(queue.filter((_, i) => i !== index));
49 };
50
51 return (
52 <div className="min-h-screen bg-black text-white flex">
53 <div className="flex-1 bg-gray-950"></div>
54
55 <div className="w-80 bg-gray-900 border-l border-gray-800 flex flex-col">
56 {/* En-tête */}
57 <div className="p-4 border-b border-gray-800">
58 <div className="flex items-center justify-between mb-2">
59 <h3 className="font-bold">File d'attente</h3>
60 <button
61 onClick={() => setQueue([])}
62 className="text-xs text-gray-400 hover:text-white"
63 >
64 Vider
65 </button>
66 </div>
67 <p className="text-xs text-gray-400">{queue.length} morceaux</p>
68 </div>
69
70 {/* En cours de lecture */}
71 {queue[currentIndex] && (
72 <div className="p-4 bg-gray-800 border-b border-gray-700">
73 <p className="text-xs text-gray-400 mb-2">En cours de lecture</p>
74 <div className="flex items-center gap-3">
75 <img
76 src={queue[currentIndex].coverArt}
77 alt={queue[currentIndex].title}
78 className="w-12 h-12 rounded"
79 />
80 <div className="flex-1 min-w-0">
81 <div className="font-semibold text-sm truncate">
82 {queue[currentIndex].title}
83 </div>
84 <div className="text-xs text-gray-400 truncate">
85 {queue[currentIndex].artist}
86 </div>
87 </div>
88 </div>
89 </div>
90 )}
91
92 {/* À suivre */}
93 <div className="flex-1 overflow-y-auto p-4">
94 <p className="text-xs text-gray-400 mb-3">À suivre</p>
95 <div className="space-y-2">
96 {queue.slice(currentIndex + 1).map((track, idx) => {
97 const actualIndex = currentIndex + 1 + idx;
98 return (
99 <div
100 key={track.id}
101 onClick={() => setCurrentIndex(actualIndex)}
102 className="flex items-center gap-3 p-2 hover:bg-gray-800 rounded cursor-pointer group"
103 >
104 <img
105 src={track.coverArt}
106 alt={track.title}
107 className="w-10 h-10 rounded"
108 />
109 <div className="flex-1 min-w-0">
110 <div className="text-sm truncate">{track.title}</div>
111 <div className="text-xs text-gray-400 truncate">
112 {track.artist}
113 </div>
114 </div>
115 <span className="text-xs text-gray-400">
116 {formatDuration(track.duration)}
117 </span>
118 <button
119 onClick={(e) => {
120 e.stopPropagation();
121 handleRemove(actualIndex);
122 }}
123 className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-white"
124 >
125 ×
126 </button>
127 </div>
128 );
129 })}
130 </div>
131 </div>
132 </div>
133 </div>
134 );
135}Tu as repéré une erreur dans cette leçon ?
Teste tes connaissances
Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.
1. Que contient une Playlist ?
2. Que permet le champ isCollaborative dans Playlist ?
Voici 2 des 7 questions de cette leçon. Réponds aux autres dans le jeu.
Tâches pratiques dans le jeu
- Clique dans l'ordre
Clique pour construire l'appel de création de playlist :
- Clique dans l'ordre
Clique pour construire l'état du nom de la playlist :
- Tri horizontal
Ordonne les colonnes de la liste des morceaux d'une playlist :
- Tri horizontal
Ordonne les éléments d'une ligne de morceau dans la liste, de gauche à droite :
- Éditeur de code
Composant d'affichage de playlist
- Clique dans l'ordre
Clique pour construire la somme de la durée des morceaux :
- Tri vertical
Ordonne les champs de la structure Library tels qu'ils apparaissent dans le code :
- Tri vertical
Ordonne les sections de PlaylistPage de haut en bas :
- Tri horizontal
Ordonne les éléments pour lire le nom du propriétaire de la playlist sur une seule ligne :
- Tri vertical
Ordonne les étapes de création d'une playlist dans la modale :
- Éditeur de code
Interface de création de playlist