ATlast — you'll never need to find your favorites on another platform again. Find your favs in the ATmosphere.
atproto
at master 17 kB view raw
1import React, { useState, useEffect, useCallback, Suspense, lazy } from "react"; 2import { ArrowRight } from "lucide-react"; 3import { useAuth } from "./hooks/useAuth"; 4import { useSearch } from "./hooks/useSearch"; 5import { useFollow } from "./hooks/useFollows"; 6import { useFileUpload } from "./hooks/useFileUpload"; 7import { useTheme } from "./hooks/useTheme"; 8import { useNotifications } from "./hooks/useNotifications"; 9import Firefly from "./components/Firefly"; 10import NotificationContainer from "./components/common/NotificationContainer"; 11import ErrorBoundary from "./components/common/ErrorBoundary"; 12import AriaLiveAnnouncer from "./components/common/AriaLiveAnnouncer"; 13import { SearchResultSkeleton } from "./components/common/LoadingSkeleton"; 14import { DEFAULT_SETTINGS } from "./types/settings"; 15import type { UserSettings, SearchResult } from "./types"; 16import { apiClient } from "./lib/api/client"; 17import { ATPROTO_APPS } from "./config/atprotoApps"; 18import { useSettingsStore } from "./stores/useSettingsStore"; 19 20// Lazy load page components 21const LoginPage = lazy(() => import("./pages/Login")); 22const HomePage = lazy(() => import("./pages/Home")); 23const LoadingPage = lazy(() => import("./pages/Loading")); 24const ResultsPage = lazy(() => import("./pages/Results")); 25 26// Loading fallback component 27const PageLoader: React.FC = () => ( 28 <div className="p-6 max-w-md mx-auto mt-8"> 29 <div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg p-8 text-center space-y-4"> 30 <div className="w-16 h-16 bg-firefly-banner dark:bg-firefly-banner-dark text-white rounded-2xl mx-auto flex items-center justify-center"> 31 <ArrowRight className="w-8 h-8 text-white animate-pulse" /> 32 </div> 33 <h2 className="text-xl font-bold text-gray-900 dark:text-gray-100"> 34 Loading... 35 </h2> 36 </div> 37 </div> 38); 39 40export default function App() { 41 // Auth hook 42 const { 43 session, 44 currentStep, 45 statusMessage, 46 setCurrentStep, 47 setStatusMessage, 48 login, 49 logout, 50 } = useAuth(); 51 52 // Notifications hook (only for errors now) 53 const { notifications, removeNotification, error } = useNotifications(); 54 55 // Aria-live announcements for non-error feedback (invisible, screen-reader only) 56 const [ariaAnnouncement, setAriaAnnouncement] = useState(""); 57 58 // Theme hook 59 const { isDark, reducedMotion, toggleTheme, toggleMotion } = useTheme(); 60 61 // Current platform state 62 const [currentPlatform, setCurrentPlatform] = useState<string>("tiktok"); 63 64 // Track saved uploads to prevent duplicates 65 const [savedUploads, setSavedUploads] = useState<Set<string>>(new Set()); 66 67 // Settings state 68 const userSettings = useSettingsStore((state) => state.settings); 69 const handleSettingsUpdate = useSettingsStore((state) => state.updateSettings); 70 71 // Search hook 72 const { 73 searchResults, 74 setSearchResults, 75 searchProgress, 76 expandedResults, 77 searchAllUsers, 78 toggleMatchSelection, 79 toggleExpandResult, 80 selectAllMatches, 81 deselectAllMatches, 82 totalSelected, 83 totalFound, 84 } = useSearch(session); 85 86 const currentDestinationAppId = 87 userSettings.platformDestinations[ 88 currentPlatform as keyof UserSettings["platformDestinations"] 89 ]; 90 91 // Follow hook 92 const { isFollowing, followSelectedUsers } = useFollow( 93 session, 94 searchResults, 95 setSearchResults, 96 currentDestinationAppId, 97 ); 98 99 // Save results handler (proper state management) 100 const saveResults = useCallback( 101 async (uploadId: string, platform: string, results: SearchResult[]) => { 102 if (!userSettings.saveData) { 103 console.log("Data storage disabled - skipping save to database"); 104 return; 105 } 106 107 if (savedUploads.has(uploadId)) { 108 console.log("Upload already saved:", uploadId); 109 return; 110 } 111 112 try { 113 setSavedUploads((prev) => new Set(prev).add(uploadId)); 114 await apiClient.saveResults(uploadId, platform, results); 115 console.log("Results saved successfully:", uploadId); 116 } catch (err) { 117 console.error("Background save failed:", err); 118 setSavedUploads((prev) => { 119 const next = new Set(prev); 120 next.delete(uploadId); 121 return next; 122 }); 123 } 124 }, 125 [userSettings.saveData, savedUploads], 126 ); 127 128 // File upload handler 129 const { handleFileUpload: processFileUpload } = useFileUpload( 130 (initialResults, platform) => { 131 setCurrentPlatform(platform); 132 setSearchResults(initialResults); 133 setCurrentStep("loading"); 134 135 const uploadId = crypto.randomUUID(); 136 const followLexicon = 137 ATPROTO_APPS[currentDestinationAppId]?.followLexicon; 138 139 searchAllUsers( 140 initialResults, 141 setStatusMessage, 142 (finalResults) => { 143 setCurrentStep("results"); 144 145 // Save results after search completes 146 if (finalResults.length > 0) { 147 saveResults(uploadId, platform, finalResults); 148 } 149 }, 150 followLexicon, 151 ); 152 }, 153 setStatusMessage, 154 userSettings, 155 ); 156 157 // Load previous upload handler 158 const handleLoadUpload = useCallback( 159 async (uploadId: string) => { 160 try { 161 setStatusMessage("Loading previous upload..."); 162 setCurrentStep("loading"); 163 164 const data = await apiClient.getUploadDetails(uploadId); 165 166 if (data.results.length === 0) { 167 setSearchResults([]); 168 setCurrentPlatform("tiktok"); 169 setCurrentStep("home"); 170 // No visual feedback needed - empty state will show in UI 171 setAriaAnnouncement("No previous results found."); 172 return; 173 } 174 175 // Detect platform from first result's username or default to twitter for extension imports 176 const platform = "twitter"; // Extension imports are always from Twitter for now 177 setCurrentPlatform(platform); 178 179 // Check if this is a new upload with no matches yet 180 const hasMatches = data.results.some(r => r.atprotoMatches.length > 0); 181 182 const loadedResults: SearchResult[] = data.results.map((result) => ({ 183 sourceUser: result.sourceUser, // SourceUser object { username, date } 184 sourcePlatform: platform, 185 isSearching: !hasMatches, // Search if no matches exist yet 186 atprotoMatches: result.atprotoMatches || [], 187 selectedMatches: new Set<string>( 188 (result.atprotoMatches || []) 189 .filter( 190 (match) => 191 !match.followStatus || 192 !Object.values(match.followStatus).some((status) => status), 193 ) 194 .slice(0, 1) 195 .map((match) => match.did), 196 ), 197 })); 198 199 setSearchResults(loadedResults); 200 201 // If no matches yet, trigger search BEFORE navigating to results 202 if (!hasMatches) { 203 const followLexicon = ATPROTO_APPS[currentDestinationAppId]?.followLexicon; 204 205 await searchAllUsers( 206 loadedResults, 207 (message) => setStatusMessage(message), 208 async (finalResults) => { 209 // Search complete - save results and navigate to results page 210 await saveResults(uploadId, platform, finalResults); 211 setCurrentStep("results"); 212 }, 213 followLexicon 214 ); 215 } else { 216 // Already has matches, navigate to results immediately 217 setCurrentStep("results"); 218 } 219 220 // Announce to screen readers only - visual feedback is navigation to results page 221 setAriaAnnouncement( 222 `Loaded ${loadedResults.length} results from previous upload`, 223 ); 224 } catch (err) { 225 console.error("Failed to load upload:", err); 226 error("Failed to load previous upload. Please try again."); 227 setCurrentStep("home"); 228 } 229 }, 230 [setStatusMessage, setCurrentStep, setSearchResults, setAriaAnnouncement, error, currentDestinationAppId, searchAllUsers, saveResults], 231 ); 232 233 // Login handler 234 const handleLogin = useCallback( 235 async (handle: string) => { 236 if (!handle?.trim()) { 237 error("Please enter your handle"); 238 return; 239 } 240 241 try { 242 await login(handle); 243 } catch (err) { 244 console.error("OAuth error:", err); 245 const errorMsg = `Error starting OAuth: ${err instanceof Error ? err.message : "Unknown error"}`; 246 setStatusMessage(errorMsg); 247 error(errorMsg); 248 } 249 }, 250 [login, error, setStatusMessage], 251 ); 252 253 // Logout handler 254 const handleLogout = useCallback(async () => { 255 try { 256 await logout(); 257 setSearchResults([]); 258 setCurrentPlatform("tiktok"); 259 setSavedUploads(new Set()); 260 // No visual feedback needed - user sees login page 261 setAriaAnnouncement("Logged out successfully"); 262 } catch (err) { 263 error("Failed to logout. Please try again."); 264 } 265 }, [logout, setSearchResults, setAriaAnnouncement, error]); 266 267 // Extension import handler 268 useEffect(() => { 269 const urlParams = new URLSearchParams(window.location.search); 270 const importId = urlParams.get('importId'); 271 272 if (!importId || !session) { 273 return; 274 } 275 276 // Fetch and process extension import 277 async function handleExtensionImport(id: string) { 278 try { 279 setStatusMessage('Loading import from extension...'); 280 setCurrentStep('loading'); 281 282 const response = await fetch( 283 `/.netlify/functions/get-extension-import?importId=${id}` 284 ); 285 286 if (!response.ok) { 287 throw new Error('Import not found or expired'); 288 } 289 290 const importData = await response.json(); 291 292 // Convert usernames to search results 293 const platform = importData.platform; 294 setCurrentPlatform(platform); 295 296 const initialResults: SearchResult[] = importData.usernames.map((username: string) => ({ 297 sourceUser: username, 298 sourcePlatform: platform, 299 isSearching: true, 300 atprotoMatches: [], 301 selectedMatches: new Set<string>(), 302 })); 303 304 setSearchResults(initialResults); 305 306 const uploadId = crypto.randomUUID(); 307 const followLexicon = ATPROTO_APPS[currentDestinationAppId]?.followLexicon; 308 309 // Start search 310 await searchAllUsers( 311 initialResults, 312 setStatusMessage, 313 (finalResults) => { 314 setCurrentStep('results'); 315 316 // Save results after search completes 317 if (finalResults.length > 0) { 318 saveResults(uploadId, platform, finalResults); 319 } 320 321 // Clear import ID from URL 322 const newUrl = new URL(window.location.href); 323 newUrl.searchParams.delete('importId'); 324 window.history.replaceState({}, '', newUrl); 325 }, 326 followLexicon 327 ); 328 } catch (err) { 329 console.error('Extension import error:', err); 330 error('Failed to load import from extension. Please try again.'); 331 setCurrentStep('home'); 332 333 // Clear import ID from URL on error 334 const newUrl = new URL(window.location.href); 335 newUrl.searchParams.delete('importId'); 336 window.history.replaceState({}, '', newUrl); 337 } 338 } 339 340 handleExtensionImport(importId); 341 }, [session, currentDestinationAppId, setStatusMessage, setCurrentStep, setSearchResults, searchAllUsers, saveResults, error]); 342 343 // Load results from uploadId URL parameter 344 useEffect(() => { 345 const urlParams = new URLSearchParams(window.location.search); 346 const uploadId = urlParams.get('uploadId'); 347 348 if (!uploadId || !session) { 349 return; 350 } 351 352 // Load results for this upload 353 handleLoadUpload(uploadId); 354 355 // Clean up URL parameter after loading 356 const newUrl = new URL(window.location.href); 357 newUrl.searchParams.delete('uploadId'); 358 window.history.replaceState({}, '', newUrl); 359 }, [session, handleLoadUpload]); 360 361 return ( 362 <ErrorBoundary> 363 <div className="min-h-screen relative overflow-hidden"> 364 {/* Notification Container - errors only */} 365 <NotificationContainer 366 notifications={notifications} 367 onRemove={removeNotification} 368 /> 369 370 {/* Invisible announcer for screen readers - non-error feedback */} 371 <AriaLiveAnnouncer message={ariaAnnouncement} politeness="polite" /> 372 373 {/* Status message for screen readers - loading/progress updates */} 374 <AriaLiveAnnouncer message={statusMessage} politeness="polite" /> 375 376 {/* Firefly particles - only render if motion not reduced */} 377 {!reducedMotion && ( 378 <div className="fixed inset-0 pointer-events-none" aria-hidden="true"> 379 {[...Array(15)].map((_, i) => ( 380 <Firefly 381 key={i} 382 delay={i * 0.5} 383 duration={3 + Math.random() * 2} 384 /> 385 ))} 386 </div> 387 )} 388 389 {/* Skip to main content link */} 390 <a 391 href="#main-content" 392 className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:bg-firefly-orange focus:text-white focus:px-4 focus:py-2 focus:rounded-lg" 393 > 394 Skip to main content 395 </a> 396 397 <main id="main-content"> 398 <Suspense fallback={<PageLoader />}> 399 {/* Checking Session */} 400 {currentStep === "checking" && <PageLoader />} 401 402 {/* Login Page */} 403 {currentStep === "login" && ( 404 <ErrorBoundary fallbackType="inline"> 405 <LoginPage 406 onSubmit={handleLogin} 407 session={session} 408 onNavigate={setCurrentStep} 409 reducedMotion={reducedMotion} 410 /> 411 </ErrorBoundary> 412 )} 413 414 {/* Home/Dashboard Page */} 415 {currentStep === "home" && ( 416 <ErrorBoundary fallbackType="inline"> 417 <HomePage 418 session={session} 419 onLogout={handleLogout} 420 onNavigate={setCurrentStep} 421 onFileUpload={processFileUpload} 422 onLoadUpload={handleLoadUpload} 423 currentStep={currentStep} 424 reducedMotion={reducedMotion} 425 isDark={isDark} 426 onToggleTheme={toggleTheme} 427 onToggleMotion={toggleMotion} 428 userSettings={userSettings} 429 onSettingsUpdate={handleSettingsUpdate} 430 /> 431 </ErrorBoundary> 432 )} 433 434 {/* Loading Page */} 435 {currentStep === "loading" && ( 436 <ErrorBoundary fallbackType="inline"> 437 <LoadingPage 438 session={session} 439 onLogout={handleLogout} 440 onNavigate={setCurrentStep} 441 searchProgress={searchProgress} 442 currentStep={currentStep} 443 sourcePlatform={currentPlatform} 444 isDark={isDark} 445 reducedMotion={reducedMotion} 446 onToggleTheme={toggleTheme} 447 onToggleMotion={toggleMotion} 448 /> 449 </ErrorBoundary> 450 )} 451 452 {/* Results Page */} 453 {currentStep === "results" && ( 454 <ErrorBoundary fallbackType="inline"> 455 <ResultsPage 456 session={session} 457 onLogout={handleLogout} 458 onNavigate={setCurrentStep} 459 searchResults={searchResults} 460 expandedResults={expandedResults} 461 onToggleExpand={toggleExpandResult} 462 onToggleMatchSelection={toggleMatchSelection} 463 onSelectAll={() => selectAllMatches(setStatusMessage)} 464 onDeselectAll={() => deselectAllMatches(setStatusMessage)} 465 onFollowSelected={() => followSelectedUsers(setStatusMessage)} 466 totalSelected={totalSelected} 467 totalFound={totalFound} 468 isFollowing={isFollowing} 469 currentStep={currentStep} 470 sourcePlatform={currentPlatform} 471 destinationAppId={currentDestinationAppId} 472 reducedMotion={reducedMotion} 473 isDark={isDark} 474 onToggleTheme={toggleTheme} 475 onToggleMotion={toggleMotion} 476 /> 477 </ErrorBoundary> 478 )} 479 </Suspense> 480 </main> 481 </div> 482 </ErrorBoundary> 483 ); 484}