Kurs vibe coding z AI · Moduł 11: Spotify Clone
Search, Discovery i Recommendations
W tej lekcji4
Spotify's power to w discovery - powerful search, personalized playlists (Discover Weekly, Release Radar) i recommendations.
Search System
interface SearchResults {
tracks: Track[];
albums: Album[];
artists: Artist[];
playlists: Playlist[];
}
interface SearchFilters {
type?: 'track' | 'album' | 'artist' | 'playlist';
genre?: string[];
year?: { min?: number; max?: number };
explicit?: boolean;
}
// Search API
async function search(
query: string,
filters?: SearchFilters
): Promise<SearchResults> {
// Implementation...
}Search Page
'use client';
import { useState, useEffect } from 'react';
export default function SearchPage() {
const [query, setQuery] = useState('');
const [activeTab, setActiveTab] = useState<'all' | 'tracks' | 'albums' | 'artists' | 'playlists'>('all');
const [results, setResults] = useState<SearchResults | null>(null);
useEffect(() => {
if (query.length > 0) {
// Debounced search
const timeout = setTimeout(() => {
performSearch(query);
}, 300);
return () => clearTimeout(timeout);
}
}, [query]);
const performSearch = async (searchQuery: string) => {
// Mock search results
setResults({
tracks: mockTracks.filter(t =>
t.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
t.artist.name.toLowerCase().includes(searchQuery.toLowerCase())
),
albums: mockAlbums.filter(a =>
a.title.toLowerCase().includes(searchQuery.toLowerCase())
),
artists: mockArtists.filter(a =>
a.name.toLowerCase().includes(searchQuery.toLowerCase())
),
playlists: mockPlaylists.filter(p =>
p.name.toLowerCase().includes(searchQuery.toLowerCase())
)
});
};
return (
<div className="min-h-screen bg-black text-white p-8">
{/* Search Input */}
<div className="mb-6">
<div className="relative max-w-md">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Czego chcesz posłuchać?"
className="w-full bg-gray-800 text-white px-12 py-3 rounded-full outline-none focus:ring-2 focus:ring-white"
autoFocus
/>
<span className="absolute left-4 top-1/2 transform -translate-y-1/2 text-xl">
⌕
</span>
</div>
</div>
{!query ? (
// Browse All / Recent Searches
<div>
<h2 className="text-2xl font-bold mb-4">Browse All</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{['Sea Shanties', 'Battle Anthems', 'Treasure Hunting', 'Calm Seas', 'Epic Adventures', 'Ship Repairs'].map(genre => (
<button
key={genre}
className="aspect-square bg-gradient-to-br from-purple-600 to-pink-600 rounded-lg p-4 font-bold text-lg hover:scale-105 transition"
>
{genre}
</button>
))}
</div>
</div>
) : results && (
<>
{/* Tabs */}
<div className="flex gap-4 mb-6 border-b border-gray-800">
{[
{ key: 'all', label: 'All' },
{ key: 'tracks', label: 'Songs' },
{ key: 'albums', label: 'Albums' },
{ key: 'artists', label: 'Artists' },
{ key: 'playlists', label: 'Playlists' }
].map(tab => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key as any)}
className={`pb-2 px-4 font-semibold ${
activeTab === tab.key
? 'border-b-2 border-green-500'
: 'text-gray-400'
}`}
>
{tab.label}
</button>
))}
</div>
{/* Results */}
<div className="space-y-6">
{(activeTab === 'all' || activeTab === 'tracks') && results.tracks.length > 0 && (
<div>
<h3 className="text-xl font-bold mb-4">Songs</h3>
<div className="space-y-2">
{results.tracks.slice(0, activeTab === 'all' ? 4 : undefined).map(track => (
<div
key={track.id}
className="flex items-center gap-4 p-2 rounded hover:bg-gray-800 cursor-pointer"
>
<img
src={track.album.coverArt}
alt={track.title}
className="w-12 h-12 rounded"
/>
<div className="flex-1">
<div className="font-semibold">{track.title}</div>
<div className="text-sm text-gray-400">
{track.artist.name}
</div>
</div>
<span className="text-sm text-gray-400">
{Math.floor(track.duration / 60)}:{(track.duration % 60).toString().padStart(2, '0')}
</span>
</div>
))}
</div>
</div>
)}
{(activeTab === 'all' || activeTab === 'artists') && results.artists.length > 0 && (
<div>
<h3 className="text-xl font-bold mb-4">Artists</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{results.artists.slice(0, activeTab === 'all' ? 4 : undefined).map(artist => (
<div
key={artist.id}
className="bg-gray-900 p-4 rounded hover:bg-gray-800 cursor-pointer"
>
<img
src={artist.avatar}
alt={artist.name}
className="w-full aspect-square rounded-full mb-4"
/>
<div className="font-semibold">{artist.name}</div>
<div className="text-sm text-gray-400">Artist</div>
</div>
))}
</div>
</div>
)}
</div>
</>
)}
</div>
);
}Recommendations System
interface RecommendationEngine {
// Based on listening history
discoverWeekly: (userId: string) => Playlist;
// New releases from followed artists
releaseRadar: (userId: string) => Playlist;
// Based on specific seed
radioStation: (seed: Track | Artist | Genre) => Track[];
// Similar to a track
similarTracks: (trackId: string, limit?: number) => Track[];
}
// Discover Weekly - personalized playlist based on listening history
function generateDiscoverWeekly(
listeningHistory: Track[],
likedTracks: Track[],
followedArtists: Artist[]
): Playlist {
// Algorithm:
// 1. Analyze user's taste (genres, artists, audio features)
// 2. Find similar tracks user hasn't heard
// 3. Weight by popularity and recency
// 4. Limit to 30 tracks
return {
id: 'discover-weekly',
name: 'Discover Weekly',
description: 'Your weekly mixtape of fresh music',
coverImage: '/discover-weekly.jpg',
owner: { id: 'spotify', username: 'Spotify', avatar: '/spotify.png' },
tracks: [...], // 30 recommended tracks
followers: 0,
isPublic: false,
isCollaborative: false,
totalDuration: 0,
createdAt: new Date(),
updatedAt: new Date()
};
}Podsumowanie
Search system - tracks, albums, artists, playlists Debounced search - 300ms delay for performance Search tabs - filter results by type Browse categories - genre cards Recent searches - history tracking Discover Weekly - personalized recommendations Release Radar - new music from followed artists Radio station - endless playback based on seed Similar tracks - recommendation algorithm
Masz teraz kompletny Spotify clone z player, playlists, search i recommendations!
Do zobaczenia!
Kod do tej lekcji: App.tsx
'use client';
import { useState } from 'react';
interface Track {
id: string;
title: string;
artist: string;
album: string;
coverArt: string;
duration: number;
isLiked: boolean;
}
const mockTracks: Track[] = [
{
id: '1',
title: "Hymn piratów",
artist: 'Kapitan Redbeard',
album: 'Morskie przygody',
coverArt: 'https://images.unsplash.com/photo-1470225620780-dba8ba36b745?w=200&h=200&fit=crop',
duration: 240,
isLiked: true
},
{
id: '2',
title: 'Przez siedem mórz',
artist: 'Rada Załogi',
album: 'Oceaniczna podróż',
coverArt: 'https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?w=200&h=200&fit=crop',
duration: 195,
isLiked: false
},
{
id: '3',
title: 'Polowanie na skarb',
artist: 'Zespół Czarnobrodego',
album: 'Złoto i chwała',
coverArt: 'https://images.unsplash.com/photo-1514320291840-2e0a9bf2a9ae?w=200&h=200&fit=crop',
duration: 210,
isLiked: true
}
];
const mockPlaylist = {
name: 'Pirackie hity',
description: 'Najlepsze szanty i piosenki o przygodach',
coverImage: 'https://images.unsplash.com/photo-1511379938547-c1f69419868d?w=400&h=400&fit=crop',
owner: {
username: 'Kapitan Redbeard',
avatar: 'https://i.pravatar.cc/150?img=12'
},
followers: 1247,
tracks: mockTracks
};
export default function PlaylistPage() {
const [tracks, setTracks] = useState(mockTracks);
const formatDuration = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const totalDuration = tracks.reduce((acc, t) => acc + t.duration, 0);
const totalMinutes = Math.floor(totalDuration / 60);
return (
<div className="min-h-screen bg-gradient-to-b from-blue-900 to-black text-white">
{/* Nagłówek */}
<div className="bg-gradient-to-b from-blue-900 to-transparent p-8">
<div className="flex items-end gap-6">
<img
src={mockPlaylist.coverImage}
alt={mockPlaylist.name}
className="w-60 h-60 rounded shadow-2xl"
/>
<div className="flex-1">
<p className="text-sm font-semibold uppercase">Playlista</p>
<h1 className="text-5xl font-bold my-4">{mockPlaylist.name}</h1>
<p className="text-gray-300 mb-4">{mockPlaylist.description}</p>
<div className="flex items-center gap-2 text-sm">
<img
src={mockPlaylist.owner.avatar}
alt={mockPlaylist.owner.username}
className="w-6 h-6 rounded-full"
/>
<span className="font-semibold">{mockPlaylist.owner.username}</span>
<span>•</span>
<span>{mockPlaylist.followers.toLocaleString()} obserwujących</span>
<span>•</span>
<span>{tracks.length} utwory, {totalMinutes} min</span>
</div>
</div>
</div>
</div>
{/* Akcje */}
<div className="px-8 py-6 flex items-center gap-4">
<button className="w-14 h-14 bg-green-500 rounded-full flex items-center justify-center hover:scale-105 transition">
<span className="text-black text-2xl ml-1">▶</span>
</button>
<button className="text-sm font-semibold text-gray-400 hover:text-white">Lubię to</button>
<button className="text-3xl text-gray-400 hover:text-white">⋯</button>
</div>
{/* Lista utworów */}
<div className="px-8 pb-8">
<div className="grid grid-cols-[16px_6fr_4fr_minmax(120px,1fr)] gap-4 px-4 py-2 border-b border-gray-700 text-sm text-gray-400 mb-2">
<div>#</div>
<div>Tytuł</div>
<div>Album</div>
<div className="text-right">⏱</div>
</div>
<div>
{tracks.map((track, index) => (
<div
key={track.id}
className="grid grid-cols-[16px_6fr_4fr_minmax(120px,1fr)] gap-4 px-4 py-2 rounded hover:bg-white hover:bg-white/10 group"
>
<div className="flex items-center text-gray-400">
<span className="group-hover:hidden">{index + 1}</span>
<button className="hidden group-hover:block">▶</button>
</div>
<div className="flex items-center gap-3">
<img src={track.coverArt} alt={track.title} className="w-10 h-10 rounded" />
<div>
<div className="font-semibold">{track.title}</div>
<div className="text-sm text-gray-400">{track.artist}</div>
</div>
</div>
<div className="flex items-center text-sm text-gray-400">
{track.album}
</div>
<div className="flex items-center justify-end gap-4">
<button className="opacity-0 group-hover:opacity-100">
{track.isLiked ? 'Polubione' : 'Lubię to'}
</button>
<span className="text-sm text-gray-400">
{formatDuration(track.duration)}
</span>
<button className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-white">
⋯
</button>
</div>
</div>
))}
</div>
</div>
</div>
);
}Widzisz błąd w tej lekcji?
Sprawdź się
Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.
1. Po co jest debouncing w search?
2. Jakie typy wyników zwraca SearchResults?
To 2 z 6 pytań do tej lekcji. Pozostałe rozwiążesz w grze.
Zadania praktyczne w grze
- Klikanie w kolejności
Kliknij elementy, aby zbudować wywołanie wyszukiwania:
- Klikanie w kolejności
Kliknij elementy, aby zbudować opóźnione wyszukiwanie:
- Układanie w poziomie
Ułóż zakładki SearchPage od lewej do prawej:
- Edytor kodu
Search interface
- Układanie w pionie
Uporządkuj kroki debounced search w useEffect:
- Układanie w pionie
Uporządkuj kroki generowania Discover Weekly:
- Klikanie w kolejności
Kliknij elementy, aby wyczyścić poprzedni timer w cleanupie:
- Układanie w poziomie
Ułóż tokeny filtrowania utworów po tytule w jednej linii:
- Układanie w pionie
Uporządkuj kroki działania Radio Station (na podstawie seeda):
- Edytor kodu
Music discovery interface