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

Playlists i Library Management

4 min czytania
W tej lekcji4

Spotify to organizacja muzyki - playlists, liked songs, albums w library.

Playlist Structure

interface Playlist {
  id: string;
  name: string;
  description: string;
  coverImage: string;

  owner: {
    id: string;
    username: string;
    avatar: string;
  };

  tracks: Track[];
  followers: number;

  isPublic: boolean;
  isCollaborative: boolean; // multiple people can add tracks

  totalDuration: number;
  createdAt: Date;
  updatedAt: Date;
}

interface Library {
  likedTracks: Track[];
  savedAlbums: Album[];
  followedArtists: Artist[];
  playlists: Playlist[];
  recentlyPlayed: Track[];
}

Playlist Page

'use client';

import { useState } from 'react';

interface PlaylistPageProps {
  playlist: Playlist;
  onPlay: (trackIndex?: number) => void;
  onLikePlaylist: () => void;
  onAddTrack: () => void;
  onRemoveTrack: (trackId: string) => void;
}

export default function PlaylistPage({
  playlist,
  onPlay,
  onLikePlaylist,
  onAddTrack,
  onRemoveTrack
}: PlaylistPageProps) {
  const [sortBy, setSortBy] = useState<'added' | 'title' | 'artist' | 'album' | 'duration'>('added');

  const totalDuration = playlist.tracks.reduce((acc, track) => acc + track.duration, 0);
  const totalHours = Math.floor(totalDuration / 3600);
  const totalMinutes = Math.floor((totalDuration % 3600) / 60);

  const formatDuration = (seconds: number) => {
    const mins = Math.floor(seconds / 60);
    const secs = Math.floor(seconds % 60);
    return `${mins}:${secs.toString().padStart(2, '0')}`;
  };

  return (
    <div className="min-h-screen bg-gradient-to-b from-blue-900 to-black text-white">
      {/* Header */}
      <div className="bg-gradient-to-b from-blue-900 to-transparent p-8">
        <div className="flex items-end gap-6">
          {/* Cover */}
          <img
            src={playlist.coverImage}
            alt={playlist.name}
            className="w-60 h-60 rounded shadow-2xl"
          />

          {/* Info */}
          <div className="flex-1">
            <p className="text-sm font-semibold uppercase">Playlist</p>
            <h1 className="text-7xl font-bold my-4">{playlist.name}</h1>
            <p className="text-gray-300 mb-4">{playlist.description}</p>

            <div className="flex items-center gap-2 text-sm">
              <img
                src={playlist.owner.avatar}
                alt={playlist.owner.username}
                className="w-6 h-6 rounded-full"
              />
              <span className="font-semibold">{playlist.owner.username}</span>
              <span>•</span>
              <span>{playlist.followers.toLocaleString()} followers</span>
              <span>•</span>
              <span>{playlist.tracks.length} songs</span>
              {totalHours > 0 && (
                <>
                  <span>•</span>
                  <span>{totalHours}h {totalMinutes}min</span>
                </>
              )}
            </div>
          </div>
        </div>
      </div>

      {/* Actions */}
      <div className="px-8 py-6 flex items-center gap-4">
        <button
          onClick={() => onPlay()}
          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
          onClick={onLikePlaylist}
          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>

      {/* Track List */}
      <div className="px-8 pb-8">
        {/* Headers */}
        <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">
          <div>#</div>
          <div>Title</div>
          <div>Album</div>
          <div>Date Added</div>
          <div className="text-right">⏱</div>
        </div>

        {/* Tracks */}
        <div>
          {playlist.tracks.map((track, index) => (
            <div
              key={track.id}
              onDoubleClick={() => onPlay(index)}
              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"
            >
              <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.album.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.name}
                  </div>
                </div>
              </div>

              <div className="flex items-center text-sm text-gray-400">
                {track.album.title}
              </div>

              <div className="flex items-center text-sm text-gray-400">
                {new Date().toLocaleDateString()}
              </div>

              <div className="flex items-center justify-end gap-4">
                <button className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-white">
                  {track.isLiked ? 'Polubione' : 'Lubię to'}
                </button>
                <span className="text-sm text-gray-400">
                  {formatDuration(track.duration)}
                </span>
                <button
                  onClick={() => onRemoveTrack(track.id)}
                  className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-white"
                >
                  ⋯
                </button>
              </div>
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}

Create Playlist Modal

'use client';

import { useState } from 'react';

interface CreatePlaylistModalProps {
  onClose: () => void;
  onCreate: (name: string, description: string, isPublic: boolean) => void;
}

export default function CreatePlaylistModal({
  onClose,
  onCreate
}: CreatePlaylistModalProps) {
  const [name, setName] = useState('');
  const [description, setDescription] = useState('');
  const [isPublic, setIsPublic] = useState(true);

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (name.trim()) {
      onCreate(name, description, isPublic);
      onClose();
    }
  };

  return (
    <div className="fixed inset-0 bg-black/75 z-50 flex items-center justify-center p-4">
      <div className="bg-gray-900 rounded-lg max-w-md w-full p-6">
        <h2 className="text-2xl font-bold text-white mb-6">Create Playlist</h2>

        <form onSubmit={handleSubmit}>
          <div className="mb-4">
            <label className="block text-sm font-semibold text-white mb-2">
              Name
            </label>
            <input
              type="text"
              value={name}
              onChange={(e) => setName(e.target.value)}
              placeholder="Moja playlista #1"
              className="w-full bg-gray-800 text-white px-4 py-3 rounded outline-none focus:ring-2 focus:ring-green-500"
              autoFocus
            />
          </div>

          <div className="mb-4">
            <label className="block text-sm font-semibold text-white mb-2">
              Description (Optional)
            </label>
            <textarea
              value={description}
              onChange={(e) => setDescription(e.target.value)}
              placeholder="Dodaj opcjonalny opis"
              className="w-full bg-gray-800 text-white px-4 py-3 rounded outline-none resize-none focus:ring-2 focus:ring-green-500"
              rows={3}
            />
          </div>

          <div className="mb-6">
            <label className="flex items-center gap-3 cursor-pointer">
              <input
                type="checkbox"
                checked={isPublic}
                onChange={(e) => setIsPublic(e.target.checked)}
                className="w-5 h-5"
              />
              <span className="text-white">Make playlist public</span>
            </label>
          </div>

          <div className="flex gap-3">
            <button
              type="button"
              onClick={onClose}
              className="flex-1 py-3 border border-gray-700 rounded-full text-white font-semibold hover:border-white"
            >
              Cancel
            </button>
            <button
              type="submit"
              disabled={!name.trim()}
              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"
            >
              Create
            </button>
          </div>
        </form>
      </div>
    </div>
  );
}

Podsumowanie

Playlist structure - tracks, followers, duration Playlist page - header z cover art, track list Track grid - #, title, album, date, duration Hover actions - play, like, remove track Double-click to play - quick playback Create playlist - modal z name, description, privacy Public/private - visibility control Playlist stats - track count, total duration, followers

W następnym ćwiczeniu dodamy 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;
  coverArt: string;
  duration: number;
}

const mockQueue: Track[] = [
  {
    id: '1',
    title: "Hymn piratów",
    artist: 'Kapitan Redbeard',
    coverArt: 'https://images.unsplash.com/photo-1470225620780-dba8ba36b745?w=200&h=200&fit=crop',
    duration: 240
  },
  {
    id: '2',
    title: 'Przez siedem mórz',
    artist: 'Rada Załogi',
    coverArt: 'https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?w=200&h=200&fit=crop',
    duration: 195
  },
  {
    id: '3',
    title: 'Polowanie na skarb',
    artist: 'Zespół Czarnobrodego',
    coverArt: 'https://images.unsplash.com/photo-1514320291840-2e0a9bf2a9ae?w=200&h=200&fit=crop',
    duration: 210
  }
];

export default function Queue() {
  const [queue, setQueue] = useState(mockQueue);
  const [currentIndex, setCurrentIndex] = useState(0);

  const formatDuration = (seconds: number) => {
    const mins = Math.floor(seconds / 60);
    const secs = Math.floor(seconds % 60);
    return `${mins}:${secs.toString().padStart(2, '0')}`;
  };

  const handleRemove = (index: number) => {
    setQueue(queue.filter((_, i) => i !== index));
  };

  return (
    <div className="min-h-screen bg-black text-white flex">
      <div className="flex-1 bg-gray-950"></div>

      <div className="w-80 bg-gray-900 border-l border-gray-800 flex flex-col">
        {/* Nagłówek */}
        <div className="p-4 border-b border-gray-800">
          <div className="flex items-center justify-between mb-2">
            <h3 className="font-bold">Kolejka</h3>
            <button
              onClick={() => setQueue([])}
              className="text-xs text-gray-400 hover:text-white"
            >
              Wyczyść
            </button>
          </div>
          <p className="text-xs text-gray-400">Utwory: {queue.length}</p>
        </div>

        {/* Teraz odtwarzane */}
        {queue[currentIndex] && (
          <div className="p-4 bg-gray-800 border-b border-gray-700">
            <p className="text-xs text-gray-400 mb-2">Teraz odtwarzane</p>
            <div className="flex items-center gap-3">
              <img
                src={queue[currentIndex].coverArt}
                alt={queue[currentIndex].title}
                className="w-12 h-12 rounded"
              />
              <div className="flex-1 min-w-0">
                <div className="font-semibold text-sm truncate">
                  {queue[currentIndex].title}
                </div>
                <div className="text-xs text-gray-400 truncate">
                  {queue[currentIndex].artist}
                </div>
              </div>
            </div>
          </div>
        )}

        {/* Następne w kolejce */}
        <div className="flex-1 overflow-y-auto p-4">
          <p className="text-xs text-gray-400 mb-3">Następne w kolejce</p>
          <div className="space-y-2">
            {queue.slice(currentIndex + 1).map((track, idx) => {
              const actualIndex = currentIndex + 1 + idx;
              return (
                <div
                  key={track.id}
                  onClick={() => setCurrentIndex(actualIndex)}
                  className="flex items-center gap-3 p-2 hover:bg-gray-800 rounded cursor-pointer group"
                >
                  <img
                    src={track.coverArt}
                    alt={track.title}
                    className="w-10 h-10 rounded"
                  />
                  <div className="flex-1 min-w-0">
                    <div className="text-sm truncate">{track.title}</div>
                    <div className="text-xs text-gray-400 truncate">
                      {track.artist}
                    </div>
                  </div>
                  <span className="text-xs text-gray-400">
                    {formatDuration(track.duration)}
                  </span>
                  <button
                    onClick={(e) => {
                      e.stopPropagation();
                      handleRemove(actualIndex);
                    }}
                    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. Co zawiera Playlist?

  2. 2. Co umożliwia pole isCollaborative w Playlist?

To 2 z 7 pytań do tej lekcji. Pozostałe rozwiążesz w grze.

Zadania praktyczne w grze

  • Klikanie w kolejności

    Kliknij elementy, aby zbudować wywołanie tworzenia playlisty:

  • Klikanie w kolejności

    Kliknij elementy, aby zbudować stan nazwy playlisty:

  • Układanie w poziomie

    Ułóż kolumny track list w playlist:

  • Układanie w poziomie

    Ułóż elementy wiersza utworu w track liście od lewej do prawej:

  • Edytor kodu

    Playlist display component

  • Klikanie w kolejności

    Kliknij elementy, aby zsumować czas trwania utworów:

  • Układanie w pionie

    Uporządkuj pola struktury Library zgodnie z kolejnością w kodzie:

  • Układanie w pionie

    Uporządkuj sekcje PlaylistPage od góry do dołu:

  • Układanie w poziomie

    Ułóż tokeny odczytu nazwy właściciela playlisty w jednej linii:

  • Układanie w pionie

    Uporządkuj kroki tworzenia playlisty w modalu:

  • Edytor kodu

    Playlist creation interface

Przydatne artykuły