Kurs vibe coding z AI · Moduł 11: Spotify Clone
Playlists i Library Management
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. Co zawiera Playlist?
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