Kurs vibe coding z AI · Moduł 10: TikTok Clone
Video Feed i For You Page
W tej lekcji5
Witaj! Czas stworzyć Treasure Reels - platformę krótkich pirackich wideo!
To będzie jak TikTok - vertical short videos, For You algorithm, sounds, effects i duet/stitch features.
TikTok vs YouTube/Instagram
YouTube:
- Long-form horizontal video (10+ min)
- Subscriptions i recommended
- Comments pod wideo
Instagram Reels:
- Short vertical video w Stories/Reels tab
- Głównie photos + video dodatkowo
- Grid layout
TikTok:
- Pure short vertical video (15-60s)
- For You Page algorithm (highly personalized)
- Sounds library i audio trends
- Duet, Stitch, Effects
- Fullscreen immersive feed
Video Structure
interface Video {
id: string;
authorId: string;
author: {
id: string;
username: string;
avatar: string;
isVerified: boolean;
};
// Video content
videoUrl: string;
thumbnail: string;
duration: number; // seconds
// Caption
caption: string;
hashtags: string[]; // #treasurehunt, #piratelife
// Audio
sound: Sound;
// Engagement
likes: number;
comments: number;
shares: number;
views: number;
// User engagement
hasLiked: boolean;
hasBookmarked: boolean;
isFollowingAuthor: boolean;
createdAt: Date;
}
interface Sound {
id: string;
name: string; // "Pirate's Anthem - Redbeard"
author: string;
duration: number;
useCount: number; // ile wideo używa tego sound
thumbnailUrl?: string;
}For You Feed
'use client';
import { useState, useRef, useEffect } from 'react';
interface FeedProps {
videos: Video[];
onLike: (videoId: string) => void;
onFollow: (authorId: string) => void;
onComment: (videoId: string) => void;
onShare: (videoId: string) => void;
}
export default function ForYouFeed({
videos,
onLike,
onFollow,
onComment,
onShare
}: FeedProps) {
const [currentIndex, setCurrentIndex] = useState(0);
const [isPlaying, setIsPlaying] = useState(true);
const videoRefs = useRef<(HTMLVideoElement | null)[]>([]);
const currentVideo = videos[currentIndex];
// Auto-play current video
useEffect(() => {
const video = videoRefs.current[currentIndex];
if (video) {
if (isPlaying) {
video.play();
} else {
video.pause();
}
}
}, [currentIndex, isPlaying]);
const handleScroll = (direction: 'up' | 'down') => {
if (direction === 'down' && currentIndex < videos.length - 1) {
setCurrentIndex(currentIndex + 1);
} else if (direction === 'up' && currentIndex > 0) {
setCurrentIndex(currentIndex - 1);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'ArrowUp') handleScroll('up');
if (e.key === 'ArrowDown') handleScroll('down');
if (e.key === ' ') {
e.preventDefault();
setIsPlaying(!isPlaying);
}
};
return (
<div
className="h-screen overflow-hidden bg-black relative"
onKeyDown={handleKeyDown}
tabIndex={0}
>
{/* Video Container */}
<div
className="absolute inset-0 transition-transform duration-300"
style={{
transform: `translateY(-${currentIndex * 100}%)`
}}
>
{videos.map((video, index) => (
<div
key={video.id}
className="h-screen w-full relative flex items-center justify-center"
>
{/* Video */}
<video
ref={el => videoRefs.current[index] = el}
src={video.videoUrl}
className="h-full w-auto max-w-full object-contain"
loop
playsInline
onClick={() => setIsPlaying(!isPlaying)}
/>
{/* Play/Pause Indicator */}
{!isPlaying && index === currentIndex && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-20 h-20 bg-black/50 rounded-full flex items-center justify-center">
<span className="text-white text-4xl">▶</span>
</div>
</div>
)}
{/* Video Info Overlay */}
{index === currentIndex && (
<VideoInfoOverlay
video={video}
onLike={() => onLike(video.id)}
onFollow={() => onFollow(video.author.id)}
onComment={() => onComment(video.id)}
onShare={() => onShare(video.id)}
/>
)}
</div>
))}
</div>
{/* Navigation Arrows */}
<div className="absolute right-4 top-1/2 transform -translate-y-1/2 flex flex-col gap-4">
<button
onClick={() => handleScroll('up')}
disabled={currentIndex === 0}
className="w-12 h-12 bg-white/20 hover:bg-white/30 disabled:bg-white/10 rounded-full flex items-center justify-center text-white"
>
▲
</button>
<button
onClick={() => handleScroll('down')}
disabled={currentIndex === videos.length - 1}
className="w-12 h-12 bg-white/20 hover:bg-white/30 disabled:bg-white/10 rounded-full flex items-center justify-center text-white"
>
▼
</button>
</div>
{/* Progress Indicator */}
<div className="absolute top-4 right-4 text-white text-sm bg-black/50 px-3 py-1 rounded-full">
{currentIndex + 1} / {videos.length}
</div>
</div>
);
}Video Info Overlay
interface VideoInfoOverlayProps {
video: Video;
onLike: () => void;
onFollow: () => void;
onComment: () => void;
onShare: () => void;
}
function VideoInfoOverlay({
video,
onLike,
onFollow,
onComment,
onShare
}: VideoInfoOverlayProps) {
return (
<>
{/* Right Side Actions */}
<div className="absolute right-4 bottom-20 flex flex-col gap-6 items-center">
{/* Author Avatar */}
<div className="relative">
<img
src={video.author.avatar}
alt={video.author.username}
className="w-12 h-12 rounded-full border-2 border-white"
/>
{!video.isFollowingAuthor && (
<button
onClick={onFollow}
className="absolute -bottom-2 left-1/2 transform -translate-x-1/2 w-6 h-6 bg-red-500 rounded-full text-white text-xl flex items-center justify-center"
>
+
</button>
)}
{video.author.isVerified && (
<div className="absolute -top-1 -right-1 w-4 h-4 bg-blue-500 rounded-full flex items-center justify-center text-white text-xs">
√
</div>
)}
</div>
{/* Like */}
<button onClick={onLike} className="flex flex-col items-center gap-1">
<div
className={`text-sm font-semibold transition ${
video.hasLiked ? 'text-red-500' : 'text-white'
}`}
>
{video.hasLiked ? 'Polubione' : 'Lubię to'}
</div>
<span className="text-white text-xs font-semibold">
{formatCount(video.likes)}
</span>
</button>
{/* Comment */}
<button onClick={onComment} className="flex flex-col items-center gap-1">
<div className="text-sm font-semibold text-white">Komentarz</div>
<span className="text-white text-xs font-semibold">
{formatCount(video.comments)}
</span>
</button>
{/* Bookmark */}
<button className="flex flex-col items-center gap-1">
<div
className={`text-sm font-semibold transition ${
video.hasBookmarked ? 'text-yellow-400' : 'text-white'
}`}
>
{video.hasBookmarked ? 'Zapisane' : 'Zapisz'}
</div>
</button>
{/* Share */}
<button onClick={onShare} className="flex flex-col items-center gap-1">
<div className="text-sm font-semibold text-white">Udostępnij</div>
<span className="text-white text-xs font-semibold">
{formatCount(video.shares)}
</span>
</button>
{/* Sound */}
<button className="w-10 h-10 rounded-full overflow-hidden border-2 border-white animate-spin-slow">
<img
src={video.sound.thumbnailUrl || video.author.avatar}
alt={video.sound.name}
className="w-full h-full object-cover"
/>
</button>
</div>
{/* Bottom Info */}
<div className="absolute bottom-4 left-4 right-20">
{/* Author */}
<div className="flex items-center gap-2 mb-2">
<span className="text-white font-bold">@{video.author.username}</span>
{video.author.isVerified && (
<span className="text-blue-400">√</span>
)}
</div>
{/* Caption */}
<p className="text-white text-sm mb-2 line-clamp-2">
{video.caption}
</p>
{/* Hashtags */}
{video.hashtags.length > 0 && (
<div className="flex flex-wrap gap-2 mb-3">
{video.hashtags.map((tag, idx) => (
<span key={idx} className="text-white text-sm font-semibold">
#{tag}
</span>
))}
</div>
)}
{/* Sound */}
<div className="flex items-center gap-2 bg-black/40 px-3 py-1.5 rounded-full max-w-xs">
<span className="text-white text-xs">Muzyka</span>
<span className="text-white text-xs truncate">
{video.sound.name}
</span>
</div>
</div>
</>
);
}
function formatCount(count: number): string {
if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`;
if (count >= 1000) return `${(count / 1000).toFixed(1)}K`;
return count.toString();
}Podsumowanie
Vertical video feed - fullscreen immersive scroll For You algorithm - personalized content Video overlay - author, caption, hashtags, sound Right side actions - like, comment, bookmark, share Scroll navigation - swipe up/down between videos Auto-play - video plays automatically when visible Play/pause - tap video to toggle Sound indicator - spinning disc with audio info Follow button - quick follow from avatar
W następnym ćwiczeniu dodamy sounds library i video creation!
Do zobaczenia!
Kod do tej lekcji: App.jsx
// Pionowy feed wideo
import { useState } from 'react';
const videos = [
{
id: 1,
author: { username: 'CaptainJack', avatar: '', isVerified: true },
caption: 'Znalazłem skarb! #PoszukiwanieSkarbów #ŻyciePirata',
hashtags: ['PoszukiwanieSkarbów', 'ŻyciePirata'],
likes: 125000,
comments: 3400,
shares: 890,
sound: { name: "Hymn piratów - Redbeard" },
hasLiked: false,
thumbnail: ''
},
{
id: 2,
author: { username: 'BlackbeardCrew', avatar: '', isVerified: false },
caption: 'Epicka bitwa na morzu! #BitwaMorska #Piraci',
hashtags: ['BitwaMorska', 'Piraci'],
likes: 89000,
comments: 2100,
shares: 560,
sound: { name: 'Motyw bitewny - Morgan' },
hasLiked: false,
thumbnail: ''
},
{
id: 3,
author: { username: 'TreasureHunter', avatar: '', isVerified: true },
caption: 'Mapa do Isla de Muerta #Mapa #TajnaWyspa',
hashtags: ['Mapa', 'TajnaWyspa'],
likes: 234000,
comments: 5600,
shares: 1200,
sound: { name: 'Przygoda czeka - Elizabeth' },
hasLiked: false,
thumbnail: ''
}
];
export default function App() {
const [currentIndex, setCurrentIndex] = useState(0);
const [videoStates, setVideoStates] = useState(videos);
const currentVideo = videoStates[currentIndex];
const handleScroll = (direction) => {
if (direction === 'down' && currentIndex < videos.length - 1) {
setCurrentIndex(currentIndex + 1);
} else if (direction === 'up' && currentIndex > 0) {
setCurrentIndex(currentIndex - 1);
}
};
const handleLike = () => {
setVideoStates(videoStates.map((v, i) =>
i === currentIndex
? { ...v, likes: v.hasLiked ? v.likes - 1 : v.likes + 1, hasLiked: !v.hasLiked }
: v
));
};
const formatCount = (count) => {
if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`;
if (count >= 1000) return `${(count / 1000).toFixed(1)}K`;
return count.toString();
};
return (
<div className="h-screen bg-black relative overflow-hidden">
{/* Kontener wideo */}
<div
className="absolute inset-0 transition-transform duration-300"
style={{ transform: `translateY(-${currentIndex * 100}%)` }}
>
{videoStates.map((video, index) => (
<div
key={video.id}
className="h-screen w-full relative flex items-center justify-center bg-gradient-to-br from-purple-900 to-blue-900"
>
{/* Miejsce na wideo */}
<div className="text-9xl">{video.thumbnail}</div>
{/* Nakładka z informacjami o wideo */}
{index === currentIndex && (
<>
{/* Akcje po prawej */}
<div className="absolute right-4 bottom-20 flex flex-col gap-6 items-center">
{/* Awatar */}
<div className="relative text-4xl">
{video.author.avatar}
{video.author.isVerified && (
<div className="absolute -top-1 -right-1 w-4 h-4 bg-blue-500 rounded-full flex items-center justify-center text-white text-xs">
√
</div>
)}
</div>
{/* Polubienie */}
<button onClick={handleLike} className="flex flex-col items-center gap-1">
<div className={`text-sm font-semibold ${video.hasLiked ? 'text-red-500' : 'text-white'}`}>
{video.hasLiked ? 'Polubione' : 'Lubię to'}
</div>
<span className="text-white text-xs font-bold">
{formatCount(video.likes)}
</span>
</button>
{/* Komentarz */}
<button className="flex flex-col items-center gap-1">
<div className="text-sm font-semibold text-white">Komentarz</div>
<span className="text-white text-xs font-bold">
{formatCount(video.comments)}
</span>
</button>
{/* Udostępnianie */}
<button className="flex flex-col items-center gap-1">
<div className="text-sm font-semibold text-white">Udostępnij</div>
<span className="text-white text-xs font-bold">
{formatCount(video.shares)}
</span>
</button>
{/* Dźwięk */}
<button className="w-10 h-10 rounded-full bg-gradient-to-r from-pink-500 to-purple-500 animate-spin-slow flex items-center justify-center text-xl">
◉
</button>
</div>
{/* Informacje na dole */}
<div className="absolute bottom-4 left-4 right-20">
<div className="flex items-center gap-2 mb-2">
<span className="text-white font-bold">@{video.author.username}</span>
{video.author.isVerified && <span className="text-blue-400">√</span>}
</div>
<p className="text-white text-sm mb-2">{video.caption}</p>
<div className="flex items-center gap-2 bg-black/40 px-3 py-1.5 rounded-full max-w-xs">
<span className="text-white text-xs">Muzyka</span>
<span className="text-white text-xs truncate">{video.sound.name}</span>
</div>
</div>
</>
)}
</div>
))}
</div>
{/* Nawigacja */}
<div className="absolute right-4 top-1/2 transform -translate-y-1/2 flex flex-col gap-4">
<button
onClick={() => handleScroll('up')}
disabled={currentIndex === 0}
className="w-12 h-12 bg-white/20 hover:bg-white/30 disabled:bg-white/10 rounded-full flex items-center justify-center text-white"
>
▲
</button>
<button
onClick={() => handleScroll('down')}
disabled={currentIndex === videos.length - 1}
className="w-12 h-12 bg-white/20 hover:bg-white/30 disabled:bg-white/10 rounded-full flex items-center justify-center text-white"
>
▼
</button>
</div>
{/* Postęp */}
<div className="absolute top-4 right-4 text-white text-sm bg-black/50 px-3 py-1 rounded-full">
{currentIndex + 1} / {videos.length}
</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. Główna różnica TikTok vs YouTube?
2. Czym jest For You Page?
To 2 z 7 pytań do tej lekcji. Pozostałe rozwiążesz w grze.
Zadania praktyczne w grze
- Edytor kodu
TikTok-style feed
- Układanie w poziomie
Ułóż linię deklarującą aktualny indeks wideo w feedzie:
- Klikanie w kolejności
Zbuduj wywołanie odtworzenia bieżącego wideo (klikaj po kolei):
- Edytor kodu
Video overlay component
- Układanie w pionie
Uporządkuj akcje na prawej krawędzi overlayu (z góry na dół):
- Układanie w poziomie
Ułóż początek mapowania hashtagów wideo na JSX: