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}