ATlast — you'll never need to find your favorites on another platform again. Find your favs in the ATmosphere.
atproto
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 () => { 143 setCurrentStep("results"); 144 145 // Save results after search completes 146 setTimeout(() => { 147 setSearchResults((currentResults) => { 148 if (currentResults.length > 0) { 149 saveResults(uploadId, platform, currentResults); 150 } 151 return currentResults; 152 }); 153 }, 1000); 154 }, 155 followLexicon, 156 ); 157 }, 158 setStatusMessage, 159 userSettings, 160 ); 161 162 // Load previous upload handler 163 const handleLoadUpload = useCallback( 164 async (uploadId: string) => { 165 try { 166 setStatusMessage("Loading previous upload..."); 167 setCurrentStep("loading"); 168 169 const data = await apiClient.getUploadDetails(uploadId); 170 171 if (data.results.length === 0) { 172 setSearchResults([]); 173 setCurrentPlatform("tiktok"); 174 setCurrentStep("home"); 175 // No visual feedback needed - empty state will show in UI 176 setAriaAnnouncement("No previous results found."); 177 return; 178 } 179 180 const platform = "tiktok"; 181 setCurrentPlatform(platform); 182 183 const loadedResults: SearchResult[] = data.results.map((result) => ({ 184 ...result, 185 sourcePlatform: platform, 186 isSearching: false, 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 setCurrentStep("results"); 201 // Announce to screen readers only - visual feedback is navigation to results page 202 setAriaAnnouncement( 203 `Loaded ${loadedResults.length} results from previous upload`, 204 ); 205 } catch (err) { 206 console.error("Failed to load upload:", err); 207 error("Failed to load previous upload. Please try again."); 208 setCurrentStep("home"); 209 } 210 }, 211 [setStatusMessage, setCurrentStep, setSearchResults, setAriaAnnouncement, error], 212 ); 213 214 // Login handler 215 const handleLogin = useCallback( 216 async (handle: string) => { 217 if (!handle?.trim()) { 218 error("Please enter your handle"); 219 return; 220 } 221 222 try { 223 await login(handle); 224 } catch (err) { 225 console.error("OAuth error:", err); 226 const errorMsg = `Error starting OAuth: ${err instanceof Error ? err.message : "Unknown error"}`; 227 setStatusMessage(errorMsg); 228 error(errorMsg); 229 } 230 }, 231 [login, error, setStatusMessage], 232 ); 233 234 // Logout handler 235 const handleLogout = useCallback(async () => { 236 try { 237 await logout(); 238 setSearchResults([]); 239 setCurrentPlatform("tiktok"); 240 setSavedUploads(new Set()); 241 // No visual feedback needed - user sees login page 242 setAriaAnnouncement("Logged out successfully"); 243 } catch (err) { 244 error("Failed to logout. Please try again."); 245 } 246 }, [logout, setSearchResults, setAriaAnnouncement, error]); 247 248 return ( 249 <ErrorBoundary> 250 <div className="min-h-screen relative overflow-hidden"> 251 {/* Notification Container - errors only */} 252 <NotificationContainer 253 notifications={notifications} 254 onRemove={removeNotification} 255 /> 256 257 {/* Invisible announcer for screen readers - non-error feedback */} 258 <AriaLiveAnnouncer message={ariaAnnouncement} politeness="polite" /> 259 260 {/* Status message for screen readers - loading/progress updates */} 261 <AriaLiveAnnouncer message={statusMessage} politeness="polite" /> 262 263 {/* Firefly particles - only render if motion not reduced */} 264 {!reducedMotion && ( 265 <div className="fixed inset-0 pointer-events-none" aria-hidden="true"> 266 {[...Array(15)].map((_, i) => ( 267 <Firefly 268 key={i} 269 delay={i * 0.5} 270 duration={3 + Math.random() * 2} 271 /> 272 ))} 273 </div> 274 )} 275 276 {/* Skip to main content link */} 277 <a 278 href="#main-content" 279 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" 280 > 281 Skip to main content 282 </a> 283 284 <main id="main-content"> 285 <Suspense fallback={<PageLoader />}> 286 {/* Checking Session */} 287 {currentStep === "checking" && <PageLoader />} 288 289 {/* Login Page */} 290 {currentStep === "login" && ( 291 <ErrorBoundary fallbackType="inline"> 292 <LoginPage 293 onSubmit={handleLogin} 294 session={session} 295 onNavigate={setCurrentStep} 296 reducedMotion={reducedMotion} 297 /> 298 </ErrorBoundary> 299 )} 300 301 {/* Home/Dashboard Page */} 302 {currentStep === "home" && ( 303 <ErrorBoundary fallbackType="inline"> 304 <HomePage 305 session={session} 306 onLogout={handleLogout} 307 onNavigate={setCurrentStep} 308 onFileUpload={processFileUpload} 309 onLoadUpload={handleLoadUpload} 310 currentStep={currentStep} 311 reducedMotion={reducedMotion} 312 isDark={isDark} 313 onToggleTheme={toggleTheme} 314 onToggleMotion={toggleMotion} 315 userSettings={userSettings} 316 onSettingsUpdate={handleSettingsUpdate} 317 /> 318 </ErrorBoundary> 319 )} 320 321 {/* Loading Page */} 322 {currentStep === "loading" && ( 323 <ErrorBoundary fallbackType="inline"> 324 <LoadingPage 325 session={session} 326 onLogout={handleLogout} 327 onNavigate={setCurrentStep} 328 searchProgress={searchProgress} 329 currentStep={currentStep} 330 sourcePlatform={currentPlatform} 331 isDark={isDark} 332 reducedMotion={reducedMotion} 333 onToggleTheme={toggleTheme} 334 onToggleMotion={toggleMotion} 335 /> 336 </ErrorBoundary> 337 )} 338 339 {/* Results Page */} 340 {currentStep === "results" && ( 341 <ErrorBoundary fallbackType="inline"> 342 <ResultsPage 343 session={session} 344 onLogout={handleLogout} 345 onNavigate={setCurrentStep} 346 searchResults={searchResults} 347 expandedResults={expandedResults} 348 onToggleExpand={toggleExpandResult} 349 onToggleMatchSelection={toggleMatchSelection} 350 onSelectAll={() => selectAllMatches(setStatusMessage)} 351 onDeselectAll={() => deselectAllMatches(setStatusMessage)} 352 onFollowSelected={() => followSelectedUsers(setStatusMessage)} 353 totalSelected={totalSelected} 354 totalFound={totalFound} 355 isFollowing={isFollowing} 356 currentStep={currentStep} 357 sourcePlatform={currentPlatform} 358 destinationAppId={currentDestinationAppId} 359 reducedMotion={reducedMotion} 360 isDark={isDark} 361 onToggleTheme={toggleTheme} 362 onToggleMotion={toggleMotion} 363 /> 364 </ErrorBoundary> 365 )} 366 </Suspense> 367 </main> 368 </div> 369 </ErrorBoundary> 370 ); 371}