ATlast — you'll never need to find your favorites on another platform again. Find your favs in the ATmosphere.
atproto

big ui changes, it has some bugs i'll fix after refactor yeesh

Changed files
+525 -570
netlify
functions
src
+1
netlify/functions/get-profile.ts
··· 82 82 handle: profile.data.handle, 83 83 displayName: profile.data.displayName, 84 84 avatar: profile.data.avatar, 85 + description: profile.data.description, 85 86 }), 86 87 }; 87 88
+524 -570
src/App.tsx
··· 1 1 import { useState, useEffect, useRef } from "react"; 2 - import { Upload, User, Check, Search, ArrowRight, Users, FileText, ChevronRight, LogOut, Home } from "lucide-react"; 2 + import { Upload, Check, Search, ArrowRight, ChevronRight, LogOut, Home, Heart, Clock, Trash2, UserPlus, ChevronDown, Twitter, Instagram, Video, Hash, Gamepad2, MessageCircle, Music, Menu, User } from "lucide-react"; 3 3 import JSZip from "jszip"; 4 - import { 5 - CompositeDidDocumentResolver, 6 - CompositeHandleResolver, 7 - PlcDidDocumentResolver, 8 - AtprotoWebDidDocumentResolver, 9 - DohJsonHandleResolver, 10 - WellKnownHandleResolver 11 - } from "@atcute/identity-resolver"; 12 4 13 5 interface atprotoSession { 14 6 did: string; 15 7 handle: string; 16 8 displayName?: string; 17 9 avatar?: string; 10 + description?: string; 18 11 } 19 12 20 13 interface TikTokUser { ··· 30 23 selectedMatches?: Set<string>; // Track selected match DIDs 31 24 } 32 25 33 - // Match Carousel Component 34 - function MatchCarousel({ 35 - matches, 36 - selectedDids, 37 - onToggleSelection, 38 - cardRef 39 - }: { 40 - matches: any[]; 41 - selectedDids: Set<string>; 42 - onToggleSelection: (did: string) => void; 43 - cardRef?: React.RefObject<HTMLDivElement | null>; 44 - }) { 45 - const [currentIndex, setCurrentIndex] = useState(0); 46 - const [touchStart, setTouchStart] = useState<number | null>(null); 47 - const [touchEnd, setTouchEnd] = useState<number | null>(null); 48 - 49 - const currentMatch = matches[currentIndex]; 50 - const hasMore = matches.length > 1; 51 - const hasPrev = currentIndex > 0; 52 - const hasNext = currentIndex < matches.length - 1; 53 - 54 - const minSwipeDistance = 50; 55 - 56 - const nextMatch = () => { 57 - if (hasNext) { 58 - setCurrentIndex(currentIndex + 1); 26 + const PLATFORMS = { 27 + twitter: { 28 + name: 'Twitter/X', 29 + icon: Twitter, 30 + color: 'from-blue-400 to-blue-600', 31 + accentBg: 'bg-blue-500', 32 + fileHint: 'following.js or account data ZIP', 33 + }, 34 + instagram: { 35 + name: 'Instagram', 36 + icon: Instagram, 37 + color: 'from-pink-500 via-purple-500 to-orange-500', 38 + accentBg: 'bg-pink-500', 39 + fileHint: 'connections.json or data ZIP', 40 + }, 41 + tiktok: { 42 + name: 'TikTok', 43 + icon: Video, 44 + color: 'from-black via-gray-800 to-cyan-400', 45 + accentBg: 'bg-black', 46 + fileHint: 'Following.txt or data ZIP', 47 + }, 48 + tumblr: { 49 + name: 'Tumblr', 50 + icon: Hash, 51 + color: 'from-indigo-600 to-blue-800', 52 + accentBg: 'bg-indigo-600', 53 + fileHint: 'following.csv or data export', 54 + }, 55 + twitch: { 56 + name: 'Twitch', 57 + icon: Gamepad2, 58 + color: 'from-purple-600 to-purple-800', 59 + accentBg: 'bg-purple-600', 60 + fileHint: 'following.json or data export', 61 + }, 62 + youtube: { 63 + name: 'YouTube', 64 + icon: Video, 65 + color: 'from-red-600 to-red-700', 66 + accentBg: 'bg-red-600', 67 + fileHint: 'subscriptions.csv or Takeout ZIP', 68 + }, 69 + }; 70 + 71 + function AppHeader({ session, onLogout, onNavigate, currentStep }: { session: atprotoSession | null; onLogout: () => void; onNavigate: (step: 'home' | 'login') => void; currentStep: string }) { 72 + const [showMenu, setShowMenu] = useState(false); 73 + const menuRef = useRef<HTMLDivElement>(null); 74 + 75 + useEffect(() => { 76 + function handleClickOutside(event: MouseEvent) { 77 + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { 78 + setShowMenu(false); 79 + } 59 80 } 60 - }; 61 - 62 - const prevMatch = () => { 63 - if (hasPrev) { 64 - setCurrentIndex(currentIndex - 1); 65 - } 66 - }; 67 - 68 - const handleKeyDown = (e: React.KeyboardEvent) => { 69 - if (e.key === 'ArrowLeft') { 70 - e.preventDefault(); 71 - prevMatch(); 72 - } else if (e.key === 'ArrowRight') { 73 - e.preventDefault(); 74 - nextMatch(); 75 - } else if (e.key === ' ' || e.key === 'Enter') { 76 - e.preventDefault(); 77 - onToggleSelection(currentMatch.did); 78 - } 79 - }; 80 - 81 - const onTouchStart = (e: React.TouchEvent) => { 82 - setTouchEnd(null); 83 - setTouchStart(e.targetTouches[0].clientX); 84 - }; 85 - 86 - const onTouchMove = (e: React.TouchEvent) => { 87 - setTouchEnd(e.targetTouches[0].clientX); 88 - }; 89 - 90 - const onTouchEnd = () => { 91 - if (!touchStart || !touchEnd) return; 92 - 93 - const distance = touchStart - touchEnd; 94 - const isLeftSwipe = distance > minSwipeDistance; 95 - const isRightSwipe = distance < -minSwipeDistance; 96 - 97 - if (isLeftSwipe && hasNext) { 98 - nextMatch(); 99 - } else if (isRightSwipe && hasPrev) { 100 - prevMatch(); 101 - } 102 - }; 81 + document.addEventListener('mousedown', handleClickOutside); 82 + return () => document.removeEventListener('mousedown', handleClickOutside); 83 + }, []); 103 84 104 - const matchLabel = `${currentMatch.displayName || currentMatch.handle}, ${currentMatch.matchScore} percent match${currentMatch.followed ? ', already followed' : ''}${hasMore ? `, match ${currentIndex + 1} of ${matches.length}` : ''}`; 105 - 106 85 return ( 107 - <div 108 - className="relative" 109 - onTouchStart={onTouchStart} 110 - onTouchMove={onTouchMove} 111 - onTouchEnd={onTouchEnd} 112 - > 113 - <div 114 - ref={(el) => { 115 - if (cardRef) { 116 - cardRef.current = el; 117 - } 118 - }} 119 - className={`flex items-center space-x-3 p-3 rounded-lg border transition-all focus-within:ring-2 focus-within:ring-blue-500 focus-within:ring-offset-2 ${ 120 - selectedDids.has(currentMatch.did) 121 - ? 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-700' 122 - : 'bg-gray-50 dark:bg-gray-700 border-gray-200 dark:border-gray-600' 123 - } ${currentMatch.followed ? 'opacity-60' : ''}`} 124 - onKeyDown={handleKeyDown} 125 - onFocus={(e) => { 126 - if (e.target === e.currentTarget) { 127 - e.currentTarget.scrollIntoView({ 128 - behavior: 'smooth', 129 - block: 'center', 130 - inline: 'nearest' 131 - }); 132 - } 133 - }} 134 - tabIndex={0} 135 - role="button" 136 - aria-label={matchLabel} 137 - aria-pressed={selectedDids.has(currentMatch.did)} 138 - aria-disabled={currentMatch.followed} 139 - > 140 - <div 141 - className="flex items-center justify-center min-w-[44px] min-h-[44px] cursor-pointer flex-shrink-0" 142 - onClick={() => !currentMatch.followed && onToggleSelection(currentMatch.did)} 143 - aria-hidden="true" 144 - > 145 - <div className={`w-5 h-5 border-2 rounded flex items-center justify-center transition-colors ${ 146 - selectedDids.has(currentMatch.did) 147 - ? 'bg-blue-600 border-blue-600' 148 - : 'bg-white dark:bg-gray-600 border-gray-300 dark:border-gray-500' 149 - } ${currentMatch.followed ? 'opacity-50 cursor-not-allowed' : ''}`}> 150 - {selectedDids.has(currentMatch.did) && ( 151 - <Check className="w-3 h-3 text-white" /> 152 - )} 153 - </div> 154 - </div> 155 - 156 - {currentMatch.avatar ? ( 157 - <img 158 - src={currentMatch.avatar} 159 - alt="" 160 - className="w-12 h-12 rounded-full object-cover flex-shrink-0" 161 - /> 162 - ) : ( 163 - <div className="w-12 h-12 bg-gradient-to-br from-blue-500 to-purple-600 rounded-full flex items-center justify-center flex-shrink-0" aria-hidden="true"> 164 - <span className="text-white font-bold text-sm"> 165 - {currentMatch.handle.charAt(0).toUpperCase()} 166 - </span> 167 - </div> 168 - )} 169 - 170 - <div className="flex-1 min-w-0" aria-hidden="true"> 171 - {currentMatch.displayName && ( 172 - <div className="font-medium text-gray-900 dark:text-gray-100 truncate"> 173 - {currentMatch.displayName} 86 + <div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700"> 87 + <div className="max-w-6xl mx-auto px-4 py-3"> 88 + <div className="flex items-center justify-between"> 89 + <button onClick={() => onNavigate(session ? 'home' : 'login')} className="flex items-center space-x-3 hover:opacity-80 transition-opacity focus:outline-none focus:ring-2 focus:ring-blue-500 rounded-lg px-2 py-1"> 90 + <div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-purple-600 rounded-xl flex items-center justify-center"> 91 + <Heart className="w-5 h-5 text-white" /> 92 + </div> 93 + <h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">ATlast</h1> 94 + </button> 95 + 96 + {session && ( 97 + <div className="relative" ref={menuRef}> 98 + <button onClick={() => setShowMenu(!showMenu)} className="flex items-center space-x-3 px-3 py-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500"> 99 + {session.avatar ? ( 100 + <img src={session.avatar} alt="" className="w-8 h-8 rounded-full object-cover" /> 101 + ) : ( 102 + <div className="w-8 h-8 bg-gradient-to-br from-blue-500 to-purple-600 rounded-full flex items-center justify-center"> 103 + <span className="text-white font-bold text-sm">{session.handle.charAt(0).toUpperCase()}</span> 104 + </div> 105 + )} 106 + <span className="text-sm font-medium text-gray-900 dark:text-gray-100 hidden sm:inline">@{session.handle}</span> 107 + <ChevronDown className={`w-4 h-4 text-gray-500 transition-transform ${showMenu ? 'rotate-180' : ''}`} /> 108 + </button> 109 + 110 + {showMenu && ( 111 + <div className="absolute right-0 mt-2 w-64 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 py-2 z-50"> 112 + <div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700"> 113 + <div className="font-medium text-gray-900 dark:text-gray-100">{session.displayName || session.handle}</div> 114 + <div className="text-sm text-gray-500 dark:text-gray-400">@{session.handle}</div> 115 + </div> 116 + <button onClick={() => { setShowMenu(false); onNavigate('home'); }} className="w-full flex items-center space-x-3 px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors text-left"> 117 + <Home className="w-4 h-4 text-gray-500" /> 118 + <span className="text-gray-900 dark:text-gray-100">Dashboard</span> 119 + </button> 120 + <button onClick={() => { setShowMenu(false); onNavigate('login'); }} className="w-full flex items-center space-x-3 px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors text-left"> 121 + <Heart className="w-4 h-4 text-gray-500" /> 122 + <span className="text-gray-900 dark:text-gray-100">About</span> 123 + </button> 124 + <div className="border-t border-gray-200 dark:border-gray-700 my-2"></div> 125 + <button onClick={() => { setShowMenu(false); onLogout(); }} className="w-full flex items-center space-x-3 px-4 py-2 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors text-left text-red-600 dark:text-red-400"> 126 + <LogOut className="w-4 h-4" /> 127 + <span>Log out</span> 128 + </button> 129 + </div> 130 + )} 174 131 </div> 175 132 )} 176 - <div className="flex items-center space-x-2"> 177 - <div className="text-sm text-gray-600 dark:text-gray-300 truncate"> 178 - @{currentMatch.handle} 179 - </div> 180 - <span className="text-xs bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-400 px-2 py-0.5 rounded flex-shrink-0"> 181 - {currentMatch.matchScore}% 182 - </span> 183 - </div> 184 133 </div> 185 - 186 - {currentMatch.followed && ( 187 - <div className="flex-shrink-0" aria-hidden="true"> 188 - <div className="flex items-center space-x-1 bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-400 px-2 py-1 rounded-full text-xs"> 189 - <Check className="w-3 h-3" /> 190 - <span>Followed</span> 191 - </div> 192 - </div> 193 - )} 194 - 195 - {hasMore && ( 196 - <div className="flex items-center space-x-1 flex-shrink-0" aria-hidden="true"> 197 - {hasPrev && ( 198 - <div className="p-2 text-gray-400 dark:text-gray-500"> 199 - <ChevronRight className="w-5 h-5 rotate-180" /> 200 - </div> 201 - )} 202 - {hasNext && ( 203 - <div className="p-2 text-gray-400 dark:text-gray-500"> 204 - <ChevronRight className="w-5 h-5" /> 205 - </div> 206 - )} 207 - </div> 208 - )} 209 134 </div> 210 - 211 - {hasMore && ( 212 - <div className="flex items-center justify-center space-x-2 mt-2" aria-hidden="true"> 213 - {matches.map((_, idx) => ( 214 - <div 215 - key={idx} 216 - className={`h-1.5 rounded-full transition-all ${ 217 - idx === currentIndex 218 - ? 'w-6 bg-blue-500' 219 - : 'w-1.5 bg-gray-300' 220 - }`} 221 - /> 222 - ))} 223 - </div> 224 - )} 225 135 </div> 226 136 ); 227 137 } 228 138 229 139 export default function App() { 230 140 const [handle, setHandle] = useState(""); 231 - const [appPassword, setAppPassword] = useState(""); 232 141 const [session, setSession] = useState<atprotoSession | null>(null); 233 - const [useAppPassword, setUseAppPassword] = useState(false); 234 142 const [searchResults, setSearchResults] = useState<SearchResult[]>([]); 235 143 const [isSearchingAll, setIsSearchingAll] = useState(false); 236 144 const [currentStep, setCurrentStep] = useState<'checking' | 'login' | 'home' | 'upload' | 'loading' | 'results'>('checking'); 237 145 const [searchProgress, setSearchProgress] = useState({ searched: 0, found: 0, total: 0 }); 238 146 const [isFollowing, setIsFollowing] = useState(false); 239 147 const [statusMessage, setStatusMessage] = useState(""); 240 - const resultCardRefs = useRef<(HTMLDivElement | null)[]>([]); 241 - 242 - const didDocumentResolver = new CompositeDidDocumentResolver({ 243 - methods: { 244 - plc: new PlcDidDocumentResolver({ apiUrl: "https://plc.directory" }), 245 - web: new AtprotoWebDidDocumentResolver(), 246 - }, 247 - }); 248 - 249 - const handleResolver = new CompositeHandleResolver({ 250 - strategy: "dns-first", 251 - methods: { 252 - dns: new DohJsonHandleResolver({ dohUrl: "https://dns.google/resolve?" }), 253 - http: new WellKnownHandleResolver(), 254 - }, 255 - }); 148 + const [expandedResults, setExpandedResults] = useState<Set<number>>(new Set()); 256 149 257 150 // Check for existing session on mount 258 151 useEffect(() => { ··· 289 182 290 183 if (res.ok) { 291 184 const data = await res.json(); 292 - setSession({ 293 - did: data.did, 294 - handle: data.handle, 295 - displayName: data.displayName, 296 - avatar: data.avatar, 297 - }); 185 + await fetchProfile(); 298 186 setCurrentStep('home'); 299 187 setStatusMessage(`Welcome back, ${data.handle}!`); 300 188 } else { ··· 322 210 handle: data.handle, 323 211 displayName: data.displayName, 324 212 avatar: data.avatar, 213 + description: data.description, 325 214 }); 326 215 setStatusMessage(`Successfully logged in as ${data.handle}`); 327 216 } catch (err) { ··· 356 245 } 357 246 358 247 // Start OAuth login 359 - const loginWithOAuth = async () => { 248 + const loginWithOAuth = async (e: React.FormEvent) => { 249 + e.preventDefault(); 360 250 try { 361 251 if (!handle) { 362 252 const errorMsg = "Please enter your handle"; ··· 393 283 } 394 284 }; 395 285 396 - // App Password Login (Fallback method) 397 - async function loginWithAppPassword() { 398 - try { 399 - if (!handle || !appPassword) { 400 - alert("Enter handle and app password"); 401 - return; 402 - } 403 - 404 - // Step 1: Resolve handle → DID 405 - const did = await handleResolver.resolve(handle as `${string}.${string}`); 406 - if (!did) { 407 - alert("Failed to resolve handle to DID"); 408 - return; 409 - } 410 - 411 - // Step 2: Resolve DID → DID Document 412 - const didDoc = await didDocumentResolver.resolve(did); 413 - if (!didDoc?.service?.[0]?.serviceEndpoint) { 414 - alert("Could not determine PDS endpoint from DID Document"); 415 - return; 416 - } 417 - 418 - // Step 3: Extract PDS endpoint 419 - const pdsEndpoint = didDoc.service[0].serviceEndpoint; 420 - 421 - // Step 4: Authenticate via App Password 422 - const sessionRes = await fetch(`${pdsEndpoint}/xrpc/com.atproto.server.createSession`, { 423 - method: "POST", 424 - headers: { "Content-Type": "application/json" }, 425 - body: JSON.stringify({ identifier: handle, password: appPassword }), 426 - }); 427 - 428 - if (!sessionRes.ok) { 429 - const errText = await sessionRes.text(); 430 - console.error("Login failed:", errText); 431 - alert("Login failed, check handle and app password"); 432 - return; 433 - } 434 - 435 - const sessionData = await sessionRes.json(); 436 - 437 - // Step 5: Store session + PDS endpoint for future API calls 438 - setSession({ 439 - ...sessionData, 440 - serviceEndpoint: pdsEndpoint, 441 - }); 442 - 443 - setCurrentStep('home'); 444 - 445 - console.log("Logged in successfully!", sessionData, pdsEndpoint); 446 - } catch (err) { 447 - console.error("Login error:", err); 448 - alert("Error during login. See console for details."); 449 - } 450 - } 451 - 452 286 async function parseJsonFile(jsonText: string): Promise<TikTokUser[]> { 453 287 const users: TikTokUser[] = []; 454 288 const jsonData = JSON.parse(jsonText); ··· 531 365 532 366 // Search all users 533 367 async function searchAllUsers(resultsToSearch?: SearchResult[]) { 534 - console.log('sau Session value:', session); 535 368 const targetResults = resultsToSearch || searchResults; 536 369 if (!session || targetResults.length === 0) return; 537 370 ··· 579 412 const data = await res.json(); 580 413 581 414 // Process batch results 582 - data.results.forEach((result: any, batchIndex: number) => { 583 - const globalIndex = i + batchIndex; 415 + data.results.forEach((result: any) => { 584 416 totalSearched++; 585 417 if (result.actors.length > 0) { 586 418 totalFound++; ··· 658 490 // Direct JSON upload 659 491 if (file.name.endsWith(".json")) { 660 492 users = await parseJsonFile(await file.text()); 661 - console.log(`Loaded ${users.length} TikTok users from JSON file`); 493 + console.log(`Loaded ${users.length} users from JSON file`); 662 494 setStatusMessage(`Loaded ${users.length} users from JSON file`); 663 495 } else if (file.name.endsWith(".txt")) { 664 496 // Direct TXT upload 665 497 users = parseTxtFile(await file.text()); 666 - console.log(`Loaded ${users.length} TikTok users from TXT file`); 498 + console.log(`Loaded ${users.length} users from TXT file`); 667 499 setStatusMessage(`Loaded ${users.length} users from TXT file`); 668 500 } else if (file.name.endsWith(".zip")) { 669 501 // ZIP upload - find Following.txt OR JSON ··· 682 514 if(followingFile) { 683 515 const followingText = await followingFile.async("string"); 684 516 users = parseTxtFile(followingText); 685 - console.log(`Loaded ${users.length} TikTok users from .ZIP file`); 517 + console.log(`Loaded ${users.length} users from .ZIP file`); 686 518 setStatusMessage(`Loaded ${users.length} users from ZIP file`); 687 519 } else { 688 520 // If no TXT, look for JSON at the top level ··· 699 531 700 532 const jsonText = await jsonFileEntry.async("string"); 701 533 users = await parseJsonFile(jsonText); 702 - console.log(`Loaded ${users.length} TikTok users from .ZIP file`); 534 + console.log(`Loaded ${users.length} users from .ZIP file`); 703 535 setStatusMessage(`Loaded ${users.length} users from ZIP file`); 704 536 } 705 537 } else { ··· 752 584 } 753 585 return result; 754 586 })); 587 + } 588 + 589 + function toggleExpandResult(index: number) { 590 + setExpandedResults(prev => { 591 + const next = new Set(prev); 592 + if (next.has(index)) next.delete(index); 593 + else next.add(index); 594 + return next; 595 + }); 755 596 } 756 597 757 598 // Select all matches across all results - only first match per TT user ··· 873 714 total + (result.selectedMatches?.size || 0), 0 874 715 ); 875 716 const totalFound = searchResults.filter(r => r.atprotoMatches.length > 0).length; 876 - const totalSearched = searchResults.filter(r => !r.isSearching).length; 877 717 878 718 return ( 879 719 <div className="min-h-screen bg-gradient-to-br from-blue-50 to-purple-50 dark:from-gray-900 dark:to-gray-800"> ··· 893 733 Skip to main content 894 734 </a> 895 735 896 - {/* Header */} 897 - <header className="bg-white dark:bg-gray-800 shadow-sm border-b dark:border-gray-700"> 898 - <div className="px-4 py-4 max-w-2xl mx-auto"> 899 - <div className="flex items-center justify-between"> 900 - <div className="flex items-center space-x-2"> 901 - <div className="w-8 h-8 bg-gradient-to-br from-blue-500 to-purple-600 rounded-lg flex items-center justify-center" aria-hidden="true"> 902 - <ArrowRight className="w-4 h-4 text-white" /> 903 - </div> 904 - <h1 className="text-lg font-bold text-gray-900 dark:text-gray-100">ATlast</h1> 905 - </div> 906 - {session && ( 907 - <div className="flex items-center space-x-3"> 908 - <div className="flex items-center space-x-2 text-sm text-gray-600 dark:text-gray-300"> 909 - <User className="w-4 h-4" aria-hidden="true" /> 910 - <span>@{session.handle}</span> 911 - </div> 912 - <button 913 - onClick={handleLogout} 914 - className="flex items-center space-x-1 px-3 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2" 915 - aria-label="Log out" 916 - > 917 - <LogOut className="w-4 h-4" /> 918 - <span className="hidden sm:inline">Logout</span> 919 - </button> 920 - </div> 921 - )} 922 - </div> 923 - </div> 924 - </header> 925 - 926 736 <main id="main-content"> 927 737 {/* Checking Session */} 928 738 {currentStep === 'checking' && ( ··· 937 747 </div> 938 748 )} 939 749 940 - {/* Login Step */} 750 + {/* Home / Login Step */} 941 751 {currentStep === 'login' && ( 942 - <div className="p-6 max-w-md mx-auto mt-8"> 943 - <div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg p-6 space-y-6"> 944 - <div className="text-center"> 945 - <div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-purple-600 rounded-2xl mx-auto mb-4 flex items-center justify-center"> 946 - <Users className="w-8 h-8 text-white" /> 752 + <div className="min-h-screen bg-gradient-to-br from-blue-50 via-purple-50 to-pink-50 dark:from-gray-900 dark:via-gray-850 dark:to-gray-800"> 753 + <div className="max-w-6xl mx-auto px-4 py-12"> 754 + {/* Welcome Section */} 755 + <div className="text-center mb-16"> 756 + <div className="inline-flex items-center justify-center w-24 h-24 bg-gradient-to-br from-blue-500 to-purple-600 rounded-3xl mb-6 shadow-xl"> 757 + <Heart className="w-12 h-12 text-white" /> 947 758 </div> 948 - <h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-2">Welcome!</h2> 949 - <p className="text-gray-600 dark:text-gray-300">Connect your ATmosphere account to sync your TikTok follows</p> 759 + <h1 className="text-5xl md:text-6xl font-bold text-gray-900 dark:text-gray-100 mb-4"> 760 + Welcome to ATlast 761 + </h1> 762 + <p className="text-xl md:text-2xl text-gray-700 dark:text-gray-300 mb-8 max-w-2xl mx-auto"> 763 + Reunite with your community on the ATmosphere 764 + </p> 950 765 </div> 766 + {/* Value Props */} 767 + <div className="grid md:grid-cols-3 gap-6 mb-16 max-w-5xl mx-auto"> 768 + <div className="bg-white dark:bg-gray-800 rounded-2xl p-6 shadow-lg border border-gray-100 dark:border-gray-700"> 769 + <div className="w-12 h-12 bg-blue-100 dark:bg-blue-900/30 rounded-xl flex items-center justify-center mb-4"> 770 + <Upload className="w-6 h-6 text-blue-600 dark:text-blue-400" /> 771 + </div> 772 + <h3 className="text-lg font-bold text-gray-900 dark:text-gray-100 mb-2"> 773 + Upload Your Data 774 + </h3> 775 + <p className="text-gray-600 dark:text-gray-400"> 776 + Import your following lists from Twitter, TikTok, Instagram, and more. Your data stays private. 777 + </p> 778 + </div> 951 779 952 - <form 953 - onSubmit={(e) => { 954 - e.preventDefault(); 955 - if (!useAppPassword) loginWithOAuth(); 956 - else loginWithAppPassword(); 957 - }} 958 - className="space-y-4" 959 - > 960 - <div> 961 - <label htmlFor="user-handle" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> 962 - User Handle 963 - </label> 964 - <input 965 - id="user-handle" 966 - type="text" 967 - className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent min-h-[44px] bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100" 968 - placeholder="yourhandle.atproto.social" 969 - value={handle} 970 - onChange={(e) => setHandle(e.target.value)} 971 - aria-required="true" 972 - autoComplete="username" 973 - /> 780 + <div className="bg-white dark:bg-gray-800 rounded-2xl p-6 shadow-lg border border-gray-100 dark:border-gray-700"> 781 + <div className="w-12 h-12 bg-purple-100 dark:bg-purple-900/30 rounded-xl flex items-center justify-center mb-4"> 782 + <Search className="w-6 h-6 text-purple-600 dark:text-purple-400" /> 783 + </div> 784 + <h3 className="text-lg font-bold text-gray-900 dark:text-gray-100 mb-2"> 785 + Find Matches 786 + </h3> 787 + <p className="text-gray-600 dark:text-gray-400"> 788 + We'll search the ATmosphere to find which of your follows have already migrated. 789 + </p> 974 790 </div> 975 791 976 - {!useAppPassword ? ( 977 - <> 978 - <button 979 - type="submit" 980 - className="w-full bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 text-white py-3 rounded-xl font-medium transition-all duration-200 shadow-lg hover:shadow-xl focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 min-h-[44px]" 981 - > 982 - Connect to the ATmosphere 983 - </button> 792 + <div className="bg-white dark:bg-gray-800 rounded-2xl p-6 shadow-lg border border-gray-100 dark:border-gray-700"> 793 + <div className="w-12 h-12 bg-pink-100 dark:bg-pink-900/30 rounded-xl flex items-center justify-center mb-4"> 794 + <Heart className="w-6 h-6 text-pink-600 dark:text-pink-400" /> 795 + </div> 796 + <h3 className="text-lg font-bold text-gray-900 dark:text-gray-100 mb-2"> 797 + Reconnect Instantly 798 + </h3> 799 + <p className="text-gray-600 dark:text-gray-400"> 800 + Follow everyone at once or pick and choose. Build your community on the ATmosphere. 801 + </p> 802 + </div> 803 + </div> 984 804 985 - <button 986 - type="button" 987 - onClick={() => setUseAppPassword(true)} 988 - className="w-full text-sm text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 underline py-2 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 rounded min-h-[44px]" 989 - > 990 - Use App Password instead 991 - </button> 992 - </> 993 - ) : ( 994 - <> 805 + {/* Login Card */} 806 + <div className="max-w-md mx-auto"> 807 + <div className="bg-white dark:bg-gray-800 rounded-3xl shadow-2xl p-8 border border-gray-100 dark:border-gray-700"> 808 + <h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-2 text-center"> 809 + Get Started 810 + </h2> 811 + <p className="text-gray-600 dark:text-gray-400 text-center mb-6"> 812 + Connect your ATmosphere account to begin finding your people 813 + </p> 814 + 815 + <form onSubmit={loginWithOAuth} className="space-y-4"> 995 816 <div> 996 - <label htmlFor="app-password" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> 997 - App Password 817 + <label htmlFor="atproto-handle" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> 818 + Your ATmosphere Handle 998 819 </label> 999 820 <input 1000 - id="app-password" 1001 - className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent min-h-[44px] bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100" 1002 - type="password" 1003 - placeholder="Not your regular password!" 1004 - value={appPassword} 1005 - onChange={(e) => setAppPassword(e.target.value)} 821 + id="atproto-handle" 822 + type="text" 823 + value={handle} 824 + onChange={(e) => setHandle(e.target.value)} 825 + placeholder="yourname.bsky.social" 826 + className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-xl bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all" 1006 827 aria-required="true" 1007 - autoComplete="off" 1008 - aria-describedby="password-help" 828 + aria-describedby="handle-description" 1009 829 /> 1010 - <p id="password-help" className="text-xs text-gray-500 dark:text-gray-300 mt-1"> 1011 - Generate this in your Bluesky settings 830 + <p id="handle-description" className="text-xs text-gray-500 dark:text-gray-400 mt-2"> 831 + Enter your full ATmosphere handle (e.g., username.bsky.social) 1012 832 </p> 1013 833 </div> 1014 834 1015 835 <button 1016 - type="button" 1017 - onClick={() => setUseAppPassword(true)} 1018 - className="w-full text-sm text-gray-600 hover:text-gray-900 underline py-2 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 rounded min-h-[44px]" 836 + type="submit" 837 + className="w-full bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 text-white py-4 rounded-xl font-bold text-lg transition-all shadow-lg hover:shadow-xl focus:ring-4 focus:ring-purple-300 dark:focus:ring-purple-800 focus:outline-none" 838 + aria-label="Connect to the ATmosphere" 1019 839 > 1020 - Use App Password instead 840 + Connect to the ATmosphere 1021 841 </button> 842 + </form> 1022 843 1023 - <button 1024 - type="button" 1025 - onClick={() => setUseAppPassword(false)} 1026 - className="w-full text-sm text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 underline py-2 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 rounded min-h-[44px]" 1027 - > 1028 - Use OAuth instead (recommended) 1029 - </button> 1030 - </> 1031 - )} 1032 - </form> 1033 - </div> 1034 - </div> 1035 - )} 844 + <div className="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700"> 845 + <div className="flex items-start space-x-2 text-sm text-gray-600 dark:text-gray-400"> 846 + <svg className="w-5 h-5 text-green-500 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true"> 847 + <path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" /> 848 + </svg> 849 + <div> 850 + <p className="font-medium text-gray-700 dark:text-gray-300">Secure OAuth Connection</p> 851 + <p className="text-xs mt-1">We use official AT Protocol OAuth. We never see your password and you can revoke access anytime.</p> 852 + </div> 853 + </div> 854 + </div> 855 + </div> 1036 856 1037 - {/* Home/Dashboard Step */} 1038 - {currentStep === 'home' && ( 1039 - <div className="p-6 max-w-md mx-auto mt-8"> 1040 - <div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg p-6 space-y-6"> 1041 - <div className="text-center"> 1042 - <div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-purple-600 rounded-2xl mx-auto mb-4 flex items-center justify-center"> 1043 - <Home className="w-8 h-8 text-white" /> 857 + {/* Privacy Notice */} 858 + <div className="mt-8 text-center"> 859 + <p className="text-sm text-gray-600 dark:text-gray-400 max-w-md mx-auto"> 860 + Your data is processed locally and never stored on our servers. We only help you find matches and reconnect with your community. 861 + </p> 1044 862 </div> 1045 - <h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-2"> 1046 - Welcome back! 1047 - </h2> 1048 - <p className="text-gray-600 dark:text-gray-300"> 1049 - What would you like to do? 1050 - </p> 1051 863 </div> 1052 864 1053 - <div className="space-y-3"> 1054 - <button 1055 - onClick={() => setCurrentStep('upload')} 1056 - className="w-full bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 text-white py-4 px-6 rounded-xl font-medium transition-all duration-200 shadow-lg hover:shadow-xl focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 min-h-[56px] flex items-center justify-center space-x-3" 1057 - > 1058 - <Upload className="w-5 h-5" /> 1059 - <span>Upload TikTok Data</span> 1060 - </button> 1061 - 1062 - <button 1063 - onClick={() => alert('View previous results feature coming soon!')} 1064 - className="w-full bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-200 py-4 px-6 rounded-xl font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 min-h-[56px] flex items-center justify-center space-x-3" 1065 - disabled 1066 - > 1067 - <FileText className="w-5 h-5" /> 1068 - <span>View Previous Results</span> 1069 - <span className="text-xs bg-gray-300 dark:bg-gray-600 px-2 py-1 rounded">Coming Soon</span> 1070 - </button> 865 + {/* How It Works */} 866 + <div className="mt-16 max-w-4xl mx-auto"> 867 + <h2 className="text-2xl font-bold text-center text-gray-900 dark:text-gray-100 mb-8"> 868 + How It Works 869 + </h2> 870 + <div className="grid md:grid-cols-4 gap-4"> 871 + <div className="text-center"> 872 + <div className="w-12 h-12 bg-blue-500 text-white rounded-full flex items-center justify-center mx-auto mb-3 font-bold text-lg" aria-hidden="true"> 873 + 1 874 + </div> 875 + <h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-1">Connect</h3> 876 + <p className="text-sm text-gray-600 dark:text-gray-400">Sign in with your ATmosphere account</p> 877 + </div> 878 + <div className="text-center"> 879 + <div className="w-12 h-12 bg-purple-500 text-white rounded-full flex items-center justify-center mx-auto mb-3 font-bold text-lg" aria-hidden="true"> 880 + 2 881 + </div> 882 + <h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-1">Upload</h3> 883 + <p className="text-sm text-gray-600 dark:text-gray-400">Import your following data from other platforms</p> 884 + </div> 885 + <div className="text-center"> 886 + <div className="w-12 h-12 bg-pink-500 text-white rounded-full flex items-center justify-center mx-auto mb-3 font-bold text-lg" aria-hidden="true"> 887 + 3 888 + </div> 889 + <h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-1">Match</h3> 890 + <p className="text-sm text-gray-600 dark:text-gray-400">We find your people on the ATmosphere</p> 891 + </div> 892 + <div className="text-center"> 893 + <div className="w-12 h-12 bg-orange-500 text-white rounded-full flex items-center justify-center mx-auto mb-3 font-bold text-lg" aria-hidden="true"> 894 + 4 895 + </div> 896 + <h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-1">Follow</h3> 897 + <p className="text-sm text-gray-600 dark:text-gray-400">Reconnect with your community</p> 898 + </div> 899 + </div> 1071 900 </div> 1072 901 </div> 1073 902 </div> 1074 903 )} 1075 904 1076 - {/* Upload Step */} 1077 - {currentStep === 'upload' && ( 1078 - <div className="p-6 max-w-md mx-auto mt-8"> 1079 - <div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg p-6 space-y-6"> 1080 - <div className="flex items-center justify-between"> 1081 - <button 1082 - onClick={() => setCurrentStep('home')} 1083 - className="flex items-center space-x-1 text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 rounded px-2 py-1" 1084 - > 1085 - <ChevronRight className="w-4 h-4 rotate-180" /> 1086 - <span>Back</span> 1087 - </button> 1088 - </div> 905 + {/* Home/Dashboard Step */} 906 + {currentStep === 'home' && ( 907 + <div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800"> 908 + {/* Header */} 909 + <AppHeader session={session} onLogout={handleLogout} onNavigate={setCurrentStep} currentStep={currentStep} /> 1089 910 1090 - <div className="text-center"> 1091 - <div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-purple-600 rounded-2xl mx-auto mb-4 flex items-center justify-center"> 1092 - <FileText className="w-8 h-8 text-white" /> 911 + <div className="max-w-4xl mx-auto px-4 py-8"> 912 + {/* Upload New Data Section */} 913 + <div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg p-6 mb-6"> 914 + <div className="flex items-center space-x-3 mb-4"> 915 + <Upload className="w-6 h-6 text-blue-600 dark:text-blue-400" /> 916 + <h2 className="text-xl font-bold text-gray-900 dark:text-gray-100"> 917 + Upload Following Data 918 + </h2> 919 + </div> 920 + <p className="text-gray-600 dark:text-gray-400 mb-6"> 921 + Upload your exported data from any platform to find matches on the ATmosphere 922 + </p> 923 + 924 + {/* Platform Grid */} 925 + <div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6"> 926 + {Object.entries(PLATFORMS).map(([key, p]) => { 927 + const PlatformIcon = p.icon; 928 + const isEnabled = key === 'tiktok'; 929 + return ( 930 + <div 931 + key={key} 932 + className={`relative p-4 rounded-xl border-2 transition-all ${ 933 + isEnabled 934 + ? 'border-gray-200 dark:border-gray-700 hover:border-blue-300 dark:hover:border-blue-600 hover:shadow-lg cursor-pointer' 935 + : 'border-gray-200 dark:border-gray-800 opacity-50 cursor-not-allowed' 936 + }`} 937 + title={isEnabled ? `Upload ${p.name} data` : 'Coming soon'} 938 + > 939 + <PlatformIcon className={`w-8 h-8 mx-auto mb-2 ${isEnabled ? 'text-gray-700 dark:text-gray-300' : 'text-gray-400 dark:text-gray-700'}`} /> 940 + <div className="text-sm font-medium text-center text-gray-900 dark:text-gray-100"> 941 + {p.name} 942 + </div> 943 + {!isEnabled && ( 944 + <div className="absolute top-2 right-2"> 945 + <span className="text-xs bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400 px-2 py-0.5 rounded-full"> 946 + Soon 947 + </span> 948 + </div> 949 + )} 950 + </div> 951 + ); 952 + })} 1093 953 </div> 1094 - <h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-2">Upload Your Data</h2> 1095 - <p className="text-gray-600 dark:text-gray-300">Upload your TikTok following data to find matches</p> 1096 - </div> 1097 - 1098 - <div className="space-y-4"> 954 + 955 + {/* Upload Area */} 1099 956 <div className="border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-xl p-6 text-center hover:border-blue-400 dark:hover:border-blue-500 focus-within:border-blue-400 dark:focus-within:border-blue-500 transition-colors"> 1100 957 <Upload className="w-12 h-12 text-gray-400 dark:text-gray-500 mx-auto mb-3" aria-hidden="true" /> 1101 958 <p className="text-lg font-medium text-gray-700 dark:text-gray-300 mb-1">Choose File</p> 1102 - <p className="text-sm text-gray-500 dark:text-gray-300 mb-3">Following.txt or TikTok data ZIP</p> 959 + <p className="text-sm text-gray-500 dark:text-gray-300 mb-3">TikTok Following.txt, JSON, or ZIP export</p> 1103 960 1104 961 <input 1105 962 id="file-upload" ··· 1125 982 </label> 1126 983 </div> 1127 984 1128 - <div className="bg-blue-50 dark:bg-blue-900/20 rounded-xl p-4" role="region" aria-label="Instructions for getting your TikTok data"> 1129 - <h3 className="font-medium text-blue-900 dark:text-blue-200 mb-2">How to get your data:</h3> 1130 - <ol className="text-sm text-blue-800 dark:text-blue-300 space-y-1 list-decimal list-inside"> 1131 - <li>Open TikTok app → Profile → Settings and privacy → Account → Download your data</li> 1132 - <li>Request data → Select "Request data"</li> 1133 - <li>Wait for notification your download is ready</li> 1134 - <li>Navigate back to Download your data</li> 1135 - <li>Download data → Select</li> 1136 - <li>Upload the Following.txt file here</li> 1137 - </ol> 985 + <div className="mt-4 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-xl"> 986 + <p className="text-sm text-blue-900 dark:text-blue-300"> 987 + 💡 <strong>How to get your TikTok data:</strong> Open TikTok → Profile → Settings → Account → Download your data → Request data → Wait for notification → Download → Upload Following.txt here 988 + </p> 1138 989 </div> 1139 990 </div> 1140 991 </div> ··· 1143 994 1144 995 {/* Loading Step */} 1145 996 {currentStep === 'loading' && ( 1146 - <div className="p-6 max-w-2xl mx-auto mt-8"> 1147 - <div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg p-8 space-y-6"> 1148 - <div className="text-center"> 1149 - <div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-purple-600 rounded-2xl mx-auto mb-4 flex items-center justify-center"> 1150 - <Search className="w-8 h-8 text-white animate-pulse" aria-hidden="true" /> 997 + <div> 998 + <AppHeader session={session} onLogout={handleLogout} onNavigate={setCurrentStep} currentStep={currentStep} /> 999 + <div className="max-w-3xl mx-auto px-4 py-8"> 1000 + <div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg p-8"> 1001 + <div className="text-center mb-6"> 1002 + <div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-purple-600 rounded-2xl mx-auto mb-4 flex items-center justify-center"> 1003 + <Search className="w-8 h-8 text-white animate-pulse" aria-hidden="true" /> 1004 + </div> 1005 + <h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-2">Finding Your People</h2> 1006 + <p className="text-gray-600 dark:text-gray-300">Searching the ATmosphere for your follows...</p> 1151 1007 </div> 1152 - <h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-2">Finding Your People</h2> 1153 - <p className="text-gray-600 dark:text-gray-300">Searching the ATmosphere for your TikTok follows...</p> 1154 - </div> 1155 1008 1156 - <div className="bg-gradient-to-br from-blue-50 to-purple-50 dark:from-gray-700 dark:to-gray-600 rounded-xl p-6" role="region" aria-label="Search progress"> 1157 - <div className="grid grid-cols-3 gap-4 text-center mb-4"> 1158 - <div> 1159 - <div className="text-3xl font-bold text-gray-900 dark:text-gray-300" aria-label={`${searchProgress.searched} searched`}>{searchProgress.searched}</div> 1160 - <div className="text-sm text-gray-600 dark:text-gray-300">Searched</div> 1009 + <div className="space-y-4"> 1010 + <div className="grid grid-cols-3 gap-4 text-center"> 1011 + <div> 1012 + <div className="text-3xl font-bold text-gray-900 dark:text-gray-100" aria-label={`${searchProgress.searched} searched`}>{searchProgress.searched}</div> 1013 + <div className="text-sm text-gray-600 dark:text-gray-300">Searched</div> 1014 + </div> 1015 + <div> 1016 + <div className="text-3xl font-bold text-blue-600 dark:text-blue-400" aria-label={`${searchProgress.found} found`}>{searchProgress.found}</div> 1017 + <div className="text-sm text-gray-600 dark:text-gray-300">Found</div> 1018 + </div> 1019 + <div> 1020 + <div className="text-3xl font-bold text-gray-400 dark:text-gray-500" aria-label={`${searchProgress.total} total`}>{searchProgress.total}</div> 1021 + <div className="text-sm text-gray-600 dark:text-gray-300">Total</div> 1022 + </div> 1161 1023 </div> 1162 - <div> 1163 - <div className="text-3xl font-bold text-blue-600 dark:text-blue-500" aria-label={`${searchProgress.found} found`}>{searchProgress.found}</div> 1164 - <div className="text-sm text-gray-600 dark:text-gray-300">Found</div> 1024 + 1025 + <div className="w-full bg-gray-200 w-full bg-gray-200 dark:bg-gray-700 rounded-full h-3" role="progressbar" aria-valuenow={searchProgress.total > 0 ? Math.round((searchProgress.searched / searchProgress.total) * 100) : 0} aria-valuemin={0} aria-valuemax={100}> 1026 + <div 1027 + className="bg-gradient-to-r from-blue-500 to-purple-600 h-full rounded-full transition-all" 1028 + style={{ width: `${searchProgress.total > 0 ? (searchProgress.searched / searchProgress.total) * 100 : 0}%` }} 1029 + /> 1165 1030 </div> 1166 - <div> 1167 - <div className="text-3xl font-bold text-gray-400 dark:text-gray-900" aria-label={`${searchProgress.total} total`}>{searchProgress.total}</div> 1168 - <div className="text-sm text-gray-600 dark:text-gray-300">Total</div> 1031 + <div className="space-y-3"> 1032 + {[...Array(5)].map((_, i) => ( 1033 + <div key={i} className="animate-pulse flex items-center space-x-3 p-4 bg-gray-50 dark:bg-gray-700 rounded-xl"> 1034 + <div className="w-12 h-12 bg-gray-200 dark:bg-gray-600 rounded-full" /> 1035 + <div className="flex-1 space-y-2"> 1036 + <div className="h-4 bg-gray-200 dark:bg-gray-600 rounded w-3/4" /> 1037 + <div className="h-3 bg-gray-200 dark:bg-gray-600 rounded w-1/2" /> 1038 + </div> 1039 + </div> 1040 + ))} 1169 1041 </div> 1170 1042 </div> 1171 - 1172 - <div className="w-full bg-gray-200 rounded-full h-3 overflow-hidden" role="progressbar" aria-valuenow={searchProgress.total > 0 ? Math.round((searchProgress.searched / searchProgress.total) * 100) : 0} aria-valuemin={0} aria-valuemax={100}> 1173 - <div 1174 - className="bg-gradient-to-r from-blue-500 to-purple-600 h-full rounded-full transition-all duration-500 ease-out" 1175 - style={{ width: `${searchProgress.total > 0 ? (searchProgress.searched / searchProgress.total) * 100 : 0}%` }} 1176 - /> 1177 - </div> 1178 - <div className="text-center mt-2 text-sm text-gray-600 dark:text-gray-300" aria-hidden="true"> 1179 - {searchProgress.total > 0 ? Math.round((searchProgress.searched / searchProgress.total) * 100) : 0}% complete 1180 - </div> 1181 1043 </div> 1182 1044 </div> 1183 1045 </div> ··· 1185 1047 1186 1048 {/* Results */} 1187 1049 {currentStep === 'results' && ( 1188 - <div className="pb-20"> 1189 - <div className="bg-white dark:bg-gray-800 border-b dark:border-gray-700"> 1190 - <div className="px-4 py-4 max-w-2xl mx-auto"> 1191 - <div className="flex items-center justify-between mb-3"> 1192 - <div className="flex items-center space-x-3"> 1193 - <button 1194 - onClick={() => setCurrentStep('home')} 1195 - className="flex items-center space-x-1 text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 rounded px-2 py-1" 1196 - > 1197 - <ChevronRight className="w-4 h-4 rotate-180" /> 1198 - <span>Home</span> 1199 - </button> 1050 + <div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 pb-24"> 1051 + <AppHeader session={session} onLogout={handleLogout} onNavigate={setCurrentStep} currentStep={currentStep} /> 1052 + {/* Platform Info Banner */} 1053 + <div className="bg-gradient-to-r from-black via-gray-800 to-cyan-400 text-white"> 1054 + <div className="max-w-3xl mx-auto px-4 py-6"> 1055 + <div className="flex items-center justify-between"> 1056 + <div className="flex items-center space-x-4"> 1057 + <Video className="w-12 h-12" /> 1200 1058 <div> 1201 - <h2 className="text-lg font-bold text-gray-900 dark:text-gray-100">Results</h2> 1202 - <p className="text-sm text-gray-600 dark:text-gray-300"> 1203 - {totalFound} of {searchResults.length} users found 1059 + <h2 className="text-xl font-bold">TikTok Matches</h2> 1060 + <p className="text-white/90 text-sm"> 1061 + {totalFound} matches from {searchResults.length} follows 1204 1062 </p> 1205 1063 </div> 1206 1064 </div> 1207 - <div className="text-right"> 1208 - <div className="text-lg font-bold text-blue-600 dark:text-blue-400">{totalSelected}</div> 1209 - <div className="text-xs text-gray-500 dark:text-gray-200">selected</div> 1210 - </div> 1065 + {totalSelected > 0 && ( 1066 + <div className="text-right"> 1067 + <div className="text-2xl font-bold">{totalSelected}</div> 1068 + <div className="text-xs text-white/80">selected</div> 1069 + </div> 1070 + )} 1211 1071 </div> 1212 - 1213 - <div className="flex space-x-2"> 1214 - <button 1215 - onClick={selectAllMatches} 1216 - className="flex-1 bg-blue-500 hover:bg-blue-600 text-white py-3 rounded-lg text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 min-h-[44px]" 1217 - type="button" 1218 - aria-label="Select all top matches" 1219 - > 1220 - Select All 1221 - </button> 1222 - <button 1223 - onClick={deselectAllMatches} 1224 - className="flex-1 bg-gray-500 hover:bg-gray-600 text-white py-3 rounded-lg text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 min-h-[44px]" 1225 - type="button" 1226 - aria-label="Clear all selections" 1227 - > 1228 - Clear 1229 - </button> 1230 - </div> 1072 + </div> 1073 + </div> 1074 + 1075 + {/* Action Buttons */} 1076 + <div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 sticky top-0 z-10"> 1077 + <div className="max-w-3xl mx-auto px-4 py-3 flex space-x-2"> 1078 + <button 1079 + onClick={selectAllMatches} 1080 + className="flex-1 bg-blue-500 hover:bg-blue-600 text-white py-3 rounded-lg text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" 1081 + type="button" 1082 + > 1083 + Select All 1084 + </button> 1085 + <button 1086 + onClick={deselectAllMatches} 1087 + className="flex-1 bg-gray-500 hover:bg-gray-600 text-white py-3 rounded-lg text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2" 1088 + type="button" 1089 + > 1090 + Clear 1091 + </button> 1231 1092 </div> 1232 1093 </div> 1233 1094 1234 - {/* Results List */} 1235 - <div className="space-y-2 p-4 max-w-2xl mx-auto" role="list" aria-label="Search results"> 1236 - {searchResults.map((result, index) => ( 1237 - <div key={index} className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border dark:border-gray-700" role="listitem"> 1238 - <div className="p-4"> 1239 - {/* TikTok User Header */} 1240 - <div className="mb-3"> 1241 - <div className="text-xs text-gray-500 dark:text-gray-200 uppercase tracking-wide mb-1" aria-hidden="true">TikTok</div> 1242 - <div className="font-semibold text-gray-900 dark:text-gray-100 text-lg"> 1243 - <span className="sr-only">TikTok user </span> 1244 - @{result.tiktokUser.username} 1095 + {/* Feed Results */} 1096 + <div className="max-w-3xl mx-auto px-4 py-4 space-y-4"> 1097 + {searchResults.map((item, idx) => { 1098 + const isExpanded = expandedResults.has(idx); 1099 + const displayMatches = isExpanded ? item.atprotoMatches : item.atprotoMatches.slice(0, 1); 1100 + const hasMoreMatches = item.atprotoMatches.length > 1; 1101 + 1102 + return ( 1103 + <div 1104 + key={idx} 1105 + className="bg-white dark:bg-gray-800 rounded-2xl shadow-sm overflow-hidden" 1106 + > 1107 + {/* Source User (minimal info - just username from TikTok) */} 1108 + <div className="p-4 bg-gray-50 dark:bg-gray-750 border-b border-gray-200 dark:border-gray-700"> 1109 + <div className="flex items-center space-x-3"> 1110 + <div className="w-10 h-10 rounded-full bg-gradient-to-r from-black via-gray-800 to-cyan-400 flex items-center justify-center text-white font-bold"> 1111 + {item.tiktokUser.username.charAt(0).toUpperCase()} 1112 + </div> 1113 + <div className="flex-1"> 1114 + <div className="font-bold text-gray-900 dark:text-gray-100"> 1115 + @{item.tiktokUser.username} 1116 + </div> 1117 + <div className="text-sm text-gray-500 dark:text-gray-400"> 1118 + from TikTok 1119 + </div> 1120 + </div> 1121 + <div className="text-xs px-2 py-1 rounded-full bg-black dark:bg-cyan-400 text-white dark:text-black"> 1122 + {item.atprotoMatches.length} {item.atprotoMatches.length === 1 ? 'match' : 'matches'} 1123 + </div> 1245 1124 </div> 1246 1125 </div> 1247 1126 1248 - {/* ATmosphere Matches */} 1249 - {result.atprotoMatches.length > 0 ? ( 1250 - <div className="space-y-2"> 1251 - <div className="sr-only">AT matches:</div> 1252 - <MatchCarousel 1253 - matches={result.atprotoMatches} 1254 - selectedDids={result.selectedMatches || new Set()} 1255 - onToggleSelection={(did) => toggleMatchSelection(index, did)} 1256 - cardRef={{ current: resultCardRefs.current[index] || null }} 1257 - /> 1258 - </div> 1259 - ) : ( 1260 - <div className="text-center py-2 text-gray-400" role="status"> 1261 - <div className="text-sm">No matches found</div> 1262 - </div> 1263 - )} 1127 + {/* Bluesky Matches (rich info from API) */} 1128 + <div className="p-4"> 1129 + {item.atprotoMatches.length === 0 ? ( 1130 + <div className="text-center py-6 text-gray-500 dark:text-gray-400"> 1131 + <MessageCircle className="w-8 h-8 mx-auto mb-2 opacity-50" /> 1132 + <p className="text-sm">Not found on Bluesky yet</p> 1133 + </div> 1134 + ) : ( 1135 + <div className="space-y-3"> 1136 + {displayMatches.map((match) => { 1137 + const isFollowed = match.followed; 1138 + const isSelected = item.selectedMatches?.has(match.did); 1139 + return ( 1140 + <div 1141 + key={match.did} 1142 + className="flex items-start space-x-3 p-3 rounded-xl bg-blue-50 dark:bg-blue-900/20 hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-all" 1143 + > 1144 + {/* Avatar */} 1145 + {match.avatar ? ( 1146 + <img 1147 + src={match.avatar} 1148 + alt="User avatar, description not provided" 1149 + className="w-12 h-12 rounded-full object-cover flex-shrink-0" 1150 + /> 1151 + ) : ( 1152 + <div className="w-12 h-12 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center flex-shrink-0"> 1153 + <span className="text-white font-bold"> 1154 + {match.handle.charAt(0).toUpperCase()} 1155 + </span> 1156 + </div> 1157 + )} 1158 + 1159 + {/* Match Info */} 1160 + <div className="flex-1 min-w-0"> 1161 + {match.displayName && ( 1162 + <div className="font-semibold text-gray-900 dark:text-gray-100"> 1163 + {match.displayName} 1164 + </div> 1165 + )} 1166 + <div className="text-sm text-gray-600 dark:text-gray-400"> 1167 + @{match.handle} 1168 + </div> 1169 + {match.description && ( 1170 + <div className="text-sm text-gray-500 dark:text-gray-400 mt-1 line-clamp-2">{match.description}</div> 1171 + )} 1172 + <div className="flex items-center space-x-3 mt-2"> 1173 + <span className="text-xs bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-300 px-2 py-1 rounded-full font-medium"> 1174 + {match.matchScore}% match 1175 + </span> 1176 + </div> 1177 + </div> 1178 + 1179 + {/* Select/Follow Button */} 1180 + <button 1181 + onClick={() => toggleMatchSelection(idx, match.did)} 1182 + disabled={isFollowed} 1183 + className={`flex items-center space-x-1 px-3 py-2 rounded-full font-medium transition-all flex-shrink-0 ${ 1184 + isFollowed 1185 + ? 'bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300 cursor-not-allowed opacity-60' 1186 + : isSelected 1187 + ? 'bg-blue-600 text-white' 1188 + : 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600' 1189 + }`} 1190 + > 1191 + {isFollowed ? ( 1192 + <> 1193 + <Check className="w-4 h-4" /> 1194 + <span className="text-sm">Followed</span> 1195 + </> 1196 + ) : isSelected ? ( 1197 + <> 1198 + <Check className="w-4 h-4" /> 1199 + <span className="text-sm">Selected</span> 1200 + </> 1201 + ) : ( 1202 + <> 1203 + <UserPlus className="w-4 h-4" /> 1204 + <span className="text-sm">Select</span> 1205 + </> 1206 + )} 1207 + </button> 1208 + </div> 1209 + ); 1210 + })} 1211 + {hasMoreMatches && ( 1212 + <button 1213 + onClick={() => toggleExpandResult(idx)} 1214 + className="w-full py-2 text-sm text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-medium transition-colors flex items-center justify-center space-x-1" 1215 + > 1216 + <span>{isExpanded ? 'Show less' : `Show ${item.atprotoMatches.length - 1} more ${item.atprotoMatches.length - 1 === 1 ? 'match' : 'matches'}`}</span> 1217 + <ChevronDown className={`w-4 h-4 transition-transform ${isExpanded ? 'rotate-180' : ''}`} /> 1218 + </button> 1219 + )} 1220 + </div> 1221 + )} 1222 + </div> 1264 1223 </div> 1265 - </div> 1266 - ))} 1224 + ); 1225 + })} 1267 1226 </div> 1227 + 1228 + {/* Fixed Bottom Action Bar */} 1229 + {totalSelected > 0 && ( 1230 + <div className="fixed bottom-0 left-0 right-0 bg-gradient-to-t from-white via-white to-transparent dark:from-gray-900 dark:via-gray-900 dark:to-transparent pt-8 pb-6"> 1231 + <div className="max-w-3xl mx-auto px-4"> 1232 + <button 1233 + onClick={followSelectedUsers} 1234 + disabled={isFollowing} 1235 + className="w-full bg-gradient-to-r from-blue-500 via-purple-500 to-pink-500 hover:from-blue-600 hover:via-purple-600 hover:to-pink-600 text-white py-5 rounded-2xl font-bold text-lg transition-all shadow-2xl hover:shadow-3xl flex items-center justify-center space-x-3 transform hover:scale-105 disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none focus:outline-none focus:ring-4 focus:ring-purple-300 dark:focus:ring-purple-800" 1236 + > 1237 + <Heart className="w-6 h-6" /> 1238 + <span>Follow {totalSelected} Selected {totalSelected === 1 ? 'User' : 'Users'}</span> 1239 + </button> 1240 + </div> 1241 + </div> 1242 + )} 1268 1243 </div> 1269 1244 )} 1270 1245 </main> 1271 - 1272 - {/* Fixed Bottom Action Bar */} 1273 - {currentStep === 'results' && totalSelected > 0 && ( 1274 - <div className="fixed bottom-0 left-0 right-0 bg-white dark:bg-gray-800 border-t dark:border-gray-700 shadow-lg"> 1275 - <div className="p-4 max-w-2xl mx-auto"> 1276 - <button 1277 - onClick={followSelectedUsers} 1278 - disabled={isFollowing} 1279 - className="w-full bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 text-white py-4 rounded-xl font-medium text-lg transition-all duration-200 shadow-lg hover:shadow-xl focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed min-h-[56px]" 1280 - type="button" 1281 - aria-live="polite" 1282 - aria-label={isFollowing ? `Following users, please wait` : `Follow ${totalSelected} selected users`} 1283 - > 1284 - {isFollowing 1285 - ? "Following Users..." 1286 - : `Follow ${totalSelected} Selected Users` 1287 - } 1288 - </button> 1289 - </div> 1290 - </div> 1291 - )} 1292 1246 </div> 1293 1247 ); 1294 1248 }