mirror of
https://github.com/vndangkhoa/purestream.git
synced 2026-04-05 01:17:58 +07:00
1220 lines
60 KiB
TypeScript
1220 lines
60 KiB
TypeScript
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<ViewState>('login');
|
||
const [activeTab, setActiveTab] = useState<TabType>('foryou');
|
||
const [videos, setVideos] = useState<Video[]>([]);
|
||
const [currentIndex, setCurrentIndex] = useState(0);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||
const [jsonInput, setJsonInput] = useState('');
|
||
const containerRef = useRef<HTMLDivElement>(null);
|
||
|
||
// Following state
|
||
const [following, setFollowing] = useState<string[]>([]);
|
||
const [newFollowInput, setNewFollowInput] = useState('');
|
||
|
||
// Suggested profiles with real data
|
||
const [suggestedProfiles, setSuggestedProfiles] = useState<UserProfile[]>([]);
|
||
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<Video[]>([]);
|
||
const [isSearching, setIsSearching] = useState(false);
|
||
|
||
// Global mute state - persists across video scrolling
|
||
const [isMuted, setIsMuted] = useState(true);
|
||
|
||
// ========== SWIPE LOGIC ==========
|
||
const touchStart = useRef<number | null>(null);
|
||
const touchEnd = useRef<number | null>(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 (
|
||
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-black to-gray-950 flex flex-col">
|
||
{/* Header */}
|
||
<div className="flex-shrink-0 pt-12 pb-6 px-6 text-center">
|
||
<div className="relative inline-block mb-4">
|
||
<div className="w-16 h-16 bg-gradient-to-r from-cyan-400 to-pink-500 rounded-2xl rotate-12 absolute -inset-1 blur-lg opacity-50" />
|
||
<div className="relative w-16 h-16 bg-gradient-to-r from-cyan-400 to-pink-500 rounded-2xl flex items-center justify-center">
|
||
<svg className="w-8 h-8 text-white" viewBox="0 0 24 24" fill="currentColor">
|
||
<path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z" />
|
||
</svg>
|
||
</div>
|
||
</div>
|
||
<h1 className="text-2xl font-bold text-white mb-1">PureStream</h1>
|
||
<p className="text-gray-500 text-sm">Ad-free TikTok viewing</p>
|
||
</div>
|
||
|
||
{/* Scrollable Content */}
|
||
<div className="flex-1 overflow-y-auto px-5 pb-8">
|
||
<div className="max-w-sm mx-auto">
|
||
{error && (
|
||
<div className="mb-5 p-4 bg-red-500/10 border border-red-500/20 rounded-2xl text-red-400 text-sm text-center">
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
{/* How to Login - Step by Step */}
|
||
<div className="mb-6">
|
||
<h2 className="text-white font-semibold text-lg mb-4 text-center">How to Login</h2>
|
||
|
||
<div className="space-y-3">
|
||
<div className="flex items-start gap-3 p-3 bg-white/5 rounded-xl">
|
||
<div className="w-7 h-7 bg-cyan-500 rounded-full flex items-center justify-center flex-shrink-0 text-white font-bold text-sm">1</div>
|
||
<div>
|
||
<p className="text-white text-sm font-medium">Open TikTok in browser</p>
|
||
<p className="text-gray-500 text-xs mt-0.5">Use Chrome/Safari on your phone or computer</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-start gap-3 p-3 bg-white/5 rounded-xl">
|
||
<div className="w-7 h-7 bg-pink-500 rounded-full flex items-center justify-center flex-shrink-0 text-white font-bold text-sm">2</div>
|
||
<div>
|
||
<p className="text-white text-sm font-medium">Export your cookies</p>
|
||
<p className="text-gray-500 text-xs mt-0.5">Use "Cookie-Editor" extension (Chrome/Firefox)</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-start gap-3 p-3 bg-white/5 rounded-xl">
|
||
<div className="w-7 h-7 bg-purple-500 rounded-full flex items-center justify-center flex-shrink-0 text-white font-bold text-sm">3</div>
|
||
<div>
|
||
<p className="text-white text-sm font-medium">Paste cookies below</p>
|
||
<p className="text-gray-500 text-xs mt-0.5">Copy the JSON and paste it here</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Cookie Input */}
|
||
<div className="mb-4">
|
||
<textarea
|
||
value={jsonInput}
|
||
onChange={(e) => setJsonInput(e.target.value)}
|
||
placeholder='Paste your cookie JSON here...'
|
||
className="w-full h-32 bg-black/60 border-2 border-white/10 rounded-2xl p-4 text-white text-sm font-mono resize-none focus:outline-none focus:border-cyan-500/50 placeholder:text-gray-600"
|
||
/>
|
||
</div>
|
||
|
||
{/* Connect Button */}
|
||
<button
|
||
onClick={handleJsonLogin}
|
||
disabled={!jsonInput.trim()}
|
||
className={`w-full py-4 text-white font-semibold rounded-2xl transition-all transform active:scale-[0.98] shadow-lg text-base ${jsonInput.trim()
|
||
? 'bg-gradient-to-r from-cyan-500 to-pink-500 hover:from-cyan-400 hover:to-pink-400 shadow-pink-500/20'
|
||
: 'bg-gray-700 cursor-not-allowed'
|
||
}`}
|
||
>
|
||
Connect to TikTok
|
||
</button>
|
||
|
||
{/* Help Link */}
|
||
<div className="mt-6 text-center">
|
||
<a
|
||
href="https://chrome.google.com/webstore/detail/cookie-editor/hlkenndednhfkekhgcdicdfddnkalmdm"
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="text-cyan-400 text-sm underline"
|
||
>
|
||
Get Cookie-Editor Extension →
|
||
</a>
|
||
</div>
|
||
|
||
{/* Desktop Browser Login - Hidden by default */}
|
||
<div className="mt-8 pt-6 border-t border-white/10">
|
||
<button
|
||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||
className="w-full text-gray-500 hover:text-gray-400 text-sm py-2 flex items-center justify-center gap-2"
|
||
>
|
||
<span>{showAdvanced ? '▲' : '▼'}</span>
|
||
<span>Desktop Browser Login</span>
|
||
</button>
|
||
|
||
{showAdvanced && (
|
||
<div className="mt-3 p-4 bg-white/5 rounded-xl">
|
||
<p className="text-gray-400 text-xs text-center mb-3">
|
||
⚠️ Only works on local machines with a display
|
||
</p>
|
||
<button
|
||
onClick={handleBrowserLogin}
|
||
className="w-full py-3 bg-white/10 hover:bg-white/20 text-white rounded-xl transition-all text-sm"
|
||
>
|
||
Open TikTok Login Window
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ========== LOADING VIEW ==========
|
||
if (viewState === 'loading') {
|
||
return (
|
||
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-black to-gray-950 flex flex-col items-center justify-center">
|
||
<div className="relative mb-8">
|
||
<div className="absolute inset-0 blur-xl bg-gradient-to-r from-cyan-500/30 via-pink-500/30 to-cyan-500/30 animate-pulse rounded-full scale-150" />
|
||
<div className="relative w-20 h-20 flex items-center justify-center">
|
||
<div className="absolute w-16 h-16 bg-cyan-400 rounded-xl rotate-12 animate-pulse" />
|
||
<div className="absolute w-16 h-16 bg-pink-500 rounded-xl -rotate-12 animate-pulse" style={{ animationDelay: '0.3s' }} />
|
||
<div className="absolute w-16 h-16 bg-white rounded-xl flex items-center justify-center z-10">
|
||
<svg className="w-8 h-8 text-black" viewBox="0 0 24 24" fill="currentColor">
|
||
<path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z" />
|
||
</svg>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<p className="text-white/70 text-sm animate-pulse">Connecting to TikTok...</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
|
||
|
||
// ========== FEED VIEW WITH TABS ==========
|
||
return (
|
||
<div
|
||
className="relative w-full h-screen bg-black overflow-hidden"
|
||
onTouchStart={onTouchStart}
|
||
onTouchMove={onTouchMove}
|
||
onTouchEnd={onTouchEnd}
|
||
>
|
||
{/* Tab Navigation */}
|
||
<div className="absolute top-0 left-0 right-0 z-50 flex justify-center pt-4 pb-2 bg-gradient-to-b from-black via-black/80 to-transparent">
|
||
<div className="flex gap-1 bg-white/10 backdrop-blur-md rounded-full p-1">
|
||
<button
|
||
onClick={() => setActiveTab('foryou')}
|
||
className={`flex items-center gap-2 px-4 py-2 rounded-full text-sm font-medium transition-all ${activeTab === 'foryou'
|
||
? 'bg-white text-black'
|
||
: 'text-white/70 hover:text-white'
|
||
}`}
|
||
title="For You"
|
||
>
|
||
<Home size={16} />
|
||
<span className="hidden md:inline">For You</span>
|
||
</button>
|
||
<button
|
||
onClick={() => setActiveTab('following')}
|
||
className={`flex items-center gap-2 px-4 py-2 rounded-full text-sm font-medium transition-all ${activeTab === 'following'
|
||
? 'bg-white text-black'
|
||
: 'text-white/70 hover:text-white'
|
||
}`}
|
||
title="Following"
|
||
>
|
||
<Users size={16} />
|
||
<span className="hidden md:inline">Following</span>
|
||
</button>
|
||
<button
|
||
onClick={() => setActiveTab('search')}
|
||
className={`flex items-center gap-2 px-4 py-2 rounded-full text-sm font-medium transition-all ${activeTab === 'search'
|
||
? 'bg-white text-black'
|
||
: 'text-white/70 hover:text-white'
|
||
}`}
|
||
title="Search"
|
||
>
|
||
<Search size={16} />
|
||
<span className="hidden md:inline">Search</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Logout Button - Left Corner Icon */}
|
||
<button
|
||
onClick={handleLogout}
|
||
className="absolute top-4 left-4 z-50 w-10 h-10 flex items-center justify-center bg-white/10 hover:bg-white/20 backdrop-blur-sm rounded-full text-white transition-colors"
|
||
title="Logout"
|
||
>
|
||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||
<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4" />
|
||
<polyline points="16,17 21,12 16,7" />
|
||
<line x1="21" y1="12" x2="9" y2="12" />
|
||
</svg>
|
||
</button>
|
||
|
||
{/* FOR YOU TAB */}
|
||
<div className={`absolute inset-0 w-full h-full transition-all duration-300 ease-out ${activeTab === 'foryou'
|
||
? 'translate-x-0 opacity-100'
|
||
: activeTab === 'following' || activeTab === 'search'
|
||
? '-translate-x-full opacity-0 pointer-events-none'
|
||
: 'translate-x-full opacity-0 pointer-events-none'
|
||
}`}>
|
||
{/* Video Counter */}
|
||
<div className="absolute bottom-6 right-4 z-40 px-3 py-1.5 bg-black/60 backdrop-blur-sm rounded-full border border-white/10">
|
||
<span className="text-xs text-white/60 font-medium">
|
||
{currentIndex + 1} / {videos.length}
|
||
{hasMore && <span className="text-cyan-400 ml-1">+</span>}
|
||
</span>
|
||
</div>
|
||
|
||
{/* Loading Indicator */}
|
||
{isFetching && (
|
||
<div className="absolute top-16 left-1/2 -translate-x-1/2 z-40 px-4 py-2 bg-black/80 backdrop-blur-md rounded-full border border-white/10 flex items-center gap-2">
|
||
<div className="w-2 h-2 bg-cyan-400 rounded-full animate-ping" />
|
||
<span className="text-xs text-white/70">Loading more...</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* Video Feed */}
|
||
<div
|
||
ref={containerRef}
|
||
onScroll={handleScroll}
|
||
className="w-full h-full overflow-y-auto snap-y snap-mandatory scrollbar-hide pt-14"
|
||
style={{ scrollbarWidth: 'none' }}
|
||
>
|
||
{videos.map((video, index) => (
|
||
<div key={video.id} className="w-full h-screen snap-start snap-always bg-black">
|
||
{Math.abs(index - currentIndex) <= 1 ? (
|
||
<VideoPlayer
|
||
video={video}
|
||
isActive={activeTab === 'foryou' && index === currentIndex}
|
||
isFollowing={following.includes(video.author)}
|
||
onFollow={handleFollow}
|
||
onAuthorClick={(author) => searchByUsername(author)}
|
||
isMuted={isMuted}
|
||
onMuteToggle={() => setIsMuted(prev => !prev)}
|
||
/>
|
||
) : (
|
||
/* Lightweight Placeholder */
|
||
<div className="w-full h-full bg-black flex items-center justify-center relative overflow-hidden">
|
||
{video.thumbnail ? (
|
||
<>
|
||
<img
|
||
src={video.thumbnail}
|
||
className="w-full h-full object-cover opacity-30 blur-xl scale-110"
|
||
loading="lazy"
|
||
/>
|
||
<div className="absolute inset-0 flex items-center justify-center">
|
||
<div className="w-10 h-10 border-4 border-white/10 border-t-white/30 rounded-full animate-spin" />
|
||
</div>
|
||
</>
|
||
) : (
|
||
<div className="w-10 h-10 border-4 border-white/10 border-t-white/30 rounded-full animate-spin" />
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* FOLLOWING TAB - Minimal Style */}
|
||
<div className={`absolute inset-0 w-full h-full pt-16 px-4 pb-6 overflow-y-auto transition-all duration-300 ease-out ${activeTab === 'following'
|
||
? 'translate-x-0 opacity-100'
|
||
: activeTab === 'foryou'
|
||
? 'translate-x-full opacity-0 pointer-events-none'
|
||
: '-translate-x-full opacity-0 pointer-events-none'
|
||
}`}>
|
||
<div className="max-w-lg mx-auto">
|
||
|
||
{/* Minimal Add Input */}
|
||
<div className="relative mb-8">
|
||
<input
|
||
type="text"
|
||
value={newFollowInput}
|
||
onChange={(e) => setNewFollowInput(e.target.value)}
|
||
onKeyDown={(e) => e.key === 'Enter' && handleAddFollow()}
|
||
placeholder="Add @username to follow..."
|
||
className="w-full bg-transparent border-b-2 border-white/20 focus:border-white/60 px-0 py-4 text-white text-lg focus:outline-none transition-colors placeholder:text-white/30"
|
||
/>
|
||
<button
|
||
onClick={handleAddFollow}
|
||
className="absolute right-0 top-1/2 -translate-y-1/2 p-2 text-white/50 hover:text-white transition-colors"
|
||
>
|
||
<Plus size={24} />
|
||
</button>
|
||
</div>
|
||
|
||
{/* My Following - Minimal chips */}
|
||
{following.length > 0 && (
|
||
<div className="mb-10">
|
||
<p className="text-white/40 text-xs uppercase tracking-wider mb-3">Following</p>
|
||
<div className="flex flex-wrap gap-2">
|
||
{following.map(user => (
|
||
<div key={user} className="flex items-center gap-2 bg-white/5 rounded-full pl-3 pr-1 py-1">
|
||
<button
|
||
onClick={() => searchByUsername(user)}
|
||
className="text-white/80 text-sm hover:text-white transition-colors"
|
||
>
|
||
@{user}
|
||
</button>
|
||
<button
|
||
onClick={() => handleFollow(user)}
|
||
className="p-1 text-white/30 hover:text-red-400 transition-colors"
|
||
>
|
||
<X size={14} />
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Trending - 2 columns */}
|
||
<div className="mb-10">
|
||
<p className="text-white/40 text-xs uppercase tracking-wider mb-3">Trending</p>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
{SUGGESTED_CATEGORIES.map(cat => (
|
||
<button
|
||
key={cat.id}
|
||
onClick={() => searchByKeyword(cat.query)}
|
||
className="bg-white/5 hover:bg-white/10 rounded-lg px-3 py-2.5 text-left text-white/70 hover:text-white text-sm transition-colors"
|
||
>
|
||
{cat.name}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Suggested Accounts - Compact avatars */}
|
||
<div>
|
||
<p className="text-white/40 text-xs uppercase tracking-wider mb-4">Suggested</p>
|
||
|
||
{loadingProfiles && (
|
||
<div className="flex justify-center py-8">
|
||
<div className="w-8 h-8 border-2 border-white/10 border-t-cyan-500 rounded-full animate-spin"></div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="grid grid-cols-4 gap-4">
|
||
{(suggestedProfiles.length > 0 ? suggestedProfiles : SUGGESTED_ACCOUNTS.map(a => ({ username: a.username.replace('@', ''), nickname: a.label }))).slice(0, suggestedLimit).map((profile: UserProfile | { username: string; nickname: string }) => {
|
||
const username = 'username' in profile ? profile.username : '';
|
||
|
||
return (
|
||
<button
|
||
key={username}
|
||
onClick={() => searchByUsername(username)}
|
||
className="flex flex-col items-center gap-2 group"
|
||
>
|
||
{'avatar' in profile && profile.avatar ? (
|
||
<img
|
||
src={profile.avatar}
|
||
alt={username}
|
||
className="w-14 h-14 rounded-full object-cover border-2 border-transparent group-hover:border-pink-500/50 transition-colors"
|
||
/>
|
||
) : (
|
||
<div className="w-14 h-14 rounded-full bg-white/10 flex items-center justify-center text-white/60 text-lg font-medium group-hover:bg-white/20 transition-colors">
|
||
{username.charAt(0).toUpperCase()}
|
||
</div>
|
||
)}
|
||
<span className="text-white/50 text-xs truncate w-full text-center group-hover:text-white/80">
|
||
@{username.slice(0, 8)}
|
||
</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* Load More Button */}
|
||
{suggestedLimit < SUGGESTED_ACCOUNTS.length && (
|
||
<button
|
||
onClick={() => setSuggestedLimit(prev => Math.min(prev + 12, SUGGESTED_ACCOUNTS.length))}
|
||
className="w-full mt-6 py-3 bg-white/5 hover:bg-white/10 rounded-xl text-white/60 text-sm transition-colors"
|
||
>
|
||
Show More ({SUGGESTED_ACCOUNTS.length - suggestedLimit} remaining)
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* SEARCH TAB - Minimal Style matching Following */}
|
||
<div className={`absolute inset-0 w-full h-full pt-16 px-4 pb-6 overflow-y-auto transition-all duration-300 ease-out ${activeTab === 'search'
|
||
? 'translate-x-0 opacity-100'
|
||
: activeTab === 'following' || activeTab === 'foryou'
|
||
? 'translate-x-full opacity-0 pointer-events-none'
|
||
: '-translate-x-full opacity-0 pointer-events-none'
|
||
}`}>
|
||
<div className="max-w-lg mx-auto">
|
||
{/* Minimal Search Input */}
|
||
<div className="relative mb-8">
|
||
<input
|
||
type="text"
|
||
value={searchInput}
|
||
onChange={(e) => setSearchInput(e.target.value)}
|
||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||
placeholder="Search..."
|
||
className="w-full bg-transparent border-b-2 border-white/20 focus:border-white/60 px-0 py-4 text-white text-lg focus:outline-none transition-colors placeholder:text-white/30"
|
||
disabled={isSearching}
|
||
/>
|
||
<button
|
||
onClick={handleSearch}
|
||
disabled={isSearching}
|
||
className="absolute right-0 top-1/2 -translate-y-1/2 p-2 text-white/50 hover:text-white transition-colors disabled:opacity-50"
|
||
>
|
||
{isSearching ? (
|
||
<svg className="w-6 h-6 animate-spin" viewBox="0 0 24 24" fill="none">
|
||
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" strokeDasharray="32" strokeLinecap="round" />
|
||
</svg>
|
||
) : (
|
||
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||
<circle cx="11" cy="11" r="8" />
|
||
<path d="M21 21l-4.35-4.35" />
|
||
</svg>
|
||
)}
|
||
</button>
|
||
{/* Subtle hint dropdown */}
|
||
<p className="text-white/20 text-xs mt-2">@username · video link · keyword</p>
|
||
</div>
|
||
|
||
{/* Loading Animation with Quote */}
|
||
{isSearching && (
|
||
<div className="flex flex-col items-center justify-center py-16">
|
||
<div className="w-10 h-10 border-2 border-white/10 border-t-cyan-500 rounded-full animate-spin mb-6"></div>
|
||
<p className="text-white/60 text-sm italic text-center max-w-xs">
|
||
"{INSPIRATION_QUOTES[Math.floor(Math.random() * INSPIRATION_QUOTES.length)].text}"
|
||
</p>
|
||
<p className="text-white/30 text-xs mt-2">
|
||
— {INSPIRATION_QUOTES[Math.floor(Math.random() * INSPIRATION_QUOTES.length)].author}
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* Empty State - Following-style layout */}
|
||
{!isSearching && searchResults.length === 0 && (
|
||
<>
|
||
{/* Trending - 2 columns */}
|
||
<div className="mb-10">
|
||
<p className="text-white/40 text-xs uppercase tracking-wider mb-3">Trending</p>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
{SUGGESTED_CATEGORIES.map(cat => (
|
||
<button
|
||
key={cat.id}
|
||
onClick={() => searchByKeyword(cat.query)}
|
||
className="bg-white/5 hover:bg-white/10 rounded-lg px-3 py-2.5 text-left text-white/70 hover:text-white text-sm transition-colors"
|
||
>
|
||
{cat.name}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Quick Search - Account avatars */}
|
||
<div>
|
||
<p className="text-white/40 text-xs uppercase tracking-wider mb-4">Popular</p>
|
||
<div className="grid grid-cols-4 gap-4">
|
||
{(suggestedProfiles.length > 0 ? suggestedProfiles : SUGGESTED_ACCOUNTS.map(a => ({ username: a.username.replace('@', '') }))).slice(0, 4).map((profile: UserProfile | { username: string }) => {
|
||
const username = 'username' in profile ? profile.username : '';
|
||
return (
|
||
<button
|
||
key={username}
|
||
onClick={() => searchByUsername(username)}
|
||
className="flex flex-col items-center gap-2 group"
|
||
>
|
||
{'avatar' in profile && profile.avatar ? (
|
||
<img
|
||
src={profile.avatar}
|
||
alt={username}
|
||
className="w-12 h-12 rounded-full object-cover border-2 border-transparent group-hover:border-pink-500/50 transition-colors"
|
||
/>
|
||
) : (
|
||
<div className="w-12 h-12 rounded-full bg-white/10 flex items-center justify-center text-white/60 group-hover:bg-white/20 transition-colors">
|
||
{username.charAt(0).toUpperCase()}
|
||
</div>
|
||
)}
|
||
<span className="text-white/40 text-xs truncate w-full text-center group-hover:text-white/60">
|
||
@{username.slice(0, 6)}
|
||
</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* Search Results */}
|
||
{!isSearching && searchResults.length > 0 && (
|
||
<div className="mt-8">
|
||
{/* Results Header with Creator & Follow */}
|
||
<div className="flex items-center justify-between mb-4">
|
||
<div className="flex items-center gap-3">
|
||
<span className="text-white/50 text-sm">{searchResults.length} videos</span>
|
||
{searchResults[0]?.author && searchResults[0].author !== 'search' && (
|
||
<button
|
||
onClick={() => handleFollow(searchResults[0].author)}
|
||
className={`px-3 py-1 rounded-full text-xs font-medium transition-colors ${following.includes(searchResults[0].author)
|
||
? 'bg-pink-500 text-white'
|
||
: 'bg-white/10 text-white/70 hover:bg-white/20'
|
||
}`}
|
||
>
|
||
{following.includes(searchResults[0].author) ? 'Following' : '+ Follow @' + searchResults[0].author}
|
||
</button>
|
||
)}
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
{/* Play All Button */}
|
||
<button
|
||
onClick={() => {
|
||
const playableVideos = searchResults.filter(v => v.url);
|
||
if (playableVideos.length > 0) {
|
||
setVideos(playableVideos);
|
||
setCurrentIndex(0);
|
||
setActiveTab('foryou');
|
||
}
|
||
}}
|
||
className="px-3 py-1 bg-gradient-to-r from-cyan-500 to-pink-500 rounded-full text-xs font-medium text-white"
|
||
>
|
||
▶ Play All
|
||
</button>
|
||
<button
|
||
onClick={() => setSearchResults([])}
|
||
className="text-white/30 text-xs hover:text-white/60"
|
||
>
|
||
Clear
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Video Grid */}
|
||
<div className="grid grid-cols-3 gap-1">
|
||
{searchResults.map((video) => (
|
||
<div
|
||
key={video.id}
|
||
className={`relative aspect-[9/16] overflow-hidden group ${video.url
|
||
? 'cursor-pointer'
|
||
: 'opacity-40'
|
||
}`}
|
||
onClick={() => {
|
||
if (!video.url) return;
|
||
// Load ALL search results into the feed, starting from clicked video
|
||
const playableVideos = searchResults.filter(v => v.url);
|
||
if (playableVideos.length > 0) {
|
||
setVideos(playableVideos);
|
||
// Set current index to the clicked video's position in playable videos
|
||
const newIndex = playableVideos.findIndex(v => v.id === video.id);
|
||
setCurrentIndex(newIndex >= 0 ? newIndex : 0);
|
||
setActiveTab('foryou');
|
||
}
|
||
}}
|
||
>
|
||
{/* Thumbnail with loading placeholder */}
|
||
{video.thumbnail ? (
|
||
<img
|
||
src={video.thumbnail}
|
||
alt={video.author}
|
||
className="w-full h-full object-cover transition-opacity group-hover:opacity-80"
|
||
onError={(e) => {
|
||
(e.target as HTMLImageElement).style.display = 'none';
|
||
}}
|
||
/>
|
||
) : (
|
||
<div className="w-full h-full bg-white/5 flex items-center justify-center">
|
||
{video.url ? (
|
||
<div className="w-6 h-6 border-2 border-white/20 border-t-cyan-500 rounded-full animate-spin"></div>
|
||
) : (
|
||
<span className="text-2xl">ℹ️</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Overlay with author */}
|
||
{video.url && (
|
||
<div className="absolute bottom-0 left-0 right-0 p-2 bg-gradient-to-t from-black/80 to-transparent">
|
||
<p className="text-white text-xs truncate">@{video.author}</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* Message for non-playable */}
|
||
{!video.url && video.description && (
|
||
<div className="absolute inset-0 flex items-center justify-center p-2">
|
||
<p className="text-white/60 text-xs text-center">{video.description}</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
);
|
||
};
|