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'; 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' }, ]; // Vietnamese TikTok Dance Influencers const SUGGESTED_ACCOUNTS = [ // Dance Queens { username: '@ciin_rubi', label: '👑 CiiN - Lisa of Vietnam' }, { username: '@hoaa.hanassii', label: '💃 Đào Lê Phương Hoa - Queen of Wiggle' }, { username: '@hoa_2309', label: '🔥 Ngô Ngọc Hòa - Hot Trend' }, { username: '@minah.ne', label: '🎵 Minah - K-pop Dancer' }, // Hot Trend Creators { username: '@lebong95', label: '💪 Lê Bống - Fitness Dance' }, { username: '@po.trann77', label: '✨ Trần Thanh Tâm' }, { username: '@gamkami', label: '🎱 Gấm Kami - Cute Style' }, { username: '@quynhalee', label: '🎮 Quỳnh Alee - Gaming Dance' }, { username: '@tieu_hy26', label: '👰 Tiểu Hý - National Wife' }, // Music & Remix { username: '@changmie', label: '🎤 Changmie - Singer/Mashups' }, { username: '@vuthuydien', label: '😄 Vũ Thụy Điển - Humor' }, ]; // 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); // 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]); const loadSuggestedProfiles = async () => { setLoadingProfiles(true); try { const usernames = SUGGESTED_ACCOUNTS.map(a => a.username.replace('@', '')).join(','); const res = await axios.get(`${API_BASE_URL}/user/profiles?usernames=${usernames}`); setSuggestedProfiles(res.data); } catch (err) { console.error('Failed to load profiles:', err); } 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 { // Stage 1: Fast Load (0 scrolls, roughly 5-10 videos) const fastRes = await axios.get(`${API_BASE_URL}/feed?fast=true`); let initialVideos: Video[] = []; if (Array.isArray(fastRes.data) && fastRes.data.length > 0) { initialVideos = fastRes.data.map((v: any, i: number) => ({ id: v.id || `video-${i}`, url: v.url, author: v.author || 'unknown', description: v.description || '', thumbnail: v.thumbnail, cdn_url: v.cdn_url, views: v.views, likes: v.likes })); setVideos(initialVideos); setViewState('feed'); } // Stage 2: Background Load (Full batch) // Silent fetch to get more videos without blocking UI // We only do this if we got some videos initially, OR if initial failed axios.get(`${API_BASE_URL}/feed`).then(res => { if (Array.isArray(res.data) && res.data.length > 0) { const moreVideos = res.data.map((v: any, i: number) => ({ id: v.id || `video-full-${i}`, url: v.url, author: v.author || 'unknown', description: v.description || '', thumbnail: v.thumbnail, cdn_url: v.cdn_url, views: v.views, likes: v.likes })); // Deduplicate and append setVideos(prev => { const existingIds = new Set(prev.map(v => v.id)); const distinctNew = moreVideos.filter((v: Video) => !existingIds.has(v.id)); return [...prev, ...distinctNew]; }); // If we were in login/error state, switch to feed now setViewState(prev => prev === 'feed' ? 'feed' : 'feed'); } }).catch(console.error); // Silent error for background fetch if (initialVideos.length === 0) { // If fast fetch failed to get videos, we wait for background... // But simplified: show 'No videos' only if fast returned empty // The background fetch will update UI if it finds something if (!initialVideos.length) { // Keep loading state until background finishes? // Or show error? For now, let's just let the user wait or see empty // Ideally we'd have a 'fetching more' indicator } } } catch (err: any) { console.error('Fast feed failed', err); // Fallback to full fetch if fast fails axios.get(`${API_BASE_URL}/feed`).then(res => { if (Array.isArray(res.data) && res.data.length > 0) { const mapped = res.data.map((v: any, i: number) => ({ id: v.id || `video-fallback-${i}`, url: v.url, author: v.author || 'unknown', description: v.description || '', thumbnail: v.thumbnail, cdn_url: v.cdn_url, views: v.views, likes: v.likes })); setVideos(mapped); setViewState('feed'); } else { setError('No videos found.'); setViewState('login'); } }).catch(e => { setError(e.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 res = await axios.get(`${API_BASE_URL}/feed`); if (Array.isArray(res.data) && res.data.length > 0) { const newVideos = res.data.map((v: any, i: number) => ({ id: v.id || `video-new-${Date.now()}-${i}`, url: v.url, author: v.author || 'unknown', description: v.description || '' })); setVideos(prev => { const existingIds = new Set(prev.map(v => v.id)); const unique = newVideos.filter((v: any) => !existingIds.has(v.id)); if (unique.length === 0) setHasMore(false); return [...prev, ...unique]; }); } else { setHasMore(false); } } 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 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 { setSearchResults([{ id: `no-videos-${username}`, url: '', author: username, description: `No videos found for @${username}` }]); } } catch (err) { console.error('Error fetching user videos:', err); setSearchResults([{ id: `error-${username}`, url: '', author: username, description: `Could not fetch videos` }]); } finally { setIsSearching(false); } }; // 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 */}