Kurs vibe coding z AI · Moduł 10: TikTok Clone

Video Feed i For You Page

5 min czytania
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. 1. Główna różnica TikTok vs YouTube?

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

Przydatne artykuły