Kurs vibe coding z AI · Moduł 11: Spotify Clone

Search, Discovery i Recommendations

4 min czytania
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. 1. Po co jest debouncing w search?

  2. 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

Przydatne artykuły