import React, { useState, useEffect, useRef } from 'react'; import { VideoPlayer } from './VideoPlayer'; import type { Video, UserProfile } from '../types'; import axios from 'axios'; import { API_BASE_URL } from '../config'; import { Home, Users, Search, X, Plus } from 'lucide-react'; import { videoPrefetcher } from '../utils/videoPrefetch'; import { feedLoader } from '../utils/feedLoader'; type ViewState = 'login' | 'loading' | 'feed'; type TabType = 'foryou' | 'following' | 'search'; // Suggested categories for Following tab const SUGGESTED_CATEGORIES = [ { id: 'hot_trend', name: '๐Ÿ”ฅ Hot Trend 2024', query: 'hot trend' }, { id: 'dance_vn', name: '๐Ÿ’ƒ Gรกi Xinh Nhแบฃy', query: 'gai xinh nhay' }, { id: 'sexy_dance', name: 'โœจ Sexy Dance', query: 'sexy dance vietnam' }, { id: 'music_remix', name: '๐ŸŽต Nhแบกc Remix TikTok', query: 'nhac remix tiktok' }, { id: 'kpop_cover', name: '๐Ÿ‡ฐ๐Ÿ‡ท K-pop Cover', query: 'kpop dance cover' }, { id: 'comedy', name: '๐Ÿ˜‚ Hร i Hฦฐแป›c', query: 'hai huoc vietnam' }, ]; // Famous Dance TikTokers - 50+ accounts from around the world const SUGGESTED_ACCOUNTS = [ // === GLOBAL STARS === { username: '@charlidamelio', label: '๐Ÿ‘‘ Charli D\'Amelio - Queen' }, { username: '@addisonre', label: 'โœจ Addison Rae' }, { username: '@bellapoarch', label: '๐ŸŽต Bella Poarch' }, { username: '@khloekardashian', label: '๐Ÿ’ซ Khloรฉ Kardashian' }, { username: '@jfrancesch', label: '๐Ÿ’ƒ Jason Derulo' }, { username: '@justmaiko', label: '๐Ÿ”ฅ Michael Le' }, { username: '@thereal.animations', label: '๐ŸŽญ Dance Animations' }, { username: '@willsmith', label: '๐ŸŒŸ Will Smith' }, // === K-POP & ASIAN === { username: '@lisa_blackpink', label: '๐Ÿ–ค๐Ÿ’– LISA BLACKPINK' }, { username: '@bfrancisco', label: '๐Ÿ‡ต๐Ÿ‡ญ Bella Francisco' }, { username: '@niana_guerrero', label: '๐ŸŒˆ Niana Guerrero' }, { username: '@ranz', label: '๐ŸŽค Ranz Kyle' }, { username: '@1milliondance', label: '๐Ÿ’ฏ 1Million Dance' }, { username: '@babymonsteryg', label: '๐Ÿพ BABYMONSTER' }, { username: '@enhypen', label: '๐ŸŽต ENHYPEN' }, { username: '@aespaficial', label: 'โœจ aespa' }, { username: '@itzy.all.in.us', label: '๐Ÿ’ช ITZY' }, { username: '@straykids_official', label: '๐Ÿ”ฅ Stray Kids' }, // === DANCE CREWS === { username: '@thechipmunks', label: '๐Ÿฟ๏ธ The Chipmunks' }, { username: '@thekinjaz', label: '๐ŸŽฏ The Kinjaz' }, { username: '@jabbawockeez', label: '๐ŸŽญ Jabbawockeez' }, { username: '@worldofdance', label: '๐ŸŒ World of Dance' }, { username: '@dancemoms', label: '๐Ÿ‘ฏ Dance Moms' }, // === VIRAL DANCERS === { username: '@mikimakey', label: '๐ŸŽ€ Miki Makey' }, { username: '@enola_bedard', label: '๐Ÿ‡ซ๐Ÿ‡ท ร‰nola Bรฉdard' }, { username: '@lizzy_wurst', label: '๐Ÿ˜Š Lizzy Wurst' }, { username: '@thepaigeniemann', label: 'โญ Paige Niemann' }, { username: '@brentrivera', label: '๐Ÿ˜„ Brent Rivera' }, { username: '@larray', label: '๐Ÿ’… Larray' }, { username: '@avani', label: '๐Ÿ–ค Avani' }, { username: '@noahbeck', label: '๐Ÿƒ Noah Beck' }, { username: '@lilhuddy', label: '๐ŸŽธ Lil Huddy' }, // === VIETNAMESE (Verified Usernames) === { username: '@cciinnn', label: '๐Ÿ‘‘ CiiN (Bรนi Thแบฃo Ly)' }, { username: '@hoaa.hanassii', label: '๐Ÿ’ƒ Hoa Hanassii' }, { username: '@lebong95', label: '๐Ÿ’ช Lรช Bแป‘ng' }, { username: '@tieu_hy26', label: '๐Ÿ‘ฐ Tiแปƒu Hรฝ' }, { username: '@hieuthuhai2222', label: '๐ŸŽง HIEUTHUHAI' }, { username: '@mtp.fan', label: '๐ŸŽค Sฦกn Tรนng M-TP' }, { username: '@changmakeup', label: '๐Ÿ’„ Changmakeup' }, { username: '@theanh28entertainment', label: '๐ŸŽฌ Theanh28' }, { username: '@quangdangofficial', label: '๐Ÿ•บ Quang ฤฤƒng' }, { username: '@chipu88', label: '๐ŸŽค Chi Pu' }, { username: '@minhhangofficial', label: '๐Ÿ‘‘ Minh Hแบฑng' }, // === CHOREOGRAPHERS === { username: '@chloearnold', label: '๐ŸŽฌ Chloe Arnold' }, { username: '@alexis_beauregard', label: '๐ŸŒŸ Alexis Beauregard' }, { username: '@mattiapolibio', label: 'โญ Mattia Polibio' }, { username: '@jawsh685', label: '๐ŸŽง Jawsh 685' }, { username: '@daviddooboy', label: '๐Ÿ•บ David Vu' }, // === FUN & COMEDY DANCE === { username: '@domainichael', label: '๐Ÿ˜‚ Domaini Michael' }, { username: '@jailifebymike', label: '๐Ÿ’ƒ Jai Life' }, { username: '@dancewithjulian', label: '๐ŸŽญ Julian' }, { username: '@leiasfanpage', label: '๐Ÿ’– Leia' }, { username: '@taylerholder', label: '๐Ÿ”ฅ Tayler Holder' }, ]; // Inspirational quotes for loading states const INSPIRATION_QUOTES = [ { text: "Dance like nobody's watching", author: "William W. Purkey" }, { text: "Life is short, make every moment count", author: "Unknown" }, { text: "Create the things you wish existed", author: "Unknown" }, { text: "Be yourself; everyone else is taken", author: "Oscar Wilde" }, { text: "Stay hungry, stay foolish", author: "Steve Jobs" }, { text: "The only way to do great work is to love what you do", author: "Steve Jobs" }, { text: "Dream big, start small", author: "Unknown" }, { text: "Creativity takes courage", author: "Henri Matisse" }, ]; // NOTE: Keyword search is now handled by the backend /api/user/search endpoint export const Feed: React.FC = () => { const [viewState, setViewState] = useState('login'); const [activeTab, setActiveTab] = useState('foryou'); const [videos, setVideos] = useState([]); const [currentIndex, setCurrentIndex] = useState(0); const [error, setError] = useState(null); const [showAdvanced, setShowAdvanced] = useState(false); const [jsonInput, setJsonInput] = useState(''); const containerRef = useRef(null); // Following state const [following, setFollowing] = useState([]); const [newFollowInput, setNewFollowInput] = useState(''); // Suggested profiles with real data const [suggestedProfiles, setSuggestedProfiles] = useState([]); const [loadingProfiles, setLoadingProfiles] = useState(false); const [suggestedLimit, setSuggestedLimit] = useState(12); // Lazy load - start with 12 // Search state const [searchInput, setSearchInput] = useState(''); const [searchResults, setSearchResults] = useState([]); const [isSearching, setIsSearching] = useState(false); // Global mute state - persists across video scrolling const [isMuted, setIsMuted] = useState(true); // ========== SWIPE LOGIC ========== const touchStart = useRef(null); const touchEnd = useRef(null); const minSwipeDistance = 50; const onTouchStart = (e: React.TouchEvent) => { touchEnd.current = null; touchStart.current = e.targetTouches[0].clientX; }; const onTouchMove = (e: React.TouchEvent) => { touchEnd.current = e.targetTouches[0].clientX; }; const onTouchEnd = () => { if (!touchStart.current || !touchEnd.current) return; const distance = touchStart.current - touchEnd.current; const isLeftSwipe = distance > minSwipeDistance; const isRightSwipe = distance < -minSwipeDistance; if (isLeftSwipe) { if (activeTab === 'foryou') setActiveTab('following'); else if (activeTab === 'following') setActiveTab('search'); } if (isRightSwipe) { if (activeTab === 'search') setActiveTab('following'); else if (activeTab === 'following') setActiveTab('foryou'); } }; // Check auth status on mount useEffect(() => { checkAuthStatus(); }, []); // Load following list when authenticated useEffect(() => { if (viewState === 'feed') { loadFollowing(); } }, [viewState]); // Load suggested profiles when switching to Following tab useEffect(() => { if (activeTab === 'following' && suggestedProfiles.length === 0 && !loadingProfiles) { loadSuggestedProfiles(); } }, [activeTab]); // Keyboard arrow navigation for desktop useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { // Only handle when in feed view and not typing in an input if (viewState !== 'feed') return; const target = e.target as HTMLElement; if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return; if (e.key === 'ArrowRight') { e.preventDefault(); if (activeTab === 'foryou') setActiveTab('following'); else if (activeTab === 'following') setActiveTab('search'); } else if (e.key === 'ArrowLeft') { e.preventDefault(); if (activeTab === 'search') setActiveTab('following'); else if (activeTab === 'following') setActiveTab('foryou'); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [activeTab, viewState]); useEffect(() => { const prefetch = async () => { await videoPrefetcher.init(); if (activeTab === 'foryou') { videoPrefetcher.prefetchNext(videos, currentIndex); } }; prefetch(); }, [currentIndex, videos, activeTab]); const loadSuggestedProfiles = async () => { setLoadingProfiles(true); try { // Try the dynamic suggested API first (auto-updates from TikTok Vietnam) const res = await axios.get(`${API_BASE_URL}/user/suggested?limit=50`); const accounts = res.data.accounts || []; if (accounts.length > 0) { // Map API response to our profile format setSuggestedProfiles(accounts.map((acc: any) => ({ username: acc.username, nickname: acc.nickname || acc.username, avatar: acc.avatar || null, followers: acc.followers || 0, verified: acc.verified || false }))); } else { // Fallback to static list if API returns empty setSuggestedProfiles(SUGGESTED_ACCOUNTS.map(a => ({ username: a.username.replace('@', ''), nickname: a.label }))); } } catch (err) { console.error('Failed to load profiles:', err); // Fallback to static list on error setSuggestedProfiles(SUGGESTED_ACCOUNTS.map(a => ({ username: a.username.replace('@', ''), nickname: a.label }))); } finally { setLoadingProfiles(false); } }; const checkAuthStatus = async () => { try { const res = await axios.get(`${API_BASE_URL}/auth/status`); if (res.data.authenticated) { loadFeed(); } } catch (err) { console.log('Not authenticated'); } }; const loadFollowing = async () => { try { const res = await axios.get(`${API_BASE_URL}/following`); setFollowing(res.data); } catch (err) { console.error('Failed to load following'); } }; const handleFollow = async (username: string) => { const cleanUsername = username.replace('@', ''); if (following.includes(cleanUsername)) { // Unfollow await axios.delete(`${API_BASE_URL}/following/${cleanUsername}`); setFollowing(prev => prev.filter(u => u !== cleanUsername)); } else { // Follow await axios.post(`${API_BASE_URL}/following`, { username: cleanUsername }); setFollowing(prev => [...prev, cleanUsername]); } }; const handleAddFollow = async () => { if (!newFollowInput.trim()) return; await handleFollow(newFollowInput); setNewFollowInput(''); }; const handleBrowserLogin = async () => { setViewState('loading'); setError(null); try { const res = await axios.post(`${API_BASE_URL}/auth/browser-login`); if (res.data.status === 'success') { loadFeed(); } else { setError(res.data.message || 'Login failed'); setViewState('login'); } } catch (err: any) { setError(err.response?.data?.detail || 'Login failed'); setViewState('login'); } }; const handleJsonLogin = async () => { if (!jsonInput.trim()) { setError('Please paste your credentials'); return; } setViewState('loading'); setError(null); try { const credentials = JSON.parse(jsonInput); await axios.post(`${API_BASE_URL}/auth/credentials`, { credentials }); loadFeed(); } catch (err: any) { setError(err.message || 'Invalid JSON format'); setViewState('login'); } }; const loadFeed = async () => { setViewState('loading'); setError(null); try { const videos = await feedLoader.loadFeedWithOptimization( false, (loaded: Video[]) => { if (loaded.length > 0) { setVideos(loaded); setViewState('feed'); } } ); if (videos.length === 0) { setError('No videos found.'); setViewState('login'); } } catch (err: any) { console.error('Feed load failed:', err); setError(err.response?.data?.detail || 'Failed to load feed'); setViewState('login'); } }; const [isFetching, setIsFetching] = useState(false); const [hasMore, setHasMore] = useState(true); const handleScroll = () => { if (containerRef.current) { const { scrollTop, clientHeight } = containerRef.current; const index = Math.round(scrollTop / clientHeight); if (index !== currentIndex) { setCurrentIndex(index); } // Preemptive fetch at 60% const watchedPercent = videos.length > 0 ? (index + 1) / videos.length : 0; if (watchedPercent >= 0.6 && hasMore && !isFetching && videos.length > 0) { loadMoreVideos(); } } }; const loadMoreVideos = async () => { if (isFetching || !hasMore) return; setIsFetching(true); try { const newVideos = await feedLoader.loadFeedWithOptimization(false); setVideos(prev => { const existingIds = new Set(prev.map(v => v.id)); const unique = newVideos.filter((v: Video) => !existingIds.has(v.id)); if (unique.length === 0) setHasMore(false); return [...prev, ...unique]; }); } catch (err) { console.error('Failed to load more:', err); } finally { setIsFetching(false); } }; const handleLogout = async () => { await axios.post(`${API_BASE_URL}/auth/logout`); setVideos([]); setViewState('login'); }; // Direct username search - bypasses state update delay // Falls back to keyword search if user not found const searchByUsername = async (username: string) => { setSearchInput(`@${username}`); setActiveTab('search'); setIsSearching(true); setSearchResults([]); try { const res = await axios.get(`${API_BASE_URL}/user/videos?username=${username}&limit=12`); const userVideos = res.data.videos as Video[]; if (userVideos.length > 0) { setSearchResults(userVideos); } else { // No videos from user profile, try keyword search console.log(`No videos from @${username}, trying keyword search...`); await fallbackToKeywordSearch(username); } } catch (err) { console.error('Error fetching user videos, trying keyword search:', err); // User not found or error - fallback to keyword search await fallbackToKeywordSearch(username); } finally { setIsSearching(false); } }; // Fallback search when user profile fails const fallbackToKeywordSearch = async (keyword: string) => { try { const res = await axios.get(`${API_BASE_URL}/user/search?query=${encodeURIComponent(keyword)}&limit=12`); const searchVideos = res.data.videos as Video[]; if (searchVideos.length > 0) { setSearchResults(searchVideos); } else { // Still no results - show friendly message setSearchResults([{ id: `no-results-${keyword}`, url: '', author: 'search', description: `No videos found for "${keyword}". Try a different search term.` }]); } } catch (searchErr) { console.error('Keyword search also failed:', searchErr); setSearchResults([{ id: `search-error`, url: '', author: 'search', description: `Search is temporarily unavailable. Please try again later.` }]); } }; // Direct keyword search - bypasses state update delay const searchByKeyword = async (keyword: string) => { setSearchInput(keyword); setActiveTab('search'); setIsSearching(true); setSearchResults([]); try { const res = await axios.get(`${API_BASE_URL}/user/search?query=${encodeURIComponent(keyword)}&limit=12`); const searchVideos = res.data.videos as Video[]; if (searchVideos.length > 0) { setSearchResults(searchVideos); } else { setSearchResults([{ id: `no-results`, url: '', author: 'search', description: `No videos found for "${keyword}"` }]); } } catch (err) { console.error('Error searching:', err); setSearchResults([{ id: `error-search`, url: '', author: 'search', description: `Search failed` }]); } finally { setIsSearching(false); } }; const handleSearch = async () => { if (!searchInput.trim()) return; setIsSearching(true); let input = searchInput.trim(); const results: Video[] = []; // ========== PARSE INPUT TYPE ========== // Type 1: Full TikTok video URL (tiktok.com/@user/video/123) const videoUrlMatch = input.match(/tiktok\.com\/@([\w.]+)\/video\/(\d+)/); if (videoUrlMatch) { const [, author, videoId] = videoUrlMatch; results.push({ id: videoId, url: input.startsWith('http') ? input : `https://www.${input}`, author: author, description: `Video ${videoId} by @${author}` }); } // Type 2: Short share links (vm.tiktok.com, vt.tiktok.com) else if (input.includes('vm.tiktok.com') || input.includes('vt.tiktok.com')) { // These are short links - add as-is, backend will resolve const shortId = input.split('/').pop() || 'unknown'; results.push({ id: `short-${shortId}`, url: input.startsWith('http') ? input : `https://${input}`, author: 'unknown', description: 'Shared TikTok video (click to watch)' }); } // Type 3: Username (@user or just user) - Fetch user's videos else if (input.startsWith('@') || /^[\w.]+$/.test(input)) { const username = input.replace('@', ''); // Show loading state results.push({ id: `loading-${username}`, url: '', author: username, description: `โณ Loading videos from @${username}...` }); setSearchResults(results); // Fetch user's videos from backend try { const res = await axios.get(`${API_BASE_URL}/user/videos?username=${username}&limit=12`); const userVideos = res.data.videos as Video[]; if (userVideos.length > 0) { // Replace loading with actual videos setSearchResults(userVideos); setIsSearching(false); return; } else { // No videos found setSearchResults([{ id: `no-videos-${username}`, url: '', author: username, description: `No videos found for @${username}` }]); setIsSearching(false); return; } } catch (err) { console.error('Error fetching user videos:', err); // Fallback message setSearchResults([{ id: `error-${username}`, url: '', author: username, description: `Could not fetch videos` }]); setIsSearching(false); return; } } // Type 4: Hashtag (#trend) or Generic search term - use search API else { // Show loading for keyword search results.push({ id: `loading-search`, url: '', author: 'search', description: `Searching for "${input}"...` }); setSearchResults(results); // Fetch videos using keyword search API try { const res = await axios.get(`${API_BASE_URL}/user/search?query=${encodeURIComponent(input)}&limit=12`); const searchVideos = res.data.videos as Video[]; if (searchVideos.length > 0) { setSearchResults(searchVideos); setIsSearching(false); return; } else { setSearchResults([{ id: `no-results`, url: '', author: 'search', description: `No videos found for "${input}"` }]); setIsSearching(false); return; } } catch (err) { console.error('Error searching:', err); setSearchResults([{ id: `error-search`, url: '', author: 'search', description: `Search failed. Try a different term.` }]); setIsSearching(false); return; } } setSearchResults(results); setIsSearching(false); // Log for debugging console.log('Search input:', input); console.log('Search results:', results); }; // ========== LOGIN VIEW ========== if (viewState === 'login') { return (
{/* Header */}

PureStream

Ad-free TikTok viewing

{/* Scrollable Content */}
{error && (
{error}
)} {/* How to Login - Step by Step */}

How to Login

1

Open TikTok in browser

Use Chrome/Safari on your phone or computer

2

Export your cookies

Use "Cookie-Editor" extension (Chrome/Firefox)

3

Paste cookies below

Copy the JSON and paste it here

{/* Cookie Input */}