Kurs vibe coding z AI · Moduł 13: Twitch Clone
Categories, Clips i Discovery
W tej lekcji4
Twitch ma discovery - browse by category, clips, VODs.
Browse Categories
interface BrowseData {
liveChannels: Channel[];
categories: Category[];
clips: Clip[];
vods: VOD[];
}
// Category page
'use client';
export default function BrowsePage() {
const [activeTab, setActiveTab] = useState<'categories' | 'live' | 'clips'>('categories');
return (
<div className="min-h-screen bg-gray-950 text-white p-8">
<h1 className="text-3xl font-bold mb-6">Browse</h1>
{/* Tabs */}
<div className="flex gap-6 mb-8 border-b border-gray-800">
{[
{ key: 'categories', label: 'Categories' },
{ key: 'live', label: 'Live Channels' },
{ key: 'clips', label: 'Clips' }
].map(tab => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key as any)}
className={`
pb-3 font-semibold
${activeTab === tab.key
? 'border-b-2 border-purple-500'
: 'text-gray-400 hover:text-white'
}
`}
>
{tab.label}
</button>
))}
</div>
{/* Content */}
{activeTab === 'categories' && (
<div className="grid grid-cols-6 gap-4">
{mockCategories.map(category => (
<CategoryCard key={category.id} category={category} />
))}
</div>
)}
{activeTab === 'live' && (
<div className="grid grid-cols-4 gap-4">
{mockLiveChannels.map(channel => (
<LiveChannelCard key={channel.id} channel={channel} />
))}
</div>
)}
{activeTab === 'clips' && (
<div className="grid grid-cols-4 gap-4">
{mockClips.map(clip => (
<ClipCard key={clip.id} clip={clip} />
))}
</div>
)}
</div>
);
}
function CategoryCard({ category }: { category: Category }) {
return (
<div className="cursor-pointer group">
<div className="relative mb-2 overflow-hidden rounded">
<img
src={category.boxArtUrl}
alt={category.name}
className="w-full aspect-[3/4] object-cover group-hover:scale-110 transition"
/>
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black to-transparent p-2">
<div className="text-xs text-gray-300">
{category.viewerCount.toLocaleString()} viewers
</div>
</div>
</div>
<h3 className="font-semibold">{category.name}</h3>
<div className="flex flex-wrap gap-1 mt-1">
{category.tags.slice(0, 2).map(tag => (
<span key={tag} className="text-xs bg-gray-800 px-2 py-1 rounded">
{tag}
</span>
))}
</div>
</div>
);
}Clips System
interface Clip {
id: string;
streamId: string;
channelId: string;
channel: Channel;
// Content
title: string;
videoUrl: string;
thumbnailUrl: string;
duration: number; // seconds (max 60s)
// Metadata
createdBy: string;
createdAt: Date;
views: number;
category: Category;
// Source
streamTimestamp: number; // position in original stream
}
// Create clip from live stream
function createClip(
streamId: string,
currentTimestamp: number,
duration: number = 30
): Clip {
// Capture video from (currentTimestamp - duration/2) to (currentTimestamp + duration/2)
// Max 60 seconds
return {
id: generateId(),
streamId,
channelId: getCurrentChannel().id,
channel: getCurrentChannel(),
title: `${getCurrentChannel().displayName} - Clip`,
videoUrl: `/clips/${generateId()}.mp4`,
thumbnailUrl: `/clips/${generateId()}-thumb.jpg`,
duration: Math.min(duration, 60),
createdBy: getCurrentUser().id,
createdAt: new Date(),
views: 0,
category: getCurrentStream().category,
streamTimestamp: currentTimestamp
};
}
// Clip player
'use client';
export default function ClipPlayer({ clip }: { clip: Clip }) {
const [isPlaying, setIsPlaying] = useState(false);
return (
<div className="bg-gray-900 rounded overflow-hidden">
{/* Video */}
<div className="relative">
<video
src={clip.videoUrl}
poster={clip.thumbnailUrl}
className="w-full aspect-video"
controls
autoPlay={isPlaying}
/>
</div>
{/* Info */}
<div className="p-4">
<div className="flex items-start gap-3 mb-3">
<img
src={clip.channel.avatar}
alt={clip.channel.displayName}
className="w-10 h-10 rounded-full"
/>
<div className="flex-1">
<h3 className="font-semibold">{clip.title}</h3>
<p className="text-sm text-gray-400">{clip.channel.displayName}</p>
</div>
</div>
<div className="flex items-center gap-4 text-sm text-gray-400">
<span>{clip.views.toLocaleString()} views</span>
<span>•</span>
<span>{new Date(clip.createdAt).toLocaleDateString()}</span>
<span>•</span>
<span>{clip.category.name}</span>
</div>
{/* Actions */}
<div className="flex gap-2 mt-4">
<button className="flex-1 px-4 py-2 bg-gray-800 rounded hover:bg-gray-700">
Share
</button>
<button className="flex-1 px-4 py-2 bg-gray-800 rounded hover:bg-gray-700">
Watch Full Video
</button>
</div>
</div>
</div>
);
}VODs (Video on Demand)
interface VOD {
id: string;
channelId: string;
channel: Channel;
// Content
title: string;
videoUrl: string;
thumbnailUrl: string;
duration: number; // seconds
// Metadata
streamedAt: Date;
views: number;
category: Category;
// Chapters (optional)
chapters?: Chapter[];
}
interface Chapter {
id: string;
title: string;
timestamp: number; // seconds from start
duration: number;
}
// VOD with chapters
'use client';
export default function VODPlayer({ vod }: { vod: VOD }) {
const [currentTime, setCurrentTime] = useState(0);
const [showChapters, setShowChapters] = useState(false);
const getCurrentChapter = () => {
if (!vod.chapters) return null;
return vod.chapters.find((chapter, i) => {
const nextChapter = vod.chapters![i + 1];
return currentTime >= chapter.timestamp &&
(!nextChapter || currentTime < nextChapter.timestamp);
});
};
const currentChapter = getCurrentChapter();
return (
<div className="bg-gray-900 rounded overflow-hidden">
<video
src={vod.videoUrl}
poster={vod.thumbnailUrl}
className="w-full aspect-video"
controls
onTimeUpdate={(e) => setCurrentTime(e.currentTarget.currentTime)}
/>
{/* Chapter indicator */}
{currentChapter && (
<div className="bg-gray-800 px-4 py-2 text-sm">
{currentChapter.title}
</div>
)}
{/* Chapters list */}
{vod.chapters && (
<div>
<button
onClick={() => setShowChapters(!showChapters)}
className="w-full px-4 py-2 bg-gray-800 text-left hover:bg-gray-700"
>
Chapters ({vod.chapters.length})
</button>
{showChapters && (
<div className="max-h-60 overflow-y-auto">
{vod.chapters.map(chapter => (
<button
key={chapter.id}
className={`
w-full px-4 py-2 text-left hover:bg-gray-800
${currentChapter?.id === chapter.id ? 'bg-gray-800' : ''}
`}
>
<div className="flex items-center gap-3">
<span className="text-sm text-gray-400">
{formatTime(chapter.timestamp)}
</span>
<span className="flex-1">{chapter.title}</span>
</div>
</button>
))}
</div>
)}
</div>
)}
</div>
);
}
function formatTime(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
if (h > 0) {
return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
}
return `${m}:${s.toString().padStart(2, '0')}`;
}Podsumowanie
Browse categories - box art, viewer count, tags Live channels grid - active streams by category Clips system - create clips from streams (max 60s) Clip player - play clip, view info, share VODs - recorded streams VOD chapters - timestamp navigation Category cards - visual discovery Trending clips - most viewed clips
Masz teraz kompletny Twitch clone z live streaming, chat, subscriptions, clips i VODs!
Do zobaczenia!
Kod do tej lekcji: App.tsx
'use client';
import { useState } from 'react';
interface Channel {
displayName: string;
description: string;
avatar: string;
banner: string;
followers: number;
totalViews: number;
isLive: boolean;
isPartner: boolean;
}
const mockChannel: Channel = {
displayName: 'Kapitan Redbeard',
description: 'Streamuję pirackie przygody i poradniki programowania!',
avatar: 'https://i.pravatar.cc/150?img=12',
banner: 'https://images.unsplash.com/photo-1544551763-46a013bb70d5?w=1200&h=400&fit=crop',
followers: 15247,
totalViews: 1234567,
isLive: true,
isPartner: true
};
export default function ChannelPage() {
const [isFollowing, setIsFollowing] = useState(false);
const [isSubscribed, setIsSubscribed] = useState(false);
const [activeTab, setActiveTab] = useState<'home' | 'videos' | 'about'>('home');
return (
<div className="min-h-screen bg-gray-950 text-white">
{/* Baner */}
<div
className="h-80 bg-cover bg-center"
style={{ backgroundImage: `url(${mockChannel.banner})` }}
/>
{/* Nagłówek kanału */}
<div className="max-w-6xl mx-auto px-6 -mt-16 relative z-10">
<div className="flex items-end gap-6 mb-6">
<img
src={mockChannel.avatar}
alt={mockChannel.displayName}
className="w-32 h-32 rounded-full border-4 border-gray-950"
/>
<div className="flex-1 pb-4">
<h1 className="text-4xl font-bold mb-2">{mockChannel.displayName}</h1>
<p className="text-gray-400">{mockChannel.description}</p>
</div>
<div className="flex items-center gap-3 pb-4">
<button
onClick={() => setIsFollowing(!isFollowing)}
className={`px-6 py-2 rounded font-semibold ${
isFollowing ? 'bg-gray-700 hover:bg-gray-600' : 'bg-purple-600 hover:bg-purple-700'
}`}
>
{isFollowing ? 'Obserwujesz' : 'Obserwuj'}
</button>
<button
onClick={() => setIsSubscribed(!isSubscribed)}
className={`px-6 py-2 rounded font-semibold ${
isSubscribed
? 'bg-purple-900 border border-purple-600 hover:bg-purple-800'
: 'bg-purple-600 hover:bg-purple-700'
}`}
>
{isSubscribed ? 'Subskrybujesz' : 'Subskrybuj'}
</button>
</div>
</div>
<div className="flex gap-6 mb-6 text-sm text-gray-400">
<div>
<span className="font-semibold text-white">
{mockChannel.followers.toLocaleString()}
</span>{' '}
obserwujących
</div>
<div>
<span className="font-semibold text-white">
{mockChannel.totalViews.toLocaleString()}
</span>{' '}
wyświetleń łącznie
</div>
{mockChannel.isPartner && (
<div className="flex items-center gap-1">
<span className="text-purple-500">√</span>
<span>Partner</span>
</div>
)}
</div>
{/* Zakładki */}
<div className="border-b border-gray-800">
<div className="flex gap-8">
{[
{ key: 'home', label: 'Strona główna' },
{ key: 'videos', label: 'Filmy' },
{ key: 'about', label: 'Informacje' }
].map(tab => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key as any)}
className={`pb-3 font-semibold ${
activeTab === tab.key
? 'border-b-2 border-purple-500 text-white'
: 'text-gray-400 hover:text-white'
}`}
>
{tab.label}
</button>
))}
</div>
</div>
</div>
{/* Treść */}
<div className="max-w-6xl mx-auto px-6 py-8">
{activeTab === 'home' && mockChannel.isLive && (
<div className="text-center py-12">
<div className="inline-block px-4 py-2 bg-red-600 text-white font-bold rounded mb-4">
TERAZ NA ŻYWO
</div>
<p className="text-xl">Kanał właśnie nadaje na żywo!</p>
</div>
)}
{activeTab === 'about' && (
<div className="max-w-2xl">
<h2 className="text-xl font-bold mb-4">Informacje</h2>
<p className="text-gray-300 leading-relaxed">{mockChannel.description}</p>
</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. Czym są clips w Twitch?
2. Jaka jest maksymalna długość clipa w funkcji createClip?
To 2 z 10 pytań do tej lekcji. Pozostałe rozwiążesz w grze.
Zadania praktyczne w grze
- Układanie w poziomie
Ułóż zakładki na stronie Browse:
- Edytor kodu
Zbuduj stronę przeglądania kategorii
- Układanie w poziomie
Ułóż wywołanie pobrania pierwszych 2 tagów kategorii (jedna linia): category.tags.slice(0, 2)
- Układanie w pionie
Uporządkuj logikę funkcji createClip (od początku do zwrócenia clipa):
- Edytor kodu
Stwórz odtwarzacz clipów
- Układanie w pionie
Uporządkuj elementy CategoryCard renderowane od góry:
- Klikanie w kolejności
Zbuduj wywołanie formatTime dla timestampu chaptera: formatTime(chapter.timestamp)
- Układanie w pionie
Uporządkuj jakości streamu od najwyższej do najniższej:
- Układanie w pionie
Uporządkuj proces tworzenia clipa: od live streamu do gotowego clipa:
- Klikanie w kolejności
Zbuduj wywołanie zmiany jakości streamu na 720p60: onQualityChange('720p60')
- Układanie w pionie
Uporządkuj ścieżkę użytkownika w discovery Twitcha:
- Układanie w poziomie
Ułóż zakładki channel page w kolejności wyświetlania:
- Układanie w pionie
Uporządkuj sekcje ClipPlayer renderowane od góry:
- Układanie w poziomie
Ułóż elementy renderowania zakładki Categories (jedna linia JSX): mapowanie po kategoriach do CategoryCard
- Układanie w poziomie
Ułóż przyciski akcji w ClipPlayer (lewo do prawo):
- Układanie w pionie
Uporządkuj elementy strony Browse renderowane od góry:
- Układanie w poziomie
Ułóż fragmenty wyniku formatTime dla nagrania krótszego niż godzina (np. 5:30):